yuqi-zheng

编译器优化:循环不变代码外提(LICM)


循环不变代码外提(Loop-Invariant Code Motion, LICM)是编译器最基础的优化之一。它的想法很直白:如果一段循环内部的计算在每次迭代都产生相同的结果 —— 因为它不依赖循环变量、也没有副作用 —— 那么编译器就可以安全地把它移到循环之外,只计算一次。

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

什么样的代码算”循环不变”?

一段代码是循环不变的,当它同时满足以下三个条件:

  • 它不依赖于循环归纳变量,也不依赖任何会在迭代间改变的值。
  • 它没有可观察的副作用。
  • 它每次求值都产生相同的结果。

当这些条件成立时,在循环之前执行一次这段代码,与在每次迭代中执行它是语义等价的 —— 而且开销低得多。

经典例子:vec.size()

看一个对 std::vector 的简单索引循环:

for (size_t i = 0; i < vec.size(); ++i) {
    // 处理 vec[i]
}

vec.size() 每次迭代都被调用,但 vector 的大小不会改变。编译器认识到这一点,把 vec.size() 只计算一次(通常是 end 指针减 begin 指针、再右移 log2(sizeof(int))),把这个缓存结果作为循环上界。生成的汇编中,size 计算只出现在循环之前,而不是每次迭代。

更有意思的情形:外提函数调用

当循环不变的计算是一次函数调用、而不是一个简单表达式时,LICM 就更有意思了。看这个统计某范围内字符数的函数:

std::pair<char, char> get_range(RangeType type);

int count_in_range(std::string_view s, RangeType type) {
    int num = 0;
    for (char c : s) {
        auto [min, max] = get_range(type);
        if (c >= min && c <= max) num++;
    }
    return num;
}

get_range(type) 写在循环内部,但 type 不在迭代间变化,get_range 也没有副作用。一个聪明的编译器应当把这个调用外提到循环之外。

Clang 在 -O1

Clang 正确完成了外提。它在循环开始之前只调用一次 get_range,把 minmax 放进寄存器,然后运行一个紧凑的内循环 —— 只有寄存器比较,没有函数调用,也没有对范围值的内存访问。

之所以能做到这一点,是因为编译器能看到函数体(或者函数被标注了 [[gnu::pure]]),这证明了”结果只依赖参数、没有可观察的副作用”。

GCC 的限制

截至本文写作,GCC 并不做这个外提。即使函数被标注为 [[gnu::const]],它仍在每次迭代中调用 get_range。根本原因看起来是:GCC 对返回结构体(比如 std::pair)的函数在公共子表达式消除(CSE)上支持不足,这阻碍了 LICM 分析的触发。该问题已作为 GCC Bug #122226 提交。

这是一个具体的例子,说明在完全相同、完全地道的 C++ 代码面前,两个主流编译器在优化能力上存在差异。

内循环中的无分支计数

Clang 外提完范围调用之后,内循环本身还展示了另一个巧妙的手法:无分支的条件计数。编译器不用会引入条件分支的 if,而是使用设置标志的指令:

cmp ecx, edi     ; c >= min?
setle r8b        ; r8b = 1(真)或 0(假)
cmp edx, edi     ; c <= max?
setge dil        ; dil = 1(真)或 0(假)
and dil, r8b     ; 两个条件都要成立
movzx edi, dil
add rax, rdi     ; 累加 0 或 1

setlesetge 指令根据 CPU 标志位写入 0 或 1,不带任何分支。and 把两个条件合并,结果被加到累加器里。这在内循环中彻底消除了分支预测失败。

帮助编译器外提函数调用

如果你有一个纯函数,但编译器看不透它(比如定义在另一个翻译单元里、也不能被内联),你可以用 GCC/Clang 的属性给编译器一些提示:

  • [[gnu::pure]]:声明函数会读取全局内存,但没有副作用。如果全局状态变化,不同次调用的结果可能不同,但在循环中没有相关全局被修改时,LICM 可以应用。
  • [[gnu::const]]:一个更强的声明,函数只依赖参数、不读取任何全局状态。只有在这一点真的为真时才使用。

这些属性让编译器可以像推理算术表达式那样,跨迭代推理函数调用。

小结

不要为了性能手工外提函数调用。 像下面这样的代码更难读、更难维护:

auto [min, max] = get_range(type);  // 手工外提
for (char c : s) {
    if (c >= min && c <= max) num++;
}

写自然、能表达意图的版本,把决策交给编译器。在 Clang 那边它已经做了正确的事;在 GCC 那边,bug 迟早会修。

用 Compiler Explorer 验证。 当你想确认某次外提是否真的发生了,把代码粘到 Compiler Explorer 里看汇编,不要凭想象下结论。

让函数对编译器可见。 编译器只能外提它能证明为纯函数的调用。在没有 LTO 时,定义在其他翻译单元里的函数对它是不透明的 —— 必须假设它们有任意副作用,从而不能外提。把热点工具函数放在头文件里(或开启 LTO),能给优化器它所需的可见性。