為什麼 JIT 還沒被 AOT 取代#

如果動態語言能編成原生碼跑得跟 C 一樣快、為什麼還用 VM?關鍵:動態語言的型別到執行期才知。AOT 編譯器看不到。

JIT 的核心優勢:

  1. 執行期觀察到實際型別、callsite 模式、熱路徑
  2. 用觀察結果做特化(specialization)
  3. 觀察錯了再 deopt 退回直譯器

這是 JIT 與 AOT 的根本差異。AOT 必須處理所有可能型別 ➡️ 通用程式碼。JIT 可以樂觀地假設,賭錯了再回去。

Tiered execution#

實務上的 JIT 不是「直譯 vs JIT」二選一,而是多層

+──────────────+       +──────────────+
| Interpreter  | →→→→→ | Baseline JIT | →→→→→ | Optimizing JIT |
+──────────────+       +──────────────+       +────────────────+
   啟動快                 中速                    最快
   收集 profile           少量優化                積極推測 + deopt
  • 第一次跑用直譯(啟動成本低)
  • 跑了幾百次 ➡️ baseline JIT(簡單機械翻譯,快但無深度優化)
  • 跑了幾萬次 ➡️ optimizing JIT(耗時但生成接近原生程式碼)
  • 推測錯誤 ➡️ deopt 回到下一層

V8、HotSpot、JSC、CRuby YJIT 都是這個模型。

Method JIT vs Tracing JIT#

兩條主流路線。

Method JIT#

把整個 method 編成原生碼。HotSpot、V8 TurboFan、JSC FTL、.NET RyuJIT 都是。

某個熱 method
    ↓
Bytecode → IR → 優化 → 原生機器碼

Tracing JIT#

不以 method 為單位,而是 trace ── 一條實際走過的執行路徑:

trace = [op1 → op2 → op3 → branch_taken → op4 → op5 → loop_head]

把這條 trace 編成原生碼。如果執行偏離(branch 走另一邊),跳回直譯器。

代表:LuaJIT、PyPy、SpiderMonkey TraceMonkey(早期)。

優點:

  • 跨 method 邊界優化(inline 一條長路徑)
  • 對長執行的迴圈極有效

缺點:

  • code 多樣性高時,trace 多 ➡️ memory 消耗大
  • branchy code 的命中率差

一個 Method JIT 的內部流程#

源 bytecode: [LOAD_LOCAL, ADD, STORE_LOCAL, ...]
       │
       ▼
       SSA 形式 IR
       │
       ▼
   Type inference / specialization
       │
       ▼
   Inline expansion(method body 內聯)
       │
       ▼
   標準優化(DCE、常量摺疊、CSE、licm)
       │
       ▼
   Register allocation
       │
       ▼
   Code emission(x86 / ARM machine code)
       │
       ▼
   Patch back into VM dispatch table

每個階段都有大量論文與實作細節。重點概念:

Inline cache ➡️ IC stub ➡️ 機器碼#

第九章的 inline cache 在 JIT 化後變成「條件式 inline 程式碼」:

;; baseline JIT 對 obj.x 的編譯結果(概念)
mov eax, [rdi + offsetof(obj, shape)]
cmp eax, [rip + cached_shape_X]      ;; 預期 shape
jne slow_path                         ;; deopt
mov eax, [rdi + 16]                   ;; 直接 load 那個 slot

四條指令,比直譯器的 hash lookup 快幾百倍。

Speculation 與 Deoptimization#

JIT 樂觀假設:

  • 「這個 callsite 永遠收 Array」
  • 「這個 var 永遠是 int32」
  • 「這個 class 永遠不會被 monkey-patch」

每個假設都附一個 guard

guard_class(receiver, Array)     ;; 如果不是 Array,跳 deopt
guard_int32(value)
guard_class_unchanged(MyClass)

guard 失敗 ➡️ 把當前 native 狀態映射回 bytecode 狀態(deopt)➡️ 從直譯器繼續執行。

deopt 是 JIT 最複雜的部分:必須在編譯時記錄「在這個 native instruction 對應的 bytecode pc、active local 在哪些 register」── 才能在 deopt 時重建直譯器 state。

實際 V8 的層級#

LiftIgnitionInterpreter   ← bytecode interpreter(與 Ignition 同義)
Sparkplug                 ← non-optimizing baseline(極快編譯)
Maglev                    ← intermediate optimizing
TurboFan                  ← top-tier optimizing

每一層都有自己的 IR、優化階段、deopt 路徑。橫跨 ~50 萬行 C++。

JSC(Safari)的 LLInt-Baseline-DFG-FTL#

全名性質
LLIntLow-Level Interpreterbytecode 直譯器(asm 寫成)
BaselineBaseline JIT機械翻譯
DFGData-Flow Graph JIT中量優化、IC 內聯
FTLFaster than Light(用 LLVM)重量優化

四層之間自由 OSR(On-Stack Replacement)── 跑著跑著從某層跳上下層。

On-Stack Replacement(OSR)#

用例:

