在x86上执行水平浮点vector和的最快方法

你有一个三(或四)的花车vector。 什么是总结他们最快的方法?

SSE(movaps,shuffle,add,movd)总是比x87快吗? SSE4.2中的横向增加指令是否值得呢? 迁移到FPU的费用是多less,然后是faddp,faddp? 什么是最快的具体指令序列?

“尝试安排事物,所以你可以一次总结四个向量”将不被接受为答案。 🙂

这里有一些版本根据Agner Fog的微型指南的微型指南和指令表进行了调整。 另请参阅x86标记wiki。 它们应该在任何CPU上都是有效的,没有主要的瓶颈。 (例如,我避免了那些能够帮助你一点点但是在另一个傲慢中缓慢的事情)。 代码大小也最小化。

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

我还包括一个AVX版本。 使用AVX / AVX2进行任何水平vextractf128都应以vextractf128和“垂直”操作开始,以减less一个XMM( __m128 )vector。

在Godbolt编译器资源pipe理器中查看所有这些代码的asm输出。 另请参阅我对Agner Fog的C ++ Vector类库 horizontal_add函数的改进。 ( 留言板线程和github上的代码)。 我使用CPPmacros为SSE2,SSE4和AVX的代码大小select最佳的movdqa ,并在AVX不可用时避免movdqa


有权衡考虑:

  • 代码大小:对于L1 Icaching原因,以及从磁盘(较小的二进制文件)获取代码,较小。 总的二进制大小对编译器决定在整个程序中重复是很重要的。 如果你打算用内在函数手工编写代码,那么如果它为整个程序提供了任何加速(值得注意的是使展开看起来不错的微观基准),那么值得花费几个代码字节。
  • uop-cache size:通常比L1 I $更珍贵。 4个单用户指令可以比2个用户占用更less的空间,所以这里是高度相关的。
  • 延迟:有时是相关的
  • 吞吐量:通常不相关,水平和不应该在最内层的循环中。
  • 总的融合域uops:如果周围的代码在hsum使用的同一个端口上没有瓶颈,这是hsum对整个事物吞吐量的影响的代理。

当横向添加不频繁时

没有uop-cache的 CPU可能会支持2x haddps :运行时速度很慢,但这并不常见。 只有2条指令可以减less对周围代码(I $ size)的影响。

具有uop-cache的 CPU可能会支持更less的uops,即使是更多的指令/更多的x86代码大小。 所使用的总uopscaching线是我们想要最小化的,这并不像最小化总uops(采取分支和32B边界总是开始一个新的uopcaching线)那样简单。

无论如何,这样说,水平的总和来了很多 ,所以这里是我精心编写一些编译好的版本的尝试。 没有在任何真实的硬件上进行基准testing,甚至是经过仔细testing 洗牌常量中可能存在一些错误或者其他的东西。


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

像K8和Core2(merom)以及更早版本的旧CPU只有64位混洗单元 。 Core2的大部分指令都有128位执行单元,但不适用于洗牌。 (Pentium M和K8将所有的128b向量指令作为两个64位的一半来处理)。

像在64位块中移动数据的movhlps混洗(在64位半内不混洗)速度也很快。

在缓慢洗牌的旧CPU上

  • movhlps (Merom:1uop)比shufps (Merom:3uops)快得多。 在奔腾-M,比movaps便宜。 另外,它运行在Core2上的FP域中,避免了其他混洗的旁路延迟。
  • unpcklpdunpcklps更快。
  • pshufd很慢, pshuflw / pshufhw很快(因为他们只洗牌一个64位的一半)
  • pshufb mm0 (MMX)很快, pshufb xmm0很慢。
  • haddps非常慢(在Merom和Pentium M上是6uops)
  • movshdup (Merom:1uop)很有意思 :它是唯一一个在64b元素内洗牌的1uop insn。

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

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

