Go でベンチマークテストを行うにあたり、1.24 で新しく testing.B.Loop が追加されているためそちらの動作を確認してみました。
testing.B.Loop() についてb.Loop() はベンチマークのイテレーションをランタイムが制御する新しい記法です。
従来の b.N ループとの主な違いをざっと整理すると以下のような内容のようです。
| 観点 | 旧 (b.N ループ) | 新 (b.Loop()) |
|---|---|---|
| 記述 | for i := 0; i < b.N; i++ | for b.Loop() |
| イテレーション変数 | b.N で管理 | ランタイムが制御 |
| 初期化・後片付けの処理 | ループ外に手動配置 + b.ResetTimer() が必要 | ループ外に置くだけでよい |
| 計測対象の明確さ | b.ResetTimer() の位置に依存 | コード構造と一致 |
| setup の実行回数 | b.N 収束まで複数回実行される | 1 回のみ |
b.N ループ)シンプルな定義をすると以下のようなイメージになります。
func BenchmarkOld(b *testing.B) {
for i := 0; i < b.N; i++ {
strings.ToUpper("hello, world")
}
}
初期化処理などが必要な場合は、ループ外に配置したうえで b.ResetTimer() を呼び出す必要がありました。
※が、ここで、b.ResetTimer() を忘れると初期化処理の時間も計測に含まれてしまうため注意が必要でした。
func BenchmarkOldWithSetup(b *testing.B) {
f, err := os.CreateTemp("", "bench_old_*.txt")
if err != nil {
b.Fatal(err)
}
defer os.Remove(f.Name())
defer f.Close()
b.ResetTimer() // 初期化処理の時間を計測から除外するために必要
for i := 0; i < b.N; i++ {
if _, err := f.WriteString("hello\n"); err != nil {
b.Fatal(err)
}
}
}
b.N ループではベンチマーク関数全体が繰り返し呼ばれるため、初期化処理などは毎回実行されます。
仮に初期化処理がやや重い処理の場合は、無駄なリソース消費に加え、テスト実行時間が予想よりも長くなってしまったりもします。
b.Loop())b.Loop() を使うと、同じベンチマークをより簡潔に書けます。
func BenchmarkNew(b *testing.B) {
for b.Loop() {
strings.ToUpper("hello, world")
}
}
初期化処理がある場合でも b.ResetTimer() は不要になります。
計測時の認識漏れを防げるのは勿論、「ループの外 = 計測対象外」という対応がコード構造と一致するため、意図が自然に伝わるのは嬉しい点です。
func BenchmarkNewWithSetup(b *testing.B) {
f, err := os.CreateTemp("", "bench_new_*.txt")
if err != nil {
b.Fatal(err)
}
defer os.Remove(f.Name())
defer f.Close()
// b.ResetTimer() 不要
for b.Loop() {
if _, err := f.WriteString("hello\n"); err != nil {
b.Fatal(err)
}
}
}
また、b.Loop() を使うことで従来のように初期化処理が毎回実行されることはなくなるため、消費リソースの節約やテスト時間の短縮にもつながるのは良さそうです。
More predictable benchmarking with testing.B.Loop
b.Loop can simply run the benchmark loop until it reaches the time threshold, and only needs to call the benchmark function once.
Apple M5 / Go 1.26.1 で実測した結果です。
BenchmarkOld-10 46335399 24.42 ns/op 16 B/op 1 allocs/op
BenchmarkNew-10 47398275 24.55 ns/op 16 B/op 1 allocs/op
BenchmarkOldWithSetup-10 1000000 1002 ns/op 0 B/op 0 allocs/op
BenchmarkNewWithSetup-10 1235643 969.6 ns/op 0 B/op 0 allocs/op
BenchmarkOld と BenchmarkNew はほぼ同等のパフォーマンスです。
b.Loop() のメリットはパフォーマンスの改善ではなく、記述の簡潔さと可読性にあると認識しています。
そもそもの testing.B.Loop() の導入背景についても少し気になったので調べてみました。
b.N の使い忘れベンチマーク関数が b.N を参照しないケースが広く存在しており、vet で検出は可能ではあるものの見落としやすい状況だったことが課題として上がっていたようです。 (golang/go#38677)
testing パッケージは b.N を段階的に増やしながらベンチマーク関数を繰り返し呼び出し、実行時間が収束したところで1回の処理にかかった時間 (ns/op) を算出します。
| 呼び出し回数 | b.N | 結果 |
|---|---|---|
| 1 回目 | 1 | 実行時間が短すぎる → 増やす |
| 2 回目 | 100 | まだ短い → 増やす |
| 3 回目 | 10000 | まだ短い → 増やす |
| 4 回目 | 1000000 | 収束 → 1回の処理にかかった時間 (ns/op) を算出 |
ここでの注意点として、以下のような b.N を参照しないコードは b.N の値に関わらず常に 1000 回しか実行されません。
func BenchmarkFoo(b *testing.B) {
for i := 0; i < 1000; i++ { // 常に 1000 回固定
doSomething()
}
}
// BenchmarkFoo-10 1000000 24.42 ns/op
b.N を参照しないコードは反復回数が固定されたままになるため、b.N の値によって ns/op が変わり、計測結果に再現性がなくなってしまうことが考えられます。
| b.N | 実行時間 | ns/op の計算 | 結果 |
|---|---|---|---|
| 1 | 24 ns × 1,000 回 = 24,000 ns | 24,000 ÷ 1 = 24,000 ns/op | 1000倍 |
| 100 | 24 ns × 1,000 回 = 24,000 ns | 24,000 ÷ 100 = 240 ns/op | 10倍 |
| 10,000 | 24 ns × 1,000 回 = 24,000 ns | 24,000 ÷ 10,000 = 2.4 ns/op | 10分の1 |
| 1,000,000 | 24 ns × 1,000 回 = 24,000 ns | 24,000 ÷ 1,000,000 = 0.024 ns/op | 1000分の1 |
本来は 24 ns/op であるべきところが、b.N の値によって 0.024 〜 24,000 まで大きくブレます。
しかも出力の形式は正常に見えるため、数値を疑わなければ気づけません。
※なお、この issue#38677 はvet を検出しようといった類の提案のように見えるのですが、根本的な解決策として b.Loop() を設計することにつながったようです。
b.N をループ回数ではなく「処理する件数」として使われてしまうtesting: add testing.B.Loop for iteration の Issue の例がわかりやすかったです。
there’s some evidence that many benchmarks use b.N incorrectly, such as using it to size the input to an algorithm, rather than as an iteration count.
// 誤った使い方の例
func BenchmarkFoo(b *testing.B) {
data := make([]int, b.N) // b.N をデータサイズに使っている
for i := range data {
doSomething(data[i])
}
}
// 正しい使い方の例
func BenchmarkFoo(b *testing.B) {
data := make([]int, 1000) // サイズは固定
for i := 0; i < b.N; i++ { // b.N 回繰り返す
doSomething(data[i%len(data)])
}
}
誤ったほうの書き方だと、testing パッケージが b.N = 1,000,000 に増やしたとき、100 万件のデータを 1 回処理することになってしまうためよくありません。
また、b.N が変わるたびに処理する件数も変わるため、ns/op の意味が一定にならない状態になってしまうことを課題として挙げているように見えました。
b.ResetTimer() の必要性こちらは前述した内容の通りです。
また、Issue や公式にも経緯などが明記されています。
testing: add testing.B.Loop for iteration
Because the benchmark framework doesn’t know when the b.N loop starts, if a benchmark has any non-trivial setup, it’s important for it to use (*testing.B).ResetTimer. It’s generally not clear what counts as non-trivial setup, and very hard to detect when ResetTimer is necessary.
More predictable benchmarking with testing.B.Loop
If setup or cleanup are non-trivial, the developer needs to surround the benchmark loop with ResetTimer and/or StopTimer calls. These are easy to forget, and even if the developer remembers they may be necessary, it can be difficult to judge whether setup or cleanup are “expensive enough” to require them.
初期化処理が計測対象に含まれないようにループの外で b.ResetTimer() を呼び出す必要がありましたが、漏れやすく気づきにくいという課題があったのが主な理由のようです。
また、初期化処理自体が比較的重い処理の場合はなんとなく検知できるチャンスはあるかもしれませんが、軽い処理の場合は数値を見てもほぼ気づけません。
testing パッケージは適切な b.N を見つけるためにベンチマーク関数を複数回呼び出します。
よって初期化処理自体のコストが高い場合(例: DBの初期化など)、予想よりも実行時間が伸びてしまう点も課題としてあったようです。
// この関数自体が b.N により何度も呼び出されることになる
func BenchmarkFoo(b *testing.B) {
db := connectDB() // 1秒かかる重い処理
defer db.Close()
b.ResetTimer()
for i := 0; i < b.N; i++ {
db.Query("SELECT 1")
}
}
上記の場合、b.Nが収束するまでに4回呼び出されるとすると、初期化処理だけで4秒かかります。
仮に -benchtime=1s を指定していても、実際の総実行時間は意図より大幅に伸びてしまうためあまり無視できない問題であったようです。
今回は Go 1.24 で追加された testing.B.Loop のコーディング例や動作、導入に至った経緯などを見ていきました。
意図が曖昧なものをできる限り減らしよりシンプルに記述できるようになったのはGoらしいなと思いつつ、実行時のパフォーマンスにも手が加えられている点は個人的には嬉しい機能でした。
余裕があれば導入に至った経緯などを深ぼるのもまた面白かったりします。
ほかにも新たに加えられた機能などもあるのでどこかでまた調査して行けたらと思います。
Go 1.24で追加されたbytes/stringsパッケージのイテレータ関数群を解説。iter.Seqを活用し、従来の一括返却から1要素ずつ処理へ変更。早期終了時のメモリ効率向上が主な利点。
Grafana × Alloy × Tempoを使用したOpenTelemetryのトレース検証。Grafanaで15件のリクエストから生成されたトレースを確認し、http-server、ArticleUsecase、ArticleRepositoryの3つのspanの階層構造と処理時間の内訳を分析。DB処理3%、usecase処理15%、HTTP処理82%の結果から、ボトルネック特定の有効性を実証。
Prometheus UIを使用してOpenTelemetryで収集したメトリクスを検証する方法を解説。PromQLを用いた閲覧数集計、ステータス別集計、リクエストレート、レイテンシ分析など、実践的なクエリ例を紹介している。
論文PDFを構造化JSONに変換するパイプラインの経路B(画像をBedrockに直接読ませる方式)について、Step Functionsでページを並列処理し、各Lambdaがページ画像からモデルで構造化データを抽出、S3に保存、最後にfinalizerで結合・正規化・検証する流れを詳説。
論文PDF処理パイプラインの最終段階について解説。両経路の結果をまとめるfinalizer Lambdaが、comparison.jsonを生成してDynamoDBを更新する仕組みと、経路ごとの状態管理、レビュー要否の判定ロジックを説明しています。