Linux内核Memory Folios深度实战:从struct page到巨型页缓存的架构革命

Memory Folios 是 Linux 内核近年来最重要的内存管理重构之一,由 Matthew Wilcox(Oracle)自2019年起推动,以 v5.16(2022年1月)首次进入主线并持续演进。Folio 将"复合页/巨型页"与普通页统一为一种类型,消除了十多年来 page cache 与 compound page 之间的模糊边界,大幅简化了虚拟内存子系统的代码复杂度,并为未来的透明巨型页(THP)在 page cache 中的普及铺平了道路。本文将从设计哲学、数据结构、API迁移、文件系统适配、性能影响及生产实践等维度进行全景剖析。

一、问题的根源:struct page 的"身份危机"

1.1 struct page 的世界

在深入了解 folio 之前,必须先理解 Linux 内存管理最核心的数据结构——struct page。系统中的每一个物理页帧都对应一个 struct page,在 x86_64 架构上是 64 字节(含两个 union 加一个 unsigned long flags)。对于一台 128GB 内存的服务器,有 33,554,432 个页帧,page 结构体总内存占用约 2GB。

struct page 中最关键的字段是第一个 union 中的 mapping 和 index:

// include/linux/mm_types.h(简化)
struct page {
    unsigned long flags;          // PG_head, PG_compound, PG_swapbacked, ...
    
    union {
        struct address_space *mapping;  // page cache映射
        struct slab_cache *slab_cache;   // slab分配器
        // ...
    };
    
    pgoff_t index;                // 在映射中的偏移
    
    union {
        void *virtual;            // 虚拟地址(高端内存时有效)
        unsigned long _pt_pad_1;   // 页表缓存
    };
    
    atomic_t _refcount;           // 引用计数
    atomic_t _mapcount;           // 映射计数(PTE entries数量)
    // ... 更多字段
};

1.2 Compound Page 的双重身份问题

Linux 内核有一种称为 compound page(复合页)的机制,用于将多个连续的页帧组合成一个逻辑单元——例如 64KB 的巨型页由 16 个 4KB 的普通页组成。历史上,compound page 的关键语义如下:

  • Head page(首部页):第一个页,其 PG_head 被置位,所有对复合页属性(如引用计数、映射关系)的操作都通过 head page
  • Tail page(尾部页):其余页,其 compound_head 字段指向 head page。私有数据如 compound_mapcount、compound_pincount、compound_dtor、compound_order 存储在 head page 的尾页区域中

这种设计在当时是合理的,但导致了两个严重问题:

问题一:page cache 中的 compound page 身份歧义

当 page cache 中缓存了一个 compound page(如 THP 在内存文件系统中的用法),内核无法仅通过 struct page* 指针判断它是一个"复合页的 head"还是一个"普通单页"。PageCompound() 宏虽然可以检测,但无数函数收到的 struct page* 可能是 tail page,它们必须先调用 compound_head() 获取 head page,再操作属性。这种模式散布在数千个函数中——不仅容易出错,还增加了代码复杂度和间接访问开销。

问题二:THP 在 page cache 中的有限支持

Linux 5.16 之前,匿名内存的 THP(Transparent Huge Pages)已实现,但 page cache 不支持 THP。换句话说,文件 IO(read/write/mmap 文件)永远只能以 base page(4KB)粒度操作,即使读取连续的大块文件数据,也会被拆成多个 4KB page cache 条目。这导致:

  • TLB 压力更大(相同大小的内存需要更多 TLB 条目)
  • page cache 的链表和基数树节点数更多
  • 缺页异常和回写操作的次数更多
  • 文件系统预读无法发挥巨型页的优势

2. Folio 的设计哲学

2.1 核心定义:一种"保证是 head page"的类型

Folio 本质上是 struct page 的一个类型包装——它确保你拿到的永远是 head page(或单页),永远不是 tail page。Folio 的定义极其简洁:

// include/linux/mm_types.h
struct folio {
    unsigned long flags;          // 与 struct page 相同布局
    union {
        struct address_space *mapping;
        struct slab_cache *slab_cache;
    };
    pgoff_t index;
    void *virtual;
    atomic_t _refcount;
    atomic_t _mapcount;
    // ... 与 struct page 共享布局直到某个偏移量
};

