「配置文件引导优化 PGO 与 BOLT」

编译器在看不到运行数据时只能靠启发式猜测热点,PGO 用真实执行画像把猜测换成证据。本文讲解采样与插桩两种画像收集方式、热点识别与权重传播、基于画像的内联与代码布局决策,以及 BOLT 这类二进制后链接优化器如何在无源码条件下重排函数。

1. 为什么需要配置文件引导优化

一句话总结: 静态编译器只能靠启发式猜测哪条分支常走、哪个函数是热点,PGO 用程序真实运行时的执行计数把猜测替换成证据。

编译器做优化时面临一个根本的信息缺口:它在编译期看不到程序将来会怎么跑。给定一段代码:

if (unlikely_error_code != 0) {
    handle_error();          // 冷路径,几乎不执行
} else {
    fast_path();             // 热路径,占 99.9% 的时间
}

编译器无从知道 handle_error 到底冷还是热。它只能依据静态启发式:函数名里带 error、handle 这类词的降低优先级;带 hot、fast 的升高优先级;assert、abort 之后的代码判定为不可达;__builtin_expect 显式提示。这些规则在很多场景下有效,但它们本质上是猜测。

猜测的代价是真实存在的。冷热判断错误会导致:

决策猜对时猜错时的代价
内联消除调用开销,暴露优化机会代码膨胀,指令缓存抖动
基本块布局热路径连续,取指友好热路径跨页跳转,iTLB 压力大
寄存器分配热点变量留在寄存器热路径频繁溢出到栈
分支提示静态预测命中前端预测失败,流水线冲刷

配置文件引导优化(Profile-Guided Optimization,PGO)的思路非常直接:先让程序跑一遍有代表性的负载,收集执行画像(profile),再拿这份画像去指导第二次编译。画像里最核心的信息是边计数(edge count)与基本块计数(block count),它们告诉编译器每个基本块执行了多少次、每条分支各走了多少次。

# 一个最小可跑的 PGO 流程(GCC/Clang 通用思路)
# 第一遍:插桩编译,生成带计数器的二进制
cc -O2 -fprofile-generate -o app_instr app.c

# 第二遍:用真实负载跑,计数器把数据写进 .gcda/.profraw
./app_instr --benchmark-suite realistic_workload

# 第三遍:读画像重新编译,优化器按真实热度决策
cc -O2 -fprofile-use -fprofile-correction -o app_opt app.c

PGO 的收益通常落在 5% 到 20% 之间,个别分支密集型的程序(解释器、数据库查询引擎、序列化库)能超过 30%。这个数字看起来不惊人,但它的成本几乎为零——不需要改代码,不需要改算法,只是把编译过程从一遍变成三遍。

1.1 静态启发式的局限

一句话总结: 静态启发式依赖模式匹配与命名约定,一旦代码风格偏离约定或分支概率偏离常见分布,判断就会系统性失真。

静态启发式的失效有几类典型模式。第一类是命名误导:一个叫 handle_request 的函数在网关里是绝对热点,但启发式可能因为 handle 前缀把它当作错误处理路径。第二类是概率分布反转:很多校验代码写成了「先检查罕见错误,再走正常路径」的形式,启发式默认 if 的真分支比假分支更可能,于是把罕见错误路径排在了前面。第三类是循环权重缺失:静态分析只能估计循环大致会转几次,而实际上一个 for 循环可能迭代 3 次,也可能迭代 300 万次,两者的布局策略完全不同。

; 带 PGO 权重的 LLVM IR 片段(示意)
; 有画像时,br 指令会带上 !prof 元数据,把真实概率写进 IR
define i32 @dispatch(i32 %code) {
entry:
  %cmp = icmp eq i32 %code, 0
  ; branch_weights 里 999 对 1,表示真分支占 99.9%
  br i1 %cmp, label %fast, label %slow, !prof !0
fast:
  ret i32 42
slow:
  ret i32 -1
}
!0 = !{!"branch_weights", i32 999, i32 1}

有了 !prof 元数据,后面的所有 pass 都能读到真实概率:内联器知道 slow 基本块可以不计入成本模型,布局器知道 fast 应该紧跟 entry,寄存器分配器知道 fast 里的变量更值得留在寄存器。这就是 PGO 的核心价值——把一条全局性的信息注入到所有局部决策中。

2. 采样与插桩两种画像收集方式

一句话总结: 插桩用编译器插入的计数器获得精确边计数,采样用周期性中断与栈回溯获得统计近似,前者准但慢,后者快但有噪声。

