JVM笔记(十一)代码优化(1)

332 阅读13分钟

一、 字段访问相关优化

在上一篇文章中,我介绍了逃逸分析,也介绍了基于逃逸分析的优化方式锁消除、栈上分配以及标量替换等内容。

其中的标量替换,可以看成将对象本身拆散为一个个字段,并把原本对对象字段的访问,替换为对一个个局部变量的访问。

class Foo {
  int a = 0;
}
 
static int bar(int x) {
  Foo foo = new Foo();
  foo.a = x;
  return foo.a;
}

举个例子,上面这段代码中的bar方法,经过逃逸分析以及标量替换后,其优化结果如下所示。(确切地说,是指所生成的 IR 图与下述代码所生成的 IR 图类似。之后不再重复解释。)

static int bar(int x) {
  int a = x;
  return a;
}

由于 Sea-of-Nodes IR 的特性,局部变量不复存在,取而代之的是一个个值。在例子对应的 IR 图中,返回节点将直接返回所输入的参数。

经过标量替换的bar方法:

下面我列举了bar方法经由 C2 即时编译生成的机器码(这里略去了指令地址的前 48 位)。

  # {method} 'bar' '(I)I' in 'FieldAccessTest'
  # parm0:    rsi       = int             // 参数 x
  #           [sp+0x20]  (sp of caller)
0x06a0: sub    rsp,0x18                   // 创建方法栈桢
0x06a7: mov    QWORD PTR [rsp+0x10],rbp   // 无关指令
0x06ac: mov    eax,esi                    // 将参数 x 存入返回值 eax 中
0x06ae: add    rsp,0x10                   // 弹出方法栈桢
0x06b2: pop    rbp                        // 无关指令
0x06b3: mov    r10,QWORD PTR [r15+0x70]   // 安全点测试
0x06b7: test   DWORD PTR [r10],eax        // 安全点测试
0x06ba: ret

在 X86_64 的机器码中,每当使用 call 指令进入目标方法的方法体中时,我们需要在栈上为当前方法分配一块内存作为其栈桢。而在退出该方法时,我们需要弹出当前方法所使用的栈桢。

由于寄存器 rsp 维护着当前线程的栈顶指针,因此这些操作都是通过增减寄存器 rsp 来实现的,即上面这段机器码中偏移量为 0x06a0 以及 0x06ae 的指令。

在介绍安全点(safepoint)时我曾介绍过,HotSpot 虚拟机的即时编译器将在方法返回时插入安全点测试指令,即图中偏移量为 0x06b3 以及 0x06ba 的指令。其中真正的安全点测试是 0x06b7 指令。

如果虚拟机需要所有线程都到达安全点,那么该 test 指令所访问的内存地址所在的页将被标记为不可访问,而该指令也将触发 segfault,并借由 segfault 处理器进入安全点之中。通常,该指令会附带; {poll_return}这样子的注释,这里被我略去了。

在 X8_64 中,前几个传入参数会被放置于寄存器中,而返回值则需要存放在 rax 寄存器中。有时候你会看到返回值被存入 eax 寄存器中,这其实是同一个寄存器,只不过 rax 表示 64 位寄存器,而 eax 表示 32 位寄存器。具体可以参考 x86 calling conventions。

当忽略掉创建、弹出方法栈桢,安全点测试以及其他无关指令之后,所剩下的方法体就只剩下偏移量为 0x06ac 的 mov 指令,以及 0x06ba 的 ret 指令。前者将所传入的 int 型参数 x 移至代表返回值的 eax 寄存器中,后者是退出当前方法并返回至调用者中。

虽然在部分情况下,逃逸分析以及基于逃逸分析的优化已经十分高效了,能够将代码优化到极其简单的地步,但是逃逸分析毕竟不是 Java 虚拟机的银色子弹。

在现实中,Java 程序中的对象或许本身便是逃逸的,或许因为方法内联不够彻底而被即时编译器当成是逃逸的。这两种情况都将导致即时编译器无法进行标量替换。这时候,针对对象字段访问的优化也变得格外重要起来。

static int bar(Foo o, int x) {
  o.a = x;
  return o.a;
}

