JVM優化之迴圈展開

喝水會長肉發表於2021-11-21

在JVM內部實現系列的前幾篇文章中,我們已經看到了Java的HotSpot虛擬機器的just-in-time (JIT)編譯技術,包括逃逸分析和鎖消除。本文我們將要討論另一種自動優化,叫作迴圈展開。JIT編譯器使用這項技術來讓迴圈(比如Java的for或者while迴圈)執行得更加高效。


由於我們要對JVM的內部機制進行深入分析,所以你會時不時看到用於講解介紹的各種C的程式碼甚至是組合語言,扶穩了!

我們先從下面這段C程式碼開始,它會去分配100萬個long型別的空間,然後用100萬個隨機的long值來填充。

int main(int argv, char** argc) {
    int MAX = 1000000;
    long* data = (long*)calloc(MAX, sizeof(long));
    for (int i = 0; i < MAX; i++) {
        data[i] = randomLong();
    }}

C被認為是一門高階語言,不過事實真的是這樣的嗎?在蘋果Mac電腦上,用Clang編譯器(開啟—S選項來列印Intel格式的組合語言)來編譯前面的程式碼會得到如下的輸出結果:


_main:                       ## @main## BB#0:
    pushq   %rbp
    movq    %rsp, %rbp
    subq    $48, %rsp
    movl    $8, %eax
    movl    %eax, %ecx
    movl    $0, -4(%rbp)
    movl    %edi, -8(%rbp)
    movq    %rsi, -16(%rbp)
    movl    $1000000, -20(%rbp)   ## imm = 0xF4240
    movslq  -20(%rbp), %rdi
    movq    %rcx, %rsi
    callq   _calloc
    movq    %rax, -32(%rbp)
    movl    $0, -36(%rbp)LBB1_1:                       ##   LBB1_1是內部迴圈的Header Depth=1
    movl    -36(%rbp), %eax
    cmpl    -20(%rbp), %eax
    jge LBB1_4## BB#2:                      ##   迴圈體內部: Header=BB1_1 Depth=1  
    callq   _randomLong
    movslq  -36(%rbp), %rcx
    movq    -32(%rbp), %rdx
    movq    %rax, (%rdx,%rcx,8)## BB#3:                      ##   迴圈體內部: Header=BB1_1 Depth=1
    movl    -36(%rbp), %eax
    addl    $1, %eax
    movl    %eax, -36(%rbp)
    jmp LBB1_1LBB1_4:
    movl    -4(%rbp), %eax
    addq    $48, %rsp
    popq    %rbp
    retq

看下這個程式碼,你會發現開始處有一次calloc函式的呼叫,並且僅存在一次randomLong()函式的呼叫(在迴圈中)。裡面有兩次跳轉,它和下面變種的C程式碼所生成的機器程式碼本質上是一樣的:


int main(int argv, char** argc) {
    int MAX = 1_000_000;
    long* data = (long*)calloc(MAX, sizeof(long));
    int i = 0;
   LOOP: if (i >= MAX)
        goto END;
    data[i] = randomLong();
    ++i;
    goto LOOP;
    END: return 0;}


Java裡面同樣的程式碼應該是這樣的:

public class LoopUnroll {
    public static void main(String[] args) {
        int MAX = 1000000;
        long[] data = new long[MAX];
        java.util.Random random = new java.util.Random();
            for (int i = 0; i < MAX; i++) {
           data[i] = random.nextLong();
        }
    }}

編譯成位元組碼的話,就成了這樣:


public static void main(java.lang.String[]);
    Code:
 0: ldc                        #2       // int 1000000 2: istore_1
 3: iload_1
 4: newarray       long
 6: astore_2
 7: new                       #3       // class java/util/Random10: dup11: invokespecial             #4       // 方法 java/util/Random."<init>:()V14: astore_315: iconst_016: istore        418: iload         420: iload_121: if_icmpge     3824: aload_225: iload         427: aload_328: invokevirtual             #5.      // 方法 java/util/Random.nextLong:()J31: lastore32: iinc          4, 135: goto          1838: return