关键洞察:struct folio 和 struct page 在内存布局上是前向兼容的。folio 只使用 page 结构体前半部分的"通用"字段,不涉及 page 后半部分的 union(那些是 tail page 专属的)。这意味着两者可以安全地在许多场景中互换。

2.2 核心不变量

Folio 引入了一条核心不变量:

struct folio* 永远指向一个 head page 或单页。绝不可能是 tail page。

基于此不变量,folio_test_head() 永远返回 true(单页本身就是自己的 head)。所有 folio API 都操作完整的逻辑页——无论是 4KB 的单页还是 2MB 的 compound folio。这种确定性使得:

  • 函数签名自文档化:参数类型 struct folio* 即表示"这是一个完整的、可直接操作的内存单元"
  • 编译器级类型安全:无法将 tail page 的 struct page* 直接传递给期望 folio 的函数
  • 消除运行时检查:不再需要在每个函数入口处调用 PageCompound() 或 compound_head()

三、数据结构深度解析

3.1 Folio 与 Page 的内存布局关系

// include/linux/mm.h
static inline struct folio *page_folio(struct page *page) {
    // 将任何 page 转换为其所属的 folio(head)
    // 对于 head page 或单 page,返回的就是 page 自身按 folio 类型解释
    return (struct folio *)page;
}

static inline struct page *folio_page(struct folio *folio, unsigned int n) {
    // 返回 folio 中的第 n 个物理 page(0 = head)
    return &folio->page + n;
}

static inline unsigned int folio_order(struct folio *folio) {
    // 返回 folio 的 order(2^order 个 page)
    if (!folio_test_head(folio))
        return 0;
    return folio->_compound_order;  // 通过私有数据获取
}

static inline size_t folio_size(struct folio *folio) {
    // 返回 folio 的总字节数
    return PAGE_SIZE << folio_order(folio);
}

3.2 引用计数语义

Folio 的引用计数语义与 page 一致——针对逻辑页(head)计数,而非每个 base page:

folio_get(folio)     // 增加逻辑页引用计数(等价于 get_page(compound_head))
folio_put(folio)     // 减少逻辑页引用计数(为0时释放整个 compound unit)
folio_ref_count(folio)  // 查询引用计数

这里有一个微妙之处:_refcount 字段在 head page 上维护总的逻辑引用数,而不是每个 base page 都独立引用。compound_mapcount 和 compound_pincount 存储在 head page 的 tail page 区域内,这是 Linux 内核中经典的"first page 携带全局元数据,后续 page 存储扩展元数据"模式。

3.3 基数树(Xarray)中的 folio 存储

Linux 的 page cache 使用 Xarray(扩展基数树,Radix Tree 的继任者)存储页缓存条目。Xarray 支持 multi-order entry(多阶条目)——即一个 Xarray 槽位可以存储一个 2^order 大小的连续单元。Folio 完美利用了这一点:

// page cache 中存储 folio 的核心操作
void filemap_add_folio(struct address_space *mapping, struct folio *folio, pgoff_t index);
filemap_add_folio()
    → xa_store(&mapping->i_pages, index, folio, gfp)
        // xa_store 检测 folio 的大小并设置 XA_MARK_ORDERED 标记
        // 树节点知道这是一个大跨度条目,lookup 时自动返回对应的 folio 起始位置

struct folio *filemap_lock_folio(struct address_space *mapping, pgoff_t index);
filemap_lock_folio()
    → xa_find(&mapping->i_pages, &index, ∞, XA_PRESENT)
        → 锁定 folio 后返回(确保不是 sleep 持有状态下返回)

四、从 Page 到 Folio 的 API 迁移之路

4.1 迁移策略:渐进式而非大爆炸

