跳到主要内容

JEP 338:矢量 API(孵化器)

概括

提供孵化器模块的初始迭代,jdk.incubator.vector以表达向量计算,这些计算在运行时可靠地编译为支持的 CPU 架构上的最佳向量硬件指令,从而实现优于等效标量计算的性能。

目标

  • 清晰简洁的 API: API 应能够清晰简洁地表达各种向量计算,这些向量计算由通常在循环内组成且可能包含控制流的向量运算序列组成。应该可以表达对向量大小(或每个向量的通道数)通用的计算,从而使此类计算能够跨支持不同向量大小的硬件进行移植(如下一个目标中详述)。

  • 平台无关: API 应与架构无关,从而支持在支持矢量硬件指令的多个 CPU 架构上运行时实现。与 Java API 中常见的情况一样,平台优化和可移植性发生冲突,因此偏向于使 Vector API 可移植,即使某些特定于平台的习惯用法无法直接用可移植代码表示。 x64 和 AArch64 性能的下一个目标代表了支持 Java 的所有平台上的适当性能目标。 ARM可扩展矢量扩展(SVE) 在这方面特别令人感兴趣,以确保 API 可以支持此架构,尽管在撰写本文时还没有已知的生产硬件实现。

  • x64 和 AArch64 架构上可靠的运行时编译和性能: Java 运行时,特别是 HotSpot C2 编译器,应在支持的 x64 架构上将向量操作序列编译为相应的向量硬件指令序列,例如Streaming SIMD支持的指令序列Extensions (SSE) 和Advanced Vector Extensions (AVX) 扩展,从而生成高效且高性能的代码。程序员应确信他们表达的向量运算将可靠地紧密映射到相关的硬件向量指令。这同样适用于编译为Neon支持的向量硬件指令序列的 ARM AArch64 架构。

  • _优雅降级:_如果向量计算无法在运行时完全表达为硬件向量指令序列,或者是因为某个架构不支持某些所需的指令,或者因为不支持另一个 CPU 架构,则 Vector API 实现应优雅降级并且仍然发挥作用。这可能包括如果向量计算无法充分编译为向量硬件指令,则向开发人员发出警告。在没有向量的平台上,优雅降级应产生与手动展开循环竞争的代码,其中展开因子是所选向量中的通道数。

非目标

  • 增强 HotSpot 中的自动矢量化支持并不是目标。

  • HotSpot 的目标不是在 x64 和 AArch64 以外的 CPU 架构上支持向量硬件指令。这种支持留给以后的 JEP。然而,重要的是要声明,正如目标中所表达的那样,API 不得排除此类实现。此外,所执行的工作可以自然地利用和扩展 HotSpot 中的现有抽象来实现自动矢量化矢量支持,从而使此类任务变得更容易。

  • 在本次或未来的迭代中支持 C1 编译器并不是我们的目标。我们希望在未来的工作中支持 Graal 编译器。

  • 支持 Java 关键字定义的严格浮点计算并不是目标strictfp。对浮点标量执行的浮点运算的结果可能不同于对浮点标量的向量执行的等效浮点运算。然而,这个目标并不排除表达或控制浮点向量计算所需精度或再现性的选项。

动机

向量计算由向量上的一系列运算组成。向量包含(通常)固定的标量值序列,其中标量值对应于硬件定义的向量通道的数量。对于每个通道,应用于具有相同通道数的两个向量的二元运算将对每个向量的相应两个标量值应用等效的标量运算。这通常称为单指令多数据(SIMD)。

向量运算表示一定程度的并行性,可以在单个 CPU 周期内执行更多工作,从而显着提高性能。例如,给定两个向量,每个向量覆盖八个整数的序列(八个通道),则可以使用单个硬件指令将这两个向量加在一起。向量加法硬件指令对十六个整数进行运算,执行八次整数加法,而通常对两个整数进行运算,执行一次整数加法。

HotSpot 支持自动向量化,其中标量运算转换为超字运算,然后映射到向量硬件指令。可变换标量操作集是有限的,并且容易受到代码形状变化的影响。此外,仅可以利用可用向量硬件指令的子集,这限制了生成的代码的性能。

希望编写能够可靠地转换为超级字操作的标量操作的开发人员需要了解 HotSpot 的自动矢量化支持及其限制,以实现可靠且可持续的性能。

