编译器优化:循环展开
每一个循环都要交税。这份税由计数器自增、边界检查、跳回循环顶部的分支,以及分支指令带来的流水线气泡组成。对于执行数百万次的循环,每次迭代摊下来这份税可以忽略。但对于处理少量固定元素的紧凑循环,它可能占到总运行时间相当可观的一部分。
循环展开(loop unrolling)通过复制循环体多次、同时按比例减少迭代次数来减轻这份税。它带来的好处是更少的控制指令、在任何时刻对 CPU 可见的独立工作更多,以及使用更高效加载指令的机会。
本文是 Matt Godbolt 的《Advent of Compiler Optimisations 2025》系列中的一篇。
关键变量:编译器是否知道迭代次数?
循环展开的激进程度几乎完全取决于编译器能否在编译期确定迭代次数。
动态大小:不展开
当循环次数要到运行时才知道时,编译器会生成一个标准循环。看一个在动态大小的 span 上求和的函数:
int sum(const std::span<int>& data);
编译器别无选择,只能生成一个每次迭代都检查剩余计数的循环:
.LBB0_2:
ldr r3, [r2], #4 ; 加载下一个元素,指针前进
subs r1, r1, #4 ; 剩余字节计数 -1
add r0, r3, r0 ; 累加
bne .LBB0_2 ; 未完则继续
每个元素三条指令加一次分支。循环是正确且合理的,但在没有更多信息的情况下,没有办法去掉这份控制开销。
固定大小:完全展开
当编译器在编译期就知道迭代次数时,它可以做一些更激进的事:
int sum(const std::span<int, 8>& data);
模板参数 8 让计数成为编译期常量。编译器会把循环完全展开,发出八次独立的加载 + 相加序列,没有循环计数器、没有边界检查、也没有分支:
ldmib r1, {r2, r3} ; 一条指令加载两个元素
add r0, r2, r3
ldr r2, [r1, #8]
add r0, r0, r2
; ……再四对 load + add ……
注意 ldmib 指令 —— 它在一条指令里加载多个寄存器,而这只有当编译器精确知道要处理多少元素、并能提前安排序列时才可能做到。结果是一段完全没有循环结构的、“扁平的”加载与加法序列。
展开的阈值
编译器并不总是展开,也不总是完全展开。启发式规则因编译器和目标架构而异,但在 ARM 上 GCC 和 Clang 的大致画像是:
| 迭代次数 | 编译器行为 |
|---|---|
| 不超过 16 | 完全展开;代码增大可接受 |
| 约 32 | 部分展开并出现寄存器溢出;尽力暴露并行但寄存器不够用 |
| 50 及以上 | 退回标准循环;代码膨胀收益不抵付出 |
对非常大的迭代次数,编译器理论上可以采用分块展开策略 —— 每一个”超迭代”处理 16 个元素,然后在这些”超迭代”上再循环。当前版本的 GCC 和 Clang 一般不会主动应用这种模式,而是依赖自动向量化来高效处理大型数组。
如何给编译器它所需要的信息
启用激进展开最直接的方式,是把迭代次数编码进类型系统。
用 std::array<T, N> 代替 std::vector<T>。 大小作为模板参数,对编译器始终可见。
用 std::span<T, N> 代替 std::span<T>。 固定 extent 的版本在编译期携带计数;动态 extent 的版本没有。
避免不必要的堆分配。 如果你知道某个集合一定恰好有 N 个元素,在栈上分配一个大小为 N 的数组,能给编译器完整信息并消除任何间接。
考虑 Profile-Guided Optimization(PGO)。 当迭代次数确实是动态的,PGO 能把运行时观察到的分布反馈给编译器,让它对”常见情形下部分展开”做出更有依据的决策。
不要做什么
不要手工展开循环。像这样写:
sum += data[0];
sum += data[1];
sum += data[2];
// ...
更难读、更难维护,而且不一定比编译器在已知上界的正常循环上生成的代码更好。编译器对目标架构的理解 —— 寄存器数量、指令吞吐、延迟特性 —— 远比手工展开要透彻。
如果你想要展开,通过类型系统传达循环上界,剩下的交给编译器。
小结
循环展开不是一种手工优化。它是”把代码实际在做什么”准确告诉编译器之后的自然结果。当迭代次数固定且可见时,编译器会彻底消除循环开销,可能还会使用常规循环里不可用的专用指令。当计数是动态的时,编译器生成正确、合理的代码,但没有额外提示就不能走得更远。
最可操作的实践是:凡是在编译期真正已知大小的地方,使用固定大小的类型(std::array、固定 extent 的 std::span)。在小而热的循环上,性能提升可能相当显著。