Go 1.24 からのベンチマークテスト
現在『効率的なGo』の読書会に参加してるんだけど,今回はその10回目の話。
今回は「8章 ベンチマーク」の最初の部分まで読み進めた。 この辺からようやく Go のツールを使った具体的な話になってくる。
内容については本を読んでもらうとして,今回はベンチマーク・コードの話。 件の本では「ほとんどのベンチマークに必要な小さなボイラープレートの生テンプレート」として以下のコードを挙げている。
func BenchmarkSum(b *testing.B) { b.ReportAllocs() // TODO(bwplotka): 必要な初期化処理を追加 b.ResetTimer() for i := 0; i < b.N; i++ { // TODO(bwplotka): テストされた機能を追加 } }
ちなみに b.ReportAllocs()
はヒープメモリの割り当て回数と総量をレポートするための関数で, go test -bench
コマンドでベンチマークテストを起動する際の -benchmem
オプションと同じ効果がある。
このコード中の for
文でカウンターを回しているのだが Go 1.24 から testing
.B.Loop()
メソッドが追加され for
ループの記述が大きく改善された。
Benchmarks may now use the faster and less error-prone
testing.B.Loop
method to perform benchmark iterations likefor b.Loop() { ... }
in place of the typical loop structures involvingb.N
like for rangeb.N
. This offers two significant advantages:
- The benchmark function will execute exactly once per
-count
, so expensive setup and cleanup steps execute only once.- Function call parameters and results are kept alive, preventing the compiler from fully optimizing away the loop body.
最初のほうの「コストのかかるセットアップおよびクリーンアップ手順は1回だけ実行される」というのは分かりにくいが,実は testing
.B.Loop()
メソッドを使うと,先のテンプレートコードにある b.ResetTimer()
を省略できて,純粋に for b.Loop() { ... }
内の処理だけ計測される。
もうひとつはもっと重要で for b.Loop() { ... }
内のコードは最適化されないことを保証するというものだ。
たとえば
func Add(a, b int) int {
return a + b
}
という関数のベンチマークを取ろうとして素朴に
for range b.N {
_ = Add(1, 2)
}
なんてなコードを書いたら,最悪の場合,最適化でループ内の処理がまるごと無くなりかねない1。
testing
.B.Loop()
メソッドを使うことでそうした事態を回避できるというわけだ。
お試しに何か書いてみよう2。 まずはこんな関数を書いてみる。
package sample
func Sum(data []int) int {
total := 0
for _, value := range data {
total += value
}
return total
}
よくある合計値を返す関数ですな。
作成した sample.Sum()
関数のベンチマークテストのコードは以下の通り。
package sample_test
import (
"math/rand/v2"
"sample"
"testing"
)
func intList(n int) []int {
list := make([]int, n)
for i := range list {
list[i] = rand.Int()
}
return list
}
func BenchmarkSum(b *testing.B) {
b.ReportAllocs()
input := intList(128 << 10)
for b.Loop() {
sample.Sum(input)
}
}
さっそく実行してみよう3。
$ go test -run '^$' -bench '^BenchmarkSum$' -count 6 | tee sample_sum_bench_v1.txt
goos: linux
goarch: amd64
pkg: sample
cpu: AMD Ryzen 5 PRO 4650G with Radeon Graphics
BenchmarkSum-12 37249 32302 ns/op 0 B/op 0 allocs/op
BenchmarkSum-12 36802 32570 ns/op 0 B/op 0 allocs/op
BenchmarkSum-12 36495 34515 ns/op 0 B/op 0 allocs/op
BenchmarkSum-12 36825 32516 ns/op 0 B/op 0 allocs/op
BenchmarkSum-12 37101 32142 ns/op 0 B/op 0 allocs/op
BenchmarkSum-12 37298 32072 ns/op 0 B/op 0 allocs/op
PASS
ok sample 7.260s
ヒープの使用もカウントされてないし,上手くいってるっぽいな。
もうひとつ。 『効率的なGo』では benchstat コマンドの紹介もされていた。 上のベンチマーク結果について簡単な統計処理をしてくれるらしい。 これも試してみよう。
まずはコマンドのインストール。
$ go install golang.org/x/perf/cmd/benchstat@latest
先程のベンチマーク結果を benchstat コマンドに食わせてみる。
$ benchstat sample_sum_bench_v1.txt
goos: linux
goarch: amd64
pkg: sample
cpu: AMD Ryzen 5 PRO 4650G with Radeon Graphics
│ sample_sum_bench_v1.txt │
│ sec/op │
Sum-12 32.41µ ± 6%
│ sample_sum_bench_v1.txt │
│ B/op │
Sum-12 0.000 ± 0%
│ sample_sum_bench_v1.txt │
│ allocs/op │
Sum-12 0.000 ± 0%
んー。 $6\,\%$ の分散はちょっと大きいかなぁ。 『効率的なGo』によると,分散が $5\,\%$ 以上ある場合は環境ノイズ(バックグラウンドのプロセスとかメモリスワップとか)が大きい可能性があるらしい。 まぁ,今回はこのまま進めよう。
benchstat で統計処理を行う場合は -count
オプションを使って少なくとも6回以上は繰り返すべきと書かれている。
これによって環境ノイズを検出しやすくなる。
benchstat コマンドは複数のベンチマーク結果を比較することもできる。
たとえば,先ほどの Sum()
関数のループ回数を半分にしたら速くなるだろうか。
試してみよう。
まず Sum()
関数を以下のように書き直す(ほとんど GitHub Copilot が書いたけどw
)。
func Sum(data []int) int {
total := 0
l := len(data)
h := l / 2
for i := range h {
total += data[i] + data[l-i-1]
}
if h*2 != l {
total += data[h]
}
return total
}
これに対して先ほどと同じ条件でベンチマークテストを行う。
$ go test -run '^$' -bench '^BenchmarkSum$' -count 6 | tee sample_sum_bench_v2.txt
goos: linux
goarch: amd64
pkg: sample
cpu: AMD Ryzen 5 PRO 4650G with Radeon Graphics
BenchmarkSum-12 30920 39051 ns/op 0 B/op 0 allocs/op
BenchmarkSum-12 30186 39492 ns/op 0 B/op 0 allocs/op
BenchmarkSum-12 30282 39615 ns/op 0 B/op 0 allocs/op
BenchmarkSum-12 30373 39535 ns/op 0 B/op 0 allocs/op
BenchmarkSum-12 30525 39413 ns/op 0 B/op 0 allocs/op
BenchmarkSum-12 30379 39540 ns/op 0 B/op 0 allocs/op
PASS
ok sample 7.215s
ありゃ。 さっきより遅くなっちゃった? benchstat コマンドで確認してみよう。
$ benchstat sample_sum_bench_v1.txt sample_sum_bench_v2.txt
goos: linux
goarch: amd64
pkg: sample
cpu: AMD Ryzen 5 PRO 4650G with Radeon Graphics
│ sample_sum_bench_v1.txt │ sample_sum_bench_v2.txt │
│ sec/op │ sec/op vs base │
Sum-12 32.41µ ± 6% 39.51µ ± 1% +21.92% (p=0.002 n=6)
│ sample_sum_bench_v1.txt │ sample_sum_bench_v2.txt │
│ B/op │ B/op vs base │
Sum-12 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=6) ¹
¹ all samples are equal
│ sample_sum_bench_v1.txt │ sample_sum_bench_v2.txt │
│ allocs/op │ allocs/op vs base │
Sum-12 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=6) ¹
¹ all samples are equal
$22\,\%$ 近く遅くなっちゃったよ orz
p
の値は統計的有意性を表す $p\,値$のことで,既定では $\alpha=0.05$ に設定されている($\alpha$ の値は -alpha
オプションで変更できる)。
つまり p
の値が 0.05 より小さければ有意な値であると見なすことができる。
それはともかく,とりあえずループ回数を畳み込むのはなしの方向で。
…てな感じで benchstat コマンドを使えば統計学や誤差論の知識がなくてもある程度の判断を下すことができる。
参考図書
- 効率的なGo ―データ指向によるGoアプリケーションの性能最適化
- Bartłomiej Płotka (著), 山口 能迪 (翻訳)
- オライリー・ジャパン 2024-02-24
- 単行本(ソフトカバー)
- 4814400535 (ASIN), 9784814400539 (EAN), 4814400535 (ISBN)
- 評価
版元で Ebook を買える。Go言語のリファレンス本ではない。フトウェア工学,プログラミング(の考え方)を学ぶ教科書的な位置づけかなぁ。
- Go言語で学ぶ並行プログラミング 他言語にも適用できる原則とベストプラクティス impress top gearシリーズ
- James Cutajar (著), 柴田 芳樹 (著)
- インプレス 2024-12-04 (Release 2024-12-04)
- Kindle版
- B0DNYMMBBQ (ASIN)
- 評価
読書会のために購入。インプレス社の本は Kindle 版より版元で PDF 版を買うのがオススメ。「並行処理」について原理的な解説から丁寧に書かれている。 Go で解説されているが Go 以外の言語でも応用できる。
- Go言語 100Tips ありがちなミスを把握し、実装を最適化する impress top gearシリーズ
- Teiva Harsanyi (著), 柴田 芳樹 (著)
- インプレス 2023-08-18 (Release 2023-08-18)
- Kindle版
- B0CFL1DK8Q (ASIN)
- 評価
版元で PDF 版を購入可能。事実上の Effective Go とも言える充実の内容。オリジナルは敢えてタイトルに “tips” という単語を入れるのを避けたのに邦題が「100 Tips」とかなっていて,原作者がお怒りとの噂(あくまで噂)
- プログラミング言語Go (ADDISON-WESLEY PROFESSIONAL COMPUTING SERIES)
- Alan A.A. Donovan (著), Brian W. Kernighan (著), 柴田 芳樹 (翻訳)
- 丸善出版 2016-06-20
- 単行本(ソフトカバー)
- 4621300253 (ASIN), 9784621300251 (EAN), 4621300253 (ISBN)
- 評価
著者のひとりは(あの「バイブル」とも呼ばれる)通称 “K&R” の K のほうである。この本は Go 言語の教科書と言ってもいいだろう。と思ったら絶版状態らしい(2025-01 現在)。復刊を望む!
-
実際に試してみたが10億回ループしてたので,やはり中身が消えてると思われる。ちなみにループ部分を
for b.Loop() { ... }
にしたら6億回のループになったので,多分そういうことなんだろう。 ↩︎ -
今回の記事のコードは
testing
パッケージのドキュメントにあるサンプルコードを参考にしている。 ↩︎ -
-run '^$'
オプションはひとつもテストを実行しないことを示す。-bench '^BenchmarkSum$'
オプションは指定したベンチマークテストコードを実行することを示す。-count 6
は指定したベンチマークテストを6回繰り返すことを示す。ベンチマークテストを繰り返すのは統計処理を行うため。 ↩︎