default.pgo をメインパッケージのディレクトリに置くと、go build はフラグなしでそれを読みます。Go 1.26.4 で実測したところ、ホットパスは 2.28% 速くなり、ビルド時間は 21.5% 伸びました。効いた場所と効かなかった場所を分けて記録します。
default.pgo を置くと go build が自動で読む
PGO(Profile-guided optimization)は Go 1.21 で正式版になった仕組み。実行時に採取した CPU プロファイルをコンパイラに渡し、最適化の判断材料にします。プレビューは Go 1.20、デフォルトで -pgo=auto になったのが Go 1.21。
-pgo に渡せる値
go help build の -pgo の項に、探索ルールがそのまま書かれています。
When the special name “auto” is specified, for each main package in the build, the go command selects a file named “default.pgo” in the package’s directory if that file exists, and applies it to the (transitive) dependencies of the main package (other packages are not affected).
-pgo=auto: メインパッケージのdefault.pgoを探して使う。Go 1.21 以降のデフォルト-pgo=off: PGO を切る。比較計測の基準にする-pgo=/tmp/cpu.pprof: 指定したファイルを使う
効く範囲は「for each main package in the build」と「the (transitive) dependencies of the main package」の2つで決まります。メインパッケージから辿れる依存パッケージまでは最適化が届き、そこから外れたパッケージは影響を受けません。
プロファイルはリポジトリに置く
公式ドキュメントの “Building with PGO” セクションは、プロファイルをソースと一緒に管理する前提で書かれています。default.pgo をコミットしておけば、CI でも手元でも同じバイナリが出ます。ビルドマシンごとにプロファイルを配る仕組みは要りません。
稼働中のサーバーからプロファイルを取る
今回の題材は、POST された JSON のテキストを4段のフィルタに通し、単語頻度の上位10件を返す HTTP サーバー。フィルタは Filter インターフェースで、ホットループの中でインターフェース越しに呼ばれます。
type Filter interface {
Apply(r rune) (rune, bool)
}
func (p *pipeline) Run(text string) string {
var b strings.Builder
b.Grow(len(text))
for _, r := range text {
keep := true
cur := r
for _, f := range p.filters {
cur, keep = f.Apply(cur) // ここが間接呼び出し
if !keep {
break
}
}
if keep {
b.WriteRune(cur)
}
}
return b.String()
}
net/http/pprof を生やして30秒取る
net/http/pprof をブランクインポートし、/debug/pprof/ をルーティングに繋ぐだけ。
import _ "net/http/pprof"
mux := http.NewServeMux()
mux.HandleFunc("POST /process", handler)
mux.Handle("/debug/pprof/", http.DefaultServeMux)
負荷をかけている最中に取るのが条件です。8並列で40秒間リクエストを流し、その途中で30秒分を採取しました。
$ curl -o cpu.pprof "http://127.0.0.1:8080/debug/pprof/profile?seconds=30"
$ cat load.txt
requests=236868 errors=0 elapsed=40.00s rps=5921.5 mean=1.351ms p50=1.309ms p95=1.814ms p99=2.107ms
$ ls -l cpu.pprof
-rw-r--r-- 1 moha wheel 39386 Aug 14 20:05 cpu.pprof
$ cp cpu.pprof default.pgo
23万リクエスト分の負荷から採れたプロファイルは 39,386 バイト。リポジトリに置いても邪魔になるサイズではありません。
複数本取ってマージする
1インスタンス・1時点のプロファイルは、その瞬間のトラフィックしか映しません。公式ドキュメントの “Collecting profiles” は複数インスタンス・複数時間帯からの採取を勧めていて、マージ手順も載っています。
$ go tool pprof -proto morning.pprof evening.pprof > default.pgo
Main binary filename not available.
マージはサンプルの単純合計。採取時間が揃っていないと、長く取ったプロファイルが結果を支配します。30秒なら全部30秒で揃えること。
コンパイラが何を変えたかは -m で見える
PGO を有効にしたビルドが実際に何をしたかは、-gcflags=-m の出力を PGO なしと突き合わせれば分かります。今回のコードでは -m の該当行が 41行から66行に増えました。
インターフェース呼び出しが直接呼び出しになる
PGO の目玉はインライン化だと説明されることが多いのですが、実際に最初に出てきたのは間接呼び出しの解決(devirtualization)でした。
$ go build -pgo=auto -gcflags='-m=2' -o server-pgo . 2>&1 | grep -i devirtualiz
./main.go:63:23: PGO devirtualizing interface call f.Apply to punctFilter.Apply
$ go build -pgo=off -gcflags='-m=2' -o server-nopgo . 2>&1 | grep -ci devirtualiz
0
f.Apply という Filter インターフェース越しの呼び出しが、punctFilter.Apply の直接呼び出しに置き換わっています。PGO なしでは0件。
直接化されるのは最もホットな1件だけ
フィルタは4実装あるのに、直接化されたのは punctFilter だけ。判断の過程は -d=pgodebug=2 で出せます。
$ go build -pgo=auto -gcflags='-d=pgodebug=2' -o /dev/null . 2>&1 | grep 'main.go:63:23'
./main.go:63:23: PGO devirtualize considering call f.Apply(cur)
./main.go:63:23: edge main.(*pipeline).Run:7 -> main.punctFilter.Apply (weight 239): hottest so far
./main.go:63:23: edge main.(*pipeline).Run:7 -> runtime.asyncPreempt (weight 57): too cold (hottest 239)
./main.go:63:23: edge main.(*pipeline).Run:7 -> main.asciiFilter.Apply (weight 43): too cold (hottest 239)
./main.go:63:23: edge main.(*pipeline).Run:7 -> main.digitFilter.Apply (weight 165): too cold (hottest 239)
./main.go:63:23: call main.(*pipeline).Run:7: hottest callee main.punctFilter.Apply (weight 239)
weight 239 の punctFilter が選ばれ、165 の digitFilter も 43 の asciiFilter も “too cold” で落ちています。選び方はコンパイラの devirtualize/pgo.go にある findHotConcreteCallee のコメントそのまま。
We still record this as the hottest callee so far because we only want to return the #1 hottest callee.
選ぶのは常に1件。しかも同じ関数のコメントには、割合による足切りが未実装であるという TODO が残っています。
// TODO(prattmic): consider total caller weight? i.e.,
// if the hottest callee is only 10% of the weight,
// maybe don't devirtualize?
呼び出し先が10種類に割れていて最頻が全体の12%しかない状況でも、その1件は直接化されます。逆に言えば、残りの実装は全部これまで通りの間接呼び出しのまま。インターフェースの実装が分散しているコードほど、PGO の取り分は小さくなります。
ホット関数のインライン予算が広がる
インライン化の変化は、同じ行のメッセージを見比べると分かりやすい。
# PGOなし
./main.go:56:6: cannot inline (*pipeline).Run: function too complex: cost 232 exceeds budget 80
# PGOあり
./main.go:56:6: can inline (*pipeline).Run with cost 850 as: method(*pipeline) func(string) string { ... }
通常のインライン予算は 80。コスト232の (*pipeline).Run は入りません。PGO を渡すと予算が広がり、コスト850まで許容されて呼び出し元に埋め込まれました。topWords や strings.Fields も同じ理由で追加インライン化されています。
デバッグ出力にも予算判定の行。
hot-budget check allows inlining for call main.punctFilter.Apply (cost 536) at ./main.go:63:23 in function main.(*pipeline).Run
直接化された punctFilter.Apply が、そのままインライン化まで進んでいます。devirtualization 単体で速くなるわけではありません。間接呼び出しが直接呼び出しになり、その結果インライン化の対象に入る。この連鎖が効果の中身です。
ホットパスで2.28%、処理全体では0.11%
HTTP のエンドツーエンドで測ると、計測を繰り返すうちにスループットが 5,613 rps から 4,708 rps まで単調に落ちました。PGO の差より機材側の変動のほうが大きく、そのままでは比較になりません。
ABBA順で6回ずつ測る
A→B の順で交互に測ると、時間とともに遅くなる環境では後攻(B)が必ず不利になります。A→B→B→A の順に並べて順序の効きを打ち消しました。計測対象は pipeline.Run 単体、1回あたり約24,000文字のテキストを処理します。
--- round 1 ---
nopgo: median=307762 ns/op
pgo : median=300250 ns/op
pgo : median=299419 ns/op
nopgo: median=308896 ns/op
--- round 2 ---
nopgo: median=309254 ns/op
pgo : median=303258 ns/op
pgo : median=304275 ns/op
nopgo: median=308742 ns/op
6回ずつの中央値は 308,930 ns/op → 301,878 ns/op。2.28% の改善で、全ラウンドで PGO 側が速い側に来ました。
効果はホットパスの占有率で頭打ちになる
同じバイナリで、後段の集計(topWords)まで含めて測ると数字が消えます。
| 計測対象 | PGOなし | PGOあり | 差 |
|---|---|---|---|
| pipeline.Run 単体 | 308,930 ns/op | 301,878 ns/op | -2.28% |
| Run + topWords | 370,857 ns/op | 370,441 ns/op | -0.11% |
| HTTP エンドツーエンド | 機材変動に埋もれて測定不能 | ||
プロファイルを見れば理由が分かります。pipeline.Run の cum は全体の 16.41%、その内訳で Apply 系4つの合計は 7.89 秒。うち直接化された punctFilter は 3.69 秒で、Apply 全体の46.8%です。改善の効く面積が最初から小さい。
$ go tool pprof -peek='Run' default.pgo
8.71s 4.93% 4.93% 29s 16.41% | main.(*pipeline).Run
10.84s 37.38% | strings.(*Builder).WriteRune
3.69s 12.72% | main.punctFilter.Apply
2.35s 8.10% | main.digitFilter.Apply
1.38s 4.76% | main.lowerFilter.Apply
0.47s 1.62% | main.asciiFilter.Apply
公式ドキュメントが挙げる 2〜14% という数字は、CPU 時間の大半をアプリ自身のコードが占めるプログラムでの話です。I/O やシリアライズが支配的なサーバーでは、ホットパスが何%速くなってもスループットには載りません。導入前に -peek で占有率を見ておけば、効果の上限が先に分かります。稼働中のプロセスから継続的に記録を残す手段としては、Go Flight Recorderの使い方—MaxBytesが効かない条件と対処で扱った実行トレースも組み合わせられます。
go test 経由のビルドには乗らない
ベンチマークで効果を確かめようとして最初に詰まったのがここ。go test でビルドされたバイナリには、-pgo を明示しても PGO が乗りません。
auto でも明示指定でも同じ
$ go test -pgo=auto -gcflags='-m=2' -run=XXX -bench=XXX . 2>&1 | grep 'main.go:56:6'
./main.go:56:6: cannot inline (*pipeline).Run: function too complex: cost 232 exceeds budget 80
$ go test -pgo=/tmp/pgodemo/default.pgo -gcflags='-m=2' -run=XXX -bench=XXX . 2>&1 | grep -ci devirtualiz
0
予算80のまま、devirtualization も0件。同じディレクトリで go build -pgo=auto を叩けばコスト850でインライン可になるので、プロファイルもコードも問題ありません。-pgo の説明にある「for each main package in the build」に、テストバイナリのビルドが該当していないという読み方になります。
main パッケージ側で測る
回避策として、計測コードを main パッケージのファイルとして足し、init() から環境変数で分岐させました。main.go には一切触っていません。
// selfbench.go
func init() {
if os.Getenv("BENCH") == "" {
return
}
// ウォームアップ後、11回計測して中央値を出す
...
fmt.Printf("median=%.0f ns/op\n", samples[len(samples)/2])
os.Exit(0)
}
$ BENCH=run ./server-pgo
target=run median=300250 ns/op min=298831 max=301683 iter=3000
ファイルを足しても main.go の行番号は動かないので、プロファイルのマッチングもそのまま維持されます。ベンチマークの取り方そのものについてはGoのb.Loopで正確なベンチマークを書く—b.Nとの違いと落とし穴も参考になります。
関数名を変えるとプロファイルが外れる
プロファイルが紐づく先は、採取した時点のソース。公式ドキュメントの “Notes” にある source stability の説明では、関数内の行オフセットでマッチングするため多少のコード変更には耐えると書かれています。どこまで耐えるかを2パターンで試しました。
8行ずらしても効果は残る
プロファイル採取後に無関係な関数を追加し、pipeline.Run の位置を56行目から64行目へずらしました。
$ go build -pgo=auto -gcflags='-m=2' -o srv . 2>&1 | grep -E 'devirtualiz|can inline \(\*pipeline\)'
./main.go:71:23: PGO devirtualizing interface call f.Apply to punctFilter.Apply
./main.go:64:6: can inline (*pipeline).Run with cost 850
行番号が8つずれても devirtualization は健在。デバッグ出力に出ていた main.(*pipeline).Run:7 の :7 が関数先頭からのオフセットで、ファイル先頭からの絶対行ではないためです。
リネームは0件になる
同じプロファイルのまま、メソッド名だけ Run から Execute に変えます。行番号は56行目のまま動かしていません。
$ go build -pgo=auto -gcflags='-m=2' -o srv . 2>&1 | grep -E 'devirtualiz|main.go:56:6'
./main.go:56:6: cannot inline (*pipeline).Execute: function too complex: cost 232 exceeds budget 80
devirtualization は 0件、インライン予算も80に戻りました。プロファイルの中の関数名は main.(*pipeline).Run のままなので、照合先が消えた形です。ホット関数のリネームやパッケージ移動をした日は、プロファイルを取り直すまで PGO の分だけ性能が戻ると考えたほうが安全。ビルドは成功して警告も出ないので、気づく手がかりは -m の出力しかありません。
ビルド時間は21.5%増、バイナリは0.54%増
コストも測りました。プロファイルはバイナリ内の全パッケージに適用されるため、初回はキャッシュが効かず全部リビルドになります。go clean -cache 後のフルビルドで比較した数字が次の通り。
| 項目 | PGOなし | PGOあり | 差 |
|---|---|---|---|
| フルビルド時間 | 2.356s | 2.863s | +21.5% |
| バイナリサイズ | 9,219,410 B | 9,268,898 B | +49,488 B (+0.54%) |
ビルド時間の増分はキャッシュが温まれば縮みます。CI でキャッシュを持たない構成だと毎回この21.5%を払うので、ビルド時間に厳しい制約があるプロジェクトでは先に測っておくところ。
まとめ
default.pgoをメインパッケージに置けばgo buildが自動で読む。切るときは-pgo=off- PGO の実体は、ホットな間接呼び出しの直接化と、ホット関数のインライン予算拡大(通常80 → コスト850を許容)
- 直接化されるのは呼び出し先のうち最頻の1件だけ。実装が分散しているインターフェースでは取り分が減る
- 今回の実測はホットパス単体で2.28%、集計処理まで含めると0.11%。ホットパスの占有率が上限を決める
go testでビルドしたバイナリには PGO が乗らない。効果を測るならmain側に計測コードを置く- 関数のリネームでマッチングは外れる。行がずれるだけなら維持される
- コストはフルビルド +21.5%、バイナリ +0.54%
採取から適用までのコマンドは curl と cp の2つで済みます。効くかどうかは、go tool pprof -peek で自分のホットパスが CPU 時間の何%を占めているかを見た時点でおおよそ決まります。
検証環境: Go 1.26.4 darwin/arm64、8並列40秒の負荷から採取した30秒プロファイル。