JEP 508:向量 API(第十次孵化)

原文:JEP 508- Vector API (Tenth Incubator)
作者:
日期:2025-10-26

所有者伊恩·格雷夫斯(Ian Graves)
类型特性
范围JDK
状态已关闭 / 已交付
发布版本25
组件核心库
讨论组panama - dev@openjdk.org
工作量极小
时长极短
相关内容JEP 489:向量 API(第九次孵化)
JEP 529:向量 API(第十一次孵化)
审核人贾廷·巴特亚(Jatin Bhateja)、桑迪娅·维斯瓦纳坦(Sandhya Viswanathan)、弗拉基米尔·伊万诺夫(Vladimir Ivanov)
批准人保罗·桑多兹(Paul Sandoz)
创建时间2025 年 3 月 31 日 18:19
更新时间2025 年 10 月 1 日 17:55
问题编号8353296

摘要

引入一个 API,用于表达向量计算,该 API 在运行时能可靠地编译为受支持 CPU 上的最优向量指令,从而实现优于等效标量计算的性能。

历史

我们在 JEP 338 中首次提出向量 API,并将其作为 孵化 API 集成到 JDK 16 中。之后,我们在 JEP 414(集成到 JDK 17)、JEP 417(JDK 18)、JEP 426(JDK 19)、JEP 438(JDK 20)、JEP 448(JDK 21)、JEP 460(JDK 22)、JEP 469(JDK 23)以及 JEP 489(JDK 24)中提议进行进一步的孵化。

在此,我们提议在 JDK 25 中重新孵化向量 API,此次有一处 API 变更和两处显著的实现变更:

  • VectorShuffle 现在支持对 MemorySegment 的访问。
  • 实现方式现在通过外部函数与内存 API(JEP 454)链接到本地数学函数库,而非 HotSpot JVM 内部的自定义 C++ 代码,从而提高了可维护性。
  • 在支持的 x64 CPU 上,对 Float16 值的加、减、除、乘、平方根以及融合乘加操作现在可自动向量化。

向量 API 将持续孵化,直到 瓦尔哈拉项目 的必要特性作为预览特性可用。届时,我们将调整向量 API 及其实现以使用这些特性,然后将向量 API 从孵化阶段提升到预览阶段。

目标

  • 清晰简洁的 API — 该 API 应能够清晰简洁地表达广泛的向量计算,这些计算由循环内组合的向量操作序列构成,并且可能包含控制流。应该能够表达关于向量大小(即每个向量的通道数)通用的计算,从而使此类计算能够在支持不同向量大小的硬件间实现可移植。
  • 平台无关性 — 该 API 应与 CPU 架构无关,支持在多种支持向量指令的架构上实现。与 Java API 通常的情况一样,当平台优化与可移植性发生冲突时,我们将倾向于使 API 具有可移植性,即使这会导致一些特定于平台的习惯用法无法在可移植代码中表达。
  • 在 x64 和 AArch64 CPU 上实现可靠编译和高性能 — 在性能足够的 x64 CPU 上,Java 运行时,特别是 HotSpot C2 编译器,应将向量操作编译为相应高效且高性能的向量指令,例如 流式单指令多数据扩展(SSE)和 高级向量扩展(AVX)所支持的指令。开发者应相信,他们所表达的向量操作将可靠地紧密映射到相关向量指令。同样,在性能足够的 ARM AArch64 CPU 上,C2 会将向量操作编译为 NEONSVE 所支持的向量指令。
  • 优雅降级 — 有时向量计算在运行时无法完全表达为向量指令序列,这可能是因为 CPU 不支持某些所需指令。在这种情况下,向量 API 实现应优雅降级并继续运行。如果向量计算无法高效编译为向量指令,这可能涉及发出警告。在不支持向量的平台上,优雅降级产生的代码应与手动展开循环的代码具有竞争力,其中展开因子为所选向量的通道数。
  • 与瓦尔哈拉项目保持一致 — 向量 API 的长期目标是利用 瓦尔哈拉项目 对 Java 对象模型的增强。这主要意味着将向量 API 当前的 基于值的类 更改为值类,以便程序能够处理值对象,即没有对象标识的类实例。更多细节,请参阅 运行时编译未来工作 部分。

