yuqi-zheng

编译器优化:部分内联


内联是编译器能做的影响最大的优化之一。把函数调用替换成函数体会消除调用开销、启用常量传播、让被内联的代码在上下文中接受进一步的优化。但内联是有代价的:如果函数体大、从多个调用点被调用,到处内联会让代码体积显著膨胀,增加指令缓存压力。

传统的选择是二元的:要么全内联、要么不内联。部分内联(partial inlining) 是编译器在函数具有特定结构 —— 一个小而常见的情形 + 一个大而罕见的情形 —— 时可以采用的更细致的做法。编译器只内联那个小的常见路径,对罕见路径则调用一个单独编译的版本。

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

触发部分内联的代码结构

部分内联在函数有清晰的快/慢路径时效果最好:

int process(unsigned int value) {
    if (value <= 100) {
        return value * 2;                // 快路径:简单、频繁
    }
    return expensive_computation(value); // 慢路径:复杂、罕见
}

int compute(unsigned int a, unsigned int b) {
    return process(a) + process(b);
}

快路径是一次比较加一次乘法,慢路径调用另一个函数。实际中 value <= 100 绝大多数时候成立,慢路径极少走到。

编译器做了什么

编译器分两步走。

第 1 步:函数外提(outlining)。 编译器把 process 拆成两部分:

  • 原函数 process 只保留快路径(那次比较和 value * 2)。
  • 一个新生成的函数,通常命名为类似 process.part.0,包含慢路径。

第 2 步:把快路径部分内联到调用方。compute 中的各个调用点,编译器直接内联快路径:

compute:
    cmp edi, 99          ; a <= 100?
    jbe .L_fast_a        ; 是则走快路径
    call process.part.0  ; 慢路径:调用被外提的函数

.L_fast_a:
    lea r8d, [rdi+rdi]   ; 快路径:a * 2(已内联)
    ; ……b 的同样模式 ……

快路径完全在 compute 内执行,没有任何函数调用开销。慢路径仍然走函数调用,但函数里包含了那些否则会在每个调用点都膨胀内联代码的复杂部分。

好处

对常见情形享受内联收益。 快路径没有 call、没有 ret、没有函数序言/尾声。更重要的是,编译器能同时看到 a 和快路径的计算,当 a 的值已知时,还能进一步做常量传播和化简。

控制代码体积。 昂贵的慢路径在二进制中只存在一次,就在 process.part.0 里。无论整个程序中 process 被调用多少次,慢路径的代码都不会被复制。

保留外部链接。process 函数仍然存在,仍可被其他翻译单元调用。优化对外部调用者是透明的。

优化触发的条件

部分内联的触发依赖几个因素:

函数必须有可识别的冷热结构。 对那种全函数都一样昂贵的,它没有”小的部分”可供内联,不是候选。

慢路径必须足够大。 如果整个函数都很小,全内联比部分内联更便宜。部分内联的好处在于:避免在”永远走不到慢路径”的调用点膨胀代码。

编译器支持程度不一。 GCC 实现了部分内联,并会在类似本例的场景下应用它。Clang 的内联器可能对相同函数做不同处理 —— 可能全内联、不内联,或产生不同的切分。具体行为取决于代码和编译器版本。

启发式主导决策。 编译器用静态启发式、或者(如果有)profile 数据来估计每条路径的大小和频率。Profile-Guided Optimization(PGO)的数据可以显著提高这些估计的准确度,也能让部分内联更有效。

[[likely]][[unlikely]] 的关系

在 C++20 中,你可以用 [[likely]][[unlikely]] 给编译器提示预期的分支频率:

int process(unsigned int value) {
    if (value <= 100) [[likely]] {
        return value * 2;
    }
    return expensive_computation(value);
}

这种标注不直接控制部分内联,但它会影响代码布局,也能影响编译器对内联决策的代价估计。

写出”有助于优化”的代码

最重要的一点是:把函数结构写成常见简单情形和罕见复杂情形之间有清晰分隔。不管有没有部分内联,这都是好的实践 —— 让代码更易读、更易推理,而且它恰恰是让编译器能应用这项优化的结构。

避免快慢路径交织的深层嵌套。一个清晰的早 return 或顶层分支对编译器的分析更友好。

用 Compiler Explorer 验证部分内联是否在你的函数上触发。查找形如 function.part.N 的生成符号 —— 它们的存在就说明编译器把慢路径外提了。调用点应当显示:快路径已内联、慢分支走对外提版本的调用。