为什么这个内循环比通过外循环的第一次迭代快4倍?

2022-09-02 10:28:29

我试图重现此处描述的一些处理器缓存效果。我知道Java是一个托管环境,这些例子不会完全翻译,但我遇到了一个奇怪的案例,我试图提炼成一个简单的例子来说明效果:

public static void main(String[] args) {
    final int runs = 10;
    final int steps = 1024 * 1024 * 1024;

    for (int run = 0; run < runs; run++) {
        final int[] a = new int[1];
        long start = System.nanoTime();
        for (int i = 0; i < steps; i++) {
            a[0]++;
        }
        long stop = System.nanoTime();
        long time = TimeUnit.MILLISECONDS.convert(stop - start, TimeUnit.NANOSECONDS);
        System.out.printf("Time for loop# %2d: %5d ms\n", run, time);
    }
}

输出:

 Time for loop#  0:    24 ms
 Time for loop#  1:   106 ms
 Time for loop#  2:   104 ms
 Time for loop#  3:   103 ms
 Time for loop#  4:   102 ms
 Time for loop#  5:   103 ms
 Time for loop#  6:   104 ms
 Time for loop#  7:   102 ms
 Time for loop#  8:   105 ms
 Time for loop#  9:   102 ms

内部循环的第一次迭代大约是后续迭代的 4 倍。这与我通常期望的相反,因为通常随着JIT的启动,性能会上升。

当然,在任何严肃的微基准测试中,人们都会做几个预热循环,但我很好奇是什么导致了这种行为,特别是因为如果我们知道环路可以在24ms内执行,那么稳态时间超过100ms并不是很令人满意。

作为参考,我正在使用的JDK(在linux上):

 openjdk version "1.8.0_40"
 OpenJDK Runtime Environment (build 1.8.0_40-b20)
 OpenJDK 64-Bit Server VM (build 25.40-b23, mixed mode)

更新:

以下是一些更新信息,基于一些评论和一些实验:

1) 将 System.out I/O 移出环路(通过将时序存储在大小为“runs”的数组中)不会对时间产生显著差异。

2)上面显示的输出是我从Eclipse中运行时。当我从命令行编译和运行时(使用相同的JDK / JVM),我得到的结果更适度,但仍然很重要(2倍而不是4倍)。这似乎很有趣,因为在日食中运行会减慢速度,如果有的话。

3)向上移动,退出循环,使其被重复使用,每次迭代都没有效果。a

4)如果更改为,则第一次迭代运行得更快(约20%),而其他迭代仍然相同(较慢)的速度。int[] along[] a

更新 2:

我认为apangin的答案可以解释它。我用Sun的1.9 JVM尝试了这个,它来自:

openjdk version "1.8.0_40"
OpenJDK Runtime Environment (build 1.8.0_40-b20)
OpenJDK 64-Bit Server VM (build 25.40-b23, mixed mode)

Time for loop#  0:    48 ms
Time for loop#  1:   116 ms
Time for loop#  2:   112 ms
Time for loop#  3:   113 ms
Time for loop#  4:   112 ms
Time for loop#  5:   112 ms
Time for loop#  6:   111 ms
Time for loop#  7:   111 ms
Time for loop#  8:   113 ms
Time for loop#  9:   113 ms

自:

java version "1.9.0-ea"
Java(TM) SE Runtime Environment (build 1.9.0-ea-b73)
Java HotSpot(TM) 64-Bit Server VM (build 1.9.0-ea-b73, mixed mode)

Time for loop#  0:    48 ms
Time for loop#  1:    26 ms
Time for loop#  2:    22 ms
Time for loop#  3:    22 ms
Time for loop#  4:    22 ms
Time for loop#  5:    22 ms
Time for loop#  6:    22 ms
Time for loop#  7:    22 ms
Time for loop#  8:    22 ms
Time for loop#  9:    23 ms

这是相当的改进!


答案 1

这是对方法的次优重新编译。

JIT 编译器依赖于在解释期间收集的运行时统计信息。当第一次编译方法时,外部循环尚未完成其第一次迭代 =>运行时统计信息告诉内部循环之后的代码永远不会执行,因此 JIT 从不费心编译它。相反,它会产生一个不常见的陷阱。main

当内部循环首次结束时,将命中不常见的陷阱,从而导致方法被取消优化。

在外部循环的第二次迭代中,将使用新知识重新编译该方法。现在,JIT 有更多的统计信息和更多的上下文需要编译。由于某种原因,现在它不会在寄存器中缓存该值(可能是因为JIT被更广泛的上下文所愚弄)。因此,它生成指令来更新内存中的数组,这实际上是内存加载和存储的组合。maina[0]addl

相反,在第一次编译期间,JIT 将的值缓存在寄存器中,只有指令将值存储在内存中(无负载)。a[0]mov

快速循环(第一次迭代):

0x00000000029fc562: mov    %ecx,0x10(%r14)   <<< array store
0x00000000029fc566: mov    %r11d,%edi
0x00000000029fc569: mov    %r9d,%ecx
0x00000000029fc56c: add    %edi,%ecx
0x00000000029fc56e: mov    %ecx,%r11d
0x00000000029fc571: add    $0x10,%r11d       <<< increment in register
0x00000000029fc575: mov    %r11d,0x10(%r14)  <<< array store
0x00000000029fc579: add    $0x11,%ecx
0x00000000029fc57c: mov    %edi,%r11d
0x00000000029fc57f: add    $0x10,%r11d
0x00000000029fc583: cmp    $0x3ffffff2,%r11d
0x00000000029fc58a: jl     0x00000000029fc562

慢速循环(重新编译后):

0x00000000029fa1b0: addl   $0x10,0x10(%r14)  <<< increment in memory
0x00000000029fa1b5: add    $0x10,%r13d
0x00000000029fa1b9: cmp    $0x3ffffff1,%r13d
0x00000000029fa1c0: jl     0x00000000029fa1b0

但是,此问题似乎在 JDK 9 中已修复。我已针对最近的 JDK 9 抢先体验版本检查了此测试,并验证了它是否按预期工作:

Time for loop#  0:   104 ms
Time for loop#  1:   101 ms
Time for loop#  2:    91 ms
Time for loop#  3:    63 ms
Time for loop#  4:    60 ms
Time for loop#  5:    60 ms
Time for loop#  6:    59 ms
Time for loop#  7:    55 ms
Time for loop#  8:    57 ms
Time for loop#  9:    59 ms

答案 2

推荐