這些程式在程式碼結構來看都非常相似。它們都在迴圈中對陣列data進行了一次操作。真實的處理器會有指令流水線(instruction pipeline),如果程式一直向下線性執行的話,就能夠充分地引用流水線,因為下一條執行的指令馬上就會就緒。

不過,一旦碰到跳轉指令,指令流水線的優勢通常就消失了,因為流水線的內容需要丟棄掉並重新從主記憶體中跳轉地址處開始載入新的操作碼。這裡所產生的效能損耗和快取未命中是類似的————都要額外從主存中載入一次。

對於前向跳轉(注:原文是back branch,從程式碼執行順序來看,是指要跳轉回前面所執行過的分支上,這裡姑且稱為前向跳轉)————跳轉回前面的執行點————正如前面for迴圈中那樣,對效能的影響取決於CPU提供的分支預測演算法的準確程度。Intel 64和IA-32架構優化參考手冊[PDF]第3.4.1節對裡面所提到的特定晶片的分支預測演算法有詳細的介紹。

不過由於有HotSpot的JIT編譯器的存在,Java程式還有著更多的可能。JIT編譯器進行了很多優化,不同的情況下所編譯出來的程式碼會很不一樣。

尤其是在使用int, short, 或者char變數作為計數器的計數迴圈(counted loops)當中,JIT進行了不少優化。它會把迴圈體展開,並用一個個排列好的原迴圈體的拷貝來替代。對迴圈的重構減少了所需的前向跳轉。而且和C程式碼編譯後所生成的彙編程式碼相比,效能上有很大的提升,因為指令流水線的快取被丟棄的次數要少得多了。

我們用幾個簡單的方法來測試下不同的迴圈執行方式的區別。你可以看下迴圈展開後的彙編程式碼,原先的多個迴圈操作是如何在一次迴圈內完成的。

在開始彙編程式碼之旅前,我們還需要對前面的Java程式碼作一些簡單的修改,以便讓JIT編譯器發揮作用,因為HotSpot虛擬機器只會對整個方法體進行編譯。不光如此,這個方法還需要在解釋模式下執行過一定次數後,編譯器才會考慮對它進行編譯(通常是執行了1萬次後才會進入完全優化的編譯模式)。如果只是前面那樣的一個單獨的main方法,JIT編譯器是永遠不會被喚起的,也就沒有任何優化可言了。

下面這個Java方法基本上是和原先的例子類似的,你可以用它來進行測試:

private long intStride1(){
    long sum = 0;
    for (int i = 0; i < MAX; i += 1)
    {
        sum += data[i];
    }
    return sum;}

這個方法會順序地從陣列中取值,並累加起來,然後將結果返回。這和前面的例子是類似的,但我們選擇把結果返回,這是為了確保JIT編譯器不會把迴圈展開和逃逸分析結合起來進行更進一步的優化,那樣就不容易確定迴圈展開的實際效果了。

我們可以從組合語言中識別出一個關鍵的訪問模式,這樣更容易幫助我們理解程式碼在幹什麼。這就是由暫存器和偏移量組成的三元組[base, index, offset],這裡面

  • base暫存器儲存的是陣列的起始地址
  • index暫存器儲存的是計數器(這個要乘上資料型別的大小)
  • offset用來記錄展開迴圈內的偏移量

實際的組合語言看起來會類似這樣:

add rbx, QWORD PTR [base register + index register * size + offset]

假設陣列型別是long的,我們來看下什麼條件下會觸發迴圈展開。需要注意的是,迴圈展開的行為在不同的HotSpot虛擬機器版本間是不太一樣的,同時也取決於具體的CPU架構,不過整體的概念是一樣的。