在上面这段代码中,对象o是传入参数,不属于逃逸分析的范围(Java 虚拟机中的逃逸分析针对的是新建对象)。该方法会将所传入的 int 型参数x的值存储至实例字段Foo.a中,然后再读取并返回同一字段的值。

这段代码将涉及两次内存访问操作:存储以及读取实例字段Foo.a。我们可以轻易地将其手工优化为直接读取并返回传入参数 x 的值。由于这段代码较为简单,因此它极大可能被编译为寄存器之间的移动指令(即将输入参数x的值移至寄存器 eax 中)。这与原本的内存访问指令相比,显然要高效得多。

static int bar(Foo o, int x) {
  o.a = x;
  return x;
}

那么即时编译器是否能够作出类似的自动优化呢?

字段读取优化

即时编译器会优化实例字段以及静态字段访问,以减少总的内存访问数目。具体来说,它将沿着控制流,缓存各个字段存储节点将要存储的值,或者字段读取节点所得到的值。

当即时编译器遇到对同一字段的读取节点时,如果缓存值还没有失效,那么它会将读取节点替换为该缓存值。

当即时编译器遇到对同一字段的存储节点时,它会更新所缓存的值。当即时编译器遇到可能更新字段的节点时,如方法调用节点(在即时编译器看来,方法调用会执行未知代码),或者内存屏障节点(其他线程可能异步更新了字段),那么它会采取保守的策略,舍弃所有缓存值。

在前面的例子中,我们见识了缓存字段存储节点的情况。下面我们来看一下缓存字段读取节点的情况。

static int bar(Foo o, int x) {
  int y = o.a + x;
  return o.a + y;
}

在上面这段代码中,实例字段Foo.a将被读取两次。即时编译器会将第一次读取的值缓存起来,并且替换第二次字段读取操作,以节省一次内存访问。

static int bar(Foo o, int x) {
  int t = o.a;
  int y = t + x;
  return t + y;
}

如果字段读取节点被替换成一个常量,那么它将进一步触发更多优化。

static int bar(Foo o, int x) {
  o.a = 1;
  if (o.a >= 0)
    return x;
  else
    return -x;
}

例如在上面这段代码中,实例字段Foo.a会被赋值为 1。接下来的 if 语句将判断同一实例字段是否不小于 0。经过字段读取优化之后,>=节点的两个输入参数分别为常数 1 和 0,因此可以直接替换为具体结果true。如此一来,else 分支将变成不可达代码,可以直接删除,其优化结果如下所示。

static int bar(Foo o, int x) {
  o.a = 1;
  return x;
}

我们再来看另一个例子。下面这段代码的bar方法中,实例字段a会被赋值为true,后面紧跟着一个以a为条件的 while 循环。

class Foo {
  boolean a;
  void bar() {
    a = true;
    while (a) {}
  }
  void whatever() { a = false; }
}

同样,即时编译器会将 while 循环中读取实例字段a的操作直接替换为常量true,即下面代码所示的死循环。

  void bar() {
    a = true;
    while (true) {}
  }
// 生成的机器码将陷入这一死循环中
0x066b: mov    r11,QWORD PTR [r15+0x70] // 安全点测试
0x066f: test   DWORD PTR [r11],eax      // 安全点测试
0x0672: jmp    0x066b                   // while (true)

在介绍 Java 内存模型时,我们便知道可以通过 volatile 关键字标记实例字段a,以此强制对它的读取。

实际上,即时编译器将在 volatile 字段访问前后插入内存屏障节点。这些内存屏障节点会阻止即时编译器将屏障之前所缓存的值用于屏障之后的读取节点之上。

就我们的例子而言,尽管在 X86_64 平台上,volatile 字段读取操作前后的内存屏障是 no-op,在即时编译过程中的屏障节点,还是会阻止即时编译器的字段读取优化,强制在循环中使用内存读取指令访问实例字段Foo.a的最新值。

0x00e0: movzx  r11d,BYTE PTR [rbx+0xc]   // 读取 a
0x00e5: mov    r10,QWORD PTR [r15+0x70]  // 安全点测试
0x00e9: test   DWORD PTR [r10],eax       // 安全点测试
0x00ec: test   r11d,r11d                 // while (a)
0x00ef: jne    0x00e0                    // while (a)

