为什么 Java 中 2*(i*i) 比 2*i*i 更快?
(给ImportNew加星标,提高Java技能)
编译:ImportNew/唐尤华
stackoverflow.com/questions/53452713/why-is-2-i-i-faster-than-2-i-i-in-java
有人在 Stack Overflow 上提问,为什么 Java 中的 2 * (i * i) 比 2 * i * i 要快?
他做了如下测试:
运行下面这段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%。
来自 rustyx 的回答,获得 1172 赞同
两种方式的字节码顺序略有不同。
2 * (i * i):
iconst_2
iload0
iload0
imul
imul
iadd
对比2 * i * i:
iconst_2
iload0
imul
iload0
imul
iadd
乍看之下没有什么不同,如果有的话,第二个版本看起来少了一个slot。
因此,需要更深入研究底层(JIT)。
请记住,对小循环JIT会主动展开。对2 * (i * i)可以看到实际展开了16x:
030 B2: # B2 B3 <- B1 B2 Loop: B2-B2 inner main of N18 Freq: 1e+006
030 addl R11, RBP
033 movl RBP, R13
036 addl RBP, #14
039 imull RBP, RBP
03c movl R9, R13
03f addl R9, #13
043 imull R9, R9
047 sall RBP, #1
049 sall R9, #1
04c movl R8, R13
04f addl R8, #15
053 movl R10, R8
056 movdl XMM1, R8
05b imull R10, R8
05f movl R8, R13
062 addl R8, #12
066 imull R8, R8
06a sall R10, #1
06d movl [rsp + #32], R10
072 sall R8, #1
075 movl RBX, R13
078 addl RBX, #11
07b imull RBX, RBX
07e movl RCX, R13
081 addl RCX, #10
084 imull RCX, RCX
087 sall RBX, #1
089 sall RCX, #1
08b movl RDX, R13
08e addl RDX, #8
091 imull RDX, RDX
094 movl RDI, R13
097 addl RDI, #7
09a imull RDI, RDI
09d sall RDX, #1
09f sall RDI, #1
0a1 movl RAX, R13
0a4 addl RAX, #6
0a7 imull RAX, RAX
0aa movl RSI, R13
0ad addl RSI, #4
0b0 imull RSI, RSI
0b3 sall RAX, #1
0b5 sall RSI, #1
0b7 movl R10, R13
0ba addl R10, #2
0be imull R10, R10
0c2 movl R14, R13
0c5 incl R14
0c8 imull R14, R14
0cc sall R10, #1
0cf sall R14, #1
0d2 addl R14, R11
0d5 addl R14, R10
0d8 movl R10, R13
0db addl R10, #3
0df imull R10, R10
0e3 movl R11, R13
0e6 addl R11, #5
0ea imull R11, R11
0ee sall R10, #1
0f1 addl R10, R14
0f4 addl R10, RSI
0f7 sall R11, #1
0fa addl R11, R10
0fd addl R11, RAX
100 addl R11, RDI
103 addl R11, RDX
106 movl R10, R13
109 addl R10, #9
10d imull R10, R10
111 sall R10, #1
114 addl R10, R11
117 addl R10, RCX
11a addl R10, RBX
11d addl R10, R8
120 addl R9, R10
123 addl RBP, R9
126 addl RBP, [RSP + #32 (32-bit)]
12a addl R13, #16
12e movl R11, R13
131 imull R11, R13
135 sall R11, #1
138 cmpl R13, #999999985
13f jl B2
从上面的代码可以看到,有1个寄存器被“spill”到了整个堆栈。
对于2 * i * i版本:
05a B3: # B2 B4 <- B1 B2 Loop: B3-B2 inner main of N18 Freq: 1e+006
05a addl RBX, R11
05d movl [rsp + #32], RBX
061 movl R11, R8
064 addl R11, #15
068 movl [rsp + #36], R11
06d movl R11, R8
070 addl R11, #14
074 movl R10, R9
077 addl R10, #16
07b movdl XMM2, R10
080 movl RCX, R9
083 addl RCX, #14
086 movdl XMM1, RCX
08a movl R10, R9
08d addl R10, #12
091 movdl XMM4, R10
096 movl RCX, R9
099 addl RCX, #10
09c movdl XMM6, RCX
0a0 movl RBX, R9
0a3 addl RBX, #8
0a6 movl RCX, R9
0a9 addl RCX, #6
0ac movl RDX, R9
0af addl RDX, #4
0b2 addl R9, #2
0b6 movl R10, R14
0b9 addl R10, #22
0bd movdl XMM3, R10
0c2 movl RDI, R14
0c5 addl RDI, #20
0c8 movl RAX, R14
0cb addl RAX, #32
0ce movl RSI, R14
0d1 addl RSI, #18
0d4 movl R13, R14
0d7 addl R13, #24
0db movl R10, R14
0de addl R10, #26
0e2 movl [rsp + #40], R10
0e7 movl RBP, R14
0ea addl RBP, #28
0ed imull RBP, R11
0f1 addl R14, #30
0f5 imull R14, [RSP + #36 (32-bit)]
0fb movl R10, R8
0fe addl R10, #11
102 movdl R11, XMM3
107 imull R11, R10
10b movl [rsp + #44], R11
110 movl R10, R8
113 addl R10, #10
117 imull RDI, R10
11b movl R11, R8
11e addl R11, #8
122 movdl R10, XMM2
127 imull R10, R11
12b movl [rsp + #48], R10
130 movl R10, R8
133 addl R10, #7
137 movdl R11, XMM1
13c imull R11, R10
140 movl [rsp + #52], R11
145 movl R11, R8
148 addl R11, #6
14c movdl R10, XMM4
151 imull R10, R11
155 movl [rsp + #56], R10
15a movl R10, R8
15d addl R10, #5
161 movdl R11, XMM6
166 imull R11, R10
16a movl [rsp + #60], R11
16f movl R11, R8
172 addl R11, #4
176 imull RBX, R11
17a movl R11, R8
17d addl R11, #3
181 imull RCX, R11
185 movl R10, R8
188 addl R10, #2
18c imull RDX, R10
190 movl R11, R8
193 incl R11
196 imull R9, R11
19a addl R9, [RSP + #32 (32-bit)]
19f addl R9, RDX
1a2 addl R9, RCX
1a5 addl R9, RBX
1a8 addl R9, [RSP + #60 (32-bit)]
1ad addl R9, [RSP + #56 (32-bit)]
1b2 addl R9, [RSP + #52 (32-bit)]
1b7 addl R9, [RSP + #48 (32-bit)]
1bc movl R10, R8
1bf addl R10, #9
1c3 imull R10, RSI
1c7 addl R10, R9
1ca addl R10, RDI
1cd addl R10, [RSP + #44 (32-bit)]
1d2 movl R11, R8
1d5 addl R11, #12
1d9 imull R13, R11
1dd addl R13, R10
1e0 movl R10, R8
1e3 addl R10, #13
1e7 imull R10, [RSP + #40 (32-bit)]
1ed addl R10, R13
1f0 addl RBP, R10
1f3 addl R14, RBP
1f6 movl R10, R8
1f9 addl R10, #16
1fd cmpl R10, #999999985
204 jl B2
出于保存中间结果的需要,这里出现了更多的“spill”及堆栈[RSP + ...]访问。
问题的答案很简单:2 *(i * i)比2 * i * i更快,因为针对前者JIT生成的汇编代码更优化。
但是,显然这两个版本都不够好。由于x86-64 CPU都至少支持SSE2,因此循环可以从向量化中受益。
因此,这是optimizer的问题:通常循环过度展开会带来问题,错失其他优化机会。
实际上,现代x86-64 CPU会把指令进一步细分为微操作(µops)。循环优化可以借助寄存器重命名、µop缓存和循环缓冲区等众多特性,而不是仅仅做一次展开。根据Agner Fog的优化指南:
如果平均指令长度超过4字节,由于µop缓存而导致的性能提升会非常可观。可以考虑下列方法优化µop缓存:
确保关键循环足够小以适应µop缓存。
将最关键的循环条目和功能条目以32对齐。
避免不必要的循环展开。
避免使用需要额外加载时间的指令:..
考虑到加载时间:即使命中最快的L1D也要花费4个周期,需要一个额外的寄存器和µop。只要对存储器访问,哪怕几次也会损害循环的性能。
再考虑矢量化方案:要了解优化能达到多快,可以使用GCC编译类似的C应用程序,直接对其进行矢量化(下面展示了AVX2、SSE2结果):
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 s,大约快2倍。
AVX:0.15 s,大约快3倍。
AVX2:0.08 s,大约快5倍。
要输出JIT生成的程序集,请获取JVM调试版本,并使用-XX:+ PrintOptoAssembly运行。
C程序版本使用-fwrapv标志进行编译,该标志使GCC可以将带符号整数溢出视为二进制补码。
推荐阅读
(点击标题可跳转阅读)
看完本文有收获?请转发分享给更多人
关注「ImportNew」,提升Java技能
好文章,我在看❤️