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 的构建,在实际环境中复现了 10% 的提速,与 Python 3.13 相比。
性能测试结果
以下是我的主要测试结果。我在两台机器上对 CPython 解释器的多个构建版本进行了基准测试:一台是我在 Hetzner 上维护的 Intel 服务器(Raptor Lake i5-13500),另一台是我的 Apple M1 MacBook Air。你可以使用我的nix 配置来复现这些构建,在同时管理如此多变量的情况下,我发现这套配置不可或缺。
所有构建均启用了 LTO 和 PGO。具体配置如下:
clang18:使用 Clang 18.1.8 构建,采用计算 goto。gcc(仅限 Intel):使用 GCC 14.2.1 构建,采用计算 goto。clang19:使用 Clang 19.1.7 构建,采用计算 goto。clang19.tc:使用 Clang 19.1.7 构建,采用新的尾调用解释器。clang19.taildup:使用 Clang 19.1.7 构建,采用计算 goto,并附加了一些可绕过该回归的-mllvm调优参数。
我以 clang18 为基准,并报告了 pyperformance/pyperf compare_to 给出的总体“平均值”。你可以在 GitHub 上找到完整的输出文件和报告。
| 平台 | clang18 | clang19 | clang19.taildup | clang19.tc | gcc |
|---|---|---|---|---|---|
| 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 块地址的表,用操作码作索引,然后执行间接跳转。
早就有人发现,通过将跳转表分发逻辑复制到每个操作码的主体中,可以加速这种风格的字节码解释器。也就是说,不再让每个操作码以 jmp loop_top 结束,而是让每个操作码都包含一份独立的“解码下一条指令并查询跳转表”逻辑。
现代 C 编译器支持获取标签地址,并通过“计算 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 中的计算 goto
出于性能考虑(指编译器本身的性能,而非生成代码的性能),Clang 和 LLVM 在内部实际上会把后一种代码中的所有 goto 合并为单一的indirectbr LLVM 指令,让每个操作码都跳转到该指令。也就是说,编译器把我们费力写好的代码,又有意重写成了与基于 switch 的解释器基本相同的控制流图!
随后,在代码生成阶段,LLVM 会执行“尾部复制”(tail duplication),再把分支复制回各个位置,恢复原有的意图。这一来回过程在一篇介绍新实现的旧版 LLVM 博客文章中有高层面的记录。
LLVM 19 的回归
之所以要先去重再复制,是因为在技术上,创建和处理包含大量 indirectbr 指令的控制流图开销可能非常大。
为了避免在某些情况下出现灾难性的编译变慢(或内存占用激增),LLVM 19 对尾部复制过程加入了一些限制,当复制会导致 IR 体积超过特定阈值时便会提前退出。
不幸的是,在 CPython 上,这些限制导致 Clang 保留了所有分发跳转的合并状态,完全抵消了基于计算 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”表示“无计算 goto”)的测试结果:
| 基准测试 | clang18 | clang18.nocg | clang19.nocg | clang19 |
|---|---|---|---|---|
| 性能变化 | (基准) | 快 1.01 倍 | 慢 1.02 倍 | 慢 1.09 倍 |
注意,clang19.nocg 仅比 clang18 慢 2%,而基础的 clang19 构建却慢了 9%!我将这“2%”视为单独复制操作码分发所带来开销/收益的更公允估计,而另一部分则仍令我费解。
我们还需要计算 goto 吗?
前面我还没提到 clang19.nocg 这一项测试,你可能已经注意到它竟然比 clang19 更快。正是在这一点上,我发现了这个故事中一个非常有趣的转折。
前面我解释过,Clang 和 LLVM 会:
- 将
switch编译为跳转表和间接跳转,与我们用计算 goto 手写的版本非常相似 - 将计算 goto 编译为与经典
switch图高度相似的控制流图,其中只有一份操作码分发逻辑,以及 - 能够在代码生成阶段逆向这一变换,将分发逻辑复制回去
把这些事实放在一起,你可能会问:“那我们能否直接从基于 switch 的解释器出发,让编译器自行完成尾部复制,从而获得同样的好处?”
答案是:可以。
Clang 18(或带有适当参数的 Clang 19)在面对“经典的”基于 switch 的解释器时,也会把分发逻辑复制到每个操作码的主体中。下表展示了相同构建中间接跳转的数量,沿用前面的 objdump | grep 检测方法:
| 基准测试 | clang18 | clang18.nocg | clang19.nocg | clang19 |
|---|---|---|---|---|
| 间接跳转数量 | 332 | 306 | 3 | 3 |
因此,可以说整个“计算 goto”解释器(至少对现代 Clang 而言)完全是不必要的复杂性。编译器完全有能力自行完成同样的变换,而且事实证明,计算 goto 甚至都不足以保证这一变换一定会发生!
不过,我也测试了 GCC,GCC(至少到 14.2.1 版本)不会复制 switch,但在使用计算 goto 时会实现预期的行为。所以至少在这种情况下,我们看到的是符合预期的表现。
修复
LLVM 的 114990 号拉取请求在我发布本文后不久合并,修复了这一回归。我在合并前就对其进行了测试,确认它恢复了预期的性能。
对于该修复之前的版本,引入回归的那个 PR 增加了一个可调选项,用于选择尾部复制在何种阈值下中止。我们只需将该限制设为一个非常大的数值,就能在 Clang 19 上恢复类似的行为1。
反思
我毫不讳言,自己被这个话题深深吸引,甚至可以说钻得比实际需要的要深得多。不过,既然已经深入研究,我认为其中有不少值得提炼和思考的经验,可以推广到软件工程和性能工程的更一般层面,下面我将尝试逐一展开。
关于基准测试
在优化系统时,我们通常会构建一套基准测试和方法,然后用它们来评估拟议的改动。
任何一套基准测试或测试流程,都隐含了我称之为“性能理论”的东西。你的性能理论是一组信念和假设,它回答了诸如“哪些变量(可能)以何种方式影响性能?”以及“基准测试结果与‘生产环境’中的‘真实’性能之间有何关系?”之类的问题。
在尾调用解释器上运行的基准测试显示,与旧的计算 goto 解释器相比有 10% 至 15% 的提速。这些测试本身是准确的——据我所知,它们确实准确地测量了这些构建之间的性能差异。然而,要将这些具体的测量数据推广为“尾调用解释器总体上比计算 goto 解释器快 10% 至 15%”,甚至“尾调用解释器将为用户带来 10% 至 15% 的 Python 提速”这样的泛化结论,就需要引入更多关于世界的假设和信念。在这个案例中,实际情况更为复杂,那些更宽泛的论断并不完全成立。
(再次强调,我真的无意责怪 Python 开发者!这类事情非常棘手,有无数种方式会让人产生混淆或得出不完全正确的结论。我花了大约三周时间进行高强度的基准测试和实验,才达到更深入的理解。我的重点在于,这是一个非常普遍的挑战!)
基线
这个例子凸显了另一个反复出现的挑战,不仅存在于软件性能领域,也存在于许多其他领域:“你与什么样的基线做对比?”
每当你为某个问题提出新的解决方案或方法时,通常都能运行你的新方法并得出相关的性能指标。
然而,一旦有了你的系统指标,就需要知道该与什么进行对比,才能判断它到底好不好!即便你在某个绝对尺度上得分很高(假设存在一个合理的绝对评价尺度),如果你的方法不如现有方案,那它很可能没什么价值。
通常,你希望与“当前已知的最佳方法”对比。但有时这很难做到!即便你在理论上理解当前的方法,也未必精通其在实践中的应用。对软件而言,这可能意味着调优操作系统、编译器选项或其他参数。当前最佳方法或许已公布过基准测试结果,但它们不一定与你的场景相关;比如,它们可能是多年前在旧硬件上测得的,因此无法与公开数据进行同等对比。或者,它们的测试规模可能是你无法负担复现的。
我如今在 Anthropic 从事机器学习工作,我们在机器学习论文中经常看到这种情况。当一篇论文声称有某种算法改进或其他进展时,我们的研究人员首先关心的往往不是“他们做了什么?”,而是“他们与什么样的基线做了对比?”如果对比的是一个调优不佳的基线,很容易就能得到看似亮眼的结果,而事实证明,相当一部分所谓的改进都可以用这一点来解释。
关于软件工程
对我而言,另一个突出的感受是,我们的软件系统是多么复杂、相互关联,又是多么快速地演进,要跟上所有环节是多么困难。
如果一个月前你问我,LLVM 的一个版本导致 CPython 出现 10% 的性能回退却五个月无人察觉的可能性有多大,我会觉得这种情况相当不可能!这两个都是被广泛使用的项目,都相当重视性能,“肯定”会有人去测试并发现问题。
而且,很可能那个特定的情况确实非常罕见!然而,面对如此众多快速演进、相互依赖又被广泛使用的软件项目,类似的回归几乎不可避免地会不断发生。
优化编译器
计算 goto 解释器的这段经历,体现了围绕优化器和优化编译器的长期张力与尚未达成共识的问题,而我们作为一个领域对此仍未找到公认的答案。
我们通常期望编译器尊重程序员的意图,并以保留其意图的方式编译所写的代码。
然而,我们也期望编译器能够优化我们的代码,并以可能复杂且不直观的方式对其进行变换,以使其运行得更快。
这两种期望是相互矛盾的,而我们缺乏一套成熟的模式和惯用法,来向编译器解释我们为何以某种方式编写代码,以及我们是否是有意要触发某种特定的输出,或做出某种与性能相关的决策。
我们的编译器通常只承诺生成与我们所写代码“行为相同”的代码;性能在这一保证之上,更多是一种尽力而为的特性。
因此,我们陷入了一个奇怪的境地:Clang 19 对计算 goto 解释器的编译是“正确”的——从生成的二进制文件产生的所有预期值来看——但同时,它生成的输出却完全违背了该优化的初衷。此外,我们还看到其他版本的编译器对“朴素的”基于 switch() 的解释器应用优化,实现了与我们通过重写源代码“意图”达成完全相同的优化。
回过头看,“计算 goto”解释器在源代码层面,与“在机器码层面复制分发逻辑”在某种程度上几乎是正交的概念!我们已经见到了这一 2×2 矩阵中各种组合的实例!由于所有这些 python 二进制文件运行时计算出的值都相同,我们现有的工具本质上无法以一致的方式来区分它们。
正是在这一点上,我认为尾调用解释器(及其背后的编译器特性)代表了当前技术水平上一种真正且有用的进步。尾调用解释器基于的 musttail 属性,这是一种相对较新的编译器特性。musttail 并不影响编译器传统意义上的“可观察程序行为”,而是一种与优化器的对话;它要求编译器必须能够进行某些优化,并在这些优化未能发生时让编译失败。
我希望这种模式最终会被证明是一种更稳健的、用于编写性能敏感代码的方式,尤其是在编译器不断演进的长期过程中。我期待未来能看到更多此类特性的实验。
具体来说,我在想,是否有可能用类似(假设的)[[clang::musttailduplicate]] 这样的属性来替代解释器 while 循环上的计算 goto 解释器。我对相关的各种中间表示和编译阶段还不够熟悉,无法对这一提议抱有十足的信心,但或许更熟悉这些领域的专家可以就其可行性发表看法。
再补充一点:关于 nix
最后,我想特别提一下 nix 在这个项目中的巨大帮助。过去一年左右,我一直在为个人基础设施尝试使用 Nix 和 NixOS,而它们在这个研究中完全成了救星。
在这些实验过程中,我构建并测试了数十个不同的 Python 解释器,涉及四种不同的编译器(gcc、clang-18、clang-19 和 clang-20)以及多种编译器参数组合。如果靠手工管理,这会极大地考验我的理智,而且我肯定会在某个环节搞混哪个构建用了哪种编译器和哪些参数。
使用 nix,我得以清晰地区分所有这些并行版本,并以可复现、隔离(hermetic)的方式构建它们。我能够编写一些简短的抽象,让这些版本的定义变得非常容易,并能绝对确信 nix 存储中的每一个构建究竟来自何处、使用了哪种编译器和哪些参数。在完成一些辅助函数的编写后,我的构建矩阵核心定义出人意料地简洁;举例如下:
{
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 默认会使用某些加固参数来构建项目,而这些参数对尾调用解释器的影响尤为显著。这个问题我已经处理了,但是否还有其他隐患呢?
此外,Nix 具有极强的可扩展性和可定制性,但要弄清楚如何实现某个具体定制,确实需要历经艰难的摸索,涉及大量试错和源码阅读。我的带补丁 LLVM 构建最终看起来相当简短整洁,但为了达成它,我阅读了大量 nixpkgs 源码,在两种文档不足的扩展机制(extend 和 overrideAttrs——不要与别处使用的 override 混淆)之间反复尝试,还经历了一次失败的尝试——那次虽然成功给 libllvm 打上了补丁,却又悄无声息地用未打补丁的版本重新构建了 clang。
尽管如此,nix 在此显然提供了巨大的帮助,总的来说,它让这种多版本探索和调试工作比我能想象的任何其他方式都要合理得多。
注意,在使用 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"
随机一篇博客
评论
登录后参与讨论