我正试图进入优化,我想问我是否得到了循环向量化和交错之间的区别,通过带来这个例子:
void add_arrays(int* a, int* b, int n){
for (int i = 0; i < n; ++i){
a[i] += b[i];
}
}
个字符
当我用clang 5.0编译第一个时,使用标志-O3 -march=core-avx2
,优化分析器(类似于编译器资源管理器中的分析器),我得到Passed - vectorized loop (vectorization width: 8, interleaved count: 4)
个
而对于第二个,其中我指示矢量化的宽度大于为AVX2启用的无符号宽度,我得到Analysis - the cost-model indicates that interleaving is not beneficial
Passed - vectorized loop (vectorization width: 64, interleaved count: 1)
为了重现结果,我链接了编译器资源管理器页面:
第一摘录
秒片段
如果我理解正确的话,当一个循环被向量化时,这意味着向量化的指令将由CPU执行,而如果它也以一定的次数'n'交织,这些指令也将并行执行,就像第一个代码片段一样,因此,当我尝试使用大的矢量化宽度进行矢量化时,这不再是最佳的,因为运行矢量化需要太多的资源。宽度为64的指令并行,是正确的吗(我也看到矢量化指令更多的是没有交织的)?或者还有更多的微妙之处?
3条答案
按热度按时间4urapxun1#
不。这比那复杂得多。要理解为什么,我们需要了解现代主流处理器如何执行指令。
现代主流处理器是super-scalar:它们可以解码、调度和执行多条指令(指单线程)在一个单核心上并行的(更不用说这些步骤是流水线的)。更具体地说,指令被解码为 * 微指令 *(µops),然后µops被调度到称为 * 端口 * 的多个处理单元。例如,让我们专注于i5- 9600 KF CPU(Intel Coffee Lake架构),具有4个ALU,2个加载端口,1个存储端口和3个能够执行整数AVX-2加法的端口。端口0,1和2可以执行标量操作和SIMD操作,这意味着这个CPU可以从内存中加载2个值,将它们相加,并并行存储结果,假设没有依赖关系(这里就是这种情况)。
在这台CPU上(与大多数现代主流处理器一样),循环的指令首先由前端解码,然后放入µops缓存(所以不要一遍又一遍地重新解码它们)。(可能有多个迭代)然后被发送到CPU的后端部分,CPU将它们调度在可用端口上。单元之间的微操作流由于(有界)而被调整。队列。CPU调度程序足够智能,可以检测µops之间的依赖关系,然后仅在依赖关系满足时才在端口上调度它们。甚至可以对寄存器进行 * 重命名 *,以增加并行度(例如,通过删除一些虚假的依赖关系)。这实际上比这更复杂一点,但关键是,来自循环多次迭代的µops可以并行执行(只要它们是独立的)。
因此,目标CPU可以例如仅在1个周期(吞吐量)中并行执行以下步骤:
i+2
的2个项;i+1
的两个项的和;i
的结果值存储在存储器中。在实践中,需要考虑每条指令的延迟。这是微操作调度器的工作。人类很难猜测指令将如何在非平凡循环中调度和执行,更不用说这非常依赖于目标CPU架构。话虽如此,有工具可以做到这一点(例如LLVM-MCA,uiCA)。这是编译器(如Clang)经常做的事情,以评估汇编代码的成本并生成有效的代码。
第一段代码已经进行了很好的优化:它是展开的,因此大多数CPU应该被后端绑定(饱和),而不是前端。事实上,uiCA工具报告第一个代码使我的i5- 9600 KF CPU的加载/存储端口饱和。这意味着它已经是最佳(它可能不在其他CPU上,但它看起来像是在所有相对较新的英特尔架构上:从桑迪桥(2011-2013)到火箭湖(2021-2024)。因此,第二个代码不应该更快。改变指令的顺序不应该影响代码的性能。(至少不是使用内核资源,但可能是内存子系统)。我认为这是Clang优化器在这里报告的。
请注意,使用太高的SIMD宽度会给SIMD寄存器带来很大的压力。实际上,伊萨中可用的AVX-2寄存器的数量以及物理SIMD寄存器的数量都是有限的。当没有足够的SIMD寄存器可用时,编译器需要将它们临时存储在内存中(并稍后重新加载它们)这是昂贵的,特别是在这段代码上。这被称为寄存器溢出。在这种情况下,它可以用于重新排序指令以减少寄存器压力。在实践中,Clang似乎足够聪明,因此在这种情况下不会首先生成这样的代码(即SIMD宽度>64,Clang决定不展开更多的循环)。这通常发生在计算临时值的更复杂的代码中(即每个项目需要更多的寄存器)。
wnavrhmk2#
杰罗姆有一个很好的直接回答。我会提供其他的东西。
您是否尝试过告诉编译器
a
和b
与__restrict
不重叠的老式方法,而不是花哨的杂注?字符串
有了这个定义和相同的函数体,asm的向量化循环部分是相同的(用32字节= 8个元素的向量进行向量化,用4个向量展开,它们的加载/数学/存储交错)。但是清理更简单,因为它不必是巨大的部分重叠数组的后备。并且介绍代码更简单,因为它不必检查重叠。
https://godbolt.org/z/8hKjY6Ghn
卷起(每次迭代1个元素)标量清理必须运行多达31次迭代,因为clang没有进行2阶段清理,要么像以前一样使用未卷起的标量(这可以在最近的Intel或Zen 3上提供更多帮助,每个时钟可以进行2次存储),要么使用一个YMM或一个XMM向量来获得末端的7或3,这比未卷起的标量要好得多。
64jmpszr3#
只是为了记录,
vectorize_width
以 * 元素 * 而不是字节计数,所以8是8x uint32_t =一个256位YMM向量。Interleave=4只是许多逻辑向量的展开计数。TL:DR:将
vectorize_width()
提升到它愿意使用的HW / asm向量寄存器宽度之外,实际上只是一种让它以已经展开的方式展开更多的方法。至少对于简单的情况;如果它必须加宽或缩小元素,我会担心它会使asm效率低下,就像你使用uint32_t[]
数组和uint8_t[]
数组一样。无序exec已经可以在循环迭代中交错独立的工作,clang已经喜欢将小循环展开2个或4个向量,这取决于它们有多小,有时甚至是8个,使用一些
-mtune
设置。(Clang的展开也是交错的,做4次加载,然后4次vpaddd ymm, ymm, [mem]
,然后4次存储,而不是4倍的加载/添加/存储。这可能对低功耗ARM Cortex-A53效率核心等有序CPU有影响。)提升到
vectorize_width(64)
,因此一个逻辑“向量”占用8x 32字节(8元素)向量寄存器,我认为它看到循环已经足够大,每次迭代一个“64元素向量”(8条指令,每条指令用于加载/加载+添加/存储),并决定不展开到该工作量的倍数1。因此,对于asm中的总展开因子8,与vectorize_width(8)
和interleave=8
完全相同,如果有办法要求的话。当请求比目标硬件支持的“向量”更宽时,该向量的块也是具有独立工作的展开,产生与更高展开计数相同的asm,至少对于输入和输出元素宽度相同的这个非常简单的问题,因此不需要发明任何 Shuffle 。
我想这可能是一种有用的方法,可以让它展开一个循环,而不是使用
-march=core-avx2
或更好的-march=haswell
(第一个使用AVX 2的英特尔“Core”CPU)所暗示的当前-mtune=
选项。**它可能在约简中更相关(如数组和或点积),其中循环迭代之间存在数据依赖性。**在这种情况下,使用更多向量寄存器展开确实会以乱序exec无法为您做的方式交错更多工作链:Why does mulss take only 3 cycles on Haswell, different from Agner's instruction tables? (Unrolling FP loops with multiple accumulators)
Clang已经使用多个浮点数展开关联数学(整数,或者使用
-ffast-math
或#pragma omp simd reduction (+:my_sum)
的FP),但是热循环可能会受益于比默认情况下更多的展开;如果没有配置文件引导的优化,它不想在可能不是热循环或者通常使用相当小的n
运行的循环上花费太多的代码大小。如果使用
-march=x86-64-v4
编译(其中包括AVX-512),即使要求16个元素的向量也不能让它使用64字节的ZMM向量,不幸的是3。为此,您需要-mprefer-vector-width=512
。或者-march=znver4
,这意味着-mtune=znver4
- Zen 4使用512位向量没有缺点(因为它们实际上是双泵256位ALU),unlike Intel,所以编译器在调优时可以自由使用它们。#pragma clang loop vectorize_width(64)
可以 * 减少 * asm中使用的向量宽度,如果使用1
,则从-mtune
默认值减少到标量,如果使用4
用于4字节元素,则减少到XMM。(1字节元素为16。)宽度为2
,它在XMM寄存器上使用vmovq
64位加载/存储,幸运的是不是MMX!vectorize_width(1)
可能有助于阻止编译器在手动向量化循环后向量化清理循环(使用intrinsic),如果它还不能看到迭代次数是0..3或其他。但它可能仍然想使展开标量,所以这可能没有帮助。一如既往,检查asm。(通常有一些方法可以使清理循环的行程计数更明显地是一个很小的数字,比如从n & 3
派生它,而不是从手动向量化的循环(如for ( ; i < n ; i++ );
)中恢复i
的迭代)脚注1:使用AVX-512展开256位或512位闪存的选项
对于
-march=znver4
(或-march=icelake-client -mprefer-vector-width=512
),它将使用64字节的ZMM寄存器(uint32_t为16个元素),vectorize_width(64)
确实使它展开了总共16个ZMM向量。这是我们要求的每个“64个元素向量”的4 x ZMM,它选择展开4,因为它认为循环仍然很小?Godbolt with Clang 17 for znver4 or
-march=x86-64-v4 -mprefer-vector-width=512
-vectorized loop (vectorization width: 64, interleaved count: 4)
AVX-512提供了32个向量的矢量,但我不认为它担心用完所有16个YMM向量;只需使用
-march=x86-64-v4
或其他允许AVX-512但更喜欢256位向量宽度的选项,我们就可以得到“向量化宽度:64”,“交织计数:这仍然比其默认的4个向量(YMM或ZMM宽度取决于调谐)展开得更多。Footnote 2:
-march=
strings:core-avx 2是指定Haswell,Skylake等的过时方法。像
core-whatever
这样的旧拱形字符串非常笨重和不清楚,因为英特尔制造了许多代具有相同“核心”命名的CPU;如果您想要一个CPU调优中立的AVX 2 +FMA+ BMI 2微架构功能级别,请使用能够理解-march=x86-64-v3
的更新clang,或者使用-march=skylake
、-march=znver3
,或-march=icelake-client -mno-avx512f
或任何针对特定CPU进行优化并启用其所有功能的功能。或-march=x86-64-v3 -mtune=skylake
。对于Skylake系列,请参阅How can I mitigate the impact of the Intel jcc erratum on gcc?,默认情况下,-mtune=skylake
的一部分不会启用How can I mitigate the impact of the Intel jcc erratum on gcc?)AFAIK,
-march=core-avx2
所暗示的-mtune
并没有明确的定义,比如说,这应该是所有Haswell和后来的CPU,它们的名字中都有“核心”,还是专门针对Haswell?如果LLVM的优化器确实知道Haswell和Skylake或Ice Lake之间的区别,(例如,像popcnt
的错误输出依赖在Ice Lake中是固定的,Skylake中的lzcnt/tzcnt也是如此),那么你宁愿指定一个特定的CPU。GCC至少没有针对带有AVX 2的通用CPU的调优设置。
-march=x86-64-v3
留下了-mtune=generic
,幸运的是,-mtune=generic
已经停止迎合第一代Sandybridge,所以它没有split 32-byte vector load/store that it can't prove must be aligned。(由于这对后来的CPU来说更糟,特别是如果你的数据在所有或大部分时间里都是对齐的,但是你没有跳过去向编译器承诺。)如果编译器 * 确实 * 有调优选项,可以为那些没有运行我们正在生成的asm的功能的CPU提供解决方案,而不仅仅是特定的CPU或纯泛型,那就太好了。(
-mtune=generic
始终是一个移动的目标,随着编译器版本的变化而变化,因为旧的CPU变得足够过时,我们不再围绕它们的性能漏洞工作,特别是对于那些不完全引人注目的东西。脚注3:与AVX-512 256位vs. 512位矢量宽度调整选项的交互
如果有一种每循环的方式来覆盖它,对于一个在大多数对齐的数据上持续繁重工作的程序来说,512位向量值得付出涡轮时钟速度的代价(特别是在旧的Intel CPU上,但negligible on Sapphire Rapids)和端口1的向量ALU在Intel上被关闭。
如果每个函数都有调优选项,那么可能有一种方法可以影响自动向量化,但
#pragma clang loop vectorize_width(16)
不是。