背景
zswap 是压缩交换缓存,匿名页换出前先压缩存进内存池。每个存入页都对应一个 struct zswap_entry,记录 swpentry、压缩长度、所属 pool 等元数据。entry 的大小是纯 per-page 开销,x86_64 上现为 56 字节,其中 8 字节是指向所属 zswap_pool 的指针。
每个存入页都背一个 56 字节 entry
┌───────────────────────────────────┐
│ 每个存入页对应一个 zswap_entry │
└─────────────────┬─────────────────┘
▼
┌───────────────────────────────────┐
│ entry 56 字节,8 字节是 pool 指针 │
└─────────────────┬─────────────────┘
▼
┌───────────────────────────────────┐
│ 同时存活的 pool 仅个位数 │
└───────────────────────────────────┘
pool 绑定一种压缩算法与一个 zsmalloc 池,同时存活的 pool 极少:只有设置或切换压缩算法时才新建,切换后旧 pool 留着复用。目前这些 pool 串在一条 RCU 链表上,链表头还兼任「current pool」。对屈指可数且身份长期稳定的对象来说,每个 entry 花 8 字节记指针、全局再维护一条链表,都超过了实际需要。
问题
- 每个 zswap_entry 花 8 字节存 pool 指针,是纯 per-page 开销
- 同时存活 pool 仅个位数,指针相对索引过于奢侈
- pool 身份长期稳定,RCU 链表属于过度设计
- 56 字节 entry 拉低 slab 密度,放大元数据总量
方案
既然存活 pool 屈指可数,就给每个 pool 一个稳定的小整数 id,entry 记 id 即可。
pool 管理从 RCU 链表换成按需分配 id 的 xarray(XA_FLAGS_ALLOC1 | XA_FLAGS_LOCK_BH):id 从 1 开始分配,0 号保留永不分配,查找 0 号只会解析出 NULL 并触发 WARN,不会悄悄别名到一个活 pool。id 上限 255,因为 entry 里存 id 的字段是 u8;上限统计仍在 xarray 中的全部 id,包括已退役但还有 entry 引用的 pool。「current pool」不再由链表头兼任,改由一个单独的 RCU 保护指针跟踪。
pool 创建把「分配 id 并发布进 xarray」作为最后一步:要么完整建好发布,要么彻底回滚,读者只会看到就绪的 pool。运行时压缩算法参数更新由模块参数锁串行化,不会有两个创建者争抢 id。查找与统计的遍历都在 rcu_read_lock() 下走 xa_for_each(),xa_lock 只剩分配与擦除两处使用。pool 腾空时用 xa_erase_bh() 从 xarray 擦除并交还 id,锁外再交给 queue_rcu_work():宽限期过后由 system_percpu_wq 的 worker 完成可睡眠的拆除,腾空路径不再同步阻塞在宽限期上。XA_FLAGS_LOCK_BH 让 xa_lock 在软中断上下文可用,腾空路径正运行在 percpu_ref 释放回调里。id 交还后可以被复用;切回某个压缩算法时它的 pool 若还在 xarray 中,直接复活续用,不占新 id。255 个 id 全部占满时新建 pool 失败,压缩算法切换被拒绝。
链表换成按需分配 id 的 xarray
┌──────────────────────────────────────┐
│ 旧 RCU 链表,表头兼任 current pool │
└──────────────────┬───────────────────┘
▼
┌──────────────────────────────────────┐
│ xarray 按 id 管理 pool,0 号保留,上 │
│ 限 255 │
└──────────────────┬───────────────────┘
▼
┌──────────────────────────────────────┐
│ current pool 由单独 RCU 指针跟踪 │
└──────────────────────────────────────┘
id 稳定之后,entry 里的 8 字节 pool 指针换成 u8 id。u8 恰好落进 bool referenced 字段后面原有的填充里,不占新空间,entry 从 56 缩到 48 字节。取 pool 时经 zswap_entry_pool() 用 xa_load() 按 id 解析:xa_load() 的查找自身有 RCU 保护,调用者无需外加 rcu_read_lock();解析出的 pool 因活着的 entry 持有 percpu_ref 引用而不会释放,其 id 也不会在引用存续期间被复用。
u8 pool id 顶替 8 字节指针
┌──────────────────────────────┐
│ entry 里 pool 指针换成 u8 id │
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ u8 落进 referenced 后的填充 │
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ entry 从 56 缩到 48 字节 │
└──────────────────────────────┘
还有一处时序收紧:pool_idx 在压缩成功后、entry 经 xa_store() 发布进 swap tree 之前就写好。若发布后再写,entry 会短暂带着 slab 复用残留的旧 pool_idx 对读者可见,可能被解析到一个无关的活 pool。
换来的代价是每次 store、free、decompress 多一次 xarray 查找,替代原来的指针解引用。存活 pool 只有一个或几个时,xarray 走的层数极浅,这点开销淹没在压缩与解压成本里。
收益
基准环境为 x86_64、compressor=lzo,MADV_PAGEOUT 触发 store 再 fault-in 触发 load:
| 指标 | Before | After | 变化 |
|---|---|---|---|
| zswap_entry 大小 | 56 字节 | 48 字节 | -8 字节 |
| 单 slab 容纳 entry 数 | 73 | 85 | +12 |
| 每 1GiB zswap 数据的元数据 | 约 14MiB | 约 12MiB | 省约 2MiB |
| store+load 中位延迟 | 基线 | 无可测回归 | 持平 |