Performance of the Python 3.14 tail-call interpreter

Nelson Elhage

Python 3.14 尾呼叫直譯器的效能表現

原文由 Nelson Elhage 發布,訂閱此部落格

大約一個月前,CPython 專案合併了一項針對位元組碼直譯器的全新實作策略。最初公布的數據非常亮眼,在多種平台上、涵蓋廣泛的基準測試中,平均帶來了 10% 至 15% 的效能提升。

不過,正如我將在本文中說明的,這些亮眼的效能提升,主要其實是因為無意間繞過了 LLVM 19 中的一項回歸問題。若改以更合理的基準來比較(例如 GCC、clang-18,或是加上特定調校參數的 LLVM 19),效能增幅就會降到大約 1% 至 5%,視實際設定而定。

尾呼叫直譯器發表時,我既驚訝又佩服它的效能提升,但同時也感到困惑:我算不上專家,但對現代 CPU 硬體、編譯器與直譯器設計略有了解,卻無法解釋為何這項改動會如此有效。出於好奇——或許還有點著迷——我便花了幾個星期,斷斷續續地編譯、做基準測試並反組譯了數十個不同的 Python 執行檔,試圖弄清楚自己看到的現象,而本文就是這些研究的成果。

在文末,我會以此事件作為案例,反思基準測試、效能工程以及廣義軟體工程所面臨的一些挑戰。

我也想先說清楚,我依然認為尾呼叫直譯器是一項出色的工作,而且確實帶來了真正的加速(只是幅度比最初預期的要小一些)。我也樂觀地認為,如同我稍後會說明的,它比舊版直譯器是更穩健的做法。我也完全無意責怪 Python 團隊中的任何人。這類混淆其實非常常見——我自己就曾多次誤解基準測試的結果——文末我也會對此多談一些。

此外,在這項研究之前,LLVM 回歸問題的影響似乎並未被發現(在本文發表當時,這個錯誤尚未修復,不過後來已經修好了);因此就這個意義而言,如果沒有這項改動,使用 clang-19 或更新版本建置的版本,確實可能會慢上 10% 至 15%。舉例來說,Simon Willison 使用來自 python-build-standalone 的建置版本,在實際環境中重現了相較於 Python 3.13 約 10% 的加速。

效能測試結果

以下是我最主要的測試結果。我在兩台機器上,用多種不同的編譯器與設定選項,測試了多個 CPython 直譯器的建置版本:一台是我在 Hetzner 上維護的 Intel 伺服器(Raptor Lake i5-13500),另一台是我的 Apple M1 MacBook Air。你可以透過我的 nix 設定重現這些建置,對我來說,這是在同時處理這麼多變因時不可或缺的工具。

所有建置都啟用了 LTO 與 PGO。各組態如下:

  • clang18:使用 Clang 18.1.8 建置,採用 computed goto。
  • gcc(僅限 Intel):使用 GCC 14.2.1 建置,採用 computed goto。
  • clang19:使用 Clang 19.1.7 建置,採用 computed goto。
  • clang19.tc:使用 Clang 19.1.7 建置,採用新的尾呼叫直譯器。
  • clang19.taildup:使用 Clang 19.1.7 建置,採用 computed goto,並加上部分 -mllvm 調校參數以繞過回歸問題。

我以 clang18 作為基準,並列出 pypeformancepyperf compare_to 所報告的整體「平均值」。完整的輸出檔與報告都可以在 GitHub 上找到。

平台clang18clang19clang19.taildupclang19.tcgcc
Raptor Lake i5-13500(基準)慢 1.09 倍快 1.01 倍快 1.03 倍快 1.02 倍
Apple M1 MacBook Air(基準)慢 1.12 倍慢 1.02 倍慢 1.00 倍N/A

可以看到,尾呼叫直譯器相較於 clang-18 仍有加速效果,但幅度遠不如升級到 clang-19 所造成的效能衰退那麼顯著。在修正這個錯誤的影響後,Python 團隊在其他一些平台上也觀察到比我更大的加速幅度。

你可能會注意到,我沒有在舊版 Clang 上測試尾呼叫直譯器(也就是所謂的 clang18.tc)。尾呼叫直譯器依賴只在 Clang 19 才加入的新編譯器功能,因此無法在更早的版本上測試。我認為,這種交互關係正是讓整件事如此令人困惑的一大原因,也是我需要做這麼多測試才敢確定自己理解狀況的原因。

