在x86上做水平浮点矢量和的最快方法

时间:2011-08-09 13:16:20

标签: optimization assembly floating-point x86 sse

你有一个三(或四)个浮点数的向量。总结它们的最快方法是什么?

SSE(movaps,shuffle,add,movd)总是比x87快吗? SSE4.2中的水平加法说明值得吗?移动到FPU的成本是多少,然后是faddp,faddp?什么是最快的特定指令序列?

“尝试安排事情,这样你一次可以总结四个向量”将不被接受为答案。 : - )

4 个答案:

答案 0 :(得分:64)

以下是根据Agner Fog's microarch guide微型指南和说明表调整的一些版本。另请参阅标记wiki。它们应该在任何CPU上都很有效,没有主要的瓶颈。 (例如,我避免了一些可以帮助一些人,但在另一个uarch上缓慢的事情)。代码大小也被最小化。

常见的2x hadd成语仅适用于代码大小,而不适用于任何现有CPU。它有用例(见下文),但这不是其中之一。

我还包括AVX版本。使用AVX / AVX2进行任何类型的水平缩小都应以vextractf128和"垂直"开头。减少一个XMM(__m128)向量的操作。

查看所有代码on the Godbolt Compiler Explorer的asm输出。另请参阅我对Agner Fog's C++ Vector Class Library horizontal_add函数的改进。 (message board threadgithub上的代码)。我使用CPP宏为SSE2,SSE4和AVX的代码大小选择最佳shuffle,并在AVX不可用时避免movdqa

需要考虑权衡:

  • 代码大小:对于L1 I-cache原因,以及从磁盘(较小的二进制文件)获取代码,较小的更好。总二进制大小主要对于在整个程序中重复进行的编译器决策很重要。如果您不想用内在函数手动编写代码,如果它为整个程序提供了任何加速,那么值得花几个代码字节(小心那些使得展开的微基准测试好看)。
  • uop-cache size:通常比L1 I $更珍贵。 4个单uop指令占用的空间少于2 haddps,因此这里非常重要。
  • 延迟:有时相关
  • 吞吐量:通常不相关,水平总和不应该在最里面的循环中。
  • 总融合域uops:如果周围代码在hsum使用的同一端口上没有瓶颈,则代表hsum对整个事物吞吐量的影响。

横向添加不常见

没有uop-cache 的CPU 可能会支持2x haddps:它运行时会变慢,但这种情况并不常见。只有2个指令可以最大限度地减少对周围代码的影响(I $ size)。

带有uop-cache 的CPU 可能会支持更少uops的东西,即使它有更多指令/ x86代码大小。使用的总uops缓存行是我们想要最小化的,这不是最小化总uop(采用分支和32B边界总是启动新的uop缓存行)。

无论如何,说到这里,横向总和出现了很多,所以我试图精心制作一些编译得很好的版本。没有在任何真实硬件上进行基准测试,甚至没有仔细测试。在随机常量或其他东西中可能存在错误。

如果您正在制作代码的后备/基线版本,请记住只有旧CPU会运行它;较新的CPU将运行您的AVX版本,或SSE4.1或其他任何。

像K8和Core2(merom)这样的旧CPU只有64位shuffle单位。 Core2有大多数指令的128位执行单元,但不适用于shuffle。 (Pentium M和K8将所有128b向量指令作为两个64位半部处理)。

movhlps那样以64位块移动数据(不会在64位半场内移动)的随机播放也很快。

在缓慢洗牌的旧CPU上

  • movhlps(Merom:1uop)明显快于shufps(Merom:3uops)。在Pentium-M上,比movaps便宜。此外,它在Core2上的FP域中运行,避免了其他shuffle的旁路延迟。
  • unpcklpdunpcklps快。
  • pshufd速度很慢,pshuflw / pshufhw速度很快(因为它们只会将64位一半洗牌)
  • pshufb mm0(MMX)很快,pshufb xmm0很慢。
  • haddps非常慢(Merom和Pentium M上6uops)
  • movshdup(Merom:1uop)很有意思:它是唯一一个在64b元素中随机播放的1uop insn。
Core2上的

shufps(包括Penryn)将数据带入整数域,导致绕过延迟,使其返回到addps的FP执行单元,但movhlps完全在FP域。 shufpd也在浮动域中运行。

movshdup在整数域中运行,但只有一个uop。

