為什麼派發是效能瓶頸#

VM 主迴圈一秒會執行幾億次。每個迴圈都做:

  1. fetch:讀下一條 opcode byte
  2. decode:把 byte 翻譯成「要做什麼」
  3. dispatch:跳到對應處理邏輯
  4. 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 byteJVM、CPython(3.10 後)fetch 簡單,opcode 上限 256
定長 4 byteDalvik、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 對話。