0/10 已展开

LLM 分析

sched/fair:修掉 enqueue 路径上 __calc_prop_weight() 的除零

系列概况

  • 标题:[BUG] sched/fair: divide error in __calc_prop_weight() from the enqueue path (flat-hierarchy series),以及后续 [PATCH] sched/fair: floor tg_cpus() at 1。
  • 作者:Jake Steinman(首发署名 "Jake S")。
  • 版本:单 patch,已合入 tip sched/urgent(commit 23906f3a1686c737bf356fdd21183b40722b2437,2026-08-20)。
  • 规模:1 file, 6 insertions(+), 1 deletion(-),全部落在 kernel/sched/fair.ctg_cpus() 函数体。
  • 修改文件kernel/sched/fair.c
  • 代码统计:把 return nr; 替换成带解释性注释的 return max(nr, 1);
  • Message-ID20260818231333.1441757-1-j@metarealtyinc.ca(首报);20260819132104.2148918-1-j@metarealtyinc.ca(PATCH)。
  • 完整性:原报告 → maintainer/AMD/Red Hat 追问 → patch → instrumentation 实测命中 → tip 合入 → 第二例用户复现 → 提议进一步 debug WARN,整条线索完整闭合。

补丁目的

堵掉 tip sched/core flat-hierarchy 重构里,enqueue 路径上 __calc_prop_weight() 触发的 #DE panic。表面看是被除数 cfs_rq->load.weight 自己变成 0,但根因在上游:tg_cpus() 不像它的兄弟 tg_tasks() 那样 floor 在 1,会把空 cpuset 的 0 一路传给 calc_concur_shares(),再用 clamp() 的边界条件悄悄绕过 MIN_SHARES 兜底,最终把 group se 的 load.weight 留在 0。

旧流程的问题

__calc_prop_weight() 在 enqueue 路径上的核心计算是:

weight *= se->load.weight;
if (parent_entity(se))
    weight /= cfs_rq->load.weight;

当父 cfs_rq 对应的 task_group 走过 calc_concur_shares() 时:

nr = min(tg_tasks(tg), tg_cpus(tg));   /* shares_max = nr */
shares = clamp_t(long, shares, MIN_SHARES, shares_max);
return shares;

tg_cpus() 直接 return cpuset_num_cpus(cgrp);,对 cgroup v2 空 cpuset / RCU 窗口 / hotplug 路径可能返回 0。一旦 tg_cpus() == 0clamp(MIN_SHARES, hi=0) 按 clamp 语义在 lo > hi 时返回 hi,于是 MIN_SHARES 兜底失效,group se 拿到 load.weight == 0。下一次任何 enqueue 进 __calc_prop_weight() 就触发 #DE

更糟的是第一次 #DE 之后:原作者观察到 oops 恢复路径 kill task → schedule() 又重新跑进同一 enqueue,而 rq 锁还没释放,二次 #DE 立即升级为 panic,连 oops 的容错窗口都用不上。

新流程

tg_cpus() 与早已 floor 在 1 的 tg_tasks() 对齐:

return max(nr, 1);

这样 shares_max >= 1MIN_SHARES 兜底真的兜得住:clamp(MIN_SHARES, hi >= 1) 至少返回 MIN_SHARES,group se 不再可能拿到 0。

Patch 概览

关键 hunk:

--- a/kernel/sched/fair.c
+++ b/kernel/sched/fair.c
@@ -4895,7 +4895,12 @@ static int tg_cpus(struct task_group *tg)
        nr = cpuset_num_cpus(cgrp);
    }
-    return nr;
+    /*
+     * An empty cpuset would propagate a 0 shares_max into
+     * __calc_smp_shares(), where clamp() yields hi when hi < lo and so
+     * defeats the MIN_SHARES floor. Match tg_tasks(), which floors at 1.
+     */
+    return max(nr, 1);
 }

关键实现

  1. tg_cpus() 直接 floor:与 tg_tasks() 行为对称,根除 min(tg_tasks, tg_cpus) 把 0 拉进 shares_max 的唯一现实路径。
  2. 不动 __calc_smp_shares() 的 clamp:作者明确说只移除 division hazard,不去改 clamp 语义本身。
  3. 不解释 cpuset_num_cpus() 为何能返回 0:commit message 与后续讨论都把这一点列为“未决的独立问题”。
  4. 后续工作(msg 10):另一例复现提议加 WARN_ON_ONCE(!cfs_rq->load.weight) debug build,在更上游捕捉 weight 为 0 的场景。

调用链与故障点 ASCII 图:

enqueue_task_fair
   |
   +-- enqueue_hierarchy
   |     |
   |     +-- enqueue_entity
   |           |
   |           +-- __calc_prop_weight  <-- #DE divide error
   |                  weight *= se->load.weight
   |                  weight /= cfs_rq->load.weight   (== 0 here)
   |
   +-- calc_concur_shares
         nr = min(tg_tasks(tg), tg_cpus(tg))   (tg_cpus() may be 0)
         shares = clamp_t(long, shares, MIN_SHARES, shares_max)
                                       ^^^^^^^^ clamp(lo=MIN, hi=0) -> 0
         -> group se->load.weight = 0

补丁修复位置:

tg_cpus()                           tg_tasks()
   |                                    |
   v                                    v
return cpuset_num_cpus(cgrp)     return max(tg->nr_tasks, 1)
   ^                                    ^
   |                                    |
   NOT floored at 1 (BUG)            floored at 1 (OK)
   |
   +-- PATCH: return max(nr, 1);

类比

tg_tasks()tg_cpus() 想成食堂里两位计数员,分别报"窗口前的人数"和"今天剩的菜份数";厨房按 min(人数, 份数) 出餐,再走一条"至少出 MIN_SHARES 份"的兜底规则。

"份数"那位计数员硬件抽风报了 0,于是 min(...) == 0,按兜底规则本该给至少一份,结果 clamp(lo=1, hi=0) 按 clamp 在"下限大于上限时返回上限"的定义给了 0 份。补丁相当于强制让"份数"计数员哪怕数据缺失也至少报 1,保证 hi >= lo,MIN_SHARES 兜底真正生效。

Highlight:风险与注意点

  • 只兜底症状,不解释根因cpuset_num_cpus() 为何能返回 0(cgroup v2 短暂空 cpuset?RCU race?s2idle/cpu hotplug?)在原报告线程里没有结论。msg 7 的 WARN 实测命中 tg_cpus() == 0,确认这条路径不是理论可达。
  • 不止 fork 路径:msg 10 的第二例复现从 idle CPU 的 sched_ttwu_pending 进来,说明任何 enqueue 都可能命中——所以 patch 必须并入 sched/urgent 而不是只跟随 sched/core
  • panic_on_oops=0 也救不了:原作者观察到 oops 恢复路径持锁重入同一 enqueue,二次 #DE 立刻 panic。
  • clamp() 的语义陷阱<lo, hi>lo > hi 时返回 hi。任何把 floor 函数和 clamp 串联的地方都需要复查这种语义冲突。
  • 下一步验证:msg 10 提议跑带 WARN_ON_ONCE(!cfs_rq->load.weight) 的 debug build,确认上游是否还会留下 weight==0 的 cfs_rq,再决定要不要在 __calc_prop_weight() 内部再加一层兜底。

版本变化

单 patch,没有 v2/v3。落地链路:

  1. msg 1–4:首发 + maintainer / AMD / Red Hat 工程师追问 suspend 状态、instrumentation。
  2. msg 5:Jake 直接出 [PATCH] tg_cpus floor,签名 Jake Steinman。
  3. msg 6–7:原 reporter 贴 instrumentation 数据,证实 tg_cpus() == 0 在 idle 路径也能命中。
  4. msg 9:tip-bot 公告合入 sched/urgent,commit 23906f3a1686,committer Peter Zijlstra。
  5. msg 10:第二例用户从 idle 路径复现,提议在更上游加 WARN_ON_ONCE(!cfs_rq->load.weight) debug build。

一句话总结

flat-hierarchy 重构里 tg_cpus() 不像 tg_tasks() 那样 floor 在 1,让空 cpuset 的 0 穿过 calc_concur_shares()min(...) 击穿 __calc_smp_shares()MIN_SHARES 兜底,最终在 __calc_prop_weight()#DE;补丁用一个 max(nr, 1) 把 division hazard 关掉,但 cpuset_num_cpus() 为何短暂返回 0 仍是未决问题,第二例 idle 路径复现说明必须并入 urgent 分支。