LTO 链接时优化:无 LTO、全量 LTO 与 ThinLTO 的实测取舍
编译优化系列的第一篇。前面讲 C++ 互斥量类型与性能、无锁编程的底层机制 时,所有代码都只用了 -O2,优化边界停留在单个翻译单元(TU,Translation Unit)。本篇把视角移到链接期,回答一个工程里天天遇到、却很少有人认真量化的问题:打开 LTO 链接时优化到底要付多少构建成本、能换回多少运行性能,全量 LTO 和 ThinLTO 又该选哪个。
本文要回答的问题
- 普通
-O2编译和-flto编译,产物内部到底差在哪里? - 为什么单文件小程序测不出 LTO 的效果,必须构造多翻译单元基准?
- 全量 LTO 与 ThinLTO 的运行收益、构建耗时、二进制体积差距有多大?
- 反汇编里能看到哪些确凿的优化证据?虚函数调用一定会被去虚化吗?
- 实际工程应该默认开哪一档,什么情况下关掉 LTO?
一、三种构建模式在链接期各做什么
先把三档构建的语义差异摊在一张卡片里,后面的实测数据都围绕这张表展开。
读这张卡抓住一条主线:普通构建的优化视野被 TU 边界切断,LTO 把优化决策推迟到链接期、拿到全程序视角;ThinLTO 则是在全程序视角和并行构建之间做的工程折中。
下图画出两条编译流水线的区别。普通链路里每个源文件编译完就是定死的机器码,链接器没有能力回头改指令;LTO 链路里 .o 只是 IR 载体,真正的机器码在链接期全局优化之后才生成。
图中灰色是普通编译的封闭阶段,红色节点点出普通链接器的能力边界——它只能拼接,不能优化;蓝色是 LTO 新增的 IR 与全局分析阶段,绿色才是最终机器码的生成点。
这套链路在工程上靠链接器插件实现:编译阶段把中间表示(IR,Intermediate Representation)连同符号表一起写进目标文件,链接器通过 LTO 插件把这些 IR 重新喂回优化器,等全局分析完成后才调用后端生成机器码。全量 LTO 的做法是把所有 IR 合并成一个大模块串行分析,优化最彻底,但内存占用随 TU 数量线性膨胀;ThinLTO 则先为每个 IR 模块生成摘要索引,记录可导入的函数与调用关系,再让各个模块在摘要指导下并行导入和内联,只在必要时跨模块搬运代码,把全程序优化拆成了可并行、可增量、可缓存的任务。理解这条机制差异,后面看到「小项目两档几乎无差、大项目全量 LTO 链接拖垮 CI」的现象时就不会觉得矛盾。
二、基准设计:为什么必须是多翻译单元
LTO 的收益来自跨 TU 优化,如果整个程序只有一个 .cpp,编译器在普通编译期就已经看清全部代码,开不开 LTO 没有区别。因此这个基准刻意拆成 10 个 TU:
sumparts.cpp定义一个非inline的小函数mix_step,声明放在头文件、定义独占一个 TU;worker0.cpp到worker7.cpp由生成脚本产出,每个 TU 定义一个Worker派生类,热循环里调用 2000 次mix_step;main.cpp只持有基类指针,经虚接口间接调用,外层再套 2000 轮。
热循环的关键设计是各次调用之间不带循环携带依赖,每次输入只由循环下标和种子决定:
std::int64_t WorkerN::compute(std::int64_t seed) const {
std::int64_t acc = seed + N * 7919LL;
for (int r = 0; r < INNER_LOOPS; ++r) {
acc += mix_step(seed + static_cast<std::int64_t>(r), r + N);
}
return acc;
}无 LTO 时,这 3200 万次调用每次都是真实的跨 TU call/ret;LTO 把 mix_step 内联进调用点之后,循环体可以被展开和流水线化,差距因此被放大。三档构建输出的 checksum 完全相同,先保证优化没有改变计算结果,再谈性能。
基准设计里还有三个容易被忽略的考量。第一,mix_step 的函数体刻意写得很轻,只有乘法、异或和掩码几条整数指令——函数越轻,固定的调用开销(参数传递、call/ret、寄存器保存恢复)在总耗时里占比越高,内联收益才不会被算术本身淹没;早期版本把它写成带串行依赖链的重函数时,三档差距几乎测不出来。第二,循环里保留 acc += 的累积并在最后打印 checksum,是为了让计算结果有可观测的副作用,阻止编译器把整段热循环当成死代码删掉。第三,8 个 worker 用生成脚本产出而不是手写,既保证每个 TU 结构同构、结果可比,也让 TU 数量可以用一个参数调整,方便读者把规模扩到 32、64 个 TU 观察构建耗时的变化曲线。
三、三档实测数据
实验在本机用 Apple clang 21.0.0 完成,统一 -O2 -std=c++17 -Wall,每档干净重建 3 次取构建耗时中位、预热 1 次后连跑 7 次取运行耗时中位。三个维度的对照如下图。
这张三联图要分开读:左图运行耗时是本次实验的主结论,51.2 ms 对 19.2 ms 和 19.1 ms,两档 LTO 都把热循环压到原来的三分之一左右;中图构建耗时三档都在 760–810 ms 区间,10 个 TU 的小项目里差距落在测量噪声内;右图给出一个反直觉事实——LTO 二进制不但没变小,反而大了三成,原因是内联与循环展开把函数体复制进了多个调用点。
| 构建档位 | 构建耗时中位 | 二进制体积 | 运行耗时中位 | 相对无 LTO |
|---|---|---|---|---|
| 无 LTO | 809 ms | 11816 B(11.5 KiB) | 51.206 ms | 1.00× |
全量 LTO -flto | 770 ms | 15472 B(15.1 KiB) | 19.229 ms | 2.66× |
ThinLTO -flto=thin | 759 ms | 15544 B(15.2 KiB) | 19.072 ms | 2.68× |
完整原始输出如下,注意三档每个样本的 checksum 都是同一个值 -5567146740081486195,说明三套二进制语义等价;同机多轮复现的绝对值随整机负载在 45–51 ms、17–19 ms 区间浮动,但 2.7 倍左右的相对差距稳定出现。
终端里的原始记录与上面的表格一一对应:build_median_ms 是三次干净重建的中位值,size_bytes 是最终二进制字节数,runtime_median_ms 来自七次计时运行,预热那一次不计入。之所以全程采用中位值而不是平均值,是因为桌面系统上单次测量容易被调度器抢占、页缓存冷热和其他进程干扰,中位值对偶发尖峰不敏感;七次样本同时留在日志里,读者可以直接观察离散程度而不是只面对一个加工过的数字。这也是并发基准测量方法反复强调的口径:先给预热、再取多次样本、最后用稳健统计量下结论。