LLVM 的回歸問題

背景簡介

典型的位元組碼直譯器,是在一個 while 迴圈內包含一個 switch 敘述,大致長這樣:

while (true) {
  opcode_t this_op = bytecode[pc++];
  switch (this_op) {
    case OP_IMM: {
      // push an immediate onto the stack
      break;
    }
    case OP_ADD: {
      // handle the add
      break;
    }
    // etc
  }
}

多數編譯器會把 switch 編譯成跳躍表——也就是產生一張包含每個 case OP_xxx 區塊位址的表格,再用操作碼(opcode)去索引並執行間接跳躍。

長久以來大家就知道,可以透過把跳躍表分派的邏輯複製到每個操作碼本體中,來加速這類位元組碼直譯器。也就是說,與其讓每個操作碼最後都以 jmp loop_top 結束,不如讓每個操作碼都各自擁有一份「解碼下一個指令並透過跳躍表索引」的邏輯。

現代的 C 編譯器支援取得標籤(label)的位址,然後透過「computed goto」來使用這些位址,以實作這種模式。因此,許多現代的位元組碼直譯器,包括 CPython(在尾呼叫改動之前),都採用了類似以下的直譯器迴圈:

static void *opcode_table[256] = {
    [OP_IMM] = &&TARGET_IMM,
    [OP_ADD] = &&TARGET_ADD,
    // etc
};

#define DISPATCH() goto *opcode_table[bytecode[pc++]]

DISPATCH();

TARGET_IMM: {
    // push an immediate onto the stack
    DISPATCH();
}
TARGET_ADD: {
    // handle the add
    DISPATCH();
}

LLVM 中的 Computed Goto

出於效能考量(指的是編譯器本身的效能,而非產生出來的程式碼),Clang 與 LLVM 在內部實際上會把上述程式碼中的所有 goto 合併成單一個indirectbr LLVM 指令,讓每個操作碼都跳到那裡。也就是說,編譯器把我們費心安排的結構,又刻意重寫成一個控制流程圖,讓它看起來基本上和基於 switch 的直譯器沒兩樣!

接著,在程式碼產生階段,LLVM 會執行「尾端複製(tail duplication)」,把分支再複製回每個位置,恢復原本的意圖。這一來一往的過程,在一篇介紹此新實作的舊 LLVM 部落格文章中有高層次的說明。

LLVM 19 的回歸問題

之所以要先合併再複製,完全是出於技術原因:建立並處理包含大量 indirectbr 指令的控制流程圖,成本可能非常高。

為了避免在某些情況下出現災難性的編譯速度變慢(或記憶體用量暴增),LLVM 19 在尾端複製的處理階段加入了一些限制,若複製會讓 IR 的大小膨脹超過特定門檻,就會直接放棄。

不幸的是,在 CPython 上,這些限制導致 Clang 讓所有分派跳躍保持合併狀態,完全抵消了基於 computed goto 實作的初衷!這個錯誤最早由另一個採用類似直譯器迴圈的語言實作所發現,但就我所能找到的資訊而言,先前並未被發現會影響 CPython。

除了效能上的影響,我們也可以直接反組譯產生的目的碼,計算其中不同間接跳躍的數量來觀察這個錯誤:

$ objdump -S --disassemble=_PyEval_EvalFrameDefault ${clang18}/bin/python3.14 | \
  egrep -c 'jmp\s+\*'
332

$ objdump -S --disassemble=_PyEval_EvalFrameDefault ${clang19}/bin/python3.14 | \
  egrep -c 'jmp\s+\*'
3

進一步的怪異現象

我很有把握,尾端複製邏輯的改動就是造成回歸的原因:如果你修復它,效能就會回到與 clang-18 相當的水準。不過,我仍無法完全解釋這次回歸的幅度為何如此之大。

過去,將位元組碼分派複製到每個操作碼中的最佳化,曾被認為能讓直譯器加速 20% 甚至到100%。然而,在具備更強分支預測器的現代處理器上,較新的研究發現加速幅度要小得多,大約只有 2% 至 4%。