同理,加锁、解锁操作也同样会阻止即时编译器的字段读取优化。

字段存储优化

除了字段读取优化之外,即时编译器还将消除冗余的存储节点。如果一个字段先后被存储了两次,而且这两次存储之间没有对第一次存储内容的读取,那么即时编译器可以将第一个字段存储给消除掉。

class Foo {
  int a = 0;
  void bar() {
    a = 1;
    a = 2;
  }
}

举例来说,上面这段代码中的bar方法先后存储了两次Foo.a实例字段。由于第一次存储之后没有读取Foo.a的值,因此,即时编译器会将其看成冗余存储,并将之消除掉,生成如下代码:

  void bar() {
    a = 2;
  }

实际上,即便是在这两个字段存储操作之间读取该字段,即时编译器还是有可能在字段读取优化的帮助下,将第一个存储操作当成冗余存储给消除掉。

class Foo {
  int a = 0;
  void bar() {
    a = 1;
    int t = a;
    a = t + 2;
  }
}
// 优化为
class Foo {
  int a = 0;
  void bar() {
    a = 1;
    int t = 1;
    a = t + 2;
  }
}
// 进一步优化为
class Foo {
  int a = 0;
  void bar() {
    a = 3;
  }
}

当然,如果所存储的字段被标记为 volatile,那么即时编译器也不能将冗余的存储操作消除掉。

这种情况看似很蠢,但实际上并不少见,比如说两个存储之间隔着许多其他代码,或者因为方法内联的缘故,将两个存储操作(如构造器中字段的初始化以及随后的更新)纳入同一个编译单元里。

死代码消除

除了字段存储优化之外,局部变量的死存储(dead store)同样也涉及了冗余存储。这是死代码消除(dead code eliminiation)的一种。不过,由于 Sea-of-Nodes IR 的特性,死存储的优化无须额外代价。

int bar(int x, int y) {
  int t = x*y;
  t = x+y;
  return t;
}

上面这段代码涉及两个存储局部变量操作。当即时编译器将其转换为 Sea-of-Nodes IR 之后,没有节点依赖于 t 的第一个值x*y。因此,该乘法运算将被消除,其结果如下所示:

int bar(int x, int y) {
  return x+y;
}

死存储还有一种变体,即在部分程序路径上有冗余存储。

int bar(boolean f, int x, int y) {
  int t = x*y;
  if (f)
    t = x+y;
  return t;
}

举个例子,上面这段代码中,如果所传入的 boolean 类型的参数f是true,那么在程序执行路径上将先后进行两次对局部变量t的存储。

同样,经过 Sea-of-Nodes IR 转换之后,返回节点所依赖的值是一个 phi 节点,将根据程序路径选择x+y或者x*y。也就是说,当f为true的程序路径上的乘法运算会被消除,其结果如下所示:

int bar(boolean f, int x, int y) {
  int t;
  if (f)
    t = x+y;
  else
    t = x*y;
  return t;
}

另一种死代码消除则是不可达分支消除。不可达分支就是任何程序路径都不可到达的分支,我们之前已经多次接触过了。

在即时编译过程中,我们经常因为方法内联、常量传播以及基于 profile 的优化等,生成许多不可达分支。通过消除不可达分支,即时编译器可以精简数据流,并且减少编译时间以及最终生成机器码的大小。

int bar(int x) {
  if (false)
    return x;
  else
    return -x;
}

举个例子,在上面的代码中,if 语句将一直跳转至 else 分支之中。因此,另一不可达分支可以直接消除掉,形成下面的代码:

int bar(int x) {
  return -x;
}

二、循环优化

循环无关代码外提

所谓的循环无关代码(Loop-invariant Code),指的是循环中值不变的表达式。如果能够在不改变程序语义的情况下,将这些循环无关代码提出循环之外,那么程序便可以避免重复执行这些表达式,从而达到性能提升的效果。

int foo(int x, int y, int[] a) {
  int sum = 0;
  for (int i = 0; i < a.length; i++) {
    sum += x * y + a[i];
  }
  return sum;
}
// 对应的字节码
int foo(int, int, int[]);
  Code:
     0: iconst_0
     1: istore 4
     3: iconst_0
     4: istore 5
     6: goto 25
