NEP 54 — SIMD 基础设施演进:迁移到 C++ 时采用 Google Highway#

作者:

Sayed Adel, Jan Wassenberg, Matti Picus, Ralf Gommers, Chris Sidebottom

状态:

已接受

类型:

标准轨道

创建时间:

2023-07-06

解决时间:

待办事项

摘要#

我们正将 SIMD 内置函数框架 Universal Intrinsics 从 C 迁移到 C++。我们还已将 Meson 作为构建系统。Google Highway 内置函数项目提议我们使用 Highway 来替代我们当前的 Universal Intrinsics,具体详见 NEP 38。这是一个复杂且多方面的问题——本 NEP 旨在描述其中涉及的权衡以及需要完成的工作。

动机与范围#

我们希望将基于 C 的 Universal Intrinsics(参见 NEP 38)重构为 C++。这项工作已经持续了一段时间,而 Google 的 Highway 被提议作为一个替代方案,它本身已经用 C++ 编写,并支持可伸缩 SVE 和其他可重用组件(如 VQSort)。

从 C 迁移到 C++ 的动机在于 (a) 代码可读性和开发便利性,(b) 需要添加对无大小 SIMD 指令(例如 ARM 的 SVE、RISC-V 的 RVV)的支持。

作为可读性改进的一个例子,这是我们当前 C 语言 Universal Intrinsics 框架中的一行典型 C 代码

// The @name@ is the numpy-specific templating in .c.src files
npyv_@sfx@  a5 = npyv_load_@sfx@(src1 + npyv_nlanes_@sfx@ * 4);

这将(如 PR gh-21057 中实现的)改为

auto a5 = Load(src1 + nlanes * 4);

如果上面的 C++ 代码底层使用 Highway,它看起来会非常相似,它使用类似易于理解的名称,例如 Load 用于单个可移植内置函数。

上面 C 版本中的 @sfx 是类型标识符的模板变量,例如:#sfx = u8, s8, u16, s16, u32, s32, u64, s64, f32, f64#。像这样显式使用按位编码的类型将无法支持无大小 SIMD 指令集。使用 C++ 可以更容易地处理;PR gh-21057 展示了如何实现以及包含更多 C++ 代码样式的完整示例。

本 NEP 的范围包括讨论采用 Google Highway 来替换我们当前 Universal Intrinsics 框架的最相关方面,包括但不限于

  • 可维护性、领域专业知识可用性、新贡献者易于上手以及其他社会方面,

  • 可能影响 NumPy 内部设计或性能的关键技术差异和约束,

  • 与构建系统相关的方面,

  • 与发布时间相关的方面。

(目前)范围之外的是重新审视我们当前 SIMD 支持策略的其他方面

  • 在为函数添加 SIMD 支持时,准确性与性能之间的权衡

  • 使用 SVML 和 x86-simd-sort(以及可能的 aarch64 的等效项)

  • 提取 Highway 的单个位或算法(如 gh-24018 中所示)或 SLEEF(如同一 PR 中讨论的)

用途与影响#

不适用 - 不会有显著的用户可见更改。

向后兼容性#

用户可见的 Python 或 C API 不会有任何变化:控制编译和运行时 CPU 功能选择的所有方法都应保留,尽管由于迁移到 C++(与 Highway/Universal Intrinsics 选择无关)可能会有一些变化。

Highway 中 CPU 功能的命名与 Universal Intrinsics 不同(参见下面的“支持的功能/目标”)。

在 Windows 上,MSVC 可能需要避免使用,因为 Highway 使用的 pragmas 在 MSVC 中支持较差。这意味着我们很可能需要使用 clang-cl 或 Mingw-w64 来构建我们的 wheel。两者都应该可行——我们之前已经合并了 clang-cl 支持(参见 gh-20866),而 SciPy 使用 Mingw-w64 进行构建。但这可能会影响其他重新分发者或在 Windows 上从源代码构建的最终用户。

为了回应关于本 NEP 的早期讨论,Highway 现在采用双重许可 Apache 2 / BSD-3。

高层考虑#

注意

