assembly 最快的方式来做水平SSE向量和(或其他减少)

7rtdyuoh  于 12个月前  发布在  其他
关注(0)|答案(5)|浏览(135)

给定一个由三个(或四个)浮点数组成的向量,求其和的最快方法是什么?
SSE(movaps,shuffle,add,movd)总是比x87快吗?SSE 3中的水平相加指令值得吗?
移到FPU,然后是faddp,faddp的成本是多少?最快的特定指令序列是什么?
“尝试安排事情,这样你就可以一次对四个向量求和”将不会被接受为答案。:-)例如,对于数组求和,你可以使用多个向量求和来进行垂直求和(以隐藏addps延迟),并在循环后减少到一个,但然后你需要对最后一个向量进行水平求和。

au9on6nz

au9on6nz1#

一般来说,对于任何类型的向量水平缩减,提取/ Shuffle 高半部分与低部分对齐,然后垂直添加(或min/max/或/和/xor/multiply/whatever);重复直到只有一个元素(在向量的其余部分中具有高垃圾)。

如果你从宽度大于128位的向量开始,缩小一半,直到128位(然后你可以在该向量上使用本答案中的函数之一)。但是如果你需要在最后将结果广播到所有元素,那么你可以考虑一直进行全宽 Shuffle 。
更宽向量、整数和FP的相关Q& A

  • __m128__m128d这个答案(见下文)
  • __m256d与Ryzen 1对比英特尔的性能分析(显示为什么vextractf128远远优于vperm2f128Get sum of values stored in __m256d with SSE/AVX
  • __m256How to sum __m256 horizontally?
  • 英特尔AVX:256位版本的点积,用于单向量的双精度浮点变量。
      • 数组的点积**(不只是3或4个元素的单个向量):在multiple accumulators中执行垂直穆尔/add或FMA,并在结尾. Complete AVX+FMA array dot-product example处执行hsum,包括循环后的高效hsum *。(对于数组的简单求和或其他缩减,使用该模式,但不使用乘法部分,例如,用add代替fma)。不要为每个SIMD向量单独做水平工作;在最后做一次。

How to count character occurrences using SIMD作为计数_mm256_cmpeq_epi8的整数示例,同样在整个数组中匹配,仅在末尾进行hsum。(值得特别提及的是,进行一些8位累加,然后扩展8 -> 64位以避免溢出,而无需在该点进行完整的hsum。)
我知道了

(For**int8_t有符号字节**您可以XOR set1_epi8(0x 80)在SAD之前翻转为无符号,然后从最终的hsum中减去偏差;请参阅details here,还显示了仅从内存中执行9个字节而不是16个字节的优化)。

  • 16位unsigned:_mm_madd_epi16和set1_epi16(1)是一个单微操作加宽水平加法:SIMD: Accumulate Adjacent Pairs。然后继续进行32位hsum。
  • __m256i__m512i带有32位元素。Fastest method to calculate sum of all packed 32-bit integers using AVX512 or AVX2。对于AVX 512,英特尔添加了一堆“reduce”内联函数(不是硬件指令)来为您执行此操作,如_mm512_reduce_add_ps(以及pd、epi 32和epi 64)。还有reduce_min/max/穆尔/和/或。手动执行此操作会导致基本相同的asm。
  • horizontal max(而不是add):使用SSE在__m128i向量中获取最大值?

this 问题的主要答案:主要是float和__m128

下面是一些基于Agner Fog's microarch guide的微架构指南和指令表进行了优化的版本。另请参阅x86标签wiki。它们在任何CPU上都应该是高效的,没有主要的瓶颈。(例如,我避免了那些对一个uarch有点帮助但对另一个uarch很慢的东西)。代码大小也被最小化。
常见的SSE 3/SSSE 3 2x hadd习惯用法仅适用于代码大小,而不是任何现有CPU的速度。它有一些用例(如转置和添加,见下文),但单个向量不是其中之一。
我还包含了一个AVX版本,任何AVX /AVX 2的水平缩减都应该从一个vextractf128开始,然后通过一个“垂直”操作缩减到一个XMM(__m128)vector。一般来说,对于宽向量,最好的办法是重复缩小一半,直到减少到128位向量,而不管元素类型如何。(除了8位整数,如果你想在不溢出的情况下对更宽的元素求和,那么vpsadbw作为第一步。

**请参阅Godbolt浏览器上所有这些代码的asm输出。**还请参阅我对Agner Fog的C++ Vector Class Library horizontal_add函数的改进。(message board thread,以及github上的代码)。我使用CPP宏为SSE 2,SSE 4和AVX选择最佳的shuffle代码大小,并在AVX不可用时避免movdqa

需要考虑以下权衡:

  • 代码大小:对于L1 I缓存和从磁盘获取代码(较小的二进制文件)来说,较小的更好。总的二进制文件大小对于在整个程序中反复进行的编译器决策至关重要。如果你费心用intrinsic手工编写代码,如果它能为整个程序提供任何加速 *,那么花几个代码字节是值得的(要小心使展开看起来很好的微基准测试)。

  • uop-cache size:通常比L1 I$更珍贵。4单uop指令占用的空间比2 haddps少,因此这在这里是高度相关的。

  • 延迟:有时相关

  • 吞吐量(后端端口):通常无关紧要,水平总和不应该是最内层循环中的唯一内容。端口压力仅作为包含此内容的整个循环的一部分。

  • throughput(total front-end fused-domain uops):如果周围代码没有在hsum使用的同一个端口上形成瓶颈,则这是hsum对整个吞吐量影响的代理。
    水平加法不常见时

没有uop-cache的CPU在很少使用的情况下可能更喜欢2x haddps:当它运行时会很慢,但这并不常见。只有2条指令可以最大限度地减少对周围代码的影响(I$ size)。
带uop-cache的CPU
可能更喜欢使用较少uop的东西,即使它是更多的指令/更多的x86代码大小。使用的uop缓存行总数是我们想要最小化的,这并不像最小化uop总数那么简单(采取的分支和32 B边界总是开始一个新的uop缓存行)。
无论如何,水平求和出现了很多次,所以这里是我精心制作的一些版本的尝试,这些版本编译得很好。没有在任何真实的硬件上进行基准测试,甚至没有仔细测试。在shuffle常量或其他东西中可能有错误。

如果您正在制作代码的回退/基线版本,请记住,只有旧的CPU才能运行它;新的CPU将运行您的AVX版本,或SSE 4.1或其他版本。
像K8和Core 2(merom)以及更早的老CPU只有64位shuffle单元。Core 2对大多数指令都有128位执行单元,但不包括shuffle。(Pentium M和K8将所有128 b向量指令作为两个64位半处理)。

movhlps这样以64位块移动数据的 Shuffle (64位半块内没有 Shuffle )也很快。
相关:新CPU上的shuffles,以及避免Haswell和更高版本上1/时钟shuffle吞吐量瓶颈的技巧:Do 128bit cross lane operations in AVX512 give better performance?

在慢速 Shuffle 的旧CPU上

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

Core 2(包括Penryn)上的shufps将数据带入整数域,导致旁路延迟将其返回到addps的FP执行单元,但movhlps完全在FP域中。
movshdup在整数域中运行,但只有一个uop。
AMD K10、Intel Core 2(Penryn/Wolfdale)和所有后续CPU都将所有xmm shuffle作为单个uop运行。(但请注意Penryn上的shufps的旁路延迟,movhlps避免了旁路延迟)

在没有AVX的情况下,避免浪费movaps/movdqa指令需要仔细选择shuffle。只有少数shuffle作为复制和shuffle工作,而不是修改目标。将来自两个输入的数据(如unpck*movhlps)合并数据合并的shuffle可以与不再需要的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
}

字符串

__m128 float with SSE 1(aka 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。它有自己的内部表示来进行shuffling,并将其转换回shuffles。gcc更经常使用与您使用的intrinsic直接匹配的指令。
通常clang比gcc做得更好,在代码中指令选择不是手工调优的,或者常量传播可以简化事情,即使内部函数对于非常量情况是最佳的。总的来说,编译器像内部函数的正确编译器一样工作是一件好事,不只是一个汇编器。编译器通常可以从标量C生成好的asm,甚至不尝试以好的asm的方式工作。最终编译器将把intrinsic当作另一个C操作符作为优化器的输入。

__m128使用SSE 3浮动

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副本来处理破坏性的shuffle(没有AVX):movshdup xmm1, xmm2的目的地是只写的,所以它为我们创建了一个死寄存器tmp。这也是我使用movehl_ps(tmp, sums)而不是movehl_ps(sums, sums)的原因。
  • 小代码大小。 Shuffle 指令很小:movhlps是3个字节,movshdup是4个字节(与shufps相同)。不需要立即字节,因此对于AVX,vshufps是5个字节,但vmovhlpsvmovshdup都是4个字节。

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

与SSE 1版本一样,向自身添加奇数元素可能会导致FP异常(如溢出),否则不会发生,但这应该不是问题。反正规化很慢,但IIRC产生+Inf结果在大多数uarch上都不会发生。

SSE 3优化代码大小

如果代码大小是您主要关心的问题,那么两个haddps_mm_hadd_ps)指令将完成此操作(Paul R的回答)。这也是最容易输入和记住的。但是它不快。即使是Intel Skylake仍然将每个haddps解码为3个uop,有6个周期的延迟。所以即使它节省了机器码字节(L1 I-cache),它在更有价值的uop-cache中占用更多的空间。haddps的真实的用例:a transpose-and-sum problem,或者在中间步骤in this SSE atoi() implementation进行一些扩展。

__m256使用AVX浮动:

这个版本比马拉特对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

__m128d double双精度:

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。如果shuffle端口压力或ALU uop是瓶颈,这很好。(注意,它不需要sub rsp, 8或任何东西,因为x86-64 SysV ABI提供了一个信号处理程序不会踩到的红色区域。)
有些人存储到数组中并对所有元素求和,但编译器通常不会意识到数组的低元素仍然存在于存储之前的寄存器中。

__m128i int32_t解析:

pshufd是一个方便的复制和混洗。不幸的是,位和字节移位是到位的,punpckhqdq将目标的高半部分放在结果的低半部分,与movhlps将高半部分提取到不同寄存器的方式相反。
在某些CPU上使用movhlps作为第一步可能是好的,但前提是我们有一个临时注册表。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);
}


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

