跳到主要内容

JEP 460:矢量 API(第七个孵化版)

QWen Max 中英对照 JEP 460: Vector API (Seventh Incubator)

总结

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

历史

Vector API 最早由 JEP 338 提出,并作为 孵化 API 集成到 JDK 16 中。后续的几轮孵化分别由 JEP 414(集成到 JDK 17)、JEP 417(JDK 18)、JEP 426(JDK 19)、JEP 438(JDK 20)以及 JEP 448(JDK 21)提出。

本 JEP 提议在 JDK 22 中重新孵化该 API,并在相对于 JDK 21 的 API 中进行小幅增强。实现中包含错误修复和性能改进。我们包括以下显著变化:

  • 支持通过堆 MemorySegment 访问向量,这些 MemorySegment 可由任意基本元素类型的数组支持。此前,访问仅限于由字节数组支持的堆 MemorySegment

目标

  • 清晰简洁的 API — 该 API 应能够清晰简洁地表达广泛的矢量计算,这些计算由循环内的矢量操作序列组成,并可能包含控制流。应该可以表达出对于矢量大小或每个矢量的通道数通用的计算,从而使这些计算能够在支持不同矢量大小的硬件上实现可移植。

  • 平台无关性 — 该 API 应与 CPU 架构无关,从而支持在多个具有矢量指令的架构上实现。正如 Java API 中常见的那样,当平台优化和可移植性发生冲突时,我们将偏向于使 API 可移植,即使这会导致某些特定平台的习惯用法无法在可移植代码中表达。

  • 在 x64 和 AArch64 架构上的可靠运行时编译和性能 — 在有能力的 x64 架构上,Java 运行时(特别是 HotSpot C2 编译器)应将矢量操作编译为相应高效且高性能的矢量指令,例如 Streaming SIMD Extensions(SSE)和 Advanced Vector Extensions(AVX)所支持的那些指令。开发人员应该相信他们表达的矢量操作会可靠地紧密映射到相关的矢量指令。在有能力的 ARM AArch64 架构上,C2 同样会将矢量操作编译为 NEONSVE 所支持的矢量指令。

  • 优雅降级 — 有时矢量计算在运行时不能完全表示为一系列矢量指令,也许是因为架构不支持某些所需的指令。在这种情况下,Vector API 的实现应该优雅降级并仍然能正常工作。如果矢量计算不能有效地编译为矢量指令,这可能涉及发出警告。在没有矢量的平台上,优雅降级将生成与手动展开循环竞争的代码,其中展开因子是所选矢量中的通道数。

  • 与 Project Valhalla 保持一致 — Vector API 的长期目标是利用 Project Valhalla 对 Java 对象模型的增强。主要这意味着将 Vector API 当前的基于值的类更改为值类,以便程序可以使用值对象,即缺乏对象标识的类实例。因此,Vector API 将在多个版本中孵化,直到 Project Valhalla 的必要功能作为预览功能可用。一旦这些 Valhalla 功能可用,我们将调整 Vector API 和其实现以使用它们,然后将 Vector API 本身提升为预览功能。更多详情,请参见运行时编译未来工作部分。

非目标

  • 增强 HotSpot 中现有的自动向量化算法并不是目标。

  • 在 x64 和 AArch64 以外的 CPU 架构上支持向量指令并不是目标。然而,正如目标中所表述的,重要的是要声明 API 不应排除此类实现。

  • 支持 C1 编译器并不是目标。

  • 保证支持 Java 平台对标量操作所要求的严格浮点计算并不是目标。对标量浮点数执行的浮点运算的结果可能与对标量浮点数向量执行的等效浮点运算的结果不同。任何偏差都将清楚地记录在文档中。这一非目标并不排除表达或控制浮点向量计算所需精度或可重复性的选项。

动机

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

向量操作表达了一定程度的并行性,这种并行性使得在单个 CPU 周期内可以完成更多的工作,从而可能带来显著的性能提升。例如,给定两个向量,每个向量包含八个整数的序列(即八个通道),这两个向量可以通过一条硬件指令相加。向量加法指令在通常处理两个整数并执行一次整数加法所需的时间内,对十六个整数进行操作,完成八次整数加法。

HotSpot 已经支持自动矢量化,它可以将标量操作转换为超字(superword)操作,然后映射到矢量指令。可转换的标量操作集合是有限的,并且对代码形状的变化也很脆弱。此外,可能只利用了可用矢量指令的一个子集,从而限制了生成代码的性能。

