內聯意味着簡化?(1) 逃逸分析

有關 JVM 內聯(inline)技術有很多說法。毫無疑問,內聯可以降低函數調用開銷,但更重要的是,當不符合條件時,JVM 會禁用或減少優化。然而,這裏還要考慮如何在靈活性與內聯的功能性之間取得平衡。在我看來,內聯的重要性被高估了。這個系列文章用 JMH 實驗評估內聯失敗對 C2 編譯器優化帶來的影響。本文是系列的第一篇,介紹內聯如何影響逃逸分析及注意事項。


> 譯註:在編譯進程優化理論中,逃逸分析是一種確定指針動態範圍的方法——分析在進程的哪些地方可以訪問到指針。Java 沒有提供手段直接指定內聯方法,通常是在 JVM 運行時完成內聯優化


內聯與數據庫反範式化類似,是一個把函數調用替換爲函數代碼的過程。數據庫反範式化通過提升數據複製級別、增加數據庫大小從而降低 join 操作開銷。內聯以代碼空間爲代價,降低函數調用的開銷。這種類比其實並不確切:拷貝函數代碼到調用的地方,像 C2 這樣的編譯器能夠進行方法內部優化,並且 C2 會積極主動完成優化。衆所周知,讓內聯複雜化有兩種辦法:設置代碼大小(`InlineSmallCode` 選項指定最大允許內聯的代碼大小,默認2KB)和大量使用多態,還可以調用 JMH `@CompilerControl(DONT_INLINE)` 註解關閉內聯機制。


> 譯註:數據庫反範式化(Database Denormalisation),允許數據冗餘或者同樣的數據存儲於多處。


第一個基準測試是一個刻意設計的示例程序。方法簡短,可以在下面的函數式 Java 代碼中找到。函數式編程利用了 Monad 函子和 bind 函數。Monad 函子將一般的計算表示爲包裝類型(Wrapper Type),被包裝的操作稱爲單元函數。bind 函數可以組合函數應用到包裝類型。你也可以把它們想象成墨西哥捲餅。Java 函數式編程常見的 Monad 類型有 Either、Try 和 Optional。Either 函子內部包含兩個不同類型的實例,Try 函子會產生一個輸出或拋出異常,Optional 是 JDK 自帶的內建類型。Java 中 Monad 類型的一個缺點是需要實現包裝類型,而不只是交給編譯器負責。使用過程中存在分配失敗的風險。


> 譯註:Monad 函子保證返回的永遠是一個單層的容器,不會出現嵌套的情況。相關介紹推薦《函數式編程入門教程》阮一峯http://www.ruanyifeng.com/blog/2017/02/fp-tutorial.html


下面的 `Escapee` 接口包含 `map` 方法,返回類型爲 `Optional`。通過對未包裝類型 `S` 和 `T` 映射安全地將類型 `S` 可能出現的 `null` 值映射爲 `Optional<T>`。爲了避免因實現不同帶來開銷的差異,接下來採用三次相同的實現,達到閾值讓 Hotspot 放棄對 `escapee` 進行內聯調用。


```java
public interface Escapee<T> {
 <S> Optional<T> map(S value, Function<S, T> mapper);
}


public class Escapee1<T> implements Escapee<T> {
 @Override
 public <S> Optional<T> map(S value, Function<S, T> mapper) {
   return Optional.ofNullable(value).map(mapper);
 }
}
```












基準測試能夠模擬調用一種到四種實現。輸入 `null` 時,程序會選擇不同分支執行,因此預期產生不同的測試結果。爲了屏蔽不同分支執行開銷的差異,在每個分支都調用了相同的函數分配 `Instant` 對象。這裏沒有考慮分支不可預測的情況,因爲這不是本文的重點。選擇 `Instant.now()` 是因爲返回值是 volatile 且不規範的(impure),因此調用過程不會受其他優化影響。


```java
@State(Scope.Benchmark)
public static class InstantEscapeeState {
 @Param({"ONE", "TWO", "THREE", "FOUR"})
 Scenario scenario;


 @Param({"true", "false"})
 boolean isPresent;
 

 Escapee<Instant>[] escapees;
 int size = 4;
 String input;
 

 @Setup(Level.Trial)
 public void init() {
   escapees = new Escapee[size];
   scenario.fill(escapees);
   input = isPresent ? "" : null;
 }
}


// 譯註:Blackhole 在 JMH 中定義,cosume 輸入的 value,不做處理
// 避免對給定值的計算結果消除 dead-code
@Benchmark
@OperationsPerInvocation(4)
public void mapValue(InstantEscapeeState state, Blackhole bh) {
 for (Escapee<Instant> escapee : state.escapees) {
   bh.consume(escapee.map(state.input, x -> Instant.now()).orElseGet(Instant::now));
 }
}
```


































