最快的方法做水平 SSE 矢量和(或其他减少)

给定一个三(或四)个浮点数的向量,最快的求和方法是什么?

SSE (移动、洗牌、添加、移动)总是比 x87快吗? SSE3中的水平添加指令值得吗?

移动到 FPU,然后是 faddp,faddp 的成本是多少? 最快的具体指令序列是什么?

“试着安排一些事情,这样你就可以一次求四个向量的和”将不被接受作为一个答案。例如,对于数组求和,可以使用多个向量累加器进行垂直求和(以隐藏 addps 延迟) ,并在循环之后减少到一个,但是之后需要水平求和最后一个向量。

46292 次浏览

你可以在 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 点积函数。您应该测试它是否优于 Paul R 发布的双水平添加。

我还建议将其与直标量(或标量 SSE)代码进行比较——奇怪的是,它通常更快(通常是因为它在内部是序列化的,但使用寄存器旁路紧密流水线,在这种情况下,特殊的水平指令可能还没有快速路径) ,除非你运行的是类似 SIMT 的代码,听起来你并没有运行(否则你会做四点乘)。

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(但我没有测量得太接近)。

一般来说,对于任何类型的向量水平缩减,提取/洗牌高的一半与低的对齐,然后垂直加(或最小/最大/或/和/xor/乘/等) ; 重复直到只有一个元素 (高垃圾在其余的向量)。

如果你从比128位更宽的向量开始,把向量一分为二,直到你得到128位(然后你可以在这个向量上使用这个答案中的一个函数)。但是,如果您需要将结果广播到最后的所有元素,那么可以考虑一直进行全宽度的洗牌。

更广泛的向量、整数和 FP的相关问答

整数

  • __m128i32位元素: 这个答案(见下文)。64位元素应该是显而易见的: 只有一个 pshufd/Padq 步骤。

  • 不包装/溢出的 __m128i 8位无符号 uint8_t元素: psadbw_mm_setzero_si128(),然后合并两个 qword 半(或4或8对于更广泛的向量)。水平求和 SSE 无符号字节向量的最快方法显示带有 SSE2的128位。 Summing 8-bit integers in __m512i with AVX intrinsics has an AVX512 example. 如何使用 SIMD 计算字符出现次数 has an AVX2 __m256i example.

    (对于 int8_t有符号字节,您可以在 SAD 之前将 XOR set1 _ pi8(0x80)转换为 unsigned,然后从最终的 hsum 中减去偏差; 参见 详情请浏览此网页,也显示了一个从内存中只执行9字节而不是16字节的优化)。

  • 16-bit unsigned: _mm_madd_epi16 with set1_epi16(1) is a single-uop widening horizontal add: 积累相邻对. Then proceed with a 32-bit hsum.

  • 具有32位元素的 __m256i__m512i使用 AVX512或 AVX2 计算所有打包32位整数和的最快方法。对于 AVX512,Intel 添加了一系列“ reduce”内联函数(不是硬件指令) ,这些函数可以为您完成这些任务,比如 _mm512_reduce_add_ps(以及 pd、 epi32和 epi64)。也可以 reduce _ min/max/mul/and/or。手动操作的结果基本上是一样的。

  • 水平最大值(而不是加法) : 使用 SSE 获取 _ _ m128i 向量的最大值?


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

下面是一些基于 Agner Fog's microarch guide的微弓指南和指令表调整的版本。另请参阅 标签 wiki。它们在任何 CPU 上都应该是高效的,没有主要瓶颈。(例如,我避免做对一个弓有帮助但对另一个弓反应迟钝的事情)。代码大小也被最小化。

常见的 SSE3/SSSE32x hadd习惯用法只适用于代码大小,而不适用于任何现有 CPU 的速度。它有一些用例(如转置和添加,见下文) ,但单个向量不是其中之一。

我还加入了 AVX 版本。AVX/AVX2的任何水平缩减都应该从 vextractf128和“垂直”操作开始,以便缩减到一个 XMM (__m128)矢量。通常,对于宽向量,最好的办法是反复缩小一半,直到缩小到128位向量,而不管元素类型如何。(除了8位整数之外,如果您想不溢出到更宽的元素,那么首先将 vpsadbw作为第一步。)

查看所有这些代码 在 Godbolt 编译器资源管理器上的最大输出。参见我对 Agner Fog 的 C + + 矢量类库 horizontal_add函数的改进。(留言板线程留言板线程,和代码在 Github)。我使用 CPP 宏为 SSE2、 SSE4和 AVX 的代码大小选择最佳的洗牌,并在 AVX 不可用时避免使用 movdqa


我们需要权衡利弊:

  • 代码大小: 由于 L1 I 缓存的原因,以及从磁盘获取代码(更小的二进制文件) ,更小的代码更好。总的二进制大小对于在整个程序中重复执行的编译器决策最为重要。如果需要用内部特性手工编写代码,那么花几个代码字节来提高 整个项目的速度是值得的(小心那些使展开看起来不错的微基准测试)。
  • Uop-cache size: 通常比 L1 I $更宝贵。4个单操作指令所占的空间可能比2个 haddps小,所以这是非常相关的。
  • 延迟: 有时是相关的
  • 吞吐量(后端端口) : 通常不相关,水平求和不应该是最内层循环中的唯一事物。端口压力只是包含这个的整个回路的一部分。
  • 吞吐量(前端融合域总吞吐量) : 如果周围的代码在 hsum 使用的同一个端口上没有瓶颈,这是 hsum 对整个系统吞吐量影响的代理。

