在 AVX(及更高版本)中打包非连续矢量元素

Packing non-contiguous vector elements in AVX (and higher)

具有这种性质的代码:

void foo(double *restrict A, double *restrict x,
                             double *restrict y) {
  y[5] += A[4] * x[5];
  y[5] += A[5] * x[1452];
  y[5] += A[6] * x[3373];
}

使用 gcc 10.2 和标记 -O3 -mfma -mavx2 -fvect-cost-model=unlimited (Compiler Explorer) 的编译结果是:

foo(double*, double*, double*):
        vmovsd  xmm1, QWORD PTR [rdx+40]
        vmovsd  xmm0, QWORD PTR [rdi+32]
        vfmadd132sd     xmm0, xmm1, QWORD PTR [rsi+40]
        vmovsd  xmm2, QWORD PTR [rdi+40]
        vfmadd231sd     xmm0, xmm2, QWORD PTR [rsi+11616]
        vmovsd  xmm3, QWORD PTR [rdi+48]
        vfmadd231sd     xmm0, xmm3, QWORD PTR [rsi+26984]
        vmovsd  QWORD PTR [rdx+40], xmm0
        ret

不打包任何数据(4vmovsd加载数据,1个存储),执行3vfmaddXXXsd。尽管如此,我将其矢量化的动机是它可以只使用一个 vfmadd231pd 来完成。我使用 AVX2 的内在函数编写此代码的“最干净”尝试是:

void foo_intrin(double *restrict A, double *restrict x,
                            double *restrict y) {
  __m256d __vop0, __vop1,__vop2;
  __m128d __lo256, __hi256;

  // THE ISSUE
  __vop0 = _mm256_maskload_pd(&A[4], _mm256_set_epi64x(0,-1,-1,-1));
  __vop1 = _mm256_mask_i64gather_pd(_mm256_setzero_pd(), &x[5], 
                                    _mm256_set_epi64x(0,3368, 1447, 0), 
                                    _mm256_set_pd(0,-1,-1,-1), 8);
  // 1 vs 3 FMADD, "the gain"
  __vop2 = _mm256_fmadd_pd(__vop0, __vop1, __vop2);

  // reducing 4 double elements: 
  // Peter Cordes' answer 
  __lo256 = _mm256_castpd256_pd128(__vop2);
  __hi256 = _mm256_extractf128_pd(__vop2, 0x1);
  __lo256 = _mm_add_pd(__lo256, __hi256);

  // question:
  // could you use here shuffle instead?
  // __hi256 = _mm_shuffle_pd(__lo256, __lo256, 0x1);
  __hi256 = _mm_unpackhi_pd(__lo256, __lo256);


  __lo256 = _mm_add_pd(__lo256, __hi256);
  
  y[5] += __lo256[0];
}

生成以下 ASM:

foo_intrin(double*, double*, double*):
        vmovdqa ymm2, YMMWORD PTR .LC1[rip]
        vmovapd ymm3, YMMWORD PTR .LC2[rip]
        vmovdqa ymm0, YMMWORD PTR .LC0[rip]
        vmaskmovpd      ymm1, ymm0, YMMWORD PTR [rdi+32]
        vxorpd  xmm0, xmm0, xmm0
        vgatherqpd      ymm0, QWORD PTR [rsi+40+ymm2*8], ymm3
        vxorpd  xmm2, xmm2, xmm2
        vfmadd132pd     ymm0, ymm2, ymm1
        vmovapd xmm1, xmm0
        vextractf128    xmm0, ymm0, 0x1
        vaddpd  xmm0, xmm0, xmm1
        vunpckhpd       xmm1, xmm0, xmm0
        vaddpd  xmm0, xmm0, xmm1
        vaddsd  xmm0, xmm0, QWORD PTR [rdx+40]
        vmovsd  QWORD PTR [rdx+40], xmm0
        vzeroupper
        ret
.LC0:
        .quad   -1
        .quad   -1
        .quad   -1
        .quad   0
.LC1:
        .quad   0
        .quad   1447
        .quad   3368
        .quad   0
.LC2:
        .long   0
        .long   -1074790400
        .long   0
        .long   -1074790400
        .long   0
        .long   -1074790400
        .long   0
        .long   0

抱歉,如果有人现在焦虑症发作,我深感抱歉。让我们分解一下:

最后但同样重要的是,这种特别的矢量化是否值得?我只是指的不是我的内在代码,而是像这样矢量化代码的概念。我怀疑有太多的数据移动无法执行,比较干净的代码编译器,一般来说,产生,所以我关心的是改进打包非连续数据的方式。

vfmaddXXXsdpd 指令是“便宜的”(单 uop,2/时钟吞吐量),甚至比洗牌(Intel CPU 上的 1/时钟吞吐量)或收集负载更便宜。 https://uops.info/。加载操作也是 2/clock,所以很多标量加载(尤其是来自同一缓存行)非常便宜,请注意其中 3 个可以折叠到 FMA 的内存源操作数中。

最坏的情况是,打包 4 (x2) 个完全不连续的输入然后手动分散输出与仅使用标量负载和标量 FMA 相比绝对不值得(尤其是当它允许 FMA 的内存源操作数时) .

你的情况远非最坏的情况;您有 1 个输入的 3 个连续元素。如果你知道你可以安全地加载 4 个元素而不会有接触未映射页面的风险,那么就可以处理该输入。 (而且您始终可以使用 maskload)。但是另一个向量仍然是不连续的,可能是加速的障碍。

如果通过洗牌比普通标量需要更多的总指令(实际上是 uops),通常是不值得的。 And/or 如果洗牌吞吐量是比标量版本中的任何瓶颈都严重。

(vgatherdpd 为此计算了尽可能多的指令,是多 uop,每次加载执行 1 次缓存访问。此外,您还必须加载索引的常量向量,而不是将偏移量硬编码到寻址模式中.

此外,在 AMD CPU 上收集速度非常慢,即使是 Zen2。在 AVX512 之前我们根本没有散射,即使在 Ice Lake 上也很慢。不过,您的案例不需要散点,只需要水平总和。这将涉及更多的洗牌和 vaddpd / sd因此,即使使用 maskload + gather 作为输入,将 3 个产品放在单独的向量元素中对您来说也不是特别方便。)


一点点 SIMD(不是整个数组,只是一些操作)可能会有帮助,但这看起来不像是重大胜利的案例之一。也许有一些值得做的事情,比如用 load + a shuffle 代替 2 loads。或者可以通过将 的 3 个产品加到输出而不是 3 个 FMA 链来求和来缩短 y[5] 的延迟链。在累加器可以容纳大量数字的情况下,这甚至可能在数值上更好;将多个小数字添加到一个大总数中会失去精度。当然,这将花费 1 mul、2 FMA 和 1 add。