PGO活用による認可APIのパフォーマンス改善検証レポート

サムネイル

はじめに

こんにちは!会津大学2年生のharukunです(自己紹介はこちら)。
合同会社DMM.comのDMM Core 開発コースに1ヶ月間参加しています。
所属チームは、プラットフォーム開発本部 第3開発部 共通基盤グループ 認可チームです。

プラットフォーム開発本部では、DMMの各プロダクトが共通で利用できるマイクロサービス群を提供しています。
認可チームでは、マイクロサービスの認可基盤として認可API「Topgun API」を開発・運用しています。

今回のインターンでは、このTopgun APIを対象に、PGO(Profile-Guided Optimization)を用いたパフォーマンス向上のための調査および検証を実施しました。

本記事では、実施したこと、工夫したこと、実施により発見できた問題について紹介していきます。運用方法などについては取り上げないため、あらかじめご了承ください。

検証実施の背景

認可チームで保守・運用している機能はDMMのプラットフォームを構築するマイクロサービスのコアの部分になっています。
認可サーバーには各サービスから大量のリクエストが送られてきます。

インターン期間中の最大RPSは下記の 6140 RPS(Request Per Second)でした。
peak

Kubernetesのpodも30個立っていて、このような大規模なシステムを維持するには莫大なコストがかかるとチームの方から聞きました。
podが1つでも減ればコストが削減でき、podを減らすためにはパフォーマンスの改善が必要です。

PGOとは?

PGO(Profile-guided optimization)とは、プロファイルを用いてパフォーマンス改善を目指すコンパイラ最適化手法です1。実行時の情報に基づいてコンパイル時に最適化を行う点で既存の最適化技術と差別化できます。

PGOでは複数の最適化が行われます。

  • 定数伝播(コンパイル時に数式を評価して定数値に置き換える)
  • エスケープ解析(ローカルスコープのオブジェクトへのヒープ割り当てを回避し、GC のオーバーヘッドを回避する)
  • インライン展開(単純な関数の本体を呼び出し元にコピーすることで関数呼び出しのオーバヘッドを削減する。インライン展開したうえで上記二つの最適化を追加で行う場合もある)
  • デバーチャライゼーション(型が静的に決定できるインターフェース値への間接呼び出しを、具体的なメソッドへの直接呼び出しに変換する)

これらの最適化は通常のビルドでも行われますが、PGOではプロファイルから"ホットスポット"と呼ばれる、コード上で頻繁に呼び出される箇所を探し出し重点的に最適化を行います。
例えば、通常のビルドではインライン展開されない、コスト(ASTのノードの数)が大きい関数についてもPGOビルドではプロファイル次第でインライン化されます。

func calc(w http.ResponseWriter, r *http.Request) {
    result := fibonacci(30)
    fmt.Fprintf(w, "Result of computation: %d\n", result)
}

func fibonacci(n int) int {
    if n <= 1 {
        return n
    }
    return fibonacci(n-1) + fibonacci(n-2)
}
// 通常ビルドではインライン展開されない
cannot inline fibonacci: function too complex:
cost 132 exceeds budget 80

// PGOビルドだとインライン展開される
can inline fibonacci with cost 132 as:
func(int) int { if n <= 1 { return n }; return fibonacci(n - 1) + fibonacci(n - 2) }

上記は、コードとビルドログの例です。
fibonacci関数はコストが大きいため通常ビルドではインライン展開されませんが、PGOではプロファイルからfibonacci関数が再帰呼び出しで頻繁に呼び出されていることがわかるため、インライン展開されています。

PGOのメリット

  • パフォーマンスは2~14%向上する可能性がある
  • 再現性のあるビルド(プロファイルのフォーマットはOS, アーキテクチャに依存しないので、プロファイルをGitHubなどで共有すればチームメンバーも最適化されたビルドができる)

PGOのデメリット

  • ビルド時間が長くなる
  • プロファイルがちゃんと実環境に即したものでないと最適化によるパフォーマンス向上は見込めない

PGOの使い方

PGOを使うワークフローは以下のようになります。 flowchart

