前陣子翻閱 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)的硬體管線上,這樣的設計存在兩個嚴重的效能瓶頸:
每執行一條 bytecode,CPU 至少得承擔兩次跳躍:
- 跳到目標 opcode:從中央
switch跳躍表發起的間接跳躍(indirect branch)。 - 跳回迴圈頂端: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.cDSL 產生的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!關鍵利器: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)
}
}在 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 必須:
- 執行運算並增加
pc。 - 執行一次無條件跳躍
jmp .loop_head(位址16fd0)回到迴圈頭部。 - 執行
cmp r8, 0x6與條件分支ja進行保護性檢查。 - 查表後執行間接跳躍
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% 的差距正來自我們稍早在反組譯分析中所看見的三大優勢:
- 指令跳躍次數減半:每條 opcode 省下了跳回迴圈頂端的一道跳躍指令。
- 省除熱路徑檢查:省下了每輪迴圈的邊界比對。
- 更友善的分支預測:處理器 BTB 分散記錄跳躍歷史,管線空泡大幅減少。
這個實測幅度,也正好落在 CPython 官方於 What’s New 中公布的 Windows(Visual Studio 18.1.1)15% 到 20% 增益區間。
線上即時體驗與組合語言對照:本實驗的完整可重現程式碼已發布至 Compiler Explorer (Godbolt),包含兩種直譯器的完整實作與 2,000 萬次指令隨機測試。讀者可直接在瀏覽器中對照編譯後的 x86-64 組合語言,並點擊 Run 即時執行驗證。
結語與技術省思
回過頭來看 Python 3.15 的變更:
- Python 依然沒有尾遞迴最佳化:使用者寫的遞迴函式行為完全沒變,依舊保留最完整的 traceback 偵錯堆疊(沒有語言層級的 TCO)。
- Tail-Call 是直譯器核心的工程升級:它藉由把 opcode 拆分為獨立函式、依賴編譯器的尾呼叫消除與
preserve_none慣例,在兼顧程式碼模組化與編譯器暫存器優化的同時,在硬體層級模擬出高效的分散式間接跳躍。 - Windows 使用者是最大受益者:長期受限於 MSVC 無法支援 computed goto 的 Windows 版 Python,透過 MSVC 18 的
[[msvc::musttail]]終於補齊了近二十年的跳躍效能缺口。
在軟體工程的世界裡,許多看似屬於高階語言語義的名詞,換到了編譯器與硬體管線的視角,往往展現出截然不同、卻又無比精妙的底層面貌。下次看見直譯器更新日誌中的「Tail-Call」,我們就能會心一笑:它不是讓你在程式碼裡寫無窮遞迴,而是悄悄替 CPU 的分支預測器卸下了一副沉重的枷鎖。