给定一个包含三个(或四个)浮点数的向量,最快的求和方法是什么?
SSE(movaps、shuffle、add、movd)是否总是比x87更快?SSE3中的水平加法指令是否值得使用?
从FPU移动到faddp、faddp的成本是多少?最快的特定指令序列是什么?
“尝试安排事物,以便您可以同时对四个向量求和”不会被接受为答案。 :-) 比如对于一个数组的求和,您可以使用多个矢量累加器进行垂直求和(以隐藏addps的延迟),并在循环结束后将其缩减为一个,但然后需要对该最后一个向量进行水平求和。
给定一个包含三个(或四个)浮点数的向量,最快的求和方法是什么?
SSE(movaps、shuffle、add、movd)是否总是比x87更快?SSE3中的水平加法指令是否值得使用?
从FPU移动到faddp、faddp的成本是多少?最快的特定指令序列是什么?
“尝试安排事物,以便您可以同时对四个向量求和”不会被接受为答案。 :-) 比如对于一个数组的求和,您可以使用多个矢量累加器进行垂直求和(以隐藏addps的延迟),并在循环结束后将其缩减为一个,但然后需要对该最后一个向量进行水平求和。
__m128
和__m128d
。请参见下面的答案。
__m256d
,包括Ryzen 1和Intel的性能分析(显示为什么vextractf128
比vperm2f128
好得多)。 使用SSE/AVX获取__m256d中存储的值的总和
__m256
。如何水平求和__m256?
数组(不仅仅是一个包含3或4个元素的单个向量)的点积:将垂直乘法/加法或FMA到多个累加器中,并在最后进行hsum。 完整的AVX+FMA数组点积示例,包括循环后高效的hsum。 (对于数组的简单求和或其他约简操作,请使用该模式但不包括乘法部分,例如使用add而不是fma)。不要为每个SIMD向量单独执行水平工作;在最后一次执行。
如何使用SIMD计算字符出现次数,作为计算_mm256_cmpeq_epi8
匹配项的整数示例,同样适用于整个数组,仅在最后进行hsum。 (值得特别提到的是,在进行完整的hsum之前进行一些8位累加,然后将8位扩展为64位,以避免溢出而不必在那一点上执行完整的hsum。)
整数
__m128i
32位元素: 参见下方答案。64位元素应该很明显:只需要一个pshufd/paddq步骤。
__m128i
8位无符号uint8_t
元素,不会溢出: 使用psadbw
和_mm_setzero_si128()
,然后对四个或八个字节的高低四字进行水平求和。Fastest way to horizontally sum SSE unsigned byte vector展示了如何使用SSE2实现128位求和。Summing 8-bit integers in __m512i with AVX intrinsics提供了AVX512的例子。How to count character occurrences using SIMD提供了一个AVX2的__m256i
例子。
(对于int8_t
有符号字节,您可以使用XOR set1_epi8(0x80)将其转换为无符号字节,然后从最终的水平求和中减去偏差; 请参见details here,它还展示了仅从内存读取9个字节而不是16个字节的优化方法)。
16位无符号: 使用_mm_madd_epi16
和set1_epi16(1)进行单uop扩展水平加法: SIMD: Accumulate Adjacent Pairs。然后继续32位求和。
__m256i
和__m512i
带有32位元素。
Fastest method to calculate sum of all packed 32-bit integers using AVX512 or AVX2。对于AVX512,英特尔添加了一堆“reduce”内联函数(不是硬件指令)来完成这项工作,例如_mm512_reduce_add_ps
(和pd、epi32和epi64)。还有reduce_min/max/mul/and/or。手动操作会导致基本相同的汇编代码。
水平最大值(而不是加法):Getting max value in a __m128i vector with SSE?
__m128
以下是根据Agner Fog的微架构指南和指令表进行调整的一些版本。请参见x86标签wiki。它们应该在任何CPU上都很高效,没有主要瓶颈。(例如,我避免了一些可能对某个uarch有所帮助但在另一个uarch上速度较慢的东西)。代码大小也被最小化。
常见的SSE3 / SSSE3 2x hadd
习惯用法只适用于代码大小,而不是任何现有CPU的速度。它有其用例(如转置和加法,请参见下文),但单个向量不是其中之一。
vextractf128
和一个“垂直”操作开始,以将其减少到一个XMM(__m128
)向量。通常对于宽向量,最好是重复将其缩小一半,直到缩小到128位向量,而不管元素类型如何。(除了8位整数,如果要进行hsum而不会溢出到更宽的元素,则应首先使用vpsadbw
。)
从所有这些代码中查看汇编输出 在Godbolt Compiler Explorer上。 还请查看我对Agner Fog的C++向量类库horizontal_add
函数的改进(留言板线程,以及github上的代码)。我使用CPP宏来选择适用于SSE2、SSE4和AVX的最佳洗牌方式,并避免在AVX不可用时使用movdqa
。
需要考虑权衡:
haddps
少,因此这在这里非常相关。当水平相加不频繁时:
如果CPU没有uop缓存,则很少使用的情况下,可能会偏爱2x haddps:当它运行时速度较慢,但这种情况并不经常发生。只有2个指令可以最小化对周围代码(I$大小)的影响。
如果CPU有uop缓存,则可能更喜欢需要较少uop的东西,即使它需要更多指令/更多的x86代码大小。我们要最小化使用的总uop缓存行,这并不像最小化总uop那么简单(采取分支和32B边界始终启动新的uop缓存行)。
无论如何,水平求和经常出现,因此我尝试精心制作了一些可以编译的版本。没有在任何真正的硬件上进行基准测试,甚至没有仔细测试。洗牌常数或其他方面可能存在错误。
movhlps
(Merom:1uop)比shufps
(Merom:3uops)快得多。在Pentium-M上,比movaps
便宜。此外,在Core2上,它在FP域中运行,避免了来自其他洗牌的旁路延迟。unpcklpd
比unpcklps
快。pshufd
很慢,pshuflw
/pshufhw
很快(因为它们只混洗64位的一半)pshufb mm0
(MMX)很快,pshufb xmm0
很慢。haddps
非常慢(Merom和Pentium M上的6uops)movshdup
(Merom:1uop)很有趣:它是唯一在64b元素内混洗的1uop指令。shufps
在Core2(包括Penryn)上将数据带入整数域,导致绕过延迟以使其返回FP执行单元用于addps
,但movhlps
完全在FP域中。 shufpd
也在浮点域中运行。
movshdup
在整数域中运行,但只有一个uop。shufps
时的绕行延迟,可通过movhlps
避免)
没有AVX,避免浪费的movaps
/movdqa
指令需要仔细选择洗牌操作。只有少数几个洗牌操作可以作为复制和洗牌操作,而不是修改目标操作。将两个输入数据组合的洗牌操作(例如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
}
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生成良好的汇编代码,即使该代码不能像良好的汇编代码那样运行。最终,编译器将把内置函数视为输入优化器的另一个C运算符。
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实际上“理解”向量混洗,并且如果它知道仅低元素重要,则会发出更好的代码。如果代码大小是您关注的主要问题,那么两个haddps
(_mm_hadd_ps
)指令可以解决问题(Paul R的答案)。这也是最容易输入和记忆的。然而,它不快。即使是Intel Skylake仍将每个haddps
解码为3个uop,并具有6个周期的延迟。因此,即使它节省了机器码字节(L1 I-cache),它在更有价值的uop-cache中占用更多空间。 haddps
的实际用例:一个转置和求和问题, 或在中间步骤执行一些缩放 SSE atoi()
实现中。
这个版本相比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]
sub rsp, 8
或任何其他操作,因为 x86-64 SysV ABI 提供了一个红区,信号处理程序不会使用它。)pshufd
是一个方便的复制和重排操作。位移和字节移位不幸地是原地操作,而 punpckhqdq
将目标的高半部分放入结果的低半部分,与 movhlps
把高半部分提取到另一个寄存器的方式相反。
在某些 CPU 上使用 movhlps
作为第一步可能是个好选择,但只有我们有一个临时寄存器时才行。pshufd
是一个安全的选择,在 Merom 之后的所有 CPU 上速度都很快。
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个代码字节,并且没有速度提升(除了代码大小/对齐效果)。
C5..
而不是C4....
)。像VSHUFPS和VMOVHLPS这样的双源洗牌与像VPSHUFD或VPERMILPS这样的单源洗牌一样快。如果有能耗差异,那可能是微不足道的。 - Peter Cordesconst __m128 t = _mm_add_ps(v, _mm_movehl_ps(v, v));
const __m128 sum = _mm_add_ss(t, _mm_shuffle_ps(t, t, 1));
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);
这将所有元素中的总和放置其中。
__m128 vector3 = _mm_castps_si128(_mm_castsi128_ps(_mm_srli_si128(vector4, 4)));
- 根据您的掩码是否已经从内存中加载,这可能比遮罩更快 - awdz9nlddpps
的操作需要4个微操作指令,延迟为13个时钟周期(但吞吐量为每1.5个时钟周期执行一次)。而haddps
操作需要3个微操作指令,延迟为6个时钟周期(但吞吐量为每2个时钟周期执行一次)。存储和标量操作的性能不算太差,因为它们的微操作指令数量不多,但与Kornel的回答相比,延迟较高。标量操作和向量操作具有相同的延迟。你关于“使用寄存器旁路严密流水线化”的猜测是不正确的。除了除法操作之外,所有操作都是完全流水线化的,但你说得对,水平指令并没有快速执行路径。它们被解码为内部洗牌微操作指令。 - Peter Cordes通常,关于“最快的方式”的问题都预设了一个需要在时间紧迫的循环中多次完成的任务。
那么,最快的方法可能是一种成对迭代工作的迭代方法,可以在迭代之间分摊一些工作。
将向量分裂为低/高部分的减少总成本为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);
}
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轮才能获得结果。
如果水平操作恰好是求和 - 那么每次迭代实际上只需使用一个hadd
。