Goのb.Loopで正確なベンチマークを書く—b.Nとの違いと落とし穴

Goのb.Loopで正確なベンチマークを書く—b.Nとの違いと落とし穴 | mohablog

0.3128 ns/op。1000個の整数を合計するベンチが返すこの数字は、測定の失敗を意味します。合計が0.3ナノ秒で終わるはずはなく、コンパイラが処理ごと消したサイン。この誤計測は、Go 1.24で入った testing.B.Loop で防げます。

目次

b.Nループが測り損なう原因

昔ながらの b.N ループで、1000要素の []int を合計する関数を測ってみます。

func sum(nums []int) int {
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}

var data = make([]int, 1000)

func BenchmarkSumNaive(b *testing.B) {
    for range b.N {
        sum(data)
    }
}

結果はこうなります。

goos: darwin
goarch: arm64
pkg: example/bench
cpu: Apple M2
BenchmarkSumNaive-8   1000000000   0.3128 ns/op
PASS

1000回の加算が0.3ナノ秒。反復回数も10億回で頭打ち。物理的にありえない値です。原因は、公式ブログ “More predictable benchmarking with testing.B.Loop”(Junyang Shao, 2025年4月)が “Old benchmark loop problems” の節でまとめています。

計測したいコードが丸ごと消える

Goコンパイラは、戻り値を使わない純粋な関数呼び出しをデッドコードとして削除します。sum はインライン化できる小さな関数。戻り値がどこにも使われないと分かれば、加算ループごと消えます。残るのは空の for だけ。だから0.3ナノ秒。

コンパイラは観測可能な挙動を変えない範囲で最適化してよく、戻り値を捨てる呼び出しは観測不能とみなされます。実際に消えたかは go test -gcflags=-m で追える。inlining call to sum と出たあと、その結果が使われなければ最適化で落ちます。

従来はこれを防ぐため、パッケージ変数に結果を代入する「sink」を挟んでいました。

var sink int

func BenchmarkSumSink(b *testing.B) {
    for range b.N {
        sink = sum(data)
    }
}
BenchmarkSumSink-8   3820000   313.5 ns/op

313ナノ秒。これが本当の値です。ただしsinkはベンチのたびに書く定型で、書き忘れれば静かに0.3ナノ秒へ逆戻り。sinkを書き忘れたまま「この実装は速い」と報告しかけ、レビューで桁を指摘された経験があります。

セットアップの時間がタイマーに乗る

もう一つはタイマー。b.N ループでは、ループ前に書いた重い準備処理も計測時間に入り込む。避けるには b.ResetTimer()、途中で止めるには b.StopTimer() を手で呼びます。呼び忘れると準備コストが1反復あたりの数字へ混ざり、結果が水増しされます。

for b.Loop() に書き換える

func BenchmarkSumLoop(b *testing.B) {
    for b.Loop() {
        sum(data)
    }
}
BenchmarkSumLoop-8   3818766   313.5 ns/op

sinkも ResetTimer も無しで313ナノ秒。素朴な b.N 版とほぼ1文字違いなのに、正しい値が出ます。for range b.Nfor b.Loop() に替えるだけ。

タイマーは自動でリセット・停止される

Loop は初回の呼び出しでベンチマークタイマーをリセットします。ループに入る前の準備は計測対象から外れる。false を返すときにはタイマーを止めるので、ループ後のクリーンアップも計測されません。ResetTimerStopTimer の定型呼び出しが要らなくなります。

Go 1.24で追加、現行では推奨

シグネチャは func (b *B) Loop() bool。Go 1.24.0で追加され、2026年時点の現行はGo 1.26系。公式ドキュメントは b.Loop を “the preferred way to write benchmarks” と位置づけています。b.N ループも引き続き有効で、廃止されたわけではありません。

デッドコード削除を止める仕組み

書き換えるだけで削除が止まる理由は、公式ブログの “How testing.B.Loop helps” 節にあります。

ループ本文のインライン化を止める

the Go compiler now detects loops where the condition is just a call to testing.B.Loop and prevents dead code elimination within the loop. In Go 1.24, this is implemented by disallowing inlining into the body of such a loop.

ループ条件が b.Loop() だとコンパイラが認識すると、その本文へのインライン化を止める。インライン化されなければ戻り値の追跡もできず、削除の判断が働きません。pkg.go.devtesting 側の記述では、ループ本文の引数・戻り値・代入変数が runtime.KeepAlive 相当で生かされる、とあります。効くのはループの波括弧の中の文だけ。

ベンチ関数の呼び出しは1回だけ

b.N 方式では、テストパッケージが b.N を増やしながらベンチ関数を何度も呼びます。準備とクリーンアップもそのたびに走る。対して b.Loop は、時間のしきい値に達するまでループを回すだけです。公式の表現で “only needs to call the benchmark function once”。重いセットアップが1回で済むぶん、”b.Loop-style benchmarks even complete in less time” とも書かれています。

効くのは準備が重いベンチ。10万件をDBへ流し込んでから検索を測る場合、b.N 方式は投入処理を b.N 決定のたびに繰り返します。b.ResetTimer で計測から外しても、実際に流し込む時間そのものは縮みません。b.Loop なら投入は1回。ベンチ全体の待ち時間が短くなります。

b.N と b.Loop の違い早見表

観点ごとの差はこうなります。

観点for range b.Nfor b.Loop()
タイマー制御手動(ResetTimer / StopTimer)自動でリセット・停止
デッドコード削除起きうる(sink変数で自衛)インライン抑止で保護
ベンチ関数の呼び出しNを変えて複数回1回
ループ前の b.N反復数として使える0(測定後に確定)
導入以前からGo 1.24〜
位置づけ引き続き有効推奨

