背景
zswap 把压缩后的匿名页放在内存池里,当池子需要腾空间时由 shrinker 触发 writeback:把 cold entry 解压进新分配的 order-0 swap cache folio,再把这个 folio 写回 swap 设备。这个 folio 从构造上就是冷的。它只是为了承载写回而生,之后不会再被访问。
问题在于,当前实现写完之后并不释放它,而是把它留在 LRU 上,等后续的 page reclaim 扫到再回收。对本该立即消失的 cold folio 来说,这样的处理既多消耗 1 次回收扫描,又让冷内存比必要的时间驻留更久。
zswap writeback 流程与 cold folio 去向
┌───────────────────────────────┐
│ zswap shrinker 触发 writeback │
└───────────────┬───────────────┘
▼
┌───────────────────────────────┐
│ 分配 swap cache folio 并解压 │
└───────────────┬───────────────┘
▼
┌───────────────────────────────┐
│ 发起写回 swap 设备 │
└───────────────┬───────────────┘
▼
┌───────────────────────────────┐
│ cold folio 留在 LRU 等回收 │ 浪费回收扫描且冷内存常驻
└───────────────────────────────┘
问题
- writeback 解压出的 folio 本质是冷的,写完即无用。
- 它却进入 LRU,必须等 page reclaim 扫到才能释放。
- 这多消耗 1 次回收扫描,并推迟真实热数据的回收。
- 大量 writeback 时 cold folio 被反复扫描旋转,pgrotated 飙升。
方案
这套补丁的思路是让 writeback 一完成就立即释放这个 cold folio,而不是丢给回收路径。
这套补丁没有把释放逻辑塞进 zswap 自己实现,而是把它做成一个通用的 core-mm 机制,再让 zswap 选用:把既有的 dropbehind 机制扩展到 swap cache folio。一个打了 PG_dropbehind 标记的 folio 在 writeback 完成时就会从所属 cache 移除,对 swap cache folio 而言,这个「drop」就是把它从 swap cache 里拿掉,而不是留在那等回收。
要能直接释放,前提是这个 folio 分配时就根本不上 LRU:folio_add_lru() 会在 per-CPU 批次里留下一份引用,drop 时引用计数对不上预期,folio 只能交回回收路径。先把 folio_add_lru() 从 swap cache 分配器里挪到调用方,于是那些写回完成就会被 drop 的 folio 可以「离开 LRU」分配,而不必再为它引入一个跳过 LRU 的标志;分配函数也随之重命名为 __swap_cache_alloc_folio()。
drop 本身要拿 folio 锁与 swap cluster 锁,其中 swap cluster 锁是关中断的自旋锁、并不睡眠,真正可能睡眠等待的是 folio 锁,而 writeback 可能在中断上下文完成,那里不能睡眠。file dropbehind 路径面对同一矛盾的解法是给写出设置 BIO_COMPLETE_IN_TASK,让完成回调改在进程上下文里跑,swap writeback 照搬这一做法,folio_end_writeback() 认出打了标记的 swap cache folio 后直接就地 drop。drop 经 remove_mapping_set_shadow() 从 swap cache 摘下,按与回收相同的语义存下 workingset shadow,之后的 swap-in 仍能按 refault 检测并重新激活;期间被 swap-in 抢走的 folio 擦掉标记放回 LRU。
zswap 把自己的 writeback folio 按离开 LRU 的方式分配并打上 dropbehind 标记,选用这套机制;启动 writeback 前还先放掉自己持有的 folio 引用,让 swap cache 持有唯一引用:remove_mapping_set_shadow() 对引用计数有预期,多一份引用 drop 就会失败,folio 被交回回收路径。这正是要提前放引用的原因:同步 IO 的完成回调随 writeback 同步执行,若引用放得晚一步,drop 就会抢在 zswap 放手之前因引用对不上而回退。
通用 dropbehind 机制让 zswap 写回 folio 即时释放
┌───────────────────────────────────────┐
│ 把 dropbehind 扩展到 swap cache folio │
│ 成为通用 core-mm 机制 │
└───────────────────┬───────────────────┘
▼
┌───────────────────────────────────────┐
│ 写回完成即从 swap cache 移除而非留给 │
│ 回收 │
└───────────────────┬───────────────────┘
▼
┌───────────────────────────────────────┐
│ zswap 把写回 folio 离开 LRU 分配并标 │
│ 记 dropbehind 选用该机制 │
└───────────────────────────────────────┘
这套机制之所以只用在 zswap 的冷写回,是有意为之。把它推广到一般回收的 swap folio 会伤害快速 refault 的捕获:一般回收保留 swap cache 是值得的,因为刚换出的页很可能马上又被访问;而同步 IO 或 zswap 写回时回收者本就在等待,不存在这个收益。实际原型验证也印证了这点,把 dropbehind 加到所有回收 swap folio 上会让 sysbench OLTP 在 NVMe swap 上吞吐回退约 15%。
收益
测试在 cgroup 限内存以强制 zswap shrinker writeback 的条件下进行,对比 baseline 与本系列。kernel build 为 defconfig、make -j4、memory.max=600M,走异步 swap(NVMe)的 BIO_COMPLETE_IN_TASK 路径:
| 指标 | Before | After | 改善 |
|---|---|---|---|
| pgrotated | 441028 | 2521 | -99.4% |
| pgsteal_direct | 3524343 | 2869004 | -18.6% |
| pgscan_direct | 8393791 | 7765008 | -7.5% |
| zswpwb | 705129 | 699019 | -0.9% |
| 构建耗时 | 1155s | 1114s | -3.6% |
MySQL/OLTP 为 sysbench、10 表各 1M 行、512M buffer pool、8 线程、300s、memory.max=256M:
| 指标 | Before | After | 改善 |
|---|---|---|---|
| transactions/s | 153.87 | 163.30 | +6.1% |
| p95 延迟 | 157.42ms | 145.82ms | -7.4% |
| 平均延迟 | 52.09ms | 49.05ms | -5.9% |
| pgrotated | 743738 | 22460 | -97.0% |
| pgsteal_direct | 6886490 | 5445278 | -20.9% |
| pgscan_direct | 13462510 | 10820730 | -19.6% |
作为验证,kernel build 中 699019 个 writeback folio 有 698990 个(99.996%)在 writeback 完成时及时释放,只有 28 个回退到回收路径。同步 IO 设备侧用 zram(作者手边唯一的 SWP_SYNCHRONOUS_IO 后端)验证:每轮约 670 万次 writeback 中 99.999% 的 folio 及时释放,引用计数回退仅触发 37~50 次;作为对照,若把 folio 放上 LRU,及时释放的比例跌到 2.7%。