要想得到JIT編譯器生成的反彙編後的原生程式碼,你還需要一個反彙編庫(一般是hsdis,HotSpot Disassembler),這個要安裝到你的Java安裝地址下面的jre/lib目錄中。

hsdis可以從OpenJDK原始碼中編譯得到,具體的操作文件可以看下JITWatch wiki。還有個方法,Oracle的GraalVM專案將hsdis作為可下載的二進位制檔案一起分發出來了———你可以從GraalVM的安裝目錄裡把它拷貝到Java的安裝位置下面。

裝好了hsdis之後,還需要配置下讓虛擬機器把方法的編譯後的彙編程式碼輸出出來。要這麼做你還得額外加上一些VM啟動引數,包括-XX:+PrintAssembly。

需要注意的是JIT執行緒編譯完方法後就會直接將對應的原生程式碼反彙編成可讀的組合語言。這是一個很昂貴的操作,會影響到應用程式的效能,所以在生產環境中不要使用。

用如下的VM選項來執行程式,你便能看到指定方法的反彙編後的組合語言了:

java -XX:+UnlockDiagnosticVMOptions \
     -XX:-UseCompressedOops         \
     -XX:PrintAssemblyOptions=intel \
     -XX:CompileCommand=print,javamag.lu.LoopUnrolling::intStride1 \
     javamag.lu.LoopUnrolling

這個命令會生成一個步進固定為1的int計數迴圈所對應的彙編程式碼。

值得注意的是,這裡我們用到了-XX:-UseCompressedOops,這只是為了把指標地址壓縮的優化給關掉,來簡化生成的彙編程式碼。在64位的JVM上這會節省一定的記憶體佔用,不過我們不建議你在普通的虛擬機器使用場景中這麼做。你可以在OpenJDK的wiki中瞭解到更多關於普通物件指標(ordinary object pointers, oops)壓縮的知識。

不斷累加的long型別的求和結果儲存在64位的暫存器rbx中。每個add指令都會從data陣列中取出下一個值,並將它加到rbx上。每次載入後,偏移量的常量會增加8(這正是Java中long基礎型別的大小)。

當展開的部分前向跳轉回主迴圈的起始處時,offset暫存器會進行自增,加上這次迴圈迭代所處理的資料量:


//==============================// 初始化程式碼//==============================// 將data陣列的地址賦值給 rcx0x00007f475d1109f7: mov rcx,QWORD PTR [rbp+0x18]  ;*getfield data// 將陣列大小賦值給  edx0x00007f475d1109fb: mov edx,DWORD PTR [rcx+0x10]// 將MAX賦值給  r8d0x00007f475d1109fe: mov r8d,DWORD PTR [rbp+0x10]  ;*getfield MAX// 迴圈計數器是 r13d, 將它和MAX進行比較0x00007f475d110a02: cmp r13d,r8d// 如果COUNTER >= MAX,跳轉到exit處0x00007f475d110a05: jge L00060x00007f475d110a0b: mov r11d,r13d0x00007f475d110a0e: inc r11d0x00007f475d110a11: xor r9d,r9d0x00007f475d110a14: cmp r11d,r9d0x00007f475d110a17: cmovl r11d,r9d0x00007f475d110a1b: cmp r11d,r8d0x00007f475d110a1e: cmovg r11d,r8d
 //==============================// 前置迴圈//==============================// 陣列邊界檢查             L0000: cmp r13d,edx0x00007f475d110a25: jae L0007// 執行加法0x00007f475d110a2b: add rbx,QWORD PTR [rcx+r13*8+0x18]  ;*ladd// 計數器自增0x00007f475d110a30: mov r9d,r13d0x00007f475d110a33: inc r9d  ;*iinc// 如果已經完成PRE-LOOP,跳轉至MAIN LOO0x00007f475d110a36: cmp r9d,r11d0x00007f475d110a39: jge L0001// 檢查迴圈計數器,如果未完成,則前向跳轉(L0000處)0x00007f475d110a3b: mov r13d,r9d0x00007f475d110a3e: jmp L0000//==============================// 主迴圈初始化//==============================             L0001: cmp r8d,edx0x00007f475d110a43: mov r10d,r8d0x00007f475d110a46: cmovg r10d,edx0x00007f475d110a4a: mov esi,r10d0x00007f475d110a4d: add esi,0xfffffff90x00007f475d110a50: mov edi,0x800000000x00007f475d110a55: cmp r10d,esi0x00007f475d110a58: cmovl esi,edi0x00007f475d110a5b: cmp r9d,esi0x00007f475d110a5e: jge L000a0x00007f475d110a64: jmp L00030x00007f475d110a66: data16 nop WORD PTR [rax+rax*1+0x0]//==============================// 開始主迴圈 (展開的部分)// 每次迭代執行8次加法//==============================             L0002: mov r9d,r13d
             L0003: add rbx,QWORD PTR [rcx+r9*8+0x18]   ;*ladd0x00007f475d110a78: movsxd r10,r9d0x00007f475d110a7b: add rbx,QWORD PTR [rcx+r10*8+0x20]  ;*ladd0x00007f475d110a80: add rbx,QWORD PTR [rcx+r10*8+0x28]  ;*ladd0x00007f475d110a85: add rbx,QWORD PTR [rcx+r10*8+0x30]  ;*ladd0x00007f475d110a8a: add rbx,QWORD PTR [rcx+r10*8+0x38]  ;*ladd0x00007f475d110a8f: add rbx,QWORD PTR [rcx+r10*8+0x40]  ;*ladd0x00007f475d110a94: add rbx,QWORD PTR [rcx+r10*8+0x48]  ;*ladd0x00007f475d110a99: add rbx,QWORD PTR [rcx+r10*8+0x50]  ;*ladd// 迴圈計數器自增80x00007f475d110a9e: mov r13d,r9d0x00007f475d110aa1: add r13d,0x8  ;*iinc// 檢查迴圈計數器,如果未完成,則前向跳轉(L0002處)0880x00007f475d110aa5: cmp r13d,esi0x00007f475d110aa8: jl L0002//==============================0x00007f475d110aaa: add r9d,0x7  ;*iinc// 如果 迴圈計數器 >= MAX 跳轉至退出處             L0004: cmp r13d,r8d0x00007f475d110ab1: jge L00090x00007f475d110ab3: nop//==============================// 後置迴圈//==============================// 陣列邊界檢查             L0005: cmp r13d,edx0x00007f475d110ab7: jae L0007// 執行一次加法0x00007f475d110ab9: add rbx,QWORD PTR [rcx+r13*8+0x18];*ladd// 迴圈計數器自增0x00007f475d110abe: inc r13d  ;*iinc// 檢查迴圈計數器,如果未完成,則前向跳轉(L0005處)0x00007f475d110ac1: cmp r13d,r8d0x00007f475d110ac4: jl L0005//==============================

(為了讓大家更容易理解,我們在彙編程式碼中加入了一些註釋,這樣每個獨立的部分更加清晰了。為了簡潔起見,我們只保留了一個退出方法的塊,不過在組合語言中通常會有多個退出塊,來處理方法結束可能的各種情況。設定部分的程式碼也包含進來了,本文稍後會將它和其它操作來進行比較。)

當在迴圈裡訪問陣列的時候,HotSpot虛擬機器會將迴圈拆分成三個部分,來消除陣列的邊界檢查:

  • 前置迴圈:執行初始迭代,並且進行邊界檢查。
  • 主迴圈:通過迴圈步長(就是每次迭代時計數器增加的大小)來計算在不需要邊界檢查情況下可以執行的最大迭代次數。
  • 後置迴圈:執行剩餘的迭代,並且進行邊界檢查。

