Python 3.11以来性能改进的背后原理

自 Python 3.11 以来,我们一直在努力提高 Python 的速度,而且成果也很明显。性能改进是实实在在的,这项工作还在继续。
一种已有近 30 年历史的语言的速度有如此显著的提升,让人感到耳目一新,也让人感到惊讶。

然而天下没有免费的午餐。

这些改进是经过深思熟虑的规划和研究的结果。在实施开始之前,我们就已经设定了约 5 倍的加速目标。

Node.js 和 Java 等成熟语言早已使用过其中一些技术,也为人们提供了启发。

在深入研究所使用的关键技术之前,让我们首先回顾一下时间线,以全面了解性能改进:

1. 专业、自适应解释器
Python VM 是一个基于堆栈的解释器。从字节码中可以看出,要进行算术运算,它首先需要将变量推送到堆栈上,然后在堆栈上执行运算。

解释器根据观察到的模式将一个或多个指令替换为更高效的指令。我们称之为更快的变体超级指令。

值得注意的是,Python 每次只专门处理一个字节码。
Python 不会尝试理解应用程序正在做什么的更广泛背景上下文,以找到针对特定问题的最佳超级指令。
Python 的专业化更加有限,基于每个指令进行操作:

  • 在基于堆栈的虚拟机中,将值推送到堆栈和从堆栈弹出可能是最常见的操作。

加速:

  • 在加速过程中,解释器会识别可以从优化中受益的操作。
  • 现代 CPU 具有专门的指令,可以高效处理相同类型的整数、浮点数甚至字符串的算术运算。如果两个操作数都是相同类型,解释器将使用该指令的专门版本。
  • 它将操作标记为自适应,这意味着如果观察到一致的类型模式(多次执行),该指令可能会从未来执行中的专业化中受益。

这其中也存在一些权衡。

其中之一就是内存——每次特化都会带来少量的内存成本,因为解释器需要将运行时信息与指令一起存储。这是通过内联缓存来管理的。
内联缓存是动态管理的,以避免过度的内存消耗,以及当缓存未命中频繁发生时系统如何回退到通用执行。PEP还提供了与此过程相关的内存使用情况的详细分析。

解释器通过收集数据并根据该信息进行字节码级调整来优化运行时的代码,从而加快执行速度!

2、更好的内存管理
更少的内存几乎总是意味着更好的缓存利用率,因此优化内存可以带来复合效益。

这次演讲中,Mark Shannon 讨论了他们如何减少基本 Python 对象的大小。由于 Python 中的所有内容都是对象,因此最小化 Python 对象的内存占用几乎总是有益的。演讲详细介绍了多年来如何实现这一目标。

总之,对象大小减少了约~75%,访问变量的内存读取量也减少了约~60%。

这个结果是我们之前提到的复合效应的一个真实例子:对象尺寸越小,访问属性时的间接寻址就越少,从而进一步提高了性能。

减少内存仍然是首要任务,我们肯定会看到该领域的更多改进。Python 3.13 为垃圾收集 (GC) 周期引入了更好的启发式方法,从而更有效地收集循环引用并减少 GC 暂停时间。

3. JIT
JIT 编译器是3.13 中的一项实验性功能, [url=https://peps.python.org/pep-0744/]PEP 744[/url]描述了其内部工作原理。
之前对专用自适应解释器和每条指令的内联缓存的改进为 JIT 编译铺平了道路。现在,借助 JIT,我们可以将专用代码编译为机器代码。

机器代码翻译过程使用一种称为复制和修补的技术。它没有运行时依赖项,但对 LLVM 有新的构建时依赖项。

在深入解释这种复制和修补的含义之前,让我们先退一步,从整体上来看一下 JIT 编译器的工作原理:

Java、Node.js 和 Python 等解释型语言对中间字节码进行操作。

  • VM 执行此字节码,并在高效运行时分析器的帮助下识别代码中的热点。然后,这些热点被发送到JIT(即时)编译器,以编译为机器码。
  • 例如,Java 虚拟机 (JVM) 利用 LLVM 框架将这些热点字节码转换为机器代码,从而允许运行时切换到本机指令以实现更快的执行。
  • 另一种方法称为AOT(Ahead-Of-Time)编译,也可用于补充 JIT 编译。在 AOT 中,部分代码甚至在程序运行之前就已优化和编译。由于没有可用的运行时分析数据,AOT 依靠静态代码分析和启发式技术来预测潜在热点。

另一方面,Python 采用一种独特且略显新颖的方法,称为“复制和修补”。
有一篇论文详细阐述了这项技术。

该概念类似于传统的 JIT 编译器:Python 执行跟踪步骤来识别潜在热点,然后将其转换为机器代码。它不是动态编译代码,而是将静态预编译的字节码复制到内存中并在运行时进行修补。这意味着在程序执行时,只有机器代码的特定部分会被即时修改 — — 这就是术语“复制和修补”的由来。

在这场富有洞察力的演讲中,Brandt Bucher 完美地演示了复制和修补的底层工作原理。

  • 选择要优化的特定字节码,并将相应的 C 代码提取到单独的文件中,并进行修改以启用运行时修补。
  • 然后使用 LLVM 将修改后的文件编译为机器代码。
  • 生成的机器代码被格式化为类似 shellcode结构的 C 头文件。
  • 最终产品是特定于平台的可修补机器代码段,JIT 编译器可以根据需要使用它。

这种方法有几个好处:

  • 无运行时依赖:使用复制和修补,不需要单独的运行时编译步骤,从而无需在解释器中使用编译器依赖项。
  • 简单性:此方法只需用 C 编写模板 JIT 代码即可实现广泛的平台支持。Python 核心开发人员不必深入研究特定于平台的汇编,从而使维护更容易,并且贡献者更容易使用该方法。
  • 性能:由于没有全面的编译步骤,因此开销显著减少。根据 Brandt 的演讲,与传统的 JIT 工具链相比,复制和修补方法可使代码生成速度提高约 100 倍,执行速度提高约 15%。
虽然它可能无法在所有情况下都与 LuaJIT 等手工制作的汇编 JIT 的性能相匹配,但它提供了相当的编译速度,并且在某些基准测试中的执行速度仅慢约 35%。

这种方法还处于早期阶段,但不可否认的是,它是一种有前途的、比实现成熟的 JIT 编译器更可行的替代方法。

结果不言而喻。

来自官方发行说明(https://docs.python.org/3/whatsnew/3.11.html#faster-cpython):

使用 pyperformance 基准测试套件测量,在 Ubuntu Linux 上使用 GCC 编译时,CPython 3.11 比 CPython 3.10 平均快 25%。根据您的工作负载,整体速度可能提高约 10-60%。

Python 3.13 引入了 JIT 编译的初步基础,但仍处于早期阶段。

然而,随着Python 3.14 的推出,JIT 有望显著成熟,并带来切实的性能改进。
还值得注意的是,这些性能改进不仅适用于纯 Python 代码。NumPy 、Pandas 和其他流行库等C 扩展也可以从更快的解释器循环和减少的函数调用开销中受益。

总而言之,升级到 Python 3.11 或 3.12 可以显著提高性能,因此强烈建议在生产部署中使用这些版本。这一趋势将延续到 Python 3.13 及更高版本。