Linux 内核内存管理:从 Compound Pages 到 Folios 的演进与 HugeTLB 工程实践


引言:为什么内核需要重新审视 "Page"

在 Linux 内核中,struct page 是最基础的内存管理元数据结构。历史上它承担了三种不同角色的语义:

  • Base page(4KB 基础页):最常见,buddy allocator 分配的基本单元。
  • Compound page(复合页):由 2 个到 512 个连续 base page 组成的复合体,传统上用于 HugeTLB 和 THP。
  • Tail page:compound page 中除 head page 之外的所有附属页。

这种混合设计在代码层面带来了大量 PageCompound(page)、PageHead(page)、PageTail(page) 的分支判断,导致 API 语义模糊。最典型的例子是 put_page(compound_head(page)) 这个模式——每个人都看过,几乎没人第一次就能正确理解。

Linux 5.16 引入的 Folio 重构试图从根本上解决这个问题。这篇文章将深入分析 Folio 的设计动机、数据结构演进、与 Buddy Allocator 的交互,以及在 AI 推理、云原生和数据库系统中管理大内存的工程实践。


一、Compound Pages 的历史包袱

1.1 它当初是如何工作的

在 Folio 出现之前,当内核需要分配一个 2MB 的 THP(Transparent Huge Page),它从 buddy allocator 中取出一对连续的物理页面,通过设置 PG_head 标志位将第一个页面标记为 head,其余所有页面通过 compound_head 指针循环回指 head。

// 旧式 compound page 访问模式
struct page *page = alloc_pages(GFP_KERNEL, 9); // 分配 2^9 个 page
SetPageHead(page);                              // page[0] 是 head
for (i = 1; i < (1 << 9); i++) {
    set_compound_head(&page[i], page);          // tail 回指 head
    page[i].compound_dtor = ...;
    page[i].compound_order = 9;
}

这种设计的问题是:任何一个指向 tail page 的指针,在使用前都必须先跳到 head page。于是我们看到代码里充斥着这样的防御性编程:

// 常见的 "put_page 前先跳到 head" 模式
put_page(compound_head(page));

更糟糕的是,很多 API 对于传入 base page、compound page 或 tail page 有不同语义。例如 get_page() 仅对 head page 增加引用计数,但开发者经常忘记判断,导致内存泄漏或提前释放。

1.2 HugeTLB 的固定池限制

传统的 HugeTLB 采用了完全不同的分配路径——启动时预留的静态池:

# 启动参数
hugepagesz=2M hugepages=1024

这意味着:

  • 无法动态扩容:池用尽后,即使有大量空闲内存,也只能回退到 4KB 页分配。
  • 分配粒度僵化:预留的 2MB 大页无法拆分用于其他用途,造成内存浪费。
  • NUMA 亲和性差:静态池不感知进程的 NUMA 拓扑,跨节点访问频繁。

在云原生场景中,容器对内存需求波动剧烈,静态池的僵化设计成为显著痛点。


二、Folio 的新范式

2.1 核心思想:用 typed pointer 替代 flag-based dispatch

Folio 的核心洞察是:如果我们明确知道一段连续内存的用途(文件页缓存 huge page、匿名大页、slab 复合分配),那就不要再用运行时 flag 判断,而是用类型系统进行区分。

// include/linux/mm_types.h
struct folio {
    unsigned long flags;
    union {
        struct address_space *mapping;  // 文件页缓存
        void *private;                  // 私有数据
    };
    struct list_head lru;
    /* ... 省略部分字段 ... */
};

关键区别:

  • struct folio 不是 struct page 的新版本,而是对连续内存更高级别的抽象。
  • struct folio 和 struct page 在内存级别上物理同构——一个 4KB 的区域既是 struct page 也可以是 struct folio,取决于你如何解释它。
  • 对于复合分配的 folio(2^n 页),struct folio 指向 head,后续 tail pages 仍然通过 struct page 管理。

2.2 从 page 到 folio 的 API 演进

为了保持向后兼容,内核采用了一种 "Folio Layer" 策略:在大多数新代码中,API 签名从 struct page * 变为 struct folio *。

// 旧 API
void unlock_page(struct page *page);
int truncate_inode_page(struct address_space *mapping, loff_t index);

// 新 API
void folio_unlock(struct folio *folio);
int folio_wait_locked(struct folio *folio);
bool folio_mark_dirty(struct folio *folio);

