在当今数字化浪潮中,编程语言的性能优化一直是开发者们关注的焦点。Python作为一种广泛使用的高级编程语言,其解释器的性能提升对于众多应用程序具有重要意义。近期,CPython项目引入了一种新的尾调用解释器实现策略,据称能带来显著的性能提升。本文将深入探讨这一新技术背后的原理、性能测试结果以及引发的思考。
性能测试结果
一个月前,CPython项目合并了一种新的字节码解释器实现策略,初步结果显示在各种平台上的广泛基准测试中平均性能提升了10-15%。然而,深入研究发现,这些令人印象深刻的性能提升主要是由于无意中规避了LLVM 19中的一个回归。当与更好的基线(如GCC、clang-18或带有某些调优标志的LLVM 19)进行对比测试时,性能提升下降到1-5%左右,具体取决于测试环境。
为了深入了解这一情况,作者使用不同的编译器和配置选项在两台机器上对CPython解释器的多个构建版本进行了基准测试:一台是英特尔服务器( Hetzner上维护的Raptor Lake i5-13500),另一台是Apple M1 Macbook Air。所有构建都使用了链接时优化(LTO)和配置文件引导优化(PGO)。测试的配置包括:
-
clang18:使用Clang 18.1.8构建,采用计算goto。
-
gcc(仅限英特尔):使用GCC 14.2.1构建,采用计算goto。
-
clang19:使用Clang 19.1.7构建,采用计算goto。
-
clang19.taildup:使用Clang 19.1.7构建,采用计算goto和一些
-mllvm
调优标志来规避回归。 -
clang19.tc:使用Clang 19.1.7构建,采用新的尾调用解释器。
以下是在不同平台上的测试结果:
平台 | 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倍 | 不适用 |
从结果可以看出,与clang-18相比,尾调用解释器仍然表现出加速,但其提升幅度远小于从clang-18迁移到clang-19所导致的性能下降。Python团队在某些其他平台上也观察到了比作者更大的加速(在考虑了错误之后)。
LLVM回归问题
背景知识
传统的字节码解释器由一个while
循环中的switch
语句组成,大致如下:
while (true) {opcode_t this_op = bytecode[pc++];switch (this_op) {case OP_IMM: {// 将立即数压入栈中break;}case OP_ADD: {// 处理加法break;}// 等等}
}
大多数编译器会将switch
编译成一个跳转表,包含每个case OP_xxx
块的地址,通过操作码索引并执行间接跳转。
长期以来,人们已经知道,通过将跳转表分派复制到每个操作码的主体中,可以加速这种风格的字节码解释器。也就是说,每个操作码不是以jmp loop_top
结尾,而是包含一个单独的实例,用于“解码下一条指令并通过跳转表索引”的逻辑。
现代C编译器支持获取标签的地址,然后在“计算goto”中使用这些标签来实现这种模式。因此,许多现代字节码解释器,包括CPython(在尾调用工作之前),采用了一个类似于以下代码的解释器循环:
static void *opcode_table[256] = {[OP_IMM] = &&TARGET_IMM,[OP_ADD] = &&TARGET_ADD,// 等等
};#define DISPATCH() goto *opcode_table[bytecode[pc++]]DISPATCH();TARGET_IMM: {// 将立即数压入栈中DISPATCH();
}
TARGET_ADD: {// 处理加法DISPATCH();
}
计算goto在LLVM中的处理
出于性能原因(编译器的性能,而非生成代码的性能),事实证明,Clang和LLVM在内部实际上将后者代码中的所有goto
合并成一个单独的indirectbr
LLVM指令,每个操作码都会跳转到该指令。也就是说,编译器将我们的努力重写成一个控制流图,其外观与switch
-based解释器基本相同!
然后,在代码生成过程中,LLVM执行“尾复制”,并将分支复制回每个位置,恢复原始意图。这一过程在一篇介绍新实现的旧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
-based解释器开始,让编译器自己进行尾复制,从而获得相同的益处?”
事实证明:是的。
clang-18(或带有适当标志的clang-19)在面对“经典”switch
-based解释器时,会继续将分派逻辑复制到每个操作码主体中。以下是使用前面的objdump | grep
测试显示的相同构建的间接跳转数量:
测试项目 | clang18 | clang18.nocg | clang19.nocg | clang19 |
间接跳转数量 | 332 | 306 | 3 | 3 |
因此,有理由认为整个“计算goto”解释器对于现代Clang来说是不必要的复杂性。编译器完全有能力自己执行相同的转换,而且(显然)计算goto甚至不足以保证它!
不过,我也测试了GCC,至少在14.2.1版本中,GCC没有复制switch
,但确实实现了使用计算goto时期望的行为。所以在这种情况下,我们看到了预期的行为。
修复措施
在发布此文章后不久,合并了LLVM拉取请求114990,并修复了回归。作者在合并前对其进行了基准测试,并确认它恢复了预期的性能。
对于该修复之前的版本,导致回归的拉取请求添加了一个可调选项,用于选择尾复制将中止的阈值。我们可以通过将该限制设置为一个非常大的数字来在clang-19上恢复类似的行为。
总结与思考
我必须承认,这个主题让我陷入了深度研究,比我真正需要的要深入得多。但这样做的结果是,我认为有许多有趣的教训和思考可以总结出来,这些总结可以推广到软件工程和性能工程领域。
关于基准测试
在优化系统时,我们通常会构建一组基准测试和基准测试方法,然后使用这些基准测试来评估提出的更改。
任何一组基准测试或基准测试程序都嵌入了(通常是隐式的)一套“性能理论”。你的性能理论是一组信念和假设,回答诸如“哪些变量(可能)影响性能,以何种方式?”以及“基准测试结果与‘生产’环境中的‘真实’性能之间有何关系?”等问题。
在尾调用解释器上运行的基准测试显示,与旧的计算goto解释器相比,速度提高了10-15%。这些基准测试在准确测量这些构建之间的性能差异方面是准确的。然而,为了将这些具体数据点推广到“尾调用解释器比计算goto解释器快10-15%,更广泛地”甚至“尾调用解释器将为我们的用户加快Python速度10-15%”等更广泛的声明,我们需要引入更多关于世界的假设和信念。在这种情况下,事实证明这个故事更加复杂,这些更广泛的声明并不完全普遍成立。
(再次强调,我真的不想指责Python开发人员!这些事情很难,有无数种方式会让人感到困惑或得出有些不正确的结论。我花了大约三周时间进行密集的基准测试和实验,才更好地理解了这个问题。我的观点是,这是一个非常普遍的挑战!)
基准线问题
这个例子突显了另一个在软件性能领域以及许多其他领域中反复出现的挑战:“你将与什么进行比较?”
每当你为某个问题提出新的解决方案或方法时,你通常有一种运行新方法并产生相关性能指标的方式。
一旦你为自己的系统获得了指标,你需要知道将它们与什么进行比较,以决定它们是否足够好!即使你在某个绝对尺度上表现良好(假设存在一个合理的绝对尺度来评估),如果你的方法比现有解决方案差,那它可能就不那么有趣了。
通常,你希望与“当前已知的最佳方法”进行比较。但有时这可能很难做到!即使你从理论上理解了当前的方法,你可能在实践中并不是这方面的专家。在软件的情况下,这可能意味着调整你的操作系统、编译器选项或其他标志。当前最佳的方法可能有已发布的基准测试,但它们并不总是与你相关;例如,它们可能是在几年前的旧硬件上发布的,因此你无法与公开数字进行苹果与苹果的比较。或者他们的测试可能是在你无法负担的规模上进行的。
如今,我在Anthropic从事机器学习工作,我们在ML论文中经常看到这种情况。当一篇论文声称某种算法改进或其他进展时,我注意到我们的研究人员首先问的通常不是“他们做了什么?”而是“他们与什么基准进行了比较?”如果与一个调整不佳的基准进行比较,很容易获得看似令人印象深刻的结果,这一观察结果解释了相当一部分所谓的改进。
关于软件工程
对我来说,另一个亮点是我们的软件系统是多么复杂和相互关联,它们的变化速度有多快,以及跟踪所有这些变化有多困难。
如果一个月前有人问我,估计LLVM发布导致CPython性能回归10%且五个月内无人发现的可能性有多大,我会认为这是一个相当不可能的情况!这两个项目都被广泛使用,都非常关注性能,“当然”会有人测试并注意到。
可能这种特定情况相当不可能!然而,有如此多不同的软件项目在快速变化,并且依赖和被如此多其他项目使用,这使得几乎不可避免地会发生一些类似的回归,几乎不断发生。
优化编译器
计算goto解释器的传奇故事揭示了围绕优化器和优化编译器的持续紧张关系和未解决的问题,这些问题在我们领域尚未达成共识。
我们通常期望编译器尊重程序员的意图,并以保留程序员意图的方式编译我们编写的代码。
同时,我们也期望编译器优化我们的代码,并以可能复杂且不直观的方式进行转换,以使其运行得更快。
这些期望是矛盾的,我们缺乏模式和习语来向编译器解释“为什么”我们以各种方式编写代码,以及我们是否是故意触发某种输出或做出某种性能相关决策。
我们的编译器通常只承诺发出“与我们编写的代码具有相同行为”的代码;性能是在该保证之上的尽力而为功能。
因此,我们最终处于这样一个奇怪的世界:clang-19“正确”地编译了计算goto解释器——从生成的二进制文件产生我们期望的所有值的意义上——但同时其输出完全与优化的意图背道而驰。此外,我们还看到其他版本的编译器对“朴素”的switch()
-based解释器应用了优化,实现了我们通过重写源代码“打算”执行的完全相同的优化。
回顾起来,似乎源代码级别的“计算goto”解释器和机器代码级别的“复制分派”最终是几乎正交的概念!我们已经看到了2x2矩阵的每个实例!因为所有这些python
二进制文件在运行时计算相同的值,我们当前的工具几乎无法以连贯的方式讨论它们之间的区别。
这种混乱是尾调用解释器(以及其背后的编译器功能)代表真正且有用的进步和先进状态的一种方式。尾调用解释器建立在musttail
属性之上,这代表了一种相对较新的编译器功能。musttail
不影响“可观察程序行为”,从编译器通常认为的经典意义上,而是与优化器的对话;它要求编译器能够进行某些优化,并要求如果这些优化没有发生则编译失败。
我希望这个框架将被证明是编写性能敏感代码的更稳健风格,特别是随着时间的推移和编译器的演变。我期待继续在这一类别的功能中进行实验。
具体来说,我在想是否可以用(假设的)[[clang::musttailduplicate]]
属性替换计算goto解释器,将其放在解释器while
循环上。我在所有相关的IR和传递方面没有足够的专业知识来对这个提议充满信心,但也许更熟悉的人可以对可行性发表意见。
关于nix
的注记
我想以强调nix
对这个项目的帮助来结束。在过去的一年左右,我一直在为我的个人基础设施尝试nix和NixOS,但它们在这个调查中成为了真正的救星。
在这个过程中,我已经构建和测试了几十个不同的Python解释器,跨越四个不同的编译器(gcc
、clang-18
、clang-19
和clang-20
)并使用了众多编译器标志组合。手动管理所有这些将使我精力耗尽,我肯定会在构建中混淆编译器和标志等细节。
使用nix
,我能够清晰地管理这些并行版本,并以可重现、封闭的方式构建它们。我能够编写一些简短的抽象,使它们非常容易定义,然后完全有信心地知道我的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
在这里显然是极大的帮助,总体而言,它肯定使这种多版本探索和调试比我能想象的任何其他方法都更加理智。
科技脉搏,每日跳动。
与敖行客 Allthinker一起,创造属于开发者的多彩世界。
- 智慧链接 思想协作 -