目前,本节试图单独涵盖每个主题,并比较未来使用 NumPy 特定的 C++ 实现与使用 Google Highway 并在此之上实现我们自己的数值例程。它(尚未)假定已做出决定或建议决定。因此,本 NEP 不是“这是建议的”,而是在“替代方案”部分提供另一个选项,而是进行并列比较。

开发工作量与长期可维护性#

迁移到 Highway 可能会是一项重大的开发工作。从长远来看,这有望通过 Highway 本身拥有更多的维护者精力来处理编译器支持中的持续问题和添加新平台来抵消。

Chromium 和 JPEG XL 等其他项目也在使用 Highway(参见 Highway 文档中 更完整的列表),这表明受益于更广泛的测试和 bug 报告/修复。

一个担忧是可能需要添加新的指令,而这通常最好是在开发需要该指令的数值内核的过程中完成。如果指令位于 Highway 中,而 Highway 是 NumPy 仓库中的一个 git 子模块,这将稍微麻烦一些——需要先实现一个临时/通用的版本,然后在 upstream 新的内置函数后更新子模块。

在文档方面,Highway 将是一个明显的优势。NumPy 的 CPU/SIMD 优化 文档相对 Highway 文档 来说相当稀疏。

迁移策略——能否逐步进行?#

这是一个分两半的故事。迁移到 Highway 的静态分派内置函数可以逐步进行,如 PR gh-24018 中所示。然而,采用 Highway 的运行时分派方式必须一次性完成——我们不能(或者不应该)有两种方式来做到这一点。

Highway 的编译器和平台支持策略#

在添加新指令时,Highway 的策略是必须以公平平衡各种 CPU 架构的方式实现。

关于支持状态以及是否所有当前支持的架构都将继续被支持,Jan 表示 Highway 可以承诺如下:

  1. 如果它可以通过 Clang 进行交叉编译并在标准 QEMU 上进行测试,则可以包含到 Highway 的 CI 中。

  2. 如果它可以通过 clang/gcc 进行交叉编译,并可以在新的 QEMU 上进行测试(可能需要额外的标志),则可以在每次 Highway 发布前通过手动测试进行支持。

  3. 只要在 QEMU 上编译/运行,现有目标将继续得到支持。

Highway 不受 Google 的“不再支持”策略的影响(或者,如其 README 中所述,这不是 Google 的官方支持产品)。这并非坏事;这意味着由于 Google 的项目业务决策而不再支持的可能性较低。GitHub org google 下的许多知名开源项目都说明了这一点,例如 JAXtcmalloc

支持的功能/目标#

两个框架都支持大量平台和 SIMD 指令集,以及通用的标量/回退版本。目前的主要区别是

  • NumPy 支持 IBM Z-system(s390x,VX/VXE/VXE2),而 Highway 支持 Z14、Z15。

  • Highway 支持 ARM SVE/SVE2 和 RISC-V RVV(无大小指令),而 NumPy 不支持。

    • NumPy 中无大小 SIMD 支持的基础工作已在 gh-21057 中完成,但 SVE/SVE2 和 RISC-V 尚未实现。

指令集组的粒度也存在差异:NumPy 支持比 Highway 更精细的架构集。请在此处查看 Highway 的目标列表 (大致按 CPU 系列划分),以及 NumPy 的目标列表 (大致按 SIMD 指令集划分)。因此,使用 Highway 我们会失去一些粒度——但这可能没问题,我们并不真正需要这种粒度,也没有多少证据表明用户会专门利用这一点来榨取自己 CPU 的最后一丝性能。

多目标编译策略与运行时分派#

Highway 一次编译,同时使用预处理技巧为同一编译单元中的每个 CPU 功能生成多个段(参见 foreach_target.h 的使用和动态分派文档了解其实现方式)。Universal Intrinsics 生成多个编译单元,每个 CPU 功能组一个,并进行多次编译,然后将它们链接在一起(使用不同的名称)进行运行时分派。Highway 技术在 MSVC 上可能无法可靠工作,而 Universal Intrinsic 技术可以在 MSVC 上工作。

哪个更健壮?专家们意见不一。Jan 认为 Highway 的方法更健壮,特别是避免了链接器将带有过时指令的函数拉入最终二进制文件。Sayed 认为 NumPy 当前的方法(OpenCV 也使用)更健壮,特别是更不容易遇到编译器特定 bug 或更早地捕获它们。两者都同意 Meson 构建系统允许指定对象链接顺序,从而产生更一致的构建。然而,这会将 NumPy 绑定到 Meson。

