JIT コンパイルとは
JIT(Just-In-Time) は、実行しながら最適化していくコンパイル方式である。まず素早く実行を開始し、実行時プロファイル(型・オブジェクト形状など)を集めながら段階的に高速化し、仮定が崩れたら deopt(最適化解除)して安全側に戻る。
実験
実験その 1
ブラウザと Node.js で以下を実行した。
function add(a, b) {
return a + b;
}
// ウォームアップ(最適化のために回す)
for (let i = 0; i < 1_000_000; i++) {
add(1, 2);
}
console.time("optimized");
for (let i = 0; i < 1_000_000_000; i++) {
add(1, 2);
}
console.timeEnd("optimized");
// 型を変えて最適化解除(deopt)を誘発
console.time("deoptimized");
for (let i = 0; i < 1_000_000_000; i++) {
add("1", 2); // string を混ぜて型が崩れる
}
console.timeEnd("deoptimized");
結果
[ブラウザ]
optimized: 514.217041015625 ms
deoptimized: 7851.2080078125 ms
[Node]
optimized: 324.991ms
deoptimized: 328.492ms
観察:
ブラウザでは想定どおり、型を崩すと遅くなった。一方 Node.js では差がほぼ出なかった。
なぜか
- Node.js(V8 のバージョンによる)では
+
のサイトが 多相化(IC が数値/文字列の両方を学習) しやすく、どちらの型でも最適化コードで走れることがある。 -
"1" + 2
は 文字列連結の高速パス が強く、数値加算との差が時間計測では見えにくい場合がある。 -
deopt 自体は起きても即再最適化され、ループ全体では差が小さくなるパターンがあり得る(
--trace-opt/--trace-deopt
にはログが出るのに、時間は変わらない)。
もっと時間差が出やすい題材としては、配列の ElementsKind(packed→holey / Smi→double) を意図的に崩すパターンが有効である。V8 は配列の“種類”に強い最適化を入れるため、ここを外すと差が出やすい。
実験その 2(ログで最適化 → 解除を確認)
function add(a, b) {
return a + b;
}
function test() {
for (let i = 0; i < 1_000_000; i++) {
add(1, 2); // 同じ型で呼び続ける
}
// 型を崩して最適化解除を誘発
add("1", 2);
}
test();
実行コマンド:
node --trace-opt --trace-deopt test2.js
出力例(抜粋):
[marking ... add ... for optimization ...]
[compiling ... add ... (target TURBOFAN) ...]
[marking ... test ... for optimization ... OSR]
[completed optimizing ... add ...]
[completed optimizing ... test ... OSR]
[bailout ... reason: Insufficient type feedback for call]
[bailout ... reason: not a Smi]
ログ | 意味 |
---|---|
marking ... for optimization |
よく呼ばれる関数を検出し、最適化キューに投入する |
compiling ... (target TURBOFAN) |
TurboFan が最適化コードを生成中である |
completed optimizing |
最適化が完了(高速なネイティブコード)である |
... deoptimizing ... |
型の変化等で仮定が崩れ、最適化解除(deopt) が発生 |
読み取りポイント
- TURBOFAN の最適化(marking → compiling → completed)が走っている。
- OSR(On-Stack Replacement) により、ループ途中から最適化版へ差し替えられている。
- その後、
Insufficient type feedback for call
(呼び出し箇所の型学習不足)やnot a Smi
(小整数仮定の破れ)で deopt している。
実験その 3(最適化 → 解除 → 再最適化の周回を可視化)
Node.js 環境では deopt しても即再最適化され、実行時間の差が小さく見える場合がある。下記のようなログが連続して出れば、「最適化 → 解除 → 再最適化」が起きている証拠である(抜粋)。
[... completed optimizing ...]
[bailout ...]
[... completed optimizing ... OSR]
[bailout ...]
optimized: 336.201ms
[bailout ... not a Smi]
[... completed optimizing ... OSR]
[bailout ... Insufficient type feedback for call]
deoptimized: 331.596ms
補足:なぜ「deoptimized」が速い/同じくらいに見えることがあるのか
- Sparkplug(ベースライン JIT)+ OSR が強力で、deopt の直後にすぐ再最適化されるため、長いループ全体では差が埋まりがちである。
-
add("1", i)
のようなケースは 文字列連結の高速パスがあり、最適化が整うと再び速くなる。
実験その 4(JIT あり/なしの比較)
実験その 1 のループ回数を減らし、JIT ありと**JIT なし(--jitless
)**を比較した。
function add(a, b) {
return a + b;
}
// ウォームアップ
for (let i = 0; i < 1_000_000; i++) add(1, 2);
console.time("optimized");
for (let i = 0; i < 100_000_000; i++) add(1, 2);
console.timeEnd("optimized");
console.time("deoptimized");
for (let i = 0; i < 100_000_000; i++) add("1", 2);
console.timeEnd("deoptimized");
# JITあり
optimized: 34.051ms
deoptimized: 47.173ms
# JITなし(--jitless)
optimized: 2.175s
deoptimized: 3.223s
→ JIT の効果は明確である。最適化/解除の差が見えにくい環境でも、JIT 有無で大きな差が出る。
JIT って意味あるの?
結論からいえば 意味は大きい。
「何万回も回さないと最適化されない」というイメージは誤解で、V8 は段階的に(短時間でも)性能を引き上げる。
段階的な実行パイプライン(4 段階)
- Ignition:バイトコードをその場で解釈実行(起動が速い・省メモリ)
- Sparkplug:最小限の推測で即席のネイティブコードを生成(中間 IR なし・超高速コンパイル)
- Maglev:より重めの最適化を一部行う**“速い最適化 JIT”**(Sparkplug と TurboFan の間を埋める)
- TurboFan:十分ホットで安定したコードに本格最適化を適用(ピーク性能)
0 段階目:Ignition とは
V8 のバイトコードインタプリタ。
JS を簡潔なバイトコードに変換し、高性能インタプリタで実行する。ここで型情報などの実行時フィードバックを集め、上位 JIT へ橋渡しする。
-
流れ
- 解析 → バイトコード生成
- 実行しながら IC/Feedback Vector に型情報を蓄積
- ホット化した箇所が上位段へ昇格
-
特徴
- 起動が速い/フットプリント小
- 実行時プロファイルを収集して次段に活用
- レジスタマシン型バイトコード+アキュムレータで中間結果を保持し、冗長なロード/ストアを削減
- 生成後に軽いインライン最適化(冗長削除など)を実施
1 段階目:Sparkplug とは
V8 のベースライン JIT。
Ignition のディスパッチオーバーヘッドを避け、バイトコードから 1 パスで機械語を吐く(中間 IR なし)ためコンパイルが非常に速い。少し使われた関数でも積極的に Sparkplug へ昇格できる。
-
なぜ必要か
実世界では短命タスク(ページ読み込み、CLI 起動など)が多く、TurboFan が動く前に処理が終わりがち。Sparkplug はその手前を高速化する役割を担う。 -
特徴
- 中間 IR なしの 1 パス生成 → 超高速コンパイル
- 最適化は最小限(ピープホール中心)で、とにかく「すぐ使える機械語」を出す
- インタプリタ互換フレームを維持(プロファイラ・例外・OSR と親和性が高い)
- 難しい JS セマンティクスは builtins を呼び出して再実装コストとコード量を抑制
-
うれしいポイント
- 短時間セッションでも効く:読み込み直後からネイティブ実行に移行
- 実運用での体感改善につながりやすい
2 段階目:Maglev
V8 の**“速い最適化 JIT”。
Sparkplug より速いコードを、TurboFan より短時間で生成してギャップを埋める**。
-
特徴
- SSA + CFG の簡素な IR を採用(Sparkplug よりリッチ、TurboFan より軽量)
- 事前パスで分岐先・ループ・ライフ情報を収集し、後工程を軽くする
- 抽象実行で Phi を配置しつつ SSA グラフを生成
- 実行時フィードバックを活用し、必要に応じてガード付きの専用ノードを生成(外れたら deopt)
- 表現選択で Smi/倍精度など適切にアンボックスし、必要時のみ再ボックス
- シンプルな前進型レジスタ割り当て、最小限の spill
- ノードが直接マクロアセンブラで命令を吐く(並行移動は Parallel Move Resolver)
3 段階目:TurboFan 最適化とは
V8 のピーク性能を狙う最適化 JIT。
よく呼ばれる(hot)関数を推測付きで大胆に最適化し、質の高い機械語を生成する。
-
特徴
- レイヤード設計:JS フロント/VM 機能/CPU 依存最適化を分離
- Sea-of-Nodes(グラフ)IR で命令順序に縛られず、コード移動や再配置が容易
- 代表的最適化:数値範囲解析、CSE/定数畳み込み、デッドコード除去、境界チェック除去、インライン化、デバーチャル化、ループ最適化、命令選択(アーキ依存)
- 旧 Crankshaft よりモダンで拡張しやすい
-
なぜ deopt(最適化解除)が起きるか
推測が外れた瞬間に起きる。例:-
not a Smi
… 小整数前提の箇所に文字列や浮動小数が来た -
Insufficient type feedback for call
… 呼び出しサイトの型学習不足 -
Insufficient type feedback for generic named access
…obj.prop
の形(Hidden Class)が不安定
多くの場合、すぐ再学習 → 再最適化され、全体時間では差が小さく見えることもある(特に Sparkplug/OSR が強い環境)。
-
-
TurboFan を効かせるコツ
- 同じ型を通す(数値パスに文字列を混ぜない)
-
Hidden Class を安定化(プロパティ定義順を揃え、
delete
しない) -
配列に穴を作らない(
delete arr[i]
は避け、必要なら TypedArray) - 呼び出し先を固定してメガモーフィック化を避ける
そもそも 1 回の実行でも同じコードは何度も走るのか
- サーバ:1 プロセスで何千~何万リクエスト。ルーティング、シリアライズ、JSON 変換、テンプレート/render、DB ドライバなど同じ関数がリクエストごとに再利用される。
- フロント:60FPS で 10 分=36,000 フレーム。毎フレームの描画・差分計算・イベント処理で同一関数が繰り返し呼ばれる。
- CLI/ビルド/テスト:AST 走査・文字列処理・圧縮・I/O バッファ処理など、数十万~数百万回の反復が普通。
→ つまり、1 回の実行の中でも十分に“ホット”になる機会が多く、JIT の効果は現実のワークロードでしっかり効く。
まとめ
- JS エンジンは Ignition → Sparkplug → Maglev → TurboFan の段階で、実行しながら最適化していく。
-
IC/OSR により最適化と deopt を高速に行き来するため、Node では「遅くならない」ように見えることがあるが、
--trace-opt/--trace-deopt
で事実は確認できる。 - 実務でも同じ関数は何度も呼ばれる(サーバ/フロント/ビルド処理)。JIT は現実のワークロードで効く。
- 効かせるコツは 型の安定・Hidden Class の固定・配列に穴を作らない・呼び先を固定。
- 迷ったら
--jitless
でベースラインを取り、JIT ありと比較して効果を測る。
参考サイト
https://v8.dev/blog/maglev
https://v8.dev/blog/ignition-interpreter
https://v8.dev/blog/turbofan-jit
https://v8.dev/blog/sparkplug