Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 744 – JIT 编译

作者:
Brandt Bucher <brandt at python.org>, Savannah Ostrowski <savannah at python.org>
讨论至:
Discourse 帖子
状态:
草案
类型:
信息性
创建日期:
2024年4月11日
Python 版本:
3.13
发布历史:
2024年4月11日

目录

摘要

今年早些时候,一个实验性“即时”编译器被合并到 CPython 的 main 开发分支中。虽然最近的 CPython 版本包含其他实质性的内部更改,但此添加代表了 CPython 传统执行 Python 代码方式的特别显著的偏离。因此,它值得更广泛的讨论。

本 PEP 旨在总结此添加背后的设计决策、当前实现状态以及使 JIT 成为 CPython 永久性非实验性部分的未来计划。它寻求提供 JIT 如何工作的全面概述,而是侧重于所选方法的特定优点和缺点,并回答自 JIT 引入以来提出的许多问题。

有兴趣了解更多关于新 JIT 的读者可以查阅以下资源

  • 在 2023 年 CPython 核心开发者冲刺中首次介绍 JIT 的演示。它包括相关背景、对所使用的“复制-修补”技术的简单技术介绍,以及核心开发者之间对其设计的公开讨论。此演示的幻灯片可在 GitHub 上找到。
  • 最初描述复制-修补的开放获取论文
  • 该论文作者详细介绍了 Lua 的复制-修补 JIT 编译器实现的博客文章。虽然这是对该方法的出色底层解释,但请注意,它也结合了其他技术并做出了与 CPython 的 JIT 不特别相关的实现决策。
  • 实现本身。

动机

在此之前,CPython 总是通过将 Python 代码编译成字节码来执行,字节码在运行时被解释。这种字节码或多或少是源代码的直接翻译:它是无类型的,并且基本上未优化。

自 Python 3.11 发布以来,CPython 使用了“专门化自适应解释器”(PEP 659),它在运行时将这些字节码指令原地重写为类型专门化版本。这个新解释器提供了显著的性能改进,尽管其优化潜力受限于单个字节码指令的边界。它还收集了大量新的剖析信息:流经程序的类型、特定对象的内存布局以及程序中执行最多的路径。换句话说,什么需要优化,以及如何优化它。

自 Python 3.12 发布以来,CPython 已使用类 C 领域特定语言 (DSL) 生成此解释器。除了驯服新自适应解释器的一些复杂性之外,DSL 还允许 CPython 的维护者避免在解释器、编译器和标准库的许多部分中手写繁琐的样板代码,这些代码必须与指令定义保持同步。这种从单一真理源生成大量运行时基础设施的能力不仅便于维护;它还为以新方式扩展 CPython 的执行解锁了许多可能性。例如,它使得自动生成用于将指令序列转换为等效的更小的“微操作”序列的表、生成这些微操作序列的优化器,甚至生成一个完整的第二个解释器来执行它们成为可能。

事实上,自 Python 3.13 发布周期早期以来,所有 CPython 构建都已包含这种精确的微操作翻译、优化和执行机制。然而,它默认是禁用的;即使是解释优化过的微操作跟踪的开销对于大多数代码来说也太大了。更强的优化可能也不会改善情况太多,因为任何新优化带来的效率提升很可能会被更小、更复杂的微操作的解释开销抵消。

克服这个新瓶颈最明显的策略是静态编译这些优化跟踪。这提供了避免解释引入的几个间接和开销来源的机会。特别是,它允许消除微操作之间的调度开销(通过用热代码的直线序列替换通用解释器)、单个微操作的指令解码开销(通过将参数、常量和缓存值的值或地址直接“烧录”到机器指令中),以及内存流量(通过将数据从堆分配的 Python 帧移动到物理硬件寄存器中)。

由于这些数据即使在程序的相同运行之间也会发生变化,并且现有的优化管道大量使用运行时剖析信息,因此提前编译这些跟踪没有多大意义,并且将是对已实现现有规范和微操作跟踪基础设施的重大重新设计。正如许多其他动态语言(甚至 Python 本身)所证明的那样,最有希望的方法是“即时”编译优化后的微操作以进行执行。

