0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code hookが遅い原因はpydanticだった ― 起動コストを実測してGoに移植したら約8割削減できた話

0
Last updated at Posted at 2026-08-19

仕上げは AI との協働でやっています。事実と表現は著者本人が確認しています。

「hookはbashでもPythonでも好きに書いていい。Anthropic公式ドキュメントも実装言語を指定していない」

その一文だけを見ると、hookの実装言語はどれでも同じだと思ってしまう。実際、Claude Codeの公式ドキュメントは言語非依存の設計で、UserPromptSubmitのデフォルトタイムアウトは30秒もある。1回の起動が100msかかったところで、タイムアウトの0.4%に過ぎない。

  • 原因: hookの起動コストの大半(108msのうち約84ms)はpydanticのインポートコストで、正規表現の数やファイルI/Oではなかった
  • 対策: プロセス分離モデル(1呼び出し=1プロセス起動)は維持したまま、起動頻度が高くロジックが単純なhookだけをGoへ移植した
  • 結果: 移植したhookは108ms→23ms、運用全体では年間約550 CPU時間分の削減(約8割減)。ただし効いたのは「Go化」自体ではなく「頻度の高い箇所を選んで絞り込んだこと」

だが、hookを122個運用し、39日間で244万回発火させた実測ログを見ると、話が変わった。単発の起動コストではなく、頻度をかけた累積コストで見ると、年間約680 CPU時間が消えている計算だった。以下、実測の過程を順を追って記録する。

hookの起動コストを実測したら、pydanticが支配的だった

計測の結果、pydanticのインポートが起動コストの大半(約84ms/108ms中)を占めていた。Claude Codeのhookは、PreToolUseやUserPromptSubmit等のライフサイクルイベントごとに、外部プロセスとして起動される。まず、実際に運用中のhookの起動コストを計測した。

for i in 1 2 3 4 5; do
  START=$(python3 -c "import time; print(time.time())")
  cat test-input.json | python3 pre-bash-code-evict-block.py > /dev/null 2>&1
  END=$(python3 -c "import time; print(time.time())")
  python3 -c "print(f'run $i: {($END - $START)*1000:.1f} ms')"
done

対象はpre-bash-code-evict-block.py。VSCode拡張内のClaude Codeセッションが、自分自身に対してcode --addのようなワークスペース再構成コマンドを実行し、セッションごと消滅する事故(実際に発生した障害)を防ぐ、正規表現1個のシンプルなガードhookだ。

結果は5回平均で約108ms。「たった0.1秒」に見えるが、これがボトルネックの正体を探る出発点になった。

python3 -X importtime -c "from pydantic import BaseModel" 2>&1 | tail -20

-X importtimeでインポート内訳をプロファイリングすると、原因がすぐに見えた。

