Skip to content

JEP 417: Vector API (Third Incubator) | Vector API(第三个孵化器)

摘要

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

历史

Vector API 最初由 JEP 338 提出,并作为 孵化 API 集成到 Java 16 中。第二轮孵化由 JEP 414 提出,并集成到 Java 17 中。

我们提议在这里纳入根据反馈进行的改进,以及性能提升和其他重要的实现改进。我们包括以下显著变化:

目标

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

  • 平台无关 — API 应与 CPU 架构无关,能够在支持向量指令的多个架构上实现。与 Java API 中的常见情况一样,当平台优化与可移植性发生冲突时,将偏向于使 API 可移植,即使这会导致某些平台特定的习惯用法无法在可移植代码中表达。

  • 在 x64 和 AArch64 架构上的可靠运行时编译和性能 — 在支持的 x64 架构上,Java 运行时(特别是 HotSpot C2 编译器)应将向量操作编译为对应的高效且性能优异的向量指令,如 流 SIMD 扩展(SSE)和 高级向量扩展(AVX)所支持的指令。开发人员应确信他们表达的向量操作将可靠地映射到相关的向量指令上。在支持的 ARM AArch64 架构上,C2 编译器也会将向量操作编译为 NEONSVE 支持的向量指令。

  • 优雅降级 — 有时,向量计算无法在运行时完全表示为一系列向量指令,可能是因为架构不支持某些所需的指令。在这种情况下,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) 包括 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> 表示的掩码。掩码中的每个元素都是一个布尔值,对应于一个向量通道。掩码选择操作应用的通道:如果通道的掩码元素为真,则应用该操作;如果掩码为假,则执行某些替代操作。

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

我们预计,在开发相对于形状具有通用性的向量计算时,掩码将发挥重要作用。这一预期基于谓词寄存器(掩码的等效物)在 ARM 可扩展向量扩展和英特尔 AVX-512 中的核心重要性。

在这样的平台上,VectorMask<E> 的实例映射到谓词寄存器,而接受掩码的操作被编译为接受谓词寄存器的向量指令。在不支持谓词寄存器的平台上,采用效率较低的方法:尽可能将 VectorMask<E> 的实例映射到兼容的向量寄存器,并且通常,接受掩码的操作由等效的非掩码操作和混合操作组成。

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

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

示例

这里是一个简单的数组元素标量计算:

java
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 的等效向量计算:

java
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 中。如果最后一个迭代后仍有数组元素剩余,则使用普通的标量循环计算这些尾部元素的结果。

此实现在大型数组上实现了最佳性能。在支持 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 的原型和 Project Panama 开发存储库 vectorIntrinsics分支 中的实现,针对上述代码进行的 JMH 微基准测试的输出。生成的机器代码的这些热点区域清楚地展示了向量化寄存器和向量化指令的转换。为了使转换更清晰,我们禁用了循环展开;否则,HotSpot 将使用现有的 C2 循环优化来展开此代码。所有 Java 对象分配都被省略了。

在支持谓词寄存器的平台上,上述示例可以编写得更简单,无需使用标量循环来处理尾部元素,同时仍能达到最佳性能:

java
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 会特别处理向量实例。

超越运算的 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 库复制到映像中。

目前,该实现仅支持 Linux 和 Windows。由于提供带有必需指令的汇编源文件需要相当多的工作,因此我们稍后会考虑 macOS 支持。

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

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

未来工作

  • 如上所述,我们最终期望将向量类声明为 原始类。此外,我们期望利用 Valhalla 项目的原始类泛型特化,以便 Vector<E> 的实例可以是具体类型为原始类型的原始值。这将更容易优化和表达向量计算。一旦我们对原始类有了泛型特化,可能就不再需要 Vector<E> 针对特定类型的子类(如 IntVector)。我们打算在多个版本中孵化 API,并根据原始类和相关设施的可用性进行调整。

  • 我们打算在 JEP 412(Foreign Function & Memory API)从孵化阶段过渡后,增强 API 以使用它加载和存储向量。描述向量种类的内存布局可能会很有用,例如,用于在由向量元素组成的内存段上进行步长遍历。

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

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

备选方案

HotSpot 的自动向量化是一种备选方法,但需要进行大量工作。此外,与 Vector API 相比,它仍然脆弱且有限,因为包含复杂控制流的自动向量化非常难以执行。

一般来说,即使经过数十年的研究(尤其是针对 FORTRAN 和 C 数组循环),似乎标量代码的自动向量化并不是优化用户编写的特定循环的可靠策略,除非用户极其小心地关注编译器准备自动向量化的哪些循环的未成文约定。编写一个因人类读者无法检测到的原因而未能自动向量化的循环太容易了。即使在 HotSpot 上,多年的自动向量化工作也给我们留下了大量仅在特殊场合下才有效的优化机制。我们希望更频繁地使用这些机制!

测试

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

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

风险与假设

  • 存在 API 偏向 x64 架构上支持的 SIMD 功能的风险,但通过使用 AArch64 支持可以减轻这一风险。这主要适用于明确固定的支持形状集合,这些集合偏向以形状通用的方式编码算法。我们认为 Vector API 的大多数其他操作偏向可移植算法。为了减轻这一风险,我们将考虑其他架构,特别是 ARM Scalar Vector Extension 架构,其编程模型会根据硬件支持的单一固定形状动态调整。我们欢迎并鼓励 OpenJDK 中负责 HotSpot ARM 特定区域的贡献者参与这一工作。

  • Vector API 使用装箱类型(如 Integer)作为原始类型(如 int)的代理。这一决定是由 Java 泛型当前的局限性所迫,这些局限性对原始类型不友好。当 Project Valhalla 最终引入更强大的泛型时,当前的决定可能会显得笨拙,并可能需要更改。我们假设这样的更改可以在不造成过多向后不兼容性的情况下进行。