// 循环体开始
     9: iload 4        // load sum
    11: iload_1        // load x
    12: iload_2        // load y
    13: imul           // x*y
    14: aload_3        // load a
    15: iload 5        // load i
    17: iaload         // a[i]
    18: iadd           // x*y + a[i]
    19: iadd           // sum + (x*y + a[i])
    20: istore 4       // sum = sum + (x*y + a[i])
    22: iinc 5, 1      // i++
    25: iload 5        // load i
    27: aload_3        // load a
    28: arraylength    // a.length
    29: if_icmplt 9    // i < a.length
// 循环体结束
    32: iload 4
    34: ireturn

举个例子,在上面这段代码中,循环体中的表达式x*y,以及循环判断条件中的a.length均属于循环不变代码。前者是一个整数乘法运算,而后者则是内存访问操作,读取数组对象a的长度。(数组的长度存放于数组对象的对象头中,可通过 arraylength 指令来访问。)

理想情况下,上面这段代码经过循环无关代码外提之后,等同于下面这一手工优化版本。

int fooManualOpt(int x, int y, int[] a) {
  int sum = 0;
  int t0 = x * y;
  int t1 = a.length;
  for (int i = 0; i < t1; i++) {
    sum += t0 + a[i];
  }
  return sum;
}

我们可以看到,无论是乘法运算x*y,还是内存访问a.length,现在都在循环之前完成。原本循环中需要执行这两个表达式的地方,现在直接使用循环之前这两个表达式的执行结果。

在 Sea-of-Nodes IR 的帮助下,循环无关代码外提的实现并不复杂。

上图我截取了 Graal 为前面例子中的foo方法所生成的 IR 图(局部)。其中 B2 基本块位于循环之前,B3 基本块为循环头。

x*y所对应的 21 号乘法节点,以及a.length所对应的 47 号读取节点,均不依赖于循环体中生成的数据,而且都为浮动节点。节点调度算法会将它们放置于循环之前的 B2 基本块中,从而实现这些循环无关代码的外提。

0x02f0: mov edi,ebx  // ebx 存放着 x*y 的结果
0x02f2: add edi,DWORD PTR [r8+r9*4+0x10]
                     // [r8+r9*4+0x10] 即 a[i]
                     // r8 指向 a,r9d 存放着 i
0x02f7: add eax,edi  // eax 存放着 sum
0x02f9: inc r9d      // i++
0x02fc: cmp r9d,r10d // i < a.length
                     // r10d 存放着 a.length
0x02ff: jl 0x02f0

上面这段机器码是foo方法的编译结果中的循环。这里面没有整数乘法指令,也没有读取数组长度的内存访问指令。它们的值已在循环之前计算好了,并且分别保存在寄存器ebx以及r10d之中。在循环之中,代码直接使用寄存器ebx以及r10d所保存的值,而不用在循环中反复计算。

从生成的机器码中可以看出,除了x*ya.length的外提之外,即时编译器还外提了 int 数组加载指令iaload所暗含的 null 检测(null check)以及下标范围检测(range check)。

如果将iaload指令想象成一个接收数组对象以及下标作为参数,并且返回对应数组元素的方法,那么它的伪代码如下所示:

int iaload(int[] arrayRef, int index) {
  if (arrayRef == null) { // null 检测
    throw new NullPointerException();
  }
  if (index < 0 || index >= arrayRef.length) { // 下标范围检测
    throw new ArrayIndexOutOfBoundsException();
  }
  return arrayRef[index];
}

foo方法中的 null 检测属于循环无关代码。这是因为它始终检测作为输入参数的 int 数组是否为 null,而这与第几次循环无关。

为了更好地阐述具体的优化,我精简了原来的例子,并将iaload展开,最终形成如下所示的代码。

int foo(int[] a) {
  int sum = 0;
  for (int i = 0; i < a.length; i++) {
    if (a == null) { // null check
      throw new NullPointerException();
    }
    if (i < 0 || i >= a.length) { // range check
      throw new ArrayIndexOutOfBoundsException();
    }
    sum += a[i];
  }
  return sum;
}

