プロファイルに基づく最適化
Go 1.20以降、Goコンパイラはビルドをさらに最適化するためにプロファイルに基づく最適化(PGO)をサポートしています。
目次
概要
プロファイルの収集
PGOを使用したビルド
注
よくある質問
付録:代替プロファイルソース
概要
プロファイルに基づく最適化(PGO)は、フィードバック指向最適化(FDO)とも呼ばれ、アプリケーションの代表的な実行から得られた情報(プロファイル)を、アプリケーションの次回のビルドのためにコンパイラにフィードバックするコンパイラ最適化手法です。コンパイラはその情報を使用して、より情報に基づいた最適化の決定を下します。例えば、プロファイルが頻繁に呼び出されることを示している関数を、コンパイラがより積極的にインライン化することを決定するかもしれません。
Goでは、コンパイラはruntime/pprofまたはnet/http/pprofなどのCPU pprofプロファイルを入力プロファイルとして使用します。
Go 1.22の時点で、代表的なGoプログラムのベンチマークでは、PGOでビルドすることでパフォーマンスが約2〜14%向上することが示されています。今後のGoのバージョンでPGOを活用する追加の最適化が導入されるにつれて、パフォーマンスの向上が一般的に増加すると予想されます。
プロファイルの収集
Goコンパイラは、PGOへの入力としてCPU pprofプロファイルを期待します。Goランタイムによって生成されたプロファイル(runtime/pprofおよびnet/http/pprofなど)は、コンパイラの入力として直接使用できます。他のプロファイリングシステムからのプロファイルを使用/変換することも可能です。追加情報については付録を参照してください。
最良の結果を得るためには、プロファイルがアプリケーションの製品環境における実際の動作を代表するものであることが重要です。代表的でないプロファイルを使用すると、製品環境でほとんど改善が見られない、あるいは全く改善が見られないバイナリが生成される可能性があります。したがって、製品環境から直接プロファイルを収集することが推奨されており、これがGoのPGOが設計されている主要な方法です。
典型的なワークフローは次のとおりです。
- 最初のバイナリをビルドしてリリースする(PGOなし)。
- 製品環境からプロファイルを収集する。
- 更新されたバイナリをリリースする際に、最新のソースからビルドし、製品プロファイルを提供する。
- GOTO 2
Go PGOは、プロファイル対象のアプリケーションのバージョンとプロファイルでビルドするバージョンとの間のずれ、および既に最適化されたバイナリから収集されたプロファイルでのビルドに対して一般的に堅牢です。これがこの反復的なライフサイクルを可能にしています。このワークフローに関する追加の詳細については、AutoFDOセクションを参照してください。
製品環境からの収集が困難または不可能な場合(例:エンドユーザーに配布されるコマンドラインツール)、代表的なベンチマークから収集することも可能です。代表的なベンチマークの構築は(アプリケーションが進化するにつれて代表性を維持することも)非常に難しいことが多いことに注意してください。特に、マイクロベンチマークはPGOプロファイリングには通常不適切な候補です。アプリケーションのごく一部しか実行しないため、プログラム全体に適用してもわずかな改善しかもたらしません。
PGOを使用したビルド
標準的なビルドのアプローチは、プロファイル対象のバイナリのメインパッケージディレクトリにファイル名`default.pgo`のpprof CPUプロファイルを保存することです。デフォルトでは、`go build`は`default.pgo`ファイルを自動的に検出し、PGOを有効にします。
プロファイルは再現可能な(そして高性能な!)ビルドにとって重要なビルド入力であるため、ソースリポジトリに直接コミットすることが推奨されます。ソースと一緒に保存することで、ソースを取得する以外にプロファイルを取得するための追加のステップが不要になるため、ビルドエクスペリエンスが簡素化されます。
より複雑なシナリオでは、`go build -pgo`フラグがPGOプロファイルの選択を制御します。このフラグは、上記の`default.pgo`の動作の場合、デフォルトで`-pgo=auto`に設定されます。このフラグを`-pgo=off`に設定すると、PGO最適化が完全に無効になります。
`default.pgo`を使用できない場合(例:1つのバイナリの異なるシナリオに対して異なるプロファイルを使用する場合、プロファイルをソースと一緒に保存できない場合など)、使用するプロファイルへのパスを直接渡すことができます(例:`go build -pgo=/tmp/foo.pprof`)。
注:`-pgo`に渡されるパスはすべてのメインパッケージに適用されます。例えば、`go build -pgo=/tmp/foo.pprof ./cmd/foo ./cmd/bar`は`foo.pprof`をバイナリ`foo`と`bar`の両方に適用しますが、これは通常望ましいことではありません。通常、異なるバイナリには異なるプロファイルが必要であり、個別の`go build`呼び出しを介して渡されます。
注:Go 1.21より前は、デフォルトは`-pgo=off`でした。PGOは明示的に有効にする必要がありました。
注
製品環境から代表的なプロファイルを収集する
プロファイルの収集で説明されているように、製品環境はアプリケーションの代表的なプロファイルの最良のソースです。
これを始める最も簡単な方法は、アプリケーションにnet/http/pprofを追加し、任意のサービスインスタンスから`/debug/pprof/profile?seconds=30`をフェッチすることです。これは始めるのに良い方法ですが、これが代表的でない場合があります。
-
このインスタンスは、通常はビジーであっても、プロファイルされる瞬間に何もしていない可能性があります。
-
トラフィックパターンは一日を通して変化する可能性があり、その結果、動作も一日を通して変化します。
-
インスタンスは長時間実行される操作(例:操作Aを5分間実行し、次に操作Bを5分間実行するなど)を実行する場合があります。30秒のプロファイルでは、単一の操作タイプしかカバーしない可能性があります。
-
インスタンスは公平なリクエストの分布を受け取らない可能性があります(一部のインスタンスは他のインスタンスよりも特定タイプのリクエストを多く受け取ります)。
より堅牢な戦略は、個々のインスタンスプロファイル間の違いの影響を制限するために、異なるインスタンスから異なる時間に複数のプロファイルを収集することです。その後、複数のプロファイルをマージして、PGOで使用する単一のプロファイルにすることができます。
多くの組織は、この種のフリート全体にわたるサンプリングプロファイリングを自動的に実行する「継続的プロファイリング」サービスを実行しており、これをPGOのプロファイルのソースとして使用できます。
プロファイルのマージ
pprofツールは、複数のプロファイルを次のようにマージできます。
$ go tool pprof -proto a.pprof b.pprof > merged.pprof
このマージは、プロファイルの経過時間に関係なく、入力におけるサンプルの単純な合計です。結果として、アプリケーションの短い時間スライスをプロファイリングする場合(例:無限に実行されるサーバー)、すべてのプロファイルが同じ経過時間であることを確認したいでしょう(つまり、すべてのプロファイルが30秒間収集される)。そうしないと、経過時間が長いプロファイルがマージされたプロファイルで過剰に表現されます。
AutoFDO
Go PGOは「AutoFDO」スタイルのワークフローをサポートするように設計されています。
プロファイルの収集で説明されているワークフローを詳しく見てみましょう。
- 最初のバイナリをビルドしてリリースする(PGOなし)。
- 製品環境からプロファイルを収集する。
- 更新されたバイナリをリリースする際に、最新のソースからビルドし、製品プロファイルを提供する。
- GOTO 2
これは一見単純に聞こえますが、ここで注意すべき重要な特性がいくつかあります。
-
開発は常に進行中であるため、プロファイル対象のバイナリのソースコード(ステップ2)は、ビルドされている最新のソースコード(ステップ3)とわずかに異なる可能性があります。Go PGOはこれに対して堅牢であるように設計されており、これをソース安定性と呼びます。
-
これは閉じたループです。つまり、最初の反復の後、プロファイル対象のバイナリのバージョンはすでに以前の反復からのプロファイルでPGO最適化されています。Go PGOはこれに対して堅牢であるように設計されており、これを反復安定性と呼びます。
ソース安定性は、プロファイルからのサンプルをコンパイル中のソースに一致させるためのヒューリスティクスを使用して実現されます。その結果、新しい関数の追加など、ソースコードへの多くの変更は既存のコードの一致に影響を与えません。コンパイラが変更されたコードに一致できない場合、一部の最適化は失われますが、これはグレースフルデグラデーションであることに注意してください。単一の関数が一致しない場合、最適化の機会を失う可能性がありますが、PGO全体の恩恵は通常、多くの関数に分散されます。一致とデグラデーションに関する詳細については、ソース安定性セクションを参照してください。
反復安定性とは、連続するPGOビルドにおける性能変動のサイクル(例:ビルド #1 は速い、ビルド #2 は遅い、ビルド #3 は速い、など)を防ぐことです。我々はCPUプロファイルを使用して、最適化のターゲットとなるホット関数を特定します。理論的には、ホット関数はPGOによって非常に高速化され、次のプロファイルではホットとして表示されなくなり、最適化されなくなるため、再び遅くなる可能性があります。GoコンパイラはPGO最適化に対して保守的なアプローチを採用しており、これにより大きな変動を防ぐことができると考えています。この種の不安定性を観察した場合は、go.dev/issue/newで問題を報告してください。
ソース安定性と反復安定性が両方備わっていることで、最初の最適化されていないビルドをカナリアとしてプロファイルし、その後PGOを使用して製品向けに再構築する2段階ビルドの必要性がなくなります(絶対的なピークパフォーマンスが必要な場合を除きます)。
ソース安定性とリファクタリング
上記で説明したように、GoのPGOは、古いプロファイルからのサンプルを現在のソースコードに一致させ続けるために、最善の努力をします。具体的には、Goは関数内の行オフセット(例:関数fooの5行目での呼び出し)を使用します。
以下を含む多くの一般的な変更は、マッチングを破りません。
-
ホット関数の外側のファイル内の変更(関数の上または下にコードを追加/変更する)。
-
関数を同じパッケージ内の別のファイルに移動する(コンパイラはソースファイル名を完全に無視します)。
マッチングを破る可能性のあるいくつかの変更
-
ホット関数内の変更(行オフセットに影響を与える可能性があります)。
-
関数の名前変更(および/またはメソッドの型)(シンボル名が変更されます)。
-
関数を別のパッケージに移動する(シンボル名が変更されます)。
プロファイルが比較的最近のものであれば、違いは少数のホット関数にのみ影響し、一致しない関数での最適化の漏れの影響は限定的です。それでも、コードが古い形に戻されることはめったにないため、時間の経過とともに劣化はゆっくりと蓄積されます。したがって、製品環境からのソースのずれを制限するために、新しいプロファイルを定期的に収集することが重要です。
プロファイルマッチングが著しく低下する可能性がある状況の一つは、多くの関数の名前を変更したり、パッケージ間で移動したりする大規模なリファクタリングです。この場合、新しいプロファイルが新しい構造を示すまで、短期間のパフォーマンス低下が発生する可能性があります。
ルーチンな名前変更の場合、既存のプロファイルを理論的に書き換えて、古いシンボル名を新しい名前に変更することができます。github.com/google/pprof/profileには、この方法でpprofプロファイルを書き換えるために必要なプリミティブが含まれていますが、この記事執筆時点では、既製のツールは存在しません。
新しいコードのパフォーマンス
新しいコードを追加したり、フラグの切り替えで新しいコードパスを有効にしたりする場合、そのコードは最初のビルドのプロファイルには存在せず、したがって新しいコードを反映する新しいプロファイルが収集されるまでPGO最適化を受けません。新しいコードのロールアウトを評価する際には、最初のリリースがその定常状態のパフォーマンスを代表するものではないことに留意してください。
よくある質問
PGOでGo標準ライブラリパッケージを最適化することはできますか?
はい。GoのPGOはプログラム全体に適用されます。標準ライブラリパッケージを含むすべてのパッケージは、潜在的なプロファイルガイド付き最適化を考慮して再ビルドされます。
依存モジュールのパッケージをPGOで最適化することは可能ですか?
はい。GoのPGOはプログラム全体に適用されます。依存関係のパッケージを含むすべてのパッケージは、潜在的なプロファイルガイド付き最適化を考慮して再ビルドされます。これは、アプリケーションが依存関係を使用する独自の方法が、その依存関係に適用される最適化に影響を与えることを意味します。
PGOを代表的でないプロファイルで使用すると、PGOを使用しない場合よりもプログラムが遅くなりますか?
いいえ、そうなるべきではありません。製品の動作を代表しないプロファイルは、アプリケーションのコールドな部分で最適化をもたらしますが、アプリケーションのホットな部分を遅くすることはありません。PGOがPGOを無効にするよりもパフォーマンスを悪化させるプログラムに遭遇した場合は、go.dev/issue/newで問題を報告してください。
異なるGOOS/GOARCHビルドに同じプロファイルを使用できますか?
はい。プロファイルの形式はOSとアーキテクチャの構成間で同等であるため、異なる構成間で使用できます。たとえば、linux/arm64バイナリから収集されたプロファイルは、windows/amd64ビルドで使用できます。
とはいえ、上記で説明したソース安定性の注意点はここでも適用されます。これらの構成間で異なるソースコードは最適化されません。ほとんどのアプリケーションでは、コードの大部分はプラットフォームに依存しないため、この形式の劣化は限定的です。
具体例として、`os`パッケージのファイルハンドリングの内部はLinuxとWindowsで異なります。これらの関数がLinuxプロファイルでホットな場合、Windowsの同等の関数はプロファイルと一致しないため、PGO最適化は適用されません。
異なるGOOS/GOARCHビルドのプロファイルをマージすることができます。そのトレードオフについては、次の質問を参照してください。
異なるワークロードタイプに使用される単一のバイナリはどのように処理すべきですか?
ここに明確な選択肢はありません。異なる種類のワークロードに使用される単一のバイナリ(例:あるサービスでは読み込みが頻繁に行われ、別のサービスでは書き込みが頻繁に行われるデータベース)は、異なるホットコンポーネントを持ち、異なる最適化の恩恵を受ける可能性があります。
3つのオプションがあります。
-
各ワークロード用に異なるバージョンのバイナリをビルドする:各ワークロードからのプロファイルを使用して、複数のワークロード固有のバイナリビルドを作成します。これにより、各ワークロードで最高のパフォーマンスが得られますが、複数のバイナリとプロファイルソースの処理に関して運用上の複雑さが増す可能性があります。
-
「最も重要な」ワークロードのプロファイルのみを使用して単一のバイナリをビルドする:「最も重要な」ワークロード(最大のフットプリント、最もパフォーマンスに敏感なもの)を選択し、そのワークロードからのプロファイルのみを使用してビルドします。これにより、選択されたワークロードで最高のパフォーマンスが得られ、ワークロード間で共有される共通コードの最適化により、他のワークロードでも控えめなパフォーマンス向上が期待できます。
-
ワークロード間でプロファイルをマージする:各ワークロードからのプロファイル(総フットプリントで重み付け)を取り、それらを単一の「フリート全体の」プロファイルにマージして、単一の共通プロファイルをビルドします。これにより、すべてのワークロードで控えめなパフォーマンス向上が期待できます。
PGOはビルド時間にどのような影響を与えますか?
PGOビルドを有効にすると、パッケージのビルド時間が増加する可能性があります。これの最も顕著な部分は、PGOプロファイルがバイナリ内のすべてのパッケージに適用されるため、プロファイルの最初の使用では依存グラフ内のすべてのパッケージの再ビルドが必要になることです。これらのビルドは他のビルドと同様にキャッシュされるため、同じプロファイルを使用するその後の増分ビルドでは完全な再ビルドは必要ありません。
ビルド時間が極端に増加した場合は、go.dev/issue/newで問題を報告してください。
注:コンパイラによるプロファイルの解析も、特に大規模なプロファイルの場合、かなりのオーバーヘッドを追加する可能性があります。大規模なプロファイルを大規模な依存グラフで使用すると、ビルド時間が大幅に増加する可能性があります。これはgo.dev/issue/58102で追跡されており、将来のリリースで対処される予定です。
PGOはバイナリサイズにどのような影響を与えますか?
PGOは、関数のインライン化が追加されるため、バイナリサイズがわずかに大きくなる可能性があります。
付録:代替プロファイルソース
Goランタイムによって生成されたCPUプロファイル(runtime/pprofなど経由)は、PGO入力として直接使用できる正しい形式です。しかし、組織は代替の推奨ツール(例:Linux perf)を持っているか、またはGo PGOで使用したい既存のフリート全体の継続的プロファイリングシステムを持っているかもしれません。
代替ソースからのプロファイルは、pprof形式に変換され、以下の一般的な要件を満たしていれば、Go PGOで使用できます。
-
サンプルインデックスのいずれかのタイプ/単位が「samples」/「count」または「cpu」/「nanoseconds」であること。
-
サンプルは、サンプル位置でのCPU時間のサンプルを表す必要があります。
-
プロファイルはシンボライズされている必要があります(Function.nameが設定されている必要があります)。
-
サンプルには、インライン化された関数のスタックフレームが含まれている必要があります。インライン化された関数が省略されている場合、Goは反復安定性を維持できません。
-
Function.start_lineが設定されている必要があります。これは関数の開始行番号です。つまり、`func`キーワードを含む行です。Goコンパイラはこのフィールドを使用して、サンプルの行オフセットを計算します(`Location.Line.line - Function.start_line`)。既存の多くのpprofコンバーターがこのフィールドを省略していることに注意してください。
注:Go 1.21より前では、DWARFメタデータは関数の開始行(`DW_AT_decl_line`)を省略しており、ツールが開始行を決定することを困難にする可能性があります。
特定のサードパーティツールのPGO互換性に関する追加情報については、Go WikiのPGOツールページを参照してください。