Linux 内存管理深度剖析(五):HugePage 与透明大页(THP)实现原理

本文基于 Linux 6.4-rc1(commit ac9a78681b92)源码,所有代码片段均来自真实内核源文件。


一、为什么需要大页?

1.1 TLB Miss 的性能代价

CPU 访问内存经历两步:先查 TLB(Translation Lookaside Buffer)将虚拟地址翻译为物理地址,TLB 命中则直接访存;TLB Miss 时要走多级页表(x86-64 通常是 PGD → P4D → PUD → PMD → PTE),每级都是一次内存读操作,典型情况下一次 TLB Miss 耗费 50 ~ 100 个 CPU 周期,而 L1 Cache 命中只需 4 个周期。

x86-64 的虚拟地址翻译过程:一个 64 位虚拟地址被分割为 [PGD(9 bits)] [P4D(9 bits)] [PUD(9 bits)] [PMD(9 bits)] [PTE(9 bits)] [Offset(12 bits)]。每次缺 TLB 时,硬件 page table walker 依次访问四级页表,最多需要 4 次独立的内存访问(每次可能引发 L1/L2/L3 缓存缺失)。使用 2MB 大页时,翻译在 PMD 层终止,仅需访问 3 级页表;使用 1GB 大页时,在 PUD 层终止,仅需 2 级。

现代服务器 CPU 的 L2 TLB(STLB)通常有 1024 ~ 4096 个条目。以 4KB 页为单位,4096 个 TLB 条目只能覆盖 16 MB 地址空间。对于需要频繁访问数 GB 热数据的数据库引擎、JVM 堆、KVM 客户机内存,TLB Miss 率居高不下,成为主要性能瓶颈之一。

TLB 的组织结构通常分为两级:

  • L1 ITLB/DTLB:延迟 1~2 个周期,容量小(4KB 页通常 64 条目,2MB 大页 32 条目)。
  • L2 Unified TLB(STLB):延迟约 8 个周期,容量 1024~4096 条目,混合 4KB 和大页条目。
  • Page Table Walk:L2 TLB Miss 后触发,延迟 50~100 周期,受内存子系统影响巨大。

值得注意的是,大页在 L1 TLB 中通常独享专用条目,与 4KB 页分开管理,这意味着即便只使用少量大页,也能获得专属的 L1 TLB 保护,效益极高。

实测数据(Intel Xeon 4th Gen,64GB JVM 堆):

页大小 4KB 2MB 1GB
同等 TLB 条目覆盖范围 16 MB 8 GB 4 TB
TLB Miss 率 ~12% ~0.3% ~0%
吞吐量提升 基准 +18% +22%

1.2 页大小对比

页类型 大小 页表级别 arch 支持
普通页 4 KB PTE 所有
大页(Huge) 2 MB PMD x86-64, ARM64
巨页(Gigantic) 1 GB PUD x86-64

大页的关键优势在于:1 个 TLB 条目覆盖 2MB,等效于 512 个普通 PTE 条目,TLB 命中率大幅提升。

1.3 适合大页的场景

  • 数据库(Oracle、PostgreSQL、MySQL):Buffer Pool / Shared Memory 访问模式高度局部化,适合显式大页(HugeTLBFS)。
  • KVM 虚拟机:Host 端为 Guest 物理内存使用大页,EPT(Extended Page Table)条目减少,VM Exit 降低。
  • HPC / 科学计算:矩阵运算、FFT 等密集内存访问,THP 即可显著改善。
  • Java 应用:JVM 使用 -XX:+UseHugeTLBFS-XX:+UseTransparentHugePages 让 GC 管理的堆使用大页。

二、显式大页(HugeTLBFS)

HugeTLBFS 是 Linux 内核提供的显式大页机制。它的设计思路是:在系统初始化或运行时预先从 buddy 分配器申请若干连续大页,将它们维护在 hstate 的空闲链表中,然后通过一个伪文件系统(hugetlbfs)暴露给用户空间,用户以 mmap/shmget 等标准接口消费。

HugeTLBFS 的整体数据流如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
sysctl vm.nr_hugepages = 1024


set_max_huge_pages(h, 1024)


alloc_fresh_hugetlb_folio() ──→ buddy 分配 512 个连续 4KB 页
│ 组成 order-9 compound page

prep_new_hugetlb_folio() ──→ 设置 HUGETLB_PAGE_DTOR,加入 hstate


hstate.hugepage_freelists[nid] ← 空闲大页链表

用户 mmap(MAP_HUGETLB)


hugetlb_reserve_pages() ──→ 从空闲计数中预留,更新 resv_huge_pages


缺页 hugetlb_fault() ──→ 从 freelist 摘取大页,填写 PTE(PMD 级)

2.1 struct hstate:大页池管理核心

内核为每种大页尺寸维护一个 struct hstate,定义在 include/linux/hugetlb.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* include/linux/hugetlb.h: line 693 */
#define HSTATE_NAME_LEN 32
/* Defines one hugetlb page size */
struct hstate {
struct mutex resize_lock;
int next_nid_to_alloc;
int next_nid_to_free;
unsigned int order; /* compound order,2MB = order 9 */
unsigned int demote_order;
unsigned long mask; /* 地址对齐掩码 */
unsigned long max_huge_pages; /* 最大大页数量 */
unsigned long nr_huge_pages; /* 系统中已有大页总数 */
unsigned long free_huge_pages; /* 当前空闲大页数量 */
unsigned long resv_huge_pages; /* 已预留但未分配的大页 */
unsigned long surplus_huge_pages; /* 超额分配(按需从 buddy 借用)*/
unsigned long nr_overcommit_huge_pages;
struct list_head hugepage_activelist;
struct list_head hugepage_freelists[MAX_NUMNODES]; /* 每 NUMA 节点的空闲链表 */
unsigned int max_huge_pages_node[MAX_NUMNODES];
unsigned int nr_huge_pages_node[MAX_NUMNODES];
unsigned int free_huge_pages_node[MAX_NUMNODES];
unsigned int surplus_huge_pages_node[MAX_NUMNODES];
char name[HSTATE_NAME_LEN];
};