計算一下add操作和jump操作的比例,你就能知道這個方法的實際優化效果是怎樣的了。在 我們前面測試的未優化的C語言的版本上,這個比例是1:1,而Java的HotSpot虛擬機器的JIT編譯器把這個數字提高到了8:1,在這一部分上減少了87%的跳轉次數。而一次跳轉的影響一般來說是會消耗2到300個CPU週期,用來等待從主存中重新載入程式碼,因此這個提升效果就非常明顯了。(如果想了解HotSpot虛擬機器是如何消除陣列迴圈過程中的邊界檢查的,可以看下這個線上文件。)

HotSpot虛擬機器也可以展開int計數,步長為常量2或4的迴圈。比方說步長為4的話,它可以將迴圈展開成8次,每次迴圈地址偏移量會增加0x20(32)。編譯器還可以支援展開short,byte或者char來計數的迴圈體,但是long型別的不支援,這個在下一節我們馬上會講到。

安全點(Safepoints)

用long型別進行迴圈計數的Java方法的程式碼,看起來和int型別的是非常類似的:


private long longStride1(){
    long sum = 0;
    for (long l = 0; l < MAX; l++)
    {
        sum += data[(int) l];
   }
    return sum;}


不過使用了long型別來計數之後,它所生成的彙編程式碼中的初始化的部分,就和前面所列出的彙編程式碼中的完全不一樣了————哪怕步長是常量1,也不會出現迴圈展開:


// 將陣列長度賦值給 r9d0x00007fefb0a4bb7b: mov    r9d,DWORD PTR [r11+0x10]// 跳轉到迴圈結束處,檢查計數器是否超上限0x00007fefb0a4bb7f: jmp    0x00007fefb0a4bb90//(這裡是前向跳轉的目標地址) - 通過r14來求和0x00007fefb0a4bb81: add    r14,QWORD PTR [r11+r10*8+0x18]// 迴圈計數器rbx自增0x00007fefb0a4bb86: add    rbx,0x1 // 安全點檢查0x00007fefb0a4bb8a: test   DWORD PTR [rip+0x9f39470],eax // 如果 迴圈計數器 >= 1_000_000 則跳轉到退出處0x00007fefb0a4bb90: cmp    rbx,0xf42400x00007fefb0a4bb97: jge    0x00007fefb0a4bbc9
 // 將迴圈計數器的低32位賦值給r10d0x00007fefb0a4bb99: mov    r10d,ebx
 // 陣列邊界檢查並前向跳轉回迴圈起始處0x00007fefb0a4bb9c: cmp    r10d,r9d0x00007fefb0a4bb9f: jb     0x00007fefb0a4bb81



現在在迴圈體內就只有一個加的指令了——加法和跳轉指令的比例又變回了1:1,迴圈展開的好處沒有了。不光如此,迴圈中還多了一次安全點檢查。

安全點是程式碼中的一些特殊位置,當執行執行緒執行到這個地方的時候,它便知道自己已經完成了對內部資料結構的所有修改(比如堆中的物件)。這個時候適合來檢查並確認JVM是否需要暫停執行Java程式碼的所有執行緒。應用執行緒通過檢查安全點並掛起執行,給JVM提供了一個機會來執行一些可能會修改記憶體佈局或內部資料結構的操作,比如stop-the-world (STW)垃圾回收。

在程式碼解釋執行的時候,有一個很適合進行安全點檢查的時機:一個位元組碼剛執行完,下一個位元組碼還未執行的時候。

在“位元組碼間”進行安全點檢查對解釋執行來說非常有用,但對JIT編譯過的方法來說,這個檢查就必須要整合插入到編譯器生成的程式碼裡面才行。

如果缺少這些檢查,就會出現其它執行緒都已經在它們的安全點上暫停但有的執行緒還在繼續執行的情況。這會導致虛擬機器進入到混亂的狀態,幾乎所有應用執行緒都停止執行了,卻還有一些在不停地執行中。