如今,一个希望编写能被可靠地转换为超字(superword)操作的标量操作的开发者,需要理解 HotSpot 的自动向量化算法及其局限性,以实现稳定且可持续的性能。在某些情况下,可能无法编写出可转换的标量操作。例如,HotSpot 并未对计算数组哈希码的简单标量操作([Arrays::hashCode](https://github.com/openjdk/jdk/blob/2a406f3ce5e200af9909ce051fdeed0cc059fea0/src/java.base/share/classes/java/util/Arrays.java#L4251) 方法)进行转换,也无法自动向量化用于按字典顺序比较两个数组的代码(因此我们为字典序比较添加了一个内在函数)。

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

描述

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

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

所支持的形状集合对应于 64、128、256 和 512 位的向量大小,以及 max 位。512 位的形状可以将 byte 打包到 64 条通道,或将 int 打包到 16 条通道,这样形状的向量可以一次操作 64 个 byte 或者一次操作 16 个 intmax 位形状支持当前架构的最大向量大小。这使得对 ARM SVE 平台的支持成为可能,在该平台上,平台实现可以支持从 128 位到 2048 位之间的任何固定大小,且以 128 位为增量单位。

我们相信,这些简单的形状通用性足够强,可以在所有相关平台上发挥作用。然而,在孵化此 API 期间,当我们尝试未来的平台时,我们可能会进一步修改形状参数的设计。这类工作并不在本项目早期的范围内,但这些可能性部分影响了形状在 Vector API 中的当前角色。(更多讨论见下文的未来工作部分。)

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

对矢量的操作分为通道内跨通道

  • 按通道操作 将标量运算符(如加法)并行应用于一个或多个向量的每个通道。按通道操作通常(但并非总是)生成相同长度和形状的向量。按通道操作进一步分为一元、二元、三元、测试或转换操作。

  • 跨通道操作 在整个向量上应用操作。跨通道操作生成标量或可能不同形状的向量。跨通道操作进一步分为排列操作或归约操作。

为了减少 API 的表面积,我们为每一类操作定义了集合方法。这些方法将运算符常量作为输入;这些常量是 VectorOperator.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 可扩展矢量扩展 (Scalable Vector Extensions) 和 Intel 的 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;
}
}

(我们假定数组参数具有相同的长度。)

下面是一个使用 Vector 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 中获取一个形状最适合当前架构的首选物种。我们将其存储在一个 static final 字段中,以便运行时编译器将该值视为常量,从而更好地优化向量计算。主循环然后以向量长度(即物种长度)为步幅迭代输入数组。它从数组 ab 中对应索引处加载给定物种的 float 向量,流畅地执行算术运算,然后将结果存储到数组 c 中。如果在最后一次迭代后还剩下任何数组元素,则这些尾部元素的结果将通过普通的标量循环计算。

该实现可在大型数组上达到最佳性能。HotSpot C2 编译器在支持 AVX 的 Intel x64 处理器上生成类似于以下内容的机器代码:

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

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

(在该特定示例中,HotSpot 能够自动向量化标量计算,并生成类似的向量指令序列。主要区别在于,自动向量化器会为乘以 -1.0f 的操作生成一个向量乘法指令,而 Vector API 的实现则生成一个向量 XOR 指令来翻转符号位。然而,此示例的关键点是展示 Vector 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 编译器可以进行增强以转换循环,剥离最后一次迭代并从循环体中移除掩码。这仍然是一个需要进一步研究的领域。

运行时编译

Vector API 有两种实现。第一种是用 Java 实现操作,因此它是可用的,但不是最优的。第二种是为 HotSpot C2 运行时编译器定义了内在的矢量操作,以便在可能的情况下,它可以将矢量计算编译到合适的硬件寄存器和矢量指令。

为避免 C2 内部函数爆炸式增长,我们定义了对应各种操作类型的广义内部函数,例如一元、二元、转换等操作,这些函数接受一个描述要执行的具体操作的参数。大约 25 个新的内部函数支持整个 API 的内部化。

我们最终期望将矢量类声明为值类,正如 Project ValhallaJEP 401)所提出的那样。在此期间,Vector<E> 及其子类被视为 基于值的类,因此应避免对其实例进行依赖标识的操作。尽管矢量实例在逻辑上由通道中的元素组成,但这些元素并不会被 C2 标量化 —— 矢量的值被视为一个整体单元,就像 intlong 一样,映射到适当大小的矢量寄存器。C2 对矢量实例进行了特殊处理,以克服逃逸分析的局限性并避免装箱。未来,我们将使这种特殊处理与 Valhalla 的值对象保持一致。

用于超越运算的 Intel SVML 内部函数

