【Go】PGO Profile-guided optimization に入門する
🌟 はじめに
業務で Go を触ることが多く、パフォーマンス改善に関して特に関心があります。最近は inlining に関心があり、色々調べていました。ドキュメントを読む中で、今回取り扱う PGO という概念に出会い、自分の中で理解が浅い概念であったため、これを機にまとめてみることにしました。
※ 基本的には Go の Document を参考にしていますので、詳しくはそちらを参照してください。
📖 対象読者
- パフォーマンス改善に興味がある方
- PGO を知らない方
- Go に興味がある方
🔖 先に...
PGO について調べるに当たって、参考にさせていただいた記事やドキュメント等は下記のスクラップに残しております。思考の過程なども載せている場合もございますので、参考までに🙏
また、記事の中で登場する inlining や pprof に関しても参考にした記事やドキュメントをスクラップまとめているので、下記を参考にして見てください🙏
🔍 PGO とは
まず、PGO とは何かを見てみましょう。
ドキュメントに記載がある通り、Profile-guided optimization はアプリケーションの実行から得られた結果(profile)を、次のアプリケーションのビルドのために使用し、より多くの情報に基づいた最適化の決定を行うためのコンパイル最適化手法のことです。
例えば、コンパイラは profile を元に、頻繁に呼び出している関数を inlining する可能性があります。
Profile-guided optimization (PGO), also known as feedback-directed optimization (FDO), is a compiler optimization technique that feeds information (a profile) from representative runs of the application back into to the compiler for the next build of the application, which uses that information to make more informed optimization decisions. For example, the compiler may decide to more aggressively inline functions which the profile indicates are called frequently.
Go 1.22 時点では PGO を使用したビルドでパフォーマンスが 2~14% 向上しているようです👏
また、バージョンが上がるにつれて、より大きなパフォーマンス向上が見込める可能性があります。
As of Go 1.22, benchmarks for a representative set of Go programs show that building with PGO improves performance by around 2-14%.
inlining とは?と気になる方もいらっしゃると思ったので、inlining とは何かについても記載します。
簡易的に言うと、関数呼び出しのコードを呼び出し元に展開することによって、関数呼び出しのオーバーヘッドを削減し高速化する、と言う最適化手法のようです。
※ Wikipedia より引用させていただきます🙏
インライン展開(インラインてんかい、英: inline expansion または 英: inlining)とは、コンパイラによる最適化手法の1つで、関数を呼び出す側に呼び出される関数のコードを展開し、関数への制御転送をしないようにする手法。これにより関数呼び出しに伴うオーバーヘッドを削減する。
Go では inlining を発生させる条件もいくつかあるので、参考までに記載しておきます。
- ASTノードの制限 : 関数は十分にシンプルでなければならず、ASTノードの数は予算(80)未満でなければならない
- 複雑な構造の禁止 : 関数にクロージャ、defer、recover、selectなどの複雑なものが含まれていないこと
- go:noinlineプレフィックス : 関数の先頭に go:noinline を付けていないこと
- go:uintptrescapesプリフィックス : 関数の先頭に go:uintptrescapes を付けていないこと
- 関数本体の必要性 : 情報はインライン化の際に失われるからです
などなど複数あります。
Only short and simple functions are inlined. To be inlined a function must conform to the rules:
- function should be simple enough, the number of AST nodes must less than the budget (80);
- function doesn’t contain complex things like closures, defer, recover, select, etc;
- function isn’t prefixed by go:noinline;
- function isn’t prefixed by go:uintptrescapes, since the escape
- information will be lost during inlining;
- function has body;
- etc.
ここまでをまとめると、PGO は一度ビルドしたアプリケーションを実行し、実行結果から profile を取得して、その profile を参考に、次回のビルドでは、例えば呼び出し頻度が多い関数を inlining することでオーバーヘッドを減らし、アプリケーションのパフォーマンス向上を図る手法であることが分かりました。
⛑️ Profile を集める
PGO を実践するには、profile を集める必要があります。ここでは、profile をどのように集め、適用すれば良いかについて見てみましょう。
Go のコンパイラは PGO への入力として CPU pprof プロファイルを期待します。runtime/pprof
, net/http/pprof
などの Go のランタイムが生成したプロファイルをコンパイラの入力として直接使用できるようです。
※ 他のプロファイリングシステムのプロファイルも使用できます。
The Go compiler expects a CPU pprof profile as the input to PGO. Profiles generated by the Go runtime (such as from runtime/pprof and net/http/pprof) can be used directly as the compiler input. It may also be possible to use/convert profiles from other profiling systems. See the appendix for additional information.
最良の profile を得るためには、プロファイルがアプリケーションの実稼働環境における実際の動作を代表するものであることが重要です。代表的でないプロファイルを使用すると、本番環境ではほとんど改善されないバイナリになる可能性が高いです。したがって、本番環境から直接プロファイルを収集することが推奨されています。
For best results, it is important that profiles are representative of actual behavior in the application’s production environment. Using an unrepresentative profile is likely to result in a binary with little to no improvement in production. Thus, collecting profiles directly from the production environment is recommended, and is the primary method that Go’s PGO is designed for.
典型的なワークフローは以下の通りで、
1. PGO なしの初期バイナリをビルドしてリリース
2. プロダクションからプロファイルを収集
3. 更新されたバイナリをリリースするタイミングで、最新のソースをプロファイルと共にビルドする
4. 2へ戻り繰り返す
The typical workflow is as follows:
- Build and release an initial binary (without PGO).
- Collect profiles from production.
- When it’s time to release an updated binary, build from the latest source and provide the production profile.
- GOTO 2
ちなみにこのサイクルは、Automatic Feedback-Directed Optimization をサポートするように設計されているため、そちらも是非参照して見てください。
Go PGO is designed to support an “AutoFDO” style workflow.
📑 検証
本記事では net/http/pprof
を使って、簡単のために local で build し、起動したアプリケーションに負荷を掛けつつ profile を取得し、その profile を使って PGO build したバイナリと元のバイナリでの変化や、起動したアプリケーションの変化を確認しようと思います。
そこで、以下のステップを踏みます。
- Go のコードを用意
- 通常通り build しアプリケーションを起動する
- 負荷試験ツールで負荷を送る
- 負荷を受けている状態で profile を取得する
- 4 で取得した profile を使って PGO build を行う
1. Go のコードの用意
まずは Go のコードを用意します。
今回は endpoint を 2つ用意し、それぞれ内部では異なる計算処理を行わせます。
package main
import (
"fmt"
"log"
"math"
"net/http"
_ "net/http/pprof" // ← profileを取得するために追加
"time"
)
func computation1(w http.ResponseWriter, r *http.Request) {
start := time.Now()
result := operation1(10000)
duration := time.Since(start)
log.Printf("computation1 completed: %f (took %s)\n", result, duration)
fmt.Fprintf(w, "Result of computation: %f (took %s)\n", result, duration)
}
func computation2(w http.ResponseWriter, r *http.Request) {
start := time.Now()
result := operation2(20)
duration := time.Since(start)
log.Printf("computation2 completed: %f (took %s)\n", result, duration)
fmt.Fprintf(w, "Result of computation: %f (took %s)\n", result, duration)
}
func operation1(n int) float64 {
result := 0.0
for i := 1; i <= n; i++ {
result += math.Pow(float64(i), 10) - math.Log(float64(i)+1)
}
return result
}
func operation2(n int) float64 {
if n <= 1 {
return float64(n)
}
return operation2(n-1) + operation2(n-2)
}
func main() {
http.HandleFunc("/computation1", computation1)
http.HandleFunc("/computation2", computation2)
log.Println("Server starting on http://localhost:8080...")
log.Fatal(http.ListenAndServe(":8080", nil))
}
2. build と起動
初回、通常通りの build を行います。
go build -o app_normal main.go
成果物を起動します。
./app_normal
3. 負荷試験ツールを使って負荷を送る
負荷試験ツールである k6 を使って、computation1
に負荷を送ります。
先述の通り、「プロファイルがアプリケーションの実稼働環境における実際の動作を代表するものであること」が重要であるので、今回は特定の時間帯において、computation1
に集中的に負荷が来ると仮定し、検証を進めようと思います。
import http from 'k6/http';
import { sleep, check } from 'k6';
export const options = {
scenarios: {
contacts: {
executor: 'constant-vus',
vus: 50,
duration: '300s',
},
}
}
export default function () {
let res = http.get('http://localhost:8080/computation1');
check(res, {
'is status 200': (r) => r.status === 200,
});
sleep(1); // 各リクエストの間に1秒間の休止
}
4. profile の取得と確認
サーバーを起動し、負荷を掛けている状態の中で profile の取得を試みます。
curl -o profile.pprof http://localhost:8080/debug/pprof/profile?seconds=60
取得した pprof を確認します。
go tool pprof -http=:8081 profile.pprof
※ web 上で確認できる FlameGraph から、意図通りの関数が呼ばれていることを確認しています。
5. PGO を使っての build
Go では main package ディレクトリに default.pgo という名前のプロファイルがある際に、自動的に PGO を有効にします。また、-pgo
フラグを使うことで、PGO に使用するプロファイルへのパスを指定できます。
The Go toolchain will automatically enable PGO when it finds a profile named default.pgo in the main package directory. Alternatively, the -pgo flag to go build takes a path to a profile to use for PGO.
今回は先ほど取得した profile.pprof を -pgo
フラグを使って指定し、 build します。
go build -gcflags="-m -m" -pgo profile.pprof -o app_pgo main.go
これで PGO を使った build ができました。
🦀 諸々比較
ここまでで、PGO を使った build を行うことができました。ここからは、PGO を使った build と通常の build を比較していきたいと思います。
そこで、以下のステップを踏みます。
- build にかかる時間の比較
- バイナリサイズの比較
- inlinging の比較
- 負荷をかけてみての比較
1. build にかかる時間の比較
まず、通常の build と PGO で build するのにかかる時間を比較していきます。
build 時間の計測
cache を削除した上で複数回 build を実行し、かかった時間を比較します。
go clean -cache
通常時の build
time go build -o app_normal
> go build -o app_normal main.go 12.25s user 2.73s system 317% cpu 4.712 total
> go build -o app_normal main.go 11.68s user 2.48s system 334% cpu 4.228 total
> go build -o app_normal main.go 11.72s user 2.46s system 343% cpu 4.133 total
> go build -o app_normal main.go 11.70s user 2.52s system 346% cpu 4.105 total
> go build -o app_normal main.go 11.79s user 2.52s system 341% cpu 4.196 total
pgo を使った build
time go build -pgo profile.pprof -o app_pgo main.go
> go build -pgo profile.pprof -o app_pgo main.go 12.13s user 2.51s system 328% cpu 4.453 total
> go build -pgo profile.pprof -o app_pgo main.go 11.85s user 2.52s system 344% cpu 4.174 total
> go build -pgo profile.pprof -o app_pgo main.go 11.83s user 2.50s system 341% cpu 4.191 total
> go build -pgo profile.pprof -o app_pgo main.go 11.78s user 2.51s system 340% cpu 4.202 total
> go build -pgo profile.pprof -o app_pgo main.go 11.88s user 2.47s system 329% cpu 4.351 total
build 時間の計測結果
対象 | user 平均 | system 平均 | cpu 平均 | total 平均 |
---|---|---|---|---|
normal | 11.828s | 2.542s | 336.2% | 4.2748秒 |
pgo | 11.894s | 2.502s | 336.4% | 4.2742秒 |
若干通常 build の方が早く build が終わっているように思います。
2. バイナリサイズの比較
build した結果のバイナリサイズを比較します。
# バイナリサイズの比較
ls -lh app_normal app_pgo
> ... 7.5M May 14 00:14 app_normal
> ... 7.5M May 14 00:17 app_pgo
結果
今回は比較的小さなアプリケーションコードであるため、バイナリサイズに差はないように見えました。
3. inlinging の比較
build 時に -gcflags="-m"
を使うことでコンパイルの詳細を見ることができるため、その内容をテキストに起こします。
go build -gcflags="-m -m" -o app_normal main.go 2> inline_normal.txt
go build -gcflags="-m -m" -pgo profile.pprof -o app_pgo main.go 2> inline_pgo.txt
テキストの diff を取ります。
diff inline_normal.txt inline_pgo.txt
以下は差分です。
2c2
< ./main.go:28:6: cannot inline operation1: function too complex: cost 156 exceeds budget 80
---
> ./main.go:28:6: can inline operation1 with cost 408 as: func(int) float64 { result := 0; for loop; return result }
5c5,6
< ./main.go:12:6: cannot inline computation1: function too complex: cost 345 exceeds budget 80
---
> ./main.go:12:6: cannot inline computation1: function too complex: cost 696 exceeds budget 80
> ./main.go:14:22: inlining call to operation1
6a8
> ./main.go:14:22: inlining call to math.Pow
差分の考察
この diff の出力から、いくつかの結果が読み取れそうです。
operation1 関数
- 通常ビルドでは、operation1 関数はインライン化されていません。その理由は、関数が複雑であり、cost が 156 と cost 80 を超えているためです。
- PGOビルドでは、operation1 関数はインライン化されており、インライン化のコストは 408 とされています。これは、PGOが提供する実行データに基づいて最適化が行われた結果、インライン化が有益であると判断されたためです。
computation1 関数
- 通常ビルドでは、computation1 関数もまたインライン化されていません。コストが 345 と cost を大きく超えているためです。
- PGOビルドでは、computation1 関数自体はインライン化されませんが、先ほど確認した通り、内部で operation1 関数の呼び出しがインライン化されています。
追加のインライン化
- PGOビルドでは、math.Pow 関数の呼び出しが computation1 内でインライン化されています。これもまた、PGOによるデータが示す特定のシナリオ下での計算効率の改善を反映しています。
4. 負荷をかけてみての比較
computation1 に対する負荷検証
まずは normal build の方で endpoint1 に対して負荷を 1分間、複数回掛けてみます。
# アプリケーションの起動
./app_normal
# k6 で負荷をかける
k6 run k6.js
# 出力された結果
> http_req_duration..............: avg=2.71ms min=261µs med=2.26ms max=12.51ms p(90)=5.18ms p(95)=6.46ms
> http_req_duration..............: avg=3.55ms min=255µs med=2.39ms max=18.97ms p(90)=8.71ms p(95)=11.43ms
> http_req_duration..............: avg=3.29ms min=242µs med=1.86ms max=16.88ms p(90)=9.39ms p(95)=11.68ms
> http_req_duration..............: avg=2.93ms min=271µs med=1.45ms max=17.95ms p(90)=8.06ms p(95)=10.02ms
> http_req_duration..............: avg=2.33ms min=259µs med=1.75ms max=11.62ms p(90)=4.68ms p(95)=6.05ms
続いて pgo build の方で endpoint1 に対して負荷を 1分間、複数回掛けてみます。
# アプリケーションの起動
./app_pgo
# k6 で負荷をかける
k6 run k6.js
# 出力された結果
> http_req_duration..............: avg=2.79ms min=252µs med=1.64ms max=63.65ms p(90)=4.53ms p(95)=6.47ms
> http_req_duration..............: avg=2.67ms min=277µs med=1.56ms max=19.01ms p(90)=5.76ms p(95)=9.45ms
> http_req_duration..............: avg=2.75ms min=252µs med=2.29ms max=22.05ms p(90)=5.35ms p(95)=6.37ms
> http_req_duration..............: avg=2.2ms min=275µs med=1.65ms max=10.92ms p(90)=4.62ms p(95)=5.77ms
> http_req_duration..............: avg=3.32ms min=308µs med=2.45ms max=15.62ms p(90)=7.5ms p(95)=10.15ms
computation1 に対する負荷検証の結果
対象 | avg 平均 | min 平均 | med 平均 | max 平均 | p(90) 平均 | p(95) 平均 |
---|---|---|---|---|---|---|
normal | 2.962ms | 257.6µs | 1.942ms | 15.586ms | 7.204ms | 9.128ms |
pgo | 2.746ms | 272.8µs | 1.918ms | 26.25ms | 5.552ms | 7.642ms |
avg だけ見れば、7.29% 程改善されたことが分かりました👏
ただし、min, max の値などは必ずしも改善された訳ではないことも分かります。
computation2 に対する負荷検証
次に通常 build の方で endpoint2 に対して負荷を 1分間、複数回掛けてみます。
# アプリケーションの起動
./app_normal
# k6 で負荷をかける
k6 run k6.js
# 出力された結果
> http_req_duration..............: avg=2.4ms min=113µs med=1.47ms max=18.44ms p(90)=5.39ms p(95)=8.41ms
> http_req_duration..............: avg=2.69ms min=113µs med=1.59ms max=28.8ms p(90)=6.67ms p(95)=8.78ms
> http_req_duration..............: avg=2.33ms min=125µs med=1.27ms max=17.21ms p(90)=5.79ms p(95)=8.05ms
> http_req_duration..............: avg=2.78ms min=94µs med=1.86ms max=25.74ms p(90)=6.35ms p(95)=8.05ms
> http_req_duration..............: avg=2.35ms min=98µs med=1.4ms max=22.7ms p(90)=5.89ms p(95)=7.97ms
続いて pgo build の方で endpoint2 に対して負荷を 1分間、複数回掛けてみます。
# アプリケーションの起動
./app_pgo
# k6 で負荷をかける
k6 run k6.js
# 出力された結果
> http_req_duration..............: avg=2.16ms min=113µs med=1.31ms max=16.5ms p(90)=4.68ms p(95)=6.94ms
> http_req_duration..............: avg=2.54ms min=85µs med=1.55ms max=19.9ms p(90)=6.15ms p(95)=7.98ms
> http_req_duration..............: avg=2.57ms min=95µs med=1.34ms max=23.75ms p(90)=6.58ms p(95)=8.84ms
> http_req_duration..............: avg=2.36ms min=112µs med=1.57ms max=19.5ms p(90)=5.16ms p(95)=7.78ms
> http_req_duration..............: avg=2.6ms min=109µs med=1.55ms max=22.71ms p(90)=6.41ms p(95)=8.54ms
computation2 に対する負荷検証の結果
対象 | avg 平均 | min 平均 | med 平均 | max 平均 | p(90) 平均 | p(95) 平均 |
---|---|---|---|---|---|---|
normal | 2.510ms | 108.6µs | 1.518ms | 22.578ms | 6.018ms | 8.252ms |
pgo | 2.446ms | 102.8µs | 1.464ms | 20.472ms | 5.796ms | 8.016ms |
こちらも avg だけ見れば、2.55% 程改善されたことが分かりました🤔
こちらは全体的に改善されていそうに見えます。
🚨 注意点
ここまでは、通常 build と PGO を使った build の結果の比較を行ってきました。
ここからは継続的に PGO を使った build を行う際の注意点について確認していきます。
そこで、以下のステップを踏みます。
- profile が代表的な profile ではない可能性を考慮する
- 複数の profile を使う
- リファクタによる影響を考慮する
- build にかかる時間
- build 結果の binary の比較
1. profile が代表的な profile ではない可能性を考慮する
先述の通り、profile を取得する際には、動作しているアプリケーションの実稼働環境における実際の動作を代表するものであることが重要になります。今回検証した通り、/debug/pprof/profile?seconds=30
を使って、プロファイルを取得するのは良い方法ですが、この取得した profile が代表的な profile ではない可能性があります。
ドキュメントにあるように、アプリケーションの動作環境は以下のように動く可能性があります。
- あるインスタンスは、通常は高負荷であるにもかかわらず、プロファイルを取得した時点では何もしていないかもしれません
- トラフィックパターンは一日を通して変化する可能性があり、一日を通して挙動が変化します
- インスタンスは長時間のオペレーションを実行することがあります(例えば、オペレーションAを5分実行した後、オペレーションBを5分実行するなど)。30秒プロファイルでは、単一の操作タイプしかカバーできない可能性が高いです
- インスタンスが公平にリクエストを受け取らない可能性があります
The simplest way to start with this is to add net/http/pprof to your application and then fetch /debug/pprof/profile?seconds=30 from an arbitrary instance of your service. This is a great way to get started, but there are ways that this may be unrepresentative:
- This instance may not be doing anything at the moment it gets profiled, even though it is usually busy.
- Traffic patterns may change throughout the day, making behavior change throughout the day.
- Instances may perform long-running operations (e.g., 5 minutes doing operation A, then 5 minutes doing operation B, etc). A 30s profile will likely only cover a single operation type.
- Instances may not receive fair distributions of requests (some instances receive more of one type of request than others).
2. 複数の profile を使う
上記を回避するために、異なるインスタンスから、異なるタイミングで複数の profile を取得し、単一の profile に merge することが可能です。これにより、個々のインスタンスの差の影響を抑えられる可能性があります。
A more robust strategy is collecting multiple profiles at different times from different instances to limit the impact of differences between individual instance profiles. Multiple profiles may then be merged into a single profile for use with PGO.
Many organizations run “continuous profiling” services that perform this kind of fleet-wide sampling profiling automatically, which could then be used as a source of profiles for PGO.
更に profile を取得し、merge する
今回は通常 build normal_app
を起動した上で、computation2
エンドポイントに対して負荷を送り、profile2.pprof
を取得します。
curl -o profile2.pprof http://localhost:8080/debug/pprof/profile\?seconds\=60
先程取得した profile.pprof
と profile2.pprof
を merge します。
go tool pprof -proto profile.pprof profile2.pprof > merged.pprof
merged.pprof を使って PGO build します。
go build -gcflags="-m -m" -pgo merged.pprof -o app_pgo_merged main.go 2> inline_pgo_merged.txt
このようにして、merge した profile を使って PGO build することが可能です。
※ 本来は merge した profile で PGO build した際の変化を見たかったのですが、検証した上で変化が見られなかったので割愛します🙇
3. リファクタによる影響を考慮する
プロファイルを取得するサイクルと同時に、開発は進みます。Go では、連続する PGO ビルドの中で性能が変動しすぎることを防ぐために、「Iterative stability」を採用しています。
Go のコンパイラは、プロファイルの結果による大きなばらつきを防ぐため、PGO 最適化に対して保守的なアプローチを取っているようです。
Iterative stability is the prevention of cycles of variable performance in successive PGO builds (e.g., build #1 is fast, build #2 is slow, build #3 is fast, etc). We use CPU profiles to identify hot functions to target with optimizations. In theory, a hot function could be sped up so much by PGO that it no longer appears hot in the next profile and does not get optimized, making it slow again. The Go compiler takes a conservative approach to PGO optimizations, which we believe prevents significant variance.
上記の通り、Go の PGO では、大きなばらつきを防ぐために、過去のコードから取得したプロファイルを、変更後のコードにマッチさせ続けるよう試みます。
その過程で、変更の仕方によっては、以前に取得したプロファイルとのマッチに影響を与えない場合もあれば、影響を与える場合もあります。これを確認しましょう。
マッチングに影響を与えない可能性がある場合
- ホット関数外のファイルでの変更
- 関数を同じ package 内の別のファイルに移動する
マッチングに影響を与える可能性がある場合
- ホット関数内の変更
- 関数の名前の変更
- 関数を別の package に移動する
Many common changes will not break matching, including:
- Changes in a file outside of a hot function (adding/changing code above or below the function).
- Moving a function to another file in the same package (the compiler ignores source filenames altogether).
Some changes that may break matching:- Changes within a hot function (may affect line offsets).
- Renaming the function (and/or type for methods) (changes symbol name).
- Moving the function to another package (changes symbol name).
4. build にかかる時間
先程も確認しましたが、PGO build を有効にすると build 時間が長くなる可能性があります。
Enabling PGO builds will likely cause measurable increases in package build times. The most noticeable component of this is that PGO profiles apply to all packages in a binary, meaning that the first use of a profile requires a rebuild of every package in the dependency graph.
5. build 結果の binary の比較
先程の確認では変化がありませんでしたが、PGO build を有効にすると binary size が多少大きくなる可能性があります。
PGO can result in slightly larger binaries due to additional function inlining.
✅ まとめ
今回は、PGO とは何か、PGO の使い方についてまとめてきました。
プロファイルを正しく取得する過程を踏むことで、アプリケーションが最適化される可能性があるのは素晴らしいことだと感じます。同時に、何度も記載した通り、「プロファイルがアプリケーションの実稼働環境における実際の動作を代表するものであること」が重要であるため、プロファイルの正しさにも注目しつつ、積極的に PGO を活用していきたいと考えます。
今後は、プロダクション環境に反映させることや、その上でうまくサイクルを回すことに注目していきたいと考えています。また、PGO は比較的新しい機能であるため、Go のバージョンが上がるにつれてどのように進化していくか楽しみです!
ここまでご精読いただき、ありがとうございました。
説明が不十分な点や誤字脱字などがございましたら、ご指摘いただければ幸いです🙌
それでは、良い PGO ライフを!
📖 参考
Discussion