背景
回收匿名 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 回退完整保留,行为不回归