背景

回收匿名 large folio 时,reclaim 路径会先为它分配 swap 槽(folio_alloc_swap)。一旦分配失败,shrink_folio_list 的处理是把它 split 成基础页大小的 folio 再重试,理由是大块连续 swap 槽拿不到时,零散的小块也许还能放下。这套逻辑在 swap 碎片化或只是部分可用时是成立的:整块连续分配会失败,而单页仍有落脚处。

┌──────────────────────────────────────┐
│ reclaim 匿名 large folio 时为其分配  │
│ swap 槽                              │
└──────────────────┬───────────────────┘
                   ▼
┌──────────────────────────────────────┐
│ 分配失败则无条件 split 成基础页重试  │
└──────────────────┬───────────────────┘
                   ▼
┌──────────────────────────────────────┐  白白毁掉 large folio,还虚增 split 统
│ swap 完全不可用时 small folio 同样无 │  计
│ 处可去                               │
└──────────────────────────────────────┘

但当 swap 完全不可用时它就失灵了。禁用 swap、全局 swap 耗尽、或者 memcg 的 swap 限额已满,这 3 种情况下 split 出去的 small folio 同样无处可去:它们拿不到 swap 槽,只能被激活回退。split 的代价却已经付了,large folio 被拆散,mTHP 带来的 TLB 与可扩展性收益随之丧失,split 统计还被虚增一笔,误导后续的调试与调优。

问题

  • swap 完全不可用时仍无条件 split large folio
  • split 后的 small folio 同样无处可去,纯属浪费
  • 白白毁掉 large folio,损失 TLB 与 mTHP 可扩展性
  • 虚增 split 统计,误导调试与调优

方案

关键在于让回收路径看清 split 到底有没有指望。

folio_alloc_swap 失败的原因,恰恰说明了拆小之后还走不走得通:swap 只是凑不齐一整块时拆小有用,swap 根本不存在时拆小无用。让 folio_alloc_swap 把失败原因分类返回,回收侧就能只在拆分有望时才拆。

旧:分配失败即拆分 large folio
┌──────────────────────────────────────┐
│           分配 swap 槽失败           │
└──────────────────┬───────────────────┘
                   ▼
┌──────────────────────────────────────┐
│      无条件 split 成基础页重试       │
└──────────────────┬───────────────────┘
                   ▼
┌──────────────────────────────────────┐
│ swap 完全不可用时 small folio 仍无处 │
│ 可去                                 │
└──────────────────────────────────────┘

新:只在拆分有望时才拆
┌────────────────────────────────────────┐
│         按失败原因区分是否拆分         │
└───────────────────┬────────────────────┘
                    ▼
┌────────────────────────────────────────┐
│ 拆分有望才 split,small folio 可能放   │
│ 得下                                   │
└───────────────────┬────────────────────┘
                    ▼
┌────────────────────────────────────────┐
│ 无 swap 可用时走 activation 保留 large │
│ folio                                  │
└────────────────────────────────────────┘

folio_alloc_swap 现在把失败细分成 3 类:

  • -E2BIG 表示这个 folio 太大放不下,例如 THP_SWAP 关闭或超出 cluster 大小,又或者全局还有 swap 只是凑不齐一整块连续槽位,拆小之后仍可能放下,这类 split 才有意义
  • -ENOSPC 表示全局根本没有 swap 空间
  • -ENOMEM 表示失败不是 split 能解决的,典型是 memcg 的 swap charge 失败且层级内已无剩余容量

后 2 类里拆分毫无帮助。

分类的关键是把全局 swap 可用性与 memcg 层级的剩余可 charge 容量当作两个分开的输入,而不是混作一谈。一次失败到底值不值得拆,取决于这两层里是否某一层还能容下更小的 folio:

  • 全局根本没有 swap,拆再小也无处可去,返回 -ENOSPC
  • memcg 层级已无余量,拆小照样过不了 memcg 计账,返回 -ENOMEM
  • 只有两层都还可能有余地、且当前正是 large folio 时才返回 -E2BIG,意味着拆小后或许还能在某处落脚

memcg swap charge 失败这条路也汇入同一套分类:此时 swap 槽其实已经分配成功,卡住的只是 memcg 计账,撤销槽位后同样按全局与 memcg 层级两层余量归类。memcg 层级的剩余可 charge 容量由新加的 page_counter_margin() 算出,它从当前 page_counter 一路向根取每一层 max 与 usage 之差的最小值,也就是整条层级里最紧的那道限额还剩多少。

两条回收路径据此收紧 split 的触发条件。匿名页走 vmscan 回收,只在 folio_alloc_swap 返回 -E2BIG 时才 split large folio,其余失败走原有的 activation 路径,把 large folio 原样保留。shmem 走 shmem_writeout(),它以前在每次 folio_alloc_swap() 失败都 split,现在同样只在 -E2BIG 才进入 split 回退,其余失败 redirty 后重新激活 folio。swap 碎片化或部分可用时的 split 回退完整保留,砍掉的只是 swap 完全不可用时那部分无意义的拆分。

收益

1GB 匿名映射(16KB mTHP)、memory.swap.max=0 下 30 次 process_madvise(MADV_PAGEOUT) 实测,两侧均无实际换出:

指标 Before After 变化
单次调用中位延迟 743.8ms 181.7ms -75.6%
每次运行 large folio split 数 65536 0 -100%

DaCapo h2 在 swap 压力下的 wall 与 CPU 时间无统计显著变化;作者明确指出整体收益 minor 且与负载相关。机制层面的收益:

  • swap 不可用场景不再无意义拆分 large folio,保留 mTHP 的 TLB 与可扩展性收益
  • shmem_writeout() 同样纳入该契约,无 swap 时不拆 shmem large folio 而是重新激活
  • 消除无效 split 及其后续 activate 回退带来的 CPU 开销
  • split 统计不再被无 swap 场景虚增,能更真实地反映碎片化压力,利于调试与调优
  • swap 碎片化或部分可用时的 split 回退完整保留,行为不回归