如何用 256 位 AVX 向量对两个复数进行平方?

How to square two complex doubles with 256-bit AVX vectors?

Matt Scarpino 对如何使用 Intel 的 AVX 内在函数将两个复数双精度相乘给出了很好的解释(尽管他承认他不确定这是最佳算法,但我向他表示感谢)。这是我验证过的他的方法:

__m256d vec1 = _mm256_setr_pd(4.0, 5.0, 13.0, 6.0);
__m256d vec2 = _mm256_setr_pd(9.0, 3.0, 6.0, 7.0);
__m256d neg  = _mm256_setr_pd(1.0, -1.0, 1.0, -1.0);

/* Step 1: Multiply vec1 and vec2 */
__m256d vec3 = _mm256_mul_pd(vec1, vec2);

/* Step 2: Switch the real and imaginary elements of vec2 */
vec2 = _mm256_permute_pd(vec2, 0x5);

/* Step 3: Negate the imaginary elements of vec2 */
vec2 = _mm256_mul_pd(vec2, neg);  

/* Step 4: Multiply vec1 and the modified vec2 */
__m256d vec4 = _mm256_mul_pd(vec1, vec2);

/* Horizontally subtract the elements in vec3 and vec4 */
vec1 = _mm256_hsub_pd(vec3, vec4);

/* Display the elements of the result vector */
double* res = (double*)&vec1;
printf("%lf %lf %lf %lf\n", res[0], res[1], res[2], res[3]);

我的问题是我想对两个复数双打求平方。我试着像这样使用 Matt 的技术:

struct cmplx a;
struct cmplx b;

a.r = 2.5341;
a.i = 1.843;

b.r = 1.3941;
b.i = 0.93;

__m256d zzs = squareZ(a, b);

double* res = (double*) &zzs;

printf("\nA: %f + %f,  B: %f + %f\n", res[0], res[1], res[2], res[3]);

使用Haskell的复数运算,我验证了结果是正确的除了,如你所见,B的实部:

A: 3.025014 + 9.340693,  B: 0.000000 + 2.593026

所以我真的有两个问题:是否有更好(更简单 and/or 更快)的方法来用 AVX 内在函数对两个复数双精度数求平方?如果没有,我该如何修改 Matt 的代码来做到这一点?

这个答案涵盖了将两个复数数组相乘的一般情况

理想情况下,将数据存储在单独的实部和虚部数组中,这样您就可以只加载实部和虚部的连续向量。这使得可以自由地进行交叉乘法(只需使用不同的寄存器/变量),而不必在向量中随机移动。

您可以相当便宜地在交错 double complex 样式和 SIMD 友好的分离向量样式之间即时转换,受 AVX 通道内洗牌的变幻莫测的影响。例如如果您不关心临时向量中数据的实际顺序,使用 unpacklo / unpackhi 洗牌可以非常便宜地去交错或在通道内重新交错。

执行这种洗牌实际上非常便宜,以至于在运行中为单个复数乘法执行它比 Matt 的代码(甚至是经过调整的版本)稍微领先,尤其是在支持 FMA 的 CPU 上。这需要以 4 个复数双打(2 个结果向量)为一组生成结果。

如果您一次只需要生成一个结果向量,我还想出了一种替代 Matt 算法的方法,它可以使用 FMA(实际上是 FMADDSUB)并避免单独的符号更改 insn.


gcc 自动将简单的复数乘法标量循环矢量化为非常好的代码,只要您使用 -ffast-math。它像我建议的那样去交错。

#include <complex.h>

// even with -ffast-math -ffp-contract=fast, clang doesn't manage to use vfmaddsubpd, instead using vmulpd and vaddsubpd :(
// gcc does use FMA though.

// auto-vectorizes with a lot of extra shuffles
void cmul(double complex *restrict dst,
          const double complex *restrict A, const double complex *restrict B)
{       // clang and gcc change strategy slightly for i<1 or i<2, vs. i<4
  for (int i=0; i<4 ; i++) {
    dst[i] = A[i] * B[i];
  }
}

