sched discussion
[PATCH 0/2] cgroup, sched: add bpf for cgroup cpu controller
LLM 分析
cgroup, sched: add BPF kfuncs for cgroup cpu controller — 分析
系列概况
- 标题:
[PATCH 0/2] cgroup, sched: add bpf for cgroup cpu controller - 作者:Ziyang Men
<ziyang.meme@gmail.com> - 版本:v1(系列号
[PATCH 0/2],未标注 -vN 后缀) - 规模:2 patches + 1 cover letter;合计 10 个文件变更,+480/-1 行;PATCH 1/2 触及 6 个文件 +143/-1,PATCH 2/2 新增测试 +337/0
- 修改文件:
include/linux/cgroup.h、kernel/cgroup/Makefile、kernel/cgroup/bpf_cpu.c(新)、kernel/cgroup/cgroup-internal.h、kernel/cgroup/rstat.c、kernel/sched/core.ctools/testing/selftests/bpf/cgroup_iter_cpu.h(新)、tools/testing/selftests/bpf/config、tools/testing/selftests/bpf/prog_tests/cgroup_iter_cpu.c(新)、tools/testing/selftests/bpf/progs/cgroup_iter_cpu.c(新)
- 代码统计:6 处修改 + 4 处新建(kernel 1 + selftest 3)
- Message-ID(首封):
20260813185846.1216892-1-ziyang.meme@gmail.com - 完整性:cover + PATCH 1/2 + PATCH 2/2 齐全;附 2 封
bot+bpf-ci自动测试结果回执,主题分别为 PATCH 1/2 和2/2 的 CI 测试成功
补丁目的
让 BPF 程序能够一次性、原子地读取一个 cgroup 的全部 CPU 统计(基础 CPU 时间 + CFS bandwidth 节流计数),避免传统 cat cpu.stat / cpu.stat.local 的路径:
- 打开 cgroup 文件需要 VFS / kernfs 调用,每次都涉及 inode、seq_file 上下文;
- 多个值是分别读取的,两次 read 之间可能跨过更新窗口,存在一致性撕裂;
- 通过 BPF kfunc 把这次读取下沉到内核,用户态可在一次 BPF 程序触发内同时拿到所有数值。
动机沿用 mm/bpf_memcontrol.c 已有的 memcg 路线,让 cpu controller 跟上 memcg 的能力。
旧流程的问题
userspace kernel
--------- ------
open cgroup_fd [kernfs_open -> seq_file]
read(cpu.stat) show_one() x N <- fields split
read(cpu.stat.local) seq_printf throttled_self
close kernel free seq_file
问题:
- 多次打开/关闭 -> 上下文切换 + 缓存抖动
- 多个 cpu.stat 字段各自独立 seq_printf,没有一次原子读出全部字段的接口
- cpu.stat.local 的 throttled_usec 是 tg->cfs_bandwidth 的求和,用户态拿不到
新流程
BPF prog kernel
--------- ------
enter cgroup iter --> css_rstat_lock
cputime_adjust (once)
css_rstat_unlock
-> bpf_cpu_cgroup_cputime(out)
-> bpf_cpu_cgroup_throttled_self(cgrp)
-> bpf_cpu_cgroup_flush_stats(cgrp)
exit return to BPF
- 一次进入 / 一次返回,所有字段同时落到 BPF 程序的
struct cpu_cgroup_cputime - 节流相关(tg->cfs_bandwidth)字段直接读 task_group 普通字段,无需新增代码;只有求和的 throttled_self 用了一个新的 kfunc
- bpf_cpu_cgroup_flush_stats 是把 cgroup 的 CPU 统计向上传播(沿 cgroup 树聚合),作为可选的主动 flush 入口
Patch 概览
- PATCH 1/2:内核实现。导出
struct cpu_cgroup_cputime与throttled_time_self()声明;新增kernel/cgroup/bpf_cpu.c暴露两个 kfunc(flush_stats、throttled_self);rstat.c 中增加bpf_cpu_cgroup_cputime并注册KF_SLEEPABLE(rstat 自旋锁不允许 NMI);sched/core.c去掉throttled_time_self()的static修饰。 - PATCH 2/2:selftest。在 test cgroup 中 fork 一个 CPU hog,设置 cpu.max 制造节流,用 BPF iter 程序读字段,再 read(cpu.stat) 对比数值是否一致;通过判断 nr_periods 字段存在与否自动适配是否开启 CONFIG_CFS_BANDWIDTH 的环境。
关键实现
1. cpu_cgroup_cputime 一次性读出基础 CPU 时间
__bpf_kfunc void bpf_cpu_cgroup_cputime(struct cgroup *cgrp,
struct cpu_cgroup_cputime *out)
{
struct cgroup_base_stat bstat;
if (cgroup_parent(cgrp)) {
__css_rstat_lock(&cgrp->self, -1);
bstat = cgrp->bstat;
cputime_adjust(&cgrp->bstat.cputime, &cgrp->prev_cputime,
&bstat.cputime.utime, &bstat.cputime.stime);
__css_rstat_unlock(&cgrp->self, -1);
} else {
root_cgroup_cputime(&bstat);
}
out->usage_usec = div_u64(bstat.cputime.sum_exec_runtime, NSEC_PER_USEC);
out->user_usec = div_u64(bstat.cputime.utime, NSEC_PER_USEC);
out->system_usec = div_u64(bstat.cputime.stime, NSEC_PER_USEC);
out->nice_usec = div_u64(bstat.ntime, NSEC_PER_USEC);
#ifdef CONFIG_SCHED_CORE
out->forceidle_usec = div_u64(bstat.forceidle_sum, NSEC_PER_USEC);
#else
out->forceidle_usec = 0;
#endif
}
要点:
cputime_adjust只调用一次,所有项同时算好后再复制到 out——「一次原子读出全部字段」的关键out由调用者清零(注释里写明 verifier reads the whole struct),verifier 会检查整个 struct,不能局部写forceidle_usec来自cgroup_base_stat.forceidle_sum,只在CONFIG_SCHED_CORE下存在
2. throttled_time_self 去除 static
// kernel/sched/core.c
-u64 static throttled_time_self(struct task_group *tg)
+u64 throttled_time_self(struct task_group *tg)
它是 cfs_bandwidth 的求和:扫描 tg->cfs_bandwidth.throttled_clock_self 不够,每个 cpu 还要再去 cfs_rq 取最新增量,所以由内核封装成 kfunc,用户态只调一次。
3. 注册为 KF_SLEEPABLE
BTF_KFUNCS_START(bpf_cpu_cgroup_kfunc_ids)
BTF_ID_FLAGS(func, bpf_cpu_cgroup_flush_stats, KF_SLEEPABLE)
BTF_ID_FLAGS(func, bpf_cpu_cgroup_cputime, KF_SLEEPABLE)
BTF_ID_FLAGS(func, bpf_cpu_cgroup_throttled_self)
BTF_KFUNCS_END(bpf_cpu_cgroup_kfunc_ids)
bpf_cpu_cgroup_throttled_self 内部用 guard(rcu)(),并不睡眠;前两个用了 __css_rstat_lock(自旋锁),NMI 上下文会破坏睡眠安全,所以必须 KF_SLEEPABLE。
4. selftest 的「冻-测-比」技巧
kill(hog, SIGSTOP); // 停止制造变化
waitpid(hog, NULL, WUNTRACED);
for (i = 0; i < 20; i++) {
parse_stats(cgroup_fd, &before, have_bw);
read_stats(link);
parse_stats(cgroup_fd, &after, have_bw);
if (!memcmp(&before, &after, sizeof(before))) { filev = before; stable = 1; break; }
usleep(100000);
}
ASSERT_EQ(q->usage_usec, filev.usage_usec, ...);
由于 CFS bandwidth 周期定时器在 hog 停掉后还会跑几拍,所以用「连续两次 parse_stats 内容相同」作为稳定信号,再和 BPF 读到的值做逐字段 ASSERT_EQ。
类比
把 CPU cgroup 想象成一个家庭电表箱:
- 旧流程:用户每分钟去电表箱前用望远镜读「本月用了多少」「白天用了多少」「夜间用了多少」——每次都得走过去,眼睛还要同时盯紧指针防止读错。
- 新流程:在电表箱里装一个智能读数器(BPF 程序),按下按钮一次,所有数字同时闪在屏幕上。这就是
bpf_cpu_cgroup_cputime:通过cputime_adjust一次性把基础 CPU 时间的多个字段在同一次持锁区间内算齐,再统一填进out。 bpf_cpu_cgroup_flush_stats像「主动校表」按钮:平时统计会沿 cgroup 树自动聚合上来,但如果你想立刻读到当前值(而不是下一拍才被汇总),就按一下。bpf_cpu_cgroup_throttled_self像「限电后累计停电分钟数」:每个电表(cpu)各记一段,但限电分钟数是「对所有电表求和」,人没法自己完成,所以内核代劳。
Highlight:风险与注意点
cputime_adjust仍是热点路径:__css_rstat_lock是全局自旋锁,每次 kfunc 调用都会和所有 cgroup 统计更新竞争;批量读取场景需要调度方评估是否真的比 N 次read便宜。- 「verifier 读整个 struct」的语义:
cpu_cgroup_cputime的 out 必须由调用者清零;verifier 在一些老版本上对未初始化字段敏感,文档需保持显式。 - 节流计数未走 kfunc:注释说
tg->cfs_bandwidth的字段(nr_periods/nr_throttled/throttled_usec/nr_bursts/burst_usec)是 BPF 程序直接读 tg 的字段——这意味着调用方要拿到struct task_group *,目前 kfunc 集合里没有导出访问入口,PATCH 1/2 与 PATCH 2/2 描述之间存在缺口(PATCH 2/2 的 selftest 怎么读这些字段需要看tools/.../progs/cgroup_iter_cpu.c,但消息正文里该文件内容截断了)。待跟进:检查完整 selftest 是否引入了额外的 kfunc / kptr 访问这些字段。 KF_SLEEPABLE的取舍:bpf_cpu_cgroup_throttled_self内部用guard(rcu)(),并不睡眠;和KF_SLEEPABLE的另两个一起注册不会破坏正确性,但读者可能会疑惑为什么非睡眠路径也走 sleepable。sched/core.c去掉static:会让throttled_time_self进入通用内核 ABI;虽然只声明在CONFIG_CFS_BANDWIDTH下,但调度侧潜在命名冲突 / 跨翻译单元可见性副作用值得调度 maintainer 关注。
一句话总结
把 cpu cgroup 的统计读取从「逐字段 open/read」下沉为三个 BPF kfunc(flush_stats、cputime、throttled_self),让一次 BPF 程序触发即可原子地拿到基础 CPU 时间 + 节流状态,沿用 mm/bpf_memcontrol.c 已有的 memcg kfunc 路线。