歡迎光臨
每天分享高質量文章

行內意味著簡化?(1) 逃逸分析

(給ImportNew加星標,提高Java技能)

 

編譯:唐尤華,

鏈接:richardstartin.uk/does-inlined-mean-streamlined-part-1-escape-analysis/

 

有關 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`。為了避免因實現不同帶來開銷的差異,接下來採用三次相同的實現,達到閾值讓 Hotspot 放棄對 `escapee` 進行行內呼叫。

 

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


public class Escapee1 implements Escapee {
  @Override
  public  Optional map(S value, Function 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[] 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 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[] 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 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[] 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)行內可能帶來的好處,或者沒有好處。

    已同步到看一看
    赞(0)

    分享創造快樂