為什麼 JIT 還沒被 AOT 取代#
如果動態語言能編成原生碼跑得跟 C 一樣快、為什麼還用 VM?關鍵:動態語言的型別到執行期才知。AOT 編譯器看不到。
JIT 的核心優勢:
- 執行期觀察到實際型別、callsite 模式、熱路徑
- 用觀察結果做特化(specialization)
- 觀察錯了再 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#
| 層 | 全名 | 性質 |
|---|---|---|
| LLInt | Low-Level Interpreter | bytecode 直譯器(asm 寫成) |
| Baseline | Baseline JIT | 機械翻譯 |
| DFG | Data-Flow Graph JIT | 中量優化、IC 內聯 |
| FTL | Faster 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...
]實作要點:
- 配可寫可執行記憶體(mmap PROT_RW,編完改 PROT_RX)
- opcode 對應的 stub 可以手寫 asm 表,runtime 直接複製拼接
- 不做任何優化 ── 每條 opcode 一段固定 stub
- 效能仍可能比直譯器快 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 怎麼設計取捨。