Huge Page:TLB 压力和页表开销
上一篇讲了 NUMA 如何让内存访问不再均匀。但即使在单 node 系统上,当工作集足够大时,另一类开销浮现:TLB miss 和页表自身的内存消耗。Huge page 通过增大映射粒度来同时缓解这两个问题。
核心问题可以压成一句话:
huge page 用更大的映射粒度减少 TLB 和页表开销,但引入碎片、分配时机和回收复杂度作为代价。
问题从哪里来
标准页大小 4KB。一个 64GB 内存的机器有 1600 万个物理页。如果一个进程映射了 8GB 工作集,对应 200 万个 PTE。
TLB(Translation Lookaside Buffer)是 MMU 的地址翻译缓存。现代 CPU 的 L1 dTLB 通常只有 64-128 个条目,L2 sTLB 有 1024-2048 个条目。200 万个活跃页面对 2048 个 TLB 条目意味着 TLB 覆盖率不足 0.1%——绝大多数访问要走 page table walk。
page table walk 不是免费的。它需要 4-5 次内存访问(每级页表一次),即使有 page walk cache 辅助,在 TLB miss 频繁时仍然构成明显开销。实测中 TLB miss 导致的 stall 可以占到 CPU 时间的 10-30%(对大内存工作负载)。
页表本身也消耗内存。映射 8GB 需要的页表页数:PGD 1 页 + P4D 1 页 + PUD 若干 + PMD 若干 + PTE 若干。其中 PTE 层是大头:200 万个 PTE × 8 字节 = 约 16MB 纯页表内存。
如果把映射粒度从 4KB 提升到 2MB(PMD 级别),同样 8GB 只需要 4096 个映射条目。TLB 每个条目覆盖 2MB 而不是 4KB——覆盖率提升 512 倍。PTE 层完全不需要了,页表内存大幅减少。
这就是 huge page 的核心价值:用更大的映射粒度降低翻译开销。
两种 huge page 机制
Linux 提供两种不同的 huge page 支持,容易混淆:
| 维度 | HugeTLB pages | Transparent Huge Pages (THP) |
|---|---|---|
| 配置方式 | 启动时或运行时预留 | 自动分配 |
| 接口 | hugetlbfs + mmap |
普通 mmap/malloc,内核透明处理 |
| 大小 | 可选(2MB、1GB 等) | 默认 PMD 级(2MB),可选多种大小 |
| 预留 | 必须预留,保证可用 | 按需分配,可能失败 |
| 回收 | 不参与常规回收 | 可拆分、可回收 |
| 适用场景 | 数据库 buffer pool 等已知大块分配 | 通用工作负载透明优化 |
HugeTLB 是较早的机制,需要管理员显式预留页面。预留后这些内存不能被系统其他部分使用。适合明确知道需要大页的场景(如数据库预分配 shared buffer)。
THP 是后来引入的透明机制,对应用无感知。内核在 page fault 时尝试分配 huge page,失败则回退到普通 4KB 页。后台 khugepaged 线程扫描已映射的 4KB 页,尝试合并(collapse)成 huge page。
本篇主要讨论 THP,因为它与前面几篇讨论的页回收、LRU、NUMA 机制直接交互。
THP 的分配路径
THP 有两条主要的分配路径:
Fault-time allocation(缺页时分配):进程第一次访问一个 2MB 对齐且长度足够的匿名区域时,内核尝试直接分配一个 2MB 的 compound page。如果成功,一次 fault 建立整个 2MB 的映射。如果分配失败(没有连续的 2MB 物理内存),回退到 4KB 逐页 fault。
khugepaged collapse(后台合并):khugepaged 内核线程在后台扫描进程的页表。如果发现某个 PMD 范围内的 512 个 PTE 都已填充且可合并,就分配一个新的 order-9 compound page,把 512 个分散的 4KB 页数据复制过去,替换 512 个 PTE 为一个 PMD entry,再释放原来的 512 个 base page。原始 4KB 页不需要物理连续——连续性由新分配的 compound page 提供。
1 | |
Multi-size THP(mTHP)是较新的扩展,允许在 PMD 级别之外使用其他中间大小(16KB、32KB、64KB 等)。这降低了"要么 2MB 要么 4KB"的二选一限制——16KB 页面比 2MB 更容易分配到连续物理内存,同时仍然减少 4 倍 page fault。
THP 的配置
三种全局模式:
1 | |
| 模式 | 行为 |
|---|---|
always |
所有匿名映射都尝试使用 THP |
madvise |
只有标记了 MADV_HUGEPAGE 的区域使用 THP |
never |
禁用 THP fault-time 分配 |
defrag 参数控制分配 huge page 失败时是否触发内存压缩:
| 模式 | 行为 |
|---|---|
always |
直接做 compaction(可能阻塞,导致延迟尖刺) |
defer |
唤醒 kcompactd 后台压缩,不阻塞当前 fault |
madvise |
只对 MADV_HUGEPAGE 区域做直接 compaction |
never |
不做 compaction,只用当前可用的连续内存 |
生产系统中常见配置是 enabled=madvise + defrag=madvise:只对明确标记的区域积极分配 THP,避免全局的延迟抖动。
THP 的代价
THP 不是"总是优化"。它引入几类代价:
内存浪费:如果进程只访问 2MB 对齐区域中的少量 4KB 页面,整个 2MB 物理页仍然被占用。这在稀疏访问模式(如 JVM 的 G1 GC region 边界)下可能浪费大量内存。
分配延迟尖刺与 compaction:fault-time 分配 2MB 连续内存时,如果 buddy allocator 没有现成的 order-9 空闲块,内核会触发 compaction(内存碎片整理)。compaction 使用双向扫描:migrate scanner 从 zone 低地址向上寻找可移动页面,free scanner 从高地址向下寻找空闲 slot,通过迁移页面在低地址腾出连续物理区间。整个过程涉及页面迁移(分配目标页 → 复制数据 → 更新 rmap 和页表 → 释放原页),可能阻塞毫秒甚至十毫秒。如果 compaction 失败(compact_result 返回失败),内核回退到 direct reclaim 释放更多页面后重试。对延迟敏感的应用(交易系统、实时服务),这种不可预测的尖刺不可接受——这也是很多生产环境设置 transparent_hugepage=madvise 而不是 always 的原因。
拆分开销:当 huge page 中部分内容需要被 swap out、COW、或 NUMA 迁移时,整个 2MB 页面必须先拆分成 512 个 4KB 页面。拆分需要分配页表页、逐一建立 PTE、更新 rmap。这是一个 O(512) 的操作。
回收复杂度:huge page 的回收比 4KB 页面复杂。部分脏的 huge page 不能整体丢弃;部分被 mlock 的 huge page 不能整体移动。内核引入了 deferred split list 来延迟处理这些情况。
模式提炼:更大粒度降低元数据成本但提升分配风险
1 | |
| 维度 | 4KB 页 | 2MB huge page |
|---|---|---|
| TLB 条目覆盖 | 4KB | 2MB(512×) |
| 页表层级 | 需要 PTE 层 | 跳过 PTE 层 |
| page fault 频率 | 每 4KB 一次 | 每 2MB 一次 |
| 分配成功率 | 几乎总是成功 | 需要连续物理内存,可能失败 |
| 内存浪费 | 最多浪费 4KB-1 | 最多浪费 2MB-1 |
| 拆分代价 | 无 | O(512) |
这种粒度权衡在系统设计中反复出现:文件系统的 block size(4KB vs 64KB)、网络的 MTU(1500 vs 9000 jumbo)、数据库的 page size(4KB vs 16KB vs 32KB)。核心规律是:大粒度降低 per-unit 管理成本,但升高碎片和分配压力。
一个最小实验:观察 THP 的效果
这个实验分配大块匿名内存,比较启用和禁用 THP 时的行为差异。
1 | |
编译与运行:
1 | |
完成实验后清理:
1 | |
读取实验结果
THP 启用 vs 禁用时预期看到的差异:
Fault time:THP 启用时,每个 page fault 覆盖 2MB,fault 总次数从 65536 降到约 128(256MB / 2MB)。总 fault 时间可能更短(因为每次 fault 的固定开销被摊销),但单次 fault 延迟更长(需要分配 order-9 页面)。
AnonHugePages:THP 启用时,/proc/<pid>/smaps 中 AnonHugePages 接近 256MB。禁用时为 0。
Sequential access:THP 启用时,每个 TLB 条目覆盖 2MB,TLB miss 减少。在 TLB 容量有限的机器上,顺序遍历 256MB 时 THP 版本可能快 10-30%(取决于 TLB 大小和访问模式)。
关键观察:
AnonHugePages非零但小于Rss:说明只有部分区域成功获得了 huge page。未对齐或分配失败的区域仍用 4KB 页。- 如果
defrag=always且系统内存碎片化,fault time 可能出现大的尖刺——某些 fault 触发了 compaction。 /proc/vmstat中的thp_fault_alloc(成功)和thp_fault_fallback(回退到 4KB)直接反映 THP 分配的成功率。
模式提炼:透明优化依赖运行时条件
1 | |
THP 的"透明"意味着应用不需要修改代码,但不意味着效果有保证。是否能实际获得 huge page 取决于运行时的物理内存碎片程度。这和其他"透明优化"类似:TCP 的 TSO/GRO 透明于应用但依赖网卡支持、编译器的自动向量化透明于源码但依赖数据对齐。
核心代码:PMD 级 huge page 判定
THP 相关的 PMD 检测(include/linux/pgtable.h + mm/huge_memory.c,精简):
1 | |
_PAGE_PSE(Page Size Extension)位在 PMD 级别表示"这个 entry 直接映射 2MB,不再向下一级页表解引用"。
源码锚点
| 入口 | 读它的目的 |
|---|---|
mm/huge_memory.c |
do_huge_pmd_anonymous_page():THP fault-time 分配 |
mm/khugepaged.c |
khugepaged_scan_mm_slot():后台 collapse 扫描 |
mm/huge_memory.c |
split_huge_page_to_list():拆分路径 |
mm/hugetlb.c |
HugeTLB 预留页面管理(与 THP 不同) |
include/linux/huge_mm.h |
THP 相关宏和条件编译 |
读 do_huge_pmd_anonymous_page() 时可以带着一个问题:分配 order-9 页面失败后,fallback 路径如何确保进程不会因为"等待 compaction"而被阻塞过久?
研究生迁移表
| Linux 概念 | 一般系统设计 |
|---|---|
| huge page(2MB/1GB) | 大 block size(64KB filesystem block, jumbo frame) |
| TLB miss | cache miss(元数据缓存不命中) |
| page table walk | 多级索引查找 |
| THP fault-time allocation | 乐观大块分配(try big, fallback small) |
| khugepaged collapse | 后台碎片整理 / compaction |
| THP split | 大粒度降级为小粒度(分片 split) |
| defrag/compaction | 存储碎片整理 |
| mTHP(多种大小) | 多级 block size(adaptive block allocation) |
大粒度 vs 小粒度的权衡在任何分层存储系统中都存在。数据库用 large page 减少 buffer pool 管理开销、网络用 jumbo frame 减少 per-packet 处理、文件系统用 large block 减少 inode/extent 数量。核心取舍永远是:管理成本 vs 碎片浪费 vs 分配难度。
常见误解
第一个误解是把 THP 和 HugeTLB 混为一谈。THP 是透明的、自动的、可拆分的。HugeTLB 是显式预留的、应用需要通过 hugetlbfs 接口使用的、不参与常规回收的。两者在内核中走不同的代码路径。
第二个误解是认为 THP always 总是提升性能。对于稀疏访问模式(大量映射但只触碰少量页面)的工作负载,THP 可能浪费内存并增加 compaction 开销。很多生产系统明确设置 enabled=madvise 来避免这个问题。
第三个误解是认为 THP 不会被回收。THP 参与正常的 LRU 回收流程。当需要回收一个 THP 中的部分页面时,内核先 split 再按 4KB 粒度回收。deferred split list 允许延迟这个 split 操作。
第四个误解是认为 MADV_HUGEPAGE 保证获得 huge page。它只是一个提示——告诉内核这个区域值得尝试使用 THP。如果物理内存碎片化到无法分配 order-9 页面,提示不会生效。MADV_COLLAPSE 更强一些(主动触发 collapse),但仍可能因为内存不足而失败。
第五个误解是认为 THP 只有 2MB 一种大小。Multi-size THP(mTHP)引入了 16KB、32KB、64KB 等中间大小。这些较小的 huge page 更容易分配成功,对 TLB 仍有帮助(某些架构支持 contiguous PTE hint),且浪费上限更小。
练习
第一,在一台 Linux 机器上查看 THP 当前配置:读取 /sys/kernel/mm/transparent_hugepage/ 下的 enabled、defrag、hpage_pmd_size。查看 /proc/meminfo 中 AnonHugePages 和 HugePages_Total 的值。
第二,按文中实验分别在 enabled=always 和 enabled=never 下运行,记录 fault time、AnonHugePages、sequential access time 的差异。计算 TLB 覆盖率的理论差异(TLB 条目数 × 页大小 / 工作集大小)。
第三,运行实验时同时观察 /proc/vmstat 中 thp_fault_alloc、thp_fault_fallback、thp_collapse_alloc、thp_split_page 的变化。解释每个计数器在实验的哪个阶段增长。
第四,阅读 mm/huge_memory.c 中 split_huge_page_to_list() 的实现。列出 split 过程中需要做的所有操作:页表修改、rmap 更新、LRU 调整、引用计数变化。
第五,在一个 Java 应用(如 Elasticsearch)运行时检查其 /proc/<pid>/smaps 中 AnonHugePages 的分布。对比设置 MADV_HUGEPAGE(通过 -XX:+UseTransparentHugePages)前后的内存布局差异。
系列导航
- 上一篇:NUMA:内存为什么有远近
- 本文:Huge Page:TLB 压力和页表开销
- 下一篇:Buddy system:物理页如何按阶分配