Vector API 支持对浮点向量进行超越函数和三角函数的按通道操作。在 x64 平台上,我们利用 Intel 短向量数学库(SVML)为这些操作提供优化的内联函数实现。这些内联操作的数值特性与 java.lang.Math 中定义的对应标量操作相同。

SVML 操作的汇编源文件位于 jdk.incubator.vector 模块的源代码中,处于特定操作系统的目录下。JDK 的构建过程会将这些源文件针对目标操作系统编译为 SVML 专用的共享库。该库相当大,大小接近 1 MB。如果通过 jlink 构建的 JDK 镜像省略了 jdk.incubator.vector 模块,那么 SVML 库不会被复制到镜像中。

该实现目前仅支持 Linux 和 Windows。由于提供带有必要指令的汇编源文件需要大量工作,我们将在稍后考虑 macOS 支持。

HotSpot 运行时会尝试加载 SVML 库,并在存在该库的情况下,将 SVML 库中的操作绑定到命名的存根例程。C2 编译器会生成代码,根据操作和向量种类(即元素类型和形状)调用相应的存根例程。

未来,如果 Project Panama 扩展了对本地调用约定的支持以支持向量值,那么 Vector API 的实现可能会从外部源加载 SVML 库。如果这种方法没有性能影响,那么就不再需要以源代码形式包含 SVML 并将其构建到 JDK 中。在此之前,考虑到潜在的性能提升,我们认为上述方法是可以接受的。

未来工作

  • 如上所述,我们最终期望将矢量类声明为值类。关于使矢量 API 与 Valhalla 对齐的持续努力,请参见 Project Valhalla 代码库的 lworld+vector 分支。我们进一步期望利用 Project Valhalla 的值类的泛型特化功能,从而使 Vector<E> 的实例成为值对象,其中 E 是诸如 int 这样的原语类,而不是其装箱类 Integer。一旦我们有了对原语类的泛型特化支持,可能不再需要像 IntVector 这样特定类型的 Vector<E> 子类型。

  • 我们可能会增加对 IEEE 浮点 binary16 值(float16 值)矢量的支持。这同样依赖于 Project Valhalla,要求我们将 float16 值表示为具有优化数组和字段布局的值对象,并增强矢量 API 实现以利用 float16 值矢量上的矢量硬件指令。有关探索性工作,请参见 Project Panama 的矢量 API 代码库中的 vectorIntrinsics+fp16 分支。

  • 我们预计会增强实现,以改进包含矢量化代码的循环优化,并随着时间推移逐步提高性能。

  • 我们还计划增强组合单元测试,以断言 C2 生成矢量硬件指令。当前的单元测试假设(未经验证)重复执行足以使 C2 生成矢量硬件指令。我们将探索使用 C2 的 IR 测试框架,跨平台地断言 IR 图中存在矢量节点(例如,使用 正则匹配)。如果这种方法有问题,我们可能会探索一种基本方法,即使用非生产性的 -XX:+TraceNewVectors 标志打印矢量节点。

  • 我们将评估定义合成矢量形状,以便更好地控制循环展开和矩阵操作,并考虑对排序和解析算法的适当支持。(更多细节请参见此演示文稿。)

替代方案

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

总体而言,即使经过数十年的研究 —— 尤其是针对 FORTRAN 和 C 数组循环的研究 —— 除非用户异常仔细地关注编译器准备自动向量化的确切循环的未写明约定,标量代码的自动向量化似乎并不是优化随意用户编写的循环的可靠策略。编写一个无法自动向量化的循环太容易了,原因可能是人类读者无法察觉的。多年以来,即使在 HotSpot 中,关于自动向量化的研究工作给我们留下了许多仅在特殊场合工作的优化机制。我们希望更频繁地利用这些机制!

测试

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

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

风险与假设

  • API 可能会对 x64 架构上支持的 SIMD 功能产生偏向性风险,但通过支持 AArch64 可以减轻这种风险。这主要适用于显式固定的受支持形状集,这些形状不利于以形状通用的方式编码算法。我们认为 Vector API 的大多数其他操作偏向于可移植算法。为了降低这种风险,我们将考虑其他架构,特别是 ARM Scalar Vector Extension 架构,其编程模型会动态调整以适应硬件支持的单一固定形状。我们欢迎并鼓励从事 HotSpot ARM 特定领域的 OpenJDK 贡献者参与这项工作。

  • Vector API 使用包装类型(例如 Integer)作为基本类型(例如 int)的代理。这一决策是由 Java 泛型的当前限制所导致的,因为 Java 泛型对基本类型并不友好。当 Project Valhalla 最终引入更强大的泛型时,当前的决策可能会显得笨拙,并且可能需要更改。我们假设这些更改可以在不引起过度向后不兼容的情况下实现。