PEP 659 – 自适应解释器的专门化
- 作者:
- Mark Shannon <mark at hotpy.org>
- 状态:
- 草稿
- 类型:
- 信息性
- 创建:
- 2021年4月13日
- 发布历史:
- 2021年5月11日
摘要
为了获得良好的性能,动态语言的虚拟机必须将其执行的代码专门化到正在运行的程序中的类型和值。这种专门化通常与“JIT”编译器相关联,但即使没有机器代码生成,它也是有益的。
自适应专门化解释器是指一个解释器,它会根据当前正在操作的类型或值进行推测性专门化,并适应这些类型和值的更改。
专门化使我们获得了更好的性能,而自适应允许解释器在程序的使用模式发生变化时快速更改,从而限制了由于错误专门化而导致的额外工作量。
本 PEP 提出使用一个专门化自适应解释器,该解释器积极地专门化代码,但在一个非常小的区域内,并且能够快速且低成本地适应错误专门化。
向 CPython 添加自适应专门化解释器将带来显著的性能提升。很难给出有意义的数字,因为它在很大程度上取决于基准测试和尚未完成的工作。大量的实验表明,加速可达 50%。即使加速只有 25%,这仍然是一个值得的增强。
动机
Python 被广泛认为速度缓慢。虽然 Python 永远无法达到像 C、Fortran 甚至 Java 这样的低级语言的性能,但我们希望它能够与脚本语言的快速实现相竞争,例如 Javascript 的 V8 或 lua 的 luajit。具体来说,我们希望通过 CPython 实现这些性能目标,使所有 Python 用户受益,包括无法使用 PyPy 或其他替代虚拟机的用户。
实现这些性能目标还有很长的路要走,并且需要大量的工程工作,但我们可以通过加速解释器来朝着这些目标迈出重要的一步。学术研究和实践实现都表明,快速的解释器是快速虚拟机的重要组成部分。
虚拟机的典型优化成本很高,因此需要很长的“预热”时间才能确信优化成本是合理的。为了快速获得加速,并且没有明显的预热时间,即使在函数执行了几次之后,VM 也应该推测专门化是合理的。为了有效地做到这一点,解释器必须能够持续且非常廉价地优化和反优化。
通过在单个虚拟机指令的粒度上使用自适应和推测性专门化,我们得到了一个更快的解释器,它还可以为将来的更复杂的优化生成性能分析信息。
基本原理
有很多实用的方法可以加速动态语言的虚拟机。但是,专门化是最重要的,无论是在其自身方面还是作为其他优化的推动因素。因此,如果我们想提高 CPython 的性能,那么首先专注于专门化是有意义的。
专门化通常在 JIT 编译器的上下文中完成,但研究表明,即使超过简单的编译器 [1],解释器中的专门化也可以显著提高性能。
学术文献中已经提出了几种实现此目的的方法,但大多数方法尝试优化的区域都大于单个字节码 [1] [2]。使用大于单个指令的区域需要代码处理区域中间的反优化。在单个字节码级别进行专门化使反优化变得微不足道,因为它不会发生在区域的中间。
通过推测性地专门化单个字节码,我们可以获得显著的性能改进,而无需任何最局部的,并且易于实现的反优化。
文献中与本 PEP 最接近的方法是“内联缓存遇见加速” [3]。本 PEP 具有内联缓存的优点,但增加了快速反优化的能力,使性能在专门化失败或不稳定时更强大。
性能
专门化带来的加速难以确定,因为许多专门化依赖于其他优化。加速似乎在 10% - 60% 的范围内。
- 大部分加速直接来自专门化。最大的贡献者是属性查找、全局变量和调用的加速。
- 一小部分,但很有用,来自改进的分派,例如超级指令和其他由加速启用的优化。
实现
概述
任何可以从专门化中获益的指令都将被该指令的“自适应”形式替换。执行时,自适应指令将根据其看到的类型和值专门化自身。此过程称为“加速”。
一旦代码对象中的指令执行了足够多次,该指令将通过将其替换为预计对该操作执行速度更快的指令来“专门化”。
加速
加速是用更快的变体替换慢速指令的过程。
加速的代码与不可变字节码相比具有一些优势
- 它可以在运行时更改。
- 它可以使用跨越多行并采用多个操作数的超级指令。
- 它不需要处理跟踪,因为它可以回退到原始字节码来执行此操作。
为了支持跟踪,加速的指令格式应与不可变的、用户可见的字节码格式匹配:8 位操作码后跟 8 位操作数的 16 位指令。
自适应指令
在加速期间,每个可以从专门化中获益的指令都将被自适应版本替换。例如,LOAD_ATTR
指令将被 LOAD_ATTR_ADAPTIVE
替换。
每个自适应指令都会定期尝试专门化自身。
专门化
CPython 字节码包含许多表示高级操作的指令,并且可以从专门化中获益。示例包括 CALL
、LOAD_ATTR
、LOAD_GLOBAL
和 BINARY_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
一个常见情况,其中属性存储在对象的 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 时,它会为每个指令分配另一个字节,并在带有缓存的情况下为每个指令分配 32 个字节(LOAD_GLOBAL
和 LOAD_ATTR
)。
下表显示了在 64 位机器上支持 3.10 opcache 或提议的自适应解释器的每个指令的额外字节数。
版本 | 3.10 冷启动 | 3.10 热启动 | 3.11 |
专门化 | 0% | ~15% | ~25% |
代码 | 2 | 2 | 2 |
opcache_map | 0 | 1 | 0 |
opcache/数据 | 0 | 4.8 | 4 |
总计 | 2 | 7.8 | 6 |
3.10 cold
指代码尚未达到约 2000 的限制之前。 3.10 hot
显示达到阈值后缓存的使用情况。
相对内存使用量取决于有多少代码“足够热”以触发在 3.10 中创建缓存。盈亏平衡点(3.10 使用的内存与 3.11 相同)约为 70%。
还需要注意的是,实际的字节码只是代码对象的一部分。代码对象还包括名称、常量和大量调试信息。
总而言之,对于大多数应用程序(其中许多函数相对未使用),3.11 将比 3.10 消耗更多内存,但不会多太多。
安全隐患
无
被拒绝的想法
通过使用内联数据缓存实现专门化的自适应解释器,我们隐式地拒绝了许多优化 CPython 的替代方法。但是,值得强调的是,一些想法,例如即时编译,并没有被拒绝,只是被推迟了。
在字节码之前存储数据缓存。
3.11 alpha 的早期 PEP 实现使用了如下所述的不同缓存方案
加速指令将存储在一个数组中(没有必要也没有必要将它们存储在 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 条指令。
我们拒绝了此方案,因为内联缓存方法更快且更简单。
参考文献
版权
本文档置于公共领域或根据 CC0-1.0-Universal 许可证,以两者中许可范围更宽者为准。
来源: https://github.com/python/peps/blob/main/peps/pep-0659.rst
上次修改时间: 2023-09-09 17:39:29 GMT