大约一个月前,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解释器构建,使用了多个不同的编译器和不同的配置选项,并在两台机器上进行:一台Intel服务器(我在Hetzner维护的Raptor Lake i5-13500)和我的Apple M1 Macbook Air。你可以使用我的nix配置重现这些构建,我发现这对于同时管理这么多不同的移动部分至关重要。
所有构建都使用了LTO和PGO。这些配置是:
- clang18: 使用Clang 18.1.8构建,使用计算跳转。
- gcc(仅Intel): 使用GCC 14.2.1构建,使用计算跳转。
- clang19: 使用Clang 19.1.7构建,使用计算跳转。
- clang19.tc: 使用Clang 19.1.7构建,使用新的尾调用解释器。
- clang19.taildup: 使用Clang 19.1.7构建,使用计算跳转和一些
-mllvm调优标志以绕过回归。
我使用clang18作为基准线,并报告了pypeformance/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语句组成,看起来像这样:
|
|
大多数编译器会将switch编译成一个跳转表——它们会发出一个包含每个case OP_xxx块地址的表,用操作码索引它,并执行一个间接跳转。
长期以来人们都知道,可以通过在每个操作码的主体中复制跳转表分派逻辑来加速这种风格的字节码解释器。也就是说,每个操作码不是以jmp loop_top结束,而是包含一个独立的“解码下一条指令并通过跳转表索引”的逻辑实例。
现代C编译器支持获取标签的地址,然后在“计算跳转”中使用这些标签来实现这种模式。因此,许多现代字节码解释器,包括CPython(在尾调用工作之前),使用一个看起来像这样的解释器循环:
|
|
LLVM 中的计算跳转
出于性能原因(编译器的性能,而非生成代码的性能),事实证明Clang和LLVM在内部实际上将后一段代码中的所有goto合并成一个单独的indirectbr LLVM指令,每个操作码都会跳转到该指令。也就是说,编译器拿走了我们辛苦的工作,并有意将其重写成一个本质上与基于switch的解释器相似的控制流图!
然后,在代码生成期间,LLVM执行“尾复制”,并将分支复制回每个位置,恢复了原始意图。这种策略在一篇介绍新实现的旧LLVM博客文章中进行了高层面的说明。
LLVM 19 回归
进行去重然后复制这个操作的根本原因是,出于技术原因,创建和操作包含许多indirectbr指令的控制流图可能会非常昂贵。
为了避免在某些情况下出现灾难性的减速(或内存使用),LLVM 19对尾复制传递实施了一些限制,如果复制会使IR的大小超过某些限制,则会导致其退出。
不幸的是,在CPython上,这些限制导致Clang留下了所有分派跳转合并状态,完全破坏了基于计算跳转的实现的目的!这个错误最初由另一个具有类似解释器循环的语言实现发现,但据我所知,尚未发现会影响CPython。
除了性能影响,我们还可以通过反汇编生成的目标代码并计算不同间接跳转的数量来直接观察这个错误:
|
|
进一步的怪异现象
我相信对尾复制逻辑的更改导致了回归:如果你修复它,性能就与clang-18匹配。然而,我无法完全解释回归的幅度。
历史上,将字节码分派复制到每个操作码的优化被引用为可以加速解释器20%到100%。然而,在具有改进的分支预测器的现代处理器上,更近期的研究发现速度提升要小得多,大约为2-4%。
我们可以在实践中验证这个2-4%的数字,因为Python仍然支持通过配置选项使用“旧式”解释器,该解释器使用单个switch语句。以下是我们如果基准测试该解释器(“.nocg”表示“无计算跳转”)看到的情况:
| 基准测试 | clang18 | clang18.nocg | clang19.nocg | clang19 |
|---|---|---|---|---|
| 性能变化 | (参考) | 快1.01倍 | 慢1.02倍 | 慢1.09倍 |
请注意,clang19.nocg仅比clang18慢2%,而基础的clang19构建却慢9%!我将“2%”解释为单独复制操作码分派的成本/收益的更公平估计,而且我并不完全理解另外的那部分。
我们需要计算跳转吗?
我还没有提到clang19.nocg基准测试,你可能注意到它声称比clang19更快。正是在这一点上,我发现了故事中一个额外的、非常有趣的转折。
我之前解释过Clang和LLVM:
- 将
switch编译成一个跳转表和一个间接跳转,与我们手动使用计算跳转创建的表非常相似。 - 将计算跳转编译成一个与经典的
switch图非常相似的控制流图,其中有一个操作码分派的单一实例。 - 能够在代码生成期间反转转换以复制分派。
这些事实结合在一起,可能会让你问:“我们能不能就从基于switch的解释器开始,让编译器进行尾复制,从而获得相同的好处?”
事实证明:是的。
clang-18(或带有适当标志的clang-19),当遇到“经典”的基于switch的解释器时,会继续将分派逻辑复制到每个操作码的主体中。下面是另一个表格,显示了使用之前objdump | grep测试的具有间接跳转数量的相同构建:
| 基准测试 | clang18 | clang18.nocg | clang19.nocg | clang19 |
|---|---|---|---|---|
| 间接跳转数量 | 332 | 306 | 3 | 3 |
因此,有理由认为整个“计算跳转”解释器最终是完全不必要的复杂性(至少对于现代Clang而言)。编译器完全能够自己执行相同的转换,并且(显然)计算跳转甚至不足以保证这一点!
也就是说,我也测试了GCC,而GCC(至少在14.2.1版本中)不会复制switch,但确实在使用计算跳转时实现了期望的行为。所以至少在那个案例中我们看到了预期的行为。
修复
在我发布这篇文章后不久,LLVM拉取请求114990合并了,并修复了该回归。我能够在合并前对其进行基准测试,并确认它恢复了预期的性能。
在该修复之前的版本中,导致回归的PR添加了一个可调选项,用于选择尾复制将中止的阈值。我们可以通过简单地将该限制设置为一个非常大的数字来在clang-19上恢复类似的行为。
反思
我会坦率地承认,我被这个话题完全吸引住了,并且走得比真正必要的要深入得多。话虽如此,这样做了之后,我认为可以从中汲取许多有趣的经验和反思,这些经验可以推广到软件工程和性能工程,我将尝试提取并思考其中的一些。
关于基准测试
当优化一个系统时,我们通常会构建一组基准测试和基准测试方法,然后使用这些基准测试来评估提议的更改。
任何一组基准测试或基准测试程序都嵌入(通常是隐式地)我称之为“性能理论”的东西。你的性能理论是一组信念和假设,用于回答诸如“哪些变量(可能)影响性能,以何种方式?”以及“基准测试结果与‘生产’中的‘真实’性能之间的关系是什么?”这样的问题。
在尾调用解释器上运行的基准测试显示,与旧的计算跳转解释器相比,有10-15%的速度提升。这些基准测试是准确的,因为它们(据我所知)准确地测量了这些构建之间的性能差异。然而,为了将这些特定的数据点推广到“尾调用解释器比计算跳转解释器快10-15%(更一般地说)”,甚至“尾调用解释器将为我们的用户加速Python 10-15%”,我们需要引入更多关于世界的假设和信念。在这个案例中,事实证明故事更复杂,那些更广泛的说法并非普遍成立。
(再次强调,我真的不想责怪Python开发者!这些东西很难,有无数种方式会让人困惑或得出有些错误的结论。我不得不做大约三周密集的基准测试和实验,才达到了更好的理解。我的观点是,这是一个非常普遍的挑战!)
基准线
这个例子凸显了另一个反复出现的挑战,不仅在软件性能方面,而且在许多其他领域:“你与什么基准线进行比较?”
任何时候你为某个问题提出新的解决方案或方法时,你通常都有一种运行新方法并产生一些相关性能指标的方式。
然而,一旦你有了系统的指标,你需要知道将它们与什么进行比较,以决定它们是否好!即使你在某个绝对尺度上得分很高(假设存在一个合理的绝对尺度来评估),如果你的方法比现有的解决方案差,它可能就没那么有趣了。
通常,你希望与“当前已知的最佳方法”进行比较。但有时这很难做到!即使你从理论上理解了当前的方法,你可能也可能不是在实践中应用它的专家。在软件的情况下,这可能意味着调整你的操作系统、编译器选项或其他标志。当前最佳方法可能有已发布的基准测试,但它们并不总是与你相关;例如,也许它是多年前在旧硬件上发布的,因此你无法与公开数据进行同类比较。或者也许他们的测试是在你无法复制的规模上运行的。
我目前在Anthropic从事机器学习工作,我们在ML论文中经常看到这种情况。当一篇论文出来声称某些算法改进或其他进展时,我注意到我们的研究人员问的第一个细节通常不是“他们做了什么?”,而是“他们与什么基准线进行了比较?”。如果你与一个调整不佳的基准线进行比较,很容易获得看起来令人印象深刻的结果,而这一观察结果最终解释了相当一部分所谓的改进。
关于软件工程
对我来说,另一个亮点是我们的软件系统是多么复杂和相互关联,变化是多么迅速,以及要跟踪所有部分是多么困难。
如果你一个月前问我,估计LLVM发布导致CPython出现10%性能回归并且五个月内没人注意到的可能性有多大,我会认为这是一个相当不太可能的情况!这两个都是广泛使用的项目,都对性能相当关心,并且“肯定”有人会测试并注意到。
也许那个特定的情况相当不可能!然而,有这么多不同的软件项目,每个项目都在如此迅速地发展,并且被如此多的其他项目依赖和使用,实际上变得不可避免,某些“像那样”的回归几乎不断发生。
优化编译器
计算跳转解释器的传奇故事,说明了围绕优化器和优化编译器的一些反复出现的紧张关系和未解决的问题,作为一个领域,我们还没有达成共识的答案。
我们通常期望我们的编译器尊重程序员的意图,并以保留程序员意图的方式编译所写的代码。
然而,我们也期望我们的编译器优化我们的代码,并可能以复杂且不直观的方式转换它以使其运行得更快。
这些期望是相互矛盾的,并且我们缺乏模式和惯用法来向编译器解释我们“为什么”以各种方式编写代码,以及我们是否有意尝试触发某种输出,或做出某种与性能相关的决定。
我们的编译器通常只承诺发出与我们编写的代码“行为相同”的代码;性能在某种程度上是该保证之上的尽力而为的特性。
因此,我们最终进入了这个奇怪的世界,clang-19将计算跳转解释器“正确地”编译——即生成的二进制文件产生所有我们期望的相同值——但与此同时,它产生的输出与优化的意图完全不符。此外,我们还看到其他版本的编译器对“朴素”的基于switch()的解释器应用优化,这些优化实现了我们通过重写源代码“意图”执行的完全相同的优化。
事后看来,“计算跳转”解释器在源代码层面,与“在机器代码层面复制分派”最终几乎是正交的概念!我们已经看到了结果2x2矩阵的每个实例的例子!因为所有这些python二进制文件在运行时计算相同的值,我们当前的工具基本上无法以连贯的方式谈论它们之间的区别。
我认为尾调用解释器(及其背后的编译器功能)代表了艺术状态的一个真正且有用的进步,而这种困惑是其中的一种方式。尾调用解释器建立在musttail属性之上,它代表了一种相对较新的编译器功能。musttail并不影响“可观察的程序行为”,在编译器思考的经典意义上,而更像是与优化器的对话;它要求编译器能够进行某些优化,并要求如果这些优化没有发生,编译就失败。
我希望这个框架最终将成为编写性能敏感代码的一种更稳健的风格,尤其是随着时间的推移和编译器的发展。我期待在该类别功能上继续进行实验。
具体来说,我发现自己想知道是否可以用类似(假设的)[[clang::musttailduplicate]]属性来替换计算跳转解释器,该属性应用于解释器while循环。我对所有相关的IR和pass还不够专业,无法对这个提议有信心,但也许有更多相关知识的人可以就可行性发表意见。
关于 nix 的一点说明
我想以一个对nix在这次项目中有多么有帮助的称赞来结束。在过去一年左右的时间里,我一直在为我的个人基础设施试验nix和NixOS,但事实证明它们对这次调查来说是救命稻草。
在这些实验过程中,我构建并基准测试了数十个不同的Python解释器,跨越四个不同的编译器(gcc、clang-18、clang-19和clang-20),并使用了多种编译器标志组合。手动管理所有这些会让我发疯,而且我肯定会在混合哪个编译器和哪些标志用于哪个构建等过程中犯很多错误。
使用nix,我能够理清所有这些并行版本,并以可重现的、封闭的风格构建它们。我能够编写一些简短的抽象,使它们非常容易定义,然后绝对确信我的nix存储中的任何给定构建来自哪里,使用了哪些编译器和哪些标志。在花费少量工作构建一些辅助函数后,我的构建矩阵的核心定义简洁得令人震惊;这里有个示例:
|
|
我甚至能够构建一个自定义版本的LLVM(带有错误修复补丁),并使用该编译器进行Python构建。这样做只需要大约10行代码。
也就是说,并非一切都是一帆风顺的。首先,与“普通人”使用软件的方式相比,nix出于必要在某些方面是“奇怪的”,我担心其中的一些奇怪之处可能以我没注意到的方式影响了我的某些基准测试或结论。例如,早期我发现nix(默认情况下)使用某些会不成比例地影响尾调用解释器的强化标志来构建项目。我已经处理了那个问题,但还有更多吗?
此外,Nix非常具有可扩展性和可定制性,但弄清楚如何进行特定的定制可能是一场真正的艰苦战斗,并且涉及大量的试验和错误以及源码挖掘。我打补丁的LLVM构建最终相当简短和清晰,但达到那里需要我阅读大量nixpkgs源代码,混合搭配两种文档不足的可扩展性机制(extend和overrideAttrs——不要与在其他地方使用的override混淆),以及一次成功的尝试,它成功地修补了libllvm,但随后却静默地构建了一个针对未修补版本的新clang。
尽管如此,nix在这里显然非常有用,总的来说,它肯定使这种多版本探索和调试比我所能想象的任何其他方法都更加理智。
注意:设置此选项在使用LTO时有点复杂。尾复制发生在代码生成期间,对于LTO构建,代码生成实际上发生在链接时,而不是编译时。因此,我们需要确保标志传递给lld,而不仅仅是编译器。我能够在./configure时通过配置Python使用这些变量使其工作:
|
|