Go1.21でのコンパイル最適化
Go言語では、コンパイルをより最適化するための仕組みでPGO(Profile-Guided Optimization)というものがあります。
Go1.20ではユーザにテストしてもらうためのプレビュー機能としてリリースされましたが、Go1.21では一般利用が可能となっています。
詳しいPGOの使用解説はこちら
概要
profileとは、Goコードを実際に実行した際のCPU/メモリなどのリソースの使用状況など、プログラムの実行の様子を情報として持っているファイルのことです。
本来コンパイラはソースコードを元にコンパイルを行うため、実行環境でコードがどのように実行されるかは知り得ません。
しかしGoではプロファイリングを行うことで、実行環境でのコードの振る舞いをprofileとして記録し、それをコンパイラに引き渡すことができます。
PGOではコンパイル時にこのprofileを参照することにより、コンパイルを最適化します。
この記事では以下の記事に沿ってPGOのデモを行い、使い方や効果のほどを確かめてみます。
デモ
プロファイリング対象のサーバ用意
まずプロファイリング対象のサーバがなければいけません。
以下のような、マークダウン形式のファイルをHTML形式に変換してレスポンスするサーバを起動するコードを利用します。
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)) }
機能としてはシンプルなサーバですが、net/http/pprof
がimportされています。
これは、後にプロファイリングを行う際のためのエンドポイントをサーバに追加するためです。
(/debug/pprof
で始まる複数のエンドポイントがこのパッケージによりサーバに追加されます。今回は/debug/pprof/profile
のみの利用となります。)
負荷生成プログラム
プロダクション環境であればサーバに定常的にリクエストが来るため、プロファイリングも簡単にできますが、開発環境でのサーバだとリクエストが来ないためプロファイリングもできません。
そのため、サーバに一定の負荷を与え続けるための負荷生成プログラムを作成します。
記事ではサーバを起動した後、以下コマンドでサーバに対し負荷を送るようにしています。
go run github.com/prattmic/markdown-pgo/load@latest
プロファイリング
負荷を送っている状態で、net/http/pprof
パッケージで追加されたエンドポイントにアクセスしてプロファイリングを行います。
curl -o cpu.pprof "http://localhost:8080/debug/pprof/profile?seconds=30"
以下のようなコマンドでもプロファイリングできます。
go tool pprof http://localhost:8080/debug/pprof/profile?seconds=30
エンドポイントに付いているseconds
というパラメータは、プロファイリング時間を指定しています。
上記コマンドで生成されたファイルがprofileです。
ビルド
Goツールチェーンは、mainパッケージのディレクトリにdefault.pgo
という名前のprofileファイルを見つけた場合、ビルド時に自動的にPGOを有効にします。
Go公式でも、生成したprofileはdefault.pgoに名前を変えることを推奨しています。
(違う名前が付いていても、ビルド時に-pgo
オプションでprofileパスを指定することでPGO有効にすることは可能です)
default.pgoを置いてビルドすると、普通のビルドより若干時間がかかりました。
go version -m {ビルド済みバイナリ}
で、該当バイナリがPGOでビルドされたことがわかります。
$ go version -m markdown.withpgo.exe markdown.withpgo.exe: go1.21.0 path example.com/markdown ........ ........ build -compiler=gc build CGO_ENABLED=1 build CGO_CFLAGS= build CGO_CPPFLAGS= build CGO_CXXFLAGS= build CGO_LDFLAGS= build GOARCH=arm64 build GOOS=darwin build -pgo=/Users/test-user/go/src/example.com/markdown/default.pgo
ベンチマーク
PGO無し/有りのビルド済みバイナリのベンチマークをそれぞれ取ります。
go test github.com/prattmic/markdown-pgo/load -bench=. -count=40 -source $(pwd)/README.md > nopgo.txt go test github.com/prattmic/markdown-pgo/load -bench=. -count=40 -source $(pwd)/README.md > withpgo.txt
golang.org/x/perf/cmd/benchstat
パッケージを利用し、ベンチマークの比較を行います。
$ go install golang.org/x/perf/cmd/benchstat@latest $ benchstat nopgo.txt withpgo.txt goos: darwin goarch: arm64 pkg: github.com/prattmic/markdown-pgo/load │ nopgo.txt │ withpgo.txt │ │ sec/op │ sec/op vs base │ Load-8 80.98µ ± 0% 79.34µ ± 1% -2.03% (p=0.000 n=40)
PGO有りの方が、実行が2%強速くなっているようです。
Go1.21では、PGOによりCPU使用率が2~7%ほど改善するようです。
今後のリリースでもパフォーマンスの向上を図っていくとのことなので、注目してみたいと思います。