原文地址:JVM Anatomy Park #2: Transparent Huge Pages
問題
大頁(Large Pages)是什么?透明大頁(Transparent Huge Pages)又是什么?它們有什么用?!
理論
現在虛擬內存被視為理所當然的特性。只有很少人還記得直接操作物理內存的“實模式(real mode)”編程,與此相反,每個進程擁有自己的虛擬地址空間,這段空間將會被映射到實際的物理內存。該特性使得兩個進程可以在相同的虛擬地址0x42424242上具有不同的數據,因為相同的虛擬地址會被映射到不同的物理地址。所以當程序訪問虛擬地址時,某個組件將會把虛擬地址轉換為物理地址。
這部分的功能通常是由操作系統(tǒng)與硬件協同完成的,操作系統(tǒng)負責維護“頁表(page table)”,而硬件通過“頁表移動(page table walk)”轉換地址。以頁的粒度維護地址轉換還是比較容易的,然而這并不高效,因為每次內存訪問都需要做地址轉換!因此這里又對最近的轉換增加了緩存——轉換查找緩存 (TLB)。TLB 通常非常小,少于100條記錄,因為它需要像 L1 緩存一樣快,甚至更快。對于許多工作負載來說,TLB不命中以及相應的頁表移動將會非常耗時。
雖然我們不能把 TLB 做大,但是我們可以把頁變大!大部分硬件支持4K大小的基本頁,以及2M/4M/1G的“大頁”。擁有更大的頁可以將頁表縮小,使得頁表移動的成本更低。
在 Linux 中至少有兩種方式實現更大的頁:
-
hugetlbfs。占用部分系統(tǒng)內存,將其暴露為虛擬文件系統(tǒng),讓應用程序從其中
mmap(2)。這種方式需要操作系統(tǒng)配置和應用程序協同修改。這也是一種“要么全部,要么沒有”的方式:hugetlbfs (持久化部分)分配的空間不能被正常的進程使用。 -
Transparent Huge Pages (THP)。這種方式對應用程序來說是透明的,應用可以像平常那樣分配內存。理想情況下,應用程序不需要做任何改動。但是實際上這種方式存在空間成本(因為對某些小對象也會分配整個大頁)和時間成本(因為有時候THP需要整理內存碎片)。好在存在一個妥協的辦法:應用程序可以通過
madvise(2)告訴 Linux 在何處使用 THP。
至于為什么在命名上交替使用了“l(fā)arge”和“huge”,那我就不清楚了。不管怎樣 OpenJDK 兩種方式都支持:
$ 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 堆到 hugetlbfs,
-XX:+UseTransparentHugePages僅僅madvise Java 堆應該使用 THP。這是一個便捷的方式,因為我們知道 Java 堆很大,基本是連續(xù)的,可以最大程度享受到大頁的好處。
-XX:+UseLargePages 是一個通用的配置,用于啟動任意可用的方式。在 Linux 中,該參數啟動 hugetlbfs,而不是 THP。我猜測這可能是歷史原因,畢竟 hugetlbfs 更早出現。
某些應用程序在開啟大頁之后卻造成了性能下降。(很有趣的是人們通過手動內存管理來避免 GC,然而卻由于 THP 的內存碎片整理造成了突增的高延時?。┪业闹庇X是 THP 對大部分生命周期較短的應用程序會造成性能下降,因為內存整理的時間相對于應用程序較短的生命周期比重更明顯。
實驗
我們可以檢驗大頁帶來的好處么?當然,讓我們以一個通常的工作負載為例,分配然后隨機訪問byte[]數組:
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)];
}
}
(完整的代碼在這里)
我們知道隨著數組變大,系統(tǒng)的性能開始受 L1 緩存不命中的影響,然后受L2緩存不命中的影響,然后受L3緩存不命中的影響,以及其他的影響。這里我們通常會忽略 TLB 不命中的影響。
在執(zhí)行測試用例之前,我們需要確定使用多大的堆內存。在我的機器上,L3緩存有 8M,所以 100M 大小的數組就可以超出緩存。這意味著通過-Xmx1G -Xms1G分配1G內存肯定就足夠了。這也為我們分配 hugetlbfs 提供了指導。
所以,確保設置了下述參數:
# HugeTLBFS should allocate 1000*2M pages:
sudo sysctl -w vm.nr_hugepages=1000
# THP to "madvise" only (some distros have an opinion about defaults):
echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/defrag
我喜歡通過“madvise”使用 THP,因為這使得我可以“選擇性”的使用收益較高的部分內存。
執(zhí)行環(huán)境 i7 4790K, Linux x86_64, JDK 8u101:
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提供了操作粒度的數值。
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?3 #/op
ByteArrayTouch.test:dTLB-loads 100000000 avgt 3 17.488 ± 1.278 #/op
讓我們開始分析吧!在基準測試中每次操作都會 dTLB load miss,但是啟動了THP之后卻很少。
當然,伴隨著啟動 THP,你也需要承擔內存碎片整理的成本。我們可以將這個成本轉移到 JVM 啟動時,這樣就可以避免程序運行時突然的卡頓,具體的方法是通過-XX:+AlwaysPreTouch參數控制 JVM 啟動時訪問堆中的每個內存頁。通常來說預訪問是個不錯的選擇。
有趣的事情發(fā)生了:通過設置-XX:+UseTransparentHugePages可以使-XX:+AlwaysPreTouch更快完成,因為 JVM 可以以更大的步長(每2M一個字節(jié))訪問堆,而不是默認情況下較小的步長(每4K一個字節(jié))。進程關閉后釋放內存也會更快,這種優(yōu)勢一直延續(xù)到并行釋放補丁進入發(fā)行版的內核。
使用 4TB 大小的堆內存:
$ 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 的內存確實需要執(zhí)行很長時間!
觀察
大頁是一個改善程序性能的小技巧。Linux 內核中的透明大頁更容易使用。JVM 支持的透明大頁也很容易啟用。通常來說使用大頁是一個好主意,特別是在你的應用程序占用大量數據和堆內存的情況下。