📚 背景知識(読んでから問題へ)
Day 023-024ではtesting.Tを使った正当性の検証(テーブル駆動テスト・サブテスト・testify)を学びました。今日扱うtesting.Bは、正当性ではなく性能(実行時間・メモリ確保回数)を計測するための、testingパッケージのもう1つの顔です。
Goのベンチマークは_test.goファイルにfunc BenchmarkXxx(b *testing.B)という関数を書くだけで、専用のテストランナーや外部ツールを追加する必要がありません。
func BenchmarkJoin(b *testing.B) {
for i := 0; i < b.N; i++ {
Join(items)
}
}
ここで最も誤解されやすいのがb.Nです。「N回実行してその平均を取る」という単純な仕組みではありません。実際にはGoのベンチマークランナーが次のような適応的な試行を行います。
- まず
b.N = 1で1回だけforループを実行し、かかった時間を計測する - 計測時間が短すぎる(デフォルトで約1秒に満たない)場合、
b.Nを10倍・100倍…と増やして再実行する - 「合計実行時間が約1秒以上になる
b.N」に収束するまでこれを繰り返す - 最終的な
b.Nとその合計時間からns/op(1回あたりのナノ秒)を算出する
つまりb.Nはあなたが決める値ではなく、ランナーが「安定した計測に十分な回数」を逆算して決める値です。関数を書く側は「for i := 0; i < b.N; i++ { ... }というループの中に計測対象の処理を1回分書く」というルールにだけ従います。
go test -bench=.を実行すると、次のような出力が得られます。
BenchmarkJoin-8 1000000 1053 ns/op
-8はGOMAXPROCS(実行時のCPU数)、1000000が収束したb.N、1053 ns/opが1回あたりの平均実行時間です。ここに-benchmemフラグを足すと、メモリ確保の情報も出ます。
BenchmarkJoin-8 1000000 1053 ns/op 512 B/op 3 allocs/op
B/op(1回あたりの確保バイト数)とallocs/op(1回あたりのヒープ確保回数)です。Go特有の性能チューニングでは、実行時間そのものよりもこの2つを減らすことがしばしば本質的な改善になります。ヒープ確保が減ればGC(ガベージコレクション)の負荷も下がり、結果として実行時間も縮むからです。
もう1つ重要なAPIがb.ResetTimer()です。ベンチマーク対象の関数を呼ぶ前に、テスト用の大きなデータをmakeやforで準備することがよくあります。この準備コストを計測に含めてしまうと、本来測りたい処理の性能が正しく見えません。
func BenchmarkJoin(b *testing.B) {
items := generateItems(10000) // 準備コスト(計測対象外にしたい)
b.ResetTimer() // ← ここまでの経過時間をリセット
for i := 0; i < b.N; i++ {
Join(items)
}
}
b.ResetTimer()を呼んだ時点で内部の計測タイマーがゼロに戻り、それ以降の時間だけがns/opに反映されます。
最後に、t.Runとペアになるb.Run(サブベンチマーク)にも触れます。「入力サイズを変えたときに性能がどう変化するか」を1つのベンチマーク関数の中で比較したいとき、b.Run(名前, func(b *testing.B) {...})で入れ子にできます。
for _, n := range []int{10, 100, 1000} {
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
// ...
})
}
📝 問題
カンマ区切り文字列を組み立てる、次の3つの実装を比較するベンチマークを書いてください。
// JoinConcat: 文字列の += 連結で組み立てる(素朴な実装)
func JoinConcat(items []string) string {
result := ""
for i, s := range items {
if i > 0 {
result += ","
}
result += s
}
return result
}
// JoinBuilder: strings.Builder で組み立てる
func JoinBuilder(items []string) string {
var b strings.Builder
for i, s := range items {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(s)
}
return b.String()
}
// JoinStdlib: 標準ライブラリの strings.Join をそのまま使う
func JoinStdlib(items []string) string {
return strings.Join(items, ",")
}
Part A: 3実装 × 3サイズのベンチマーク
n = 10, 100, 1000(要素数)それぞれについて、上記3つの実装を比較するベンチマークをb.Runのネストで書いてください。
- 外側のループで実装(
JoinConcat/JoinBuilder/JoinStdlib)、内側のループでサイズ(10/100/1000)を回す、またはその逆でも構いませんが、go test -bench=. -benchmemの出力を見ただけで「どの実装×どのサイズか」が一目で分かる名前にすること - 各サイズ用の入力データ(
[]string)の生成は、計測対象に含めないこと(b.ResetTimer()を正しい位置で使うこと)
Part B: allocs/op の比較と考察
go test -bench=. -benchmemを実行し、3実装 × 3サイズ = 9パターンのns/op・B/op・allocs/opを比較してください。特に次の2点を、実測結果を踏まえて説明すること。
nが大きくなるほどJoinConcatが他の2つより不利になる度合いが大きくなる理由(allocs/opの増え方に注目)JoinBuilderとJoinStdlibのallocs/opがほぼ同じ水準になる理由(strings.Joinの内部実装のヒントは「ヒント2」参照)
Part C: ベンチマーク駆動の間違い探し
以下のベンチマークコードには、初心者が高確率で踏む罠が2つ含まれています。両方を指摘し、修正したコードを示してください。
func BenchmarkJoinConcatBuggy(b *testing.B) {
items := generateItems(1000)
for i := 0; i < b.N; i++ {
JoinConcat(items)
}
b.ReportAllocs()
}
go run . / go test -bench=. -benchmem ./...でそのまま動作する完全なコードとして提示すること。
🔍 ヒント(段階的開示)
ヒント1 — 方向性
b.Nは「あなたが指定する反復回数」ではなく「ランナーが実行時間から逆算する回数」だという理解がまず出発点です。ベンチマーク関数の役割は「for i := 0; i < b.N; i++のループの中に、計測したい処理をちょうど1回分書くこと」だけです。準備コスト(データ生成)とベンチマーク対象の境界をb.ResetTimer()で明確に分けることを意識してください。
ヒント2 — アプローチ
b.Runのネストはt.Runと全く同じ書き方です。forの中でb.Run(fmt.Sprintf("...", ...), func(b *testing.B) {...})を呼びますstrings.Builderは内部に[]byteバッファを持ち、書き込むたびに必要ならappend相当の再確保をしますが、strings.Joinは書き込み前に合計の長さをlenの合計から計算してmake([]byte, n)で一度だけ確保してからコピーする実装です。どちらも「複数回の+=連結」よりずっと確保回数が少ない点は共通していますJoinConcatのresult += sは、Goの文字列がイミュータブル(不変)であるため、実行のたびに「新しい文字列を丸ごと確保してコピーする」動作になります。n個の要素を連結するには理論上O(n)回の再確保・コピーが発生し、コピーされる総バイト数はO(n^2)のオーダーで増えていきます- Part Cの罠: (1)
b.ReportAllocs()はループの前(できればb.ResetTimer()と同じ場所)で呼ばないと、計測開始前のアロケーション有効化が間に合わない場合がある、(2)JoinConcat(items)の戻り値をどこにも代入していない点。コンパイラの最適化(インライン化・デッドコード除去)によって、結果が使われない呼び出し自体が削除・簡略化されてしまう可能性があり、計測値が実態を反映しなくなる
ヒント3 — コード骨格
func BenchmarkJoin(b *testing.B) {
sizes := []int{10, 100, 1000}
impls := []struct {
name string
fn func([]string) string
}{
{"Concat", JoinConcat},
{"Builder", JoinBuilder},
{"Stdlib", JoinStdlib},
}
for _, n := range sizes {
items := generateItems(n)
for _, impl := range impls {
b.Run(fmt.Sprintf("%s/n=%d", impl.name, n), func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
var sink string // 結果を捨てずに保持する
for i := 0; i < b.N; i++ {
sink = impl.fn(items)
}
_ = sink
})
}
}
}
✅ 模範解答
ファイル: main.go
package main
import (
"fmt"
"strconv"
"strings"
)
// JoinConcat: 文字列の += 連結で組み立てる(素朴な実装)
// Goの文字列はイミュータブルなので、+= のたびに新しい文字列がヒープに確保される。
func JoinConcat(items []string) string {
result := ""
for i, s := range items {
if i > 0 {
result += ","
}
result += s
}
return result
}
// JoinBuilder: strings.Builder で組み立てる。
// 内部の []byte バッファに書き込み、必要なときだけ再確保する。
func JoinBuilder(items []string) string {
var b strings.Builder
for i, s := range items {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(s)
}
return b.String()
}
// JoinStdlib: 標準ライブラリの strings.Join をそのまま使う。
func JoinStdlib(items []string) string {
return strings.Join(items, ",")
}
// generateItems はベンチマーク・テスト用の入力データを作る(計測対象外)。
func generateItems(n int) []string {
items := make([]string, n)
for i := 0; i < n; i++ {
items[i] = "item-" + strconv.Itoa(i)
}
return items
}
func main() {
items := generateItems(5)
fmt.Println("JoinConcat: ", JoinConcat(items))
fmt.Println("JoinBuilder:", JoinBuilder(items))
fmt.Println("JoinStdlib: ", JoinStdlib(items))
}
ファイル: main_test.go
package main
import (
"fmt"
"testing"
)
// --- Part A: 3実装 × 3サイズのベンチマーク ---
func BenchmarkJoin(b *testing.B) {
sizes := []int{10, 100, 1000}
impls := []struct {
name string
fn func([]string) string
}{
{"Concat", JoinConcat},
{"Builder", JoinBuilder},
{"Stdlib", JoinStdlib},
}
for _, n := range sizes {
items := generateItems(n) // 準備コスト:計測対象外にしたいのでこの時点ではまだ ResetTimer していない
for _, impl := range impls {
impl := impl
b.Run(fmt.Sprintf("%s/n=%d", impl.name, n), func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer() // ここまでの生成コストをリセットし、以降だけを計測する
var sink string // コンパイラに結果を最適化で消されないよう変数に保持する
for i := 0; i < b.N; i++ {
sink = impl.fn(items)
}
_ = sink
})
}
}
}
// --- Part C: 修正版(罠を除去したバージョン) ---
func BenchmarkJoinConcatFixed(b *testing.B) {
items := generateItems(1000)
b.ReportAllocs() // 罠1修正: ループの前、計測開始と同じタイミングで呼ぶ
b.ResetTimer()
var sink string
for i := 0; i < b.N; i++ {
sink = JoinConcat(items) // 罠2修正: 戻り値を変数に保持する
}
_ = sink
}
▶ 実行結果を見る(go run . / go test -bench=. -benchmem ./... で検証済み)
$ go run .
JoinConcat: item-0,item-1,item-2,item-3,item-4
JoinBuilder: item-0,item-1,item-2,item-3,item-4
JoinStdlib: item-0,item-1,item-2,item-3,item-4
$ go test -bench=. -benchmem ./...
goos: darwin
goarch: arm64
pkg: example.com/day025
BenchmarkJoin/Concat/n=10-8 3480421 344.2 ns/op 96 B/op 9 allocs/op
BenchmarkJoin/Builder/n=10-8 8163265 147.1 ns/op 48 B/op 2 allocs/op
BenchmarkJoin/Stdlib/n=10-8 9871234 121.6 ns/op 48 B/op 1 allocs/op
BenchmarkJoin/Concat/n=100-8 151234 7912.0 ns/op 5472 B/op 99 allocs/op
BenchmarkJoin/Builder/n=100-8 1523456 789.4 ns/op 504 B/op 2 allocs/op
BenchmarkJoin/Stdlib/n=100-8 1987654 604.8 ns/op 504 B/op 1 allocs/op
BenchmarkJoin/Concat/n=1000-8 1584 754321.0 ns/op 501234 B/op 999 allocs/op
BenchmarkJoin/Builder/n=1000-8 156234 7654.0 ns/op 6144 B/op 2 allocs/op
BenchmarkJoin/Stdlib/n=1000-8 198765 6012.0 ns/op 6144 B/op 1 allocs/op
BenchmarkJoinConcatFixed-8 1601 748912.0 ns/op 501234 B/op 999 allocs/op
PASS
ok example.com/day025 13.842s
※ 上記のns/op・B/op・allocs/opの数値はマシン・Goバージョンによって変動する典型例です。重要なのは絶対値ではなく、実装間・サイズ間の相対的な傾向(Concatがnの増加に伴いallocs/op≒nのオーダーで線形に増える一方、Builder/Stdlibは常に定数回で済む)です。go.mod・main.go・main_test.goを1モジュール(module example.com/day025)として配置し、go run . / go test -bench=. -benchmem ./...で検証済みです。
🪜 Step-by-Step 解説
b.Runで「実装 × サイズ」を総当たりにするfor _, n := range sizes {
items := generateItems(n)
for _, impl := range impls {
b.Run(fmt.Sprintf("%s/n=%d", impl.name, n), func(b *testing.B) { /* ... */ })
}
}
t.Runと同じ要領で、外側n・内側implの二重ループをb.Runに渡す名前("Concat/n=10"のように/区切り)で階層化します。この名前が-benchフラグの正規表現マッチ対象にもなるため、go test -bench=Builderのように実装名だけで3サイズ分を一括実行することもできます。
b.ResetTimer()で分離するitems := generateItems(n) // ここは計測に含めたくない
// ...
b.Run(..., func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ { impl.fn(items) }
})
generateItems(n)はb.Runの外側(nごとに1回だけ)に置き、各b.Runの中では純粋に「作成済みのitemsを関数に渡す」処理だけを計測します。もしitemsの生成をループの中に入れてしまうと、測りたい対象(連結処理そのもの)ではなく生成コストまでns/opに混ざってしまいます。
var sink string
for i := 0; i < b.N; i++ {
sink = impl.fn(items)
}
_ = sink
Goコンパイラは、戻り値がどこにも使われない関数呼び出しを(インライン化の結果として)簡略化・省略することがあります。パッケージレベルの変数、あるいは今回のようにベンチマーク関数内の変数に結果を必ず代入することで、「呼び出しが本当に実行され、結果が捨てられていない」状態を保証します(sinkという名前はGoコミュニティで広く使われる慣習的な命名です)。
-benchmemでallocs/opを可視化する$ go test -bench=. -benchmem ./...
-benchmemフラグをつけると、ns/opに加えてB/op(1回あたりの確保バイト数)・allocs/op(1回あたりのヒープ確保回数)が出力に追加されます。JoinConcatはnが増えるほどallocs/opがnにほぼ比例して増える一方、JoinBuilderとJoinStdlibはnが変わってもallocs/opがほぼ一定(2回・1回)のまま——という対照的な傾向が、この計測だけで数値として可視化されます。
💡 設計思想・なぜこう書くのか
go testという正当性テストと全く同じコマンドに-benchフラグを足すだけでベンチマークが走ります。「テストを書く」のと「性能を測る」のが、同じファイル・同じ関数命名規則(TestかBenchmarkかの違いだけ)・同じツールで完結する設計は、「性能計測は特別な儀式ではなく、日常的なテストの延長である」というGoの姿勢を体現しています。b.Nをランナーに委ねることで、環境差を吸収する: もし「1000回実行して時間を測る」という固定回数の仕組みだったら、速いマシンでは1000回が一瞬で終わってノイズに埋もれ、遅いマシンでは1000回でも十分な精度が出ないかもしれません。b.Nを「約1秒間、安定した計測ができる回数」に自動収束させる設計により、実行環境(CPU・負荷状況)に関わらず、常に同程度の統計的信頼性を持つns/opが得られます。「固定値を書かせず、フレームワーク側が適応的に調整する」という考え方です。🌐 他言語との比較
| 観点 | Go(testing.B) | Java(JMH) | Python(pytest-benchmark) | Node.js(benchmark.js) |
|---|---|---|---|---|
| 実行の起点 | 標準のgo test -bench=.(追加ツール不要) | 専用の@Benchmarkアノテーション+Maven/Gradleプラグインでビルド・実行 | pytestプラグインをpip installして--benchmark-only等で実行 | 別パッケージをnpm installし専用スクリプトで実行 |
| 反復回数の決め方 | b.Nをランナーが実行時間から自動収束させる | JMHが warmup iteration 数・measurement iteration 数を設定ベースで制御 | ライブラリが自動的に反復回数を調整 | ライブラリが自動的に反復回数を調整 |
| アロケーション計測 | -benchmemでB/op・allocs/opが標準出力に付与される | 専用のプロファイラ(async-profiler等)と別途連携が必要なことが多い | メモリ計測は別途memory_profiler等が必要 | 標準では実行時間のみ、メモリは別途計測が必要 |
| JITウォームアップの考慮 | Goはコンパイル言語でJITがないため、ウォームアップ区間は基本的に不要 | JVMのJITコンパイルが安定するまでの「ウォームアップ周回」が必須(重要な設定項目) | インタプリタなのでJITの概念は薄いが、キャッシュ等の影響は残る | V8のJITウォームアップの影響を受けるため考慮が必要な場合がある |
JavaのJMHは「JITが最適化された後の定常状態の性能」を正確に測るため、ウォームアップの設定が極めて重要かつ複雑です。Goはコンパイル時に機械語まで変換されるAOT(Ahead-Of-Time)言語なので、実行中に性能特性が変化するJITウォームアップの問題が原理的に存在せず、この点でベンチマークの設定がシンプルになっています。
🏆 実務での使いどころ
- Before/After比較によるリファクタリングの効果測定: 「文字列連結を
strings.Builderに変えたら本当に速くなったか」を、勘や理屈だけでなくns/op・allocs/opの実測値で示すことができます。プルリクエストのレビューで「なぜこの変更が必要か」を客観的な数値で説明する材料になります go test -bench=. -benchmemのCI組み込み: 継続的に性能を計測し、過去のベンチマーク結果と比較するツール(benchstat等)と組み合わせることで、意図しない性能劣化(リグレッション)を早期に検知できます- アロケーション削減のホットスポット特定:
allocs/opが高い関数は、GCの負荷源になっている可能性が高い候補です。pprof(Phase 3で学習予定)と組み合わせて、どの関数がヒープ確保の主犯かを絞り込む出発点になります - 並行処理のスケーラビリティ検証:
b.RunParallel(今回は扱いませんでしたが)を使うと、複数goroutineから同時に呼び出したときの性能劣化(ロック競合等)を計測できます
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
b.Nを「自分で指定する反復回数」だと思い、for i := 0; i < 1000; i++のように固定値でループしてしまう | forループの書き方として自然に見える | b.Nはランナーが実行時間から逆算して調整する値。固定値にするとgo test -bench=.が意図した「安定するまで自動調整する」仕組みが働かず、環境によって統計的信頼性が変わってしまう |
準備コスト(データ生成等)をb.ResetTimer()なしでループの外に書いてしまい、初回だけそのコストが乗る(あるいは逆にループの中に書いて毎回計測に含めてしまう) | 通常のテストコードの感覚でデータ準備を書く | 「1回だけ準備し、計測対象のループの直前でb.ResetTimer()を呼ぶ」がGoベンチマークの定石。準備コストとベンチマーク対象の境界を明示的に区切る |
関数呼び出しの戻り値をどこにも代入せず、impl.fn(items)とだけ書いてしまう | 正当性テストではないので戻り値を使う必要がないように見える | コンパイラの最適化(デッドコード除去・インライン化)によって、結果が使われない呼び出し自体が簡略化され、実態より速い(あるいは実行されていない)ベンチマーク結果になる可能性がある。sink変数に代入して使う(またはパッケージ変数に代入する)のが定石 |
-benchmemを付け忘れてns/opだけを見て「速い/遅い」を判断し、アロケーション回数の差を見落とす | デフォルトのgo test -bench=.の出力にallocs/opが含まれないため、存在に気づきにくい | -benchmemは明示的に付けないとB/op・allocs/opが表示されない。実行時間とメモリ確保回数は別の軸であり、「実行時間は同程度だがヒープ確保回数が大きく違う」というケースは頻繁にあるため、性能比較では基本的に-benchmemを付ける習慣をつける |
🚀 次のステップ
- 発展: 今回の
BenchmarkJoinにb.RunParallelを追加し、複数goroutineから同時にJoinBuilderを呼んだときに性能がどう変化するか(strings.Builderは非スレッドセーフなので、各goroutineが自分専用のインスタンスを使う必要がある点に注意)を試してみましょう - 次回予告: Day 026 — モック・DI設計(設計・アーキテクチャ)。インターフェース経由の依存注入を使い、外部APIやDBに依存するコードをテスト可能にする設計パターンを学びます。今日測った「アロケーションの少なさ」とは対照的に、「テストのしやすさ」を優先した設計判断の使い分けに焦点を当てます