单个 .cpp 编译成 .o 时,编译器只能看到这一个文件里的代码;另一个 .cpp 里的 square 函数体,对它来说是不存在的。所以只能生成一条 call square,把优化留到运行期碰运气。-flto 和 PGO 就是来解决「编译器看不到全局」这个根本限制的两种武器。这篇把它们的机制、实测和代价讲清楚。
1. 引子:编译单元边界,让优化止步于 .o
C++ 的编译模型是「一个 .cpp 一个翻译单元(translation unit)」,各自独立编译成目标文件,最后链接。这个模型带来的直接后果是:
无 LTO 的传统编译
═══════════════════════════════════════════════════════════════
main.cpp ──▶ main.o (编译器只看得见 main.cpp,看不见 util.cpp)
util.cpp ──▶ util.o
└──▶ 链接器把两个 .o 拼起来 ──▶ 可执行文件
链接器不做事后优化,square 只能是一次 call
═══════════════════════════════════════════════════════════════
main.cpp 里调用了 util.cpp 定义的 square(x),但编译 main.cpp 时编译器根本不知道 square 的实现,无法把它内联进来。用 __attribute__((noinline)) 可以复现这种「看不到函数体」的效果:
#include <cstdio>
// noinline:强制不内联,模拟「函数体在另一个编译单元、编译器看不到」
__attribute__((noinline)) int add_one(int x) { return x + 1; }
int main() {
long r = 0;
constexpr int n = 1000;
for (int i = 0; i < n; ++i) {
r += add_one(i); // 每次迭代都要 call add_one
}
std::printf("r = %ld\n", r);
}
r = 500500
汇编上,call add_one 这条指令就是「跨 TU 无法内联」的具象。对比一下内联版(godbolt,gcc 13.2,-O2):
use_noinline(int): ← 看不到函数体,只能 call
.L6:
mov edi, edx
add edx, 1
call square_noinline(int) ← 每次迭代一次函数调用
add ecx, eax
cmp esi, edx
jne .L6
use_inline(int): ← 函数体可见,直接内联
.L11:
mov ecx, eax
imul ecx, eax ← 内联:乘法直接展开,无 call
add eax, 1
add edx, ecx
cmp edi, eax
jne .L11
call 的代价不只是跳转本身,还阻断了寄存器分配、常量传播等一系列后续优化。LTO 要做的,就是把这个「看不到」的边界在链接期拆掉。
官方文档:翻译阶段(translation phases)——第 8 阶段「每个翻译单元独立编译」,就是这条边界的由来。
2. LTO:把优化推迟到链接期
-flto 让编译器不直接产出机器码,而是把**中间表示(IR,GIMPLE)**存进 .o;链接时再把这些 IR 合并,做一次全局优化。于是跨 TU 的内联、常量传播、死代码消除都成为可能:
有 LTO 的编译
═══════════════════════════════════════════════════════════════
main.cpp ──▶ main.o(内含 GIMPLE IR,不是最终机器码)
util.cpp ──▶ util.o(内含 GIMPLE IR)
└──▶ 链接器 + LTO 插件:合并 IR,全局优化 ──▶ 可执行文件
square 被跨 TU 内联,call 消失
═══════════════════════════════════════════════════════════════
下面是实测:一个「main.cpp 调 util.cpp 的 square」的两文件工程,CMake 加 -flto,在 Compiler Explorer 的 CMake 端点上真构建:
# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(lto_demo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(demo main.cpp util.cpp)
target_compile_options(demo PRIVATE -flto) # 编译期产出 IR
target_link_options(demo PRIVATE -flto) # 链接期做全局优化
[ 33%] Building CXX object CMakeFiles/demo.dir/main.cpp.o
[ 66%] Building CXX object CMakeFiles/demo.dir/util.cpp.o
[100%] Linking CXX executable demo
===== 程序输出开始 =====
square(9) = 81
===== 程序输出结束 =====
[100%] Built target demo
构建成功、程序正确输出 square(9) = 81。这坐实了「LTO 至少能编能跑」。真正的收益在汇编里:无 LTO 时 square 是 call,有 LTO 时它被内联进 main,call 消失。
官方文档:GCC Optimize Options(-flto)——
-flto的实现与代价(编译更慢、内存更高、产物更难调试)。
官方文档:CMake INTERPROCEDURAL_OPTIMIZATION——CMake 里更省心的写法是
set_property(TARGET demo PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE),它帮你同时加编译期和链接期-flto。
3. PGO:先跑一遍,用真实热点重新编译
LTO 解决「看不到代码」,PGO 解决「不知道哪里热」。静态编译器只能猜哪个分支常走,而 PGO 用真实运行数据来告诉它。流程分三步:
PGO 三段式
═══════════════════════════════════════════════════════════════
① 插桩编译 -fprofile-generate (在代码里埋计数器)
② 跑代表性负载 ./app (生成 .gcda,记录分支/调用频率)
③ 用 profile 重新编译 -fprofile-use (按真实热点优化)
═══════════════════════════════════════════════════════════════
目标程序长这样(冷热分支分明,是 PGO 的典型优化对象):
#include <cstdio>
int main() {
long hot = 0;
long cold = 0;
constexpr int n = 100000;
for (int i = 0; i < n; ++i) {
if (i % 1000 == 0) {
++cold; // 冷路径:每 1000 次才一次
} else {
++hot; // 热路径:99.9% 都走这里
}
}
std::printf("hot = %ld, cold = %ld\n", hot, cold);
}
hot = 99900, cold = 100
对应的三步命令(用法示例):
# ① 插桩编译:程序里埋好计数器
g++ -O2 -fprofile-generate app.cpp -o app
# ② 用代表性数据跑一遍,生成 .gcda 文件(记录真实频率)
./app
# ③ 用收集到的 profile 重新编译:按真实热点做布局/内联/分支优化
g++ -O2 -fprofile-use app.cpp -o app
实测补充:PGO 的完整闭环这篇已经跑通——在线沙箱的 CMake 工程里,用
execute_process手动执行「插桩编译 → 运行 → 用.gcda重编译」三步。真实结果:第一步生成/app/build/app-pgo.gcda(180 字节的 profile 数据),第三步-fprofile-use编译没有任何-Wmissing-profile警告(说明 profile 确实被读到了),两阶段运行输出都是acc=3500。一个关键坑要记牢:gcda 文件名 =
<输出二进制名>-pgo.gcda,所以两阶段编译必须用同一个-o名。如果第一阶段-o app生成app-pgo.gcda、第二阶段却-o app2去找app2-pgo.gcda,就会报-Wmissing-profile(profile 没被用上),看似跑通了、实际白跑。
PGO 能做的优化:把热路径的函数内联进来、把冷分支挪到远离热路径的位置(改善指令缓存)、对间接调用做 devirtualization。它的前提是「跑的那一遍负载有代表性」——拿 A/B 测试流量训练出来的 profile,拿去优化 C 场景,可能反而更糟。
官方文档:GCC Instrumentation Options(-fprofile-*)——
-fprofile-generate/-fprofile-use的完整说明。
4. LTO 与 PGO 怎么配合,代价是什么
两者是正交的:LTO 决定「优化时能看见多少代码」,PGO 决定「按什么倾向优化」。配合使用(-flto -fprofile-generate → 跑 → -flto -fprofile-use)能拿到 1+1>2 的效果,但代价也叠加:
| 手段 | 解决什么 | 代价 | 适用 |
|---|---|---|---|
LTO(-flto) | 跨 TU 看不到函数体 | 编译慢、链接期内存高、增量构建变弱 | 发布构建、跨模块库 |
| PGO | 不知道真实热点 | 要维护 profile 数据、负载要代表性 | 长期稳定运行的服务 |
| 两者配合 | 全局 + 按热点 | 双重代价,构建流程最复杂 | 性能敏感的核心服务 |
关键结论:LTO 和 PGO 都不是「开了就一定更快」的免费午餐。它们改变的是编译器的「信息量」,收益取决于代码里到底有多少「跨 TU 的小函数调用」和「可预测的热点分支」。没有这些场景,开了只会让构建更慢。
5. 完整示例:一个同时受益于 LTO 和 PGO 的程序
#include <cstdio>
// 跨 TU 的小函数(真实工程里这个函数体在另一个 .cpp,LTO 才能内联它)
__attribute__((noinline)) int classify(int x) {
return (x % 1000 == 0) ? 1 : 0; // 冷热分支(PGO 会按真实频率优化)
}
int main() {
long hot = 0, cold = 0;
constexpr int n = 100000;
for (int i = 0; i < n; ++i) {
if (classify(i) == 1) ++cold; else ++hot;
}
std::printf("hot=%ld cold=%ld\n", hot, cold);
}
hot=99900 cold=100
这个程序同时踩中两个优化点:classify 是「跨 TU 的小函数」(LTO 想内联掉它的 call),里面的 x % 1000 == 0 是「99.9% 走假」的冷热分支(PGO 想把真分支挪到冷区)。生产环境里,main.cpp 和 util.cpp 分属两个翻译单元,只有 -flto 才能让 classify 跨进来,只有 PGO 才知道它几乎永远返回 0。
6. 延伸阅读
- GCC Optimize Options:
-flto、-fno-inline等优化的权威定义 - GCC Instrumentation Options:
-fprofile-generate/-fprofile-use的机制 - CMake INTERPROCEDURAL_OPTIMIZATION:CMake 里一条属性等价于
-flto - 翻译阶段:编译单元边界的标准定义
- as-if 规则:为什么跨 TU 内联、PGO 重排都是合法的
- Compiler Explorer:想验证 LTO 是否真的内联,加
-flto看汇编里call有没有消失
本知识库内的相关篇目:
- 《SIMD 与自动向量化:march=native、__restrict 与 likely》 —— 自动向量化(auto-vectorization)让编译器把标量循环翻译成 SIMD 指令
- 《基准测试的陷阱:优化掉、频率漂移、缓存预热》 —— 基准测试(benchmark)的三个经典陷阱
- 《C++ 缓存预热与 prefetch:什么时候该手写》 —— C++ 缓存预热与 prefetch,同体系里最该一起看的另一篇。
7. 一句话总结
LTO 用 -flto 把 IR 留到链接期做全局优化、拆掉编译单元边界(跨 TU 内联让 call 变成 imul,这篇已用 CMake 工程真构建验证);PGO 用 -fprofile-generate 插桩、跑代表性负载生成 .gcda、再 -fprofile-use 按真实热点重编(沙箱受限、这篇讲流程为主)。两者正交、可叠加,但都是「用构建时间换信息量」——没有跨 TU 小函数和可预测热点,开了也白开。
757



被折叠的 条评论
为什么被折叠?