非目标

  • 无意增强 HotSpot JVM 中现有的自动向量化算法。
  • 无意在 x64 和 AArch64 之外的 CPU 架构上实现对向量指令的支持,尽管该 API 不能排除此类实现。其他贡献者已经开始在其他架构上实现向量 API,例如 RISC - V
  • 无意支持 C1 编译器。
  • 无意保证对标量运算所需的严格浮点计算的支持。对浮点标量执行的浮点运算结果,可能与对浮点标量向量执行的等效浮点运算结果不同。任何偏差都将有清晰记录。此非目标并不排除表达或控制浮点向量计算所需精度或可重复性的选项。

动机

向量计算由对向量的一系列操作组成。一个向量包含(通常)固定顺序的标量值,其中标量值的数量与硬件定义的向量通道数相对应。应用于两个等长向量的二元运算,会对每个向量中对应的标量值执行等效的标量运算。这通常被称为 单指令多数据(SIMD)。

向量运算表达了一定程度的并行性,使得在单个 CPU 周期内可以执行更多工作,从而可显著提升性能。例如,假设有两个向量,每个向量包含八个整数的序列(即八个通道),可以使用单个硬件指令将这两个向量相加。向量加法指令对十六个整数进行操作,执行八次整数加法,而通常执行一次整数加法仅对两个整数进行操作。

HotSpot JVM 已经支持 自动向量化,它将标量运算转换为超级字运算,然后映射到向量指令。但是可转换的标量运算集合是有限的,并且对于代码结构的变化也很脆弱。此外,可能仅会利用可用向量指令的一个子集,这限制了生成代码的性能。

如今,希望编写能可靠转换为向量指令的标量运算的开发人员,需要了解 HotSpot 的自动向量化算法及其局限性,以便实现可靠且可持续的性能。在某些情况下,可能无法编写可转换的标量运算。例如,HotSpot 不会转换用于计算数组哈希码的简单标量运算(Arrays::hashCode 方法),它也无法对按字典序比较两个数组的代码进行自动向量化(因此我们 为字典序比较添加了一个内联方法)。

向量 API 旨在通过提供一种在 Java 代码中编写复杂向量算法的方式来改善这种情况,它使用现有的 HotSpot 自动向量化器,但采用的用户模型使向量化更具可预测性和稳健性。手工编写的向量循环可以表达高性能算法,例如向量化的 hashCode 或专门的数组比较,而自动向量化器可能永远无法对这些进行优化。许多领域都可以从这个显式向量 API 中受益,包括机器学习、线性代数、密码学、金融以及 JDK 自身内部的代码。

描述

向量由抽象类 Vector<E> 表示。类型变量 E 被实例化为向量所涵盖的标量基本整数或浮点元素类型的装箱类型。向量还具有一个“形状”,它以位为单位定义向量的大小。当 HotSpot C2 编译器编译向量计算时,向量的形状决定了 Vector<E> 的实例如何映射到硬件向量寄存器。向量的长度,即通道数或元素数,是向量大小除以元素大小。

支持的元素类型(E)有 ByteShortIntegerLongFloatDouble,分别对应标量基本类型 byteshortintlongfloatdouble

支持的形状对应 64 位、128 位、256 位和 512 位的向量大小,以及“最大”位。一个 512 位形状可以将 byte 打包成 64 个通道,或者将 int 打包成 16 个通道,具有这种形状的向量可以一次对 64 个 byte 或一次对 16 个 int 进行操作。“最大”位形状支持当前 CPU 的最大向量大小。这为 ARM SVE 平台提供了支持,在该平台实现中,可以支持从 128 位到 2048 位的任何固定大小,以 128 位为增量。

