以下 Java 程序平均需要 0.50 秒到 0.55 秒才能运行:
public static void main(String[] args) {
long startTime = System.nanoTime();
int n = 0;
for (int i = 0; i < 1000000000; i++) {
n += 2 * (i * i);
}
System.out.println(
(double) (System.nanoTime() - startTime) / 1000000000 + " s");
System.out.println("n = " + n);
}
如果我将 2 * (i * i)
替换为 2 * i * i
,则运行需要 0.60 到 0.65 秒。怎么来的?
我运行程序的每个版本 15 次,在两者之间交替运行。结果如下:
2*(i*i) │ 2*i*i
──────────┼──────────
0.5183738 │ 0.6246434
0.5298337 │ 0.6049722
0.5308647 │ 0.6603363
0.5133458 │ 0.6243328
0.5003011 │ 0.6541802
0.5366181 │ 0.6312638
0.515149 │ 0.6241105
0.5237389 │ 0.627815
0.5249942 │ 0.6114252
0.5641624 │ 0.6781033
0.538412 │ 0.6393969
0.5466744 │ 0.6608845
0.531159 │ 0.6201077
0.5048032 │ 0.6511559
0.5232789 │ 0.6544526
最快的 2 * i * i
比最慢的 2 * (i * i)
的运行时间更长。如果它们具有相同的效率,那么发生这种情况的概率将小于 1/2^15 * 100% = 0.00305%
。
原文由 Stefan 发布,翻译遵循 CC BY-SA 4.0 许可协议
字节码的顺序略有不同。
2 * (i * i)
:与
2 * i * i
:乍一看,这应该没什么区别。如果有的话,第二个版本会更优化,因为它少用了一个插槽。
所以我们需要深入挖掘底层(JIT) 1 。
请记住,JIT 倾向于非常积极地展开小循环。事实上,我们观察到
2 * (i * i)
案例的 16 倍展开:我们看到有 1 个寄存器“溢出”到堆栈上。
对于
2 * i * i
版本:在这里,我们观察到更多的“溢出”和对堆栈的更多访问
[RSP + ...]
,因为需要保留更多的中间结果。因此问题的答案很简单:
2 * (i * i)
比2 * i * i
快,因为 JIT 会为第一种情况生成更优化的汇编代码。但当然很明显,第一版和第二版都不好。循环确实可以从矢量化中受益,因为任何 x86-64 CPU 都至少支持 SSE2。
所以这是优化器的问题;通常情况下,它过于激进地展开并射中自己的脚,同时错过了各种其他机会。
事实上,现代 x86-64 CPU 将指令进一步分解为微操作 (µops),并具有寄存器重命名、µop 缓存和循环缓冲区等功能,循环优化比简单展开以获得最佳性能需要更多技巧。 根据 Agner Fog 的优化指南:
关于那些加载时间—— 即使是最快的 L1D 命中也要花费 4 个周期、一个额外的寄存器和 µop,所以是的,即使是对内存的几次访问也会损害紧密循环中的性能。
但是回到向量化的机会——看看它有多快, 我们可以用 GCC 编译一个类似的 C 应用程序,它完全向量化它(显示了 AVX2,SSE2 类似) 2 :
随着运行时间:
1要获取 JIT 生成的程序集输出,请 获取调试 JVM 并使用
-XX:+PrintOptoAssembly
运行2C 版本使用
-fwrapv
标志编译,它使 GCC 能够将有符号整数溢出视为二进制补码环绕。