当旧代码仍在传递 struct page * 时,通过 page_folio(page) 宏动态转换——它检查 page 是否是 head,如果是 compound/tail 则先跳到 head,再解释为 folio。

2.3 Buddy Allocator 与 Folio 的交互

Folio 重构引入了一个关键的新 buddy 交互层:复合分配语义从内核路径消失,转而由 folio API 封装。

// 分配一个 2MB 的 folio(等效于 9 阶 compound page)
struct folio *folio = folio_alloc(GFP_TRANSHUGE, 9);
// 等价于旧式的 alloc_pages(GFP_TRANSHUGE, 9),但返回类型统一为 folio*

if (!folio)
    return -ENOMEM;

// 释放
folio_put(folio);

这意味着 buddy allocator 仍然是底层调度器——它处理物理页框的合并与分裂——但上层不再直接面对 "我的复合分配包含了哪些页面,谁负责释放哪一个"。


三、Transparent Huge Pages:从 THP 到大 Folios

3.1 THP 的局限性与 transparent 的幻觉

THP 最初的设计目标是"透明"——应用程序无需修改内核就能自动使用大页。这在许多场景下确实有效,但有两个根本缺陷:

1. 碎片化导致 THP 分配失败

当系统运行时间较长后,物理内存被各种小块分配切割得支离破碎。此时即使空闲内存总量足够,也无法凑出 2MB 的连续物理块。THP 分配就会静默失败回退到 4KB——"transparent" 变成了 "默默地失效"。

# 当 /proc/vmstat 中 thp_fault_alloc 停止增长而 thp_fault_fallback 持续增长时,
# 就说明 THP 正在系统性失效
$ grep thp_ /proc/vmstat
thp_fault_alloc 12847
thp_fault_fallback 29384    # fallback 远超 alloc,碎片化严重

2. THP 的突发延迟峰

当 THP 分配失败时,内核会触发 compaction(内存规整)来合并出连续空间。Compaction 的过程涉及页面迁移、TLB 刷新和 CPU 密集扫描,在某些场景下会导致毫秒级延迟抖动——对于数据库和实时处理系统是不可接受的。

3.2 HugeTLB Folios:打破静态池

Linux 5.17+ 引入了 HugeTLB Folio,这是最关键的应用场景:

// mm/hugetlb.c - 大页分配的 folio 化
struct folio *folio;
folio = alloc_hugetlb_folio(vma, addr, flags);
if (!folio)
    return ERR_PTR(-ENOMEM);

HugeTLB Folio 实现了动态大小层次:

  • 同样使用 buddy allocator 作为后端,不再依赖启动时预留的静态池。
  • 支持多种大页大小(4KB、2MB、1GB)的统一管理。
  • 失败时可优雅降级(fallback)到更小尺寸,而非直接失败。

这意味着可以在运行时动态调整大页池大小:

# 动态调整 2MB 大页池
echo 512 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
# 无需重启立即生效

四、生产环境工程实践

4.1 AI 推理系统中的 HugeTLB 配置

在部署大型语言模型的推理服务时(如 vLLM + LLaMA-70B),模型参数加载后常驻内存。如果不使用大页,4KB 的粒度会导致巨大的 TLB miss 开销:

# 查看当前 TLB miss 率
perf stat -e dtlb_load_misses.stlb_hit,dtlb_load_misses.miss_causes_a_walk \
  -p $(pgrep -f vllm) sleep 10

生产部署建议:

# 1. 启用 THP madvise 模式(仅在使用 madvise 的区域生效)
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled

# 2. 在应用代码中显式标记需要大页的内存区域
#include <sys/mman.h>
void *model_weights = mmap(NULL, 140 *GiB,
                           PROT_READ | PROT_WRITE,
                           MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB,
                           -1, 0);
madvise(model_weights, 140 * GiB, MADV_HUGEPAGE);

对于 vLLM 这类依赖 PagedAttention 的服务,HugeTLB 不仅减少 TLB miss,还因为 folio 与 page cache 的更好集成,使得 KV Cache 的页面换入换出开销更低。

4.2 数据库系统中的显式 HugeTLB

PostgreSQL 和 MySQL 等数据库通常建议对共享缓冲池(shared_buffers)使用大页:

# postgresql.conf
huge_pages = on          # 启动时预分配 huge pages
shared_buffers = 64GB    # 64GB 缓冲池

对应的内核配置:

# /etc/sysctl.conf
vm.nr_hugepages = 32768  # 64GB / 2MB = 32768 页