k10s72fa

k10s72fa2#

SSE 2

四个:

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));


我发现它们的速度和double HADDPS差不多(但我没有太仔细地测量过)。

gkl3eglg

gkl3eglg3#

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

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

字符串
这将所有元素的和。

hujrc8aj

hujrc8aj4#

我肯定会给给予SSE 4.2一个尝试。如果你是这样做多次(如果性能是一个问题,我假设你是),你可以用(1,1,1,1)预加载一个寄存器,然后执行几个dot 4(my_vec(s),one_vec)。是的,它做了一个多余的乘法,但这些都是相当便宜的,这样的操作很可能是由水平依赖关系主导的,这可能是更优化的新的SSE点积函数。你应该测试,看看它是否优于双水平加保罗R张贴。
我还建议将其与纯标量(或标量SSE)代码进行比较-奇怪的是,它通常更快(通常是因为它在内部是串行化的,但使用寄存器旁路进行了紧密的流水线处理,其中特殊的水平指令可能还没有快速路径),除非您正在运行类似SIMT的代码,这听起来好像不是(否则您会做四个点积)。

jm81lzqq

jm81lzqq5#

通常,“最快可能的方法”的问题预先假设了一个需要在时间关键循环中多次完成的任务。
那么最快的方法可能是成对工作的迭代方法,它可以分摊迭代之间的一些工作。

  • 通过将向量拆分为低/高部分来减少的总成本是O(log 2(N)),而通过将向量拆分为偶数/奇数序列来减少的摊销成本是O(1)。