フェーズ 所要時間(累積)
Python起動(site有効) 約40ms
pydantic単体インポート 約84ms
(うちpydantic._internal._generate_schema 約14ms
(うちpydantic_core + pydantic.json_schema等) 約28ms

このhookが依存する共通ライブラリ(hook_base.py)が、入力バリデーション用にpydanticBaseModelを使っていた。正規表現の数でも、ファイルI/Oの回数でもなく、pydanticのインポートコストそのものが支配的要因だった。 最適化する前に、何がボトルネックかを推測でなく実測で特定することが最初の一歩になる。

Go/Rust/bashで同じ処理を書いたら、起動コストの下限に収束した

Go・Rust・bashのいずれで書いても、実測レイテンシは約23〜25msに収束した。pydanticの起動コストが分かったところで、次に「言語を変えたらどこまで縮むか」を検証した。同じ正規表現マッチ処理を、Go・Rust・bash+grepで書き、起動から終了までの実測時間を比較した。

// Goでの最小実装
pattern := regexp.MustCompile(`(Apps Script|clasp)`)
if pattern.MatchString(line) {
    fmt.Println("matched")
}

Claude Code hook startup latency by runtime

ランタイム 実測レイテンシ
Python(pydanticなし) 約40ms
Python(pydanticあり、現状) 約108ms
Goネイティブバイナリ 約23ms
Rustネイティブバイナリ 約24ms
bash + grep 約24ms

Go・Rust・bashのいずれも約23〜25msに収束した。言語による差はほぼ消え、OSのプロセスfork自体のオーバーヘッドという物理的な下限に張り付く。つまり「Goだから速い」のではなく、「pydanticを積んだPythonが重い」というのが正確な言い方になる。

桁で見ると、Python+pydanticは約108ms、Go移植版は約23ms。おおよそ5倍近い差がある。

なぜこの桁差になるか

桁差の正体は、Pythonが起動のたびに重量ライブラリの初期化コストを払い直す設計にある。Pythonはインタプリタ起動のたびにモジュールを解決・実行し、pydanticのような重量ライブラリは内部でpydantic_core(Rust実装のコアバリデータ)に加え、zoneinfojson_schema生成のためのメタプログラミング機構まで読み込む。これはプロセスが常駐している場合(Webサーバーのアプリケーションコード等)は1回払えばよいコストだが、プロセスを毎回使い捨てる設計(hookの標準的な実行モデル)では、この初期化コストを呼び出しのたびに払い続けることになる。

一方、Go/Rustはコンパイル時に依存が静的リンクされ、実行時に「モジュールを解決する」フェーズ自体が存在しない。起動直後からmain()が走る。アルゴリズムの設計目的が違うので、速度差はそのまま設計の差として現れる。

実務では「使い捨てプロセスで頻繁に呼ばれる小さな処理」ほど、コンパイル言語の恩恵が大きい、というのが現実的な指針になる。

累積コストで見ると、無視できない規模になる

単発の108ms→23msという差は小さく見えるが、実際の運用頻度をかけると年間約680 CPU時間規模の差になる。

Estimated CPU time for pydantic-dependent hooks

39日間・244万回の実測hook発火ログを集計すると、122個のPython hookのうち105個がpydantic依存の共通ライブラリを使っており、1日あたり約53,912回発火していた。

期間 現状 Go移植後
1日 約1.9 CPU時間 約0.4 CPU時間
1ヶ月 約56 CPU時間 約11 CPU時間
1年 約680 CPU時間 約135 CPU時間

差は年間で約550 CPU時間。これは「体感が遅い」という個人的な不満ではなく、マシンリソース・電力・並行運用スケールで効いてくる規模だ。ただし、この数字をそのまま「全部Go化しよう」に飛躍させるのは早い。次はその採用条件を確認する。

移植して速くなった、だけでは採用できない

移植の採用条件は、速度だけでなく元の判定ロジックと挙動が完全一致することだ。速くなったことと、元の判定ロジックと完全に同じ挙動をすることは別問題。移植ミスで誤検知・検知漏れが起きれば、速度向上の意味がない。

pre-bash-code-evict-block.pyのdocstringには、過去の誤検知対策として12件のテストケースが明記されていた。

検証した12件のテストケース
許可するもの(誤検知しない):
  code --version, code . (ワークスペース変更を伴わない code 呼び出し)
  git add, npm run add (code でない add 系)
  decode --addr x, encode -a file, vscode-cli --add x (code が単語境界でない)

blockするもの:
  code --add /path, code -a ., code --remove foo
  パイプ/AND後 (cd /x && code --add bar)
  echo内文字列 (echo code --add - 安全側にblock)

この12ケース全てを、Python版とGo版の両方に同一入力として投げ、判定結果が完全一致するか機械的に検証した。

for command, expected, desc in test_cases:
    py_result = run_python(command)
    go_result = run_go(command)
    match = (py_result == expected) and (go_result == expected) and (py_result == go_result)
    print(f"{command:<35} {expected} {py_result} {go_result} {'OK' if match else 'MISMATCH'}")

結果は12/12件で完全一致。この検証があって初めて「速くなった」だけでなく「速くなった、かつ品質は変わっていない」と言える。

移植は「動いた」で終わらせず、元の実装との等価性を機械的に証明してから採用する。

「デーモン化すれば速い」も落とし穴

デーモン化は単発では速いが、並行リクエスト下では直列化して逆に遅くなる。高速化のもう一つの候補として、hookプロセスを常駐化(デーモン化)し、Unixソケット経由でリクエストを受ける方式も検証した。1回のプロセス起動コストを1回だけ払えば、以降はソケット通信のオーバーヘッドだけで済むはずだ。

実際、シングルスレッドの単純なデーモンで1件ずつ処理すると、25ms程度まで縮んだ。しかし、これは「暇な時」の話だった。

Concurrency risk: single-thread daemon serializes under load

意図的に1件50msの処理時間を持つデーモンを立て、100並列でリクエストを送ってみた。

構成 100並列・50ms処理での総所要時間
シングルスレッドaccept-loop 5,438ms
マルチスレッド 140ms
参照: プロセスforkそのもの(100並列) 90ms

シングルスレッド実装は、100並列リクエストが来ると完全に直列化され、理論値通り約5.4秒かかった。単発では速くても、並行負荷下ではプロセス分離方式(毎回fork)よりむしろ遅くなる。

マルチスレッド化すれば解決するが、それは「独自の並行処理設計・障害復旧・監視」という新しい複雑性を背負い込むことになる。hookは失敗した瞬間にセッション全体をブロックしうる安全装置という性質上、複雑性を増やして障害範囲を広げるのは筋が悪い、という判断で、デーモン化は見送った。

この検証が、次に述べる「言語かプロセスモデルか」という問いの立て方そのものの転換点になった。

問うべきは「言語」ではなく「プロセスモデル」だった

速度を決めていたのは言語選択ではなく、プロセスをどう起動するかというモデルの選択だった。

最初は「Pythonは遅い、コンパイル言語に変えれば速い」という単純な問いで始まった。だが実測すると、Go・Rust・bashのいずれもプロセスforkのオーバーヘッド(約20〜25ms)という同じ下限に収束した。「どの言語を使うか」ではなく「プロセスをどう起動するか」が本質だった。

デーモン化はこの下限をさらに下げられるが、実装の複雑性・単一障害点というリスクとのトレードオフになる。安全装置としてのhookには、速度よりも「壊れにくさ」を優先する判断がある。

この約550 CPU時間の差は「Goが速いから」生まれたのではなく、「pydanticという特定のコストを、頻度の高い箇所からだけ取り除いたから」生まれた差だ。Go移植そのものが目的だったら、低頻度のhookまで手広く書き直していたはずだが、実際に効いたのは頻度の高い箇所を選んで絞り込んだことだった。

代わりに効くのは、ものさしを「速いか遅いか」から**「プロセス分離という安全性を維持したまま、どこまで下限に近づけるか」**へ移すことだった。今回の実測では、それは「重量級ライブラリ(pydantic)への依存を、起動コストが問題になる箇所(頻度が高く、判定ロジックが単純なhook)に限って外す」という具体的な作業に落ちた。

場面 選ぶもの 根拠
高頻度・単純ロジックのhook(正規表現マッチ等) Go/Rust移植 起動コストの累積が支配的、移植の等価性検証も容易
低頻度・複雑なロジックのhook Python維持 移植コスト・リスクが速度向上に見合わない
判定待ちが許容される処理 公式HTTP hookタイプ(常駐サーバー) Anthropic公式サポート内でデーモン化と同等の効果
UserPromptSubmitのような同期・注入系 プロセス分離のまま最適化 公式ドキュメントで非同期化の対象外と明記されている

次にやりたいことが1つ明確なレンズになった。「速そうだから」で全部を書き直すのではなく、実測した頻度と複雑度のマトリクスで、移植する箇所を選ぶこと。

頻度を掛けて初めて、累積コストは見える

年間約680 CPU時間・約8割削減という数字は、単発の108ms→23msという差だけを見ていたら出てこなかった。「hookはPython/bashどちらでもいい」という認識のままでは、この累積コストは見えてこない。頻度をかけて初めて、単発では見えなかった規模が姿を現した。

そしてこの数字自体が目的地ではなく、そこから「言語ではなくプロセスモデルを見る」という判断軸に気づけたことが、実務上はより再利用性が高い。実測の過程を順に追って、「プロセスモデルを維持したまま下限に近づける」という設計判断が具体的な数字の裏付けとして見えると、大規模なhookエコシステムを持つ運用における言語選択の必然性が腑に落ちる。

実務で触る時にも、起動コスト・判定ロジックの複雑度・移植の等価性検証コストの3つは別の軸で支えられている、という意識を持っておくと、「速そうだから移植する」の一歩先の判断ができるようになる。

この観測について

本稿の数字は、2026-07-10に実施した実測に基づく。Python版・Go版のレイテンシは、対象マシン上で各5回実行した平均値。累積CPU時間試算は、~/.claude/logs/hook-metrics/の39日間・244万イベントの実測ログから、pydantic依存hookの呼び出し頻度を集計し、単発コスト差を乗算したもの。Anthropic公式のタイムアウト値・hookタイプの仕様は、Hooks referenceの記述に基づく。

参考(出典)

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?