基本原理

尽管它们声名在外,JIT 编译器并不是神奇的“加速”机器。为单个平台(更不用说所有 CPython 最流行的支持平台)开发和维护任何形式的优化编译器都是一项极其复杂、昂贵的任务。使用像 LLVM 这样的现有编译器框架可以使这项任务更简单,但这会引入沉重的运行时依赖和显著更高的 JIT 编译开销。

显然,成功在运行时编译 Python 代码不仅需要针对正在运行的代码进行高质量的 Python 特定的优化,还需要快速为优化后的程序生成高效的机器代码。Python 核心开发团队具备前者的必要技能和经验(与解释器紧密耦合的中间层),而复制-修补编译为后者提供了诱人的解决方案。

简而言之,复制-修补允许从用于生成解释器其余部分的相同 DSL 生成高质量的模板 JIT 编译器。对于像 CPython 这样广泛使用、由志愿者驱动的项目来说,这一好处怎么强调都不为过:CPython 的维护者只需编辑字节码定义,就可以“免费”同时更新所有 JIT 支持平台的 JIT 后端。无论是添加、修改还是删除指令,都是如此。

与解释器的其余部分一样,JIT 编译器在构建时生成,并且没有运行时依赖。它支持广泛的平台(参见下面的支持部分),并且维护负担相对较低。总的来说,当前的实现由大约 900 行构建时 Python 代码和 500 行运行时 C 代码组成。

规范

JIT 目前不是默认构建配置的一部分,并且在可预见的未来可能仍将如此(尽管官方二进制文件可能包含它)。即便如此,一旦满足以下所有条件,JIT 将变为非实验性

  1. 它为至少一个流行平台提供了有意义的性能改进(实际情况是,大约 5%)。
  2. 它可以在最小干扰下构建、分发和部署。
  3. 指导委员会应请求确定,如果启用 JIT,它将比禁用时为社区提供更多价值(考虑维护负担、内存使用或替代设计的可行性等权衡)。

这些标准应被视为起点,并可能随着时间的推移而扩展。例如,对本 PEP 的讨论可能会揭示需要将额外的要求(例如多名承诺的维护者、安全审计、开发指南中的文档、进程外调试支持或禁用 JIT 的运行时选项)添加到此列表中。

在 JIT 变为非实验性之前,不应将其用于生产环境,并且可能随时中断或删除,恕不另行通知。

一旦 JIT 不再是实验性的,它应以与 --enable-optimizations--with-lto 等其他构建选项大致相同的方式对待。它可能是某些平台的推荐(甚至是默认)选项,并且发布经理可以选择在官方版本中启用它。

支持

JIT 已针对 PEP 11 的所有当前一级平台、大部分二级平台以及一个三级平台进行开发。具体来说,CPython 的 main 分支在以下平台上对发布和调试版本进行 JIT CI 构建和测试:

  • aarch64-apple-darwin/clang
  • aarch64-pc-windows/msvc [1]
  • aarch64-unknown-linux-gnu/clang [2]
  • aarch64-unknown-linux-gnu/gcc [2]
  • i686-pc-windows-msvc/msvc
  • x86_64-apple-darwin/clang
  • x86_64-pc-windows-msvc/msvc
  • x86_64-unknown-linux-gnu/clang
  • x86_64-unknown-linux-gnu/gcc

值得注意的是,一些平台,甚至是未来的第一级平台,可能永远无法获得 JIT 支持。这可能是由于各种原因,包括 LLVM 支持不足(powerpc64le-unknown-linux-gnu/gcc)、平台固有的限制(wasm32-unknown-wasi/clang),或缺乏开发者兴趣(x86_64-unknown-freebsd/clang)。

一旦为平台添加了 JIT 支持(意味着 JIT 成功构建而没有向用户显示警告),就应该按照 PEP 11 的规定进行处理:它应该有可靠的 CI/构建机器人,并且一级和二级平台上的 JIT 故障应该阻止发布。尽管不必更新 PEP 11 以指定 JIT 支持,但这样做可能仍然有帮助。否则,支持的平台列表应保留在 JIT 的 README 中。

