我想对两个内存对齐数组的乘法进行向量化。我在AVX/AVX 2中没有找到任何方法来进行64*64位的乘法,所以我只做了循环展开和AVX 2加载/存储。有没有更快的方法来做到这一点?
**注意:**我不想保存每次乘法的高半部分结果。
void multiply_vex(long *Gi_vec, long q, long *Gj_vec){
int i;
__m256i data_j, data_i;
__uint64_t *ptr_J = (__uint64_t*)&data_j;
__uint64_t *ptr_I = (__uint64_t*)&data_i;
for (i=0; i<BASE_VEX_STOP; i+=4) {
data_i = _mm256_load_si256((__m256i*)&Gi_vec[i]);
data_j = _mm256_load_si256((__m256i*)&Gj_vec[i]);
ptr_I[0] -= ptr_J[0] * q;
ptr_I[1] -= ptr_J[1] * q;
ptr_I[2] -= ptr_J[2] * q;
ptr_I[3] -= ptr_J[3] * q;
_mm256_store_si256((__m256i*)&Gi_vec[i], data_i);
}
for (; i<BASE_DIMENSION; i++)
Gi_vec[i] -= Gj_vec[i] * q;
}
字符串
**更新:**我使用Haswell微架构和ICC/GCC编译器。所以AVX和AVX 2都很好。我在乘法循环展开后将-=
替换为C语言_mm256_sub_epi64
,在那里它得到了一些加速。目前,它是ptr_J[0] *= q; ...
我使用__uint64_t
,但是出现了错误。正确的数据类型是__int64_t
。
2条答案
按热度按时间bqf10yzr1#
你似乎在你的代码中假设
long
是64位,但也使用了__uint64_t
。在32位中,x32 ABI,在Windows上,long
是32位类型。你的标题提到了long long
,但你的代码忽略了它。我想知道你的代码是否假设long
是32位。使用AVX 256加载,然后将指针别名化到
__m256i
上进行标量操作,这完全是在搬起石头砸自己的脚。gcc放弃了,给了你想要的糟糕代码:向量加载,然后是一堆extract
和insert
指令。你的写作方式意味着都vectors必须被解包,才能在标量中执行sub
,而不是使用vpsubq
。Modern x86 CPUs have very fast L1 cache that can handle two operations per clock.(Haswell和更高版本:每个时钟两次加载和一次存储)。从同一个缓存行执行多个标量加载优于向量加载和解包。(尽管不完美的uop调度将吞吐量降低到84%左右:见下文)
gcc 5.3 -O3 -march=haswell(Godbolt compiler explorer)很好地自动向量化了一个简单的标量实现。当AVX 2不可用时,gcc仍然愚蠢地自动向量化了128 b个向量:在Haswell上,这实际上是理想标量64位代码速度的1/2。(请参阅下面的perf分析,但每个向量替换2个元素而不是4个)。
字符串
内部循环:
型
如果你愿意的话,可以把它翻译回intrinsic,但是让编译器自动向量化会容易得多,我没有试图分析它,看看它是否是最优的。
如果你不经常使用
-O3
编译,你可以在循环之前使用#pragma omp simd
(和-fopenmp
)。当然,与其使用标量结尾,不如对Gj_vec的最后32 B进行非对齐加载,并将其存储到Gi_vec的最后32 B中,这可能会与循环中的最后一次存储重叠。(如果数组小于32 B,则仍然需要标量回退。)
GCC 13和Clang 17 still both vectorize this way,使用另外两个
vpmuludq
而不是1xvpmulld
来完成两个32位叉积,并稍微减少了 Shuffle 。但它们能够优化OP指针中的插入/提取-别名ptr_I[0] -= ptr_J[0] * q;
,因此编译为与简单标量版本相同的asm循环。AVX 2改进的vector intrinsic版本
从我对Z Boson的回答的评论中。基于Agner Fog的vector类库代码,在2023年沿着进行了各种改进,并修复了一个错误。(8年来,没有人注意到我将叉积添加到完整结果的底部,而不是添加到上半部分以匹配它们的位置值;感谢@Petr实际测试这个。)
Agner Fog的版本通过使用
phadd
+pshufd
(我使用psllq
/pand
/paddd
)保存了一条指令,但在shuffle端口上存在瓶颈。vpmulld
将两个交叉产品打包在一起在Haswell和更高版本的Intel上是2个uop(与两个单独的vpmuludq
指令相同,但输入的混洗较少),但在Zen 3和更高版本的AMD上,两个端口中的任何一个都只有1个uop。由于其中一个操作数是常量,因此请确保将
set1(q)
作为b
而不是a
传递,以便可以提升“B_swap”shuffle。型
这已经过测试,现在可以工作,包括非零的高半值和低半值。在Godbolt上查看。
请注意,这不包括最后的减法,只有乘法。
这个版本在Haswell上的性能应该比gcc的自动向量化版本好一点。(比如每4个周期一个向量,而不是每5个周期一个向量,检查端口0的吞吐量。用带有向量常量的
vpshufb
替换vpsllq
(srli_epi64
),或者用4替换vpslldq
,并在添加后进行掩码,可以将端口0瓶颈降低到3个周期。)在Skylake和Rocket Lake等较新的CPU上,任何一种方式都可以使用,其中https://uica.uops.info/根据执行端口压力估计吞吐量瓶颈为2.67(只做乘法,没有循环或减法),有完美的调度。我用的是移位版本,所以它不需要额外的向量常数,并让shift/和并行运行,以获得更好的关键路径延迟,以防这是依赖链的一部分,不像在问题中,我们在加载和存储之间没有对每个元素做太多其他操作,因此无序的exec不必努力在迭代之间重叠。我们可以通过执行
sumcross = ((crossprod >> 32) + crossprod) << 32
来完全避免向量常量。这仍然需要3条指令,但是AND变成了移位,所以它是另一个与乘法竞争的uop。(在Intel P核上)。它的关键路径延迟更差。但它避免了常量,使用更少的临时寄存器(出于同样的原因,它具有更差的ILP和更高的延迟),所以如果乘法作为一个更大的循环体的一部分,否则会耗尽寄存器,那么它可能是有用的。AVX 1或SSE 4.1版本(每个向量两个元素)不会比64位标量
imul r64, [mem]
快。除非你已经有了向量数据,否则不要这样做,并希望得到一个向量。(这可能比提取到标量和回来更好。)或者在一个CPU上,有缓慢的imul r64, r64
,也许像Silvermont-在这种情况下,_mm_add_epi32
比_mm_add_epi64
快。add_epi64
在任何AVX 2 CPU上都不会更慢或更大的代码大小,但是SSE版本在一些早期的CPU上。我尽可能使用add_epi32
,因为它不会更慢,并且可能保存一些功率,或者在一些假设的未来CPU上可能具有更低的延迟。根据https://uops.info/,英特尔自Haswell和AMD自Zen 1运行vpaddd
相同的vpaddq
,包括桤木湖E核心。GCC自动向量化代码的性能分析(非固有版本)
背景:参见Agner Fog's insn tables and microarch guide,以及x86标签wiki中的其他链接。
直到AVX 512(见下文),这可能只是略快于标量64位代码:
imul r64, m64
在Intel CPU上的吞吐量为每时钟一个(但在AMD Bulldozer-family上每4个时钟就有一个). load/imul/sub with-memory-dest是英特尔CPU上的4个融合域uop(具有可以微融合的寻址模式,GCC未能使用该寻址模式)。流水线宽度是每时钟4个融合域uop,因此,即使是一个大展开也不能使其在每个时钟发出一次。有了足够的展开,我们将在加载/存储吞吐量上形成瓶颈。在Haswell上,每个时钟可以进行2次加载和一次存储,但是根据英特尔的手册,存储地址uop窃取加载端口将使吞吐量降低到大约81/96 = 84%。所以也许Haswell最好的方法是加载并乘以标量,(2个uops),然后
vmovq
/pinsrq
/vinserti128
,这样你就可以用vpsubq
做减法。这是8个uops来加载和乘以所有4个标量,7 shuffle uop,将数据放入__m256i(2(movq)+ 4(pinsrq是2 uop)+1 vinserti 128),还有3个uop用于执行vector load / vpsubq / vector store。(4.5个周期发出),但是7个shuffle uops(7个周期执行)。所以nvm,这与纯标量相比并不好。自动向量化的代码对每个四值向量使用8个向量ALU指令。在Haswell上,其中5个uop(乘法和移位)只能在端口0上运行,所以无论你如何展开这个算法,它最多只能实现每5个周期一个向量(即每5/4个周期一个乘法)。
移位可以用
pshufb
(端口5)来代替,以移动数据并移位为零(其他shuffle不支持清零,而是从输入中复制一个字节,并且我们可以复制的输入中没有任何已知的零)。paddq
/psubq
可以在Haswell的1/5端口上运行,或者在Skylake的p015端口上运行。Skylake在p01上运行
pmuludq
和立即计数向量移位,因此理论上它可以管理每个最大值一个向量的吞吐量(5/2,8/3,11/4)= 11/4 = 2.75个周期。因此,它会对总的融合域uop吞吐量造成瓶颈(包括2个向量加载和1个向量存储)。所以一点循环展开会有帮助。可能由于不完美的调度导致的资源冲突会使其瓶颈略低于4个融合-每个时钟的域uops。循环开销可以在端口6上运行,它只能处理一些标量操作,包括add
和比较和分支,留下端口0/1/5用于向量ALU操作,因为它们接近饱和(8/3 = 2.666个时钟)。但是加载/存储端口远未接近饱和。因此,Skylake理论上可以每2.75个周期管理一个向量(加上循环开销),或者使用GCC自动向量化每约0.7个循环执行一次乘法,而Haswell的最佳选择(理论上对于标量每1.2个周期一个,或者理论上对于向量每1.25个周期一个)。但是,标量每1.2个周期一个可能需要手动调优asm循环,因为编译器不知道如何使用一个寄存器的寻址模式来存储,以及两个寄存器的寻址模式来加载(
dst + (src-dst)
和增量dst
)。此外,如果你的数据在L1缓存中不是热的,那么用更少的指令完成工作可以让前端在执行单元之前完成,并在需要数据之前开始加载。硬件预取不会跨页行,因此对于大型数组,矢量循环在实践中可能会击败标量,甚至可能对于较小的数组。
AVX-512 DQ引入了64 bx 64 b-> 64 b向量乘法
如果你添加
-mavx512dq
,GCC可以使用它自动矢量化。(在实践中,使用-march=x86-64-v4
,或-march=native
或-march=skylake-avx512
或任何启用其他东西并设置旋转选项。)型
因此,AVX 512 DQ(预计将在2017年成为Skylake多插槽Xeon(Purley)的一部分)将给予比2倍大得多的加速(从更宽的向量),如果这些指令以每个时钟一个的速度流水线化。
更新:Skylake-AVX 512(也称为SKL-X或SKL-SP)每1.5个周期运行一次xmm,ymm或zmm向量的VPMULLQ。它是3个uop,15 c延迟。(如果不是测量故障in the AIDA results,zmm版本可能会有额外的1 c延迟。)
vpmullq
比你用32位块构建的任何东西都要快得多,所以即使当前的CPU没有64位元素的向量乘法硬件,也非常值得为此编写一条指令。(假设它们在FMA单元中使用尾数乘数。)Zen 4将
vpmullq
作为2个端口中的任一个的单个uop运行,因此256位向量的吞吐量为2/时钟,512位向量的吞吐量为1/时钟。后来的Intel CPU(如桤木Lake / Sapphire急流)仍然将其作为端口0/1的3个uop运行,因此吞吐量为1.5c。https://uops.info/js4nwp542#
如果你对SIMD 64 bx 64 b到64 b(更低)操作感兴趣,这里有来自Agner Fog的Vector Class Library的AVX和AVX 2解决方案。我会用数组测试这些解决方案,看看它与GCC对通用循环(如Peter Cordes的answer)的处理效果如何。
AVX(使用SSE -您仍然可以使用
-mavx
编译以获得vex编码)。字符串
AVX2
型
这些函数适用于有符号和无符号的64位整数。在你的例子中,由于
q
在循环中是常量,你不需要在每次迭代中重新计算一些东西,但你的编译器可能会发现这一点。