(給ImportNew加星標,提高Java技能)
編譯:唐尤華
連結:shipilev.net/jvm-anatomy-park/2-transparent-huge-pages/
“[JVM 解剖公園][1]”是一個持續更新的系列迷你部落格,閱讀每篇文章一般需要5到10分鐘。限於篇幅,僅對某個主題按照問題、測試、基準程式、觀察結果深入講解。因此,這裡的資料和討論可以當軼事看,不做寫作風格、句法和語意錯誤、重覆或一致性檢查。如果選擇採信文中內容,風險自負。
Aleksey Shipilёv,JVM 效能極客
推特 [@shipilev][2]
問題、評論、建議傳送到 [aleksey@shipilev.net][3]
[1]:https://shipilev.net/jvm-anatomy-park
[2]:http://twitter.com/shipilev
[3]:aleksey@shipilev.net
2. 問題
什麼是大記憶體頁?什麼是THP(透明巨大頁面)?瞭解它能為我們帶來什麼幫助?
3. 理論
“虛擬記憶體”概念已經被大家廣泛接受。現在只有少數人還記得”real mode”程式設計,更不用提實際操作了。在這種樣式下程式設計,會用到實際物理記憶體。與”real mode”相反,每個行程都擁有自己的虛擬記憶體空間,虛擬記憶體空間會對映到實際記憶體。例如,兩個行程可以在相同的虛擬地址 `0x42424242` 中儲存不同資料,這些資料實際存放在不同的物理記憶體中。當程式訪問該地址時,透過某種機制會把虛擬地址轉換成實際物理地址。
這個過程一般透過由作業系統維護的”[頁表][4]”實現,硬體透過”遍歷頁表”進行地址轉換。雖然以頁面為單位進行地址轉換更容易,但由於每次訪問記憶體都會發生地址轉換會帶來不小開銷。為此,引入 [TLB(轉換查詢緩衝)][5]快取最近的轉換記錄。TLB 要求至少要與 L1 快取一樣快,因此通常快取少於100條。對工作負載較大的情況,TLB 缺失和由此引發的頁表遍歷需要很多時間。
[4]:https://en.wikipedia.org/wiki/Page_table
[5]:https://en.wikipedia.org/wiki/Translation_lookaside_buffer
雖然不能建立更大的 TLB,但我們還可以做一些別的事情:建立更大的記憶體頁!大多數硬體都提供4K大小的基本頁,2M/4M/1G”大頁面”。 使用更大的頁面改寫同一區域也可以縮小頁表,從而減少頁面遍歷的時間。
在 Linux 世界,至少有兩種截然不同的方法可以在應用程式中做到這一點:
– [hugetlbfs][6]。裁剪一塊系統記憶體作為虛擬檔案系統,應用程式透過 mmap(2) 對其進行訪問。這是一種特殊介面,需要同時配置作業系統和應用程式後才能使用。這也是一種”全有或全無”的處理:為 hugetlbfs 分配的(持久化)空間不能為其它常規行程使用。
– [THP(透明巨型頁)][7]。應用程式可以像平常那樣分配記憶體,但 THP 會嘗試嚮應用程式透明地提供後臺大頁面儲存支援。理想情況下,啟用 THP 無需修改應用程式,但是我們能夠看到應用程式從中受益。實際上,啟用 THP 會帶來記憶體開銷或者時間開銷。前者因為可能會為一些較小的內容分配整個大頁面,後者因為 THP 分配頁面有時需要進行記憶體碎片整理(defrag)。好訊息是這裡有一種折衷方法:應用程式呼叫 madvise(2) 建議 Linux 在何處啟用 THP。
[6]:https://www.kernel.org/doc/Documentation/vm/hugetlbpage.txt
[7]:https://www.kernel.org/doc/Documentation/vm/transhuge.txt
我不明白為什麼術語”large”和”huge”可以互換。 不管怎樣,OpenJDK 支援兩種樣式:
```java
$ java -XX:+PrintFlagsFinal 2>&1 | grep Huge
bool UseHugeTLBFS = false {product} {default}
bool UseTransparentHugePages = false {product} {default}
$ java -XX:+PrintFlagsFinal 2>&1 | grep LargePage
bool UseLargePages = false {pd product} {default}
```
`-XX:+UseHugeTLBFS` 把 Java 堆 mmaps 到獨立的 hugetlbfs 中。
`-XX:+UseTransparentHugePages` 用 madvise -s 建議 Java 堆應該使用 THP。這是一個便捷選項,因為我們知道 Java 堆很大且大部分是連續的,並且極有可能因大頁面受益。
`-XX:+UseLargePages` 是一種啟用所有功能的快捷方式。在 Linux 上,該選項會啟用 hugetlbfs 而不是 THP。我想這是歷史的原因,因為 hugetlbfs 出現得更早。
一些應用程式在啟用大頁面時確實會[受到影響][8](有時會看到人們為了避免 GC 手動記憶體管理,結果卻觸發 THP 碎片整理進而導致延遲達到峰值)。我的直覺是 THP 在生命週期較短的應用程式上效果不佳,這些應用程式碎片整理耗費的時間與應用生命週期相比非常可觀。
[8]:https://bugs.openjdk.java.net/browse/JDK-8024838
4. 實驗
能否舉例展示大頁面給我們帶來的好處?當然可以。任何一位系統效能工程師在三十多歲時至少執行過一次類似這樣的工作負載,分配並隨機訪問 `byte[]` 陣列:
```java
public class ByteArrayTouch {
@Param(...)
int size;
byte[] mem;
@Setup
public void setup() {
mem = new byte[size];
}
@Benchmark
public byte test() {
return mem[ThreadLocalRandom.current().nextInt(size)];
}
}
```
(完整原始碼參見[這裡][9])
[9]:https://shipilev.net/jvm/anatomy-quarks/2-transparent-huge-pages/ByteArrayTouch.java
我們知道陣列大小各有不同,程式效能可能最終由 L1 快取失敗、L2 快取失敗或 L3 快取失敗決定。這裡通常忽略 TLB 失敗成本。
執行測試前,我們需要確定堆大小。我的電腦 L3 大約8M,所以100M陣列足以超過。這意味著用 `-Xmx1G -Xms1G` 分配1G大小的堆就可以滿足測試條件。同時,也可以參照這種方式確定 hugetlbfs 所需資源。
接下來,確保設定下列選項:
```
# HugeTLBFS 應該分配 1000*2M 頁面:
sudo sysctl -w vm.nr_hugepages=1000
# THP 僅進行 "madvise" 建議(一些發行版本提供設定預設值選項):
echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/defrag
```
我比較喜歡為 THP 做 “madvise”,因為它允許我選擇已經知道可能受益的特定記憶體。
在 i7 4790K、Linux x86_64、JDK 8u101 環境下執行:
```java
Benchmark (size) Mode Cnt Score Error Units
# Baseline
ByteArrayTouch.test 1000 avgt 15 8.109 ± 0.018 ns/op
ByteArrayTouch.test 10000 avgt 15 8.086 ± 0.045 ns/op
ByteArrayTouch.test 1000000 avgt 15 9.831 ± 0.139 ns/op
ByteArrayTouch.test 10000000 avgt 15 19.734 ± 0.379 ns/op
ByteArrayTouch.test 100000000 avgt 15 32.538 ± 0.662 ns/op
# -XX:+UseTransparentHugePages
ByteArrayTouch.test 1000 avgt 15 8.104 ± 0.012 ns/op
ByteArrayTouch.test 10000 avgt 15 8.060 ± 0.005 ns/op
ByteArrayTouch.test 1000000 avgt 15 9.193 ± 0.086 ns/op // !
ByteArrayTouch.test 10000000 avgt 15 17.282 ± 0.405 ns/op // !!
ByteArrayTouch.test 100000000 avgt 15 28.698 ± 0.120 ns/op // !!!
# -XX:+UseHugeTLBFS
ByteArrayTouch.test 1000 avgt 15 8.104 ± 0.015 ns/op
ByteArrayTouch.test 10000 avgt 15 8.062 ± 0.011 ns/op
ByteArrayTouch.test 1000000 avgt 15 9.303 ± 0.133 ns/op // !
ByteArrayTouch.test 10000000 avgt 15 17.357 ± 0.217 ns/op // !!
ByteArrayTouch.test 100000000 avgt 15 28.697 ± 0.291 ns/op // !!!
```
下麵是一些觀察結果:
- 對於較小的陣列,快取和 TLB 表現都很好,與基準測試沒有顯著差別。
- 在大陣列情況下,快取失敗開始佔主導地位,這就是為什麼每種配置開銷都在增加。
- 對於較大的陣列,會出現 TLB 錯誤,啟用更大的頁面非常有幫助!
- `UseTHP` 和 `UseHTLBFS` 都能起到幫助,因為它們嚮應用程式提供了相同的服務。
為了驗證出現 TLB 失敗這一假設,可以檢視硬體計數器。執行 JMH `-prof perfnorm` 會按操作輸出統一結果。
```java
Benchmark (size) Mode Cnt Score Error Units
# Baseline
ByteArrayTouch.test 100000000 avgt 15 33.575 ± 2.161 ns/op
ByteArrayTouch.test:cycles 100000000 avgt 3 123.207 ± 73.725 #/op
ByteArrayTouch.test:dTLB-load-misses 100000000 avgt 3 1.017 ± 0.244 #/op // !!!
ByteArrayTouch.test:dTLB-loads 100000000 avgt 3 17.388 ± 1.195 #/op
# -XX:+UseTransparentHugePages
ByteArrayTouch.test 100000000 avgt 15 28.730 ± 0.124 ns/op
ByteArrayTouch.test:cycles 100000000 avgt 3 105.249 ± 6.232 #/op
ByteArrayTouch.test:dTLB-load-misses 100000000 avgt 3 ≈ 10⁻³ #/op
ByteArrayTouch.test:dTLB-loads 100000000 avgt 3 17.488 ± 1.278 #/op
```
好了!在基準測試中,每個操作都會發生一次 dTLB 載入失敗,啟用 THP 後會少得多。
當然,啟用 THP 碎片整理後,在分配或訪問時會有碎片整理開銷。為了將這些成本轉移到 JVM 啟動階段,避免應用程式執行中出現意料之外的延遲問題,可以讓 JVM 在初始化時使用 `-XX:+AlwaysPreTouch` 訪問 Java 堆中的每個頁面。無論如何,為較大的堆啟用 `pre-touch` 是一個好辦法。
有趣的是: 實際使用中,啟用 `-XX:+UseTransparentHugePages` 讓 `-XX:+AlwaysPreTouch` 變得更快。因為 JVM 知道,現在它必須以更大的量程(比如每2M一個位元組),而不是更小的量程(每4K一個位元組)訪問堆。啟用 THP 行程死亡記憶體釋放速度也會加快,這種粗暴的效果要等到併發記憶體釋放補丁加入發行版核心才會結束。
例如,使用 4TB (Terabyte)大小的堆:
```java
$ time java -Xms4T -Xmx4T -XX:-UseTransparentHugePages -XX:+AlwaysPreTouch
real 13m58.167s
user 43m37.519s
sys 1011m25.740s
$ time java -Xms4T -Xmx4T -XX:+UseTransparentHugePages -XX:+AlwaysPreTouch
real 2m14.758s
user 1m56.488s
sys 73m59.046s
```
提交和釋放4TB肯定需要一段相當長的時間了。
5. 觀察
使用大頁面是一種提高應用程式效能的簡單技巧。核心中 THP 讓應用訪問記憶體變得更加容易。JVM 中對 THP 的支援讓選擇大頁面更方便。當應用程式擁有大量資料和大堆疊時,嘗試使用大頁面總是一個好主意。