このページの目次

JIT コンパイル

実行時にバイトコードを機械語にコンパイルする。階層型コンパイルはベースラインで起動速度を確保し、最適化されたJITでピークパフォーマンスを向上させる。インラインキャッシングと推測+デオープティマイズにより、JITはAOTでは行えない積極的な最適化を実行できる——推測が外れた場合はインタプリタ実行にフォールバックする。

概要

従来のコンパイル(AOT)は、プログラム実行前にすべての機械コードを生成する。JIT(Just-In-Time)は、⁠実行時にバイトコードやIR(中間表現)を機械コードにコンパイルする——「このコードが実際に呼び出された場合」のみコンパイルを行う。これにより、2つの独自の利点が生まれる。⁠実行時の情報⁠(どの分岐がホットか、どの型が実際に使用されているかを知ることができる)と、⁠未実行のコードのコンパイルが不要⁠(コンパイル範囲を縮小し、起動を高速化する)。その代償として、コンパイル時間が実行時間に含まれるため、JIT設計の核心的なジレンマは「コンパイルの品質 vs コンパイルのオーバーヘッド」にある——このエントリでは、JITが階層型コンパイル推測+デオープティマイズを用いてこのジレンマをどのように解決するかを解説する。

階層型コンパイル:単一のJITではなく、補完的な複数のコンパイラ

現代のJITは単一のコンパイラではなく、階層化されている:

baseline/JIT (Ignition/V8、または C1/HotSpot):
  → 高速コンパイル、ほぼ最適化なし
  → 各関数は1回だけコンパイルされ、コンパイル時間はインタプリタ実行で節約される時間より << 短い

最適化JIT (TurboFan/V8、または C2/HotSpot):
  → 「ホットスポット」関数のみ(多数呼び出された関数、またはループを多数反復した関数)
  → 一連の最適化を実行:インライン展開、GVN、LICM、ループ展開、SROA
  → コンパイル時間は長いが、ホットスポット関数の実行比率が高いため、価値がある

呼び出しカウントとループバックエッジカウントが「どの階層に昇格させるか」を決定する:

counter[function] += 1                                  ← 呼び出しごとに1加算
if counter[function] >= threshold_baseline:
    baseline code としてコンパイル

backedge_counter[loop] += 1                             ← バックエッジごとに1加算
if backedge_counter[loop] >= threshold_optimized:
    最適化コンパイラへ移行

閾値は動的に調整される(V8のTurboFanは起動フェーズで閾値を緩め、コールドスタートを高速化し、定常状態に入ると閾値を下げ、ホットスポットをより早く最適化する)。

インラインキャッシング:多相呼び出しの高速パス

JITの最も中核的な最適化の一つで、「同じ obj.method() 呼び出しが1000回行われ、毎回同じ型である」といったシナリオを対象とする:

通常パス vs インラインキャッシュ(IC)パス

通常パス (ICなし) objの型を検索 型テーブルでmethodのオフセットを検索 オフセットへジャンプ

インラインキャッシュパス (ICあり) obj.type == prev_type ?

ヒット >95% prev_offsetのコードへ直接ジャンプ 高速パス ミス 型テーブルを検索 (低速パス slow path) prev_type を更新 prev_offset 高速パスは2〜3命令(型比較+条件分岐)のみで、ヒット率は通常 >95%——本質的には「型が前回と同じ」という推測を行う

これはコンパイラ内でのみ行われるものではない——⁠インラインキャッシュは、機械コード内のデータ+コードの組み合わせとして生成され⁠、呼び出しごとに1回の型比較+条件分岐(2〜3命令)のみを実行し、ヒット率は通常 >95% となる。高速パスがヒットした場合、この2〜3命令を1つの推測(speculation)⁠として扱う——「今回の呼び出しの型は前回と同じ」と推測する。インラインキャッシングは、多相呼び出しの典型的な推測最適化である。

推測 + ガード + デオープティマイズ:JITの独自の武器

AOTコンパイラは実行時の型分布を知らないため、保守的なコードしか生成できない。JITは「この変数は1000回中999回が int である」ことを知った後、それを推測して int として扱い、最適パス+ガードを生成できる:

guard: if (typeof(x) != INT) goto deopt_entry      ← ガード
fast_code: return x + 1                             ← 推測パス

deopt_entry:
    現在の状態をスタックフレームに保存 (on-stack replacement)
    インタプリタまたはbaseline codeへ切り替え
    インタプリタ内でこの操作を再実行