我们认为这些形状具有足够的通用性,可在所有相关平台上发挥作用。不过,在该 API 的孵化过程中,随着我们对未来平台的探索,可能会进一步修改形状参数的设计。此类工作不在本项目早期范围内,但这些可能性在一定程度上影响了当前形状在向量 API 中的作用。(更多讨论见下文 未来工作 部分。)

元素类型和形状的组合决定了向量的“种类”,由 VectorSpecies<E> 表示。

对向量的操作分为“逐通道”或“跨通道”两类。

  • “逐通道”操作将标量运算符(如加法)并行应用于一个或多个向量的每个通道。逐通道操作通常(但并非总是)生成具有相同长度和形状的向量。逐通道操作进一步分为一元、二元、三元、测试或转换操作。
  • “跨通道”操作将操作应用于整个向量。跨通道操作生成一个标量或可能具有不同形状的向量。跨通道操作进一步分为置换或归约操作。

为了减少 API 的表面复杂度,我们为每类操作定义了集合方法。这些方法将运算符常量作为输入;这些常量是 VectorOperators.Operator 类的实例,并在 VectorOperators 类的静态最终字段中定义。为方便起见,对于一些常见的“全功能”操作(如加法和乘法),我们定义了专用方法,可代替通用方法使用。

对向量的某些操作(如转换和重新解释)本质上是“形状改变”的;即,它们生成的向量形状与输入向量的形状不同。向量计算中的形状改变操作可能会对可移植性和性能产生负面影响。因此,在适用的情况下,API 为每个形状改变操作定义了“形状不变”的变体。为获得最佳性能,开发人员应尽可能使用形状不变的操作编写形状不变的代码。在 API 规范中,形状改变操作会被标识出来。

Vector<E> 类声明了一组所有元素类型都支持的常见向量操作方法。对于特定于元素类型的操作,Vector<E> 有六个抽象子类,每种支持的元素类型各一个:ByteVectorShortVectorIntVectorLongVectorFloatVectorDoubleVector。这些特定类型的子类定义了与元素类型相关的其他操作,因为方法签名涉及元素类型或相关的数组类型。此类操作的示例包括归约(例如,将所有通道求和为一个标量值),以及将向量的元素复制到数组中。这些子类还定义了特定于整数子类型的其他全功能操作(例如,逻辑或等按位操作),以及特定于浮点类型的操作(例如,指数运算等超越数学函数)。

作为实现细节,Vector<E> 的这些特定类型的子类会由针对不同向量形状的具体子类进一步扩展。这些具体子类不是公共的,因为无需提供特定于类型和形状的操作。这将 API 的表面复杂度从乘积形式简化为求和形式。具体 Vector 类的实例可通过在 Vector<E> 基类及其特定类型的子类中定义的工厂方法获取。这些工厂方法将所需向量实例的种类作为输入,并生成各种类型的实例,例如元素为默认值的向量实例(即零向量),或从给定数组初始化的向量实例。

为了支持控制流,一些向量操作可选地接受由公共抽象类 VectorMask<E> 表示的掩码。掩码中的每个元素都是一个布尔值,对应于向量的一个通道。掩码选择应用操作的通道:如果通道的掩码元素为 true,则应用操作;如果掩码为 false,则采取其他替代操作。

与向量类似,VectorMask<E> 的实例是为每种元素类型和长度组合定义的非公共具体子类的实例。操作中使用的 VectorMask<E> 实例应与操作中涉及的向量实例具有相同的类型和长度。向量比较操作会生成掩码,然后可将其作为其他操作的输入,以选择性地对某些通道进行操作,从而模拟流控制。掩码也可以使用 VectorMask<E> 类中的静态工厂方法创建。

我们预计掩码在针对形状通用的向量计算开发中将发挥重要作用。这一预期基于谓词寄存器(相当于掩码)在 ARM 可扩展向量扩展和 x64 的 AVX - 512 中的核心重要性。

