Post

深入二进制系列:C++ 全局优化技术简史

编译器的 -O2 / -O3 已经很努力了,但它有两个天生的盲区:看不远(优化卡在单个翻译单元的边界上)、看不到未来(不知道程序真正跑起来是什么样)。这篇文章把 PGO、LTO/ThinLTO、AutoFDO、LLVM-BOLT 串成一条线——它们其实都在干同一件事:一步步把优化器的视野放大,直到放大到「已经链接成型的二进制」本身。

最近给 blade 构建系统补三平台支持时,我把 gcc / clang / MSVC 三套工具链的 PGO、AutoFDO、覆盖率这些都接了一遍(见 《一台 MacBook Pro 上跑通 Linux/Windows 开发与测试》)。接的过程里发现,这些「全局优化」技术散落在各家文档里,名字一堆、缩写一堆,但背后的主线其实很清晰。这篇就把这条主线讲清楚。

引子:优化器的两个盲区

先把问题摆正。一个传统编译器的优化,受两条根本限制:

  1. 作用域限制(看不远):编译以翻译单元(TU,一个 .cpp + 它的头文件)为单位a.cpp 里的热点函数想内联进 b.cpp?做不到——它们是分开编译的,链接器只负责把目标文件拼起来,不做优化。跨文件的常量传播、去虚化、死代码消除,统统看不见。
  2. 信息限制(看不到未来):优化是静态的。哪个分支是热的?哪个 if 99% 走 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 效果很实在,热点明显的服务端程序常有两位数的提升。但它带来了三个新问题,恰好引出后面的技术:

  1. 插桩版慢一倍——计数本身有开销,没法拿去线上跑。
  2. 两遍构建很麻烦——插桩构建、跑负载、再优化构建,流程笨重;而且长跑/fork 型服务还得手工调 API(__llvm_profile_write_file / __gcov_dump / MSVC 的 PgoAutoSweep)把 profile 刷出来。
  3. 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 前后)就是冲着这个来的,思路很漂亮:

  1. 编译期:每个模块除了 bitcode,再额外生成一份摘要(summary)——函数有多大、调了谁、有哪些可导入的符号,不含函数体
  2. thin link 期:只把这些轻量摘要汇总起来,做全局决策——谁该被导入到谁那里去内联、跨模块的引用关系。这一步又快又省内存。
  3. 后端期:各模块并行优化,按上一步的决策把需要的函数体从别的模块按需导入进来内联。

于是 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 recordcreate_gcov 转成 .afdo
LLVM/Clang-fprofile-sample-use=<profile>perf record -bllvm-profgen(或 create_llvm_prof
MSVCSPGO:链接 /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(再优化最终布局)。

各家工具链横向对照:

技术GCCLLVM/ClangMSVC
插桩 PGO-fprofile-generate/use.gcda-fprofile-instr-generate/use、IR -fprofile-generate/use + llvm-profdata/LTCG:PGINSTRUMENTPGOPTIMIZE(POGO)
LTO-flto(WHOPR 并行)-flto(full)/ -flto=thinThinLTO/GL + /LTCG
采样 AutoFDO-fauto-profilecreate_gcov-fprofile-sample-usellvm-profgenSPGO /spgo/spdinxperf+SPDConvert,较新)
Post-link(PLO)可用 BOLTBOLT / Propeller无独立工具(能力内建于 LTCG/POGO)

结语

「全局优化」听起来玄,拆开看其实就一句话:编译器一开始只能看一个文件、只会静态猜;这几十年的技术,都是在想方设法让它看得更远、看得更真,直到有人嫌不够,干脆绕过编译器去改最终的二进制。 PGO 给它「水晶球」,LTO 给它「望远镜」,AutoFDO 让水晶球便宜到能天天用,BOLT 则在所有人收工之后,把成品再打磨一遍。

往前看,下一步是让机器自己学优化策略——LLVM 的 MLGO 已经在用机器学习模型做内联、寄存器分配的决策了。某种意义上,这又是「信息轴」的延伸:从「喂给编译器运行时数据」,到「喂给编译器一个会判断的模型」。

我这轮在 blade 里把这些一一接过来、在三平台上验过去,最大的感受是:这些技术文档分散、缩写吓人,但主线异常清晰。理解了「放大视野」这条线,再去看各家那一堆 -f... / /... 标志,就都对得上号了。

This post is licensed under CC BY 4.0 by the author.

© chen3feng. Some rights reserved.

Using the Chirpy theme for Jekyll.