Goブログ

Go 1.21のプロファイルガイド付き最適化

Michael Pratt
2023年9月5日

2023年の初めに、Go 1.20はユーザーがテストできるプロファイルガイド付き最適化(PGO)のプレビュー版をリリースしました。プレビュー版における既知の制限事項に対処し、コミュニティからのフィードバックと貢献によるさらなる改良を経て、Go 1.21におけるPGOサポートは一般の運用環境での使用準備が整いました! 詳細なドキュメントについては、プロファイルガイド付き最適化ユーザーガイドを参照してください。

以下では、PGOを使用してアプリケーションのパフォーマンスを向上させる例について説明します。その前に、「プロファイルガイド付き最適化」とは具体的に何でしょうか?

Goバイナリをビルドすると、Goコンパイラは最適化を実行して、可能な限り最高の性能を持つバイナリを生成しようとします。たとえば、定数伝播はコンパイル時に定数式を評価することで、実行時の評価コストを回避できます。エスケープ解析は、ローカルスコープのオブジェクトに対してヒープ割り当てを回避し、GCのオーバーヘッドを削減します。インライン化は、単純な関数の本体を呼び出し元にコピーすることで、多くの場合、呼び出し元でさらなる最適化(追加の定数伝播やより良いエスケープ解析など)を可能にします。非仮想化は、静的に型を決定できるインターフェース値に対する間接呼び出しを、具体的なメソッドへの直接呼び出しに変換します(これにより、呼び出しのインライン化が可能になることがよくあります)。

Goはリリースごとに最適化を改善していますが、そうすることは容易ではありません。一部の最適化は調整可能ですが、コンパイラはすべての最適化で「11まで上げる」ことはできません。なぜなら、過度に積極的な最適化は実際にはパフォーマンスを低下させたり、ビルド時間を長くしたりする可能性があるからです。他の最適化では、コンパイラが関数の「一般的な」パスと「一般的ではない」パスについて判断を下す必要があります。コンパイラは、実行時にどのケースが一般的になるかを知ることができないため、静的ヒューリスティックに基づいて最善の推測を行う必要があります。

それとも、できますか?

本番環境でコードがどのように使用されているかについての明確な情報がない場合、コンパイラはパッケージのソースコードのみを操作できます。しかし、本番動作を評価するためのツールがあります。それはプロファイリングです。プロファイルをコンパイラに提供すれば、より多くの情報に基づいて決定を行うことができます。つまり、最も頻繁に使用される関数をより積極的に最適化したり、一般的なケースをより正確に選択したりできます。