在这样的平台上,VectorMask<E> 的实例被映射到谓词寄存器,接受掩码的操作被编译为接受谓词寄存器的向量指令。在不支持谓词寄存器的平台上,我们采用一种效率较低的方法:在可能的情况下,将 VectorMask<E> 的实例映射到兼容的向量寄存器,一般来说,接受掩码的操作由等效的无掩码操作和混合操作组成。

为了支持跨通道置换操作,一些向量操作接受由公共抽象类 VectorShuffle<E> 表示的混洗。混洗中的每个元素是一个 int 值,对应于一个通道索引。混洗是通道索引的映射,描述了通道元素从给定向量到结果向量的移动。

与向量和掩码类似,VectorShuffle<E> 的实例是为每种元素类型和长度组合定义的非公共具体子类的实例。操作中使用的 VectorShuffle<E> 实例应与操作中涉及的向量实例具有相同的类型和长度。

示例

以下是对数组元素进行的简单标量计算:

void scalarComputation(float[] a, float[] b, float[] c) {
   for (int i = 0; i < a.length; i++) {
        c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f;
   }
}
  

(我们假设数组参数的长度相同。)

以下是使用向量 API 进行的等效向量计算:

static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;
void vectorComputation(float[] a, float[] b, float[] c) {
    int i = 0;
    int upperBound = SPECIES.loopBound(a.length);
    for (; i < upperBound; i += SPECIES.length()) {
        // FloatVector va, vb, vc;
        var va = FloatVector.fromArray(SPECIES, a, i);
        var vb = FloatVector.fromArray(SPECIES, b, i);
        var vc = va.mul(va)
                   .add(vb.mul(vb))
                   .neg();
        vc.intoArray(c, i);
    }
    for (; i < a.length; i++) {
        c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f;
    }
}
  

首先,我们从 FloatVector 获取一个首选种类,其形状对当前 CPU 是最优的。我们将其存储在一个 static final 字段中,以便运行时编译器将该值视为常量,从而更好地优化向量计算。然后主循环以向量长度(即种类长度)为步长,对输入数组进行迭代。它从数组 ab 的相应索引处加载给定种类的 float 向量,流畅地执行算术运算,然后将结果存储到数组 c 中。如果在最后一次迭代后仍有任何数组元素剩余,那么这些“尾部”元素的结果将通过普通标量循环计算。

这种实现对于大数组能实现最优性能。在支持 AVX 的 x64 处理器上,HotSpot C2 编译器生成类似于以下的机器代码:

0.43%  / │  0x0000000113d43890: vmovdqu 0x10(%r8,%rbx,4),%ymm0
  7.38%  │ │  0x0000000113d43897: vmovdqu 0x10(%r10,%rbx,4),%ymm1
  8.70%  │ │  0x0000000113d4389e: vmulps %ymm0,%ymm0,%ymm0
  5.60%  │ │  0x0000000113d438a2: vmulps %ymm1,%ymm1,%ymm1
 13.16%  │ │  0x0000000113d438a6: vaddps %ymm0,%ymm1,%ymm0
 21.86%  │ │  0x0000000113d438aa: vxorps -0x7ad76b2(%rip),%ymm0,%ymm0
  7.66%  │ │  0x0000000113d438b2: vmovdqu %ymm0,0x10(%r9,%rbx,4)
 26.20%  │ │  0x0000000113d438b9: add    $0x8,%ebx
  6.44%  │ │  0x0000000113d438bc: cmp    %r11d,%ebx
         \ │  0x0000000113d438bf: jl     0x0000000113d43890
  

这是使用向量 API 原型及在巴拿马项目开发仓库的 vectorIntrinsics 分支 上找到的实现,对上述代码进行 JMH 微基准测试的输出。生成的机器代码的这些热点区域清楚地展示了向向量寄存器和向量指令的转换。为了使转换更清晰,我们通过 HotSpot 选项 -XX:LoopUnrollLimit=0 禁用了循环展开;否则,HotSpot 会使用现有的 C2 循环优化来展开此代码。所有 Java 对象分配都被省略。