在这段代码中,null 检测涉及了控制流依赖,因而无法通过 Sea-of-Nodes IR 转换以及节点调度来完成外提。

在 C2 中,null 检测的外提是通过额外的编译优化,也就是循环预测(Loop Prediction,对应虚拟机参数-XX:+UseLoopPredicate)来实现的。该优化的实际做法是在循环之前插入同样的检测代码,并在命中的时候进行去优化。这样一来,循环中的检测代码便会被归纳并消除掉。

int foo(int[] a) {
  int sum = 0;
  if (a == null) {
    deoptimize(); // never returns
  }
  for (int i = 0; i < a.length; i++) {
    if (a == null) { // now evluate to false
      throw new NullPointerException();
    }
    if (i < 0 || i >= a.length) { // range check
      throw new ArrayIndexOutOfBoundsException();
    }
    sum += a[i];
  }
  return sum;
}

除了 null 检测之外,其他循环无关检测都能够按照这种方式外提至循环之前。甚至是循环有关的下标范围检测,都能够借助循环预测来外提,只不过具体的转换要复杂一些。

之所以说下标范围检测是循环有关的,是因为在我们的例子中,该检测的主体是循环控制变量i(检测它是否在[0, a.length)之间),它的值将随着循环次数的增加而改变。

由于外提该下标范围检测之后,我们无法再引用到循环变量i,因此,即时编译器需要转换检测条件。具体的转换方式如下所示:

for (int i = INIT; i < LIMIT; i += STRIDE) {
  if (i < 0 || i >= a.length) { // range check
    throw new ArrayIndexOutOfBoundsException();
  }
  sum += a[i];
}
----------
// 经过下标范围检测外提之后:
if (INIT < 0 || IMAX >= a.length) {
  // IMAX 是 i 所能达到的最大值,注意它不一定是 LIMIT-1
  detopimize(); // never returns
}
for (int i = INIT; i < LIMIT; i += STRIDE) {
  sum += a[i]; // 不包含下标范围检测
}

循环展开

另外一项非常重要的循环优化是循环展开(Loop Unrolling)。它指的是在循环体中重复多次循环迭代,并减少循环次数的编译优化。

int foo(int[] a) {
  int sum = 0;
  for (int i = 0; i < 64; i++) {
    sum += (i % 2 == 0) ? a[i] : -a[i];
  }
  return sum;
}

举个例子,上面的代码经过一次循环展开之后将形成下面的代码:

int foo(int[] a) {
  int sum = 0;
  for (int i = 0; i < 64; i += 2) { // 注意这里的步数是 2
    sum += (i % 2 == 0) ? a[i] : -a[i];
    sum += ((i + 1) % 2 == 0) ? a[i + 1] : -a[i + 1];
  }
  return sum;
}

在 C2 中,只有计数循环(Counted Loop)才能被展开。所谓的计数循环需要满足如下四个条件。

维护一个循环计数器,并且基于计数器的循环出口只有一个(但可以有基于其他判断条件的出口)。 循环计数器的类型为 int、short 或者 char(即不能是 byte、long,更不能是 float 或者 double)。 每个迭代循环计数器的增量为常数。 循环计数器的上限(增量为正数)或下限(增量为负数)是循环无关的数值。

for (int i = START; i < LIMIT; i += STRIDE) { .. }
// 等价于
int i = START;
while (i < LIMIT) {
  ..
  i += STRIDE;
}

在上面两种循环中,只要LIMIT是循环无关的数值,STRIDE是常数,而且循环中除了i < LIMIT之外没有其他基于循环变量i的循环出口,那么 C2 便会将该循环识别为计数循环。

循环展开的缺点显而易见:它可能会增加代码的冗余度,导致所生成机器码的长度大幅上涨。

不过,随着循环体的增大,优化机会也会不断增加。一旦循环展开能够触发进一步的优化,总体的代码复杂度也将降低。比如前面的例子经过循环展开之后便可以进一步优化为如下所示的代码:

int foo(int[] a) {
  int sum = 0;
  for (int i = 0; i < 64; i += 2) {
    sum += a[i];
    sum += -a[i + 1];
  }
  return sum;
}

