PEP 590 – Vectorcall:CPython 的快速调用协议
- 作者:
- Mark Shannon <mark at hotpy.org>, Jeroen Demeyer <J.Demeyer at UGent.be>
- BDFL 委托:
- Petr Viktorin <encukou at gmail.com>
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 创建日期:
- 2019年3月29日
- Python 版本:
- 3.8
- 发布历史:
摘要
本 PEP 引入了一个新的 C API 来优化对象的调用。它引入了一个新的“vectorcall”协议和调用约定。这基于“fastcall”约定,CPython 已经在内部使用它。新功能可用于任何用户定义的扩展类。
大多数新 API 在 CPython 3.8 中是私有的。计划是在 Python 3.9 中最终确定语义并公开它。
注意:本 PEP 仅处理 Python/C API,不影响 Python 语言或标准库。
动机
调用约定的选择会影响调用两端代码的性能和灵活性。性能和灵活性之间往往存在张力。
当前的 tp_call [2] 调用约定足够灵活,可以覆盖所有情况,但其性能较差。性能不佳主要是由于在调用期间必须创建中间元组,可能还有中间字典。CPython 通过包含特殊情况代码来加速对 Python 和内置函数的调用,从而缓解了这个问题。不幸的是,这意味着其他可调用对象,如类和第三方扩展对象,使用较慢、更通用的 tp_call 调用约定进行调用。
本 PEP 提议将 Python 和内置函数内部使用的调用约定通用化并发布,以便所有调用都能从更好的性能中受益。新提出的调用约定并非完全通用,但涵盖了绝大多数调用。它旨在消除临时对象创建和多次间接调用的开销。
tp_call 约定中效率低下的另一个来源是它每个类有一个函数指针,而不是每个对象一个。这对于对类的调用来说效率低下,因为需要创建几个中间对象。对于一个类 cls,序列 type.__call__、cls.__new__、cls.__init__ 中的每个调用至少创建一个中间对象。
本 PEP 提出了一个供扩展模块使用的接口。如果没有消费者参与,此类接口无法有效地进行测试或设计。因此,我们提供了私有(带下划线前缀)名称。API 可能会在 Python 3.9 中发生变化(基于消费者反馈),我们预计它将在那时最终确定,并删除下划线。
规范
函数指针类型
通过函数指针进行调用,参数如下
PyObject *callable:被调用对象PyObject *const *args:参数向量size_t nargs:参数数量加上可选标志PY_VECTORCALL_ARGUMENTS_OFFSET(见下文)PyObject *kwnames:NULL或包含关键字参数名称的元组
这由函数指针类型实现:typedef PyObject *(*vectorcallfunc)(PyObject *callable, PyObject *const *args, size_t nargs, PyObject *kwnames);
PyTypeObject 结构体的更改
未使用的槽位 printfunc tp_print 被替换为 tp_vectorcall_offset。其类型为 Py_ssize_t。添加了一个新的 tp_flags 标志 _Py_TPFLAGS_HAVE_VECTORCALL,任何使用 vectorcall 协议的类都必须设置此标志。
如果设置了 _Py_TPFLAGS_HAVE_VECTORCALL,则 tp_vectorcall_offset 必须是正整数。它是对象中类型为 vectorcallfunc 的 vectorcall 函数指针的偏移量。此指针可能为 NULL,在这种情况下,行为与未设置 _Py_TPFLAGS_HAVE_VECTORCALL 时的行为相同。
重新使用 tp_print 槽位作为 tp_vectorcall_offset 槽位,以便外部项目更容易将 vectorcall 协议向后移植到更早的 Python 版本。特别是,Cython 项目对此表示了兴趣(参见 https://mail.python.org/pipermail/python-dev/2018-June/153927.html)。
描述符行为
另外指定了一个类型标志:Py_TPFLAGS_METHOD_DESCRIPTOR。
如果可调用对象使用描述符协议创建类似绑定方法(bound method)的对象,则应设置 Py_TPFLAGS_METHOD_DESCRIPTOR。解释器使用此标志来避免在调用方法时创建临时对象(参见 _PyObject_GetMethod 和 LOAD_METHOD/CALL_METHOD 操作码)。
具体来说,如果 type(func) 设置了 Py_TPFLAGS_METHOD_DESCRIPTOR,那么
func.__get__(obj, cls)(*args, **kwds)(当obj非 None) 必须等价于func(obj, *args, **kwds)。func.__get__(None, cls)(*args, **kwds)必须等价于func(*args, **kwds)。
对对象 func.__get__(obj, cls) 没有限制。后者不要求实现 vectorcall 协议。
调用
调用形式为 ((vectorcallfunc)(((char *)o)+offset))(o, args, n, kwnames),其中 offset 是 Py_TYPE(o)->tp_vectorcall_offset。调用者负责创建 kwnames 元组并确保其中没有重复项。
n 是位置参数的数量加上可选标志 PY_VECTORCALL_ARGUMENTS_OFFSET。
PY_VECTORCALL_ARGUMENTS_OFFSET
如果被调用者被允许临时更改 args[-1],则应将标志 PY_VECTORCALL_ARGUMENTS_OFFSET 添加到 n。换句话说,如果 args 指向分配向量中的参数 1,则可以使用此标志。被调用者必须在返回之前恢复 args[-1] 的值。
每当它们能够廉价地(无需分配)这样做时,鼓励调用者使用 PY_VECTORCALL_ARGUMENTS_OFFSET。这样做将允许可调用对象(如绑定方法)廉价地进行后续调用。字节码解释器已经在堆栈上为可调用对象分配了空间,因此它可以免费使用此技巧。
有关被调用者如何使用 PY_VECTORCALL_ARGUMENTS_OFFSET 以避免分配的示例,请参见 [3]。
要从参数 n 获取实际参数数量,必须使用宏 PyVectorcall_NARGS(n)。这允许将来的更改或扩展。
新的 C API 和 CPython 的更改
以下函数或宏已添加到 C API
PyObject *_PyObject_Vectorcall(PyObject *obj, PyObject *const *args, size_t nargs, PyObject *keywords):使用给定参数调用obj。请注意,nargs可能包含标志PY_VECTORCALL_ARGUMENTS_OFFSET。实际位置参数数量由PyVectorcall_NARGS(nargs)给出。参数keywords是关键字名称的元组或NULL。空元组与传递NULL效果相同。它在内部使用 vectorcall 协议或tp_call;如果两者都不支持,则会引发异常。PyObject *PyVectorcall_Call(PyObject *obj, PyObject *tuple, PyObject *dict):使用旧的*args和**kwargs调用约定调用对象(必须支持 vectorcall)。这主要用于放在tp_call槽位中。Py_ssize_t PyVectorcall_NARGS(size_t nargs):给定一个 vectorcallnargs参数,返回实际参数数量。目前等价于nargs & ~PY_VECTORCALL_ARGUMENTS_OFFSET。
子类化
扩展类型从基类继承类型标志 _Py_TPFLAGS_HAVE_VECTORCALL 和值 tp_vectorcall_offset,前提是它们以与基类相同的方式实现 tp_call。此外,如果 tp_descr_get 以与基类相同的方式实现,则继承标志 Py_TPFLAGS_METHOD_DESCRIPTOR。
堆类型永远不会继承 vectorcall 协议,因为那不安全(堆类型可以动态更改)。此限制将来可能会解除,但这需要在 type.__setattribute__ 中对 __call__ 进行特殊处理。
API 的最终确定
_PyObject_Vectorcall 和 _Py_TPFLAGS_HAVE_VECTORCALL 名称中的下划线表示此 API 可能会在 Python 的次要版本中更改。当最终确定时(计划在 Python 3.9 中),它们将被重命名为 PyObject_Vectorcall 和 Py_TPFLAGS_HAVE_VECTORCALL。旧的带下划线前缀的名称将作为别名继续可用。
新 API 将照常记录,但会发出上述警告。
本 PEP 中引入的其他名称(PyVectorcall_NARGS、PyVectorcall_Call、Py_TPFLAGS_METHOD_DESCRIPTOR、PY_VECTORCALL_ARGUMENTS_OFFSET)的语义已最终确定。
CPython 内部更改
现有类的更改
function, builtin_function_or_method, method_descriptor, method, wrapper_descriptor, method-wrapper 类将使用 vectorcall 协议(并非所有这些类都将在初始实现中更改)。
对于 builtin_function_or_method 和 method_descriptor(它们使用 PyMethodDef 数据结构),可以为每个现有调用约定实现一个特定的 vectorcall 包装器。是否有必要这样做还有待观察。
将 vectorcall 协议用于类
对于类 cls,使用 cls(xxx) 创建新实例需要多次调用。在 type.__call__、cls.__new__、cls.__init__ 序列中的每次调用至少会创建一个中间对象。因此,将 vectorcall 用于调用类非常有意义。这实际上意味着为 type 实现 vectorcall 协议。一些最常用的类将使用此协议,可能是 range、list、str 和 type。
PyMethodDef 协议和 Argument Clinic
Argument Clinic [4] 自动围绕低级可调用对象生成包装函数,提供原始类型的安全解包和其他安全检查。Argument Clinic 可以扩展以生成符合新 vectorcall 协议的包装对象。这将允许执行从调用者流向 Argument Clinic 生成的包装器,然后流向手写代码,仅进行一次间接调用。
使用 vectorcall 的第三方扩展类
为了使调用性能与 Python 函数和内置函数相媲美,第三方可调用对象应包含一个 vectorcallfunc 函数指针,将 tp_vectorcall_offset 设置为正确的值,并添加 _Py_TPFLAGS_HAVE_VECTORCALL 标志。任何执行此操作的类都必须实现 tp_call 函数并确保其行为与 vectorcallfunc 函数一致。将 tp_call 设置为 PyVectorcall_Call 即可。
这些更改对性能的影响
本 PEP 对现有代码的性能影响不大(无论好坏)。它主要旨在允许编写高效的新代码,而不是使现有代码更快。
尽管如此,本 PEP 优化了 METH_FASTCALL 函数。使用 METH_VARARGS 的函数性能会略有下降。
稳定的 ABI
本 PEP 的任何内容都不会添加到稳定的 ABI 中(PEP 384)。
替代建议
bpo-29259
PEP 590 与 bpo-29259 [1] 中提出的方案接近。主要区别在于本 PEP 将函数指针存储在实例中而不是类中。这对于在 C 中实现函数更有意义,因为每个实例对应一个不同的 C 函数。它还允许优化 type.__call__,这在 bpo-29259 中是不可能的。
PEP 576 和 PEP 580
PEP 576 和 PEP 580 都旨在使第三方对象既具有表现力又具有高性能(与 CPython 对象相当)。本 PEP 的目的是提供一种统一的方式来在 CPython 生态系统中调用对象,使其既具有表现力又尽可能地高性能。
本 PEP 的范围比 PEP 576 更广,并且使用可变而不是固定偏移的函数指针。底层调用约定是相似的。因为 PEP 576 只允许函数指针的固定偏移,所以它不允许对任何对其布局有约束的对象进行改进。
PEP 580 提出了对用于定义内置函数的 PyMethodDef 协议的重大更改。本 PEP 以新调用约定形式提供了更通用和简单的机制。本 PEP 还扩展了 PyMethodDef 协议,但仅仅是为了规范现有约定。
其他被拒绝的方法
曾经考虑过一种更长的、6 个参数的形式,结合了向量、可选元组和字典参数。然而,发现将其与旧的 tp_call 形式相互转换的代码过于繁琐且效率低下。此外,由于 x64 Windows 上只在寄存器中传递 4 个参数,因此额外的两个参数会产生不可忽略的开销。
也曾考虑过移除所有特殊情况并使所有调用都使用 tp_call 形式。然而,除非找到一种更有效的方法来创建和销毁元组,以及在较小程度上销毁字典,否则它会太慢。
致谢
感谢 Victor Stinner 在 CPython 内部开发了最初的“fastcall”调用约定。本 PEP 规范并扩展了他的工作。
参考资料
参考实现
最小实现可在 https://github.com/markshannon/cpython/tree/vectorcall-minimal 找到
版权
本文档已置于公共领域。
来源:https://github.com/python/peps/blob/main/peps/pep-0590.rst