(在这个特定示例中,HotSpot 能够对标量计算进行自动向量化,并且会生成类似的向量指令序列。主要区别在于自动向量化器为乘以 -1.0f 生成一个向量乘法指令,而向量 API 实现生成一个翻转符号位的向量异或指令。然而,此示例的关键在于展示向量 API 以及其实现如何生成向量指令,而非将其与自动向量化器进行比较。)

在具有谓词寄存器的架构上,上述示例可以写得更简单,无需用于处理尾部元素的标量循环,同时仍能实现最优性能:

void vectorComputation(float[] a, float[] b, float[] c) {
    for (int i = 0; i < a.length; i += SPECIES.length()) {
        // VectorMask<Float>  m;
        var m = SPECIES.indexInRange(i, a.length);
        // FloatVector va, vb, vc;
        var va = FloatVector.fromArray(SPECIES, a, i, m);
        var vb = FloatVector.fromArray(SPECIES, b, i, m);
        var vc = va.mul(va)
                   .add(vb.mul(vb))
                   .neg();
        vc.intoArray(c, i, m);
    }
}
  

在循环体中,我们获取一个与循环相关的掩码,作为加载和存储操作的输入。当 i < SPECIES.loopBound(a.length) 时,掩码 m 声明所有通道都已设置。对于循环的最后一次迭代,当 SPECIES.loopBound(a.length) <= i < a.length(a.length - i) <= SPECIES.length() 时,掩码可能声明有一部分通道未设置。由于掩码阻止访问超出数组长度的部分,加载和存储操作不会抛出越界异常。

我们希望开发人员在所有支持的平台上都以上述风格编写代码并实现最优性能,但目前在没有谓词寄存器的平台上,上述方法并非最优。理论上,可以增强 C2 编译器来转换循环,剥离最后一次迭代并从循环体中移除掩码。这仍是一个有待进一步研究的领域。

运行时编译

向量 API 有两种实现。第一种在 Java 代码中实现操作,因此它能正常工作但并非最优。第二种为 HotSpot C2 运行时编译器定义了内部向量操作,以便编译器在可用时将向量计算编译为适当的硬件寄存器和向量指令。

为避免 C2 内部函数激增,我们定义了与各种操作(如一元、二元、转换等)相对应的通用内部函数,这些内部函数接受一个描述要执行操作的参数。大约 25 个新的内部函数支持整个 API 的内部实现。

我们期望最终像 瓦尔哈拉项目JEP 401)提议的那样,将向量类声明为值类。同时,Vector<E> 及其子类被视为 基于值的类,因此应避免对其实例进行身份敏感操作。尽管向量实例抽象地由通道中的元素组成,但这些元素不会被 C2 标量化 —— 向量的值被视为一个整体单元,类似于 intlong,映射到适当大小的向量寄存器。C2 对向量实例进行特殊处理,以克服逃逸分析的限制并避免装箱。未来,我们将使这种特殊处理与瓦尔哈拉项目的值对象保持一致。

超越函数与三角函数操作的内部实现

向量 API 支持对浮点向量进行逐通道的超越函数与三角函数操作。在 x64 架构上,我们利用英特尔短向量数学库(SVML);在 ARM 和 RISC - V 架构上,我们利用 基本函数求值的 SIMD 库(SLEEF),为这类操作提供优化的内部实现。这些内部操作与 java.lang.Math 中定义的相应标量操作具有相同的数值特性。

对于所有平台,我们使用 巴拿马项目 的外部函数与内存(FFM)API 链接到本地数学函数,这提高了可维护性,并显著减少了 HotSpot 中所需的 C++ 代码,以便将本地函数链接并作为可被 C2 访问的存根公开。调用本地函数仍然需要一些特殊的内部支持,但如果 巴拿马项目 扩展其对本地调用约定的支持以支持向量值,那么我们或许能够完全消除这种需求。

SVML 和 SLEEF 的源代码包含在 jdk.incubator.vector 模块的源代码中。JDK 构建过程会根据目标 CPU 架构,将 SVML 或 SLEEF 库编译为共享库。编译后的 SVML 库较大,略小于 1 兆字节,但如果通过 jlink 工具构建的 JDK 镜像省略了 jdk.incubator.vector 模块,该库就不会被复制到镜像中。

