本答案涵盖了复数数组乘法的一般情况
理想情况下,将数据存储在单独的实数和虚数数组中,这样您就可以加载连续的实部和虚部向量。这使得交叉相乘变得自由(只需使用不同的寄存器/变量),而无需在向量内部重新排列内容。
您可以在飞行中以相对便宜的方式在交错的double complex
样式和SIMD友好的单独向量样式之间进行转换,但受AVX在通道内洗牌的影响。例如,使用unpacklo / unpackhi shuffle相当便宜,可以在一个通道内解交错或重新交错,如果您不关心临时向量内数据的实际顺序。
实际上,这种洗牌非常便宜,即使针对单个复杂乘积,在支持FMA的CPU上也比Matt的代码(即使是经过调整的版本)更为出色。这需要以4个复杂双精度(2个结果向量)的组形式产生结果。
如果您只需要一次生成一个结果向量,则我还提出了一种替代Matt算法的方法,可以使用FMA(实际上是FMADDSUB)并避免单独的符号更改指令。
如果你使用了-ffast-math
,gcc会自动将简单的复数乘法标量循环向量化成相当不错的代码。它会像我建议的那样进行解开交错。
#include <complex.h>
void cmul(double complex *restrict dst,
const double complex *restrict A, const double complex *restrict B)
{
for (int i=0; i<4 ; i++) {
dst[i] = A[i] * B[i];
}
}
请看
Godbolt编译器资源管理器上的汇编代码。我不确定clang的汇编质量如何;它使用了许多64b->128b
VMODDDUP广播加载。这种形式在Intel CPU上纯粹在加载端口中处理(请参见
Agner Fog's insn tables),但仍需要执行很多操作。如前所述,gcc使用4个VPERMPD洗牌来重新排列各行,在乘法/ FMA之前,然后使用另外4个VPERMPD将结果重新排序,再与VSHUFPD组合。这是4个复杂乘法需要额外进行8次洗牌。
将gcc版本转换回内在函数并去除冗余的洗牌操作可以得到最优代码。(显然,gcc希望其临时变量按照A B C D顺序排列,而不是由
VUNPCKLPD (_mm256_unpacklo_pd
)的in-lane行为导致的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)
__m256d A2 = _mm256_loadu_pd((double*)(A+2))
__m256d realA = _mm256_unpacklo_pd(A0, A2)
__m256d imagA = _mm256_unpackhi_pd(A0, A2)
// 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)
__m256d B2 = _mm256_loadu_pd((double*)(B+2))
__m256d realB = _mm256_unpacklo_pd(B0, B2)
__m256d imagB = _mm256_unpackhi_pd(B0, B2)
// 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)
__m256d imag = _mm256_add_pd(rAiB, rBiA)
// interleave the separate real and imaginary vectors back into packed format
__m256d dst0 = _mm256_shuffle_pd(real, imag, 0b0000)
__m256d dst2 = _mm256_shuffle_pd(real, imag, 0b1111)
_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个in-lane洗牌(每个输入向量一个,每个输出一个)
- 2个VMULPD
- 2个VFMA...something
- (如果我们可以使用分离的实部和虚部向量或者输入已经处于这种格式,则只需4个洗牌;否则不需洗牌)
- 在Intel Skylake上的延迟(不包括加载/存储):14个周期= 4个洗牌周期直到第二个VMULPD开始+ 4个周期(第二个VMULPD)+ 4c(第二个vfmadd231pd)+ 1c(第一个结果向量准备好的1c洗牌)+ 1c(第二个结果向量洗牌)
因此,在吞吐量方面,这完全受到洗牌端口的瓶颈。(每个时钟周期1个洗牌吞吐量,与Intel Haswell及更高版本的每个时钟周期总共2个MUL / FMA / ADD相比)。这就是为什么打包存储很糟糕的原因:洗牌的吞吐量有限,花费更多的指令来洗牌而不是做数学运算并不好。
Matt Scarpino的代码,我做了一些小改动(重复4次以执行4个复杂的乘法)。 (有关更有效地生成一个向量的我的重写,请参见下文)。
- 相同的6个加载/存储操作
- 6个通道内洗牌操作(在当前的英特尔和AMD CPU上,HSUBPD是2个洗牌操作和一个减法操作)
- 4个乘法操作
- 2个减法操作(不能与乘法合并为FMA操作)
- 额外的一条指令(加上一个常数)来翻转虚数元素的符号。Matt选择了乘以1.0或-1.0,但高效的选择是使用异或位运算(即XORPD与
-0.0
)。
- 在Intel Skylake上第一个结果向量的延迟为11个周期。1个周期(vpermilpd和vxorpd在同一周期内)+ 4个周期(第二个vmulpd)+ 6个周期(vhsubpd)。第一个vmulpd与其他操作重叠,在洗牌和vxorpd的同一周期开始。计算第二个结果向量应该相当不错地交错进行。
马特代码的主要优点是一次只需使用一个向量宽度的复数乘法,而不需要你拥有4个输入数据向量。它具有较低的延迟。但请注意,我的版本不需要2对输入向量来自连续内存,也不需要彼此相关。它们在处理过程中混合在一起,但结果是2个独立的32B向量。
我的修改版马特代码在没有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
。(或者在乘法之前使用 shuffle 创建 n m
)
使用 movsldup(a b) 创建 a a
使用 fmaddsubpd 生成最终结果:[a|a]*[m|n] -/+ [bn|bm]
。
是的,SSE/AVX 有 ADDSUBPD 来在偶数/奇数元素中交替进行减法/加法(按照这种用例的顺序)。FMA 包括 FMADDSUB132PD,它执行减法和加法(以及反向操作 FMSUBADD,执行加法和减法)。
每4个结果:6倍洗牌,2倍乘法,2倍FMADD/SUB。 因此,除非我错了,
它与去交错的方法一样有效(当不平方相同的数字时)。 Skylake延迟= 10c =1+4+1来创建
bn bm
(与创建
a a
重叠1个周期),+ 4(FMA)。 因此,它比Matt的延迟低一个周期。
在Bulldozer系列上,将两个输入都洗牌到第一个乘法中会是胜利,因此乘法->fmaddsub关键路径保持在FMA域内(延迟降低1个周期)。 另一种做法可防止愚蠢的编译器过早进行movsldup(a b)并延迟mulpd。 (在循环中,许多迭代将正在飞行并且将在洗牌端口瓶颈。)
这个方法相对于Matt的方法来说在平方运算上仍然更好(仍然可以使用XOR并且可以使用FMA),但我们无法节省任何洗牌操作。
我也尝试了一些可能性,比如
(a+b) * (a+b) = aa+2ab+bb
,或者
(r-i)*(r+i) = rr - ii
,但是没有任何进展。在步骤之间进行四舍五入意味着浮点数计算无法完全抵消,因此做这样的事情甚至不会产生完全相同的结果。
squareZ
是什么? - mtrw_mm256_xor_pd
来翻转符号位,这是 FP 版本向量 XOR/AND/OR 的主要用例之一。同时需要注意的是将实部和虚部放在两个单独的向量中可以实现更高效的操作:1. 不需要洗牌去交换实部/虚部:通过操作不同的变量就可以免费完成。2. 快速垂直子,而不是慢速水平子。3. 每条指令可以翻转 4 个虚部的符号。使用shuffle_pd
对打包输入/输出执行此操作可能会获得胜利。 - Peter Cordesstd::complex
数组... - Mysticialstd::complex
遵循一些 IEEE 规范来处理通常的 NaN 边界情况 - 这扩展到了 这个怪物。这甚至不好笑。 - Mysticial