JVM解剖公园2:透明的大页表

问题

什么是大页表?什么是透明大页表?有什么用?

理论

虚拟内存很常见,但很少有人提,更不用说“实模式”编程了,跟物理内存直接打交道。但实际上,每个进程都有自己的虚拟内存空间,并映射到物理内存中。甚至,两个进程可能有相同的虚拟地址,但对应的数据不同,中间会将虚拟地址映射为不同的物理地址。

译者注:实模式,通过“段:偏移”模式进行内存地址定位,程序指定的地址就是物理地址,但会有风险,因为可以定位到其他程序的所在的内存地址。

虚拟内存通常由操作系统的“页表(Page table)”来实现,硬件通过该表转换地址。整个过程相对容易,但不是很快,因为每次内存访问都要转换,为此,中间还加了层小缓存,页表缓存Translation Lookaside Buffer (TLB)(TLB)。TLB非常小,通常低于100条数据,因为它需要跟CPU的L1 Cache一样快,否则,对TLB未命中再进行页表转换耗时就大了。

TLB不能更大,但我们可以做更大的页表。大多数计算机有4K大小基本页表,以及2M / 4M / 1G“大页表”。单个页表容量越大,总页表容量会越小,从而使页表成本降低。

在Linux中,至少有两种不同的实现:

  1. hugetlbfs,划出系统内存的一部分,作为公共虚拟内存,程序使用系统的mmap(2)接口存取数据。这是一个特殊的接口,需要系统和程序配合使用,程序要做修改,才能使用hugetlbfs(持久性部分)的空间。

  2. 透明巨大页表Transparent Huge Pages (THP)。透明地为程序提供大页表,程序可以像往常一样分配内存。理想情况下,不需要修改程序。不过,还是存在额外的内存开销(较小的内容也会分配整个大页表)或时间开销(THP需要对内存进行碎片整理)。可以通过madvise(2)接口查询哪里使用了THP。

为什么有“Large”和“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,用mmaps将Java堆转换为hugetlbfs。
-XX:+UseTransparentHugePages,让Java堆使用THP。Java堆很大,且大多是连续的,可以最大程度地受益于大页表。

-XX:+UseLargePages,通用的参数,可以使在任何Linux上,它启用hugetlbfs,而不是THP,我想这是出于历史原因,因为hugetlbfs排在第一位。

某些程序确实会遇到大页表问题。(有时候会看到有人手动进行内存管理以避免使用GC,只是为了解决THP碎片整理导致的延迟)我感觉这是开历史的倒车,与应用程序运行时间相比,碎片整理成本不值一提。

实验

大页表真的有好处吗?当然可以,随机分配一个byte[]数组:

@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(value = 3, jvmArgsAppend = {"-Xmx1g", "-Xms1g"})
@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class ByteArrayTouch {
    @Param({"1000",
            "10000",
            "1000000",
            "10000000",
            "100000000"})
    int size;
    byte[] mem;

    @Setup
    public void setup() {
        mem = new byte[size];
    }

    @Benchmark
    public byte test() {
        return mem[ThreadLocalRandom.current().nextInt(size)];
    }
}

根据数据大小不同,性能将受CPU的L1、L2、L3多层cache未命中的影响,但TLB未命中经常被忽略。
在运行测试之前,我们需要确定采用多大的堆。在我的机器上,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

我喜欢为THP做“madvise”,因为由我来决定哪块内存使用部分。

译者注:madvise() 函数能建议内核从 addr 指定的地址开始,长度等于 len 参数值的范围内,该区域的用户虚拟内存应遵循特定的使用模式。

在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 // !!!

这里有一些观察:
在较小的数组上,CPU缓存和TLB都可以,并且与基线没有区别。
在较大的数组上,CPU缓存未命中率开始增加。
在更大的数组上,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⁻³            #/op
ByteArrayTouch.test:dTLB-loads        100000000  avgt    3   17.488 ±  1.278   #/op

基线中的dTLB-load-misses一行的开销,在启用THP后则小得多。

当然,启用THP后,分配/访问时会有碎片整理开销,可以将这些开销转移到JVM启动时,避免在运行时出现延迟,用JVM -XX:+AlwaysPreTouch在初始化时预分配Java堆中的页表,最好为大的堆启用预分配。

并且有趣的是,启用-XX:+UseTransparentHugePages会让-XX:+AlwaysPreTouch更快,因为JVM知道它要用更大的块(例如,每2M一个字节)分配堆,而不是更小的(例如,每4K一个字节)。在进程死亡后释放内存的速度也快于THP,这直到并行释放整合到了Linux发行版内核。

例如,使用4TB的堆,启用和不启用UseTransparentHugePages的区别:

$ 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

结论

大页表是提高性能的简单技巧。Linux内核中的透明大页表使其更易使用。JVM中的透明大页表也很容易。特别是如果你的程序有大量数据和大堆,不妨尝试配置大页表。

Comments
Write a Comment