AMD K10,Intel Core2(Penryn / Wolfdale)以及所有后来的CPU都将xmm shuffle作为单个uop运行。 (但请注意Penryn上的shufps旁路延迟,movhlps

如果没有AVX,请避免浪费movaps / movdqa指令,需要仔细选择随机播放。只有少数shuffle可以作为复制和随机播放,而不是修改目的地。组合来自两个输入(例如unpck*movhlps)的数据的随机播放可以与不再需要的tmp变量一起使用,而不是_mm_movehl_ps(same,same)

其中一些可以更快(保存MOVAPS)但更丑/更少"清洁"通过使用虚拟arg作为初始shuffle的目的地。例如:

// Use dummy = a recently-dead variable that vec depends on,
//  so it doesn't introduce a false dependency,
//  and the compiler probably still has it in a register
__m128d highhalf_pd(__m128d dummy, __m128d vec) {
#ifdef __AVX__
    // With 3-operand AVX instructions, don't create an extra dependency on something we don't need anymore.
    (void)dummy;
    return _mm_unpackhi_pd(vec, vec);
#else
    // Without AVX, we can save a MOVAPS with MOVHLPS into a dead register
    __m128 tmp = _mm_castpd_ps(dummy);
    __m128d high = _mm_castps_pd(_mm_movehl_ps(tmp, _mm_castpd_ps(vec)));
    return high;
#endif
}

SSE1(又名SSE):

float hsum_ps_sse1(__m128 v) {                                  // v = [ D C | B A ]
    __m128 shuf   = _mm_shuffle_ps(v, v, _MM_SHUFFLE(2, 3, 0, 1));  // [ C D | A B ]
    __m128 sums   = _mm_add_ps(v, shuf);      // sums = [ D+C C+D | B+A A+B ]
    shuf          = _mm_movehl_ps(shuf, sums);      //  [   C   D | D+C C+D ]  // let the compiler avoid a mov by reusing shuf
    sums          = _mm_add_ss(sums, shuf);
    return    _mm_cvtss_f32(sums);
}
    # gcc 5.3 -O3:  looks optimal
    movaps  xmm1, xmm0     # I think one movaps is unavoidable, unless we have a 2nd register with known-safe floats in the upper 2 elements
    shufps  xmm1, xmm0, 177
    addps   xmm0, xmm1
    movhlps xmm1, xmm0     # note the reuse of shuf, avoiding a movaps
    addss   xmm0, xmm1

    # clang 3.7.1 -O3:  
    movaps  xmm1, xmm0
    shufps  xmm1, xmm1, 177
    addps   xmm1, xmm0
    movaps  xmm0, xmm1
    shufpd  xmm0, xmm0, 1
    addss   xmm0, xmm1

我报了clang bug about pessimizing the shuffles。它有自己的内部表示,用于改组,然后将其转换为随机播放。 gcc更经常使用与您使用的内在函数直接匹配的指令。

在指令选择未经手动调整的代码中,clang通常比gcc更好,或者即使内在函数对于非常数情况是最优的,常量传播也可以简化事物。总的来说,编译器像内在函数的正确编译器一样工作,而不仅仅是汇编程序,这是一件好事。编译器通常可以从标量C生成良好的asm,它甚至不会尝试按照好的方式工作。最终,编译器会将内在函数视为另一个C运算符,作为优化器的输入。

SSE3

float hsum_ps_sse3(__m128 v) {
    __m128 shuf = _mm_movehdup_ps(v);        // broadcast elements 3,1 to 2,0
    __m128 sums = _mm_add_ps(v, shuf);
    shuf        = _mm_movehl_ps(shuf, sums); // high half -> low half
    sums        = _mm_add_ss(sums, shuf);
    return        _mm_cvtss_f32(sums);
}

    # gcc 5.3 -O3: perfectly optimal code
    movshdup    xmm1, xmm0
    addps       xmm0, xmm1
    movhlps     xmm1, xmm0
    addss       xmm0, xmm1

这有几个好处:

  • 不需要任何movaps份副本来解决破坏性混洗(没有AVX):movshdup xmm1, xmm2目的地是只写的,所以它会创建{{ 1}}出于我们的死登记册。这也是我使用tmp代替movehl_ps(tmp, sums)

  • 的原因
  • 小代码。混洗指令很小:movehl_ps(sums, sums)是3个字节,movhlps是4个字节(与movshdup相同)。不需要立即字节,因此对于AVX,shufps为5个字节,vshufpsvmovhlps均为4。

我可以使用vmovshdup而不是addps保存另一个字节。由于不能在内部环路内使用,因此切换额外晶体管的额外能量可能可以忽略不计。来自上3个元素的FP异常不存在风险,因为所有元素都包含有效的FP数据。然而,clang / LLVM实际上"理解"矢量shuffle,如果它知道只有低元素很重要,则发出更好的代码。

与SSE1版本一样,将奇数元素添加到自身可能会导致FP异常(如溢出),否则不会发生,但这不应该是一个问题。非正规数很慢,但IIRC产生+ Inf结果并不适用于大多数搜索。

SSE3优化代码大小

如果代码大小是您最关心的问题,那么两个addsshaddps)指令就可以解决问题(Paul R的回答)。这也是最容易打字和记忆的。但是,不快。甚至英特尔Skylake仍然会将每个_mm_hadd_ps解码为3 uop,具有6个周期延迟。因此,即使它节省了机器代码字节(L1 I-cache),它也会在更有价值的uop-cache中占用更多空间。 haddps的真实用例:a transpose-and-sum problem,或在中间步in this SSE atoi() implementation进行一些缩放。

AVX:

此版本保存代码字节与Marat's answer to the AVX question

haddps

双精度:

#ifdef __AVX__
float hsum256_ps_avx(__m256 v) {
    __m128 vlow  = _mm256_castps256_ps128(v);
    __m128 vhigh = _mm256_extractf128_ps(v, 1); // high 128
           vlow  = _mm_add_ps(vlow, vhigh);     // add the low 128
    return hsum_ps_sse3(vlow);         // and inline the sse3 version, which is optimal for AVX
    // (no wasted instructions, and all of them are the 4B minimum)
}
#endif

 vmovaps xmm1,xmm0               # huh, what the heck gcc?  Just extract to xmm1
 vextractf128 xmm0,ymm0,0x1
 vaddps xmm0,xmm1,xmm0
 vmovshdup xmm1,xmm0
 vaddps xmm0,xmm1,xmm0
 vmovhlps xmm1,xmm1,xmm0
 vaddss xmm0,xmm0,xmm1
 vzeroupper 
 ret

存储到内存并返回可避免ALU uop。如果洗牌端口压力或一般的ALU uops是一个瓶颈,这是好事。 (请注意,它不需要double hsum_pd_sse2(__m128d vd) { // v = [ B | A ] __m128 undef = _mm_undefined_ps(); // don't worry, we only use addSD, never touching the garbage bits with an FP add __m128 shuftmp= _mm_movehl_ps(undef, _mm_castpd_ps(vd)); // there is no movhlpd __m128d shuf = _mm_castps_pd(shuftmp); return _mm_cvtsd_f64(_mm_add_sd(vd, shuf)); } # gcc 5.3.0 -O3 pxor xmm1, xmm1 # hopefully when inlined, gcc could pick a register it knew wouldn't cause a false dep problem, and avoid the zeroing movhlps xmm1, xmm0 addsd xmm0, xmm1 # clang 3.7.1 -O3 again doesn't use movhlps: xorpd xmm2, xmm2 # with #define _mm_undefined_ps _mm_setzero_ps movapd xmm1, xmm0 unpckhpd xmm1, xmm2 addsd xmm1, xmm0 movapd xmm0, xmm1 # another clang bug: wrong choice of operand order // This doesn't compile the way it's written double hsum_pd_scalar_sse2(__m128d vd) { double tmp; _mm_storeh_pd(&tmp, vd); // store the high half double lo = _mm_cvtsd_f64(vd); // cast the low half return lo+tmp; } # gcc 5.3 -O3 haddpd xmm0, xmm0 # Lower latency but less throughput than storing to memory # ICC13 movhpd QWORD PTR [-8+rsp], xmm0 # only needs the store port, not the shuffle unit addsd xmm0, QWORD PTR [-8+rsp] 或其他任何东西,因为x86-64 SysV ABI提供了一个红色区域,信号处理程序不会踩到它。)

有些人存储到一个数组并总结所有元素,但编译器通常不会意识到数组的低元素仍然存在于商店之前的寄存器中。

整数:

sub rsp, 8是一种方便的复制和随机播放。遗憾的是,位和字节移位就位,pshufd将目标的高半部分置于结果的低半部分,与punpckhqdq将高半部分提取到不同寄存器的方式相反。

在某些CPU上使用movhlps可能会很好,但前提是我们有一个临时注册表。 movhlps是一个安全的选择,并且在Merom之后的所有事情上都很快。

pshufd

在某些CPU上,对整数数据使用FP shuffle是安全的。我没有这样做,因为在现代CPU上最多可以节省1或2个代码字节,没有速度增益(除了代码大小/对齐效果)。

答案 1 :(得分:18)

SSE2

全部四个:

const __m128 t = _mm_add_ps(v, _mm_movehl_ps(v, v));
const __m128 sum = _mm_add_ss(t, _mm_shuffle_ps(t, t, 1));

R1 + R2 + R3:

const __m128 t1 = _mm_movehl_ps(v, v);
const __m128 t2 = _mm_add_ps(v, t1);
const __m128 sum = _mm_add_ss(t1, _mm_shuffle_ps(t2, t2, 1));

我发现这些速度与双倍HADDPS大致相同(但我没有太仔细地测量过)。

答案 2 :(得分:10)

您可以在SSE3中的两个HADDPS说明中执行此操作:

v = _mm_hadd_ps(v, v);
v = _mm_hadd_ps(v, v);

这将总和放在所有元素中。

答案 3 :(得分:3)

我肯定会试试SSE 4.2。若你多次这样做(我假设你是性能问题),你可以用(1,1,1,1)预先加载一个寄存器,然后做几个dot4(my_vec(s),one_vec)在上面。是的,它确实是多余的,但这些日子相当便宜,而且这种操作可能会受到水平依赖性的支配,这可能在新的SSE点积函数中更加优化。您应该测试它是否优于Paul R发布的双重水平添加。

我还建议将它与直标量(或标量SSE)代码进行比较 - 奇怪的是它通常更快(通常是因为内部序列化但使用寄存器旁路紧密流水线,其中特殊的水平指令可能无法快速完成(尚未) )除非你运行类似SIMT的代码,听起来你不是(否则你会做四个点产品)。