featured.svg

前陣子翻閱 Python 3.15 的 What’s New 文件時,官方安裝檔的一項底層變更引起了我的注意:在官方 64-bit Windows 安裝檔中,預設正式啟用了 Tail-Call Interpreter(尾呼叫直譯器)。

社群裡不少人的第一反應是:「哇!Python 終於支援尾遞迴最佳化(Tail Call Optimization, TCO)了嗎?以後寫深度遞迴不用再怕 RecursionError 了?」

很可惜,答案是完全沒有。

如果你在 Python 3.15 裡寫了一個沒有終止條件的遞迴函式,它依然會在跑完第一千層時準時噴出熟悉的 RecursionError: maximum recursion depth exceeded。Python 之父 Guido van Rossum 與核心開發團隊過去多次明確表示,為了保留完整的呼叫堆疊資訊(traceback)與除錯能力,Python 不會採用語言層級的尾遞迴消除。

那麼,官方所說的 Tail-Call Interpreter 究竟是什麼?

它不是提供給一般開發者的語言語法特性,而是 CPython 虛擬機核心在執行位元組碼(bytecode)時,在指令調度(opcode dispatch)架構上的一場精妙重構。這項機制最初在 Python 3.14 以選用編譯參數(--with-tail-call-interp)亮相,到了 Python 3.15 則正式成為官方 Windows 64-bit 二進位發行版的預設架構。

這篇文章我們就來拆解:直譯器核心是如何執行位元組碼的?從經典的 switch-case 迴圈到 computed goto,再到現在的 tail-call interpreter,底層發生了什麼改變?接著,我們會用 Rust 親手刻出兩套直譯器,透過真實的 A/B 效能評測與組合語言反組譯,看看尾呼叫調度究竟是憑藉哪些硬體機制跑出 15% 到 20% 的效能提升。

什麼是直譯器的指令調度(Opcode Dispatch)?

要理解尾呼叫直譯器,首先得先看看所有虛擬機的心臟地帶。

當我們把 Python 原始碼編譯成位元組碼後,程式會變成一連串緊湊的指令流。虛擬機的主要工作,就是一個無止境的調度迴圈:讀取下一個 opcode、執行它對應的邏輯、再讀取下一個。

在 CPython 歷史與大多數入門直譯器中,最直觀的寫法是 Switch Dispatch:

// 傳統的 switch 調度迴圈
while (true) {
    switch (*pc++) {
        case OP_ADD:
            acc += *pc++;
            break;
        case OP_SUB:
            acc -= *pc++;
            break;
        case OP_HALT:
            return acc;
    }
}

這段程式碼看起來非常單純,但在現代超純量處理器(superscalar CPU)的硬體管線上,這樣的設計存在兩個嚴重的效能瓶頸:

switch-dispatch.svg

每執行一條 bytecode,CPU 至少得承擔兩次跳躍:

  1. 跳到目標 opcode:從中央 switch 跳躍表發起的間接跳躍(indirect branch)。
  2. 跳回迴圈頂端:opcode 處理完後踩到 break,無條件跳回 while 迴圈開頭。

更致命的是硬體分支預測器(branch predictor)的負擔。在中央 switch 處,只有唯一一個間接跳躍指令位址。不管當前執行的位元組碼是加法、乘法還是屬性存取,全都必須通過這同一個跳躍點。現代處理器的分支目標緩衝區(Branch Target Buffer, BTB)很難在單一站點記錄高動態的跳躍規律,導致間接分支預測失誤(branch misprediction)頻繁發生。每次預測失誤,CPU 都得清空管線(pipeline flush),白白浪費 15 到 20 個時脈週期。

從 Direct-Threaded Code 到編譯器的極限

為了解決中央跳躍點的擁擠問題,直譯器領域多年前就發展出了 Direct-Threaded Code(直通線程代碼,常利用 C 語言的 computed goto 實現):

// GCC / Clang 的 Labels as Values 擴充
static void* dispatch_table[] = { &&do_add, &&do_sub, &&do_halt };

#define DISPATCH() goto *dispatch_table[*pc++]

do_add:
    acc += *pc++;
    DISPATCH();

do_sub:
    acc -= *pc++;
    DISPATCH();

