如何用256位AVX向量平方两个复数双精度?

时间:2016-09-15 11:17:07

标签: c simd complex-numbers intrinsics avx

Matt Scarpino给出了一个很好的解释(虽然他承认他不确定它是最优算法,但我感谢他),感谢他如何将两个复杂的双倍乘以英特尔' s 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]);

我的问题是我想要两个复杂的双打。我尝试使用马特的技术:

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

所以我真的有两个问题:是否有更好的(更简单和/或更快)的方法来与AVX内在函数对齐两个复杂的双精度?如果没有,我如何修改Matt的代码呢?

2 个答案:

答案 0 :(得分:8)

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

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

您可以相当便宜地在交错的double complex样式和SIMD友好的单独矢量样式之间进行转换,这取决于AVX车道内洗牌的变幻莫测。例如如果您不关心临时矢量中数据的实际顺序,那么非常便宜地使用unpacklo / unpackhi shuffle来解交错或在一个通道内重新交错。

实际上这样做是非常便宜的,因为单个复杂的乘法运行它在某种程度上超过了Matt的代码(甚至是经过调整的版本),特别是在支持的CPU上FMA。这需要以4个复数双精度(2个结果向量)的组生成结果。

如果你一次只需要生成一个结果向量,我还想出了一个替代Matt的算法,可以使用FMA(实际上是FMADDSUB)并避免单独的符号变换insn < /强>

只要你使用-ffast-math,gcc就可以将简单的复数乘法标量循环自动矢量化为非常好的代码。它像我建议的那样解开。

#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上的asm。我不确定铿锵有多好;它使用了大量64b-> 128b VMODDDUP broadcast-loads。此表单仅在Intel CPU上的加载端口中处理(请参阅Agner Fog's insn tables),但它仍然需要执行大量操作。如前所述,gcc在乘以/ FMA之前使用4个VPERMPD shuffle在通道内重新排序,然后在将它们与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

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

  • 4次装载(每次32B)
  • 2个商店(每个32B)
  • 6个内置shuffle(每个输入向量一个,每个输出一个)
  • 2 VMULPD
  • 2 VFMA ...某事
  • (如果我们可以在分离的真实和图像矢量中使用结果,则只进行4次重复,如果输入也已经采用此格式,则为0次随机播放)
  • 英特尔Skylake上的延迟(不计入负载/存储):14个周期= 4c进行4次重复,直到第二个VMULPD可以启动+4个周期(第二个VMULPD)+ 4c(第二个vfmadd231pd)+ 1c(随机第一个结果向量就绪1c早期)+ 1c(洗牌第二结果向量)

因此,对于吞吐量,这是洗牌端口的完全瓶颈。 (每个时钟吞吐量1次洗牌,而英特尔Haswell及更高版本每个时钟2次总MUL / FMA / ADD)。这就是为什么打包存储非常糟糕:shuffle的吞吐量有限,并且花费更多的指令改组而不是数学运算并不好。

Matt Scarpino's代码与我的小调整(重复做4次复数乘法)。 (请参阅下文,了解我一次更有效地生成一个向量的重写)。

  • 相同的6次装载/存储
  • 6个内置shuffle(HSUBPD是2次shuffle,减去当前的Intel和AMD CPU)
  • 4倍增
  • 2次减法(不能将muls与FMA结合)
  • 翻转虚构元素符号的额外指令(+常量)。 Matt选择乘以1.0或-1.0,但有效的选择是对符号位进行异或(即XORPD与-0.0)。
  • 英特尔Skylake对第一个结果向量的延迟:11个周期。 1c(同一周期中的vpermilpd和vxorpd)+ 4c(第二个vmulpd)+ 6c(vhsubpd)。第一个vmulpd与其他操作重叠,从与shuffle和vxorpd相同的循环开始。计算第二个结果向量应该很好地交错。

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

我调整过的Matt代码版本几乎和没有FMA的CPU一样好(就像4次一样的版本)(只需花费额外的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。 (或者在mul之前用shuffle创建n m
  • 使用movsldup(a b)
  • 创建a a
  • 使用fmaddsubpd生成最终结果:[a|a]*[m|n] -/+ [bn|bm]

    是的,SSE / AVX有ADDSUBPD交替减去/添加偶数/奇数元素(按此顺序,可能是因为这个用例)。 FMA包括减去和添加的FMADDSUB132PD(反之,加法和减法的FMSUBADD)。

每4个结果:6x shuffle,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数学没有完全取消,所以做这样的事情甚至不会产生完全相同的结果。

答案 1 :(得分:3)

请参阅我的其他答案,了解乘以不同复数的一般情况,而不是平方。

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

Squaring略微简化了数学运算:rAiB和rBiA不再需要两种不同的交叉积,而是相同。但它仍然需要加倍,所以基本上我们最终得到2 mul + 1 FMA + 1加,而不是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下调。在Haswell / Broadwell上,VADDPD的延迟低于FMA,在Skylake上相同)。

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