The Go Blog

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 https://: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 "https://: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 の有無にかかわらず、このマークダウンアプリケーションを分析してみましょう。

ここでは、差分プロファイリングと呼ばれる手法を使用します。2つのプロファイル(1つは PGO あり、もう1つは 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 なしの場合よりも 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.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 行目で URL をローカル変数として作成し (var url URL)、その後 145 行目でその変数へのポインタを返します (return &url, nil)。通常、これには変数をヒープに割り当てる必要があります。なぜなら、その参照が関数の戻りを超えて存在するためです。しかし、mdurl.Parsemarkdown.normalizeLink にインライン化されると、コンパイラは変数が normalizeLink からエスケープしないことを観察でき、これによりコンパイラはそれをスタックに割り当てることができます。markdown.normalizeLinkTextmarkdown.normalizeLink と同様です。

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

これらのケースでは、ヒープ割り当ての減少によりパフォーマンスが向上しました。PGO とコンパイラ最適化の力の一部は、割り当てへの影響がコンパイラの 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 の使用)をインライン化して追加の最適化を適用できることですが、プロファイルが常にそうであるという保証ではないため、フォールバックパスも維持します。

Markdown サーバーの分析では、PGO 主導のデ仮想化は見られませんでしたが、影響の大きい上位領域しか見ていません。PGO (およびほとんどのコンパイラ最適化) は、通常、多くの異なる場所での非常に小さな改善の集合体としてその恩恵をもたらすため、私たちが調べた以上のことが起こっている可能性があります。

インライン化とデ仮想化は、Go 1.21 で利用できる PGO 主導の 2 つの最適化ですが、ご覧のとおり、これらはしばしば追加の最適化を可能にします。さらに、将来の Go のバージョンでは、追加の最適化により PGO が改善され続けるでしょう。

謝辞

Go へのプロファイルに基づく最適化の追加はチームの努力であり、特に Uber の Raj Barik と Jin Lin、Google の Cherry Mui と Austin Clements の貢献に感謝したいです。この種のコミュニティ間のコラボレーションは、Go を素晴らしいものにするための重要な部分です。

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