Linux 社区对 folio 迁移采取了一种务实的分层策略——不要求一次性重写所有代码,而是通过"洋葱模型"逐步将核心逻辑迁移到 folio API:

  • 核心层迁移:将 page cache 核心操作(filemap.c、readahead.c、memory.c)迁到 folio API。影响最大,最先进行
  • 文件系统适配:各文件系统对 page 操作内部逻辑。ext4、XFS、Btrfs、NFS、F2FS 等逐个适配
  • 块层与驱动:bio 子系统和块设备驱动继续使用 struct page*,但高层传下来的 folio 可以将 base page 提取出来传给块层
  • VFS 层:struct address_space_operations 添加 folio 版本方法,旧方法通过 wrapper 兼容

4.2 文件系统钩子函数的重写示例

以 readpage 这一关键操作为例,展示迁移前后的变化:

// 迁移前(旧 API)— 每个 base page 单独操作
static int ext4_readpage(struct file *file, struct page *page)
{
    // page 可能是 compound page 的某个 page(不一定是 head)
    // 如果是 tail page,还需处理 compound_head
    return ext4_readahead_block(file->f_mapping, page->index, page);
}

// 迁移后(folio API)— 按逻辑页操作
static int ext4_read_folio(struct file *file, struct folio *folio)
{
    // folio 保证是 head,直接处理 16 pages(order-4,64KB)或 1 page
    size_t len = folio_size(folio);
    pgoff_t start = folio->index;
    
    // 文件系统可以直接操作整个 folio 区域
    return ext4_readahead_blocks(file->f_mapping, start, folio_order(folio));
}

4.3 仍在使用 page 的场景

并非所有场景都适合 folio。以下场景继续使用 struct page*:

  • Slab 分配器:slab 返回的永远不会是 compound page,没有 head/tail 歧义
  • 页表映射:PTE 永远是 base page 粒度
  • DMA / IOMMU:IOMMU IOVA 映射以 base page 为单位
  • 页帧号(PFN)操作:需要逐页处理物理页帧

内核通过 folio_page(folio, n) 提取 folio 中的第 n 个 page,供上述场景使用。

五、THP for Page Cache:Folio 解锁的新能力

5.1 问题场景

在 folio 出现之前,THP 只能用于匿名内存(MAP_ANONYMOUS mmap),三大限制导致其效果受限:

  • 只能匿名映射:文件的 mmap 映射使用 base page
  • 只能 madvise 触发:需显式标记 MADV_HUGEPAGE 才能尝试
  • 碎片化导致分配失败:长期运行后,物理内存可能碎片化,无法找到连续 2MB

5.2 Folio 带来的文件支持 THP

Folio 完成了 THP in page cache 的基础设施,核心变化包括:

(1)filemap_alloc_folio():按 hint 分配适当大小的 folio

struct folio *filemap_alloc_folio(gfp_t gfp, unsigned int order)
{
    struct folio *folio = folio_alloc(gfp, order);
    
    if (folio && >0) {
        // order >= HPAGE_PMD_ORDER (即 2MB) 的 folio 被视为"大页"
        folio_set_active(folio);  // 统计和回收使用
    }
    return folio;
}

(2)readahead 的自动升级

当 readahead 检测到顺序读模式时,会提示分配更大 order 的 folio:

// mm/readahead.c
void page_cache_ra_order(...)
{
    // ...
    if (_ra->ra_pages > 256) {
        // 顺序读扩大:分配 order-4 (64KB) 甚至 order-9 (2MB) 的 folio
        order = min(ra_order, 9);
        folio = filemap_alloc_folio(GFP | __GFP_NORETRY, order);
        filemap_add_folio(mapping, folio, index);
        // ...
    }
}

(3)writeback 按 folio 粒度写入

folio-aware 的 writeback 可以一次回写整个 2MB 区域(而非 512 个独立 4KB page),大幅减少文件系统块分配次数和 journal 事务提交次数。

5.3 实际性能影响

根据 kernel.org 邮件列表和社区 benchmark 数据:

工作负载改进幅度关键原因
大文件顺序 read5-15% 吞吐提升TLB miss 减少、预读粒度提升
大文件 writeback10-30% IOPS 提升减少 fs metadata 操作、合并块分配
mmap 文件随机访问2-8% 提升减少 page cache 级数遍历深度
内存回收(shrink)复杂 case 下 5-20% 提升一次扫描操作可回收更大内存单元
文件系统 metadata 操作15-25% 提升分配、journal 和回写批量化