基於對 C2 編譯器內聯功能的瞭解,期望場景 THREE 和場景 FOUR 不做內聯優化,而場景 ONE 會內聯,場景 TWO 有條件內聯。可使用 `-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining` 選項輸出結果。參見 Aleksey Shipilёv 的[權威文章][1]。


[1]:https://shipilev.net/blog/2015/black-magic-method-dispatch/


基準測試使用下列參數運行。首先,禁用分層編譯繞過 C1 編譯器。接着,設置更大的 heap 避免測試結果受到垃圾回收暫停影響。最後,選擇低開銷 `SerialGC` 最大限度地減少 Write Barrier 帶來的干擾。


```shell
taskset -c 0 java -jar target/benchmarks.jar -wi 5 -w 1 -r 1 -i 5 -f 3 -rf CSV -rff escapee.csv -prof gc
-jvmArgs="-XX:-TieredCompilation -XX:+UseSerialGC -mx8G" EscapeeBenchmark.mapValue$
```



> 譯註:Write Barrier。在垃圾回收過程中,Write Barrier 指每次存儲操作之前編譯器調用的代碼以保持 Generational Invariant。


雖然在吞吐量方面幾乎沒有絕對差異,預期發生內聯場景的吞吐量略高於不發生內聯時的吞吐量,但是實際的結果非常有趣。


圖片


當輸入 `null` 時,Megamorphic 內聯實現會稍快一些,不加入其他優化可以很容易做到這一點。當輸入總是 `null`,或當前只有一種實現(場景 ONE)並且輸入不爲 `null` 時,標準(normalised)分配速度都是 24B/op。輸入非 `null` 時,過半的測試結果爲 40B/op。


> 譯註:Megamorphic inline caching(超對稱內聯緩存)。內聯緩存技術(Inline Caching)包括 Monomorphic、Polymorphic、Megamorphic三類,通過爲特定調用創建代碼執行 first-level 方法查找可實現 Megamorphic 內聯緩存。


圖片


當使用 SerialGC 這樣簡單的垃圾收集器時,24B/op 表示 `Instant` 類的實例大小,包括8字節1970年到現在的秒數、4字節納秒數以及12字節對象頭。這種情況不會分配包裝類型。40B/op 包括 `Optional` 佔用的16字節,其中12字節存儲對象頭,4字節存儲壓縮過的 `Instance` 對象引用。當方法無法內聯優化或者在條件語句中偶爾出現分配時,編譯器會放棄內聯。在場景 TWO 中,兩種實現會引入一個條件語句,這意味着每個操作都爲 `optional` 分配了16字節。


這些信息在上面的基準測試中表現得不夠明顯,幾乎都被分配24字節 `Instant` 對象掩蓋住了。爲了突出差異,我們把後臺分配從基準測試中分離出來,再一次跟蹤相同的指標。


```java
@State(Scope.Benchmark)
public static class StringEscapeeState {
 @Param({"ONE", "TWO", "THREE", "FOUR"})
 Scenario scenario;


 @Param({"true", "false"})
 boolean isPresent;
 Escapee<String>[] escapees;
 int size = 4;
 String input;
 String ifPresent;
 String ifAbsent;


 @Setup(Level.Trial)
 public void init() {
   escapees = new Escapee[size];
   scenario.fill(escapees);
   ifPresent = UUID.randomUUID().toString();
   ifAbsent = UUID.randomUUID().toString();
   input = isPresent ? "" : null;
 }
}


@Benchmark
@OperationsPerInvocation(4)
public void mapValueNoAllocation(StringEscapeeState state, Blackhole bh) {
 for (Escapee<String> escapee : state.escapees) {
   bh.consume(escapee.map(state.input, x -> state.ifPresent).orElseGet(() -> state.ifAbsent));
 }
}
```


