字段含义:

  • nr_huge_pages:全局大页池的页面总数(/proc/meminfo 中的 HugePages_Total)。
  • free_huge_pages:当前可分配数(HugePages_Free)。
  • resv_huge_pages:已经通过 mmap(MAP_HUGETLB) 预留但尚未发生缺页的数量(HugePages_Rsvd)。
  • surplus_huge_pages:在 max_huge_pages 基础上超额从 buddy 系统临时借用的页数(HugePages_Surp)。
  • hugepage_freelists:按 NUMA 节点组织的空闲链表,优先从本节点分配大页以降低跨节点访问代价。

全局数组 hstates[] 保存所有注册的 hstate(mm/hugetlb.c 第 52 行):

1
struct hstate hstates[HUGE_MAX_HSTATE];

系统中可以同时存在多个 hstate,每种大页尺寸(2MB、1GB)对应一个。default_hstate 是默认的 2MB 大页,用户通过 /proc/sys/vm/nr_hugepages 控制的就是这个默认 hstate。hugepage_subpool 是文件系统级别的子池,对 hugetlbfs 挂载点设置 sizemin_size 参数时,子池负责在全局池和文件系统之间进行二次分配和配额管理。

resv_mapfile_region 一起记录哪些页偏移范围已经预留了大页。对于共享映射(如 shmget + SHM_HUGETLB),resv_map 挂在 inode 上,多个映射共享同一 resv_map;对于私有映射(MAP_PRIVATE | MAP_HUGETLB),每个 VMA 拥有独立的 resv_map,确保 COW 语义下预留计数正确。

2.2 alloc_fresh_hugetlb_folio:从 buddy 分配 compound page

当大页池需要扩充时,调用 alloc_fresh_hugetlb_foliomm/hugetlb.c):

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
/* mm/hugetlb.c: line 2175 */
static struct folio *alloc_fresh_hugetlb_folio(struct hstate *h,
gfp_t gfp_mask, int nid, nodemask_t *nmask,
nodemask_t *node_alloc_noretry)
{
struct folio *folio;
bool retry = false;

retry:
if (hstate_is_gigantic(h))
folio = alloc_gigantic_folio(h, gfp_mask, nid, nmask);
else
folio = alloc_buddy_hugetlb_folio(h, gfp_mask,
nid, nmask, node_alloc_noretry);
if (!folio)
return NULL;
if (hstate_is_gigantic(h)) {
if (!prep_compound_gigantic_folio(folio, huge_page_order(h))) {
free_gigantic_folio(folio, huge_page_order(h));
if (!retry) {
retry = true;
goto retry;
}
return NULL;
}
}
prep_new_hugetlb_folio(h, folio, folio_nid(folio));
return folio;
}

对于 2MB 大页(非 gigantic),调用 alloc_buddy_hugetlb_folio,其核心是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* mm/hugetlb.c: line 2105 */
static struct folio *alloc_buddy_hugetlb_folio(struct hstate *h,
gfp_t gfp_mask, int nid, nodemask_t *nmask,
nodemask_t *node_alloc_noretry)
{
int order = huge_page_order(h); /* 2MB: order = 9 */
struct page *page;
...
gfp_mask |= __GFP_COMP|__GFP_NOWARN;
if (alloc_try_hard)
gfp_mask |= __GFP_RETRY_MAYFAIL;
...
page = __alloc_pages(gfp_mask, order, nid, nmask);
...
__count_vm_event(HTLB_BUDDY_PGALLOC);
return page_folio(page);
}

关键点:

  1. order = 9:从 buddy 分配 2^9 = 512 个连续物理页(2MB)。
  2. __GFP_COMP:将 512 个页面组成一个 compound page(复合页),head page 的 compound_order 设为 9,tail pages 指向 head。
  3. 分配后调用 prep_new_hugetlb_folio 设置析构函数(HUGETLB_PAGE_DTOR)并更新 hstate 计数器。

2.3 hugetlb_fault:大页缺页处理

用户访问 MAP_HUGETLB 区域触发缺页时,handle_mm_fault 会识别到该 VMA 对应 HugeTLBFS 并调用 hugetlb_faultmm/hugetlb.c):

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
/* mm/hugetlb.c: line 6057 */
vm_fault_t hugetlb_fault(struct mm_struct *mm, struct vm_area_struct *vma,
unsigned long address, unsigned int flags)
{
pte_t *ptep, entry;
spinlock_t *ptl;
vm_fault_t ret;
u32 hash;
pgoff_t idx;
struct page *page = NULL;
struct hstate *h = hstate_vma(vma);
...
/* 序列化同一 page 的并发缺页,防止竞态重复分配 */
mapping = vma->vm_file->f_mapping;
idx = vma_hugecache_offset(h, vma, haddr);
hash = hugetlb_fault_mutex_hash(mapping, idx);
mutex_lock(&hugetlb_fault_mutex_table[hash]);

hugetlb_vma_lock_read(vma);
ptep = huge_pte_alloc(mm, vma, haddr, huge_page_size(h));
if (!ptep) {
hugetlb_vma_unlock_read(vma);
mutex_unlock(&hugetlb_fault_mutex_table[hash]);
return VM_FAULT_OOM;
}

entry = huge_ptep_get(ptep);
if (huge_pte_none_mostly(entry))
/* PTE 为空:调用 hugetlb_no_page 完成物理页分配与映射 */
return hugetlb_no_page(mm, vma, mapping, idx, address, ptep,
entry, flags);
...
}