1. PGO なしの初期バイナリをビルドしてリリース

go build

いつも通りビルドするだけです。

2. (本番環境から)プロファイルを収集

プロファイルとは?

そもそもプロファイルとはなんぞや?という人もいると思うので簡単に説明します。
プロファイル(プロファイリング)とは、CPUの使用率やメモリ使用量、関数の呼び出し回数、実行時間などを測定するプロセスです。プロファイルにより、プログラム上のボトルネックを特定できます。

プロファイルを収集する手段はいくつかあります。

  • runtime/pprof
  • net/http/pprof
  • Datadogのprofiler etc...

runtime/pprofはGoの標準ライブラリで用意されているパッケージです。
net/http/pprofはruntime/pprofのラッパーの様なもので、サーバーにプロファイル収集用エンドポイントを追加して使うことができます。
DatadogのprofilerはDatadogのAPMに組み込まれているプロファイラーです。

今回の検証では、ローカル・クラウドのどちらでも試せるnet/http/pprofを使うことにしました。

公式ドキュメントなどには、

import (
    _ "net/http/pprof" // ← profileを収集するために追加!
)

と書いてありますが、GoのWebフレームワークであるechoを使っている場合、pprofが内部で使うhttp.DefaultServeMux()が自動で登録されないのでプロファイル収集用のエンドポイントを自分で登録する必要があります。

   // profile収集用
    e.GET("/debug/pprof/*", echo.WrapHandler(http.HandlerFunc(pprof.Index)))
    e.GET("/debug/pprof/profile", echo.WrapHandler(http.HandlerFunc(pprof.Profile)))
    e.GET("/debug/pprof/heap", echo.WrapHandler(http.HandlerFunc(pprof.Handler("heap").ServeHTTP)))

無事ビルドできたら、負荷を流しながらcurl "<YOUR_BASE_URL>/debug/pprof/profile?seconds=30"を実行すればプロファイルが収集できるはずです2
secondsパラメータでプロファイリング時間を指定できます(指定しなかった場合は30秒になります)。

3. 更新されたバイナリをリリースするタイミングで、最新のソースをプロファイルと共にビルドする

 go build -pgo=$LATEST_PROFILE

2で収集したプロファイルを、Go buildのpgoオプションに引数として渡してあげるとPGOでビルドされたバイナリが吐き出されます。

検証

環境

エンドポイント選定

検証を実施するにあたって、負荷をかけるエンドポイントを選定する必要がありました。
認可チームでは複数のAPIを運用しているのですが、その中から

  • なるべくI/O処理少なめ
    調査の結果、I/O処理にはPGOでのパフォーマンス改善があまり期待できないと推測したため
  • RPS高め
    実際に導入するにあたり、RPSの高いエンドポイントでパフォーマンスを改善できるとより説得力があると考えたため
  • レイテンシー高め
    PGOで最適化が行われた時にもっとも効果が見えやすいのはレイテンシーだと考えたため

上記の要件で3つのエンドポイントに絞りました。
ローカル、開発環境で検証し、最終的に一番レイテンシーが高かったエンドポイントを使うことにしました。
レイテンシーを一番重視したのは、PGOで最適化が行われた時にもっとも効果が見えやすいのはレイテンシーだと考えたからです(平均レイテンシーが30msだと、PGOで数ms改善されても誤差の範囲内になってしまう可能性があるため)。

検証項目

以下の負荷試験を実施し、PGOビルドと通常ビルドでのパフォーマンスの違いを比較しました。

  • 選定したエンドポイントに対して、10rpsで10分間負荷を流す
    検証手順があっているのかの確認も兼ねた試験。中程度の負荷をかける(今思えばもう少し負荷高くしても良かったかも...)
  • 選定したエンドポイントに対して、負荷を20rpsで10分間負荷を流す
    上記の試験の比較として、負荷を上げた場合に試験結果とプロファイルにどのような変化があるのかを確認する
  • 選定したエンドポイントに対して、負荷を1rpsで10分間負荷を流す
    上記の試験と比較して、負荷を下げた場合に試験結果とプロファイルにどのような変化があるのかを確認する
  • 全エンドポイントに対して、合計100rpsで20分間負荷を流す
    今回実施した検証の中で一番本番環境に近い想定の試験。この試験の結果、PGOビルドでパフォーマンスが向上していれば本番環境でも効果が見込めると考えた