我們可以在實務上驗證這個 2% 至 4% 的數字,因為 Python 仍透過一個設定選項支援使用單一 switch 敘述的「舊式」直譯器。以下是針對該直譯器進行基準測試的結果(下表中「.nocg」代表「no computed gotos」,即不使用 computed goto):

基準測試clang18clang18.nocgclang19.nocgclang19
效能變化(基準)快 1.01 倍慢 1.02 倍慢 1.09 倍

請注意,clang19.nocg 僅比 clang18 慢 2%,儘管基本的 clang19 建置慢了 9%!我認為那個「2%」才是單純複製操作碼分派所帶來成本/效益的更合理估計,至於另一部分的差距,我仍無法完全理解。

我們還需要 Computed Goto 嗎?

我先前還沒提到 clang19.nocg 的測試結果,你可能會注意到它竟然顯示比 clang19 還快。正是在這個時候,我發現了這個故事中另一個非常有趣的轉折。

我稍早解釋過,Clang 與 LLVM:

  1. 會把 switch 編譯成跳躍表與間接跳躍,其結果與我們手動用 computed goto 建立的非常相似
  2. 會把 computed goto 編譯成一個控制流程圖,其結構非常接近只有單一分派實例的傳統 switch 圖,以及
  3. 能夠在程式碼產生階段反轉上述轉換,以複製分派邏輯

把這些事實放在一起,你可能會問:「那我們何不直接從基於 switch 的直譯器出發,讓編譯器去做尾端複製,也能得到同樣的好處呢?」

結果答案是:可以。

clang-18(或加上適當參數的 clang-19)在面對「傳統」基於 switch 的直譯器時,還是會逕自把分派邏輯複製到每個操作碼的本體中。以下是使用稍早的 objdump | grep 測試,列出相同建置版本中間接跳躍數量的表格:

基準測試clang18clang18.nocgclang19.nocgclang19
間接跳躍數量33230633

因此,可以說整個「computed goto」直譯器其實是完全不必要的複雜度(至少對現代 Clang 而言)。編譯器本身就完全有能力自行完成相同的轉換,而且(顯然)就算用了 computed goto,也無法保證一定會達到效果!

不過,我也有測試 GCC,而 GCC(至少到 14.2.1 版)並不會複製 switch,但在使用 computed goto 時確實會產生預期的行為。所以至少在那個情況下,我們看到的是符合預期的結果。

修復方式

LLVM 的第 114990 號 pull request 在本文發表後不久就合併了,並修復了這項回歸。我在合併前就對其進行了基準測試,並確認它恢復了預期的效能。

對於該修復之前的版本,造成回歸的 PR 新增了一個可調參數,用來設定尾端複製在何種門檻下會放棄。我們只要在 clang-19 上把該限制設為一個非常大的數字,就能恢復類似的行為1

反思

我得坦承,這個題目確實讓我深深著迷,鑽研得比實際需要還要深入得多。不過既然已經如此,我認為其中有不少值得汲取的經驗與反思,可以推廣到軟體工程與效能工程上,我會試著整理並探討其中幾點。

關於基準測試

在最佳化系統時,我們通常會建立一組基準測試與測試方法,然後用這些測試來評估所提出的改動。

任何一組基準測試或測試流程,都隱含(往往是隱性地)了我所說的「效能理論」。你的效能理論是一套信念與假設,用來回答像是「哪些變因(可能)以何種方式影響效能?」以及「基準測試的結果與在『實際生產環境』中的『真實』效能之間有何關聯?」這類問題。

針對尾呼叫直譯器執行的基準測試顯示,相較於舊的 computed goto 直譯器有 10% 至 15% 的加速。這些測試本身是準確的,就我所知,它們確實準確量測了那些建置版本之間的效能差異。然而,若要把這些特定的數據點,推廣為「廣義而言,尾呼叫直譯器比 computed goto 直譯器快 10% 至 15%」,甚至「尾呼叫直譯器將為使用者帶來 10% 至 15% 的 Python 加速」這樣的陳述,就需要引入更多對世界的假設與信念。在這個案例中,事實證明情況更為複雜,那些更廣泛的主張並非在所有情況下都成立。

(再次強調,我真的無意責怪 Python 開發者!這些事情非常困難,有無數種方式會讓人混淆或得出某種程度上不正確的結論。我花了大約三週密集的基準測試與實驗,才對情況有更深入的理解。我的重點是,這是一個非常普遍的挑戰!)