hugetlb_fault 使用 per-page 互斥锁hugetlb_fault_mutex_table,4096 个桶的哈希表)序列化对同一大页的并发缺页,避免重复分配。PTE 为空时转入 hugetlb_no_page 完成实际的页分配、页表填充工作。

2.4 hugetlb_reserve_pages:mmap 时的预留

调用 mmap(MAP_HUGETLB) 时,内核并不立即分配物理大页,而是先预留

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
/* mm/hugetlb.c: line 6845 */
bool hugetlb_reserve_pages(struct inode *inode,
long from, long to,
struct vm_area_struct *vma,
vm_flags_t vm_flags)
{
long chg = -1, add = -1;
struct hstate *h = hstate_inode(inode);
struct hugepage_subpool *spool = subpool_inode(inode);
struct resv_map *resv_map;
...
/* VM_NORESERVE:跳过预留,留到缺页时再尝试 */
if (vm_flags & VM_NORESERVE)
return true;

if (!vma || vma->vm_flags & VM_MAYSHARE) {
/* 共享映射:基于 inode resv_map 计算需新增预留数 */
resv_map = inode_resv_map(inode);
chg = region_chg(resv_map, from, to, &regions_needed);
} else {
/* 私有映射:完整预留整个区间 */
resv_map = resv_map_alloc();
...
chg = to - from;
set_vma_resv_map(vma, resv_map);
set_vma_resv_flags(vma, HPAGE_RESV_OWNER);
}
...
}

预留机制确保 mmap 成功即意味着将来的缺页一定能得到大页,避免在运行时因大页不足而 OOM。hstate.resv_huge_pages 记录当前预留数量,与 free_huge_pages 共同决定是否还能新增预留。

可分配的大页数量判断逻辑:当 free_huge_pages - resv_huge_pages > 0 时可以新增预留;当全局大页池耗尽但配置了 nr_overcommit_hugepages 时,允许临时从 buddy 分配额外的 surplus 大页(surplus_huge_pages),一旦使用完毕后归还 buddy 而不是放回大页池。这种 overcommit 机制可以平滑应对短时的大页需求峰值。

2.5 应用层使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <sys/mman.h>
#include <stdio.h>
#include <string.h>

#define HUGE_SIZE (2UL * 1024 * 1024) /* 2MB */
#define MAP_HUGE_2MB (21 << MAP_HUGE_SHIFT)

int main(void)
{
/* 系统准备:echo 64 > /proc/sys/vm/nr_hugepages */
void *p = mmap(NULL, HUGE_SIZE,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB | MAP_HUGE_2MB,
-1, 0);
if (p == MAP_FAILED) {
perror("mmap");
return 1;
}
memset(p, 0x42, HUGE_SIZE); /* 触发缺页,分配实际大页 */
printf("addr = %p\n", p);
munmap(p, HUGE_SIZE);
return 0;
}

通过 HugeTLBFS 挂载点也可以使用文件方式访问大页:

1
2
3
mkdir -p /mnt/hugepages
mount -t hugetlbfs nodev /mnt/hugepages
# 应用打开该目录下的文件并 mmap 即可得到大页映射

三、透明大页(THP)

3.1 工作原理

THP(Transparent Huge Pages)让内核无需应用修改就能自动使用 2MB 大页。其核心思想是:在缺页时直接分配 2MB PMD 大页;在进程运行期间,khugepaged 后台守护线程扫描已存在的 4KB 页,尝试将 512 个连续物理页合并(collapse)成一个 2MB 大页。

THP 与 HugeTLBFS 最本质的区别在于管理主体:HugeTLBFS 由用户空间显式控制大页的申请和使用;THP 完全由内核透明管理,用户程序无需感知大页的存在。这种透明性带来了极大的易用性,但也引入了新的复杂性——内核必须在合适的时机自动拆分和合并大页,而这些操作可能在应用程序的关键路径上产生意料之外的开销。

THP 支持两种内存类型:

  • 匿名内存(Anonymous):mmap(MAP_ANONYMOUS) 或进程堆/栈,是 THP 最主要的使用场景。
  • 共享内存(Shmem/tmpfs):/dev/shmmemfd_create 等,需要单独配置 /sys/kernel/mm/transparent_hugepage/shmem_enabled

文件背景内存(page cache)目前不支持 THP,因为文件系统的块 I/O 和页缓存管理对 2MB 粒度有较高的复杂度要求。

THP 的标志字变量(mm/huge_memory.c 第 57 行):

1
2
3
4
5
6
7
8
9
10
unsigned long transparent_hugepage_flags __read_mostly =
#ifdef CONFIG_TRANSPARENT_HUGEPAGE_ALWAYS
(1<<TRANSPARENT_HUGEPAGE_FLAG)|
#endif
#ifdef CONFIG_TRANSPARENT_HUGEPAGE_MADVISE
(1<<TRANSPARENT_HUGEPAGE_REQ_MADV_FLAG)|
#endif
(1<<TRANSPARENT_HUGEPAGE_DEFRAG_REQ_MADV_FLAG)|
(1<<TRANSPARENT_HUGEPAGE_DEFRAG_KHUGEPAGED_FLAG)|
(1<<TRANSPARENT_HUGEPAGE_USE_ZERO_PAGE_FLAG);

3.2 do_huge_pmd_anonymous_page:THP 缺页路径

