yuqi-zheng

编译器优化:循环外分支(Loop Unswitching)


Loop unswitching 是一个针对特定且常见模式的优化:一个位于循环内部、但条件在迭代间不变的条件分支。编译器不再在每次迭代都重新判断这个不变条件,而是把条件移到循环之外,生成两份专用的循环副本 —— 每个分支结果一份。

本文是 Matt Godbolt 的《Advent of Compiler Optimisations 2025》系列中的一篇。

问题:循环里的不变条件

看一个对 vector 求和的函数,可选对元素平方后求和:

int sum(const std::vector<int>& data, bool squared) {
    int total = 0;
    for (int x : data)
        total += squared ? x * x : x;
    return total;
}

squared 的值在整个循环期间是固定的。在每次迭代都检查一遍它是浪费 —— 但这是最自然的写法,我们不应该因为这种担忧去重构代码。

编译器在 -O2 下的选择

-O2 下,编译器偏向紧凑的表示。它保留一个循环,用条件选择或”乘一”的小技巧来避免分支:

mla r0, r2, r2, r0   ; total += x * (squared ? x : 1)

乘法总是执行。当 squared 为假时,x 乘以 1 —— 数学上是空操作,但计算上并不是。循环小且无分支,但当不需要平方时,它在每次迭代中都做了没必要的工作。

-O2 的这种取舍是有意为之:代码体积保持较小,循环也仍然合理地快。

编译器在 -O3 下的选择

-O3 下,编译器启用循环外分支(loop unswitching)。它把单个循环变换为:

  1. 循环前对 squared 的一次测试。
  2. 两份完全独立的循环副本 —— 一份计算 total += x,一份计算 total += x * x

生成的汇编大致如下:

cbz w1, .L_non_squared   ; squared 为假则跳到"非平方"循环

.L_squaring_loop:
    ; 紧凑循环:total += x * x
    ; 内部没有条件、没有分支

.L_non_squared:
    ; 紧凑循环:total += x
    ; 内部没有条件、没有分支

两个循环现在都非常干净。没有条件、没有不必要的乘法、没有冗余工作。优化器在这些简化后的循环上还能更激进地应用进一步的变换 —— 尤其是向量化 —— 因为它们只包含单一、均匀的操作。

代码体积的取舍

这个优化之所以不在 -O2 下触发,理由很直接:它会让循环体积翻倍。对大循环来说,这意味着二进制显著增大。指令缓存压力很重要,把每一个恰好包含不变条件的循环都膨胀一倍,会伤害那些既有大量此类循环、缓存又有限的程序。

-O2-O3 的分界反映了一个有意的策略选择:

等级优先级Loop unswitching
-O2平衡速度与体积
-O3最大化速度

对性能关键的代码路径,-O3 通常是合适的选择。对体积和缓存行为都重要的通用构建,-O2 更保守,通常也足够用。

除消除分支之外的收益

循环一旦被外分支拆开,二级优化就变得更有效:

向量化。 自动向量化在统一、可预测的操作上效果最好。一个总是做 x * x 的循环,远比一个”条件平方”的循环更容易向量化。

指令级并行。 没有条件分支的循环让 CPU 的乱序执行引擎对数据流有更清晰的视野。

寄存器分配。 更少的活跃值、更简单的控制流图,让寄存器分配器的工作更容易。

实用建议

写能清晰表达意图的代码。如果一个循环有一个”对所有迭代统一生效”的标志参数,就把它作为参数传入、让编译器自己决定是否外分支。不要手工把循环拆成两处调用来强行实现这个优化 —— 那会让代码更难维护,也剥夺了优化器的灵活性。

使用 Compiler Explorer 验证外分支是否在你的具体代码和编译器版本上触发。这取决于循环大小、内联决定以及其他从源代码上并不明显的上下文因素。

如果你正在处理一条热路径、并且 -O2-O3 之间的性能差异可测,先做 profiling,然后有选择地用 __attribute__((optimize("O3"))) 或按函数级别的 pragma,而不是把整个项目都切到 -O3 重新构建。