使用 HugeTLB Folios 后,数据库可以运行在 huge_pages=try 模式——如果大页不足,MySQL 会回退到普通页,而非像以前那样直接启动失败。这一点在容器化部署中尤为重要,因为Pod的内存限制可能动态调整。

4.3 监控与诊断

在 THP 环境中,监控的关键指标:

# 查看 THP 使用情况
$ cat /proc/meminfo | grep -i huge
AnonHugePages:   180224 kB      # 当前匿名大页使用量
HugePages_Total:     1024
HugePages_Free:       512
Hugepagesize:       2048 kB

# 查看 THP 分配失败趋势(核心诊断指标)
$ grep -E "thp_fault|thp_fault_fallback|thp_compaction" /proc/vmstat
thp_fault_alloc 82947
thp_fault_fallback 120938    # fallback 率 = 59%,碎片化需要干预
thp_compaction_alloc_fail 892

# 查看 buddy allocator 碎片化级别
$ cat /proc/buddyinfo
Node 0, zone   Normal  128 56 23 8 4 2 1 0 0 0 0
# 第 n 列表示 2^n 个连续页面块的可用数量
# 2^9=512页(2MB) 的连续块 = 0,说明 2MB 连续空间已耗尽

当发现 thp_fault_fallback 与 thp_fault_alloc 比例超过 50%,且 buddy 信息显示高阶块严重不足时,需要触发主动内存整理:

# 触发一次全量内存规整
echo 1 > /proc/sys/vm/compact_memory

五、代码实战:编写一个使用 Folio 的内核模块

以下是创建 HugeTLB 映射的内核模块代码片段,演示现代内核 API 的正确用法:

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/mm.h>
#include <linux/hugetlb.h>
#include <linux/slab.h>

static int __init huge_init(void)
{
    struct folio *folio;
    void *addr;
    struct page *pages;

    /* 1. 分配一个 2MB 的 HugeTLB folio */
    folio = alloc_hugetlb_folio(current->mm->mmap, 0,
                                 VM_READ | VM_WRITE);
    if (!folio) {
        pr_warn("HugeTLB folio 分配失败,回退到普通页");
        return -ENOMEM;
    }

    /* 2. 获取 folio 对应的内核虚拟地址 */
    addr = folio_address(folio);
    if (!addr) {
        folio_put(folio);
        return -ENOMEM;
    }

    /* 3. 读取 folio 的物理地址和大小 */
    pr_info("HugeTLB folio: pfn=%#lx, size=%u pages\n",
            folio_pfn(folio),
            folio_nr_pages(folio));

    /* 4. 增加引用计数避免被回收 */
    folio_get(folio);

    return 0;
}

static void __exit huge_exit(void)
{
    /* 在实际模块中需要保存 folio 指针用于释放 */
    pr_info("HugeTLB module exited");
}

module_init(huge_init);
module_exit(huge_exit);
MODULE_LICENSE("GPL");

六、总结与展望

从 Compound Pages 到 Folios 的演进,本质上是 Linux 内核内存管理系统的一次语义清晰化重构:

  1. 类型明确的 API:folio 指针语义明确,消除了 head/tail 分支判断的混乱。
  2. HHugeTLB 动态化:打破了静态池限制,与 buddy allocator 统一后端。
  3. THP 的精细化控制:支持 madvise 显式声明,避免 compaction 的不可预测性。
  4. 为 CXL 内存铺路:分层内存架构需要更灵活的页粒度抽象,folio 为此提供了基础。

对于高性能系统工程人员来说,理解 Folio 不仅有助于读懂内核代码,更是正确配置 AI 推理服务、数据库和云原生负载内存管理的必经之路。当你的应用程序报错 "Cannot allocate memory" 时,也许不是你用了太多内存——而是内核找不到连续的物理块来回答你的 THP 请求。

关键命令速查:

# 检查 THP 模式
cat /sys/kernel/mm/transparent_hugepage/enabled

# 检查碎片化
cat /proc/buddyinfo

# 检查 THP 分配失败率
grep thp_fault /proc/vmstat

# 动态调整大页池
echo N > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages

# 手动触发规整
echo 1 > /proc/sys/vm/compact_memory

参考资料

  • Linux 内核源码:mm/hugetlb.c、mm/madvise.c、include/linux/mm_types.h
  • Matthew Wilcox, "Folios" LWN series (2020-2021)
  • Linux 5.16/5.17 内核 HugeTLB 相关合并提交
  • "HugeTLB Folios" - kernelnewbies mailing list discussions (2022)

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.351379s