当匿名映射发生缺页,且 VMA 满足 THP 条件(大小 >= 2MB、地址对齐)时,handle_mm_faultdo_huge_pmd_anonymous_pagemm/huge_memory.c):

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
/* mm/huge_memory.c: line 779 */
vm_fault_t do_huge_pmd_anonymous_page(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
gfp_t gfp;
struct folio *folio;
unsigned long haddr = vmf->address & HPAGE_PMD_MASK; /* 2MB 对齐 */

if (!transhuge_vma_suitable(vma, haddr))
return VM_FAULT_FALLBACK;
if (unlikely(anon_vma_prepare(vma)))
return VM_FAULT_OOM;
/* 将该 VMA 加入 khugepaged 扫描列表 */
khugepaged_enter_vma(vma, vma->vm_flags);

/* 只读缺页且允许 zero page:先映射 huge zero page */
if (!(vmf->flags & FAULT_FLAG_WRITE) &&
!mm_forbids_zeropage(vma->vm_mm) &&
transparent_hugepage_use_zero_page()) {
...
set_huge_zero_page(pgtable, vma->vm_mm, vma,
haddr, vmf->pmd, zero_page);
...
return ret;
}

/* 写缺页:分配真正的 2MB folio */
gfp = vma_thp_gfp_mask(vma);
folio = vma_alloc_folio(gfp, HPAGE_PMD_ORDER, vma, haddr, true);
if (unlikely(!folio)) {
count_vm_event(THP_FAULT_FALLBACK);
return VM_FAULT_FALLBACK; /* 回退到 4KB 页 */
}
return __do_huge_pmd_anonymous_page(vmf, &folio->page, gfp);
}

HPAGE_PMD_ORDER = 9vma_alloc_folio 从 buddy 分配 512 页连续物理内存。分配失败时以 VM_FAULT_FALLBACK 回退,内核继续处理 4KB 缺页,保证应用程序正常运行。

3.3 __do_huge_pmd_anonymous_page:填写 PMD 页表项

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
/* mm/huge_memory.c: line 651 */
static vm_fault_t __do_huge_pmd_anonymous_page(struct vm_fault *vmf,
struct page *page, gfp_t gfp)
{
struct vm_area_struct *vma = vmf->vma;
struct folio *folio = page_folio(page);
pgtable_t pgtable;
unsigned long haddr = vmf->address & HPAGE_PMD_MASK;
vm_fault_t ret = 0;

/* mem_cgroup 计费 */
if (mem_cgroup_charge(folio, vma->vm_mm, gfp)) {
folio_put(folio);
count_vm_event(THP_FAULT_FALLBACK_CHARGE);
return VM_FAULT_FALLBACK;
}

pgtable = pte_alloc_one(vma->vm_mm); /* 为 pte 页表页预留(COW 时拆分用)*/
...
clear_huge_page(page, vmf->address, HPAGE_PMD_NR); /* 清零 512 页 */
__folio_mark_uptodate(folio);

vmf->ptl = pmd_lock(vma->vm_mm, vmf->pmd);
if (unlikely(!pmd_none(*vmf->pmd))) {
goto unlock_release; /* 并发缺页已处理,放弃 */
} else {
pmd_t entry;
...
/* 构造 2MB PMD 表项 */
entry = mk_huge_pmd(page, vma->vm_page_prot);
entry = maybe_pmd_mkwrite(pmd_mkdirty(entry), vma);
folio_add_new_anon_rmap(folio, vma, haddr);
folio_add_lru_vma(folio, vma);
/* 将 pte 页表页存入 PMD "deposit" 供后续拆分使用 */
pgtable_trans_huge_deposit(vma->vm_mm, vmf->pmd, pgtable);
set_pmd_at(vma->vm_mm, haddr, vmf->pmd, entry);
update_mmu_cache_pmd(vma, vmf->address, vmf->pmd);
add_mm_counter(vma->vm_mm, MM_ANONPAGES, HPAGE_PMD_NR);
mm_inc_nr_ptes(vma->vm_mm);
spin_unlock(vmf->ptl);
count_vm_event(THP_FAULT_ALLOC);
}
return 0;
...
}

关键实现细节:

  1. mk_huge_pmd 在 PMD 表项中设置 _PAGE_PSE(Page Size Extension)位,告知 MMU 此 PMD 直接映射 2MB 物理页,不再向下走 PTE 级。
  2. pgtable_trans_huge_deposit 把预先分配的 PTE 页表页”存入” PMD 旁,为未来 COW 拆分时复用。
  3. set_pmd_at 是一个内存屏障写,确保在 TLB 更新前物理页内容(clear_huge_page 的零化)对所有 CPU 可见。

3.4 THP 的分裂(split_huge_page)

当需要对 THP 的一部分进行操作(如 munmap 非 2MB 对齐区域、部分 mprotect、发生 ptrace、被 KSM 扫描、内存迁移)时,必须先将 2MB THP 拆回 512 个 4KB 页。THP 的拆分分为两个层面:

  • PMD 级拆分__split_huge_pmd):只修改页表,将 PMD 大页表项拆成 512 个 PTE,物理页内存布局不变,compound page 继续存在。
  • 物理页级拆分split_huge_page_to_list):将 compound page 拆分为 512 个独立的 4KB struct page,更新 rmap、LRU、引用计数,物理内存组织发生实质变化。

两种拆分通常配合使用:先 PMD 级拆分解除大页表项,再视需要决定是否进行物理页级拆分。

入口函数 split_huge_page_to_listmm/huge_memory.c,第 2637 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int split_huge_page_to_list(struct page *page, struct list_head *list)
{
struct folio *folio = page_folio(page);
...
VM_BUG_ON_FOLIO(!folio_test_locked(folio), folio);
VM_BUG_ON_FOLIO(!folio_test_large(folio), folio);

if (folio_test_anon(folio)) {
anon_vma = folio_get_anon_vma(folio);
anon_vma_lock_write(anon_vma); /* 防止并发 split/collapse */
} else {
mapping = folio->mapping;
...
}
...
__split_huge_page(page, list, end); /* 核心拆分逻辑 */
...
}

