Go 1.21におけるプロファイル誘導最適化
Profile-guided optimization in Go 1.21 by Michael Pratt
2023年の早い時期に、Go 1.20ではプロファイル誘導最適化(PGO)のプレビューがユーザー向けに提供され、試用できるようになりました。プレビューで判明していた既知の制限に対応し、さらにコミュニティからのフィードバックや貢献によって改良を重ねた結果、Go 1.21のPGOサポートは本番環境での一般利用に対応できる状態になりました。詳細なドキュメントについてはプロファイル誘導最適化ユーザーガイドを参照してください。
後述の例では、PGOを使ってアプリケーションのパフォーマンスを改善する方法を見ていきます。それに入る前に、そもそも「プロファイル誘導最適化」とは何なのでしょうか?
Goのバイナリをビルドするとき、Goコンパイラはできる限りパフォーマンスの良いバイナリを生成しようとして最適化を行います。たとえば、定数畳み込み(constant propagation)はコンパイル時に定数式を評価することで、実行時の評価コストを避けます。エスケープ解析は、ローカルスコープのオブジェクトに対するヒープアロケーションを避けることで、GCのオーバーヘッドを削減します。インライン化は単純な関数の本体を呼び出し元にコピーするもので、呼び出し元でのさらなる最適化(追加の定数畳み込みやより良いエスケープ解析など)を可能にすることがよくあります。脱仮想化は、静的に型を決定できるインターフェース値への間接呼び出しを、具象メソッドへの直接呼び出しに変換します。この変換によって、その呼び出しのインライン化が可能になることもよくあります。
Goはリリースを重ねるごとに最適化を改善していますが、それは決して簡単な作業ではありません。最適化の中には調整可能なものもありますが、コンパイラはすべての最適化を単純に「限界まで強めれば良い」というわけにはいきません。過度に積極的な最適化はかえってパフォーマンスを悪化させたり、ビルド時間を過度に長くしたりする可能性があるからです。また別の最適化では、コンパイラがある関数の中でどこが「よく通る」パスで、どこが「あまり通らない」パスかを判断しなければなりません。コンパイラは実行時にどのケースがよく発生するかを知る術がないため、静的なヒューリスティクスに基づいて最善の推測をするしかありません。
本当にそうなのでしょうか?
本番環境でコードがどのように使われているかについての確定的な情報がなければ、コンパイラはパッケージのソースコードのみを頼りに動作するしかありません。しかし、本番環境での振る舞いを評価するためのツールが実はあります。それがプロファイリングです。コンパイラにプロファイルを渡せば、より多くの情報に基づいた判断ができるようになります。たとえば、最も頻繁に使われる関数をより積極的に最適化したり、「よく通る」ケースをより正確に選び出したりできるようになります。
アプリケーションの動作を記録したプロファイルをコンパイラの最適化に利用する手法は、プロファイル誘導最適化(Profile-Guided Optimization, PGO)と呼ばれています。フィードバック指向最適化(Feedback-Directed Optimization, FDO)と呼ばれることもあります。
例
MarkdownをHTMLに変換するサービスを作ってみましょう。ユーザーがMarkdownのソースを /render にアップロードすると、HTMLに変換した結果が返ってくるというものです。これは gitlab.com/golang-commonmark/markdown を使えば簡単に実装できます。
セットアップ
$ go mod init example.com/markdown
$ go get gitlab.com/golang-commonmark/markdown@bf3e522c626a
main.go は次のようになります。
package main
import (
"bytes"
"io"
"log"
"net/http"
_ "net/http/pprof"
"gitlab.com/golang-commonmark/markdown"
)
func render(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed)
return
}
src, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("error reading body: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
md := markdown.New(
markdown.XHTMLOutput(true),
markdown.Typographer(true),
markdown.Linkify(true),
markdown.Tables(true),
)
var buf bytes.Buffer
if err := md.Render(&buf, src); err != nil {
log.Printf("error converting markdown: %v", err)
http.Error(w, "Malformed markdown", http.StatusBadRequest)
return
}
if _, err := io.Copy(w, &buf); err != nil {
log.Printf("error writing response: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
func main() {
http.HandleFunc("/render", render)
log.Printf("Serving on port 8080...")
log.Fatal(http.ListenAndServe(":8080", nil))
}
サーバーをビルドして実行します。
$ go build -o markdown.nopgo.exe
$ ./markdown.nopgo.exe
2023/08/23 03:55:51 Serving on port 8080...
別のターミナルからMarkdownを送信してみましょう。サンプルの文書としてGoプロジェクトの README.md を使います。
$ curl -o README.md -L "https://raw.githubusercontent.com/golang/go/c16c2c49e2fa98ae551fc6335215fadd62d33542/README.md"
$ curl --data-binary @README.md http://localhost:8080/render
<h1>The Go Programming Language</h1>
<p>Go is an open source programming language that makes it easy to build simple,
reliable, and efficient software.</p>
...
プロファイリング
動作するサービスができたので、プロファイルを収集し、PGOを使って再ビルドすることで、パフォーマンスが向上するかどうかを確認してみましょう。
main.go では net/http/pprof をインポートしています。これによって、CPUプロファイルを取得するための /debug/pprof/profile エンドポイントがサーバーに自動的に追加されます。
通常は、本番環境での振る舞いを代表するようなプロファイルをコンパイラに与えるために、本番環境からプロファイルを収集したいところです。この例には「本番」環境がないため、プロファイルを収集する間に負荷をかけるための簡単なプログラムを用意しました。負荷生成プログラムを取得して起動します。サーバーがまだ動いていることを確認してください!
$ go run github.com/prattmic/markdown-pgo/load@latest
それが動いている間に、サーバーからプロファイルをダウンロードします。
$ curl -o cpu.pprof "http://localhost:8080/debug/pprof/profile?seconds=30"
これが完了したら、負荷生成プログラムとサーバーを終了します。
プロファイルの利用
Goのツールチェインは、mainパッケージのディレクトリ内に default.pgo という名前のプロファイルを見つけると、自動的にPGOを有効にします。あるいは、 go build の -pgo フラグに、PGOで使うプロファイルへのパスを渡すこともできます。
default.pgo ファイルはリポジトリにコミットすることをお勧めします。プロファイルをソースコードと一緒に保存しておけば、ユーザーは(バージョン管理システム経由であれ go get 経由であれ)リポジトリを取得するだけで自動的にそのプロファイルにアクセスできるようになりますし、ビルドの再現性も保たれます。
ビルドしてみましょう。
$ mv cpu.pprof default.pgo
$ go build -o markdown.withpgo.exe
ビルドでPGOが有効になっていたことは go version で確認できます。
$ go version -m markdown.withpgo.exe
./markdown.withpgo.exe: go1.21.0
...
build -pgo=/tmp/pgo121/default.pgo
評価
PGOがパフォーマンスに与える効果を評価するために、負荷生成プログラムをGoのベンチマーク形式にしたバージョンを使います。
まず、PGOなしでサーバーをベンチマークします。サーバーを起動します。
$ ./markdown.nopgo.exe
それが動いている間に、ベンチマークを何度か実行します。
$ go get github.com/prattmic/markdown-pgo@latest
$ go test github.com/prattmic/markdown-pgo/load -bench=. -count=40 -source $(pwd)/README.md > nopgo.txt
完了したら、元のサーバーを終了し、PGO版のサーバーを起動します。
$ ./markdown.withpgo.exe
それが動いている間に、ベンチマークを何度か実行します。
$ go test github.com/prattmic/markdown-pgo/load -bench=. -count=40 -source $(pwd)/README.md > withpgo.txt
完了したら、結果を比較してみましょう。
$ go install golang.org/x/perf/cmd/benchstat@latest
$ benchstat nopgo.txt withpgo.txt
goos: linux
goarch: amd64
pkg: github.com/prattmic/markdown-pgo/load
cpu: Intel(R) Xeon(R) W-2135 CPU @ 3.70GHz
│ nopgo.txt │ withpgo.txt │
│ sec/op │ sec/op vs base │
Load-12 374.5µ ± 1% 360.2µ ± 0% -3.83% (p=0.000 n=40)
新しいバージョンは約3.8%高速です!Go 1.21では、一般的なワークロードでPGOを有効にすることで2%から7%程度のCPU使用率の改善が得られます。プロファイルにはアプリケーションの動作に関する豊富な情報が含まれており、Go 1.21ではその情報を限られた種類の最適化に使うことで、ようやくその可能性の入り口に足を踏み入れたところです。今後のリリースでは、コンパイラのより多くの部分がPGOを活用するようになるにつれて、パフォーマンスの改善が続いていく予定です。
次のステップ
今回の例では、プロファイルを収集した後、元のビルドで使ったのとまったく同じソースコードを使ってサーバーを再ビルドしました。しかし実際の開発現場では、開発は常に進行しています。そのため、先週のコードが動いている本番環境からプロファイルを収集し、それを今日のソースコードのビルドに使うということも起こり得ます。それでもまったく問題ありません!GoのPGOは、ソースコードへの小さな変更であれば問題なく扱えます。もちろん、時間が経つにつれてソースコードは徐々に乖離していくので、プロファイルを時々更新することはやはり重要です。
PGOの使い方や、気を付けるべきベストプラクティスや注意点についてさらに詳しく知りたい場合は、プロファイル誘導最適化ユーザーガイドを参照してください。内部で何が起きているのか興味がある方は、このまま読み進めてください!
内部の仕組み
このアプリケーションがなぜ速くなったのかをより深く理解するために、内部に踏み込んでパフォーマンスがどのように変化したのかを見ていきましょう。ここでは、PGOによって駆動される2種類の最適化を見ていきます。
インライン化
インライン化による改善を観察するために、このmarkdownアプリケーションをPGOがある場合とない場合の両方で分析してみましょう。
この比較には差分プロファイリング(differential profiling)と呼ばれる手法を使います。これは、PGOがある場合とない場合の2つのプロファイルを収集して比較する手法です。差分プロファイリングでは、2つのプロファイルが表す量が同じ時間ではなく同じ作業量であることが重要です。そこで、サーバーが自動的にプロファイルを収集するように調整し、負荷生成プログラムも決まった数のリクエストを送信したらサーバーを終了させるように変更しました。
サーバーに加えた変更と収集したプロファイルは、https://github.com/prattmic/markdown-pgo で確認できます。負荷生成プログラムは -count=300000 -quit で実行しました。
簡単な整合性チェックとして、30万件のリクエストすべてを処理するのに必要だった合計CPU時間を見てみましょう。
$ go tool pprof -top cpu.nopgo.pprof | grep "Total samples"
Duration: 116.92s, Total samples = 118.73s (101.55%)
$ go tool pprof -top cpu.withpgo.pprof | grep "Total samples"
Duration: 113.91s, Total samples = 115.03s (100.99%)
CPU時間は約118秒から約115秒へ、およそ3%減少しました。これはベンチマークの結果と一致しており、これらのプロファイルが代表的なものであるという良い兆候です。
それでは、差分プロファイルを開いて、どこで削減が起きているかを見てみましょう。
$ go tool pprof -diff_base cpu.nopgo.pprof cpu.withpgo.pprof
File: markdown.profile.withpgo.exe
Type: cpu
Time: Aug 28, 2023 at 10:26pm (EDT)
Duration: 230.82s, Total samples = 118.73s (51.44%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top -cum
Showing nodes accounting for -0.10s, 0.084% of 118.73s total
Dropped 268 nodes (cum <= 0.59s)
Showing top 10 nodes out of 668
flat flat% sum% cum cum%
-0.03s 0.025% 0.025% -2.56s 2.16% gitlab.com/golang-commonmark/markdown.ruleLinkify
0.04s 0.034% 0.0084% -2.19s 1.84% net/http.(*conn).serve
0.02s 0.017% 0.025% -1.82s 1.53% gitlab.com/golang-commonmark/markdown.(*Markdown).Render
0.02s 0.017% 0.042% -1.80s 1.52% gitlab.com/golang-commonmark/markdown.(*Markdown).Parse
-0.03s 0.025% 0.017% -1.71s 1.44% runtime.mallocgc
-0.07s 0.059% 0.042% -1.62s 1.36% net/http.(*ServeMux).ServeHTTP
0.04s 0.034% 0.0084% -1.58s 1.33% net/http.serverHandler.ServeHTTP
-0.01s 0.0084% 0.017% -1.57s 1.32% main.render
0.01s 0.0084% 0.0084% -1.56s 1.31% net/http.HandlerFunc.ServeHTTP
-0.09s 0.076% 0.084% -1.25s 1.05% runtime.newobject
(pprof) top
Showing nodes accounting for -1.41s, 1.19% of 118.73s total
Dropped 268 nodes (cum <= 0.59s)
Showing top 10 nodes out of 668
flat flat% sum% cum cum%
-0.46s 0.39% 0.39% -0.91s 0.77% runtime.scanobject
-0.40s 0.34% 0.72% -0.40s 0.34% runtime.nextFreeFast (inline)
0.36s 0.3% 0.42% 0.36s 0.3% gitlab.com/golang-commonmark/markdown.performReplacements
-0.35s 0.29% 0.72% -0.37s 0.31% runtime.writeHeapBits.flush
0.32s 0.27% 0.45% 0.67s 0.56% gitlab.com/golang-commonmark/markdown.ruleReplacements
-0.31s 0.26% 0.71% -0.29s 0.24% runtime.writeHeapBits.write
-0.30s 0.25% 0.96% -0.37s 0.31% runtime.deductAssistCredit
0.29s 0.24% 0.72% 0.10s 0.084% gitlab.com/golang-commonmark/markdown.ruleText
-0.29s 0.24% 0.96% -0.29s 0.24% runtime.(*mspan).base (inline)
-0.27s 0.23% 1.19% -0.42s 0.35% bytes.(*Buffer).WriteRune
pprof -diff_base を指定すると、pprofに表示される値は2つのプロファイルの差分になります。たとえば、 runtime.scanobject はPGOありの方がPGOなしよりも0.46秒少ないCPU時間しか使っていません。一方で、 gitlab.com/golang-commonmark/markdown.performReplacements は0.36秒多くCPU時間を使っています。差分プロファイルでは、パーセンテージには意味がないため、通常は絶対値(flat と cum の列)に注目したくなります。
top -cum は累積変化量の大きい順に差分を表示します。つまり、ある関数とそこから推移的に呼ばれるすべての呼び出し先を合わせたCPUの差分です。これは通常、プログラムの呼び出しグラフの中で一番外側にあるフレーム、たとえば main や他のゴルーチンのエントリーポイントを表示することになります。ここでは、HTTPリクエストを処理する中の ruleLinkify の部分から、削減量の大部分が来ていることがわかります。
top は、その関数自体の変化に限定した差分を表示します。これは通常、プログラムの呼び出しグラフの中で内側にあるフレーム、つまり実際の作業の大部分が行われている場所を表示することになります。ここでは、個々の削減の大部分が runtime の関数から来ていることがわかります。
これらは何でしょうか? 呼び出しスタックを遡って、どこから来ているのか見てみましょう。
(pprof) peek scanobject$
Showing nodes accounting for -3.72s, 3.13% of 118.73s total
----------------------------------------------------------+-------------
flat flat% sum% cum cum% calls calls% + context
----------------------------------------------------------+-------------
-0.86s 94.51% | runtime.gcDrain
-0.09s 9.89% | runtime.gcDrainN
0.04s 4.40% | runtime.markrootSpans
-0.46s 0.39% 0.39% -0.91s 0.77% | runtime.scanobject
-0.19s 20.88% | runtime.greyobject
-0.13s 14.29% | runtime.heapBits.nextFast (inline)
-0.08s 8.79% | runtime.heapBits.next
-0.08s 8.79% | runtime.spanOfUnchecked (inline)
0.04s 4.40% | runtime.heapBitsForAddr
-0.01s 1.10% | runtime.findObject
----------------------------------------------------------+-------------
(pprof) peek gcDrain$
Showing nodes accounting for -3.72s, 3.13% of 118.73s total
----------------------------------------------------------+-------------
flat flat% sum% cum cum% calls calls% + context
----------------------------------------------------------+-------------
-1s 100% | runtime.gcBgMarkWorker.func2
0.15s 0.13% 0.13% -1s 0.84% | runtime.gcDrain
-0.86s 86.00% | runtime.scanobject
-0.18s 18.00% | runtime.(*gcWork).balance
-0.11s 11.00% | runtime.(*gcWork).tryGet
0.09s 9.00% | runtime.pollWork
-0.03s 3.00% | runtime.(*gcWork).tryGetFast (inline)
-0.03s 3.00% | runtime.markroot
-0.02s 2.00% | runtime.wbBufFlush
0.01s 1.00% | runtime/internal/atomic.(*Bool).Load (inline)
-0.01s 1.00% | runtime.gcFlushBgCredit
-0.01s 1.00% | runtime/internal/atomic.(*Int64).Add (inline)
----------------------------------------------------------+-------------
そのため runtime.scanobject は最終的に runtime.gcBgMarkWorker から来ています。Go GCガイドによれば runtime.gcBgMarkWorker はガベージコレクタの一部なので、 runtime.scanobject の削減はGCの削減であるはずです。では nextFreeFast や他の runtime の関数はどうでしょうか?
(pprof) peek nextFreeFast$
Showing nodes accounting for -3.72s, 3.13% of 118.73s total
----------------------------------------------------------+-------------
flat flat% sum% cum cum% calls calls% + context
----------------------------------------------------------+-------------
-0.40s 100% | runtime.mallocgc (inline)
-0.40s 0.34% 0.34% -0.40s 0.34% | runtime.nextFreeFast
----------------------------------------------------------+-------------
(pprof) peek writeHeapBits
Showing nodes accounting for -3.72s, 3.13% of 118.73s total
----------------------------------------------------------+-------------
flat flat% sum% cum cum% calls calls% + context
----------------------------------------------------------+-------------
-0.37s 100% | runtime.heapBitsSetType
0 0% | runtime.(*mspan).initHeapBits
-0.35s 0.29% 0.29% -0.37s 0.31% | runtime.writeHeapBits.flush
-0.02s 5.41% | runtime.arenaIndex (inline)
----------------------------------------------------------+-------------
-0.29s 100% | runtime.heapBitsSetType
-0.31s 0.26% 0.56% -0.29s 0.24% | runtime.writeHeapBits.write
0.02s 6.90% | runtime.arenaIndex (inline)
----------------------------------------------------------+-------------
(pprof) peek heapBitsSetType$
Showing nodes accounting for -3.72s, 3.13% of 118.73s total
----------------------------------------------------------+-------------
flat flat% sum% cum cum% calls calls% + context
----------------------------------------------------------+-------------
-0.82s 100% | runtime.mallocgc
-0.12s 0.1% 0.1% -0.82s 0.69% | runtime.heapBitsSetType
-0.37s 45.12% | runtime.writeHeapBits.flush
-0.29s 35.37% | runtime.writeHeapBits.write
-0.03s 3.66% | runtime.readUintptr (inline)
-0.01s 1.22% | runtime.writeHeapBitsForAddr (inline)
----------------------------------------------------------+-------------
(pprof) peek deductAssistCredit$
Showing nodes accounting for -3.72s, 3.13% of 118.73s total
----------------------------------------------------------+-------------
flat flat% sum% cum cum% calls calls% + context
----------------------------------------------------------+-------------
-0.37s 100% | runtime.mallocgc
-0.30s 0.25% 0.25% -0.37s 0.31% | runtime.deductAssistCredit
-0.07s 18.92% | runtime.gcAssistAlloc
----------------------------------------------------------+-------------
nextFreeFast や上位10件の中にある他のいくつかは、最終的に runtime.mallocgc から来ているようです。GCガイドによれば、これはメモリアロケータです。
GCとアロケータのコストが削減されたということは、全体としてのアロケーション量が減っていることを示唆しています。ヒーププロファイルを見て詳しく調べてみましょう。
$ go tool pprof -sample_index=alloc_objects -diff_base heap.nopgo.pprof heap.withpgo.pprof
File: markdown.profile.withpgo.exe
Type: alloc_objects
Time: Aug 28, 2023 at 10:28pm (EDT)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for -12044903, 8.29% of 145309950 total
Dropped 60 nodes (cum <= 726549)
Showing top 10 nodes out of 58
flat flat% sum% cum cum%
-4974135 3.42% 3.42% -4974135 3.42% gitlab.com/golang-commonmark/mdurl.Parse
-4249044 2.92% 6.35% -4249044 2.92% gitlab.com/golang-commonmark/mdurl.(*URL).String
-901135 0.62% 6.97% -977596 0.67% gitlab.com/golang-commonmark/puny.mapLabels
-653998 0.45% 7.42% -482491 0.33% gitlab.com/golang-commonmark/markdown.(*StateInline).PushPending
-557073 0.38% 7.80% -557073 0.38% gitlab.com/golang-commonmark/linkify.Links
-557073 0.38% 8.18% -557073 0.38% strings.genSplit
-436919 0.3% 8.48% -232152 0.16% gitlab.com/golang-commonmark/markdown.(*StateBlock).Lines
-408617 0.28% 8.77% -408617 0.28% net/textproto.readMIMEHeader
401432 0.28% 8.49% 499610 0.34% bytes.(*Buffer).grow
291659 0.2% 8.29% 291659 0.2% bytes.(*Buffer).String (inline)
-sample_index=alloc_objects オプションは、サイズに関わらずアロケーションの回数を表示します。CPU使用率の減少を調査しているので、これは有用です。CPU使用率はアロケーションのサイズよりも回数と相関する傾向にあるからです。ここにはかなりの数の削減が見られますが、最も大きな削減である mdurl.Parse に注目しましょう。
参考として、この関数のPGOなしでの合計アロケーション回数を見てみましょう。
$ go tool pprof -sample_index=alloc_objects -top heap.nopgo.pprof | grep mdurl.Parse
4974135 3.42% 68.60% 4974135 3.42% gitlab.com/golang-commonmark/mdurl.Parse
以前の合計回数は4974135だったので、 mdurl.Parse はアロケーションを100%削減したことになります!
差分プロファイルに戻って、もう少し詳しい文脈を見てみましょう。
(pprof) peek mdurl.Parse
Showing nodes accounting for -12257184, 8.44% of 145309950 total
----------------------------------------------------------+-------------
flat flat% sum% cum cum% calls calls% + context
----------------------------------------------------------+-------------
-2956806 59.44% | gitlab.com/golang-commonmark/markdown.normalizeLink
-2017329 40.56% | gitlab.com/golang-commonmark/markdown.normalizeLinkText
-4974135 3.42% 3.42% -4974135 3.42% | gitlab.com/golang-commonmark/mdurl.Parse
----------------------------------------------------------+-------------
mdurl.Parse への呼び出しは、 markdown.normalizeLink と markdown.normalizeLinkText から来ています。
(pprof) list mdurl.Parse
Total: 145309950
ROUTINE ======================== gitlab.com/golang-commonmark/mdurl.Parse in /usr/local/google/home/mpratt/go/pkg/mod/gitlab.com/golang-commonmark/mdurl@v0.0.0-20191124015652-932350d1cb84/parse
.go
-4974135 -4974135 (flat, cum) 3.42% of Total
. . 60:func Parse(rawurl string) (*URL, error) {
. . 61: n, err := findScheme(rawurl)
. . 62: if err != nil {
. . 63: return nil, err
. . 64: }
. . 65:
-4974135 -4974135 66: var url URL
. . 67: rest := rawurl
. . 68: hostless := false
. . 69: if n > 0 {
. . 70: url.RawScheme = rest[:n]
. . 71: url.Scheme, rest = strings.ToLower(rest[:n]), rest[n+1:]
これらの関数と呼び出し元の完全なソースは、以下で確認できます。
ここで何が起きたのでしょうか? PGOなしのビルドでは、 mdurl.Parse はインライン化の対象にするには大きすぎると判断されます。しかし、PGOプロファイルによってこの関数への呼び出しがホット(頻繁に呼ばれる)であることが示されていたため、コンパイラはこれをインライン化しました。これはプロファイル中の「(inline)」という注記から確認できます。
$ go tool pprof -top cpu.nopgo.pprof | grep mdurl.Parse
0.36s 0.3% 63.76% 2.75s 2.32% gitlab.com/golang-commonmark/mdurl.Parse
$ go tool pprof -top cpu.withpgo.pprof | grep mdurl.Parse
0.55s 0.48% 58.12% 2.03s 1.76% gitlab.com/golang-commonmark/mdurl.Parse (inline)
mdurl.Parse は66行目 (var url URL) でローカル変数として URL を作成し、145行目 (return &url, nil) でその変数へのポインタを返しています。通常であれば、この変数への参照が関数のreturn後も生き続けるため、ヒープにアロケーションする必要があります。しかし、 mdurl.Parse が markdown.normalizeLink にインライン化されると、コンパイラはこの変数が normalizeLink の外にエスケープしないことを観測できるようになり、その結果スタック上にアロケーションできるようになります。 markdown.normalizeLinkText も markdown.normalizeLink と同様です。
プロファイルに示されている2番目に大きな削減である mdurl.(*URL).String も、インライン化後にエスケープが解消された同様のケースです。
これらのケースでは、ヒープアロケーションが減ったことでパフォーマンスが改善しました。PGOやコンパイラの最適化全般が持つ力の一部は、アロケーションへの影響がコンパイラのPGO実装そのものにはまったく含まれていないという点にあります。PGOが行った変更は、これらのホットな関数呼び出しのインライン化を可能にしたことだけです。エスケープ解析やヒープアロケーションへの影響は、すべてどんなビルドにも適用される標準的な最適化によるものです。エスケープの振る舞いの改善はインライン化の素晴らしい波及効果ですが、唯一の効果というわけではありません。インライン化を活かせる最適化は他にもたくさんあります。たとえば定数畳み込みは、入力の一部が定数である場合に、インライン化後の関数内のコードを単純化できることがあります。
脱仮想化
上の例で見たインライン化に加えて、PGOはインターフェース呼び出しの条件付き脱仮想化(devirtualization)も駆動できます。
PGOによる脱仮想化について説明する前に、一旦話を戻して、一般的な「脱仮想化」を定義しておきましょう。次のようなコードがあるとします。
f, _ := os.Open("foo.txt")
var r io.Reader = f
r.Read(b)
ここでは io.Reader インターフェースのメソッド Read を呼び出しています。インターフェースは複数の実装を持ちうるため、コンパイラは間接的な関数呼び出しを生成します。つまり、実行時にインターフェース値の型から呼び出すべき正しいメソッドを検索するということです。間接呼び出しには直接呼び出しに比べてわずかな追加の実行時コストがありますが、それよりも問題なのは、いくつかのコンパイラの最適化を妨げてしまうことです。たとえば、コンパイラは具象的なメソッド実装がわからないため、間接呼び出しに対してエスケープ解析を行えません。
しかし上の例では、具象的なメソッド実装が実はわかっています。 r に代入されうる型は *os.File しかないため、これは必ず os.(*File).Read になります。この場合、コンパイラは脱仮想化を行い、 io.Reader.Read への間接呼び出しを os.(*File).Read への直接呼び出しに置き換えます。これによって他の最適化が可能になります。
(おそらく「そんなコードは無意味だし、誰がそんな書き方をするんだ」と思っていることでしょう。もっともな指摘ですが、上のようなコードはインライン化の結果として生まれることがある点に注意してください。 f が io.Reader を引数に取る関数に渡されているとします。その関数がインライン化されると、この io.Reader は具象型になります。)
PGOによる脱仮想化は、この考え方を、具象型が静的にはわからないものの、プロファイリングによって、たとえば io.Reader.Read の呼び出しがほとんどの場合 os.(*File).Read を対象にしていることが判明するような状況にまで拡張します。この場合、PGOは r.Read(b) を次のようなコードに置き換えられます。
if f, ok := r.(*os.File); ok {
f.Read(b)
} else {
r.Read(b)
}
つまり、最も出現しやすい具象型に対する実行時チェックを追加し、それに該当すれば具象型を使った呼び出しを行い、そうでなければ通常の間接呼び出しにフォールバックするということです。ここでの利点は、よく通るパス(*os.File を使う方)をインライン化してさらなる最適化を適用できることですが、プロファイルは常にそうなることを保証するものではないため、フォールバックのパスもきちんと維持されます。
今回のmarkdownサーバーの分析ではPGOによる脱仮想化は見られませんでしたが、私たちが見たのは影響の大きかった上位の箇所だけです。PGO(そしてほとんどのコンパイラの最適化)は、一般的にさまざまな場所での小さな改善の積み重ねとして効果を発揮するため、私たちが見た以上のことが実際には起きている可能性が高いです。
インライン化と脱仮想化は、Go 1.21で利用できる2種類のPGO駆動の最適化ですが、これまで見てきたように、これらはしばしば追加の最適化の道を開きます。さらに、今後のGoのバージョンでも、追加の最適化によってPGOは改善され続けていく予定です。
謝辞
Goにプロファイル誘導最適化を追加する作業はチームによる取り組みであり、中でもUberのRaj BarikさんとJin Linさん、そしてGoogleのCherry MuiさんとAustin Clementsさんの貢献を特に取り上げたいと思います。このようなコミュニティを横断した協力関係は、Goを素晴らしいものにする上での重要な要素です。
By Michael Pratt