SSE - 不存在的haddsub内置函数?

3
在浏览可用的内部函数时,我注意到没有水平addsub/subadd指令。虽然它在过时的3DNow!扩展中是可用的,但由于明显的原因,其使用是不切实际的。为什么这种“基本”操作没有与类似的水平和addsub操作一起被实现在SSE3扩展中呢?
顺便问一下,在现代指令集(SSE3,SSE4,AVX等)中,最快的替代方法是什么?(每个值有2个双精度数,即__m128d)。

1
我并不认为任何水平指令都是“基本”的。它们都打破了SIMD的范式,这就是为什么它们在新处理器上变得越来越慢,并完全被排除在AVX512之外的原因。 - Mysticial
哪个平台?哪个编译器?请适当标记此问题。 - mlp
@mlp:它已经被适当地标记了:x86(某个版本的SSE)与英特尔的内置函数,这些函数在所有主要编译器中都得到支持。这个问题足够简单,可以考虑在英特尔和AMD微架构上使用,特别是因为在AMD和英特尔之间的水平操作没有任何区别,所以我们不需要将其缩小到只有Skylake或只有Ryzen等。 - Peter Cordes
这里已经有一个关于不进行大量水平操作的原理的好回答,但你可能想看看这篇博客文章。链接:https://blogs.msdn.microsoft.com/chuckw/2012/09/11/directxmath-sse3-and-ssse3/。 - Chuck Walbourn
@ChuckWalbourn:那篇博客文章声称使用 _mm_hadd_ps 两次进行水平求和是一个好主意。如果你想要高吞吐量,那并不是这样的;请参见 https://dev59.com/g2w05IYBdhLWcg3w11Qk。有用的 SSE3 指令是 _mm_movehdup_ps,它可以给你一个 FP 的复制和重排,所以即使在没有 AVX 的情况下编译,编译器也不需要 movaps / shufps - Peter Cordes
2个回答

6
一般来说,你应该尽量避免在代码设计中使用水平操作;尝试并行地对多个数据执行相同的操作,而不是使用不同的元素执行不同的操作。但有时局部优化仍然值得尝试,水平操作可能比纯标量更好。
英特尔曾经在SSE3中尝试添加水平操作,但从未添加专用硬件来支持它们。它们在所有支持它们的CPU上解码为2个洗牌和1个垂直操作(包括AMD)。请参见Agner Fog's instruction tables。最近的ISA扩展大多没有包含更多的水平操作,除了SSE4.1 dpps/dppd(与手动洗牌相比,通常也不值得使用)。

SSSE3 pmaddubsw很有道理,因为元素宽度已经成为扩展乘法的问题,而SSE4.1 phminposuw立即获得了专用硬件支持,使其值得使用(如果不使用它来执行相同的操作将会产生大量的uops,而且它特别适用于视频编码)。但是AVX / AVX2 / AVX512水平运算符非常稀缺。AVX512确实引入了一些不错的洗牌操作,因此如果需要,您可以基于强大的两个输入车道交叉洗牌构建自己的水平操作。


如果你的问题的最有效解决方案已经包括以两种不同的方式混合两个输入并将其馈送到加法或减法中,那么使用haddpd是一种有效的编码方式;特别是在没有AVX的情况下,因为shufpd是破坏性的(在使用内部函数时由编译器默默发出,但仍然会消耗前端带宽,在像Sandybridge和早期的CPU上还会有延迟,这些CPU不能消除寄存器对寄存器的移动), 准备输入可能也需要一个movaps指令。

但是,如果你要两次使用相同的输入,则haddpd是错误的选择。请参见Fastest way to do horizontal float vector sum on x86。只有在使用两个不同的输入时,例如作为矩阵上其他操作的一部分进行即时转置时,hadd/hsub才是一个好主意。


无论如何,重点是,如果你想要,可以自己构建haddsub_pd,由两个洗牌+SSE3 addsubpd组成(在支持的CPU上,它确实具有单uop硬件支持)。使用AVX,它将与假设的haddsubpd指令一样快,而没有AVX通常会多花费一个movaps,因为编译器需要保留第一个洗牌的两个输入。(代码大小会更大,但我说的是前端的uops成本和后端的执行端口压力。)
 // Requires SSE3 (for addsubpd)

  // inputs: a=[a1 a0]  b=[b1 b0]
  // output:   [b1+b0, a1-a0],  like haddpd for b and hsubpd for a
static inline
__m128d haddsub_pd(__m128d a, __m128d b) {
    __m128d lows  = _mm_unpacklo_pd(a,b);  // [b0,    a0]
    __m128d highs = _mm_unpackhi_pd(a,b);  // [b1,    a1]
    return _mm_addsub_pd(highs, lows);     // [b1+b0, a1-a0]
}

使用 gcc -msse3 和 clang (在 Godbolt 上),我们得到了预期的结果:

    movapd  xmm2, xmm0          # ICC saves a code byte here with movaps, but gcc/clang use movapd on double vectors for no advantage on any CPU.
    unpckhpd        xmm0, xmm1
    unpcklpd        xmm2, xmm1
    addsubpd        xmm0, xmm2
    ret