__split_huge_page 将 compound page 的每个 tail page 重新初始化为独立的 4KB 页,依次更新 rmap、LRU 链表、引用计数,最后调用 __split_huge_page_tail 处理每个 tail page。

3.5 __split_huge_pmd:PMD 级拆分

在 COW 或 munmap 时,还需要在页表层面将 PMD 大页表项拆成 512 个 PTE:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* mm/huge_memory.c: line 2266 */
void __split_huge_pmd(struct vm_area_struct *vma, pmd_t *pmd,
unsigned long address, bool freeze, struct folio *folio)
{
spinlock_t *ptl;
struct mmu_notifier_range range;

/* 通知 MMU Notifier(如 KVM、IOMMU)此范围即将变更 */
mmu_notifier_range_init(&range, MMU_NOTIFY_CLEAR, 0, vma->vm_mm,
address & HPAGE_PMD_MASK,
(address & HPAGE_PMD_MASK) + HPAGE_PMD_SIZE);
mmu_notifier_invalidate_range_start(&range);
ptl = pmd_lock(vma->vm_mm, pmd);

if (pmd_trans_huge(*pmd) || pmd_devmap(*pmd) ||
is_pmd_migration_entry(*pmd)) {
if (folio && folio != page_folio(pmd_page(*pmd)))
goto out;
__split_huge_pmd_locked(vma, pmd, range.start, freeze);
}
out:
spin_unlock(ptl);
mmu_notifier_invalidate_range_only_end(&range);
}

__split_huge_pmd_locked 从 PMD 的 “deposit” 中取出预存的 PTE 页表页,填充 512 个 PTE 条目后将 PMD 表项替换为指向该 PTE 页表页的普通 PMD 指针,同时刷新 TLB。


四、THP 的 COW 处理

4.1 do_huge_pmd_wp_page:THP 写时复制

当父进程 fork() 后子进程写入 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
/* mm/huge_memory.c: line 1294 */
vm_fault_t do_huge_pmd_wp_page(struct vm_fault *vmf)
{
const bool unshare = vmf->flags & FAULT_FLAG_UNSHARE;
struct vm_area_struct *vma = vmf->vma;
struct folio *folio;
struct page *page;
unsigned long haddr = vmf->address & HPAGE_PMD_MASK;
pmd_t orig_pmd = vmf->orig_pmd;

vmf->ptl = pmd_lockptr(vma->vm_mm, vmf->pmd);
...
spin_lock(vmf->ptl);
...
page = pmd_page(orig_pmd);
folio = page_folio(page);

/* 快速路径:该页已经是排他匿名页(只有一个引用),直接标脏重用 */
if (PageAnonExclusive(page))
goto reuse;

...
/* 引用计数 == 1:可以直接 reuse(无其他进程共享)*/
if (folio_ref_count(folio) == 1) {
pmd_t entry;
page_move_anon_rmap(page, vma);
folio_unlock(folio);
reuse:
if (unlikely(unshare)) {
spin_unlock(vmf->ptl);
return 0;
}
entry = pmd_mkyoung(orig_pmd);
entry = maybe_pmd_mkwrite(pmd_mkdirty(entry), vma);
if (pmdp_set_access_flags(vma, haddr, vmf->pmd, entry, 1))
update_mmu_cache_pmd(vma, vmf->address, vmf->pmd);
spin_unlock(vmf->ptl);
return 0;
}

unlock_fallback:
folio_unlock(folio);
spin_unlock(vmf->ptl);
fallback:
/* 存在其他共享者:先拆分 THP,再走普通 4KB COW 路径 */
__split_huge_pmd(vma, vmf->pmd, vmf->address, false, NULL);
return VM_FAULT_FALLBACK;
}

COW 处理有两条路径:

  • 快速路径(reuse):若 THP 无共享(引用计数为 1 或 PageAnonExclusive),直接将 PMD 标为 dirty + writable,无需复制,O(1) 完成。
  • 慢速路径(fallback):存在共享时,调用 __split_huge_pmd 将 2MB PMD 拆成 512 个 PTE,然后返回 VM_FAULT_FALLBACK,由上层按 4KB 粒度完成 COW 复制,只复制实际被写的那 1 个 4KB 页。

这是 THP 相比 HugeTLBFS 的一个重要差异:HugeTLBFS 的 COW 必须复制整个 2MB 页(代价高昂),而 THP COW 可以回退到 4KB 粒度,只复制被写的页,节省 511 个页的内存复制开销

fork() 后的 THP 生命周期:父进程调用 fork() 时,子进程以写保护方式共享父进程的 THP,PMD 表项标记为只读。当任意一方发生写操作时,触发 do_huge_pmd_wp_page:若此时引用计数为 1(另一方已经退出),走快速路径直接解除写保护;若双方均存在,则拆分 PMD 并按 4KB 粒度 COW。这套机制使得 fork() + exec() 的典型模式(子进程很快 exec)不会引发大页整体复制,性能开销与 4KB 页一致。


五、khugepaged:后台大页合并

5.1 守护线程结构

khugepaged 是内核专用的后台线程,负责将已存在的 4KB 页合并为 2MB THP。它维护一个全局扫描游标:

1
2
3
4
5
6
/* mm/khugepaged.c: line 129 */
struct khugepaged_scan {
struct list_head mm_head; /* 所有候选 mm 的链表 */
struct khugepaged_mm_slot *mm_slot;/* 当前正在扫描的 mm */
unsigned long address; /* 当前扫描地址 */
};

主要调优参数(均可通过 sysfs 配置):

