使用SSE计算绝对值的最快方法

23

我知道3种方法,但据我所知,通常只使用前两种:

  1. 使用andpsandnotps屏蔽符号位。

    • 优点:如果掩码已在寄存器中,则只需要一条快速指令,非常适合在循环中多次执行此操作。
    • 缺点:掩码可能不在寄存器中,更糟的是,甚至不在缓存中,导致非常长的内存提取时间。
  2. 从零开始减去该值以进行否定,然后获取原始值和否定值的最大值。

    • 优点:固定成本,因为无需像掩码那样提取任何内容。
    • 缺点:如果条件理想,且我们必须等待subps完成才能使用maxps指令,则始终比掩码方法慢。
  3. 与选项2类似,从零开始减去原始值以进行否定,然后使用andps对结果与原始值进行“按位与”。 我运行了一次测试,将其与方法2进行比较,除了处理NaN时,结果将是与方法2的结果不同的NaN外,它似乎表现完全相同。

    • 优点:由于andps通常比maxps速度更快,因此应该比方法2稍快。
    • 缺点:当涉及到NaN时,是否会产生任何意外行为?可能不会,因为NaN仍然是NaN,即使它是不同的NaN值,对吧?

欢迎提出想法和意见。


@RaymondChen,没错,但这仍然是两个额外的指令和一个旁路延迟,很可能使其始终比方法2或3慢。 - Kumputer
1
如何进行左移一位,然后再进行无符号右移一位? - Raymond Chen
为什么要假设,不如对几种方法进行基准测试,看看是否存在显著差异呢? - Paul R
@PaulR:当有可用于进行准确预测的信息时,为什么要进行基准测试呢?:D 测试移位想法的旁路延迟并不是一个坏主意,但对我来说,在寄存器中生成掩码,然后使用andps与之配合,对于大多数情况来说是最佳解决方案。 - Peter Cordes
@RaymondChen:我把你的想法加入了我的答案。它唯一的优点就是使用VEX编码时体积最小。只有在无法重复使用预生成的AND掩码的情况下才有2个uop,所以其他情况都需要3个uop或者可能会出现缓存未命中的情况。 - Peter Cordes
显示剩余2条评论
1个回答

43

简而言之:几乎所有情况下,使用pcmeq / shift生成掩码,并使用andps使用它。 它的关键路径远远最短(与来自内存的常量并列),且不会出现缓存未命中。

如何使用内部函数实现

使编译器在未初始化的寄存器上发出pcmpeqd 可能有些棘手。(godbolt)。gcc / icc 的最佳方式似乎是

__m128 abs_mask(void){
  // with clang, this turns into a 16B load,
  // with every calling function getting its own copy of the mask
  __m128i minus1 = _mm_set1_epi32(-1);
  return _mm_castsi128_ps(_mm_srli_epi32(minus1, 1));
}
// MSVC is BAD when inlining this into loops
__m128 vecabs_and(__m128 v) {
  return _mm_and_ps(abs_mask(), v);
}


__m128 sumabs(const __m128 *a) { // quick and dirty no alignment checks
  __m128 sum = vecabs_and(*a);
  for (int i=1 ; i < 10000 ; i++) {
      // gcc, clang, and icc hoist the mask setup out of the loop after inlining
      // MSVC doesn't!
      sum = _mm_add_ps(sum, vecabs_and(a[i])); // one accumulator makes addps latency the bottleneck, not throughput
  }
  return sum;
}

clang 3.5及更高版本将set1 / shift“优化”为从内存中加载常量。但它将使用pcmpeqd来实现set1_epi32(-1)TODO:查找一系列的内置函数以生成所需的机器代码与clang。从内存中加载常量并不是性能灾难,但每个函数使用一个不同的掩码副本非常糟糕。

MSVC:VS2013:

  • _mm_uninitialized_si128()未定义。

  • 在未初始化的变量上使用_mm_cmpeq_epi32(self,self)会在这个测试用例中发出一个movdqa xmm,[ebp-10h](即从堆栈加载一些未初始化的数据)。这比仅从内存加载最终常量具有较小的缓存失误风险。然而,Kumputer称MSVC没有成功地将pcmpeqd / psrld提升出循环(我假设在内联vecabs时),因此除非您手动内联并将常量提升出循环,否则无法使用。

  • 使用_mm_srli_epi32(_mm_set1_epi32(-1), 1)会导致movdqa加载所有-1的向量(在循环外提升),以及循环内的psrld。所以这非常可怕。如果要加载16B常量,则应该是最终向量。每次循环迭代都生成掩码的整数指令也很可怕。

