深入二进制系列:C++ 全局优化技术简史
编译器的
-O2/-O3已经很努力了,但它有两个天生的盲区:看不远(优化卡在单个翻译单元的边界上)、看不到未来(不知道程序真正跑起来是什么样)。这篇文章把 PGO、LTO/ThinLTO、AutoFDO、LLVM-BOLT 串成一条线——它们其实都在干同一件事:一步步把优化器的视野放大,直到放大到「已经链接成型的二进制」本身。
最近给 blade 构建系统补三平台支持时,我把 gcc / clang / MSVC 三套工具链的 PGO、AutoFDO、覆盖率这些都接了一遍(见 《一台 MacBook Pro 上跑通 Linux/Windows 开发与测试》)。接的过程里发现,这些「全局优化」技术散落在各家文档里,名字一堆、缩写一堆,但背后的主线其实很清晰。这篇就把这条主线讲清楚。
引子:优化器的两个盲区
先把问题摆正。一个传统编译器的优化,受两条根本限制:
- 作用域限制(看不远):编译以翻译单元(TU,一个
.cpp+ 它的头文件)为单位。a.cpp里的热点函数想内联进b.cpp?做不到——它们是分开编译的,链接器只负责把目标文件拼起来,不做优化。跨文件的常量传播、去虚化、死代码消除,统统看不见。 - 信息限制(看不到未来):优化是静态的。哪个分支是热的?哪个
if99% 走 else?哪个虚调用其实总是同一个实现?编译器只能靠启发式去猜。而经验告诉我们:程序 90% 的时间花在 10% 的代码上,可编译器并不知道是哪 10%。
接下来你会看到的所有技术,本质上都在消除这两个盲区中的一个或两个:
- LTO / ThinLTO 解决「看不远」——把作用域从一个 TU 放大到整个程序。
- PGO / AutoFDO 解决「看不到未来」——把真实运行时的数据喂回编译器。
- LLVM-BOLT 更狠——它说前面这些都还在 IR 层折腾,最终二进制的物理布局仍然没人管,于是它直接去改链接完成后的二进制。
下面按「提出问题 → 分析 → 解决,然后这个解法又带来新问题」的脉络一路走下来。
第 0 步:从「测量」和「人肉提示」说起
要喂运行时信息给编译器,得先能测量。这件事的祖师爷是 gprof(1982 年,伴随 BSD/GNU 工具链)。
但这里要先澄清一个常见误解:gprof 不是 PGO。它是给人看的性能分析器——用 -pg 在每个函数入口插一个 mcount 调用记录调用关系,再加定时采样统计耗时,最后生成一张「时间花在哪」的报告(gprof 命令解析 gmon.out)。它告诉你热点在哪,然后你去手工优化。它并不把数据喂回编译器。
不过 gprof 立下了一个范式:插桩 → 运行 → 收集 → 分析。后来的 PGO 几乎照搬了这套流程,只是把「给人看的报告」换成了「给编译器吃的 profile」。
与此同时,还有一种人肉版的「告诉编译器运行时信息」:
1
2
3
4
5
if (__builtin_expect(error_code != 0, 0)) { // 告诉编译器:这个分支几乎不发生
handle_error();
}
// C++20 起有了语言级写法:
if (x) [[likely]] { ... } else [[unlikely]] { ... }
__builtin_expect / [[likely]] 本质上是让程序员手工填补编译器的信息盲区——你知道哪个分支冷,就标给它。但这有两个问题:靠人猜也会猜错,而且只能标分支方向,标不了「这个虚调用 95% 是某个实现」「这个函数调了 10 亿次值得激进内联」这类更丰富的信息。
很自然地,下一个问题就是:能不能让机器自动、精确地把这些运行时信息收集好,喂给编译器? 这就是 PGO。
PGO:把运行时喂回编译器
问题:-O2 在猜分支概率、猜该不该内联、猜代码该怎么摆。猜错的代价是实打实的——冷代码挤在热路径里污染指令缓存、热分支被预测错、本该内联的没内联。
解法:Profile-Guided Optimization(按运行剖析引导优化)。先插桩编译一版,跑一遍有代表性的负载,收集每个分支/函数的实际执行次数,再用这份 profile 重新编译,让编译器拿着真实数据做决策。
它能换来的东西,正好对症下药:
- 基本块布局 / 冷热分离:热路径排成直线(fall-through,少跳转),冷路径(错误处理、慢分支)挪到一边,提升指令缓存和 iTLB 命中。
- 更准的内联:profile 说某个 callee 被调了上亿次,就值得激进内联;很少调的就别内联,省体积。
- 间接调用提升(去虚化):profile 发现某个虚调用 95% 落到同一个实现,就把它特化成直接调用 + 内联,再留个慢路径兜底。这是 PGO 最值钱的优化之一。
- 寄存器分配 / 分支预测提示:冷路径上才溢出寄存器,等等。
三家编译器的实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
# GCC:插桩 -> 跑 -> 用 profile 重编。profile 是 .gcda 文件(和 gcov 覆盖率共用一套机制)
gcc -O2 -fprofile-generate prog.cc -o prog && ./prog <代表性负载>
gcc -O2 -fprofile-use prog.cc -o prog # 自带 -fprofile-correction 容忍多线程计数偏差
# LLVM/Clang:有两套。前端 source-based(和覆盖率共用)和 IR-based。.profraw 要先 merge
clang -O2 -fprofile-instr-generate prog.cc -o prog && ./prog
llvm-profdata merge -o prog.profdata default*.profraw
clang -O2 -fprofile-instr-use=prog.profdata prog.cc -o prog
# MSVC:POGO,和 LTCG(全程序优化)绑在一起
cl /O2 /GL prog.cc /link /LTCG:PGINSTRUMENT
prog.exe <代表性负载> # 生成 .pgc,由 .pgd 汇总
cl /O2 /GL prog.cc /link /LTCG:PGOPTIMIZE
PGO 效果很实在,热点明显的服务端程序常有两位数的提升。但它带来了三个新问题,恰好引出后面的技术:
- 插桩版慢一倍——计数本身有开销,没法拿去线上跑。
- 两遍构建很麻烦——插桩构建、跑负载、再优化构建,流程笨重;而且长跑/fork 型服务还得手工调 API(
__llvm_profile_write_file/__gcov_dump/ MSVC 的PgoAutoSweep)把 profile 刷出来。 - profile 会过期——源码一改,插桩的计数就对不上了。
问题 1、3 后面交给 AutoFDO。但在那之前,先解决另一个正交的盲区——「看不远」。
LTO / ThinLTO:把整个程序喂给编译器
问题:回到第一个盲区。a.cpp 里一个小函数被 b.cpp 频繁调用,但因为分属两个 TU,编译 b.cpp 时根本看不到它的定义,内联无从谈起;跨模块的常量传播、去虚化、死函数消除也都做不了。链接器只是把编译好的机器码拼起来,不做优化。
解法:Link-Time Optimization。核心思路是把”真正的优化”推迟到链接时——那时整个程序的所有模块都到齐了。具体做法是:编译阶段不直接生成机器码,而是把中间表示(IR)塞进「目标文件」(GCC 是 GIMPLE 字节码,LLVM 是 bitcode),链接时再把这些 IR 交还给编译器后端,做全程序的内联、去虚化、死代码消除,最后才生成机器码。
1
2
3
gcc -O2 -flto ... # GCC,4.5 起
clang -O2 -flto ... # LLVM,full LTO
cl /O2 /GL ... /link /LTCG # MSVC 的 LTCG(Link-Time Code Generation)就是它的全程序优化
但 full LTO 自己又撞上了一堵墙:它把所有模块的 IR 合并成一个巨大的 module 再优化——于是内存爆炸、串行、慢。一个大型 C++ 程序(想想浏览器、数据库)做一次 full LTO 可能要几十 GB 内存、几十分钟,而且改一个文件就要重链整个世界,增量构建几乎失效。
ThinLTO(Teralingua……Teresa Johnson 等人,LLVM 3.9 前后)就是冲着这个来的,思路很漂亮:
- 编译期:每个模块除了 bitcode,再额外生成一份摘要(summary)——函数有多大、调了谁、有哪些可导入的符号,不含函数体。
- thin link 期:只把这些轻量摘要汇总起来,做全局决策——谁该被导入到谁那里去内联、跨模块的引用关系。这一步又快又省内存。
- 后端期:各模块并行优化,按上一步的决策把需要的函数体从别的模块按需导入进来内联。
于是 ThinLTO 拿到了接近 full LTO 的效果,却是并行、可增量、可缓存的——大型项目的实用之选。
1
clang -O2 -flto=thin ... # ThinLTO
GCC 这边对应的是 WHOPR(Whole-Program optimization 的分区并行模式),思路类似:先做轻量的全局分析(WPA),再分区并行做本地优化。MSVC 的
/LTCG内部也是整体来做。
LTO 和 PGO 是正交的、可叠加的:PGO 提供的「这个跨模块调用很热」恰好能指导 LTO「值不值得把它内联进来」。现代的极致构建,往往是 ThinLTO + PGO 一起上。
AutoFDO:免插桩,直接采生产
现在回头收拾 PGO 的痛点:插桩慢一倍、要专门构建、profile 易过期。
问题:能不能不插桩,直接拿线上正在跑的、普通编译的二进制来采集 profile?
解法:AutoFDO(Sample-based PGO,Google,2014 年前后)。不再插桩,而是用硬件性能计数器(perf + LBR——Last Branch Records,记录最近若干次分支的源/目的地址)去采样一个普通优化过的二进制,再把采样数据按调试行号信息映射回源码,转成编译器能吃的 profile。
它一举解决了 PGO 的几个痛点:
- 开销约 1%(采样而非插桩),可以直接在生产环境长期采集——profile 来自真实流量,比你构造的”代表性负载”更代表。
- 不需要专门的插桩构建——你发布的就是被采样的那个二进制。
- 对源码漂移更宽容——profile 按「函数 + 行号 + discriminator」键控,而非脆弱的精确计数,小改动不会让整份 profile 作废。
三家的支持情况:
| 编译器 | 标志 | 采集 / 转换工具 |
|---|---|---|
| GCC | -fauto-profile=<profile> | perf record → create_gcov 转成 .afdo |
| LLVM/Clang | -fprofile-sample-use=<profile> | perf record -b → llvm-profgen(或 create_llvm_prof) |
| MSVC | SPGO:链接 /spgo 采集、/spdin: 使用 | xperf 采样 → SPDConvert(VS 2022/2026,较新) |
MSVC 的 SPGO(Sample Profile Guided Optimization)是这几家里最晚补上的——很长一段时间 Windows 上只有插桩式 POGO。它用
xperf(ETW)采样,IP 采样在任意 CPU 上都行,LBR 则要较新的硬件(Intel Haswell+ / AMD Zen 4+ / ARM ARMv9.2-A+)。
AutoFDO 自然也有它的代价,正好对应它的「采样」本质:
- 依赖硬件。要
perf+ LBR,基本是 x86_64 Linux 裸机(或暴露了 PMU 的实例)的事;ARM 虚拟机往往拿不到 LBR,macOS 干脆没有perf。 - 精度略低于插桩——统计采样给的是估计,不是精确计数。
- 强依赖调试行表。采样到的是地址,要靠 debug line table 才能映射回源码——所以采集构建至少得带
-gmlt(最小行表);clang 还有个-fdebug-info-for-profiling专门增强行表里的「函数归属 + 内联穿透」信息,让映射更准(我在 blade 里接 AutoFDO 时就专门兜底了这一条:哪怕项目把调试信息关了,采集构建也要补上最小行表,否则采了个寂寞)。
进阶一笔:LLVM 还有 CSSPGO(Context-Sensitive Sample PGO),用 pseudo-probe 给采样带上调用上下文,让「同一个函数在不同调用点」的 profile 不被糊在一起,精度更高。
LLVM-BOLT:去优化「已经链接好的二进制」
到这里,PGO 给了运行时信息、LTO 放大了作用域、AutoFDO 让采集变廉价。看起来该榨的都榨干了?还没有。
问题:前面所有优化都在编译器内部、IR 层面进行。但最终二进制在物理上怎么摆——函数的先后顺序、基本块的排列、热代码和冷代码在地址空间里是否挨在一起——很大程度上是链接器按粗粒度规则定的,编译器在 IR 层根本「看不见」这个最终布局。对于数据中心里那种巨型二进制(几百 MB、上万个函数),指令缓存 / iTLB 的命中率成了瓶颈:CPU 大量时间耗在等指令、查页表上,而这恰恰是 IR 层优化够不着的地方。
解法:BOLT(Binary Optimization and Layout Tool,Facebook/Meta,2019 年 CGO 论文,现已并入 LLVM)。它是一个 post-link(链接后)优化器——直接拿最终的 ELF 二进制 + 一份 perf profile,重写这个二进制:
- 重排基本块,让最热的路径连成直线、减少跳转;
- 冷热彻底分离——把所有冷代码(错误处理等)搬到二进制的远端,让热代码在地址上紧凑排布,大幅提升 icache / iTLB 命中;
- 重排函数、优化跳转桩等。
它最特别的地方在于:工作在编译器看不到的层级(链接后的物理布局),用的还是和 AutoFDO 同一份 perf 采样数据;而且它和 PGO/LTO 叠加——在已经 PGO + ThinLTO 过的二进制上,BOLT 常常还能再榨出 5%~15% 的性能(大型服务端尤其明显)。甚至对拿不到源码、只有带符号二进制的情况它也能干活。
同路线的还有 Google 的 Propeller:同样是用 profile 做 post-link 布局优化,但走的是「重新链接」的路子(编译期开 basic-block sections,链接器按 profile 重排),相比 BOLT「重写整个二进制」更增量、更适合融进构建系统。两者代表了 PLO(Post-Link Optimization) 的两种实现哲学。
这些优化具体在做什么:从内联到 cache
前面按「技术」讲,这里换个切面,按「底层手法」讲——这堆技术翻来覆去其实在用同一组优化原语,只是各自喂的信息、作用的层级不同。
内联:一切的发动机
内联本身省的只是一次调用开销,但它真正的价值是打通优化边界:函数体展开到调用点之后,常量传播、死代码消除、去虚化、公共子表达式消除……全都能跨过原来那道调用墙继续做下去。所以内联是「使能优化」——它自己不省多少,却让后面一长串优化成为可能。
- 跨 TU 内联正是 LTO 的核心:没有 LTO,
a.cpp里的热点小函数对b.cpp就是一道不可逾越的墙(只能 call);有了 LTO/ThinLTO,它能被拉进来内联,墙就拆了。 - 「该内联谁」则是 PGO 的活:内联是把双刃剑(展开多了会膨胀、反噬 icache),profile 说某调用点上亿次就值得激进内联,很少走的就别内联省体积。「能不能内联」靠 LTO,「值不值得内联」靠 PGO。
去虚拟化:干掉间接跳转
呼应前面,两条路:
- 静态 / 思辨去虚化(LTO):全程序视角若能证明某虚调用的实际类型(比如某基类全程序只有一个子类、或类型可推断),就把 vtable 间接调用换成直接调用——进而可内联。
- 间接调用提升 ICP(PGO/AutoFDO):profile 看到某虚调用 / 函数指针 95% 落到同一实现,就生成
if (p == &HotImpl) HotImpl(); else (*p)();,热路径直接调用 + 内联,留个慢路径兜底。
为什么值得专门对付它:间接跳转既难预测(吃 BTB、容易 mispredict),又挡住内联(编译器不知道跳去哪,没法展开)。去虚化把这两个问题一起解了——这也是 PGO 最值钱的优化之一。
分支:编译器摆位,CPU 预测
先澄清一个常见混淆:分支预测是 CPU 在运行时干的(靠 BTB 和方向预测器);编译器能做的是摆好代码、给好提示,让预测器和取指单元舒服:
- 布局成 fall-through:把热的后继排成「不跳转」的落空路径,减少实际 taken 的分支,利于顺序取指。
- 分支提示:
__builtin_expect/ PGO 得到的方向,在部分 ISA 上落成提示位,更重要的是驱动上面的布局。 - if-conversion / cmov:对那种反复 mispredict 的近五五开分支,干脆编译成无分支的条件传送(
cmov),用「永远不跳」换掉「猜不准的跳」;而对很好预测的分支则保留分支形式(cmov 会强制两边都算)。到底选哪种——PGO 的命中率数据说了算。
冷热分离:让热代码挤在一起
把冷代码(错误处理、异常路径、unlikely 分支)从热函数体里抽出去,丢进单独的冷区段(如 .text.unlikely)。收益直接打在 cache 上:热函数体变小变密,icache 装得下更多热代码、iTLB 摸到的页更少;冷代码也不再污染热路径取指经过的那些 cache line 和内存页。冷热的判定来自 PGO/AutoFDO,而 BOLT 把这件事做到了整个二进制的尺度。
代码布局与 cache:全局优化真正的杀手锏
这就是你会特别关心的「cache 方面的优化」——而且它恰恰是全局优化最独特、收益最大的地方。道理很直白:-O2 优化单个函数时,根本不知道整个二进制最终怎么摆;而 icache / iTLB 命中本质上是个全二进制的物理布局问题,只有把视野放大到「整个程序 / 最终二进制」这个尺度才管得了。
具体手法(多数要 profile,且越靠链接后的工具做得越彻底):
- 函数重排(function reordering):把互相调用、或总是一起变热的函数在二进制里排到一起,让它们共享 cache line / 内存页,减少 icache 抖动和 iTLB miss。经典算法是 1990 年的 Pettis–Hansen(按调用频率给函数与基本块定序),至今仍是 profile-guided 代码布局的基石。
- 基本块重排(BB ordering):函数内部把热块连成直线、冷块踢走(就是上面的冷热分离)。
- iTLB 与大页:巨型二进制的代码横跨成千上万个页,iTLB 频繁失效。把热代码聚拢能压缩「热页」工作集;更激进的是把热代码段放上大页(huge page)(BOLT 支持),直接砍 iTLB miss。
- 谁做哪一层:编译器在 IR 层只能做到「函数内的块重排 + 冷热分区」;跨函数的全局布局得靠链接器的符号排序文件、LTO,或者最彻底的 BOLT / Propeller——它们在链接后 / 重链接时拿着 profile 把整个二进制的代码重新摆一遍。这也解释了为什么 BOLT 能在 PGO + LTO 之上再榨出可观收益:前面所有人都在 IR 层使劲,没人管最终的物理排布。
那数据 cache 呢? 上面这些全是指令侧(icache / iTLB / 分支)——这正是 PGO / LTO / BOLT 的主战场,因为指令的布局在 IR 和二进制层级看得见、profile 也抓得准。而数据 cache 的优化(结构体字段重排、冷热字段拆分、AoS↔SoA、软件预取)基本是另一个话题,更偏手工或专门的 IPA pass(GCC 有实验性的
-fipa-struct-reorg/-fipa-reorder-fields,但主流 PGO 工具链着力不多)。所以一句话收口:这些「全局优化」技术,绝大部分红利落在指令侧的 cache 与分支上,而不是数据 cache。
一条主线,两个轴
把这一路串起来,会发现所有这些技术都在沿着两个轴给优化器扩权:
- 作用域轴(看多远):单 TU → 整个程序(LTO/ThinLTO)→ 最终二进制(BOLT/Propeller)。
- 信息轴(看多真):静态启发式 → 人肉提示(
__builtin_expect)→ 插桩 profile(PGO)→ 生产采样(AutoFDO)。
而且它们层层可叠加。一个追求极致的现代构建,典型形态是:
ThinLTO(放大作用域)+ AutoFDO/PGO(喂真实数据)+ BOLT(再优化最终布局)。
各家工具链横向对照:
| 技术 | GCC | LLVM/Clang | MSVC |
|---|---|---|---|
| 插桩 PGO | -fprofile-generate/use(.gcda) | -fprofile-instr-generate/use、IR -fprofile-generate/use + llvm-profdata | /LTCG:PGINSTRUMENT→PGOPTIMIZE(POGO) |
| LTO | -flto(WHOPR 并行) | -flto(full)/ -flto=thin(ThinLTO) | /GL + /LTCG |
| 采样 AutoFDO | -fauto-profile(create_gcov) | -fprofile-sample-use(llvm-profgen) | SPGO /spgo、/spdin(xperf+SPDConvert,较新) |
| Post-link(PLO) | 可用 BOLT | BOLT / Propeller | 无独立工具(能力内建于 LTCG/POGO) |
结语
「全局优化」听起来玄,拆开看其实就一句话:编译器一开始只能看一个文件、只会静态猜;这几十年的技术,都是在想方设法让它看得更远、看得更真,直到有人嫌不够,干脆绕过编译器去改最终的二进制。 PGO 给它「水晶球」,LTO 给它「望远镜」,AutoFDO 让水晶球便宜到能天天用,BOLT 则在所有人收工之后,把成品再打磨一遍。
往前看,下一步是让机器自己学优化策略——LLVM 的 MLGO 已经在用机器学习模型做内联、寄存器分配的决策了。某种意义上,这又是「信息轴」的延伸:从「喂给编译器运行时数据」,到「喂给编译器一个会判断的模型」。
我这轮在 blade 里把这些一一接过来、在三平台上验过去,最大的感受是:这些技术文档分散、缩写吓人,但主线异常清晰。理解了「放大视野」这条线,再去看各家那一堆 -f... / /... 标志,就都对得上号了。