1
2
3
4
5
6
7
8
9
/* mm/khugepaged.c: line 70 */
/* default scan 8*512 pte (or vmas) every 30 second */
static unsigned int khugepaged_pages_to_scan __read_mostly;
static unsigned int khugepaged_scan_sleep_millisecs __read_mostly = 10000; /* 10s */
/* during fragmentation poll the hugepage allocator once every minute */
static unsigned int khugepaged_alloc_sleep_millisecs __read_mostly = 60000; /* 60s */
static unsigned int khugepaged_max_ptes_none __read_mostly;
static unsigned int khugepaged_max_ptes_swap __read_mostly;
static unsigned int khugepaged_max_ptes_shared __read_mostly;

5.2 khugepaged_scan_mm_slot:扫描 mm 槽位

每当进程调用 mmap 创建新的匿名 VMA 且满足 THP 条件时,khugepaged_enter_vma(由 do_huge_pmd_anonymous_page 调用,见上文 mm/huge_memory.c 第 790 行)会将该进程的 mm_struct 注册到 khugepaged_scan.mm_head 链表。khugepaged 线程从该链表轮询,依次扫描每个 mm 中的 VMA。

khugepaged_do_scan 循环调用 khugepaged_scan_mm_slot

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
/* mm/khugepaged.c: line 2420 */
static unsigned int khugepaged_scan_mm_slot(unsigned int pages, int *result,
struct collapse_control *cc)
{
struct vma_iterator vmi;
struct khugepaged_mm_slot *mm_slot;
struct mm_struct *mm;
struct vm_area_struct *vma;
int progress = 0;
...
mm = slot->mm;
if (unlikely(!mmap_read_trylock(mm)))
goto breakouterloop_mmap_lock; /* 锁竞争时跳过本 mm */

vma_iter_init(&vmi, mm, khugepaged_scan.address);
for_each_vma(vmi, vma) {
...
if (!hugepage_vma_check(vma, vma->vm_flags, false, false, true))
goto skip;

hstart = round_up(vma->vm_start, HPAGE_PMD_SIZE);
hend = round_down(vma->vm_end, HPAGE_PMD_SIZE);
...
while (khugepaged_scan.address < hend) {
...
*result = hpage_collapse_scan_pmd(mm, vma,
khugepaged_scan.address,
&mmap_locked, cc);
...
}
}
...
}

5.3 hpage_collapse_scan_pmd:检查 PMD 区域是否可合并

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
/* mm/khugepaged.c: line 1237 */
static int hpage_collapse_scan_pmd(struct mm_struct *mm,
struct vm_area_struct *vma,
unsigned long address, bool *mmap_locked,
struct collapse_control *cc)
{
pmd_t *pmd;
pte_t *pte, *_pte;
int result = SCAN_FAIL, referenced = 0;
int none_or_zero = 0, shared = 0;
...
pte = pte_offset_map_lock(mm, pmd, address, &ptl);
/* 扫描 512 个 PTE */
for (_address = address, _pte = pte; _pte < pte + HPAGE_PMD_NR;
_pte++, _address += PAGE_SIZE) {
pte_t pteval = *_pte;
if (is_swap_pte(pteval)) {
++unmapped;
if (!cc->is_khugepaged ||
unmapped <= khugepaged_max_ptes_swap) {
continue; /* 允许少量 swap 页 */
} else {
result = SCAN_EXCEED_SWAP_PTE;
goto out_unmap;
}
}
if (pte_none(pteval) || is_zero_pfn(pte_pfn(pteval))) {
++none_or_zero;
if (!userfaultfd_armed(vma) &&
(!cc->is_khugepaged ||
none_or_zero <= khugepaged_max_ptes_none)) {
continue; /* 允许少量空页 */
} else {
result = SCAN_EXCEED_NONE_PTE;
goto out_unmap;
}
}
...
if (pte_write(pteval))
writable = true;
...
}
...
}

扫描逻辑:

  1. 逐一检查 512 个 PTE,允许一定数量的 swap 页(khugepaged_max_ptes_swap)和空页(khugepaged_max_ptes_none)存在。
  2. 若 PTE 带有 uffd-wp(userfaultfd 写保护)则放弃合并。
  3. 通过检查后,调用 collapse_huge_page 分配新 2MB 大页,将 512 个 4KB 页的内容复制进去,替换 PMD 表项。

5.4 collapse_huge_page:实际合并流程

hpage_collapse_scan_pmd 检查通过后,由 collapse_huge_pagemm/khugepaged.c 第 1079 行)完成实际的合并操作:

  1. 分配新 2MB 大页:调用 alloc_charge_hpage 从 buddy 分配 order-9 页面。
  2. 隔离 512 个 4KB 页:调用 __collapse_huge_page_isolate,逐页从 LRU 链表摘除,检查引用计数,处于 swap 中的页面执行 swapin__collapse_huge_page_swapin)。
  3. 复制内容__collapse_huge_page_copy 将 512 个 4KB 页的内容逐页复制进新的 2MB folio。
  4. 替换页表:在 mmap_write_lock 保护下,用一条 PMD 大页表项替换原来的 512 个 PTE,刷新 TLB。
  5. 释放旧 4KB 页:原来的 512 个 4KB 物理页引用计数归零后归还 buddy。

整个合并过程需要持有目标 mmmmap_write_lock,因此对应用程序有短暂的阻塞影响(通常微秒级)。这也是为什么高延迟敏感场景建议关闭 khugepaged 的主要原因——不可预知的合并时机会引入随机延迟。


六、THP 控制参数

6.1 sysfs 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 查看当前模式
cat /sys/kernel/mm/transparent_hugepage/enabled
# always [madvise] never
# always: 对所有匿名映射启用 THP
# madvise: 仅对 madvise(MADV_HUGEPAGE) 标记的区域启用
# never: 完全禁用 THP