do_halt:
    return acc;

在 direct-threaded code 裡,不再有中央的 while 迴圈與 switch。每個 opcode 執行完後,直接抓取下一個 opcode 的位址並就地跳躍。

這個做法把間接跳躍點分散到各個 opcode 的末端。例如,do_add 末尾有一個專屬的 goto *,do_sub 末尾也有自己的 goto *。硬體分支預測器可以分別記錄不同 opcode 之後通常會接續哪些指令,分支預測命中率因此顯著上升。

自 2010 年前後起,CPython 在 Unix 平台(使用 GCC/Clang 編譯)就一直預設開啟 computed goto。但這個方案有兩個難以克服的痛點:

  • 非標準語法與平台碎片化:Labels as values 是 GCC/Clang 的擴充功能,微軟的 MSVC 編譯器長期以來完全不支援。這意味著近二十年間,Windows 平台上的 Python 官方安裝檔只能退回最慢的 switch 迴圈!
  • 編譯器最佳化的惡夢:CPython 的執行核心 ceval.c(連同內嵌由 Python/bytecodes.c DSL 產生的 generated_cases.c.h)超過五千行,核心評估函式 _PyEval_EvalFrameDefault 本身也有數千行。當一個函式裡塞入幾百個互相 goto 的標籤時,編譯器的暫存器分配器(register allocator)會面臨嚴重的溢出(register spilling)。現代 LLVM/GCC 很難對這種錯綜複雜的控制流程圖(Control Flow Graph, CFG)進行激進的就地最佳化。

這就是為什麼 Faster CPython 團隊(包括 Mark Shannon、Brandt Bucher、Ken Jin 等人)開始探索新的調度架構:Tail-Call Interpreter。

Tail-Call Interpreter 的運作機制

尾呼叫直譯器的核心概念非常純粹:將每一個 opcode 拆成一個獨立、專門的小函式,函式的最後一行直接尾呼叫(tail call)下一個 opcode 函式。

在現代 C 語言中(仰賴 Clang 與 MSVC 提供的屬性):

typedef void (*OpHandler)(State* state, const uint8_t* pc, int64_t acc);

void op_add(State* state, const uint8_t* pc, int64_t acc) {
    acc += *pc++;
    uint8_t next_op = *pc;
    [[clang::musttail]] return state->table[next_op](state, pc, acc);
}

void op_sub(State* state, const uint8_t* pc, int64_t acc) {
    acc -= *pc++;
    uint8_t next_op = *pc;
    [[clang::musttail]] return state->table[next_op](state, pc, acc);
}

這個結構看起來像是相互遞迴,直覺上會讓人擔心:「每執行一個指令就呼叫一個函式,呼叫堆疊(stack frame)不是幾千層就爆掉了嗎?」

這正是關鍵所在:musttail 屬性會強制編譯器保證進行尾呼叫消除 (tail call elimination, TCE)。

當函式在 return 時直接呼叫另一個函式,且本身不再需要保留任何本地變數時,編譯器不需要分配新的堆疊框架,也不需要發出 call 與 ret 指令。它會直接重複使用當前的堆疊框架,並將呼叫改寫成單純的跳躍指令:

jmp    rax                            ; 一條直接跳躍,堆疊深度永遠維持在 1!
tail-call-dispatch.svg

關鍵利器:preserve_none 呼叫慣例

許多人不知道的是,單純依靠 musttail 其實並不足以榨出直譯器的極致效能。在傳統 ABI 呼叫慣例(calling convention)下,跨函式呼叫依然必須維護被呼叫端保存暫存器(callee-saved registers)的序幕(prologue)與尾聲(epilogue)。

CPython 真正的關鍵突破,是結合了源自 GHC 風格的專屬呼叫慣例:__attribute__((preserve_none))。

何謂 GHC 風格?

這裡提到的 GHC,指的是純函數式程式語言 Haskell 最知名的旗艦編譯器——Glasgow Haskell Compiler。

在純函數式語言的世界裡,語法層面完全沒有傳統的 while 或 for 迴圈。所有的重複運算、狀態移轉與控制流程,本質上都是透過高階函式與廣泛的尾遞迴呼叫來實現。這意味著 GHC 編譯出的二進位程式,每一秒鐘都在底層進行數以百萬計的尾呼叫與跳躍。

