为什么在Java中2*(i*i)比2*i*i更快?

912

以下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%


5
我得到了类似的结果(数字稍有不同,但绝对存在明显且一致的差距,肯定不是样本误差造成的)。 - Krease
31
请参考以下链接: https://dev59.com/hHRB5IYBdhLWcg3wz6UK该链接提供了如何编写正确的Java微基准测试的方法。 - lexicore
3
@Krease 很好,你发现了我的错误。根据我跑的新基准测试,2 * i * i 的速度比较慢。我也会尝试使用 Graal 进行运行。 - Jorn Vernee
6
要真正找出其中一种方法比另一种方法快的原因,我们需要获取这些方法的反汇编或优化后代码图表。汇编语言很难理解,所以我正在尝试获取可以输出漂亮图表的OpenJDK调试版本。 - Jorn Vernee
4
您可以将您的问题重命名为“为什么i * i * 22 * i * i更快?”以提高清晰度,表明问题在于操作顺序。 - Cœur
显示剩余10条评论
10个回答

1246

字节码的顺序存在轻微差异。

2 * (i * i):

     iconst_2
     iload0
     iload0
     imul
     imul
     iadd

2 * i * i相比:

     iconst_2
     iload0
     imul
     iload0
     imul
     iadd

乍一看这似乎没有任何区别;如果有的话,第二个版本更优,因为它使用了一个较少的槽位。

因此,我们需要深入挖掘低级别(JIT)1

记住,JIT倾向于非常积极地展开小循环。实际上,我们观察到2 *(i * i)的情况下展开了16倍:

030   B2: # B2 B3 <- B1 B2  Loop: B2-B2 inner main of N18 Freq: 1e+006
030     addl    R11, RBP    # int
033     movl    RBP, R13    # spill
036     addl    RBP, #14    # int
039     imull   RBP, RBP    # int
03c     movl    R9, R13 # spill
03f     addl    R9, #13 # int
043     imull   R9, R9  # int
047     sall    RBP, #1
049     sall    R9, #1
04c     movl    R8, R13 # spill
04f     addl    R8, #15 # int
053     movl    R10, R8 # spill
056     movdl   XMM1, R8    # spill
05b     imull   R10, R8 # int
05f     movl    R8, R13 # spill
062     addl    R8, #12 # int
066     imull   R8, R8  # int
06a     sall    R10, #1
06d     movl    [rsp + #32], R10    # spill
072     sall    R8, #1
075     movl    RBX, R13    # spill
078     addl    RBX, #11    # int
07b     imull   RBX, RBX    # int
07e     movl    RCX, R13    # spill
081     addl    RCX, #10    # int
084     imull   RCX, RCX    # int
087     sall    RBX, #1
089     sall    RCX, #1
08b     movl    RDX, R13    # spill
08e     addl    RDX, #8 # int
091     imull   RDX, RDX    # int
094     movl    RDI, R13    # spill
097     addl    RDI, #7 # int
09a     imull   RDI, RDI    # int
09d     sall    RDX, #1
09f     sall    RDI, #1
0a1     movl    RAX, R13    # spill
0a4     addl    RAX, #6 # int
0a7     imull   RAX, RAX    # int
0aa     movl    RSI, R13    # spill
0ad     addl    RSI, #4 # int
0b0     imull   RSI, RSI    # int
0b3     sall    RAX, #1
0b5     sall    RSI, #1
0b7     movl    R10, R13    # spill
0ba     addl    R10, #2 # int
0be     imull   R10, R10    # int
0c2     movl    R14, R13    # spill
0c5     incl    R14 # int
0c8     imull   R14, R14    # int
0cc     sall    R10, #1
0cf     sall    R14, #1
0d2     addl    R14, R11    # int
0d5     addl    R14, R10    # int
0d8     movl    R10, R13    # spill
0db     addl    R10, #3 # int
0df     imull   R10, R10    # int
0e3     movl    R11, R13    # spill
0e6     addl    R11, #5 # int
0ea     imull   R11, R11    # int
0ee     sall    R10, #1
0f1     addl    R10, R14    # int
0f4     addl    R10, RSI    # int
0f7     sall    R11, #1
0fa     addl    R11, R10    # int
0fd     addl    R11, RAX    # int
100     addl    R11, RDI    # int
103     addl    R11, RDX    # int
106     movl    R10, R13    # spill
109     addl    R10, #9 # int
10d     imull   R10, R10    # int
111     sall    R10, #1
114     addl    R10, R11    # int
117     addl    R10, RCX    # int
11a     addl    R10, RBX    # int
11d     addl    R10, R8 # int
120     addl    R9, R10 # int
123     addl    RBP, R9 # int
126     addl    RBP, [RSP + #32 (32-bit)]   # int
12a     addl    R13, #16    # int
12e     movl    R11, R13    # spill
131     imull   R11, R13    # int
135     sall    R11, #1
138     cmpl    R13, #999999985
13f     jl     B2   # loop end  P=1.000000 C=6554623.000000

我们发现有1个寄存器被"溢出"到堆栈上。

而对于 2 * i * i 版本:

05a   B3: # B2 B4 <- B1 B2  Loop: B3-B2 inner main of N18 Freq: 1e+006
05a     addl    RBX, R11    # int
05d     movl    [rsp + #32], RBX    # spill
061     movl    R11, R8 # spill
064     addl    R11, #15    # int
068     movl    [rsp + #36], R11    # spill
06d     movl    R11, R8 # spill
070     addl    R11, #14    # int
074     movl    R10, R9 # spill
077     addl    R10, #16    # int
07b     movdl   XMM2, R10   # spill
080     movl    RCX, R9 # spill
083     addl    RCX, #14    # int
086     movdl   XMM1, RCX   # spill
08a     movl    R10, R9 # spill
08d     addl    R10, #12    # int
091     movdl   XMM4, R10   # spill
096     movl    RCX, R9 # spill
099     addl    RCX, #10    # int
09c     movdl   XMM6, RCX   # spill
0a0     movl    RBX, R9 # spill
0a3     addl    RBX, #8 # int
0a6     movl    RCX, R9 # spill
0a9     addl    RCX, #6 # int
0ac     movl    RDX, R9 # spill
0af     addl    RDX, #4 # int
0b2     addl    R9, #2  # int
0b6     movl    R10, R14    # spill
0b9     addl    R10, #22    # int
0bd     movdl   XMM3, R10   # spill
0c2     movl    RDI, R14    # spill
0c5     addl    RDI, #20    # int
0c8     movl    RAX, R14    # spill
0cb     addl    RAX, #32    # int
0ce     movl    RSI, R14    # spill
0d1     addl    RSI, #18    # int
0d4     movl    R13, R14    # spill
0d7     addl    R13, #24    # int
0db     movl    R10, R14    # spill
0de     addl    R10, #26    # int
0e2     movl    [rsp + #40], R10    # spill
0e7     movl    RBP, R14    # spill
0ea     addl    RBP, #28    # int
0ed     imull   RBP, R11    # int
0f1     addl    R14, #30    # int
0f5     imull   R14, [RSP + #36 (32-bit)]   # int
0fb     movl    R10, R8 # spill
0fe     addl    R10, #11    # int
102     movdl   R11, XMM3   # spill
107     imull   R11, R10    # int
10b     movl    [rsp + #44], R11    # spill
110     movl    R10, R8 # spill
113     addl    R10, #10    # int
117     imull   RDI, R10    # int
11b     movl    R11, R8 # spill
11e     addl    R11, #8 # int
122     movdl   R10, XMM2   # spill
127     imull   R10, R11    # int
12b     movl    [rsp + #48], R10    # spill
130     movl    R10, R8 # spill
133     addl    R10, #7 # int
137     movdl   R11, XMM1   # spill
13c     imull   R11, R10    # int
140     movl    [rsp + #52], R11    # spill
145     movl    R11, R8 # spill
148     addl    R11, #6 # int
14c     movdl   R10, XMM4   # spill
151     imull   R10, R11    # int
155     movl    [rsp + #56], R10    # spill
15a     movl    R10, R8 # spill
15d     addl    R10, #5 # int
161     movdl   R11, XMM6   # spill
166     imull   R11, R10    # int
16a     movl    [rsp + #60], R11    # spill
16f     movl    R11, R8 # spill
172     addl    R11, #4 # int
176     imull   RBX, R11    # int
17a     movl    R11, R8 # spill
17d     addl    R11, #3 # int
181     imull   RCX, R11    # int
185     movl    R10, R8 # spill
188     addl    R10, #2 # int
18c     imull   RDX, R10    # int
190     movl    R11, R8 # spill
193     incl    R11 # int
196     imull   R9, R11 # int
19a     addl    R9, [RSP + #32 (32-bit)]    # int
19f     addl    R9, RDX # int
1a2     addl    R9, RCX # int
1a5     addl    R9, RBX # int
1a8     addl    R9, [RSP + #60 (32-bit)]    # int
1ad     addl    R9, [RSP + #56 (32-bit)]    # int
1b2     addl    R9, [RSP + #52 (32-bit)]    # int
1b7     addl    R9, [RSP + #48 (32-bit)]    # int
1bc     movl    R10, R8 # spill
1bf     addl    R10, #9 # int
1c3     imull   R10, RSI    # int
1c7     addl    R10, R9 # int
1ca     addl    R10, RDI    # int
1cd     addl    R10, [RSP + #44 (32-bit)]   # int
1d2     movl    R11, R8 # spill
1d5     addl    R11, #12    # int
1d9     imull   R13, R11    # int
1dd     addl    R13, R10    # int
1e0     movl    R10, R8 # spill
1e3     addl    R10, #13    # int
1e7     imull   R10, [RSP + #40 (32-bit)]   # int
1ed     addl    R10, R13    # int
1f0     addl    RBP, R10    # int
1f3     addl    R14, RBP    # int
1f6     movl    R10, R8 # spill
1f9     addl    R10, #16    # int
1fd     cmpl    R10, #999999985
204     jl     B2   # loop end  P=1.000000 C=7419903.000000

在这里,我们观察到更多的“spilling”和对堆栈 [RSP + ...] 的访问,因为需要保留更多的中间结果。

因此,问题的答案很简单:2*(i*i)2*i*i 更快,因为JIT为第一种情况生成了更优化的汇编代码。


当然,显然两个版本都不是很好; 循环实际上可以受益于矢量化,因为任何x86-64 CPU至少具有SSE2支持。

所以这是一个优化器的问题;通常情况下,它太过于严格而错失了各种机会。

事实上,现代x86-64 CPU将指令进一步分解为微操作(μops),通过类似寄存器重命名、μop缓存和循环缓冲区等功能,循环优化需要比简单的展开更多的技巧来获得最佳性能。根据Agner Fog的优化指南

由于μop高速缓存的性能增益可能相当大,如果平均指令长度大于4字节,则可以考虑以下优化μop高速缓存的使用方法:

  • 确保关键循环足够小,适合μop高速缓存。
  • 将最关键的循环入口和函数入口对齐到32位。
  • 避免不必要的循环展开。
  • 避免具有额外负载时间的指令。

关于那些加载时间 - 即使是最快的L1D命中也会花费4个周期,一个额外的寄存器和μop,因此,在紧密循环中,甚至少量的内存访问也会损害性能。

但回到矢量化机会 - 为了看到它有多快,我们可以用GCC编译类似的C应用程序,它直接将其矢量化(AVX2显示,SSE2类似)2

  vmovdqa ymm0, YMMWORD PTR .LC0[rip]
  vmovdqa ymm3, YMMWORD PTR .LC1[rip]
  xor eax, eax
  vpxor xmm2, xmm2, xmm2
.L2:
  vpmulld ymm1, ymm0, ymm0
  inc eax
  vpaddd ymm0, ymm0, ymm3
  vpslld ymm1, ymm1, 1
  vpaddd ymm2, ymm2, ymm1
  cmp eax, 125000000      ; 8 calculations per iteration
  jne .L2
  vmovdqa xmm0, xmm2
  vextracti128 xmm2, ymm2, 1
  vpaddd xmm2, xmm0, xmm2
  vpsrldq xmm0, xmm2, 8
  vpaddd xmm0, xmm2, xmm0
  vpsrldq xmm1, xmm0, 4
  vpaddd xmm0, xmm0, xmm1
  vmovd eax, xmm0
  vzeroupper

具有以下运行时间:

  • SSE:0.24秒,比原来快2倍。
  • AVX:0.15秒,比原来快3倍。
  • AVX2:0.08秒,比原来快5倍。

1 要获取JIT生成的汇编输出,请获取调试JVM并使用-XX:+PrintOptoAssembly运行。

2 C版本使用-fwrapv标志进行编译,这使得GCC将有符号整数溢出视为二进制补码环绕。


2
4c L1d的load-use延迟在这里不是一个因素。RSP始终保持不变,因此乱序执行可以足够早地运行加载以准备好数据。溢出/重载的成本都在于它所耗费的额外的uops。存储/重载存储转发延迟(3到5个时钟周期)与L1d缓存命中延迟分离,并且可能存在问题,但我认为在这里并没有发生。循环每次迭代需要超过5个周期,因此它不是瓶颈。而且我也不认为存储吞吐量是一个瓶颈。 - Peter Cordes
9
可能只是因为代码生成效率低下造成了前端瓶颈。它甚至没有使用LEA作为“mov”/“add-immediate”的窥孔。例如,“movl RBX,R9” / “addl RBX,#8”应该是“leal ebx,[r9 + 8]”,1个uop复制和添加。或者leal ebx, [r9 + r9 + 16]来做ebx = 2 *(r9 + 8)。所以,展开到溢出的程度是愚蠢的,而且天真的代码生成不利用整数恒等式和结合整数数学也是如此。 - Peter Cordes
@kasperd - 对于那个版本,答案也是肯定的。 - rustyx
7
在C2中,顺序约简的向量化被禁用了(https://bugs.openjdk.java.net/browse/JDK-8078563),但现在正在考虑重新启用(https://bugs.openjdk.java.net/browse/JDK-8188313)。 - pron
在完全关闭循环展开后,我得到了一些有趣的结果 - Oleksandr Pyrohov
显示剩余3条评论

135

(编辑注:这个答案与另一个答案展示的汇编代码矛盾。这只是一个猜测,支持一些实验,但结果证明不正确。)


当乘法为2 * (i * i)时,JVM能够从循环中分解出乘法2,从而产生等效但更高效的代码:

int n = 0;
for (int i = 0; i < 1000000000; i++) {
    n += i * i;
}
n *= 2;

但是当乘法是(2 * i) * i时,JVM不会对其进行优化,因为常数乘法不再紧接着n +=加法。

以下是我认为这种情况的几个原因:

  • 在循环开始处添加一个if (n == 0) n = 1语句,可以使两个版本都同样高效,因为分解乘法不再保证结果相同
  • 通过分解乘法优化的版本与2 * (i * i)版本的速度完全相同

以下是我用来得出这些结论的测试代码:

public static void main(String[] args) {
    long fastVersion = 0;
    long slowVersion = 0;
    long optimizedVersion = 0;
    long modifiedFastVersion = 0;
    long modifiedSlowVersion = 0;

    for (int i = 0; i < 10; i++) {
        fastVersion += fastVersion();
        slowVersion += slowVersion();
        optimizedVersion += optimizedVersion();
        modifiedFastVersion += modifiedFastVersion();
        modifiedSlowVersion += modifiedSlowVersion();
    }

    System.out.println("Fast version: " + (double) fastVersion / 1000000000 + " s");
    System.out.println("Slow version: " + (double) slowVersion / 1000000000 + " s");
    System.out.println("Optimized version: " + (double) optimizedVersion / 1000000000 + " s");
    System.out.println("Modified fast version: " + (double) modifiedFastVersion / 1000000000 + " s");
    System.out.println("Modified slow version: " + (double) modifiedSlowVersion / 1000000000 + " s");
}

private static long fastVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += 2 * (i * i);
    }
    return System.nanoTime() - startTime;
}

private static long slowVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += 2 * i * i;
    }
    return System.nanoTime() - startTime;
}

private static long optimizedVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += i * i;
    }
    n *= 2;
    return System.nanoTime() - startTime;
}

private static long modifiedFastVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        if (n == 0) n = 1;
        n += 2 * (i * i);
    }
    return System.nanoTime() - startTime;
}

private static long modifiedSlowVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        if (n == 0) n = 1;
        n += 2 * i * i;
    }
    return System.nanoTime() - startTime;
}

以下是结果:

Fast version: 5.7274411 s
Slow version: 7.6190804 s
Optimized version: 5.1348007 s
Modified fast version: 7.1492705 s
Modified slow version: 7.2952668 s

3
我会尽力进行翻译,以下是您需要翻译的内容: 我认为在优化版本中,应该是 n *= 2000000000; - StefansArya
4
不。考虑极限为4的情况,我们试图计算2*1*1 + 2*2*2 + 2*3*3。显然,计算1*1 + 2*2 + 3*3并乘以2是正确的,而乘以8则不是。 - Martin Bonner supports Monica
@MartinBonner - 我同意,我想有人几天前解释过,但现在他的评论已经消失了。无论如何,还是谢谢你的解释。 - StefansArya
5
这个数学方程是这样的 2(1²) + 2(2²) + 2(3²) = 2(1² + 2² + 3²)。很简单,我只是因为循环递增而忘记了它。 - StefansArya
6
如果你在使用调试JVM输出汇编代码时,这个结果似乎不正确。在循环中会看到一堆sall ... ,#1,它们是乘以2的操作。有趣的是,较慢的版本似乎在循环中没有乘法操作。 - Daniel Berlin
3
为什么JVM可以从2 * (i * i)中因式分解出2,但不能从(2 * i) * i中因式分解出2呢?我认为它们是等价的(这可能是我的错误假设)。如果是这样,JVM在优化之前不会规范化表达式吗? - RedSpikeyThing

41

字节码: https://cs.nyu.edu/courses/fall00/V22.0201-001/jvm2.html 字节码查看器: https://github.com/Konloch/bytecode-viewer

在我的JDK(Windows 10 64位,1.8.0_65-b17)上,我可以重现并解释:

public static void main(String[] args) {
    int repeat = 10;
    long A = 0;
    long B = 0;
    for (int i = 0; i < repeat; i++) {
        A += test();
        B += testB();
    }

    System.out.println(A / repeat + " ms");
    System.out.println(B / repeat + " ms");
}


private static long test() {
    int n = 0;
    for (int i = 0; i < 1000; i++) {
        n += multi(i);
    }
    long startTime = System.currentTimeMillis();
    for (int i = 0; i < 1000000000; i++) {
        n += multi(i);
    }
    long ms = (System.currentTimeMillis() - startTime);
    System.out.println(ms + " ms A " + n);
    return ms;
}


private static long testB() {
    int n = 0;
    for (int i = 0; i < 1000; i++) {
        n += multiB(i);
    }
    long startTime = System.currentTimeMillis();
    for (int i = 0; i < 1000000000; i++) {
        n += multiB(i);
    }
    long ms = (System.currentTimeMillis() - startTime);
    System.out.println(ms + " ms B " + n);
    return ms;
}

private static int multiB(int i) {
    return 2 * (i * i);
}

private static int multi(int i) {
    return 2 * i * i;
}

输出:

...
405 ms A 785527736
327 ms B 785527736
404 ms A 785527736
329 ms B 785527736
404 ms A 785527736
328 ms B 785527736
404 ms A 785527736
328 ms B 785527736
410 ms
333 ms

那么为什么呢? 这就是字节码:

 private static multiB(int arg0) { // 2 * (i * i)
     <localVar:index=0, name=i , desc=I, sig=null, start=L1, end=L2>

     L1 {
         iconst_2
         iload0
         iload0
         imul
         imul
         ireturn
     }
     L2 {
     }
 }

 private static multi(int arg0) { // 2 * i * i
     <localVar:index=0, name=i , desc=I, sig=null, start=L1, end=L2>

     L1 {
         iconst_2
         iload0
         imul
         iload0
         imul
         ireturn
     }
     L2 {
     }
 }

区别在于:

  • 用括号的(2 * (i * i)):
  • 将常量压入栈中
  • 将局部变量压入栈中
  • 将局部变量压入栈中
  • 将堆栈顶部相乘
  • 将堆栈顶部相乘

不用括号的(2 * i * i):

  • 将常量压入栈中
  • 将局部变量压入栈中
  • 将堆栈顶部相乘
  • 将局部变量压入栈中
  • 将堆栈顶部相乘

将所有内容加载到堆栈上,然后从堆栈顶端向下操作会比在压栈和操作之间切换更快。


2
但是为什么推-推-乘-乘比推-乘-推-乘更快呢? - m0skit0
1
事实上,答案并不是通过字节码来解释的,而是通过查看实际的JIT x86-64汇编代码来解释的。 对于具有更多寄存器的机器(例如AArch64或PowerPC),使用相同的16倍展开可能在其他ISA上没有任何差异,不像x86-64或可能是32位ARM。 在Java字节码中推动所有东西并向下工作并不固有地更快,或者至少这个问答并没有证明它。 在这种情况下,JIT编译器在一个情况下比另一个情况更容易出现问题。 - Peter Cordes

35

Kasperd在接受答案的评论中问道:

Java和C的示例使用了非常不同的寄存器名称。这两个示例都使用了AMD64 ISA吗?

xor edx, edx
xor eax, eax
.L2:
mov ecx, edx
imul ecx, edx
add edx, 1
lea eax, [rax+rcx*2]
cmp edx, 1000000000
jne .L2

我在评论区没有足够的声望来回答这个问题,但这些都是相同的ISA。值得指出的是,GCC版本使用32位整数逻辑,而JVM编译版本在内部使用64位整数逻辑。

R8到R15只是新的X86_64 寄存器。EAX到EDX是RAX到RDX通用寄存器的低位部分。回答中重要的部分是,GCC版本没有展开。它只执行实际机器代码循环的一轮。而JVM版本在一个物理循环中有16轮循环(基于rustyx的回答,我没有重新解释汇编)。这是使用更多寄存器的原因之一,因为循环体实际上长了16倍。


2
很遗憾,gcc没有注意到它可以将*2从循环中移出。尽管在这种情况下,这样做甚至不是一个胜利,因为它使用LEA免费完成了这项工作。在英特尔CPU上,lea eax,[rax + rcx * 2]add eax,ecx具有相同的1个时钟延迟。但是,在AMD CPU上,任何缩放的索引都会增加LEA延迟到2个周期。因此,循环传递的依赖链长度增加到2个周期,在Ryzen上成为瓶颈。(imul ecx,edx吞吐量在Ryzen和Intel上每个时钟为1)。 - Peter Cordes

31

虽然与问题的环境无直接关系,但为了满足好奇心,我在.NET Core 2.1、x64、发布模式下进行了相同的测试。

这里有一个有趣的结果,证实类似现象(反过来)也会发生在黑暗面的力量上。代码:

static void Main(string[] args)
{
    Stopwatch watch = new Stopwatch();

    Console.WriteLine("2 * (i * i)");

    for (int a = 0; a < 10; a++)
    {
        int n = 0;

        watch.Restart();

        for (int i = 0; i < 1000000000; i++)
        {
            n += 2 * (i * i);
        }

        watch.Stop();

        Console.WriteLine($"result:{n}, {watch.ElapsedMilliseconds} ms");
    }

    Console.WriteLine();
    Console.WriteLine("2 * i * i");

    for (int a = 0; a < 10; a++)
    {
        int n = 0;

        watch.Restart();

        for (int i = 0; i < 1000000000; i++)
        {
            n += 2 * i * i;
        }

        watch.Stop();

        Console.WriteLine($"result:{n}, {watch.ElapsedMilliseconds}ms");
    }
}

结果:

2 * (i * i)

  • 结果: 119860736,用时438 ms
  • 结果: 119860736,用时433 ms
  • 结果: 119860736,用时437 ms
  • 结果: 119860736,用时435 ms
  • 结果: 119860736,用时436 ms
  • 结果: 119860736,用时435 ms
  • 结果: 119860736,用时435 ms
  • 结果: 119860736,用时439 ms
  • 结果: 119860736,用时436 ms
  • 结果: 119860736,用时437 ms

2 * i * i

  • 结果: 119860736,用时417 ms
  • 结果: 119860736,用时417 ms
  • 结果: 119860736,用时417 ms
  • 结果: 119860736,用时418 ms
  • 结果: 119860736,用时418 ms
  • 结果: 119860736,用时417 ms
  • 结果: 119860736,用时418 ms
  • 结果: 119860736,用时416 ms
  • 结果: 119860736,用时417 ms
  • 结果: 119860736,用时418 ms

1
虽然这不是问题的答案,但它确实增加了价值。话虽如此,如果某些内容对您的帖子非常重要,请将其嵌入到帖子中,而不是链接到外部资源。链接会失效。 - Jared Smith
1
@JaredSmith 感谢您的反馈。考虑到您提到的链接是“结果”链接,那个图片不是外部来源。我通过 stackoverflow 的面板上传了它。 - Ünsal Ersöz
1
这是指向imgur的链接,所以是的,无论您如何添加链接都没有关系。我不明白复制粘贴一些控制台输出有什么难度。 - Jared Smith
5
除此之外,这是相反的情况。 - leppie
2
@SamB 它仍然在imgur.com域上,这意味着它只能存活与imgur同样长的时间。 - p91paul
显示剩余4条评论

20

我得到了类似的结果:

2 * (i * i): 0.458765943 s, n=119860736
2 * i * i: 0.580255126 s, n=119860736

如果两个循环都在同一程序中,或者每个循环在单独的.java文件/.class中执行,分别在单独的运行中,我会得到相同的结果。

最后,这是每个循环的javap -c -v <.java>反编译:

     3: ldc           #3                  // String 2 * (i * i):
     5: invokevirtual #4                  // Method java/io/PrintStream.print:(Ljava/lang/String;)V
     8: invokestatic  #5                  // Method java/lang/System.nanoTime:()J
     8: invokestatic  #5                  // Method java/lang/System.nanoTime:()J
    11: lstore_1
    12: iconst_0
    13: istore_3
    14: iconst_0
    15: istore        4
    17: iload         4
    19: ldc           #6                  // int 1000000000
    21: if_icmpge     40
    24: iload_3
    25: iconst_2
    26: iload         4
    28: iload         4
    30: imul
    31: imul
    32: iadd
    33: istore_3
    34: iinc          4, 1
    37: goto          17

对决。

     3: ldc           #3                  // String 2 * i * i:
     5: invokevirtual #4                  // Method java/io/PrintStream.print:(Ljava/lang/String;)V
     8: invokestatic  #5                  // Method java/lang/System.nanoTime:()J
    11: lstore_1
    12: iconst_0
    13: istore_3
    14: iconst_0
    15: istore        4
    17: iload         4
    19: ldc           #6                  // int 1000000000
    21: if_icmpge     40
    24: iload_3
    25: iconst_2
    26: iload         4
    28: imul
    29: iload         4
    31: imul
    32: iadd
    33: istore_3
    34: iinc          4, 1
    37: goto          17

供您参考 -

java -version
java version "1.8.0_121"
Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, mixed mode)

1
一个更好的答案,也许你可以投票以撤销删除 - https://dev59.com/TFQJ5IYBdhLWcg3wnXV-#53452836 ... 顺便提一下 - 我不是那个投反对票的人。 - Naman
@nullpointer - 我同意。如果可以的话,我肯定会投票撤销删除。我也想“双倍点赞”Stefan,因为他给出了“显著”的定量定义。 - paulsm4
那个被自行删除了,因为它测量的是错误的东西 - 请参见上面问题的作者的评论。 - Krease
有了JIT,字节码就不那么重要了。显示JIT代码。 - rustyx
2
获取一个调试版JRE,并使用-XX:+PrintOptoAssembly运行。或者直接使用VTune或类似工具。 - rustyx
1
@rustyx - 如果问题是JIT实现...那么“获取完全不同的JRE的调试版本”并不一定会有所帮助。尽管如此:根据您在JRE上的JIT反汇编上面发现,这也解释了OP的JRE和我的行为。并且也解释了为什么其他JRE的行为“不同”。+1:感谢您出色的侦探工作! - paulsm4

18

使用Java 11进行有趣的观察,并通过以下VM选项关闭循环展开:

-XX:LoopUnrollLimit=0

使用 2 * (i * i) 表达式的循环会产生更紧凑的本机代码1:

L0001: add    eax,r11d
       inc    r8d
       mov    r11d,r8d
       imul   r11d,r8d
       shl    r11d,1h
       cmp    r8d,r10d
       jl     L0001

2 * i * i版本相比:

L0001: add    eax,r11d
       mov    r11d,r8d
       shl    r11d,1h
       add    r11d,2h
       inc    r8d
       imul   r11d,r8d
       cmp    r8d,r10d
       jl     L0001

Java版本:

java version "11" 2018-09-25
Java(TM) SE Runtime Environment 18.9 (build 11+28)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11+28, mixed mode)

基准测试结果:

Benchmark          (size)  Mode  Cnt    Score     Error  Units
LoopTest.fast  1000000000  avgt    5  694,868 ±  36,470  ms/op
LoopTest.slow  1000000000  avgt    5  769,840 ± 135,006  ms/op

基准测试源代码:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@State(Scope.Thread)
@Fork(1)
public class LoopTest {

    @Param("1000000000") private int size;

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
            .include(LoopTest.class.getSimpleName())
            .jvmArgs("-XX:LoopUnrollLimit=0")
            .build();
        new Runner(opt).run();
    }

    @Benchmark
    public int slow() {
        int n = 0;
        for (int i = 0; i < size; i++)
            n += 2 * i * i;
        return n;
    }

    @Benchmark
    public int fast() {
        int n = 0;
        for (int i = 0; i < size; i++)
            n += 2 * (i * i);
        return n;
    }
}

1 - 使用的VM选项:-XX:+ UnlockDiagnosticVMOptions -XX:+ PrintAssembly -XX:LoopUnrollLimit = 0



2
哇,这是一些愚蠢的汇编代码。它在计算2*i之前没有对i进行递增,而是在之后进行递增,因此需要额外的add r11d,2指令。(此外,它错过了add same,same peephole,而不是shl 1( add可以在更多端口上运行)。如果它真的想按照某种疯狂的指令调度原因按顺序执行操作,它还错过了一个x*2 + 2的LEA peephole(lea r11d, [r8*2 + 2])。我们已经可以从展开的版本中看到,错过LEA已经花费了大量的uops,就像这里的两个循环一样。 - Peter Cordes
2
如果JIT编译器有时间在长时间运行的循环中寻找这种优化,那么lea eax, [rax + r11 * 2]将替换两个指令(在两个循环中)。任何像样的预编译器都会发现它。(除非只针对AMD进行调整,其中缩放索引LEA具有2个周期的延迟,因此可能不值得。) - Peter Cordes

15

我尝试了使用默认原型的JMH: 我还根据Runemoro的解释添加了一个优化版本。

@State(Scope.Benchmark)
@Warmup(iterations = 2)
@Fork(1)
@Measurement(iterations = 10)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
//@BenchmarkMode({ Mode.All })
@BenchmarkMode(Mode.AverageTime)
public class MyBenchmark {
  @Param({ "100", "1000", "1000000000" })
  private int size;

  @Benchmark
  public int two_square_i() {
    int n = 0;
    for (int i = 0; i < size; i++) {
      n += 2 * (i * i);
    }
    return n;
  }

  @Benchmark
  public int square_i_two() {
    int n = 0;
    for (int i = 0; i < size; i++) {
      n += i * i;
    }
    return 2*n;
  }

  @Benchmark
  public int two_i_() {
    int n = 0;
    for (int i = 0; i < size; i++) {
      n += 2 * i * i;
    }
    return n;
  }
}

结果在这里:

Benchmark                           (size)  Mode  Samples          Score   Score error  Units
o.s.MyBenchmark.square_i_two           100  avgt       10         58,062         1,410  ns/op
o.s.MyBenchmark.square_i_two          1000  avgt       10        547,393        12,851  ns/op
o.s.MyBenchmark.square_i_two    1000000000  avgt       10  540343681,267  16795210,324  ns/op
o.s.MyBenchmark.two_i_                 100  avgt       10         87,491         2,004  ns/op
o.s.MyBenchmark.two_i_                1000  avgt       10       1015,388        30,313  ns/op
o.s.MyBenchmark.two_i_          1000000000  avgt       10  967100076,600  24929570,556  ns/op
o.s.MyBenchmark.two_square_i           100  avgt       10         70,715         2,107  ns/op
o.s.MyBenchmark.two_square_i          1000  avgt       10        686,977        24,613  ns/op
o.s.MyBenchmark.two_square_i    1000000000  avgt       10  652736811,450  27015580,488  ns/op

在我的电脑上(Core i7 860 - 它除了在我的智能手机上阅读之外没有做太多事情):

  • n += i*i 然后 n*2 是第一个结果
  • 2 * (i * i) 是第二个结果。

JVM 显然不像人一样优化(基于 Runemoro 的回答)。

现在,来看一下字节码:javap -c -v ./target/classes/org/sample/MyBenchmark.class

我不是字节码专家,但我们在 imul 之前加载了 iload_2:这可能就是你得到差异的地方:我可以推测 JVM 优化了两次读取的 ii 已经存在,没有必要再次加载),而在 2*i*i 中它不能这样做。


4
据我所知,字节码对性能来说并不是非常重要的,我不会试图根据它来估计什么更快。它只是JIT编译器的源代码...当然,改变源代码行的顺序可能会影响生成的代码和效率,但这一切都是相当不可预测的。 - maaartinus

13

更像是一个附注。我使用IBM最新的Java 8虚拟机成功地进行了实验重现:

java version "1.8.0_191"
Java(TM) 2 Runtime Environment, Standard Edition (IBM build 1.8.0_191-b12 26_Oct_2018_18_45 Mac OS X x64(SR5 FP25))
Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)

这显示出非常相似的结果:

0.374653912 s
n = 119860736
0.447778698 s
n = 119860736

(第二个结果使用 2 * i * i)。

有趣的是,在同一台机器上运行,但使用 Oracle Java:

Java version "1.8.0_181"
Java(TM) SE Runtime Environment (build 1.8.0_181-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.181-b13, mixed mode)

结果平均会慢一些:

0.414331815 s
n = 119860736
0.491430656 s
n = 119860736

长话短说:在这里,HotSpot的次要版本号甚至也很重要,因为JIT实现中的细微差别可能会产生显着影响。


6
添加的两种方法确实会生成略有不同的字节码:
  17: iconst_2
  18: iload         4
  20: iload         4
  22: imul
  23: imul
  24: iadd

对于 2 * (i * i) 和:

  17: iconst_2
  18: iload         4
  20: imul
  21: iload         4
  23: imul
  24: iadd

关于 2 * i * i

当使用像这样的JMH基准测试时:

@Warmup(iterations = 5, batchSize = 1)
@Measurement(iterations = 5, batchSize = 1)
@Fork(1)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
public class MyBenchmark {

    @Benchmark
    public int noBrackets() {
        int n = 0;
        for (int i = 0; i < 1000000000; i++) {
            n += 2 * i * i;
        }
        return n;
    }

    @Benchmark
    public int brackets() {
        int n = 0;
        for (int i = 0; i < 1000000000; i++) {
            n += 2 * (i * i);
        }
        return n;
    }

}

区别很明显:
# JMH version: 1.21
# VM version: JDK 11, Java HotSpot(TM) 64-Bit Server VM, 11+28
# VM options: <none>

Benchmark                      (n)  Mode  Cnt    Score    Error  Units
MyBenchmark.brackets    1000000000  avgt    5  380.889 ± 58.011  ms/op
MyBenchmark.noBrackets  1000000000  avgt    5  512.464 ± 11.098  ms/op

您观察到的是正确的,而不仅仅是您基准测试方式的异常情况(即没有预热,请参见如何编写正确的Java微基准测试?)。

使用Graal再次运行:

# JMH version: 1.21
# VM version: JDK 11, Java HotSpot(TM) 64-Bit Server VM, 11+28
# VM options: -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler

Benchmark                      (n)  Mode  Cnt    Score    Error  Units
MyBenchmark.brackets    1000000000  avgt    5  335.100 ± 23.085  ms/op
MyBenchmark.noBrackets  1000000000  avgt    5  331.163 ± 50.670  ms/op

您可以看到结果更加接近,这是有道理的,因为Graal编译器性能更好,更现代化。

所以这只取决于JIT编译器优化特定代码的能力,没有必然的逻辑原因。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接