🔥

Profile-guided Optimization (PGO) を本番環境に導入するぞ!!! (前編)

2023/12/25に公開

はじめに

皆さんGo1.21は使っていますでしょうか?

Go1.21ではスライスの操作を簡単にするslicesパッケージ等、ジェネリクスを使ったパッケージがいくつか標準パッケージとしてリリースされました。他にもProfile-guided Optimization (PGO)がGAになりました。

今回のブログでは、Go1.21でGAとしてリリースされたPGOについて紹介したいと思います!

実際にPGOを本番環境に導入してみたいと考えており、本番環境に実際に導入する前にローカルの環境でPGOを導入して効果があるのかどうかを調査してみました!

それらの調査結果を前編としてまとめてました!実際に本番環境に導入することができれば、その内容を後編にまとめたいと思います!

この記事を読んで知れること

  • Profile-guided Optimization (PGO) とは何か
  • PGOの導入の仕方
  • PGOをWebサービスに導入したらどの程度の改善が見込まれるのか
  • PGOを導入する際に考慮すべき点

Profile-guided Optimization (PGO) とは?

まずPGOとは何か?ということですが、Goの公式ブログProfile-guided optimization in Go 1.21に詳細が載っています。

上記のブログを読んだ上で、筆者なりにまとめた考えを以下に記載します。

  • Goのコンパイラはビルドするバイナリがよりよりパフォーマンスを出すための最適化をいくつか行う (インライン展開、エスケープ解析、etc…)
  • リリースごとに改善を重ねてきたが容易な仕事ではない
  • コンパイラはコードが実際に本番環境でどのように使われるかについての情報が無いまま最適化を行う
  • ビルド時に本番環境でのプロファイルを提供することでより良い最適化を行うことをPGOと呼ぶ