收集画像有两条技术路线,它们在精度、开销、可部署性上各有取舍。

2.1 插桩 PGO

一句话总结: 插桩 PGO 由编译器在每个基本块与边上插入计数器,运行时累加,退出时落盘,精度最高但运行时开销可达 30% 到 100%。

插桩(instrumentation)PGO 是最经典的做法。编译器在生成代码时,在每个基本块入口与每条边上插入一个自增指令,函数返回或程序退出时把这些计数器写到磁盘上的画像文件。它的优点是精确:得到的是真实执行次数,不是抽样估计。缺点是开销大:每个基本块多一条内存自增,热点循环里的开销可能让程序慢 30% 到 100%。

插桩还有一个隐蔽的陷阱:计数器本身会改变性能特征。加了计数器之后,程序的瓶颈可能从计算转移到计数器的缓存行争用,导致热点判定偏离真实情况。现代实现用无锁的分片计数器缓解这个问题,但无法完全消除。

// 插桩的等价示意:编译器自动插入的计数器
static unsigned long __counters[4096];
static unsigned __cid = 0;

int compute(int x) {
    __counters[__cid++]++;      // 基本块入口计数
    if (x > 0) {
        __counters[__cid++]++;  // 分支边计数
        return x * 2;
    }
    return -x;
}

// 程序退出时注册的 atexit 钩子把计数器写盘
__attribute__((destructor))
static void dump_profile(void) {
    FILE *f = fopen("default.profraw", "ab");
    fwrite(__counters, sizeof(unsigned long), __cid, f);
    fclose(f);
}

2.2 采样 PGO

一句话总结: 采样 PGO 用操作系统的周期性定时中断抓取指令指针与调用栈,靠统计规律反推热度,开销低到可以开在生产环境。

采样(sampling)PGO 走的是另一条路。它借助 perf_event_open 之类的接口,让内核以固定频率(典型是 1kHz)给进程发信号,每次信号到来时记录当前指令指针与调用栈。跑得越久,采样点越多,统计上越接近真实执行时间的分布。

采样画像的最大优势是低开销:典型情况下 1% 到 3%,可以直接开在线上灰度集群里,收集真实流量而不是人造基准。它的代价是噪声:执行时间短的函数可能一个采样点都拿不到,尾调用与内联函数的归属需要靠调试信息反解。

# 用 Linux perf 采集采样画像
perf record -F 999 -g --call-graph dwarf -o perf.data ./app --workload real
perf report --stdio --sort symbol --percent-limit 0.5 | head -30

# 转换成 LLVM 能消费的画像(BOLT 也吃这个格式)
llvm-profdata merge -o perf.profdata perf.data

# Clang 直接用采样画像做 PGO
clang -O2 -fprofile-use=perf.profdata -o app_opt app.c
维度插桩 PGO采样 PGO
计数精度精确统计估计
运行时开销30% 到 100%1% 到 3%
部署位置测试环境可上生产
覆盖率未执行路径权重为 0未采样路径权重为 0
画像体积较大中等
冷路径识别明确(计数为 0)不可靠(可能是采样太少)
典型工具-fprofile-generateperf、-fprofile-sample-use

实践中常见的组合是:测试环境用插桩拿精确画像,生产环境用采样拿真实流量画像,两者可以合并——把采样画像与插桩画像用 llvm-profdata merge 加权融合,让训练集覆盖得更广。

3. 热点识别与权重传播

一句话总结: 原始计数只是入口,编译器要把函数级、基本块级、边级的计数沿调用图与控制流图传播,才能得到每个决策点可用的相对热度。

画像收集到的原始数据是稀疏的:可能只有一部分函数被插桩,可能采样只覆盖了部分栈帧。要让它可用,需要做权重传播(weight propagation)。

传播的基本规则有三条:

  1. 调用图传播:如果调用者 A 执行了 1000 次,其中 800 次调用了 B,那么 B 的入口计数至少贡献 800。当 B 被内联进 A 时,这段计数要归到内联后的副本上,而不是原函数。
  2. 控制流传播:基本块计数满足流量守恒——进入一个块的次数等于离开它的次数。如果某个块的入边计数之和与出边计数之和不一致(画像来自不同运行、或部分路径没被覆盖),需要用最小二乘之类的办法做一致性修复。
  3. 相对化:绝对计数没有意义,有意义的是相对于程序总执行量的比例。一个函数执行了 10 万次,如果整个程序执行了 10 亿条指令,它是热点;如果程序执行了 10 万亿条,它就不值一提。