循环展开有一种特殊情况,那便是完全展开(Full Unroll)。当循环的数目是固定值而且非常小时,即时编译器会将循环全部展开。此时,原本循环中的循环判断语句将不复存在,取而代之的是若干个顺序执行的循环体。

int foo(int[] a) {
  int sum = 0;
  for (int i = 0; i < 4; i++) {
    sum += a[i];
  }
  return sum;
}

举个例子,上述代码将被完全展开为下述代码:

int foo(int[] a) {
  int sum = 0;
  sum += a[0];
  sum += a[1];
  sum += a[2];
  sum += a[3];
  return sum;
}

即时编译器会在循环体的大小与循环展开次数之间做出权衡。例如,对于仅迭代三次(或以下)的循环,即时编译器将进行完全展开;对于循环体 IR 节点数目超过阈值的循环,即时编译器则不会进行任何循环展开。

其他循环优化

除了循环无关代码外提以及循环展开之外,即时编译器还有两个比较重要的循环优化技术:循环判断外提(loop unswitching)以及循环剥离(loop peeling)。

循环判断外提指的是将循环中的 if 语句外提至循环之前,并且在该 if 语句的两个分支中分别放置一份循环代码。

int foo(int[] a) {
  int sum = 0;
  for (int i = 0; i < a.length; i++) {
    if (a.length > 4) {
      sum += a[i];
    }
  }
  return sum;
}

举个例子,上面这段代码经过循环判断外提之后,将变成下面这段代码:

int foo(int[] a) {
  int sum = 0;
  if (a.length > 4) {
    for (int i = 0; i < a.length; i++) {
      sum += a[i];
    }
  } else {
    for (int i = 0; i < a.length; i++) {
    }
  }
  return sum;
}
// 进一步优化为:
int foo(int[] a) {
  int sum = 0;
  if (a.length > 4) {
    for (int i = 0; i < a.length; i++) {
      sum += a[i];
    }
  }
  return sum;
}

循环判断外提与循环无关检测外提所针对的代码模式比较类似,都是循环中的 if 语句。不同的是,后者在检查失败时会抛出异常,中止当前的正常执行路径;而前者所针对的是更加常见的情况,即通过 if 语句的不同分支执行不同的代码逻辑。

循环剥离指的是将循环的前几个迭代或者后几个迭代剥离出循环的优化方式。一般来说,循环的前几个迭代或者后几个迭代都包含特殊处理。通过将这几个特殊的迭代剥离出去,可以使原本的循环体的规律性更加明显,从而触发进一步的优化。

int foo(int[] a) {
  int j = 0;
  int sum = 0;
  for (int i = 0; i < a.length; i++) {
    sum += a[j];
    j = i;
  }
  return sum;
}

举个例子,上面这段代码剥离了第一个迭代后,将变成下面这段代码:

int foo(int[] a) {
  int sum = 0;
  if (0 < a.length) {
    sum += a[0];
    for (int i = 1; i < a.length; i++) {
      sum += a[i - 1];
    }
  }
  return sum;
}

三、向量化

如何进一步优化下面这段代码。

void foo(byte[] dst, byte[] src) {
  for (int i = 0; i < dst.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];
  }
  ... // post-loop
}

由于 X86_64 平台不支持内存间的直接移动,上面代码中的dst[i] = src[i]通常会被编译为两条内存访问指令:第一条指令把src[i]的值读取至寄存器中,而第二条指令则把寄存器中的值写入至dst[i]中。

因此,上面这段代码中的一个循环迭代将会执行四条内存读取指令,以及四条内存写入指令。

由于数组元素在内存中是连续的,当从src[i]的内存地址处读取 32 位的内容时,我们将一并读取src[i]至src[i+3]的值。同样,当向dst[i]的内存地址处写入 32 位的内容时,我们将一并写入dst[i]至dst[i+3]的值。

通过综合这两个批量操作,我们可以使用一条内存读取指令以及一条内存写入指令,完成上面代码中循环体内的全部工作。如果我们用x[i:i+3]来指代x[i]至x[i+3]合并后的值,那么上述优化可以被表述成如下所示的代码:

void foo(byte[] dst, byte[] src) {
  for (int i = 0; i < dst.length - 4; i += 4) {
    dst[i:i+3] = src[i:i+3];
  }
  ... // post-loop
}