実際にPGOを導入することで、本来なら最適化されないコードも最適化されるようになっているようです (参考: https://github.com/golang/go/blob/2da8a55584aa65ce1b67431bb8ecebf66229d462/src/cmd/compile/internal/inline/inl.go#L310)

どれくらいの改善が期待されるのか?

こちらの公式ドキュメントに以下のような記載がありました。

As of Go 1.21, benchmarks for a representative set of Go programs show that building with PGO improves performance by around 2-7%.

2~7%ほどの改善が期待できるそうです🎉

どのようにしてPGOを利用するのか?

PGOは以下のようにして利用することが可能です。

  1. PGOを有効にしないバイナリを本番環境にリリースする
  2. 本番環境でのプロファイルを集める
  3. 集めたプロファイルを元に新しいバイナリをリリースする
  4. 2に戻る

4番目のステップに2に戻ると書いてあるように、PGOは一回きりの最適化ではなく、継続的に行う必要がある最適化ということがわかると思います。

ブログの例で実際に試してみる

まずはProfile-guided optimization in Go 1.21のブログの例を試してみます。

細かい実行コマンドは上記のブログを参考にしていただいて、このブログではざっくりと要点だけを紹介したいと思います。

GoのブログではMarkdown形式のファイルをHTML形式に変換するサービスをPGOの導入対象としています。そのサービスをローカル上で立ち上げた上で、net/http/pprofを使用してプロファイルを集めます。集めたプロファイルを元にPGOを有効化したバイナリを生成し、PGOを有効化していないバイナリとベンチマークをとって性能を比較しています。


筆者が実際にやってみた結果は以下のようになりました。

PGOなし PGOあり vs base
1操作当たりの時間 172.3µs ± 0% 172.9µs ± 1% +0.36% (p=0.027 n=40)

注目したいのはvs baseの箇所で、この値はPGOを有効にしなかったバイナリに対して、PGOを有効にしたバイナリのパフォーマンスがどうだったかの結果になっています。

結果としては+0.36%で、PGOを有効にした方がパフォーマンスが悪くなったことになります😞
とはいえ、実際の数値の差としては0.6µsなのでほぼ誤差の範囲とも解釈できます。ブログの例だとPGOの有効性はなさそうでした。

弊社のサーバーの例で実際に試してみる

さて、いよいよここからが本題です。PGOを弊社のサーバーに適用してみたいと思います。
いきなり本番環境に導入するのは色々時間がかかって大変なので、まずはローカルで可能な限り本番環境に近い環境を再現した上でPGOを導入してみたいと思います。

PGOを導入するためのステップは以下の4つでした。

  1. PGOを有効にしないバイナリを本番環境にリリースする
  2. 本番環境でのプロファイルを集める
  3. 集めたプロファイルを元に新しいバイナリをリリースする
  4. 2に戻る

この4つのステップを弊社のサーバーに適用するための検証として以下のようにしてみます。

  1. PGOを有効にしないバイナリを本番環境にリリースする
    • コードを書き換えて、ローカル環境で本番環境と同じバイナリを使用できるようにする
  2. 本番環境でのプロファイルを集める
    • よく呼ばれるAPIの上位3つを調査する
    • locust + boomer (後述) を利用して負荷をかけつつ、プロファイルを取得する
  3. 集めたプロファイルを元に新しいバイナリをリリースする
    • 取得したプロファイルを元に新しいバイナリをビルドする
  4. 2に戻る
    • 2に戻るのではなく、ベンチマークをとってパフォーマンスを比較する

ステップ1: PGOを有効にしないバイナリを本番環境にリリースする

こちらのステップに関しては、ローカル環境で本番環境と同じバイナリがビルドできて動かせれば良いので、詳細は割愛します。

ステップ2: 本番環境でのプロファイルを集める

このステップに関しては、可能な限り本番環境に近い負荷をかけてみてプロファイルを取得してみました。

まずはよく呼ばれるAPIの上位3つを調査しました。ここでは単純にAPI 1, API 2, API 3としておきます (数字が小さい方がよく呼ばれているAPIになります)。さらにこれらのAPIについて以下の項目を調査しました。

  • 全体でどれくらいの回数呼ばれているのか?
  • セッションあり/なしではどれくらい呼ばれているのか?
  • 1秒当たりのリクエスト数はどれくらいなのか?

上記の調査結果と稼働しているサーバーの数等を比較して、ローカル環境の1サーバーに対してそれぞれのAPIをどれくらいの割合で、どれくらいの量の負荷をかけるか概算します。


概算ができたところで、locustboomerを使って負荷をかけてプロファイルを取得していきます。boomerはGoで負荷のシナリオを記述できる点とサーバー側のコードをいじることなくプロファイルが取得できるようになっている点が個人的には良かったです。

以下のコマンドでlocustを起動しつつ

❯ locust --master -f dummy.py

boomerで負荷をかけながらプロファイルを取得していきます

❯ go build -o boomer main.go && ./boomer --max-rps 10 -cpu-profile cpu.pprof -cpu-profile-duration 60s

どれくらい間のプロファイルを取得するかですが、今回の検証では適当に60秒としています。

ステップ3: 集めたプロファイルを元に新しいバイナリをリリースする

PGOを有効にするには -pgo=auto のフラグを go build コマンドに渡すだけになります。
-pgo=autoのフラグが指定されていた場合、default.pgoというプロファイルがあればそちらを利用してビルドが行われるようになります。

プロファイルへのパスを明示的に指定することも可能です。PGOを有効にしたビルドに関しての詳細はこちらをみていただくと良いと思います。

ステップ4: ベンチマークをとってパフォーマンスを比較する

いよいよベンチマークをとってパフォーマンスの比較をしてみます。

今回のベンチマークは単純にAPIを指定回数分呼ぶというコードにしています。本来ならステップ2で調査した呼ばれる回数や割合に応じてベンチマークをかくべきですが、簡略化のために指定回数分呼ぶというコードにしました。


結果は以下のようになりました。

テストケース PGOなし PGOあり vs base
API 1 (セッションあり) 6.106ms ± 17% 5.447ms ± 38% -10.79% (p=0.021, n=40)
API 1 (セッションなし) 1.793ms ± 23% 1.799 ± 19% ~ (p=0.844, n=40)
API 2 (セッションあり) 2.625ms ± 8% 2.674ms ± 33% ~ (p=0.589, n=40)
API 2 (セッションなし) 2.110ms ± 32% 2.205ms ± 26% ~ (p=0.663, n=40)
API 3 (セッションあり) 6.208ms ± 5% 5.715ms ± 5% -7.95% (p=0.028, n=40)
API 3 (セッションなし) 1.704ms ± 41% 2.137ms ± 32% ~ (p=0.677, n=40)

単純に結果だけをみると、API 1とAPI 3のセッションありの場合に関してはPGO導入で7~10%ほどのレイテンシーの向上が見込めることになります🎉🎉🎉

考察

単純な数値の結果は記載しましたが、それ以上にとても興味深い結果になったと思いました。結果を踏まえた上で、もっと考察すべき点は以下のようになるかなと思います。

  1. セッションなしの場合はPGOによる改善が期待されなかったのはどういう理由からなのか?
  2. 公式ドキュメントでは2~7%のパフォーマンスの向上が期待されるとあるが、実際には7.95%と10.79%とそれ以上の数値が出ているのはどういう理由なのか?
  3. 改善が期待されるAPI 1とAPI 3では、実際にどの部分が高速化されているのか?

これらの観点に関してはもう少し調査してみたいと思います!

PGOに関する注意事項

最後にPGOに関する以下の2つの注意事項について、述べておきたいと思います。

  • ソース安定性
  • 反復安定性

ソース安定性について

プロファイルを取得するコードと、取得したプロファイルを使用してビルドされるコードは、少し差異が出てくるケースがあります。なので、取得したプロファイルを使用してビルドされるコードの一部は、PGOによる最適化は行われないことになります。ただし、それは局所的なものであり、全体的に考えればPGOによる最適化が適用される関数が多いので、そこまで問題にはならないのではないかと思われます。

ただし、大規模なリファクタや関数名の変更、パッケージを跨いだ関数の移動等を行うとPGOによる最適化がうまく行われない可能性があるそうで、継続的にプロファイルを集めることが大事だと思います。

反復安定性

ある関数がPGOによって最適化された場合、次のプロファイルでは既に最適化済みなので最適化されずにパフォーマンスに影響が出る可能性があります。Goのは上記の問題が出ないように保守的にPGOを使用しているようです。このことを反復安定性と呼びます。

まとめ

  • 本番環境に実際に導入する前段階の調査として、ローカルに本番環境と近い環境を再現し、PGOを導入した
  • よく呼ばれるAPIの上位3のうち、2つのAPIに関して7~10%ほどのレイテンシーの向上が見込めた
  • いくつか考察した方が良い点や反復安定性についてもまだ調査が不十分なので時間があれば調査してみたい

Discussion