# 权重一致性修复的简化模型:给定块的入口计数,
# 用最小二乘调整各出边权重,使流量守恒
import numpy as np

def fix_flow(block_in, edges, edge_w):
    # edges[i] = (src, dst),edge_w 为画像给出的边权重
    # 目标:调整 edge_w,使得每个块的 sum(入边) == sum(出边)
    A = np.zeros((len(block_in), len(edges)))
    for i, (s, d) in enumerate(edges):
        A[s, i] = -1.0     # 出边贡献负
        A[d, i] = 1.0      # 入边贡献正
    b = np.array(block_in, dtype=float)
    w, *_ = np.linalg.lstsq(A, b, rcond=None)   # 求解 min ||A w - b||
    return np.maximum(w, 0.0)                   # 权重不得为负

传播之后,编译器手里就有一张带权控制流图。后续所有优化都在这张图上做局部决策,但决策的影响是全局的:一个基本块的权重变化会通过内联、循环展开、布局等变换传导到很远的地方。

4. 基于画像的核心优化

一句话总结: 画像的用途集中在三处:决定内联什么、决定分支怎么摆、决定代码怎么排,这三者共同决定了指令缓存与分支预测的效率。

4.1 函数布局与基本块重排

一句话总结: 把频繁一起执行的函数放在相邻的地址上、把热路径基本块排成顺序执行,可以显著降低指令缓存缺失与无条件跳转数量。

代码布局是 PGO 收益最大的单项。CPU 的取指单元按行(典型 64 字节)抓取指令,如果热路径跨越多个不连续的页面,iTLB 与 L1i 都会频繁缺失。

基本块重排(basic block reordering)把控制流图上的块重新排成一维序列,目标是让最可能的后继块紧跟在当前块之后,从而把条件跳转变成顺序执行(fall-through)。给定边权重,这个问题等价于寻找一条最大化热边覆盖的路径,实践中用贪心或近似算法求解:

// 重排前:热路径需要一次条件跳转 + 一次无条件跳转
//   cmp; jne .Lslow; ...hot...; jmp .Lend; .Lslow: ...; .Lend:
//
// 重排后:热路径完全顺序执行,冷路径被推到函数尾部
int process(int x) {
    if (x < 0) goto slow;      // 权重 1,几乎不走
    return x * 2;              // 权重 999,顺序执行
slow:
    return recover(x);
}

函数重排(function reordering)处理的是函数粒度。用调用图上的边权重做聚类,把互相调用的函数、或者在同一次采样中经常共现的函数放在一起。经典算法包括 Pettis-Hansen 的贪心聚类与 C3 的边排序。

# 用 BOLT 做基于画像的函数重排(见第 5 节)
llvm-bolt app -o app.bolt \
  --data perf.fdata \
  --reorder-functions=hfsort \
  --reorder-blocks=ext-tsp \
  --split-functions --split-all-cold \
  --icf=all --dyno-stats

4.2 内联与分支提示

一句话总结: 有了边计数,内联器可以按真实调用频次分配代码体积预算,把预算花在真正高频的调用点上。

内联的核心矛盾是「消除调用开销」与「代码膨胀」之间的取舍。静态启发式只能用函数体大小与嵌套深度做判断,有了画像之后,成本模型可以写成收益除以成本再乘以热度的形式:

# 简化的内联收益模型
def inline_score(call_site):
    benefit = (call_site.call_overhead
               + call_site.constant_prop_opportunities * 2
               + call_site.dead_code_after_inline * 3)
    cost = call_site.callee_body_size
    heat = call_site.exec_count / call_site.caller_exec_count
    # 热度接近 1 的调用点(每次都走)收益最大
    return benefit * heat / max(cost, 1)

这个模型的关键项是 heat:一个函数即使很大,如果它只在一个冷路径上被调用一次,内联它毫无价值;反过来,一个中等大小的函数如果在最内层循环里被调用上亿次,即使膨胀 10 倍也值得。这正是静态启发式最容易判断错的场景。

分支提示(branch hint)在 PGO 下的效果取决于目标架构。x86 的静态分支预测器早已不读 0x2E/0x3E 前缀,但代码布局本身就是最强的分支提示:把热分支的后继块排在前一个块的后面,取指单元天然就顺着走。在 ARM 上,__builtin_expect 会通过布局影响 cbz/cbnz 的选择,仍然有效。

5. 二进制后链接优化 BOLT

