暗色模式

Linux 大页内存:从透明大页 THP 到 hugetlbfs 显式预留

技术教程
2026-09-23
3
0
本文要点
  • 大页解决的是 TLB 查表次数:x86-64 默认页 4KiB,1GiB 工作集就是 26 万个页表项,TLB 装不下;换成 2MiB 大页只剩 512 项
  • 透明大页由三个文件共同决定:enabled(always/madvise/never)、defrag(内核肯不肯为它做内存规整)、shmem_enabled(共享内存那一侧)。这台机器是 madvise + madvise + never
  • 实测同一个 256MiB 匿名分配:不给建议时 AnonHugePages: 0 kB;加一句 madvise(MADV_HUGEPAGE) 变成 260096 kB(127 个 2MiB 页);MADV_NOHUGEPAGE 又回到 0
  • ⚠️ 反直觉实测:把全局 THP 改成 always 反而只拿到 57344 kB。因为 defrag=madvise 时内核只对显式 MADV_HUGEPAGE 的区域做内存规整,凑不出连续 2MiB 就直接退回 4KiB 页(thp_fault_fallback 持续上涨)
  • ⚠️ 写代码的坑mmap.mmap(-1, size) 默认是 MAP_SHARED 匿名映射,走的是 shmem(tmpfs)那一路,页被记在 Pss_Shmem 而不是 Anonymous,THP 也归 shmem_enabled 管(本机是 never)。想要大页必须显式写 flags=MAP_PRIVATE|MAP_ANONYMOUS
  • hugetlbfs 是显式预留的另一条路:先 echo 32 > /proc/sys/vm/nr_hugepages 把页从伙伴系统里抽走,再挂载使用。实测 8MiB 文件正好吃掉 4 个页,HugePages_Free 32 → 28
  • 不是普通文件系统dd 直接写(1M/2M/4M 三种块都试了)一律 Invalid argument,必须先 fallocate 预分配再 mmap 访问
  • 1GiB 大页在本机拿不到:nr_hugepages 写 4 后回读仍是 0,而 /proc/buddyinfo 显示最大的空闲连续块只有 order 10(4MiB)——1GiB 大页需要 order 18

Linux 大页内存:从透明大页 THP 到 hugetlbfs 显式预留

操作系统课上都讲过「一页是 4KiB」,这句话在 x86-64 上已经过时十年了。CPU 支持 2MiB 和 1GiB 的页,Linux 也一直在用,只是默认行为把它藏得很好——你 malloc 一大块内存,拿到的可能全是 4KiB 页,也可能是 2MiB 页,取决于三个你从来没打开过的文件。

大页不是「更大所以更快」这么简单,它解决的是一个具体的硬件瓶颈:地址翻译。本文在一台 Ubuntu 22.04(内核 5.15.0-30-generic、1 核、1.9G 内存、无 swap)的小机上,把透明大页和显式大页两条路都跑了一遍,包括几个和常见说法相反的实测结果。

先搞清楚大页在省什么

CPU 访问内存要先把虚拟地址翻译成物理地址,翻译结果缓存在 TLB(Translation Lookaside Buffer)里。TLB 是硬件,条目数固定且很少——典型的两级 TLB 加起来也就一两千条。

  • 用 4KiB 页:1GiB 工作集 = 262144 个页,TLB 只能记住其中一千多个,其余每次访问都要走一遍多级页表(一次 miss 意味着多出几次内存访问)。
  • 用 2MiB 页:1GiB 工作集 = 512 个页,TLB 装得下相当一部分,miss 率断崖式下降。

代价也很直接:大页是连续的,分配不到就只能退回 4KiB 页;而且 2MiB 页哪怕只用 1 字节,也占满 2MiB。所以这不是「开了就更快」的开关,而是一个需要按工作集特征做的取舍。内核给了两条路:透明大页(自动,THP)和显式大页(手动预留,hugetlbfs)。

三个开关决定透明大页怎么工作

透明大页(Transparent Huge Pages)的「透明」是指应用不用改代码——内核在缺页时自己决定给 4KiB 还是 2MiB。但「自动」不等于「无脑」,它的行为由三个文件控制:

uname -r
cat /sys/kernel/mm/transparent_hugepage/enabled
cat /sys/kernel/mm/transparent_hugepage/defrag
cat /sys/kernel/mm/transparent_hugepage/shmem_enabled
grep -E 'AnonHugePages|HugePages_Total|Hugepagesize|Hugetlb' /proc/meminfo
echo "--- 同一个计数器在两个文件里的可见性 ---"
echo "status 里 AnonHugePages 行数 = $(grep -c AnonHugePages /proc/self/status)"
echo "smaps_rollup 里 = $(grep AnonHugePages /proc/self/smaps_rollup)"