在某些情况下,开发人员可能无法编写可转换的标量操作。例如,HotSpot 不会转换用于计算数组哈希码的简单标量操作(请参阅Arrays::hashCodeJDK 源代码中的方法实现),也无法自动向量化代码以按字典顺序比较两个数组(这就是为什么内部函数是添加以执行字典顺序比较,请参阅8033148)。

Vector API 旨在通过提供一种用 Java 编写复杂矢量算法的机制来解决这些问题,使用 HotSpot 中预先存在的矢量化支持,但使用用户模型使矢量化更加可预测和稳健。手动编码的向量循环可以表达hashCode自动向量化器可能永远无法优化的高性能算法(例如向量化或专门的数组比较)。这种显式矢量化 API 可能适用于许多领域,例如机器学习、线性代数、密码学、金融和 JDK 本身的用法。

描述

向量将由抽象类表示Vector<E>。类型变量E对应于向量覆盖的标量原始整型或浮点元素类型的装箱类型。向量还具有定义向量大小(以位为单位)的_形状_Vector<E>。当 HotSpot C2 编译器编译向量计算时,向量的形状将控制如何将 的实例映射到向量硬件寄存器(请参阅稍后的从实例到 x64 向量寄存器的映射)。向量的长度(通道或元素的数量)将是向量大小除以元素大小。

支持的元素类型集 ( )将EByteShortIntegerLongFloat,分别Double对应于标量基元类型byteshortintlong和。float``double

支持的形状集将对应于 64、128、256 和 512 位的矢量大小。对应于512位大小的形状可以将bytes打包成64个通道或将ints打包成16个通道,并且这种形状的向量可以byte一次操作64秒,或者int一次操作16秒。

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

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

的实例Vector<E>是不可变的,并且是基于值的类型,默认情况下保留对象标识不变量(有关这些不变量的放宽,请参阅后面的内容)。

对向量的操作可以分为车道间操作和跨车道操作。 Lane-wise 操作可以进一步分类为一元、二元、三元和比较。跨车道操作可以分为排列、转换和归约。为了减少 API 的表面,我们将为每个操作类定义集体方法,然后将运算符作为输入。支持的运算符是Operator类的实例,并在类中定义为静态最终字段VectorOperators。一些常见的操作(例如,add、mul),称为全服务操作,将具有可以用来代替通用方法的专用方法。

对向量的某些操作,例如逐车道转换和重新解释,可以说本质上是_形状改变的_。在矢量计算中进行形状改变操作可能会对可移植性和性能产生意想不到的影响。因此,只要适用,API 都会定义此类操作的附加形状不变风格。鼓励用户使用形状不变的操作风格来编写形状不变的代码。此外,形状改变操作将在 Javadoc 中明确指出。

Vector<E>声明一组所有元素类型都支持的常见向量运算的方法。为了支持特定于元素类型的操作,有 6 个抽象子类Vector<E>,每个支持的元素类型都有一个子类:ByteVectorShortVectorIntVectorLongVectorFloatVectorDoubleVector。这些子类定义了绑定到元素类型的附加操作,因为方法签名引用了元素类型(或等效的数组类型),例如归约操作(例如,将所有元素求和为标量值)或存储向量元素到数组。它们还定义了特定于积分子类型的附加全服务操作,例如按位运算(例如,逻辑或),以及特定于浮点类型的操作,例如数学运算(例如,超越函数,例如 pow( ))。

这些类通过为向量的不同形状(大小)定义的具体子类进一步扩展。

具体子类是非公开的,因为不需要提供特定于类型和形状的操作。这将 API 表面简化为关注点的总和,而不是产品。因此,Vector无法直接构造具体类的实例。相反,实例是通过基Vector<E>类及其特定类型的子类中定义的工厂方法获取的。这些方法将所需向量实例的种类作为输入。除了提供不同类型向量之间转换的规范支持之外,工厂方法还提供了获取向量实例的不同方法,例如其元素被初始化为默认值的向量实例(零向量)或来自数组的向量。或形状(例如铸件)。