如果 GHC 遵循標準 C 語言的呼叫慣例(例如 x86-64 上的 System V AMD64 ABI 或 Windows x64 ABI),編譯器就必須在每次呼叫時為呼叫端保留一整批 callee-saved 暫存器(如 rbx、r12–r15)。在無窮無盡的連續尾呼叫場景中,反覆把暫存器 push 到記憶體堆疊、跳躍後再 pop 還原,完全是在虛耗寶貴的 CPU 週期。

為了解決這個瓶頸,GHC 很久以前就為其內部虛擬機設計了專屬的呼叫慣例:

  • 零 callee-saved 暫存器:沒有任何暫存器需要為呼叫端保留,所有一般暫存器全數視為拋棄式的暫時暫存器(scratch registers)。
  • 狀態長駐暫存器:核心狀態(如堆疊指標、當前指令指標、累加值)可以在暫存器之間自由直通傳遞,跳躍時不需要進出記憶體堆疊。

這項設計在編譯器領域極為著名,甚至十多年前 LLVM 就特別為 GHC 內建了專屬呼叫慣例代碼 ghccc(GHC Calling Convention)。

將 GHC 哲學帶入 CPython

傳統上,這種高度客製化的呼叫慣例只存在於 GHC 等少數編譯器內部。但在位元組碼直譯器的場景中,問題本質其實與 GHC 如出一轍:每一個 opcode handler 執行完畢後,只會直接尾呼叫下一個 opcode handler,根本沒有返回原本呼叫端的需求。

現代 Clang 所引入的 __attribute__((preserve_none)),本質上就是將 GHC 這種「零保留負擔」的設計哲學通用化引進 C 語言體系。

當我們為 opcode handler 加上這個屬性時,等於明確告訴編譯器「被呼叫的函式不需要為呼叫端保留任何一般暫存器」。這讓虛擬機能將當前評估堆疊指標(stack pointer)、位元組碼指標(program counter)與累加狀態長駐在 CPU 暫存器中,跳躍時不需要反覆在記憶體堆疊上存取與還原,大幅釋放了暫存器分配器(register allocator)的自由度,才真正實現了與 musttail 完美搭配的極致調度效能。

跨平台編譯器支援:Clang 與 MSVC 18

在編譯器支援層面上:

  • [[clang::musttail]] 屬性早在 Clang 13(2021 年)就已支援;而 CPython 的尾呼叫直譯器之所以在規格上指定 Clang 19+,是因為結合了 preserve_none 慣例與針對 tail call 的深度優化。
  • 在 Windows 平台,微軟在 MSVC 18(Visual Studio 2026)中正式引入了 [[msvc::musttail]](見 CPython 相關 issue gh-143068 與 gh-139922)。這讓 Python 官方 Windows 64-bit 發行版在 Python 3.15 終於能原生啟用尾呼叫直譯器,徹底告別長年落後的 switch 迴圈!

Nelson Elhage 的效能排錯與真實數據

在 Python 3.14 開發初期,社群首次測試 --with-tail-call-interp 時,曾傳出「效能暴增 10% 到 15%」的驚人數據。

知名系統工程師 Nelson Elhage 在其部落格文章中深入分析反組譯與編譯器行為後,揭開了有趣的真相:這項驚人的數據,很大一部分是因為當時主流的 LLVM 19 出現了針對 computed goto 的效能衰退(regression),導致作為對照組的基準直譯器異常變慢,才反襯出尾呼叫直譯器的巨大優勢。

當基準線修正回正常的編譯器設定後:

  • 在原本就有 computed goto 的 Linux/macOS 環境中,尾呼叫帶來的純效能增益大約是穩健的 1% 到 5%。
  • 但在原本只能跑 switch 迴圈的 Windows x86-64 平台,官方 What’s New 數據顯示,在 Visual Studio 18.1.1 編譯下獲得了高達 15% 到 20% 的幾何平均吞吐量躍升(pyperformance geomean)!

用 Rust 親手實作兩套直譯器

