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

Fee*_*ure 41 floating-point optimization x86 assembly sse

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

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

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

Pet*_*des 69

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

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

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

请参阅Godbolt Compiler Explorer上所有这些代码的asm输出. 另请参阅我对Agner Fog的C++ Vector类库 horizontal_add函数的改进.(留言板线程github上的代码).我使用CPP宏为SSE2,SSE4和AVX的代码大小选择最佳shuffle,并避免movdqaAVX不可用时.


需要考虑以下权衡:

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

当水平添加不频繁时:

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

具有uop-cache的 CPU 可能会偏向于需要更少uops的东西,即使它是更多指令/更多x86代码大小.使用的总uops缓存行是我们想要最小化的,这并不像最小化总uop那样简单(采用的分支和32B边界总是启动一个新的uop缓存行).

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


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

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

这样的随机播放movhlps以64位块移动数据(在64位半部内没有改组)也很快.

在具有慢速shuffle的旧CPU上:

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

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

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

AMD K10,Intel Core2(Penryn/Wolfdale)以及所有后来的CPU都将xmm shuffle作为单个uop运行.(但请注意shufpsPenryn 的旁路延迟,避免使用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
}
Run Code Online (Sandbox Code Playgroud)

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
Run Code Online (Sandbox Code Playgroud)

我报告了一个关于对洗牌感到悲观的吵闹声.它有自己的内部表示,用于改组,然后将其转换为随机播放.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
Run Code Online (Sandbox Code Playgroud)

这有几个好处:

  • 不需要任何movaps副本来破坏性混洗(没有AVX):movshdup xmm1, xmm2目的地是只写的,所以它tmp为我们创建了一个死寄存器.这也是我用来movehl_ps(tmp, sums)代替的原因movehl_ps(sums, sums).

  • 小代码.混洗指令很小: movhlps是3个字节,movshdup是4个字节(相同shufps).没有直接的字节是必需的,因此与AVX,vshufps是5个字节,但vmovhlpsvmovshdup都是4.

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

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


SSE3优化代码大小

如果代码大小是你主要关注的问题,那么两个haddps(_mm_hadd_ps)指令就可以解决问题(Paul R的回答).这也是最容易打字和记忆的.但这并不快.甚至英特尔Skylake仍然将每个解码haddps为3 uop,具有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
Run Code Online (Sandbox Code Playgroud)

双精度:

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]
Run Code Online (Sandbox Code Playgroud)

存储到内存和后退避免了ALU uop.如果洗牌端口压力或一般的ALU uops是一个瓶颈,这是好的.(请注意,它不需要sub rsp, 8或任何东西,因为x86-64 SysV ABI提供了一个红色区域,信号处理程序不会踩到它.)

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


整数:

pshufd是一个方便的复制和随机播放.遗憾的是,位和字节移位就位,punpckhqdq并将目标的高半部分置于结果的低半部分,与此方式相反movhlps可将高半部分提取到不同的寄存器中.

使用movhlps的第一个步骤可能是对某些CPU好,但前提是我们有一个从无到有REG. pshufd是一个安全的选择,在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);
}
Run Code Online (Sandbox Code Playgroud)

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

  • @plasmacel:在包括 Intel SnB 系列在内的许多 CPU 上,将 FP 指令的结果转发到整数 shuffle 以及从 PSHUFD 到 ADDPS 存在额外的旁路延迟延迟。如果您关心吞吐量和 uop 计数而不是延迟,那就太好了。(整数指令之间的 SHUFPS 对 SnB 系列没有惩罚(与 Nehalem 不同),但反之则不然。) (2认同)
  • 如果您有特定的微体系结构和编译器,您可以而且应该制作一个更适合该微体系结构和编译器的版本。**这个答案试图对于像 Haswell 这样的现代 CPU 来说是最佳的(延迟、吞吐量和代码大小),同时在旧 CPU 上尽可能少地吸收**。即我的 SSE1 / SSE2 版本不会做任何在 Haswell 上更糟糕的事情,只是为了在像 Merom 这样的旧 SlowShuffle CPU 上运行得更快。对于 Merom 来说,PSHUFD 可能是一个胜利,因为它和 SHUFPS 都运行在 flt->int 域中。 (2认同)
  • @plasmacel:不,除非您的向量一开始就在内存中,因为 VPERMILPS 可以加载+随机播放。使用旧指令的 AVX 版本可以获得更小的代码大小,因为您不需要立即数,并且它们只需要 2 字节 VEX 前缀(`C5 ..` 而不是 `C4 .. ..`) . 像 VSHUFPS 和 VMOVHLPS 这样的双源洗牌并不比像 VPSHUFD 或 VPERMILPS 这样的单源洗牌慢。如果能量消耗存在差异,则可能可以忽略不计。 (2认同)
  • @plasmacel:正如我的回答所指出的,我的 SSE3 版本使用 AVX 进行了最佳编译,但 clang 将其悲观为 VPERMILPD:https://godbolt.org/g/ZH88wH。gcc 的版本是四个 4B 指令(不包括 RET)。clang 的版本长 2 个字节,速度相同。是什么让您认为 VPERMILPS 胜过 SHUFPS?AFAIK,当源已经在寄存器中时,clang 支持它用于立即洗牌是错误的。Agner Fog 的表格没有显示差异。它对 load+shuffle 和 variable-shuffles 很有用,对于编译器来说可能更容易,因为它是 1-input 指令,但不是更快 (2认同)

Kor*_*nel 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));
Run Code Online (Sandbox Code Playgroud)

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));
Run Code Online (Sandbox Code Playgroud)

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


Pau*_*l R 10

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

v = _mm_hadd_ps(v, v);
v = _mm_hadd_ps(v, v);
Run Code Online (Sandbox Code Playgroud)

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

  • 总和不是最终的*all*元素吗? (2认同)

Cro*_*ey9 5

我肯定会尝试 SSE 4.2。如果您多次执行此操作(如果性能是一个问题,我假设您是这样做的),您可以使用 (1,1,1,1) 预加载寄存器,然后执行几次 dot4(my_vec(s), one_vec)在上面。是的,它做了多余的乘法,但现在这些乘法相当便宜,而且这样的操作很可能由水平依赖关系主导,这可能在新的 SSE 点积函数中得到更优化。您应该测试一下它是否优于 Paul R 发布的双水平添加。

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

  • 即使在 Skylake 中,1 个“dpps”也是 4 uops,13c 延迟。(但是每 1.5c 吞吐量一个)。`haddps` 是 3uops,6c 延迟。(每 2c 吞吐量一个)。存储和标量并不算太糟糕,因为它不会花费很多微指令,但与 Kornel 的答案相比,它的延迟非常糟糕。不过,标量操作与矢量操作具有相同的延迟。您的“使用寄存器旁路进行紧密流水线”的猜测是不正确的。除了 div 之外的所有内容都是完全流水线化的,但是您说得对,水平指令不是快速路径。它们被解码为内部随机微指令。 (3认同)

归档时间:

查看次数:

24754 次

最近记录:

8 年,2 月 前