六、生产环境实践与调优

6.1 查看系统的 folio/THP 状态

$ grep -i huge /proc/meminfo
AnonHugePages:   524288 kB    # 匿名 THP 使用量
ShmemHugePages:       0 kB    # 共享内存 THP(tmpfs 等)
FileHugePages:   1048576 kB    # 文件支持 THP 使用量(kernel 6.0+)
FilePMDMapped:    393216 kB    # 文件页表直接映射 2MB

$ cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never

$ ls /sys/kernel/mm/hugepages/
hugepages-2048kB/    # 1024 x 2MB THP pool
hugepages-32768kB/   # ARM64 32MB (若支持)
hugepages-65536kB/   # 64KB (aarch64 级联页表)
hugepages-1048576kB/ # 1GB 巨型页(需静态预分配)

6.2 配置建议

OLTP 数据库(MySQL/PostgreSQL):建议关闭匿名 THP(避免延迟尖刺),但内核文件 IO 层自动产生的 folio 通常有益——THP in page cache 是按需求逐文件产生的,不会产生 promote/defrag 的开销。

# 仅关闭匿名 THP,文件 THP 的行为由 readahead 逻辑自动管理
echo never > /sys/kernel/mm/transparent_hugepage/enabled
# 仍可使用 MADV_HUGEPAGE madvise 让特定 mmap 区域启用文件 THP

存储服务器(分布式存储/RADOS/Ceph):开启大页预分配 + 文件 THP:

# 增加 THP pool
echo 4096 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
# readahead 自动升级为 2MB folio
echo always > /sys/kernel/mm/transparent_hugepage/enabled
# 预读窗口调大
echo 8192 > /sys/block/nvme0n1/queue/read_ahead_kb

虚拟化和容器:对 guest 来说,host 的 THP 仅影响 host 自身的 page cache。若使用 virtiofs 或 9p 传递文件,guest 内部的文件 IO 是否使用 THP 由 guest 内核决定。

6.3 关键内核启动参数

# 彻底禁用 THP(包括文件支持)
transparent_hugepage=never

# 仅通过 madvise 启用(推荐生产默认值)
transparent_hugepage=madvise

# 全局启用(适用于大多数场景)
transparent_hugepage=always

# 对 tmpfs/shmem 的全局 THP 设置
/sys/kernel/mm/transparent_hugepage/shmem_enabled
# 选项:always|within_size|advise|never|deny|force

七、安全视角:新的攻击面与缓解

7.1 TLB 侧信道与 folio

更大的 page 大小(2MB vs 4KB)意味着 TLB 条目覆盖的内存范围更大。这使得某些 TLB 侧信道攻击更容易(一个 TLB miss 可泄漏 2MB 的信息而非 4KB)。然而:

  • 正向效应:更少的 TLB 条目意味着 KPTI(内核页表隔离)的 TLB 刷新开销更低
  • 缓解:现代 CPU 的 TLB 按 page size 隔离,不同大小的 TLB 条目独立淘汰
  • 实践:Spectre/Meltdown 缓解的 TLB 刷新开销与 page size 成反比

7.2 巨型页碎片化拒绝服务

申请 2MB 不可移动的 folio 可能加剧内存碎片化。Linux 内核通过以下机制缓解:

  • defrag 水位:sysctl vm.extfrag_threshold 控制内存压缩触发阈值
  • GFP 标志约束:GFP | __GFP_NORETRY 让 folio 分配失败时优雅降级为 smaller order
  • 渐进式分配:先尝试 order-9(2MB),失败后回退到 order-6(256KB),再到 base page

八、代码实战:手动操作 folio

8.1 内核模块中的 folio 生命周期管理

#include <linux/fs.h>
#include <linux/pagemap.h>
#include <linux/falloc.h>