为了支持控制流,相关向量操作将选择性地接受由公共抽象类表示的掩码VectorMask<E>。掩码中的每个元素(布尔值或位)对应于向量通道。当掩码是操作的输入时,它控制操作是否应用于每个通道;如果通道的掩码位已设置(为真),则应用该操作。如果未设置掩码位(为假),则会出现替代行为。与向量类似, 的实例VectorMask<E>是为每个元素类型和长度组合定义的(私有)具体子类的实例。操作中使用的实例应与操作中涉及的VectorMask<E>实例具有相同的类型和长度。Vector<E>比较操作产生掩码,然后可以将其输入到其他操作以选择性地禁用某些通道上的操作,从而模拟流量控制。创建掩码的另一种方法是在VectorMask<E>.

我们预计掩模可能会在形状通用的矢量计算的开发中发挥重要作用。 (这种期望是基于谓词寄存器的核心重要性,相当于 ARM 可扩展向量扩展以及 Intel 的 AVX-512 中的掩码。)

例子

这是对数组元素的简单标量计算:

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 实现等效向量计算的显式方法如下:

// Example 1

static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_256;

void vectorComputation(float[] a, float[] b, float[] c) {

for (int i = 0; i < a.length; i += SPECIES.length()) {
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);
}
}

在此示例中,256 位宽浮点向量的种类是从 获得的FloatVector。物种存储在一个static final字段中,因此运行时编译器会将字段的值视为常量,从而能够更好地优化向量计算。

矢量计算的特点是主循环内核以矢量长度(即物种长度)的步幅迭代数组。静态方法从数组中并在相应的索引fromArray()处加载float给定物种的向量。然后流畅地进行运算,最后将结果存储到 array 中。a``b``c

我们使用由 生成的掩码indexInRange()来防止读取/写入超过数组长度。第一次floor(a.length / SPECIES.length())迭代将有一个设置了所有车道的掩模。只有最终迭代(如果a.length不是 的倍数SPECIES.length())才会具有设置了第一个通道的掩码a.length % SPECIES.length()

由于在所有迭代中都使用掩码,因此上述实现对于大数组长度可能无法实现最佳性能。可以在没有掩码的情况下实现相同的计算,如下所示:

// Example 2

static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_256;

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;
}
}

尾部元素_的_长度小于物种长度,在矢量计算后使用标量计算进行处理。处理尾部元素的另一种方法是使用单个掩码向量计算。

当在大型阵列上操作时,上述实现实现了最佳性能。

对于第二个示例,HotSpot 编译器应在支持 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 原型和实现(vectorIntrinsics巴拿马项目开发存储库的分支)测试的示例代码的 JMH 微基准测试的实际输出。这显示了 C2 生成的机器代码的热门区域。向量寄存器和向量硬件指令有明确的转换。 (禁用循环展开是为了使转换更清晰,否则 HotSpot 应该能够使用现有的 C2 循环优化技术展开。)所有 Java 对象分配都被省略。

支持更复杂的非平凡向量计算是一个重要的目标,这些计算可以清楚地转换为生成的机器代码。

然而,这种特定的向量计算存在一些问题:

  1. 该循环被硬编码为具体的向量形状,因此计算无法动态适应架构支持的最大形状,该最大形状可能小于或大于 256 位。因此,代码的可移植性较差,并且性能可能较低。

  2. 循环上限的计算虽然很简单,但可能是编程错误的常见来源。

  3. 最后需要一个标量循环,复制代码。

我们将在此 JEP 中解决前两个问题。可以获得一个首选物种,其形状对于当前架构来说是最佳的,然后可以用通用形状编写向量计算,并且物种上的方法可以向下舍入数组长度,例如:

static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;

void vectorComputation(float[] a, float[] b, float[] c,
VectorSpecies<Float> species) {
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;
}
}

vectorComputation(a, b, c, SPECIES);

第三个问题不会在本 JEP 中得到完全解决,并将成为未来工作的主题。如第一个示例所示,您可以使用掩码来实现向量计算,而无需尾部处理。我们预计此类屏蔽循环将适用于一系列架构,包括 x64 和 ARM,但需要额外的运行时编译器支持才能生成最高效的代码。此类有关屏蔽循环的工作虽然很重要,但超出了本 JEP 的范围。

HotSpot C2 编译器详细信息

Vector API 有两种实现方式来实现此 JEP 的目标。第一个在 Java 中实现操作,因此它是功能性的,但不是最佳的。对于 HotSpot C2 编译器来说,第二个使那些对 Vector API 类型进行特殊处理的操作成为内在的。在存在架构支持和翻译实现的情况下,这允许正确翻译到硬件寄存器和指令。

