Linux 内核 page cache 的三次革命:从 buffer_head 到 folio 的性能跃迁

为什么现代 Linux 系统上 NVMe 的随机 4K 读取性能是十年前文件系统的五倍以上?page cache 数据结构的演进是最被低估的因素。本文从 `buffer_head` 出发,追踪内核如何一步步将文件缓存从块设备的附属品重构为独立的内存管理核心组件。

一、buffer_head 时代——块设备的影子(1991—2003)

1.1 原始设计:每个块一个 buffer_head

Linux 诞生之初,文件系统没有独立的缓存层。buffer_head 既是缓存单元,也是块 I/O 的传输描述符:


// include/linux/buffer_head.h (早期内核)
struct buffer_head {
    struct block_device *b_dev;   // 所属块设备
    sector_t b_blocknr;           // 设备上的块号
    unsigned short b_size;         // 块大小(通常 512B)
    char *b_data;                  // 指向实际页面的某个位置
    struct buffer_head *b_this_page; // 同一页面内的下一个 bh
    struct page *b_page;           // 反向指针到所在页面
    // ...
};

这种设计的哲学是:缓存是块设备的延伸。每个 buffer_head 精确对应设备上的一个逻辑块。多个 buffer_head 通过 b_this_page 链表挂载到同一个 struct page 上。

1.2 致命缺陷:缓存与 I/O 的耦合

这种耦合架构带来了三个根本问题:

内存浪费严重:每个 buffer_head 约 64 字节,4KB 页面需要 8 个 buffer_head(假设 512B 块),仅元数据就占 512 字节,加上链表指针开销,每台运行数据库的机器可能有数百 MB 内存被 buffer_head 消耗。

大 I/O 低效:要提交一次 1MB 的预读请求,需要构造 2048 个 buffer_head,逐个设置状态标志,逐个加入 I/O 调度器。当 NVMe 设备支持 128K 甚至 1MB 的原生传输粒度时,这种逐块处理成为瓶颈。

巨页不友好:当系统使用 2MB 巨页时,仍然需要 4096 个 buffer_head,且无法将巨页作为一个整体管理。

1.3 4K 时代的冲击

2002 年前后,Linux 引入 ext3 与 LVM,企业存储开始使用 4K 物理扇区的磁盘(Advanced Format)。此时 buffer_head 被迫支持 b_size=4096,但 b_blockno 的映射变得不直观——一个 buffer_head 覆盖整个页面,失去了"逐块管理"的原始语义。

更糟的是,当 ext3 使用 data=journal 模式时,每次写入需要经过:用户缓冲区 → page cache → journal page → buffer_head → 块层 → 设备。buffer_head 在这一长链中仅是冗余的中介。

二、page 的崛起——缓存成为一等公民(2003—2011)

2.1 address_space 与关键分离

2.6 内核引入了 address_space,将文件缓存与块设备解耦:


struct address_space {
    struct inode *host;          // 所属的 inode
    struct radix_tree_root page_tree; // 页面缓存的基数树
    spinlock_t tree_lock;        // 保护 page_tree 的锁
    const struct address_space_operations *a_ops;
    // ...
};

struct page {
    unsigned long flags;
    atomic_t _refcount;
    atomic_t _mapcount;
    struct {
        union {
            struct address_space *mapping; // 映射的 address_space
            void *s_mem;                    // slab 分配器使用
        };
    };
    pgoff_t index;               // 在 mapping 中的页面偏移
    struct list_head lru;        // LRU 链表节点
    // ...
};

关键变化是:页面从 buffer_head 的附庸变为独立的缓存单元。address_space.page_tree 按文件偏移(pgoff_t)索引页面,buffer_head 被降级为"描述页面中哪些脏块需要写回"的辅助结构。

2.2 bio:真正的块 I/O 描述符

2001 年 Jens Axboe 引入 struct bio,彻底取代了 buffer_head 的 I/O 传输角色:


struct bio {
    struct bio *bi_next;         // 请求队列中的下一个 bio
    struct block_device *bi_bdev;
    unsigned short bi_opf;       // 操作类型(READ/WRITE/DISCARD...)
    unsigned short bi_flags;
    struct bvec_iter bi_iter;    // 段迭代器
    struct bio_vec *bi_io_vec;   // 段数组
    // ...
};

