CPU 很多时候并不是缺少算力,而是程序一次只告诉它处理一个数。普通循环的源码通常表达“算完当前元素,再去算下一个”;现代处理器则提供 SIMD 指令,可以在一条指令里对一组 lanes 同时做相同运算。

Project Vector 的目标,是让 Java 程序员显式表达这种数据并行,同时把实际指令选择交给 JIT 与目标平台。Vector API 不是 CPU 特定汇编的薄封装,也不是“写了 vector 就保证快”。截至 JDK 27,它仍通过 JEP 537 处于第十二轮 incubator;API 继续等待 Valhalla 的值类型能力,以便确定长期稳定的类型与表示模型。

从 scalar 到 vector:改变的是一次操作覆盖的数据宽度

假设两个数组逐元素相乘:

Java
for (int i = 0; i < a.length; i++) {
    out[i] = a[i] * b[i];
}

从 Java 语义看,每次循环只处理一个 int。JIT 有时能证明循环规整、没有别名问题、边界足够安全,于是自动把多次标量运算合并成向量指令;但分支、调用、复杂边界和别名分析都可能阻止自动向量化。

Vector API 将“这批相邻元素可以一起计算”的意图写进程序:

TEXT
scalar:  a0*b0  -> a1*b1  -> a2*b2  -> a3*b3
vector: [a0 a1 a2 a3] * [b0 b1 b2 b3]

这并不规定硬件指令宽度。一个平台可能用 128-bit SIMD,另一个平台可能选择更宽的向量寄存器;同一程序还可以在没有特定指令的机器上退化到较窄或标量实现。

Vector API 是 incubator 模块里的显式运算模型

主要抽象包括:

  • Vector<E> 与具体的 IntVector、LongVector、FloatVector、DoubleVector;
  • VectorSpecies<E>:元素类型、lanes 数量与向量形状;
  • VectorMask<E>:每个 lane 是否参与本次运算;
  • VectorShuffle<E>:在 lanes 之间重排数据;
  • VectorOperators:加、乘、比较、位运算、转换与归约等操作。

它通过 incubator module 暴露。使用当前目标 JDK 时,编译和运行通常需要把 jdk.incubator.vector 加入模块图:

Shell
javac --add-modules jdk.incubator.vector Main.java
java  --add-modules jdk.incubator.vector Main

Incubator 意味着 API 可在后续 JDK 里调整,不等于 JVM 的 SIMD 后端只是实验室玩具。生产代码要同时考虑目标 JDK 的模块支持、源代码兼容和依赖库升级。

Species 描述可移植的向量形状

常见选择是使用平台偏好的 species:

Java
VectorSpecies<Float> species = FloatVector.SPECIES_PREFERRED;

species.length() 告诉程序当前一个向量包含多少个 float lanes。偏好 species 让同一段 Java 代码适应当前 JVM 和硬件,而不是把 8、16 等 lane 数写死在业务逻辑里。

也可以选择固定形状的 species,例如固定 128、256 或 512 位的向量,以控制工作块大小或比较不同实现。但固定宽度只是代码意图,不保证目标处理器能以同样宽度执行;JIT 可能需要拆分、模拟或选用其他指令序列。

把数组循环改写为显式向量循环

下面示例把主体按 species 宽度处理,末尾剩余元素再用标量循环完成:

Java
static void multiply(float[] a, float[] b, float[] out) {
    VectorSpecies<Float> species = FloatVector.SPECIES_PREFERRED;
    int bound = species.loopBound(a.length);
    int i = 0;

    for (; i < bound; i += species.length()) {
        FloatVector av = FloatVector.fromArray(species, a, i);
        FloatVector bv = FloatVector.fromArray(species, b, i);
        av.mul(bv).intoArray(out, i);
    }

    for (; i < a.length; i++) {
        out[i] = a[i] * b[i];
    }
}

loopBound 把数组长度向下收整到完整向量块,主体循环就不需要对每个 lane 做越界检查。尾部 scalar loop 处理不足一个向量宽度的剩余元素。