透明大页的三个策略文件与 /proc/meminfo 里的计数器,最后两行对比了 AnonHugePages 在两个 proc 文件里的可见性

三个文件的取值和含义:

文件取值含义
enabledalways / madvise / never全局策略:所有匿名映射都给、只给显式申请的区域、完全不给
defragalways / defer / defer+madvise / madvise / never内存不连续时,内核肯不肯花代价做内存规整(compaction)来凑出大页
shmem_enabledalways / within_size / advise / never / deny / force共享内存(tmpfs、MAP_SHARED 匿名映射)那一侧的开关

输出里的方括号就是当前值。这台机器是 always [madvise] neveralways defer defer+madvise [madvise] neveralways within_size advise [never] deny force——匿名内存只在显式申请时给大页,共享内存那一侧干脆关死。这是 Ubuntu 多年来的默认组合,也是后面所有现象的起点。

/proc/meminfo 里对应这几个计数器:

AnonHugePages:         0 kB     # 匿名内存里有多少是 2MiB 大页
HugePages_Total:       0        # hugetlbfs 显式预留的页数(下面再讲)
Hugepagesize:       2048 kB     # 默认大页尺寸
Hugetlb:               0 kB     # 显式预留占用的内存

⚠️ 这里有个小坑值得单独说。很多资料让你去 /proc/<pid>/statusgrep AnonHugePages,但截图最后两行是本文实测——这个字段在这台 5.15 内核的 status 里根本不存在grep -c 返回 0),它只出现在 /proc/<pid>/smaps_rollup 里。按进程看大页用量,认准 smaps_rollup

实测:拿到大页的分水岭是 madvise

光看开关不够,得实际分配一次。下面这段脚本分配 256MiB 匿名内存并把它真正写满(不写满就不会触发缺页,也就没有任何页会被分配),然后读出自己 smaps_rollup 里的三个字段:

cat > /tmp/thp_alloc.py <<'PY'
import ctypes, mmap, sys
SIZE = 256 * 1024 * 1024
mode = sys.argv[1]
libc = ctypes.CDLL("libc.so.6", use_errno=True)
m = mmap.mmap(-1, SIZE, flags=mmap.MAP_PRIVATE | mmap.MAP_ANONYMOUS)
addr = ctypes.addressof(ctypes.c_char.from_buffer(m))
if mode in ("huge", "nohuge"):
    rc = libc.madvise(ctypes.c_void_p(addr), ctypes.c_size_t(SIZE),
                      14 if mode == "huge" else 15)
    print("madvise(MADV_%s) -> rc=%d" % (mode.upper(), rc))
m.write(b"x" * SIZE)
for line in open("/proc/self/smaps_rollup"):
    if line.startswith(("Rss:", "Anonymous:", "AnonHugePages:")):
        print("   " + line.rstrip())
PY
for m in none huge nohuge; do
  echo "=== 分配前 madvise 建议: $m ==="
  python3 /tmp/thp_alloc.py $m
done
echo "=== 全局 THP 改成 always(defrag 保持 madvise)==="
echo always > /sys/kernel/mm/transparent_hugepage/enabled
python3 /tmp/thp_alloc.py none
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled
cat /sys/kernel/mm/transparent_hugepage/enabled
grep -E '^thp_fault_alloc|^thp_fault_fallback' /proc/vmstat

MADV_HUGEPAGE 与 MADV_NOHUGEPAGE 的对照实验:同样的 256MiB 分配,AnonHugePages 分别是 0、260096 kB、0;全局 always 模式下反而只有 57344 kB

三种情况对比得很干净:

madvise 建议RssAnonymousAnonHugePages
不给建议271612 kB265640 kB0 kB
MADV_HUGEPAGE271648 kB265644 kB260096 kB
MADV_NOHUGEPAGE271664 kB265644 kB0 kB

260096 kB ÷ 2048 kB = 127 个 2MiB 页,占这块内存的 98%——madvise(MADV_HUGEPAGE) 是这台机器上拿到大页的入口。

注意 RssAnonymous 在三种情况下几乎一样(27 万 kB),只有 AnonHugePages 这一行能看出差异。这也是排查时最容易看错的地方:内存占用没变,变的是这些页的「粒度」。

always 模式为什么反而更差