struct bio_vec {
    struct page *bv_page;        // 页面指针
    unsigned int bv_len;         // 段长度
    unsigned int bv_offset;      // 页面内偏移
};

bio 的设计哲学完全不同:它描述的是段(segment)的集合,每个段是一块连续内存(页面片段 + 长度)。一个 bio 可以包含多个段,每个段可以跨越页面边界,支持任意粒度的分散/聚集 I/O。

2.3 性能影响

这一分离带来的直接收益:

  • 内存占用下降:每个页面只需 1 个 page 结构(64 字节)+ N 个 buffer_head(N 取决于需要写回的块数),缓存 1TB 数据的元数据从 ~1GB 降至 ~128MB。
  • 预读效率提升:ondemand_readahead 可以一次性分配多个页面、构造单个 bio,将寻道次数减少 10 倍以上。
  • 异步 I/O 简化:一个 bio 的完成回调可以携带完整的上下文信息,不需要像以前那样逐个 buffer_head 检查状态。

三、buffer_head 的退化——从主角到客串(2011—2021)

3.1 buffer_head 的真实角色

到 3.x/4.x 内核,buffer_head 实际上只承担一个职责:标记页面中哪些块是脏的,需要写回。


// 写回逻辑(简化版)
static int __block_write_full_page(struct inode *inode, struct page *page,
                                   get_block_t *get_block, struct writeback_control *wbc)
{
    struct buffer_head *bh, *head;
    head = create_page_buffers(page, inode, 0);
    bh = head;
    do {
        if (buffer_mapped(bh) && buffer_dirty(bh)) {
            // 仅标记需要写回的块
            map_bh(bh, inode->i_sb, block);
        }
        bh = bh->b_this_page;
    } while (bh != head);
    
    // 最终提交的是 bio,不是 buffer_head
    return submit_bh_wbc(WRITE, bh_head, wbc);
}

3.2 buffer_head 的隐藏成本

尽管 I/O 不再依赖 buffer_head,但文件系统元数据(如 ext4 的 inode 表、目录块)仍然通过 buffer_head 读写。这带来一个微妙的问题:

元数据页面与普通数据页面共享同一个缓存链,但生命周期完全不同。元数据需要即时一致性(journal commit 必须等待 buffer_head 写回完成),而普通数据页面可以延迟回写。将两者混在同一个 LRU 上,导致内存压力下的错误回收——回收了一个即将被 journal 引用的元数据页面,然后需要同步读回。

ext4 的 jbd2 层在 4.14 内核中被迫实现 jbd2_journal_get_write_access() 等防御性 API,本质是在弥补 buffer_head 元数据管理与 page cache 通用机制之间的语义鸿沟。

3.3 巨页与文件缓存的碰撞(THP for file)

4.x 内核引入了透明巨页(THP)对文件缓存的支持(shmem_enabled=always、ext4/xfs 的 transparent_hugepage/file)。但 page 结构本身的问题浮现:

page 结构假设系统使用 4K 基础页面大小。2MB 巨页是 512 个 4K page 的复合页(compound page),但文件缓存代码(如 find_lock_entry())是按 index 索引的,每个 index 对应一个 4K page。使用 THP 文件代码必须将 512 个 page 锁定为"复合页",并通过 PG_head / PG_tail 标志管理。

这在概念上混乱了:缓存索引粒度(4K)与分配粒度(2MB)不一致,导致大量边界检查代码。

四、folio——统一缓存与大页的解法(2021—至今)

4.1 folio 的设计哲学

Matthew Wilcox 在 5.16 内核引入 struct folio,其核心思想是:

让内核只处理"缓存单元",而非"物理页面"。


// include/linux/mm.h (5.16+)
struct folio {
    unsigned long flags;
    union {
        struct address_space *mapping;
        void *s_mem;                    
    };
    pgoff_t index;               // 在文件中的偏移
    void *virtual;               // 虚拟地址
    atomic_t _mapcount;
    atomic_t _refcount;
    // ... 嵌入在 compound page 的第一个 page 中
};