HotSpot使用了幾種啟發法(heuristics)來往編譯後的程式碼中插入安全點檢查。最常用的兩個就是在前向跳轉之前(就像這個例子中這樣),還有就是在方法即將退出但控制流還沒回到呼叫方的時候。

不過,long計數的例子中安全點檢查的出現也暴露了int計數迴圈中另一個特點:它們沒有安全點檢查。也就是說在整個int計數迴圈(步長為常量)的執行過程中不會出現任何安全點檢查,在極端情下這可能會佔用相當長的時間。

然而,如果是int計數但步長不固定的迴圈體,比如說每次方法呼叫時步長都可能發生變化:


private long intStrideVariable(int stride){
    long sum = 0;
    for (int i = 0; i < MAX; i += stride)
    {
        sum += data[i];
    }return sum;}


這段程式碼便會強制要求JIT編譯器在前向跳轉處生成安全點檢查。

長時間執行的int計數迴圈會導致其它執行緒一直在安全點等待,直到它執行結束,如果你對這個所導致的延遲暫停時間比較敏感的話,可以使用啟動引數-XX:+UseCountedLoopSafepoints來解決這個問題。這個選項會在未展開的迴圈體的前向跳轉處加入一個安全點檢查。這樣在剛才前面的例子中所生成的長長的彙編程式碼中,每進行8次加法運算,便會出現一次安全點檢查。

除非你已經在效能測試中明確證實加上這個引數後會對效能有明顯的提高,不然不要啟用這個選項,其它會對效能產生影響的命令列引數也是同樣的處理原則。很少有程式能從啟用該選項中受益,因此不要盲目地開啟這一選項。Java 10引入了一項更高階的叫迴圈切分(loop strip mining)的技術來更進一步地平衡安全點檢查對吞吐量和延遲所產生的影響。

我們用JMH來測試下同樣的陣列使用int計數和使用long計數的效能差異,來做一下總結。正如前面所解釋的,使用long計數的迴圈是不會被展開的,同時每次迴圈也會包含一次安全點檢查。


package optjava.jmh;import org.openjdk.jmh.annotations.*;import java.util.concurrent.TimeUnit; @BenchmarkMode(Mode.Throughput)@OutputTimeUnit(TimeUnit.SECONDS)@State(Scope.Thread)public class LoopUnrollingCounter{
    private static final int MAX = 1_000_000;
    private long[] data = new long[MAX];
    @Setup
    public void createData()
   {
        java.util.Random random = new java.util.Random();
        for (int i = 0; i < MAX; i++)
        {
            data[i] = random.nextLong();
        }
    }
     @Benchmark
  public long intStride1()
  {
      long sum = 0;
      for (int i = 0; i < MAX; i++)
      {
          sum += data[i];
      }
       return sum;
  }
  @Benchmark
  public long longStride1()
  {
      long sum = 0;
     for (long l = 0; l < MAX; l++)
      {
          sum += data[(int) l];
       }
       return sum;
   }}


最後的輸出結果如下:

1Benchmark Mode Cnt Score Error Units
2LoopUnrollingCounter.intStride1 thrpt 200 2423.818 ± 2.547 ops/s
3LoopUnrollingCounter.longStride1 thrpt 200 1469.833 ± 0.721 ops/s

也就是說使用int計數的迴圈每秒執行的操作能高出64%。

結論

HotSpot虛擬機器可以執行更復雜的迴圈展開優化——比如說,當迴圈包含多個退出點時。這種情況下,迴圈會被展開,且每個展開的迭代都會進行一次終止條件的檢查。

作為一個虛擬機器,HotSpot利用迴圈展開的能力來減少或消除了前向跳轉所帶來的效能損耗。不過對大多數的Java開發人員來說,他們並不需要知道這個能力——這只不過又是一項執行時所提供的對他們透明的效能優化罷了。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70010294/viewspace-2843298/,如需轉載,請註明出處,否則將追究法律責任。

相關文章