LTO(Link-Time Optimization,链接时优化)是在最终链接阶段让编译器重新看到多个翻译单元的中间表示,从而做跨文件内联、死代码删除、常量传播和去虚化等过程间优化的技术。
核心问题
普通 C/C++ 构建通常按文件编译:
a.c -> a.o
b.c -> b.o
a.o + b.o + libx.a -> app这个流程有一个信息断层:
- 编译
a.c时,编译器只完整知道a.c这个翻译单元,不知道b.c里函数的真实函数体。 - 链接时,传统链接器知道符号定义、引用和重定位,但通常只看到机器码和符号表,不知道高层控制流、类型关系和表达式结构。
因此,普通编译很难做这类优化:把 b.c 的小函数内联进 a.c、发现跨文件函数其实从未被调用、确认某个虚调用目标唯一、把跨文件常量传播到调用点。LTO 的核心思路是:编译阶段先保留足够高层的中间表示(IR),最终链接时再把这些信息交给优化器。
核心对象
| 对象 | 作用 |
|---|---|
| 翻译单元 | 预处理后的单个编译输入,是普通编译的分析边界。 |
| 中间表示(IR) | 编译器内部表示,例如 GCC 的 GIMPLE、LLVM bitcode,保留比机器码更适合优化的信息。 |
| 目标文件 | .o / .obj 文件;在 LTO 构建中可能同时包含机器码、符号表和 LTO IR。 |
| 链接器 / linker plugin | 解析符号和库,并把“哪些符号可见、哪些符号必须保留”的信息传给 LTO 优化器。 |
| 符号可见性 | 决定函数或变量是否可能被外部模块、动态库、dlsym、插件入口等观察到。 |
| preserved symbol set | 不能被优化删除或随意改名的符号集合,例如入口点、导出 API、被非 LTO 目标文件引用的符号。 |
核心机制
以 GCC/Clang 类工具链为例,LTO 通常分为五步:
- 编译每个翻译单元时启用 LTO,例如
-flto。编译器不只写最终机器码,还把 GIMPLE 或 LLVM bitcode 等中间表示(IR)写进目标文件。 - 如果目标文件被放入静态库,归档工具也要能保留和暴露 LTO 信息。GCC 场景下常见做法是使用
gcc-ar、gcc-ranlib、gcc-nm或启用 linker plugin。 - 最终链接时继续启用 LTO,例如
gcc -O2 -flto a.o b.o -o app。链接器先做符号解析,确定哪些定义被引用、哪些符号要导出、哪些库成员需要被抽取。 - LTO 优化器读取参与 LTO 的 IR,运行跨模块优化。典型优化包括跨文件内联、跨文件死代码删除、跨文件常量传播、函数克隆、去虚化和只读全局变量推断。
- 优化器生成新的本地目标文件或机器码,链接器继续做重定位、布局和最终可执行文件/动态库生成。
可以用一个最小图模型理解 LTO 的“删代码”能力:
G = (V, E)
R = {入口函数、导出符号、非 LTO 目标文件引用的符号、必须保留符号}
Live = reachability(G, R)
可删除集合 = V - Live这里 V 是函数和全局变量节点,E 是调用或引用边,R 是根集合。reachability(G, R) 表示从 R 出发沿调用/引用边能到达的节点。普通编译中,跨文件可见函数通常必须保守保留;LTO 通过链接器给出的符号可见性和引用信息,能更准确地判断某些节点其实不会被最终程序观察到。
跨文件内联也类似:如果 a.c 中调用 b.c 的 int add1(int x) { return x + 1; },普通编译只看到一个外部函数调用;LTO 能在链接阶段看到 add1 函数体,于是把调用替换成表达式 x + 1,随后继续做常量折叠、死分支删除或寄存器分配优化。
最小使用方式
gcc -O2 -flto -c a.c -o a.o
gcc -O2 -flto -c b.c -o b.o
gcc -O2 -flto a.o b.o -o app
clang -O2 -flto=thin -c a.c -o a.o
clang -O2 -flto=thin -c b.c -o b.o
clang -O2 -flto=thin a.o b.o -o app关键点是:编译阶段和最终链接阶段都要给出 LTO 相关选项。只在最终链接时加 -flto,但前面的对象没有 LTO IR,优化器就缺少可分析材料;只在编译阶段加、最终链接不用匹配工具链,也可能退化或失败。
工程用途
LTO 常用于 release 构建,而不是日常每次增量开发构建。它适合这些场景:
- 大量代码被拆进多个源文件、静态库或模板实例化单元,普通编译边界阻碍优化。
- 对二进制大小敏感,例如嵌入式固件、CLI 工具、游戏引擎发行包。
- 热路径函数跨库边界调用,性能剖析显示函数调用、分支或虚调用开销集中。
- 希望配合 PGO、隐藏符号导出表、
-ffunction-sections/--gc-sections等手段进一步优化性能或体积。
验证时不要只看“启用了选项”。应同时观察:
- 链接耗时和峰值内存是否可接受。
- 二进制大小是否变小,或是否因内联过多而变大。
perf、benchmark、启动时间、包体大小等真实指标是否改善。- 动态库导出符号、插件入口、C ABI 边界是否仍然符合预期。
边界与常见坑
- LTO 不是
-O3。-O3是优化级别,LTO 是让优化器跨文件获得更多信息;两者可以组合,但解决的问题不同。 - LTO 不是传统链接器自己“看懂机器码”后做高层优化。核心优化通常由编译器的 LTO 组件完成,链接器提供符号解析和可见性事实。
- LTO 对非 LTO 对象有限。没有 IR 的
.o、第三方库或系统库只能按普通二进制对象处理,最多向优化器提供引用边界。 - 符号可见性配置会直接影响优化空间。过多导出符号会迫使优化器保守;错误隐藏插件 API、
dlsym入口或跨动态库 ABI,会造成运行时找不到符号或行为变化。 - C/C++ 的 One Definition Rule 或跨翻译单元声明不一致问题,可能在 LTO 下更早暴露,也可能把原本偶然工作的未定义行为优化成崩溃。
- 调试体验可能变差。跨文件内联、函数合并和死代码删除会让栈帧、断点和变量位置更难对应源码。
- 工具链版本要一致。GCC LTO IR 不是稳定跨版本格式;混用不同 GCC 版本、不同 linker plugin 或不同
ar/nm工具时容易失败。