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

Python 增强提案

PEP 659 – 解释器自适应优化

作者:
Mark Shannon <mark at hotpy.org>
状态:
最终版
类型:
信息性
创建日期:
2021年4月13日
发布历史:
2021年5月11日

目录

重要

本PEP是一份历史文档。最新的规范文档现在可以在解释器自适应优化中找到。

×

有关如何提出更改,请参阅 PEP 1

摘要

为了获得良好的性能,动态语言的虚拟机必须根据正在运行的程序中的类型和值来优化其执行的代码。这种优化通常与“JIT”编译器相关联,但即使没有机器代码生成,它也是有益的。

一个自适应的专业化解释器,是指根据当前操作的类型或值进行推测性优化,并适应这些类型和值的变化的解释器。

优化提高了我们的性能,而自适应则允许解释器在程序使用模式发生变化时迅速调整,从而限制了因错误优化而导致的额外工作量。

本PEP建议使用一个自适应的专业化解释器,该解释器积极地进行代码优化,但仅限于非常小的区域,并且能够以低成本快速调整错误的优化。

在CPython中添加一个自适应的专业化解释器将带来显著的性能提升。很难给出有意义的数字,因为它很大程度上取决于基准测试和尚未进行的工作。大量的实验表明,速度提升高达50%。即使速度提升只有25%,这也是一个值得的改进。

动机

Python被广泛认为是缓慢的。虽然Python永远无法达到C、Fortran甚至Java等底层语言的性能,但我们希望它能与像Javascript的V8或Lua的luajit等脚本语言的快速实现相竞争。具体来说,我们希望在CPython中实现这些性能目标,以造福所有Python用户,包括那些无法使用PyPy或其他替代虚拟机的用户。

实现这些性能目标还有很长的路要走,需要大量的工程投入,但我们可以通过加快解释器速度,朝着这些目标迈出重要一步。学术研究和实际实现都表明,快速的解释器是快速虚拟机的关键组成部分。

虚拟机的典型优化成本很高,因此需要较长的“预热”时间才能确信优化成本是合理的。为了快速获得加速,而没有明显的预热时间,虚拟机应该在函数执行几次后就推测优化是合理的。为了有效地做到这一点,解释器必须能够持续、廉价地进行优化和反优化。

通过在单个虚拟机指令的粒度上使用自适应和推测性优化,我们获得了更快的解释器,它还可以为未来更复杂的优化生成性能分析信息。

基本原理

有许多实际方法可以加速动态语言的虚拟机。然而,优化是最重要的,无论是其本身还是作为其他优化的推动者。因此,如果我们想提高CPython的性能,首先将精力集中在优化上是很有意义的。

优化通常在JIT编译器的背景下进行,但研究表明,解释器中的优化可以显著提高性能,甚至优于简单的编译器[1]

学术文献中提出了几种实现方法,但大多数都试图优化比单个字节码更大的区域[1] [2]。使用比单个指令更大的区域需要代码来处理区域中间的反优化。在单个字节码级别进行优化使得反优化变得微不足道,因为它不能发生在区域中间。

通过推测性地优化单个字节码,我们可以在不进行任何反优化的情况下获得显著的性能改进,除了最局部的、易于实现的反优化。

文献中最接近本PEP的方法是“内联缓存遇上快速化”[3]。本PEP具有内联缓存的优点,但增加了快速反优化的能力,使得在优化失败或不稳定时性能更加健壮。

性能

优化的加速很难确定,因为许多优化都依赖于其他优化。加速似乎在10%到60%之间。

  • 大部分加速直接来自优化。最大的贡献者是对属性查找、全局变量和函数调用的加速。
  • 一小部分但有用的加速来自改进的调度,例如超级指令和其他由快速化启用的优化。

实施

概述

任何受益于优化的指令都将被替换为该指令的“自适应”形式。执行时,自适应指令将根据其看到的类型和值进行优化。这个过程被称为“快速化”。

一旦代码对象中的指令执行了足够多次,该指令将被“优化”,替换为预期能更快执行该操作的新指令。

快速化

快速化是用更快的变体替换慢速指令的过程。

快速化代码比不可变字节码具有许多优点

  • 它可以在运行时更改。
  • 它可以使用跨行并接受多个操作数的超级指令。
  • 它不需要处理跟踪,因为它可以回退到原始字节码进行跟踪。

为了支持跟踪,快速化指令格式应与不可变的、用户可见的字节码格式匹配:16位指令,由8位操作码后跟8位操作数组成。

自适应指令

在快速化过程中,每一个受益于优化的指令都会被替换为自适应版本。例如,LOAD_ATTR指令将被替换为LOAD_ATTR_ADAPTIVE

每个自适应指令都会定期尝试进行自我优化。

优化

CPython字节码包含许多表示高级操作的指令,并且会受益于优化。例如CALLLOAD_ATTRLOAD_GLOBALBINARY_ADD

通过为这些指令中的每一个引入一个“家族”的专用指令,可以实现有效的优化,因为每个新指令都专用于单个任务。每个家族将包括一个“自适应”指令,该指令维护一个计数器,并在计数器归零时尝试进行自我优化。

每个家族还将包括一个或多个专用指令,这些指令在输入符合预期的情况下,会比通用操作快得多地执行等效操作。每个专用指令将维护一个饱和计数器,当输入符合预期时,该计数器将递增。如果输入不符合预期,计数器将递减,并执行通用操作。如果计数器达到最小值,该指令将通过简单地将其操作码替换为自适应版本来反优化。

辅助数据

