走完 PGO 完整流程并实测收益;避开三个会浪费大量时间的坑;理解 LTO 的兼容性约束。
为什么需要 PGO
第 9 讲的所有选项都是"静态"的------编译器看着代码猜哪里是热点、哪个分支走得多、循环一般跑多少次。
猜错了,优化方向就偏了。 比如编译器认为某个分支很少走,就把它放到冷区、不做优化;实际上那个分支在生产环境每分钟走一万次。
PGO(Profile-Guided Optimization)的做法是:不猜,实测。先让程序跑一遍真实负载,记录每个函数和分支的实际执行次数,然后用这份数据指导第二次编译。
收益区间 :数据库与分布式存储这类数据密集、计算前端瓶颈高的负载,可提升 10%--30% 。官方实测数据:数据库(MySQL、GaussDB)用 LTO + PGO 提升 20%--30% ;分布式存储(Ceph、LAVA)提升 10% 以上。
准备一个测试程序
写一个分支行为不均衡的程序------这是 PGO 最能体现价值的场景:
c
/*
* pgo_demo.c ------ 一个分支高度不均衡的程序,用于演示 PGO 收益
*
* 真实场景中这种"99% 走同一分支"的代码很常见(错误检查、边界判断)。
* 编译器静态分析时不知道哪个分支是热点,PGO 能告诉它。
*/
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#define N (1 << 22) /* 4194304 次迭代 */
/* 一个不常走的分支:处理异常值 */
__attribute__((noinline))
static int handle_exception(int x)
{
/* 模拟复杂处理逻辑 */
int r = 0;
for (int i = 0; i < 32; i++) r += (x * i) % 7;
return r;
}
/* 主处理函数:99% 的情况走快速路径 */
static int process(int *data, int n)
{
int sum = 0;
for (int i = 0; i < n; i++) {
int v = data[i];
/* 分支预测关键点:这个条件几乎永远是假 */
if (__builtin_expect(v > 1000000, 0)) {
sum += handle_exception(v);
} else {
sum += v;
}
}
return sum;
}
int main(void)
{
int *data = malloc((size_t)N * sizeof(int));
if (!data) return 1;
/* 构造数据:99.99% 是正常值,极少数是异常值 */
for (int i = 0; i < N; i++) {
/* 每 10000 个元素插入一个异常值 */
data[i] = (i % 10000 == 0) ? 2000000 : (i % 1000);
}
int sum = process(data, N);
printf("sum = %d\n", sum);
free(data);
return 0;
}
PGO 完整四步
第 1 步:插桩编译
bash
export LLVM_DIR=/opt/llvm # 替换为实际 LLVM 路径
export PROFILE_DATA_PATH=/home/test/profdata
mkdir -p $PROFILE_DATA_PATH
cd /home/test/pgo_demo
# 插桩编译:生成带计数器的可执行文件
$LLVM_DIR/bin/clang -O2 -fprofile-generate=$PROFILE_DATA_PATH -o app_instr pgo_demo.c
关键 :-fprofile-generate 必须同时用于编译和链接。如果项目是多文件的,编译和链接两步都要带上这个选项,否则计数器不会被正确插入。
第 2 步:用真实负载运行
bash
# 用有代表性的输入运行插桩程序
./app_instr
# 运行结束后,profile 数据落在 PROFILE_DATA_PATH 下
ls -la $PROFILE_DATA_PATH/*.profraw
这一步的输入质量决定了 PGO 的成败------见下方"三个坑"。
第 3 步:合并 profile
bash
cd $PROFILE_DATA_PATH
$LLVM_DIR/bin/llvm-profdata merge -output=foo.profdata ./*.profraw
# 确认生成了 profdata 文件
ls -la foo.profdata
第 4 步:用 profile 重新编译
bash
cd /home/test/pgo_demo
# 关闭插桩,改用 profile 指导优化
$LLVM_DIR/bin/clang -O2 -fprofile-use=$PROFILE_DATA_PATH/foo.profdata -o app_opt pgo_demo.c
实测对比
用第 2 讲的计时思路对比两个版本:
bash
echo "=== 普通编译 ==="
$LLVM_DIR/bin/clang -O2 -o app_base pgo_demo.c
for i in 1 2 3; do
/usr/bin/time -f "%e 秒" ./app_base
done
echo "=== PGO 编译 ==="
for i in 1 2 3; do
/usr/bin/time -f "%e 秒" ./app_opt
done
判读:如果差距不明显,很可能是测试程序的负载太简单------PGO 的收益主要来自分支预测和函数布局,简单循环里编译器静态分析本来就能猜对。真实的复杂应用收益更明显。
PGO 会做哪些优化
五类优化手段:
| 优化手段 | 做法 |
|---|---|
| 冷热分区 | 把冷分支剥离出去、热代码聚合在一起,提升指令缓存命中率 |
| 函数重排 | 按调用频率重排函数,改善 iTLB 和指令缓存 miss |
| 分支预测 | 重排分支降低分支缺失率 |
| 基于反馈的函数内联 | 比启发式内联更准确------知道哪些调用真的频繁 |
| switch 优化 | 重构 switch,减少跳转 |
三个必踩的坑
坑一:插桩程序必须正常退出
用 kill -9 硬杀进程不会生成 profile 文件。
这个坑很隐蔽------你会以为采集成功了(程序确实跑了),实际上什么都没拿到。原因是 profile 数据在程序正常退出时才会被写入。
解法:让它自然结束。如果是常驻服务,见坑二。
坑二:程序无法正常退出时用 gdb 强制落盘
服务类程序不会自己退出。用 gdb 附加进去,调用 LLVM 提供的落盘函数:
bash
# 构造 gdb 命令脚本
echo "set height 0" > gdb.cmd
echo "handle SIGPIPE SIGUSR1 SIGUSR2 SIG36 noprint nostop" >> gdb.cmd
echo "call (void)__llvm_profile_write_file()" >> gdb.cmd
echo "detach" >> gdb.cmd
echo "q" >> gdb.cmd
# 附加到目标进程
gdb -x gdb.cmd -p $(pidof my_service)
那几行 handle ... noprint nostop 是让 gdb 忽略常见信号------否则附加时会因为信号中断而失败。
坑三:counter overflow
合并时如果报 counter overflow,说明计数器溢出了(程序跑得太久、热点执行次数太多)。解法是按进程区分 profile 文件:
bash
export LLVM_PROFILE_FILE=$PROFILE_DATA_PATH/code-%p
%p 会被替换为进程 ID,这样多进程场景下每个进程写自己的文件,避免混在一起溢出。
关于 GCC 路径
openEuler 的官方文档里没有 GCC 手写 PGO 的完整指南------它推荐的是自动化路径 A-FOT(针对内核和应用的自动反馈优化)和 DFOT(动态反馈优化)。
如果你用 GCC,标准流程是(注意这与 openEuler 文档不是同一条路径):
bash
# 第 1 步:插桩编译(编译和链接都要带)
gcc -O2 -fprofile-generate=/home/test/profdata -o app_instr pgo_demo.c
# 第 2 步:运行
./app_instr
# 第 3 步:重新编译(GCC 会自动从 profile 目录读取数据)
gcc -O2 -fprofile-use=/home/test/profdata -o app_opt pgo_demo.c
⚠️ 待实机确认:GCC 的
-fprofile-generate/-fprofile-use是标准 GCC 特性,但在 openEuler 上没有成体系的官方指南 ,-fprofile-use的完整语法(含可选路径参数)与-fprofile-correction的说明未在官方文档中确认。建议实机跑一遍,用-fprofile-use后查看是否有profile feedback相关的警告。
一个容易混淆的点 :openEuler 的 LLVM 文档用的是 -fprofile-generate / -fprofile-use(不是 clang 原生的 -fprofile-instr-generate / -fprofile-instr-use)。两套选项的 profile 格式不同,不要混用。
LTO:另一个跨文件优化手段
LTO(Link Time Optimization)解决的是编译单元隔离问题。
常规流程是每个源文件独立编译成 .o,再链接。这支持快速增量构建,但代价是------编译器优化单个文件时看不到其他文件的信息。它不知道某个函数在别的模块里被怎么调用、有没有内联价值。
LTO 把优化时机推迟到链接期,那时所有编译单元的信息都可见了。
三条代价与约束:
约束一:LTO 对象文件不跨 GCC 版本兼容。 这是最实际的约束。openEuler 的应对方式是打 RPM 包前剥离 .o/.a 里的 LTO 相关段,只保留常规汇编,这样静态库不受影响。
约束二:渐进式推广。 openEuler 从 24.09 创新版引入 LTO,方式是往全局编译选项插入 -flto -ffat-lto-objects(后者产生"胖目标对象",同时含 LTO 信息和常规汇编)。但当前只对 500 多个包开启 ,白名单在 /usr/lib/rpm/%{_vendor}/lto_white_list。
约束三:热补丁机制当前与 LTO 不兼容。 启用 LTO 时热补丁失败。如果你依赖热补丁做在线修复,这是个必须评估的冲突。
关于 LTO 的收益 :openEuler 的 LTO 文档没有给出单独的量化数字 。前面提到的 20%--30% 是 LTO + PGO 组合的结果,不能拆分成 LTO 单独的贡献。汇报时要注意措辞。
内核层:Kernel FDO
如果瓶颈在内核而非应用,还有 Kernel FDO(内核反馈优化):
bash
# 安装依赖
sudo yum install -y kernel-source A-FOT make gcc flex bison \
elfutils-libelf-devel diffutils openssl-devel dwarves
# 配置 a-fot.ini 后执行
a-fot --config_file ./a-fot.ini -s
关键配置项:pgo_mode(必须是 arc 或 all)、pgo_phase(1=插桩阶段,2=收集并优化阶段)。
两个约束 :openEuler 23.09 版本内核暂不支持完整 PGO,目前只支持 arc 模式 ;路径必须用绝对路径。另外 -s 选项会自动重启切换内核------生产环境执行前要清楚这一点。
完整验证闭环
把第 6 讲的 KSYS 和第 9 讲的验证脚本都用上:
bash
# ── 1) 基线性能 ──
./ksys collect -d 30 -o /home/test/out/ /home/test/demo/app_base
# ── 2) PGO 四步 ──
export PROFILE_DATA_PATH=/home/test/profdata
$LLVM_DIR/bin/clang -O2 -fprofile-generate=$PROFILE_DATA_PATH -o app_instr app.c
./app_instr <真实负载输入> # 必须正常退出!
$LLVM_DIR/bin/llvm-profdata merge -output=$PROFILE_DATA_PATH/foo.profdata \
$PROFILE_DATA_PATH/*.profraw
$LLVM_DIR/bin/clang -O2 -fprofile-use=$PROFILE_DATA_PATH/foo.profdata -o app_opt app.c
# ── 3) 优化后性能 ──
./ksys collect -d 30 -o /home/test/out/ /home/test/demo/app_opt
# ── 4) 对比 ──
./ksys diff -i /home/test/out/<before>.json /home/test/out/<after>.json -o /home/test/out/
# ── 5) 指令级验证 ──
./verify_opts.sh app_base app_opt
不要同时上 PGO 和 LTO------否则收益无法归因,出问题也无法定位。一次只改一项。
动手练习
- 把
pgo_demo.c的__builtin_expect(v > 1000000, 0)改成__builtin_expect(v > 1000000, 1)(故意给编译器错误提示),验证 PGO 能否纠正静态提示的错误。 - 用
kill -9杀掉插桩程序,验证 profile 文件确实没有生成------成本最低的"体验坑"方式。 - 对
pgo_demo.c完整跑一遍 GCC PGO 流程,与 LLVM 版本的结果对比。
进阶与拓展
- 同类手段定位 :AutoFDO 用 perf 采集的 PMU 硬件数据替代插桩(GCC 走
-fauto-profile,clang 走-fprofile-sample-use),免去"插桩编译 + 重跑"两步,适合不便插桩的生产环境;BOLT 更进一步------直接对已链接完成的二进制按 profile 重排函数布局,不依赖重编译,与编译期 PGO 互补。 - 样本过期问题 :
-fprofile-use的收益依赖 profile 与当前代码同步。改了源码却沿用旧采样,轻则收益缩水,重则按过期的分支概率做出错误优化(GCC 对函数与 profile 不匹配的情况会给出 coverage 不匹配警告)。工程上应把"重新采样"绑定到代码变更流程,而非一次性动作。 - 多线程插桩的原子性 :GCC 路径下计数器更新默认非原子,多线程程序可能计数失真,可加
-fprofile-update=atomic换取准确(有少量运行时开销)。 - 组合取舍:本文"一次只改一项"是正确起点;LTO + PGO 组合收益最大(官方 20%--30% 即组合值),但构建代价与排查成本都翻倍,先单变量验证再考虑叠加。
参考来源
- openEuler · LLVM PGO 用户指南 --- 四步流程、10%--30% 收益区间、五类优化手段、三个实践坑(正常退出/ gdb 落盘 / counter overflow)
- openEuler · GCC LTO 用户指南 --- LTO 原理、胖目标对象、500+ 包白名单与热补丁不兼容约束
- openEuler · GCC Kernel FDO 用户指南 --- 内核反馈优化的版本要求与
arc模式限制