在内联时,这通常不会有影响,但作为独立函数时,gcc和clang在需要使用与a不同的寄存器返回值时会遇到问题,而b开始的寄存器仍需保留(例如,如果参数颠倒,则是haddsub(b,a))。
# gcc for  haddsub_pd_reverseargs(__m128d b, __m128d a) 
    movapd  xmm2, xmm1          # copy b
    unpckhpd        xmm1, xmm0
    unpcklpd        xmm2, xmm0
    movapd  xmm0, xmm1          # extra copy to put the result in the right register
    addsubpd        xmm0, xmm2
    ret

实际上,clang 做得更好,使用不同的 shuffle(movhlps 而不是 unpckhpd),仍然只使用一个寄存器复制:

# clang5.0
    movapd  xmm2, xmm1              # clangs comments go in least-significant-element first order, unlike my comments in the source which follow Intel's convention in docs / diagrams / set_pd() args order
    unpcklpd        xmm2, xmm0      # xmm2 = xmm2[0],xmm0[0]
    movhlps xmm0, xmm1              # xmm0 = xmm1[1],xmm0[1]
    addsubpd        xmm0, xmm2
    ret

对于使用__m256d向量的AVX版本,_mm256_unpacklo/hi_pd的内部行为实际上是你想要的,一次性获取偶数/奇数元素。
static inline
__m256d haddsub256_pd(__m256d b, __m256d a) {
    __m256d lows  = _mm256_unpacklo_pd(a,b);  // [b2, a2 | b0, a0]
    __m256d highs = _mm256_unpackhi_pd(a,b);  // [b3, a3 | b1, a1]
    return _mm256_addsub_pd(highs, lows);     // [b3+b2, a3-a2 | b1+b0, a1-a0]
}

# clang and gcc both have an easy time avoiding wasted mov instructions
    vunpcklpd       ymm2, ymm1, ymm0 # ymm2 = ymm1[0],ymm0[0],ymm1[2],ymm0[2]
    vunpckhpd       ymm0, ymm1, ymm0 # ymm0 = ymm1[1],ymm0[1],ymm1[3],ymm0[3]
    vaddsubpd       ymm0, ymm0, ymm2

当然,如果您有相同的输入两次,即您想要向量的两个元素之和和差,您只需要一个shuffle将其输入addsubpd

// returns [a1+a0  a1-a0]
static inline
__m128d sumdiff(__m128d a) {
    __m128d swapped = _mm_shuffle_pd(a,a, 0b01);
    return _mm_addsub_pd(swapped, a);
}

这个代码使用gcc和clang编译时会比较繁琐:

    movapd  xmm1, xmm0
    shufpd  xmm1, xmm0, 1
    addsubpd        xmm1, xmm0
    movapd  xmm0, xmm1
    ret

但是如果编译器不需要在同一个寄存器中使用结果,那么第二个movapd应该在内联时消失。我认为gcc和clang都缺少一种优化:在复制后它们可以交换xmm0

     # compilers should do this, but don't
    movapd  xmm1, xmm0         # a = xmm1 now
    shufpd  xmm0, xmm0, 1      # swapped = xmm0
    addsubpd xmm0, xmm1        # swapped +- a
    ret

据推测,他们基于SSA的寄存器分配器没有考虑使用第二个寄存器来存储相同值的a,以释放xmm0用于swapped。通常情况下,在不同的寄存器中产生结果是可以的(甚至更可取),因此只有在查看函数的独立版本时才会出现问题,而内联时很少出现问题。


1
很好的完整回答,看起来最好的选择是进行设计更改以避免使用水平指令。 - user2054583

2
如何考虑:
__m128d a, b; //your inputs

const __m128d signflip_low_element = 
         _mm_castsi128_pd(_mm_set_epi64(0,0x8000000000000000));
b = _mm_xor_pd(b, signflip_low_element);  // negate b[0]
__m128d res = _mm_hadd_pd(a,b);

这个代码使用 haddpd 来构建 haddsubpd,所以只需要额外一条指令。不幸的是,在大多数CPU上,haddpd 的吞吐量很低,每2个时钟周期只能处理一个,受浮点运算吞吐量的限制。

但这种方式对于x86机器码的代码大小优化效果很好。


haddpd在所有支持它的CPU上需要2次洗牌和一个addpd。最好使用一次洗牌来提供SSE3 addsubpd。特别是在AVX中,您不需要任何奇怪的技巧(如对FP数据的pshufd)来复制+洗牌并避免movaps - Peter Cordes
@PaulR:关于您的编辑日志中的更改记录:_mm_xor_pd在Skylake上比_mm_xor_ps更快?当然,旧版本的代码无法编译,但是使用xorps在双精度FP指令之间进行汇编等效操作应该与xorpd完全相同,但对于非AVX版本可以节省一个指令字节。我不知道任何CPU在PS / PD布尔运算方面有任何不同。当然,如果您关心速度,首先就不会使用haddpd - Peter Cordes
@PeterCordes:可能是英特尔指令集手册中的错误,但它说xorps的吞吐量为1,而xorpd为0.33(仅适用于Skylake)。 - Paul R
@PaulR:那是一个错误。它们的吞吐量都是0.33c,就像整数版本一样,并不像Broadwell只限于port5。我认为ps和pd版本解码到相同的内部uop。 - Peter Cordes
@PeterCordes:是的,我的错 - OP正在使用-mavx编译,所以这些是VEX指令。实际上,Intel Instrinsics指南将xorpd列为SSE2指令 - 它只是xorps的别名吗? - Paul R
显示剩余7条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接