理論說得再漂亮,不如親手實作並觀察底層指令。我們用現代系統語言 Rust 來實作一個精簡的虛擬機,並同時打造 Switch Dispatch 與 Tail-Call Dispatch 兩個版本進行 A/B 評測。

指令集定義

我們定義一組包含算術、位元運算與控制流的典型指令:

#[repr(C)]
#[derive(Copy, Clone, Debug)]
pub struct Instruction {
    pub op: u32,
    pub arg: i32,
}

pub const OP_ADD: u32 = 0;
pub const OP_SUB: u32 = 1;
pub const OP_MUL: u32 = 2;
pub const OP_XOR: u32 = 3;
pub const OP_INC: u32 = 4;
pub const OP_DEC: u32 = 5;
pub const OP_HALT: u32 = 6;

對照組:經典 Switch 直譯器

首先是大家最熟悉的中央迴圈版本:

#[inline(never)]
pub fn run_switch(code: &[Instruction], mut acc: i64) -> i64 {
    let mut pc = 0;
    loop {
        let instr = unsafe { *code.get_unchecked(pc) };
        match instr.op {
            OP_ADD => {
                acc = acc.wrapping_add(instr.arg as i64);
            }
            OP_SUB => {
                acc = acc.wrapping_sub(instr.arg as i64);
            }
            OP_MUL => {
                acc = acc.wrapping_mul(instr.arg as i64);
            }
            OP_XOR => {
                acc ^= instr.arg as i64;
            }
            OP_INC => {
                acc = acc.wrapping_add(1);
            }
            OP_DEC => {
                acc = acc.wrapping_sub(1);
            }
            OP_HALT => {
                break;
            }
            _ => unreachable!(),
        }
        pc += 1;
    }
    acc
}

實驗組:Tail-Call 直譯器

在 Tail-Call 版本中,我們定義一個函式指標簽名 OpHandler。每個指令都是一個獨立函式,參數透過暫存器傳遞,最後一行抓出下一個指令並直接尾呼叫:

pub struct VmContext {
    pub table: [OpHandler; 16],
}

pub type OpHandler = unsafe fn(ctx: &VmContext, pc: *const Instruction, acc: i64) -> i64;

#[inline(never)]
pub unsafe fn op_add(ctx: &VmContext, pc: *const Instruction, acc: i64) -> i64 {
    let next_acc = acc.wrapping_add((*pc).arg as i64);
    let next_pc = pc.add(1);
    let next_op = (*next_pc).op as usize;
    let next_fn = *ctx.table.get_unchecked(next_op);
    next_fn(ctx, next_pc, next_acc)
}

#[inline(never)]
pub unsafe fn op_sub(ctx: &VmContext, pc: *const Instruction, acc: i64) -> i64 {
    let next_acc = acc.wrapping_sub((*pc).arg as i64);
    let next_pc = pc.add(1);
    let next_op = (*next_pc).op as usize;
    let next_fn = *ctx.table.get_unchecked(next_op);
    next_fn(ctx, next_pc, next_acc)
}

#[inline(never)]
pub unsafe fn op_mul(ctx: &VmContext, pc: *const Instruction, acc: i64) -> i64 {
    let next_acc = acc.wrapping_mul((*pc).arg as i64);
    let next_pc = pc.add(1);
    let next_op = (*next_pc).op as usize;
    let next_fn = *ctx.table.get_unchecked(next_op);
    next_fn(ctx, next_pc, next_acc)
}

#[inline(never)]
pub unsafe fn op_xor(ctx: &VmContext, pc: *const Instruction, acc: i64) -> i64 {
    let next_acc = acc ^ ((*pc).arg as i64);
    let next_pc = pc.add(1);
    let next_op = (*next_pc).op as usize;
    let next_fn = *ctx.table.get_unchecked(next_op);
    next_fn(ctx, next_pc, next_acc)
}

#[inline(never)]
pub unsafe fn op_inc(ctx: &VmContext, pc: *const Instruction, acc: i64) -> i64 {
    let next_acc = acc.wrapping_add(1);
    let next_pc = pc.add(1);
    let next_op = (*next_pc).op as usize;
    let next_fn = *ctx.table.get_unchecked(next_op);
    next_fn(ctx, next_pc, next_acc)
}