有些算法适合对尾部使用 mask:让一个部分向量只激活有效 lanes。mask 可以与 vector compare、where 和 blend 等操作组合;但尾部 mask 并不自动比标量收尾快,具体取决于目标 ISA、向量长度、代码生成和数据规模。

Mask 和 Shuffle 让 API 不止是批量加乘

实际数据处理常包含条件与重排。比较操作可以生成 mask:

TEXT
values: [ 3, 12,  7, 21 ]
mask:   [ F,  T,  F,  T ]   // values > threshold

后续可以基于 mask 选择值、屏蔽 lane 或执行受条件控制的存储。Shuffle 则可交换、复制或重排 lane,适合交错数据转换、局部归约与小型 stencil 运算。

这些抽象表达的是 lane 级语义。具体操作是否对应一条 vblend、permute、predicate-register 指令,或若干较慢指令,由平台后端决定。可移植的含义稳定,单条指令映射不是 API 契约。

JIT 怎样把 Java 向量运算变成硬件指令

Vector API 的关键不在于普通 Java 对象数组“天然会并行”,而是 API 操作能被 HotSpot 识别,并由 JIT lowering 到平台指令:

TEXT
Vector API 调用
       ↓
JIT 识别运算节点与 species
       ↓
按目标 CPU 特性生成 SIMD 指令
       ↓
运行时按向量块处理数据

JIT 需要根据目标架构能力选择指令,例如 x64 的 AVX 系列或 AArch64 的 NEON/SVE。相同源代码在不同平台可能走不同代码生成路径,也可能由于虚拟化、处理器特性或编译层级而使用较窄实现。

源码中出现 FloatVector 并不能证明 benchmark 已经执行宽 SIMD。应检查编译日志、JIT 汇编、目标 CPU feature flags,或用 profiler / perf counter 观察指令与吞吐。API 负责表达向量运算;JIT 仍须成功内联、识别 intrinsic,并找到合适机器指令。

它和自动向量化是什么关系

HotSpot 的自动向量化会从标量循环推断并行结构。它对简单、规整、无复杂别名的循环很有用,而且不要求源码依赖 incubator API。

Vector API 则由程序员显式承诺运算按 lanes 进行,适合:

  • 想避免依赖自动向量化的启发式;
  • 需要 mask、shuffle 或固定运算宽度;
  • 已经确认某个数据热点值得手工表达;
  • 希望把 SIMD 算法形状作为代码的一部分维护。

显式表达也意味着更多责任:向量块和 tail 必须正确,内存布局要适合批量访问,且要承担 incubator API 的迁移成本。若一个简单 scalar loop 已被 JIT 充分向量化,换成显式 Vector API 未必能进一步提速。

浮点归约不能默认与 scalar 完全相同

整数向量运算通常较容易理解;浮点加法和乘法却不具备实数上的完全结合律。改成不同的分组次序以后:

TEXT
scalar:  ((a0 + a1) + a2) + a3
vector:  (a0 + a1) + (a2 + a3)

舍入误差可能不同。平行 reduction 可能改变结果末位,也可能因为平台使用 fused multiply-add 而改变舍入。金融、统计或科学计算若要求精确可复现,要先明确误差预算、NaN/正负零语义与可接受的跨平台差异。

Vector API 不会自动放宽 Java 浮点语义。程序要采用哪类算术、能否接受不同归约顺序,应由算法需求决定,不能只看吞吐数字。

内存访问往往比算术更快成为瓶颈

如果每个元素只做一次乘法,然后读取两个输入数组、写回一个输出数组,程序可能很快受内存带宽限制。扩大向量宽度也不会让 DRAM 凭空变快。

常见限制包括:

  • 数据集太小,JIT 预热和调用开销淹没计算;
  • 数据集太大,cache miss 与内存带宽主导;
  • 访问模式不连续,无法合并加载;
  • 分支导致每个 lane 有效工作不一致;
  • 每轮计算太少,函数调用、边界处理和存储开销占比高;
  • 数据结构是指针密集对象图,而不是连续 primitive buffer。