static int __init folio_demo_init(void)
{
    struct folio *folio;
    struct page *page0, *page1;
    void *addr;
    
    // 1. 分配一个 order-2 (16KB) 的 folio
    folio = folio_alloc(GFP_KERNEL, 2);
    if (!folio)
        return -ENOMEM;
    
    // 2. 锁定(防止被回收)
    folio_lock(folio);
    
    // 3. 映射到内核虚拟地址
    addr = folio_address(folio);
    memset(addr, 0, folio_size(folio));  // 使用 folio_size() 而非硬编码 PAGE_SIZE
    
    // 4. 操作 folio 中的子页
    page0 = folio_page(folio, 0);  // head page
    page1 = folio_page(folio, 1);  // 第2个 page
    SetPageDirty(page1);  // 标记子页 dirty(PG_dirty 只作用于 page 级)
    
    // 5. 引用计数操作
    folio_get(folio);  // +1
    // ... 使用 folio ...
    folio_put(folio);  // -1
    folio_put(folio);  // -1 → 触发释放
    
    pr_info("folio_demo: allocated 4 pages (order-2), addr %pK\n", addr);
    return 0;
}

static void __exit folio_demo_exit(void)
{
    pr_info("folio_demo: exited\n");
}

MODULE_LICENSE("GPL");

8.2 用户态观测 folio/THP 行为

# 通过 /proc/pid/smaps_rollup 查看进程级 THP 使用
$ grep -i anonhuge /proc/self/smaps_rollup
AnonHugePages:   131072 kB

# 通过 perf 追踪 THP 分配事件
$ perf stat -e dTLB-load-misses,iTLB-load-misses \
    -p $(pidof my_app) sleep 5

# 通过 ftrace 追踪 folio 分配
$ echo 1 > /sys/kernel/debug/tracing/events/kmem/mm_page_alloc_extfrag/enable
$ cat /sys/kernel/debug/tracing/trace_pipe
  # 查看哪些调用路径因碎片化导致分配降级

# 通过 /proc/vmstat 查看 THP 统计
$ grep thp /proc/vmstat
thp_fault_alloc             847    # THP 通过缺页分配的次数
thp_fault_fallback          23     # THP 降级为 base page 的次数
thp_file_alloc             1502    # 文件 THP 分配次数(folio)
thp_file_fallback           41     # 文件 THP 降级次数
thp_split_page               68    # THP 被拆分为 base page 的次数

九、未来演进路线

9.1 Folio 化的最终目标

Linux 内核社区的目标是将 page cache 的核心代码路径完全基于 folio,最终将 struct page 在虚拟内存层中逐步"降级"为仅由低级硬件接口(页表、DMA、slab)使用的类型。这不是短期内能完成的目标——数以万计的驱动和文件系统代码仍需逐步适配。

9.2 与 CXL.mem 的协同

CXL 2.0/3.0 引入的 CXL.mem(内存扩展)设备通常以 2MB 或 1GB 粒度映射。File-backed folio 天然适配大页面映射——主机可以将 CXL-attached 内存中的文件 THP(2MB folio)整体映射到用户态,避免多级页表遍历。这对 AI/ML 大模型训练中频繁加载参数文件的场景有直接性能价值。

9.3 Name-Based Mapping

更激进的方向是(Name-Based Mapping)——不将文件映射到虚拟地址空间的固定偏移,而是通过文件名 + offset 直接定位 page cache 条目。这可以消除 mmap 中 VM 管理的复杂度,从根本上避免 VMA 膨胀问题。Folio 已经为此铺平了道路——一个 order-N 的 folio 是一个自包含的逻辑单元,可以脱离虚拟地址空间单独管理。

十、总结

Memory Folios 绝非简单的重命名或类型包装,而是 Linux 内存管理架构的一次根本性简化。它解决了困扰内核开发者十余年的 compound page 歧义问题,为 THP 在 page cache 中的全面支持奠定基础,并通过类型安全提升代码可维护性。对于高性能存储、数据库、AI 训练和大规模虚拟化工作负载,理解 folio 的分配、锁定、引用和回收机制是优化 IO 吞吐和内存效率的关键。随着 Linux 6.x+ 的不断成熟,folio 已成为现代 Linux 存储栈的事实标准——掌握它,就是掌握了虚拟内存子系统的演进方向。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.354506s