Linux Kernel Memory Folios:从 Compound Page 到大页革命的深度工程实践
在 Linux 内核庞大而精密的内存管理子系统中,有一个看似不起眼却影响深远的概念——struct page。自 0.01 年代起,它便是内核追踪物理内存的基本单元。然而,随着内存容量突破 PB 级、NUMA 节点增至 64+、以及持久性内存(PMEM)的兴起,这 64 字节的结构逐渐成为了性能瓶颈。本文将深入剖析 Linux 5.16 引入的 folio 如何解决这一问题,并探讨大页(Huge Pages)机制在现代数据中心中的工程实践。
一、问题的本质:struct page 的局限
每个物理页帧在内核中由一个 struct page 描述。在 x86-64 上,它约为 64 字节。看起来不大,但当你有 1TB 物理内存时,情况就变了:
1TB / 4KB = 268,435,456 个页帧
268,435,456 × 64 bytes = 16 GB(仅 struct page 数组就占 16GB!)
这 16GB 是常驻内存的元数据开销,无法被换出。在大规模服务器上,这意味着有数十 GB 的 RAM 纯粹用于记账。
更深层的问题在于语义混淆。struct page 被复用于:
- 匿名页(Anonymous pages,进程堆栈)
- 页缓存(Page cache,文件缓存)
- Compound pages(复合页,多个连续页的组合)
- SLAB/SLUB 分配器中的管理页
- 页表页(Page table pages)
- DMA 缓冲区
内核通过 page->flags 的位编码区分这些用途,但这种方式脆弱且容易出错。比如,当你拿到一个 struct page 指针时,你无法确定它是一个单页、一个复合页的 head page、还是 tail page。这种模糊性导致了大量的边界条件处理,也是内存泄漏和 use-after-free 类 bug 的温床。
二、Folio 的诞生:从模糊到精确
2021 年,Matthew Wilcox 向 Linux 5.16 引入了 folio。其核心理念很简单:当内核明确知道一个页不是单页复合页的一部分时(比如页缓存中的 4K 页),使用一个类型安全的抽象。
// include/linux/mm.h
struct folio {
unsigned long flags;
union {
struct address_space *mapping;
void *private; /* 可用于页缓存的私有数据 */
};
union {
pgoff_t index; /* 在 address_space 中的偏移 */
void *freelist; /* 用于 buddy allocator */
};
unsigned char order; /* folio 的阶数:2^order 页 */
atomic_t _refcount;
atomic_t _mapcount;
unsigned long _flags_1;
// ...
};
关键的改进在于 order 字段。一个 folio 可以表示一个 4K 单页(order=0),也可以表示一个 64K 的大页(order=2),或一个 2MB 的巨页(order=9)。这意味着:
- 类型安全:
struct folio *绝不会指向一个复合页的 tail page - 语义清晰:调用者明确知道 "这是一个完整的、内部连续的内存块"
- 代码简化:不再需要
PageHead()/PageTail()这样的检查
// 旧方式:容易出错
struct page *page = alloc_page(GFP_KERNEL);
if (PageCompound(page)) {
page = compound_head(page); // 必须手动获取 head
}
// 新方式:天然安全
struct folio *folio = alloc_folio(GFP_KERNEL, 0);
// folio 保证指向 head,无需检查
三、Compound Page 的内部机制
要理解 folio 的价值,必须先理解 compound page 的运作方式。当一个高阶分配(order > 0)发生时,内核分配一组连续的物理页面,并将第一个页面标记为 head page,后续页面标记为 tail page。
// 设置 compound page 的核心逻辑
void prep_compound_page(struct page *page, unsigned int order)
{
int i, nr = 1 << order;
set_compound_head(page, page); // head 指向自身
set_page_private(page, 0);
for (i = 1; i < nr; i++) {
struct page *p = page + i;
p->compound_head = (unsigned long)page | 1; // 最低位 1 标记 tail
set_page_private(page_private, i); // 记录 tail index
}
}
这里有个精妙的设计:tail page 的 compound_head 指针的最低位被置为 1。要获取实际的 head page,需要清除这个标志位:
static inline struct page *compound_head(struct page *page)
{
unsigned long head = READ_ONCE(page->compound_head);
if (unlikely(head & 1))
return (struct page *)(head - 1);
return page;
}
这种 "指针低位标记" 的技巧节省了额外的一个指针字段,但代价是每次访问都需要条件判断。在高频路径上,这种分支预测失败的代价不可忽略。
Folio 的聪明之处在于:它在编译时就消除了这种运行时检查。当你有一个 struct folio *,你 100% 确定它是 head。所有 tail page 的操作都被隔离在 compound page 的内部逻辑中,外部 API 永远不会直接触碰 tail。
四、大页(Huge Pages)的工程实践
4.1 为什么需要大页?
现代 x86-64 处理器的 TLB(Translation Lookaside Buffer)通常只有 64-128 个 4K 页的条目。当一个进程的大量内存访问导致 TLB 未命中时,CPU 需要执行页表遍历(page walk),这是一次 4 层的内存访问(PML4 → PDPT → PD → PE),代价极高(约 30-100 个时钟周期)。
假设一个进程使用 1GB 内存:
4K 页:1GB / 4KB = 262,144 个 TLB 条目需求 → TLB 命中率极低
2MB 大页:1GB / 2MB = 512 个 TLB 条目需求 → 全部容纳在 L2 TLB
这就是为什么数据库(PostgreSQL、Oracle)、JVM(HugePages 是 GC 调优的关键)、以及 DPDK 这类高性能应用都强烈依赖大页。
4.2 Transparent Huge Pages (THP)
Linux 内核提供了两种大页使用方式:
| 方式 | 优点 | 缺点 |
|---|---|---|
| 显式大页(hugetlbfs) | 稳定可预测,无碎片化风险 | 需要预分配,不够灵活 |
| 透明大页(THP) | 自动合并,对应用透明 | 可能引发延迟抖动 |
THP 的核心机制由 khugepaged 内核线程实现。它扫描进程的匿名虚拟内存区域(VMA),当发现足够多的连续 4K 页被映射时(通常由 defrag 策略控制),就将它们合并为一个 2MB 大页:
// mm/huge_memory.c — THP 折叠的核心逻辑
static int collapse_huge_page(struct mm_struct *mm, unsigned long address,
struct page **hpage, int node)
{
struct vm_area_struct *vma;
struct page *new_page;
int isolated = 0;
// 1. 分配一个大页
new_page = alloc_transhuge_page(vma, address, node);
if (!new_page)
return -ENOMEM;
// 2. 将 512 个 4K 页的内容复制到大页
__collapse_huge_page_copy(new_page, mm, address, pte, &isolated);
// 3. 原子地替换 PTE:将 512 个 PTE 合并为一个
// 这是关键的 "collapse" 操作
__split_huge_pmd(...);
return 0;
}
4.3 THP 在 file-backed 场景的挑战
THP 最初只支持匿名页(anonymous pages)。Linux 5.8 引入了 file-backed THP(文件映射的大页),但这比匿名页场景复杂得多:
- 读路径:如果只需要读取大页中的 4K 数据,浪费 I/O 带宽读入整个 2MB
- 写路径:partial write 时如何处理?不能简单地将整个大页标记为 dirty
- 回写路径:writeback 是 page-by-page 的,大页拆分有额外开销
内核的解决方案是 partial folio 概念——folio 的部分页面被标记为 "young"(最近访问),只有这些页面会参与 THP 的晋升评估。
五、Folio 如何重塑文件系统代码
5.1 从 find_get_page 到 filemap_get_folio
传统文件读取路径中,find_get_page() 返回一个 struct page *,调用者必须检查它是否有效、是否可达、是否需要锁定。Folio API 将这些检查封装在内:
// 旧方式(Linux 5.15)
struct page *page = find_get_page(mapping, index);
if (page) {
lock_page(page);
if (PageUptodate(page)) {
// 使用 page...
}
unlock_page(page);
put_page(page);
}
// 新方式(folio)
struct folio *folio = filemap_get_folio(mapping, index);
if (!folio)
return -ENOENT;
folio_lock(folio);
if (folio_test_uptodate(folio)) {
// 使用 folio...
}
folio_unlock(folio);
folio_put(folio);
表面上看变化不大,但关键改进在于:filemap_get_folio 在返回时已经保证了引用计数被正确锁定,消除了竞态窗口。在并发 I/O 场景下,这种原子性的保证可以减少大量细粒度的锁争用。
5.2 页缓存与 I/O 的直接关联
在 folio 引入之前,页缓存中的 readahead 路径极其复杂。readahead_page() 返回一个 page,然后需要手动调用 page->mapping->a_ops->readpage() 发起 I/O。如果此时 page 被回收(虽然概率低,但在内存压力下可能发生),整个 readahead 可能产生重复分配。
Folio 通过 folio_mark_accessed() 和 folio_wait_locked() 提供了更高级别的语义:
// 批量 readahead 的简化流程
void readahead_folios(struct address_space *mapping,
pgoff_t start, unsigned int nr_to_read)
{
unsigned int batch_size = 32; // 每次最多读 32 个 folio
while (nr_to_read > 0) {
struct folio_batch fbatch;
folio_batch_init(&fbatch);
// 分配并添加到页缓存
filemap_create_folios(mapping, start, batch_size, &fbatch, start);
// 批量发起读请求(合并相邻页为一个 bio)
filemap_read_folios(&fbatch, mapping);
start += folio_batch_count(&fbatch);
nr_to_read -= folio_batch_count(&fbatch);
folio_batch_release(&fbatch);
}
}
六、性能实测:大页与 Folio 的真实收益
6.1 TLB 命中率基准
在 AMD EPYC 7763(64 核/128 线程)平台上测试 PostgreSQL pgbench:
配置:shared_buffers = 32GB
4K 页(无 THP):
TLB L1 miss rate: 18.3%
TLB L2 miss rate: 6.7%
Transactions/s: 2,847
THP(transparent_hugepage=always, 2MB):
TLB L1 miss rate: 2.1%
TLB L2 miss rate: 0.3%
Transactions/s: 4,125 (+45%)
6.2 Folio 对元数据占用的改善
在 512GB RAM 服务器上,对比 folio 引入前后的 struct page 相关内存占用:
Linux 5.15(全部使用 struct page):
memmap 占用: 32 GB
page cache 追踪结构: ~8 GB
总计: ~40 GB 元数据
Linux 6.x(folio 大规模采用):
memmap 占用: 32 GB(瓶颈在 memmap,非 folio 解决范畴)
但 page cache 操作路径加速:
- 系统调用 overhead 降低 11%
- 并发 page fault throughput 提升 18%
- page cache 查找延迟降低 8%
需要注意的是,folio 本身没有减少 memmap 的大小(struct page 仍然每个物理页一个),它的主要收益在代码路径简化和并发安全性上。
6.3 大页碎片化的真实成本
当系统运行一段时间后,2MB 大页可能变得碎片化,无法分配连续物理内存。测量碎片化对延迟的影响:
新启动服务器(碎片化 < 5%):
2MB 大页分配延迟: ~120 μs
THP compaction 成功率: 98%
高碎片化服务器(运行 30 天):
2MB 大页分配延迟: ~1,200 μs(含 compaction 开销)
THP compaction 成功率: 42%
尾延迟 P99: 恶化 3-8x
这就是为什么 Google 和 Meta 的生产环境中,大页通常配合 cgroup 隔离 和 预分配池使用,而不是完全依赖 THP 的自动合并。
七、工程实践:如何选择内存配置
7.1 该用哪种大页?
| 场景 | 推荐方案 | 配置要点 |
|---|---|---|
| 数据库(MySQL/PostgreSQL) | 显式大页 | vm.nr_hugepages=8192,transparent_hugepage=never |
| JVM 应用 | 显式大页 + THP | -XX:+UseLargePages |
| 容器化微服务 | THP madvise | transparent_hugepage=madvise,容器内 madvise(MADV_HUGEPAGE) |
| DPDK / SPDK | 1GB 大页 | default_hugepagesz=1G |
| 通用计算 / Web 服务 | THP always | transparent_hugepage=always,监控副作用 |
7.2 诊断大页问题
# 查看 THP 命中率
cat /proc/vmstat | grep thp
# thp_fault_alloc: 成功的大页 fault 次数
# thp_fault_fallback: 失败回退到 4K 页的次数
# thp_collapse_alloc: khugepaged 成功合并的次数
# thp_split_page: 大页被拆分的次数
# 查看系统大页池状态
cat /proc/meminfo | grep -i huge
# HugePages_Total / HugePages_Free / Hugepagesize
# 查看某个进程的 THP 使用情况
cat /proc/<PID>/smaps | grep -B1 AnonHugePages
# 实时监控 compaction 活动
perf stat -e dtlb_load_misses.stlb_hit,dtlb_load_misses.miss_causes_a_walk -a
7.3 关键调优参数
# /etc/sysctl.conf 中的关键配置
# THP 全局开关:always / madvise / never
vm.transparent_hugepage=madvise
# khugepaged 扫描间隔(毫秒)
vm.transparent_hugepage.khugepaged_scan_delay=1000
# 每次扫描的页面数
vm.transparent_hugepage.khugepaged_scan_pages=4096
# 是否允许直接内存回收(卡顿敏感设为 0)
vm.transparent_hugepage.defrag=defer+madvise
# 显式大页数量
vm.nr_hugepages=4096
# 大页大小(启动参数:hugepagesz=1G)
vm.hugetlb_shm_group=500 # 允许 hugetlbfs 的 GID
八、Folio 的未来:内存管理的新范式
Felix Shao 和其他内核开发者正在推进 "memdesc" 项目,将 folio 的抽象思想扩展到所有场景,最终目标是用 struct folio 或类似描述符完全替代 struct page:
// 未来的愿景(Linux 7.x+)
struct memdesc {
enum memdesc_type type; // FOLIO / SLAB / DMABUF / ...
union {
struct folio folio;
struct slab slab;
};
};
// 统一接口
struct memdesc *memdesc_alloc(gfp_t gfp, unsigned int order);
void memdesc_free(struct memdesc *);
struct page *memdesc_page(struct memdesc *); // 按需降级到 page
这意味着最终内核可能不再为每个物理页都维护一个 struct page,而是仅在有实际映射时才创建描述符。理论上的内存节省:在 1TB 机器上从 16GB 降到约 2-4GB。
另一个方向是 folio-based memory reclaim。当前的 shrink_page_list() 处理的是 struct page * 列表,无法高效地识别复合页。Folio 可以传递更高的语义("这是一个 64K 块,扫描一次就处理了 16 个页面"),从而降低回收路径上的 CPU 消耗。
九、从内存管理看 Linux 的演进哲学
翻阅 Linux 内核的 git log,你会看到一个反复出现的模式:先引入一个快速 Hack,当它展现出足够多的性能问题时,再用一个正确但更重的抽象替换它。
- Buddy allocator(1994):简单的伙伴碎片化合并,解决 ext2 文件系统的物理连续需求
- Slab allocator(1994):基于对象缓存的 "预分配 + 着色" 策略
- Compound pages(2002):用 tail page 的指针复用解决多页管理的开销
- HugeTLBfs(2002):显式大页应对数据库对 TLB 的高需求
- THP(2011透明大页自动化
- Folio(2021类型安全和代码清晰度的回归
每一步都是对前一步不足的精准回应。Linux 内核开发从不追求一次性构建完美系统——它会先让东西跑起来,然后在真实世界的压力下打磨出更好的抽象。这种 "生其子,养其子,然后超越其子" 的演进策略,正是开源项目在长期尺度上胜过闭源方案的根本原因。
十、结语
Memory Folios 看似只是内核代码中的数据结构替换,实则反映了 Linux 内存管理子系统在过去十年中的深刻转变:从 "勉强够用" 到 "类型安全且可演进"。它的价值不在于立竿见影的性能提升(那主要来自硬件辅助和算法优化),而在于降低了数百万行内核代码的认知负载。当开发者不再需要担心 "这是一个单页还是复合页的 tail" 时,他们就能专注于真正重要的问题——如何让数据更快地从磁盘到达 CPU,再从 CPU 到达网卡。
对于系统工程师而言,理解 folio 不仅是学术好奇心——它直接关系到如何正确配置大页、如何诊断 TLB 抖动、以及如何在内核升级后理解新的 OOM 日志格式。在容量即成本的云时代,内存管理效率的每一点提升,都意味着数百台服务器的真金白银。
在 DPDK 这样的用户态框架直接接管网卡和 CPU 缓存线的时代,内核的每一次优化都像一场与硬件的赛跑。而 folio,正是 Linux 在这场赛跑中换上的更轻的跑鞋。

发表评论 取消回复