Claude Media
Claude Codeでbenchstatを使い、ベンチの性能回帰をhookで止める

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. 1

    Claude Codeがgoファイルを編集する

    EditまたはWriteが成功すると、PostToolUseが発火します。ifが.go以外のファイルを落とします。

  2. 2

    hookが再計測する

    go test -benchを-count=10で回し、bench/new.txtに保存します。

  3. 3

    benchstatで比べ、悪化行だけを拾う

    判定スクリプトが、有意に悪化した行を取り出します。

  4. 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つが固まっていれば、スクリプトは数十行で動きます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/hooks
  2. pkg.go.dev/golang.org/x/perf/cmd/benchstat
  3. code.claude.com/docs/en/permissions
この記事を共有:XはてブLinkedIn