The Go Blog
testing.B.Loopによるより予測可能なベンチマーク
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) {
... setup ...
b.ResetTimer() // if setup may be expensive
for range b.N {
... code to measure ...
... use sinks or accumulation to prevent dead-code elimination ...
}
b.StopTimer() // if cleanup or reporting may be expensive
... cleanup ...
... report ...
}
セットアップやクリーンアップが複雑な場合、開発者はベンチマークループをResetTimerやStopTimer呼び出しで囲む必要があります。これらは忘れやすく、開発者が必要であると覚えていたとしても、セットアップやクリーンアップがそれらを必要とするほど「高価」であるかどうかを判断するのは難しい場合があります。
これらがないと、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.StopTimerはtesting.B.Loopに統合されており、セットアップコードとクリーンアップコードのベンチマークタイマーを手動で管理する必要がなくなります。
さらに、Goコンパイラは、条件がtesting.B.Loopの呼び出しのみであるループを検出するようになり、ループ内のデッドコード削除を防ぎます。Go 1.24では、これはそのようなループの本体へのインライン化を禁止することで実装されていますが、将来的にはこれを改善する予定です。
testing.B.Loopのもう1つの優れた機能は、ワンショットのランプアップアプローチです。b.Nスタイルのベンチマークでは、テストパッケージは異なるb.N値でベンチマーク関数を数回呼び出し、測定時間がしきい値に達するまでランプアップする必要があります。対照的に、b.Loopは単に時間しきい値に達するまでベンチマークループを実行でき、ベンチマーク関数を1回だけ呼び出す必要があります。内部的には、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) {
... setup ...
for b.Loop() {
// optional timer control for in-loop setup/cleanup
... code to measure ...
}
... cleanup ...
}
testing.B.Loopは、より高速で正確、そしてより直感的なベンチマークを提供します。
謝辞
この機能がリリースされた際に、提案の問題についてフィードバックを提供し、バグを報告してくださったコミュニティの皆様に心より感謝申し上げます!Eli Benderskyの役立つブログの要約にも感謝しています。そして最後に、Austin Clements、Cherry Mui、Michael Prattのレビュー、設計オプションと思慮深い作業、およびドキュメントの改善に深く感謝いたします。皆様の貢献に感謝いたします!