📚 背景知識(読んでから問題へ)
Day 038〜041では、pprofを使ってCPU/メモリの「どこが重いか」を見つける方法を学びました。今回のテーマ「ベンチマーク駆動最適化」は、その一歩先です。問題箇所を見つけた後、実際に直して、直したことで本当に速くなった(あるいはアロケーションが減った)ことを数値で証明するワークフローを扱います。
Goのtestingパッケージにはtesting.Bというベンチマーク専用の型があり、go test -bench=.で実行できます。ポイントはb.Nです。これは「Goのベンチマークフレームワークが自動的に調整する繰り返し回数」で、開発者が決めるものではありません。フレームワークは実行時間が安定して測定できるまでb.Nを増やしながら何度も実行します。さらに-benchmemフラグを付けると、ns/op(1回あたりの実行時間)に加えてB/op(1回あたりの確保バイト数)とallocs/op(1回あたりのアロケーション回数)も出力されます。
benchstatのような比較ツールや単純なgo test -benchの出力比較で、Before/Afterを定量的に語れることがプロフェッショナルとアマチュアの違いです。📝 問題
以下は、メールアドレスの形式を検証する関数IsValidEmailSlowのコードレビュー依頼です。この関数は、Webアプリケーションのリクエストバリデーションの中で、リクエストが来るたびに何度も呼ばれる関数だとします。
package validator
import "regexp"
// IsValidEmailSlow はリクエストごとに呼ばれるメールアドレス検証関数。
func IsValidEmailSlow(s string) bool {
re := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
return re.MatchString(s)
}
- このコードのパフォーマンス上の問題点を、
regexp.MustCompileが何をしているかに触れながら指摘する testing.Bを使い、go test -bench=. -benchmemでこの関数の現状のBenchmarkコードを書く(実行はしなくてよいが、go testでそのまま動く完全なコードにすること)- 問題点を修正した
IsValidEmailFastを実装する - Before/Afterの両方をベンチマークし、
ns/op・B/op・allocs/opの3指標がそれぞれどう変化するはずかを、コードの構造から論理的に予測する - 「ループの中で毎回コンパイルする」という同種のアンチパターンが、正規表現以外にどんな標準ライブラリ・処理で起こりうるか、最低2つ挙げる
🔍 ヒント(段階的開示)
ヒント1 — 方向性
regexp.MustCompileが返すのは、単なる文字列ではなく「コンパイル済みの内部状態を持つ構造体」です。このコンパイル処理自体に計算コストとメモリ確保コストがかかります。この関数が「呼ばれるたびに」何をしているかを、行ごとに分解して考えてください。
ヒント2 — アプローチ
Goでは、変更されない値(正規表現パターン・設定・接続プールなど)はパッケージレベルの変数として一度だけ初期化し、関数の中では使い回すのが定石です。var re = regexp.MustCompile(...)をパッケージのトップレベルに置き、関数内ではre.MatchString(s)だけを呼ぶ形に変えられないか考えてください。
ヒント3 — コード骨格
package validator
import "regexp"
// パッケージ初期化時(プログラム起動時)に一度だけ実行される
var emailPattern = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
func IsValidEmailFast(s string) bool {
return emailPattern.MatchString(s) // コンパイル済みのものを使い回すだけ
}
ベンチマークは以下の形が基本形です。
func BenchmarkXxx(b *testing.B) {
b.ReportAllocs() // -benchmem 相当の情報を明示的に有効化する(-benchmemフラグと併用可)
for i := 0; i < b.N; i++ {
// 計測したい処理
}
}
✅ 模範解答
validator.go(Before/After 両方を1ファイルに置く。実務ではAfterのみ残しBeforeは削除する)
package validator
import "regexp"
// IsValidEmailSlow は「レビュー対象」のBefore実装。
// 呼び出されるたびに regexp.MustCompile でパターンをコンパイルし直している。
func IsValidEmailSlow(s string) bool {
re := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
return re.MatchString(s)
}
// emailPattern はパッケージ初期化時に一度だけコンパイルされる。
var emailPattern = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
// IsValidEmailFast はAfter実装。コンパイル済みの正規表現を使い回す。
func IsValidEmailFast(s string) bool {
return emailPattern.MatchString(s)
}
validator_test.go
package validator
import "testing"
// 動作が変わっていないことを先に保証する(最適化で挙動を壊していないかの担保)
func TestIsValidEmail(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"a@example.com", true},
{"not-an-email", false},
}
for _, c := range cases {
if got := IsValidEmailSlow(c.in); got != c.want {
t.Errorf("Slow(%q) = %v, want %v", c.in, got, c.want)
}
if got := IsValidEmailFast(c.in); got != c.want {
t.Errorf("Fast(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func BenchmarkIsValidEmailSlow(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
IsValidEmailSlow("user@example.com")
}
}
func BenchmarkIsValidEmailFast(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
IsValidEmailFast("user@example.com")
}
}
実行コマンドと実測結果(go test -bench=. -benchmem -run=^$。-run=^$は通常のテストをスキップしベンチマークだけ走らせる指定):
goos: darwin
goarch: amd64
pkg: validator
cpu: VirtualApple @ 2.50GHz
BenchmarkIsValidEmailSlow-8 232021 4441 ns/op 5858 B/op 66 allocs/op
BenchmarkIsValidEmailFast-8 2958976 411.5 ns/op 0 B/op 0 allocs/op
PASS
ns/opは 4441 → 411.5(約10.8倍高速化)、B/opは 5858 → 0、allocs/opは 66 → 0(アロケーション完全ゼロ化)。🪜 Step-by-Step 解説
regexp.MustCompileは、渡された正規表現の文字列パターンを解析し、内部で有限オートマトン相当の実行可能な構造体(*regexp.Regexp)を構築する処理です。この構築処理には、文字列パース・内部データ構造のヒープ確保が伴います。IsValidEmailSlowは呼ばれるたびにこの構築処理を毎回ゼロからやり直しているため、本質的に1回で済むはずのコストを呼び出し回数分だけ払い続けています。
b.N回のループの中に「計測したい処理そのもの」だけを置くのが鉄則です。ループの外に置いてしまうと1回しか計測されず、b.Nによる自動調整の意味がなくなります。b.ReportAllocs()を呼ぶことで、ns/opに加えてB/op・allocs/opも出力されるようになります。
var emailPattern = regexp.MustCompile(...)をパッケージレベルに置くことで、この式はパッケージが最初にロードされるとき(プログラム起動時)に一度だけ評価されます。以降、IsValidEmailFastが何回呼ばれても、コンパイル済みのemailPatternを参照するだけで、MatchString(マッチング処理そのもの)以外のコストは発生しません。
ns/op: コンパイル処理そのものがなくなるため大幅に減少するはず → 実測 4441ns→411.5ns(約10.8倍)で一致。B/op: コンパイル時に確保されていたメモリがなくなるため大幅に減少するはず → 実測 5858B→0Bで一致。allocs/op: コンパイル処理由来のアロケーション(パターン解析中の内部スライス確保等)がなくなるはず → 実測 66回→0回で一致。MatchString自体は非バックトラック型のオートマトンで動作し、マッチ対象の入力に対して追加のヒープ確保を必要としない実装になっているため、Afterではallocs/opが完全に0になっています。
time.LoadLocationやtemplate.New(...).Parse(...)のようなパース/コンパイル系APIをリクエストハンドラの中で毎回呼ぶケース(テンプレートのパースも構文解析コストが高い)。json.NewEncoderやsql.Prepareのような「準備コストの高いオブジェクト」をループや関数内で毎回生成するケース(database/sqlではPrepareしたステートメントを使い回さないと、DB側でも毎回SQLパース・実行計画作成が走る)。
💡 設計思想・なぜこう書くのか
var宣言→init()→main())という基本仕様の自然な応用です。Goが「巨大なフレームワークによるDI(依存性注入)コンテナ」を必須としない設計になっているのも、こうしたシンプルなパッケージレベル変数や明示的なコンストラクタ関数(NewXxx)で十分に対応できる場面が多いからです。testing.Bがb.Nを自動調整する設計は、「開発者が繰り返し回数を勘で決めて誤った結論を出す」ことを防ぎ、測定の再現性と信頼性を標準ツールチェーンだけで担保するというGoの実務志向を体現しています。🌐 他言語との比較
| 言語 | 同等の概念 | Goとの違い |
|---|---|---|
| Java | JMH(Java Microbenchmark Harness) | JMHもウォームアップ・繰り返し回数の自動調整を行う点でGoのtesting.Bに近いが、外部ライブラリの追加とアノテーション設定が必要。GoはSDK標準のgo test -benchだけで完結する |
| Python | timeitモジュール | timeitは繰り返し回数を開発者が明示的に指定するのが基本(number=引数)で、Goのb.Nのような自動収束の仕組みは持たない。アロケーション計測も標準では付属せずtracemalloc等の別ツールが必要 |
| Node.js | benchmark.js(外部ライブラリ) | Node.js標準には統計的に安定したベンチマーク機構がなく、benchmark.jsのような外部パッケージの導入が事実上必須。Goは追加依存なしに-benchmemでメモリ計測まで得られる |
🏆 実務での使いどころ
- 正規表現・テンプレート・SQLプリペアドステートメントのグローバル初期化: リクエストハンドラの中で
regexp.MustCompileやtemplate.Parse、db.Prepareを毎回呼んでいないか、コードレビューの定番チェック項目にする - リリース前のBefore/Afterベンチマーク添付: パフォーマンス改善のPull Requestには、修正前後の
go test -bench=. -benchmemの出力を貼り付ける運用にすると、レビュアーが体感ではなく数値で改善を判断できる - CIでのベンチマーク回帰検知:
benchstatで複数回の実行結果を統計的に比較し、意図しない性能劣化(regression)をリリース前に検出するパイプラインに組み込む
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| ベンチマークのループの外に「計測したい処理」の準備コードまで置いてしまい、実質何も計測できていない | b.Nループの中身と外側の役割を混同している | ループの外は「1回だけでよい準備」(テストデータの用意など)、中は「計測したい処理そのもの」を置く。準備をループ内に書くと準備コストまで計測に混入する |
regexp.MustCompileをパッケージレベルに移すだけで満足し、動作確認をしない | 「速くなるはず」という予測に自信があり検証を省略する | 最適化前に必ずTestXxxで入出力が変わっていないことを保証してから、ベンチマークで速度を確認する。挙動が変わっていては本末転倒 |
ns/opだけを見て「速くなった」と結論し、allocs/opの変化を見ない | ベンチマークの主目的を実行時間の短縮だけだと思い込んでいる | GCの負荷(Day 041で学んだalloc_space相当の概念)はアロケーション回数に比例する。ns/opが同程度でもallocs/opが減っていれば、GC全体のスループットには効いている場合がある |
🚀 次のステップ
- 発展:
go install golang.org/x/perf/cmd/benchstat@latestでbenchstatを導入し、同じベンチマークを複数回(-count=10など)実行した結果を統計的に比較し、「誤差の範囲内か、有意な差か」を判定する練習をしてみましょう - 次回予告: reflectパッケージ — 「なぜリフレクションは遅いのか」を、今回学んだベンチマーク手法を使って自分で実測しながら理解します