1. 值編號
我們知道C1內部使用的是一種圖結構的HIR,它由基本塊構成一個圖,然后每個基本塊里面是SSA形式的指令,關於這點如可以參考[Inside HotSpot] C1編譯器工作流程及中間表示。值編號(Value numbering)是指為每個計算得到的值分配一個獨一無二的編號,然后遍歷指令尋找可優化的機會。比如下面的代碼:
a = 1;b=4;
c = a+b;
d = a+b;
e = b;
編譯器可以在計算a的時候為它指定一個hash值(0x12a3e)然后放入hash表;b同理指定0xf23de放入;遇到a+b時需要為a+b這整個指令計算一個hash值,比如可以定義+為1,然后hash(a+b) = hash(a)+1+hash(b),算法取決於實現。現在a+b的hash值為0xe52ba;當遇到第三行代碼d=a+b時,編譯器查表發現a+b已經計算過了,可以直接使用計算過的值,而不需要再次計算a+b;最后e也是查表發現b的存在而復用。

值編號的好處最明顯的就是公共子表達式的消除,比如上面例子的a+b,其它還有常量替換,如果hash(a)發現hash表里面是常量,那么后面對a的使用可以直接替換為1。以及代數恆等式的消除。
前面說了值編號,那么C1使用的全局值編號(Global value numbering,GVN)是在多個基本塊里面進行值編號,這樣可以擴大優化范圍,比如基本塊A里面有a+b,隔着很遠的基本塊F里面也有a+b,GVN就可以消除該公共子表達式,要注意的坑是全局值編號的"全局"表示一個方法內的多個基本塊,而不是編程語言里通常說的跨越方法的全局。與之相對的還有局部值編號(Local value numbering,LVN),它是指在一個基本塊里面發現優化時機,這一步發生在C1編譯器構建原始HIR的過程中。
2. C1編譯器的全局值編號
HotSpot的全局值編號優化位於hotspot\share\c1\c1_ValueMap.cpp,它除了完成本職工作外還順帶做了短循環優化和循環不變代碼外提。使用虛擬機標志-XX:+UseGlobalValueNumbering可開啟GVN(默認開啟),另外如果虛擬機是fastdebug版本,還可以加上-XX:+PrintValueNumbering -XX:+PrintLIRWithAssembly -XX:+PrintIR查看C1編譯器內部GVN的詳細流程。
3. 示例:公共子表達式消除(成功)
package com.github.kelthuzadx;
public class C1Optimizations {
public static int gvn(int invariant, int num){
int adder = invariant+8;
while (num<100){
num=invariant+8;
}
return num+adder;
}
public static void main(String[] args) {
gvn(10,1024);
}
}
gvn()函數里面invariant+8出現了兩次,這樣的公共表達式正是GVN大展身手的好地方,先關閉GVN(-XX:-UseGlovalValueNumbering)看看機器代碼:
mov %eax,-0x9000(%rsp)
push %rbp
sub $0x30,%rsp
mov %rdx,%rax ; adder=invariant
add $0x8,%eax ; adder+=8
jmpq _Loop
nop
_Loop
mov %rdx,%rsi ; tmp = invariant
add $0x8,%esi ; tmp+=8
add %r8d,%esi ; tmp+=num
mov 0x120(%r15),%r10 ; 安全點
test %eax,(%r10) ; 輪詢
mov %rsi,%r8 ; num = tmp
cmp $0x64,%r8d ; if num<100
jl _Loop
add %eax,%r8d
mov %r8,%rax
add $0x30,%rsp
pop %rbp
mov 0x120(%r15),%r10
test %eax,(%r10)
retq
公共子表達式沒有消除,循環里面創建了臨時變量tmp並重復計算invariant+8。然后開啟GVN( -XX:-UseGlobalValueNumbering):
mov %eax,-0x9000(%rsp)
push %rbp
sub $0x30,%rsp
mov %rdx,%rax ; adder=invariant
add $0x8,%eax ; adder+=8
jmpq _Loop
nop
_Loop:
add %eax,%r8d ; num+=adder
mov 0x120(%r15),%r10 ; 安全點
test %eax,(%r10) ; 輪詢
cmp $0x64,%r8d ; if num<100
jl _Loop
add %eax,%r8d ; num+=adder
mov %r8,%rax ; ret_value = num
add $0x30,%rsp
pop %rbp
mov 0x120(%r15),%r10
test %eax,(%r10)
retq
循環中檢測到invariant+8是公共子表達式,已經計算過值,所以直接復用num+=adder。
4. 示例:代數恆等式變換(失敗)
還是之前的例子,我們增加一些數學恆等式:
public static int gvn(int invariant, int num){
int adder = invariant+8;
while (num<100){
num+=invariant+8;
num*=1;
num/=1;
num+=0;
num-=0;
}
return num+adder;
}
HotSpot的GVN沒有進行代數恆等式的變換,無論是否開啟GVN都會產出對應的代碼:
_Loop
add %edi,%r8d
shl $0x0,%r8d
mov %r8,%rax
mov $0x1,%ebx
cmp $0x80000000,%eax
jne 0x000002ee006b9226
xor %edx,%edx
cmp $0xffffffff,%ebx
je 0x000002ee006b9229
cltd
idiv %ebx
mov 0x120(%r15),%r10
test %eax,(%r10)
mov %rax,%r8
cmp $0x64,%r8d
jl _Loop
相比之下g++ 8.0和clang++ 8.0在-O1優化強度上消除了多余的恆等式:
// g++ 8.0
gvn(int, int):
lea eax, [rdi+8]
cmp esi, 99
jg .L2
.L3:
add esi, eax
cmp esi, 99
jle .L3
.L2:
add eax, esi
ret
// clang++ 8.0
gvn(int, int): # @gvn(int, int)
mov eax, esi
mov ecx, -8
sub ecx, edi
add edi, 8
.LBB0_1: # =>This Inner Loop Header: Depth=1
add eax, edi
lea edx, [rcx + rax]
cmp edx, 100
jl .LBB0_1
ret
所以寫Java的時候遇到恆等式(很少情況)如果可以請手動消除。
5. 循環不變代碼外提(成功但受限)
循環不變代碼外提(Loop Invariant Code Motion)很好理解,如果循環內某個值不會發生改變,那么不必每次都做計算,可以提到循環外面。但是循環不變代碼外提優化有個嚴重的問題,它僅在關閉分層編譯模式(-XX:-TieredCompilation)下才能進行。。。
public static int loopInvariantCodeMotion(int invariant, int num){
for(int i=0;i<invariant*8+10;i++){
num+=i;
}
return num;
}
關閉分層編譯得到產出如下:
mov %eax,-0x9000(%rsp)
push %rbp
sub $0x30,%rsp
mov %rdx,%rax ; tmp = invariant
shl $0x3,%eax ; tmp*=8;
add $0xa,%eax ; tmp+= 10
mov $0x0,%esi ; i=0
jmpq _Cond
nop
_Loop
add %esi,%r8d ; num+=i
inc %esi ; i++
mov 0x120(%r15),%r10 ;安全點
test %eax,(%r10) ;輪詢
_Cond:
cmp %eax,%esi ; if i<tmp
jl _Loop
mov %r8,%rax
add $0x30,%rsp
pop %rbp
mov 0x120(%r15),%r10
test %eax,(%r10)
retq
如果可能,請盡量將循環不變代碼手動外提,而不是(盲目)依賴JIT編譯器。
最后想多說一點,我們不能簡單的根據某個指標來評判事物好壞,看到C++做了某種優化Java沒做就批評Java,這樣不好也是不公平的,與其口舌之爭不如深入分析為什么后者沒有做某種優化。虛擬機的編譯是JIT,動態編譯器的編譯成本是需要計算在運行成本之內的,它的每個優化都需要經過深思熟慮。
