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.txtgolang.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%ほど改善するようです。
今後のリリースでもパフォーマンスの向上を図っていくとのことなので、注目してみたいと思います。
 
  
  
  
  
コメント