JEP 489:Vector 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 上的超越和三角通道运算现在通过调用SIMD 基本函数求值库 (SLEEF) 的内部函数来实现。
-
新的基于值的类表示IEEE 754 binary16
Float16
格式的 16 位浮点数。未来,我们打算在支持硬件上自动矢量化操作,增强 Vector API 以涵盖作为值的元素,并最终在Project Valhalla可用时迁移为值类。Float16``Float16``Float16
-
算术积分车道运算现在包括:
- 饱和无符号加法和减法,
- 饱和有符号加法和减法,以及
- 无符号最大值和最小值。
在新的类中声明了等效的标量方法
VectorMath
,并根据这些新的标量方法指定车道操作。
Vector API 将处于孵化阶段,直到Project Valhalla的必要功能作为预览功能推出。届时,我们将调整 Vector API 及其实现以使用它们,并将 Vector API 从孵化阶段推进到预览阶段。
目标
-
清晰简洁的 API — API 应能够清晰简洁地表达各种向量计算,这些计算由循环内组成的向量操作序列组成,并可能带有控制流。应能够表达与向量大小或每个向量的通道数相关的通用计算,从而使此类计算能够在支持不同向量大小的硬件之间移植。
-
平台无关性— API 应与 CPU 架构无关,从而能够在支持矢量指令的多种架构上实现。与 Java API 中常见的情况一样,如果平台优化和可移植性发生冲突,那么我们将倾向于使 API 可移植,即使这会导致某些平台特定的习语无法在可移植代码中表达。
-
x64 和 AArch64 架构上可靠的运行时编译和性能— 在功能强大的 x64 架构上,Java 运行时(特别是 HotSpot C2 编译器)应将矢量操作编译为相应的高效且性能卓越的矢量指令,例如流 SIMD 扩展(SSE) 和高级矢量扩展(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方法),也无法自动矢量化代码以按字典顺序比较两个数组(因此我们添加了用于按字典顺序比较的内在函数)。
Vector API 旨在通过提供一种在 Java 中编写复杂矢量算法的方法,使用现有的 HotSpot 自动矢量化器,但使用用户模型,使矢量化更加可预测和可靠,从而改善这种情况。手工编码的矢量循环可以表达高性能算法,例如矢量化hashCode
或专用数组比较,而自动矢量化器可能永远不会优化这些算法。许多领域都可以从这种显式矢量 API 中受益,包括机器学习、线性代数、密码学、金融和 JDK 本身内的代码。
描述
向量由抽象类表示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
s 打包到 64 个通道中或将int
s 打包到 16 个通道中,并且这种形状的向量可以byte
一次对 64 个 s 或int
一次对 16 个 s 进行操作。_最大_位数形状支持当前架构的最大向量大小。这可以支持 ARM SVE 平台,其中平台实现可以支持从 128 到 2048 位的任何固定大小,以 128 位为增量。
我们相信这些简单的形状足够通用,可以在所有相关平台上使用。但是,当我们在孵化此 API 期间尝试未来平台时,我们可能会进一步修改形状参数的设计。这类工作不在本项目的早期范围内,但这些可能性在一定程度上说明了形状在 Vector API 中的当前作用。(有关进一步讨论,请参阅下面的未来工作部分。)
元素类型和形状的组合决定了矢量的_种类_,用表示VectorSpecies<E>
。
矢量上的操作分为_车道级操作_和_跨车道级_操作。
-
_逐行_操作将标量运算符(例如加法)并行应用于一个或多个向量的每个通道。逐行操作通常(但并非总是)产生长度和形状相同的向量。逐行操作进一步分为一元、二元、三元、测试或转换操作。
-
_跨通道_运算将运算应用于整个向量。跨通道运算会产生标量或可能形状不同的向量。跨通道运算进一步分为置换运算或缩减运算。
为了减少 API 的面,我们为每类操作定义了集合方法。这些方法将运算符常量作为输入;这些常量是类的实例VectorOperators.Operator
,并在类中的静态 final 字段中定义。为方便起见,我们为一些常见的_全服务_操作(例如加法和乘法)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 可扩展矢量扩展和英特尔的 AVX-512 中的核心重要性。
在此类平台上,的实例VectorMask<E>
被映射到谓词寄存器,而掩码接受操作被编译为谓词寄存器接受向量指令。在不支持谓词寄存器的平台上,将采用效率较低的方法:VectorMask<E>
在可能的情况下,将的实例映射到兼容的向量寄存器,并且通常,掩码接受操作由等效的非掩码操作和混合操作组成。
为了支持跨通道置换操作,一些向量操作接受由公共抽象类表示的 shuffle。shuffleVectorShuffle<E>
中的每个元素都是int
与通道索引相对应的值。shuffle 是通道索引的映射,描述通道元素从给定向量到结果向量的移动。
与向量和掩码类似,的实例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
字段中,以便运行时编译器将该值视为常量,从而可以更好地优化向量计算。然后,主循环以向量长度(即物种长度)的步幅对输入数组进行迭代。它从数组和 的相应索引处加载float
给定物种的向量,流畅地执行算术运算,然后将结果存储到数组 中。如果在最后一次迭代后剩下任何数组元素,则使用普通标量循环计算这些_尾部元素的结果。_a``b``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 的内在化。
我们最终希望将向量类声明为值类,正如Valhalla 项目( JEP 401 ) 所提议的那样。同时,Vector<E>
及其子类被视为基于值的类,因此应避免对其实例执行身份敏感操作。尽管向量实例抽象地由通道中的元素组成,但这些元素不会被 C2 标量化 - 向量的值被视为一个整体单元,例如int
或long
,它映射到适当大小的向量寄存器。C2 对向量实例进行特殊处理,以克服逃逸分析中的限制并避免装箱。将来,我们会将这种特殊处理与 Valhalla 的值对象保持一致。
用于超越运算的英特尔 SVML 内在函数
Vector API 支持对浮点向量进行超越和三角函数逐行运算。在 x64 上,我们利用 Intel 短向量数学库 (SVML) 为此类运算提供优化的内在实现。内在运算具有与 中定义的相应标量运算相同的数值属性java.lang.Math
。
SVML 操作的汇编源文件位于模块的源代码中jdk.incubator.vector
,位于特定于操作系统的目录下。JDK 构建过程会将这些目标操作系统的源文件编译为特定于 SVML 的共享库。这个库相当大,大小不到一兆字节。如果通过 构建的 JDK 映像jlink
省略了该jdk.incubator.vector
模块,则不会将 SVML 库复制到映像中。
目前该实现仅支持 Linux 和 Windows。我们稍后会考虑 macOS 支持,因为提供具有所需指令的汇编源文件是一项不小的工作量。
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 浮点二进制 16 值(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 泛型的当前限制所致,这些限制不利于原始类型。当 Valhalla 项目最终引入功能更强大的泛型时,当前的决定将显得尴尬,并且可能需要进行更改。我们假设此类更改是可能的,而不会产生过多的向后不兼容性。