为什么 Java 中 2*(i*i) 比 2*i*i 更快?
有人在 Stack Overflow 上提问,为什么 Java 中的 2 * (i * i) 比 2 * i * i 要快?
他做了如下测试:
运行下面这段Java代码平均需要0.50到0.55秒:
如果把2 *(i * i)替换成2 * i * i,那么运行时间在0.60到0.65秒之间。为什么出现这样的结果?
我把程序的每个版本运行了15次,两次之间交替运行。结果如下:
2 * i * i的最快运行时间比2 * (i * i)最慢运行时间还要长。如果两者效率相当,发生这种情况的可能性小于1/2^15 * 100% = 0.00305%。
来自 rustyx 的回答,获得 1172 赞同
两种方式的字节码顺序略有不同。
2 * (i * i):
对比2 * i * i:
乍看之下没有什么不同,如果有的话,第二个版本看起来少了一个slot。
因此,需要更深入研究底层(JIT)。
请记住,对小循环JIT会主动展开。对2 * (i * i)可以看到实际展开了16x:
从上面的代码可以看到,有1个寄存器被“spill”到了整个堆栈。
对于2 * i * i版本:
出于保存中间结果的需要,这里出现了更多的“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结果):
运行时间:
SSE:0.24 s,大约快2倍。
AVX:0.15 s,大约快3倍。
AVX2:0.08 s,大约快5倍。
要输出JIT生成的程序集,请获取JVM调试版本,并使用-XX:+ PrintOptoAssembly运行。
C程序版本使用-fwrapv标志进行编译,该标志使GCC可以将带符号整数溢出视为二进制补码。
翻译: 唐尤华
stackoverflow.com/questions/53452713/why-is-2-i-i-faster-than-2-i-i-in-java
本文转载自微信公众号朱小厮的博客
- 点赞
- 收藏
- 关注作者
评论(0)