bigywater.tech
Go · 48

PGO:用 profile 引导编译优化,真实能快多少

PGO(profile 引导优化)据说能让程序快 2% 到 14%。这篇自己做一遍:收集 profile、重新编译、看编译器的决定改了什么,然后在五种工作负载上对照运行时间。结果不全是好消息,这正是值得记录的地方。

8.1%
regexp 匹配在 PGO 构建下的提升(471 → 433 ms)
0% 与 -2.0%
其它四种负载的结果:接口调用、直接调用、flate 几乎不变,接口加较长函数的组合慢了 2.0%
146 → 可内联
score 的代价 146 超过默认预算 80,PGO 下在热调用点被内联

没有 profile 时

实测 · 本机执行输出
  main.go:26:6: cannot inline score: function too complex: cost 146 exceeds budget 80

默认构建下,-gcflags=-m=2 的诊断是:cannot inline score: function too complex: cost 146 exceeds budget 80——函数的内联代价 146,超过了默认预算 80,所以不会被内联。这是后面对照的起点。

收集与使用 profile

实测 · 本机执行输出
  mode=iface:total=14093400583.312 elapsed_ms=1447;profile 862 字节
  mode=inline:total=4953580481961.155 elapsed_ms=373;profile 607 字节
  mode=both:total=320842445200.226 elapsed_ms=516;profile 675 字节
  mode=flate:total=73400320.000 elapsed_ms=91;profile 1567 字节
  mode=regexp:total=1500000.000 elapsed_ms=470;profile 1392 字节
  合并后的 default.pgo 3295 字节
  go tool pprof -top(前几行):
    Showing nodes accounting for 2590ms, 91.84% of 2820ms total
    Dropped 31 nodes (cum <= 14.10ms)
    Showing top 6 nodes out of 25
    flat flat% sum% cum cum%
    1420ms 50.35% 50.35% 1420ms 50.35% main.runIface
    610ms 21.63% 71.99% 640ms 22.70% main.score
    200ms 7.09% 79.08% 200ms 7.09% regexp/syntax.(*Inst).MatchRunePos
    160ms 5.67% 84.75% 490ms 17.38% main.runBoth

profile 就是普通的 CPU profile(runtime/pprof 或 go test -cpuprofile 产生),多个 profile 可以用 go tool pprof -proto 合并。命名为 default.pgo 放在 main 包目录里,go build 默认(-pgo=auto)就会使用;-pgo=off 关闭。这个实验里每个 profile 很小(几百字节到 1.5 KB)——工作负载只跑了不到几秒。

编译器改变了什么

实测 · 本机执行输出
  main.go:26:6: can inline score with cost 146 as: func(float64, float64, float64) float64 { a := x * x + y * y; b := y * y + z * z; c := z * z + x * x; if a > b { a, b = b, a }; if b > c { b,
  main.go:46:17: inlining call to score
  main.go:59:41: inlining call to score
  <autogenerated>:1: inlining call to Circle.Area
  <autogenerated>:1: inlining call to Square.Area
  <autogenerated>:1: inlining call to Tri.Area

从 -m=2 的诊断能直接看到:score 的代价仍是 146,但在 profile 里的热调用点上内联预算被放宽,所以被内联了;接口调用 s.Area() 的热目标也被内联(实现上是在调用点先判断具体类型再直接调用,这是我对“Circle.Area 等被内联”这一诊断的解释,没有读生成的汇编)。

结果:不保证更快

实测 · 本机执行输出
  只有接口调用 s.Area()(95% 是 Circle)(n = 400000000):
    基线:[1422, 1423, 1423, 1423, 1424, 1426, 1429];中位 1423
    PGO :[1419, 1421, 1422, 1422, 1422, 1426, 1451];中位 1422
    PGO 比基线快 0.1%(中位数)
  只有直接调用较长的 score()(n = 100000000):
    基线:[353, 353, 353, 353, 354, 354, 358];中位 353
    PGO :[353, 353, 353, 353, 353, 353, 353];中位 353
    PGO 比基线慢 0.0%(中位数)
  接口调用 + 较长的 score()(n = 100000000):
    基线:[507, 507, 507, 507, 508, 509, 511];中位 507
    PGO :[516, 516, 516, 517, 520, 520, 524];中位 517
    PGO 比基线慢 2.0%(中位数)
  compress/flate 压缩 3.6 MB 重复文本(n = 20):
    基线:[80, 80, 80, 81, 81, 81, 82];中位 81
    PGO :[81, 81, 81, 81, 81, 82, 82];中位 81
    PGO 比基线慢 0.0%(中位数)
  regexp 匹配邮箱格式(n = 3000000):
    基线:[468, 469, 471, 471, 472, 472, 477];中位 471
    PGO :[432, 433, 433, 433, 434, 434, 434];中位 433
    PGO 比基线快 8.1%(中位数)
  二进制大小:基线 3239922 字节,PGO 3256354 字节

五种负载里只有 regexp 的匹配循环有明显提升(8.1%)。接口调用与直接调用的两个微基准几乎没变,compress/flate 也没变,而同时有接口调用与较长函数的组合反而慢了 2%。我没有去查慢的原因(代码布局、寄存器分配都有可能),只能说:PGO 的收益取决于程序,需要拿自己的负载测,官方给的“几个百分点”不是保证,微基准更容易得到 0。另外这些数字是 arm64 单机上的结果,二进制也略有变大。

默认行为

实测 · 本机执行输出
  默认 go build 的产物里的构建信息:build	-pgo=<gtmp>/pg1/default.pgo
  -pgo=off 的产物:构建信息里没有 -pgo

因为 -pgo=auto 是默认,往 main 包目录里放一个 default.pgo,所有人构建的结果都会变——所以有这个文件时,要把它当作构建输入对待(纳入版本控制、定期更新),并在 CI 里确认没有性能退化。

分步演示

PGO
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。基准是自己写的 5 种负载(带 -mode 参数的同一个程序);每种 mode 各跑 7 次取中位数;profile 来自基线构建的 runtime/pprof CPU profile,用 go tool pprof -proto 合并;编译器的决定来自 -gcflags=-m=2
    • 没有验证:更大、更真实的服务端程序上的收益、profile 的质量(采样时长、负载代表性)对结果的影响、PGO 构建的编译时间、慢 2.0% 的原因、PGO 在 amd64 上的结果、profile 随代码演进过期后的表现
    京ICP备2026067502号-1京公网安备11010502063487号