跳到主要内容

JEP 417:矢量 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 编译器)应将向量操作编译为相应的高效且高性能的向量指令,例如Streaming SIMD Extensions (SSE) 和Advanced支持的向量指令矢量扩展(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) 为ByteShortIntegerLong和,分别对应于标量基元类型、、、Float和。Double``byte``short``int``long``float``double

支持的形状集对应于 64、128、256 和 512 位以及_最大位的_矢量大小。 512 位形状可以将bytes 打包到 64 个通道或将ints 打包到 16 个通道,并且这种形状的向量可以byte一次运行 64 秒或int一次运行 16 秒。max -bits形状支持当前架构的最大向量大小。这可以支持 ARM SVE 平台,该平台实现可以支持从 128 位到 2048 位的任何固定大小(以 128 位为增量)。

我们相信这些简单的形状足够通用,可以在所有相关平台上使用。然而,当我们在这个 API 的孵化过程中对未来的平台进行实验时,我们可能会进一步修改形状参数的设计。此类工作不属于该项目的早期范围,但这些可能性在一定程度上说明了形状在 Vector API 中的当前作用。 (有关进一步的讨论,请参阅下面的未来工作部分。)

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

对向量的操作被分类为_逐车道_或_跨车道_。

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

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

为了减少 API 的表面,我们为每个操作类定义了集体方法。这些方法将运算符常量作为输入;这些常量是VectorOperator.Operator类的实例,并在类的静态最终字段中定义VectorOperators。为了方便起见,我们定义了专用方法,可以用来代替通用方法,用于一些常见的_全服务_操作,例如加法和乘法。

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

该类Vector<E>声明了一组用于所有元素类型支持的常见向量运算的方法。对于特定于元素类型的操作,有 6 个抽象子类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>。 shuffle 中的每个元素都是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字段中,以便运行时编译器将该值视为常量,从而可以更好地优化向量计算。然后,主循环以向量长度(即物种长度)的步幅迭代输入数组。它float从数组中加载给定物种的向量ab在相应的索引处,流畅地执行算术运算,然后将结果存储到 array 中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

这是上述代码的 JMH 微基准测试的输出,使用 Vector API 的原型和在巴拿马项目开发存储库的vectorIntrinsics分支上找到的实现。生成的机器代码的这些热门区域显示了对向量寄存器和向量指令的清晰转换。为了使翻译更清晰,我们禁用了循环展开;否则,HotSpot 将使用现有的 C2 循环优化来展开此代码。所有 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 的内在化。

我们期望最终将向量类声明为原始类,正如JEP 401(原始对象)Valhalla 项目所提议的那样。同时,及其子类被视为基于值的类,因此应避免对其实例进行身份敏感操作。尽管向量实例抽象地由通道中的元素组成,但这些元素不会被 C2 标量化——向量的值被视为一个整体单元,如 an或 a ,映射到适当大小的向量寄存器。 C2 对向量实例进行了特殊处理,以克服逃逸分析的局限性并避免装箱。Vector<E>``int``long

用于超越运算的英特尔 SVML 内在函数

Vector API 支持浮点向量的超越和三角车道运算。在 x64 上,我们利用英特尔短向量数学库 (SVML) 为此类操作提供优化的内在实现。内在运算具有与 中定义的相应标量运算相同的数值属性java.lang.Math

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

目前该实现仅支持 Linux 和 Windows。我们稍后会考虑 macOS 支持,因为为程序集源文件提供所需的指令是一项艰巨的工作。

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

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

未来的工作

  • 如上所述,我们希望最终将向量类声明为原始类。进一步,我们期望利用 Valhalla 项目对原始类的通用专业化,以便 的实例Vector<E>可以是原始值,其具体类型是原始类型。这将使优化和表达向量计算变得更加容易。一旦我们对原始类进行了通用专业化,就可能不需要特定类型的子类型Vector<E>,例如。IntVector我们打算在多个版本中孵化 API,并在原始类和相关设施可用时对其进行调整。

  • 我们打算在 API 脱离孵化状态时增强 API,以使用JEP 412(外部函数和内存 API)加载和存储向量。描述向量种类的存储器布局可能被证明是有用的,例如跨过由向量元素组成的存储器段。

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

  • 我们还期望增强组合单元测试,以断言 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 项目最终引入功能更强大的仿制药时,当前的决定将显得尴尬,并且可能需要改变。我们假设这样的改变是可能的,而不会出现过度的向后不兼容性。