基準線

這個例子凸顯了另一個反覆出現的挑戰,不僅存在於軟體效能領域,也出現在許多其他領域:「你該拿什麼作為比較的基準?」

每當你為某個問題提出新的解決方案或方法,通常都會有一套執行新方法並產出相關效能指標的方式。

然而,一旦有了自己系統的指標,你還需要知道該拿它跟什麼比較,才能判斷它到底好不好!即使你在某個絕對尺度上表現不錯(假設存在一個合理的絕對評量尺度),如果你的方法比現有方案還差,那大概也沒什麼吸引力。

通常,你會想與「目前已知最好的方法」比較。但有時這並不容易做到!即使你在理論上理解現有方法,你也未必是實際應用它的專家。以軟體來說,這可能意味著要調整作業系統或編譯器選項等各種旗標。目前最好的方法或許有已發表的基準測試,但那些數據不一定對你有參考價值;例如,它可能是多年前在舊硬體上發表的,因此你無法與公開數據進行同等條件的比較。或者,他們的測試規模可能是你無法負擔重現的。

我目前在 Anthropic 從事機器學習工作,我們在機器學習論文中經常看到這種情況。當一篇論文宣稱有某種演算法改進或其他進展時,我注意到我們的研究人員最先關心的往往不是「他們做了什麼?」而是「他們拿什麼基準來比較?」如果拿一個調校不佳的基準來比較,很容易就能得到看起來很亮眼的結果,而這個觀察結果解釋了相當多所謂改進的真正原因。

關於軟體工程

對我而言,另一個深刻的體會是,我們的軟體系統是多麼複雜且彼此緊密連結,變化又是多麼快速,要掌握所有環節是多麼困難。

如果在一個月前問我,LLVM 的某個版本導致 CPython 出現 10% 效能回歸卻五個月都沒人發現的可能性有多大,我會覺得這種情況相當不可能!這兩個都是被廣泛使用的專案,而且都相當重視效能,「理應」會有人測試並發現才對。

而那個特定的情況或許確實相當罕見!然而,在有如此多不同軟體專案、各自快速演進且彼此相互依賴與被依賴的情況下,像那樣的回歸幾乎不斷在發生,幾乎是無可避免的。

最佳化編譯器

Computed goto 直譯器的這段經歷,體現了圍繞最佳化器與最佳化編譯器中反覆出現的張力與未解問題,對於這些問題,我們這個領域至今仍未有一致的答案。

我們通常期望編譯器能尊重程式設計師的意圖,並以保留其意圖的方式來編譯所撰寫的程式碼。

然而,我們同時也期望編譯器能最佳化我們的程式碼,並以可能複雜且不直觀的方式來轉換它,以讓執行速度更快。

這些期望彼此之間存在張力,而且我們缺乏足夠的模式與慣用語來向編譯器解釋,我們為何以特定方式撰寫程式碼,以及我們是否刻意想觸發某種輸出或做出某個與效能相關的決策。

我們的編譯器通常只保證會產生與我們撰寫的程式碼具有「相同行為」的程式碼;效能則是在該保證之上,某種盡力而為的附加特性。

因此,我們陷入了一個奇怪的處境:clang-19「正確地」編譯了 computed goto 直譯器——就產生出來的二進位檔會產生所有預期數值而言——但同時它產生的輸出卻完全違背了該最佳化的初衷。此外,我們也看到其他版本的編譯器對「單純」基於 switch() 的直譯器套用了最佳化,而這些最佳化恰好實作了我們原本「打算」透過重寫原始碼來達成的同樣最佳化。

事後看來,在原始碼層次的「computed goto」直譯器,與在機器碼層次「複製分派邏輯」這兩件事,最後竟成了幾乎正交的概念!我們已經看到了這個 2x2 矩陣中每一種組合的實例!由於所有那些 python 執行檔在執行時計算出相同的數值,我們現有的工具基本上無法以一致的方式來描述它們之間的差異。

這種混淆正好說明了我為何認為尾呼叫直譯器(及其背後的編譯器功能)代表了一項真正且有用的技術進展。尾呼叫直譯器是建立在 musttail 屬性之上,這是一種相對較新的編譯器功能。musttail 並不會影響編譯器傳統意義上的「可觀察程式行為」,而是與最佳化器的一種對話;它要求編譯器必須能夠進行某些最佳化,若無法達成,就必須讓編譯失敗。