AMD K10,Intel Core2(Penryn / Wolfdale)以及所有以后的CPU,将所有xmm混洗作为一个单独的uop运行。 (但是请注意,在Penryn上使用movhlps避开movhlps


没有AVX,避免浪费movaps / movdqa指令需要仔细select洗牌 。 只有less数洗牌作为复制和洗牌,而不是修改目的地。 将两个input(如unpck*movhlps )的数据合并的unpck* movhlps可以与不再需要的tmpvariables一起使用,而不是_mm_movehl_ps(same,same)

其中一些可以通过采用一个虚拟参数作为初始混洗的目的地,使得速度更快(保存MOVAPS),但更简洁/更“干净”。 例如:

 // 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 = [ DC | BA ] __m128 shuf = _mm_shuffle_ps(v, v, _MM_SHUFFLE(2, 3, 0, 1)); // [ CD | AB ] __m128 sums = _mm_add_ps(v, shuf); // sums = [ D+C C+D | B+A A+B ] shuf = _mm_movehl_ps(shuf, sums); // [ CD | 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 

我报告了一个关于悲剧洗牌的叮咚声 。 它有自己的洗牌内部代表,并把它变回洗牌。 gcc更经常地使用直接匹配你使用的内在的指令。

在没有手动调整的情况下,clang通常比gcc好,或者即使内部函数对于非常量情况是最优的,常量传播也可以简化事物。 总的来说,编译器像内部函数的正确编译器一样工作,而不仅仅是汇编器。 编译器通常可以从标量C生成良好的asm,甚至不会尝试以良好的方式工作。 最终编译器将内部函数作为另一个C运算符作为优化器的input。


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的目的地是只写的,所以它为我们创build了一个死亡寄存器的tmp 。 这也是为什么我用movehl_ps(tmp, sums)而不是movehl_ps(sums, sums)

  • 小的代码大小。 混洗指令很小: movhlps是3个字节, movshdup是4个字节(与shufps相同)。 不需要立即字节,所以对于AVX, vshufps是5字节,但vmovhlpsvmovshdup都是4。

我可以保存另一个字节与addps而不是addss 。 由于这不会在内部环路中使用,额外的能量来切换额外的晶体pipe可能可以忽略不计。 来自上面3个元素的FPexception没有风险,因为所有元素都保存有效的FP数据。 然而,clang / LLVM实际上“理解”向量洗牌,并且如果知道只有低位元素很重要,则发出更好的代码。

像SSE1版本一样,将奇怪的元素添加到自己可能会导致FPexception(如溢出),否则不会发生,但这不应该是一个问题。 非正常速度很慢,但是IIRC产生+ Inf结果并不是最常见的。


SSE3针对代码大小进行了优化

如果代码大小是你主要关心的问题,那么两个haddps_mm_hadd_ps )指令就可以完成这个任务(Paul R的回答)。 这也是最容易input和记住的。 不过,这并不快 。 即使英特尔Skylake仍然解码每个haddps到3 haddps ,与6周期延迟。 所以即使节省了机器码字节(L1 I-cache),它在更有价值的uop-cache中占用了更多的空间。 haddps实际用例: 转置求和问题 ,或者在这个SSE atoi()实现的中间步骤中进行一些缩放。


AVX:

此版本与Marat对AVX问题的答案保存了一个代码字节。

 #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 

双精度:

 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] 

存储到内存和回避避免了一个ALU uop。 如果洗牌端口压力,或者ALU总体上来说是一个瓶颈,那就太好了。 (请注意,它不需要sub rsp, 8或其他东西,因为x86-64 SysV ABI提供了一个信号处理程序不会继续的红区。)

有些人将数组存储到一个数组中并对所有元素进行求和,但是编译器通常并不知道数组中的低元素仍然存在于存储之前的寄存器中。


整数:

pshufd是一个方便的复制和洗牌。 不幸的是,位和字节移位就位,而punpckhqdq将目标的高位一半放在结果的低位一半,与movhlps将高位一半提取到另一个寄存器的方式相反。

第一步使用movhlps在某些CPU上可能是好的,但是只有当我们有一个scratch reg。 pshufd是一个安全的select,并且在Merom之后的一切上都很快。

 int hsum_epi32_sse2(__m128i x) { #ifdef __AVX__ __m128i hi64 = _mm_unpackhi_epi64(x, x); // 3-operand non-destructive AVX lets us save a byte without needing a mov #else __m128i hi64 = _mm_shuffle_epi32(x, _MM_SHUFFLE(1, 0, 3, 2)); #endif __m128i sum64 = _mm_add_epi32(hi64, x); __m128i hi32 = _mm_shufflelo_epi16(sum64, _MM_SHUFFLE(1, 0, 3, 2)); // Swap the low two elements __m128i sum32 = _mm_add_epi32(sum64, hi32); return _mm_cvtsi128_si32(sum32); // SSE2 movd //return _mm_extract_epi32(hl, 0); // SSE4, even though it compiles to movd instead of a literal pextrd r32,xmm,0 } # gcc 5.3 -O3 pshufd xmm1,xmm0,0x4e paddd xmm0,xmm1 pshuflw xmm1,xmm0,0x4e paddd xmm0,xmm1 movd eax,xmm0 int hsum_epi32_ssse3_slow_smallcode(__m128i x){ x = _mm_hadd_epi32(x, x); x = _mm_hadd_epi32(x, x); return _mm_cvtsi128_si32(x); } 

在一些CPU上,在整数数据上使用FP shuffle是安全的。 我没有这样做,因为现代的CPU最多只能保存1或2个代码字节,没有速度增益(代码大小/alignment效应除外)。

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大致相同(但我没有测得过于密切)。

你可以在SSE3的两个HADDPS指令中做到这一点:

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

这把所有元素的总和。

我肯定会给SSE 4.2一个尝试。 如果你这样做了多次(我假设你是如果性能是一个问题),你可以预先加载一个寄存器(1,1,1,1),然后做几个dot4(my_vec(s),one_vec)在上面。 是的,它有一个多余的繁殖,但这些日子相当便宜,这样的操作可能是由水平的依赖主导,这可能是在新的SSE点产品function更优化。 你应该testing,看看它是否胜过双水平添加保罗R张贴。

我还build议将它与直接标量(或标量SSE)代码进行比较 – 奇怪的是,它通常更快(通常是因为内部是序列化的,但使用寄存器旁路紧密stream水线化,特殊的水平指令可能不会快速运行)正在运行类似SIMT的代码,这听起来像你不是(否则你会做四点产品)。