Claude Codeでbenchstatを使い、ベンチの性能回帰をhookで止める
go test -benchの前後をbenchstatで比べ、p値つきの悪化だけをPostToolUse hookでClaude Codeに返す構成です。判定スクリプトと設定例を載せます。
Claude Codeに関数を書き換えさせたあと、ベンチマークが遅くなっていないかを人が毎回見るのは続きません。go test -benchを1回回して数字を比べるやり方も、ノイズに負けます。ここでは、benchstatで前後を統計的に比べ、有意な悪化があったときだけhookでClaude Codeに差し戻す構成を作ります。
先にpprofで原因を絞る流れはClaude Codeでpprofの出力を読む記事にあります。この記事は、その手前の「悪化したことに気づく」仕組みが主題です。
benchstatは何を根拠に回帰と呼ぶのか
benchstatは、Goのベンチマーク結果を統計的に要約し、A/B比較するコマンドです。入力はGoのベンチマーク形式のファイルで、go test -benchの出力がそのまま使えます。変更の前後で2つのファイルを渡すと、同名のベンチマークを1行ずつ比べます。
判定の材料は3つあります。
- 各ベンチマークの中央値と、その信頼区間(95%)
- 前後の中央値の差(
vs base列の±%) - 差が偶然のばらつきで説明できる確率(p値)
差が統計的に有意でない行には、~が付きます。付くのはp値が大きい行で、数字の差があっても「ノイズの範囲」と読む行です。逆に有意でも大きな変化とは限りません。ノイズの小さい環境では、わずかな差でも有意になります。
標本数の目安も決まっています。各ベンチマークを少なくとも10回走らせる前提で、-count=10を付けて前後を取ります。1回ずつの計測を渡しても、中央値の信頼区間は意味を持ちません。
出力の形
benchstatのドキュメントにある例を引きます。公式の例なので、自分のコードで出る値ではありません。
│ old.txt │ new.txt │
│ sec/op │ sec/op vs base │
Encode/format=json-48 1.718µ ± 1% 1.423µ ± 1% -17.20% (p=0.000 n=10)
Encode/format=gob-48 3.066µ ± 0% 3.070µ ± 2% ~ (p=0.446 n=10)
geomean 2.295µ 2.090µ -8.94%sec/opは1回あたりの時間なので、減れば速くなっています。-17.20%は改善で、回帰は+で表れます。最終行のgeomeanは、全ベンチマークの幾何平均です。n本のうち1本だけが2倍になっても、geomeanの動きは2のn乗根倍にとどまります。1本の悪化は埋もれるので、個別の行を見ます。
前後のファイルを作る
回帰の判定には基準が要ります。変更前のコミットで一度だけ計測し、bench/base.txtとして残します。
go install golang.org/x/perf/cmd/benchstat@latest
mkdir -p bench
go test -run='^$' -bench=. -count=10 ./internal/codec/ \
> bench/base.txt-run='^$'は、通常のテストを走らせず、ベンチマークだけを実行するための指定です。benchstatのドキュメントの例も同じ形を使っています。
変更後は同じコマンドの出力先をbench/new.txtに変え、次のように比べます。
benchstat bench/base.txt bench/new.txt比べられない条件がある
ファイル先頭のgoos・goarch・pkgといった設定行が前後で違うと、benchstatは設定ごとに別の表を出します。別マシンで取ったbase.txtと比べると、表が分かれて比較自体が成立しません。baseは、hookを動かす環境と同じマシンで取ります。
PostToolUseで再計測を自動で回す
ここからが本題です。Claude Codeがgoファイルを編集した直後に、再計測と比較を走らせます。
PostToolUseは、ツール呼び出しが成功した直後に動くイベントです。入力にはツールへの引数(tool_input)と結果(tool_response)が入ります。ファイルを書き換えるEditやWriteは、matcherにEdit|Writeと書くと両方を拾えます。
発火の絞り込みには、ハンドラのifフィールドが使えます。Edit(**/*.go)のように、権限ルールと同じ書式でパスを指定します。Editルールは、ファイルを編集する組み込みツール全般に効きます。そのため、Editで書いたルールがWriteによる変更にも当たります。
編集からフィードバックまでの流れ
- 1
Claude Codeがgoファイルを編集する
EditまたはWriteが成功すると、PostToolUseが発火します。
ifが.go以外のファイルを落とします。 - 2
hookが再計測する
go test -benchを-count=10で回し、bench/new.txtに保存します。 - 3
benchstatで比べ、悪化行だけを拾う
判定スクリプトが、有意に悪化した行を取り出します。
- 4
exit 2でClaude Codeに返す
悪化があれば標準エラーに書いて終了コード2で抜けます。Claude Codeはその内容を見て直しに入ります。
設定
.claude/settings.jsonに次を書きます。timeoutはhookの打ち切り秒数で、commandの既定は600秒です。ベンチマークが長くなる場合は、ここを明示しておくほうが後で迷いません。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"if": "Edit(**/*.go)",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/bench-check.sh",
"timeout": 300,
"statusMessage": "ベンチマークを再計測中"
}
]
}
]
}
}${CLAUDE_PROJECT_DIR}は、セッションを始めたプロジェクトのルートを指します。スクリプトの中でも、同名の環境変数として参照できます。
計測と通知のスクリプト
.claude/hooks/bench-check.shです。計測対象は-benchの正規表現で絞ります。パッケージ全体のベンチマークを毎回10回ずつ回すと、編集のたびに待たされるからです。
#!/bin/bash
cd "$CLAUDE_PROJECT_DIR" || exit 0
mkdir -p bench
go test -run='^$' -bench='BenchmarkEncode|BenchmarkDecode' \
-count=10 ./internal/codec/ > bench/new.txt 2> bench/err.txt \
|| { cat bench/err.txt >&2; exit 2; }
benchstat bench/base.txt bench/new.txt > bench/report.txt
if ! .claude/hooks/bench-gate.sh 5 < bench/report.txt \
> bench/regress.txt; then
echo "性能回帰: 5%以上の有意な悪化があります" >&2
cat bench/regress.txt >&2
exit 2
fi
exit 0終了コード2の扱いは、イベントごとに決まっています。PostToolUseでは、標準エラーがClaude Codeに表示されます。ツールはすでに実行済みなので、編集そのものは取り消せません。計測に失敗したとき(コンパイルエラーなど)も、同じ経路でエラー内容が返ります。
悪化行だけを拾う判定スクリプト
benchstatの表を目で読ませる代わりに、機械的に悪化行を抜きます。.claude/hooks/bench-gate.shです。引数が閾値(%)で、標準入力にbenchstatの出力を受けます。
#!/bin/bash
THRESHOLD=${1:-5}
awk -v th="$THRESHOLD" '
/B\/s/ { track = 0 }
/sec\/op|B\/op|allocs\/op/ { track = 1 }
track && /\+[0-9.]+% \(p=/ {
for (i = 1; i <= NF; i++)
if ($i ~ /^\+[0-9.]+%$/) d = substr($i, 2, length($i) - 2) + 0
if (d >= th) { print; bad = 1 }
}
END { exit bad ? 1 : 0 }
'仕組みは3点です。
- 時間・メモリ・確保回数の列(
sec/op・B/op・allocs/op)だけを追います。これらは増えるほど悪化です。B/sのようなスループットの列は増えるほど良いので、追いません +x% (p=...)の形をした行だけを見ます。有意でない行は~なので、パターンに当たりません- 差が閾値以上の行だけを出力し、1行でもあれば終了コード1で終わります
次のような入力で試すと、動きが分かります。1〜3行目はbenchstatのドキュメントの例、Decodeの2行は判定を試すために用意した例です。
Encode/format=json-48 1.718µ ± 1% 1.423µ ± 1% -17.20% (p=0.000 n=10)
Encode/format=gob-48 3.066µ ± 0% 3.070µ ± 2% ~ (p=0.446 n=10)
Decode/format=json-48 2.000µ ± 1% 2.300µ ± 1% +15.00% (p=0.002 n=10)
Decode/format=gob-48 2.000µ ± 1% 2.020µ ± 1% +1.00% (p=0.004 n=10)閾値5で通すと、拾われるのはDecode/format=json-48の1行です。+1.00%の行は有意ですが5%未満なので外れ、閾値を20にすれば何も残りません。「有意かどうか」と「見過ごせない大きさか」を、別々のつまみにしてあるわけです。
閾値の置き方で拾う行が変わる
閾値 5(%)
+15.00%の行だけが残り、+1.00%の行は有意でも外れます。小さな退行も拾いたい関数向けです。
閾値 20(%)
この入力では何も残りません。計測環境のばらつきが大きいマシンでは、誤検知を減らせます。
「止める」の限界とStopでの関門
ここまでの構成には、押さえておく点があります。PostToolUseのexit 2は、Claude Codeに指摘を見せますが、ツールの実行や編集を止めはしません。ツールが走った後だからです。
作業の終わりに関所を置くなら、Stopイベントを使います。Stopフックのexit 2は、Claude Codeが応答を終えるのを止め、会話を続けさせます。回帰が残ったまま「完了しました」と言わせない、という使い方です。
使うときは、入力に含まれるstop_hook_activeを確認します。この値がtrueなら、すでにstopフックの結果で続行している最中です。同じ条件で何度も止めると、解消しない条件に引っかかり続けます。また、stopフックによる続行は連続8回で打ち切られる上限があり、Claude Codeが次にツールを呼ぶとカウントは戻ります。上限はCLAUDE_CODE_STOP_HOOK_BLOCK_CAPで変えられます。
編集のたびに計測するPostToolUseと、終了時に1回だけ計測するStopの違いは、次のとおりです。
| 観点 | PostToolUse(編集ごと) | Stop(終了時) |
|---|---|---|
| 回帰に気づく早さ | PostToolUse(編集ごと)悪化させた編集の直後 | Stop(終了時)作業の最後 |
| 計測の回数 | PostToolUse(編集ごと)編集のたびに増える | Stop(終了時)1ターンに1回 |
| 変更を止める力 | PostToolUse(編集ごと)なし(指摘のみ) | Stop(終了時)あり(終了を止める) |
| 向く場面 | PostToolUse(編集ごと)短いベンチマーク、性能が主題の作業 | Stop(終了時)長いベンチマーク、全体の関門 |
ベンチマークが数十秒かかるなら、Stopに寄せるほうが待ち時間は少なく済みます。両方に同じスクリプトを登録し、PostToolUse側だけ-benchを絞る使い分けもできます。
差し戻された回帰を、Claude Codeにどう直させるか
hookが返すのは「どの行が何%悪化したか」だけです。直し方の約束は、CLAUDE.mdに書いておきます。
## ベンチマークの扱い
- bench/base.txt は勝手に更新しない。更新は人が依頼したときだけ
- 悪化の指摘を受けたら、まず bench/report.txt を読む
- ~ の行はノイズ扱い。有意な +% の行だけを直す対象にする
- 直した後は同じ -bench の指定と -count=10 で取り直す
- 改善を主張するときは、benchstat の出力をそのまま貼る特に重要なのは1行目です。回帰を止められなくなったClaude Codeが、base.txtを新しい結果で上書きして「解消した」と報告する事態を、手順で防ぎます。悪化の原因を関数単位で調べたいときは、pprofのtopとlistを渡す流れに移ります。手順はpprofの記事に書いています。
ノイズに振り回されないための確認項目
- 前後の
nが10未満になっている。-count=10を付けているか、-benchの正規表現が前後で同じかを見ます - 同じコードなのにたまに
+8%と出る。CPUを他のプロセスと共有している環境です。まず同じコードで2回取り、~になることを確かめます。ならなければ、-countを増やすか閾値を上げます - baseとnewで表が2つに分かれる。
goosやpkgなどの設定行が違います。baseを同じ環境で取り直します - hookが毎回タイムアウトする。
timeoutを延ばすか、計測対象の正規表現を絞ります。長いものはStopに寄せます - 全パッケージを計測して待たされる。
./...を避け、ベンチマークのあるパッケージだけを指定します
落とし穴
base.txtをコミットし忘れると、比較が毎回ずれる
別のブランチで取ったbase.txtが残っていると、コードの変更と無関係な差が回帰として出ます。baseは、比較したいコミットの時点で取り、その場でコミットしておきます。ブランチを切り替えたら、取り直す前提で運用します。
ほかのhook構成との使い分け
テストが通るまで止めない構成は、agent hookでテストを検証させる記事にあります。編集のたびにテストを回す基本形はhooksでテストを自動実行する記事、競合状態の検出はgo test -raceのレポートを読む記事が受け持ちます。ベンチマーク結果の可視化をSaaS側に任せるなら、CodSpeedプラグインの記事が選択肢になります。
この記事の構成が向くのは、自分のリポジトリでベンチマークを持ち、前後比較を手元で完結させたい場合です。ベンチマークがまだ無い関数は、先にテーブル駆動テストを整える流れが先です。手順はGoのテーブル駆動テストの記事にあります。
まとめ
benchstatは、中央値・信頼区間・p値で「ノイズではない悪化」を言い切れるツールです。それをPostToolUseに載せると、Claude Codeが性能を落とした編集の直後に、悪化した行を受け取れます。ただしPostToolUseは指摘までで、止めるのはStopの役割です。小さな差でも有意になるので、閾値は別に決めます。
最初に決めるのは、基準にするbase.txtを取る環境、計測するベンチマークの範囲、閾値の3つです。この3つが固まっていれば、スクリプトは数十行で動きます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。