重要なメカニズムはデオープティマイズ(deoptimization)⁠である:推測が外れた場合、JITは現在の最適化フレームの状態(レジスタ、ローカル変数)を「未最適化IRがこの時点で持つべき状態」にマッピングし、インタプリタまたはbaselineへジャンプして実行を継続する必要がある。これには、コンパイラが最適化コードから未最適化コードへのマッピングテーブル⁠(デオープティマイズメタデータ)を維持することが要求される——各最適化ポイントが未最適化IRのどの位置に対応し、各最適化変数がどの未最適化変数に対応するか。

On-Stack Replacement (OSR)

デオープティマイズの特殊なケース:関数がループを実行中(10000回実行済みなど)のタイミングで最適化またはデオープティマイズがトリガーされ、⁠スタックフレームの途中で実行状態を旧バージョンから新バージョンへ移行する必要がある——これがOSRである。OSRでは、JITが「実行中のコードのスタックフレームを、ターゲットコードが期待するスタックフレームへ変換」できることが要求され、これはデオープティマイズメタデータの完全性に依存する。

コードキャッシュと断片化

JITでコンパイルされたコードは code cache に保存される。GCがオブジェクトを管理するように、code cacheも管理が必要である:

  • 容量⁠: 関数を多くコンパイルしすぎるとcode cacheが満杯になる → 冷却された(ホットでない)コンパイル済みコードを破棄(Code Cache Flush)し、インタプリタ実行またはbaselineへフォールバックする。
  • 断片化⁠: コンパイル済み関数のサイズが一定でないため、頻繁なフラッシュにより断片化が発生する → code cacheはスlabアロケータ(mallocのarenaに類似)を使用し、一般的なサイズに対して事前に分割を行う。

階層型とAOTの境界の曖昧化

現代のランタイムは純粋なJITではないものが多くある:

  • Android ART: インストール時にdexバイトコードをAOTで機械コードにコンパイル(インストール時間を利用)し、実行時はホットスポットのみをJITで補完する。
  • GraalVM Native Image: AOTでネイティブバイナリへフルコンパイル —— JITは存在しないが、⁠推測最適化⁠(クラス階層が閉じていることが分かれば、デヴァーチャライズを行う)を利用する。
  • WasmのJIT: ブラウザでWasmを実行する場合、まず高速なベースラインコンパイル(数ミリ秒)を行い、その後ホットスポットに対して最適化JIT(数十ミリ秒)を行う——JSのJITエンジンと同じアーキテクチャである。

JITとAOTの共通点は:⁠コンパイラは1つの表現から機械に近い表現への翻訳を行うという点であり、違いは「翻訳がいつ行われるか」のみである。階層型コンパイルはこの時間を複数の層に分割し、各翻訳が最も適したタイミングで行われるようにする。

トレードオフと失敗パターン

  • コンパイル時間が利益を食いつぶす⁠: 短期スクリプトは1回しか実行されないため、コンパイルがインタプリタより遅くなる → 階層型を採用し、ホットスポットのみを重コンパイルし、まずはインタプリタ実行を許容する。
  • 推測の行き過ぎ⁠: 型推測が変な入力に対して連続してミスすると → デオープティマイズ嵐(deopt → recompile → deopt のループ)—— deopt率を監視し、閾値を超えた場合はその関数の再最適化を停止する。
  • code cacheの満杯化⁠: 長時間プロセスで新しいコードを継続的にコンパイルする場合 → code cacheの上限を設定し、LRUで淘汰し、満杯時はbaseline/インタプリタへ降格させる。
  • デバッグの地獄⁠: JITの機械コードとソースコード間のマッピングは動的である — deoptによりスタックフレームが変化し、インライン展開によりコールスタックが変化する → デバッグ情報は「最適化後のコールスタック」を「ソースレベルのコールスタック」へマッピングする必要がある(JVMの -XX:+PrintDeoptimizationDetails、V8の --trace-deopt)。

参考文献

  • V8: Ignition(インタプリタ)+ Sparkplug(ベースラインJIT)+ Maglev/TurboFan(最適化JIT)— V8ブログには各層の設計ドキュメントがある
  • JVM: HotSpot C1(クライアントコンパイラ)+ C2(サーバーコンパイラ)、-XX:+PrintCompilation で階層型を観察
  • Aycock (2003): "A Brief History of Just-In-Time" — JIT初期の学術的レビュー

Keywords: JIT, just-in-time, method JIT, tracing JIT, tiered compilation, baseline compiler, optimizing compiler, hotspot, inline caching, hidden class, speculation, guard, deoptimization, on-stack replacement, OSR, deopt metadata, code cache, PGO, profile-guided optimization, AOT