SIMD 指令

在前面的示例中,我们使用的是 byte 数组,四个数组元素并起来也才 4 个字节。如果换成 int 数组,或者 long 数组,那么四个数组元素并起来将会是 16 字节或 32 字节。

我们知道,X86_64 体系架构上通用寄存器的大小为 64 位(即 8 个字节),无法暂存这些超长的数据。因此,即时编译器将借助长度足够的 XMM 寄存器,来完成 int 数组与 long 数组的向量化读取和写入操作。(为了实现方便,byte 数组的向量化读取、写入操作同样使用了 XMM 寄存器。)

所谓的 XMM 寄存器,是由 SSE(Streaming SIMD Extensions)指令集所引入的。它们一开始仅为 128 位。自从 X86 平台上的 CPU 开始支持 AVX(Advanced Vector Extensions)指令集后(2011 年),XMM 寄存器便升级为 256 位,并更名为 YMM 寄存器。原本使用 XMM 寄存器的指令,现将使用 YMM 寄存器的低 128 位。

前几年推出的 AVX512 指令集,更是将 YMM 寄存器升级至 512 位,并更名为 ZMM 寄存器。HotSpot 虚拟机也紧跟时代,更新了不少基于 AVX512 指令集以及 ZMM 寄存器的优化。不过,支持 AVX512 指令集的 CPU 都比较贵,目前在生产环境中很少见到。 SSE 指令集以及之后的 AVX 指令集都涉及了一个重要的概念,那便是单指令流多数据流(Single Instruction Multiple Data,SIMD),即通过单条指令操控多组数据的计算操作。这些指令我们称之为 SIMD 指令。

SIMD 指令将 XMM 寄存器(或 YMM 寄存器、ZMM 寄存器)中的值看成多个整数或者浮点数组成的向量,并且批量进行计算。

举例来说,128 位 XMM 寄存器里的值可以看成 16 个 byte 值组成的向量,或者 8 个 short 值组成的向量,4 个 int 值组成的向量,两个 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 的演讲。下图中内存的右边是高位,寄存器的左边是高位,因此数组元素的顺序是反过来的。

也就是说,原本需要c.length次加法操作的代码,现在最少只需要c.length/4次向量加法即可完成。因此,SIMD 指令也被看成 CPU 指令级别的并行。

这里c.length/4次是理论值。现实中,C2 还将考虑缓存行对齐等因素,导致能够应用向量化加法的仅有数组中间的部分元素。

使用 SIMD 指令的 HotSpot Intrinsic

SIMD 指令虽然非常高效,但是使用起来却很麻烦。这主要是因为不同的 CPU 所支持的 SIMD 指令可能不同。一般来说,越新的 SIMD 指令,它所支持的寄存器长度越大,功能也越强。

目前几乎所有的 X86_64 平台上的 CPU 都支持 SSE 指令集,绝大部分支持 AVX 指令集,三四年前量产的 CPU 支持 AVX2 指令集,最近少数服务器端 CPU 支持 AVX512 指令集。AVX512 指令集的提升巨大,因为它不仅将寄存器长度增大至 512 字节,而且引入了非常多的新指令。

为了能够尽量利用新的 SIMD 指令,我们需要提前知道程序会被运行在支持哪些指令集的 CPU 上,并在编译过程中选择所支持的 SIMD 指令中最新的那些。

或者,我们可以在编译结果中纳入同一段代码的不同版本,每个版本使用不同的 SIMD 指令。在运行过程中,程序将根据 CPU 所支持的指令集,来选择执行哪一个版本。

虽然程序中包含当前 CPU 可能不支持的指令,但是只要不执行到这些指令,程序便不会出问题。如果不小心执行到这些不支持的指令,CPU 会触发一个中断,并向当前进程发出sigill信号。

不过,这对于使用即时编译技术的 Java 虚拟机来说,并不是一个大问题。

我们知道,Java 虚拟机所执行的 Java 字节码是平台无关的。它首先会被解释执行,而后反复执行的部分才会被 Java 虚拟机即时编译为机器码。换句话说,在进行即时编译的时候,Java 虚拟机已经运行在目标 CPU 之上,可以轻易地得知其所支持的指令集。

