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

Python 增强提案

PEP 523 – 在 CPython 中添加帧评估 API

作者:
Brett Cannon <brett at python.org>,Dino Viehland <dinoviehland at gmail.com>
状态:
最终版
类型:
标准跟踪
创建日期:
2016年5月16日
Python 版本:
3.6
发布历史:
2016年5月16日
决议:
Python-Dev 消息

目录

摘要

本 PEP 提议扩展 CPython 的 C API [2],以允许指定一个每解释器函数指针来处理帧的评估 [5]。本提案还建议在代码对象中添加一个新字段 [3],用于存储供帧评估函数使用的任意数据。

基本原理

Python 中一直缺乏灵活性的一点是 Python 代码的直接执行。虽然 CPython 的 C API [2] 允许构建进入帧对象的数据,然后通过 PyEval_EvalFrameEx() [5] 对其进行评估,但 Python 代码执行的控制权在于单个对象,而不是在帧级别对执行进行整体控制。

虽然希望影响帧评估可能看起来有点过于底层,但它确实为在 CPython 中引入方法级 JIT 提供了可能性,而无需 CPython 本身提供一个。通过允许外部 C 代码控制帧评估,JIT 可以在评估发生的关键点参与 Python 代码的执行。这使得 JIT 可以根据需要有条件地将 Python 字节码重新编译为机器代码,同时在不需要运行 JIT 时仍然可以执行常规 CPython 字节码。这可以通过允许解释器指定要调用哪个函数来评估帧来实现。通过将 API 放在帧评估级别,它允许 JIT 全面了解代码的执行环境。

这种指定帧评估函数的能力也允许除了为 CPython 开放 JIT 之外的其他用例。例如,使用此 API 在调用级别实现跟踪或分析功能并不困难。虽然 CPython 确实提供了在 Python 级别设置跟踪或分析功能的能力,但这将能够匹配分析器的数据收集,并且通过简单地跳过逐行跟踪支持,可能会更快地进行跟踪。

它还开辟了调试的可能性,即帧评估函数仅在检测到它即将执行特定代码对象时才执行特殊的调试工作。在这种情况下,字节码理论上可以在适当的位置就地重写以注入断点函数调用以帮助调试,而无需像 sys.settrace() 所要求的那样采用重手方法。

为了帮助促进这些用例,我们还提议通过一个新字段在代码对象上添加一个“暂存空间”。这将允许将每个代码对象数据与代码对象本身一起存储,以便帧评估函数在必要时轻松检索。该字段本身将只是一个 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

用于 GDB 中 Python 支持的生成的 python-gdb.py 文件对 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 对象,该对象存储四条信息

  1. 执行计数
  2. 一个布尔值,表示上次 JIT 尝试是否失败
  3. 一个指向跳板的函数指针(可以是类型跟踪或不跟踪)
  4. 一个指向任何 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 开发的用于向 CPython 添加 JIT 支持的 API。

调试

在与 Visual Studio 的 Python 工具团队(PTVS)[12] 的对话中,他们认为这些 API 更改对于实现更高性能的调试会很有用。如 原理 部分所述,此 API 将允许仅在需要调试功能的帧中打开调试功能。这可以允许跳过 sys.settrace() 通常提供的信息,甚至可以进一步动态重写字节码,以便在执行之前注入例如字节码中的断点。

事实证明,Google 在内部也提供了非常相似的 API。它已被用于高性能调试目的。

实施

Pyjion 项目 [1] 提供了实现提议 API 的一组补丁。其当前形式对 CPython 的更改比本提议的 API 更大,但这只是为了开发方便,而不是实现其目标的严格要求。

未解决的问题

允许 eval_frameNULL

目前,帧评估函数被期望始终设置。它也可以很容易地简单地默认为 NULL,这将表示使用 _PyEval_EvalFrameDefault()。当前不特殊处理该字段的提议似乎最直接,但它确实要求该字段不会意外被清除,否则可能会发生崩溃。

被拒绝的想法

一个 JIT 特定的 C API

最初,本 PEP 打算提出一个更庞大、更具 JIT 特性的 API 更改。然而,在征求 Numba 团队 [6] 的反馈后,很明显该 API 过于庞大。我们意识到,真正需要的只是提供一个跳板函数来处理已 JIT 编译的 Python 代码的执行,以及一种将编译后的机器代码与其他关键数据附加到相应 Python 代码对象的方法。一旦证明在不损失功能或性能的同时最小化所需的 API 更改,该提案就更改为当前形式。

co_extra 是否需要?

在 PyCon US 2016 讨论此 PEP 时,一些核心开发人员表达了他们对 co_extra 字段使代码对象可变的担忧。他们的想法似乎是,在代码对象创建后被修改的字段使该对象看起来是可变的,即使代码对象的其他方面没有改变。

本 PEP 认为 co_extra 字段不会改变代码对象是不可变的事实。本 PEP 中指定的该字段不包含使代码对象可用的所需信息,使其更像一个缓存字段。它可以被视为类似于字符串对象内部的 UTF-8 缓存;尽管字符串有一个有条件设置的字段,但它们仍然被认为是不可变的。

还对 JIT 工作负载不可用的字段进行了性能测量。当使用 C++ 的无序映射或 Python 的字典将代码对象与 JIT 特定数据对象关联时,丢失该字段被认为对性能而言代价太高。

参考资料


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

最后修改时间: 2025-10-03 20:38:03 GMT