# 内存碎片整理策略
cat /sys/kernel/mm/transparent_hugepage/defrag
# always defer defer+madvise [madvise] never
# always: 缺页时同步等待内存压缩,延迟高
# defer: 唤醒 kcompactd,缺页失败直接回退 4KB
# madvise: 仅对 MADV_HUGEPAGE 区域同步压缩
# never: 从不压缩,完全依赖物理连续内存

transparent_hugepage_flags 中各比特位对应 defrag 策略(mm/huge_memory.c 第 738 行 vma_thp_gfp_mask):不同 defrag 模式会向 vma_alloc_folio 传递不同的 gfp 标志,控制内存分配器的压缩行为。

6.2 madvise 精细控制

1
2
3
4
5
/* 对特定内存区域启用 THP(即使全局为 madvise 模式)*/
madvise(addr, len, MADV_HUGEPAGE);

/* 对特定区域禁用 THP(即使全局为 always 模式)*/
madvise(addr, len, MADV_NOHUGEPAGE);

在数据库场景中,通常对 Buffer Pool 使用 MADV_HUGEPAGE,对其他小内存结构使用 MADV_NOHUGEPAGE 避免碎片化。

6.3 khugepaged 调优参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# khugepaged 每次扫描多少页(默认 4096)
echo 4096 > /sys/kernel/mm/transparent_hugepage/khugepaged/pages_to_scan

# 两次扫描之间的睡眠时间(毫秒,默认 10000ms)
echo 10000 > /sys/kernel/mm/transparent_hugepage/khugepaged/scan_sleep_millisecs

# 分配大页失败后的等待时间(毫秒,默认 60000ms)
echo 60000 > /sys/kernel/mm/transparent_hugepage/khugepaged/alloc_sleep_millisecs

# 允许 512 个 PTE 中有多少个是空的(默认 511 表示几乎无限制)
echo 511 > /sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_none

# 允许 512 个 PTE 中有多少个在 swap 中
echo 0 > /sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_swap

七、HugePage 在数据库中的实践

7.1 显式大页配置

系统配置(持久化):

1
2
3
4
5
6
7
# 在 /etc/sysctl.conf 中设置
vm.nr_hugepages = 1024 # 预留 1024 个 2MB 大页(共 2GB)
vm.nr_overcommit_hugepages = 128 # 允许超额 128 个

# NUMA 场景下按节点分配
echo 512 > /sys/devices/system/node/node0/hugepages/hugepages-2048kB/nr_hugepages
echo 512 > /sys/devices/system/node/node1/hugepages/hugepages-2048kB/nr_hugepages

PostgreSQL 配置:

1
2
3
# postgresql.conf
huge_pages = on # 使用大页(MAP_HUGETLB)
shared_buffers = 2GB # 共享缓冲区,将全部由大页承载

PostgreSQL 在 shmget/mmap 共享内存时会优先传递 SHM_HUGETLB 标志;若大页不足则回退普通页(huge_pages = try)或直接报错退出(huge_pages = on)。

Oracle 数据库:

1
2
3
4
# /etc/security/limits.conf
oracle soft memlock unlimited
oracle hard memlock unlimited
# Oracle 使用 mlock + HugeTLBFS 锁定 SGA(System Global Area)

MySQL InnoDB(MariaDB 10.5+):

1
2
3
# my.cnf
large_pages = ON # 等同于 MAP_HUGETLB
innodb_buffer_pool_size = 16G

7.2 KVM 虚拟机使用大页

libvirt 配置(XML):

1
2
3
4
5
6
<memoryBacking>
<hugepages>
<page size="2048" unit="KiB" nodeset="0-1"/>
</hugepages>
<locked/> <!-- mlock,防止被换出 -->
</memoryBacking>

QEMU 会对 Guest RAM 执行 mmap(MAP_HUGETLB),EPT(Extended Page Table)中的 Level-2(对应 Host PMD)条目直接为 2MB 大页,减少 EPT 走表层级,VM Exit 频率可降低 10% ~ 30%。

7.3 THP 对数据库的负面影响

THP 对数据库工作负载有几个典型负面效应:

  1. 延迟抖动(Latency Spikes)khugepaged 合并时会短暂持有 anon_vma_lock_write,高并发场景下与应用线程竞争,产生几毫秒的随机延迟。
  2. 内存碎片化加剧:THP 需要连续 2MB 物理内存,频繁分配/释放后系统碎片化导致 THP 分配失败,触发同步内存压缩(defrag=always 时),造成长时间停顿。
  3. fork() COW 放大:若 THP 被多进程共享(如 PostgreSQL 的 fork() 后 worker),COW 时即便只修改 1 个字节也需拆分整个 2MB THP 的页表,增加额外开销。

数据库推荐配置:

1
2
3
4
5
6
7
8
9
10
11
# 方案一:完全关闭 THP(最保险)
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag

# 方案二:关闭 khugepaged 自动合并,但保留缺页时的 THP 分配
echo 0 > /sys/kernel/mm/transparent_hugepage/khugepaged/pages_to_scan

# 方案三(高级):仅对 Buffer Pool 使用显式大页,其余关闭 THP
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled
# 应用层对 Buffer Pool 调用 madvise(MADV_HUGEPAGE)
# 对其他内存调用 madvise(MADV_NOHUGEPAGE)

开机自动禁用(systemd):

1
2
3
4
5
6
7
8
9
10
11
# /etc/systemd/system/disable-thp.service
[Unit]
Description=Disable Transparent Huge Pages

