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 <savannahostrowski at gmail.com>
讨论列表:
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. 指导委员会在收到请求后,已确定如果启用它比禁用它对社区更有价值(考虑到维护负担、内存使用或替代设计的可行性等权衡)。

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

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

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

支持

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

  • 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/buildbots,并且第一层和第二层平台上的 JIT 故障应该阻止发布。虽然没有必要更新PEP 11 来指定 JIT 支持,但这样做可能会有所帮助。否则,应在JIT 的自述文件中维护受支持平台的列表。

由于始终应该能够在没有 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 一样,在运行时会生成大量可执行数据。这为 CPython 引入了潜在的新攻击面,因为能够影响此数据内容的恶意行为者因此能够执行任意代码。这是 JIT 编译器的众所周知的漏洞

为了降低这种风险,JIT 的编写考虑了最佳实践。特别是,问题数据在 JIT 编译器中保持可写状态时不会暴露给程序的其他部分,并且在任何时候数据都不会同时可写可执行

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

但是,假设 JIT 中不存在任何可能的漏洞是幼稚的,尤其是在这个早期阶段。作者不是安全专家,但愿意加入或与 Python 安全响应团队紧密合作,对出现的安全问题进行分类和修复。

苹果硅

虽然在没有实际签名和打包 macOS 版本的情况下很难测试,但似乎macOS 版本应该为强化运行时启用 JIT 授权

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

如何教授

选择最能描述您的部分

  • 如果您是 Python 程序员或最终用户…
    • …对您来说没有任何变化。在它仍然是实验性功能时,没有人应该向您分发启用 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,因为它是在 C 编译器中唯一支持保证尾调用(musttail)的编译器,而尾调用是 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

上次修改时间:2024-09-12 20:31:23 GMT