新規ベンチは b.Loop でよく、既存の b.N ベンチを急いで書き換える必要はありません。

書き換えでハマるところ

ループ前の b.N は 0

b.N 方式では、ループ前に make([]T, b.N) で結果領域を確保する書き方ができました。b.Loop では反復数が事前に分かりません。b.N が確定するのはループを抜けたあと。ループ前に読むと 0 です。

func BenchmarkN(b *testing.B) {
    b.Logf("ループ前 b.N=%d", b.N)
    for b.Loop() {
    }
    b.Logf("ループ後 b.N=%d", b.N)
}
=== RUN   BenchmarkN
    n_test.go:2: ループ前 b.N=0
    n_test.go:5: ループ後 b.N=48219753
BenchmarkN-8   48219753   24.71 ns/op

反復数に依存した事前確保をしていたベンチは、ループ内で確保するか設計を見直します。ただし毎反復の make は割り当てを増やし、計測対象そのものを変えかねません。確保はループ外に固定サイズで置き、中身だけ書き換えるのが無難。抜けたあとの b.N は総反復数として、1件あたりの平均バイト数のような独自指標の計算に使えます。

条件は b.Loop() と正確に書く

削除防止が効くのは、ループ条件が b.Loop() という形のときだけ。戻り値を変数に受けてから判定すると、コンパイラの特別扱いから外れます。

// 保護が外れる書き方(戻り値を変数に受けている)
for ok := b.Loop(); ok; ok = b.Loop() {
    sum(data)
}

// 正しい書き方
for b.Loop() {
    sum(data)
}
# 変数に受けた版 → また消えて 0.3ns/op
BenchmarkAssign-8   1000000000   0.3129 ns/op
# 直接条件に書いた版 → 本当の値
BenchmarkDirect-8      3820001   313.4 ns/op

公式ドキュメントも “the loop condition must be written exactly as b.Loop()” と明記しています。凝った書き方をせず、素直に for b.Loop() と書くのが安全です。

b.N ループとの併用は不可

ベンチ関数の中に b.Loop ループと b.N ループを同居させることはできません。”When to use” 節に “a b.N-style loop cannot coexist with a b.Loop-style loop” とあります。ループは1つだけ。各反復が同じ処理を繰り返す前提です。

反復ごとに準備が変わるとき

各反復で毎回ちがう入力を作りたい場合、その準備を計測から外す責任は書き手に残ります。ソート対象を毎回シャッフルするベンチが典型。準備をループ内に置いたまま StopTimer を忘れると、シャッフルの時間がソートの数字へ足し込まれます。

func BenchmarkSortInts(b *testing.B) {
    ints := make([]int, 1024)
    for b.Loop() {
        b.StopTimer()
        fillRandomInts(ints)
        b.StartTimer()
        slices.Sort(ints)
    }
}
BenchmarkSortInts-8   32551   36420 ns/op

b.StopTimer()b.StartTimer()fillRandomInts の時間を除外します。この形は公式ブログの “When to use” 節が挙げている例と同じ。b.Loop にしても、ループ内のタイマー管理まで自動になるわけではありません。

メモリ割り当ても一緒に測る

時間だけでなく、1操作あたりのメモリ割り当ても同じベンチで取れます。b.Loop ループはそのまま、割り当て統計を有効にするだけ。

b.ReportAllocs か -benchmem で出す

ベンチ関数の中で b.ReportAllocs() を呼ぶか、実行時に -benchmem を付けます。公式ドキュメントは ReportAllocs を “enables malloc statistics for this benchmark” と説明し、”equivalent to setting -test.benchmem” と補足しています。前者はそのベンチ関数だけ、後者はパッケージ内の全ベンチに効く。

func BenchmarkJoin(b *testing.B) {
    parts := []string{"a", "b", "c", "d", "e"}
    b.ReportAllocs()
    for b.Loop() {
        _ = strings.Join(parts, "/")
    }
}
$ go test -bench=Join
BenchmarkJoin-8   18240000   64.30 ns/op   16 B/op   1 allocs/op

ns/op に加えて B/op(1操作あたりのバイト数)と allocs/op(割り当て回数)が並びます。strings.Join は5要素の連結で16バイトを1回。ここが 0 allocs/op に減れば、割り当てを削れた証拠になります。

数字を安定させる回し方

1回の実行では値がぶれます。-count で複数回まわし、benchstat で中央値とばらつきを見るのが定番です。

$ go test -bench=Join -benchmem -count=6 | benchstat -
         │   stdin    │
         │   sec/op   │
Join-8   64.11n ± 2%

± 2% は6回の測定のばらつき。数%を超えて揺れるなら、バックグラウンド負荷や周波数変動を疑います。b.Loop で計測範囲が締まっても、環境ノイズまで消えるわけではありません。

まとめ

testing.B.Loop は、b.N ベンチが抱えていた計測ミスを構造的に減らします。

  • for range b.Nfor b.Loop() に替えるだけで、タイマーのリセット・停止が自動になる
  • ループ本文はインライン化が抑止され、戻り値を捨ててもデッドコード削除で消えない。sink変数が不要
  • ベンチ関数の呼び出しは1回。重いセットアップの繰り返しが減り、実行時間も短くなる
  • ループ前の b.N0。反復数に依存した事前確保は要見直し
  • 条件は b.Loop() と正確に書く。b.N ループとの併用は不可
  • 反復ごとの準備は StopTimer / StartTimer で自分で外す

既存の b.N ベンチは、触るファイルのベンチから順に for b.Loop() へ寄せ、sinkや ResetTimer の定型を消していくのが現実的。急いで全部を書き換える必要はありません。

テストコード側の書き分けは Goテストの書き方 でも整理しています。並行処理を決定的に測る話は testing/synctest が近いテーマです。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次