1. 简介

Vector API 是 Java 生态中的一个孵化器 API,用于在支持的 CPU 架构上表达向量计算。其目标是在向量计算上提供比等效标量替代方案更优的性能。

在 Java 19 中,Vector API 作为 JEP 426 的一部分提出了第四轮孵化。

本教程将探索 Vector API 的相关术语以及如何利用该 API。

2. 标量、向量与并行性

深入了解 Vector API 前,理解 CPU 操作中的标量和向量概念至关重要。

2.1. 处理单元与 CPU

CPU 利用多个处理单元执行操作。处理单元一次只能计算一个值这个值称为标量值,因为它只是一个值。操作可以是单目操作(操作单个操作数)或双目操作(操作两个操作数)。数字加 1 是单目操作的例子,而两个数字相加是双目操作。

处理单元执行这些操作需要一定时间,以时钟周期衡量。处理单元执行某些操作可能需要 0 个周期,而执行其他操作(如加法)可能需要多个周期

2.2. 并行性

现代 CPU 通常有多个核心,每个核心包含多个可执行操作的处理单元。这提供了在这些处理单元上并行执行操作的能力。多个线程可在各自核心上运行程序,实现操作的并行执行。

当处理大规模计算(如从大型数据源添加大量数字)时,可将数据拆分为小块并分配给多个线程,从而加速处理。这是并行计算的一种方式。

2.3. SIMD 处理器

另一种并行计算方式是使用 SIMD 处理器。SIMD 代表单指令多数据。这些处理器没有多线程概念。SIMD 处理器依赖多个处理单元,这些单元在单个 CPU 周期内执行相同操作。它们共享执行的程序(指令)但不共享底层数据,因此得名。它们执行相同操作但操作不同操作数。

与处理器从内存加载标量值不同,SIMD 机器在操作前将整数数组从内存加载到寄存器。SIMD 硬件的组织方式使得数组值的加载操作可在单个周期内完成。SIMD 机器允许我们在数组上并行执行计算,无需依赖并发编程。

由于 SIMD 机器将内存视为数组或值范围,我们称之为向量,SIMD 机器执行的任何操作都成为向量操作。因此,利用 SIMD 架构原理是执行并行处理任务的强大高效方式。

3. Vector API

了解了向量概念后,我们探讨 Java 提供的 Vector API 基础。Java 中的向量由抽象类 Vector<E> 表示。其中 E 是以下标量原始整数类型(byteshortintlong)和浮点类型(floatdouble)的包装类型。

3.1. 形状、种类与通道

存储和处理向量的预定义空间范围是 64 到 512 位。假设有一个 Integer 类型的向量,存储空间为 256 位,则总共有 8 个分量(因为原始 int 值占 32 位)。这些分量在 Vector API 中称为通道

向量的形状是其位大小或位数。形状为 512 位的向量有 16 个通道,可同时操作 16 个 int 值;而 64 位的向量只有 2 个通道。使用术语通道是为了类比数据在 SIMD 机器通道中的流动方式。

向量的种类是其形状和数据类型(如 intfloat 等)的组合,由 VectorSpecies<E> 表示。

3.2. 向量的通道操作

向量操作主要分为两类:通道操作和跨通道操作。

顾名思义,通道操作一次只对单个通道执行标量操作。这些操作可将一个向量的通道与另一个向量的通道组合(例如加法操作)。

跨通道操作可计算或修改向量不同通道的数据。对向量分量排序是跨通道操作的例子。跨通道操作可从源向量生成标量或不同形状的向量。跨通道操作可进一步分为置换和归约操作。

3.3. Vector API 的层次结构

Vector<E> 类有六个抽象子类,对应六种支持类型:ByteVectorShortVectorIntVectorLongVectorFloatVectorDoubleVector。特定实现对 SIMD 机器很重要,因此每个类型都有形状特定的子类(如 Int128VectorInt512Vector 等)进一步扩展这些类。

4. 使用 Vector API 进行计算

现在看一些 Vector API 代码示例,涵盖通道操作和跨通道操作。

4.1. 两个数组相加

将两个整数数组相加并存储到第三个数组中。传统标量实现方式:

public int[] addTwoScalarArrays(int[] arr1, int[] arr2) {
    int[] result = new int[arr1.length];
    for(int i = 0; i< arr1.length; i++) {
        result[i] = arr1[i] + arr2[i];
    }
    return result;
}

用向量方式重写。Vector API 包位于 jdk.incubator.vector,需导入类。

首先从两个数组创建向量。使用 fromArray() 方法,需提供向量种类和数组起始偏移量(此处为 0)。使用默认的 SPECIES_PREFERRED(适合平台的最大位大小):

static final VectorSpecies<Integer> SPECIES = IntVector.SPECIES_PREFERRED;
var v1 = IntVector.fromArray(SPECIES, arr1, 0);
var v2 = IntVector.fromArray(SPECIES, arr2, 0);

得到两个向量后,使用 add() 方法:

var result = v1.add(v2);

最后将向量结果转换为数组并返回:

public int[] addTwoVectorArrays(int[] arr1, int[] arr2) {
    var v1 = IntVector.fromArray(SPECIES, arr1, 0);
    var v2 = IntVector.fromArray(SPECIES, arr2, 0);
    var result = v1.add(v2);
    return result.toArray();
}

在 SIMD 机器上,加法操作会在同一 CPU 周期内将两个向量的所有通道相加。

4.2. VectorMasks

上述代码存在局限性:仅当通道数与 SIMD 机器可处理的向量大小匹配时才能高效运行。这引出了向量掩码(VectorMasks<E>)的概念,它类似于布尔值数组。当无法将所有输入数据填入向量时,需借助 VectorMasks

掩码选择要应用操作的通道:若对应通道值为 true 则应用操作;否则执行备用操作。

掩码使操作独立于向量形状和大小。可使用预定义的 length() 方法在运行时获取向量形状。

以下是使用掩码改进的代码,按向量长度步长迭代输入数组,并执行尾部清理:

public int[] addTwoVectorsWithMasks(int[] arr1, int[] arr2) {
    int[] finalResult = new int[arr1.length];
    int i = 0;
    for (; i < SPECIES.loopBound(arr1.length); i += SPECIES.length()) {
        var mask = SPECIES.indexInRange(i, arr1.length);
        var v1 = IntVector.fromArray(SPECIES, arr1, i, mask);
        var v2 = IntVector.fromArray(SPECIES, arr2, i, mask);
        var result = v1.add(v2, mask);
        result.intoArray(finalResult, i, mask);
    }

    // 尾部清理循环
    for (; i < arr1.length; i++) {
        finalResult[i] = arr1[i] + arr2[i];
    }
    return finalResult;
}

此代码更安全,独立于向量形状执行。

4.3. 计算向量范数

本节计算两个值的范数(平方和的平方根)。标量实现:

public float[] scalarNormOfTwoArrays(float[] arr1, float[] arr2) {
    float[] finalResult = new float[arr1.length];
    for (int i = 0; i < arr1.length; i++) {
        finalResult[i] = (float) Math.sqrt(arr1[i] * arr1[i] + arr2[i] * arr2[i]);
    }
    return finalResult;
}

向量实现:

首先获取 FloatVector 的最优种类:

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

使用掩码概念,循环到第一个数组的 loopBound 值,步长为种类长度。每步将浮点值加载到向量并执行与标量版本相同的数学运算。最后用普通标量循环处理剩余元素:

public float[] vectorNormalForm(float[] arr1, float[] arr2) {
    float[] finalResult = new float[arr1.length];
    int i = 0;
    int upperBound = SPECIES.loopBound(arr1.length);
    for (; i < upperBound; i += SPECIES.length()) {
        var va = FloatVector.fromArray(PREFERRED_SPECIES, arr1, i);
        var vb = FloatVector.fromArray(PREFERRED_SPECIES, arr2, i);
        var vc = va.mul(va)
          .add(vb.mul(vb))
          .sqrt();
        vc.intoArray(finalResult, i);
    }
    
    // 尾部清理
    for (; i < arr1.length; i++) {
        finalResult[i] = (float) Math.sqrt(arr1[i] * arr1[i] + arr2[i] * arr2[i]);
    }
    return finalResult;
}

4.4. 归约操作

Vector API 中的归约操作将向量的多个元素合并为单个结果,可用于计算向量元素的和、最大值、最小值或平均值。

Vector API 提供多种利用 SIMD 架构的归约操作:

  • reduceLanes():接受数学操作(如 ADD),将向量所有元素合并为单个值
  • reduceAll():类似但接受二元归约操作(输入两值输出一值)
  • reduceLaneWise():归约特定通道的元素,生成归约通道值的向量

示例:计算向量平均值。使用 reduceLanes(ADD) 计算所有元素和,再除以数组长度:

public double averageOfaVector(int[] arr) {
    double sum = 0;
    for (int i = 0; i< arr.length; i += SPECIES.length()) {
        var mask = SPECIES.indexInRange(i, arr.length);
        var V = IntVector.fromArray(SPECIES, arr, i, mask);
        sum += V.reduceLanes(VectorOperators.ADD, mask);
    }
    return sum / arr.length;
}

5. Vector API 的注意事项

虽然 Vector API 优势显著,但需谨慎使用: ⚠️ API 仍处于孵化阶段,计划将向量类声明为原始类 ⚠️ 依赖硬件:依赖 SIMD 指令,部分功能在其他平台/架构可能不可用 ⚠️ 维护开销:向量化操作比传统标量操作维护成本更高 ⚠️ 基准测试困难:在通用硬件上难以进行基准测试,需了解底层架构

6. 总结

谨慎使用 Vector API 可带来巨大收益。性能提升和操作向量化简化对图形行业、大规模计算等领域大有裨益。我们探讨了 Vector API 的重要术语,并通过代码示例深入实践。

所有代码示例可在 GitHub 获取。


原始标题:The Vector API in Java 19