これら4つの項目に対し、通常のビルド・PGOのビルドで各3回ずつ負荷試験を実施して平均をとりました。

検証手順

verification_flow

手順はほとんどPGOの使い方と同じなので割愛しますが、上記のワークフローに沿って手順書を作成し、実施しました。

manual
作成した手順書から一部抜粋

検証結果

プロファイルの比較

プロファイルの比較には、情報をWebUIで可視化できるpprofを使いました。

負荷試験中にプロファイルを収集した後、

go tool pprof -http=":8081" {収集したプロファイルのパス}}

上記コマンドを実行すると、WebUIが立ち上がります。 画面左上の"View"からいろいろな形式でプロファイルを可視化できるのですが、個人的に一番見やすいと思うのは"Flame Graph"です。

flamegraph
fibonacci関数の通常ビルドのプロファイル例

右上に処理の合計時間(計測時間のうち、処理の実行に使われた時間の合計。例えば、100msの処理が計測時間中に10回実行された場合、合計時間は1000msになる)が表示され、関数のブロックはその実行時間をブロックの幅で表しています。

関数の内部で呼ばれている関数は下方向に伸びていきます。
例えば、上記の画像ではnet/http.(*conn).serve関数の下にnet/http.serverHandler.ServeHTTP関数があり...といった感じです。

この例では、main.cpu関数で特に時間がかかっているとわかります。
また、内部で呼ばれているmain.fibonacci関数が特に大きな割合を占めていることを確認できます。
このようにして、処理全体の中でどの関数がパフォーマンスのボトルネックとなっているかを把握できます。

同様に、PGOビルドのプロファイルも可視化して通常ビルドと比較すると、最適化の効果を確認できます。

comp_inline
左: 通常ビルド、右: PGOビルドのプロファイル例

若干わかりづらいのですが、赤丸で囲った部分、func A関数とその内部で使われている関数func B, func Cのブロックが、PGOビルドでは黒線で区切られておらず、インライン化されていることがわかります。

また、特にプロファイル上で改善が顕著だったのはGC周りの関数です。
ほぼ全てのプロファイルで実行時間の短縮が確認できました。
インライン化されている様子や、実行時間が若干短縮されエスケープ解析などPGOによる最適化の効果が確認できます。

gc
左: 通常ビルド、右: PGOビルドのプロファイル例

プロファイルを確認するうえで一つ注意しておきたいのは、あくまでも確認したいことはボトルネックの特定やPGOによる最適化の影響であり、パフォーマンスの改善はプロファイルの実行時間から正確に把握できないということです。
通常ビルド・PGOビルドに関わらず、実行時間の上振れ下振れは必ず発生します。
今回の検証では負荷を10分から20分かけていますが、プロファイルを計測した時間はそのうちの1分間です。
プロファイルを計測したタイミングでたまたま実行時間の下振れがおきていると、プロファイル上ではPGOビルドの実行時間が通常ビルドより長くなってしまう可能性もあります。
パフォーマンスが改善されているかどうかは、負荷試験の結果から判断するようにしました。

負荷試験の結果(値は3回行なった試験の平均)