```shell
taskset -c 0 java -jar target/benchmarks.jar -wi 5 -w 1 -r 1 -i 5 -f 3 -rf CSV -rff escapee-string.csv -prof gc
-jvmArgs="-XX:-TieredCompilation -XX:+UseSerialGC -mx8G" EscapeeBenchmark.mapValueNoAllocation
```



即使看起來非常簡單的實際調用,比如分配時間戳,取消操作也足以減少內聯失敗的情況。而加入 no-op 的虛擬調用也會讓內聯失敗的情況變得嚴重。場景 ONE 和場景 TWO 測試結果比其他更快,因爲無論輸入是否爲 `null` 至少都消除了虛函數調用。


圖片


很容易想到內存分配被縮減了,只有在使用多態情況下會超過逃逸分析的限值。場景 ONE 不發生分配,一定是逃逸分析起效了。場景 TWO,由於存在條件內聯,每次用非 `null` 調用時都會分配16字節 `Optional`;當輸入一直爲 `null` 時分配減少。然而,內聯在場景 THREE 和場景 FOUR 中不起作用,每次調用會額外分配16字節。這個分配與內聯無關,變量12字節對象頭以及4字節壓縮後的 String 引用。你會多久檢查一次自己的基準測試,確保測量信息與設想的一致?


圖片


這不是實際編程中可以實用的技術,而是當方法傳入 `null` 值,無論是虛函數或內聯函數都可以更好地減少內存分配。實際上,`Optional.empty()` 總是返回相同實例,因此從測試開始就沒有分配任何內存。


雖然上面通過設計的示例強調了內聯失敗帶來的影響,但值得注意的是,與分配實例和使用不同垃圾回收器帶來的開銷差異相比內聯失敗的影響要小得多。一些開發人員似乎沒有意識到這一類開銷。


```java
@State(Scope.Benchmark)
public static class InstantStoreEscapeeState {
 @Param({"ONE", "TWO", "THREE", "FOUR"})
 Scenario scenario;


 @Param({"true", "false"})
 boolean isPresent;


 int size = 4;
 String input;
 Escapee<Instant>[] escapees;
 Instant[] target;
 

 @Setup(Level.Trial)
 public void init() {
   escapees = new Escapee[size];
   target = new Instant[size];
   scenario.fill(escapees);
   input = isPresent ? "" : null;
 }
}


@Benchmark
@OperationsPerInvocation(4)
public void mapAndStoreValue(InstantStoreEscapeeState state, Blackhole bh) {
 for (int i = 0; i < state.escapees.length; ++i) {
   state.target[i] = state.escapees[i].map(state.input, x -> Instant.now()).orElseGet(Instant::now);
 }
 bh.consume(state.target);
}
```



































用兩種模式運行相同的基準測試:


```shell
taskset -c 0 java -jar target/benchmarks.jar -wi 5 -w 1 -r 1 -i 5 -f 3 -rf CSV -rff escapee-store-serial.csv
-prof gc -jvmArgs="-XX:-TieredCompilation -XX:+UseSerialGC -mx8G" EscapeeBenchmark.mapAndStoreValue$
```



```shell
taskset -c 0 java -jar target/benchmarks.jar -wi 5 -w 1 -r 1 -i 5 -f 3 -rf CSV -rff escapee-store-g1.csv
-prof gc -jvmArgs="-XX:-TieredCompilation -XX:+UseG1GC -mx8G" EscapeeBenchmark.mapAndStoreValue$
```



改變垃圾回收器觸發 Write Barrier(對串行回收器來說很簡單,對 G1 來說很複雜)帶來的開銷與內聯失敗的開銷相當。注意:這並不代表垃圾回收器開銷不可接受。


圖片


內聯優化使逸出分析成爲可能,但是僅在只有一種實現時起效。即使出現很小的內存分配也會降低邊際效益也會下降,但隨着內存分配減少邊際效益會逐漸增大。這種差異甚至會比某些垃圾回收器中 Write Barrier 帶來的開銷更小。基準測試可以在 [github] 上找到,本文的測試環境爲 OpenJDK 11+28,操作系統爲 Ubuntu 18.04.2 LTS。


[2]:https://github.com/richardstartin/runtime-benchmarks/tree/master/src/main/java/com/openkappa/runtime/inlining/escapee


這種分析也許是膚淺的,許多優化比依賴內聯技術的逸出分析更強大。下一篇將討論類似 hash code 這樣的簡化操作(Reduction Operation)內聯可能帶來的好處,或者沒有好處。



發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章