From: Fernand Sieber <sieberf@amazon.com>
To: <mingo@redhat.com>, <peterz@infradead.org>,
<juri.lelli@redhat.com>, <vincent.guittot@linaro.org>,
<tj@kernel.org>
Cc: <bsegall@google.com>, <dietmar.eggemann@arm.com>,
<rostedt@goodmis.org>, <mgorman@suse.de>, <vschneid@redhat.com>,
<kprateek.nayak@amd.com>, <hannes@cmpxchg.org>,
<mkoutny@suse.com>, <void@manifault.com>, <arighi@nvidia.com>,
<changwoo@igalia.com>, <skhan@linuxfoundation.org>,
<corbet@lwn.net>, <rdunlap@infradead.org>,
<cgroups@vger.kernel.org>, <sched-ext@lists.linux.dev>,
<linux-doc@vger.kernel.org>, <linux-kselftest@vger.kernel.org>,
<bpf@vger.kernel.org>, <linux-kernel@vger.kernel.org>,
<nh-open-source@amazon.com>, <hborghor@amazon.de>,
<fmubeen@amazon.de>, <dwmw@amazon.co.uk>, <karataev@amazon.com>,
<sieberf@amazon.com>
Subject: [PATCH v2 4/4] sched_ext: charge BPF scheduler tasks to cpu.max bandwidth
Date: Tue, 10 Nov 2026 13:08:51 +0200 [thread overview]
Message-ID: <3977ed2471c6fdc19c672b2dcd13c70f997b9eff.1794303670.git.sieberf@amazon.com> (raw)
In-Reply-To: <cover.1794303670.git.sieberf@amazon.com>
A BPF scheduler that enforces cpu.max keeps each cgroup's budget
itself, and no runtime moves between it and the fair class. One loaded
after a cpu.max.runtime write starts the cgroup on a fresh quota,
losing the unspent grant.
Conversely, a read reports the fair class's runtime, which refills to
quota + burst meanwhile. On disable, the fair class takes the tasks
back with that full runtime, so a cgroup that used up its budget runs
over its quota until the period accounting catches up.
Under SCX_OPS_SWITCH_PARTIAL, both budgets apply at once: a cgroup with
tasks in both classes runs up to its quota in each, which no handover
at load or unload can fix. Keep one budget, the fair class's, and let
a BPF scheduler opt in to drawing from it with the new
SCX_OPS_CHARGE_CPU_MAX flag.
With SCX_OPS_CHARGE_CPU_MAX, update_curr_scx() charges a task's runtime
to its cgroups' cfs_rqs as a fair task's would, without throttling it.
A task the scheduler stops late leaves its CPU in debt, which the next
periods pay, also once the task has moved; debt past minus a slice
moves to cfs_b, whose next refill pays up to a quota of it. The new
kfunc scx_bpf_task_cpu_max_runtime() returns the runtime left less that
debt, and the scheduler runs the task only while it is positive.
Cgroups whose period timer is stopped are skipped, as only a charge
restarts it.
Both classes then draw from one budget: under SCX_OPS_SWITCH_PARTIAL a
cgroup gets its quota per period however its tasks are split, and
nothing is handed over at load or unload. A task returning to the fair
class leaves its CPU at most a slice in debt; schedulers without the
flag behave as before.
cfs_b->lock is taken at most twice per millisecond of run time per CPU.
The cgroup_runtime selftest covers these cases.
Signed-off-by: Fernand Sieber <sieberf@amazon.com>
---
Documentation/admin-guide/cgroup-v2.rst | 4 +-
kernel/sched/ext/ext.c | 34 +
kernel/sched/ext/internal.h | 24 +-
kernel/sched/fair.c | 122 ++++
kernel/sched/sched.h | 12 +
tools/sched_ext/include/scx/common.bpf.h | 1 +
tools/sched_ext/include/scx/compat.h | 1 +
tools/testing/selftests/sched_ext/Makefile | 1 +
.../selftests/sched_ext/cgroup_runtime.bpf.c | 195 ++++++
.../selftests/sched_ext/cgroup_runtime.c | 652 ++++++++++++++++++
tools/testing/selftests/sched_ext/config | 1 +
11 files changed, 1045 insertions(+), 2 deletions(-)
create mode 100644 tools/testing/selftests/sched_ext/cgroup_runtime.bpf.c
create mode 100644 tools/testing/selftests/sched_ext/cgroup_runtime.c
diff --git a/Documentation/admin-guide/cgroup-v2.rst b/Documentation/admin-guide/cgroup-v2.rst
index b77ca6f8ca548..db9fa6e6dbd49 100644
--- a/Documentation/admin-guide/cgroup-v2.rst
+++ b/Documentation/admin-guide/cgroup-v2.rst
@@ -1258,7 +1258,9 @@ will be referred to. All time durations are in microseconds.
This file affects only processes under the fair-class scheduler and a BPF
scheduler with the ``cgroup_set_runtime`` callback depending on what
- the callback actually does.
+ the callback actually does. A BPF scheduler with the
+ ``SCX_OPS_CHARGE_CPU_MAX`` flag draws what its processes run from
+ the same runtime, so the cgroup's processes in both share it.
cpu.pressure
A read-write nested-keyed file.
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);
}
@@ -2605,6 +2613,13 @@ static bool dequeue_task_scx(struct rq *rq, struct task_struct *p, int core_deq_
ops_dequeue(rq, p, deq_flags);
+ /* charge what @p ran, then return what @rq holds for its cgroups */
+ if (scx_task_charges_cpu_max(p)) {
+ if (task_current_donor(rq, p))
+ update_curr_scx(rq);
+ cfs_bandwidth_return_task(rq, p);
+ }
+
/*
* A current scheduling context which is going off @rq first gets
* dequeued and then stops running. As we want running <-> stopping
@@ -11793,6 +11808,24 @@ __bpf_kfunc s32 scx_bpf_task_cpu(const struct task_struct *p)
return task_cpu(p);
}
+/**
+ * scx_bpf_task_cpu_max_runtime - Runtime a task has left under cpu.max
+ * @p: task of interest
+ *
+ * Return the runtime in nsecs that @p's cgroup and its ancestors have left
+ * under their cpu.max quotas for @p on its current CPU, the least over those
+ * with a quota: the cgroup's cpu.max.runtime plus what the CPU holds for
+ * the cgroup, less what it owes. U64_MAX if none limits it. Read without
+ * locks.
+ *
+ * With %SCX_OPS_CHARGE_CPU_MAX, the scheduler should run @p only while this
+ * returns more than 0. The runtime grows when a period refills it.
+ */
+__bpf_kfunc u64 scx_bpf_task_cpu_max_runtime(struct task_struct *p)
+{
+ return cfs_bandwidth_task_runtime(p);
+}
+
/**
* scx_bpf_task_cid - cid a task is currently associated with
* @p: task of interest
@@ -12119,6 +12152,7 @@ BTF_ID_FLAGS(func, scx_bpf_get_online_cpumask, KF_ACQUIRE)
BTF_ID_FLAGS(func, scx_bpf_put_cpumask, KF_RELEASE)
BTF_ID_FLAGS(func, scx_bpf_task_running, KF_RCU)
BTF_ID_FLAGS(func, scx_bpf_task_cpu, KF_RCU)
+BTF_ID_FLAGS(func, scx_bpf_task_cpu_max_runtime, KF_RCU)
BTF_ID_FLAGS(func, scx_bpf_task_cid, KF_RCU)
BTF_ID_FLAGS(func, scx_bpf_locked_rq, KF_IMPLICIT_ARGS | KF_RET_NULL)
BTF_ID_FLAGS(func, scx_bpf_cpu_curr, KF_IMPLICIT_ARGS | KF_RET_NULL | KF_RCU_PROTECTED)
diff --git a/kernel/sched/ext/internal.h b/kernel/sched/ext/internal.h
index e794507596d6f..e8c0b573d0a43 100644
--- a/kernel/sched/ext/internal.h
+++ b/kernel/sched/ext/internal.h
@@ -243,6 +243,27 @@ enum scx_ops_flags {
*/
SCX_OPS_ENQ_BLOCKED = 1LLU << 10,
+ /*
+ * If set, what the scheduler's tasks run is charged to the cpu.max
+ * bandwidth of their cgroups, the runtime the fair class keeps and
+ * cpu.max.runtime reads, as what fair tasks run is. The tasks of a
+ * cgroup in both classes then share one quota, also with
+ * %SCX_OPS_SWITCH_PARTIAL, and nothing is handed over when tasks
+ * switch class.
+ *
+ * The kernel never throttles the scheduler's tasks. The scheduler
+ * enforces the quota by running a task only while
+ * scx_bpf_task_cpu_max_runtime() returns more than 0. What a task runs
+ * beyond that is charged: up to a slice (sched_cfs_bandwidth_slice_us)
+ * per CPU as a debt the cgroup's fair tasks on that CPU pay back, and up
+ * to a quota more from the cgroup's next period. The scheduler does not
+ * need ops.cgroup_set_runtime(), as a write of cpu.max.runtime sets that
+ * runtime.
+ *
+ * Without CONFIG_CFS_BANDWIDTH, this flag has no effect.
+ */
+ SCX_OPS_CHARGE_CPU_MAX = 1LLU << 11,
+
SCX_OPS_ALL_FLAGS = SCX_OPS_KEEP_BUILTIN_IDLE |
SCX_OPS_ENQ_LAST |
SCX_OPS_ENQ_EXITING |
@@ -253,7 +274,8 @@ enum scx_ops_flags {
SCX_OPS_ALWAYS_ENQ_IMMED |
SCX_OPS_TID_TO_TASK |
SCX_OPS_LAZY_RESCHED |
- SCX_OPS_ENQ_BLOCKED,
+ SCX_OPS_ENQ_BLOCKED |
+ SCX_OPS_CHARGE_CPU_MAX,
/* high 8 bits are internal, don't include in SCX_OPS_ALL_FLAGS */
__SCX_OPS_INTERNAL_MASK = 0xffLLU << 56,
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;
}
@@ -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());
+ }
+
+ 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);
+ }
+ }
+}
+
+/*
+ * 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;
+
+ 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;
+}
+
#ifdef CONFIG_NO_HZ_FULL
/* called from pick_next_task_fair() */
static void sched_fair_update_stop_tick(struct rq *rq, struct task_struct *p)
diff --git a/kernel/sched/sched.h b/kernel/sched/sched.h
index 676e6371d0265..fd98000ffd744 100644
--- a/kernel/sched/sched.h
+++ b/kernel/sched/sched.h
@@ -456,6 +456,7 @@ struct cfs_bandwidth {
u64 runtime;
u64 burst;
u64 runtime_snap;
+ u64 debt;
s64 hierarchical_quota;
u8 idle;
@@ -621,6 +622,17 @@ static inline bool cfs_task_bw_constrained(struct task_struct *p) { return false
#endif /* !CONFIG_CGROUP_SCHED */
+#ifdef CONFIG_CFS_BANDWIDTH
+void cfs_bandwidth_charge_task(struct rq *rq, struct task_struct *p, u64 delta_exec);
+void cfs_bandwidth_return_task(struct rq *rq, struct task_struct *p);
+u64 cfs_bandwidth_task_runtime(struct task_struct *p);
+#else
+static inline void cfs_bandwidth_charge_task(struct rq *rq, struct task_struct *p,
+ u64 delta_exec) { }
+static inline void cfs_bandwidth_return_task(struct rq *rq, struct task_struct *p) { }
+static inline u64 cfs_bandwidth_task_runtime(struct task_struct *p) { return RUNTIME_INF; }
+#endif
+
/*
* A weight of 0 or 1 can cause arithmetics problems.
* A weight of a cfs_rq is the sum of weights of which entities
diff --git a/tools/sched_ext/include/scx/common.bpf.h b/tools/sched_ext/include/scx/common.bpf.h
index 6194484d4d9a9..cfb7f1b70665f 100644
--- a/tools/sched_ext/include/scx/common.bpf.h
+++ b/tools/sched_ext/include/scx/common.bpf.h
@@ -98,6 +98,7 @@ s32 scx_bpf_pick_any_cpu_node(const cpumask_t *cpus_allowed, int node, u64 flags
s32 scx_bpf_pick_any_cpu(const cpumask_t *cpus_allowed, u64 flags) __ksym;
bool scx_bpf_task_running(const struct task_struct *p) __ksym;
s32 scx_bpf_task_cpu(const struct task_struct *p) __ksym;
+u64 scx_bpf_task_cpu_max_runtime(struct task_struct *p) __ksym __weak;
struct rq *scx_bpf_cpu_rq(s32 cpu) __ksym __weak;
struct rq *scx_bpf_locked_rq(void) __ksym;
struct task_struct *scx_bpf_cpu_curr(s32 cpu) __ksym __weak;
diff --git a/tools/sched_ext/include/scx/compat.h b/tools/sched_ext/include/scx/compat.h
index 07fb55e63c57c..60b9972f1a5dc 100644
--- a/tools/sched_ext/include/scx/compat.h
+++ b/tools/sched_ext/include/scx/compat.h
@@ -215,6 +215,7 @@ static inline bool __COMPAT_struct_has_field(const char *type, const char *field
#define SCX_OPS_BUILTIN_IDLE_PER_NODE SCX_OPS_FLAG(SCX_OPS_BUILTIN_IDLE_PER_NODE)
#define SCX_OPS_ALWAYS_ENQ_IMMED SCX_OPS_FLAG(SCX_OPS_ALWAYS_ENQ_IMMED)
#define SCX_OPS_ENQ_BLOCKED SCX_OPS_FLAG(SCX_OPS_ENQ_BLOCKED)
+#define SCX_OPS_CHARGE_CPU_MAX SCX_OPS_FLAG(SCX_OPS_CHARGE_CPU_MAX)
#define SCX_PICK_IDLE_FLAG(name) __COMPAT_ENUM_OR_ZERO("scx_pick_idle_cpu_flags", #name)
diff --git a/tools/testing/selftests/sched_ext/Makefile b/tools/testing/selftests/sched_ext/Makefile
index c5d3a2eaea7da..4a727b8dc7aab 100644
--- a/tools/testing/selftests/sched_ext/Makefile
+++ b/tools/testing/selftests/sched_ext/Makefile
@@ -165,6 +165,7 @@ all_test_bpfprogs := $(foreach prog,$(wildcard *.bpf.c),$(INCLUDE_DIR)/$(patsubs
auto-test-targets := \
cgroup_nr_cpus \
+ cgroup_runtime \
create_dsq \
dequeue \
dequeue_iter \
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 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * A scheduler with SCX_OPS_CHARGE_CPU_MAX, for the cgroup_runtime tests. It
+ * runs a task only while scx_bpf_task_cpu_max_runtime() says it has runtime
+ * left, unless @ignore_runtime is set. A task that has none waits in HELD_DSQ,
+ * and a timer kicks the idle CPUs every millisecond, so that they take it
+ * once a period refills the runtime.
+ *
+ * partial_ops sets SCX_OPS_SWITCH_PARTIAL and full_ops does not. Both pass
+ * the cpu.max and cpu.max.runtime writes of @cgid on to the test.
+ */
+
+#include <scx/common.bpf.h>
+
+char _license[] SEC("license") = "GPL";
+
+#define HELD_DSQ 0
+#define KICK_NS 1000000
+
+UEI_DEFINE(uei);
+
+/* the cgroup under test, set before attaching */
+u64 cgid;
+
+/* run tasks whether or not they have runtime left */
+bool ignore_runtime;
+
+/* tasks held for lack of runtime */
+u64 nr_held;
+
+/* the cpu.max and cpu.max.runtime writes of @cgid */
+u64 nr_set_bw;
+u64 quota_us;
+u64 burst_us;
+u64 nr_set_runtime;
+/* cgroup_set_runtime() calls above the quota + burst last given */
+u64 nr_bad_order;
+
+struct kick_timer {
+ struct bpf_timer timer;
+};
+
+struct {
+ __uint(type, BPF_MAP_TYPE_ARRAY);
+ __uint(max_entries, 1);
+ __type(key, u32);
+ __type(value, struct kick_timer);
+} kick_timer SEC(".maps");
+
+static bool runnable(struct task_struct *p)
+{
+ return ignore_runtime || scx_bpf_task_cpu_max_runtime(p);
+}
+
+s32 BPF_STRUCT_OPS(cgroup_runtime_select_cpu, struct task_struct *p,
+ s32 prev_cpu, u64 wake_flags)
+{
+ return prev_cpu;
+}
+
+void BPF_STRUCT_OPS(cgroup_runtime_enqueue, struct task_struct *p,
+ u64 enq_flags)
+{
+ if (!runnable(p)) {
+ __sync_fetch_and_add(&nr_held, 1);
+ scx_bpf_dsq_insert(p, HELD_DSQ, SCX_SLICE_DFL, enq_flags);
+ return;
+ }
+ /* the last task of a CPU going idle must go to its local DSQ */
+ if (enq_flags & SCX_ENQ_LAST)
+ scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, enq_flags);
+ else
+ scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
+}
+
+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);
+}
+
+/*
+ * Stop a task that ran out, so that it goes through enqueue again.
+ * SCX_OPS_ENQ_LAST makes that so also when it is alone on its CPU.
+ */
+void BPF_STRUCT_OPS(cgroup_runtime_tick, struct task_struct *p)
+{
+ if (!runnable(p))
+ scx_bpf_task_set_slice(p, 0);
+}
+
+static int kick_timerfn(void *map, int *key, struct bpf_timer *timer)
+{
+ s32 cpu;
+
+ if (scx_bpf_dsq_nr_queued(HELD_DSQ))
+ bpf_for(cpu, 0, scx_bpf_nr_cpu_ids())
+ scx_bpf_kick_cpu(cpu, SCX_KICK_IDLE);
+
+ bpf_timer_start(timer, KICK_NS, 0);
+ return 0;
+}
+
+s32 BPF_STRUCT_OPS_SLEEPABLE(cgroup_runtime_init)
+{
+ struct bpf_timer *timer;
+ u32 key = 0;
+ s32 ret;
+
+ ret = scx_bpf_create_dsq(HELD_DSQ, -1);
+ if (ret)
+ return ret;
+
+ timer = bpf_map_lookup_elem(&kick_timer, &key);
+ if (!timer)
+ return -ESRCH;
+ bpf_timer_init(timer, &kick_timer, CLOCK_MONOTONIC);
+ bpf_timer_set_callback(timer, kick_timerfn);
+ return bpf_timer_start(timer, KICK_NS, 0);
+}
+
+static bool is_target(struct cgroup *cgrp)
+{
+ return cgid && cgrp->kn->id == cgid;
+}
+
+/* sleepable: cpu.max writes call it in process context */
+void BPF_STRUCT_OPS_SLEEPABLE(cgroup_runtime_cgroup_set_bandwidth,
+ struct cgroup *cgrp, u64 period_us, u64 quota,
+ u64 burst)
+{
+ if (!is_target(cgrp))
+ return;
+
+ __sync_fetch_and_add(&nr_set_bw, 1);
+ quota_us = quota;
+ burst_us = burst;
+}
+
+void BPF_STRUCT_OPS_SLEEPABLE(cgroup_runtime_cgroup_set_runtime,
+ struct cgroup *cgrp, u64 runtime_us)
+{
+ if (!is_target(cgrp))
+ return;
+
+ __sync_fetch_and_add(&nr_set_runtime, 1);
+ if (runtime_us > quota_us + burst_us)
+ __sync_fetch_and_add(&nr_bad_order, 1);
+}
+
+s32 BPF_STRUCT_OPS(cgroup_runtime_cgroup_init, struct cgroup *cgrp,
+ struct scx_cgroup_init_args *args)
+{
+ if (is_target(cgrp)) {
+ quota_us = args->bw_quota_us;
+ burst_us = args->bw_burst_us;
+ }
+ return 0;
+}
+
+void BPF_STRUCT_OPS(cgroup_runtime_exit, struct scx_exit_info *ei)
+{
+ UEI_RECORD(uei, ei);
+}
+
+SCX_OPS_DEFINE(partial_ops,
+ .select_cpu = (void *)cgroup_runtime_select_cpu,
+ .enqueue = (void *)cgroup_runtime_enqueue,
+ .dispatch = (void *)cgroup_runtime_dispatch,
+ .tick = (void *)cgroup_runtime_tick,
+ .init = (void *)cgroup_runtime_init,
+ .cgroup_init = (void *)cgroup_runtime_cgroup_init,
+ .cgroup_set_bandwidth = (void *)cgroup_runtime_cgroup_set_bandwidth,
+ .cgroup_set_runtime = (void *)cgroup_runtime_cgroup_set_runtime,
+ .exit = (void *)cgroup_runtime_exit,
+ .flags = SCX_OPS_SWITCH_PARTIAL |
+ SCX_OPS_ENQ_LAST |
+ SCX_OPS_CHARGE_CPU_MAX,
+ .name = "cgroup_runtime");
+
+SCX_OPS_DEFINE(full_ops,
+ .select_cpu = (void *)cgroup_runtime_select_cpu,
+ .enqueue = (void *)cgroup_runtime_enqueue,
+ .dispatch = (void *)cgroup_runtime_dispatch,
+ .tick = (void *)cgroup_runtime_tick,
+ .init = (void *)cgroup_runtime_init,
+ .cgroup_init = (void *)cgroup_runtime_cgroup_init,
+ .cgroup_set_bandwidth = (void *)cgroup_runtime_cgroup_set_bandwidth,
+ .cgroup_set_runtime = (void *)cgroup_runtime_cgroup_set_runtime,
+ .exit = (void *)cgroup_runtime_exit,
+ .flags = SCX_OPS_ENQ_LAST |
+ SCX_OPS_CHARGE_CPU_MAX,
+ .name = "cgroup_runtime_full");
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 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Verify that a BPF scheduler with SCX_OPS_CHARGE_CPU_MAX shares a cgroup's
+ * cpu.max quota with the fair class: what the cgroup's tasks run in both
+ * classes, with and without SCX_OPS_SWITCH_PARTIAL and while tasks switch
+ * class, stays within the quota; nothing is gained or lost when the
+ * scheduler loads and unloads; and a scheduler that ignores
+ * scx_bpf_task_cpu_max_runtime() leaves the fair tasks at most a quota of
+ * debt and a slice per CPU, while one that stops tasks late pays the overrun
+ * back.
+ * Also verify that cpu.max and cpu.max.runtime writes reach the scheduler
+ * in the order the fair class applied them.
+ *
+ * Usage is cpu.stat's usage_usec. A window of n periods may hold n + 1
+ * refills and start or end with up to a quota, and each CPU may hold up to
+ * a slice (sched_cfs_bandwidth_slice_us), so the checks allow that.
+ */
+
+#define _GNU_SOURCE
+#include <bpf/bpf.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <linux/limits.h>
+#include <pthread.h>
+#include <sched.h>
+#include <scx/common.h>
+#include <signal.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "cgroup_runtime.bpf.skel.h"
+#include "cgroup_util.h"
+#include "scx_test.h"
+
+#ifndef SCHED_EXT
+#define SCHED_EXT 7
+#endif
+
+#define PERIOD_US 100000LL
+#define QUOTA_US 20000LL
+#define NR_PERIODS 10
+#define NR_BURNERS 2
+
+struct ctx {
+ struct cgroup_runtime *skel;
+ struct bpf_link *link;
+ char root[PATH_MAX];
+ char parent[PATH_MAX];
+ char cg[PATH_MAX];
+ bool parent_created;
+ bool cg_created;
+ pid_t burner[NR_BURNERS];
+ long long slice_us;
+ long long saved_slice_us;
+};
+
+static int join_path(char *dst, size_t dst_size, const char *parent, const char *name)
+{
+ int ret;
+
+ ret = snprintf(dst, dst_size, "%s/%s", parent, name);
+ if (ret < 0 || (size_t)ret >= dst_size)
+ return -ENAMETOOLONG;
+ return 0;
+}
+
+static u64 now_us(void)
+{
+ struct timespec ts;
+
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ return ts.tv_sec * 1000000ULL + ts.tv_nsec / 1000;
+}
+
+static u64 cgroup_id(const char *path)
+{
+ union {
+ u64 id;
+ unsigned char bytes[8];
+ } id = {};
+ struct file_handle *handle;
+ int mount_id, ret;
+
+ handle = calloc(1, sizeof(*handle) + sizeof(id));
+ if (!handle)
+ return 0;
+ handle->handle_bytes = sizeof(id);
+ ret = name_to_handle_at(AT_FDCWD, path, handle, &mount_id, 0);
+ if (!ret && handle->handle_bytes == sizeof(id))
+ memcpy(id.bytes, handle->f_handle, sizeof(id));
+ free(handle);
+
+ return ret ? 0 : id.id;
+}
+
+static long long read_runtime(struct ctx *ctx)
+{
+ return cg_read_long(ctx->cg, "cpu.max.runtime");
+}
+
+static long long read_usage(struct ctx *ctx)
+{
+ return cg_read_key_long(ctx->cg, "cpu.stat", "usage_usec");
+}
+
+static int write_str(const char *cgroup, const char *file, const char *fmt, ...)
+{
+ char buf[64];
+ va_list ap;
+
+ va_start(ap, fmt);
+ vsnprintf(buf, sizeof(buf), fmt, ap);
+ va_end(ap);
+ return cg_write(cgroup, file, buf);
+}
+
+static bool controller_enabled(const char *cgroup, const char *file, const char *controller)
+{
+ char buf[4096], *saveptr, *token;
+
+ if (cg_read(cgroup, file, buf, sizeof(buf)))
+ return false;
+ for (token = strtok_r(buf, "\n ", &saveptr); token;
+ token = strtok_r(NULL, "\n ", &saveptr))
+ if (!strcmp(token, controller))
+ return true;
+ return false;
+}
+
+static int set_policy(pid_t pid, int policy)
+{
+ struct sched_param param = {};
+
+ return sched_setscheduler(pid, policy, ¶m);
+}
+
+static void stop_burners(struct ctx *ctx)
+{
+ int i;
+
+ for (i = 0; i < NR_BURNERS; i++) {
+ if (ctx->burner[i] <= 0)
+ continue;
+ kill(ctx->burner[i], SIGKILL);
+ waitpid(ctx->burner[i], NULL, 0);
+ ctx->burner[i] = 0;
+ }
+}
+
+/* a task in @ctx->cg that spins on CPU @i + 1 under @policy */
+static int start_burner(struct ctx *ctx, int i, int policy)
+{
+ cpu_set_t set;
+ pid_t pid;
+
+ pid = fork();
+ if (pid < 0)
+ return -errno;
+ if (!pid)
+ for (;;)
+ ;
+
+ ctx->burner[i] = pid;
+ CPU_ZERO(&set);
+ CPU_SET((i + 1) % libbpf_num_possible_cpus(), &set);
+ if (sched_setaffinity(pid, sizeof(set), &set) ||
+ cg_enter(ctx->cg, pid) || set_policy(pid, policy))
+ return -1;
+ return 0;
+}
+
+static void cleanup(void *arg)
+{
+ struct ctx *ctx = arg;
+
+ if (!ctx)
+ return;
+ stop_burners(ctx);
+ bpf_link__destroy(ctx->link);
+ cgroup_runtime__destroy(ctx->skel);
+ if (ctx->saved_slice_us)
+ write_str("/proc/sys/kernel", "sched_cfs_bandwidth_slice_us", "%lld",
+ ctx->saved_slice_us);
+ if (ctx->cg_created)
+ cg_destroy(ctx->cg);
+ if (ctx->parent_created)
+ cg_destroy(ctx->parent);
+ free(ctx);
+}
+
+/*
+ * Open the skeleton and make parent/cg under the cgroup2 root, with a quota
+ * of @quota us per @period us, a burst of @burst us and a runtime of
+ * @runtime us on cg.
+ */
+static enum scx_test_status setup_ctx(void **ctxp, long long quota,
+ long long period, long long burst,
+ long long runtime)
+{
+ struct ctx *ctx;
+ char name[64];
+
+ ctx = calloc(1, sizeof(*ctx));
+ if (!ctx)
+ return SCX_TEST_FAIL;
+ *ctxp = ctx;
+
+ /* SCX_ENUM_INIT() exits if vmlinux BTF can't be loaded: before cgroups */
+ ctx->skel = cgroup_runtime__open();
+ if (!ctx->skel) {
+ SCX_ERR("Failed to open skel");
+ return SCX_TEST_FAIL;
+ }
+ SCX_ENUM_INIT(ctx->skel);
+
+ ctx->slice_us = 5000;
+ if (!access("/proc/sys/kernel/sched_cfs_bandwidth_slice_us", R_OK))
+ ctx->slice_us = cg_read_long("/proc/sys/kernel",
+ "sched_cfs_bandwidth_slice_us");
+
+ if (cg_find_unified_root(ctx->root, sizeof(ctx->root), NULL))
+ return SCX_TEST_SKIP;
+ if (!controller_enabled(ctx->root, "cgroup.subtree_control", "cpu") &&
+ cg_write(ctx->root, "cgroup.subtree_control", "+cpu"))
+ return SCX_TEST_SKIP;
+
+ snprintf(name, sizeof(name), "scx_cgroup_runtime_%d", getpid());
+ if (join_path(ctx->parent, sizeof(ctx->parent), ctx->root, name) ||
+ join_path(ctx->cg, sizeof(ctx->cg), ctx->parent, "cg")) {
+ SCX_ERR("Cgroup path is too long");
+ return SCX_TEST_FAIL;
+ }
+
+ 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;
+}
+
+static enum scx_test_status setup_rate(void **ctxp)
+{
+ return setup_ctx(ctxp, QUOTA_US, PERIOD_US, 0, QUOTA_US);
+}
+
+static enum scx_test_status attach_ops(struct ctx *ctx, bool partial)
+{
+ ctx->link = bpf_map__attach_struct_ops(partial ? ctx->skel->maps.partial_ops :
+ ctx->skel->maps.full_ops);
+ SCX_FAIL_IF(!ctx->link, "Failed to attach scheduler");
+ return SCX_TEST_PASS;
+}
+
+static enum scx_test_status load_ops(struct ctx *ctx, bool partial)
+{
+ bpf_map__set_autoattach(ctx->skel->maps.partial_ops, false);
+ bpf_map__set_autoattach(ctx->skel->maps.full_ops, false);
+ SCX_FAIL_IF(cgroup_runtime__load(ctx->skel), "Failed to load skel");
+ return attach_ops(ctx, partial);
+}
+
+static void detach(struct ctx *ctx)
+{
+ bpf_link__destroy(ctx->link);
+ ctx->link = NULL;
+}
+
+/*
+ * Run two burners in cg under @policy[], the first switching class every
+ * 7 ms if @toggle, and check that cg runs its quota per period, no more and
+ * not much less.
+ */
+static enum scx_test_status run_rate(struct ctx *ctx, bool partial,
+ const int *policy, bool toggle)
+{
+ long long tol = NR_BURNERS * ctx->slice_us + QUOTA_US;
+ long long used, u0;
+ bool ext = false;
+ u64 end;
+ int i;
+
+ if (load_ops(ctx, partial))
+ return SCX_TEST_FAIL;
+ for (i = 0; i < NR_BURNERS; i++)
+ SCX_EQ(start_burner(ctx, i, policy[i]), 0);
+
+ usleep(3 * PERIOD_US);
+ u0 = read_usage(ctx);
+ end = now_us() + NR_PERIODS * PERIOD_US;
+ while (now_us() < end) {
+ if (toggle) {
+ ext = !ext;
+ SCX_EQ(set_policy(ctx->burner[0], ext ? SCHED_EXT : SCHED_OTHER), 0);
+ }
+ usleep(7000);
+ }
+ used = read_usage(ctx) - u0;
+ stop_burners(ctx);
+
+ printf("ran %lld us in %d periods of quota %lld us, %lu held\n",
+ used, NR_PERIODS, QUOTA_US, (unsigned long)ctx->skel->bss->nr_held);
+ SCX_GE(used, QUOTA_US * NR_PERIODS - tol);
+ SCX_LE(used, QUOTA_US * NR_PERIODS + tol);
+ SCX_ASSERT_ALIVE(ctx->skel, uei);
+ return SCX_TEST_PASS;
+}
+
+static enum scx_test_status run_full(void *arg)
+{
+ static const int policy[] = { SCHED_OTHER, SCHED_OTHER };
+
+ return run_rate(arg, false, policy, false);
+}
+
+static enum scx_test_status run_partial_ext(void *arg)
+{
+ static const int policy[] = { SCHED_EXT, SCHED_EXT };
+
+ return run_rate(arg, true, policy, false);
+}
+
+static enum scx_test_status run_partial_fair(void *arg)
+{
+ static const int policy[] = { SCHED_OTHER, SCHED_OTHER };
+
+ return run_rate(arg, true, policy, false);
+}
+
+static enum scx_test_status run_partial_mixed(void *arg)
+{
+ static const int policy[] = { SCHED_EXT, SCHED_OTHER };
+
+ return run_rate(arg, true, policy, false);
+}
+
+static enum scx_test_status run_switch(void *arg)
+{
+ static const int policy[] = { SCHED_OTHER, SCHED_EXT };
+
+ return run_rate(arg, true, policy, true);
+}
+
+/*
+ * With a quota of 1 ms per second and a runtime of 3 s, what cg runs while
+ * the scheduler loads, runs and unloads is what its runtime loses, up to a
+ * slice per CPU and the few refills.
+ */
+#define LU_QUOTA_US 1000LL
+#define LU_PERIOD_US 1000000LL
+
+static enum scx_test_status setup_load_unload(void **ctxp)
+{
+ return setup_ctx(ctxp, LU_QUOTA_US, LU_PERIOD_US, 4000000, 3000000);
+}
+
+static enum scx_test_status run_load_unload(void *arg)
+{
+ struct ctx *ctx = arg;
+ long long r0, u0, drawn, used, refills, tol;
+ u64 t0, t1;
+ int i;
+
+ bpf_map__set_autoattach(ctx->skel->maps.partial_ops, false);
+ bpf_map__set_autoattach(ctx->skel->maps.full_ops, false);
+ SCX_FAIL_IF(cgroup_runtime__load(ctx->skel), "Failed to load skel");
+
+ t0 = now_us();
+ r0 = read_runtime(ctx);
+ u0 = read_usage(ctx);
+ for (i = 0; i < NR_BURNERS; i++)
+ SCX_EQ(start_burner(ctx, i, SCHED_OTHER), 0);
+ usleep(100000);
+ if (attach_ops(ctx, false))
+ return SCX_TEST_FAIL;
+ usleep(200000);
+ detach(ctx);
+ usleep(100000);
+ stop_burners(ctx);
+ drawn = r0 - read_runtime(ctx);
+ used = read_usage(ctx) - u0;
+ t1 = now_us();
+
+ refills = LU_QUOTA_US * ((t1 - t0) / LU_PERIOD_US + 2);
+ tol = NR_BURNERS * ctx->slice_us;
+ printf("ran %lld us, runtime down %lld us\n", used, drawn);
+ SCX_GE(used, 300000);
+ SCX_GE(used, drawn - tol);
+ SCX_LE(used, drawn + refills + tol);
+ return SCX_TEST_PASS;
+}
+
+/*
+ * A scheduler that ignores scx_bpf_task_cpu_max_runtime() runs a task far
+ * past the quota. Once the task is back on the fair class, it runs its quota
+ * again, less at most a quota and a slice of debt.
+ */
+static enum scx_test_status run_clamp(void *arg)
+{
+ struct ctx *ctx = arg;
+ long long u0, ignored, used;
+
+ ctx->skel->bss->ignore_runtime = true;
+ if (load_ops(ctx, true))
+ return SCX_TEST_FAIL;
+ SCX_EQ(start_burner(ctx, 0, SCHED_EXT), 0);
+
+ u0 = read_usage(ctx);
+ usleep(5 * PERIOD_US);
+ ignored = read_usage(ctx) - u0;
+
+ SCX_EQ(set_policy(ctx->burner[0], SCHED_OTHER), 0);
+ u0 = read_usage(ctx);
+ usleep(5 * PERIOD_US);
+ used = read_usage(ctx) - u0;
+ stop_burners(ctx);
+
+ printf("ran %lld us ignoring the quota, then %lld us on the fair class\n",
+ ignored, used);
+ SCX_GE(ignored, 4 * PERIOD_US);
+ SCX_GE(used, 4 * QUOTA_US - 2 * ctx->slice_us);
+ SCX_LE(used, 6 * QUOTA_US + ctx->slice_us);
+ SCX_ASSERT_ALIVE(ctx->skel, uei);
+ return SCX_TEST_PASS;
+}
+
+/*
+ * The scheduler stops a task only at a tick, so each period it overruns a
+ * quota of 2 ms by up to a tick. With a slice of 1 ms, the next tick's
+ * charge takes the CPU past the -slice floor. The debt must still be paid
+ * from the next periods, so over 100 periods cg runs its quota, with the
+ * second burner moving between CPUs 2 and 3 every 7 ms.
+ */
+#define DEBT_QUOTA_US 2000LL
+#define DEBT_PERIOD_US 10000LL
+#define DEBT_PERIODS 100
+
+static enum scx_test_status setup_debt(void **ctxp)
+{
+ enum scx_test_status ret;
+ struct ctx *ctx;
+
+ ret = setup_ctx(ctxp, DEBT_QUOTA_US, DEBT_PERIOD_US, 0, DEBT_QUOTA_US);
+ if (ret != SCX_TEST_PASS)
+ return ret;
+ ctx = *ctxp;
+ if (write_str("/proc/sys/kernel", "sched_cfs_bandwidth_slice_us", "1000"))
+ return SCX_TEST_SKIP;
+ ctx->saved_slice_us = ctx->slice_us;
+ ctx->slice_us = 1000;
+ return SCX_TEST_PASS;
+}
+
+static enum scx_test_status run_debt(void *arg)
+{
+ struct ctx *ctx = arg;
+ long long tol = (NR_BURNERS + 1) * ctx->slice_us + DEBT_QUOTA_US;
+ long long want = DEBT_QUOTA_US * DEBT_PERIODS;
+ long long used, u0;
+ bool moved = false;
+ cpu_set_t set;
+ u64 end;
+ int i;
+
+ if (libbpf_num_possible_cpus() < 4)
+ return SCX_TEST_SKIP;
+ if (load_ops(ctx, true))
+ return SCX_TEST_FAIL;
+ for (i = 0; i < NR_BURNERS; i++)
+ SCX_EQ(start_burner(ctx, i, SCHED_EXT), 0);
+
+ usleep(3 * DEBT_PERIOD_US);
+ u0 = read_usage(ctx);
+ end = now_us() + DEBT_PERIODS * DEBT_PERIOD_US;
+ while (now_us() < end) {
+ moved = !moved;
+ CPU_ZERO(&set);
+ CPU_SET(moved ? 3 : 2, &set);
+ SCX_EQ(sched_setaffinity(ctx->burner[1], sizeof(set), &set), 0);
+ usleep(7000);
+ }
+ used = read_usage(ctx) - u0;
+ stop_burners(ctx);
+
+ printf("ran %lld us in %d periods of quota %lld us\n", used,
+ DEBT_PERIODS, DEBT_QUOTA_US);
+ SCX_GE(used, want - tol);
+ SCX_LE(used, want + tol);
+ SCX_ASSERT_ALIVE(ctx->skel, uei);
+ return SCX_TEST_PASS;
+}
+
+/*
+ * One thread alternates cpu.max between quotas of 100ms and 10ms, without a
+ * burst, while another writes a runtime of 90ms, which only the first
+ * allows. The scheduler must never get a runtime above the quota it was last
+ * given, and must end with the quota cpu.max has.
+ */
+#define INTERLEAVE_US 2000000ULL
+
+struct interleave {
+ struct ctx *ctx;
+ long nr_ok;
+ long nr_einval;
+ long nr_err;
+};
+
+static void *interleave_quota(void *arg)
+{
+ struct interleave *il = arg;
+ u64 end = now_us() + INTERLEAVE_US;
+ bool high = false;
+
+ while (now_us() < end) {
+ if (write_str(il->ctx->cg, "cpu.max", "%lld %lld",
+ high ? 100000LL : 10000LL, 1000000LL))
+ il->nr_err++;
+ else
+ il->nr_ok++;
+ high = !high;
+ }
+ return NULL;
+}
+
+static void *interleave_runtime(void *arg)
+{
+ struct interleave *il = arg;
+ u64 end = now_us() + INTERLEAVE_US;
+
+ while (now_us() < end) {
+ int fd = cg_open(il->ctx->cg, "cpu.max.runtime", O_WRONLY);
+
+ if (fd < 0) {
+ il->nr_err++;
+ continue;
+ }
+ if (write(fd, "90000", 5) == 5)
+ il->nr_ok++;
+ else if (errno == EINVAL)
+ il->nr_einval++;
+ else
+ il->nr_err++;
+ close(fd);
+ }
+ return NULL;
+}
+
+static enum scx_test_status setup_interleave(void **ctxp)
+{
+ return setup_ctx(ctxp, 100000, 1000000, 0, 100000);
+}
+
+static enum scx_test_status run_interleave(void *arg)
+{
+ struct ctx *ctx = arg;
+ struct interleave quota = { .ctx = ctx }, runtime = { .ctx = ctx };
+ pthread_t tq, tr;
+ char buf[64];
+
+ if (load_ops(ctx, true))
+ return SCX_TEST_FAIL;
+ SCX_EQ(ctx->skel->bss->quota_us, 100000);
+
+ SCX_EQ(pthread_create(&tq, NULL, interleave_quota, "a), 0);
+ SCX_EQ(pthread_create(&tr, NULL, interleave_runtime, &runtime), 0);
+ pthread_join(tq, NULL);
+ pthread_join(tr, NULL);
+
+ SCX_EQ(quota.nr_err, 0);
+ SCX_EQ(runtime.nr_err, 0);
+ SCX_GT(runtime.nr_ok, 0);
+ SCX_EQ(ctx->skel->bss->nr_bad_order, 0);
+ SCX_EQ(ctx->skel->bss->nr_set_bw, quota.nr_ok);
+ SCX_EQ(ctx->skel->bss->nr_set_runtime, runtime.nr_ok);
+
+ SCX_EQ(cg_read(ctx->cg, "cpu.max", buf, sizeof(buf)), 0);
+ SCX_EQ(ctx->skel->bss->quota_us, atoll(buf));
+ SCX_EQ(ctx->skel->bss->burst_us, 0);
+
+ printf("cpu.max writes %ld, cpu.max.runtime writes %ld (%ld EINVAL)\n",
+ quota.nr_ok, runtime.nr_ok, runtime.nr_einval);
+ SCX_ASSERT_ALIVE(ctx->skel, uei);
+ return SCX_TEST_PASS;
+}
+
+#define RUNTIME_TEST(__name, __setup, __desc) \
+ struct scx_test cgroup_runtime_##__name = { \
+ .name = "cgroup_runtime_" #__name, \
+ .description = __desc, \
+ .setup = __setup, \
+ .run = run_##__name, \
+ .cleanup = cleanup, \
+ }
+
+RUNTIME_TEST(full, setup_rate, "Without partial switch, the BPF scheduler's tasks run their quota");
+RUNTIME_TEST(partial_ext, setup_rate, "SCHED_EXT tasks of a cgroup run its quota");
+RUNTIME_TEST(partial_fair, setup_rate, "Fair tasks of a cgroup run its quota");
+RUNTIME_TEST(partial_mixed, setup_rate, "Fair and SCHED_EXT tasks of a cgroup share its quota");
+RUNTIME_TEST(switch, setup_rate, "A task switching class every 7 ms stays within the quota");
+RUNTIME_TEST(load_unload, setup_load_unload, "Load and unload neither gain nor lose runtime");
+RUNTIME_TEST(clamp, setup_rate, "A scheduler ignoring the quota leaves a bounded debt");
+RUNTIME_TEST(debt, setup_debt, "Debt past a quota is paid in the next periods");
+RUNTIME_TEST(interleave, setup_interleave, "cpu.max writes reach the scheduler in order");
+
+/* REGISTER_SCX_TEST() can be used once per file */
+__attribute__((constructor))
+static void register_cgroup_runtime_tests(void)
+{
+ scx_test_register(&cgroup_runtime_full);
+ scx_test_register(&cgroup_runtime_partial_ext);
+ scx_test_register(&cgroup_runtime_partial_fair);
+ scx_test_register(&cgroup_runtime_partial_mixed);
+ scx_test_register(&cgroup_runtime_switch);
+ scx_test_register(&cgroup_runtime_load_unload);
+ scx_test_register(&cgroup_runtime_clamp);
+ scx_test_register(&cgroup_runtime_debt);
+ scx_test_register(&cgroup_runtime_interleave);
+}
diff --git a/tools/testing/selftests/sched_ext/config b/tools/testing/selftests/sched_ext/config
index 8173f9170ffe8..495b140403a18 100644
--- a/tools/testing/selftests/sched_ext/config
+++ b/tools/testing/selftests/sched_ext/config
@@ -9,3 +9,4 @@ CONFIG_DEBUG_INFO=y
CONFIG_DEBUG_INFO_BTF=y
CONFIG_EXPERT=y
CONFIG_SCHED_PROXY_EXEC=y
+CONFIG_CFS_BANDWIDTH=y
--
2.47.3
Amazon Development Centre (South Africa) (Proprietary) Limited
29 Gogosoa Street, Observatory, Cape Town, Western Cape, 7925, South Africa
Registration Number: 2004 / 034463 / 07
next prev parent reply other threads:[~2026-11-10 11:10 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 ` Fernand Sieber [this message]
2026-11-10 11:39 ` [PATCH v2 4/4] sched_ext: charge BPF scheduler tasks to cpu.max bandwidth sashiko-bot
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=3977ed2471c6fdc19c672b2dcd13c70f997b9eff.1794303670.git.sieberf@amazon.com \
--to=sieberf@amazon.com \
--cc=arighi@nvidia.com \
--cc=bpf@vger.kernel.org \
--cc=bsegall@google.com \
--cc=cgroups@vger.kernel.org \
--cc=changwoo@igalia.com \
--cc=corbet@lwn.net \
--cc=dietmar.eggemann@arm.com \
--cc=dwmw@amazon.co.uk \
--cc=fmubeen@amazon.de \
--cc=hannes@cmpxchg.org \
--cc=hborghor@amazon.de \
--cc=juri.lelli@redhat.com \
--cc=karataev@amazon.com \
--cc=kprateek.nayak@amd.com \
--cc=linux-doc@vger.kernel.org \
--cc=linux-kernel@vger.kernel.org \
--cc=linux-kselftest@vger.kernel.org \
--cc=mgorman@suse.de \
--cc=mingo@redhat.com \
--cc=mkoutny@suse.com \
--cc=nh-open-source@amazon.com \
--cc=peterz@infradead.org \
--cc=rdunlap@infradead.org \
--cc=rostedt@goodmis.org \
--cc=sched-ext@lists.linux.dev \
--cc=skhan@linuxfoundation.org \
--cc=tj@kernel.org \
--cc=vincent.guittot@linaro.org \
--cc=void@manifault.com \
--cc=vschneid@redhat.com \
/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.