如果「大页更快」,那把全局策略改成 always 应该全场大页才对。实测正相反:同样是 256MiB、同样不给 madvise 建议,always 下只拿到 57344 kB(28 个页),不到 MADV_HUGEPAGE 那次的四分之一。

原因在 defrag 那一栏。这台机器是 madvise,语义是「只为显式 MADV_HUGEPAGE 的区域做内存规整」。于是:

  • enabled=madvise + 显式 MADV_HUGEPAGE → 缺页时内核愿意迁移/压缩内存去凑连续 2MiB → 成功率高。
  • enabled=always + 没有 madvise → 内核不会为它做规整,只能看手头有没有现成的连续块 → 凑不齐就退回 4KiB 页。

/proc/vmstat 里的两个计数器把这件事记录得很清楚:

thp_fault_alloc 629       # 累计成功拿到大页的次数
thp_fault_fallback 263    # 累计尝试后失败、退回 4KiB 页的次数

截图里这两个值是之前所有轮次累加的结果。要单看一轮的效果,跑之前先记一次、跑完再对比差值即可——fallback 在涨,就说明内存碎片正在拖后腿

一个容易踩的坑:Python 的 mmap 默认不是匿名内存

上面那段脚本里有一行是刻意写全的:

m = mmap.mmap(-1, SIZE, flags=mmap.MAP_PRIVATE | mmap.MAP_ANONYMOUS)

如果按最省事的写法 mmap.mmap(-1, SIZE),结果完全不同。因为 Python 的默认 flags 是 MAP_SHARED,而 MAP_SHARED | MAP_ANONYMOUS 在 Linux 上走的是 shmem(tmpfs)那一条路径,不算匿名内存。实测两者同分配 64MiB:

# mmap.mmap(-1, 64MiB)  —— 默认 MAP_SHARED
Rss:               74692 kB
Pss_Shmem:         65536 kB      ← 64MiB 全记在共享内存头上
Anonymous:          3260 kB
AnonHugePages:         0 kB

# mmap.mmap(-1, 64MiB, MAP_PRIVATE|MAP_ANONYMOUS)
Rss:               74580 kB
Pss_Shmem:             0 kB      ← 一个字节都不算共享内存
Anonymous:         68796 kB
AnonHugePages:         0 kB

两种写法的 Rss 几乎一样(74692 与 74580),但 Pss_Shmem 一个是 65536 kB、一个是 0——判断一段内存是不是匿名内存,要看 AnonymousPss_Shmem,不能看 Rss

对本文的主题来说这个区别是决定性的:共享内存那一侧归 shmem_enabled 管,而它是 never。也就是说,用默认 flags 写的程序,不管怎么 madvise 都拿不到大页

hugetlbfs:不走自动,直接预留

透明大页是「内核看着办」,另一条路是「提前把内存抽出来」:先用 nr_hugepages 把页从伙伴系统里预留成一个大页池,再挂载 hugetlbfs 使用。这条路不依赖 defrag、不受碎片影响,代价是这块内存在池子里闲着也不算「可用内存」。

echo 32 > /proc/sys/vm/nr_hugepages
grep -E 'HugePages_Total|HugePages_Free|Hugetlb' /proc/meminfo
mkdir -p /mnt/huge && mount -o size=64M -t hugetlbfs none /mnt/huge
df -h /mnt/huge
cat > /tmp/huge_write.py <<'PY'
import mmap, os
f = os.open("/mnt/huge/demo.bin", os.O_RDWR)
m = mmap.mmap(f, 8 << 20)
m.write(b"A" * (8 << 20))
print("mmap 写入 8MB 成功:", m[:8])
m.close(); os.close(f)
PY
dd if=/dev/zero of=/mnt/huge/demo.bin bs=2M count=1 status=none 2>&1; echo "dd 直接写 rc=$?"
fallocate -l 8M /mnt/huge/demo.bin; echo "fallocate 预分配 rc=$?"
python3 /tmp/huge_write.py
grep -E 'HugePages_Free' /proc/meminfo
echo 4 > /sys/kernel/mm/hugepages/hugepages-1048576kB/nr_hugepages
echo "写入返回码=$?,回读 nr_hugepages = $(cat /sys/kernel/mm/hugepages/hugepages-1048576kB/nr_hugepages)"
cat /proc/buddyinfo
rm -f /mnt/huge/demo.bin; umount /mnt/huge; echo 0 > /proc/sys/vm/nr_hugepages
grep -E 'HugePages_Total|Hugetlb' /proc/meminfo

hugetlbfs 显式大页实测:预留 32 个 2MiB 页、挂载后 df 报 64M;dd 直接写报 Invalid argument,fallocate + mmap 成功;1GiB 池写入返回 0 但回读仍是 0

