public class Foo {
public void foo(byte[] src,byte[] dst){
for(int i=0;i<src.length/4;i+=4){
dst[i]=src[i];
dst[i+1]=src[i+1];
dst[i+2]=src[i+2];
dst[i+3]=src[i+3];
}
}
}
由于 X86_64 不支持内存间的直接移动,所以 dst[i]=src[i] 必须有两条指令才能完成:
1、把 src[i] 中的值写入寄存器。
2、把寄存器中的值写入 dst[i]。
因此上面代码中的一个循环迭代需要 8 条指令才能完成。由于数组中元素的地址是连续的,从 src[i] 元素的地址起,接下来的 32 位内存中分别对应 src[i]、src[i+1]、src[i+2]、src[i+3] 的值,所以我们可以一次读取32位内容至寄存器中,然后从 dst[i] 地址处写入32位内容就完成了一个循环迭代。
所有上面的代码可以优化成:
for(int i=0;i<src.length/4;i+=4){
dst[i:i+3]=src[i:i+3]
}
SIMD 指令
在上面的例子中,byte 只占一个字节,4个元素占 4 个字节,如果是 4 个long 元素,超过了指令总线的宽度(X86_64 体系的通用寄存器只有64位),因此 Java 即时编译器需要够借助能存储更长的 XMM 寄存器。
所谓的 XMM 寄存器,是由 SSE(Streaming SIMD Extensions)指令集所引入的。它们一开始仅为 128 位。自从 X86 平台上的 CPU 开始支持 AVX(Advanced Vector Extensions)指令集后(2011 年),XMM 寄存器便升级为 256 位,并更名为 YMM 寄存器。原本使用 XMM 寄存器的指令,现将使用 YMM 寄存器的低 128 位。
SSE 指令集以及之后的 AVX 指令集都涉及了一个重要的概念,那便是单指令流多数据流(Single Instruction Multiple Data,SIMD),即通过单条指令操控多组数据的计算操作。这些指令我们称之为 SIMD 指令。
举例说明:128 位的XMM 寄存器的值,可以看作 16 个 byte 值组成的向量, 8 个short 值组成的向量,4 个 int 值组成的向量和 2 个 long 值组成的向量。而 SIMD 指令 PADDB、PADDW、PADDD、PADDQ ,将分别实现 byte 值、short 值、int 值或者 long 值的向量加法。
void foo(int[] a, int[] b, int[] c) {
for (int i = 0; i < c.length; i++) {
c[i] = a[i] + b[i];
}
}
上面这段代码经过向量化优化后,将使用 PADDD 指令来实现 c[i:i+3] = a[i:i+3] + b[i:i+3] 。其执行过程中的数据流如下图所示,图片源自 Vladimir Ivanov 的演讲 。下图中内存的右边是高位,寄存器的左边是高位,因此数组元素的顺序是反过来的。
也就是说,理论上(现实中,C2 还将考虑缓存行对齐等因素,导致能够应用向量化加法的仅有数组中间的部分元素)原来需要 length 次加法操作,现在最少只需要 length/4 次向量加法即可完成。
HotSpot Intrinsic
HotSpot 虚拟机提供了 Java 层面的 intrinsic 方法,这些 intrinsic 方法的语义要比单个 SIMD 指令复杂得多。在运行过程中,HotSpot 虚拟机将根据当前体系架构来决定是否将对该 intrinsic 方法的调用替换为另一高效的实现。如果不,则使用原本的 Java 实现。
另外,这些 intrinsic 方法只能做到点覆盖,在不少情况下,应用程序并不会用到这些 intrinsic 的语义,却又存在向量化优化的机会。这个时候,我们便需要借助即时编译器中的自动向量化(auto vectorization)。
自动向量化