一、为什么需要 Folio?Page 的历史包袱
在 Folio 出现之前,struct page 这个 64 字节的结构体承担了过多令人困惑的语义角色——同一个 page 指针所指向的可能是:
- 基础页(base page):4KB 的单独物理页面,这是最常见的场景。
- 复合页(compound page):由 2 个到 512 个连续物理页组成的逻辑单元,THP(Transparent Huge Page)就是 order=9(2MB)的复合页。
- 匿名页的 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);
这带来两个显著的性能收益:
- TLB 覆盖率提升:2MB folio 使用 1 个 2MB TLB entry,覆盖从 512 个 4KB 页所需的所有 TLB entries 的内存范围。在顺序读 workload 中,TLB miss rate 可下降 60% 以上。
- 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 内核最重要的底层重构之一。

发表评论 取消回复