预留完成后 HugePages_Total: 32HugePages_Free: 32Hugetlb: 65536 kB——注意 Hugetlb 这一项会直接占用内存,32 个页就是 64MiB 实打实地从可用内存里拿走了。

挂载参数里的 size=64M 不是装饰:不带它时 df 会报 Size 0(本文实测,同一台机器加上它才显示 64M),因为 hugetlbfs 的 statfs 在没设上限时不上报池子大小。

它只接受两种访问方式

接下来是这个文件系统最反直觉的地方。往 /mnt/huge 里写文件,dd 会直接失败:

dd: error writing '/mnt/huge/demo.bin': Invalid argument
dd 直接写 rc=1

本文试了 bs=1M count=2bs=2M count=1bs=4M count=2 三种块大小,一律 Invalid argument(所以这跟「写入是否按大页对齐」无关)。hugetlbfs 支持的只有两条路径:fallocate -l 8M 把文件按大页粒度预分配出来,再用 mmap 读写。截图里 fallocate 之后文件就是 8MiB,池子立刻少掉 4 个页(8MiB ÷ 2MiB),HugePages_Free 从 32 变成 28,随后 mmap 写入 8MiB 一切正常。

想让程序用上显式大页,就得走 fallocate + mmap,普通的 write()cpdd 一概不行。

1GiB 大页:写进去返回 0,回读还是 0

内核还支持 1GiB 大页(/sys/kernel/mm/hugepages/hugepages-1048576kB/),这台机器上它永远是 0。截图里那两行的结果是 写入返回码=0,回读 nr_hugepages = 0——sysfs 的写入返回码 0 只代表「内核接受了这次写入」,不代表「要的页真的拿到了」,判断成败必须回读。

拿不到的原因在 cat /proc/buddyinfo 里一目了然:

Node 0, zone    DMA32  12452   6632   2611    850    897    462    193    100     31      0     99

这一串是空闲块的个数,按 order 从 0 到 10 排列(order N 的块大小 = 2^N 个 4KiB 页)。最后几列的含义是:order 8(1MiB)还有 31 块,order 9(2MiB)已经是 0,order 10(4MiB)有 99 块。2MiB 大页需要 order 9 起步,1GiB 大页需要 order 18——这台 1.9G 的机器连一个 order 18 的连续块都凑不出来,那个 0 是必然结果。

两条路怎么选

透明大页(THP)显式大页(hugetlbfs)
谁来决定内核在缺页时自动决定你提前预留,程序显式使用
怎么触发全局 always,或代码里 madvise(MADV_HUGEPAGE)nr_hugepages + fallocate/mmap
内存占用的代价按需,失败自动退回 4KiB预留即占用,闲着也不算可用内存
受内存碎片影响是(可以靠 defrag 缓解)否(池子里的页永远是连续的)
适合通用服务、malloc 大户数据库、DPDK 这类要求确定性延迟的场景

大多数服务器只需要关心一件事:要不要在程序里加一句 madvise(MADV_HUGEPAGE)。加了通常就能拿到(本文实测 98%);不加,在这台机器的默认配置下就是 0。至于 always,它看起来最省事,实际效果却取决于 defrag 那一栏——两个开关是配套的,只改一个是本文实测到的最容易踩的坑。

小结

  • 大页省的是 TLB miss,不是带宽,所以它只对工作集远大于 TLB 覆盖范围的程序有意义。
  • Ubuntu 默认 enabled=madvise必须显式 madvise(MADV_HUGEPAGE),本文实测 256MiB 分配里 260096 kB 变成了大页。
  • always 不等于「一定有大页」,它和 defrag 要一起看;这台机器上 always 只拿到 22%。
  • 看大页用量认准 /proc/<pid>/smaps_rollup/proc/<pid>/status 里没有这个字段。
  • MAP_SHARED 匿名映射属 shmem,不归 AnonHugePages 管——写代码时 MAP_PRIVATE|MAP_ANONYMOUS 不能省。
  • hugetlbfs 是显式预留:fallocate + mmap 才行,ddEINVAL;sysfs 写成功不等于分配成功,要回读。

想继续往下挖内存这一层,可以配合看本站的 Linux 内存排查:从 free 到 /proc/meminfo 与 OOM Killer 定位(页缓存与 OOM 那条线)和 Linux cgroup 资源控制:从 CPU 与内存限制到 systemd 运行单元hugetlb 控制器的限额用法)。

参考资料:

发表评论

暂无评论,快来抢沙发吧!