Go プロファイリングガイド
pprof、traceを使ってGoアプリケーションのボトルネックを特定し、パフォーマンスを最適化する
Go プロファイリングガイド
pprof、traceを使ってGoアプリケーションのボトルネックを特定し、パフォーマンスを最適化する
この章で学ぶこと
- pprof を使ったCPU・メモリ・goroutineプロファイリングの手法
- runtime/trace によるgoroutineスケジューリングとレイテンシの可視化
- ベンチマーク連携 — テストからプロファイルを取得して最適化サイクルを回す方法
- Mutex/Block プロファイリング — ロック競合とブロッキング操作の分析
- 継続的プロファイリング — 本番環境での常時監視戦略
前提知識
このガイドを読む前に、以下の知識があると理解が深まります:
- 基本的なプログラミングの知識
- 関連する基礎概念の理解
- Go ジェネリクスガイド の内容を理解していること
1. Goプロファイリングの全体像
プロファイリングツールの分類
+----------------------------------------------------------+
| Go プロファイリング体系 |
+----------------------------------------------------------+
| |
| +-----------------+ +------------------+ +-----------+|
| | CPU Profile | | Memory Profile | | Trace ||
| | どこで時間を | | どこでメモリを | | いつ何が ||
| | 消費しているか | | 確保しているか | | 起きたか ||
| +-----------------+ +------------------+ +-----------+|
| | | | |
| v v v |
| go tool pprof go tool pprof go tool trace |
| |
| +-----------------+ +------------------+ |
| | Goroutine Prof | | Block Profile | |
| | goroutine の | | ロック待ちの | |
| | 状態を確認 | | 分析 | |
| +-----------------+ +------------------+ |
| |
| +-----------------+ +------------------+ |
| | Mutex Profile | | Threadcreate | |
| | mutex の競合 | | OSスレッド生成 | |
| | を分析 | | の追跡 | |
| +-----------------+ +------------------+ |
+----------------------------------------------------------+
プロファイル取得方法の選択
プロファイルを取りたい
|
+-- 本番サーバー(常時稼働)
| |
| v
| net/http/pprof (HTTPエンドポイント)
|
+-- テスト・ベンチマーク
| |
| v
| go test -cpuprofile / -memprofile
|
+-- 短命プログラム(CLI等)
| |
| v
| runtime/pprof (プログラム内で開始/停止)
|
+-- 継続的モニタリング
|
v
Pyroscope / Parca / Google Cloud Profiler
プロファイリングの基本フロー
+----------------------------------------------------------+
| パフォーマンス最適化サイクル |
+----------------------------------------------------------+
| |
| 1. 計測 (Measure) |
| | ベンチマーク / 負荷テストで現状を数値化 |
| v |
| 2. プロファイル (Profile) |
| | pprof でホットスポットを特定 |
| v |
| 3. 分析 (Analyze) |
| | フレームグラフ・コールグラフで原因を理解 |
| v |
| 4. 最適化 (Optimize) |
| | ボトルネック箇所のみを改善 |
| v |
| 5. 検証 (Verify) |
| | ベンチマークで効果を定量的に確認 |
| v |
| 6. 1に戻る(改善が不十分なら繰り返す) |
+----------------------------------------------------------+
2. net/http/pprof — HTTPサーバーのプロファイリング
コード例1: pprof エンドポイントの追加
package main
import (
"log"
"net/http"
_ "net/http/pprof" // 副作用インポートでエンドポイント登録
)
func main() {
// アプリケーションのルーティング
mux := http.NewServeMux()
mux.HandleFunc("/api/users", handleUsers)
// pprofは DefaultServeMux に登録されるため
// 別ポートで pprof 専用サーバーを起動(本番推奨)
go func() {
log.Println("pprof server: http://localhost:6060/debug/pprof/")
log.Fatal(http.ListenAndServe(":6060", nil))
}()
// アプリケーションサーバー
log.Fatal(http.ListenAndServe(":8080", mux))
}コード例2: カスタム mux での pprof 登録
package main
import (
"net/http"
"net/http/pprof"
"log"
)
func main() {
// アプリケーション用 mux
appMux := http.NewServeMux()
appMux.HandleFunc("/api/users", handleUsers)
// pprof 専用 mux(認証付き)
debugMux := http.NewServeMux()
debugMux.HandleFunc("/debug/pprof/", pprof.Index)
debugMux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
debugMux.HandleFunc("/debug/pprof/profile", pprof.Profile)
debugMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
debugMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
// 認証ミドルウェアを適用
protectedDebug := basicAuth(debugMux, "admin", "secret-password")
go func() {
log.Println("pprof server (auth required): http://localhost:6060/debug/pprof/")
log.Fatal(http.ListenAndServe("127.0.0.1:6060", protectedDebug))
}()
log.Fatal(http.ListenAndServe(":8080", appMux))
}
func basicAuth(next http.Handler, username, password string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != username || pass != password {
w.Header().Set("WWW-Authenticate", `Basic realm="pprof"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}pprofエンドポイント一覧
| エンドポイント | 内容 | 取得方法 |
|---|---|---|
/debug/pprof/ |
プロファイル一覧ページ | ブラウザで直接アクセス |
/debug/pprof/profile?seconds=30 |
CPUプロファイル(30秒間) | go tool pprof URL |
/debug/pprof/heap |
ヒープメモリプロファイル | go tool pprof URL |
/debug/pprof/allocs |
メモリアロケーション累積 | go tool pprof URL |
/debug/pprof/goroutine |
goroutine スタックトレース | go tool pprof URL |
/debug/pprof/block |
ブロッキング操作プロファイル | go tool pprof URL |
/debug/pprof/mutex |
ミューテックス競合プロファイル | go tool pprof URL |
/debug/pprof/threadcreate |
OSスレッド生成プロファイル | go tool pprof URL |
/debug/pprof/trace?seconds=5 |
実行トレース(5秒間) | go tool trace |
pprofエンドポイントのクエリパラメータ
| パラメータ | 適用先 | 説明 | 例 |
|---|---|---|---|
seconds |
profile, trace | サンプリング期間(秒) | ?seconds=60 |
debug |
goroutine, heap等 | テキスト出力モード(0=バイナリ, 1=テキスト, 2=詳細) | ?debug=2 |
gc |
heap | プロファイル前にGCを実行(1=実行) | ?gc=1 |
3. CPU プロファイリング
コード例3: go tool pprof の操作
# CPUプロファイル取得(30秒間サンプリング)
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
# 保存済みプロファイルの分析
go tool pprof cpu.prof
# Web UI で表示(ブラウザが開く)
go tool pprof -http=:8081 cpu.prof
# 特定の関数にフォーカス
go tool pprof -focus=handleRequest cpu.prof
# 特定の関数を除外
go tool pprof -ignore=runtime cpu.prof
# テキスト出力(CI向け)
go tool pprof -text cpu.prof
# 2つのプロファイルの差分を表示
go tool pprof -diff_base=before.prof after.profpprof インタラクティブモード
(pprof) top10
Showing nodes accounting for 4.5s, 90% of 5s total
flat flat% sum% cum cum%
2.0s 40.00% 40.00% 2.0s 40.00% runtime.memmove
1.0s 20.00% 60.00% 1.5s 30.00% encoding/json.(*decodeState).object
0.5s 10.00% 70.00% 0.5s 10.00% runtime.mallocgc
...
(pprof) list encoding/json.(*decodeState).object
# ソースコード付きで各行のコストを表示
(pprof) web
# SVG のコールグラフをブラウザで表示
(pprof) peek handleRequest
# handleRequest を呼び出している/呼び出されている関数を表示
(pprof) tree
# コールツリー形式で表示
(pprof) disasm handleRequest
# アセンブリコード付きのプロファイル表示flat vs cum の違い
+----------------------------------------------------------+
| flat vs cum の理解 |
+----------------------------------------------------------+
| |
| func A() { A の flat = 1s (A自身の処理) |
| doWork() // 1s A の cum = 4s (A + B + C の合計) |
| B() // 3s |
| } |
| |
| func B() { B の flat = 1s (B自身の処理) |
| doWork() // 1s B の cum = 3s (B + C の合計) |
| C() // 2s |
| } |
| |
| func C() { C の flat = 2s (C自身の処理) |
| doWork() // 2s C の cum = 2s (C のみ) |
| } |
| |
| top コマンドで: |
| flat が高い → その関数自体が重い |
| cum が高い → その関数の呼び出し先が重い |
| flat と cum の差が大きい → 下流に原因がある |
+----------------------------------------------------------+
コード例4: プログラム内でCPUプロファイルを取得
package main
import (
"flag"
"log"
"os"
"runtime/pprof"
)
var cpuprofile = flag.String("cpuprofile", "", "CPUプロファイルの出力先")
var memprofile = flag.String("memprofile", "", "メモリプロファイルの出力先")
func main() {
flag.Parse()
// CPUプロファイル開始
if *cpuprofile != "" {
f, err := os.Create(*cpuprofile)
if err != nil {
log.Fatal(err)
}
defer f.Close()
if err := pprof.StartCPUProfile(f); err != nil {
log.Fatal(err)
}
defer pprof.StopCPUProfile()
}
// プロファイル対象の処理
doHeavyWork()
// メモリプロファイル取得
if *memprofile != "" {
f, err := os.Create(*memprofile)
if err != nil {
log.Fatal(err)
}
defer f.Close()
// GC を実行して最新のメモリ状態を取得
runtime.GC()
if err := pprof.WriteHeapProfile(f); err != nil {
log.Fatal(err)
}
}
}コード例5: プロファイル付きの HTTP サーバー(条件付き有効化)
package main
import (
"log"
"net/http"
"os"
"runtime"
)
func main() {
// 環境変数で pprof を有効化
if os.Getenv("ENABLE_PPROF") == "true" {
// Block/Mutex プロファイリングを有効化
runtime.SetBlockProfileRate(1)
runtime.SetMutexProfileFraction(1)
// メモリプロファイリングのサンプリングレートを調整
// デフォルト: 512KB ごとに1回
// より詳細にする場合: runtime.MemProfileRate = 1 (全アロケーション記録)
// 本番環境: runtime.MemProfileRate = 524288 (デフォルト)
go func() {
import _ "net/http/pprof"
log.Println("pprof enabled on :6060")
log.Fatal(http.ListenAndServe("127.0.0.1:6060", nil))
}()
}
// アプリケーション起動
srv := &http.Server{Addr: ":8080", Handler: appRouter()}
log.Fatal(srv.ListenAndServe())
}4. メモリプロファイリング
コード例6: ヒーププロファイルの取得と分析
# ヒーププロファイル取得
go tool pprof http://localhost:6060/debug/pprof/heap
# アロケーション累積(プログラム開始からの総量)
go tool pprof -alloc_space http://localhost:6060/debug/pprof/allocs
# 現在使用中のメモリのみ
go tool pprof -inuse_space http://localhost:6060/debug/pprof/heap
# アロケーション回数(オブジェクト数)
go tool pprof -alloc_objects http://localhost:6060/debug/pprof/allocs
# 現在使用中のオブジェクト数
go tool pprof -inuse_objects http://localhost:6060/debug/pprof/heap
# Web UI でフレームグラフ表示
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/heapメモリプロファイルのモード比較
| モード | フラグ | 計測対象 | 用途 |
|---|---|---|---|
| inuse_space | -inuse_space |
現在使用中のメモリ量 | メモリリーク検出 |
| inuse_objects | -inuse_objects |
現在使用中のオブジェクト数 | GC圧力の調査 |
| alloc_space | -alloc_space |
累積アロケーション量 | ホットパスの特定 |
| alloc_objects | -alloc_objects |
累積アロケーション回数 | アロケーション多発箇所の特定 |
メモリプロファイルの分析フロー
+-------------------+ +-------------------+ +-------------------+
| ヒーププロファイル | --> | top / list で | --> | ホットスポット |
| 取得 | | アロケーション | | 特定 |
| | | 箇所を特定 | | |
+-------------------+ +-------------------+ +-------------------+
|
v
+-------------------+ +-------------------+ +-------------------+
| 最適化適用 | <-- | 改善策検討 | <-- | sync.Pool? |
| ベンチマークで検証 | | バッファ再利用? | | プリアロケート? |
+-------------------+ +-------------------+ +-------------------+
コード例7: メモリリークの検出パターン
// メモリリークしやすいパターンと対策
package main
import (
"context"
"log"
"os"
"runtime"
"runtime/pprof"
"time"
)
// NG: goroutine リーク
func leakyFunction() {
for i := 0; i < 1000; i++ {
go func() {
ch := make(chan int)
<-ch // 永遠にブロック → goroutine リーク
}()
}
}
// OK: コンテキストでキャンセル可能
func safeFunction(ctx context.Context) {
for i := 0; i < 1000; i++ {
go func() {
ch := make(chan int)
select {
case v := <-ch:
process(v)
case <-ctx.Done():
return // クリーンに終了
}
}()
}
}
// goroutine 数の監視
func monitorGoroutines() {
ticker := time.NewTicker(10 * time.Second)
for range ticker.C {
log.Printf("goroutine count: %d", runtime.NumGoroutine())
}
}
// goroutine プロファイルをファイルに書き出し
func dumpGoroutineProfile() {
f, _ := os.Create("goroutine.prof")
defer f.Close()
pprof.Lookup("goroutine").WriteTo(f, 1)
}コード例8: メモリリークのスナップショット比較
package main
import (
"fmt"
"net/http"
"os"
"runtime"
"runtime/pprof"
"time"
)
// 2つの時点のヒーププロファイルを比較してリークを検出
func detectMemoryLeak() {
// スナップショット1を取得
runtime.GC()
f1, _ := os.Create("heap_before.prof")
pprof.WriteHeapProfile(f1)
f1.Close()
// 負荷をかける
runLoad()
// 一定時間待ってGCを実行
time.Sleep(30 * time.Second)
runtime.GC()
time.Sleep(5 * time.Second) // GCが完了するのを待つ
// スナップショット2を取得
f2, _ := os.Create("heap_after.prof")
pprof.WriteHeapProfile(f2)
f2.Close()
// 差分分析
// go tool pprof -diff_base=heap_before.prof heap_after.prof
fmt.Println("Run: go tool pprof -http=:8081 -diff_base=heap_before.prof heap_after.prof")
}
// runtime.ReadMemStats でメモリ使用状況を確認
func printMemStats() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc = %v MiB\n", m.Alloc/1024/1024)
fmt.Printf("TotalAlloc = %v MiB\n", m.TotalAlloc/1024/1024)
fmt.Printf("Sys = %v MiB\n", m.Sys/1024/1024)
fmt.Printf("NumGC = %v\n", m.NumGC)
fmt.Printf("HeapObjects= %v\n", m.HeapObjects)
fmt.Printf("HeapInuse = %v MiB\n", m.HeapInuse/1024/1024)
fmt.Printf("StackInuse = %v MiB\n", m.StackInuse/1024/1024)
}コード例9: スライスのメモリリークパターン
// NG: 大きなスライスの一部を参照 → 元の配列全体がGCされない
func getFirstThree(data []byte) []byte {
return data[:3]
// data の底層配列全体が保持される(100MB → 100MB保持)
}
// OK: コピーして参照を切る
func getFirstThree(data []byte) []byte {
result := make([]byte, 3)
copy(result, data[:3])
return result
// data はGC対象になる
}
// NG: append でキャパシティが過大に残るケース
func filterLarge(items []Item) []Item {
// 10000 件のスライスから 10 件にフィルタ
// しかし底層配列は 10000 件分のキャパシティを保持
var result []Item
for _, item := range items {
if item.IsImportant() {
result = append(result, item)
}
}
return result
}
// OK: 必要に応じてキャパシティを切り詰める
func filterLarge(items []Item) []Item {
var result []Item
for _, item := range items {
if item.IsImportant() {
result = append(result, item)
}
}
// キャパシティを長さに合わせて切り詰め
return slices.Clip(result) // Go 1.21+ (= result[:len(result):len(result)])
}5. Mutex / Block プロファイリング
コード例10: Mutex プロファイリング
package main
import (
"log"
"net/http"
_ "net/http/pprof"
"runtime"
"sync"
"time"
)
func main() {
// Mutex プロファイリングを有効化
// 引数: n → n回のmutex競合ごとに1回サンプリング
// 1 = 全ての競合を記録(開発用)
// 5 = 5回に1回記録(本番用)
runtime.SetMutexProfileFraction(5)
// Block プロファイリングを有効化
// 引数: ナノ秒単位の閾値
// 1 = 全てのブロッキングイベントを記録
// 1000000 = 1ms以上のブロッキングのみ記録
runtime.SetBlockProfileRate(1)
go func() {
log.Fatal(http.ListenAndServe(":6060", nil))
}()
// Mutex 競合が発生するワークロード
var mu sync.Mutex
var counter int
for i := 0; i < 100; i++ {
go func() {
for {
mu.Lock()
counter++
time.Sleep(time.Millisecond)
mu.Unlock()
}
}()
}
select {}
}# Mutex プロファイルの取得
go tool pprof http://localhost:6060/debug/pprof/mutex
# Block プロファイルの取得
go tool pprof http://localhost:6060/debug/pprof/block
# インタラクティブモードで確認
(pprof) top
(pprof) list main.main.func2
(pprof) webコード例11: RWMutex の競合分析
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
// 読み取りが多い場合は RWMutex の方が効率的
type Cache struct {
mu sync.RWMutex
items map[string]string
}
func NewCache() *Cache {
return &Cache{items: make(map[string]string)}
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock() // 読み取りロック(並行可能)
defer c.mu.RUnlock()
v, ok := c.items[key]
return v, ok
}
func (c *Cache) Set(key, value string) {
c.mu.Lock() // 書き込みロック(排他的)
defer c.mu.Unlock()
c.items[key] = value
}
// Mutex vs RWMutex の使い分け
//
// Mutex:
// - 読み書きの比率が同程度
// - 実装がシンプル
// - ロック保持時間が短い場合(RWMutex のオーバーヘッドが相対的に大きくなる)
//
// RWMutex:
// - 読み取りが圧倒的に多い(90%以上が読み取り)
// - 読み取り処理に時間がかかる場合
// - 並行読み取りの恩恵が大きい場合
//
// sync.Map:
// - キーが安定(追加されるが削除されない)
// - 読み取りが圧倒的に多い
// - goroutine ごとにアクセスするキーが異なる
func benchmarkMutexVsRWMutex() {
cache := NewCache()
// プリロード
for i := 0; i < 1000; i++ {
cache.Set(fmt.Sprintf("key_%d", i), fmt.Sprintf("value_%d", i))
}
start := time.Now()
var wg sync.WaitGroup
// 95% 読み取り、5% 書き込み
for i := 0; i < 100; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 10000; j++ {
key := fmt.Sprintf("key_%d", j%1000)
if j%20 == 0 { // 5% 書き込み
cache.Set(key, fmt.Sprintf("new_%d", j))
} else { // 95% 読み取り
cache.Get(key)
}
}
}(i)
}
wg.Wait()
fmt.Printf("Duration: %v\n", time.Since(start))
}Lock 競合の可視化フロー
+----------------------------------------------------------+
| Lock 競合の調査フロー |
+----------------------------------------------------------+
| |
| 1. Mutex プロファイル取得 |
| go tool pprof http://localhost:6060/debug/pprof/mutex |
| | |
| v |
| 2. top で競合が多い箇所を特定 |
| (pprof) top |
| → contentions/delay が高い関数 |
| | |
| v |
| 3. list で具体的なコード行を確認 |
| (pprof) list MyFunction |
| | |
| v |
| 4. 改善策の検討 |
| +--> ロック粒度を細かくする(構造体フィールドごと) |
| +--> RWMutex に変更 |
| +--> sync.Map / atomic に置き換え |
| +--> ロック保持時間を短縮 |
| +--> シャーディング(複数のMutex に分割) |
+----------------------------------------------------------+
6. runtime/trace — 実行トレース
コード例12: トレースの取得と分析
package main
import (
"os"
"runtime/trace"
)
func main() {
f, err := os.Create("trace.out")
if err != nil {
panic(err)
}
defer f.Close()
// トレース開始
if err := trace.Start(f); err != nil {
panic(err)
}
defer trace.Stop()
// トレース対象の処理
doWork()
}# トレースの可視化(ブラウザで開く)
go tool trace trace.outコード例13: カスタムタスクとリージョン
package main
import (
"context"
"runtime/trace"
)
func processOrder(ctx context.Context, orderID string) error {
// タスクを作成(trace UI でグルーピングされる)
ctx, task := trace.NewTask(ctx, "processOrder")
defer task.End()
// リージョン(タスク内のフェーズ)
trace.WithRegion(ctx, "validate", func() {
validateOrder(ctx, orderID)
})
trace.WithRegion(ctx, "payment", func() {
processPayment(ctx, orderID)
})
trace.WithRegion(ctx, "shipping", func() {
createShipment(ctx, orderID)
})
// ログイベント(trace UI で確認可能)
trace.Log(ctx, "orderID", orderID)
return nil
}
// HTTP ハンドラでのトレース
func handleOrder(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx, task := trace.NewTask(ctx, "handleOrder")
defer task.End()
trace.WithRegion(ctx, "decode", func() {
// リクエストのデコード
})
trace.WithRegion(ctx, "process", func() {
// ビジネスロジック
})
trace.WithRegion(ctx, "respond", func() {
// レスポンスの送信
})
}trace vs pprof 比較表
| 項目 | pprof | trace |
|---|---|---|
| 目的 | CPU/メモリのホットスポット特定 | 時系列のイベント分析 |
| 粒度 | 関数レベルの統計 | goroutineレベルのイベント |
| オーバーヘッド | 低(サンプリング) | 高(全イベント記録) |
| 適したケース | 「何が遅い」を知りたい | 「なぜ遅い」を知りたい |
| 可視化 | コールグラフ、フレームグラフ | タイムライン、goroutine解析 |
| 取得時間 | 30秒〜数分 | 数秒〜10秒推奨 |
| GC分析 | 不可 | GC イベントの詳細が見える |
| ネットワーク | 不可 | ネットワーク待ちが見える |
| スケジューラ | 不可 | P/G/M の関係が見える |
trace で見えるもの
+----------------------------------------------------------+
| go tool trace タイムライン表示 |
+----------------------------------------------------------+
| |
| Proc 0 |===G1====| |==G3==| |====G1====| |
| Proc 1 |==G2====| |==G4==| |===G2===| |
| Proc 2 |=G5=| |===G6===| |==G5==| |
| Proc 3 |==G7==| |=====G8=====| |
| |
| Network |---wait---| |---wait------| |
| GC | |GC| |GC| |
| |
| 時間 → 0ms 50ms 100ms 150ms 200ms |
+----------------------------------------------------------+
G=goroutine, GC=ガベージコレクション
コード例14: trace による GC 分析
package main
import (
"fmt"
"os"
"runtime"
"runtime/debug"
"runtime/trace"
"time"
)
func main() {
// GC の統計情報を取得
var stats debug.GCStats
debug.ReadGCStats(&stats)
fmt.Printf("GC回数: %d\n", stats.NumGC)
fmt.Printf("最後のGC: %v\n", stats.LastGC)
fmt.Printf("GC合計時間: %v\n", stats.PauseTotal)
// GC パーセンテージの設定
// GOGC=100 はデフォルト(ヒープが倍になったらGC)
// GOGC=50 はGC頻度を上げる(レイテンシ重視)
// GOGC=200 はGC頻度を下げる(スループット重視)
oldGOGC := debug.SetGCPercent(100)
fmt.Printf("Previous GOGC: %d\n", oldGOGC)
// メモリ制限の設定(Go 1.19+)
// GOMEMLIMIT=1GiB またはプログラムから設定
debug.SetMemoryLimit(1 << 30) // 1 GiB
// トレース付きでGC挙動を観察
f, _ := os.Create("gc_trace.out")
defer f.Close()
trace.Start(f)
defer trace.Stop()
// メモリを大量に確保する処理
allocateAndRelease()
// go tool trace gc_trace.out で GC タイミングを確認
}
func allocateAndRelease() {
for i := 0; i < 100; i++ {
data := make([]byte, 10*1024*1024) // 10MB
_ = data
time.Sleep(10 * time.Millisecond)
}
}GODEBUG 環境変数によるGCトレース
# GC のタイミングと所要時間を標準エラーに出力
GODEBUG=gctrace=1 ./myapp
# 出力例:
# gc 1 @0.012s 2%: 0.019+0.85+0.003 ms clock, 0.076+0.20/0.75/0+0.012 ms cpu, 4->4->0 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 4 P
#
# 読み方:
# gc 1 → 1回目のGC
# @0.012s → プログラム開始から0.012秒後
# 2% → GCがCPU時間の2%を消費
# 0.019+0.85+0.003 ms → STW sweep start + concurrent + STW mark termination
# 4->4->0 MB → GC前ヒープ -> GC後ヒープ -> ライブデータ
# 4 MB goal → 次のGCトリガーサイズ
# スケジューラの詳細情報
GODEBUG=schedtrace=1000 ./myapp
# 1000ms ごとにスケジューラの状態を出力7. ベンチマーク連携プロファイリング
コード例15: ベンチマークからプロファイル取得
# CPUプロファイル付きベンチマーク
go test -bench=BenchmarkSerialize -cpuprofile=cpu.prof -count=5
# メモリプロファイル付きベンチマーク
go test -bench=BenchmarkSerialize -memprofile=mem.prof -count=5
# トレース付きベンチマーク
go test -bench=BenchmarkSerialize -trace=trace.out
# Block プロファイル付きベンチマーク
go test -bench=BenchmarkSerialize -blockprofile=block.prof
# Mutex プロファイル付きベンチマーク
go test -bench=BenchmarkSerialize -mutexprofile=mutex.prof
# プロファイル分析
go tool pprof cpu.prof
go tool pprof mem.prof
go tool pprof -http=:8081 cpu.profコード例16: メモリアロケーション最適化のサイクル
// 最適化前
func ConcatStrings(strs []string) string {
result := ""
for _, s := range strs {
result += s // 毎回新しい文字列を確保
}
return result
}
func BenchmarkConcatStrings(b *testing.B) {
strs := make([]string, 1000)
for i := range strs {
strs[i] = "hello"
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
ConcatStrings(strs)
}
}
// BenchmarkConcatStrings 500 2145678 ns/op 5308416 B/op 999 allocs/op
// 最適化後: strings.Builder
func ConcatStringsOptimized(strs []string) string {
var b strings.Builder
size := 0
for _, s := range strs {
size += len(s)
}
b.Grow(size) // 事前にキャパシティ確保
for _, s := range strs {
b.WriteString(s)
}
return b.String()
}
// BenchmarkConcatStringsOpt 50000 28456 ns/op 5120 B/op 1 allocs/op
// 75x高速化 999x削減コード例17: sync.Pool によるアロケーション削減
package main
import (
"bytes"
"encoding/json"
"sync"
"testing"
)
// sync.Pool を使ったバッファ再利用
var bufPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
// Pool なし版
func marshalJSON(v interface{}) ([]byte, error) {
var buf bytes.Buffer // 毎回アロケーション
enc := json.NewEncoder(&buf)
if err := enc.Encode(v); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// Pool あり版
func marshalJSONPooled(v interface{}) ([]byte, error) {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufPool.Put(buf)
enc := json.NewEncoder(buf)
if err := enc.Encode(v); err != nil {
return nil, err
}
// Pool に返す前にコピー(bufはPoolに返却されるため)
result := make([]byte, buf.Len())
copy(result, buf.Bytes())
return result, nil
}
func BenchmarkMarshalJSON(b *testing.B) {
data := map[string]interface{}{"name": "Alice", "age": 30, "active": true}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
marshalJSON(data)
}
}
func BenchmarkMarshalJSONPooled(b *testing.B) {
data := map[string]interface{}{"name": "Alice", "age": 30, "active": true}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
marshalJSONPooled(data)
}
}
// 典型的な結果:
// BenchmarkMarshalJSON-8 500000 3200 ns/op 768 B/op 3 allocs/op
// BenchmarkMarshalJSONPooled-8 800000 1800 ns/op 256 B/op 2 allocs/opコード例18: プリアロケーションによる最適化
package main
import "testing"
// NG: append のたびに底層配列が再割り当て
func collectItemsSlow(n int) []int {
var result []int
for i := 0; i < n; i++ {
result = append(result, i*2)
}
return result
}
// OK: 事前にキャパシティを確保
func collectItemsFast(n int) []int {
result := make([]int, 0, n)
for i := 0; i < n; i++ {
result = append(result, i*2)
}
return result
}
// さらに高速: インデックス直接代入
func collectItemsFastest(n int) []int {
result := make([]int, n)
for i := 0; i < n; i++ {
result[i] = i * 2
}
return result
}
func BenchmarkCollectSlow(b *testing.B) {
for i := 0; i < b.N; i++ {
collectItemsSlow(10000)
}
}
func BenchmarkCollectFast(b *testing.B) {
for i := 0; i < b.N; i++ {
collectItemsFast(10000)
}
}
func BenchmarkCollectFastest(b *testing.B) {
for i := 0; i < b.N; i++ {
collectItemsFastest(10000)
}
}
// 典型的な結果:
// BenchmarkCollectSlow-8 10000 152000 ns/op 386048 B/op 20 allocs/op
// BenchmarkCollectFast-8 50000 28000 ns/op 81920 B/op 1 allocs/op
// BenchmarkCollectFastest-8 50000 25000 ns/op 81920 B/op 1 allocs/op8. フレームグラフの読み方
フレームグラフの構造
+----------------------------------------------------------+
| フレームグラフの読み方 |
+----------------------------------------------------------+
| |
| 横軸 = CPU消費時間の割合(広いほど時間を使っている) |
| 縦軸 = コールスタックの深さ(上に行くほど呼び出し先) |
| |
| +---------------------------------------------------+ |
| | main.main | |
| +---------------------------------------------------+ |
| | main.handleRequest | main.other | |
| +-------------------------------------+-------------+ |
| | main.processData | main.queryDB | | |
| +-------------------+----------------+ | |
| | json.Unmarshal | sql.Query | | |
| +-------------------+----------------+ | |
| |
| → json.Unmarshal と sql.Query が主なボトルネック |
| → handleRequest が全体の75%を占めている |
+----------------------------------------------------------+
フレームグラフで見るべきポイント
1. 幅の広いフレーム
→ CPU時間を多く消費している関数
→ まずここを最適化候補にする
2. 深いコールスタック
→ 呼び出し階層が深い(リファクタリング候補)
→ 間接呼び出しが多い場合はインライン化を検討
3. runtime.* の占める割合
→ runtime.mallocgc が大きい → アロケーションが多い
→ runtime.gcBgMarkWorker が大きい → GC負荷が高い
→ runtime.futex / runtime.notesleep → ロック待ち
4. 同じ関数が複数箇所に出現
→ 異なるコールパスから呼ばれている
→ ホットな共有関数の最適化効果が大きい
コード例19: ベンチマーク結果の比較ツール
# benchstat でベンチマーク結果を統計的に比較
# インストール
go install golang.org/x/perf/cmd/benchstat@latest
# 最適化前のベンチマーク
go test -bench=. -count=10 -benchmem > before.txt
# 最適化後のベンチマーク
go test -bench=. -count=10 -benchmem > after.txt
# 比較
benchstat before.txt after.txt
# 出力例:
# name old time/op new time/op delta
# Serialize-8 2.15ms ± 3% 0.85ms ± 2% -60.47% (p=0.000 n=10+10)
#
# name old alloc/op new alloc/op delta
# Serialize-8 5.30MB ± 0% 0.01MB ± 0% -99.81% (p=0.000 n=10+10)
#
# name old allocs/op new allocs/op delta
# Serialize-8 999 ± 0% 1 ± 0% -99.90% (p=0.000 n=10+10)9. 継続的プロファイリング
継続的プロファイリングの必要性
+----------------------------------------------------------+
| 従来のプロファイリング vs 継続的プロファイリング |
+----------------------------------------------------------+
| |
| 従来: |
| - 問題が発生してからプロファイルを取得 |
| - 再現が困難な問題を見逃す |
| - 開発環境と本番環境のパフォーマンス差を把握しにくい |
| |
| 継続的: |
| - 常時プロファイルを収集 |
| - 時系列で傾向を分析(リグレッション検知) |
| - デプロイ前後の比較が容易 |
| - 低頻度の問題も捕捉可能 |
+----------------------------------------------------------+
コード例20: Pyroscope を使った継続的プロファイリング
package main
import (
"log"
"net/http"
"os"
"github.com/grafana/pyroscope-go"
)
func main() {
// Pyroscope の設定
pyroscope.Start(pyroscope.Config{
ApplicationName: "myapp",
ServerAddress: os.Getenv("PYROSCOPE_SERVER"), // 例: http://pyroscope:4040
Logger: pyroscope.StandardLogger,
// プロファイルの種類を選択
ProfileTypes: []pyroscope.ProfileType{
pyroscope.ProfileCPU,
pyroscope.ProfileAllocObjects,
pyroscope.ProfileAllocSpace,
pyroscope.ProfileInuseObjects,
pyroscope.ProfileInuseSpace,
pyroscope.ProfileGoroutines,
pyroscope.ProfileMutexCount,
pyroscope.ProfileMutexDuration,
pyroscope.ProfileBlockCount,
pyroscope.ProfileBlockDuration,
},
// タグでフィルタリング可能にする
Tags: map[string]string{
"env": os.Getenv("APP_ENV"),
"version": version,
"region": os.Getenv("AWS_REGION"),
},
})
// 特定の処理にタグを付ける
pyroscope.TagWrapper(context.Background(), pyroscope.Labels(
"handler", "processOrder",
"orderType", "premium",
), func(ctx context.Context) {
processOrder(ctx)
})
http.ListenAndServe(":8080", router())
}コード例21: Google Cloud Profiler との統合
package main
import (
"log"
"cloud.google.com/go/profiler"
)
func main() {
// Google Cloud Profiler の設定
cfg := profiler.Config{
Service: "myapp",
ServiceVersion: version,
ProjectID: "my-gcp-project",
// MutexProfiling: true, // Mutex プロファイリングを有効化
}
if err := profiler.Start(cfg); err != nil {
log.Printf("Cloud Profiler の起動に失敗: %v", err)
// プロファイラの起動失敗はアプリケーション停止の理由にしない
}
// アプリケーション起動
startServer()
}継続的プロファイリングツール比較
| ツール | 提供元 | 価格 | 特徴 |
|---|---|---|---|
| Pyroscope | Grafana | OSS / Cloud | Grafana との統合、Go SDK が充実 |
| Parca | Polar Signals | OSS | eBPF ベース、低オーバーヘッド |
| Cloud Profiler | GCP利用料に含む | GCP 統合、設定が簡単 | |
| Datadog Profiler | Datadog | 有料 | APM との統合、豊富な分析機能 |
| pprof + 自前収集 | - | 無料 | 柔軟だが運用コスト高 |
10. 実践的な最適化パターン
コード例22: HTTP レスポンスのストリーミング最適化
package main
import (
"encoding/json"
"net/http"
"sync"
)
// NG: レスポンス全体をメモリに構築
func handleUsersNG(w http.ResponseWriter, r *http.Request) {
users, err := db.GetAllUsers() // 全ユーザーをメモリに読み込み
if err != nil {
http.Error(w, err.Error(), 500)
return
}
json.NewEncoder(w).Encode(users) // 巨大な JSON を一括エンコード
}
// OK: ストリーミングで段階的に書き出し
func handleUsersOK(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("["))
rows, err := db.QueryUsers(r.Context())
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer rows.Close()
enc := json.NewEncoder(w)
first := true
for rows.Next() {
var user User
if err := rows.Scan(&user); err != nil {
break
}
if !first {
w.Write([]byte(","))
}
first = false
enc.Encode(user)
}
w.Write([]byte("]"))
}コード例23: マップの事前サイズ指定
package main
import "testing"
// NG: サイズ指定なし → rehash が複数回発生
func createMapSlow(n int) map[string]int {
m := make(map[string]int) // 初期バケット数が小さい
for i := 0; i < n; i++ {
m[fmt.Sprintf("key_%d", i)] = i
}
return m
}
// OK: 事前にサイズ指定 → rehash を回避
func createMapFast(n int) map[string]int {
m := make(map[string]int, n) // 必要なバケット数を事前確保
for i := 0; i < n; i++ {
m[fmt.Sprintf("key_%d", i)] = i
}
return m
}
// ベンチマーク結果例(n=10000):
// BenchmarkMapSlow-8 5000 312000 ns/op 687432 B/op 172 allocs/op
// BenchmarkMapFast-8 8000 198000 ns/op 473440 B/op 12 allocs/opコード例24: 文字列操作の最適化
package main
import (
"fmt"
"strconv"
"strings"
"testing"
)
// NG: fmt.Sprintf は反射を使うため遅い
func formatUserSlow(name string, age int) string {
return fmt.Sprintf("Name: %s, Age: %d", name, age)
}
// OK: strings.Builder + strconv で高速化
func formatUserFast(name string, age int) string {
var b strings.Builder
b.Grow(20 + len(name)) // 必要なサイズを事前確保
b.WriteString("Name: ")
b.WriteString(name)
b.WriteString(", Age: ")
b.WriteString(strconv.Itoa(age))
return b.String()
}
// OK: 文字列結合が少ない場合は + 演算子でも十分
func formatUserSimple(name string, age int) string {
return "Name: " + name + ", Age: " + strconv.Itoa(age)
}
// ベンチマーク結果例:
// BenchmarkFormatSlow-8 5000000 280 ns/op 64 B/op 2 allocs/op
// BenchmarkFormatFast-8 15000000 85 ns/op 48 B/op 1 allocs/op
// BenchmarkFormatSimple-8 12000000 95 ns/op 48 B/op 1 allocs/opコード例25: インターフェースの具体型アサーションによる最適化
package main
import (
"io"
"os"
)
// io.WriterTo インターフェースを活用した最適化
// 標準ライブラリの多くの型が WriterTo を実装している
func copyData(dst io.Writer, src io.Reader) (int64, error) {
// io.Copy は内部で WriterTo / ReaderFrom をチェックする
// - src が WriterTo を実装 → src.WriteTo(dst) を呼ぶ
// - dst が ReaderFrom を実装 → dst.ReadFrom(src) を呼ぶ
// - どちらもない → 中間バッファを介してコピー
return io.Copy(dst, src)
}
// バッファサイズの指定(大きなファイルの場合)
func copyLargeFile(dst io.Writer, src io.Reader) (int64, error) {
// 32KB のデフォルトバッファではなく、より大きなバッファを使用
buf := make([]byte, 1024*1024) // 1MB バッファ
return io.CopyBuffer(dst, src, buf)
}
// 型アサーションで最適パスを選択
type Flusher interface {
Flush() error
}
func writeWithFlush(w io.Writer, data []byte) error {
if _, err := w.Write(data); err != nil {
return err
}
// Flusher を実装している場合はフラッシュ
if f, ok := w.(Flusher); ok {
return f.Flush()
}
return nil
}11. アンチパターン
アンチパターン1: 本番環境でpprofを公開ポートに露出
// NG: 本番で公開ポートにpprof
import _ "net/http/pprof"
func main() {
// pprofが外部からアクセス可能 → セキュリティリスク
http.ListenAndServe(":8080", nil)
}
// OK: pprofは別ポート+内部ネットワークのみ
func main() {
go func() {
// localhost のみ、または内部ネットワークのみ
log.Fatal(http.ListenAndServe("127.0.0.1:6060", nil))
}()
http.ListenAndServe(":8080", appHandler)
}アンチパターン2: プロファイリングなしの推測的最適化
// NG: 「ここが遅いはず」と推測で最適化
// → 実際にはボトルネックではない箇所に時間を浪費
// OK: プロファイルに基づく最適化サイクル
// 1. ベンチマーク実行
// 2. プロファイル取得
// 3. ホットスポット特定
// 4. 改善実装
// 5. ベンチマークで効果検証
// 6. 1に戻るアンチパターン3: sync.Pool の誤用
// NG: 小さすぎるオブジェクトに sync.Pool を使う
var intPool = sync.Pool{
New: func() interface{} {
v := 0
return &v // int のポインタはアロケーションが小さすぎてPoolのオーバーヘッドが上回る
},
}
// NG: Pool から取得したオブジェクトを初期化せずに使う
var bufPool = sync.Pool{
New: func() interface{} {
return &bytes.Buffer{}
},
}
func process() {
buf := bufPool.Get().(*bytes.Buffer)
defer bufPool.Put(buf)
// buf.Reset() を忘れている → 前回のデータが残っている
buf.WriteString("new data")
}
// OK: 適切なサイズのオブジェクトでPoolを使い、必ず初期化
var bufPool = sync.Pool{
New: func() interface{} {
return bytes.NewBuffer(make([]byte, 0, 4096))
},
}
func process() {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() // 必ずリセット
defer bufPool.Put(buf)
buf.WriteString("new data")
}アンチパターン4: トレースを長時間取得する
// NG: トレースを60秒間取得 → データが膨大になりUIが固まる
// go tool trace trace_60s.out → ブラウザがクラッシュ
// OK: トレースは短時間(1〜5秒)に限定
// curl "http://localhost:6060/debug/pprof/trace?seconds=3" > trace.out
// go tool trace trace.out
// 特定の操作をトレースしたい場合はプログラム内で制御
func traceOperation(ctx context.Context) error {
f, _ := os.CreateTemp("", "trace_*.out")
defer f.Close()
trace.Start(f)
defer trace.Stop()
// トレース対象の操作(短時間で完了するもの)
return doOperation(ctx)
}アンチパターン5: MemProfileRate を 1 にして本番運用
// NG: 全アロケーションを記録(パフォーマンスへの影響大)
func init() {
runtime.MemProfileRate = 1 // 全てのアロケーションを記録
}
// OK: 本番環境ではデフォルト値を使う
// runtime.MemProfileRate のデフォルトは 524288 (512KB)
// 必要に応じて調整
func init() {
if os.Getenv("DETAILED_MEMPROFILE") == "true" {
runtime.MemProfileRate = 1 // デバッグ時のみ
}
// それ以外はデフォルト (512KB ごとに1回サンプリング)
}FAQ
Q1. プロファイリングのオーバーヘッドは本番環境で許容できるか?
net/http/pprof のエンドポイントが存在するだけではオーバーヘッドはほぼゼロ。CPUプロファイルはリクエスト時のみサンプリングが走り、通常1-5%程度の影響。メモリプロファイルは runtime.MemProfileRate で制御でき、デフォルトでは512KBごとに1回サンプリング。Block/Mutex プロファイルは SetBlockProfileRate / SetMutexProfileFraction で制御し、本番では低いサンプリングレートを推奨。
Q2. フレームグラフはどう読むか?
フレームグラフは横軸がCPU消費時間の割合、縦軸がコールスタックの深さを表す。幅の広いフレームがボトルネック。上に行くほど呼び出し先の関数。go tool pprof -http=:8081 cpu.prof のFlame Graph タブで確認可能。runtime.mallocgc が広い場合はアロケーション過多、runtime.gcBgMarkWorker が広い場合はGC負荷が高いことを示す。
Q3. goroutineリークの検出方法は?
runtime.NumGoroutine() を定期的にログ出力し、増加傾向がないか監視する。/debug/pprof/goroutine?debug=1 でgoroutineのスタックトレースを確認し、同じスタックトレースのgoroutineが大量にある場合はリークの可能性が高い。goleak パッケージ(go.uber.org/goleak)をテストに組み込むことで、テスト終了時に未完了のgoroutineを検出できる。
// goleak を使ったgoroutineリーク検出テスト
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
// 個別のテストで使う場合
func TestSomething(t *testing.T) {
defer goleak.VerifyNone(t)
// テストコード
}Q4. pprof の Web UI で利用できるビューは?
go tool pprof -http=:8081 profile.prof で起動するWeb UIには以下のビューがある。
- Top: 関数ごとのCPU/メモリ消費ランキング
- Graph: コールグラフ(関数間の呼び出し関係)
- Flame Graph: フレームグラフ(横幅=コスト、縦=コールスタック深さ)
- Peek: 特定関数の呼び出し元・呼び出し先
- Source: ソースコード上でのコスト表示
- Disasm: アセンブリコード上でのコスト表示
Q5. GOGC と GOMEMLIMIT の使い分けは?
GOGC はヒープの成長率に基づいてGCをトリガーする(デフォルト100 = ヒープが倍になったらGC)。GOMEMLIMIT(Go 1.19+)はメモリの上限を設定し、上限に近づくとGCを積極的に実行する。コンテナ環境では GOMEMLIMIT をコンテナのメモリ制限の80-90%に設定するのが推奨。両方を組み合わせて使うことも可能。
# コンテナ環境での推奨設定例
# コンテナメモリ制限: 1GB
GOMEMLIMIT=900MiB # メモリ制限の90%
GOGC=100 # デフォルト(GOMEMLIMITと組み合わせ)Q6. ベンチマークの結果が毎回異なるのはなぜか?
CPUのサーマルスロットリング、他のプロセスの影響、OSのスケジューリングなどが原因。安定した結果を得るには: (1) -count=10 で複数回実行し benchstat で統計処理、(2) taskset / cpuset でCPUを固定、(3) ターボブーストを無効化、(4) 他のプロセスを最小限にする。CI環境ではノイズが大きいため、ローカルでの計測を推奨。
Q7. Escape Analysis の結果を確認する方法は?
# ヒープへの escape を確認
go build -gcflags="-m" ./...
# より詳細な情報
go build -gcflags="-m -m" ./...
# 出力例:
# ./main.go:15:6: can inline NewUser
# ./main.go:20:10: &User{...} escapes to heap
# → "&User{...}" がヒープに割り当てられることがわかるスタックに割り当てられるとGCの負荷がかからないため高速。ヒープに escape する主な原因: (1) ポインタを返す、(2) インターフェースに代入、(3) クロージャで参照、(4) サイズが大きすぎる(通常64KB以上)。
まとめ
| 概念 | 要点 |
|---|---|
| net/http/pprof | HTTPエンドポイントでプロファイル取得 |
| go tool pprof | プロファイルの分析・可視化ツール |
| CPU profile | 関数ごとのCPU消費時間を特定 |
| Heap profile | メモリアロケーションのホットスポット特定 |
| goroutine profile | goroutineリーク検出 |
| Mutex/Block profile | ロック競合とブロッキング操作の分析 |
| runtime/trace | 時系列イベントの可視化 |
| -bench + -cpuprofile | ベンチマークとプロファイルの連携 |
| b.ReportAllocs() | アロケーション数の計測 |
| sync.Pool | オブジェクト再利用でアロケーション削減 |
| benchstat | ベンチマーク結果の統計的比較 |
| GOGC / GOMEMLIMIT | GCの挙動制御 |
| 継続的プロファイリング | Pyroscope / Parca / Cloud Profiler |
| Escape Analysis | go build -gcflags="-m" でヒープ割り当てを確認 |
次に読むべきガイド
- 03-tools/03-deployment.md — デプロイ:Docker、クロスコンパイル
- 03-tools/04-best-practices.md — ベストプラクティス:Effective Go
- 02-web/04-testing.md — テスト:table-driven tests、testify、httptest
参考文献
- Go Blog — Profiling Go Programs https://go.dev/blog/pprof
- Go公式 — runtime/pprof パッケージ https://pkg.go.dev/runtime/pprof
- Go公式 — runtime/trace パッケージ https://pkg.go.dev/runtime/trace
- Julia Evans — A Practical Guide to pprof https://jvns.ca/blog/2017/09/24/profiling-go-with-pprof/
- Go公式 — runtime/debug パッケージ https://pkg.go.dev/runtime/debug
- Pyroscope 公式ドキュメント https://pyroscope.io/docs/
- Google Cloud Profiler https://cloud.google.com/profiler/docs
- benchstat ツール https://pkg.go.dev/golang.org/x/perf/cmd/benchstat