検証項目 通常ビルド PGOビルド 変化
10rpsの負荷 RPS: 9.95
Average: 200.33ms
p50: 200ms
p99: 256.66ms
RPS: 10.02
Average: 198.77ms
p50: 203.33ms
p99: 255.33ms
Average: -1.56ms(-0.78%)
p50: +3.33ms(+1.66%)
p99: -3.33ms(-1.3%)
20rpsの負荷 RPS: 19.85
Average: 201.03ms
p50: 200ms
p99: 273.3ms
RPS: 20.09
Average: 196.33ms
p50: 196.66ms
p99: 263.3ms
Average: -4.7ms(-2.33%)
p50: -3.34ms(-1.66%)
p99: -10ms(-3.66%)
1rpsの負荷 RPS: 0.97
Average: 207.28ms
p50: 206.66ms
p99: 253.3ms
RPS: 1.05
Average: 178.59ms
p50: 176.66ms
p99: 240ms
Average: -28.69ms(-13.84%)
p50: -30ms(-14.51%)
p99: -13.3ms(-5.25%)
全エンドポイントへ100rpsの負荷 RPS: 101.23
Average: 87.91ms
p50: 38.66ms
p99: 586.7ms
RPS: 100.94
Average: 89.17ms
p50: 38.66ms
p99: 546.7ms
Average: +1.26ms(+1.43%)
p50: +-0ms(+-0%)
p99: -40ms(-6.82%)

考察

検証結果からわかったことをまとめると以下のようになります。

  • 負荷が極端に低い場合、プロファイル・負荷試験の計測結果に大きなブレが生じ、結果に信頼性がなくなる(よって以降は負荷が低い場合の結果は考察しない)
  • 平均レイテンシーはほぼ変化なし、もしくは若干改善されていた
  • p50も同様に、ほとんど変化が見られなかった
  • p99はかなり改善が見られた

また、10RPSの負荷をかけた時よりも20RPSの負荷をかけた時の方が、よりパフォーマンスが改善されていることがわかりました。
これは、負荷が高いほどプログラムの実行がより頻繁かつ集中的になることで、PGOによるホットスポットへの最適化効果が顕著に現れるためと考えられます。

ほとんどの検証でp99に改善が見られたのは、おそらくロジック周りの改善ではなくて、全ての処理に共通するGCやランタイム周りの最適化によるものだと思われます(PGOとは?で解説した、エスケープ解析によるものかも...!)。
Averageとp50にほとんど改善が見られないのは、

  • 現状のコードがすでに十分最適化されていて高速な処理であること
  • I/O処理が多めなこと

の2つが原因だと考えています。

trace
選定したエンドポイントのトレース。処理のうち、86.9%がDBアクセス

課題

PGOを実際に運用に導入するにあたって2つの課題があると考えています。

一つは、人的コストが増えてしまうことです。
PGOを使うワークフローではプロファイルの収集とPGOを使った再ビルドが必要になるため、通常のビルドに比べて手順が増えます。
小さな変更であれば、変更前の本番環境から収集したプロファイルを使い回すことができ、手間が増えることはありません。3
しかし、大きな変更(APIの追加など)を加えた場合は、デプロイしてプロファイルを収集→PGOビルドで再度デプロイという2段階デプロイが発生してしまいます。
また、変更を確認して2段階デプロイを行うかどうかを人力で判断する必要があるため、リリース時の人的コストが増えてしまうと懸念されます。

もう一つは経済的コストについてです。
今回の検証では、プロファイルの収集にnet/http/pprofを使いましたが、実際の運用では自動的かつ継続的にプロファイルを収集できる、Datadogのprofilerを使うことを検討することになると思います。
今回の検証背景として、podを減らすことでコスト削減を目指すというのがあったので、Datadogのprofilerを使う場合はDatadogの利用料金が増えることも考慮する必要があります。

まとめ

今回の検証の結果、Topgun APIにおいて、PGOはAverage/p50の改善はほとんど見られなかったものの、運用上の安定性に直結するp99のレイテンシー改善に効果があると実証されました。

認可処理というのは各サービスの基盤として頻繁に発生する処理なため、p99の改善は認可チーム的にかなり意味がある結果を得られたと思っています。
チームメンバーにも結果を共有したところ、1ヶ月ほど試験的にPGOを導入することになりました!

上記の課題についてもチーム内で相談しながら調査を続けていきたいと考えています。


  1. https://go.dev/doc/pgo
  2. また、Datadogでメトリクスを監視している場合、Datadogのprofilerとコード内で使っているnet/http/pprofが衝突してエラーを起こす可能性があります。環境変数にDD_PROFILING_ENABLED: falseを追加してください。
  3. https://go.dev/doc/pgo#source-stability