跳到主要内容

JEP 489: Vector API(第九个孵化版本)

QWen Max 中英对照 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 操作。

  • selectFromrearrange 跨车道操作现在对索引进行包装而不是检查是否越界。结合其他实现改进,这些操作现在显著更快。因此,Vector API 可以表达使用表查找的高效 SIMD 算法。

  • 在 ARM 和 RISC-V 上的超越函数和三角函数车道级操作现在通过调用 SIMD Library for Evaluating Elementary Functions (SLEEF) 的内部函数来实现。

  • 新的基于值的类 Float16 表示 IEEE 754 binary16 格式的 16 位浮点数。未来我们计划在支持的硬件上自动向量化 Float16 操作,增强 Vector API 以覆盖 Float16 值元素,并最终在 Project Valhalla 可用时将 Float16 迁移为值类。

  • 算术整数车道级操作现在包括:

    • 饱和无符号加法和减法,
    • 饱和有符号加法和减法,以及
    • 无符号最大值和最小值。

    相当的标量方法在新类 VectorMath 中声明,我们用这些新的标量方法来规定车道级操作。

Vector API 将会继续孵化,直到 Project Valhalla 的必要特性作为预览特性提供。届时,我们将使 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 同样会将向量操作编译为 NEONSVE 支持的向量指令。

  • 优雅的降级 — 有时向量计算不能完全在运行时作为一系列向量指令来表达,可能是由于架构不支持某些所需的指令。在这种情况下,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 旨在通过提供一种使用现有 HotSpot 自动向量化器但采用用户模型(该模型使向量化更加可预测和健壮)来用 Java 编写复杂向量算法的方法来改善这种情况。手写的向量循环可以表达高性能的算法,例如向量化的 hashCode 或专门的数组比较,而自动向量化器可能永远不会优化这些算法。许多领域都可以从这种显式的向量 API 中受益,包括机器学习、线性代数、密码学、金融以及 JDK 本身的代码。

描述

向量由抽象类 Vector<E> 表示。类型变量 E 被实例化为向量所涵盖的标量原始整数或浮点元素类型的封装类型。向量还有一个形状,它定义了向量的大小(以位为单位)。向量的形状决定了当向量计算由 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 孵化期间,随着我们尝试未来的平台,我们可能会进一步修改 shape 参数的设计。这项工作不在本项目的早期范围内,但这些可能性在一定程度上决定了形状在 Vector API 中的当前作用。(有关详细讨论,请参阅下面的未来工作部分。)

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

向量操作被分类为逐车道跨车道

  • 车道级操作将诸如加法之类的标量运算符并行应用于一个或多个向量的每个车道。车道级操作通常会产生一个长度和形状相同的向量,但并不总是如此。车道级操作进一步分为一元、二元、三元、测试或转换操作。

  • 跨车道操作会对整个向量应用一个运算。跨车道操作会产生一个标量或可能具有不同形状的向量。跨车道操作进一步分为排列或归约操作。

为了减少 API 的表面复杂性,我们为每类操作定义了集体方法。这些方法接受操作符常量作为输入;这些常量是 VectorOperators.Operator 类的实例,并在 VectorOperators 类中的静态 final 字段中定义。为了方便起见,我们为一些常见的全服务操作(如加法和乘法)定义了专门的方法,这些方法可以替代通用方法使用。

某些向量操作,例如转换和重新解释,本质上是改变形状的;也就是说,它们产生的向量形状与其输入的形状不同。向量计算中的改变形状操作可能会对可移植性和性能产生负面影响。因此,API 在适用时为每个改变形状的操作定义了一个形状不变的版本。为了获得最佳性能,开发人员应尽可能使用形状不变的操作来编写形状不变的代码。改变形状的操作在 API 规范中被明确标识。

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

在实现方面,这些 Vector<E> 的类型特定子类进一步由不同向量形状的具体子类扩展。这些具体子类不是公开的,因为不需要提供特定于类型和形状的操作。这将 API 表面减少为关注点的总和,而不是乘积。具体 Vector 类的实例是通过在基类 Vector<E> 及其类型特定子类中定义的工厂方法获得的。这些工厂以所需向量实例的种类作为输入,并生成各种类型的实例,例如元素为默认值的向量实例(即零向量),或从给定数组初始化的向量实例。

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

与向量类似,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 字段中,这样运行时编译器会将该值视为常量,从而可以更好地优化向量计算。主循环然后以向量长度(即物种长度)为步长遍历输入数组。它从数组 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

这是上述代码使用 Vector API 原型和 Project Panama 开发存储库的 vectorIntrinsics 分支 中实现的 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 内联函数的激增,我们定义了泛化的内联函数,对应于各种类型的操作,如一元、二元、转换等,并且这些内联函数带有一个描述要执行的具体操作的参数。大约二十五个新的内联函数支持整个 API 的内联化。

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

Intel SVML 内联函数用于超越运算

向量 API 支持对浮点向量进行超越和三角逐元素运算。在 x64 上,我们利用 Intel Short Vector Math Library (SVML) 来为这些运算提供优化的内在函数实现。内在函数运算与 java.lang.Math 中定义的相应标量运算具有相同的数值特性。

SVML 操作的汇编源文件位于 jdk.incubator.vector 模块的源代码中,处于特定于操作系统的目录下。JDK 构建过程会将这些源文件编译为目标操作系统的一个特定于 SVML 的共享库。这个库相当大,将近一兆字节。如果通过 jlink 构建的 JDK 镜像省略了 jdk.incubator.vector 模块,则 SVML 库不会被复制到镜像中。

该实现目前仅支持 Linux 和 Windows。我们将考虑在以后支持 macOS,因为提供具有所需指令的汇编源文件是一项相当复杂的工作。

HotSpot 运行时将尝试加载 SVML 库,如果存在,则将 SVML 库中的操作绑定到命名的存根例程。C2 编译器生成调用适当存根例程的代码,该代码基于操作和向量种类(即,元素类型和形状)。

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

未来的工作

  • 如上所述,我们最终期望将向量类声明为value 类。有关使 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 实现以利用浮点16值向量上的向量硬件指令。对于探索性工作,请参阅 Project Panama 的 Vector API 代码库的 vectorIntrinsics+fp16 分支。

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

  • 我们还预计会增强组合单元测试,以确保 C2 生成向量硬件指令。当前的单元测试假设重复执行足以促使 C2 生成向量硬件指令,而没有进行验证。我们将探索使用 C2 的 IR Test Framework,跨平台断言 IR 图中存在向量节点(例如,使用 regex matching)。如果这种方法有问题,我们可能会探索一种基本方法,使用非生产 -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 通用类目前对原始类型的限制而被迫做出的。当 Project Valhalla 最终引入更强大的通用类时,当前的决定将显得笨拙,并且可能需要更改。我们假设这样的更改可以在不产生过多向后不兼容性的情况下进行。