はじめに
jq — JSON を操作するコマンドラインツール。便利だけど、ガチプログラミング言語として使うと遅い。
「Claude に JIT コンパイラを作らせたらどうなるか」という実験をしてみた結果がこれ。
リポジトリ: m5d215/jq-jit
# 10001 番目の素数を求める jq プログラム
# jq: 1.6s
jq -n '
def takeWhile(cond):
label $L | foreach .[] as $item (null; null;
if $item | cond then $item else break $L end
);
def isprime(knownPrimes):
. as $n
| [knownPrimes | takeWhile(. * . <= $n)]
| all($n % . != 0);
def next(knownPrimes):
first(
knownPrimes[-1] + 1 | while(true; . + 1)
| select(. % 2 != 0 and (. == 5 or . % 5 != 0))
| select(isprime(knownPrimes))
);
def primes:
[2]
| while(true; . as $knownPrimes | $knownPrimes + [next($knownPrimes)])
| .[-1];
nth(10000; primes)
'
# jq-jit: 40ms
jq-jit -n '
def takeWhile(cond):
label $L | foreach .[] as $item (null; null;
if $item | cond then $item else break $L end
);
def isprime(knownPrimes):
. as $n
| [knownPrimes | takeWhile(. * . <= $n)]
| all($n % . != 0);
def next(knownPrimes):
first(
knownPrimes[-1] + 1 | while(true; . + 1)
| select(. % 2 != 0 and (. == 5 or . % 5 != 0))
| select(isprime(knownPrimes))
);
def primes:
[2]
| while(true; . as $knownPrimes | $knownPrimes + [next($knownPrimes)])
| .[-1];
nth(10000; primes)
'
JIT コンパイルしているから当然だが、計算部分がボトルネックなケースでは圧倒的に速い。
流石に jq でプログラミングしている人はあまりいないと思うが、大量の JSONL をフィルタするようなケースでも同様の結果が得られる。
そしてこのコード、人間が書いた行数はゼロ。142コミット、19,772行の Rust コード、全て Claude が自律的に書いた。
開発環境は M1 MacBook Air(メモリ8GB) 1台のみ。libjq のバイトコードを読み取るというアーキテクチャ選択も、Cranelift を JIT バックエンドに採用する判断も、最適化の戦略も、全て Claude が自分で決めた。人間が関与したのは「jq 互換の高速な実装を作れ」という目的を与えたことだけ。
第1章:まず正しく動かす — インタープリタ実装
アーキテクチャの選択
最初の設計判断として、Claude は「libjq のバイトコードを読み取る」アプローチを選んだ。
jq のフィルター式(.foo | select(.bar > 0) のような式)をパースしてバイトコードに変換する部分は libjq の jq_compile() をそのまま使い、そのバイトコード構造体を FFI で直接読み取って自前のインタープリタで実行する。パーサーを一から書くのではなく、既存の信頼できるコンパイラの出力を土台にした。
jq フィルター式
↓ libjq の jq_compile()
libjq バイトコード
↓ FFI で構造体を直接読み取り
自前の IR (中間表現)
↓
インタープリタ / JIT コンパイラ
4時間で509/509
インタープリタの実装は驚くほど速かった。タイムラインを見てほしい。
| 時刻 | コミット | 内容 |
|---|---|---|
| 14:35 | 51aeacd |
libjq ベースの実行バックエンド初期セットアップ |
| 14:47 | 04b8dfb |
バイトコード VM、ランタイム組み込み関数、IR 定義を追加 |
| 16:50 | e6243dd |
複合代入、try-catch スコーピング、all/any 修正 |
| 18:16 | 7430803 |
モジュールシステム実装 → 507/509 (99.6%) |
| 18:39 | bf079e1 |
509/509 (100%) 達成 |
最初のコミットから4時間で、公式テストスイートを100%パスするインタープリタが完成した。
この段階では jq と同程度の速度。でも、正しく動くことが最優先。テストスイートという明確なゴールがあったからこそ、Claude は迷わず実装を進められたのだと思う。
第2章:JIT コンパイル — Cranelift で飛ぶ
100%達成の73分後
509/509を達成した73分後、Claude は次のコミットを積んだ。
8b8da8f Add Cranelift JIT compilation backend (Phase B)
Cranelift — Mozilla が Wasmtime のために開発したコンパイラバックエンド。LLVM より軽量でコンパイル速度が速く、JIT コンパイルに適している。
初期の JIT 実装では、identity、リテラル、パイプ、if-then-else、二項演算、配列操作など基本的な操作をカバー。JIT できないケースはインタープリタにフォールバックする設計にした。
4時間で78%、そしてフォールバック全除去へ
JIT のカバレッジ拡張も凄まじいペースだった。
| 時刻 | 進捗 | 主な追加機能 |
|---|---|---|
| 19:52 | JIT 導入 | 基本演算、配列、オブジェクト構築 |
| 20:16 | — | 純粋 Rust JSON パーサー追加 |
| 21:05 | — | TryCatch + スレッドローカルエラー伝搬 |
| 22:09 | — | Label/Break、Limit、FuncCall |
| 22:46 | — | SIGSEGV 修正、Assign/Update/Recurse |
| 23:20 | 78% (400/509) | limit+collect 修正 |
| 23:51 | 84% (408/485) | 汎用フォールバック追加 |
| 翌07:00 | フォールバック全除去 | del/1、再帰定義、first/last の JIT 実装完了 |
18コミットを約4時間で積み、JIT カバレッジを0%から84%まで引き上げた。翌朝には libjq へのフォールバックを完全に除去し、全てのフィルターを自前で実行できるようになった。
この時点での性能
v0.2.0(JIT + 初期最適化)時点のベンチマーク:
| Workload | jq-jit | vs jq | vs jaq |
|---|---|---|---|
| empty | 0.143s | 9.0x | 5.5x |
| identity | 0.227s | 12.7x | 3.3x |
| field access | 0.190s | 10.4x | 4.5x |
| select | 0.209s | 10.2x | 6.5x |
| object construct | 0.301s | 9.1x | 6.2x |
jq 比で9〜13倍。十分速いが、まだ伸びしろがあった。
第3章:最適化 — 一晩で56コミット
v0.2.0 のリリース後、Claude は最適化フェーズに入った。その夜のうちに56コミットを積み、全ワークロードで30〜45%の性能向上を達成した。
最適化の3層構造
最適化は下位レイヤーから上位レイヤーへと体系的に進んだ。
層1:JSON I/O の高速化
入出力は全ての処理の土台。ここを速くすれば全体が速くなる。
-
fast-float クレート導入 —
f64のパースを高速化 - itoa クレート導入 — 整数の文字列化を高速化
-
出力バッファリング —
write_allシステムコール回数を削減 - 文字列シリアライズ最適化 — エスケープ不要な文字列を連続バッファで一括書き込み
層2:JIT コンパイラの融合最適化
複数の操作を1つの専用命令に融合し、中間データの生成を省く。
-
ObjFromFields —
ObjNew+ObjCopyFieldを単一パスに融合。オブジェクト構築が大幅に高速化 -
FieldIsTruthy —
select(.field)パターンを1命令で処理。フィールド取得と真偽判定を分離せずに実行 - all/any 短絡評価 — 条件を満たした時点で即座にループを抜ける
層3:eval エンジンの徹底チューニング
56コミット中26コミットがこの層。eval.rs に +1,589行。
パターン融合:
While + LetBinding + Add + Collect → in-place 配列追加
個別に実行すると「ループ → 変数束縛 → 加算 → 配列収集」と4段階の処理が走るが、これらが連続するパターンを検出して1つの専用処理に融合した。
ゼロクローン数値演算:
// Before: 値をクローンしてから数値を取り出す
let a = env.vars[i].clone();
let b = env.vars[j].clone();
let result = a.as_f64()? + b.as_f64()?;
// After: 環境変数から直接 f64 を取り出す
let a = env.get_num(i)?;
let b = env.get_num(j)?;
let result = a + b;
複合条件の単一パス評価:
all(condition) のような操作では、各要素に対して「条件を評価 → 真偽判定」を別々に行っていた。これを eval_bool_compound という専用評価器で1パスにまとめた。
結果
| Workload | v0.2.0 | v0.3.0 | 高速化 |
|---|---|---|---|
| empty | 0.143s | 0.105s | 1.36x |
| identity | 0.227s | 0.175s | 1.30x |
| field access | 0.190s | 0.142s | 1.34x |
| arithmetic | 0.224s | 0.171s | 1.31x |
| select | 0.209s | 0.161s | 1.30x |
| string concat | 0.252s | 0.184s | 1.37x |
| object construct | 0.301s | 0.208s | 1.45x |
全項目で30〜45%の底上げ。対 jq の倍率は9〜13倍から 12〜17倍 に、対 jaq の倍率は3.3〜6.7倍から 4.3〜9.0倍 に向上した。
Claude にどう作らせたか
プロンプト
Claude への指示は驚くほどシンプルだった。
## 概要
jq の JIT コンパイラを実装してください。
jq 公式のテストケースを 100% パスするような実装をゼロベースで構築してください。
## ゴール
- 100% jq 互換 → jq コマンドを置き換えて使える
- JIT コンパイルにより jq より速い
## 注意事項
- こちらには一切の判断を求めず、全て自律的に遂行してください
- このPCローカルの作業にとどめてください。外部に影響を与えてはいけません
- 適切にコミットしてください
- 複数セッションにわたる非常に長期的なプロジェクトになります。セッション管理やプロジェクトマネージメントも考慮してください
「自律的に遂行する」— これが全て。何を実装するか、どういう順番で進めるか、どの最適化手法を選ぶか、全て Claude に委ねた。
強調しておきたいのは、実装方針や最適化の戦略について人間は一切関与していないということ。
- libjq のバイトコードを読み取るアーキテクチャ → Claude が選んだ
- Cranelift を JIT バックエンドに採用 → Claude が選んだ
- インタープリタ → JIT → 最適化の開発順序 → Claude が決めた
- パターン融合、ゼロクローン演算などの最適化手法 → Claude が自分で考えた
- fast-float、itoa、mimalloc などのクレート選定 → Claude が自分で見つけて導入した
人間がやったのは:
- 最初のプロンプト
- ベンチマーク結果を見て「もっと速くして」と言う
- (本当にちゃんとできたのか不安だから) テスト結果を確認する
コードレビューすらしていない。142コミット、19,772行、全てノータッチ。
開発環境
もう一つ特筆すべきは、これが M1 MacBook Air(メモリ8GB) という決してハイスペックとは言えないマシン1台で完結していること。GPU もクラウドも使っていない(人間が追記: ここ謎ですね。伝えたいことは、claude codeが凄すぎて、手元の端末はM1レベルだけで作れたということです)。Claude Code(CLI版)をターミナルで動かし、ローカルで cargo build と cargo test を回すだけ。
8GB のメモリで Cranelift を使った JIT コンパイラをビルドし、509件のテストを実行し、200万行の NDJSON でベンチマークを取る。制約のある環境だからこそ、Claude はメモリ効率を意識した最適化(Rc プーリング、CompactString、in-place 操作)を選んだのかもしれない (知らんけど)。
なぜうまくいったか
いくつか要因があると思う。
- 明確なゴールがあった: 「jq の公式テストスイート509件を全てパスする」という客観的な達成基準。曖昧な仕様ではなく、テストが通るか通らないかの二値。
- フィードバックループが速い:
cargo testで即座に結果がわかる。509件中何件通ったかが数値で出る。Claude はこの数値を見て次の手を自分で決められた。 - 段階的に進めた: インタープリタ → JIT → 最適化という順番は Claude 自身が選んだ。まず正しく動くものを作り、次に速くする。ソフトウェア開発の基本に忠実だった。
- 制約が少なかった: 「自律的に遂行する」というルールは、言い換えれば「いちいち聞くな」ということ。Claude は自分の判断でアーキテクチャを決め、ライブラリを選び、最適化手法を選択できた。
おわりに
正直なところ、ここまでの結果は予想していなかった。
テスト互換性100%は達成できるだろうと思っていた。でも、それを一晩の自律作業で実現するとは。
v0.2.0 → v0.3.0 の最適化フェーズでは、Claude は56コミットを約10時間で積んだ。パターン融合、ゼロクローン演算、複合条件評価器——個々の手法は教科書的だが、それらを組み合わせて実際のコードに落とし込む密度と速度は、人間には真似しにくい。
それでも、「AI にJITコンパイラを作らせる」という実験の結果としては、十分すぎる成果だったと思う。