一般来说,对于任何类型的向量水平缩减,提取/洗牌高一半与低对齐,然后垂直添加(或 min/max/or/and/xor/multiply/whatever);重复直到只有一个元素(向量的其余部分有大量垃圾)。
如果您从大于 128 位的向量开始,缩小一半直到达到 128(然后您可以在该向量上使用此答案中的函数之一)。但是如果你需要将结果广播到最后的所有元素,那么你可以考虑一直做全角洗牌。
更宽向量、整数和FP
的相关问答
整数
这个问题的主要答案:主要是浮动和__m128
这里有一些基于Agner Fog's microarch guide 的微架构指南和指令表调整的版本。另请参阅x86 标签维基。它们在任何 CPU 上都应该是高效的,没有重大瓶颈。 (例如,我避免了对一个 uarch 有一点帮助但对另一个 uarch 很慢的事情)。代码大小也被最小化了。
常见的 SSE3 / SSSE3 2x hadd 习惯用法仅适用于代码大小,而不适用于任何现有 CPU 的速度。它有一些用例(如转置和添加,见下文),但单个向量不是其中之一。
我还包含了一个 AVX 版本。任何使用 AVX / AVX2 的水平缩减都应该从 vextractf128 和“垂直”操作开始,以缩减到一个 XMM (__m128) 向量。一般来说,对于宽向量,最好的办法是重复缩小一半,直到缩小到 128 位向量,无论元素类型如何。 (除了 8 位整数,如果你想在不溢出到更宽的元素的情况下进行 hsum,那么第一步是 vpsadbw。)
查看所有这些代码 on the Godbolt Compiler Explorer 的 asm 输出。 另请参阅我对 Agner Fog's C++ Vector Class Library horizontal_add 函数的改进。 (message board thread 和github 上的代码)。我使用 CPP 宏为 SSE2、SSE4 和 AVX 的代码大小选择最佳随机播放,并在 AVX 不可用时避免movdqa。
需要权衡取舍:
- 代码大小:由于 L1 I-cache 原因以及从磁盘获取代码(较小的二进制文件),较小的更好。总二进制大小对于在整个程序中重复做出的编译器决策很重要。如果您正在费心用内在函数手动编写代码,那么如果它可以为整个程序提供任何加速,那么值得花费一些代码字节(小心使展开看起来不错的微基准)。李>
- uop-cache 大小:通常比 L1 I$ 更宝贵。 4 条单 uop 指令占用的空间比 2 条
haddps 少,因此这里非常重要。
- 延迟:有时相关
- 吞吐量(后端端口):通常不相关,水平总和不应是最内层循环中的唯一内容。端口压力仅作为包含此压力的整个循环的一部分很重要。
- 吞吐量(前端融合域 uops 总数):如果周围代码在 hsum 使用的同一端口上没有瓶颈,则这是 hsum 对整个事物吞吐量影响的代理。
当横向添加不频繁时:
没有 uop-cache 的 CPU 如果很少使用 2x haddps 可能会更受欢迎:它在运行时速度很慢,但这种情况并不常见。只有 2 条指令可以最大限度地减少对周围代码的影响(I$ 大小)。
CPU带有 uop-cache 可能会偏爱需要更少 uop 的东西,即使它需要更多指令/更多 x86 代码大小。使用的总 uops 缓存线是我们想要最小化的,这并不像最小化总 uops 那样简单(采用的分支和 32B 边界总是启动一个新的 uop 缓存线)。
无论如何,话虽如此,水平总和会产生很多很多,所以这是我精心制作的一些编译良好的版本的尝试。没有在任何真实硬件上进行基准测试,甚至没有经过仔细测试。随机播放常量或其他内容中可能存在错误。
如果您正在制作代码的后备/基线版本,请记住只有旧 CPU 才能运行它;较新的 CPU 将运行您的 AVX 版本或 SSE4.1 或其他任何版本。
像 K8 和 Core2(merom) 及更早的旧 CPU 只有 64 位随机播放单元。 Core2 对大多数指令都有 128 位执行单元,但对于随机播放则没有。 (Pentium M 和 K8 将所有 128b 向量指令处理为两个 64 位的一半)。
像 movhlps 这样以 64 位块移动数据(在 64 位半段内不进行混排)的混洗速度也很快。
相关:新 CPU 上的 shuffle,以及避免 Haswell 及更高版本上 1/clock shuffle 吞吐量瓶颈的技巧:Do 128bit cross lane operations in AVX512 give better performance?
在慢速洗牌的旧 CPU 上:
-
movhlps (Merom: 1uop) 明显快于 shufps (Merom: 3uops)。在 Pentium-M 上,比movaps 便宜。此外,它在 Core2 上的 FP 域中运行,避免了其他 shuffle 的绕过延迟。
-
unpcklpd 比 unpcklps 快。
-
pshufd 很慢,pshuflw/pshufhw 很快(因为它们只随机播放 64 位的一半)
-
pshufb mm0 (MMX) 很快,pshufb xmm0 很慢。
-
haddps 非常慢(在 Merom 和 Pentium M 上为 6 微秒)
-
movshdup (Merom: 1uop) 很有趣:它是唯一一个在 64b 元素内随机播放的 1uop insn。
Core2(包括 Penryn)上的shufps 将数据带入整数域,导致绕过延迟将其返回到addps 的 FP 执行单元,但 movhlps 完全在 FP 域中。 shufpd 也在浮点域中运行。
movshdup 在整数域中运行,但只有一个 uop。
AMD K10、Intel Core2(Penryn/Wolfdale) 和所有更高版本的 CPU 将所有 xmm shuffle 作为单个 uop 运行。 (但请注意 Penryn 上 shufps 的绕过延迟,movhlps 避免了)
如果没有 AVX,避免浪费 movaps/movdqa 指令需要仔细选择随机播放。只有少数洗牌可以作为复制和洗牌,而不是修改目的地。组合来自两个输入的数据(如 unpck* 或 movhlps)的随机播放可以与不再需要的 tmp 变量一起使用,而不是 _mm_movehl_ps(same,same)。
通过将虚拟 arg 用作初始洗牌的目的地,其中一些可以变得更快(保存 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
我举报了clang bug about pessimizing the shuffles。它有自己的洗牌内部表示,并将其转回洗牌。 gcc 更经常使用与您使用的内在函数直接匹配的指令。
在指令选择不是手动调整的代码中,clang 通常比 gcc 做得更好,或者即使内在函数对于非常量情况是最佳的,常量传播也可以简化事情。总体而言,编译器可以像内部函数的适当编译器一样工作,而不仅仅是汇编器,这是一件好事。编译器通常可以从标量 C 生成好的 asm,甚至不会尝试像好的 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 的目标是只写的,因此它会为我们从死寄存器中创建tmp。这也是我使用movehl_ps(tmp, sums) 而不是movehl_ps(sums, sums) 的原因。
-
小代码大小。改组指令很小:movhlps 是 3 个字节,movshdup 是 4 个字节(与 shufps 相同)。不需要立即字节,因此对于 AVX,vshufps 是 5 个字节,但 vmovhlps 和 vmovshdup 都是 4。
我可以用addps 代替addss 保存另一个字节。由于这不会在内部循环中使用,因此切换额外晶体管的额外能量可能可以忽略不计。前 3 个元素的 FP 异常没有风险,因为所有元素都包含有效的 FP 数据。然而,clang/LLVM 实际上“理解”向量混洗,如果它知道只有低元素很重要,它会发出更好的代码。
与 SSE1 版本一样,将奇数元素添加到自身可能会导致 FP 异常(如溢出),否则不会发生,但这应该不是问题。非正规函数很慢,但 IIRC 产生 +Inf 结果不在大多数 uarches 上。
SSE3 针对代码大小进行优化
如果代码大小是您主要关心的问题,两条 haddps (_mm_hadd_ps) 指令就可以解决问题(Paul R 的回答)。这也是最容易输入和记住的。不过,它并不快。甚至英特尔 Skylake 仍将每个 haddps 解码为 3 微指令,具有 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节省了一个代码字节。
#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。如果 shuffle 端口压力或一般的 ALU 微指令是一个瓶颈,那就太好了。 (请注意,它不需要 sub rsp, 8 或其他任何东西,因为 x86-64 SysV ABI 提供了一个信号处理程序不会踩到的红色区域。)
有些人存储到一个数组并将所有元素求和,但编译器通常没有意识到数组的低元素仍然存在于存储之前的寄存器中。
整数:
pshufd 是一种方便的复制和随机播放。不幸的是,位和字节移位是就地的,punpckhqdq 将目标的高半部分放在结果的低半部分,这与 movhlps 可以将高半部分提取到不同的寄存器中的方式相反。
第一步使用movhlps 在某些CPU 上可能会很好,但前提是我们有一个临时寄存器。 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 个代码字节,并且没有速度提升(除了代码大小/对齐效果)。