大家好,我是Tim。
我们都知道由于Java语言的各种问题,向量化一直是C++或Rust的代名词。
最近一个使用Java向量化API实现的Json解析库simdjson-java开源,它的性能是fastjson2解析库的2.5倍!
这意味着使用Java向量化技术带来至少2x多的性能提升,这是相当可观的,它对于原使用Java实现的项目进行向量优化,又是一个新的可探索的路。
是否可以给原使用Java系语言实现的大数据基座hadoop、Spark、Hive等带来新春?
那么今天就来探索一下,要了解simdjson库,首先就要明白Java向量化是什么?它与C++或Rust实现的向量化是否存在优势?
下面我们就细说下Java是如何实现和使用向量计算的。
什么是向量化和SIMD指令
对于向量化计算来说,大家都能说出来一二三,即同时处理多个数据元素来提高计算效率的方法。
举个形象的例子,想象一下,在农场里收割玉米,传统的方式是手动用镰刀一个个地割玉米,这样效率较低。
而现代化的方式是使用联合收割机,它可以在短时间内快速地收割大片玉米田,从而提高了收割的效率。
类比到计算机中,传统的计算方式就相当于一次处理一个数据,就像手动用镰刀一个个地割玉米;
而向量化计算则相当于通过特殊的指令和硬件支持,能够在同一时间内对多个数据执行相同的操作,就像使用联合收割机一样。
这里要注意的是CPU SIMD的实现方式和GPU的SIMT是有本质区别的,SIMT属于单指令多线程。
GPU的加速方式就好像原来是一个人一个个的收割玉米,现在换成1万个初中生同时给你收割玉米。
下面我们举一个实际的代码例子:
void foo(int[] d, int[] s) {
for (int i = 0; i < d.length - 4; i += 4) {
d[i] = s[i];
d[i+1] = s[i+1];
d[i+2] = s[i+2];
d[i+3] = s[i+3];
}
...
}
如上图所示,我们在Java中实现一个循环拷贝,每次循环会执行4次不同地址的相似的拷贝动作。
那么我们如何使用向量化来优化上述代码呢?
void foo(int[] d, int[] s) {
for (int i = 0; i < d.length - 4; i += 4) {
d[i:i+3] = s[i:i+3];
}
...
}
如上示例,很简单,我们直接将4次拷贝转换为一次大的范围拷贝即可。
当然上面展示的实现并不可直接运行,但道理就是这么个道理。
那么现在问题来了,我们能将4个int值一次性拷贝过去吗?
学过编译原理的都知道,对于数值拷贝和赋值操作,通常需要涉及到寄存器的使用。
X86_64 体系架构上通用寄存器的大小为 64 位,即 8 个字节。而4个int值元素合并起来,需要占用16 个字节,这显然是无法完成这个任务的。
所以在实际应用中,各种处理器架构都提供了对SIMD指令集的硬件支持。比如英特尔的SSE(Streaming SIMD Extensions)、AVX(Advanced Vector Extensions)等。
SSE指令集引入了128 位XMM 寄存器,这时我们就可以将上述代码中的4个int值元素合并起来放到XMM寄存器中进行操作。