由于始终可以在不使用 JIT 的情况下构建 CPython,因此移除对某个平台的 JIT 支持不应被视为向后不兼容的更改。但是,如果合理,则应遵循 PEP 387 中概述的正常弃用过程。

JIT 的构建时依赖可以在合理范围内在版本之间更改。

向后兼容性

由于当前的解释器和 JIT 后端都是从相同的规范生成的,因此 Python 代码的行为应该完全不变。实际上,在测试过程中发现并修复的可见差异往往是现有微操作转换和优化阶段中的错误,而不是复制-修补步骤中的错误。

调试

用于剖析和调试 Python 代码的工具将继续正常工作。这包括使用 Python 提供的功能(如 sys.monitoringsys.settracesys.setprofile)的进程内工具,以及从解释器状态遍历 Python 帧的进程外工具。

然而,针对 C 代码的剖析器和调试器目前似乎无法通过 JIT 帧进行回溯。使用叶子帧是可能的(JIT 本身就是这样调试的),但由于 JIT 帧缺乏适当的调试信息,其效用有限。

由于 JIT 发出的代码模板由 Clang 编译,因此可能可以通过简单地修改编译器标志以更谨慎地使用帧指针来允许 JIT 帧被跟踪。也可能收集并发出 Clang 生成的调试信息。这两个想法都没有被深入探索。

虽然这是一个应该修复的问题,但目前修复它并不是一个特别高的优先级。这可能是一个最好由在该领域拥有更多专业知识的人与 JIT 维护者合作探索的问题,因为 JIT 维护者对这些工具的内部工作原理知之甚少。

安全隐患

此 JIT 与任何 JIT 一样,在运行时生成大量可执行数据。这为 CPython 引入了一个潜在的新攻击面,因为能够影响此数据内容的恶意行为者因此能够执行任意代码。这是 JIT 编译器的一个众所周知的漏洞

为了减轻这种风险,JIT 在编写时考虑了最佳实践。特别是,JIT 编译器不会将所讨论的数据暴露给程序的其他部分,只要它仍可写,并且在任何时候,数据都不是既可写可执行的。

基于模板的 JIT 的性质也严重限制了可以生成的代码种类,进一步降低了成功利用的可能性。作为额外的预防措施,模板本身存储在静态、只读内存中。

然而,假定 JIT 中不存在任何可能的漏洞是天真的,尤其是在这个早期阶段。作者并非安全专家,但可随时加入或与 Python 安全响应团队密切合作,以在出现安全问题时进行分类和修复。

Apple Silicon

尽管在未实际签署和打包 macOS 版本的情况下难以测试,但 macOS 版本似乎应该为强化运行时启用 JIT 授权

这不应使 Python 的安装变得更困难,但可能会为发布经理增加额外的步骤。

如何教授此内容