コンパイラ最適化にアプリケーション動作のプロファイルを使用することをプロファイルガイド付き最適化(PGO)(フィードバック指向最適化(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ツールチェーンは、メインパッケージディレクトリにdefault.pgoという名前のプロファイルが見つかった場合、PGOを自動的に有効にします。または、go buildへの-pgoフラグは、PGOに使用するプロファイルへのパスを取ります。

default.pgoファイルをリポジトリにコミットすることをお勧めします。ソースコードと共にプロファイルを保存することで、ユーザーはリポジトリ(バージョン管理システムを介して、またはgo getを介して)を取得するだけで自動的にプロファイルにアクセスでき、ビルドの再現性が確保されます。

ビルドしましょう

$ mv cpu.pprof default.pgo
$ go build -o markdown.withpgo.exe

go versionを使用して、ビルドでPGOが有効になっていることを確認できます。

$ 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つの異なる最適化を見ていきます。

インライン化

インライン化の改善を観察するには、PGOありとPGOなしの両方でこのMarkdownアプリケーションを分析しましょう。

これは差分プロファイリングと呼ばれる手法を使用して比較します。差分プロファイリングでは、2つのプロファイル(PGOありとPGOなし)を収集して比較します。差分プロファイリングでは、両方のプロファイルが同じ時間ではなく同じ量の**作業**を表すことが重要であるため、サーバーが自動的にプロファイルを収集するようにサーバーを調整し、負荷ジェネレーターが一定数の要求を送信してからサーバーを終了するようにしました。

サーバーに加えた変更と収集されたプロファイルは、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使用率の減少(割り当てサイズよりも割り当て回数とより相関関係が高い傾向がある)を調査しているため便利です。いくつかの削減が見られますが、最大の削減である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.normalizeLinkmarkdown.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)でその変数へのポインタを返します。通常、これは、参照が関数からの復帰後も残るため、ヒープ上に変数を割り当てる必要があります。しかし、mdurl.Parsemarkdown.normalizeLinkにインライン化されると、コンパイラは変数がnormalizeLinkからエスケープしないことを確認できるため、スタック上に割り当てることができます。markdown.normalizeLinkTextmarkdown.normalizeLinkと同様です。

プロファイルに表示される2番目に大きな削減は、mdurl.(*URL).Stringからのものであり、インライン化後のエスケープの排除と同様のケースです。

これらのケースでは、ヒープ割り当ての削減によってパフォーマンスが向上しました。PGOとコンパイラの最適化全般の威力の1つは、割り当てへの影響がコンパイラのPGO実装には全く関係しないことです。PGOが行った唯一の変更は、これらのホットな関数呼び出しのインライン化を許可することでした。エスケープ分析とヒープ割り当てへの影響はすべて、あらゆるビルドに適用される標準的な最適化です。エスケープ動作の改善は、インライン化による大きな下流効果ですが、唯一の効果ではありません。多くの最適化がインライン化を利用できます。たとえば、定数伝播は、一部の入力が定数の場合は、インライン化後に関数のコードを簡素化できる可能性があります。

仮想化解除

上記の例で見たインライン化に加えて、PGOはインターフェース呼び出しの条件付き仮想化解除も促進できます。

PGOによる仮想化解除について説明する前に、一般的に「仮想化解除」を定義しましょう。次のようなコードがあるとします。

f, _ := os.Open("foo.txt")
var r io.Reader = f
r.Read(b)

ここでは、io.ReaderインターフェースメソッドReadへの呼び出しがあります。インターフェースには複数の実装があるため、コンパイラは間接関数呼び出しを生成します。つまり、インターフェース値の型から実行時に呼び出す正しいメソッドを検索します。間接呼び出しは直接呼び出しと比較してわずかな追加の実行時コストがありますが、さらに重要なことに、一部のコンパイラの最適化を妨げます。たとえば、コンパイラは、具体的なメソッド実装がわからないため、間接呼び出しでエスケープ分析を実行できません。

しかし、上記の例では、具体的なメソッド実装はわかっています*os.Filerに割り当てることができる唯一の型であるため、os.(*File).Readでなければなりません。この場合、コンパイラは仮想化解除を実行し、io.Reader.Readへの間接呼び出しをos.(*File).Readへの直接呼び出しに置き換え、他の最適化を可能にします。

(おそらく「そのコードは無意味です。なぜそのような書き方をする人がいるのでしょうか?」と考えているでしょう。これは良い点ですが、上記のコードはインライン化の結果である可能性があることに注意してください。fio.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の使用)をインライン化して追加の最適化を適用できることです。プロファイルは常にこれが当てはまるという保証ではないため、フォールバックパスは維持されます。

マークダウンサーバーの分析では、PGOによる仮想化解除は見られませんでしたが、最も影響の大きい領域しか調べていません。PGO(およびほとんどのコンパイラの最適化)は、多くの異なる場所での非常に小さな改善の集計で一般的にその利点を生み出すため、私たちが調べたもの以外にも多くのことが起こっている可能性があります。

インライン化と仮想化解除は、Go 1.21で使用できる2つのPGO駆動の最適化ですが、これらは多くの場合、追加の最適化を可能にすることがわかりました。さらに、今後のGoのバージョンでは、追加の最適化によってPGOが継続的に改善されます。

謝辞

Goへのプロファイルガイド付き最適化の追加はチームの努力であり、特にUberのRaj BarikとJin Lin、GoogleのCherry MuiとAustin Clementsの貢献を称賛したいです。このようなクロスコミュニティの協力は、Goを素晴らしいものにするための重要な部分です。

次の記事:成長するGoエコシステムのためのgoplsのスケーリング
前の記事:完全に再現可能で検証済みのGoツールチェーン
ブログ索引