关于循环展开、内存访问与递归吞吐量的编译器优化决策疑问
嘿,这个问题问得特别好——编译器的这种看似“奇怪”的展开策略,其实是在寄存器压力、内存延迟隐藏和指令吞吐量之间做的精妙平衡,咱们先看一下你给出的代码和对应的汇编,再一步步拆解:
原C++代码
#include <hwy/highway.h> namespace hn = hwy::HWY_NAMESPACE; using T = float; void MulAddLoop(const T* HWY_RESTRICT mul_array, const T* HWY_RESTRICT add_array, const size_t size, T* HWY_RESTRICT x_array) { const hn::ScalableTag<T> d; for (size_t i = 0; i < size; i += hn::Lanes(d)) { const auto mul = hn::Load(d, mul_array + i); const auto add = hn::Load(d, add_array + i); auto x = hn::Load(d, x_array + i); x = hn::MulAdd(mul, x, add); hn::Store(x, d, x_array + i); } }
对应汇编核心循环
vmovaps ymm0, ymmword ptr [rdi + 4*rdx] vmovaps ymm1, ymmword ptr [rcx + 4*rdx] vmovaps ymm2, ymmword ptr [rcx + 4*rdx + 32] vmovaps ymm3, ymmword ptr [rcx + 4*rdx + 64] vmovaps ymm4, ymmword ptr [rcx + 4*rdx + 96] vfmadd213ps ymm1, ymm0, ymmword ptr [rsi + 4*rdx] # ymm1 = (ymm0 * ymm1) + mem vmovaps ymmword ptr [rcx + 4*rdx], ymm1 vmovaps ymm0, ymmword ptr [rdi + 4*rdx + 32] vfmadd213ps ymm0, ymm2, ymmword ptr [rsi + 4*rdx + 32] # ymm0 = (ymm2 * ymm0) + mem vmovaps ymmword ptr [rcx + 4*rdx + 32], ymm0 vmovaps ymm0, ymmword ptr [rdi + 4*rdx + 64] vfmadd213ps ymm0, ymm3, ymmword ptr [rsi + 4*rdx + 64] # ymm0 = (ymm3 * ymm0) + mem vmovaps ymmword ptr [rcx + 4*rdx + 64], ymm0 vmovaps ymm0, ymmword ptr [rdi + 4*rdx + 96] vfmadd213ps ymm0, ymm4, ymmword ptr [rsi + 4*rdx + 96] # ymm0 = (ymm4 * ymm0) + mem vmovaps ymmword ptr [rcx + 4*rdx + 96], ymm0 add rdx, 32 add r8, -4 jne .LBB0_8
为什么编译器会选择这种展开方式?
核心限制:寄存器资源的极致复用
你可能觉得AVX2有16个YMM寄存器,足够一次性装下32个mul_array和add_array的元素,但编译器的算盘打得更精:如果一次性加载4组mul(占4个寄存器)+4组add(占4个寄存器)+4组x(占4个寄存器),光是存操作数就用了12个寄存器,虽然剩下的还够,但编译器更倾向于避免任何潜在的寄存器溢出(把数据临时写到栈上,这会严重拖慢性能)。你看汇编里的操作:它复用
ymm0来依次加载mul_array的每一组8个元素,用完就覆盖;而add_array的4组元素一次性加载到ymm1-ymm4后就一直留着用。更绝的是,编译器把x_array的加载直接融合到了FMA指令里(就是vfmadd213ps的第三个内存操作数),根本不用单独给x分配寄存器——这一下又省了4个寄存器,总共只用到5个YMM寄存器,完美规避了寄存器压力。内存延迟的巧妙隐藏
你提到内存加载的递归吞吐量是0.5或1/3,但实际性能更取决于能不能隐藏内存延迟。现代CPU是乱序执行的,编译器得把加载、计算、存储指令穿插开,让CPU在等内存数据的时候还能干活。这里编译器先一口气把
add_array的4组连续数据加载完(CPU的预取器会全力配合这种连续内存访问,加载效率极高),然后分批加载mul_array,每加载一组就立刻做FMA计算+存储。这样一来,当CPU在处理第一组的FMA和存储时,后续的mul_array加载已经在进行了,完美隐藏了内存访问的延迟。如果一次性把所有加载指令堆在前面,反而会让CPU空等内存数据,浪费流水线周期。指令吞吐量的动态平衡
AVX2的FMA指令每周期能跑2条,编译器得保证流水线一直有活干。这种分批处理的方式,能持续给FMA指令提供新鲜的操作数,不会出现“加载完所有数据后集中计算,然后又空等下一轮加载”的情况。同时,存储操作也能和计算、加载穿插进行,不会让内存带宽成为瓶颈。
说白了,编译器的这个决策,本质上是在“最大化寄存器复用”“隐藏内存延迟”“平衡指令吞吐量”这三个目标之间找到了最优解——看起来和咱们手动展开的思路不一样,但背后都是基于CPU硬件特性的深度优化。
备注:内容来源于stack exchange,提问作者fabian

