inline、去虚拟化与编译器优化全景
inline 的真正威力:不是省调用,是打开优化空间
很多人对 inline 的理解停在「省了函数调用的压栈/出栈开销」。这话对,但只说到了表面,当代 CPU 上一次常规函数调用也就几个纳秒,inline 省下的调用开销通常不是大头。inline 真正的威力在于:它把被调函数的代码「搬进」调用点,让编译器能跨函数边界做优化。
举个例子:
int square(int x) { return x * x; }
int f(int n) { return square(n) + square(n); }不 inline,f 调了两次 square,两次函数调用、两次 n*n。inline 后,编译器看到的是 return n*n + n*n,于是:
- 公共子表达式消除(CSE):
n*n算了两次 → 合并成int t = n*n; return t + t;。 - 强度折减:
t + t→t << 1(加法换位移,虽然这里没意义,但编译器会评估)。 - 常量传播(如果
n是编译期已知):整个f(5)折叠成常量50。
这些优化不 inline 就做不了,因为编译器看不见 square 的实现(它在另一个翻译单元,或者编译器不敢跨调用点假设)。inline 一旦发生,函数边界消失,优化空间打开。这才是 inline 的核心价值。
inline 关键字只是「提示」
C++ 的 inline 关键字是个提示,不是命令。现代编译器(GCC/Clang)按自己的代价模型决定是否真内联:函数太大不内联(代码膨胀 icache miss 得不偿失)、递归不能完全内联、虚函数不能内联(运行时才知道调谁)。反过来,没标 inline 的函数,编译器只要看得见实现,也会自动内联(尤其在 -O2/-O3 + LTO 下)。
所以你的工作不是「到处标 inline」,而是:
- 把热函数的实现放在头文件里(或开 LTO),让编译器看得见实现,这是内联的前提。
- 别用
noinline随便挡(有时为了调试或控制代码体积会用,但记得去掉)。 - 真的必须强制内联时用
[[gnu::always_inline]](GCC/Clang)或 C++ 没标准化的__attribute__((always_inline)),但慎用,编译器的代价模型通常比你准。 - C++20 的
[[gnu::flatten]]能强制内联整个调用链,极度敏感的热点偶尔用。
一个常见误区:把 inline 当「让函数变快」的银弹到处贴。如果函数实现不在头文件(跨翻译单元),inline 关键字没用,编译器还是看不见实现。LTO(ch07-02)解决这个。
去虚拟化:把虚函数变回可内联
虚函数是 inline 的天敌,因为调谁要等到运行时查 vtable 才知道,编译器不敢内联。但如果编译器能证明「运行时类型是确定的」,它会把虚调用**去虚拟化(devirtualization)**成直接调用,然后就能内联了。我们测四种调用方式(本机,平均每次 ns):
===== 虚函数与去虚拟化 =====
虚函数(指针,运行时多态): 0.55 ns ← 查 vtable + 间接跳转,阻碍内联
final 类(编译器去虚化): 0.54 ns
直接对象(非指针,常去虚化): 0.23 ns
CRTP(静态多态,无虚表): 0.22 ns ← 可内联
虚函数/CRTP = 2.5x读出两件事:
1. 虚函数通过指针调用(0.55 ns)比可内联的 CRTP/直接对象(0.22-0.23 ns)慢 2.5 倍。 看汇编,在本例里虚调用其实被 GCC 推测性去虚化了(运行时比对 vptr 与编译期已知目标地址,猜中走内联快速路径),函数体 x*3+1 已被内联,所以 0.55 ns 这一档多出来的开销不是「vtable 查表 + 间接跳转」,而是循环里 per-iteration 的类型守卫(加载 vptr、与已知地址比较、条件分支);直接对象/CRTP 那一档连守卫都没有,所以快。CRTP(静态多态,模板实现) 把多态推到编译期,没有虚表、能内联,是最快的。
2. final 在这个例子里没让循环变快(0.54 ≈ 0.55)。 我标了 struct DerivedF final,期望编译器「知道没有更派生类」就敢去虚化,但 0.54 ≈ 0.55 不是「final 没去虚化」!本机 -fopt-info-all 显示 final 类(DerivedF)和普通派生(Derived)的虚调用都被 GCC 推测性去虚化了(和上面第 1 点同一种机制)。0.54 ≈ 0.55 的真因是「推测去虚化的 per-iteration 类型守卫开销和一次虚调用相当,没省下来」。这是个诚实、重要的结果:别以为标了 final 就自动快,去虚化能不能省下开销,要看汇编里是不是真变成了直接 call(-S:虚调用是 call [vtable+offset] 间接跳转,完全去虚化是直接 call func)。
推论:别为了「可能快点」提前把虚函数改成 CRTP/模板。先测,编译器可能已经去虚拟化了(尤其你用直接对象调用时);真测出虚函数是瓶颈,再考虑 CRTP 或 final。这条 ch06-01 会展开成完整的「虚函数成本」讨论。
编译器优化全景:-O2/-O3 都在做什么
把 inline、devirt 放进更大的图景。一张表看 GCC/Clang 各 -O 级别大致做什么(精确清单查官方文档):
| 级别 | 大致做什么 | 何时用 |
|---|---|---|
-O0 | 不优化,变量可观察 | 调试(性能数字无意义) |
-O1 | 基本优化(常量折叠、简单内联) | — |
-O2 | 大多数优化:CSE、 LICM、寄存器分配、调度、自动内联(同 TU)、基础向量化 | release 默认甜点 |
-O3 | 更激进:循环向量化、自动展开、更激进内联 | 数值/SIMD 受益;偶尔反伤(icache) |
-Os/-Oz | 优化体积 | 嵌入式 flash 受限 |
关键认知:
-O2是 release 的默认甜点。它已经做了 04-02 讲的循环优化大半、同翻译单元内联、基础调度。-O3比-O2多的主要是「更激进的向量化 + 展开 + 内联」。对数值计算/SIMD 友好的代码有用;对分支密集、不规则代码可能反而变慢(代码膨胀 → icache miss)。- 跨翻译单元的内联和优化,
-O2/-O3都做不了,要靠 LTO(ch07-02)。这就是为什么大项目 release 推荐开 LTO。
别挡编译器的路:optimization blockers
inline 和这一节的各种优化,前提都是「编译器看得见、敢优化」。有几种情况会挡住编译器(optimization blockers,ch07-01 详讲):
- 跨翻译单元:看不见实现 → 不敢内联。解法:头文件放实现 / 开 LTO。
- 指针别名:编译器不知道两个指针是否指向同址,不敢激进重排内存读写。解法:
__restrict声明无别名(C99 引入,C++ 是扩展但 GCC/Clang 都支持)。 volatile:强制每次真读写内存,等于禁用优化。只在 MMIO/信号/无锁标志用,别用于性能。
这三条都是「你写了别的东西,挡了编译器的路」。本篇的核心精神就是:内联和编译器优化的主要工作不是「主动做什么」,而是「别挡路 + 让它看得见实现」。主动手写(强制内联、CRTP)只在测出瓶颈后做。
把这一篇压成几句:inline 的真正价值是打开跨函数边界的优化空间(CSE、常量传播、强度折减),不是省调用开销;inline 关键字只是提示,让编译器看得见实现(头文件 / LTO)才是内联的前提;去虚拟化能把虚函数变回可内联,但不是 final 一标就省开销,本例 final 类和普通派生都被 GCC 推测性去虚化了(-fopt-info-all 可证),0.54 ≈ 0.55 是去虚化的 per-iteration 守卫开销没省下来,直接对象调用更易完全去虚化,CRTP 是静态多态、最快(virtual/CRTP = 2.5×);-O2 是 release 甜点,-O3 多的是激进向量化/展开,数值友好但偶尔反伤;optimization blockers(跨 TU、别名、volatile)是你挡了编译器的路,下一篇 ch04-05 会在 SIMD 上下文里再撞一次「编译器敢不敢」的问题。
参考资源
- Piotr Padlewski C++ devirtualization in clang(CppCon 2015 Lightning)——去虚拟化机制(vol10 复用)
- GCC/Clang 文档
-Olevels、-finline-*、__restrict、always_inline属性 - ch06-01 虚函数与去虚拟化(本卷,virtual 成本的完整讨论)
- 本篇实测代码:
code/volumn_codes/vol6-performance/ch04/virtual_devirt.cpp