為什麼派發是效能瓶頸#
VM 主迴圈一秒會執行幾億次。每個迴圈都做:
- fetch:讀下一條 opcode byte
- decode:把 byte 翻譯成「要做什麼」
- dispatch:跳到對應處理邏輯
- execute:實際做事
一個直譯器中,13 step 占整體時間 3070% ── 真正的工作(4)反而是少數。所以 dispatch 是直譯器效能的天敵。本章看四種主流 dispatch 策略,並理解為什麼 V8 的 Ignition、Lua 5.4、CPython 3.12+ 都在這層下功夫。
0. baseline:function call dispatch#
最樸素的:每個 opcode 一個函式,主迴圈用查表 + indirect call:
typedef void (*OpFn)(VM *vm);
OpFn op_table[NUM_OPS] = { op_push, op_add, op_call, ... };
while (1) {
op_table[*vm->pc++](vm);
if (vm->halted) break;
}優點:模組化、易讀。 缺點:每條 opcode 都付一次完整函式呼叫成本(push / pop frame、save registers)── 比 switch 慢 2~3 倍,幾乎沒有現代 VM 用這個。
1. switch dispatch(最常見)#
while (1) {
uint8_t op = *vm->pc++;
switch (op) {
case OP_PUSH_INT: { ... break; }
case OP_ADD: { ... break; }
case OP_RET: return;
// ...
}
}編譯器(gcc/clang)通常會把這個 switch 編成 跳躍表:
jmp *jump_table[op] ; 一條 indirect jump優點:
- 純標準 C,極可移植
- 編譯器能優化 switch 成跳躍表
- 直觀,好除錯
缺點:
- branch predictor 不友善:所有 dispatch 共用一條 indirect branch,對 CPU 來說「下一條跳哪裡」完全隨機。BTB 命中率慘
- 每次 dispatch 都跳回迴圈頂端再跳出去,路徑長
CPython 3.10 之前主要是這個。
2. computed goto / threaded dispatch#
GCC/Clang 擴充:標籤位址(label-as-value)與 goto *expr。
static void *table[] = { &&op_push, &&op_add, &&op_ret, ... };
#define DISPATCH() goto *table[*vm->pc++]
DISPATCH();
op_push: {
...
DISPATCH(); // 直接跳到下一條 opcode 的 handler
}
op_add: {
...
DISPATCH();
}
op_ret:
return;關鍵差別:每個 handler 自己做 dispatch,不必先跳回主迴圈。
效能優勢:
- 每個 opcode 後面自己有一個 indirect branch ── BTB 可以為每個 opcode 預測它的下一條最常見後繼。如 ADD 後常接 STORE,BTB 會記住這個 pattern
- 路徑短,instruction cache 友善
實測:相比 switch dispatch 通常快 20~30%。
代價:
- 不是 ISO C,依賴 GCC/Clang 擴充(MSVC 不支援)
- 迴圈消失了,整段 code 變成 goto 大網
CPython 3.11 引入 computed goto 後一次性提升了大量效能;Lua、Ruby YARV 也都用這個。
3. direct threaded code#
Forth、SmallTalk 一脈的傳統技法:bytecode 不是 byte,而是 handler 位址陣列。
傳統 bytecode: threaded code:
[0x01, 0x05, 0x02] [&op_push, 5, &op_add, &op_ret, ...]
PUSH 5 ADD 直接是函式位址dispatch 變成:
typedef void (*Handler)(VM *vm);
Handler *pc; // 指向 handler 表
#define NEXT() do { Handler h = *pc++; h(vm); } while(0)或用 GCC 擴充:
void **pc; // 每個元素是 label 位址
#define NEXT() goto **pc++優點:
- 沒有 decode 步驟(fetch 直接得到要跳哪)
- 比 computed goto 再省一次查表
缺點:
- bytecode 大小膨脹(從 1 byte 變成 8 byte 一個 handler 位址)
- 不能直接 dump 到磁碟(位址在不同程序不同)
實務:bytecode 仍以 byte 形式儲存於檔案,載入時動態翻譯成 threaded code。LuaJIT、Wren、許多 Forth 系統用這個。
4. subroutine threading / inline threading#
更進一步:handler 不是「一段程式碼 + 跳回去」,而是真實的 native function,bytecode 是「call、call、call …」連串。配合 inline cache、可以變成 baseline JIT 雛形。
實際商用 VM 如 V8 Baseline 走這個方向:每個 bytecode 對應一段預先編好的 native code stub。
5. JIT#
Dispatch 完全消失 ── bytecode 直接編成原生機器碼,CPU 一條一條執行真實指令。第十一章專講。
效能對比(粗略)#
function call table: ── 1.0x (baseline,最慢)
switch dispatch: ── 3~4x
computed goto: ── 4~5x
direct threading: ── 5~6x
baseline JIT: ── 10~30x
optimizing JIT: ── 50~200x具體倍數高度依 benchmark、語言、CPU 微架構。但順序大致不會反轉。
為什麼 Branch Prediction 這麼重要#
現代 CPU pipeline 深(1520 階段)。一個錯誤預測 = flush pipeline = 浪費 1020 cycle。VM dispatch loop 的 indirect branch 如果命中率低,效能損失極為慘重。
舉例:手寫迴圈 for (i=0; i<N; i++) 在 CPython 3.10 大致是這樣執行:
LOAD_FAST i
LOAD_CONST N
COMPARE_OP <
POP_JUMP_IF_FALSE end
... loop body ...
JUMP_ABSOLUTE start每次迴圈都跑這六條 opcode。switch dispatch 下,CPU 看到的 indirect branch 模式是:
jmp ?, jmp ?, jmp ?, jmp ?, ..., jmp ?, jmp ?, jmp ?, jmp ?所有 jump 共用同一個 indirect branch 指令 ➡️ BTB 只能記一個目標 ➡️ 命中率約 1/6。
computed goto 下:
jmp ?_LOAD_FAST, jmp ?_LOAD_CONST, jmp ?_COMPARE, ..., jmp ?_POP_JUMP, jmp ?_JUMP_ABS每個 opcode handler 有自己的 indirect branch ➡️ BTB 為每個分別記 ➡️ 命中率接近 100%(因為迴圈裡 LOAD_FAST 後幾乎一定接 LOAD_CONST)。
編碼方式:定長 vs 變長#
bytecode opcode 編碼也影響 dispatch 速度:
| 方式 | 例子 | 優缺點 |
|---|---|---|
| 定長 1 byte | JVM、CPython(3.10 後) | fetch 簡單,opcode 上限 256 |
| 定長 4 byte | Dalvik、Lua 5.x 等 register VM | 一次讀 32-bit 指令,含運算元 |
| 變長 | Wasm、x86 機器碼 | 編碼緊湊,fetch 較複雜 |
引數編碼#
立即引數(如 jump offset、constant index)有兩種:
方案 A:opcode 與 args 緊鄰
PUSH_INT <4 byte int> <next opcode>
方案 B:對齊(每條 opcode 固定 size,args 放固定位)
[opcode][a][b][c] ← 32-bit instruction word
Lua 5: A B C 三個 register / immediate 場方案 B 對 register VM 是事實標準(Lua、mruby)。因為 register VM 大部分 opcode 都需要 3 個運算元。
進階話題:superinstruction#
把連續高頻 opcode 合併:
LOAD_LOCAL n
LOAD_LOCAL m
ADD
STORE_LOCAL k合成單一 superinstruction:
ADD_LOCALS k, n, m ;; 一次 dispatch 做完少了三次 dispatch、解碼、stack push/pop。CPython 3.11 引入的 “specialization”(PEP 659 / adaptive interpreter)就是動態做這件事 ── 觀察熱路徑後把通用 opcode 換成特化版本。
範例:一條 ADD 在不同策略下#
假設 stack VM 的 ADD 做:pop b、pop a、push a+b。
switch dispatch#
case OP_ADD: {
Value b = *--sp;
Value a = *--sp;
if (IS_INT(a) && IS_INT(b)) {
*sp++ = INT_VAL(AS_INT(a) + AS_INT(b));
} else {
*sp++ = generic_add(a, b);
}
break;
}computed goto#
op_add: {
Value b = *--sp;
Value a = *--sp;
if (IS_INT(a) && IS_INT(b))
*sp++ = INT_VAL(AS_INT(a) + AS_INT(b));
else
*sp++ = generic_add(a, b);
DISPATCH(); // ← 此處跳下一條 opcode handler
}CPython 3.12 specialized#
CPython 觀察到迴圈中 ADD 永遠是 int + int,把 opcode 替換成 BINARY_OP_ADD_INT:
op_add_int: {
Value b = *--sp;
Value a = *--sp;
if (UNLIKELY(!IS_INT(a) || !IS_INT(b))) {
DEOPT(); // 觀察到非預期型別 → 退回通用版本
}
*sp++ = INT_VAL(AS_INT(a) + AS_INT(b));
DISPATCH();
}省去型別檢查與分支 ── 直譯器也可以做型別特化。
小結#
- Dispatch 是直譯器最關鍵的微觀效能點
- 主流梯子:switch ➡️ computed goto ➡️ direct threading ➡️ JIT
- 效能差異主要來自 branch prediction 與 記憶體延遲
- 現代 VM 趨勢:在直譯器層做 specialization(CPython 3.11+)、threaded code(LuaJIT)、baseline JIT(V8、JSC)
下次看到某個 dispatch 迴圈寫得很「奇怪」 ── 它在跟 CPU 的 branch predictor 對話。