我期望這個框架最終會成為撰寫效能敏感程式碼時一種更穩健的方式,尤其是在時間推移、編譯器不斷演進的過程中。我很期待看到對這類功能的持續實驗。

具體來說,我不禁在想,是否可以用類似(假想的)[[clang::musttailduplicate]] 屬性,加在直譯器的 while 迴圈上,來取代 computed goto 直譯器。我對所有相關的 IR 與處理階段還不夠熟悉,無法對這個提議有十足把握,但或許有更熟悉的人可以評估其可行性。

最後再補充一點:關於 nix

最後,我想特別提一下 nix 在這個專案中帶來了多大的幫助。過去一年左右,我一直在用 nix 與 NixOS 來實驗個人的基礎設施,而它們在這次研究中可說是徹底拯救了我。

在這些實驗過程中,我橫跨四種不同的編譯器(gccclang-18clang-19clang-20)並搭配多種編譯器旗標組合,建置並測試了數十個不同的 Python 直譯器。如果全靠手動管理,肯定會讓人崩潰,而且我確信自己一定會犯下許多錯誤,例如搞混哪個建置用了哪個編譯器與哪些旗標等等。

使用 nix,我得以清楚區分這些並行的版本,並以可重現、隔離的方式建置它們。我寫了一些簡短的抽象層,讓這些版本的定義變得非常容易,並且能完全確定 nix store 中任何一個建置的來源、使用了哪個編譯器與哪些旗標。在花了一點時間建立輔助函式後,我的建置矩陣核心定義簡潔得令人驚訝;以下是其中一小部分:

{
    base = callPackage buildPython { python3 = python313; };
    optimized = withOptimizations base;
    optLTO = withLTO optimized;

    clang18 = withLLVM llvmPackages_18 optLTO;
    clang19 = withLLVM llvmPackages_19 optLTO;
    clang20 = withLLVM llvmPackages_20 optLTO;

    clang18nozero = noZeroCallUsed clang18;
    clang18nocg = withoutCG clang18;

    clang19taildup = withTailDup clang19;
}

我甚至還建置了一個自訂版本的 LLVM(包含錯誤修復的補丁),並用該編譯器來建置 Python。而這只需要大約 10 行程式碼就能完成。

話雖如此,也並非一切都盡善盡美。首先,nix 本質上在許多方面都與「一般人」使用軟體的方式不同,顯得有點「怪異」,我擔心其中某些怪異之處可能在不自覺中影響了我的部分基準測試或結論。舉例來說,我早期就發現 nix(預設)會使用某些強化(hardening)旗標來建置專案,而這些旗標對尾呼叫直譯器的影響特別大。這個問題我已經處理了,但是否還有其他類似情況呢?

此外,Nix 具有極高的可擴充性與可客製性,但要弄清楚如何進行特定的客製化,往往是一場硬仗,需要大量嘗試、反覆試錯並深入閱讀原始碼。我的修補版 LLVM 建置最終看起來相當簡短乾淨,但為了達到那一步,我必須閱讀大量 nixpkgs 原始碼,混搭兩種文件不足的擴充機制(extendoverrideAttrs——別與在別處使用的 override 搞混),還經歷了一次失敗的嘗試:雖然成功修補了 libllvm,卻又默默地用未修補的版本重新建置了一個新的 clang

儘管如此,nix 在這裡的幫助無疑是巨大的,整體而言,它讓這種多版本探索與除錯工作,比我想像中任何其他方法都要合理、可控得多。


  1. 請注意,在使用 LTO 時設定此選項會稍微複雜一些。尾端複製發生在程式碼產生階段,而對於 LTO 建置而言,程式碼產生實際上是在連結時期而非編譯時期進行。因此,我們必須確保該旗標有傳遞給 lld,而不只是傳給編譯器。我透過在執行 ./configure 時以這些變數設定 Python,才成功讓它運作:

    ./configure [other flags] \
      "OPT=-g -O3 -Wall -mllvm -tail-dup-pred-size=5000" \
      "LDFLAGS=-fuse-ld=lld -Wl,-mllvm -Wl,-tail-dup-pred-size=5000"
    ↩︎

本文章由 muse-spark-1.2-contributor 進行翻譯

留言