大多数专用指令家族将需要比8位操作数所能容纳的更多信息。为此,指令后面紧跟的多个16位条目用于存储这些数据。这是一种内联缓存形式,即“内联数据缓存”。未优化或自适应指令将使用此缓存的第一个条目作为计数器,并简单地跳过其他条目。

指令族示例

LOAD_ATTR

LOAD_ATTR指令加载堆栈顶部对象的命名属性,然后用该属性替换堆栈顶部对象。

这是优化的一个明显候选。属性可能属于普通实例、类、模块或许多其他特殊情况之一。

LOAD_ATTR最初会被快速化为LOAD_ATTR_ADAPTIVE,它会跟踪其执行频率,并在执行足够多次后调用内部函数_Py_Specialize_LoadAttr,或者跳转到原始的LOAD_ATTR指令来执行加载。在优化时,会检查属性的类型,如果找到合适的专用指令,它将原地替换LOAD_ATTR_ADAPTIVE

LOAD_ATTR的优化可能包括

  • LOAD_ATTR_INSTANCE_VALUE 属性存储在对象的数值数组中,并且没有被覆盖的描述符遮蔽的常见情况。
  • LOAD_ATTR_MODULE 从模块加载属性。
  • LOAD_ATTR_SLOT 从定义了__slots__的对象的类加载属性。

请注意,这如何允许与其他优化互补的优化。LOAD_ATTR_INSTANCE_VALUE与许多对象使用的“惰性字典”配合得很好。

LOAD_GLOBAL

LOAD_GLOBAL指令在全局命名空间中查找名称,如果全局命名空间中不存在,则在内置命名空间中查找。在3.9版本中,LOAD_GLOBAL的C代码包括检查整个代码对象是否应被修改以添加缓存的代码,无论是在全局命名空间还是内置命名空间中查找值的代码,以及回退代码。这使得它变得复杂和臃肿。即使在所谓的优化之后,它也会执行许多冗余操作。

使用指令家族使得代码更易于维护和更快,因为每条指令只需处理一个关注点。

优化将包括

  • LOAD_GLOBAL_ADAPTIVE将像上面的LOAD_ATTR_ADAPTIVE一样操作。
  • LOAD_GLOBAL_MODULE 可以针对值在全局命名空间中的情况进行优化。在检查命名空间的键没有改变之后,它可以从存储的索引加载值。
  • LOAD_GLOBAL_BUILTIN 可以针对值在内置命名空间中的情况进行优化。它需要检查全局命名空间的键没有被添加,并且内置命名空间没有改变。请注意,我们不关心全局命名空间的值是否改变,只关心键。

有关完整实现,请参阅[4]

注意

本PEP概述了管理优化的机制,但没有指定要应用的具体优化。随着代码的进一步开发,细节甚至整个实现都可能发生变化。

兼容性

语言、库或API不会有任何变化。

用户检测新解释器存在的唯一方式是通过计时执行、使用调试工具或测量内存使用情况。

成本

内存使用

任何执行某种缓存的方案都会引起一个显而易见的担忧:“它会使用多少额外的内存?”简短的回答是“不多”。

与3.10的内存使用比较

CPython 3.10每条指令使用2字节,直到执行计数达到约2000次,此时它为每条指令额外分配1字节,对于带缓存的指令(LOAD_GLOBALLOAD_ATTR),则为32字节。

下表显示了在64位机器上,为支持3.10操作码缓存或拟议的自适应解释器而增加的每条指令的额外字节数。

版本 3.10 冷启动 3.10 热启动 3.11
专用 0% ~15% ~25%
代码 2 2 2
操作码映射 0 1 0
操作码缓存/数据 0 4.8 4
总计 2 7.8 6

3.10 冷启动是指代码尚未达到约2000的限制。3.10 热启动显示了达到阈值后缓存的使用情况。

相对内存使用取决于有多少代码“热”到足以触发在3.10中创建缓存。内存使用与3.11相同时的盈亏平衡点约为70%。

值得注意的是,实际的字节码只是代码对象的一部分。代码对象还包括名称、常量和大量调试信息。

总之,对于大多数函数相对未使用的应用程序,3.11将比3.10消耗更多内存,但不会多很多。

安全隐患

被拒绝的想法

通过实现带有内联数据缓存的自适应解释器,我们 implicitly 拒绝了许多优化CPython的替代方法。然而,值得强调的是,一些想法,例如即时编译,并未被拒绝,只是被推迟了。

在字节码之前存储数据缓存。

本PEP的早期3.11 alpha版本实现使用了如下所述的不同缓存方案

快速指令将以与原始字节码相同的格式存储在一个数组中(将其存储在Python对象中既不必要也不可取)。辅助数据将存储在单独的数组中。

每条指令将使用0个或更多数据条目。一个家族中的每条指令必须分配相同数量的数据,尽管某些指令可能不会使用全部数据。无法优化的指令(例如POP_TOP)不需要任何条目。实验表明,25%到30%的指令可以有效地优化。不同的家族将需要不同数量的数据,但大多数需要2个条目(在64位机器上为16字节)。

为了支持大于256条指令的函数,我们将指令的第一个数据条目的偏移量计算为(instruction offset)//2 + (quickened operand)

与Python 3.10中的opcache相比,这种设计

  • 更快;它无需内存读取来计算偏移量。3.10需要两次相关的读取。
  • 使用更少的内存,因为数据对于不同的指令族可以有不同的尺寸,并且不需要额外的偏移量数组。可以支持更大的函数,每个函数最多约5000条指令。3.10可以支持约1000条指令。

我们拒绝了这种方案,因为内联缓存方法更快更简单。

参考资料


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

最后修改:2024-10-29 10:45:35 GMT