[Service]
Type=oneshot
ExecStart=/bin/sh -c "echo never > /sys/kernel/mm/transparent_hugepage/enabled"
ExecStart=/bin/sh -c "echo never > /sys/kernel/mm/transparent_hugepage/defrag"

[Install]
WantedBy=multi-user.target

八、诊断与性能分析

8.1 /proc/meminfo 大页字段

1
grep -i huge /proc/meminfo

输出示例:

1
2
3
4
5
6
7
8
9
10
11
AnonHugePages:    614400 kB   # THP 匿名大页(单位 KB,614400/2048 = 300 个 2MB THP)
ShmemHugePages: 0 kB # shmem/tmpfs THP
ShmemPmdMapped: 0 kB
FileHugePages: 0 kB
FilePmdMapped: 0 kB
HugePages_Total: 1024 # HugeTLBFS 总大页数
HugePages_Free: 768 # 空闲大页数
HugePages_Rsvd: 64 # 已预留但未分配(mmap 已预留,缺页未触发)
HugePages_Surp: 0 # surplus 超额大页(临时从 buddy 借用)
Hugepagesize: 2048 kB
Hugetlb: 2097152 kB # HugeTLBFS 总占用内存

8.2 /proc/PID/smaps 进程级诊断

1
cat /proc/$(pgrep postgres | head -1)/smaps | grep -A20 "heap"

关键字段:

1
2
3
4
7f8000000000-7f8080000000 rw-p  ...  [heap]
Size: 524288 kB
AnonHugePages: 520192 kB # 该 VMA 中使用了多少 THP
THPeligible: 1 # 该区域是否满足 THP 条件(1=是)

8.3 /proc/sys/vm/nr_hugepages 动态调整

1
2
3
4
5
6
7
8
# 查看当前配置
cat /proc/sys/vm/nr_hugepages

# 运行时增加大页(需要足够的连续物理内存)
echo 2048 > /proc/sys/vm/nr_hugepages

# NUMA 节点专属配置
echo 1024 > /sys/devices/system/node/node0/hugepages/hugepages-2048kB/nr_hugepages

注意:大页预留应在系统启动早期(内存碎片化程度低时)完成;运行中的系统若内存碎片严重,增加 nr_hugepages 可能只能部分满足。

8.4 numastat 查看 per-NUMA 大页统计

1
numastat -m

输出:

1
2
3
4
5
                          Node 0          Node 1           Total
--------------- --------------- ---------------
HugePages_Total 512.00 512.00 1024.00
HugePages_Free 384.00 384.00 768.00
HugePages_Surp 0.00 0.00 0.00

NUMA 不均衡时,跨节点大页分配会增加内存访问延迟,应确保大页按应用所在 NUMA 节点均匀分配。

8.5 bpftrace 追踪 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
# 追踪 THP 缺页分配(do_huge_pmd_anonymous_page)
bpftrace -e '
kprobe:do_huge_pmd_anonymous_page {
@[comm] = count();
}
interval:s:5 {
print(@);
clear(@);
}
'

# 追踪 THP 分配失败(回退到 4KB 页)
bpftrace -e '
tracepoint:huge_memory:mm_khugepaged_scan_pmd_entry {
printf("khugepaged scan: %s pid=%d addr=%lx\n",
comm, pid, args->address);
}'

# 统计 THP 分配成功/失败事件
bpftrace -e '
tracepoint:huge_memory:mm_anon_huge_fault_alloc {
@thp_alloc[comm] = count();
}
tracepoint:huge_memory:mm_anon_huge_fault_fallback {
@thp_fallback[comm] = count();
}
END {
print(@thp_alloc); print(@thp_fallback);
}'

也可通过 /proc/vmstat 快速获取系统级 THP 统计:

1
grep -i thp /proc/vmstat
1
2
3
4
5
thp_fault_alloc              45231     # THP 缺页分配成功次数
thp_fault_fallback 3021 # THP 分配失败回退到 4KB 次数
thp_collapse_alloc 1284 # khugepaged 合并成功次数
thp_collapse_alloc_failed 87 # khugepaged 合并失败次数
thp_split_page 342 # THP 被拆分次数

thp_fault_fallback 占比高说明物理内存碎片严重,应考虑调整 defrag 策略或提前预留大页;thp_split_page 过高说明存在频繁 COW 或 munmap 非对齐区域,应排查应用的内存使用模式。


九、总结

Linux 大页机制从两个维度解决 TLB Miss 问题:

特性 HugeTLBFS(显式大页) THP(透明大页)
使用方式 需应用显式调用 MAP_HUGETLB 内核自动,应用无感知
支持大小 2MB、1GB 2MB(匿名/shmem)
内存锁定 预留后不可回收 可被拆分、换出
COW 代价 复制整个 2MB 回退 4KB,只复制被写的页
碎片影响 需启动时预留(影响小) 运行时分配,受碎片影响大
适用场景 数据库 Buffer、KVM Guest RAM HPC、JVM、通用服务
调优难度 中(需规划容量) 高(defrag/khugepaged 参数)

对于延迟敏感的数据库(Oracle、PostgreSQL),推荐:

  1. 使用 HugeTLBFS(显式 2MB 大页)承载 Buffer Pool/SGA。
  2. 关闭 THP 或至少关闭 khugepagedpages_to_scan=0),消除随机延迟抖动。

对于吞吐优先的 HPC、大内存 Java 应用,推荐:

  1. 开启 THP(enabled=alwaysenabled=madvise)。
  2. defrag=defer+madvise:避免同步内存压缩阻塞缺页路径。
  3. 适当增大 khugepaged/pages_to_scan,加快后台合并速度。

参考源文件:

  • mm/hugetlb.c
  • mm/huge_memory.c
  • mm/khugepaged.c
  • include/linux/hugetlb.h

延伸阅读: