进行水平 SSE 向量求和(或其他缩减)的最快方法

Posted

技术标签:

【中文标题】进行水平 SSE 向量求和(或其他缩减)的最快方法【英文标题】:Fastest way to do horizontal SSE vector sum (or other reduction) 【发布时间】:2011-08-09 13:16:20 【问题描述】:

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

SSE(movaps、shuffle、add、movd)总是比 x87 快吗? SSE3 中的水平添加指令值得吗?

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

“尝试安排事物,以便一次将四个向量相加”不会被接受为答案。 :-) 例如为了对数组求和,您可以使用多个向量累加器进行垂直求和(以隐藏 addps 延迟),并在循环后减少到一个,但是您需要对最后一个向量进行水平求和。

【问题讨论】:

如果水平添加对您的性能至关重要,那么您很可能以一种不太理想的方式接近 SIMD 编码 - 发布一些代码,说明您需要如何以及在何处执行此操作。 主要是向量之间角度的点积。注意最后一句话。 我看了最后一句话,但我仍然认为可能有更好的方法。 我知道有一个更好的方法,它是“一次执行循环四个元素,以便您可以并行化所有内容”。问题是,除了这种方式(复杂且令人困惑),我们能做的最好的事情是什么? 没有“x86 上最快的方式...”。不同的 x86 处理器具有不同的执行特性。你的目标是什么处理器?您的“三个浮点向量”最初是在内存中,还是在 SSE 寄存器中或其他地方? 【参考方案1】:

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

如果您从大于 128 位的向量开始,缩小一半直到达到 128(然后您可以在该向量上使用此答案中的函数之一)。但是如果你需要将结果广播到最后的所有元素,那么你可以考虑一直做全角洗牌。

更宽向量、整数和FP

的相关问答

__m128__m128d 这个答案(见下文)

__m256d 对 Ryzen 1 与 Intel 进行性能分析(说明为什么 vextractf128vperm2f128 好得多)Get sum of values stored in __m256d with SSE/AVX

__m256How to sum __m256 horizontally?

Intel AVX: 256-bits version of dot product for double precision floating point variables 的单个向量。

数组的点积(不仅仅是 3 或 4 个元素的单个向量):对multiple accumulators 执行垂直 mul/add 或 FMA,最后是 hsum。 Complete AVX+FMA array dot-product example,包括一个有效的 hsum after 循环。 (对于数组的简单求和或其他减少,请使用该模式但不使用乘法部分,例如添加而不是 fma)。 为每个 SIMD 向量单独做水平工作;最后做一次。

How to count character occurrences using SIMD 作为计数_mm256_cmpeq_epi8 匹配的整数示例,再次在整个数组上,仅在末尾进行hsumming。 (特别值得一提的是,先进行一些 8 位累加,然后扩大 8 -> 64 位以避免溢出,此时无需进行完整的 hsum。)

整数

__m128i 32 位元素:这个答案(见下文)。 64 位元素应该很明显:只有一个 pshufd/paddq 步骤。

__m128i 8-bit unsigned uint8_t 元素,没有环绕/溢出:psadbw_mm_setzero_si128(),然后对两个 qword 半部分进行 hsum(或 4 或 8 用于更宽的向量)。 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) 在 SAD 之前翻转为无符号,然后从最终的 hsum 中减去偏差;参见 details here,还显示了针对从内存中只做 9 个字节而不是 16 个)。

16 位无符号:_mm_madd_epi16 和 set1_epi16(1) 是单 uop 加宽水平添加:SIMD: Accumulate Adjacent Pairs。然后继续使用 32 位 hsum。

__m256i__m512i 具有 32 位元素。 Fastest method to calculate sum of all packed 32-bit integers using AVX512 or AVX2。对于 AVX512,英特尔添加了一堆“减少”内联函数(不是硬件指令)来为您执行此操作,例如 _mm512_reduce_add_ps(以及 pd、epi32 和 epi64)。还有 reduce_min/max/mul/和/或。手动执行会导致基本相同的 asm。

水平最大值(而不是添加):Getting max value in a __m128i vector with SSE?


这个问题的主要答案:主要是浮动和__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 的绕过延迟。 unpcklpdunpcklps 快。 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 个字节,但 vmovhlpsvmovshdup 都是 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 个代码字节,并且没有速度提升(除了代码大小/对齐效果)。

【讨论】:

@plasmacel:在包括 Intel SnB 系列在内的许多 CPU 上,将 FP 指令的结果转发到整数 shuffle 以及从 PSHUFD 到 ADDPS 都存在额外的旁路延迟延迟。如果您关心吞吐量和 uop 计数但不关心延迟,那就太好了。 (整数指令之间的 SHUFPS 对 SnB 系列没有惩罚(与 Nehalem 不同),但反之则不然。) 如果您有一个特定的微架构和编译器,您可以并且应该制作一个更优化的版本。 这个答案试图为 Haswell 等现代 CPU 提供最佳(延迟、吞吐量和代码大小),同时尽可能少地吸食旧 CPU。即我的 SSE1 / SSE2 版本在 Haswell 上没有做任何更糟糕的事情,只是为了在像 Merom 这样的旧 SlowShuffle CPU 上运行得更快。对于 Merom 来说,PSHUFD 可能是一个胜利,因为它和 SHUFPS 都在 flt->int 域中运行。 @plasmacel:不,除非您的向量一开始就在内存中,因为 VPERMILPS 可以加载+随机播放。您可以通过使用旧指令的 AVX 版本获得更小的代码大小,因为您不需要立即数,而且它们只需要 2 字节 VEX 前缀(C5 .. 而不是 C4 .. ..)。 VSHUFPS 和 VMOVHLPS 等双源 shuffle 并不比 VPSHUFD 或 VPERMILPS 等单源 shuffle 慢。如果能源消耗存在差异,则可能可以忽略不计。 @plasmacel:正如我的回答所指出的,我的 SSE3 版本与 AVX 进行了最佳编译,但 clang 将其悲观为 VPERMILPD:godbolt.org/g/ZH88wH。 gcc 的版本是四个 4B 指令(不包括 RET)。 clang 的版本长 2 个字节,速度相同。是什么让您认为 VPERMILPS 胜过 SHUFPS? AFAIK,当源已经在寄存器中的情况下,立即改组是错误的。 Agner Fog 的表格没有显示任何差异。它对于加载+随机播放和变量随机播放很有用,而且对于编译器来说可能更容易,因为它是一条 1 输入指令,但速度不快 @plasmacel:有趣的事实:在 Knight's Landing (Xeon Phi = modified silvermont + AVX512),VPERMILPS (3c lat, 1c rtput) 比 VSHUFPS (4c lat, 2c rtput) 更有效,它确实超过该架构的指令长度差异。我假设这是 1-input shuffle vs 2-input。 Agner Fog 为 KNL 更新了他的资料。 :)【参考方案2】:

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

我发现这些速度与 double HADDPS 的速度差不多(但我没有仔细测量)。

【讨论】:

【参考方案3】:

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

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

这会将总和放入所有元素中。

【讨论】:

总和不是在 all 元素中结束吗? @Jens:是的,谢谢 - 我认为你是对的 - 我会更新我的答案。 对于 3 向量和,我需要先将第四个分量设置为零。最快的方法是什么?我倾向于“加载掩码,andps”——有没有一种快速的方法来屏蔽一个元素? 我没有看到比ANDPS 更快的方法,这是一条指令(当然掩码是不变的)。 @Royi:请参阅 Peter 在他的回答中的 cmets,标题为 “SSE3 针对代码大小进行优化”【参考方案4】:

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

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

【讨论】:

即使在 Skylake 中,一个 dpps 也是 4 uops,13c 延迟。 (但每 1.5c 吞吐量一个)。 haddps 是 3uops,6c 延迟。 (每 2c 吞吐量一个)。存储和标量并不算太糟糕,因为它不会花费很多微指令,但与 Kornel 的答案相比,它的延迟非常糟糕。不过,标量操作与向量操作具有相同的延迟。您的“使用寄存器旁路紧密流水线”的推测是不正确的。除了 div 之外的所有东西都是完全流水线的,但是你是对的,水平指令不是快速路径。它们被解码为内部 shuffle uops。【参考方案5】:

最快可能的方式的问题通常预设了一项需要在时间紧迫的循环中多次完成的任务。

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

将向量拆分为低/高部分的总成本为 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 context0,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 轮才能获得结果。

如果水平操作恰好是求和——那么实际上每次迭代只能使用一个hadd

【讨论】:

以上是关于进行水平 SSE 向量求和(或其他缩减)的最快方法的主要内容,如果未能解决你的问题,请参考以下文章

用 SSE 在 C++ 中将两个 32 位整数向量相乘的最快方法

使用 192/256 位整数对无符号 64 位整数向量的点积求和的最快方法?

在整数 SSE 寄存器中移动更高或更低 64 位的最快方法

使用SSE计算绝对值的最快方法

AVX 或 SSE 上的水平尾随最大值

SSE 的整数/浮点值