2
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?

JSのJIT コンパイルについてちょっと調べた

Posted at

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 段階)

  1. Ignition:バイトコードをその場で解釈実行(起動が速い・省メモリ)
  2. Sparkplug:最小限の推測で即席のネイティブコードを生成(中間 IR なし・超高速コンパイル)
  3. Maglev:より重めの最適化を一部行う**“速い最適化 JIT”**(Sparkplug と TurboFan の間を埋める)
  4. TurboFan:十分ホットで安定したコードに本格最適化を適用(ピーク性能)

0 段階目:Ignition とは

V8 のバイトコードインタプリタ
JS を簡潔なバイトコードに変換し、高性能インタプリタで実行する。ここで型情報などの実行時フィードバックを集め、上位 JIT へ橋渡しする。

  • 流れ

    1. 解析 → バイトコード生成
    2. 実行しながら IC/Feedback Vector に型情報を蓄積
    3. ホット化した箇所が上位段へ昇格
  • 特徴

    • 起動が速い/フットプリント小
    • 実行時プロファイルを収集して次段に活用
    • レジスタマシン型バイトコード+アキュムレータで中間結果を保持し、冗長なロード/ストアを削減
    • 生成後に軽いインライン最適化(冗長削除など)を実施

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


2
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
2
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?