[Inside HotSpot] C1編譯器優化:全局值編號(GVN)


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.0clang++ 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,動態編譯器的編譯成本是需要計算在運行成本之內的,它的每個優化都需要經過深思熟慮。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM