sched discussion
[BUG] sched/fair: divide error in __calc_prop_weight() from the enqueue path (flat-hierarchy series)
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.c的tg_cpus()函数体。 - 修改文件:
kernel/sched/fair.c。 - 代码统计:把
return nr;替换成带解释性注释的return max(nr, 1);。 - Message-ID:
20260818231333.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() == 0,clamp(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 >= 1,MIN_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);
}
关键实现
tg_cpus()直接 floor:与tg_tasks()行为对称,根除min(tg_tasks, tg_cpus)把 0 拉进shares_max的唯一现实路径。- 不动
__calc_smp_shares()的 clamp:作者明确说只移除 division hazard,不去改 clamp 语义本身。 - 不解释
cpuset_num_cpus()为何能返回 0:commit message 与后续讨论都把这一点列为“未决的独立问题”。 - 后续工作(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。落地链路:
- msg 1–4:首发 + maintainer / AMD / Red Hat 工程师追问 suspend 状态、instrumentation。
- msg 5:Jake 直接出
[PATCH] tg_cpus floor,签名 Jake Steinman。 - msg 6–7:原 reporter 贴 instrumentation 数据,证实
tg_cpus() == 0在 idle 路径也能命中。 - msg 9:tip-bot 公告合入
sched/urgent,commit23906f3a1686,committer Peter Zijlstra。 - 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 分支。