NEP 53 — NumPy 2.0 的 NumPy C-API 演进#

作者:

Sebastian Berg <sebastianb@nvidia.com>

状态:

草稿

类型:

标准

创建:

2022-04-10

摘要#

NumPy C-API 用于下游项目(通常通过 Cython)扩展 NumPy 功能。支持这些包通常意味着演进我们的 C-API 速度缓慢,并且某些更改无法在正常的 NumPy 版本中进行,因为 NumPy 必须保证向后兼容性:针对旧 NumPy 版本(例如 1.17)编译的下游包(例如 SciPy 1.10)通常可以与新 NumPy 版本(例如 1.25)一起使用。

NumPy 2.0 版本允许部分打破这一承诺:我们可以接受 SciPy 版本在 NumPy 1.17(例如 SciPy 1.10)下编译,不会与 NumPy 2.0 兼容。但是,它仍然必须很容易创建单个 SciPy 二进制文件,该二进制文件与 NumPy 1.x 和 NumPy 2.0 都兼容。

考虑到这些约束,本 NEP 概述了允许对我们的 C-API 进行重大更改的途径。类似于为 NumPy 2.0 提出的 Python API 更改,NEP 目标是允许进行大多数下游包预计不需要或只需要少量代码更改的更改。

此 NEP 的实现将包括两个步骤

  1. 作为一般改进的一部分,从 NumPy 1.25 开始,使用 NumPy 构建将默认导出旧 API 版本,以允许与最新可用 NumPy 版本的向后兼容构建。(除非选择加入,否则新 API 不可用。)

  2. NumPy 2.0 将

    • 要求下游包重新编译以与 NumPy 2.0 兼容。

    • 需要在 NumPy 1.x 上运行时依赖 `numpy2_compat`。

    • 需要一些下游代码更改以适应更改的 API。

动机和范围#

NumPy API 包含 300 多个函数和许多宏。其中许多已经过时:一些仅在 NumPy 内部使用,仅与 NumPy 的前身兼容,或者没有或只有一个已知下游用户(即 SciPy)。

此外,NumPy 使用的许多结构一直是公开的,这使得无法在主要版本之外更改它们。多年来一直计划进行一些更改,这正是 `NPY_NO_DEPRECATED_API` 和进一步弃用的原因,如 C API 弃用 中所述。

虽然我们可能没有理由更改数组结构的布局(例如 `PyArrayObject_fields`),但通过更改 `PyArray_Descr` 结构,可以更容易地开发和改进 dtypes。

此 NEP 提出了一些具体的 C-API 更改,主要作为示例。但是,更多更改将按情况处理,我们不打算在此 NEP 中提供完整的更改列表。

添加状态不在范围之内#

新的开发,例如 CPython 对子解释器的支持和 HPy API,可能需要 NumPy C-API 以一种可能需要(或至少更喜欢)传递状态的方式演进。

目前,我们不打算在此处包含这些更改。我们不能期望用户对许多 NumPy 函数进行大量代码更新以传递例如 `HPy` 上下文。

虽然我们可以在 NumPy 2.0 中为此目的引入第二个 API,但我们预计这没有必要,并且此处引入的规定

  • 能够使用最新的 NumPy 版本进行编译,但与旧版本兼容,

  • 以及更新 `numpy2_compat` 包的可能性。

应该允许在次要版本中添加这样的 API。

用法和影响#

向后兼容构建#

将在文档中更详细地描述向后兼容构建。简而言之,我们将允许用户使用类似于

#define NPY_TARGET_VERSION NPY_1_22_API_VERSION

来选择他们希望编译的版本(最低兼容版本)。默认情况下,向后兼容性将是生成的二进制文件与支持相同 Python 版本的最旧 NumPy 版本兼容:NumPy 1.19.x 是第一个支持 Python 3.9 的,NumPy 1.25 支持 Python 3.9 或更高版本,因此 NumPy 1.25 默认设置为 1.19 兼容性。因此, API 的用户可能需要添加定义,但希望与旧版本兼容的用户无需执行任何操作,除非他们希望具有非常长的兼容性。

过去几年的 API 添加非常有限,因此这种变化最多只对全球少数用户必要。

此机制与 Python 受限 API 非常相似,因为 NumPy 的 C-API 对 ABI 稳定性有类似的需求。

破坏 C-API 和更改 ABI#

NumPy 有太多函数,其中许多是别名。以下是我们将删除的示例,用户必须适应以与 NumPy 2.0 兼容:

  • `PyArray_Mean` 和 `PyArray_Std` 是与 `arr.mean()` 和 `arr.std()` 类似的未经测试的实现。我们计划删除这些,因为它们可以用方法调用来替换。

  • `MapIter` API 函数集(和结构)允许下游实现高级索引语义。有一个唯一的历史已知用户(theano),并且用例可以用不同的方式以更快速、更简单的方式实现。API 复杂,需要深入 NumPy 才能有用,并且它的公开性使实现更困难。除非发现新的重要用例,否则我们建议将其删除。

ABI 更改的示例是更改 `PyArray_Descr`(`np.dtype` 实例的结构)的布局,以允许更大的最大项目大小和新的标志(对未来的自定义用户 DTypes 有用)。对于此特定更改,直接访问结构字段的用户需要更改其代码。下游搜索显示这不太常见,主要影响是

  • 对 `descr->elsize` 字段(和其他字段)的访问将需要用类似 `PyDataType_ITEMSIZE(descr)` 的宏替换(NumPy 可能在需要时包含版本检查)。

  • 自定义数据类型的实现者将需要更改几行代码,幸运的是,这样的自定义数据类型很少。(详细信息是我们将结构重命名为 `PyArray_DescrProto` 作为静态定义,并显式从 NumPy 获取实际实例。)

