LTOLink-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 通常分为五步:

  1. 编译每个翻译单元时启用 LTO,例如 -flto。编译器不只写最终机器码,还把 GIMPLE 或 LLVM bitcode 等中间表示(IR)写进目标文件
  2. 如果目标文件被放入静态库,归档工具也要能保留和暴露 LTO 信息。GCC 场景下常见做法是使用 gcc-argcc-ranlibgcc-nm 或启用 linker plugin。
  3. 最终链接时继续启用 LTO,例如 gcc -O2 -flto a.o b.o -o app链接器先做符号解析,确定哪些定义被引用、哪些符号要导出、哪些库成员需要被抽取。
  4. LTO 优化器读取参与 LTO 的 IR,运行跨模块优化。典型优化包括跨文件内联、跨文件死代码删除、跨文件常量传播、函数克隆、去虚化和只读全局变量推断。
  5. 优化器生成新的本地目标文件或机器码,链接器继续做重定位、布局和最终可执行文件/动态库生成。

可以用一个最小图模型理解 LTO 的“删代码”能力:

G = (V, E)
R = {入口函数、导出符号、非 LTO 目标文件引用的符号、必须保留符号}
Live = reachability(G, R)
可删除集合 = V - Live

这里 V 是函数和全局变量节点,E 是调用或引用边,R 是根集合。reachability(G, R) 表示从 R 出发沿调用/引用边能到达的节点。普通编译中,跨文件可见函数通常必须保守保留;LTO 通过链接器给出的符号可见性和引用信息,能更准确地判断某些节点其实不会被最终程序观察到。

跨文件内联也类似:如果 a.c 中调用 b.cint 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 工具时容易失败。

相关术语

参考