Matti 和 Ralf 认为当前的构建策略对 NumPy 运行良好,并且更改构建和运行时分派的优势,以及可能未知的稳定性,抵消了采用 Highway 动态分派可能带来的优势。

我们过去四年的经验表明,“无效指令”类型的崩溃 bugs 总是由于功能检测问题——最常见的原因是用户在模拟环境下运行,有时是因为我们的 CPU 功能检测代码确实存在问题。据我们所知,很少有证据表明链接器会拉入为不同架构多次编译的函数并选择了包含不支持指令的函数。为确保避免此问题,建议将数值内核保留在源代码中,并避免在可缓存对象中定义非内联函数。

C++ 重构的考虑因素#

我们希望从 C 迁移到 C++,这自然会涉及大量的重构,主要有两个原因:

  • 摒弃 NumPy 特定的模板语言,采用更具表达力的 C++

  • 这将使使用无大小内置函数(例如 SVE)更容易。

此外,我们还看到以下考虑因素:

  • 如果我们使用 Highway,则需要将 C++ 包装器从 universal intrinsics 切换到 Highway。另一方面,迁移到 C++ 的工作尚未完成。

  • 如果我们使用 Highway,则需要使用 Highway 内置函数重写现有的内核。但同样,迁移到 C++ 无论如何都需要修改所有这些内核。

  • 关于 Highway 的一个担忧是,是否有可能获取特定架构函数的函数指针,而不是直接调用该函数。这样做是为了确保调用单次 Python API 调用中的一维内循环多次不会多次产生分派开销。这一点已经过调查:使用 Highway 也可以做到。

  • 第二个担忧是,使用 Highway 是否可以允许用户在运行时选择或禁用对某些指令集的分派。这是可能的。

  • Highway 的 C++ 实现中使用标签可减少代码重复,但增加的模板使 C 级测试和跟踪更加复杂。

_simd 单元测试模块#

最近在 PR gh-24069 中,_simd testing 模块被重写为使用 C++。它依赖于迁移到 C++ 的主 PR gh-21057。它允许从 Python 访问 C++ 内置函数,其签名几乎相同。这不仅是测试的好方法,也是设计新 SIMD 内核的绝佳方式。

或许可以为 Highway 添加类似的测试和原型设计功能(它使用纯 googletest),但目前 NumPy 的方式要好得多。

数学例程#

数学或数值例程的编写比 universal intrinsics(本 NEP 的主要关注点)具有更高的抽象级别。Highway 只有有限数量的数学例程,并且它们的精度不足以满足 NumPy 的需求。因此,无论哪种方式,NumPy 现有的例程(使用 universal intrinsics)都将保留,如果采用 Highway 路线,它们只需在内部使用 Highway 原语。我们仍然可以使用 Highway 的排序例程。如果我们接受较低精度的例程(通过用户选择,即扩展 errstate 以允许精度选项),我们可以使用 Highway 原生例程。

可能还有其他库具有可重用于 NumPy 的数值例程(例如,来自 SLEEF,或者可能来自 JPEG XL 或其他使用 Highway 的库)。这里可能有一点好处,但可能关系不大。

支持和缺失的内置函数#

NumPy 所需的一些特定内置函数可能在 Highway 中缺失。类似地,NumPy 需要用于实现例程的一些内置函数已经在 Highway 中实现,但 NumPy 中缺失。

Highway 拥有比 NumPy 的 universal intrinsics 更多的指令,因此 NumPy 的未来内核需求可能已经在那里得到满足。

无论如何,我们总是必须在这两种解决方案中实现内置函数。

实现#

待办事项

替代方案#

使用 Google Highway 进行动态分派。其他替代方案包括:什么都不做,继续使用 C universal intrinsics;使用 Xsimd 作为 SIMD 框架(不如 Highway 全面——例如不支持 SVE 或 PowerPC);或者使用/供应商 SLEEF(一个不错的库,但维护不一致)。这两个替代方案似乎都不吸引人。

讨论#

参考文献和脚注#