本文要点
- 大页解决的是 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_Free32 → 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)"
三个文件的取值和含义:
| 文件 | 取值 | 含义 |
|---|---|---|
enabled | always / madvise / never | 全局策略:所有匿名映射都给、只给显式申请的区域、完全不给 |
defrag | always / defer / defer+madvise / madvise / never | 内存不连续时,内核肯不肯花代价做内存规整(compaction)来凑出大页 |
shmem_enabled | always / within_size / advise / never / deny / force | 共享内存(tmpfs、MAP_SHARED 匿名映射)那一侧的开关 |
输出里的方括号就是当前值。这台机器是 always [madvise] never、always defer defer+madvise [madvise] never、always 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>/status 里 grep 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
三种情况对比得很干净:
| madvise 建议 | Rss | Anonymous | AnonHugePages |
|---|---|---|---|
| 不给建议 | 271612 kB | 265640 kB | 0 kB |
MADV_HUGEPAGE | 271648 kB | 265644 kB | 260096 kB |
MADV_NOHUGEPAGE | 271664 kB | 265644 kB | 0 kB |
260096 kB ÷ 2048 kB = 127 个 2MiB 页,占这块内存的 98%——madvise(MADV_HUGEPAGE) 是这台机器上拿到大页的入口。
注意 Rss 和 Anonymous 在三种情况下几乎一样(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——判断一段内存是不是匿名内存,要看 Anonymous 和 Pss_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
预留完成后 HugePages_Total: 32、HugePages_Free: 32、Hugetlb: 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=2、bs=2M count=1、bs=4M count=2 三种块大小,一律 Invalid argument(所以这跟「写入是否按大页对齐」无关)。hugetlbfs 支持的只有两条路径:先 fallocate -l 8M 把文件按大页粒度预分配出来,再用 mmap 读写。截图里 fallocate 之后文件就是 8MiB,池子立刻少掉 4 个页(8MiB ÷ 2MiB),HugePages_Free 从 32 变成 28,随后 mmap 写入 8MiB 一切正常。
想让程序用上显式大页,就得走 fallocate + mmap,普通的 write()、cp、dd 一概不行。
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才行,dd会EINVAL;sysfs 写成功不等于分配成功,要回读。
想继续往下挖内存这一层,可以配合看本站的 Linux 内存排查:从 free 到 /proc/meminfo 与 OOM Killer 定位(页缓存与 OOM 那条线)和 Linux cgroup 资源控制:从 CPU 与内存限制到 systemd 运行单元(hugetlb 控制器的限额用法)。
参考资料:
评论 (0)
暂无评论,快来抢沙发吧!