一句话总结: BOLT 在链接完成之后、拿着最终地址布局的二进制上做重排,能利用链接器布局、PLT 桩、对齐填充等编译期不可见的信息,收益常常超过传统 PGO。

传统 PGO 作用于编译器内部,此时还不知道链接后的最终布局:函数可能被链接器重排、被 ICF 合并、被段对齐填充隔开。BOLT(Binary Optimization and Layout Tool)把优化推迟到链接之后,直接对可执行文件下手。

5.1 BOLT 的工作流程

一句话总结: BOLT 先反汇编并重建控制流图,再按采样画像重排基本块与函数、拆分冷代码、合并相同函数,最后重写二进制并修正所有引用。

BOLT 的流程分四步:

# 1) 用 perf 采样收集画像(注意采集时要带 -g 拿调用栈)
perf record -e cycles:u -j any,u -o perf.data -- ./app --real-workload
perf2bolt -p perf.data -o app.fdata ./app

# 2) 反汇编并重建 CFG,输出统计
llvm-bolt app -o /dev/null --data app.fdata --dyno-stats

# 3) 执行重排、冷热分离、函数合并
llvm-bolt app -o app.bolt \
  --data app.fdata \
  --reorder-blocks=ext-tsp \
  --reorder-functions=hfsort+ \
  --split-functions --split-strategy=profile2 \
  --icf=all \
  --align-blocks

# 4) 因为重排改变了地址,需要修正调试信息与展开表
llvm-bolt app -o app.bolt --data app.fdata --update-debug-sections

BOLT 的几项关键变换:

变换作用典型收益来源
基本块重排热路径顺序化减少无条件跳转与取指跳转
函数重排热函数聚簇降低 iTLB 与 L1i 缺失
冷代码分离把冷块挪到远处热代码密度提升
函数合并 ICF合并相同函数体减小体积,提升缓存命中
大页对齐热段按 2MB 对齐减少 iTLB 项数
# BOLT 前后的对比(示意输出)
# 优化前:热代码分散在 3 个 4KB 页上,每次调用跨页
# 优化后:热代码集中,iTLB 缺失下降 40%,L1i 缺失下降 25%
# 实测:分支密集的 JSON 解析器提升 12%,解释器循环提升 18%

BOLT 的最大工程价值在于不需要源码、不需要重新编译。对于第三方库、已经发布的历史版本、甚至只有二进制的闭源组件,只要能在测试环境跑一遍采样,就能拿到重排收益。代价是需要可执行的采样负载、需要保留调试信息或符号表(否则函数边界识别不准),以及需要在 CI 里维护一套二进制改写与验证流程。

5.2 PGO 与 BOLT 的叠加

一句话总结: 两者作用层次不同,PGO 影响编译器内部的优化决策,BOLT 影响最终布局,叠加使用收益近似相加。

实践中推荐的做法是两者都用:

