PEP 523 – 在 CPython 中添加帧评估 API
- 作者:
- Brett Cannon <brett at python.org>, Dino Viehland <dinov at microsoft.com>
- 状态:
- 最终
- 类型:
- 标准跟踪
- 创建:
- 2016-05-16
- Python 版本:
- 3.6
- 历史记录:
- 2016-05-16
- 决议:
- Python-Dev 邮件
摘要
本 PEP 提出扩展 CPython 的 C API [2],以允许指定一个每解释器函数指针来处理帧的评估 [5]。本提案还建议在代码对象中添加一个新字段 [3],用于存储帧评估函数使用的任意数据。
理由
Python 在直接执行 Python 代码方面一直缺乏灵活性。虽然 CPython 的 C API [2] 允许构建进入帧对象的数据,然后通过 PyEval_EvalFrameEx()
[5] 评估它,但对 Python 代码执行的控制权归结于单个对象,而不是在帧级别对执行进行整体控制。
虽然希望对帧评估有影响力可能看起来太底层了,但它确实为将方法级 JIT 引入 CPython 开启了可能性,而无需 CPython 本身提供一个。通过允许外部 C 代码控制帧评估,JIT 可以参与 Python 代码的执行,并在评估发生的关键点参与执行。然后,这允许 JIT 有条件地将 Python 字节码重新编译为机器码,同时在不需要运行 JIT 时仍然允许执行常规的 CPython 字节码。这可以通过允许解释器指定要调用哪个函数来评估帧来实现。通过将 API 放在帧评估级别,它允许 JIT 对代码的执行环境进行完整的视图。
指定帧评估函数的能力也允许使用超出仅向 CPython 打开 JIT 的其他用例。例如,使用此 API 在调用级别实现跟踪或分析函数并不难。虽然 CPython 提供了在 Python 级别设置跟踪或分析函数的能力,但这将能够匹配分析器的 数据收集,并且可能通过简单地跳过每行跟踪支持来更快地进行跟踪。
它还打开了调试的可能性,其中帧评估函数仅在检测到它将要执行特定代码对象时执行特殊调试工作。在这种情况下,理论上可以就地重写字节码,以在适当的位置注入断点函数调用,以帮助调试,而无需像 sys.settrace()
所需那样采取严格的方式。
为了帮助促进这些用例,我们还建议通过一个新字段在代码对象上添加“scratch space”。这将允许在代码对象本身存储每个代码对象数据,以便帧评估函数根据需要轻松检索它。该字段本身将只是一个 PyObject *
类型,因此存储在该字段中的任何数据都将参与正常的对象内存管理。
提案
下面提出的所有 C API 更改将不属于稳定的 ABI。
扩展 PyCodeObject
将在 PyCodeObject
结构体中添加一个字段 [3]
typedef struct {
...
void *co_extra; /* "Scratch space" for the code object. */
} PyCodeObject;
co_extra
默认情况下将为 NULL
,仅在需要时填充。存储在该字段中的值预计在代码对象正常运行时不需要,因此允许该字段数据丢失是可以接受的。
已引入一个私有 API 用于处理该字段
PyAPI_FUNC(Py_ssize_t) _PyEval_RequestCodeExtraIndex(freefunc);
PyAPI_FUNC(int) _PyCode_GetExtra(PyObject *code, Py_ssize_t index,
void **extra);
PyAPI_FUNC(int) _PyCode_SetExtra(PyObject *code, Py_ssize_t index,
void *extra);
该字段的用户预计将调用 _PyEval_RequestCodeExtraIndex()
来接收(应该被视为)一个不透明的索引值,用于将数据添加到 co-extra
中。使用该索引,用户可以使用 _PyCode_SetExtra()
设置数据,然后使用 _PyCode_GetExtra()
检索数据。API 有意列为私有,以传达这样一个事实,即在 Python 版本之间,API 没有语义保证。
使用列表和元组被认为,但发现性能较差,而且由于一个主要用例是 JIT 使用,因此性能考虑在使用自定义结构体而不是 Python 对象方面胜出。
字典也被考虑过,但性能再次更加重要。虽然字典在查找数据时将具有恒定的开销,但对于存储在数据结构中的单个对象的常见情况,元组具有更好的性能特征(即,迭代长度为 1 的元组比哈希和查找对象的开销更快在字典中)。
扩展 PyInterpreterState
帧评估函数的入口点是每个解释器
// Same type signature as PyEval_EvalFrameEx().
typedef PyObject* (*_PyFrameEvalFunction)(PyFrameObject*, int);
typedef struct {
...
_PyFrameEvalFunction eval_frame;
} PyInterpreterState;
默认情况下,eval_frame
字段将初始化为一个函数指针,它代表 PyEval_EvalFrameEx()
的当前值(称为 _PyEval_EvalFrameDefault()
,将在本 PEP 中的后面部分讨论)。第三方代码可以随后设置自己的帧评估函数来控制 Python 代码的执行。可以使用指针比较来检测该字段是否设置为 _PyEval_EvalFrameDefault()
,因此尚未被修改。
对 Python/ceval.c
的更改
PyEval_EvalFrameEx()
[5] 按照目前的标准将被重命名为 _PyEval_EvalFrameDefault()
。新的 PyEval_EvalFrameEx()
将成为
PyObject *
PyEval_EvalFrameEx(PyFrameObject *frame, int throwflag)
{
PyThreadState *tstate = PyThreadState_GET();
return tstate->interp->eval_frame(frame, throwflag);
}
这允许第三方代码将自己直接放置在 Python 代码执行的路径中,同时向后兼容已经使用现有 C API 的代码。
更新 python-gdb.py
生成的 python-gdb.py
文件用于 GDB 中的 Python 支持,对 PyEval_EvalFrameEx()
做了一些硬编码的假设,例如局部变量的名称。它需要更新以与建议的更改一起使用。
性能影响
由于本 PEP 提出了一种添加可插拔性的 API,因此性能影响仅在没有第三方代码进行任何更改的情况下进行考虑。
pybench [14] 的多次运行一致地表明,仅 API 更改不会产生性能成本。
Python 基准测试套件的运行 [9] 表明性能没有可衡量的成本。
在内存影响方面,由于通常在一个进程中不会有太多 CPython 解释器执行,这意味着唯一令人担忧的是 co_extra
被添加到 PyCodeObject
中的影响。根据 [8],Python 测试套件的运行导致大约 72,395 个代码对象被创建。在 64 位 CPU 上,如果所有代码对象同时处于活动状态并且它们的 co_extra
字段中没有设置任何内容,则将导致额外的 579,160 字节内存被使用。
使用示例
CPython 的 JIT
Pyjion
Pyjion 项目 [1] 已使用此提出的 API 来使用 CoreCLR 的 JIT [4] 为 CPython 实现 JIT。每个代码对象都有其 co_extra
字段设置为一个 PyjionJittedCode
对象,该对象存储四个信息
- 执行次数
- 一个布尔值,表示之前尝试 JIT 是否失败
- 一个指向蹦床的函数指针(可以是类型跟踪的,也可以不是类型跟踪的)
- 一个指向任何 JIT 编译的机器码的 void 指针
帧评估函数具有(大致)以下算法
def eval_frame(frame, throw_flag):
pyjion_code = frame.code.co_extra
if not pyjion_code:
frame.code.co_extra = PyjionJittedCode()
elif not pyjion_code.jit_failed:
if not pyjion_code.jit_code:
return pyjion_code.eval(pyjion_code.jit_code, frame)
elif pyjion_code.exec_count > 20_000:
if jit_compile(frame):
return pyjion_code.eval(pyjion_code.jit_code, frame)
else:
pyjion_code.jit_failed = True
pyjion_code.exec_count += 1
return _PyEval_EvalFrameDefault(frame, throw_flag)
关键点是,所有这些工作和逻辑都与 CPython 分开,但使用提出的 API 更改,它能够提供与 Python 语义兼容的 JIT(截至撰写本文时,性能几乎等同于没有新 API 的 CPython)。这意味着在技术上没有任何东西可以阻止其他人通过利用提出的 API 为 CPython 实现自己的 JIT。
其他 JIT
应该提到,Pyston 团队咨询了本 PEP 的早期版本,该版本更特定于 JIT,他们对利用提出的更改不感兴趣,因为他们想要控制内存布局,他们对直接支持 CPython 本身没有兴趣。与 PyPy 团队开发人员的非正式讨论得出了类似的评论。
另一方面,Numba [6] 建议他们对提出的更改感兴趣,以备将来在 1.0 版本之后使用 [7]。
实验性的 Coconut JIT [13] 本可以从本 PEP 中受益。在与 Coconut 创建者的私人对话中,我们被告知我们的 API 可能优于他们为 Coconut 开发的 API,以将 JIT 支持添加到 CPython。
调试
与 Python Tools for Visual Studio 团队 (PTVS) [12] 交流后,他们认为这些 API 更改对于实现更高性能的调试非常有用。如 基本原理 部分所述,此 API 允许仅在需要的地方启用调试功能。这可以跳过 sys.settrace()
通常提供的信息,甚至可以执行到在执行之前动态重写字节码以在字节码中注入断点。
事实证明,Google 内部也提供了一个非常类似的 API。它已被用于高性能调试目的。
实现
通过 Pyjion 项目 [1] 可以获得实现所提议 API 的一组补丁。以其目前的形态,它对 CPython 的修改远不止这个提议的 API,但这仅仅是为了开发的方便,而不是为了实现其目标的严格要求。
未解决的问题
允许 eval_frame
为 NULL
目前,框架评估函数始终被预期设置。它可以很容易地简单地默认为 NULL
,这将指示使用 _PyEval_EvalFrameDefault()
。当前不特殊处理该字段的提议看起来最直接,但它确实要求该字段不会意外被清除,否则可能会发生崩溃。
被拒绝的想法
特定于 JIT 的 C API
最初,这个 PEP 将提议一个更大范围的 API 更改,该更改更具体地针对 JIT。然而,在征求了 Numba 团队 [6] 的反馈后,很明显 API 过于庞大。人们意识到,真正需要的只是提供一个弹跳函数来处理已进行 JIT 编译的 Python 代码的执行,以及一种方法将该编译后的机器代码以及其他关键数据附加到相应的 Python 代码对象。一旦证明,在最小化所需的 API 更改的同时,不会丢失功能或性能,提议便更改为当前形式。
是否需要 co_extra?
在 PyCon US 2016 上讨论这个 PEP 时,一些核心开发人员表达了他们对 co_extra
字段使代码对象可变的担忧。这种想法似乎是,拥有一个在代码对象创建后被修改的字段使得该对象看起来是可变的,即使代码对象的任何其他方面都没有改变。
这个 PEP 的观点是,co_extra
字段不会改变代码对象是不可变的事实。这个 PEP 中指定了该字段不包含使代码对象可用的必要信息,使其更像是缓存字段。它可以被认为类似于字符串对象内部的 UTF-8 缓存;即使它们有一个条件设置的字段,字符串仍然被认为是不可变的。
对于 JIT 工作负载,人们也进行了字段不可用的性能测量。当使用来自 C++ 或 Python 的 dict 的无序映射来将代码对象与 JIT 特定数据对象相关联时,字段的丢失被认为对性能过于昂贵。
参考文献
版权
本文件已置于公共领域。
来源:https://github.com/python/peps/blob/main/peps/pep-0523.rst
最后修改时间:2023-09-09 17:39:29 GMT