背景

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 通过。