#[inline(never)]
pub unsafe fn op_dec(ctx: &VmContext, pc: *const Instruction, acc: i64) -> i64 {
    let next_acc = acc.wrapping_sub(1);
    let next_pc = pc.add(1);
    let next_op = (*next_pc).op as usize;
    let next_fn = *ctx.table.get_unchecked(next_op);
    next_fn(ctx, next_pc, next_acc)
}

#[inline(never)]
pub unsafe fn op_halt(_ctx: &VmContext, _pc: *const Instruction, acc: i64) -> i64 {
    acc
}

static TABLE: [OpHandler; 16] = {
    let mut t: [OpHandler; 16] = [op_unreachable; 16];
    t[OP_ADD as usize] = op_add;
    t[OP_SUB as usize] = op_sub;
    t[OP_MUL as usize] = op_mul;
    t[OP_XOR as usize] = op_xor;
    t[OP_INC as usize] = op_inc;
    t[OP_DEC as usize] = op_dec;
    t[OP_HALT as usize] = op_halt;
    t
};

pub unsafe fn op_unreachable(_ctx: &VmContext, _pc: *const Instruction, _acc: i64) -> i64 {
    unreachable!()
}

#[inline(never)]
pub fn run_tail_call(code: &[Instruction], acc: i64) -> i64 {
    let ctx = VmContext { table: TABLE };
    let pc = code.as_ptr();
    unsafe {
        let first_op = (*pc).op as usize;
        let first_fn = *ctx.table.get_unchecked(first_op);
        first_fn(&ctx, pc, acc)
    }
}
 Note

