LTO与PGO优化
# 54.LTO与PGO优化
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. LTO(Link-Time Optimization)——把优化从编译期延伸到链接期
- 4. ThinLTO——可扩展的链接时优化
- 5. PGO(Profile-Guided Optimization)——根据真实数据优化
- 6. Bolt——后链接的二进制重排优化
- 7. 二进制瘦身实战——省代码也省 iCache
- 8. 常见陷阱与反模式
- 9. 综合案例串讲
# 1. 案例引入
# 1.1 LTO 开启后虚函数失效——编译器假设跨 TU 没有覆盖
某嵌入式框架在开启 LTO 后——原本正常工作的虚函数调用全部跳到了错误的位置:
// base.h
struct Base { virtual void execute() = 0; };
// module_a.cpp —— 一个完整的 .cpp 中定义了 Base 的唯一子类
struct Derived : Base {
void execute() override { do_work_a(); }
};
// main.cpp —— 另一个 .cpp 通过工厂函数获取 Base*
Base* create(); // 返回 new Derived——但 LTO 不知道这个事实
// LTO 分析 main.cpp 时:Base 在 module_a.cpp 中有子类
// → 但 main.cpp 中没有子类(没有用 Derived 的地方)
// → LTO 误判:Base 没有派生类!
// → 把 Base::execute 的虚调用 devirtualize 为直接调用——但跳到了错误的实现!
2
3
4
5
6
7
8
9
10
11
12
13
14
根因:LTO 在做全局分析时——如果在当前可见的依赖图中发现某个虚函数只有一个实现——会「devirtualize」——把虚调用改成直接调用(省掉 vtable 查找)。但如果这个派生类在 LTO 没有看到的地方被实例化(如通过工厂函数隐式创建)——直接调用跳到空实现或错误实现。
# 1.2 PGO 训练集不覆盖——冷路径获得了错误的热度权重
某 Web 服务器用 PGO 优化——训练时跑了基准测试——上线后 P99 延迟反而增加了:
void handle_request(Request& req) {
if (validate_auth(req)) { // ← 训练时覆盖率低——被标记为「冷」路径
process_secure(req); // ← 被移到 .text 末尾——iTLB miss 惩罚
} else {
process_public(req); // ← 训练时覆盖率较高——被标记为「热」路径
}
}
// 训练流量:大部分请求是公开 API——process_public 被频繁调用
// 生产流量:大部分请求需要鉴权——process_secure 是真正的热路径!
// PGO 把 process_secure 移到了代码段的尾部——每次调用 iTLB miss → +15ns
2
3
4
5
6
7
8
9
10
根因:PGO 优化基于训练数据——如果训练流量和生产流量不同——热路径判断会反。PGO 不是「自适应优化」——是「基于历史数据的固定优化」——历史错、优化就错。
# 1.3 七个待解疑问
① LTO 和普通编译有什么区别?为什么编译器在 TU 边界上被「截肢」? → 第 2 / 第 3 章
② LTO 怎么实现「跨 .cpp 内联」?链接器不是只处理符号吗? → 第 3.1 章
③ ThinLTO 和全量 LTO 有什么区别?为什么 ThinLTO 对大型项目更友好? → 第 4 章
④ PGO 是怎么工作的?插桩和采样两种方式有什么区别? → 第 5 章
⑤ Bolt 是什么?和 LTO/PGO 有什么区别?为什么可以叠加使用? → 第 6 章
⑥ LTO + ICF + gc-sections 怎么三级瘦身二进制?能省多少? → 第 7 章
⑦ LTO 有什么副作用?ODR 违规在 LTO 下会怎么样? → 第 8 章
2
3
4
5
6
7
# 2. 架构概览
# 2.1 为什么需要链接时优化
普通编译的困境:
a.cpp: void foo() { bar(); } ← 编译 a.o 时——编译器不知道 bar() 的实现
b.cpp: void bar() { baz(); } ← bar 的定义在这里——但编译器在分别编译
正常编译:a.o 对 bar() 的调用必须产生完整函数调用(call bar@PLT 或 call bar)
→ 即使 bar 是空函数、或者可以被内联——编译器也看不出来
→ 因为 bar 的定义在 b.cpp 中——而编译器一次只看一个 TU
LTO 编译:a.o 和 b.o 在链接时一起优化
→ 链接器看到 bar() 的定义 → 内联到 foo() 中
→ 再看 baz() → 也内联 → foo() = 完整的三个操作——一条 call 都没有
2
3
4
5
6
7
8
9
10
11
12
# 2.2 LTO vs ThinLTO vs PGO vs Bolt——四层优化金字塔
┌─────────────────────────────────────────────┐
│ Bolt — 后链接优化 (Post-Link Optimization) │ ← 对已有二进制做优化
│ 根据 profile 重排 .text 段函数布局 │ 不需要源码——只需要二进制 + perf 数据
├─────────────────────────────────────────────┤
│ PGO — 编译期 + 运行时双重优化 │ ← 根据真实数据选择优化策略
│ 插桩/采样 → profile → 优化编译决策 │ 需要源码 + 训练数据
├─────────────────────────────────────────────┤
│ ThinLTO — 轻量链接时优化 │ ← LTO 的可扩展版本
│ 模块间函数导入、并行优化 │ 需要源码 + 编译系统支持
├─────────────────────────────────────────────┤
│ LTO — 全量链接时优化 │ ← 最激进的跨 TU 优化
│ 全程序 IR 分析 + 内联 + 消除 │ 需要大量内存和链接时间
└─────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
# 3. LTO(Link-Time Optimization)——把优化从编译期延伸到链接期
# 3.1 LTO 的内部机制——编译器输出 IR 而非 .o 目标代码
普通编译:
.cpp → 编译器前端 (AST+优化) → .o (x86 binary + 符号表 + 重定位表)
.o 文件中是机器指令——链接器只能理解符号、不能理解指令语义
LTO 编译:
.cpp → 编译器前端 → 中间表示 (IR) → .o (IR + 符号表)
.o 文件中是编译器 IR(GIMPLE/LLVM IR)——不是机器指令!
链接器:收集所有 IR → 全局优化遍 → 代码生成 → 链接
GCC: -flto 生成 GIMPLE IR
Clang: -flto 生成 LLVM bitcode (.bc)
2
3
4
5
6
7
8
9
10
11
关键:LTO 下 .o 文件不是 x86 汇编——是编译器中间语言。链接器在合并所有 IR 后——做一遍完整的优化 pass——然后才生成机器代码。
# 3.2 LTO 如何发现跨 TU 的内联机会——两个 .cpp 中的函数被合并
// math_utils.cpp
int square(int x) { return x * x; } // 小函数——内联候选
// main.cpp
#include "math_utils.h" // 声明: int square(int);
int main() {
return square(42); // 普通编译:call square(至少 5 条指令)
} // LTO:内联为 return 42 * 42 → 编译期算出
// LTO 看到的:整个程序中 square() 只在 main.cpp 中调用一次
// → 内联 + 常量折叠 → 返回值在编译期确定 → ret 1764(只有 2 条指令)
2
3
4
5
6
7
8
9
10
11
收益量化:
| 场景 | 普通编译 | LTO | 收益 |
|---|---|---|---|
| 简单的 getter/setter 跨 .cpp | call + ret (5 指令) | inline (1 指令) | -80% 指令 |
| 模板的跨 TU 实例化 | N 份代码 | 1 份去重代码 | -30% .text |
| 虚函数的 devirtualize | vtable 间接调用 | 直接 call | -15% 延迟 |
# 3.3 LTO 的虚函数消除——如果整个程序只有一个派生类——虚调用可以被消除
案例 1.1 的失败是 LTO 的「过度优化」——但正确场景下 LTO 的 devirtualize 是合法的:
// 合法 devirtualize 的场景:
struct Base { virtual void f() = 0; };
struct Derived : Base { void f() override { ... } };
int main() {
Derived d;
Base* b = &d;
b->f(); // LTO 分析:整个程序中 b 指向 Derived ——只有这个子类
// → 把虚调用改成直接调 Derived::f ——省了 vtable 查找
}
2
3
4
5
6
7
8
9
10
# 3.4 LTO 的 const 传播——跨 TU 的常量折叠
// config.cpp
const int MAX_THREADS = 8; // const——值在编译期可知
// worker.cpp
extern const int MAX_THREADS;
int pool[MAX_THREADS]; // 普通编译:数组大小编译期不确定 → 在栈上动态分配
// LTO:看到 config.cpp 中 MAX_THREADS=8 → 编译期折× → pool[8]——静态分配
2
3
4
5
6
7
# 4. ThinLTO——可扩展的链接时优化
# 4.1 为什么需要 ThinLTO——全量 LTO 的内存和时间爆炸
全量 LTO 的问题:
所有 .o 的 IR 同时加载到内存 → 单线程运行全局优化遍
大型项目——1000 个 .cpp → 单个链接器进程消耗 20-60 GB 内存
→ 链接时间从 10s 变成 10min(60× 增长)
ThinLTO 的解决方案:
① 先跑一个轻量的「索引阶段」→ 分析调用图——但不做深度优化
② 把优化分散到多个「后端」并行进行——每个 .o 独立优化
③ 需要其他 TU 的函数时——只导入「函数摘要」——不是完整 IR
→ 内存降低 90% + 编译并行化 → 大型项目链接时间从 10min 降到 2min
2
3
4
5
6
7
8
9
10
# 4.2 ThinLTO 的索引阶段——生成全局调用图的摘要
索引阶段(Serial——单线程):
① 扫描所有 .o 的 IR——提取函数摘要(function summary)
摘要包含:函数名、参数、内联可能性、调用关系、全局变量引用
② 构建全局调用图——分析「谁调用谁」
③ 对每个函数——生成「跨模块导入列表」
如果一个函数在 main.o 中被调用、定义在 worker.o 中
→ 把 worker.o 的 IR 声明为「main.o 的后端可导入」
④ 索引阶段结束——输出一个「优化索引」——给后续并行阶段使用
2
3
4
5
6
7
8
9
10
11
# 4.3 并行后端的跨 TU 导入——每个 .o 独立优化、按需引入其他 TU 的函数
后端优化阶段(多线程并行):
每个 .o 被分派到一个后端线程——独立优化
main.o 的后端:
→ 读索引 → 发现 main() 调用了 worker::process()
→ 根据索引——worker::process() 在小函数列表(内联候选)
→ 从 worker.o 的 IR 中导入 process() 的函数体
→ 把 process() 内联到 main() 中
所有后端并行跑——每个后端只导入需要的摘要 IR——不加载完整 IR
2
3
4
5
6
7
8
9
10
# 4.4 LTO vs ThinLTO 的关键数据对比——编译时间、内存、性能收益
| 维度 | 无 LTO | LTO | ThinLTO |
|---|---|---|---|
| 链接内存 | ~2 GB | ~20 GB | ~4 GB |
| 链接时间(1000 .cpp) | ~1 min | ~12 min | ~3 min |
| 性能收益(SPEC CPU) | 基准 | +5~10% | +4~8% |
| 跨 TU 内联 | ❌ | ✅ 全量 | ✅ 按需 |
| 并行度 | 1 线程 | 1 线程 | N 线程 |
| 增量链接 | ✅ 快 | ❌ 全部重做 | ⚠️ 部分重做 |
# 5. PGO(Profile-Guided Optimization)——根据真实数据优化
# 5.1 插桩 PGO——编译时注入计数代码、运行时收集 profiles
三阶段流程:
阶段 1:编译(-fprofile-generate)
g++ -fprofile-generate app.cpp -o app_instrumented
编译器在生成的二进制中插入计数代码:
- 每个函数的入口计数器
- 每个分支的取/不取计数器
- 每个间接调用的目标计数器
阶段 2:训练(运行插桩二进制)
./app_instrumented < training_data.txt
运行结束后 → 生成 .gcda 文件(profiles)
包含:函数被调用了多少次、每个分支多少次/不取、虚函数实际目标
阶段 3:重编译(-fprofile-use)
g++ -fprofile-use app.cpp -o app_optimized
编译器读 .gcda → 根据实际数据做优化:
- 热路径:积极内联 + 代码布局靠前
- 冷路径:减少内联 + 代码布局靠后
- 分支预测:按实际频率排列
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 5.2 采样 PGO——Linux perf + AutoFDO 的无插桩方案
插桩 PGO 的问题:
插桩二进制运行慢 2-3×——训练时间比正常运刑长
不适合生产环境的实时采样
采样 PGO:
① 用 perf record 在生产环境采集硬件采样数据
perf record -b ./app → 生成 perf.data
(-b 收集分支预测数据——每个分支的取/不取)
② 用 create_llvm_prof 把 perf.data 转成 LLVM profile 格式
③ 重编译——Clang 用 -fprofile-sample-use 读采样数据
clang++ -fprofile-sample-use=app.prof app.cpp -o app_optimized
AutoFDO:Google 的开源工具——把 perf.data → GCC profile 格式
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 5.3 PGO 的关键优化——分支预测、代码布局、函数排序、内联决策
① 分支预测优化:
if (likely_condition) { ... } else { ... }
PGO 数据:likely=true 98%, likely=false 2%
→ 编译器生成预测 take 的代码路径——减少分支错误预测 -7%
② 函数排序:
把调用频繁的函数放在 .text 段的前部——同一 iTLB 页内
→ 减少 iTLB miss
③ 内联决策:
PGO 数据:hot_func() 被调 100 万次——只有一个调用点
→ 编译器积极内联——即使函数体比较大
④ 寄存器分配:
热路径上的变量——优先分配寄存器
冷路径上的变量——可以用栈(spill to stack)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 5.4 PGO 训练数据的质量——训练集不覆盖冷路径的灾难
案例 1.2 的完整推演:
PGO 优化的风险取决于训练数据和生产数据的相似度:
训练数据 = 生产数据 → 最优优化
训练数据 ≠ 生产数据 → 最差优化(比不优化还差)
关键原则:
① 训练数据必须覆盖:
- 正常流量(最常见路径)
- 峰值流量(边界触发)
- 冷路径(至少覆盖一次——避免完全未优化的冷路径)
② 训练数据必须来自:
- 真实生产环境(perf 采样)或
- 经过审查的基准测试(确保和生产流量相似)
③ 定期验证:
- PGO 优化后的二进制发布前——跑一次 A/B 测试
- 如果新二进制的延迟反而增加 → 训练数据有问题
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 6. Bolt——后链接的二进制重排优化
# 6.1 Bolt 的原理——采样 perf 数据 → 重排 .text 段中的函数布局
Bolt (Binary Optimization and Layout Tool):
① 输入:已有的二进制(不需要重新编译!)
② perf 采样:收集生产环境的函数级/基本块级热度数据
③ 分析:识别热点函数和热点基本块
④ 输出:新的二进制——.text 段被重排——热点集中、冷点移开
不需要源码、不需要重新编译——适用于第三方库和已经部署的二进制。
2
3
4
5
6
7
# 6.2 函数热点重排——把热的函数放在一起减少 iTLB 和 iCache miss
没有 Bolt——函数布局遵循链接器默认(.o 出现的顺序):
.text: [cold1][hot1][cold2][hot2][cold3][hot3] ...
运行时:hot1→hot2→hot3(每跳一次 iTLB miss—可能不在同页)
Bolt 之后——热点函数被重新排列到一起:
.text: [hot1][hot2][hot3] ... [cold1][cold2][cold3]
运行时:hot1→hot2→hot3(都在同一 iTLB 页——零 miss)
2
3
4
5
6
7
# 6.3 基本块重排——把热基本块集中、冷基本块移到末尾
// 函数内部——热路径和冷路径被混合
void process(Request& r) {
// 热基本块
auto data = parse(r); // 一直执行——热
if (r.needs_validation()) { // 大部分是 false——冷基本块
validate(data); // ← 冷——穿插在中间——占 iCache
}
// 热基本块续
transform(data); // 热——继续
}
2
3
4
5
6
7
8
9
10
Bolt 优化后:validate 基本块被移到函数末尾——热路径 parse→transform 在缓存中连续。
# 6.4 LTO + PGO + Bolt 的组合使用——三者的优化是非重叠的
LTO:跨 TU 的代码消除——内联远处函数、消除虚调用、常量传播
PGO:根据真实数据的编译期决策——分支预测、内联积极性、代码布局
Bolt:根据运行数据的链接后期重排——函数/基本块重排——iTLB/iCache 优化
三者的优化维度独立——可以叠加使用:
g++ -flto=thin -fprofile-use ... -o app_pgo
perf record -b ./app_pgo
llvm-bolt app_pgo -o app_bolt -data=perf.fdata
收益叠加:LTO +5%, PGO +7%, Bolt +5% → 总额 +17%(部分叠加可能重叠)
2
3
4
5
6
7
8
9
10
# 7. 二进制瘦身实战——省代码也省 iCache
# 7.1 -Os vs -Oz——为体积优化到什么程度
-Os:体积优化——拒绝会增加代码体积的优化(如激进内联)
-Oz:更激进的体积优化——Clang 专属——比 -Os 更激进
对比:
-O2: 2.4 MB
-Os: 1.8 MB (-25%)
-Os + LTO: 1.5 MB (-37%)
-Oz: 1.4 MB (-42%)
2
3
4
5
6
7
8
# 7.2 --icf=safe——合并完全相同的函数体
ICF (Identical Code Folding) = 把二进制中相同内容的函数合并为一个
int max_int(int a, int b) { return a > b ? a : b; }
int max_long(long a, long b) { return a > b ? a : b; }
// 这两个函数编译后的机器码完全相同——同一个算法、不同参数类型
// → --icf=safe 把它们合并为一个函数——两个入口点跳到同一段代码
// 风险:如果代码依赖函数地址的唯一性(如比较函数指针来判断类型)→ 出问题
2
3
4
5
6
7
# 7.3 --gc-sections + -ffunction-sections——前文链接的再审视
第 50 篇已有详细展开——这里在 LTO 语境下补充:
gc-sections 和 LTO 的组合效果:
gc-sections:删除整个不可达函数的代码(function-level)
LTO:删除被内联后无剩余调用的函数(因为所有调用点都已内联)
→ LTO + gc-sections 可以让「内联后无用」的函数也被消除——比单独的 gc-sections 更好
2
3
4
# 7.4 实战效果——LTO + ICF + gc-sections 三级瘦身
# 基准——普通编译
$ size app_normal
text data bss dec hex
824560 9876 3456 837892 cc904
# 一级:-ffunction-sections + --gc-sections
$ size app_gc
text data bss dec hex
741230 9812 3456 754498 b8342 (-10%)
# 二级:+ -Os + --icf=safe
$ size app_os_icf
text data bss dec hex
652890 9780 3456 666126 a2a0e (-12% vs gc)
# 三级:+ LTO
$ size app_lto
text data bss dec hex
561234 9740 3456 574430 8c35e (-14% vs os_icf, -32% vs base)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 8. 常见陷阱与反模式
# 8.1 LTO + ODR 违规——LTO 可能让隐藏的 ODR 违规浮出水面
第 51 篇的 ODR 违规在 LTO 下变得更危险:
正常编译:两个 .cpp 中的不同 inline 函数——链接器随机选一(弱符号规则)
LTO:链接器看到两份不同的 IR → 发现语义不一致 → 可能报错、可能内联错误的版本
→ LTO 把「运行时 UB」升级为「编译期确定的错误版本」
→ 原来随机选一个的弱符号——LTO 可能内联了错误的版本——错误更确定
2
3
4
# 8.2 PGO 训练集不覆盖的场景——冷路径的性能退化
已在第 5.4 详细展开——核心结论:PGO 不是 magic——训练数据错 = 优化方向错。
# 8.3 LTO 与调试信息——-g 与 -flto 的组合限制
# LTO 下的调试信息
$ g++ -g -flto main.cpp -o app
# 问题:GCC ≤ 12 的 LTO + debug 有时导致 DWARF 信息不完整
# → GDB 在 LTO 编译的二进制上设置断点可能不稳定
# GCC ≥ 13 + -g -flto 已有改进——建议用较新版本
2
3
4
5
6
# 8.4 链接时间爆炸——全量 LTO 对大型项目的编译时间影响
小型项目(< 50 .cpp):LTO 链接时间 +50%——可接受
中型项目(50-500 .cpp):LTO 链接时间 +200%——用 ThinLTO
大型项目(> 500 .cpp):LTO 不可行——必须 ThinLTO
经验法则:全量 LTO 不适合 > 100 个 .cpp 的项目
2
3
4
5
# 9. 综合案例串讲
# 9.1 案例真相揭晓
| # | 疑问 | 答案 |
|---|---|---|
| ① | LTO 和普通编译区别? | 第 2.1/3.1:LTO 在链接时做全局优化——跨越 TU 边界 |
| ② | 跨 TU 内联原理? | 第 3.2:.o 存 IR→链接器看到定义→内联——包括虚函数的 devirtualize |
| ③ | ThinLTO vs LTO? | 第 4 章:ThinLTO 并行化 + 内存 -90%——收益接近 LTO |
| ④ | PGO 插桩 vs 采样? | 第 5 章:插桩需要专门训练、采样可以用 perf 生产数据 |
| ⑤ | Bolt 是什么? | 第 6 章:不需要重编译——对已有二进制做函数/基本块重排 |
| ⑥ | 三级瘦身? | 第 7 章:gc-sections + ICF + LTO——三层叠加 -32% text |
| ⑦ | LTO 副作用? | 第 8 章:ODR 违规显形、调试信息不完整、链接时间爆炸 |
案例①修复——LTO devirtualize 误判:检查是否所有派生类都在 LTO 可见范围内——或加 -fno-devirtualize 关闭此优化。
案例②修复——PGO 训练数据不匹配:用生产环境的 perf 采样数据替代基准测试数据——确保训练流量 = 生产流量。
# 9.2 优化金字塔的实战序列——从源码到最优二进制的完整路径
阶段 1:系统级优化(不依赖 profile)
-flto=thin -ffunction-sections -fdata-sections
-Wl,--gc-sections -Wl,--icf=safe
-O2
→ 基准二进制——10~15% 性能提升 + 30~35% 体积缩减
阶段 2:PGO(需要训练数据)
-fprofile-generate → 运行训练 → -fprofile-use
→ 追加 5~8% 性能提升
阶段 3:Bolt(需要生产 perf 数据)
perf record → llvm-bolt
→ 追加 3~5% 性能提升(iTLB/iCache 优化——非重叠于 PGO)
最终:-flto=thin + -fprofile-use + gc-sections + icf + Bolt
总预期性能提升:15~25%
总体积缩减:30~40%
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 9.3 设计哲学回扣
哲学 1:LTO 击穿了编译单元这个封闭边界——把「整个程序」作为优化对象
编译器在传统编译模型中的根本困境:它只能在一个 .cpp 的窗口内优化——看不到函数调用者的全貌。LTO 打破了 TU 的围墙——把优化器的视野从「一个 .cpp 的 200 行」扩展到「整个程序的 20000 行」。 这就像给编译器戴上了望远镜——之前只能看到函数内部的代码——现在能扫描整个程序。
哲学 2:PGO 是「优化器的训练数据」——优化决策从静态启发式变为数据驱动
传统编译器的内联决策基于静态启发式——函数体大小、调用深度。PGO 用实际数据替代这些猜测——把「编译器认为这是热函数」变成「真实世界 100 万次调用证明这是热函数」。 这和机器学习一样——静态分析 = 先验知识、profile = 后验知识、PGO = 两相结合。
哲学 3:Bolt 反转了编译优化的传统顺序——不是从源码优化、是从二进制优化
传统优化 = 源码 → 编译器 → 二进制。Bolt = 二进制 + profile → 更好的二进制。这种「后链接优化」的哲学是:你已经有了完整二进制和运行时数据——单是重新组织代码的物理布局就能再获 5% 性能。 这和磁盘碎片整理同构——不在逻辑层改变代码——只在物理层重新排列。
哲学 4:优化是非叠加的——但 LTO + PGO + Bolt 在三个不同的层工作——可以叠加
LTO 优化逻辑结构(跨 TU 内联、虚消除)——PGO 优化编译决策(分支预测、内联决策)——Bolt 优化物理布局(函数重排、基本块重排)。三层在三个独立的维度上工作——互不冲突——这是为什么可以叠加使用。 但叠加收益不是加法——LTO 内联后 Bolt 可能少了一些函数重排的机会——实际叠加收益 ~15-25%。
# 9.4 速查表合集
四层优化金字塔:
| 优化层 | 输入 | 阶段 | 收益(典型) | 代价 |
|---|---|---|---|---|
| LTO | 源码 + IR .o | 编译+链接 | +5~10% | 链接时间 +200% |
| ThinLTO | 源码 + IR .o | 编译+链接 | +4~8% | 链接时间 +30% |
| PGO | 源码 + profile | 编译+训练+重编 | +5~8% | 训练周期 |
| Bolt | 二进制 + perf | 后链接 | +3~5% | perf 采样 |
CMake LTO 配置:
# 二进制体积优化全开
set(CMAKE_CXX_FLAGS_RELEASE "-O2 -flto=thin")
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION TRUE) # CMake 3.9+ LTO 捷径
add_link_options(-Wl,--gc-sections)
add_link_options(-Wl,--icf=safe)
add_compile_options(-ffunction-sections -fdata-sections)
2
3
4
5
6
7
PGO 三阶段:
# 阶段 1:编译插桩
g++ -fprofile-generate app.cpp -o app
# 阶段 2:训练
./app < training.txt
# 阶段 3:PGO 重编译
g++ -fprofile-use app.cpp -o app_pgo
2
3
4
5
6
7
8
本篇小结:LTO 把编译优化从 TU 级扩展到全程序级——跨 .cpp 看到函数定义——实现内联、虚消除、常量传播。ThinLTO 用索引+并行后端把 LTO 的内存和链接时间从 20GB/12min 降到 4GB/3min。PGO 用实际数据指导编译决策——让热路径获得最优代码布局。Bolt 不需要重编译——对已有二进制做函数/基本块重排——消除 iTLB/iCache miss。三层叠加可获得 15-25% 性能提升——且优化维度互不重叠。
下一篇:七卷编译链接全部完成。下一篇进入最后一卷——卷八「现代特性与设计哲学」——55.C++17核心特性回顾,从结构化绑定到 if constexpr,从 fold expressions 到 string_view——把本系列前三卷的现代特性做系统性回扣。