// 关键:folio 是"基础"结构,page 是派生
#define page_folio(_p)  (_Generic((_p), struct page *: __page_folio(_p)))
static inline struct folio *__page_folio(struct page *page)
{
    return (struct folio *)page;
}

关键区分:

  • 一个 4K base page = 1 个 folio
  • 一个 2MB 巨页 = 1 个 folio(包含 512 个 struct page)

address_space.page_tree 改为按 index 索引,每个 index 对应一个 struct folio,彻底解决了 THP 复合页的语义混乱。

4.2 folio 如何消除 buffer_head

folio 引入后,文件脏块标记不再需要 buffer_head:


// 标记脏页(folio 版本)
void folio_mark_dirty(struct folio *folio)
{
    struct address_space *mapping = folio_mapping(folio);
    if (mapping) {
        __folio_mark_dirty(folio, mapping, 0);
        // 直接操作 folio 的 dirty 位,无需 buffer_head 遍历
    }
}

// 写回一个 folio
int filemap_write_and_wait_range(struct address_space *mapping,
                                 loff_t start, loff_t end)
{
    struct folio_batch fbatch;
    folio_batch_init(&fbatch);
    
    // 批量收集脏 folio
    filemap_get_folios_tag(mapping, &start, &end, PAGECACHE_TAG_DIRTY, &fbatch);
    
    // 批量提交,不需要逐个 buffer_head 处理
    for (unsigned int i = 0; i < fbatch.nr; i++)
        filemap_write_folio(fbatch.folios[i], wbc);
}

4.3 文件系统元数据页——folio 的第二战场

ext4 5.16+ 的 jbd2 层开始支持 folio:直接的元数据缓存管理,跳过 buffer_head 的 b_page 反向映射:


// fs/ext4/super.c (folio 适配后)
struct buffer_head *ext4_get_bitmap(struct super_block *sb, ext4_group_t block_group)
{
    struct buffer_head *bh = sb_getblk(sb, block_bitmap);
    // sb_getblk 现在基于 folio 管理页面缓存
    // 不再需要 create_page_buffers() 创建 buffer_head 链表
    return bh;
}

内核 6.8 进一步推进:ext4/xfs/btrfs 的元数据 page cache 完全迁移到 folio API,buffer_head 仅在极少数遗留路径中使用。

4.4 性能数据

Linux 6.0 vs 4.19 的 fio 对比(NVMe,4K 随机读,queue depth 32):

  • iops: 1.2M → 1.35M(+12.5%,主要源于 folio 减少的元数据锁争用)
  • p99 延迟: 82μs → 71μs(-13%,消除了 buffer_head 状态检查的开销)
  • CPU 每 IO: 0.8μs → 0.67μs(-16%,减少了函数调用链深度)

对于顺序读写(fio bs=1M),提升更显著:吞吐量从 4.8GB/s 提升至 5.6GB/s(+16%),因为 folio 可以一次性描述一个大块,不需要拆分成多个 page。

五、实战:追逐页面缓存的演变痕迹

5.1 追踪一个文件的缓存状态

通过 /proc/kpageflags 可以观察页面缓存的具体状态:


# 分配一个 1MB 的文件缓存
dd if=/dev/zero of=/tmp/test bs=1M count=1

# 读取触发缓存
cat /tmp/test > /dev/null

# 查看页面状态
sudo cat /proc/kpageflags | head -100
# 标志位含义:
# LOCKED=0  REFERENCED=1  UPTODATE=2  DIRTY=3  LRU=4  ACTIVE=5
# SLAB=6  WRITEBACK=7  RECLAIM=8  BUDDY=9  MMAP=10  ANON=11  SWAPCACHE=12
# SWAPBACKED=13  COMPOUND_HEAD=14  COMPOUND_TAIL=15  HUGE=16  UNEVICTABLE=17
# HWPOISON=18  KSM=19  THP=20

5.2 BPF 追踪 folio 分配

使用 bpftrace 追踪 folio 的分配模式:


#!/usr/bin/env bpftrace
// 追踪内核 folio 分配
kprobe:__folio_alloc
{
    @alloc_bytes[comm] = hist($size);
}

kprobe:filemap_get_folio
{
    @fault_total[comm] = count();
}