对于MSVC的建议:放弃在运行时生成掩码,只需编写

const __m128 absmask = _mm_castsi128_ps(_mm_set1_epi32(~(1<<31));

也许你只需将口罩作为16B常量存储在内存中。希望不是每个使用它的函数都会重复。将口罩设置为内存常量更有可能在32位代码中有所帮助,因为你只有8个XMM寄存器,所以如果没有空闲寄存器来保存常量,vecabs 可以与一个内存源操作数进行 ANDPS。

待办事项:找出如何避免在内联中到处复制常量。可能使用全局常量而不是匿名 set1 更好。但是然后你需要初始化它,但我不确定内部函数是否适用于全局 __m128 变量的初始化器。你希望它进入只读数据部分,而不是在程序启动时运行构造函数。


或者,使用

__m128i minus1;  // undefined
#if _MSC_VER && !__INTEL_COMPILER
minus1 = _mm_setzero_si128();  // PXOR is cheaper than MSVC's silly load from the stack
#endif
minus1 = _mm_cmpeq_epi32(minus1, minus1);  // or use some other variable here, which will probably cost a mov insn without AVX, unless the variable is dead.
const __m128 absmask = _mm_castsi128_ps(_mm_srli_epi32(minus1, 1));

额外的PXOR很便宜,但仍然是一个uop,代码大小仍为4字节。如果有任何更好的解决方案来克服MSVC不愿发出我们想要的代码,请留言或编辑。但是,如果内联到循环中,这样做就没有用了,因为pxor/pcmp/psrl都在循环内部。
使用movd加载32位常量并使用shufps广播可能还可以(再次强调,您可能必须手动提升此操作以避免循环)。这是3条指令(将立即数移动到GP寄存器,movd,shufps),而movd在AMD上很慢,因为向量单元在两个整数核之间共享。(它们的超线程版本。)

选择最佳汇编序列

好的,让我们以英特尔Sandybridge到Skylake为例进行研究,还要稍微提及Nehalem。请参阅Agner Fog的 微架构指南和指令计时,了解我是如何得出这个结论的。我还使用了在http://realwordtech.com/ 论坛帖子中发布的Skylake数据。


假设我们想要对向量进行 abs(),它在 xmm0 中,并且像 FP 代码一样是长依赖链的一部分。
因此,假设任何不依赖于 xmm0 的操作可以在几个周期之前开始。我已经测试过,具有内存操作数的指令不会为依赖链增加额外的延迟,假设内存操作数的地址不是依赖链的一部分(即不是关键路径的一部分)。
我不完全清楚当内存操作作为微融合uop的一部分时可以提前多少开始。据我了解,重排序缓冲区(ROB)与融合uops配合使用,并跟踪从发布到退役的uops(168(SnB)到224(SKL)个条目)。还有一个调度程序在未融合领域中工作,仅保留已准备好其输入操作数但尚未执行的uops。当它们被解码(或从uop高速缓存加载)时,uops可以同时进入ROB(融合)和调度程序(未融合)。如果我理解正确,在Sandybridge到Broadwell中为54到64个条目, 在Skylake中为97个。 有一些毫无根据的猜测认为它不再是统一的(ALU / load-store)调度程序
还有关于Skylake每个时钟周期处理6个uops的说法。据我所知,Skylake将整个uop缓存行(最多6个uops)每个时钟周期读入到uop缓存和ROB之间的缓冲区中。ROB/调度器的发行仍然是4宽度的(即使是nop也是每个时钟周期4个)。这个缓冲区有助于解决代码对齐/uop缓存行边界导致之前Sandybridge微架构设计瓶颈的问题。我之前认为这个“发行队列”就是这个缓冲区,但显然不是这样。 无论如何,如果地址不在关键路径上,调度器足够大,可以及时从缓存中获取数据

1a:使用内存操作数进行掩码

ANDPS  xmm0, [mask]  # in the loop
  • 字节数:7个指令,16个数据。(AVX:8个指令)
  • 融合域微操作:1 * n
  • 关键路径加入的延迟:1c(假设L1缓存命中)
  • 吞吐量:1/c。(Skylake: 2/c)(受限于每个周期2次加载)
  • 如果xmm0在此指令发出时准备就绪,则为“延迟”:在L1缓存命中时约为4c。

1b:来自寄存器的掩码

movaps   xmm5, [mask]   # outside the loop

ANDPS    xmm0, xmm5     # in a loop
# or PAND   xmm0, xmm5    # higher latency, but more throughput on Nehalem to Broadwell

# or with an inverted mask, if set1_epi32(0x80000000) is useful for something else in your loop:
VANDNPS   xmm0, xmm5, xmm0   # It's the dest that's NOTted, so non-AVX would need an extra movaps
  • 字节:10指令 + 16数据。(AVX:12指令字节)
  • 融合域uops: 1 + 1*n
  • 延迟添加到依赖链中:1c (如果在循环早期存在相同的缓存未命中注意)
  • 吞吐量:1/c。(Skylake:3/c)

PAND 在 Nehalem 至 Broadwell 上的吞吐量为 3/c,但延迟为 3c(如果在两个 FP 域操作之间使用,在 Nehalem 上甚至更糟)。我猜只有端口5可以直接将按位运算转发到其他 FP 执行单元(Skylake 之前)。 在 Nehalem 以前和 AMD 上,按位 FP 操作与整数 FP 操作处理方式相同,因此它们可以在所有端口上运行,但具有转发延迟。


1c: 在运行时生成掩码:

# outside a loop
PCMPEQD  xmm5, xmm5  # set to 0xff...  Recognized as independent of the old value of xmm5, but still takes an execution port (p1/p5).
PSRLD    xmm5, 1     # 0x7fff...  # port0
# or PSLLD xmm5, 31  # 0x8000...  to set up for ANDNPS

ANDPS    xmm0, xmm5  # in the loop.  # port5
  • 字节数:12 (AVX: 13)
  • 融合域uops:2 + 1*n (无内存操作)
  • 添加到依赖链的延迟时间:1c
  • 吞吐量:1/c。 (Skylake: 3/c)
  • 所有3个uop的吞吐量:1/c,饱和了3个向量ALU端口
  • "延迟"如果xmm0在此序列发出时已准备就绪(没有循环):3c(+1c可能的旁路延迟,如果ANDPS必须等待整数数据就绪。 Agner Fog表示,在某些情况下,在SnB / IvB上进行整数-> FP-boolean不会有额外的延迟。)

这个版本仍然比内存中带有16B常量的版本占用更少的内存。 对于不经常调用的函数也非常理想,因为没有负载需要遭受缓存未命中。

"绕过延迟"不应该成为问题。如果xmm0是长依赖链的一部分,生成掩码指令将会提前执行,因此xmm5中的整数结果将有足够的时间在xmm0准备好之前到达ANDPS,即使它采用了慢车道。
根据Agner Fog的测试,Haswell没有整数结果到FP布尔值的旁路延迟。他对SnB/IvB的描述表明,这是某些整数指令的输出情况。因此,即使在“起步”依赖链开头的情况下,当这个指令序列发出时,如果xmm0已经准备好了,那么在*well上只需要3个时钟周期,在*Bridge上需要4个时钟周期。如果执行单元正在以被发出的速度清除uops的积压,则延迟可能并不重要。
无论如何,ANDPS的输出将在FP域中,并且如果用于MULPS等操作,则不会有旁路延迟。
在 Nehalem 上,绕过延迟为 2c。因此,在 Nehalem 上的 dep 链的开头(例如,在分支错误预测或 I$ 错误之后),如果 xmm0 已准备就绪,则“延迟”为 5c。如果您非常关心 Nehalem,并且期望此代码是在频繁的分支错误预测或类似的管道停顿之后第一次运行的东西,这会使得 OoOE 机制无法在 xmm0 准备就绪之前开始计算掩码,那么这可能不是非循环情况下的最佳选择。

2a: AVX max(x, 0-x)

VXORPS  xmm5, xmm5, xmm5   # outside the loop

VSUBPS  xmm1, xmm5, xmm0   # inside the loop
VMAXPS  xmm0, xmm0, xmm1
  • 字节数: AVX: 12
  • 融合域uops: 1 + 2*n (没有内存操作)
  • 添加到依赖链的延迟: 6c (Skylake: 8c)
  • 吞吐量: 每2个周期1次(两个端口1 uops)。(Skylake: 1/c,假设MAXPS使用与SUBPS相同的两个端口。)

Skylake取消了单独的向量FP加法单元,并在端口0和1上的FMA单元中执行向量加法。这将加倍FP加法吞吐量,但代价是多1个周期的延迟。FMA延迟降至4(从*well的5)。x87 FADD仍然是3个周期的延迟,因此仍然有一个3个周期的标量80位FP加法器,但只有一个端口。

2b:没有AVX的相同内容:

# inside the loop
XORPS  xmm1, xmm1   # not on the critical path, and doesn't even take an execution unit on SnB and later
SUBPS  xmm1, xmm0
MAXPS  xmm0, xmm1
  • 字节数:9
  • 融合域 uops:3*n(无内存操作)
  • 延迟添加到依赖链中:6c(Skylake:8c)
  • 吞吐量:每2个周期1个(两个端口1 uop)。 (Skylake:1/c)
  • 如果xmm0在此序列发出时准备就绪(无循环),则为“延迟”:相同

使用处理器识别的清零惯用语(如xorps same,same)清零寄存器是在Sandbridge系列微架构上在寄存器重命名期间处理的,并且具有零延迟和4 / c的吞吐量。 (与IvyBridge及更高版本可以消除的reg-> reg移动相同。)

但它并不是免费的:它仍需要一个融合域uop,因此如果您的代码仅受4uop / cycle问题率的瓶颈限制,则会使您变慢。 在超线程中更有可能发生。


3: ANDPS(x, 0-x)

VXORPS  xmm5, xmm5, xmm5   # outside the loop.  Without AVX: zero xmm1 inside the loop

VSUBPS  xmm1, xmm5, xmm0   # inside the loop
VANDPS  xmm0, xmm0, xmm1
  • 字节数:AVX: 12 非-AVX: 9
  • 融合域uops:1 + 2*n (没有内存操作)。 (没有AVX:3*n)
  • 延迟添加到dep链中:4c (Skylake: 5c)
  • 吞吐量:1/c (饱和p1和p5)。 Skylake: 3/2c:(3个向量uop /周期) / (uop_p01 + uop_p015)。
  • "延迟",如果xmm0在此序列发出时已准备就绪(无循环):相同

这应该可以工作,但我不知道NaN会发生什么。很好的观察是ANDPS具有较低的延迟,并且不需要FPU加法端口。

这是非-AVX的最小尺寸。


4: 左/右移位:

PSLLD  xmm0, 1
PSRLD  xmm0, 1
  • 字节数:10(AVX:10)
  • 融合域uops:2*n
  • 添加到依赖链的延迟:4c(2c +旁路延迟)
  • 吞吐量:1/2c(饱和p0,也被FP乘法使用)。 (Skylake 1 / c:加倍向量移位吞吐量)
  • 如果xmm0在发出此序列时已准备好(无循环),“延迟”相同

    这是最小的(按字节计算)带有AVX。

    这有可能在你不能节约寄存器的情况下,且它不在循环中使用。 (在没有可节约的寄存器的循环中,可能会使用andps xmm0,[mask])。

我假设从FP到整数移位有1c的旁路延迟,然后又有1c的延迟返回,因此这与SUBPS / ANDPS一样慢。 它确实节省了一个无执行端口uop,因此如果融合域uop吞吐量是问题,并且您无法将掩码生成移出循环,则具有优势。 (例如,因为这是在循环中调用的函数,而不是内联的)。


何时使用何种方法:从内存中加载掩码使代码简单,但有缓存未命中的风险。而且需要占用16B的只读数据,而不是9个指令字节。

  • 在循环中需要:1c:在循环外生成掩码(使用pcmp/shift);在内部使用单个 andps 。如果你不能节约寄存器,请将其溢出到堆栈中,并使用1aandps xmm0,[rsp + mask_local]。(生成和存储不太可能导致缓存未命中,而常量则会)。无论如何都只增加了1个单-uop指令的关键路径的一个周期。这是一个端口5的uop,因此如果您的循环饱和了洗牌端口并且没有受到延迟的限制,则PAND可能更好。 (SnB/IvB在p1/p5上具有混洗单元,但Haswell/Broadwell/Skylake只能在p5上进行混洗。Skylake确实增加了(V)(P)BLENDV的吞吐量,但没有其他混洗端口操作。如果AIDA数字正确,则非AVX BLENDV为1c lat〜3 / c tput,但AVX BLENDV为2c lat,1/c tput(仍然比Haswell更快))

  • 在频繁调用的非循环函数中(因此您不能将掩码生成分摊到多个用途中):

    1. 如果uop吞吐量是一个问题:1aandps xmm0,[mask]。偶尔的缓存未命中应该分摊了uops的节省,如果那真的是瓶颈的话。
    2. 如果延迟不是问题(函数仅用作短非循环传递的依赖链的一部分,例如arr [i] = abs(2.0 + arr [i]);),并且您想要避免内存中的常量:4,因为它只有2个uop。如果abs在依赖链的开头或结尾,则不会从负载到存储产生旁路延迟。
    3. 如果uop吞吐量不是问题: 1c :使用整数 pcmpeq / shift 即时生成。不可能缓存未命中,并且关键路径仅增加了1c。
  • 在稀少调用的函数中需要(在任何循环之外):只需针对大小进行优化(两个较小的版本都不使用来自内存的常量)。非AVX:3。AVX:4。它们并不差,并且不能缓存未命中。与版本1c获得的4个周期延迟相比,关键路径的4个周期延迟要更糟糕,因此如果您认为3个指令字节不是很重要,则选择 1c 。版本 4 适用于寄存器压力情况下,性能不重要,并且您希望避免溢出任何内容。



  • AMD CPU:在 ANDPS (本身具有2c的延迟) 的通路中有一个绕过延迟,但我认为它仍然是最佳选择。它仍然能够击败 SUBPS 的5-6个周期延迟。 MAXPS 具有2c的延迟。由于Bulldozer系列CPU上FP操作的高延迟,你更有可能通过乱序执行,在另一个操作数到达 ANDPS 时即时生成屏蔽位。我猜测 Bulldozer 到 Steamroller 没有单独的FP加法器,而是在FMA单元中进行向量加法和乘法。3 总是在AMD Bulldozer系列CPU上很差。在这种情况下,2 看起来更好,因为从fma域到fp域和返回的旁路延迟更短。请参阅Agner Fog的微架构指南,第182页(15.11不同执行域之间的数据延迟)。

  • Silvermont:与SnB类似的延迟。仍然使用 1c 进行循环,对于一次性使用也很适合。Silvermont是乱序执行的,因此它可以提前准备好屏蔽位,仍然只增加1个周期到关键路径。


我刚在VS2013上验证了一下,在未初始化的变量上使用 _mm_cmpeq_epi32 将在此测试用例中从 ebp-10h 发出 movdqa 指令。 也就是说,我正在一个循环中对一个大型浮点数组进行 abs 测试,编译器至少足够聪明,可以将内存移动滑动到循环之上,但不足够聪明,无法将 pcmpeqdpsrld 移动到循环之上。 使用 _mm_set1_epi32(-1) 会从常量内存位置(但也在循环之外)发出 movdqa_mm_uninitialized_si128() 是未定义的。 - Kumputer
@Kumputer:那么您最好手动内联vec_abs函数,并在循环外生成常量。我建议使用#ifdef MSVC / myvec = _mm_setzero_si128(); / #endif来获得PXOR而不是从堆栈中加载。或者使用整数常量的PINSRD并进行广播。或者让编译器从内存中加载整个16B常量,而不是即时生成它。这不是太糟糕了,可能不会经常缓存未命中。虽然这不直接影响,但是Ebp?您正在创建32位代码,仅具有8个xmm寄存器和x87中的FP返回值?呕。 - Peter Cordes
@Kumputer:使用 _mm_set1_epi32(-1),你是指最终的移位常量是从内存中加载的,就像clang > 3.5一样吗?还是循环内部仍然有psrld操作? - Peter Cordes
@Kumputer:在 Nehalem 上,左/右移操作会有很大的惩罚,因为整数和浮点之间的旁路延迟每个方向都需要两个周期。如果包括“abs”的依赖链的延迟是限制因素,请勿在需要在 Nehalem 上快速运行的代码中使用它。如果延迟不重要,则这不是一个坏选择。循环内有两条指令而不是一条。我建议使用带有内存源的 ANDPS,除非分析显示它经常缺失缓存。如果可能,请尝试将掩码全局变量放在与 FP 循环中所需的其他常量相同的缓存行中。 - Peter Cordes
1
至少目前版本的clang、icc和gcc都会在使用本地掩码变量时组合相同的掩码常量。当您编译AVX-512时,它们足够聪明以使用广播加载,因此对于浮点(双精度)向量,常量仅为32位(64位)。AVX-512引入了一个绝对值内置函数,但没有底层指令,它只是语法糖:clang和icc使用常量加载处理它,而gcc则进行一些vbroadcast无聊操作。 - BeeOnRope
显示剩余16条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接