四、反汇编证据:优化发生在哪、没发生在哪
数据只说明「变快了」,反汇编才能回答「为什么变快」。对三档二进制分别做符号表与反汇编统计,得到下面的对照记录。
## 三档构建与符号/反汇编对照(Apple clang 21.0.0,-O2)
===== none =====
$ nm lto-none | grep mix_step
00000001000007e0 T __Z8mix_stepxi
$ otool -tvV lto-none | grep -c callq
47
$ otool -tvV lto-none | grep 'callq.*\*' | wc -l # 间接调用(虚调用)条数
16
===== full =====
$ nm lto-full | grep mix_step
(无 mix_step 外部符号:已被内化/内联)
$ otool -tvV lto-full | grep -c callq
31
$ otool -tvV lto-full | grep 'callq.*\*' | wc -l # 间接调用(虚调用)条数
16
===== thin =====
$ nm lto-thin | grep mix_step
(无 mix_step 外部符号:已被内化/内联)
$ otool -tvV lto-thin | grep -c callq
31
$ otool -tvV lto-thin | grep 'callq.*\*' | wc -l # 间接调用(虚调用)条数
16这份记录里有两个关键事实。第一,无 LTO 二进制保留着外部符号 __Z8mix_stepxi(T 表示代码段中的全局符号,后缀是名称修饰后的编码),全程序的 callq 指令共 47 条;两档 LTO 二进制里这个符号直接消失,callq 降到 31 条——少掉的 16 条正是被跨 TU 内联消化掉的直接调用。第二,三档二进制的间接调用都是 16 条,一条没少:main 里经基类指针发起的虚调用没有被去虚化。原因是对象由别的 TU 中的工厂函数创建、再装进基类指针向量,即使链接期拥有全程序视角,编译器也没有为这种间接形态建立起足够牢靠的类型证明。
这也修正了一个常见误解:LTO 不是「什么都能优化」的万能开关。它对内联边界明确的普通函数效果显著,对经指针/引用发起的多态调用则常常无能为力——后者要靠 final 关键字、PGO 剖面引导或手工改造调用形态,本系列后续的去虚化条件实测会专门展开。
五、编译器接入与常见坑
三档开关在主流工具链上的写法高度一致,但配套工具的细节有差异,接入前值得逐条核对:
| 事项 | Clang | GCC | 说明 |
|---|---|---|---|
| 全量 LTO | -flto | -flto(可加 =N 并行度) | 编译和链接阶段都要带同一 flag |
| ThinLTO | -flto=thin | 自 GCC 12 起部分支持 | GCC 上 ThinLTO 成熟度落后于 Clang |
| 静态库打包 | llvm-ar | gcc-ar + gcc-ranlib | 传统 ar 不带 LTO 插件,会丢弃 IR 索引 |
| 链接器 | lld 最佳,系统 ld 可用 | gold/lld/ld.bfd | 老版本 ld 需显式启用插件 |
| ThinLTO 缓存 | -Wl,--thinlto-cache-dir=... | 同左 | 命中缓存时增量链接可再快数倍 |
实践中最常见的坑有三个。一是编译和链接 flag 不一致:只在编译期加 -flto、链接期漏掉,产物会悄悄退回非 LTO 代码,排查时应当像本篇一样用 nm 核对关键符号是否还在,而不是只看构建日志有没有报错。二是混合链接兼容问题:LTO 目标文件里同时保留 IR 和机器码,可以与普通 .o 一起链接,但第三方预编译静态库若不含 IR,库内部的调用同样无法被跨边界内联,这部分代码不享受 LTO 收益。三是调试体验退化:内联会让调用栈出现大量 <inlined frame>、变量被优化成不存在,线上排障时应保留一份无 LTO 的 RelWithDebInfo 构建对照,而不是在 LTO 二进制上硬啃调试器。
六、工程选型建议
把实测结论翻译成可以直接执行的构建策略:
| 场景 | 建议档位 | 理由 |
|---|---|---|
| 发布版生产二进制 | ThinLTO | 拿到本例 2.68 倍的热路径收益,大型工程还保留并行与增量链接能力 |
| 本地日常开发迭代 | 无 LTO | 链接步骤最轻,改一行不用等全局分析;调试信息也最直观 |
| CI 夜间构建 / 固件镜像 | 全量 LTO | 不计较链接时间时榨干极限优化,配合 PGO 收益还能再叠加 |
| 对二进制体积极度敏感 | 无 LTO 或加 -Os | 内联展开会让 LTO 产物显著变大,闪存受限场景要实测权衡 |
| 调试疑难 bug | 无 LTO + -O0/-Og | 内联会打散调用栈与变量生命周期,先关 LTO 再排查 |
两点补充。其一,本例只有 10 个 TU,构建耗时差距被噪声淹没;在几百个 TU 的真实工程里,全量 LTO 的串行全局分析会让链接时间成倍上涨,ThinLTO 的并行优势才真正显现,这正是 Google、Meta 等大厂把 ThinLTO 作为默认发布配置的原因。其二,LTO 与 缓存友好性、并发基准测量方法正交——它改的是指令质量,不改数据布局;做性能实验时仍要按基准方法论做预热、多次取样和中位值统计,本篇的 run.sh 就是按这个口径写的。
实验环境
- 机器/系统:Mac mini(x86_64)/ macOS 26
- 工具版本:Apple clang 21.0.0(clang-2100.1.1.101),编译参数
-O2 -std=c++17 -Wall - 日期:2026-11-11
- 完整命令:
make gen生成 8 个 worker TU →bash run.sh三档各干净重建 3 次、预热 1 次后计时 7 次 →nm/otool -tvV采集符号与反汇编证据
代码获取
完整可复现工程(源码、Makefile、生成脚本、run.sh 与实测 results.txt)已公开:
- 仓库地址:https://github.com/derekzhuo/geek-doc.cn/tree/main/experiments/cpp-lto-thin-full-benchmark
- 复现方式:克隆后进入该目录执行
bash run.sh,约半分钟输出三档构建耗时、体积与运行耗时对照表;也可make bin/lto-thin单独构建 ThinLTO 版本。
这一篇在知识体系里的位置
本篇属于 C++ 性能实测系列的编译优化分组,向上承接 C++ 入门篇的编译流程基础,与低延迟编程模式、互斥量类型性能对照互为补充——那些篇讨论运行期的同步与调度,本篇讨论构建期的优化边界。同组后续会展开 PGO 两阶段剖面引导、虚函数去虚化条件与异常机制开销的对照实测。
总结
LTO 把优化视野从单个翻译单元扩展到整个程序:在这个跨 TU 调用密集的基准上,全量 LTO 与 ThinLTO 都把运行耗时从 51.2 ms 压到 19 ms 上下、约 2.7 倍提升,反汇编可见 16 条直接调用被内联消除,但经基类指针的虚调用两档都未能去虚化;10 个 TU 的小项目里构建耗时三档持平、LTO 产物体积反而增大约三成,工程上发布版默认选 ThinLTO、开发迭代关 LTO,是收益与构建成本最平衡的组合。