为了避免添加到 C2 的内部函数激增,将定义一组与二进制、一元、比较等操作类型相对应的内部函数,其中传递常量参数来描述操作细节。大约需要 20 个新的内在函数来支持 API 所有部分的内在化。

Vector实例是基于价值的,即应避免身份敏感操作的道德价值观。此外,虽然向量实例抽象地由通道中的元素组成,但这些元素并未被 C2 标量化。向量值被视为一个整体单元,例如intlong,映射到适当大小的硬件向量寄存器。内联类型需要一些相关的增强功能,以确保将向量值视为一个整体单元。

在内联类型可用之前,VectorC2 将专门处理实例,以克服逃逸分析中的限制并避免装箱。因此,应避免对向量进行身份敏感操作。

未来的工作

一旦准备就绪,Vector API 将从值类型中受益匪浅(请参阅Valhalla 项目)。 a 的实例Vector<E>可以是值,其具体类是内联类型。这将使优化和表达向量计算变得更加容易。Vector<E>特定类型的子类型(例如IntVector)可能不需要内联类型和特定于类型的方法声明的泛型专业化。

因此,Vector API 的未来版本将使用内联类型和增强的泛型,如上所述。因此,我们将在 JDK 的多个版本上孵化 API,并随着内联类型的可用而进行调整。

当 API 从孵化 API 过渡时,我们将使用JEP 370 外部内存访问 API的功能来增强 API 来加载和存储向量。此外,描述向量种类的存储器布局可能被证明是有用的,例如跨过由元素组成的存储器段。

我们预计通过以下方式加强实施:

  • 包括对矢量化超越运算的支持(例如对数和三角函数),

  • 改进包含矢量化代码的循环的优化,

  • 优化支持平台上的屏蔽向量操作,以及

  • 对大矢量大小进行调整(例如,ARM SVE 支持的矢量大小)。

随着我们对实施的逐步改进,绩效工作将持续进行。

备择方案

HotSpot 的自动矢量化是一种替代方法,但需要大量工作。此外,与使用 Vector API 相比,它可能仍然很脆弱且受到限制,因为具有复杂控制流的自动矢量化很难执行。

一般来说,即使经过数十年的研究(特别是对于 FORTRAN 和 C 数组循环),标量代码的自动向量化似乎也不是优化临时用户编写的循环的可靠策略,除非用户异常仔细地关注关于编译器准备自动矢量化哪些循环的不成文约定。编写一个无法自动矢量化的循环太容易了,其原因是优化器无法检测到的,但人类读者无法检测到。多年的自动矢量化工作,即使是在 HotSpot 中,也给我们留下了许多只在特殊场合起作用的优化机制。我们希望更频繁地享受使用这台机器的乐趣!

测试

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

我们还将开发性能测试,以确保满足性能目标,并将矢量计算有效地映射到矢量硬件指令。这可能包括 JMH 微基准,但还需要有用算法的更现实的示例。此类测试最初可能驻留在项目特定的存储库中。考虑到测试的比例及其生成方式,在集成到主存储库之前可能需要进行管理。

作为性能测试的备份,我们可以创建白盒测试来强制 JIT 向我们报告矢量 API 源代码实际上触发了矢量化。

风险和假设

API 存在偏向 x64 架构上支持的 SIMD 功能的风险,但通过支持 AArch64 可以缓解这种风险。这主要适用于明确固定的受支持形状集,其对形状通用方式的编码算法产生偏见。我们认为 Vector API 的大多数其他操作都偏向于可移植算法。为了减轻这种风险,我们将考虑其他架构,特别是 ARM 标量矢量扩展架构,其编程模型会动态调整以适应硬件支持的单一固定形状。我们欢迎并鼓励致力于 HotSpot ARM 特定领域的 OpenJDK 贡献者参与这项工作。

Vector API 使用框类型(例如Integer)作为基本类型(例如int)的代理。这一决定是由于 Java 泛型当前的限制而做出的,它对原始类型怀有敌意。当瓦哈拉项目最终引入功能更强大的仿制药时,当前的决定将显得尴尬,并且可能需要改变。我们假设这样的改变是可能的,而不会出现过度的向后不兼容性。