最后一个示例是将 `NPY_MAXDIMS` 提高到 `64`。`NPY_MAXDIMS` 主要用于静态分配临时空间

func(PyArrayObject *arr) {
    npy_intp shape[NPY_MAXDIMS];
    /* Work with a shape or strides from the array */
}

如果 NumPy 在次要版本中将其更改为 64,这会导致如果代码使用 `NPY_MAXDIMS=32` 编译,但传递了一个 40 维数组,则会导致未定义的行为。但是,较大的值也是先前 NumPy 版本的正确最大值,因此对 NumPy 2.0 更改通常是安全的。(可以想象代码想要知道实际的运行时值。我们实践中没有看到这样的代码,但它需要进行调整。)

对 Cython 用户的影响#

Cython 用户可以使用 `cimport numpy as cnp` 通过 NumPy C-API。由于 Cython 开发的不确定性,对 Cython 用户的影响有两种情况。

如果可以依赖 Cython 3,那么 Cython 用户的影响小于 C-API 用户,因为 Cython 3 允许我们隐藏结构布局更改(例如对 `PyArray_Descr` 的更改)。如果情况并非如此,并且我们必须支持 Cython 0.29.x(这是 Cython 3 之前的历史分支),那么 Cython 用户也必须使用类似 `PyDataType_ITEMSIZE()` 的函数/宏(或使用 Python 对象)。这在 Cython 代码中不太典型,但对于 dtype 结构字段/属性的常见模式也不太可能。

进一步的影响是,一些未来的 API 添加,例如新类,可能需要放置在单独的 `.pyd` 文件中,以避免 Cython 生成在旧版 NumPy 上失败的代码。

最终用户和打包影响#

以与 NumPy 2.0 兼容的方式打包将需要重新编译依赖于 NumPy C-API 的下游库。这可能需要一些时间,尽管希望在 NumPy 2.0 本身发布之前开始此过程。

此外,为了更容易地在 NumPy 2.0 中进行更大的更改,我们预计将创建一个 `numpy2_compat` 包。当一个库使用 NumPy 2.0 构建但想要支持 NumPy 1.x 时,它将必须依赖 `numpy2_compat`。最终用户无需了解此依赖关系,并且当模块缺失时,可以引发信息性错误。

一些新的 API 可以回溯移植#

允许用户使用最新版本的 NumPy 编译的一个很大优势是,在某些情况下,我们可以回溯移植新的 API。一些新的 API 函数可以根据旧的函数或直接包含。

注意

通过兼容的 `numpy2_compat` 包,可能可以公开存在于 NumPy 1.x 中但私有的函数。

这意味着在某些新的 API 添加中,可以更快地提供给下游用户。它们需要新的 NumPy 版本进行编译,但它们的轮子可以与早期版本向后兼容。

实现#

第一部分实现(允许构建较早的 API 版本)非常简单,因为 NumPy C-API 多年以来演变缓慢。一些结构字段默认情况下将被隐藏,并且在较新版本中引入的函数将被标记并隐藏,除非用户选择加入较新的 API 版本。可以在 PR 23528 中找到实现。

第二部分主要关于以不破坏向后兼容性的方式识别和实现所需的更改,并且 API 突破对下游库来说仍然易于管理。我们进行的每个更改都必须简要说明如何适应 API 更改(即替代函数)。

NumPy 2 兼容性和 API 表更改#

为了允许更改 API 表,NumPy 2.0 将提供与 NumPy 1.x 不同的表(函数和符号列表)。

为了兼容性,我们需要将 1.x 表转换为 2.0 表。理论上,这可以在标头中完成,但这似乎过于笨拙。因此,我们建议添加一个 `numpy2_compat` 包。此包的主要目的是在单个位置提供 1.x 表到 2.x 表的转换(填充任何必要的空白)。

引入此包解决了“过渡”问题,因为它允许用户

  • 安装与 2.0 和 1.x 兼容的 SciPy 版本

  • 并继续使用 NumPy 1.x,因为他们使用的其他包尚未兼容。

对 `numpy2_compat` 的导入(以及当它缺失时的错误)将由 NumPy 标头作为 `import_array()` 调用的一部分插入。

替代方案#

总是有可能不进行某些更改(例如,由于下游用户注意到它们继续需要它)。例如,如果需要,函数 `PyArray_Mean` 可以替换为调用 `array.mean()` 的函数。

NEP 建议通过引入兼容包 `numpy2_compat` 来允许对 API 表进行更大的更改。我们可以在不引入此类包的情况下进行许多更改。

默认 API 版本可以选择为较旧的版本或当前版本。较旧的版本旨在针对希望比 NEP 29 建议的兼容性更大的库。选择当前版本将默认情况下删除不必要的兼容性 shim 用于不分发轮子的用户。建议的默认值选择有利于分发轮子的库,并且希望与 NEP 29 相似的兼容性范围。这是因为兼容性 shim 应该是轻量级的,我们预计很少有库需要更长的兼容性。

向后兼容性#

如上所述,向后兼容性是通过以下方式实现的:

  1. 强制下游重新编译 NumPy 2.0

  2. 提供 `numpy2_compat` 库。

但依赖于用户适应 Usage and Impact 部分中描述的更改的 C-API。

讨论#

  • numpy/numpy#5888 之前提到,允许在标头中导出较旧的 API 版本将很有帮助。这从未实现,而是依赖于 oldest-support-numpy

  • 此提案的初稿在 2023 年 4 月 3 日的 NumPy 2.0 计划会议上进行了展示。

参考文献和脚注#