PGO(profile 引导优化)据说能让程序快 2% 到 14%。这篇自己做一遍:收集 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,所以不会被内联。这是后面对照的起点。
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.runBothprofile 就是普通的 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 里确认没有性能退化。