yuqi-zheng

编译器优化:内联 —— 优化之王


绝大多数编译器优化一次只作用于单个函数。它们重排指令、消除冗余计算、高效分配寄存器 —— 但只在它们能”看见”的范围内。内联扩大了这个范围。当函数调用被被调用函数的函数体副本取代时,优化器获得了对这段代码”到底做什么”的可见性,于是一整类此前被阻断的变换突然变得可能。

这就是为什么内联常被称为”优化之王”。它不仅仅移除了 call 指令的成本;它是让几乎所有其他高级优化得以应用的前提。

内联到底做了什么

当编译器把一个函数内联时,它把调用点替换为函数体,并把形参替换为实参。callret 指令消失,相关的栈帧建立也一并消失。但这只是好处中最不有趣的部分。

更重要的效果是:优化器现在可以跨越曾经的函数边界进行推理。看这段代码:

void process(bool flag) {
    if (flag)
        do_fast_path();
    else
        do_slow_path();
}

如果 do_fast_pathdo_slow_path 没有被内联,对优化器而言它们是黑盒。编译器必须假设它们可以做任何事:修改全局状态、调用其他函数、读写内存。它无法推理它们的内容,无法消除调用之后变得冗余的计算,也无法把 flag 的值传播进这两个函数内部去简化它们的逻辑。

内联之后,所有这些推理都成为可能。

连锁反应

内联往往会引发一系列后续优化的连锁反应。典型顺序如下:

常量参数传播。 当编译器把实参替换进函数体后,一个调用点上是变量的形参,可能在内联后的函数体内就变成了字面常量。内联一个 n=4 的调用后,被调用函数里的 if (n == 0) 变成 if (4 == 0),优化器立刻就能求值。

死代码消除。 一旦某个条件被确定总为真或总为假,不可能到达的分支就整体被移除。那些只为被消除的分支才需要的代码 —— 变量、分配、循环 —— 也一并被删除。

循环简化和向量化。 被调用函数内部的循环可能包含依赖于参数的条件逻辑。经过常量折叠和死代码消除后,这些逻辑可能消失,留下一个简单的循环体,自动向量化器就能把它转换成 SIMD 指令。

寄存器分配的改进。 函数调用会建立一个边界:编译器必须假设被调用函数按调用约定使用和修改寄存器。如果某些值在调用前存在于寄存器中、调用后仍需使用,就必须溢出到栈上。内联后,合并的代码成为一整块,寄存器分配器可以在整个计算过程中工作,而不受这些人为的溢出点影响。

如果调用不被内联,这些后果一个也不会发生。函数体不透明,参数消失不见,优化器只看到一条调用指令。

编译器如何决策

现代编译器使用启发式代价模型来决定是否内联某个调用点。通常考虑的因素包括:

  • 被调用函数的大小:小函数几乎总会被内联;非常大的函数很少被内联
  • 调用频率:紧循环中的调用权重高于只执行一次的调用
  • 参数是否为常量:当内联能带来常量折叠时,更值得做
  • 被调用函数中是否有循环或递归:递归函数不能无条件内联
  • 目标架构特性:有些架构对分支预测失败的惩罚更重,这影响内联的价值

C++ 中的 inline 关键字并不强制内联。它影响的是链接 —— 允许函数定义放在头文件中而不违反唯一定义规则 —— 但编译器完全可以忽略这个内联提示,如果代价模型认为内联不合算,它就不会内联。

链接期优化

按翻译单元编译的一个根本限制是:编译器无法内联一个定义在其他 .cpp 文件中的函数调用。函数体被单独编译,在调用点处不可用。

链接期优化(LTO)消除了这一限制。在 GCC 或 Clang 上开启 -flto,所有翻译单元的函数体以中间表示的形式保留在目标文件中,链接器执行一次整程序的优化 pass,可以跨模块边界进行内联。

对于那些由许多分散在不同文件中的小辅助函数组成的程序,LTO 的影响可以非常显著。启用它不需要修改任何源代码。

模板函数和头文件

C++ 模板函数在每个使用它的翻译单元中都会被实例化,它们通常定义在头文件里。这意味着编译器在调用点总是能拿到函数体,可以自由内联。constexpr 函数也是如此。

这是为什么 C++ 标准库实现把这么多代码放在头文件里的原因之一:在调用点可以内联,是从 std::vectorstd::spanstd::string_view 等抽象中获得良好性能的核心条件。

内联的代价

内联并不免费。复制函数体会增加代码大小,这会增加指令缓存压力。一个被多处调用、又在每处都被内联的函数,可能让整个二进制文件变大,其拖慢程度反而超过内联带来的加速。

编译器会尝试自动平衡这些。在没有 profiling 证据证明”编译器的默认判断是错的”之前,开发者应克制手工标记 __attribute__((always_inline))[[msvc::forceinline]] 的冲动。这些标注偶尔必要,但更多时候反映的是对性能瓶颈所在的误解。

实用准则

保持函数小而专注。 小函数更易被内联,内联之后优化器也能做更多。一个只做一件事的函数,与其上下文的耦合更少,对变换的阻碍也更少。

把模板和 constexpr 函数放到头文件里。 这确保编译器在任何调用点都能拿到函数体。对那些性能关键、被多处调用的非模板函数,也可以考虑放进头文件,或开启 LTO 获得跨模块内联。

生产构建开启 LTO。 在 GCC 和 Clang 上,-flto 启用整程序内联。编译和链接时间会更长,但效果通常显著优于按翻译单元编译。

大型程序使用 Profile-Guided Optimization(PGO)。 PGO 把运行时的频率数据反馈给编译器,让它在”哪些调用点值得内联”上做更明智的决定。热路径被激进内联;冷路径保留为调用,以节省代码大小。

不要依赖 inline 关键字强制内联。 如果你需要验证某个具体函数是否被内联,看汇编输出。Compiler Explorer 让这件事很简单。

小结

理解内联的最佳方式,不是把它看成一种优化本身,而是看成启用其他所有优化的前提条件。通过把函数体复制到调用点,编译器获得它所需的全局视野,用以折叠常量、消除死代码、向量化循环、跨越曾经坚硬的边界进行寄存器分配。本系列中讨论的其他几乎所有优化,要么在内联之后效果更好,要么只有因为内联才变得可能。