向量计算常与 SoA(Structure of Arrays)布局相配:所有 x 连续放置、所有 y 连续放置。若使用 AoS(Array of Structures),每个点的字段交错排列,程序可能需要 shuffle 或 gather 才能组合出合适向量。

与 Panama、Valhalla 的关系

Panama 的 FFM API 处理 Java 与 native memory、函数 ABI 和外部资源生命周期;Vector API 表达 lanes 级计算。二者可共同用于数值计算:FFM 负责安全访问一段外部内存,Vector API 负责对其中的数据做 SIMD 运算。它们解决的是不同层的问题。

Vector API 长期受 Valhalla 影响更直接。当前 API 有按 primitive 类型区分的向量类与抽象层;如果 Java 能稳定表达 value-based vector types 与相应泛型约束,API 才能进一步统一类型设计。因此 JEP 537 继续孵化,等待 Valhalla 值类型能力进入 preview 阶段;这是一项设计依赖,不是“SIMD 指令尚未实现”。

截至 JDK 27:

版本 JEP 阶段 含义
JDK 16–26 JEP 338 起至 JEP 529 十一轮 incubation 逐步验证 API 与平台实现
JDK 27 JEP 537 第十二轮 incubation 继续孵化,未定稿为标准 API
后续版本 尚未承诺 取决于 Valhalla 与新 JEP 不把预期时间表当成正式承诺

“已经孵化十二轮”是版本事实,不足以说明 API 不可用或未来一定会被弃用。是否适合生产,要看团队能否固定 JDK 版本、隔离 API 边界并承受升级测试。

Benchmark:要同时验证生成代码与端到端收益

评价 Vector API 不能只用一个数组长度跑一次 System.nanoTime()。较可靠的实验应包含:

  • JMH warmup、measurement、fork 和结果消费,避免死代码消除;
  • scalar baseline、自动向量化 baseline 与 Vector API 版本;
  • 多种数组长度,覆盖 L1/L2/LLC 与超出 cache 的数据规模;
  • 目标 JDK、CPU 型号、ISA、操作系统和 JVM flags;
  • 吞吐、分配、GC、功耗或内存带宽等与应用目标相关的指标;
  • 反汇编或 profiler 验证是否生成预期 SIMD 指令;
  • 正确性测试涵盖 tail、溢出、NaN、正负零和 mask 边界。

结果应按平台报告,而不是只写“使用 Vector API 提升了 N 倍”。若 benchmark 没有展示生成代码和工作集条件,读者无法判断收益来自 API、JIT 新版本、输入数据驻留 cache,还是测量方法本身。

迁移到工程代码时的边界

  • 把 Vector API 封装在计算模块内,避免 incubator 类型散落到公共业务 API。
  • scalar fallback 保留为正确性基线;不要将某个 SIMD ISA 当成部署环境前提。
  • 保持 vector loop 的数组边界、tail 与输入长度假设明确。
  • 升级 JDK 时重跑目标硬件基准,检查 API 变化和生成代码变化。
  • 对低频路径先评估复杂度;手写 SIMD 不是所有数值代码的默认选择。
  • 需要 native memory 时,再把 FFM 生命周期与向量计算放到同一基准中评估。

结论:Vector API 是可移植的 SIMD 意图,不是性能保证

Vector API 让 Java 代码能显式描述并行 lanes、mask、shuffle 和归约,让 HotSpot 根据目标 CPU 生成适合的 SIMD 指令。它给性能热点提供了比“希望 JIT 自动发现”更清楚的运算表达,也把循环尾部、浮点语义、内存布局和 API 升级责任交还给开发者。

JDK 27 的 JEP 537 仍是第十二轮 incubation。今天可以在固定版本、受控硬件和明确性能边界内验证它;不要把 incubator API 写成已定稿的 Java 标准库契约,也不要把 SIMD 理解为只要改写循环就能获得稳定倍数的加速。

延伸阅读