# 完整的四阶段流水线
# 阶段一:插桩编译 + 训练
clang -O2 -fprofile-generate -o app_gen src/*.c
./app_gen --train

# 阶段二:用画像编译出 PGO 优化版
clang -O2 -fprofile-use -fprofile-correction -o app_pgo src/*.c

# 阶段三:对 PGO 版做采样
perf record -e cycles:u -o perf.data -- ./app_pgo --real-workload
perf2bolt -p perf.data -o app.fdata ./app_pgo

# 阶段四:BOLT 重排
llvm-bolt app_pgo -o app_final --data app.fdata \
  --reorder-blocks=ext-tsp --reorder-functions=hfsort --icf=all

需要注意两者的画像不可互换:PGO 画像作用于 IR 层的边计数,BOLT 画像作用于机器码层的基本块地址。同一个采样文件可以分别转换给两者用,但转换工具不同(llvm-profdata 对 perf2bolt)。

6. 工程落地与流水线集成

一句话总结: PGO 落地的难点不在编译选项,而在于维护一套可复现的训练负载、把画像文件纳入构建产物管理、并在每次发布前重新采集。

把 PGO 接进 CI 有三个工程问题要解决。

第一,训练负载的代表性。 画像决定了优化方向,训练集覆盖不到的场景会被系统性降级。一个只跑单元测试的训练集会让编译器把测试框架的热点当作程序热点,线上真实负载反而变慢。解决办法是:用生产采样画像做主导,测试画像做补充;定期回归对比 PGO 版与非 PGO 版在真实流量下的表现。

第二,画像文件的版本管理。 画像与源码强相关:源码改动导致控制流图变化后,旧画像的边计数可能对不上。GCC 与 Clang 都能检测这种不匹配(-Wprofile-mismatch),但默认行为是丢弃整个画像,导致优化悄悄失效。推荐把画像文件当作构建产物缓存起来,key 里带上源码哈希与训练负载版本。

第三,构建时间与缓存。 三阶段编译让构建时间翻倍。可行的缓解是:只在发布分支上开 PGO,日常开发用普通构建;把画像编译的中间产物做成可复用缓存;用采样 PGO 替代插桩 PGO 以省掉插桩编译那一遍。

# 一个把 PGO 接进 CI 的骨架(伪配置,展示关键阶段)
jobs:
  pgo_pipeline:
    steps:
      - run: cc -O2 -fprofile-generate -o app_gen src/*.c
      - run: ./app_gen --train-suite realistic
      - run: cc -O2 -fprofile-use -fprofile-correction -o app_pgo src/*.c
      - run: perf record -e cycles:u -o perf.data -- ./app_pgo --smoke
      - run: perf2bolt -p perf.data -o app.fdata ./app_pgo
      - run: llvm-bolt app_pgo -o app_final --data app.fdata --icf=all
      - run: ./run_perf_regression.sh app_final
# 画像失配诊断:不匹配时编译器会静默丢弃整个画像,优化随之失效
cc -O2 -fprofile-use app.c -o app 2>&1 | grep -i profile
llvm-profdata show --all-functions --counts default.profdata | tail -3

7. 陷阱与收益评估

一句话总结: PGO 的收益并非无条件,训练集偏差、画像失配、代码膨胀与安全加固的冲突都会吃掉收益,必须用真实负载做 A/B 验证。

常见的踩坑点:

陷阱表现应对
训练集偏差线上热点与训练热点不符用生产采样画像主导
画像失配优化静默失效监控 -Wprofile-mismatch,把画像纳入缓存 key
代码膨胀二进制变大,iTLB 恶化限制内联预算,开 --icf
冷热分离过度冷代码跳到远处,页缺失增加控制冷段粒度,不要逐函数拆分
与加固冲突CFI 检查点改变布局收益先量测再决定是否保留
构建时间翻倍CI 变慢只在发布分支开,日常关

收益评估必须用真实负载的端到端指标,而不是微基准。PGO 经常出现「微基准提升 20%,端到端只提升 2%」的情况,原因是微基准把程序压缩到一个热点循环上,放大了布局收益;真实程序受内存带宽、IO、锁竞争制约,布局只是众多因素之一。

# 推荐的评估方式:同一份二进制跑同一份真实流量,比较 P99 与吞吐
hyperfine --warmup 3 --runs 20 \
  './app_baseline --serve --port 8001' \
  './app_final    --serve --port 8002'
# 同时观察硬件事件,确认收益来源符合预期
perf stat -e cycles,instructions,cache-misses,iTLB-load-misses ./app_final --serve

一个常被忽视的事实是:PGO 的收益在编译器与 CPU 越来越强之后并没有消失,只是换了个地方。现代 CPU 的分支预测器很强,布局收益中的一部分被吸收掉了;但 iTLB 压力、指令缓存密度、内联决策质量这些问题依然存在,而且随着代码规模增长越来越显著。这也解释了为什么大型项目(浏览器、数据库、JIT 运行时)几乎都把 PGO 当作发布流水线的标配。

8. 总结

环节要点
动机静态启发式只能猜冷热,PGO 用执行计数替换猜测
插桩收集计数器精确,开销 30% 到 100%,适合测试环境
采样收集统计估计,开销 1% 到 3%,可上生产
权重传播调用图与控制流图传播,需做流量一致性修复
内联决策收益乘热度除以成本,把预算花在高频调用点
代码布局基本块顺序化 + 函数聚簇,收益最大的单项
BOLT链接后二进制重排,无源码可用,与 PGO 叠加
工程落地训练集代表性、画像版本管理、构建缓存
评估纪律用真实负载端到端指标,警惕微基准放大

PGO 与 BOLT 代表了一类共同的工程思想:用测量替代推测。它们的收益不来自更聪明的算法,而来自把「程序实际怎么跑」这个事实喂给编译器。理解了这一点,再看内联、布局、寄存器分配这些具体优化,就会发现它们的参数其实都只是在等一个准确的概率。下一篇我们转向优化的微观层面——窥孔优化与超优化,看看在指令序列这个尺度上,改写规则是如何被发现与验证的。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「compiler」更多文章

  1. MLIR 与多层次 IR
  2. 可复现构建与确定性输出
  3. 约束求解与类型类