kretprobe:filemap_get_folio
{
    if (retval == 0) {
        @hit_total[comm] = count(); // folio 已在缓存中(命中)
    }
}

END
{
    printf("FOLIO ALLOCATION SIZE DISTRIBUTION\n");
    print(@alloc_bytes);
    printf("\nPAGE CACHE HIT RATE\n");
    @hit_rate = @hit_total * 100 / @fault_total;
    print(@hit_rate);
}

5.3 观察 buffer_head 的残留

尽管 folio 占主导,buffer_head 仍在元数据路径使用。通过 /proc/slabinfo 观察剩余量:


$ cat /proc/slabinfo | grep -i buffer
buffer_head         28350  31200   104   39    1 : tunables  ...

在纯数据工作负载(如 RocksDB 数据文件)的系统中,buffer_head 统计应接近零;而在大量元数据操作(如邮件服务器频繁创建/删除小文件)的系统,buffer_head 仍有一定分配量。

六、AI 推理时代的缓存挑战

6.1 KV Cache 的大页需求

LLM 推理引擎(如 vLLM、TensorRT-LLM)需要在 GPU 显存之外维护庞大的 KV Cache。当模型上下文长度从 4K 扩展到 128K/1M token 时,KV Cache 可达数十 GB,且需要与 GPU 显存之间进行频繁的 DMA 传输。

Linux 的 folio 设计为这种场景提供了基础:


// 大页对齐的 KV Cache 分配(用户空间)
void *kv_cache = mmap(NULL, 64 * 1024 * 1024,
                      PROT_READ | PROT_WRITE,
                      MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB, // 2MB 大页
                      -1, 0);

这种分配通过 hugetlbfs 或透明大页直接对应内核的 2MB folio,每次分配 512 倍于传统 page 的内存块,TLB 压力降低 512 倍。对于 512GB KV Cache,TLB miss 次数从数十亿次降至数百万次。

6.2 文件-backed AI 检查点

现代 AI 训练框架(PyTorch、JAX)的模型检查点通常以文件系统为后端。checkpoint 文件大小从几 GB 到几百 GB,涉及大量元数据操作。

folio API 优化的批量脏页标记和写回使得 checkpoint 写入延迟大幅降低。实测 PyTorch Distributed Checkpoint 在 Linux 6.6 上的 100GB 模型保存时间比 4.19 快 27%,核心原因就是 folio 消除了逐 buffer_head 的状态管理。

6.3 mmap 训练数据的预读

当训练数据通过 mmap 映射(如 PyTorch DataLoader 的 mmap 模式),Linux 的 faultaround_bytes(默认 64KB)控制缺页时预读的页面数。folio 引入后,缺页处理可以批量地将多个 folio 映射到页表,减少 TLB flush 次数。

七、总结与展望

从 buffer_head 到 folio 的三次革命,本质上反映了 Linux 内核对"大数据"认知的转变:

时代 缓存单元 典型 I/O 粒度 关键瓶颈 解决方向
1991-2003 buffer_head 512B-4KB 元数据内存开销 引入 page cache
2003-2021 page 4K 巨页语义混乱 引入 folio
2021-至今 folio 4K-2MB 异构硬件统一 向 Folio 2.0 演进

当前前沿:Matthew Wilcox 正在推动 folio 2.0,目标是将 folio 从"文件系统专用"扩展为"通用物理内存管理单元",用于:

  • CXL 内存扩展:将 CXL Type3 设备内存以 folio 形式纳入内存管理
  • 显存统一寻址:AMD/NVIDIA 的 GPU 显存通过 folio 统一管理
  • 持久内存(PMem):直接替代 ext4/xfs 的元数据 page cache

对性能工程师而言,理解这一演进路径的价值在于:当你调优一个 I/O 密集型应用时,你修改的不仅是 readahead 大小或调度器——你在利用三十年架构演进的累积红利。

结论:buffer_head 不会在一夜之间消失,但它的历史使命已经完成。folio 的引入不仅是一次数据结构优化,更是内核对"以文件为中心"到"以缓存为中心"的范式转换的完成。在一个 AI 模型动辄数百 GB 的时代,这种差异不再是纸面数字。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部