function fib(n) {
  let a = 0,
    b = 1;
  for (let i = 0; i < n; i++) {
    // ← 跑久了被 JIT 偵測為熱
    const t = a + b;
    a = b;
    b = t;
  }
  return a;
}
fib(10000000);

迴圈跑了一半,JIT 編好了 ── 但這個 frame 已經在直譯器跑了。OSR 把 frame 從直譯器狀態安全切換到 JIT 編譯的程式碼,從同一個位置繼續跑。技術細節極繁。

Profile-Guided Optimization#

JIT 不只看「型別」,還看「分支機率」:

if (cond)
    A();
else
    B();

profile 顯示 cond 99% 為 true ➡️ 把 A() 放 fall-through path、把 B() 放冷區域 ➡️ 改善 instruction cache、branch prediction。

CPU 自己也有 branch predictor,但 layout 對 i-cache 友善是 hot/cold 分離的真實收益。

Inlining 的價值#

function add(a, b) {
  return a + b;
}

for (let i = 0; i < 1e6; i++) {
  sum += add(i, 1);
}

直譯器:每次 call add 都付 dispatch + frame setup 開銷。 JIT inline 後:等同寫 sum += i + 1; ── 同樣的代碼,沒有函式呼叫開銷。

inline 是 JIT 最重要的單一優化。一個好的 inliner 能把連續 4~5 層深的小 method 全部攤平成一段直線程式碼。

為什麼 JIT 不適合所有場景#

代價:

  • 編譯時間:optimizing JIT 編一個 method 可能毫秒級,對短跑 program 弊大於利
  • 記憶體:要存 native code、profile data、deopt info
  • 攻擊面:可寫 + 可執行的 page 是 exploit 寶地
  • 不可預測:tier-up、deopt 都是黑盒,效能難 tune

不適合:

  • 短跑 CLI 工具(啟動時間敏感)
  • 嵌入式(記憶體不夠)
  • 安全敏感環境(不允許 W^X 違反)
  • 簡單 batch script

對應方案:AOT 編譯(Java GraalVM Native Image、.NET AOT、Dart)── 預先編譯,犧牲動態優化但獲得即時啟動。

Wasm 的特殊位置#

WebAssembly 介於 JIT 與 AOT 之間:

  • 是「靜態型別 bytecode」── 沒有動態派發、沒有逃逸 boxing
  • 瀏覽器的 Wasm runtime 用 baseline + tiered JIT 跑,但無需 deopt(型別固定)
  • 啟動快、執行接近原生

對「想要 JIT 效能但不要 JIT 複雜度」的場景,Wasm 越來越成為打包格式。

對嵌入式的意義#

JIT 在 MCU 上幾乎不可行:

  • ROM 受限:不能放 JIT 編譯器(幾百 KB ~ MB)
  • RAM 受限:不能存 native code buffer
  • 缺 W+X 控制:硬體可能不支援動態 code 載入

所以 mruby/c、MicroPython 都選擇 純直譯。對 MCU 級的 workload,直譯也夠用。

看現代 VM 的 JIT 結構#

研究 VM 效能時建議讀:

  • HotSpot:JVM 最成熟,有大量論文
  • V8:blog post 細節豐富(v8.dev)
  • PyPy:tracing JIT 教學典範(rpython)
  • LuaJIT:Mike Pall 個人作,極簡又極快
  • JSC:開源,是現代多層 JIT 的代表
  • YJIT:CRuby 的 JIT,Shopify 做的,較新

每個都有不同切角的 trade-off。

自己寫一個 baseline JIT 多難#

最簡單的 baseline JIT 把每個 bytecode opcode 翻成幾條 x86 / ARM machine code,串起來:

原 bytecode: [PUSH_INT 5, ADD, RETURN]
        ↓
原生碼: [
   mov rax, 5
   push rax
   pop rax
   pop rbx
   add rax, rbx
   push rax
   ; return path...
]

實作要點:

  1. 配可寫可執行記憶體(mmap PROT_RW,編完改 PROT_RX)
  2. opcode 對應的 stub 可以手寫 asm 表,runtime 直接複製拼接
  3. 不做任何優化 ── 每條 opcode 一段固定 stub
  4. 效能仍可能比直譯器快 2~3x

「baseline JIT 看似複雜,其實核心就是『串 stub』」。但要進入 optimizing 等級,IR + dataflow + register allocation + deopt 全套,工程量是另一個量級。

小結#

  • JIT 在動態語言中不可或缺,補上 AOT 看不到的型別資訊
  • 主流是 tiered execution:直譯 ➡️ baseline ➡️ optimizing
  • 兩派:method JIT(主流)vs tracing JIT(PyPy、LuaJIT)
  • 核心技術:speculation + guard + deopt
  • inline 是效能金鑰
  • baseline JIT 容易做、optimizing JIT 是 PhD-thesis 級工程

讀懂這章,看 V8 / HotSpot 的 blog 不會再卡關。寫一個可用的 JIT 不是周末專案,但寫一個能跑通的概念 demo周末足夠。

下章看 JIT 為什麼在 MCU 上行不通,以及嵌入式 VM 怎麼設計取捨。