When a horizontal add is infrequent:

如果很少使用,CPU 没有高速缓存可能偏向于使用2 x haddps: 它运行起来很慢,但这种情况并不常见。只有2个指令可以最小化对周围代码的影响(I $size)。

CPU 还有高速缓存可能更倾向于使用更少的 uop,即使它更多的指令/更大的 x86代码大小。所使用的总 uops 缓存线路是我们想要最小化的,这并不像最小化总 uops 那么简单(获取的分支和32B 边界总是启动一个新的 uop 缓存线路)。

不管怎么说,水平求和得到的是 lot,所以这里我尝试精心制作一些可以很好编译的版本。没有在任何真正的硬件上进行基准测试,甚至没有经过仔细的测试。洗牌常数里可能有漏洞什么的。


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

像 K8和 Core2(merom)这样的老式 CPU 以及更早的 CPU 只有64位的 shuffle 单元 。对于大多数指令,Core2具有128位执行单元,但对于洗牌则没有。(PentiumM 和 K8将所有128b 向量指令作为两个64位的半部分处理)。

movhlps这样以64位块移动数据的洗牌(不在64位块内洗牌)也很快。

Related: shuffles on new CPUs, and tricks for avoiding 1/clock shuffle throughput bottleneck on Haswell and later: AVX512中的128位跨车道操作是否能提供更好的性能?

On old CPUs with slow shuffles:

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

shufps on Core2(including Penryn) brings data into the integer domain, causing a bypass delay to get it back to the FP execution units for addps, but movhlps is entirely in the FP domain. shufpd also runs in the float domain.

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

AMD K10、 Intel Core2(Penryn/Wolfdale)和所有后来的 CPU 都将所有 xmm shuffle 作为一个 uop 运行。(但请注意,在彭林使用 shufps绕行时会出现延迟,而使用 movhlps则可以避免这种延迟)


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

I reported a 关于对洗牌感到悲观的唠叨. It has its own internal representation for shuffling, and turns that back into shuffles. gcc more often uses the instructions that directly match the intrinsic you used.

通常 clang 比 gcc 做得更好,在指令选择不是手动调优的代码中,或者即使对于非常数情况,内部函数是最优的,常数传播也可以简化事情。总的来说,编译器工作起来像一个适合内部特性的编译器,而不仅仅是一个汇编器,这是一件好事。编译器通常可以从标量 C 中生成好的结果,而这些结果甚至不会像好的结果那样工作。最终,编译器将把内部函数视为优化器的另一个 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的目标是只写的,所以它为我们从死寄存器中创建 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 实际上“理解”了向量洗牌,并且如果它知道只有低元素才重要,它会发出更好的代码。

与 SSE1版本一样,向自身添加奇怪的元素可能会导致 FP 异常(如溢出) ,而这在其他情况下是不会发生的,但这应该不成问题。正规变量是缓慢的,但是 IIRC 产生 + Inf 结果不是在大多数 uarches 上。


SSE3代码大小优化

If code-size is your major concern, two haddps (_mm_hadd_ps) instructions will do the trick (Paul R's answer). This is also the easiest to type and remember. It is 不是很快, though. Even Intel Skylake still decodes each haddps to 3 uops, with 6 cycle latency. So even though it saves machine-code bytes (L1 I-cache), it takes up more space in the more-valuable uop-cache. Real use-cases for haddps: 一个移位和求和问题, or doing some scaling at an intermediate step 在这个 SSE atoi()实施.


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

Double-precision:

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 操作。如果洗牌端口压力,或者一般的 ALU 上升,是一个瓶颈,这是很好的。(注意,它不需要 sub rsp, 8或任何东西,因为 x86-64 SysV ABI 提供了一个信号处理程序不会涉足的红色区域。)

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


整数:

pshufd是一个方便的复制和洗牌。遗憾的是,位和字节位移位于正确位置,而 punpckhqdq将目标的高半部分放在结果的低半部分中,这与 movhlps将高半部分提取到不同寄存器的方式相反。

在第一步中使用 movhlps可能对某些 CPU 有好处,但前提是我们有一个从头记录。pshufd是一个安全的选择,并快速的一切后梅罗姆。

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 洗牌是安全的。我没有这样做,因为在现代 CPU 上,最多只能节省1或2个代码字节,没有速度增益(除了代码大小/对齐效果)。

通常,最快的方法的问题预先假定了一个需要在关键时间循环中多次完成的任务。

那么,最快的方法可能就是成对工作的迭代法,在迭代之间分期完成一些工作。

将向量分解为低/高部分的总约简成本为 O (log2(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 ]
        

I have doubts, if this would prove to be faster for a vector length of 3 or 4 than presented by Mr Cordes, however for 16 or 8 bit data this method should prove to be worthwhile. Then of course one needs to perform 3 or 4 rounds respectively before the result can be acquired.

如果水平运算碰巧是 sum ——那么每次迭代实际上只能使用一个 hadd