在 Rust 穩定的編譯器版本中,當函式末端的型別簽名與呼叫慣例完全契合時,LLVM 後端會自動將其最佳化為單一跳躍指令。在 Rust 的語言演進中,亦有專屬的 become 關鍵字提案(RFC 3407 / #![feature(explicit_tail_calls)],追蹤於 issue #112788)正在推進,旨在讓開發者能在語言層級強制要求編譯器進行保證消除。

組合語言對決:為什麼尾呼叫能勝出?

我們使用 objdump -d -M intel 將編譯出來的機器碼反組譯,答案立刻浮現眼前。

對照組:run_switch 的反組譯機器碼

; 中央 switch 迴圈的頭部 (位址 16fd0)
.loop_head:
    mov    r8d, DWORD PTR [rdi+rax*8]     ; [16fd0] 讀取 code[pc].op
    cmp    r8, 0x6                        ; [16fd4] 邊界檢查
    ja     .out_of_bounds                 ; [16fd8] 超出範圍跳出 (1701e)
    movsxd rsi, DWORD PTR [rdi+rax*8+0x4] ; [16fda] 讀取 code[pc].arg
    movsxd r8, DWORD PTR [rcx+r8*4]       ; [16fdf] 查詢跳躍表
    add    r8, rcx                        ; [16fe3]
    jmp    r8                             ; [16fe6] 【跳躍 1】間接跳躍到 handler

; OP_ADD 處理區塊 (位址 16fe9)
.op_add:
    add    rdx, rsi                       ; [16fe9] acc += arg
    inc    rax                            ; [16fec] pc++
    jmp    .loop_head                     ; [16fef] 【跳躍 2】無條件跳回迴圈頭部 (16fd0)!

看見問題了嗎? 每執行一個指令,CPU 必須:

  1. 執行運算並增加 pc。
  2. 執行一次無條件跳躍 jmp .loop_head(位址 16fd0)回到迴圈頭部。
  3. 執行 cmp r8, 0x6 與條件分支 ja 進行保護性檢查。
  4. 查表後執行間接跳躍 jmp r8 進入下一個 opcode。

實驗組:op_add(Tail Call)的反組譯機器碼

再來看看 Tail Call 版本的 op_add:

op_add:
    movsxd rax, DWORD PTR [rsi+0x4]       ; [18320] 讀取當前 arg
    add    rdx, rax                       ; [18324] acc += arg
    mov    eax, DWORD PTR [rsi+0x8]       ; [18327] 預先讀取下一個指令的 op
    add    rsi, 0x8                       ; [1832a] pc++
    jmp    QWORD PTR [rdi+rax*8]          ; [1832e] 【唯一的跳躍】直接跳進下一個 handler!

整整只有 5 條組合語言指令!

  • 沒有第二個跳躍指令:執行完運算後,直接一條 jmp 跳到下一個 handler 的位址。
  • 免除熱路徑檢查:不需要跳回迴圈頂端做 cmp/ja。
  • 零堆疊框架:沒有 call、沒有 ret、沒有 push rbp。
  • 分散的分支預測目標:op_add 的間接跳躍位址固定在 1832e,op_sub 則在 1834e,兩者各自建立獨立的分支歷史快取。

A/B 實測 Benchmark 數據

我們寫了一個基準測試程式,隨機生成包含加法、減法、乘法、XOR、遞增、遞減等混合分佈的 2,000 萬條(20,000,000)bytecode 指令序列。

為了避免編譯器 dead code elimination,我們使用 std::hint::black_box 封裝輸入,並嚴格斷言兩種直譯器跑完 2,000 萬條指令後產生的數值完全一致:

// 驗證運算結果正確性
assert_eq!(res_switch, res_tail, "Results must match!");

實測數據對比

在 x86-64 機器(Linux, Release Mode, opt-level = 3)上執行 5 次運算取平均值:

指令調度機制 2,000 萬條指令平均執行時間 指令吞吐量(Ops / sec) 效能表現
Switch Dispatch 91.09 ms 219.6 M ops/sec 基準對照組
Tail-Call Dispatch 74.86 ms 267.1 M ops/sec 快 17.81%(1.22x 吞吐量)

各輪次運算時間十分穩定:

[Switch]   Run 1: 91.25 ms | Run 2: 91.03 ms | Run 3: 90.93 ms | Run 4: 90.87 ms | Run 5: 91.38 ms
[TailCall] Run 1: 74.85 ms | Run 2: 74.82 ms | Run 3: 74.78 ms | Run 4: 74.89 ms | Run 5: 74.98 ms

在完全相同的指令流與相同的硬體環境下,Tail-Call Dispatch 跑出了整整 17.81% 的效能提升!

這 17.8% 的差距正來自我們稍早在反組譯分析中所看見的三大優勢:

  1. 指令跳躍次數減半:每條 opcode 省下了跳回迴圈頂端的一道跳躍指令。
  2. 省除熱路徑檢查:省下了每輪迴圈的邊界比對。
  3. 更友善的分支預測:處理器 BTB 分散記錄跳躍歷史,管線空泡大幅減少。

這個實測幅度,也正好落在 CPython 官方於 What’s New 中公布的 Windows(Visual Studio 18.1.1)15% 到 20% 增益區間。

 Tip

線上即時體驗與組合語言對照:本實驗的完整可重現程式碼已發布至 Compiler Explorer (Godbolt),包含兩種直譯器的完整實作與 2,000 萬次指令隨機測試。讀者可直接在瀏覽器中對照編譯後的 x86-64 組合語言,並點擊 Run 即時執行驗證。

結語與技術省思

回過頭來看 Python 3.15 的變更:

  1. Python 依然沒有尾遞迴最佳化:使用者寫的遞迴函式行為完全沒變,依舊保留最完整的 traceback 偵錯堆疊(沒有語言層級的 TCO)。
  2. Tail-Call 是直譯器核心的工程升級:它藉由把 opcode 拆分為獨立函式、依賴編譯器的尾呼叫消除與 preserve_none 慣例,在兼顧程式碼模組化與編譯器暫存器優化的同時,在硬體層級模擬出高效的分散式間接跳躍。
  3. Windows 使用者是最大受益者:長期受限於 MSVC 無法支援 computed goto 的 Windows 版 Python,透過 MSVC 18 的 [[msvc::musttail]] 終於補齊了近二十年的跳躍效能缺口。

在軟體工程的世界裡,許多看似屬於高階語言語義的名詞,換到了編譯器與硬體管線的視角,往往展現出截然不同、卻又無比精妙的底層面貌。下次看見直譯器更新日誌中的「Tail-Call」,我們就能會心一笑:它不是讓你在程式碼裡寫無窮遞迴,而是悄悄替 CPU 的分支預測器卸下了一副沉重的枷鎖。