回複”666“擷取公衆号專屬資料
有人在 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
想知道更多?掃描下面的二維碼關注我