From: sashiko-bot@kernel.org
To: "Fernand Sieber" <sieberf@amazon.com>
Cc: sched-ext@lists.linux.dev, bpf@vger.kernel.org, tj@kernel.org,
hannes@cmpxchg.org, mkoutny@suse.com
Subject: Re: [PATCH v2 4/4] sched_ext: charge BPF scheduler tasks to cpu.max bandwidth
Date: Tue, 10 Nov 2026 11:39:00 +0000 [thread overview]
Message-ID: <sashiko-outbox-206945@kernel.org> (raw)
In-Reply-To: <3977ed2471c6fdc19c672b2dcd13c70f997b9eff.1794303670.git.sieberf@amazon.com>
Thank you for your contribution! Sashiko AI review found 8 potential
issues to consider:
- [High] sched/fair: lost refills and wrong runtime in
cfs_bandwidth_task_runtime()
- [High] sched/fair: cfs_bandwidth_task_runtime() returns RUNTIME_INF on
zero runtime
- [Medium] sched/fair: cfs_bandwidth_return_task() strands unpaid debt
on old CPU
- [Medium] sched/fair: stale cfs_b->debt persists across cpu.max
reconfiguration
- [Medium] sched_ext: scx_can_stop_tick() stops tick for cpu.max
constrained tasks
- [Medium] sched/fair: cfs_bandwidth_charge_task() consumes 1ns async
unthrottle grant
- [Medium] selftests/sched_ext: cgroup and memory leaks in setup_ctx()
and setup_debt()
- [Low] selftests/sched_ext: head-only peek in cgroup_runtime_dispatch()
--
commit 0c5e278d21c97ca5fabb3de9616fe36039f5635b
Author: Fernand Sieber <sieberf@amazon.com>
sched_ext: charge BPF scheduler tasks to cpu.max bandwidth
This commit adds the SCX_OPS_CHARGE_CPU_MAX flag and the
scx_bpf_task_cpu_max_runtime() kfunc so BPF schedulers can charge task
execution against a cgroup's CFS cpu.max bandwidth pool and share a single
quota with the fair class.
> diff --git a/kernel/sched/ext/ext.c b/kernel/sched/ext/ext.c
> index 265a80dd93282..6ffc9ca2c5c9f 100644
> --- a/kernel/sched/ext/ext.c
> +++ b/kernel/sched/ext/ext.c
> @@ -1625,6 +1625,11 @@ static void expire_task_slice(struct task_struct *p)
> scx_set_task_slice(p, 0);
> }
>
> +static bool scx_task_charges_cpu_max(const struct task_struct *p)
> +{
> + return scx_task_sched(p)->ops.flags & SCX_OPS_CHARGE_CPU_MAX;
> +}
> +
> static void update_curr_scx(struct rq *rq)
> {
> struct task_struct *donor;
> @@ -1650,6 +1655,9 @@ static void update_curr_scx(struct rq *rq)
> if (unlikely(donor == scx_rescuee(rq)))
> scx_rescue_charge(rq, delta_exec);
>
> + if (scx_task_charges_cpu_max(donor))
> + cfs_bandwidth_charge_task(rq, donor, delta_exec);
> +
> dl_server_update(&rq->ext_server, delta_exec);
> }
[Severity: Medium]
Can scx_can_stop_tick() still stop the scheduler tick on a CONFIG_NO_HZ_FULL
CPU when a bandwidth-constrained task runs with SCX_SLICE_INF?
set_next_task_scx() sets SCX_RQ_CAN_STOP_TICK whenever
p->scx.slice == SCX_SLICE_INF && !p->is_blocked, and __need_bw_check() in
kernel/sched/core.c only checks fair_sched_class tasks. If a BPF scheduler
with SCX_OPS_CHARGE_CPU_MAX dispatches a task with SCX_SLICE_INF on a
nohz_full CPU, stopping the tick stops update_curr_scx() and ops.tick() from
running while the task stays in userspace, so cpu.max is neither charged nor
enforced.
[ ... ]
> diff --git a/kernel/sched/fair.c b/kernel/sched/fair.c
> index bc068b3fa4ed1..9ff239f122bda 100644
> --- a/kernel/sched/fair.c
> +++ b/kernel/sched/fair.c
> @@ -6875,6 +6875,11 @@ void __refill_cfs_bandwidth_runtime(struct cfs_bandwidth *cfs_b)
> }
>
> cfs_b->runtime = min(cfs_b->runtime, cfs_b->quota + cfs_b->burst);
> +
> + /* pay the debt cfs_bandwidth_charge_task() left */
> + runtime = min(cfs_b->runtime, cfs_b->debt);
> + cfs_b->runtime -= runtime;
> + cfs_b->debt -= runtime;
> cfs_b->runtime_snap = cfs_b->runtime;
> }
[Severity: Medium]
Does cfs_b->debt stay stale across cpu.max and cpu.max.runtime writes?
In tg_set_cfs_bandwidth(), setting cpu.max to "max" sets cfs_b->quota to
RUNTIME_INF and resets each CPU's cfs_rq->runtime_remaining to 1, but
__refill_cfs_bandwidth_runtime() returns early on RUNTIME_INF without
clearing cfs_b->debt. Lowering cfs_b->quota (for example from 100ms to
10ms) also leaves prior cfs_b->debt up to the old quota, starving the group
for multiple periods under the new quota. Similarly,
cfs_bandwidth_set_runtime() overwrites cfs_b->runtime without clearing
cfs_b->debt, and __return_cfs_rq_runtime() adds returned slack to
cfs_b->runtime without paying down cfs_b->debt.
> @@ -8076,6 +8081,123 @@ bool cfs_task_bw_constrained(struct task_struct *p)
> return false;
> }
>
> +/*
> + * Charge @delta_exec, which @p ran on @rq outside the fair class, to the
> + * bandwidth of its task group and of each ancestor with a quota, as
> + * account_cfs_rq_runtime() charges a fair task. A cfs_rq that runs out takes
> + * another slice from cfs_b, but @p is never throttled: the BPF scheduler that
> + * runs it stops it, and the group's fair tasks are throttled when they run.
> + *
> + * Debt below -slice that cfs_b cannot cover moves to cfs_b, up to a quota,
> + * and the next refill pays it, so that a scheduler which keeps running @p
> + * cannot put the group's fair tasks on @rq in more debt than a slice.
> + */
> +void cfs_bandwidth_charge_task(struct rq *rq, struct task_struct *p,
> + u64 delta_exec)
> +{
> + s64 floor = -(s64)sched_cfs_bandwidth_slice();
> + struct task_group *tg;
> +
> + lockdep_assert_rq_held(rq);
> +
> + if (!cfs_bandwidth_used())
> + return;
> +
> + for (tg = task_group(p); tg; tg = tg->parent) {
> + struct cfs_bandwidth *cfs_b = tg_cfs_bandwidth(tg);
> + struct cfs_rq *cfs_rq = tg_cfs_rq(tg, cpu_of(rq));
> +
> + if (!cfs_rq->runtime_enabled)
> + continue;
> +
> + cfs_rq->runtime_remaining -= delta_exec;
> +
> + /*
> + * A throttled cfs_rq gets runtime from distribute_cfs_runtime().
> + * Without runtime, an active period timer refills it, so skip
> + * the lock. A stopped one is restarted by the assign.
> + */
> + if (cfs_rq->runtime_remaining <= 0 && !cfs_rq->throttled &&
> + (READ_ONCE(cfs_b->runtime) || !READ_ONCE(cfs_b->period_active))) {
> + scoped_guard (raw_spinlock, &cfs_b->lock)
> + __assign_cfs_rq_runtime(cfs_b, cfs_rq,
> + sched_cfs_bandwidth_slice());
> + }
[Severity: Medium]
Can cfs_bandwidth_charge_task() consume the 1ns runtime grant from
distribute_cfs_runtime() before the async unthrottle CSD runs, aborting the
unthrottle?
CPU0 CPU1
---- ----
/* cfs_rq->throttled == 1,
* SCX task running on CPU0
*/
do_sched_cfs_period_timer()
distribute_cfs_runtime()
rq_lock(CPU0)
cfs_rq->runtime_remaining = 1;
unthrottle_cfs_rq_async(cfs_rq)
rq_unlock(CPU0)
update_curr_scx()
cfs_bandwidth_charge_task()
cfs_rq->runtime_remaining -= delta;
/* <= 0, but cfs_rq->throttled == 1
* so __assign_cfs_rq_runtime() is
* skipped
*/
__cfsb_csd_unthrottle()
unthrottle_cfs_rq()
if (cfs_rq->runtime_remaining <= 0)
return; <-- aborts unthrottle
Because cfs_rq->h_curr is NULL while an SCX task is running on CPU0,
distribute_cfs_runtime() does not call update_curr(cfs_rq) before setting
cfs_rq->runtime_remaining = 1. If update_curr_scx() runs on CPU0 before
__cfsb_csd_unthrottle(), cfs_rq->runtime_remaining drops back to <= 0 while
cfs_rq->throttled is still 1, so unthrottle_cfs_rq() bails out and leaves
the group's fair tasks on CPU0 throttled for the rest of the period while
subsequent SCX charges also skip __assign_cfs_rq_runtime().
> +
> + if (cfs_rq->runtime_remaining < floor) {
> + scoped_guard (raw_spinlock, &cfs_b->lock)
> + cfs_b->debt = min_t(u64, cfs_b->debt + floor -
> + cfs_rq->runtime_remaining, cfs_b->quota);
> + cfs_rq->runtime_remaining = floor;
> + }
> + }
> +}
> +
> +/*
> + * @p leaves @rq: give the runtime @rq holds for its groups back, less
> + * min_cfs_rq_runtime, as a fair dequeue does when a cfs_rq empties. Pay
> + * what @rq owes from cfs_b, as @p may not run on @rq again.
> + */
> +void cfs_bandwidth_return_task(struct rq *rq, struct task_struct *p)
> +{
> + struct task_group *tg;
> +
> + lockdep_assert_rq_held(rq);
> +
> + if (!cfs_bandwidth_used())
> + return;
> +
> + for (tg = task_group(p); tg; tg = tg->parent) {
> + struct cfs_bandwidth *cfs_b = tg_cfs_bandwidth(tg);
> + struct cfs_rq *cfs_rq = tg_cfs_rq(tg, cpu_of(rq));
> +
> + return_cfs_rq_runtime(cfs_rq);
> +
> + if (cfs_rq->runtime_enabled && cfs_rq->runtime_remaining < 0 &&
> + !cfs_rq->throttled && READ_ONCE(cfs_b->runtime)) {
> + scoped_guard (raw_spinlock, &cfs_b->lock)
> + __assign_cfs_rq_runtime(cfs_b, cfs_rq, 0);
> + }
> + }
> +}
[Severity: Medium]
When a task overruns on rq and is dequeued while cfs_b->runtime is 0, does
cfs_bandwidth_return_task() leave the negative cfs_rq->runtime_remaining
stranded on the old CPU?
cfs_bandwidth_charge_task() already pulls from cfs_b->runtime whenever it is
non-zero, so cfs_rq->runtime_remaining is negative here primarily when
cfs_b->runtime has been exhausted to 0. If p then migrates, sleeps, or
switches cgroups before the next period refill, cfs_bandwidth_return_task()
does nothing because cfs_b->runtime is 0 and never transfers the unpaid
cfs_rq->runtime_remaining to cfs_b->debt. The task escapes up to a slice of
debt on each CPU it moves across, while the old CPU's cfs_rq keeps the debt
and later refills of cfs_b only pay cfs_b->debt.
> +
> +/*
> + * The runtime @p's groups have left for it on its CPU, without locks: the
> + * least, over the groups with a quota, of what the CPU holds or owes and
> + * cfs_b's runtime. Debt counts up to a quota less 1ns, so that a full period
> + * lets @p run and pay the rest. A group whose period timer is stopped does
> + * not count, as the next charge restarts the timer and refills what it owes.
> + * RUNTIME_INF if no group counts.
> + */
> +u64 cfs_bandwidth_task_runtime(struct task_struct *p)
> +{
> + u64 runtime = RUNTIME_INF;
> + struct task_group *tg;
> + int cpu = task_cpu(p);
> +
> + if (!cfs_bandwidth_used())
> + return runtime;
> +
> + guard(rcu)();
> +
> + for (tg = task_group(p); tg; tg = tg->parent) {
> + struct cfs_bandwidth *cfs_b = tg_cfs_bandwidth(tg);
> + struct cfs_rq *cfs_rq = tg_cfs_rq(tg, cpu);
> + s64 held;
> +
> + if (!READ_ONCE(cfs_rq->runtime_enabled) ||
> + !READ_ONCE(cfs_b->period_active))
> + continue;
[Severity: High]
Can skipping groups with !cfs_b->period_active make
cfs_bandwidth_task_runtime() return RUNTIME_INF when cfs_b->runtime is 0 and
no idle period refills are owed?
When only SCX tasks run, cfs_rqs are never throttled, so
do_sched_cfs_period_timer() sets cfs_b->idle = 1 at every period boundary.
If __refill_cfs_bandwidth_runtime() consumes the refill paying cfs_b->debt
(leaving cfs_b->runtime == 0), neither holding tasks in BPF while
cfs_b->runtime is 0 nor charging further into cfs_b->debt in
cfs_bandwidth_charge_task() clears cfs_b->idle. At the next period boundary,
do_sched_cfs_period_timer() sees cfs_b->idle && !throttled and deactivates
the period timer (cfs_b->period_active = 0) with period_timer already
forwarded to the end of the new period. cfs_bandwidth_task_runtime() then
sees !cfs_b->period_active and returns RUNTIME_INF even though
cfs_b->runtime is 0 and start_cfs_bandwidth() on the next charge will find 0
elapsed idle periods to refill.
Writing 0 to cpu.max.runtime while cfs_b->period_active is 0 hits the same
state: cfs_bandwidth_set_runtime() forwards period_timer via
cfs_bandwidth_refill_idle_periods() and sets cfs_b->runtime = 0 without
starting period_timer, so scx_bpf_task_cpu_max_runtime() returns U64_MAX
instead of 0.
> +
> + held = max_t(s64, READ_ONCE(cfs_rq->runtime_remaining),
> + 1 - (s64)READ_ONCE(cfs_b->quota));
> + held += READ_ONCE(cfs_b->runtime);
> + runtime = min_t(u64, runtime, max_t(s64, held, 0));
> + }
> +
> + return runtime;
> +}
[Severity: High]
Does splitting overrun debt between cfs_rq->runtime_remaining and
cfs_b->debt cause cfs_bandwidth_task_runtime() to return 0 across a refill,
discarding the unspent refill on the next period?
When a task overruns past -slice and waits in a BPF DSQ on the same CPU,
cfs_rq->runtime_remaining stays at -slice while the rest of the overrun goes
into cfs_b->debt. At the next period refill,
__refill_cfs_bandwidth_runtime() adds cfs_b->quota to cfs_b->runtime and
immediately subtracts cfs_b->debt, leaving cfs_b->runtime = quota - debt
while cfs_rq->runtime_remaining is still -slice. Whenever
quota - debt <= min(slice, quota - 1) (for example, any non-zerocfs_b->debt
when quota <= slice), or whenever another CPU consumes any runtime from
cfs_b->runtime while this CPU owes -quota, held is <= 0 and
cfs_bandwidth_task_runtime() returns 0 for the whole period. Because the BPF
scheduler does not run the task, __assign_cfs_rq_runtime() is never called to
apply cfs_b->runtime against cfs_rq->runtime_remaining, and the next period
refill caps cfs_b->runtime at quota + burst, losing the previous refill
while cfs_rq->runtime_remaining remains at -slice.
Conversely, when cfs_b->quota < sched_cfs_bandwidth_slice() and
cfs_b->debt == 0 (for example quota = 1ms, slice = 5ms, and
cfs_rq->runtime_remaining = -4ms), clamping cfs_rq->runtime_remaining at
1 - quota makes held = 1ns after a single 1ms refill, letting the task run
every period despite owing multiple periods of debt on cfs_rq. Finally,
held does not subtract READ_ONCE(cfs_b->debt) when cfs_b->runtime is
populated before a refill (e.g. via __return_cfs_rq_runtime() or
cfs_bandwidth_set_runtime()).
[ ... ]
> diff --git a/tools/testing/selftests/sched_ext/cgroup_runtime.bpf.c b/tools/testing/selftests/sched_ext/cgroup_runtime.bpf.c
> new file mode 100644
> index 0000000000000..ec98a25d19c29
> --- /dev/null
> +++ b/tools/testing/selftests/sched_ext/cgroup_runtime.bpf.c
> @@ -0,0 +1,195 @@
[ ... ]
> +void BPF_STRUCT_OPS(cgroup_runtime_dispatch, s32 cpu, struct task_struct *prev)
> +{
> + struct task_struct *p = __COMPAT_scx_bpf_dsq_peek(HELD_DSQ);
> +
> + if (p && runnable(p))
> + scx_bpf_dsq_move_to_local(HELD_DSQ, 0);
> +}
[Severity: Low]
Since the two burner tasks are pinned to different CPUs and share HELD_DSQ,
can peeking only the head of HELD_DSQ check the wrong task's per-CPU runtime?
If the head task in HELD_DSQ is pinned to CPU 1 and has no runtime on CPU 1,
cgroup_runtime_dispatch() on CPU 2 sees !runnable(p) and does not move the
second burner pinned to CPU 2 even if CPU 2 has runtime. Conversely, if the
head task on CPU 1 is runnable while the second task on CPU 2 is not,
cgroup_runtime_dispatch() on CPU 2 calls scx_bpf_dsq_move_to_local(), which
skips the CPU 1 task and dispatches the non-runnable CPU 2 task.
[ ... ]
> diff --git a/tools/testing/selftests/sched_ext/cgroup_runtime.c b/tools/testing/selftests/sched_ext/cgroup_runtime.c
> new file mode 100644
> index 0000000000000..86d50a63a6e73
> --- /dev/null
> +++ b/tools/testing/selftests/sched_ext/cgroup_runtime.c
> @@ -0,0 +1,652 @@
[ ... ]
> + if (cg_create(ctx->parent)) {
> + SCX_ERR("Failed to create %s", ctx->parent);
> + return SCX_TEST_FAIL;
> + }
> + ctx->parent_created = true;
> + if (cg_write(ctx->parent, "cgroup.subtree_control", "+cpu")) {
> + SCX_ERR("Failed to enable cpu in %s", ctx->parent);
> + return SCX_TEST_FAIL;
> + }
> + if (cg_create(ctx->cg)) {
> + SCX_ERR("Failed to create %s", ctx->cg);
> + return SCX_TEST_FAIL;
> + }
> + ctx->cg_created = true;
> +
> + if (access(cg_control(ctx->cg, "cpu.max.runtime"), F_OK))
> + return SCX_TEST_SKIP;
> +
> + if (write_str(ctx->cg, "cpu.max", "%lld %lld", quota, period) ||
> + write_str(ctx->cg, "cpu.max.burst", "%lld", burst) ||
> + write_str(ctx->cg, "cpu.max.runtime", "%lld", runtime)) {
> + SCX_ERR("Failed to configure %s", ctx->cg);
> + return SCX_TEST_FAIL;
> + }
> +
> + ctx->skel->bss->cgid = cgroup_id(ctx->cg);
> + if (!ctx->skel->bss->cgid) {
> + SCX_ERR("Failed to read the cgroup ID of %s", ctx->cg);
> + return SCX_TEST_FAIL;
> + }
> + if (libbpf_num_possible_cpus() < NR_BURNERS + 1)
> + return SCX_TEST_SKIP;
> + return SCX_TEST_PASS;
> +}
[Severity: Medium]
Does returning SCX_TEST_SKIP or SCX_TEST_FAIL from setup_ctx() (and
setup_debt()) after cg_create() leak the cgroups and cause later tests in
the same runner to fail with EEXIST?
run_test() in tools/testing/selftests/sched_ext/runner.c only calls
test->cleanup() when test->setup() returns SCX_TEST_PASS. Because all
cgroup_runtime_* tests in the runner share getpid() in the cgroup path
scx_cgroup_runtime_<pid>, any skip (such as having fewer than 3 CPUs, missing
cpu.max.runtime, or failing to write sched_cfs_bandwidth_slice_us in
setup_debt()) leaves ctx->parent and ctx->cg behind and makes subsequent
tests fail cg_create(ctx->parent). Also, passing cg_control() directly to
access() leaks the string allocated by cg_control().
--
Sashiko AI review · https://sashiko.dev/#/patchset/cover.1794303670.git.sieberf@amazon.com?part=4
next prev parent reply other threads:[~2026-11-10 11:39 UTC|newest]
Thread overview: 18+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-11-10 11:08 [PATCH v2 0/4] sched/fair: cpu.max.runtime and idle refill for CPU credits Fernand Sieber
2026-11-10 11:08 ` [PATCH v2 1/4] sched/fair: expose cpu.max.runtime to set bandwidth runtime directly Fernand Sieber
2026-11-10 11:28 ` sashiko-bot
2026-11-11 8:28 ` bot+bpf-ci
2026-11-10 11:08 ` [PATCH v2 2/4] sched/fair: refill bandwidth for the periods the timer slept through Fernand Sieber
2026-11-10 11:29 ` sashiko-bot
2026-11-10 11:08 ` [PATCH v2 3/4] sched_ext: add cgroup_set_runtime ops callback Fernand Sieber
2026-11-11 8:08 ` bot+bpf-ci
2026-11-10 11:08 ` [PATCH v2 4/4] sched_ext: charge BPF scheduler tasks to cpu.max bandwidth Fernand Sieber
2026-11-10 11:39 ` sashiko-bot [this message]
2026-11-11 8:28 ` bot+bpf-ci
2026-11-10 20:10 ` [PATCH v2 0/4] sched/fair: cpu.max.runtime and idle refill for CPU credits Tejun Heo
2026-11-11 12:20 ` Fernand Sieber
2026-11-11 17:26 ` Tejun Heo
2026-11-12 8:11 ` Fernand Sieber
2026-11-11 12:29 ` [PATCH v3 0/2] sched/fair: cpu.max.runtime and idle refill for a burstable budget Fernand Sieber
2026-11-11 12:29 ` [PATCH v3 1/2] sched/fair: expose cpu.max.runtime to set bandwidth runtime directly Fernand Sieber
2026-11-11 12:29 ` [PATCH v3 2/2] sched/fair: refill bandwidth for the periods the timer slept through Fernand Sieber
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=sashiko-outbox-206945@kernel.org \
--to=sashiko-bot@kernel.org \
--cc=bpf@vger.kernel.org \
--cc=hannes@cmpxchg.org \
--cc=mkoutny@suse.com \
--cc=sashiko-reviews@lists.linux.dev \
--cc=sched-ext@lists.linux.dev \
--cc=sieberf@amazon.com \
--cc=tj@kernel.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.