背景
memcg 的两个核心数据结构访问频率极高:struct mem_cgroup 每个 cgroup 一个,struct mem_cgroup_per_node 每个 cgroup 每个 node 一个。分配的 charge 路径、回收、socket 计费、统计读取都要经手它们,而各路径的访问模式差别很大:有的字段每次回收迭代都写,有的在 memcg 创建时写好之后只读,有的只有 cgroup 控制路径才碰。
CPU 缓存以 cache line 为一致性粒度:一个 CPU 写某字段,同一条线上其余字段在所有 CPU 上的缓存拷贝一并失效。字段按声明顺序排布时,访问模式不同的字段很容易落进同一条线,形成 false sharing,一侧高频写,另一侧的读路径被反复拖慢。
更麻烦的是布局完全隐式:新增字段、删除字段、把几个字段收进 config 选项,都让后续成员整体挪动。memcg 因此反复踩坑,commit 98c9daf5ae6b 把 lruvec 挪到 lru_zone_size[] 旁边引发回归,靠 commit f59adcf59332 补 padding 修复,commit c1afbd5de131 又不得不在 vmstats 与 events 之间加 ____cacheline_aligned_in_smp。每个修复都正确,但没有任何机制阻止下一个字段再次破坏布局。
反复上演的布局回归
┌─────────────────────────────────────────┐
│ 新增、删除字段或 config 变化,结构体 │
│ 布局整体挪动 │
└────────────────────┬────────────────────┘
▼
┌─────────────────────────────────────────┐
│ 访问模式不同的字段落进同一条 cache line │
└────────────────────┬────────────────────┘
▼
┌─────────────────────────────────────────┐
│ 一侧频繁写,其余 CPU 的读路径缓存反 │
│ 复失效 │
└────────────────────┬────────────────────┘
▼
┌─────────────────────────────────────────┐
│ 手工补 padding 修复,下一次挪动又可 │
│ 能复发 │
└─────────────────────────────────────────┘
问题
- 字段按声明顺序排布,写热字段与只读字段常落进同一条 cache line。
- objcg 每次计费分配都读,却与每次 LRU 更新都写的 lru_zone_size[] 同线。
- 三个 cgroup_file 共 192 字节 notify 状态,紧挨统计指针 vmstats。
- 布局没有守护者,任何字段增删都可能再次引入 false sharing 回归。
方案
把布局从隐式约定变成编译期契约,做法与 struct net_device 的 cacheline group 相同:字段按访问模式分进命名组,组两端的标记负责对齐与隔离,memcg_struct_check() 在编译期逐字段断言归属,放错位置的字段直接让编译失败,不再静默流失几个百分点的性能。
布局契约如何生效
┌────────────────────┐ ┌────────────────────────┐ ┌────────────────────────┐
│ 字段按访问模式声明 │ │ 组标记负责对齐与隔离, │ │ memcg_struct_check() │
│ 进命名 cacheline ├──▶│ 组尾 padding 挡住 ├──▶│ 编译期逐字段断言归属, │
│ 组 │ │ 后续字段 │ │ 放错即编译失败 │
└────────────────────┘ └────────────────────────┘ └────────────────────────┘
契约由三组宏搭成。__cacheline_group_begin_aligned(name) 是零长标记加 SMP_CACHE_BYTES 对齐,组内字段从新的 cache line 开始;__cacheline_group_end_aligned(name) 在组尾补 padding,后续字段掉不进组的最后一条线;CACHELINE_ASSERT_GROUP_MEMBER 展开为 BUILD_BUG_ON,校验字段偏移落在组标记区间内,memcg_struct_check() 集中列出全部已分组字段,由 mem_cgroup_init() 调用。
__cacheline_group_begin_aligned(memcg_write_hot);
u64 socket_pressure;
atomic_long_t memory_events[MEMCG_NR_MEMORY_EVENTS];
...
__cacheline_group_end_aligned(memcg_write_hot);
分组之前有两处先行调整。current_obj_cgroup() 每次计费分配都读 nodeinfo[nid]->objcg,该字段原本在 struct mem_cgroup_per_node 尾部,与 lru_zone_size[]、iter 同线,先搬到头部只读指针旁。另一处是 struct mem_cgroup_private_id 的成员访问模式分裂:id 只在创建与销毁时写、每次驱逐与 refault 都读,ref 每次 swap charge 与 uncharge 都写,一个成员无法跨两个组,拆成 private_id 与 private_id_ref 两个标量后各进各的组。
struct mem_cgroup 分成三组:
重组后的 struct mem_cgroup
┌─────────────────────────────────────┐
│ css 与各 page_counter 保持原位 │
└──────────────────┬──────────────────┘
▼
┌─────────────────────────────────────┐ socket_pressure、memory_events、vmpressure、
│ write_hot 组,charge、回收与 socket │ private_id_ref 等
│ 路径频繁写 │
└──────────────────┬──────────────────┘
▼
┌─────────────────────────────────────┐ events_file 等三个 cgroup_file、cgwb 系
│ cold 组,仅 cgroup 控制路径触碰 │ 列、mm_list
└──────────────────┬──────────────────┘
▼
┌─────────────────────────────────────┐ vmstats_percpu、private_id、kmemcg_id
│ read_mostly 组,创建后只读 │ 等
└──────────────────┬──────────────────┘
▼
┌─────────────────────────────────────┐
│ nodeinfo 柔性数组同为只读,组尾不补 │
│ padding,与其共享一条线 │
└─────────────────────────────────────┘
write_hot 组里 socket_pressure 刻意与 memory_events 相邻:mem_cgroup_sk_under_memory_pressure() 读前者、累加后者,同线反而省一次取线。memory_events 的累加沿层级向上传播,忙的子 memcg 会弄脏整条祖先链,是写得最频繁的字段之一。
cold 组并非不写:cgwb_domain 每次 writeout 完成都写,mm_list 在 fork、exit 与 MGLRU aging 时写。分组的意义在于这些写落在自己的 cache line 上,不再波及快路径读取的字段。
read_mostly 组放在结构体尾部,用不带 padding 的 __cacheline_group_end 收尾,同为只读的柔性数组 nodeinfo[] 得以与组尾共享一条线,省内存也不引入 false sharing。vmstats_percpu 进了这一组,它头上历史遗留的 ____cacheline_aligned_in_smp 随之删除:原对齐只管字段开头,紧随其后、每次 writeout 完成都写的 cgwb_domain 仍与它同线,组边界则两头都管。cgroup v1 的遗留字段不进组,v1 已是遗产,整理不值得。
struct mem_cgroup_per_node 分成四组:
struct mem_cgroup_per_node 的四组
┌──────────────────────────────────────┐
│ read_mostly 组,反向指针与统计指针, │
│ 创建后只读 │ objcg 从尾部搬来,每次计费分配都读
└──────────────────┬───────────────────┘
▼
┌──────────────────────────────────────┐ lru_zone_size 被无锁读,与 lruvec 同线
│ lruvec 独占 cache line │ 曾致回归
└──────────────────┬───────────────────┘
▼
┌──────────────────────────────────────┐
│ write_hot 组,LRU 更新与回收迭代都写 │ lru_zone_size、iter、NMI slab 统计
└──────────────────┬───────────────────┘
▼
┌──────────────────────────────────────┐
│ cold 组,reparent 与释放时才触碰 │ orig_objcg、objcg_list
└──────────────────────────────────────┘
lruvec 独占一组是因为无锁读:lru_zone_size[] 在 lru_lock 下写,lruvec_lru_size() 读它时不持锁,两者同线曾导致回归。cold 组独立出来的代价是每 node 每 memcg 多占一条 cache line。
收益
两台同配置 30 核 Xeon 各跑新旧内核并互换启动顺序,机器与顺序影响相互抵消;false sharing 只有在一读一写并存时才显现,故测试在一个 cgroup 内并发两个负载:
| 负载组合 | 提升 |
|---|---|
| slab 分配侧,与 page fault 并发 | +1.2% |
| page fault 侧,与 memory.stat 读取并发 | +1.3% |
| memory.stat 读取侧,与 page fault 并发 | +0.8% |
| 其余组合 | 无变化 |
cgroup selftests 在新旧内核下结果一致。绝对提升不大,系列的重心是编译期契约:今后字段放错位置会在编译期暴露,而不是由性能机器人事后报告。