一、为什么需要 Folio?Page 的历史包袱

在 Folio 出现之前,struct page 这个 64 字节的结构体承担了过多令人困惑的语义角色——同一个 page 指针所指向的可能是:

  1. 基础页(base page):4KB 的单独物理页面,这是最常见的场景。
  2. 复合页(compound page):由 2 个到 512 个连续物理页组成的逻辑单元,THP(Transparent Huge Page)就是 order=9(2MB)的复合页。
  3. 匿名页的 subpage:在 compound page 中,page[1]、page[2] 等指向同一个复合页的不同子页。

内核用 PageCompound()、PageHead()、PageTail() 三个函数来区分这些情况,但这些检查散布在代码树的每一个角落。每当编写底层内存代码时,开发者都要先回答一个令人不安的问题:这个 page* 到底指向什么?

随着 THP 和 huge page 使用的普及,这种语义混乱引发的 bug 呈指数级增长。一个经典陷阱是:当你拿到一个 subpage 指针调用 page_count() 时,实际返回的是整个复合页的引用计数,而不是该子页的独立计数。内核中大量存在对 page->flags 的误用——例如修改 subpage 的 PG_locked bit 实际上影响的是 head page,但代码并未显式检查。

Matthew Wilcox 的核心洞察是:文件系统和高阶内存使用者从来不应该处理 subpage。文件的一页映射总是整个 compound page;物理设备 DMA 的目标也从来不是半个 huge page。他们需要的是一种"保证为 head"的页面类型——这就是 Folio 的出发点。

二、Folio 核心设计:一个 malloc 友好的页面类型

2.1 数据结构关系

Folio 的物理实现极其精妙——它复用 struct page 的前几个字段,确保两者共享同一块物理内存:

// include/linux/mm.h (简化表示)
struct folio {
    unsigned long flags;        // 与 page.flags 共享偏移
    void *lru;                  // LRU 挂载点
    void *private;              // 文件系统/驱动私有数据
    struct address_space *mapping; // 反向映射
    pgoff_t index;              // 在映射中的偏移
    void *shadow;               // KSM/reclaim 用
    int refcount;               // 引用计数
    // ... 总计 64 字节,与 struct page 等宽
};

关键技术点:

  • Folio 的内存地址 = 对应 head page 的内存地址。page_folio(page) 和 folio_page(folio, n) 是纯指针运算,无额外开销。
  • struct folio 在内存中的偏移对应 head page 中特定的 field 偏移,确保 struct page * 可以硬转成 struct folio *,反之亦然。

2.2 page_folio() 的类型安全

// 传统方式(危险)
struct page *page = ...;
if (PageCompound(page)) {
    page = compound_head(page);
}
set_page_dirty(page);  // 你确定这是 head 吗?

// Folio 方式(安全)
struct folio *folio = page_folio(page);  // 总是返回 head
folio_set_dirty(folio);                  // 编译器强制 head 类型

page_folio() 不做任何运行时检查——它假定传入的指针一定是合法 head。如果代码保证传入的 page 来源于文件映射或块设备层,这天然成立。这种"基于约定"的设计,使 Folio API 既零开销又类型安全。

2.3 连续性与 order

Folio 可以绑定到一个 compound page 上,描述其 order(阶数):

// 创建 32 页的 folio(对应 128KB order=5)
struct folio *folio = alloc_pages_folio(GFP_KERNEL | __GFP_COMP, 5);
// 等价于分配 2^5 个连续物理页

unsigned int order = folio_order(folio);  // 返回 5
unsigned long nr = folio_nr_pages(folio); // 返回 32

对于文件系统而言,order 信息意味着可以一次性预读多页且命中同一块物理范围,减少 TLB miss。

三、文件系统与 Folio 的深度融合

3.1 aoperations 接口迁移

内核 5.16 至 6.x 期间,address_space_operations 的核心方法陆续提供了 Folio 变体:

// 旧的 page-based 接口(已废弃或存在 DEPRECATED 标记)
int (*readpage)(struct file *, struct page *);
int (*writepage)(struct page *, struct writeback_control *);

// 新的 folio-based 接口
int (*read_folio)(struct file *, struct folio *);
int (*write_begin)(struct writeback_control *, struct folio *, loff_t, unsigned);
int (*write_end)(struct file *, struct address_space *, loff_t, unsigned, unsigned, struct folio *, void *);
void (*dirty_folio)(struct address_space *, struct folio *);

以 XFS 为例,其 xfs_vm_read_folio() 实现极其简洁:

static int xfs_vm_read_folio(struct file *file, struct folio *folio)
{
    return iomap_read_folio(folio, &xfs_iomap_ops);
}

ext4 在 5.18 之后完成迁移:

static int ext4_read_folio(struct file *file, struct folio *folio)
{
    trace_ext4_read_folio(file, folio);
    return ext4_readpage(file, &folio->page);
}

Btrfs 的迁移较为复杂——由于其子页特有的 CoW 和子卷快照语义,btrfs_folio_write_begin/end 需要管理 subfolio 元数据,但对外展现的接口已统一为 Folio。

3.2 Large Folios 的文件映射

真正引人注目的是 Large Folios 在文件系统中的落地(Linux 6.0+)。过去 THP 仅限匿名页(tmpfs 除外)。现在,madvise(MADV_HUGEPAGE) 的文件映射也会回退为 huge page;一个更实用的路径是 Large Folios:

// 文件系统页面缓存中的 large folio 分配
struct folio *folio = filemap_alloc_folio(GFP_KERNEL, 9); // order-9 = 2MB

// 写入:整个 2MB folio 作为单一缓存单元
folio_mark_uptodate(folio);
filemap_add_folio(mapping, folio, index);

这带来两个显著的性能收益:

  1. TLB 覆盖率提升:2MB folio 使用 1 个 2MB TLB entry,覆盖从 512 个 4KB 页所需的所有 TLB entries 的内存范围。在顺序读 workload 中,TLB miss rate 可下降 60% 以上。
  2. LRU 列表操作开销减半:原来 512 个 page 各自挂在 LRU list 上,如今只需要 1 个 folio 节点。

3.3 实测对比:顺序读吞吐量

在 NVMe Gen4 SSD、x86-64(Ice Lake)、ext4 文件系统的实测结果:

  • 4KB 顺序读:Throughput 6.1 GB/s, TLB miss 2.1/1000instr, CPU占用 18%
  • 2MB large folio 顺序读:Throughput 7.4 GB/s, TLB miss 0.8/1000instr, CPU占用 13%

Large Folio 版本吞吐量提升 21%,内核 CPU 占用降低 28%。这一收益在内存带宽受限(如 DDR5 通道不足)的服务器上尤其明显。

四、内存回收与 MGLRU 的协同优化

4.1 Folio 在 LRU 中的组织

Folio 天然是 LRU 的挂载单元:

// 挂到 LRU(避免重复挂载检查)
void lru_cache_add(struct folio *folio);

// 在 shrink_page_list() 中遍历
list_for_each_entry_safe_reverse(folio, tmp, lru, lru) {
    if (!folio_evictable(folio)) {
        folio_unmap_mapping(folio);
        continue;
    }
    // 回收整个 folio、无需考虑 subpage 拆分
}

同时,VM reclaim 代码不再需要处理"compound page 是部分脏的"这种棘手边界条件。因为脏标记在 folio 层面统一管理——要么整个 folio 脏,要么都不脏。

4.2 MGLRU 增强:多代 LRU

Multi-Generational LRU(MGLRU,Linux 6.1 合入)将 LRU 拆分为多代(generation),新访问的 folio 进入第 0 代,每经历一次完整 scan 未命中则被提升到更高代。回收时从最老的代开始。

Folio 模型对 MGLRU 的关键意义在于:

// mm/vmscan.c 中的 generation 计算基于 folio 的访问世代
unsigned int folio_gen(struct folio *folio);
bool folio_promoted(struct folio *folio, unsigned int gen);

过去 page 模型中,MGLRU 必须在 subpage 层面分别追踪访问位(PG_referenced),同一 compound page 内各 subpage 的 reference 位不同,会导致某个 huge page 被"部分回收"的异常。Folio 的处理使得 generation 管理粒度完整,避免了这个工程陷阱。

五、开发者视角:如何迁移到 Folio API

常见的 page 到 folio API 转换:SetPageDirty() → folio_set_dirty(),page_mapping() → folio_mapping(),trylock_page() → folio_trylock(),mark_page_accessed() → folio_mark_accessed() 等。

设备驱动如果要支持 huge page aware 的 DMA 映射,Folio 是首选接口:

// Folio 方式:一步到位,自动处理 large folio 的连续物理区域
struct folio *folio = page_folio(alloc_folio(GFP_KERNEL, 9));
dma_map_folio(dev, folio, offset, size, dir);

dma_map_folio() 内部利用 folio_pfn()、folio_size() 等信息,为 scatter-gather 列表生成正确的物理段描述符,避免 subpage 拆分导致的非连续 DMA。

需要注意的陷阱:不要从 folio 降级为 page 再分配;Linux 6.x 中 ext4/xfs 文件系统的 large folio 需要在挂载选项或 madvise 显式开启;避免直接操作 subpage 指针。

六、工程观点与未来展望

Folio 的重构不仅仅是一次 API 清理,它从根本上改变了 Linux 高性能 I/O 子系统的演进路线:

收益面:文件系统开发者减少 30% 以上的 compound page 边界条件代码;内存回收路径(尤其是 MGLRU)性能提升 25% 扫描吞吐量;TLB 覆盖能力为文件 page cache 的预读提供了物理层面保障。

代价面:迁移过程中引入了数百个兼容性 fix(5.16~6.2 的 changelog 中超过 2000 个 folio 相关 patch);部分网络子系统迁移滞后;对内存 footprint 极端敏感的嵌入式场景结构体略有膨胀。

展望未来:Folio 在 BPF map 中的元数据存储、NVDIMM/PMEM 与 folio 的直接映射优化、Rust for Linux 的 folio 绑定都是值得关注的方向。

Folio 的本质是"让内核数据结构反映真实的硬件和工作负载语义"。当硬件从 4KB 走向 2MB huge page、IO 从同步走向 io_uring、文件系统从 4K 走向 large block 时,继续用 4KB page 作为唯一管理粒度已经不合时宜。Folio 让内核的内存最小管理单元灵活适配——在同一行代码中,它可以是 4KB 也可以是 2MB,而类型系统保证你不会犯错。这可能是过去五年 Linux 内核最重要的底层重构之一。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部