选择最能描述你的部分

  • 如果你是 Python 程序员或最终用户……
    • ……你的一切照旧。在 JIT 仍是实验性功能期间,没有人应该向你分发启用 JIT 的 CPython 解释器。一旦它变为非实验性,你可能会注意到性能略有提高,内存使用量略有增加。你不应该能观察到任何其他变化。
  • 如果您是第三方软件包的维护者……
    • ……你的一切照旧。没有 API 或 ABI 更改,JIT 不会暴露给第三方代码。你不需要更改你的 CI 矩阵,并且你不应该能观察到 JIT 启用时你的包工作方式的差异。
  • 如果你剖析或调试 Python 代码……
    • ……你的一切照旧。所有 Python 剖析和跟踪功能都保持不变。
  • 如果你剖析或调试 C 代码……
    • ……目前,通过 JIT 帧进行跟踪的能力有限。如果你需要观察整个 C 调用堆栈,而不仅仅是“叶子”帧,这可能会导致问题。有关更多信息,请参阅上面的调试部分。
  • 如果你编译自己的 Python 解释器……
    • ……如果你不想构建 JIT,你可以直接忽略它。否则,你需要安装兼容版本的 LLVM,并将适当的标志传递给构建脚本。你的构建可能需要长达一分钟的时间。请注意,在 JIT 仍处于实验阶段时,不应将其分发给最终用户或用于生产环境。
  • 如果你是 CPython(或 CPython 的分支)的维护者……
    • ……并且你更改了字节码定义或主解释器循环……
      • ……一般来说,JIT 不会给你带来太多不便(取决于你想要做什么)。微操作解释器不会消失,并且仍然提供与当前主字节码解释器类似的调试体验。对解释器进行较大更改(例如添加新的局部变量、更改错误处理和去优化逻辑,或更改微操作格式)很可能需要更改用于生成 JIT 的 C 模板,该模板旨在模仿主解释器循环。你偶尔也可能会不幸地破坏 JIT 代码生成,这需要你自行修改 Python 构建脚本,或者寻求更熟悉它们的人的帮助(见下文)。
    • ……并且你正在研究 JIT 本身……
      • ……你希望你已经对你正在做的事情有了一个不错的概念。你将定期修改 Python 构建脚本、用于生成 JIT 的 C 模板以及构成 JIT 运行时部分的 C 代码。你还将处理各种崩溃,在调试器中逐步执行机器代码,盯着 COFF/ELF/Mach-O 转储,在各种平台上进行开发,并且通常是当 CI 开始在他们的 PR 上失败时(见上文)与更改字节码的人员的联系点。理想情况下,你至少熟悉汇编,上过几门名称中包含“编译器”的课程,并且阅读过一两篇关于链接器的博客文章。
    • ……并且你维护 CPython 的其他部分……
      • ……你的一切照旧。你不需要在本地使用 JIT 构建进行开发。如果你选择这样做(例如,为了帮助重现和分类 JIT 问题),每次相关文件被修改时,你的构建可能需要多达一分钟的时间。

参考实现

实施的关键部分包括

被拒绝的想法

在 CPython 之外维护它

虽然可能在 CPython 之外维护 JIT,但其实现与解释器其余部分的关联过于紧密,以至于保持其最新状态可能比实际开发 JIT 本身更困难。此外,从事现有微操作定义和优化的贡献者需要修改和构建两个独立的项​​目才能衡量其更改在 JIT 下的影响(而今天,存在自动为任何拟议更改执行此操作的基础设施)。

独立“JIT”项目的发布可能还需要与特定的 CPython 预发布和补丁发布相对应,具体取决于存在哪些更改。版本之间独立的 CPython 提交可能根本没有相应的 JIT 发布,这进一步使调试工作复杂化(例如通过二分法查找上游的破坏性更改)。

由于 JIT 已经相当稳定,并且最终目标是使其成为 CPython 的非实验性部分,因此将其保留在 main 中似乎是最好的前进方向。尽管如此,相关代码的组织方式使得如果 JIT 最终未能实现其目标,可以轻松“删除”它。

默认开启

另一方面,有人建议 JIT 应该以其当前形式默认启用。

再次强调,重要的是要记住 JIT 并非神奇的“加速”机器;目前,JIT 的速度与现有的专业化解释器大致相同。这听起来可能令人失望,但它实际上是一个相当显著的成就,也是这种方法被认为足够可行并被合并到 main 中以进行进一步开发的主要原因。

虽然 JIT 在现有微操作解释器方面提供了显著的性能提升,但当始终启用时,它尚未取得明确的胜利(尤其是考虑到其增加的内存消耗和额外的构建时依赖项)。这就是本 PEP 的目的:阐明应满足哪些客观标准才能“切换开关”的期望。

至少目前,将其放在 main 中,但默认关闭,似乎是始终开启和根本不提供之间的良好折衷。

支持多种编译器工具链

Clang 之所以特别需要,是因为它是唯一支持保证尾调用(musttail)的 C 编译器,而 CPython 的续传式 JIT 编译方法需要尾调用。没有它,模板之间的尾递归调用可能会导致无限制的 C 堆栈增长(并最终溢出)。