另外其同时提供了SIMD 指令,包括PADDB、PADDW、PADDD以及PADDQ,将分别实现 byte 值、short 值、int 值或者 long 值的向量加法。
所以,最终上面这段代码经过向量化优化之后,将使用PADDD指令并通过XMM寄存器来实现d[i:i+3] = s[i:i+3] + d[i:i+3]。
此外,从 X86 平台上的 CPU 开始支持 AVX指令集后,改为YMM寄存器,其大小为256 位。
前几年推出的 AVX512 指令集,更是将 YMM 寄存器升级至 512 位,并更名为 ZMM 寄存器。
在这种情况下CPU SIMD可同时处理的数据可更大了。
从上面可以看出,SIMD就像收割机割玉米一样,但是并不会有大到离谱的收割机,所以其一次性处理的数据量是有限的,最大也就到ZMM的大小。
但GPU使用的是SIMT加速计算,可以单指令同时操作上万线程进行同时工作,这说明GPU的加速上限远大于CPU。
JVM自动向量化
JVM的自动向量化其实是一个槽点,因为实现JVM的自动向量化限制条件有很多,且最终是否执行了向量化用户并不能控制。
JVM的自动向量化存在很多的限制,下面我们来看下:
首先,JVM的自动向量化是依赖于C2 JIT编译器的;
其次,JVM的自动向量化其仅针对能够展开的常量计数循环;
即使是常量的计数循环,在Java8中也还有几个条件:
循环变量的增量应为 1,即能够遍历整个数组。
循环如果存在判断条件需要为“与”而不能为“或”。
循环变量不能为 long 类型,否则 C2 无法将循环识别为计数循环。
循环迭代之间最好不要有数据依赖,例如出现类似于a[i] = a[i-1]的语句。当循环展开之后,循环体内存在数据依赖,那么 C2 无法进行自动向量化。
循环体内不要有分支跳转。
不要手工进行循环展开。如果 C2 无法自动展开,那么它也将无法进行自动向量化。
我们可以看到,自动向量化的条件还是较为苛刻,这也是Java后续不得不提供Vector API的原因。
JVM Intrinsics 实现向量化
我们知道,Java 虚拟机所执行的 Java 字节码是平台无关的。它首先会被解释执行,而后反复执行的部分才会被 Java 虚拟机即时编译为机器码。
换句话说,在进行即时编译的时候,Java 虚拟机已经运行在目标 CPU 之上,可以轻易地得知其所支持的指令集。
然而,由于Java 字节码的平台无关性却引发了另一个问题,那便是 Java 程序无法像 C++ 程序那样,直接使用由 Intel 提供的,将被替换为具体 SIMD 指令的 intrinsic 方法。
HotSpot 虚拟机提供的替代方案是 Java 层面的 intrinsic 方法,这些 intrinsic 方法的语义要比单个 SIMD 指令复杂得多。
在运行过程中,HotSpot 虚拟机将根据当前体系架构来决定是否将对该 intrinsic 方法的调用替换为另一高效的实现。如果不,则使用原本的 Java 实现。
目前在HotSpot 虚拟机中,所有该注解 @HotSpotIntrinsicCandidate 标注的方法都是 HotSpot intrinsic,如下所示:
public final class java.lang.Class<T> implements … {
@HotSpotIntrinsicCandidate
public native boolean isInstance(Object obj);
换句话说,HotSpot 虚拟机将为标注了@HotSpotIntrinsicCandidate注解的方法额外维护一套高效实现。
如果 Java 核心类库的开发者更改了原本的实现,那么虚拟机中的高效实现也需要进行相应的修改,以保证程序语义一致。
为什么不直接在源代码中使用这些高效实现呢?
这是因为高效实现通常依赖于具体的 CPU 指令,而这些 CPU 指令不好在 Java 源程序中表达。
再者,JVM是可移植的,说不定换个运行平台没有对应的 CPU 指令了。
就比如,StringLatin1.indexOf方法将在一个字符串(byte 数组)中查找另一个字符串(byte 数组),并且返回命中时的索引值,或者 -1(未命中)
它就使用了intrinsic,这是因为X86_64 体系架构的 SSE4.2 指令集就包含一条指令 PCMPESTRI,让它能够在 16 字节以下的字符串中,查找另一个 16 字节以下的字符串,并且返回命中时的索引值。
因此,HotSpot 虚拟机便围绕着这一指令,开发出 X86_64 体系架构上的高效实现,并替换原本对StringLatin1.indexOf方法的调用。
除了这些,还有System.arraycopy(), Arrays.copyOf(), Arrays.equals()等。
Vector API 声明式向量化
Java Vector API是一种声明式的方式来进行向量化计算。
通过 Java Vector API,开发者可以利用底层硬件的 SIMD 指令集,以一种更直观和简单的方式来执行并行计算。
Java Vector API提供了一整套的向量化API,方便使用者调用,simdjson-java就是使用这种方式实现的。
Vector API (Sixth Incubator) 引入了 java.util.vector 包,其中包含了一些新的类和接口,如 Vector、FloatVector 和 IntVector 等。这些类提供了一组向量化操作方法,例如加法、减法、乘法等,以及对应的掩码操作。
下面从一个简单的例子开始,我们可以详细看一下下面的代码片段:
public static int[] simpleSum(int[] a, int[] b) {
var c = new int[a.length];
for (var i = 0; i < a.length; i++) {
c[i] = a[i] + b[i];
}
return c;
}
相同的代码通过 Vector API 转换为数据并行加速代码:
private static final VectorSpecies SPECIES = IntVector.SPECIES_PREFERRED;
public static int[] vectorSum(int[] a, int[] b) {
var c = new int[a.length];
var upperBound = SPECIES.loopBound(a.length);
var i = 0;
for (; i < upperBound; i += SPECIES.length()) {
var va = IntVector.fromArray(SPECIES, a, i);
var vb = IntVector.fromArray(SPECIES, b, i);
var vc = va.add(vb);
vc.intoArray(c, i);
}
// Compute elements not fitting in the vector alignment.
for (; i < a.length; i++) {
c[i] = a[i] + b[i];
}
return c;
}
下面我们来详细说明下上述代码:
1. 声明定义数据的宽度
在使用向量化API利用 SIMD 加速之前,我们必须先确定数据宽度,其代表了一次计算并行数据量。
private static final VectorSpecies SPECIES = IntVector.SPECIES_PREFERRED;
目前AVX 兼容 CPU 可以最大处理 256 位,而 AVX-512 可以提供最大处理 512 位数据宽度。
2. 配置循环大小
从上面的代码可以看出,一般的for循环使用的是i++作为循环计算的迭代方式的,而在SIMD中则使用的是i += SPECIES .length(),每次for循环迭代相加的为我们设定的数据宽度。
而for循环的退出条件,在这里也需要通过数据宽度提供的方法计算出来。
var upperBound = SPECIES.loopBound(a.length);
其次,在计算的过程中,假设数据宽度为16,则第一次迭代执行 a[0]+b[0] 到 a[15]+b[15]的计算,下一个操作对 a[16]+b[16] 到 a[31]+b[31]执行计算,依此类推…
3. 处理未在数据宽度内对齐的所有剩余数据
假设数据的长度并不能被数据宽度16整除,那么必然存在未被处理的剩余数据,这时可以从向量循环未触及的第一项开始以非并行方式继续执行操作,如下代码所示。
for (; i < a.length; i++) { // 清理循环
c[i] = a[i] + b[i];
}
需要注意的是,虽然 Vector API (Sixth Incubator) 可以提高应用程序的性能,但要获得最佳性能还需要进行适当的优化。例如,避免频繁的向量化操作和数据拷贝等。
向量化API Vector性能如何呢?
根据官网提供的信息可见,Vector API针对融合乘加的测试基准程序性能提升了16倍,但对于上述最简单的累加操作却没有明显性能提升。
可以说,Java Vector向量化API的性能很大程度上取决于操作的复杂性,更复杂的运算 (FMA) 往往比简单运算 (SimpleSum) 的性能更好。
从使用体验上也可以看出,Vector API也不是非常简单的,基本上需要对代码进行完全的适配和改造才能使用,而是否值得为了这个小小的好处而重写整个算法,还是需要斟酌的。
总结
Java提供的自动向量化技术和JVM Intrinsics是非常方便实用的,然而其限制较多使得在多数项目中其加速效果有限。
从Java9后,Java主推实现了声明式的向量化API,它在simdjson-java项目中的成功应用,至少证明向量化API是对Java性能优化可探索的方向。
然而使用Java实现高效的向量API计算,一方面需要依赖更高的Java版本,当前国内的很多企业内部Java还停留在Java8;
另一方面Java向量化API的复杂性使得对于老项目使用向量化API进行加速基本都需要对代码进行重构。
而相比于C++或Rust来说,Java的GC问题和对象的更大内存占用问题,使其依然不占优势。
对于一些新的项目来说,使用更高版本的Java 向量化计算API可以更好的带来性能加速。
而对于老的项目来说,例如Spark、Hadoop等,既然要重构为什么不用C++或Rust重构呢?