inline vec update(vec context, vec data) {
    vec even = get_evens(context, data);
    vec odd = get_odds(context, data);
    return vertical_operation(even, odd);
}

void my_algo(vec *data, int N, vec_element_type *out) {

   vec4 context{0,0,0,0};
   context = update(context, data[0]);
   int i;
   for (int i = 0; i < N-1; i++) {
       context = update(context, data[i+1]);
       output[i] = extract_lane(context, 1);
   }
   context = update(context, anything);
   output[N-1] = extract_lane(context, 1);
}

字符串
所需的和将从累加器的第二个元素(索引1)中找到(在1次迭代之后),而第一个元素将包含迄今为止所有元素的总减少。

Reduct = [ -- ][ -- ][ -- ][ -- ]
New input = [i0 ][ i1 ][ i2 ][ i3 ]

evens = [ -- ][ -- ][ i0 ][ i2 ]
odds  = [ -- ][ -- ][ i1 ][ i3 ]
-------   vertical arithmetic reduction ----
Reduct = [ -- ][ -- ][ 01 ][ 23 ]

input = [ 4 ][ 5 ][ 6 ][ 7 ]

evens = [ -- ][ 01 ][ 4 ][ 6 ]
odds  = [ -- ][ 23 ][ 5 ][ 7 ]

Reduct = [ -- ][ 0123 ][ 45 ][ 67 ]

New input: [ 8 ] [ 9 ] [ a ] [ b ]
evens = [ -- ][ 45 ][ 8 ][ a ]
odds =  [0123][ 67 ][ 9 ][ b ]
------------------------------
Reduct = [0123][4567][ 89 ][ ab ]


我有疑问,如果这将被证明是更快的矢量长度为3或4比先生提出的Cordes,然而,对于16或8位数据,这种方法应该证明是值得的。然后当然需要执行3或4轮分别才能获得结果。

如果水平操作恰好是sum --那么每次迭代实际上可以只使用一个hadd

相关问题