由于 LLVM 还包含 JIT 构建过程所需的其他功能(即,用于目标文件解析和反汇编的实用程序),并且额外的工具链会增加额外的测试和维护负担,因此目前只支持一个主要版本的一个工具链是很方便的。

编译基础解释器的字节码

复制-修补的现有技术大多将其用作快速基线 JIT,而 CPython 的 JIT 则使用该技术编译优化后的微操作跟踪。

在实践中,新 JIT 目前介于其他动态语言运行时的“基线”和“优化”编译器层之间。这是因为 CPython 使用其专门的自适应解释器来收集运行时剖析信息,这些信息用于检测和优化代码中的“热”路径。此步骤通过自修改代码执行,这是一种使用 JIT 编译器实现起来要困难得多的技术。

虽然可能使用复制-修补编译普通字节码(事实上,早期原型在微操作解释器之前就做到了这一点),但它似乎无法像更细粒度的微操作格式那样提供足够的优化潜力。

添加 GPU 支持

JIT 目前仅支持 CPU。例如,它不会像 Numba 等 JIT 那样,将 NumPy 数组计算卸载到 CUDA GPU。

已经有丰富的工具生态系统可以加速这类专业任务,CPython 的 JIT 无意取代它们。相反,它旨在提高通用 Python 代码的性能,这些代码不太可能从更深层次的 GPU 集成中受益。

未解决的问题

速度

目前,JIT 在大多数平台上与现有专用解释器速度大致相同。在这一点上,提高性能显然是首要任务,因为提供显著的性能提升是拥有 JIT 的全部动机。一些拟议的改进正在进行中,这项持续的工作正在 GH-115802 中进行跟踪。

内存

由于它为可执行机器代码分配额外的内存,JIT 在运行时确实比现有解释器使用更多的内存。根据官方基准测试,JIT 目前比基础解释器多使用大约 10-20% 的内存。此范围的上限是由于 aarch64-apple-darwin,它具有更大的页面大小(因此,更大的最小分配粒度)。

然而,这些数字应该慎重对待,因为基准测试本身的内存使用基线并不高。由于它们的代码与数据的比率更高,JIT 的内存开销比在典型工作负载中更明显,而内存压力在典型工作负载中更可能是一个真正的问题。

目前,尚未投入太多精力来优化 JIT 的内存使用,因此这些数字可能代表一个上限,未来会随着时间的推移而减少。改进这一点属于中等优先级,并在 GH-116017 中进行跟踪。我们将来可能会考虑公开可配置参数以限制内存消耗,但在 JIT 达到被视为非实验性的要求之前,不会公开任何官方 API。

JIT 的早期版本采用了一种更复杂的内存分配方案,对生成的代码的大小和布局施加了许多脆弱的限制,并显著增加了 Python 可执行文件的内存占用。这些问题在当前设计中已不复存在。

依赖

在撰写本文时,JIT 在构建时依赖 LLVM。LLVM 用于将单个微操作指令编译成机器代码块,然后将这些代码块链接在一起形成 JIT 的模板。这些模板用于构建 CPython 本身。JIT 在运行时不依赖 LLVM,因此完全不作为依赖项暴露给最终用户。

构建 JIT 会使构建过程增加 3 到 60 秒,具体取决于平台。它只会在生成文件过期时重建,因此只有那些积极开发主解释器循环的人才会频繁重建它。

与 CPython 中的许多其他生成文件不同,JIT 的生成文件不受 Git 跟踪。这是因为它们包含特定于主机平台以及该平台当前构建配置的编译二进制代码模板。因此,托管它们将需要大量的工程工作来为每个更改生成代码的提交构建和托管数十个大型二进制文件。虽然可能可行,但这并不是优先事项,因为对于大多数构建 CPython 的人来说,安装所需的工具并不困难,并且构建步骤也不特别耗时。

由于仍有人对此可能性感兴趣,讨论正在 GH-115869 中进行跟踪。

脚注


来源:https://github.com/python/peps/blob/main/peps/pep-0744.rst

最后修改:2025-02-01 07:28:42 GMT