JEP 489: Vector API (Ninth Incubator) | 向量 API(第九次孵化器)
摘要
引入一个 API 以表达向量计算,该计算在运行时可靠地编译为支持的 CPU 架构上的最优向量指令,从而实现优于等效标量计算的性能。
历史
我们首先在 JEP 338 中提出了 Vector 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)中进一步提议了孵化。
我们在此提议在 JDK 24 中重新孵化此 API,并有以下显著变化:
selectFrom
跨通道操作的新变体接受两个输入向量,用作查找表。这补充了相应的现有rearrange
操作。selectFrom
和rearrange
跨通道操作现在包装索引而不是检查越界索引。结合其他实施改进,这些操作现在显著加快。因此,Vector API 可以高效地表达使用查找表的 SIMD 算法。在 ARM 和 RISC-V 上对超越函数和三角函数的 lane-wise 操作现在通过调用 SIMD Library for Evaluating Elementary Functions (SLEEF) 实现。
新的价值基础类
Float16
表示 IEEE 754 binary16 格式的 16 位浮点数。未来我们打算在支持的硬件上自动向量化Float16
操作,增强 Vector API 以覆盖元素是Float16
值的情况,并最终当 Project Valhalla 可用时将Float16
迁移为值类。算术整数 lane-wise 操作现在包括:
- 饱和无符号加法和减法,
- 饱和有符号加法和减法,以及
- 无符号最大值和最小值。
等效的标量方法在新类
VectorMath
中声明,我们根据这些新的标量方法指定了 lane-wise 操作。
直到 Project Valhalla 的必要特性成为 预览特性 之前,Vector API 将继续孵化。届时,我们将调整 Vector API 及其实施以使用它们,并将 Vector API 从孵化提升为预览。
目标
清晰简洁的 API —— API 应该能够清晰简洁地表达由循环内或可能带有控制流的向量操作序列组成的广泛向量计算。应该能够表达相对于向量大小或每个向量的通道数量而言通用的计算,从而使此类计算能够在支持不同向量大小的硬件上可移植。
平台无关性 —— API 应该是与 CPU 架构无关的,使得可以在支持向量指令的多个架构上实现。正如 Java API 中通常的做法一样,如果平台优化与可移植性发生冲突,我们将偏向于使 API 具有可移植性,即使这意味着一些特定于平台的惯用代码无法在可移植代码中表达。
x64 和 AArch64 架构上的可靠运行时编译和性能 —— 在支持的 x64 架构上,Java 运行时,特别是 HotSpot C2 编译器,应将向量操作编译为对应的高效且高性能的向量指令,例如 Streaming SIMD Extensions(SSE)和 Advanced Vector Extensions(AVX)所支持的那些指令。开发人员应该有信心,他们表达的向量操作将可靠地映射到相关的向量指令。在支持的 ARM AArch64 架构上,C2 同样会将向量操作编译为 NEON 和 SVE 所支持的向量指令。
优雅降级 —— 有时,由于架构不支持某些必需的指令,无法完全以向量指令序列的形式表达向量计算。在这种情况下,Vector API 的实现应当优雅降级并且仍然能够工作。这可能涉及在向量计算无法有效地编译成向量指令时发出警告。在没有向量的平台上,优雅降级将产生竞争力与手动展开的循环相当的代码,其中展开因子是选定向量中的通道数。
与 Project Valhalla 的一致性 —— Vector API 的长期目标是利用 Project Valhalla 对 Java 对象模型的增强。主要是将 Vector API 当前的 基于值的类 更改为值类,以便程序可以处理值对象,即缺乏对象身份的类实例。有关详细信息,请参阅 运行时编译 和 未来工作 部分。
非目标
不旨在增强 HotSpot 中现有的自动矢量化算法。
不旨在支持除 x64 和 AArch64 之外的 CPU 架构上的向量指令。然而重要的是要说明,如目标所述,API 不得排除此类实现的可能性。
不旨在支持 C1 编译器。
不旨在保证像 Java 平台要求标量操作那样支持严格的浮点计算。对标量浮点数执行的浮点运算的结果可能与对浮点数组执行的等效浮点运算不同。任何偏差都将被清楚记录。这个非目标不排除表达或控制所需精度或浮点向量计算再现性的选项。
动机
向量计算由一系列作用于向量的操作组成。向量包含了一个(通常是)固定的标量值序列,这些标量值对应于硬件定义的向量通道数量。对具有相同通道数的两个向量应用一个二元操作,会将等效的标量操作应用于每个向量中对应的两个标量值。这通常被称为 单指令多数据(SIMD)。
向量操作表达了一种并行性,可以在单个 CPU 周期内执行更多工作,从而可能带来显著的性能提升。例如,给定两个向量,每个向量包含八个整数的序列(即,八个通道),可以使用单一硬件指令将这两个向量相加。向量加法指令在相当于进行一次普通整数加法所需的时间内,对十六个整数执行了八次整数加法。
HotSpot 已经支持 自动矢量化,它将标量操作转换为超字操作,然后映射到向量指令。可转换的标量操作集是有限的,并且对于代码形状的变化也很脆弱。此外,仅利用了可用向量指令的一部分子集,限制了生成代码的性能。
今天,如果开发者希望编写能够可靠地转换为超字操作的标量操作,则需要了解 HotSpot 的自动矢化算法及其局限性,以实现可靠和可持续的性能。在某些情况下,可能无法编写出可转换的标量操作。例如,HotSpot 不会转换用于计算数组哈希码的简单标量操作(即 Arrays::hashCode 方法),也无法自动矢量化代码来按字典顺序比较两个数组(因此我们 添加了按字典顺序比较的内在函数)。
向量 API 旨在通过提供一种使用现有 HotSpot 自动矢化器但用户模型使得矢量化更加可预测和健壮的方式来编写复杂的向量算法,以此改善这种状况。手写的向量循环可以表达高性能算法,如向量化 hashCode
或专门的数组比较,这是自动矢化器可能永远无法优化的。包括机器学习、线性代数、密码学、金融以及 JDK 内部代码在内的许多领域都可以从这种显式的向量 API 中受益。
描述
向量由抽象类 Vector<E>
表示。类型变量 E
被实例化为向量覆盖的标量基本整数或浮点元素类型的装箱类型。向量还有一个 形状,它定义了向量的大小(以位为单位)。向量的形状控制着当向量计算由 HotSpot C2 编译器编译时,如何将 Vector<E>
的一个实例映射到硬件向量寄存器。向量的长度,即通道或元素的数量,是向量大小除以元素大小的结果。
支持的元素类型(E
)集合是 Byte
、Short
、Integer
、Long
、Float
和 Double
,分别对应于标量基本类型 byte
、short
、int
、long
、float
和 double
。
支持的形状集合对应于 64、128、256 和 512 位的向量大小,以及 最大 位数。一个 512 位形状可以打包 byte
为 64 个通道或打包 int
为 16 个通道,而这样一个形状的向量可以一次性操作 64 个 byte
或 16 个 int
。最大 位数形状支持当前架构的最大向量大小。这使得支持 ARM SVE 平台成为可能,在该平台上,平台实现可以支持从 128 位到 2048 位(以 128 位递增)的任何固定大小。
我们认为,这些简单的形状足够通用,可以在所有相关平台上使用。然而,在此 API 孵化期间当我们实验未来平台时,我们可能会进一步修改形状参数的设计。此类工作不在本项目早期范围之内,但这些可能性部分地影响了形状在向量 API 中的当前角色。(有关进一步讨论,请参阅下面的 未来工作 部分。)
元素类型和形状的组合决定了向量的 种类,由 VectorSpecies<E>
表示。
向量上的操作分为 按通道 或 跨通道。
一个 按通道 操作将诸如加法之类的标量运算符并行应用于一个或多个向量的每个通道。按通道操作通常但不总是产生相同长度和形状的向量。按通道操作进一步分类为一元、二元、三元、测试或转换操作。
一个 跨通道 操作在整个向量上应用一个操作。跨通道操作产生的结果要么是一个标量,要么是一个可能不同形状的向量。跨通道操作进一步分类为排列或缩减操作。
为了减少 API 的表面复杂度,我们为每类操作定义了集合方法。这些方法将操作符常量作为输入;这些常量是
VectorOperators.Operator
类的实例,并在VectorOperators
类中的静态最终字段中定义。为了方便起见,我们为一些常见的 全服务 操作(如加法和乘法)定义了专用方法,这些方法可以替代通用方法使用。
向量上的某些操作,如转换和重新解释,本质上是 形状变化的;即,它们产生的向量形状与其输入的形状不同。向量计算中的形状变化操作可能会对可移植性和性能产生负面影响。因此,当适用时,API 为每个形状变化操作定义了一个 形状不变 版本。为了获得最佳性能,开发人员应尽可能使用形状不变的操作编写形状不变的代码。API 规范中标记了哪些操作是形状变化的。
Vector<E>
类声明了一组由所有元素类型支持的常用向量操作的方法。对于特定于元素类型的操作,有六个 Vector<E>
的抽象子类,每个支持的元素类型一个:ByteVector
、ShortVector
、IntVector
、LongVector
、FloatVector
和 DoubleVector
。这些类型特定的子类定义了额外绑定到元素类型的附加操作,因为方法签名引用了元素类型或相关的数组类型。此类操作的例子包括归约(例如,将所有通道求和为一个标量值),以及将向量的元素复制到数组中。这些子类还定义了特定于整数子类型的附加全服务操作(例如按位操作如逻辑或),以及特定于浮点数类型的操作(例如超越数学函数如指数运算)。
从实现角度来看,Vector<E>
的这些类型特定的子类被进一步扩展为不同的向量形状的具体子类。这些具体子类不是公共的,因为不需要提供特定于类型和形状的操作。这将 API 表面简化为关注点的总和而非乘积。具体的 Vector
类的实例通过在基类 Vector<E>
及其类型特定子类中定义的工厂方法获取。这些工厂接受所需向量实例的种类作为输入,并生成各种实例,例如元素为默认值(即零向量)的向量实例,或者从给定数组初始化的向量实例。
为了支持控制流,一些向量操作可选地接受由公共抽象类 VectorMask<E>
表示的掩码。掩码中的每个元素是一个布尔值,对应于向量的一个通道。掩码选择应用操作的通道:如果通道的掩码元素为真,则应用该操作,如果掩码为假,则采取一些替代行动。
类似于向量,VectorMask<E>
的实例是非公共具体子类的实例,针对每种元素类型和长度组合定义。用于操作的 VectorMask<E>
实例应与参与操作的向量实例具有相同的类型和长度。向量比较操作产生掩码,然后可以用作其他操作的输入,以选择性地对某些通道执行操作,从而模拟流程控制。也可以使用 VectorMask<E>
类中的静态工厂方法创建掩码。
我们预计,在开发相对于形状通用的向量计算时,掩码将发挥重要作用。这种预期基于谓词寄存器(相当于掩码)在 ARM 可扩展向量扩展和 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
字段中,以便运行时编译器将该值视为常量,并因此能够更好地优化向量计算。主循环然后以向量长度即种类长度为步长遍历输入数组。它从数组 a
和 b
在对应索引处加载给定种类的 float
向量,流畅地执行算术操作,然后将结果存储到数组 c
中。如果最后一个迭代之后有任何数组元素剩余,则使用普通的标量循环计算这些 尾部 元素的结果。
这种实现方式在大型数组上实现了最优性能。在支持 AVX 的 Intel 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
这是使用 Vector API 原型及其实现对上述代码进行 JMH 微基准测试的输出,可以在 Project Panama 开发库的 vectorIntrinsics
分支 找到。生成的热点机器代码区域清楚地显示了向量寄存器和向量指令的转换。我们禁用了循环展开(通过 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 内在函数的爆炸式增长,我们定义了对应于各种类型操作(如一元、二元、转换等)的广义内在函数,这些函数接受一个描述要执行的具体操作的参数。大约二十五个新的内在函数支持整个 API 的内在化。
我们最终期望将向量类声明为值类,正如 Project Valhalla (JEP 401) 所提议的那样。同时,Vector<E>
及其子类被视为 基于值的类,因此应避免对其实例进行与身份相关的操作。尽管从抽象上来说向量实例由通道中的元素组成,但这些元素不会被 C2 标量化——C2 将向量的值视为一个整体单元,如同 int
或 long
,映射到适当大小的向量寄存器。为了克服逃逸分析的限制并避免装箱,C2 对向量实例进行了特殊处理。将来我们会使这种特殊处理与 Valhalla 的值对象保持一致。
Intel SVML 内在函数用于超越运算
Vector API 支持浮点向量上的超越和三角通道操作。在 x64 架构上,我们利用 Intel Short Vector Math Library (SVML) 提供此类操作的优化内在实现。内在的操作具有与 java.lang.Math
中定义的相应标量操作相同的数值属性。
SVML 操作的汇编源文件位于 jdk.incubator.vector
模块的源代码中,处于特定于操作系统的目录下。JDK 构建过程会为目标操作系统编译这些源文件,生成特定于 SVML 的共享库。这个库相当大,接近一兆字节。如果通过 jlink
构建的 JDK 镜像省略了 jdk.incubator.vector
模块,则 SVML 库不会被复制到镜像中。
HotSpot 运行时会尝试加载 SVML 库,并且如果存在的话,会将 SVML 库中的操作绑定到命名的存根例程。C2 编译器根据操作和向量种类(即元素类型和形状)生成调用相应的存根例程的代码。
未来,如果 Project Panama 扩展其对本地调用约定的支持以包括向量值,则 Vector API 实现可能能够从外部源加载 SVML 库。如果这种方法没有性能损失,那么就不再需要将 SVML 包含在源码形式中并构建到 JDK 中。在此之前,考虑到潜在的性能提升,我们认为上述方法是可以接受的。
未来的工作
如上所述,我们最终希望将向量类声明为 值类。有关使 Vector API 与 Valhalla 保持一致的持续努力,请参见 Project Valhalla 代码仓库的 lworld+vector 分支。此外,我们预计进一步利用 Project Valhalla 的值类泛型特化,使得
Vector<E>
实例成为值对象,其中E
是诸如int
而非其装箱类Integer
的原始类。一旦我们有了针对原始类的泛型特化,可能就不再需要Vector<E>
的具体类型的子类型,如IntVector
。我们可能会添加对 IEEE 浮点 binary16 值(float16 值)向量的支持。这也依赖于 Project Valhalla,要求我们将 float16 值表示为值对象,在数组和字段中有优化布局,并增强 Vector API 实现以利用 float16 向量的向量硬件指令。有关探索性工作,请参见 Project Panama 的 Vector API 代码仓库的 vectorIntrinsics+fp16 分支。
我们预计增强实现以改进包含向量化代码的循环的优化,并通常随着时间的推移逐步提高性能。
我们还预计增强组合单元测试以验证 C2 是否生成了向量硬件指令。目前的单元测试假定,无需验证,重复执行足以导致 C2 生成向量硬件指令。我们将探索使用 C2 的 IR 测试框架 来跨平台断言 IR 图中是否包含向量节点(例如,使用 正则表达式匹配)。如果这种方法有问题,我们可能会探索一种使用非产品
-XX:+TraceNewVectors
标志打印向量节点的基本方法。我们将评估定义合成向量形状以更好地控制循环展开和矩阵操作,并考虑对排序和解析算法提供适当的支持。(更多详情请参阅 此演示文稿。)
替代方案
HotSpot 的自动向量化是另一种方法,但这需要大量的工作。而且,与 Vector API 相比,它仍然会显得脆弱和有限,因为在复杂的控制流下进行自动向量化是非常困难的。
总的来说,即使经过了几十年的研究——特别是对于 FORTRAN 和 C 数组循环——似乎自动向量化标量代码并不是优化用户编写循环的可靠策略,除非用户对编译器准备自动向量化的循环有着异常细致的关注。非常容易写出一个无法自动向量化的循环,而这种错误人类读者根本无法察觉。尽管在 HotSpot 上多年来的自动向量化工作为我们留下了许多仅在特殊场合有效的优化机制,但我们希望更频繁地使用这些机制!
测试
我们将开发组合单元测试,以确保所有操作、所有支持的类型和形状以及各种数据集的覆盖范围。
我们还将开发性能测试,以确保达到性能目标,并使向量计算高效映射到向量指令上。这可能包括 JMH 微基准测试,但同时还需要更现实的有用算法示例。此类测试最初可能会位于特定项目的仓库中。考虑到测试的数量及其生成方式,在集成到主仓库之前可能需要进行整理。
风险和假设
存在 API 偏向 x64 架构支持的 SIMD 功能的风险,但通过支持 AArch64 来缓解这种情况。这主要适用于显式固定的形状集合,这对以通用形状编码算法造成了一定偏见。我们认为 Vector API 的大多数其他操作偏向于可移植算法。为了减轻该风险,我们将考虑其他架构,特别是 ARM 标量矢量扩展架构,其编程模型动态调整以适应硬件支持的单一固定形状。我们欢迎并鼓励参与 HotSpot 特定于 ARM 领域工作的 OpenJDK 贡献者参与到这一努力中来。
Vector API 使用装箱类型(例如
Integer
)作为原始类型(例如int
)的代理。这是由于当前 Java 泛型的局限性所致,后者对原始类型不友好。当 Project Valhalla 最终引入更强大的泛型时,目前的决定将显得笨拙,并且很可能需要更改。我们假设可以进行这些更改而不会造成过度的向后不兼容。