参见Godbolt compiler explorer. I'm not sure how good clang's asm is; it uses a lot of 64b->128b VMODDDUP broadcast-loads. This form is handled purely in the load ports on Intel CPUs (see Agner Fog's insn tables)上的asm,不过还是有很多操作。如前所述,gcc 在乘法 / FMA 之前使用 4 个 VPERMPD 洗牌在通道内重新排序,然后在将结果与 VSHUFPD 组合之前使用另一个 4 VPERMPD 对结果重新排序。这是 4 次复数乘法的 8 次额外洗牌。


将 gcc 的版本转换回内在函数并删除冗余的 shuffle 得到最佳代码。 (gcc 显然希望其临时对象按 A B C D 顺序排列,而不是 VUNPCKLPD (_mm256_unpacklo_pd) 的车道内行为导致的 A C B D 顺序)。

我将代码 on Godbolt 与 Matt 代码的调整版本一起放置。因此,您可以使用不同的编译器选项以及不同的编译器版本。

// multiplies 4 complex doubles each from A and B, storing the result in dst[0..3]
void cmul_manualvec(double complex *restrict dst,
          const double complex *restrict A, const double complex *restrict B)
{
                                         // low element first, little-endian style
  __m256d A0 = _mm256_loadu_pd((double*)A);    // [A0r A0i  A1r A1i ] // [a b c d ]
  __m256d A2 = _mm256_loadu_pd((double*)(A+2));                       // [e f g h ]
  __m256d realA = _mm256_unpacklo_pd(A0, A2);  // [A0r A2r  A1r A3r ] // [a e c g ]
  __m256d imagA = _mm256_unpackhi_pd(A0, A2);  // [A0i A2i  A1i A3i ] // [b f d h ]
  // the in-lane behaviour of this interleaving is matched by the same in-lane behaviour when we recombine.
  
  __m256d B0 = _mm256_loadu_pd((double*)B);                           // [m n o p]
  __m256d B2 = _mm256_loadu_pd((double*)(B+2));                       // [q r s t]
  __m256d realB = _mm256_unpacklo_pd(B0, B2);                         // [m q o s]
  __m256d imagB = _mm256_unpackhi_pd(B0, B2);                         // [n r p t]

  // desired:  real=rArB - iAiB,  imag=rAiB + rBiA
  __m256d realprod = _mm256_mul_pd(realA, realB);
  __m256d imagprod = _mm256_mul_pd(imagA, imagB);
  
  __m256d rAiB     = _mm256_mul_pd(realA, imagB);
  __m256d rBiA     = _mm256_mul_pd(realB, imagA);

  // gcc and clang will contract these nto FMA.  (clang needs -ffp-contract=fast)
  // Doing it manually would remove the option to compile for non-FMA targets
  __m256d real     = _mm256_sub_pd(realprod, imagprod);  // [D0r D2r | D1r D3r]
  __m256d imag     = _mm256_add_pd(rAiB, rBiA);          // [D0i D2i | D1i D3i]

  // interleave the separate real and imaginary vectors back into packed format
  __m256d dst0 = _mm256_shuffle_pd(real, imag, 0b0000);  // [D0r D0i | D1r D1i]
  __m256d dst2 = _mm256_shuffle_pd(real, imag, 0b1111);  // [D2r D2i | D3r D3i]
  _mm256_storeu_pd((double*)dst, dst0);
  _mm256_storeu_pd((double*)(dst+2), dst2);   
}

  Godbolt asm output: gcc6.2 -O3 -ffast-math -ffp-contract=fast -march=haswell
    vmovupd         ymm0, YMMWORD PTR [rsi+32]
    vmovupd         ymm3, YMMWORD PTR [rsi]
    vmovupd         ymm1, YMMWORD PTR [rdx]
    vunpcklpd       ymm5, ymm3, ymm0
    vunpckhpd       ymm3, ymm3, ymm0
    vmovupd         ymm0, YMMWORD PTR [rdx+32]
    vunpcklpd       ymm4, ymm1, ymm0
    vunpckhpd       ymm1, ymm1, ymm0
    vmulpd          ymm2, ymm1, ymm3
    vmulpd          ymm0, ymm4, ymm3
    vfmsub231pd     ymm2, ymm4, ymm5     # separate mul/sub contracted into FMA
    vfmadd231pd     ymm0, ymm1, ymm5
    vunpcklpd       ymm1, ymm2, ymm0
    vunpckhpd       ymm0, ymm2, ymm0
    vmovupd         YMMWORD PTR [rdi], ymm1
    vmovupd         YMMWORD PTR [rdi+32], ymm0
    vzeroupper
    ret

对于(2 对输入向量的)4 个复数乘法,我的代码使用:

  • 4 次加载(每次 32B)
  • 2 家商店(每家 32B)
  • 6 次通道内洗牌(每个输入向量一个,每个输出一个)
  • 2 VMULPD
  • 2 VFMA...某事
  • (如果我们可以在分离的实数和图像向量中使用结果,则只有 4 次打乱,或者如果输入也已经采用这种格式,则为 0 次打乱)
  • Intel Skylake 上的延迟(不计算 loads/stores):14 个周期 = 4c 进行 4 次随机播放,直到第二个 VMULPD 可以启动 + 4 个周期(第二个 VMULPD)+ 4c(第二个 vfmadd231pd)+ 1c(首先随机播放result vector ready 1c earlier) + 1c (shuffle second result vector)

因此对于吞吐量,这完全是随机端口上的瓶颈。 (每个时钟吞吐量 1 次洗牌,而在 Intel Haswell 及更高版本上每个时钟总共 2 MUL/FMA/ADD)。这就是打包存储很糟糕的原因:洗牌的吞吐量有限,花更多的指令洗牌而不是做数学是不好的。


Matt Scarpino's 代码经过我的细微调整(重复执行 4 次复数乘法)。 (请参阅下文,了解我如何更有效地一次生成一个向量的重写)。

  • 同6个loads/stores
  • 6 次通道内洗牌(HSUBPD 是 2 次洗牌,是当前 Intel 和 AMD CPU 的减法)
  • 4 倍
  • 2 次减法(不能与 muls 组合成 FMA)
  • 一个额外的指令(+一个常量)来翻转虚数元素的符号。 Matt 选择乘以 1.0 或 -1.0,但有效的选择是对符号位进行异或(即 XORPD 与 -0.0)。
  • 第一个结果向量在 Intel Skylake 上的延迟:11 个周期。 1c(同一周期中的 vpermilpd 和 vxorpd)+ 4c(第二个 vmulpd)+ 6c(vhsubpd)。第一个 vmulpd 与其他操作重叠,与 shuffle 和 vxorpd 在同一周期开始。第二个结果向量的计算应该很好地交错。

Matt 代码的主要优点是它一次只处理一个矢量宽度的复数乘法,而不是要求您有 4 个输入数据矢量。它的延迟稍低。但请注意,我的版本不需要 2 对输入向量来自连续的内存,或者完全相互关联。它们在处理时混合在一起,但结果是 2 个独立的 32B 向量。

我调整后的 Matt 代码版本在没有 FMA 的 CPU 上几乎和 4-at-a-time 版本一样好(只是花费额外的 VXORPD),但当它阻止我们利用FMA。此外,它永远不会以非压缩形式提供结果,因此您不能将分离形式用作另一个乘法的输入并跳过改组。


一次一个矢量结果,使用 FMA:

如果您有时计算平方而不是乘以两个不同的复数,请不要使用此方法。这就像 Matt 的算法一样,公共子表达式消除不会简化。

我没有为此输入 C 内在函数,只是算出了数据移动。由于所有洗牌都是在车道内进行的,因此我只会展示低车道。使用相关指令的 256b 版本在两个通道中进行相同的洗牌。他们保持分开。

// MULTIPLY: for each AVX lane: am-bn, an+bm  
 r i  r i
 a b  c d       // input vectors: a + b*i, etc.
 m n  o p

算法:

  • 用 movshdup(a b) + mulpd

    创建 bm bn
  • 用 shufpd 在上一个结果上创建 bn bm。 (或创建 n m 并在 mul 之前进行随机播放)

  • 用 movsldup(a b)

    创建 a a
  • 使用 fmaddsubpd 产生最终结果:[a|a]*[m|n] -/+ [bn|bm].

    是的,SSE/AVX 有 ADDSUBPD to do alternating subtract/add in even/odd elements (in that order, presumably because of this use-case). FMA includes FMADDSUB132PD 减法和加法(相反,FMSUBADD 加法和减法)。

每 4 个结果:6x 随机播放、2x mul、2xfmaddsub。因此,除非我弄错了,否则 它与去交错方法 一样有效(当不对相同的数字求平方时)。 Skylake 延迟 = 10c = 1+4+1 以创建 bn bm(与 1 个周期重叠以创建 a a),+ 4 (FMA)。所以它比 Matt 的延迟低一个周期。

在 Bulldozer 系列上,将两个输入混洗到第一个 mul 将是一个胜利,因此 mul->fmaddsub 关键路径保持在 FMA 域内(延迟低 1 个周期)。以另一种方式进行操作有助于阻止愚蠢的编译器通过过早执行 movsldup(a b) 并延迟 mulpd 来造成资源冲突。 (不过,在一个循环中,许多迭代将在随机端口上运行和瓶颈。)


这仍然比 Matt 的平方更好(仍然保存 XOR,并且可以使用 FMA),但我们不保存任何混洗:

// SQUARING: for each AVX lane: aa-bb, 2*ab
// ab bb  // movshdup + mul
// bb ab  // ^ -> shufpd

// a  a          // movsldup
// aa-bb  ab+ab  // fmaddsubpd : [a|a]*[a|b] -/+ [bb|ab]
// per 4 results: 6x shuffle, 2x mul, 2xfmaddsub

我也尝试过一些可能性,例如 (a+b) * (a+b) = aa+2ab+bb(r-i)*(r+i) = rr - ii,但没有得到任何结果。步骤之间的舍入意味着 FP 数学不能完全抵消,所以做这样的事情甚至不会产生完全相同的结果。

请参阅我对不同复数相乘的一般情况的其他回答,而不是平方。

TL:DR:只需使用我的其他答案中的代码,两个输入都相同。编译器在冗余方面做得很好。


平方稍微简化了数学运算:不需要两个不同的叉积,rAiB 和 rBiA 是相同的。但它仍然需要加倍,所以基本上我们最终得到 2 mul + 1 FMA + 1 add,而不是 2 mul + 2 FMA。

对于 SIMD 不友好的交错存储格式,它极大地促进了去交错方法,因为只有一个输入要洗牌。 Matt 的方法根本没有任何好处,因为它计算两个具有相同向量乘法的叉积。


使用我的其他答案中的 cmul_manualvec():

// squares 4 complex doubles from A[0..3], storing the result in dst[0..3]
void csquare_manual(double complex *restrict dst,
          const double complex *restrict A) {
  cmul_manualvec(dst, A, A);
}

gcc 和 clang 足够聪明,可以优化使用相同输入两次的冗余,因此无需制作带有内在函数的自定义版本。 clang 在标量自动矢量化版本上做得不好,所以不要使用它。我看不出这个 asm 输出有什么好处 (from Godbolt):

        clang3.9 -O3 -ffast-math -ffp-contract=fast -march=haswell
    vmovupd         ymm0, ymmword ptr [rsi]
    vmovupd         ymm1, ymmword ptr [rsi + 32]
    vunpcklpd       ymm2, ymm0, ymm1
    vunpckhpd       ymm0, ymm0, ymm1   # doing this shuffle first would let the first multiply start a cycle earlier.  Silly compiler.
    vmulpd          ymm1, ymm0, ymm0   # imag*imag
    vfmsub231pd     ymm1, ymm2, ymm2   # real*real - imag*imag
    vaddpd          ymm0, ymm0, ymm0   # imag+imag = 2*imag
    vmulpd          ymm0, ymm2, ymm0   # 2*imag * real
    vunpcklpd       ymm2, ymm1, ymm0
    vunpckhpd       ymm0, ymm1, ymm0
    vmovupd ymmword ptr [rdi], ymm2
    vmovupd ymmword ptr [rdi + 32], ymm0
    vzeroupper
    ret

可能不同的指令顺序会更好,以减少资源冲突。例如将实际向量加倍,因为它先解包,所以 VADDPD 可以在 imag*imag VMULPD 之前更快地开始一个循环。但是在 C 源代码中重新排序行通常不会直接转换为 asm 重新排序,因为现代编译器是复杂的野兽。 (IIRC,gcc 并没有特别尝试为 x86 安排指令,因为乱序执行主要隐藏了这些影响。)

总之,每 4 个复方块:

  • 2 次装载(从 4 次减少)+ 2 家商店,原因显而易见
  • 4 次洗牌(从 6 次减少),同样明显
  • 2 VMULPD(相同)
  • 1 FMA + 1 VADDPD(低于 2 FMA。VADDPD 在 Haswell/Broadwell 上的延迟低于 FMA,在 Skylake 上也是如此)。

马特的版本仍然是 6 次洗牌,其他一切都一样。