[Goのはじめ方] Part28: ベンチマークテストと例示

Go

🚀 ベンチマークテストとは?

ベンチマークテストは、プログラムの特定の部分のパフォーマンス(実行速度やメモリ使用量)を測定するためのテストです。Go言語では、標準の testing パッケージを使って簡単にベンチマークテストを作成・実行できます。

なぜベンチマークテストが重要なのでしょうか?

  • 性能改善の指標: コードの変更によって性能がどれだけ向上したか(または悪化したか)を具体的に知ることができます。
  • 実装方法の比較: 同じ機能を実現する複数の方法がある場合、どちらがより効率的かを客観的に判断できます。
  • ボトルネックの特定: プログラムの中で処理に時間がかかっている箇所を見つけ出し、最適化のターゲットを絞り込めます。

Go言語の生みの親の一人であるRob Pikeは、「推測するな、計測せよ (Don’t guess, measure.)」と言っています。感覚や推測に頼らず、実際のデータに基づいてパフォーマンスを評価することが大切です。⏱️

📝 ベンチマークテストの書き方

ベンチマークテストは、通常のテスト(TestXxx)と同じ _test.go ファイル内に記述します。基本的なルールは以下の通りです。

  • 関数名は Benchmark で始まり、その後に続く文字は大文字でなければなりません(例: BenchmarkMyFunction)。
  • 引数として *testing.B を受け取ります。
  • ベンチマーク対象の処理を for i := 0; i < b.N; i++ ループの中で実行します。

*testing.B はベンチマークの実行を制御するための型です。b.N はベンチマークフレームワークが自動的に調整するループ回数で、信頼できる測定結果が得られるまで増加します。

Go 1.24からの新しい書き方: b.Loop
Go 1.24からは、より推奨される書き方として b.Loop メソッドが導入されました。これはタイマー管理やコンパイラの最適化防止を自動で行ってくれるため、より正確で簡潔なベンチマークが書けます。
<!-- Go 1.24以降 -->
func BenchmarkMyFunctionNew(b *testing.B) {
    // セットアップ処理 (タイマー管理は不要)
    setupData := prepareData()
    b.ResetTimer() // Loopを使う場合でも明示的なリセット推奨

    // b.Loop は自動でタイマーを管理し、必要な回数ループを実行
    for b.Loop() {
        // ベンチマーク対象の処理
        processData(setupData)
    }

    // クリーンアップ処理 (必要な場合)
}
古い書き方 (for i := 0; i < b.N; i++) も引き続き利用可能ですが、新しいプロジェクトでは b.Loop の使用を検討すると良いでしょう。
package main

import (
	"strings"
	"testing"
)

// 比較対象の関数1: '+' 演算子による文字列結合
func concatPlus(strs []string) string {
	var result string
	for _, s := range strs {
		result += s // ループごとにメモリ確保が発生しやすい
	}
	return result
}

// 比較対象の関数2: strings.Builder を使用
func concatBuilder(strs []string) string {
	var builder strings.Builder
	for _, s := range strs {
		builder.WriteString(s) // 効率的なメモリ管理
	}
	return builder.String()
}

// ベンチマークテスト関数 (concatPlus用)
func BenchmarkConcatPlus(b *testing.B) {
	data := []string{"Go", " ", "is", " ", "awesome", "!"}
	// b.N はベンチマークフレームワークが自動で決定する
	for i := 0; i < b.N; i++ {
		concatPlus(data) // 計測したい関数を呼び出す
	}
}

// ベンチマークテスト関数 (concatBuilder用)
func BenchmarkConcatBuilder(b *testing.B) {
	data := []string{"Go", " ", "is", " ", "awesome", "!"}
	// b.N はベンチマークフレームワークが自動で決定する
	for i := 0; i < b.N; i++ {
		concatBuilder(data) // 計測したい関数を呼び出す
	}
}

この例では、二つの異なる文字列結合方法(+ 演算子と strings.Builder)のパフォーマンスを比較するためのベンチマークテストを定義しています。

💻 ベンチマークテストの実行方法

ベンチマークテストを実行するには、go test コマンドに -bench フラグを付けて実行します。

  • すべてのベンチマークを実行:
    go test -bench=.
    . は正規表現で、すべてのベンチマーク関数にマッチします。
  • 特定のベンチマークを実行:
    go test -bench=Concat
    関数名に “Concat” を含むベンチマーク(BenchmarkConcatPlusBenchmarkConcatBuilder)が実行されます。
  • メモリ割り当て情報も表示:
    go test -bench=. -benchmem
    -benchmem フラグを追加すると、実行時間だけでなく、メモリ割り当て回数(allocs/op)と割り当てられたメモリ量(B/op)も表示されます。これはパフォーマンスチューニングにおいて非常に重要な情報です。

実行結果の例:

$ go test -bench=. -benchmem
goos: linux
goarch: amd64
pkg: myproject/concat
cpu: Intel(R) Core(TM) i7-8700K CPU @ 3.70GHz
BenchmarkConcatPlus-12        	 6986688	       168.6 ns/op	     112 B/op	       5 allocs/op
BenchmarkConcatBuilder-12     	25417878	        46.91 ns/op	      64 B/op	       1 allocs/op
PASS
ok  	myproject/concat	3.450s

実行結果の見方:

項目説明例 (ConcatPlus)例 (ConcatBuilder)
BenchmarkConcatPlus-12ベンチマーク関数名と GOMAXPROCS の値(利用CPUコア数)。BenchmarkConcatPlus-12BenchmarkConcatBuilder-12
6986688実行されたループ回数 (b.N の最終的な値)。6,986,688回25,417,878回
168.6 ns/op1回の操作あたりの平均実行時間 (ナノ秒/オペレーション)。小さいほど高速168.6 ns46.91 ns
112 B/op1回の操作あたりに割り当てられた平均メモリ量 (バイト/オペレーション)。小さいほどメモリ効率が良い112 B64 B
5 allocs/op1回の操作あたりのメモリアロケーション(メモリ確保)回数。小さいほどGCへの負荷が少ない5回1回

この結果から、strings.Builder を使った concatBuilder 関数の方が、+ 演算子を使った concatPlus 関数よりも実行時間が短く、メモリ割り当ても少ない(効率が良い)ことがわかります。✨

💡 ベンチマークテストの注意点とTips

  • b.ResetTimer(): ベンチマークのループが始まる前に時間のかかるセットアップ処理がある場合、その時間を計測から除外するためにループの直前で b.ResetTimer() を呼び出します。
    func BenchmarkWithSetup(b *testing.B) {
        // 時間のかかるセットアップ処理
        expensiveData := setupExpensiveData()
    
        b.ResetTimer() // ここでタイマーをリセット
    
        for i := 0; i < b.N; i++ {
            processData(expensiveData)
        }
    }
  • b.StopTimer()b.StartTimer(): ループの中で計測に含めたくない処理(例:テストデータの準備など)がある場合、その処理の前後に b.StopTimer()b.StartTimer() を使ってタイマーを一時停止・再開できます。ただし、頻繁な呼び出しは測定結果に影響を与える可能性があるので注意が必要です。
  • コンパイラの最適化: ベンチマーク対象の関数の結果がどこでも使われていない場合、コンパイラがその処理を「不要」と判断して最適化(削除)してしまうことがあります。これを避けるために、結果をパッケージレベルの変数(sinkパターン)に代入することがあります。
    var resultSink string // 結果を代入するためのパッケージレベル変数
    
    func BenchmarkMyFunc(b *testing.B) {
        var r string
        for i := 0; i < b.N; i++ {
            r = myFunction() // 結果を変数に代入
        }
        resultSink = r // 結果をパッケージレベル変数に代入して最適化を防ぐ
    }
    ただし、b.Loop を使用する場合は、この最適化防止策が不要になることが多いです。
  • 安定した結果を得るために: ベンチマーク結果は実行環境(CPU、OS、他の実行中プロセスなど)によって変動する可能性があります。より信頼性の高い結果を得るためには、複数回(-count フラグで指定可能)実行して平均を取ったり、benchstat ツールを使って統計的に比較したりすることが推奨されます。
    go test -bench=. -count=5 > old.txt
    # コード変更後...
    go test -bench=. -count=5 > new.txt
    benchstat old.txt new.txt
  • テーブル駆動ベンチマーク: ユニットテストと同様に、複数の入力パターンでベンチマークを取りたい場合は、b.Run を使ったテーブル駆動ベンチマークが便利です。
    func BenchmarkMyFunctionTable(b *testing.B) {
        testCases := []struct {
            name  string
            input int
        }{
            {"SmallInput", 10},
            {"MediumInput", 1000},
            {"LargeInput", 100000},
        }
    
        for _, tc := range testCases {
            b.Run(tc.name, func(b *testing.B) {
                for i := 0; i < b.N; i++ {
                    myFunction(tc.input)
                }
            })
        }
    }

まとめ

Go言語のベンチマークテストは、コードのパフォーマンスを測定し、改善するための強力なツールです。

  • Benchmark 接頭辞と *testing.B 引数を持つ関数を定義します。
  • for i := 0; i < b.N; i++ ループまたは b.Loop() を使って対象コードを実行します。
  • go test -bench=. コマンドで実行し、-benchmem でメモリ情報も取得します。
  • 結果 (ns/op, B/op, allocs/op) を見て、パフォーマンスを評価・比較します。

推測に頼らず、ベンチマークによって得られた客観的なデータに基づいて、コードの最適化を進めていきましょう! 💪

コメント

タイトルとURLをコピーしました