SVML 实现在 Linux 和 Windows 系统上支持 x64 架构。SLEEF 实现在 Linux 和 macOS 系统上支持 ARM 和 RISC - V 架构。

Float16

向量 API 包含一个基于值的类 Float16,它以 IEEE 754 binary16 格式表示 16 位浮点数。在支持的 CPU 上,HotSpot C2 编译器可以对加法、减法、除法、乘法、平方根以及融合乘加操作进行自动向量化。

未来工作

  • 如上文所述,我们期望最终将向量类声明为 值类。有关使向量 API 与瓦尔哈拉项目保持一致的持续工作,请参阅瓦尔哈拉项目开发仓库的 lworld + vector 分支。我们进一步期望利用瓦尔哈拉项目对值类的泛型特化,使得 Vector<E> 的实例成为值对象,其中 E 是诸如 int 这样的基本类,而非其装箱类 Integer。一旦我们对基本类有了泛型特化,针对特定类型的 Vector<E> 子类型(如 IntVector)可能就不再需要。
  • 我们可能会拓宽 Float16 操作的自动向量化范围,最终覆盖支持硬件上的所有相关操作。我们也可能增强向量 API 及其实现以涵盖 Float16 值的向量;有关探索性工作,请参阅巴拿马项目开发仓库的 vectorIntrinsics + fp16 分支。当 瓦尔哈拉项目 可用时,我们将把 Float16 迁移为值类。
  • 我们预计会增强实现,以改进对包含向量化代码的循环的优化,并随着时间的推移逐步提升性能。
  • 我们还预计会增强组合单元测试,以确保 C2 生成向量硬件指令。当前的单元测试在未经验证的情况下假定,重复执行足以使 C2 生成向量硬件指令。我们将探索使用 C2 的 IR 测试框架,在跨平台环境下断言 IR 图中存在向量节点(例如,使用 正则表达式匹配)。如果这种方法存在问题,我们可能会探索一种基本方法,即使用非产品性的 -XX:+TraceNewVectors 标志来打印向量节点。
  • 我们将评估合成向量形状的定义,以便更好地控制循环展开和矩阵操作,并考虑对排序和解析算法提供适当支持。(更多细节请参见 此演示文稿。)

替代方案

HotSpot 的自动向量化是一种替代方法,但这需要大量工作。此外,与向量 API 相比,它仍然脆弱且有限,因为对复杂控制流进行自动向量化非常困难。

总体而言,即使经过数十年的研究(特别是针对 FORTRAN 和 C 数组循环),除非用户异常仔细地关注编译器准备对哪些循环进行自动向量化的不成文约定,否则对标量代码进行自动向量化似乎并不是优化临时用户编写的循环的可靠策略。编写一个无法自动向量化的循环太容易了,而且原因往往难以被人工察觉。即使在 HotSpot 中,多年的自动向量化工作也只留下了许多仅在特定情况下才有效的优化机制。我们希望能更频繁地使用这些机制!

测试

我们将开发组合单元测试,以确保对所有支持的类型和形状的所有操作,在各种数据集上都有覆盖。

我们还将开发性能测试,以确保达到性能目标,并且向量计算能高效地映射到向量指令。这可能包括 JMH 微基准测试,但也需要更实际的有用算法示例。此类测试最初可能存放在特定项目的仓库中。鉴于测试的比例以及生成方式,在集成到主仓库之前可能需要进行整理。

风险与假设

  • 向量 API 使用装箱类型(例如 Integer)作为基本类型(例如 int)的代理。这一决定是由于 Java 泛型目前的局限性所迫,Java 泛型对基本类型不太友好。当瓦尔哈拉项目最终引入功能更强大的泛型时,当前的决定可能会显得笨拙,并且可能需要更改。我们假设进行此类更改不会带来过多的向后不兼容性。