上一篇讲了 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
2
3
4
5
6
7
8
9
10
11
12
13
fault-time path:
page fault on 2MB-aligned address
-> try alloc_pages(order=9) [2MB = 512 × 4KB = order 9]
-> success? install PMD entry (one TLB entry covers 2MB)
-> fail? fallback to 4KB page

khugepaged path:
scan process page tables
-> find PMD with 512 base pages all present and mergeable
-> allocate 2MB compound page
-> copy 512 × 4KB into 2MB page
-> replace 512 PTEs with one PMD entry
-> free original 512 base pages

Multi-size THP(mTHP)是较新的扩展,允许在 PMD 级别之外使用其他中间大小(16KB、32KB、64KB 等)。这降低了"要么 2MB 要么 4KB"的二选一限制——16KB 页面比 2MB 更容易分配到连续物理内存,同时仍然减少 4 倍 page fault。

THP 的配置

三种全局模式:

1
2
cat /sys/kernel/mm/transparent_hugepage/enabled
# [always] madvise never
模式 行为
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
larger granularity -> lower metadata cost + higher allocation risk
维度 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <time.h>
#include <unistd.h>

static long diff_ns(struct timespec *start, struct timespec *end) {
return (end->tv_sec - start->tv_sec) * 1000000000L +
(end->tv_nsec - start->tv_nsec);
}

static void show_smaps_thp(void) {
char path[64];
snprintf(path, sizeof(path), "/proc/%d/smaps_rollup", getpid());
FILE *f = fopen(path, "r");
if (!f) {
/* fallback: parse smaps manually */
snprintf(path, sizeof(path), "/proc/%d/smaps", getpid());
f = fopen(path, "r");
if (!f) return;
}
char line[256];
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, "AnonHugePages:", 14) == 0 ||
strncmp(line, "Rss:", 4) == 0) {
printf(" %s", line);
}
}
fclose(f);
}

int main(void) {
size_t size = 256UL * 1024 * 1024; /* 256MB */
long page_size = sysconf(_SC_PAGESIZE);

printf("Allocating %zu MB with mmap(MAP_ANONYMOUS)\n", size >> 20);

char *buf = mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
if (buf == MAP_FAILED) { perror("mmap"); return 1; }

/* 可选:标记为适合 THP */
madvise(buf, size, MADV_HUGEPAGE);

/* 测量首次写入(触发 page fault)的耗时 */
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (size_t off = 0; off < size; off += (size_t)page_size)
buf[off] = (char)(off & 0xFF);
clock_gettime(CLOCK_MONOTONIC, &t1);

long fault_time_ms = diff_ns(&t0, &t1) / 1000000;
long pages_faulted = (long)(size / (size_t)page_size);
printf("Fault time: %ld ms for %ld pages (~%ld ns/page)\n",
fault_time_ms, pages_faulted,
diff_ns(&t0, &t1) / pages_faulted);

printf("Memory stats:\n");
show_smaps_thp();

/* 测量顺序访问延迟(TLB 效果) */
volatile char sink = 0;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (int r = 0; r < 50; r++) {
for (size_t off = 0; off < size; off += (size_t)page_size)
sink += buf[off];
}
clock_gettime(CLOCK_MONOTONIC, &t1);
(void)sink;

printf("Sequential access: %ld ms (50 rounds)\n",
diff_ns(&t0, &t1) / 1000000);

munmap(buf, size);
return 0;
}

编译与运行:

1
2
3
4
5
6
7
8
9
10
11
12
cc -Wall -Wextra -O2 thp_demo.c -o thp_demo

# 启用 THP 运行
echo always | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
./thp_demo

# 禁用 THP 运行
echo never | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
./thp_demo

# 恢复默认
echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/enabled

完成实验后清理:

1
rm -f thp_demo thp_demo.c

读取实验结果

THP 启用 vs 禁用时预期看到的差异:

Fault time:THP 启用时,每个 page fault 覆盖 2MB,fault 总次数从 65536 降到约 128(256MB / 2MB)。总 fault 时间可能更短(因为每次 fault 的固定开销被摊销),但单次 fault 延迟更长(需要分配 order-9 页面)。

AnonHugePages:THP 启用时,/proc/<pid>/smapsAnonHugePages 接近 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
2
THP enabled ≠ THP effective
effective THP requires: alignment + contiguous memory + no split triggers

THP 的"透明"意味着应用不需要修改代码,但不意味着效果有保证。是否能实际获得 huge page 取决于运行时的物理内存碎片程度。这和其他"透明优化"类似:TCP 的 TSO/GRO 透明于应用但依赖网卡支持、编译器的自动向量化透明于源码但依赖数据对齐。

核心代码:PMD 级 huge page 判定

THP 相关的 PMD 检测(include/linux/pgtable.h + mm/huge_memory.c,精简):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* 判断 PMD 是否指向一个 transparent huge page */
static inline int pmd_trans_huge(pmd_t pmd) {
return (pmd_val(pmd) & (_PAGE_PSE | _PAGE_PRESENT))
== (_PAGE_PSE | _PAGE_PRESENT);
}

/* THP page fault 入口 */
vm_fault_t do_huge_pmd_anonymous_page(struct vm_fault *vmf) {
/* 尝试分配 order-9 页面 */
folio = vma_alloc_folio(gfp, HPAGE_PMD_ORDER, vma, haddr);
if (!folio)
goto fallback; /* 回退到普通 4KB fault */
...
}

_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/ 下的 enableddefraghpage_pmd_size。查看 /proc/meminfoAnonHugePagesHugePages_Total 的值。

第二,按文中实验分别在 enabled=alwaysenabled=never 下运行,记录 fault time、AnonHugePages、sequential access time 的差异。计算 TLB 覆盖率的理论差异(TLB 条目数 × 页大小 / 工作集大小)。

第三,运行实验时同时观察 /proc/vmstatthp_fault_allocthp_fault_fallbackthp_collapse_allocthp_split_page 的变化。解释每个计数器在实验的哪个阶段增长。

第四,阅读 mm/huge_memory.csplit_huge_page_to_list() 的实现。列出 split 过程中需要做的所有操作:页表修改、rmap 更新、LRU 调整、引用计数变化。

第五,在一个 Java 应用(如 Elasticsearch)运行时检查其 /proc/<pid>/smapsAnonHugePages 的分布。对比设置 MADV_HUGEPAGE(通过 -XX:+UseTransparentHugePages)前后的内存布局差异。

系列导航

参考资料