testing.B.Loopによるより予測可能なベンチマーク

More predictable benchmarking with testing.B.Loop by Junyang Shao

testing パッケージを使ってベンチマークを書いたことがあるGo開発者なら、その様々な落とし穴に遭遇したことがあるかもしれません。Go 1.24では、同じくらい簡単に使えて、しかもはるかに堅牢な新しいベンチマークの書き方が導入されました。それが testing.B.Loop です。

従来、Goのベンチマークは0から b.N までのループを使って書かれていました。

func Benchmark(b *testing.B) {
  for range b.N {
    ... code to measure ...
  }
}

代わりに b.Loop を使うのは些細な変更です。

func Benchmark(b *testing.B) {
  for b.Loop() {
    ... code to measure ...
  }
}

testing.B.Loop には多くの利点があります。

  • ベンチマークループ内で望ましくないコンパイラの最適化を防ぎます。
  • セットアップとクリーンアップのコードを自動的にベンチマークの計測時間から除外します。
  • コードが誤って総イテレーション数や現在のイテレーションに依存してしまうことがありません。

これらはいずれも b.N 方式のベンチマークで犯しやすい間違いであり、気づかないうちにでたらめなベンチマーク結果を招いていました。おまけに、 b.Loop 方式のベンチマークは実行時間まで短くなります。

testing.B.Loop の利点と、それを効果的に活用する方法を見ていきましょう。

旧来のベンチマークループの問題点

Go 1.24以前は、ベンチマークの基本構造自体は単純である一方、より高度なベンチマークを書くにはさらなる注意が必要でした。

func Benchmark(b *testing.B) {
  ... セットアップ ...
  b.ResetTimer() // セットアップが高コストな場合
  for range b.N {
    ... 計測したいコード ...
    ... デッドコード削除を防ぐためsinkや積算を使う ...
  }
  b.StopTimer() // クリーンアップやレポート出力が高コストな場合
  ... クリーンアップ ...
  ... レポート出力 ...
}

セットアップやクリーンアップが軽くない処理の場合、開発者はベンチマークループの前後を ResetTimerStopTimer の呼び出しで囲む必要があります。これらは忘れやすく、たとえ必要だと覚えていたとしても、セットアップやクリーンアップが「呼び出しが必要なほど高コストかどうか」を判断するのは難しいものです。

これらの呼び出しがなければ、 testing パッケージはベンチマーク関数全体の時間しか計測できません。ベンチマーク関数がこれらを省略すると、セットアップとクリーンアップのコードが全体の計測時間に含まれてしまい、気づかないうちに最終的なベンチマーク結果が歪められてしまいます。

さらに、より深い理解を要する、もっと見えにくい落とし穴もあります。(出典

func isCond(b byte) bool {
  if b%3 == 1 && b%7 == 2 && b%17 == 11 && b%31 == 9 {
    return true
  }
  return false
}

func BenchmarkIsCondWrong(b *testing.B) {
  for range b.N {
    isCond(201)
  }
}

この例では、 isCond がサブナノ秒で実行されているように見えるかもしれません。CPUは高速ですが、さすがにそこまでは速くありません。この一見異常な結果は、 isCond がインライン化され、その戻り値がどこでも使われないために、コンパイラがデッドコードとして削除してしまうことに起因します。その結果、このベンチマークは isCond を何一つ計測しておらず、実際には「何もしない」のにかかる時間を計測しているに過ぎません。このケースではサブナノ秒という結果が明らかな異常のサインになっていますが、より複雑なベンチマークでは、部分的なデッドコード削除によって、一見妥当に見えながらも実際には意図したものを計測していない結果が生じることがあります。

testing.B.Loop がどう役立つか

b.N 方式のベンチマークとは異なり、 testing.B.Loop はベンチマーク内で自分が最初に呼び出されたタイミングと、最後のイテレーションが終わるタイミングを把握できます。ループの開始時の b.ResetTimer とループの終了時の b.StopTimertesting.B.Loop に統合されているため、セットアップやクリーンアップのコードのためにベンチマークタイマーを手動で管理する必要がなくなります。

また、Goコンパイラは条件式が単に testing.B.Loop の呼び出しであるループを検出し、そのループ内でのデッドコード削除を防ぐようになりました。Go 1.24では、こうしたループの本体へのインライン化を禁止することでこれを実現していますが、今後さらに改善していく予定です。

testing.B.Loop のもう一つの優れた点は、そのワンショットのランプアップ方式です。 b.N 方式のベンチマークでは、 testing パッケージは計測時間がしきい値に達するまで、異なる b.N の値でベンチマーク関数を何度も呼び出しながら値を増やしていく必要がありました。一方、 b.Loop では計測時間がしきい値に達するまでベンチマークループを単純に実行し続けるだけでよく、ベンチマーク関数の呼び出しは一度だけで済みます。内部的には b.Loop も計測オーバーヘッドを償却するためにランプアッププロセスを使っていますが、これは呼び出し元からは隠蔽されており、より効率的になり得ます。

b.N 方式のループにあったいくつかの制約は、 b.Loop 方式のループにも依然として当てはまります。必要な場合にベンチマークループ内でタイマーを管理する責任は、引き続き利用者にあります。(出典

func BenchmarkSortInts(b *testing.B) {
  ints := make([]int, N)
  for b.Loop() {
    b.StopTimer()
    fillRandomInts(ints)
    b.StartTimer()
    slices.Sort(ints)
  }
}

この例では、 slices.Sort によるインプレースソートの性能を計測するために、イテレーションごとにランダムに初期化された配列が必要です。このような場合、利用者は依然として手動でタイマーを管理しなければなりません。

加えて、ベンチマーク関数の本体にはこのようなループがちょうど1つだけ存在する必要があり( b.N 方式のループと b.Loop 方式のループを共存させることはできません)、ループの各イテレーションは同じ処理を行うべきです。

いつ使うべきか

testing.B.Loop メソッドは、今やベンチマークを書くための推奨される方法です。

func Benchmark(b *testing.B) {
  ... セットアップ ...
  for b.Loop() {
    // ループ内でのセットアップやクリーンアップに対する任意のタイマー制御
    ... 計測したいコード ...
  }
  ... クリーンアップ ...
}

testing.B.Loop は、より速く、より正確で、より直感的なベンチマークを実現します。

謝辞

プロポーザルのissueにフィードバックを寄せてくれた、そしてこの機能がリリースされてからバグを報告してくれたコミュニティの皆さんに心から感謝します。また、役立つブログでのまとめを書いてくれたEli Bendersky氏にも感謝します。最後に、レビューと設計上の選択肢についての思慮深い検討、そしてドキュメントの改善に携わってくれたAustin Clements氏、Cherry Mui氏、Michael Pratt氏にも大きな感謝を。皆さんの貢献に感謝します!

By Junyang Shao