Day 042 — ベンチマーク駆動最適化(コードレビュー: ループ内での正規表現再コンパイル)

2026-09-08 🟡 上級者 / Phase 3 デバッグ・コードレビュー ベンチマーク駆動最適化

📚 背景知識(読んでから問題へ)

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)
}
  1. このコードのパフォーマンス上の問題点を、regexp.MustCompileが何をしているかに触れながら指摘する
  2. testing.Bを使い、go test -bench=. -benchmemでこの関数の現状のBenchmarkコードを書く(実行はしなくてよいが、go testでそのまま動く完全なコードにすること)
  3. 問題点を修正したIsValidEmailFastを実装する
  4. Before/Afterの両方をベンチマークし、ns/op・B/op・allocs/opの3指標がそれぞれどう変化するはずかを、コードの構造から論理的に予測する
  5. 「ループの中で毎回コンパイルする」という同種のアンチパターンが、正規表現以外にどんな標準ライブラリ・処理で起こりうるか、最低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 解説

1
問題点の特定
regexp.MustCompileは、渡された正規表現の文字列パターンを解析し、内部で有限オートマトン相当の実行可能な構造体(*regexp.Regexp)を構築する処理です。この構築処理には、文字列パース・内部データ構造のヒープ確保が伴います。IsValidEmailSlowは呼ばれるたびにこの構築処理を毎回ゼロからやり直しているため、本質的に1回で済むはずのコストを呼び出し回数分だけ払い続けています。
2
Beforeのベンチマークを書く
b.N回のループの中に「計測したい処理そのもの」だけを置くのが鉄則です。ループの外に置いてしまうと1回しか計測されず、b.Nによる自動調整の意味がなくなります。b.ReportAllocs()を呼ぶことで、ns/opに加えてB/op・allocs/opも出力されるようになります。
3
Afterの実装
var emailPattern = regexp.MustCompile(...)をパッケージレベルに置くことで、この式はパッケージが最初にロードされるとき(プログラム起動時)に一度だけ評価されます。以降、IsValidEmailFastが何回呼ばれても、コンパイル済みのemailPatternを参照するだけで、MatchString(マッチング処理そのもの)以外のコストは発生しません。
4
3指標の変化の予測と実測の一致
ns/op: コンパイル処理そのものがなくなるため大幅に減少するはず → 実測 4441ns→411.5ns(約10.8倍)で一致。B/op: コンパイル時に確保されていたメモリがなくなるため大幅に減少するはず → 実測 5858B→0Bで一致。allocs/op: コンパイル処理由来のアロケーション(パターン解析中の内部スライス確保等)がなくなるはず → 実測 66回→0回で一致。MatchString自体は非バックトラック型のオートマトンで動作し、マッチ対象の入力に対して追加のヒープ確保を必要としない実装になっているため、Afterではallocs/opが完全に0になっています。
5
同種のアンチパターンが起こりうる他の場面
time.LoadLocationやtemplate.New(...).Parse(...)のようなパース/コンパイル系APIをリクエストハンドラの中で毎回呼ぶケース(テンプレートのパースも構文解析コストが高い)。json.NewEncoderやsql.Prepareのような「準備コストの高いオブジェクト」をループや関数内で毎回生成するケース(database/sqlではPrepareしたステートメントを使い回さないと、DB側でも毎回SQLパース・実行計画作成が走る)。

💡 設計思想・なぜこう書くのか

📌
初期化順序の自然な応用: Goでは「パッケージレベル変数の初期化はプログラム起動時に一度だけ行われる」という言語仕様を、初期化コストの高いオブジェクトの使い回しに積極的に利用します。これは特別なテクニックではなく、Goの初期化順序(var宣言→init()→main())という基本仕様の自然な応用です。Goが「巨大なフレームワークによるDI(依存性注入)コンテナ」を必須としない設計になっているのも、こうしたシンプルなパッケージレベル変数や明示的なコンストラクタ関数(NewXxx)で十分に対応できる場面が多いからです。
📌
測定の再現性を標準ツールチェーンで担保する: testing.Bがb.Nを自動調整する設計は、「開発者が繰り返し回数を勘で決めて誤った結論を出す」ことを防ぎ、測定の再現性と信頼性を標準ツールチェーンだけで担保するというGoの実務志向を体現しています。

🌐 他言語との比較

言語同等の概念Goとの違い
JavaJMH(Java Microbenchmark Harness)JMHもウォームアップ・繰り返し回数の自動調整を行う点でGoのtesting.Bに近いが、外部ライブラリの追加とアノテーション設定が必要。GoはSDK標準のgo test -benchだけで完結する
Pythontimeitモジュールtimeitは繰り返し回数を開発者が明示的に指定するのが基本(number=引数)で、Goのb.Nのような自動収束の仕組みは持たない。アロケーション計測も標準では付属せずtracemalloc等の別ツールが必要
Node.jsbenchmark.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パッケージ — 「なぜリフレクションは遅いのか」を、今回学んだベンチマーク手法を使って自分で実測しながら理解します

🎯 自己評価

自分の回答

気づき・メモ