灵魂拷问:用移位来代替除法运算真的效率高吗?Java 编译器到底有没有做除法优化?
目錄
- 引入
- C++ 編譯器對除法的優化
- Java 編譯器對除法的優化
- 移位運算對應的字節碼
- 除法操作對應的字節碼
- 查看及分析 JIT 即時編譯結果
- 1、手動編譯 OpenJDK
- 2、編譯 hsdis-amd64.so 反匯編適配器
- 3、編寫測試代碼
- 4、javac 編譯 & JIT 即時編譯
- 補充
- 本文不足
引入
我們知道,相比移位運算,除法運算的開銷比較大。
在 Leetcode 做題的時候,當除數是 2 的 n 次冪時,大家通常會用移位來代替除法運算,并且希望這樣能夠提升運算效率——這似乎已成為了算法題解中的“標配”。
那么問題來了,使用移位操作代替乘除運算,真的效率更高嗎?
對于這個靈魂拷問,本文 分別以 C++ / Java 為例,從匯編層面帶你一探究竟。
C++ 編譯器對除法的優化
在 Optimizations in C++ Compilers [中文譯文在此] 這篇文章中,作者建議不要在代碼中用 >> 代替除法運算,原因有兩個。一方面,編譯器已經幫你做了這個優化,能夠幫你正確處理符號;另一方面,我們希望代碼的可讀性更高。
Integer division by a constant
It may be surprising to learn that—until very recently—about the most expensive thing you could do on a modern CPU is an integer divide. Division is more than 50 times more expensive than addition and more than 10 times more expensive than multiplication. (This was true until Intel’s release of the Cannon Lake microarchitecture, where the maximum latency of a 64-bit divide was reduced from 96 cycles to 18.6 This is only around 20 times slower than an addition, and 5 times more expensive than multiplication.)
Thankfully, compiler authors have some strength reduction tricks up their sleeves when it comes to division by a constant. I’m sure we’ve all realized that division by a power of two can often be replaced by a logical shift right—rest assured the compiler will do this for you. I would advise not writing a >> in your code to do division; let the compiler work it out for you. It’s clearer, and the compiler also knows how to account properly for signed values: integer division truncates toward zero, and shifting down by itself truncates toward negative infinity.
來,舉個例子,方便理解。先寫一段 C++ 代碼用來測試:
// test.cpp #include <stdio.h> int main(void){int a = 64;int b = a >> 3;int c = a / 8;return 0; }然后編譯成匯編代碼:
gcc -S test.cpp cat test.cpp由于篇幅原因,我們只截取部分匯編結果,來看一看生成的匯編碼是啥樣的。
movl $64, -12(%rbp) # a=64 movl -12(%rbp), %eax sarl $3, %eax # a>>3 movl %eax, -8(%rbp) movl -12(%rbp), %eax leal 7(%rax), %edx testl %eax, %eax cmovs %edx, %eax sarl $3, %eax # a/8 優化后等同于 a>>3 movl %eax, -4(%rbp) movl $0, %eax popq %rbp .cfi_def_cfa 7, 8 ret .cfi_endproc可以看到,a/8 經過編譯器優化后,是等同于 a>>3 的。簡而言之,我們可以依靠編譯器來很好地優化除法。
況且編譯器對于除法的優化不僅限于除數為 2 的 n 次冪的情況,例如對于 x/3 這樣的運算,編譯器將除法替換為了更廉價的定點乘法逆運算,本文不再詳述。
如果感興趣的話,仍然可以參考 Optimizations in C++ Compilers 這篇文章,里面有詳細的講解。
Java 編譯器對除法的優化
盡管上面這個例子只是眾多優化中的一點皮毛,但已經能讓我們感受到 C++ 編譯器的強大之處了。如此看來,在 C++ 編譯器的優化下,我們現在很難再通過位移操作來榨取乘除運算的性能了。
然后回到我們文章最開始的靈魂拷問,使用移位操作代替乘除運算,真的效率更高嗎??
從上面這個例子來看,并沒有。可我不甘心啊——既然有人這么寫,就一定是有它的道理的!于是我把希望寄托在 Java 編譯器上,希望 javac 沒有做過這個優化。
話不多說,直接上例子。我們在兩個方法 test1, test2 中分別使用 i >> 3 運算與 i / 8 運算,比較二者的運行時間以及編譯生成的字節碼。
public class Solution {public int test1() {int sum = 0;for (int i = 0; i < 1000000000; i++) {sum += i >> 3;}return sum;}public int test2() {int sum = 0;for (int i = 0; i < 1000000000; i++) {sum += i / 8;}return sum;}public static void main(String[] args) {Solution solution = new Solution();long start, end;for (int i = 0; i < 10; i++) {System.out.println("=== loop " + i+" ===");start = System.currentTimeMillis();solution.test1();end = System.currentTimeMillis();System.out.println(end - start);start = System.currentTimeMillis();solution.test2();end = System.currentTimeMillis();System.out.println(end - start);}} }從下面的運行時間上來看,在前兩個 loop 中,test1 明顯比 test2 效率高,但在后面 8 個 loop 中,兩種方法的耗時幾乎沒有區別。
=== loop 0 === 348 626 === loop 1 === 266 492 === loop 2 === 225 242 === loop 3 === 227 245 === loop 4 === 253 227 === loop 5 === 233 227 === loop 6 === 236 244 === loop 7 === 236 232 === loop 8 === 255 235 === loop 9 === 271 300在查看字節碼時,我們用到了一個 IDEA 插件叫 Jclasslib。在 Settings -> Plugin 里搜一下即可安裝。
然后,我們來見證奇跡,看看經過編譯之后生成的字節碼。
移位運算對應的字節碼
除法操作對應的字節碼
從字節碼上來看,Java 編譯器是沒有對除法做優化的。移位是 ishr,除法是 idiv。然后虛擬機對這些字節碼進行解釋執行,然后就沒有然后了。
真的就沒有然后了嗎?Java 在優化上做的這么挫嗎?看到這里,你是不是有一種想要優化 javac 的沖動?如果真是這樣的話,后 8 次循環中兩方法耗時相同怎么解釋呢?…
直到我看到了這段話:
因為Javac這類前端編譯器對代碼的運行效率幾乎沒有任何優化措施可言(在JDK 1.3之后,Javac的-O優化參數就不再有意義),哪怕是編譯器真的采取了優化措施也不會產生什么實質的效果。因為Java虛擬機設計團隊選擇把對性能的優化全部集中到運行期的即時編譯器中,這樣可以讓那些不是由Javac產生的Class文件(如JRuby、Groovy等語言的Class文件)也同樣能享受到編譯器優化措施所帶來的性能紅利。但是,如果把“優化”的定義放寬,把對開發階段的優化也計算進來的話,Javac確實是做了許多針對Java語言編碼過程的優化措施來降低程序員的編碼復雜度、提高編碼效率。相當多新生的Java語法特性,都是靠編譯器的“語法糖”來實現,而不是依賴字節碼或者Java虛擬機的底層改進來支持。我們可以這樣認為,Java中即時編譯器在運行期的優化過程,支撐了程序執行效率的不斷提升;而前端編譯器在編譯期的優化過程,則是支撐著程序員的編碼效率和語言使用者的幸福感的提高。
—— 摘自《深入理解Java虛擬機 第10章 前端編譯與優化》
哦!要想知道 Java 究竟有沒有做除法優化,我們需要去看 JIT 的編譯產物!
查看及分析 JIT 即時編譯結果
一般來說,虛擬機的即時編譯過程對用戶程序是完全透明的,我們也不需要知道。但如果一定要看,當然是有辦法的。
步驟比較復雜,網上教程也有不少坑,讓我們慢慢道來~
1、手動編譯 OpenJDK
本文使用的部分運行參數輸出反匯編信息需要 FastDebug 或者 SlowDebug 優化的虛擬機才能支持,所以首先要編譯自己的 JDK。
OpenJDK 官網上的速度太慢了,可以從我的 gitee 直接 clone,然后根據里面的 doc/building.html 文檔進行編譯。
需要提前安裝一些依賴:
sudo apt-get update sudo apt-get install build-essential sudo apt-get install libfreetype6-dev sudo apt-get install libcups2-dev sudo apt-get install libx11-dev libxext-dev libxrender-dev libxrandr-dev libxtst-dev libxt-dev sudo apt-get install libasound2-dev sudo apt-get install libffi-dev sudo apt-get install autoconf sudo apt-get install openjdk-11-jdk # 自舉,需要安裝前一版本JDK,相當于“先有雞還是先有蛋”問題裝好依賴之后,cd 到剛下載好的 OpenJDK12 目錄,先初始化配置
bash configure --enable-debug --disable-warnings-as-errors然后編譯
make images在我的 6c 16g 機器上,完成編譯需要 15 分鐘左右,喝水等一下吧。
編完之后,完整的編譯結果在 OpenJDK12/build/配置名稱/jdk 目錄下,把它加到環境變量里,就可以作為一個完整的 JDK 使用啦。
# 環境變量示例 export JAVA_HOME=/home/hanquan/OpenJDK12/build/linux-x86_64-server-fastdebug/jdk export PATH=$JAVA_HOME/bin:$PATH2、編譯 hsdis-amd64.so 反匯編適配器
OpenJDK 源碼中已經包含了 hsdis 工具,但是需要我們自己編譯。
hsdis 源碼在 OpenJDK12/src/utils/hsdis 目錄下,里面有個 README,直接根據這個文檔來編就可以了(網上教程都不好使)。
里面說需要用到一個工具 binutils,直接從它給你的鏈接就可以下載,或者使用 清華鏡像,注意要和 README 里面的版本匹配。解壓后,放在你喜歡的地方就好了,后面可以顯示指定路徑。
顯示指定 binutils 的路徑,然后 編譯 hsdis:
make BINUTILS=/home/hanquan/OpenJDK12/build/binutils-2.30 ARCH=amd64編譯完成之后,會生成一個 build 目錄。
編譯產物叫 hsdis-amd64.so,找到它,復制到和 libjvm.so 相同的目錄下(一般會在OpenJDK12/build/linux-x86_64-server-fastdebug/jdk/lib/server),就可以被虛擬機自動找到了。
3、編寫測試代碼
至此,工具都準備好了,后面就是常規操作。
測試代碼中,為了觸發 JIT 的即時編譯,所以寫了兩個循環。重點還是看其中的移位和除法操作。
public class Solution {public int test1() {int sum = 0;for (int i = 0; i < 1000000000; i++) {sum += i >> 3;}return sum;}public int test2() {int sum = 0;for (int i = 0; i < 1000000000; i++) {sum += i / 8;}return sum;}public static void main(String[] args) {Solution solution = new Solution();long start, end;for (int i = 0; i < 10; i++) {solution.test1();solution.test2();}} }4、javac 編譯 & JIT 即時編譯
終于到了關鍵一步,這一步就能看到 JIT 即時編譯的匯編代碼了。
# 編譯得到字節碼 javac Solution.java # 使用參數 -XX:CompileCommand=compileonly,<類名>.<方法名> 這樣可以只編譯指定類的指定方法 java -XX:+PrintAssembly -XX:CompileCommand=compileonly,Solution.test* Solution > assembly.log輸出被存到了 assembly.log 中,這就是我們一直想看的 JIT 即時編譯的匯編代碼。
來,看看它長啥樣,到底有沒有做除法優化。
好吧,Java 誠不我欺,在 JIT 的即時編譯階段是做了除法優化的,具體是,將除法運算改為了移位運算。
另外,如上圖所示,也看到了一些其他的優化,比如循環展開,本文就不細講了。
補充
上面我們使用的 Java 測試代碼,10 次循環中消耗的時間并不相同。
在 前兩次循環 中,test2 耗時明顯較長,我們猜測是由于短時間內 JIT 未完成編譯,虛擬機在編譯器還未完成編譯之前,仍然按照解釋方式繼續執行代碼,所以前兩個 loop 的這段時間仍然是解釋執行的過程。從 loop2 開始,編譯完成了,才開始執行編譯器輸出的本地代碼。
本文不足
我們的結論是,盡管 javac 前端編譯對字節碼的優化幾乎為 0,但在 JIT 即時編譯的時候,確實進行了一些優化,這其中包括對除法的優化。
JIT 中的 Server Compiler 模式是一個充分優化過的高級編譯器,幾乎能達到 GNU C++編譯器使用-O2 參數時的優化強度,它會執行所有的經典的優化動作,如無用代碼消除(Dead Code Elimination)、循環展開(Loop Unrolling)、循環表達式外提(Loop Expression Hoisting)、消除公共子表達式(Common Subexpression Elimination)、常量傳播(Constant Propagation)、基本塊沖排序(Basic Block Reordering)等,還會實施一些與Java 語言特性密切相關的優化技術,如范圍檢查消除(Range Check Elimination)、空值檢查消除(Null Check Elimination ,不過并非所有的空值檢查消除都是依賴編譯器優化的,有一些是在代碼運行過程中自動優化 了)等。另外,還可能根據解釋器或Client Compiler提供的性能監控信息,進行一些不穩定的激進優化,如守護內聯(Guarded Inlining)、分支頻率預測(Branch Frequency Prediction)等。
—— 摘自《深入理解Java虛擬機 第11章 后端編譯與優化》
最后要說的是,為了觸發即時編譯,寫測試樣例的時候,我們 只在循環中 使用了除法,并沒有對 除法獨立出現 的情況進行測試。這將是本文可以完善的一個方向。
感謝閱讀 ~ 筆者才疏學淺,如有不足之處,請多多指教,歡迎評論區探討。
總結
以上是生活随笔為你收集整理的灵魂拷问:用移位来代替除法运算真的效率高吗?Java 编译器到底有没有做除法优化?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Squash my last X com
- 下一篇: leetcode 658. Find K