0/5 已展开

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.hkernel/cgroup/Makefilekernel/cgroup/bpf_cpu.c(新)、kernel/cgroup/cgroup-internal.hkernel/cgroup/rstat.ckernel/sched/core.c
    • tools/testing/selftests/bpf/cgroup_iter_cpu.h(新)、tools/testing/selftests/bpf/configtools/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 的路径:

  1. 打开 cgroup 文件需要 VFS / kernfs 调用,每次都涉及 inode、seq_file 上下文;
  2. 多个值是分别读取的,两次 read 之间可能跨过更新窗口,存在一致性撕裂;
  3. 通过 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_cputimethrottled_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:风险与注意点

  1. cputime_adjust 仍是热点路径__css_rstat_lock 是全局自旋锁,每次 kfunc 调用都会和所有 cgroup 统计更新竞争;批量读取场景需要调度方评估是否真的比 N 次 read 便宜。
  2. 「verifier 读整个 struct」的语义cpu_cgroup_cputime 的 out 必须由调用者清零;verifier 在一些老版本上对未初始化字段敏感,文档需保持显式。
  3. 节流计数未走 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 访问这些字段。
  4. KF_SLEEPABLE 的取舍bpf_cpu_cgroup_throttled_self 内部用 guard(rcu)(),并不睡眠;和 KF_SLEEPABLE 的另两个一起注册不会破坏正确性,但读者可能会疑惑为什么非睡眠路径也走 sleepable。
  5. sched/core.c 去掉 static:会让 throttled_time_self 进入通用内核 ABI;虽然只声明在 CONFIG_CFS_BANDWIDTH 下,但调度侧潜在命名冲突 / 跨翻译单元可见性副作用值得调度 maintainer 关注。

一句话总结

把 cpu cgroup 的统计读取从「逐字段 open/read」下沉为三个 BPF kfunc(flush_statscputimethrottled_self),让一次 BPF 程序触发即可原子地拿到基础 CPU 时间 + 节流状态,沿用 mm/bpf_memcontrol.c 已有的 memcg kfunc 路线。