然而,Java 字节码的平台无关性却引发了另一个问题,那便是 Java 程序无法像 C++ 程序那样,直接使用由 Intel 提供的,将被替换为具体 SIMD 指令的 intrinsic 方法 [2]。

HotSpot 虚拟机提供的替代方案是 Java 层面的 intrinsic 方法,这些 intrinsic 方法的语义要比单个 SIMD 指令复杂得多。在运行过程中,HotSpot 虚拟机将根据当前体系架构来决定是否将对该 intrinsic 方法的调用替换为另一高效的实现。如果不,则使用原本的 Java 实现。

举个例子,Java 8 中Arrays.equals(int[], int[])的实现将逐个比较 int 数组中的元素。

    public static boolean equals(int[] a, int[] a2) {
        if (a==a2)
            return true;
        if (a==null || a2==null)
            return false;
        int length = a.length;
        if (a2.length != length)
            return false;
        // 关键循环
        for (int i=0; i<length; i++)
            if (a[i] != a2[i])
                return false;
 
        return true;
    }

对应的 intrinsic 高效实现会将数组的多个元素加载至 XMM/YMM/ZMM 寄存器中,然后进行按位比较。如果两个数组相同,那么其中若干个元素合并而成的值也相同,其按位比较也应成功。反过来,如果按位比较失败,则说明两个数组不同。

使用 SIMD 指令的 HotSpot intrinsic 是虚拟机开发人员根据其语义定制的,因而性能相当优越。

不过,由于开发成本及维护成本较高,这种类型的 intrinsic 屈指可数,如用于复制数组的System.arraycopy和Arrays.copyOf,用于比较数组的Arrays.equals,以及 Java 9 新加入的Arrays.compare和Arrays.mismatch,以及字符串相关的一些方法String.indexOf、StringLatin1.inflate。

Arrays.copyOf将调用System.arraycopy,实际上只有后者是 intrinsic。在 Java 9 之后,数组比较真正的 intrinsic 是ArraySupports.vectorizedMismatch方法,而Arrays.equals、Arrays.compare和Arrays.mismatch将调用至该方法中。

另外,这些 intrinsic 方法只能做到点覆盖,在不少情况下,应用程序并不会用到这些 intrinsic 的语义,却又存在向量化优化的机会。这个时候,我们便需要借助即时编译器中的自动向量化(auto vectorization)。

自动向量化

即时编译器的自动向量化将针对能够展开的计数循环,进行向量化优化。如前面介绍过的这段代码,即时编译器便能够自动将其展开优化成使用PADDD指令的向量加法。

void foo(int[] a, int[] b, int[] c) {
  for (int i = 0; i < c.length; i++) {
    c[i] = a[i] + b[i];
  }
}

关于计数循环的判定,我在上一篇介绍循环优化时已经讲解过了,这里我补充几点自动向量化的条件。

循环变量的增量应为 1,即能够遍历整个数组。 循环变量不能为 long 类型,否则 C2 无法将循环识别为计数循环。 循环迭代之间最好不要有数据依赖,例如出现类似于a[i] = a[i-1]的语句。当循环展开之后,循环体内存在数据依赖,那么 C2 无法进行自动向量化。 循环体内不要有分支跳转。 不要手工进行循环展开。如果 C2 无法自动展开,那么它也将无法进行自动向量化。 我们可以看到,自动向量化的条件较为苛刻。而且,C2 支持的整数向量化操作并不多,据我所致只有向量加法,向量减法,按位与、或、异或,以及批量移位和批量乘法。C2 还支持向量点积的自动向量化,即两两相乘再求和,不过这需要多条 SIMD 指令才能完成,因此并不是十分高效。

为了解决向量化 intrinsic 以及自动向量化覆盖面过窄的问题,我们在 OpenJDK 的 Paname 项目 [3] 中尝试引入开发人员可控的向量化抽象。

该抽象将提供一套通用的跨平台 API,让 Java 程序能够定义诸如IntVector的向量,并使用由它提供的一系列向量化 intrinsic 方法。即时编译器负责将这些 intrinsic 的调用转换为符合当前体系架构 /CPU 的 SIMD 指令。