背景
memcg-aware 的 shrinker 要按 memcg 记两笔账:这个 memcg 是否持有该 shrinker 的对象(bitmap 里的 1 位),该 shrinker 欠这个 memcg 多少可回收对象(nr_deferred 计数器)。每 64 个 shrinker id 的账本打包成 1 个 shrinker_info_unit:64 个 atomic_long_t 计数加 1 个 bitmap 字,64 位机上一共 520 字节。
每个 superblock 都注册 1 个这样的 shrinker,而 memcg 一上线就在每个 NUMA node 按 shrinker_nr_max(历史发过的最大 id 数)把覆盖到的 unit 全部预分配好。unit 数量因此是三个数的乘积:cgroup 数、node 数、id 段数。
问题出在分槽。unit 走 kzalloc_node(),kmalloc 按 2 的幂分槽,520 字节放不下 512 字节槽,只能进 1 KB 槽。
520 字节的 unit 住进 1 KB 槽位
┌───────────────────────────────────────────┐
│ unit 装 64 个 deferred 计数加 1 个 bitmap │
│ 字,共 520 字节 │
└─────────────────────┬─────────────────────┘
▼
┌───────────────────────────────────────────┐
│ kmalloc 按 2 的幂分槽,520 字节只能 │
│ 进 1 KB 槽 │
└─────────────────────┬─────────────────────┘
▼
┌───────────────────────────────────────────┐
│ 每个 unit 近一半是 padding,浪费再乘 │
│ cgroup 数与 node 数 │
└───────────────────────────────────────────┘
每个 unit 浪费近一半,再被乘数放大:挂载越多 id 段越多,cgroup 越多、node 越多,padding 的总量越大,而这笔内存不可回收。
问题
- unit 共 520 字节,kmalloc 按 2 的幂分槽只能进 1 KB 槽
- 每个 unit 近一半是 padding,且不可回收
- unit 数量随 cgroup 数乘 node 数乘 id 段数放大
- 每个 superblock 注册 1 个 shrinker,id 段数随挂载上涨
方案
思路直接:既然通用槽位按 2 的幂分,就给 unit 建一个按它实际大小分槽的专用 kmem_cache。memcg 子系统初始化时创建 shrinker_unit_cachep,unit 的分配与释放从 kzalloc_node()、kfree() 改走 kmem_cache_alloc_node()、kmem_cache_free(),unit 本身一个字节都不动。
同样 16 KB,装得更密
┌───────────────────┐ ┌───────────────────────┐
│ 旧,kmalloc-1k 槽 │ │ 新,专用 cache 按 520 │
│ 位,16 KB 只装 16 ├──▶│ 字节分槽,16 KB 装 31 │
│ 个 unit │ │ 个 │
└───────────────────┘ └───────────────────────┘
同样一个 order-2 的 16 KB slab,kmalloc-1k 只装 16 个 unit,专用 cache 装 31 个,密度接近翻倍。
一个刻意的取舍:这个 cache 不做 cacheline 对齐。unit 内部的 64 个计数器本来就被不同 shrinker 共享 cacheline,对齐单位边界只能让相邻 unit 互不跨线,收益有限;而对齐会把 520 字节重新撑大,吞掉一部分节省。
不做 cacheline 对齐的取舍
┌────────────────────────────────────────┐
│ unit 内部的计数器本来就被不同 shrinker │
│ 共享 cacheline │
└───────────────────┬────────────────────┘
▼
┌────────────────────────────────────────┐
│ 对齐只能让相邻 unit 互不跨边界,收益 │
│ 有限 │
└───────────────────┬────────────────────┘
▼
┌────────────────────────────────────────┐
│ 但对齐会把 520 字节重新撑大,吞掉一 │
│ 部分节省 │
└────────────────────────────────────────┘
收益
作者在 4-CPU 单 node 虚拟机上测:先挂 8000 个 tmpfs,再建 2000 个空 memcg,统计每个 cgroup 的 unreclaimable slab。
| 指标 | Before | After | 改善 |
|---|---|---|---|
| 每个空 cgroup 的 unreclaimable slab | 144 KB | 87 KB | 39.6% |
删除 cgroup 并 drop caches 后,新 cache 恢复原大小。功能验证:memory.reclaim 仍能正常回收 cgroup 在 140 个 ext4 superblock 上的对象;挂载继续抬高 shrinker_nr_max 期间,首次产生对象的 cgroup 在 dmesg 里无异常,包括对新 cache 开 slub_debug=FZPU 的情况;selftests 的 test_memcontrol 通过。