* [PATCHSET v4 sched_ext/for-6.20] sched_ext: Fix ops.dequeue() semantics
@ 2026-02-01 9:08 Andrea Righi
2026-02-01 9:08 ` [PATCH 1/2] " Andrea Righi
2026-02-01 9:08 ` [PATCH 2/2] selftests/sched_ext: Add test to validate " Andrea Righi
0 siblings, 2 replies; 41+ messages in thread
From: Andrea Righi @ 2026-02-01 9:08 UTC (permalink / raw)
To: Tejun Heo, David Vernet, Changwoo Min
Cc: Kuba Piecuch, Emil Tsalapatis, Christian Loehle, Daniel Hodges,
sched-ext, linux-kernel
The callback ops.dequeue() is provided to let BPF schedulers observe when a
task leaves the scheduler, either because it is dispatched or due to a task
property change. However, this callback is currently unreliable and not
invoked systematically, which can result in missed ops.dequeue() events.
In particular, once a task is removed from the scheduler (whether for
dispatch or due to a property change) the BPF scheduler loses visibility of
the task and the sched_ext core may not always trigger ops.dequeue().
This breaks accurate accounting (i.e., per-DSQ queued runtime sums) and
prevents reliable tracking of task lifecycle transitions.
This patch set fixes the semantics of ops.dequeue(), by guaranteeing that
each task entering the BPF scheduler's custody triggers exactly one
ops.dequeue() call when it leaves that custody, whether the exit is due to
a dispatch (regular or via a core scheduling pick) or to a scheduling
property change (e.g. sched_setaffinity(), sched_setscheduler(),
set_user_nice(), NUMA balancing, etc.).
To identify property change dequeues a new ops.dequeue() flag is
introduced: %SCX_DEQ_SCHED_CHANGE.
Together, these changes allow BPF schedulers to reliably track task
ownership and maintain accurate accounting.
= Open issues =
Even though a few refinements are still pending, I'm sending a new
patchset, so we can comment more effectively based on the latest
agreed-upon semantics.
Open issues that still need agreement:
- Should we trigger ops.dequeue() for tasks dispatched to SCX_DSQ_GLOBAL?
(do we treat SCX_DSQ_GLOBAL as a local DSQ or a user DSQ?). In the
current implementation, SCX_DSQ_GLOBAL is treated like a built-in user
DSQ -> ops.dequeue() invoked for tasks dispatched to SCX_DSQ_GLOBAL.
Changes in v4:
- Introduce the concept of "BPF scheduler custody"
- Do not trigger ops.dequeue() for tasks directly dispatched to local DSQs
- Trigger ops.dequeue() only once; after the task leaves BPF scheduler
custody, further dequeue events are not reported.
- Link to v3: https://lore.kernel.org/all/20260126084258.3798129-1-arighi@nvidia.com
Changes in v3:
- Rename SCX_DEQ_ASYNC to SCX_DEQ_SCHED_CHANGE
- Handle core-sched dequeues (Kuba)
- Link to v2: https://lore.kernel.org/all/20260121123118.964704-1-arighi@nvidia.com
Changes in v2:
- Distinguish between "dispatch" dequeues and "property change" dequeues
(flag SCX_DEQ_ASYNC)
- Link to v1: https://lore.kernel.org/all/20251219224450.2537941-1-arighi@nvidia.com
Andrea Righi (2):
sched_ext: Fix ops.dequeue() semantics
selftests/sched_ext: Add test to validate ops.dequeue() semantics
Documentation/scheduler/sched-ext.rst | 76 ++++++++
include/linux/sched/ext.h | 1 +
kernel/sched/ext.c | 168 ++++++++++++++++-
kernel/sched/ext_internal.h | 7 +
tools/sched_ext/include/scx/enum_defs.autogen.h | 1 +
tools/sched_ext/include/scx/enums.autogen.bpf.h | 2 +
tools/sched_ext/include/scx/enums.autogen.h | 1 +
tools/testing/selftests/sched_ext/Makefile | 1 +
tools/testing/selftests/sched_ext/dequeue.bpf.c | 234 ++++++++++++++++++++++++
tools/testing/selftests/sched_ext/dequeue.c | 198 ++++++++++++++++++++
10 files changed, 686 insertions(+), 3 deletions(-)
create mode 100644 tools/testing/selftests/sched_ext/dequeue.bpf.c
create mode 100644 tools/testing/selftests/sched_ext/dequeue.c
^ permalink raw reply [flat|nested] 41+ messages in thread
* [PATCH 1/2] sched_ext: Fix ops.dequeue() semantics
2026-02-01 9:08 [PATCHSET v4 sched_ext/for-6.20] sched_ext: Fix ops.dequeue() semantics Andrea Righi
@ 2026-02-01 9:08 ` Andrea Righi
2026-02-01 22:47 ` Christian Loehle
2026-02-02 11:56 ` Kuba Piecuch
2026-02-01 9:08 ` [PATCH 2/2] selftests/sched_ext: Add test to validate " Andrea Righi
1 sibling, 2 replies; 41+ messages in thread
From: Andrea Righi @ 2026-02-01 9:08 UTC (permalink / raw)
To: Tejun Heo, David Vernet, Changwoo Min
Cc: Kuba Piecuch, Emil Tsalapatis, Christian Loehle, Daniel Hodges,
sched-ext, linux-kernel
Currently, ops.dequeue() is only invoked when the sched_ext core knows
that a task resides in BPF-managed data structures, which causes it to
miss scheduling property change events. In addition, ops.dequeue()
callbacks are completely skipped when tasks are dispatched to non-local
DSQs from ops.select_cpu(). As a result, BPF schedulers cannot reliably
track task state.
Fix this by guaranteeing that each task entering the BPF scheduler's
custody triggers exactly one ops.dequeue() call when it leaves that
custody, whether the exit is due to a dispatch (regular or via a core
scheduling pick) or to a scheduling property change (e.g.
sched_setaffinity(), sched_setscheduler(), set_user_nice(), NUMA
balancing, etc.).
BPF scheduler custody concept: a task is considered to be in "BPF
scheduler's custody" when it has been queued in BPF-managed data
structures and the BPF scheduler is responsible for its lifecycle.
Custody ends when the task is dispatched to a local DSQ, selected by
core scheduling, or removed due to a property change.
Tasks directly dispatched to local DSQs (via %SCX_DSQ_LOCAL or
%SCX_DSQ_LOCAL_ON) bypass the BPF scheduler entirely and are not in its
custody. As a result, ops.dequeue() is not invoked for these tasks.
To identify dequeues triggered by scheduling property changes, introduce
the new ops.dequeue() flag %SCX_DEQ_SCHED_CHANGE: when this flag is set,
the dequeue was caused by a scheduling property change.
New ops.dequeue() semantics:
- ops.dequeue() is invoked exactly once when the task leaves the BPF
scheduler's custody, in one of the following cases:
a) regular dispatch: task was dispatched to a non-local DSQ (global
or user DSQ), ops.dequeue() called without any special flags set
b) core scheduling dispatch: core-sched picks task before dispatch,
dequeue called with %SCX_DEQ_CORE_SCHED_EXEC flag set
c) property change: task properties modified before dispatch,
dequeue called with %SCX_DEQ_SCHED_CHANGE flag set
This allows BPF schedulers to:
- reliably track task ownership and lifecycle,
- maintain accurate accounting of managed tasks,
- update internal state when tasks change properties.
Cc: Tejun Heo <tj@kernel.org>
Cc: Emil Tsalapatis <emil@etsalapatis.com>
Cc: Kuba Piecuch <jpiecuch@google.com>
Signed-off-by: Andrea Righi <arighi@nvidia.com>
---
Documentation/scheduler/sched-ext.rst | 76 ++++++++
include/linux/sched/ext.h | 1 +
kernel/sched/ext.c | 168 +++++++++++++++++-
kernel/sched/ext_internal.h | 7 +
.../sched_ext/include/scx/enum_defs.autogen.h | 1 +
.../sched_ext/include/scx/enums.autogen.bpf.h | 2 +
tools/sched_ext/include/scx/enums.autogen.h | 1 +
7 files changed, 253 insertions(+), 3 deletions(-)
diff --git a/Documentation/scheduler/sched-ext.rst b/Documentation/scheduler/sched-ext.rst
index 404fe6126a769..6d9e82e6ca9d4 100644
--- a/Documentation/scheduler/sched-ext.rst
+++ b/Documentation/scheduler/sched-ext.rst
@@ -252,6 +252,80 @@ The following briefly shows how a waking task is scheduled and executed.
* Queue the task on the BPF side.
+ **Task State Tracking and ops.dequeue() Semantics**
+
+ Once ``ops.select_cpu()`` or ``ops.enqueue()`` is called, the task may
+ enter the "BPF scheduler's custody" depending on where it's dispatched:
+
+ * **Direct dispatch to local DSQs** (``SCX_DSQ_LOCAL`` or
+ ``SCX_DSQ_LOCAL_ON | cpu``): The task bypasses the BPF scheduler
+ entirely and goes straight to the CPU's local run queue. The task
+ never enters BPF custody, and ``ops.dequeue()`` will not be called.
+
+ * **Dispatch to non-local DSQs** (``SCX_DSQ_GLOBAL`` or custom DSQs):
+ the task enters the BPF scheduler's custody. When the task later
+ leaves BPF custody (dispatched to a local DSQ, picked by core-sched,
+ or dequeued for sleep/property changes), ``ops.dequeue()`` will be
+ called exactly once.
+
+ * **Queued on BPF side**: The task is in BPF data structures and in BPF
+ custody, ``ops.dequeue()`` will be called when it leaves.
+
+ The key principle: **ops.dequeue() is called when a task leaves the BPF
+ scheduler's custody**. A task is in BPF custody if it's on a non-local
+ DSQ or in BPF data structures. Once dispatched to a local DSQ or after
+ ops.dequeue() is called, the task is out of BPF custody and the BPF
+ scheduler no longer needs to track it.
+
+ This works correctly with the ``ops.select_cpu()`` direct dispatch
+ optimization: even though it skips ``ops.enqueue()`` invocation, if the
+ task is dispatched to a non-local DSQ, it enters BPF custody and will
+ get ``ops.dequeue()`` when it leaves. This provides the performance
+ benefit of avoiding the ``ops.enqueue()`` roundtrip while maintaining
+ correct state tracking.
+
+ The dequeue can happen for different reasons, distinguished by flags:
+
+ 1. **Regular dispatch workflow**: when the task is dispatched from a
+ non-local DSQ to a local DSQ (leaving BPF custody for execution),
+ ``ops.dequeue()`` is triggered without any special flags.
+
+ 2. **Core scheduling pick**: when ``CONFIG_SCHED_CORE`` is enabled and
+ core scheduling picks a task for execution while it's still in BPF
+ custody, ``ops.dequeue()`` is called with the
+ ``SCX_DEQ_CORE_SCHED_EXEC`` flag.
+
+ 3. **Scheduling property change**: when a task property changes (via
+ operations like ``sched_setaffinity()``, ``sched_setscheduler()``,
+ priority changes, CPU migrations, etc.) while the task is still in
+ BPF custody, ``ops.dequeue()`` is called with the
+ ``SCX_DEQ_SCHED_CHANGE`` flag set in ``deq_flags``.
+
+ **Important**: Once a task has left BPF custody (dispatched to local
+ DSQ), property changes will not trigger ``ops.dequeue()``, since the
+ task is no longer being managed by the BPF scheduler.
+
+ **Property Change Notifications for Running Tasks**:
+
+ For tasks that have left BPF custody (running or on local DSQs),
+ property changes can be intercepted through the dedicated callbacks:
+
+ * ``ops.set_cpumask()``: Called when a task's CPU affinity changes
+ (e.g., via ``sched_setaffinity()``). This callback is invoked for
+ all tasks regardless of their state or BPF custody.
+
+ * ``ops.set_weight()``: Called when a task's scheduling weight/priority
+ changes (e.g., via ``sched_setscheduler()`` or ``set_user_nice()``).
+ This callback is also invoked for all tasks.
+
+ These callbacks provide complete coverage for property changes,
+ complementing ``ops.dequeue()`` which only applies to tasks in BPF
+ custody.
+
+ BPF schedulers can choose not to implement ``ops.dequeue()`` if they
+ don't need to track these transitions. The sched_ext core will safely
+ handle all dequeue operations regardless.
+
3. When a CPU is ready to schedule, it first looks at its local DSQ. If
empty, it then looks at the global DSQ. If there still isn't a task to
run, ``ops.dispatch()`` is invoked which can use the following two
@@ -319,6 +393,8 @@ by a sched_ext scheduler:
/* Any usable CPU becomes available */
ops.dispatch(); /* Task is moved to a local DSQ */
+
+ ops.dequeue(); /* Exiting BPF scheduler */
}
ops.running(); /* Task starts running on its assigned CPU */
while (task->scx.slice > 0 && task is runnable)
diff --git a/include/linux/sched/ext.h b/include/linux/sched/ext.h
index bcb962d5ee7d8..0d003d2845393 100644
--- a/include/linux/sched/ext.h
+++ b/include/linux/sched/ext.h
@@ -84,6 +84,7 @@ struct scx_dispatch_q {
/* scx_entity.flags */
enum scx_ent_flags {
SCX_TASK_QUEUED = 1 << 0, /* on ext runqueue */
+ SCX_TASK_OPS_ENQUEUED = 1 << 1, /* under ext scheduler's custody */
SCX_TASK_RESET_RUNNABLE_AT = 1 << 2, /* runnable_at should be reset */
SCX_TASK_DEQD_FOR_SLEEP = 1 << 3, /* last dequeue was for SLEEP */
diff --git a/kernel/sched/ext.c b/kernel/sched/ext.c
index afe28c04d5aa7..6d6f1253039d8 100644
--- a/kernel/sched/ext.c
+++ b/kernel/sched/ext.c
@@ -924,6 +924,19 @@ static void touch_core_sched(struct rq *rq, struct task_struct *p)
#endif
}
+/**
+ * is_local_dsq - Check if a DSQ ID represents a local DSQ
+ * @dsq_id: DSQ ID to check
+ *
+ * Returns true if @dsq_id is a local DSQ, false otherwise. Local DSQs are
+ * per-CPU queues where tasks go directly to execution.
+ */
+static inline bool is_local_dsq(u64 dsq_id)
+{
+ return dsq_id == SCX_DSQ_LOCAL ||
+ (dsq_id & SCX_DSQ_LOCAL_ON) == SCX_DSQ_LOCAL_ON;
+}
+
/**
* touch_core_sched_dispatch - Update core-sched timestamp on dispatch
* @rq: rq to read clock from, must be locked
@@ -1274,6 +1287,24 @@ static void mark_direct_dispatch(struct scx_sched *sch,
p->scx.ddsp_dsq_id = dsq_id;
p->scx.ddsp_enq_flags = enq_flags;
+
+ /*
+ * Mark the task as entering BPF scheduler's custody if it's being
+ * dispatched to a non-local DSQ. This handles the case where
+ * ops.select_cpu() directly dispatches to a non-local DSQ - even
+ * though ops.enqueue() won't be called, the task enters BPF
+ * custody and should get ops.dequeue() when it leaves.
+ *
+ * For local DSQs, clear the flag, since the task bypasses the BPF
+ * scheduler entirely. This also clears any flag that was set by
+ * do_enqueue_task() before we knew the dispatch destination.
+ */
+ if (SCX_HAS_OP(sch, dequeue)) {
+ if (!is_local_dsq(dsq_id))
+ p->scx.flags |= SCX_TASK_OPS_ENQUEUED;
+ else
+ p->scx.flags &= ~SCX_TASK_OPS_ENQUEUED;
+ }
}
static void direct_dispatch(struct scx_sched *sch, struct task_struct *p,
@@ -1287,6 +1318,40 @@ static void direct_dispatch(struct scx_sched *sch, struct task_struct *p,
p->scx.ddsp_enq_flags |= enq_flags;
+ /*
+ * The task is about to be dispatched, handle ops.dequeue() based
+ * on where the task is going.
+ *
+ * Key principle: ops.dequeue() is called when a task leaves the
+ * BPF scheduler's custody. A task is in BPF custody if it's on a
+ * non-local DSQ or in BPF data structures. Once dispatched to a
+ * local DSQ, it's out of BPF custody.
+ *
+ * Direct dispatch to local DSQs: task never enters BPF scheduler's
+ * custody, it goes straight to the CPU. Don't call ops.dequeue()
+ * and clear the flag so future property changes also won't trigger
+ * it.
+ *
+ * Direct dispatch to non-local DSQs: task enters BPF scheduler's
+ * custody. Mark the task as in BPF custody so that when it's later
+ * dispatched to a local DSQ or dequeued for property changes,
+ * ops.dequeue() will be called.
+ *
+ * This also handles the ops.select_cpu() direct dispatch to
+ * non-local DSQs: the shortcut skips ops.enqueue() invocation but
+ * the task still enters BPF custody if dispatched to a non-local
+ * DSQ, and thus needs ops.dequeue() when it leaves.
+ */
+ if (SCX_HAS_OP(sch, dequeue)) {
+ if (!is_local_dsq(dsq->id)) {
+ p->scx.flags |= SCX_TASK_OPS_ENQUEUED;
+ } else {
+ if (p->scx.flags & SCX_TASK_OPS_ENQUEUED)
+ SCX_CALL_OP_TASK(sch, SCX_KF_REST, dequeue, rq, p, 0);
+ p->scx.flags &= ~SCX_TASK_OPS_ENQUEUED;
+ }
+ }
+
/*
* We are in the enqueue path with @rq locked and pinned, and thus can't
* double lock a remote rq and enqueue to its local DSQ. For
@@ -1391,6 +1456,21 @@ static void do_enqueue_task(struct rq *rq, struct task_struct *p, u64 enq_flags,
WARN_ON_ONCE(atomic_long_read(&p->scx.ops_state) != SCX_OPSS_NONE);
atomic_long_set(&p->scx.ops_state, SCX_OPSS_QUEUEING | qseq);
+ /*
+ * Mark that ops.enqueue() is being called for this task. This
+ * indicates the task is entering the BPF scheduler's data
+ * structures (QUEUED state).
+ *
+ * However, if the task was already marked as in BPF custody by
+ * mark_direct_dispatch() (ops.select_cpu() direct dispatch to
+ * non-local DSQ), don't clear that - keep the flag set so
+ * ops.dequeue() will be called when appropriate.
+ *
+ * Only track this flag if ops.dequeue() is implemented.
+ */
+ if (SCX_HAS_OP(sch, dequeue))
+ p->scx.flags |= SCX_TASK_OPS_ENQUEUED;
+
ddsp_taskp = this_cpu_ptr(&direct_dispatch_task);
WARN_ON_ONCE(*ddsp_taskp);
*ddsp_taskp = p;
@@ -1523,6 +1603,30 @@ static void ops_dequeue(struct rq *rq, struct task_struct *p, u64 deq_flags)
switch (opss & SCX_OPSS_STATE_MASK) {
case SCX_OPSS_NONE:
+ /*
+ * Task is not in BPF data structures (either dispatched to
+ * a DSQ or running). Only call ops.dequeue() if the task
+ * is still in BPF scheduler's custody
+ * (%SCX_TASK_OPS_ENQUEUED is set).
+ *
+ * If the task has already been dispatched to a local DSQ
+ * (left BPF custody), the flag will be clear and we skip
+ * ops.dequeue()
+ *
+ * If this is a property change (not sleep/core-sched) and
+ * the task is still in BPF custody, set the
+ * %SCX_DEQ_SCHED_CHANGE flag.
+ */
+ if (SCX_HAS_OP(sch, dequeue) &&
+ p->scx.flags & SCX_TASK_OPS_ENQUEUED) {
+ u64 flags = deq_flags;
+
+ if (!(deq_flags & (DEQUEUE_SLEEP | SCX_DEQ_CORE_SCHED_EXEC)))
+ flags |= SCX_DEQ_SCHED_CHANGE;
+
+ SCX_CALL_OP_TASK(sch, SCX_KF_REST, dequeue, rq, p, flags);
+ p->scx.flags &= ~SCX_TASK_OPS_ENQUEUED;
+ }
break;
case SCX_OPSS_QUEUEING:
/*
@@ -1531,9 +1635,24 @@ static void ops_dequeue(struct rq *rq, struct task_struct *p, u64 deq_flags)
*/
BUG();
case SCX_OPSS_QUEUED:
- if (SCX_HAS_OP(sch, dequeue))
- SCX_CALL_OP_TASK(sch, SCX_KF_REST, dequeue, rq,
- p, deq_flags);
+ /*
+ * Task is still on the BPF scheduler (not dispatched yet).
+ * Call ops.dequeue() to notify. Add %SCX_DEQ_SCHED_CHANGE
+ * only for property changes, not for core-sched picks or
+ * sleep.
+ *
+ * Clear the flag after calling ops.dequeue(): the task is
+ * leaving BPF scheduler's custody.
+ */
+ if (SCX_HAS_OP(sch, dequeue)) {
+ u64 flags = deq_flags;
+
+ if (!(deq_flags & (DEQUEUE_SLEEP | SCX_DEQ_CORE_SCHED_EXEC)))
+ flags |= SCX_DEQ_SCHED_CHANGE;
+
+ SCX_CALL_OP_TASK(sch, SCX_KF_REST, dequeue, rq, p, flags);
+ p->scx.flags &= ~SCX_TASK_OPS_ENQUEUED;
+ }
if (atomic_long_try_cmpxchg(&p->scx.ops_state, &opss,
SCX_OPSS_NONE))
@@ -1630,6 +1749,7 @@ static void move_local_task_to_local_dsq(struct task_struct *p, u64 enq_flags,
struct scx_dispatch_q *src_dsq,
struct rq *dst_rq)
{
+ struct scx_sched *sch = scx_root;
struct scx_dispatch_q *dst_dsq = &dst_rq->scx.local_dsq;
/* @dsq is locked and @p is on @dst_rq */
@@ -1638,6 +1758,15 @@ static void move_local_task_to_local_dsq(struct task_struct *p, u64 enq_flags,
WARN_ON_ONCE(p->scx.holding_cpu >= 0);
+ /*
+ * Task is moving from a non-local DSQ to a local DSQ. Call
+ * ops.dequeue() if the task was in BPF custody.
+ */
+ if (SCX_HAS_OP(sch, dequeue) && (p->scx.flags & SCX_TASK_OPS_ENQUEUED)) {
+ SCX_CALL_OP_TASK(sch, SCX_KF_REST, dequeue, dst_rq, p, 0);
+ p->scx.flags &= ~SCX_TASK_OPS_ENQUEUED;
+ }
+
if (enq_flags & (SCX_ENQ_HEAD | SCX_ENQ_PREEMPT))
list_add(&p->scx.dsq_list.node, &dst_dsq->list);
else
@@ -2107,6 +2236,24 @@ static void finish_dispatch(struct scx_sched *sch, struct rq *rq,
BUG_ON(!(p->scx.flags & SCX_TASK_QUEUED));
+ /*
+ * Direct dispatch to local DSQs: call ops.dequeue() if task was in
+ * BPF custody, then clear the %SCX_TASK_OPS_ENQUEUED flag.
+ *
+ * Dispatch to non-local DSQs: task is in BPF scheduler's custody.
+ * Mark it so ops.dequeue() will be called when it leaves.
+ */
+ if (SCX_HAS_OP(sch, dequeue)) {
+ if (!is_local_dsq(dsq_id)) {
+ p->scx.flags |= SCX_TASK_OPS_ENQUEUED;
+ } else {
+ if (p->scx.flags & SCX_TASK_OPS_ENQUEUED)
+ SCX_CALL_OP_TASK(sch, SCX_KF_REST, dequeue, task_rq(p), p, 0);
+
+ p->scx.flags &= ~SCX_TASK_OPS_ENQUEUED;
+ }
+ }
+
dsq = find_dsq_for_dispatch(sch, this_rq(), dsq_id, p);
if (dsq->id == SCX_DSQ_LOCAL)
@@ -2894,6 +3041,14 @@ static void scx_enable_task(struct task_struct *p)
lockdep_assert_rq_held(rq);
+ /*
+ * Clear enqueue/dequeue tracking flags when enabling the task.
+ * This ensures a clean state when the task enters SCX. Only needed
+ * if ops.dequeue() is implemented.
+ */
+ if (SCX_HAS_OP(sch, dequeue))
+ p->scx.flags &= ~SCX_TASK_OPS_ENQUEUED;
+
/*
* Set the weight before calling ops.enable() so that the scheduler
* doesn't see a stale value if they inspect the task struct.
@@ -2925,6 +3080,13 @@ static void scx_disable_task(struct task_struct *p)
if (SCX_HAS_OP(sch, disable))
SCX_CALL_OP_TASK(sch, SCX_KF_REST, disable, rq, p);
scx_set_task_state(p, SCX_TASK_READY);
+
+ /*
+ * Clear enqueue/dequeue tracking flags when disabling the task.
+ * Only needed if ops.dequeue() is implemented.
+ */
+ if (SCX_HAS_OP(sch, dequeue))
+ p->scx.flags &= ~SCX_TASK_OPS_ENQUEUED;
}
static void scx_exit_task(struct task_struct *p)
diff --git a/kernel/sched/ext_internal.h b/kernel/sched/ext_internal.h
index 386c677e4c9a0..befa9a5d6e53f 100644
--- a/kernel/sched/ext_internal.h
+++ b/kernel/sched/ext_internal.h
@@ -982,6 +982,13 @@ enum scx_deq_flags {
* it hasn't been dispatched yet. Dequeue from the BPF side.
*/
SCX_DEQ_CORE_SCHED_EXEC = 1LLU << 32,
+
+ /*
+ * The task is being dequeued due to a property change (e.g.,
+ * sched_setaffinity(), sched_setscheduler(), set_user_nice(),
+ * etc.).
+ */
+ SCX_DEQ_SCHED_CHANGE = 1LLU << 33,
};
enum scx_pick_idle_cpu_flags {
diff --git a/tools/sched_ext/include/scx/enum_defs.autogen.h b/tools/sched_ext/include/scx/enum_defs.autogen.h
index c2c33df9292c2..dcc945304760f 100644
--- a/tools/sched_ext/include/scx/enum_defs.autogen.h
+++ b/tools/sched_ext/include/scx/enum_defs.autogen.h
@@ -21,6 +21,7 @@
#define HAVE_SCX_CPU_PREEMPT_UNKNOWN
#define HAVE_SCX_DEQ_SLEEP
#define HAVE_SCX_DEQ_CORE_SCHED_EXEC
+#define HAVE_SCX_DEQ_SCHED_CHANGE
#define HAVE_SCX_DSQ_FLAG_BUILTIN
#define HAVE_SCX_DSQ_FLAG_LOCAL_ON
#define HAVE_SCX_DSQ_INVALID
diff --git a/tools/sched_ext/include/scx/enums.autogen.bpf.h b/tools/sched_ext/include/scx/enums.autogen.bpf.h
index 2f8002bcc19ad..5da50f9376844 100644
--- a/tools/sched_ext/include/scx/enums.autogen.bpf.h
+++ b/tools/sched_ext/include/scx/enums.autogen.bpf.h
@@ -127,3 +127,5 @@ const volatile u64 __SCX_ENQ_CLEAR_OPSS __weak;
const volatile u64 __SCX_ENQ_DSQ_PRIQ __weak;
#define SCX_ENQ_DSQ_PRIQ __SCX_ENQ_DSQ_PRIQ
+const volatile u64 __SCX_DEQ_SCHED_CHANGE __weak;
+#define SCX_DEQ_SCHED_CHANGE __SCX_DEQ_SCHED_CHANGE
diff --git a/tools/sched_ext/include/scx/enums.autogen.h b/tools/sched_ext/include/scx/enums.autogen.h
index fedec938584be..fc9a7a4d9dea5 100644
--- a/tools/sched_ext/include/scx/enums.autogen.h
+++ b/tools/sched_ext/include/scx/enums.autogen.h
@@ -46,4 +46,5 @@
SCX_ENUM_SET(skel, scx_enq_flags, SCX_ENQ_LAST); \
SCX_ENUM_SET(skel, scx_enq_flags, SCX_ENQ_CLEAR_OPSS); \
SCX_ENUM_SET(skel, scx_enq_flags, SCX_ENQ_DSQ_PRIQ); \
+ SCX_ENUM_SET(skel, scx_deq_flags, SCX_DEQ_SCHED_CHANGE); \
} while (0)
--
2.52.0
^ permalink raw reply [flat|nested] 41+ messages in thread
* [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-01 9:08 [PATCHSET v4 sched_ext/for-6.20] sched_ext: Fix ops.dequeue() semantics Andrea Righi
2026-02-01 9:08 ` [PATCH 1/2] " Andrea Righi
@ 2026-02-01 9:08 ` Andrea Righi
1 sibling, 0 replies; 41+ messages in thread
From: Andrea Righi @ 2026-02-01 9:08 UTC (permalink / raw)
To: Tejun Heo, David Vernet, Changwoo Min
Cc: Kuba Piecuch, Emil Tsalapatis, Christian Loehle, Daniel Hodges,
sched-ext, linux-kernel
Add a new kselftest to validate the following scenarios:
- scenario 0 (Local DSQ): tasks don't increment enqueue counters since
they never enter BPF scheduler custody (no tracking or validation,
expects 0 enqueues and 0 dequeues),
- scenario 1 (User DSQ): full enqueue/dequeue lifecycle tracking with
state machine validation (expects 1:1 enqueue/dequeue pairing).
The test validates that:
- local DSQ dispatch don't trigger ops.dequeue(),
- non-local DSQ dispatch has exact 1:1 ops.enqueue/dequeue() pairing,
- dispatch dequeues have no flags (normal workflow),
- property change dequeues have %SCX_DEQ_SCHED_CHANGE flag,
- no duplicate enqueues or invalid state transitions are happening.
This validates that the new ops.dequeue() semantics work correctly for
all task lifecycle scenarios.
Cc: Tejun Heo <tj@kernel.org>
Cc: Emil Tsalapatis <emil@etsalapatis.com>
Cc: Kuba Piecuch <jpiecuch@google.com>
Signed-off-by: Andrea Righi <arighi@nvidia.com>
---
tools/testing/selftests/sched_ext/Makefile | 1 +
.../testing/selftests/sched_ext/dequeue.bpf.c | 238 ++++++++++++++++++
tools/testing/selftests/sched_ext/dequeue.c | 201 +++++++++++++++
3 files changed, 440 insertions(+)
create mode 100644 tools/testing/selftests/sched_ext/dequeue.bpf.c
create mode 100644 tools/testing/selftests/sched_ext/dequeue.c
diff --git a/tools/testing/selftests/sched_ext/Makefile b/tools/testing/selftests/sched_ext/Makefile
index 5fe45f9c5f8fd..764e91edabf93 100644
--- a/tools/testing/selftests/sched_ext/Makefile
+++ b/tools/testing/selftests/sched_ext/Makefile
@@ -161,6 +161,7 @@ all_test_bpfprogs := $(foreach prog,$(wildcard *.bpf.c),$(INCLUDE_DIR)/$(patsubs
auto-test-targets := \
create_dsq \
+ dequeue \
enq_last_no_enq_fails \
ddsp_bogus_dsq_fail \
ddsp_vtimelocal_fail \
diff --git a/tools/testing/selftests/sched_ext/dequeue.bpf.c b/tools/testing/selftests/sched_ext/dequeue.bpf.c
new file mode 100644
index 0000000000000..092956becb554
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/dequeue.bpf.c
@@ -0,0 +1,238 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * A scheduler that validates ops.dequeue() is called correctly:
+ * - for tasks on BPF data structures (not yet dispatched)
+ * - for tasks dispatched to non-local DSQs (global or user DSQs)
+ * - That every ops.enqueue() is followed by ops.dequeue() except for tasks
+ * directly dispatched to local DSQs, which bypass the BPF scheduler
+ * entirely
+ *
+ * Copyright (c) 2026 NVIDIA Corporation.
+ */
+
+#include <scx/common.bpf.h>
+
+#define SHARED_DSQ 0
+
+char _license[] SEC("license") = "GPL";
+
+UEI_DEFINE(uei);
+
+/*
+ * Counters to track the lifecycle of tasks:
+ * - enqueue_cnt: Number of times ops.enqueue() was called
+ * - dequeue_cnt: Number of times ops.dequeue() was called (any type)
+ * - dispatch_dequeue_cnt: Number of regular dispatch dequeues (no flag)
+ * - change_dequeue_cnt: Number of property change dequeues (%SCX_DEQ_SCHED_CHANGE)
+ */
+u64 enqueue_cnt, dequeue_cnt, dispatch_dequeue_cnt, change_dequeue_cnt;
+
+/*
+ * Test scenarios:
+ * - 0: Dispatch to local DSQ (bypasses BPF scheduler, no dequeue callbacks)
+ * - 1: Dispatch to shared DSQ (enters BPF scheduler, dequeue callbacks expected)
+ */
+u32 test_scenario;
+
+/*
+ * Per-task state to track lifecycle and validate workflow semantics.
+ * State transitions:
+ * NONE -> ENQUEUED (on enqueue)
+ * ENQUEUED -> DISPATCHED (on dispatch dequeue)
+ * DISPATCHED -> NONE (on property change dequeue or re-enqueue)
+ * ENQUEUED -> NONE (on property change dequeue before dispatch)
+ */
+enum task_state {
+ TASK_NONE = 0, /* Task is outside scheduler control */
+ TASK_ENQUEUED, /* ops.enqueue() called, waiting for dequeue */
+ TASK_DISPATCHED, /* Dispatch dequeue received, can get property change or re-enqueue */
+};
+
+struct task_ctx {
+ enum task_state state; /* Current state in the workflow */
+ u64 enqueue_seq; /* Sequence number for debugging */
+};
+
+struct {
+ __uint(type, BPF_MAP_TYPE_TASK_STORAGE);
+ __uint(map_flags, BPF_F_NO_PREALLOC);
+ __type(key, int);
+ __type(value, struct task_ctx);
+} task_ctx_stor SEC(".maps");
+
+static struct task_ctx *try_lookup_task_ctx(struct task_struct *p)
+{
+ return bpf_task_storage_get(&task_ctx_stor, p, 0, 0);
+}
+
+s32 BPF_STRUCT_OPS(dequeue_select_cpu, struct task_struct *p,
+ s32 prev_cpu, u64 wake_flags)
+{
+ /* Always bounce to ops.enqueue() */
+ return prev_cpu;
+}
+
+void BPF_STRUCT_OPS(dequeue_enqueue, struct task_struct *p, u64 enq_flags)
+{
+ struct task_ctx *tctx;
+
+ tctx = try_lookup_task_ctx(p);
+ if (!tctx)
+ return;
+
+ switch (test_scenario) {
+ case 0:
+ /*
+ * Scenario 0: Direct dispatch to the local DSQ.
+ *
+ * Task bypasses BPF scheduler entirely - no enqueue
+ * tracking, no dequeue callbacks. Don't increment counters
+ * or validate state since the task never enters BPF
+ * scheduler management.
+ */
+ scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, enq_flags);
+ break;
+
+ case 1:
+ /*
+ * Scenario 1: Dispatch to shared DSQ.
+ *
+ * Task enters BPF scheduler management - track
+ * enqueue/dequeue lifecycle and validate state
+ * transitions.
+ */
+ __sync_fetch_and_add(&enqueue_cnt, 1);
+
+ /*
+ * Validate state transition: enqueue is only valid from
+ * NONE or DISPATCHED states. Getting enqueue while in
+ * ENQUEUED state indicates a missing dequeue.
+ */
+ if (tctx->state == TASK_ENQUEUED)
+ scx_bpf_error("%d (%s): enqueue while in ENQUEUED state seq=%llu",
+ p->pid, p->comm, tctx->enqueue_seq);
+
+ /* Transition to ENQUEUED state */
+ tctx->state = TASK_ENQUEUED;
+ tctx->enqueue_seq++;
+
+ scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, enq_flags);
+ break;
+ }
+}
+
+void BPF_STRUCT_OPS(dequeue_dequeue, struct task_struct *p, u64 deq_flags)
+{
+ struct task_ctx *tctx;
+
+ __sync_fetch_and_add(&dequeue_cnt, 1);
+
+ tctx = try_lookup_task_ctx(p);
+ if (!tctx)
+ return;
+
+ /*
+ * For scenario 0 (local DSQ), ops.dequeue() should never be called
+ * because tasks bypass the BPF scheduler entirely. If we get here,
+ * it's a kernel bug. We don't track enqueues for scenario 0, so
+ * tctx->enqueue_seq will be 0.
+ */
+ if (test_scenario == 0) {
+ scx_bpf_error("%d (%s): dequeue called for local DSQ scenario - kernel bug!",
+ p->pid, p->comm);
+ return;
+ }
+
+ /*
+ * Validate state: dequeue should only happen from ENQUEUED or
+ * DISPATCHED states. Getting dequeue from NONE indicates a bug.
+ */
+ if (tctx->state == TASK_NONE) {
+ scx_bpf_error("%d (%s): dequeue from NONE state seq=%llu",
+ p->pid, p->comm, tctx->enqueue_seq);
+ return;
+ }
+
+ if (deq_flags & SCX_DEQ_SCHED_CHANGE) {
+ /*
+ * Property change interrupting the workflow. Valid from
+ * both ENQUEUED and DISPATCHED states. Transitions task
+ * back to NONE state.
+ */
+ __sync_fetch_and_add(&change_dequeue_cnt, 1);
+
+ /* Validate state transition */
+ if (tctx->state != TASK_ENQUEUED && tctx->state != TASK_DISPATCHED)
+ scx_bpf_error("%d (%s): invalid property change dequeue state=%d seq=%llu",
+ p->pid, p->comm, tctx->state, tctx->enqueue_seq);
+
+ /* Transition back to NONE: task outside scheduler control */
+ tctx->state = TASK_NONE;
+ } else {
+ /*
+ * Regular dispatch dequeue: normal workflow step. Valid
+ * only from ENQUEUED state (after enqueue, before dispatch
+ * dequeue). Transitions to DISPATCHED state.
+ */
+ __sync_fetch_and_add(&dispatch_dequeue_cnt, 1);
+
+ /* Dispatch dequeue should not have %SCX_DEQ_SCHED_CHANGE flag */
+ if (deq_flags & SCX_DEQ_SCHED_CHANGE)
+ scx_bpf_error("%d (%s): SCX_DEQ_SCHED_CHANGE in dispatch dequeue seq=%llu",
+ p->pid, p->comm, tctx->enqueue_seq);
+
+ /* Must be in ENQUEUED state */
+ if (tctx->state != TASK_ENQUEUED)
+ scx_bpf_error("%d (%s): dispatch dequeue from state %d seq=%llu",
+ p->pid, p->comm, tctx->state, tctx->enqueue_seq);
+
+ /* Transition to DISPATCHED: normal cycle completed dispatch */
+ tctx->state = TASK_DISPATCHED;
+ }
+}
+
+void BPF_STRUCT_OPS(dequeue_dispatch, s32 cpu, struct task_struct *prev)
+{
+ scx_bpf_dsq_move_to_local(SHARED_DSQ);
+}
+
+s32 BPF_STRUCT_OPS(dequeue_init_task, struct task_struct *p,
+ struct scx_init_task_args *args)
+{
+ struct task_ctx *tctx;
+
+ tctx = bpf_task_storage_get(&task_ctx_stor, p, 0,
+ BPF_LOCAL_STORAGE_GET_F_CREATE);
+ if (!tctx)
+ return -ENOMEM;
+
+ return 0;
+}
+
+s32 BPF_STRUCT_OPS_SLEEPABLE(dequeue_init)
+{
+ s32 ret;
+
+ ret = scx_bpf_create_dsq(SHARED_DSQ, -1);
+ if (ret)
+ return ret;
+
+ return 0;
+}
+
+void BPF_STRUCT_OPS(dequeue_exit, struct scx_exit_info *ei)
+{
+ UEI_RECORD(uei, ei);
+}
+
+SEC(".struct_ops.link")
+struct sched_ext_ops dequeue_ops = {
+ .select_cpu = (void *)dequeue_select_cpu,
+ .enqueue = (void *)dequeue_enqueue,
+ .dequeue = (void *)dequeue_dequeue,
+ .dispatch = (void *)dequeue_dispatch,
+ .init_task = (void *)dequeue_init_task,
+ .init = (void *)dequeue_init,
+ .exit = (void *)dequeue_exit,
+ .name = "dequeue_test",
+};
diff --git a/tools/testing/selftests/sched_ext/dequeue.c b/tools/testing/selftests/sched_ext/dequeue.c
new file mode 100644
index 0000000000000..805970834612b
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/dequeue.c
@@ -0,0 +1,201 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Copyright (c) 2025 NVIDIA Corporation.
+ */
+#define _GNU_SOURCE
+#include <stdio.h>
+#include <unistd.h>
+#include <signal.h>
+#include <bpf/bpf.h>
+#include <scx/common.h>
+#include <sys/wait.h>
+#include <sched.h>
+#include <pthread.h>
+#include "scx_test.h"
+#include "dequeue.bpf.skel.h"
+
+#define NUM_WORKERS 8
+
+/*
+ * Worker function that creates enqueue/dequeue events. It alternates
+ * between CPU work, sleeping, and affinity changes to trigger dequeues.
+ */
+static void worker_fn(int id)
+{
+ cpu_set_t cpuset;
+ int i;
+ volatile int sum = 0;
+
+ for (i = 0; i < 1000; i++) {
+ int j;
+
+ /* Do some work to trigger scheduling events */
+ for (j = 0; j < 10000; j++)
+ sum += j;
+
+ /* Change affinity to trigger dequeue */
+ if (i % 10 == 0) {
+ CPU_ZERO(&cpuset);
+ /* Rotate through the first 4 CPUs */
+ CPU_SET(i % 4, &cpuset);
+ sched_setaffinity(0, sizeof(cpuset), &cpuset);
+ }
+
+ /* Do additional work */
+ for (j = 0; j < 10000; j++)
+ sum += j;
+
+ /* Sleep to trigger dequeue */
+ usleep(1000 + (id * 100));
+ }
+
+ exit(0);
+}
+
+static enum scx_test_status run_scenario(struct dequeue *skel, u32 scenario,
+ const char *scenario_name)
+{
+ struct bpf_link *link;
+ pid_t pids[NUM_WORKERS];
+ int i, status;
+ u64 enq_start, deq_start, dispatch_deq_start, change_deq_start;
+ u64 enq_delta, deq_delta, dispatch_deq_delta, change_deq_delta;
+
+ /* Set the test scenario */
+ skel->bss->test_scenario = scenario;
+
+ /* Record starting counts */
+ enq_start = skel->bss->enqueue_cnt;
+ deq_start = skel->bss->dequeue_cnt;
+ dispatch_deq_start = skel->bss->dispatch_dequeue_cnt;
+ change_deq_start = skel->bss->change_dequeue_cnt;
+
+ link = bpf_map__attach_struct_ops(skel->maps.dequeue_ops);
+ SCX_FAIL_IF(!link, "Failed to attach struct_ops for scenario %s", scenario_name);
+
+ /* Fork worker processes to generate enqueue/dequeue events */
+ for (i = 0; i < NUM_WORKERS; i++) {
+ pids[i] = fork();
+ SCX_FAIL_IF(pids[i] < 0, "Failed to fork worker %d", i);
+
+ if (pids[i] == 0) {
+ worker_fn(i);
+ /* Should not reach here */
+ exit(1);
+ }
+ }
+
+ /* Wait for all workers to complete */
+ for (i = 0; i < NUM_WORKERS; i++) {
+ SCX_FAIL_IF(waitpid(pids[i], &status, 0) != pids[i],
+ "Failed to wait for worker %d", i);
+ SCX_FAIL_IF(status != 0, "Worker %d exited with status %d", i, status);
+ }
+
+ bpf_link__destroy(link);
+
+ SCX_EQ(skel->data->uei.kind, EXIT_KIND(SCX_EXIT_UNREG));
+
+ /* Calculate deltas */
+ enq_delta = skel->bss->enqueue_cnt - enq_start;
+ deq_delta = skel->bss->dequeue_cnt - deq_start;
+ dispatch_deq_delta = skel->bss->dispatch_dequeue_cnt - dispatch_deq_start;
+ change_deq_delta = skel->bss->change_dequeue_cnt - change_deq_start;
+
+ printf("%s:\n", scenario_name);
+ printf(" enqueues: %lu\n", (unsigned long)enq_delta);
+ printf(" dequeues: %lu (dispatch: %lu, property_change: %lu)\n",
+ (unsigned long)deq_delta,
+ (unsigned long)dispatch_deq_delta,
+ (unsigned long)change_deq_delta);
+
+ /*
+ * Validate enqueue/dequeue lifecycle tracking.
+ *
+ * For scenario 0 (Local DSQ), both enqueues and dequeues should be
+ * 0 because tasks bypass the BPF scheduler entirely: they never
+ * enter BPF scheduler's custody. For scenario 1 (ser DSQ) , we
+ * expect both enqueues and dequeues.
+ *
+ * The BPF code does strict state machine validation with
+ * scx_bpf_error() to ensure the workflow semantics are correct. If
+ * we reach here without errors, the semantics are validated
+ * correctly.
+ */
+ if (scenario == 0) {
+ /* Local DSQ: tasks bypass BPF scheduler completely */
+ SCX_EQ(enq_delta, 0);
+ SCX_EQ(deq_delta, 0);
+ SCX_EQ(dispatch_deq_delta, 0);
+ SCX_EQ(change_deq_delta, 0);
+ } else {
+ /* Non-local DSQ: tasks enter BPF scheduler's custody */
+ SCX_GT(enq_delta, 0);
+ SCX_GT(deq_delta, 0);
+ /* Validate 1:1 enqueue/dequeue pairing */
+ SCX_EQ(enq_delta, deq_delta);
+ }
+
+ return SCX_TEST_PASS;
+}
+
+static enum scx_test_status setup(void **ctx)
+{
+ struct dequeue *skel;
+
+ skel = dequeue__open();
+ SCX_FAIL_IF(!skel, "Failed to open skel");
+ SCX_ENUM_INIT(skel);
+ SCX_FAIL_IF(dequeue__load(skel), "Failed to load skel");
+
+ *ctx = skel;
+
+ return SCX_TEST_PASS;
+}
+
+static enum scx_test_status run(void *ctx)
+{
+ struct dequeue *skel = ctx;
+ enum scx_test_status status;
+
+ status = run_scenario(skel, 0, "Local DSQ (direct dispatch)");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 1, "User DSQ");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ printf("\n=== Summary ===\n");
+ printf("Total enqueues: %lu\n", (unsigned long)skel->bss->enqueue_cnt);
+ printf("Total dequeues: %lu\n", (unsigned long)skel->bss->dequeue_cnt);
+ printf(" Dispatch dequeues: %lu (no flag, normal workflow)\n",
+ (unsigned long)skel->bss->dispatch_dequeue_cnt);
+ printf(" Property change dequeues: %lu (SCX_DEQ_SCHED_CHANGE flag)\n",
+ (unsigned long)skel->bss->change_dequeue_cnt);
+ printf("\nAll scenarios passed - no state machine violations detected\n");
+ printf("-> Validated: Local DSQ dispatch bypasses BPF scheduler (no dequeue callbacks)\n");
+ printf("-> Validated: Non-local DSQ dispatch triggers dequeue callbacks\n");
+ printf("-> Validated: Dispatch dequeues have no flags (normal workflow)\n");
+ printf("-> Validated: Async dequeues have SCX_DEQ_SCHED_CHANGE flag (interruptions)\n");
+ printf("-> Validated: No duplicate enqueues or invalid state transitions\n");
+
+ return SCX_TEST_PASS;
+}
+
+static void cleanup(void *ctx)
+{
+ struct dequeue *skel = ctx;
+
+ dequeue__destroy(skel);
+}
+
+struct scx_test dequeue_test = {
+ .name = "dequeue",
+ .description = "Verify ops.dequeue() semantics for local and non-local DSQ dispatch",
+ .setup = setup,
+ .run = run,
+ .cleanup = cleanup,
+};
+
+REGISTER_SCX_TEST(&dequeue_test)
--
2.52.0
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 1/2] sched_ext: Fix ops.dequeue() semantics
2026-02-01 9:08 ` [PATCH 1/2] " Andrea Righi
@ 2026-02-01 22:47 ` Christian Loehle
2026-02-02 7:45 ` Andrea Righi
2026-02-02 11:56 ` Kuba Piecuch
1 sibling, 1 reply; 41+ messages in thread
From: Christian Loehle @ 2026-02-01 22:47 UTC (permalink / raw)
To: Andrea Righi, Tejun Heo, David Vernet, Changwoo Min
Cc: Kuba Piecuch, Emil Tsalapatis, Daniel Hodges, sched-ext, linux-kernel
On 2/1/26 09:08, Andrea Righi wrote:
> Currently, ops.dequeue() is only invoked when the sched_ext core knows
> that a task resides in BPF-managed data structures, which causes it to
> miss scheduling property change events. In addition, ops.dequeue()
> callbacks are completely skipped when tasks are dispatched to non-local
> DSQs from ops.select_cpu(). As a result, BPF schedulers cannot reliably
> track task state.
>
> Fix this by guaranteeing that each task entering the BPF scheduler's
> custody triggers exactly one ops.dequeue() call when it leaves that
> custody, whether the exit is due to a dispatch (regular or via a core
> scheduling pick) or to a scheduling property change (e.g.
> sched_setaffinity(), sched_setscheduler(), set_user_nice(), NUMA
> balancing, etc.).
>
> BPF scheduler custody concept: a task is considered to be in "BPF
> scheduler's custody" when it has been queued in BPF-managed data
> structures and the BPF scheduler is responsible for its lifecycle.
> Custody ends when the task is dispatched to a local DSQ, selected by
> core scheduling, or removed due to a property change.
>
> Tasks directly dispatched to local DSQs (via %SCX_DSQ_LOCAL or
> %SCX_DSQ_LOCAL_ON) bypass the BPF scheduler entirely and are not in its
> custody. As a result, ops.dequeue() is not invoked for these tasks.
>
> To identify dequeues triggered by scheduling property changes, introduce
> the new ops.dequeue() flag %SCX_DEQ_SCHED_CHANGE: when this flag is set,
> the dequeue was caused by a scheduling property change.
>
> New ops.dequeue() semantics:
> - ops.dequeue() is invoked exactly once when the task leaves the BPF
> scheduler's custody, in one of the following cases:
> a) regular dispatch: task was dispatched to a non-local DSQ (global
> or user DSQ), ops.dequeue() called without any special flags set
> b) core scheduling dispatch: core-sched picks task before dispatch,
> dequeue called with %SCX_DEQ_CORE_SCHED_EXEC flag set
> c) property change: task properties modified before dispatch,
> dequeue called with %SCX_DEQ_SCHED_CHANGE flag set
>
> This allows BPF schedulers to:
> - reliably track task ownership and lifecycle,
> - maintain accurate accounting of managed tasks,
> - update internal state when tasks change properties.
>
So I have finally gotten around updating scx_storm to the new semantics,
see:
https://github.com/cloehle/scx/tree/cloehle/scx-storm-qmap-insert-local-dequeue-semantics
I don't think the new ops.dequeue() are enough to make inserts to local-on
from anywhere safe, because it's still racing with dequeue from another CPU?
Furthermore I can reproduce the following with this patch applied quite easily
with something like
hackbench -l 1000 & timeout 10 ./build/scheds/c/scx_storm
[ 44.356878] sched_ext: BPF scheduler "simple" enabled
[ 59.315370] sched_ext: BPF scheduler "simple" disabled (unregistered from user space)
[ 85.366747] sched_ext: BPF scheduler "storm" enabled
[ 85.371324] ------------[ cut here ]------------
[ 85.373370] WARNING: kernel/sched/sched.h:1571 at update_locked_rq+0x64/0x6c, CPU#5: gmain/1111
[ 85.373392] Modules linked in: qrtr
[ 85.380088] ------------[ cut here ]------------
[ 85.380719] ------------[ cut here ]------------
[ 85.380722] WARNING: kernel/sched/sched.h:1571 at update_locked_rq+0x64/0x6c, CPU#10: kworker/u48:1/82
[ 85.380728] Modules linked in: qrtr 8021q garp mrp stp llc binfmt_misc sm3_ce r8169 cdns3_pci_wrap nf_tables nfnetlink fuse dm_mod ipv6
[ 85.380745] CPU: 10 UID: 0 PID: 82 Comm: kworker/u48:1 Tainted: G S 6.19.0-rc7-cix-build+ #256 PREEMPT
[ 85.380749] Tainted: [S]=CPU_OUT_OF_SPEC
[ 85.380750] Hardware name: Radxa Computer (Shenzhen) Co., Ltd. Radxa Orion O6/Radxa Orion O6, BIOS 1.1.0-1 2025-12-25T02:55:53+00:00
[ 85.380754] Workqueue: 0x0 (events_unbound)
[ 85.380760] Sched_ext: storm (enabled+all), task: runnable_at=+0ms
[ 85.380762] pstate: 634000c9 (nZCv daIF +PAN -UAO +TCO +DIT -SSBS BTYPE=--)
[ 85.380764] pc : update_locked_rq+0x64/0x6c
[ 85.380767] lr : update_locked_rq+0x60/0x6c
[ 85.380769] sp : ffff8000803a3bd0
[ 85.380770] x29: ffff8000803a3bd0 x28: fffffdffbf622dc0 x27: ffff0000911e5040
[ 85.380773] x26: 0000000000000000 x25: ffffd204426cad80 x24: ffffd20442ba5bb8
[ 85.380776] x23: c00000000000000a x22: 0000000000000000 x21: ffffd20442ba4830
[ 85.380778] x20: ffff00009af0b000 x19: ffff0001fef2ed80 x18: 0000000000000000
[ 85.380781] x17: 0000000000000000 x16: 0000000000000000 x15: 0000aaaadd996940
[ 85.380783] x14: 0000000000000000 x13: 00000000000a0000 x12: 0000000000000000
[ 85.380786] x11: 0000000000000040 x10: ffffd204402e7ca0 x9 : ffffd2044324b000
[ 85.380788] x8 : ffff0000810e0000 x7 : 0000d00202cc2dc0 x6 : 0000000000000050
[ 85.380790] x5 : ffffd204426b5648 x4 : fffffdffbf622dc0 x3 : ffff0000810e0000
[ 85.380793] x2 : 0000000000000002 x1 : ffff2dfdbc960000 x0 : 0000000000000000
[ 85.380795] Call trace:
[ 85.380796] update_locked_rq+0x64/0x6c (P)
[ 85.380799] flush_dispatch_buf+0x2a8/0x2dc
[ 85.380801] pick_task_scx+0x2b0/0x6d4
[ 85.380804] __schedule+0x62c/0x1060
[ 85.380811] schedule+0x48/0x15c
[ 85.380813] worker_thread+0xdc/0x358
[ 85.380824] kthread+0x134/0x1fc
[ 85.380831] ret_from_fork+0x10/0x20
[ 85.380839] irq event stamp: 34386
[ 85.380840] hardirqs last enabled at (34385): [<ffffd20441511408>] _raw_spin_unlock_irq+0x30/0x6c
[ 85.380850] hardirqs last disabled at (34386): [<ffffd20441507100>] __schedule+0x510/0x1060
[ 85.380852] softirqs last enabled at (34014): [<ffffd204400c7280>] handle_softirqs+0x514/0x52c
[ 85.380865] softirqs last disabled at (34007): [<ffffd204400105c4>] __do_softirq+0x14/0x20
[ 85.380867] ---[ end trace 0000000000000000 ]---
[ 85.380969] ------------[ cut here ]------------
[ 85.380970] WARNING: kernel/sched/sched.h:1571 at update_locked_rq+0x64/0x6c, CPU#10: kworker/u48:1/82
[ 85.380974] Modules linked in: qrtr 8021q garp mrp stp llc binfmt_misc sm3_ce r8169 cdns3_pci_wrap nf_tables nfnetlink fuse dm_mod ipv6
[ 85.380984] CPU: 10 UID: 0 PID: 82 Comm: kworker/u48:1 Tainted: G S W 6.19.0-rc7-cix-build+ #256 PREEMPT
[ 85.380987] Tainted: [S]=CPU_OUT_OF_SPEC, [W]=WARN
[ 85.380988] Hardware name: Radxa Computer (Shenzhen) Co., Ltd. Radxa Orion O6/Radxa Orion O6, BIOS 1.1.0-1 2025-12-25T02:55:53+00:00
[ 85.380990] Workqueue: 0x0 (events_unbound)
[ 85.380993] Sched_ext: storm (enabled+all), task: runnable_at=+0ms
[ 85.380994] pstate: 634000c9 (nZCv daIF +PAN -UAO +TCO +DIT -SSBS BTYPE=--)
[ 85.380996] pc : update_locked_rq+0x64/0x6c
[ 85.380997] lr : update_locked_rq+0x60/0x6c
[ 85.380999] sp : ffff8000803a3bd0
[ 85.381000] x29: ffff8000803a3bd0 x28: fffffdffbf622dc0 x27: ffff00009151b580
[ 85.381002] x26: 0000000000000000 x25: ffffd204426cad80 x24: ffffd20442ba5bb8
[ 85.381005] x23: c00000000000000a x22: 0000000000000000 x21: ffffd20442ba4830
[ 85.381007] x20: ffff00009af0b000 x19: ffff0001fef52d80 x18: 0000000000000000
[ 85.381009] x17: 0000000000000000 x16: 0000000000000000 x15: 0000aaaae6917960
[ 85.381012] x14: 0000000000000000 x13: 00000000000a0000 x12: 0000000000000000
[ 85.381014] x11: 0000000000000040 x10: ffffd204402e7ca0 x9 : ffffd2044324b000
[ 85.381016] x8 : ffff0000810e0000 x7 : 0000d00202cc2dc0 x6 : 0000000000000050
[ 85.381019] x5 : ffffd204426b5648 x4 : fffffdffbf622dc0 x3 : ffff0000810e0000
[ 85.381021] x2 : 0000000000000002 x1 : ffff2dfdbc960000 x0 : 0000000000000000
[ 85.381023] Call trace:
[ 85.381024] update_locked_rq+0x64/0x6c (P)
[ 85.381026] flush_dispatch_buf+0x2a8/0x2dc
[ 85.381028] pick_task_scx+0x2b0/0x6d4
[ 85.381030] __schedule+0x62c/0x1060
[ 85.381032] schedule+0x48/0x15c
[ 85.381034] worker_thread+0xdc/0x358
[ 85.381036] kthread+0x134/0x1fc
[ 85.381039] ret_from_fork+0x10/0x20
[ 85.381041] irq event stamp: 34394
[ 85.381042] hardirqs last enabled at (34393): [<ffffd20441511408>] _raw_spin_unlock_irq+0x30/0x6c
[ 85.381044] hardirqs last disabled at (34394): [<ffffd20441507100>] __schedule+0x510/0x1060
[ 85.381046] softirqs last enabled at (34014): [<ffffd204400c7280>] handle_softirqs+0x514/0x52c
[ 85.381049] softirqs last disabled at (34007): [<ffffd204400105c4>] __do_softirq+0x14/0x20
[ 85.381050] ---[ end trace 0000000000000000 ]---
[ 85.381199] ------------[ cut here ]------------
[ 85.381201] WARNING: kernel/sched/sched.h:1571 at update_locked_rq+0x64/0x6c, CPU#10: kworker/u48:1/82
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 1/2] sched_ext: Fix ops.dequeue() semantics
2026-02-01 22:47 ` Christian Loehle
@ 2026-02-02 7:45 ` Andrea Righi
2026-02-02 9:26 ` Andrea Righi
` (2 more replies)
0 siblings, 3 replies; 41+ messages in thread
From: Andrea Righi @ 2026-02-02 7:45 UTC (permalink / raw)
To: Christian Loehle
Cc: Tejun Heo, David Vernet, Changwoo Min, Kuba Piecuch,
Emil Tsalapatis, Daniel Hodges, sched-ext, linux-kernel
Hi Christian,
On Sun, Feb 01, 2026 at 10:47:22PM +0000, Christian Loehle wrote:
> On 2/1/26 09:08, Andrea Righi wrote:
> > Currently, ops.dequeue() is only invoked when the sched_ext core knows
> > that a task resides in BPF-managed data structures, which causes it to
> > miss scheduling property change events. In addition, ops.dequeue()
> > callbacks are completely skipped when tasks are dispatched to non-local
> > DSQs from ops.select_cpu(). As a result, BPF schedulers cannot reliably
> > track task state.
> >
> > Fix this by guaranteeing that each task entering the BPF scheduler's
> > custody triggers exactly one ops.dequeue() call when it leaves that
> > custody, whether the exit is due to a dispatch (regular or via a core
> > scheduling pick) or to a scheduling property change (e.g.
> > sched_setaffinity(), sched_setscheduler(), set_user_nice(), NUMA
> > balancing, etc.).
> >
> > BPF scheduler custody concept: a task is considered to be in "BPF
> > scheduler's custody" when it has been queued in BPF-managed data
> > structures and the BPF scheduler is responsible for its lifecycle.
> > Custody ends when the task is dispatched to a local DSQ, selected by
> > core scheduling, or removed due to a property change.
> >
> > Tasks directly dispatched to local DSQs (via %SCX_DSQ_LOCAL or
> > %SCX_DSQ_LOCAL_ON) bypass the BPF scheduler entirely and are not in its
> > custody. As a result, ops.dequeue() is not invoked for these tasks.
> >
> > To identify dequeues triggered by scheduling property changes, introduce
> > the new ops.dequeue() flag %SCX_DEQ_SCHED_CHANGE: when this flag is set,
> > the dequeue was caused by a scheduling property change.
> >
> > New ops.dequeue() semantics:
> > - ops.dequeue() is invoked exactly once when the task leaves the BPF
> > scheduler's custody, in one of the following cases:
> > a) regular dispatch: task was dispatched to a non-local DSQ (global
> > or user DSQ), ops.dequeue() called without any special flags set
> > b) core scheduling dispatch: core-sched picks task before dispatch,
> > dequeue called with %SCX_DEQ_CORE_SCHED_EXEC flag set
> > c) property change: task properties modified before dispatch,
> > dequeue called with %SCX_DEQ_SCHED_CHANGE flag set
> >
> > This allows BPF schedulers to:
> > - reliably track task ownership and lifecycle,
> > - maintain accurate accounting of managed tasks,
> > - update internal state when tasks change properties.
> >
>
> So I have finally gotten around updating scx_storm to the new semantics,
> see:
> https://github.com/cloehle/scx/tree/cloehle/scx-storm-qmap-insert-local-dequeue-semantics
>
> I don't think the new ops.dequeue() are enough to make inserts to local-on
> from anywhere safe, because it's still racing with dequeue from another CPU?
Yeah, with this patch set BPF schedulers get proper ops.dequeue()
callbacks, but we're not fixing the usage of SCX_DSQ_LOCAL_ON from
ops.dispatch().
When task properties change between scx_bpf_dsq_insert() and the actual
dispatch, task_can_run_on_remote_rq() can still trigger a fatal
scx_error().
The ops.dequeue(SCX_DEQ_SCHED_CHANGE) notifications happens after the
property change, so it can't prevent already-queued dispatches from
failing. The race window is between ops.dispatch() returning and
dispatch_to_local_dsq() executing.
We can address this in a separate patch set. One thing at a time. :)
>
> Furthermore I can reproduce the following with this patch applied quite easily
> with something like
>
> hackbench -l 1000 & timeout 10 ./build/scheds/c/scx_storm
>
> [ 44.356878] sched_ext: BPF scheduler "simple" enabled
> [ 59.315370] sched_ext: BPF scheduler "simple" disabled (unregistered from user space)
> [ 85.366747] sched_ext: BPF scheduler "storm" enabled
> [ 85.371324] ------------[ cut here ]------------
> [ 85.373370] WARNING: kernel/sched/sched.h:1571 at update_locked_rq+0x64/0x6c, CPU#5: gmain/1111
Ah yes! I think I see it, can you try this on top?
Thanks,
-Andrea
kernel/sched/ext.c | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/kernel/sched/ext.c b/kernel/sched/ext.c
index 6d6f1253039d8..d8fed4a49195d 100644
--- a/kernel/sched/ext.c
+++ b/kernel/sched/ext.c
@@ -2248,7 +2248,7 @@ static void finish_dispatch(struct scx_sched *sch, struct rq *rq,
p->scx.flags |= SCX_TASK_OPS_ENQUEUED;
} else {
if (p->scx.flags & SCX_TASK_OPS_ENQUEUED)
- SCX_CALL_OP_TASK(sch, SCX_KF_REST, dequeue, task_rq(p), p, 0);
+ SCX_CALL_OP_TASK(sch, SCX_KF_REST, dequeue, rq, p, 0);
p->scx.flags &= ~SCX_TASK_OPS_ENQUEUED;
}
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 1/2] sched_ext: Fix ops.dequeue() semantics
2026-02-02 7:45 ` Andrea Righi
@ 2026-02-02 9:26 ` Andrea Righi
2026-02-02 10:02 ` Christian Loehle
2026-02-02 10:09 ` Christian Loehle
2026-02-02 13:59 ` Kuba Piecuch
2 siblings, 1 reply; 41+ messages in thread
From: Andrea Righi @ 2026-02-02 9:26 UTC (permalink / raw)
To: Christian Loehle
Cc: Tejun Heo, David Vernet, Changwoo Min, Kuba Piecuch,
Emil Tsalapatis, Daniel Hodges, sched-ext, linux-kernel
On Mon, Feb 02, 2026 at 08:45:18AM +0100, Andrea Righi wrote:
...
> > So I have finally gotten around updating scx_storm to the new semantics,
> > see:
> > https://github.com/cloehle/scx/tree/cloehle/scx-storm-qmap-insert-local-dequeue-semantics
> >
> > I don't think the new ops.dequeue() are enough to make inserts to local-on
> > from anywhere safe, because it's still racing with dequeue from another CPU?
>
> Yeah, with this patch set BPF schedulers get proper ops.dequeue()
> callbacks, but we're not fixing the usage of SCX_DSQ_LOCAL_ON from
> ops.dispatch().
>
> When task properties change between scx_bpf_dsq_insert() and the actual
> dispatch, task_can_run_on_remote_rq() can still trigger a fatal
> scx_error().
>
> The ops.dequeue(SCX_DEQ_SCHED_CHANGE) notifications happens after the
> property change, so it can't prevent already-queued dispatches from
> failing. The race window is between ops.dispatch() returning and
> dispatch_to_local_dsq() executing.
>
> We can address this in a separate patch set. One thing at a time. :)
Thinking more on this, the problem is that we're passing enforce=true to
task_can_run_on_remote_rq(), triggering a critical failure - scx_error().
There's a logic in task_can_run_on_remote_rq() to fallback to the global
DSQ, that doesn't happen if we pass enforce=true, due to scx_error().
However, instead of the global DSQ fallback, I was wondering if it'd be
better to simply re-enqueue the task - setting SCX_ENQ_REENQ - if the
target local DSQ isn't valid anymore when the dispatch is finalized.
In this way using SCX_DSQ_LOCAL_ON | cpu from ops.dispatch() would simply
trigger a re-enqueue when "cpu" isn't valid anymore (due to concurrent
affinity / migration disabled changes) and the BPF scheduler can handle
that in another ops.enqueue().
What do you think?
Thanks,
-Andrea
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 1/2] sched_ext: Fix ops.dequeue() semantics
2026-02-02 9:26 ` Andrea Righi
@ 2026-02-02 10:02 ` Christian Loehle
2026-02-02 15:32 ` Andrea Righi
0 siblings, 1 reply; 41+ messages in thread
From: Christian Loehle @ 2026-02-02 10:02 UTC (permalink / raw)
To: Andrea Righi
Cc: Tejun Heo, David Vernet, Changwoo Min, Kuba Piecuch,
Emil Tsalapatis, Daniel Hodges, sched-ext, linux-kernel
On 2/2/26 09:26, Andrea Righi wrote:
> On Mon, Feb 02, 2026 at 08:45:18AM +0100, Andrea Righi wrote:
> ...
>>> So I have finally gotten around updating scx_storm to the new semantics,
>>> see:
>>> https://github.com/cloehle/scx/tree/cloehle/scx-storm-qmap-insert-local-dequeue-semantics
>>>
>>> I don't think the new ops.dequeue() are enough to make inserts to local-on
>>> from anywhere safe, because it's still racing with dequeue from another CPU?
>>
>> Yeah, with this patch set BPF schedulers get proper ops.dequeue()
>> callbacks, but we're not fixing the usage of SCX_DSQ_LOCAL_ON from
>> ops.dispatch().
>>
>> When task properties change between scx_bpf_dsq_insert() and the actual
>> dispatch, task_can_run_on_remote_rq() can still trigger a fatal
>> scx_error().
>>
>> The ops.dequeue(SCX_DEQ_SCHED_CHANGE) notifications happens after the
>> property change, so it can't prevent already-queued dispatches from
>> failing. The race window is between ops.dispatch() returning and
>> dispatch_to_local_dsq() executing.
>>
>> We can address this in a separate patch set. One thing at a time. :)
>
> Thinking more on this, the problem is that we're passing enforce=true to
> task_can_run_on_remote_rq(), triggering a critical failure - scx_error().
> There's a logic in task_can_run_on_remote_rq() to fallback to the global
> DSQ, that doesn't happen if we pass enforce=true, due to scx_error().
>
> However, instead of the global DSQ fallback, I was wondering if it'd be
> better to simply re-enqueue the task - setting SCX_ENQ_REENQ - if the
> target local DSQ isn't valid anymore when the dispatch is finalized.
>
> In this way using SCX_DSQ_LOCAL_ON | cpu from ops.dispatch() would simply
> trigger a re-enqueue when "cpu" isn't valid anymore (due to concurrent
> affinity / migration disabled changes) and the BPF scheduler can handle
> that in another ops.enqueue().
>
> What do you think?
I think that's a lot more versatile for the BPF scheduler than using the
global DSQ as fallback in that case, so yeah I'm all for it!
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 1/2] sched_ext: Fix ops.dequeue() semantics
2026-02-02 7:45 ` Andrea Righi
2026-02-02 9:26 ` Andrea Righi
@ 2026-02-02 10:09 ` Christian Loehle
2026-02-02 13:59 ` Kuba Piecuch
2 siblings, 0 replies; 41+ messages in thread
From: Christian Loehle @ 2026-02-02 10:09 UTC (permalink / raw)
To: Andrea Righi
Cc: Tejun Heo, David Vernet, Changwoo Min, Kuba Piecuch,
Emil Tsalapatis, Daniel Hodges, sched-ext, linux-kernel
On 2/2/26 07:45, Andrea Righi wrote:
> Hi Christian,
>
> On Sun, Feb 01, 2026 at 10:47:22PM +0000, Christian Loehle wrote:
>> On 2/1/26 09:08, Andrea Righi wrote:
>>> Currently, ops.dequeue() is only invoked when the sched_ext core knows
>>> that a task resides in BPF-managed data structures, which causes it to
>>> miss scheduling property change events. In addition, ops.dequeue()
>>> callbacks are completely skipped when tasks are dispatched to non-local
>>> DSQs from ops.select_cpu(). As a result, BPF schedulers cannot reliably
>>> track task state.
>>>
>>> Fix this by guaranteeing that each task entering the BPF scheduler's
>>> custody triggers exactly one ops.dequeue() call when it leaves that
>>> custody, whether the exit is due to a dispatch (regular or via a core
>>> scheduling pick) or to a scheduling property change (e.g.
>>> sched_setaffinity(), sched_setscheduler(), set_user_nice(), NUMA
>>> balancing, etc.).
>>>
>>> BPF scheduler custody concept: a task is considered to be in "BPF
>>> scheduler's custody" when it has been queued in BPF-managed data
>>> structures and the BPF scheduler is responsible for its lifecycle.
>>> Custody ends when the task is dispatched to a local DSQ, selected by
>>> core scheduling, or removed due to a property change.
>>>
>>> Tasks directly dispatched to local DSQs (via %SCX_DSQ_LOCAL or
>>> %SCX_DSQ_LOCAL_ON) bypass the BPF scheduler entirely and are not in its
>>> custody. As a result, ops.dequeue() is not invoked for these tasks.
>>>
>>> To identify dequeues triggered by scheduling property changes, introduce
>>> the new ops.dequeue() flag %SCX_DEQ_SCHED_CHANGE: when this flag is set,
>>> the dequeue was caused by a scheduling property change.
>>>
>>> New ops.dequeue() semantics:
>>> - ops.dequeue() is invoked exactly once when the task leaves the BPF
>>> scheduler's custody, in one of the following cases:
>>> a) regular dispatch: task was dispatched to a non-local DSQ (global
>>> or user DSQ), ops.dequeue() called without any special flags set
>>> b) core scheduling dispatch: core-sched picks task before dispatch,
>>> dequeue called with %SCX_DEQ_CORE_SCHED_EXEC flag set
>>> c) property change: task properties modified before dispatch,
>>> dequeue called with %SCX_DEQ_SCHED_CHANGE flag set
>>>
>>> This allows BPF schedulers to:
>>> - reliably track task ownership and lifecycle,
>>> - maintain accurate accounting of managed tasks,
>>> - update internal state when tasks change properties.
>>>
>>
>> So I have finally gotten around updating scx_storm to the new semantics,
>> see:
>> https://github.com/cloehle/scx/tree/cloehle/scx-storm-qmap-insert-local-dequeue-semantics
>>
>> I don't think the new ops.dequeue() are enough to make inserts to local-on
>> from anywhere safe, because it's still racing with dequeue from another CPU?
>
> Yeah, with this patch set BPF schedulers get proper ops.dequeue()
> callbacks, but we're not fixing the usage of SCX_DSQ_LOCAL_ON from
> ops.dispatch().
>
> When task properties change between scx_bpf_dsq_insert() and the actual
> dispatch, task_can_run_on_remote_rq() can still trigger a fatal
> scx_error().
>
> The ops.dequeue(SCX_DEQ_SCHED_CHANGE) notifications happens after the
> property change, so it can't prevent already-queued dispatches from
> failing. The race window is between ops.dispatch() returning and
> dispatch_to_local_dsq() executing.
>
> We can address this in a separate patch set. One thing at a time. :)
>
>>
>> Furthermore I can reproduce the following with this patch applied quite easily
>> with something like
>>
>> hackbench -l 1000 & timeout 10 ./build/scheds/c/scx_storm
>>
>> [ 44.356878] sched_ext: BPF scheduler "simple" enabled
>> [ 59.315370] sched_ext: BPF scheduler "simple" disabled (unregistered from user space)
>> [ 85.366747] sched_ext: BPF scheduler "storm" enabled
>> [ 85.371324] ------------[ cut here ]------------
>> [ 85.373370] WARNING: kernel/sched/sched.h:1571 at update_locked_rq+0x64/0x6c, CPU#5: gmain/1111
>
> Ah yes! I think I see it, can you try this on top?
>
> Thanks,
> -Andrea
>
> kernel/sched/ext.c | 2 +-
> 1 file changed, 1 insertion(+), 1 deletion(-)
>
> diff --git a/kernel/sched/ext.c b/kernel/sched/ext.c
> index 6d6f1253039d8..d8fed4a49195d 100644
> --- a/kernel/sched/ext.c
> +++ b/kernel/sched/ext.c
> @@ -2248,7 +2248,7 @@ static void finish_dispatch(struct scx_sched *sch, struct rq *rq,
> p->scx.flags |= SCX_TASK_OPS_ENQUEUED;
> } else {
> if (p->scx.flags & SCX_TASK_OPS_ENQUEUED)
> - SCX_CALL_OP_TASK(sch, SCX_KF_REST, dequeue, task_rq(p), p, 0);
> + SCX_CALL_OP_TASK(sch, SCX_KF_REST, dequeue, rq, p, 0);
>
> p->scx.flags &= ~SCX_TASK_OPS_ENQUEUED;
> }
Yup, that fixes it!
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 1/2] sched_ext: Fix ops.dequeue() semantics
2026-02-01 9:08 ` [PATCH 1/2] " Andrea Righi
2026-02-01 22:47 ` Christian Loehle
@ 2026-02-02 11:56 ` Kuba Piecuch
2026-02-04 10:11 ` Andrea Righi
1 sibling, 1 reply; 41+ messages in thread
From: Kuba Piecuch @ 2026-02-02 11:56 UTC (permalink / raw)
To: Andrea Righi, Tejun Heo, David Vernet, Changwoo Min
Cc: Kuba Piecuch, Emil Tsalapatis, Christian Loehle, Daniel Hodges,
sched-ext, linux-kernel
Hi Andrea,
Looks good overall, but we need to settle on the global DSQ semantics, plus
some edge cases that need clearing up.
On Sun Feb 1, 2026 at 9:08 AM UTC, Andrea Righi wrote:
> diff --git a/Documentation/scheduler/sched-ext.rst b/Documentation/scheduler/sched-ext.rst
> index 404fe6126a769..6d9e82e6ca9d4 100644
> --- a/Documentation/scheduler/sched-ext.rst
> +++ b/Documentation/scheduler/sched-ext.rst
> @@ -252,6 +252,80 @@ The following briefly shows how a waking task is scheduled and executed.
>
> * Queue the task on the BPF side.
>
> + **Task State Tracking and ops.dequeue() Semantics**
> +
> + Once ``ops.select_cpu()`` or ``ops.enqueue()`` is called, the task may
> + enter the "BPF scheduler's custody" depending on where it's dispatched:
> +
> + * **Direct dispatch to local DSQs** (``SCX_DSQ_LOCAL`` or
> + ``SCX_DSQ_LOCAL_ON | cpu``): The task bypasses the BPF scheduler
> + entirely and goes straight to the CPU's local run queue. The task
> + never enters BPF custody, and ``ops.dequeue()`` will not be called.
> +
> + * **Dispatch to non-local DSQs** (``SCX_DSQ_GLOBAL`` or custom DSQs):
> + the task enters the BPF scheduler's custody. When the task later
> + leaves BPF custody (dispatched to a local DSQ, picked by core-sched,
> + or dequeued for sleep/property changes), ``ops.dequeue()`` will be
> + called exactly once.
> +
> + * **Queued on BPF side**: The task is in BPF data structures and in BPF
> + custody, ``ops.dequeue()`` will be called when it leaves.
> +
> + The key principle: **ops.dequeue() is called when a task leaves the BPF
> + scheduler's custody**. A task is in BPF custody if it's on a non-local
> + DSQ or in BPF data structures. Once dispatched to a local DSQ or after
> + ops.dequeue() is called, the task is out of BPF custody and the BPF
> + scheduler no longer needs to track it.
> +
> + This works correctly with the ``ops.select_cpu()`` direct dispatch
> + optimization: even though it skips ``ops.enqueue()`` invocation, if the
> + task is dispatched to a non-local DSQ, it enters BPF custody and will
> + get ``ops.dequeue()`` when it leaves. This provides the performance
> + benefit of avoiding the ``ops.enqueue()`` roundtrip while maintaining
> + correct state tracking.
> +
> + The dequeue can happen for different reasons, distinguished by flags:
> +
> + 1. **Regular dispatch workflow**: when the task is dispatched from a
> + non-local DSQ to a local DSQ (leaving BPF custody for execution),
> + ``ops.dequeue()`` is triggered without any special flags.
Maybe add a note that this can happen asynchronously, without the BPF
scheduler explicitly dispatching the task to a local DSQ, when the task
is on a global DSQ? Or maybe make that case into a separate dequeue reason
with its own flag, e.g. SCX_DEQ_PICKED_FROM_GLOBAL_DSQ?
> diff --git a/include/linux/sched/ext.h b/include/linux/sched/ext.h
> index bcb962d5ee7d8..0d003d2845393 100644
> --- a/include/linux/sched/ext.h
> +++ b/include/linux/sched/ext.h
> @@ -84,6 +84,7 @@ struct scx_dispatch_q {
> /* scx_entity.flags */
> enum scx_ent_flags {
> SCX_TASK_QUEUED = 1 << 0, /* on ext runqueue */
> + SCX_TASK_OPS_ENQUEUED = 1 << 1, /* under ext scheduler's custody */
Nit: I think "in BPF scheduler's custody" would be a bit clearer, as
"ext scheduler" could potentially be interpreted to mean SCHED_CLASS_EXT
as a whole.
> @@ -1523,6 +1603,30 @@ static void ops_dequeue(struct rq *rq, struct task_struct *p, u64 deq_flags)
>
> switch (opss & SCX_OPSS_STATE_MASK) {
> case SCX_OPSS_NONE:
> + /*
> + * Task is not in BPF data structures (either dispatched to
> + * a DSQ or running). Only call ops.dequeue() if the task
> + * is still in BPF scheduler's custody
> + * (%SCX_TASK_OPS_ENQUEUED is set).
> + *
> + * If the task has already been dispatched to a local DSQ
> + * (left BPF custody), the flag will be clear and we skip
> + * ops.dequeue()
> + *
> + * If this is a property change (not sleep/core-sched) and
> + * the task is still in BPF custody, set the
> + * %SCX_DEQ_SCHED_CHANGE flag.
> + */
> + if (SCX_HAS_OP(sch, dequeue) &&
> + p->scx.flags & SCX_TASK_OPS_ENQUEUED) {
> + u64 flags = deq_flags;
> +
> + if (!(deq_flags & (DEQUEUE_SLEEP | SCX_DEQ_CORE_SCHED_EXEC)))
> + flags |= SCX_DEQ_SCHED_CHANGE;
I think this logic will result in ops.dequeue(SCHED_CHANGE) being called for
tasks being picked from a global DSQ being migrated from a remote rq to the
local rq, which, while technically correct since the task is migrating rqs,
may be confusing, since it fits two cases in the documentation:
* Since the task is leaving BPF custody for execution, ops.dequeue() should be
called without any special flags.
* Since the task is being migrated between rqs, ops.dequeue() should be called
with SCX_DEQ_SCHED_CHANGE.
> +
> + SCX_CALL_OP_TASK(sch, SCX_KF_REST, dequeue, rq, p, flags);
> + p->scx.flags &= ~SCX_TASK_OPS_ENQUEUED;
> + }
> break;
> case SCX_OPSS_QUEUEING:
> /*
Thanks,
Kuba
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 1/2] sched_ext: Fix ops.dequeue() semantics
2026-02-02 7:45 ` Andrea Righi
2026-02-02 9:26 ` Andrea Righi
2026-02-02 10:09 ` Christian Loehle
@ 2026-02-02 13:59 ` Kuba Piecuch
2026-02-04 9:36 ` Andrea Righi
2 siblings, 1 reply; 41+ messages in thread
From: Kuba Piecuch @ 2026-02-02 13:59 UTC (permalink / raw)
To: Andrea Righi, Christian Loehle
Cc: Tejun Heo, David Vernet, Changwoo Min, Kuba Piecuch,
Emil Tsalapatis, Daniel Hodges, sched-ext, linux-kernel
Hi Andrea,
On Mon Feb 2, 2026 at 7:45 AM UTC, Andrea Righi wrote:
> diff --git a/kernel/sched/ext.c b/kernel/sched/ext.c
> index 6d6f1253039d8..d8fed4a49195d 100644
> --- a/kernel/sched/ext.c
> +++ b/kernel/sched/ext.c
> @@ -2248,7 +2248,7 @@ static void finish_dispatch(struct scx_sched *sch, struct rq *rq,
> p->scx.flags |= SCX_TASK_OPS_ENQUEUED;
> } else {
> if (p->scx.flags & SCX_TASK_OPS_ENQUEUED)
> - SCX_CALL_OP_TASK(sch, SCX_KF_REST, dequeue, task_rq(p), p, 0);
> + SCX_CALL_OP_TASK(sch, SCX_KF_REST, dequeue, rq, p, 0);
>
> p->scx.flags &= ~SCX_TASK_OPS_ENQUEUED;
> }
This looks risky from a locking perspective. Are we relying on
SCX_OPSS_DISPATCHING to protect against racing dequeues? If so, it might
be worth calling out in a comment.
Thanks,
Kuba
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 1/2] sched_ext: Fix ops.dequeue() semantics
2026-02-02 10:02 ` Christian Loehle
@ 2026-02-02 15:32 ` Andrea Righi
0 siblings, 0 replies; 41+ messages in thread
From: Andrea Righi @ 2026-02-02 15:32 UTC (permalink / raw)
To: Christian Loehle
Cc: Tejun Heo, David Vernet, Changwoo Min, Kuba Piecuch,
Emil Tsalapatis, Daniel Hodges, sched-ext, linux-kernel
On Mon, Feb 02, 2026 at 10:02:30AM +0000, Christian Loehle wrote:
> On 2/2/26 09:26, Andrea Righi wrote:
> > On Mon, Feb 02, 2026 at 08:45:18AM +0100, Andrea Righi wrote:
> > ...
> >>> So I have finally gotten around updating scx_storm to the new semantics,
> >>> see:
> >>> https://github.com/cloehle/scx/tree/cloehle/scx-storm-qmap-insert-local-dequeue-semantics
> >>>
> >>> I don't think the new ops.dequeue() are enough to make inserts to local-on
> >>> from anywhere safe, because it's still racing with dequeue from another CPU?
> >>
> >> Yeah, with this patch set BPF schedulers get proper ops.dequeue()
> >> callbacks, but we're not fixing the usage of SCX_DSQ_LOCAL_ON from
> >> ops.dispatch().
> >>
> >> When task properties change between scx_bpf_dsq_insert() and the actual
> >> dispatch, task_can_run_on_remote_rq() can still trigger a fatal
> >> scx_error().
> >>
> >> The ops.dequeue(SCX_DEQ_SCHED_CHANGE) notifications happens after the
> >> property change, so it can't prevent already-queued dispatches from
> >> failing. The race window is between ops.dispatch() returning and
> >> dispatch_to_local_dsq() executing.
> >>
> >> We can address this in a separate patch set. One thing at a time. :)
> >
> > Thinking more on this, the problem is that we're passing enforce=true to
> > task_can_run_on_remote_rq(), triggering a critical failure - scx_error().
> > There's a logic in task_can_run_on_remote_rq() to fallback to the global
> > DSQ, that doesn't happen if we pass enforce=true, due to scx_error().
> >
> > However, instead of the global DSQ fallback, I was wondering if it'd be
> > better to simply re-enqueue the task - setting SCX_ENQ_REENQ - if the
> > target local DSQ isn't valid anymore when the dispatch is finalized.
> >
> > In this way using SCX_DSQ_LOCAL_ON | cpu from ops.dispatch() would simply
> > trigger a re-enqueue when "cpu" isn't valid anymore (due to concurrent
> > affinity / migration disabled changes) and the BPF scheduler can handle
> > that in another ops.enqueue().
> >
> > What do you think?
>
> I think that's a lot more versatile for the BPF scheduler than using the
> global DSQ as fallback in that case, so yeah I'm all for it!
>
Ack, I already have a working patch do to this, I'll post it as a separate
patch set.
Thanks,
-Andrea
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 1/2] sched_ext: Fix ops.dequeue() semantics
2026-02-02 13:59 ` Kuba Piecuch
@ 2026-02-04 9:36 ` Andrea Righi
2026-02-04 9:51 ` Kuba Piecuch
0 siblings, 1 reply; 41+ messages in thread
From: Andrea Righi @ 2026-02-04 9:36 UTC (permalink / raw)
To: Kuba Piecuch
Cc: Christian Loehle, Tejun Heo, David Vernet, Changwoo Min,
Emil Tsalapatis, Daniel Hodges, sched-ext, linux-kernel
Hi Kuba,
sorry for the late response.
On Mon, Feb 02, 2026 at 01:59:24PM +0000, Kuba Piecuch wrote:
> Hi Andrea,
>
> On Mon Feb 2, 2026 at 7:45 AM UTC, Andrea Righi wrote:
> > diff --git a/kernel/sched/ext.c b/kernel/sched/ext.c
> > index 6d6f1253039d8..d8fed4a49195d 100644
> > --- a/kernel/sched/ext.c
> > +++ b/kernel/sched/ext.c
> > @@ -2248,7 +2248,7 @@ static void finish_dispatch(struct scx_sched *sch, struct rq *rq,
> > p->scx.flags |= SCX_TASK_OPS_ENQUEUED;
> > } else {
> > if (p->scx.flags & SCX_TASK_OPS_ENQUEUED)
> > - SCX_CALL_OP_TASK(sch, SCX_KF_REST, dequeue, task_rq(p), p, 0);
> > + SCX_CALL_OP_TASK(sch, SCX_KF_REST, dequeue, rq, p, 0);
> >
> > p->scx.flags &= ~SCX_TASK_OPS_ENQUEUED;
> > }
>
> This looks risky from a locking perspective. Are we relying on
> SCX_OPSS_DISPATCHING to protect against racing dequeues? If so, it might
> be worth calling out in a comment.
You're right, we're relying on SCX_OPSS_DISPATCHING to protect against
racing dequeues and this definitely deserves a comment. How about something
like the following?
Thanks,
-Andrea
---
diff --git a/kernel/sched/ext.c b/kernel/sched/ext.c
index 292adf10fee1b..b189339e74101 100644
--- a/kernel/sched/ext.c
+++ b/kernel/sched/ext.c
@@ -2260,6 +2260,15 @@ static void finish_dispatch(struct scx_sched *sch, struct rq *rq,
if (!is_terminal_dsq(dsq_id)) {
p->scx.flags |= SCX_TASK_OPS_ENQUEUED;
} else {
+ /*
+ * Locking: we're holding the @rq lock (the
+ * dispatch CPU's rq), but not necessarily
+ * task_rq(p), since @p may be from a remote CPU.
+ *
+ * This is safe because SCX_OPSS_DISPATCHING state
+ * prevents racing dequeues, any concurrent
+ * ops_dequeue() will wait for this state to clear.
+ */
if (p->scx.flags & SCX_TASK_OPS_ENQUEUED)
SCX_CALL_OP_TASK(sch, SCX_KF_REST, dequeue, rq, p, 0);
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 1/2] sched_ext: Fix ops.dequeue() semantics
2026-02-04 9:36 ` Andrea Righi
@ 2026-02-04 9:51 ` Kuba Piecuch
0 siblings, 0 replies; 41+ messages in thread
From: Kuba Piecuch @ 2026-02-04 9:51 UTC (permalink / raw)
To: Andrea Righi, Kuba Piecuch
Cc: Christian Loehle, Tejun Heo, David Vernet, Changwoo Min,
Emil Tsalapatis, Daniel Hodges, sched-ext, linux-kernel
On Wed Feb 4, 2026 at 9:36 AM UTC, Andrea Righi wrote:
> diff --git a/kernel/sched/ext.c b/kernel/sched/ext.c
> index 292adf10fee1b..b189339e74101 100644
> --- a/kernel/sched/ext.c
> +++ b/kernel/sched/ext.c
> @@ -2260,6 +2260,15 @@ static void finish_dispatch(struct scx_sched *sch, struct rq *rq,
> if (!is_terminal_dsq(dsq_id)) {
> p->scx.flags |= SCX_TASK_OPS_ENQUEUED;
> } else {
> + /*
> + * Locking: we're holding the @rq lock (the
> + * dispatch CPU's rq), but not necessarily
> + * task_rq(p), since @p may be from a remote CPU.
> + *
> + * This is safe because SCX_OPSS_DISPATCHING state
> + * prevents racing dequeues, any concurrent
> + * ops_dequeue() will wait for this state to clear.
> + */
> if (p->scx.flags & SCX_TASK_OPS_ENQUEUED)
> SCX_CALL_OP_TASK(sch, SCX_KF_REST, dequeue, rq, p, 0);
Looks good, thanks :)
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 1/2] sched_ext: Fix ops.dequeue() semantics
2026-02-02 11:56 ` Kuba Piecuch
@ 2026-02-04 10:11 ` Andrea Righi
2026-02-04 10:33 ` Kuba Piecuch
0 siblings, 1 reply; 41+ messages in thread
From: Andrea Righi @ 2026-02-04 10:11 UTC (permalink / raw)
To: Kuba Piecuch
Cc: Tejun Heo, David Vernet, Changwoo Min, Emil Tsalapatis,
Christian Loehle, Daniel Hodges, sched-ext, linux-kernel
On Mon, Feb 02, 2026 at 11:56:43AM +0000, Kuba Piecuch wrote:
> Hi Andrea,
>
> Looks good overall, but we need to settle on the global DSQ semantics, plus
> some edge cases that need clearing up.
On this one I think we settled on the assumption that SCX_DSQ_GLOBAL can be
considered a "terminal DSQ", so we won't trigger ops.dequeue().
>
> On Sun Feb 1, 2026 at 9:08 AM UTC, Andrea Righi wrote:
> > diff --git a/Documentation/scheduler/sched-ext.rst b/Documentation/scheduler/sched-ext.rst
> > index 404fe6126a769..6d9e82e6ca9d4 100644
> > --- a/Documentation/scheduler/sched-ext.rst
> > +++ b/Documentation/scheduler/sched-ext.rst
> > @@ -252,6 +252,80 @@ The following briefly shows how a waking task is scheduled and executed.
> >
> > * Queue the task on the BPF side.
> >
> > + **Task State Tracking and ops.dequeue() Semantics**
> > +
> > + Once ``ops.select_cpu()`` or ``ops.enqueue()`` is called, the task may
> > + enter the "BPF scheduler's custody" depending on where it's dispatched:
> > +
> > + * **Direct dispatch to local DSQs** (``SCX_DSQ_LOCAL`` or
> > + ``SCX_DSQ_LOCAL_ON | cpu``): The task bypasses the BPF scheduler
> > + entirely and goes straight to the CPU's local run queue. The task
> > + never enters BPF custody, and ``ops.dequeue()`` will not be called.
> > +
> > + * **Dispatch to non-local DSQs** (``SCX_DSQ_GLOBAL`` or custom DSQs):
> > + the task enters the BPF scheduler's custody. When the task later
> > + leaves BPF custody (dispatched to a local DSQ, picked by core-sched,
> > + or dequeued for sleep/property changes), ``ops.dequeue()`` will be
> > + called exactly once.
> > +
> > + * **Queued on BPF side**: The task is in BPF data structures and in BPF
> > + custody, ``ops.dequeue()`` will be called when it leaves.
> > +
> > + The key principle: **ops.dequeue() is called when a task leaves the BPF
> > + scheduler's custody**. A task is in BPF custody if it's on a non-local
> > + DSQ or in BPF data structures. Once dispatched to a local DSQ or after
> > + ops.dequeue() is called, the task is out of BPF custody and the BPF
> > + scheduler no longer needs to track it.
> > +
> > + This works correctly with the ``ops.select_cpu()`` direct dispatch
> > + optimization: even though it skips ``ops.enqueue()`` invocation, if the
> > + task is dispatched to a non-local DSQ, it enters BPF custody and will
> > + get ``ops.dequeue()`` when it leaves. This provides the performance
> > + benefit of avoiding the ``ops.enqueue()`` roundtrip while maintaining
> > + correct state tracking.
> > +
> > + The dequeue can happen for different reasons, distinguished by flags:
> > +
> > + 1. **Regular dispatch workflow**: when the task is dispatched from a
> > + non-local DSQ to a local DSQ (leaving BPF custody for execution),
> > + ``ops.dequeue()`` is triggered without any special flags.
>
> Maybe add a note that this can happen asynchronously, without the BPF
> scheduler explicitly dispatching the task to a local DSQ, when the task
> is on a global DSQ? Or maybe make that case into a separate dequeue reason
> with its own flag, e.g. SCX_DEQ_PICKED_FROM_GLOBAL_DSQ?
And I guess we don't need this if we consider SCX_DSQ_GLOBAL as a terminal
DSQ, because we won't trigger ops.dequeue().
>
> > diff --git a/include/linux/sched/ext.h b/include/linux/sched/ext.h
> > index bcb962d5ee7d8..0d003d2845393 100644
> > --- a/include/linux/sched/ext.h
> > +++ b/include/linux/sched/ext.h
> > @@ -84,6 +84,7 @@ struct scx_dispatch_q {
> > /* scx_entity.flags */
> > enum scx_ent_flags {
> > SCX_TASK_QUEUED = 1 << 0, /* on ext runqueue */
> > + SCX_TASK_OPS_ENQUEUED = 1 << 1, /* under ext scheduler's custody */
>
> Nit: I think "in BPF scheduler's custody" would be a bit clearer, as
> "ext scheduler" could potentially be interpreted to mean SCHED_CLASS_EXT
> as a whole.
Ack. Will change that.
>
> > @@ -1523,6 +1603,30 @@ static void ops_dequeue(struct rq *rq, struct task_struct *p, u64 deq_flags)
> >
> > switch (opss & SCX_OPSS_STATE_MASK) {
> > case SCX_OPSS_NONE:
> > + /*
> > + * Task is not in BPF data structures (either dispatched to
> > + * a DSQ or running). Only call ops.dequeue() if the task
> > + * is still in BPF scheduler's custody
> > + * (%SCX_TASK_OPS_ENQUEUED is set).
> > + *
> > + * If the task has already been dispatched to a local DSQ
> > + * (left BPF custody), the flag will be clear and we skip
> > + * ops.dequeue()
> > + *
> > + * If this is a property change (not sleep/core-sched) and
> > + * the task is still in BPF custody, set the
> > + * %SCX_DEQ_SCHED_CHANGE flag.
> > + */
> > + if (SCX_HAS_OP(sch, dequeue) &&
> > + p->scx.flags & SCX_TASK_OPS_ENQUEUED) {
> > + u64 flags = deq_flags;
> > +
> > + if (!(deq_flags & (DEQUEUE_SLEEP | SCX_DEQ_CORE_SCHED_EXEC)))
> > + flags |= SCX_DEQ_SCHED_CHANGE;
>
> I think this logic will result in ops.dequeue(SCHED_CHANGE) being called for
> tasks being picked from a global DSQ being migrated from a remote rq to the
> local rq, which, while technically correct since the task is migrating rqs,
> may be confusing, since it fits two cases in the documentation:
>
> * Since the task is leaving BPF custody for execution, ops.dequeue() should be
> called without any special flags.
> * Since the task is being migrated between rqs, ops.dequeue() should be called
> with SCX_DEQ_SCHED_CHANGE.
This also should be fixed with the new logic, because a task disptched to a
global DSQ is considered outside of the BPF scheduler's custody, so
ops.dequeue() is not invoked at all.
I'll post a new patch set later today, so we can better discuss if all
these assumptions have been addressed properly. :)
>
> > +
> > + SCX_CALL_OP_TASK(sch, SCX_KF_REST, dequeue, rq, p, flags);
> > + p->scx.flags &= ~SCX_TASK_OPS_ENQUEUED;
> > + }
> > break;
> > case SCX_OPSS_QUEUEING:
> > /*
>
> Thanks,
> Kuba
Thanks,
-Andrea
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 1/2] sched_ext: Fix ops.dequeue() semantics
2026-02-04 10:11 ` Andrea Righi
@ 2026-02-04 10:33 ` Kuba Piecuch
0 siblings, 0 replies; 41+ messages in thread
From: Kuba Piecuch @ 2026-02-04 10:33 UTC (permalink / raw)
To: Andrea Righi, Kuba Piecuch
Cc: Tejun Heo, David Vernet, Changwoo Min, Emil Tsalapatis,
Christian Loehle, Daniel Hodges, sched-ext, linux-kernel
On Wed Feb 4, 2026 at 10:11 AM UTC, Andrea Righi wrote:
> On Mon, Feb 02, 2026 at 11:56:43AM +0000, Kuba Piecuch wrote:
>> Hi Andrea,
>>
>> Looks good overall, but we need to settle on the global DSQ semantics, plus
>> some edge cases that need clearing up.
>
> On this one I think we settled on the assumption that SCX_DSQ_GLOBAL can be
> considered a "terminal DSQ", so we won't trigger ops.dequeue().
Correct, I made this comment before we settled it.
Thanks,
Kuba
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-12 17:15 ` Christian Loehle
@ 2026-02-12 18:25 ` Andrea Righi
0 siblings, 0 replies; 41+ messages in thread
From: Andrea Righi @ 2026-02-12 18:25 UTC (permalink / raw)
To: Christian Loehle
Cc: Tejun Heo, David Vernet, Changwoo Min, Kuba Piecuch,
Emil Tsalapatis, Daniel Hodges, sched-ext, linux-kernel
On Thu, Feb 12, 2026 at 05:15:28PM +0000, Christian Loehle wrote:
> On 2/10/26 21:26, Andrea Righi wrote:
...
> > +void BPF_STRUCT_OPS(dequeue_enqueue, struct task_struct *p, u64 enq_flags)
> > +{
> > + struct task_ctx *tctx;
> > + s32 pid = p->pid;
>
> unused
This one is used, but the one in dequeue_select_cpu() is not. I'll remove
that. :)
> > +static enum scx_test_status run_scenario(struct dequeue *skel, u32 scenario,
> > + const char *scenario_name)
> > +{
> > + struct bpf_link *link;
> > + pid_t pids[NUM_WORKERS];
> > + pthread_t hammer;
> > +
> > + int i, status;
> > + u64 enq_start, deq_start,
> > + dispatch_deq_start, change_deq_start, bpf_queue_full_start;
> > + u64 enq_delta, deq_delta,
> > + dispatch_deq_delta, change_deq_delta, bpf_queue_full_delta;
> > +
> > + /* Set the test scenario */
> > + skel->bss->test_scenario = scenario;
> > +
> > + /* Record starting counts */
> > + enq_start = skel->bss->enqueue_cnt;
> > + deq_start = skel->bss->dequeue_cnt;
> > + dispatch_deq_start = skel->bss->dispatch_dequeue_cnt;
> > + change_deq_start = skel->bss->change_dequeue_cnt;
> > + bpf_queue_full_start = skel->bss->bpf_queue_full;
> > +
> > + link = bpf_map__attach_struct_ops(skel->maps.dequeue_ops);
> > + SCX_FAIL_IF(!link, "Failed to attach struct_ops for scenario %s", scenario_name);
> > +
> > + /* Fork worker processes to generate enqueue/dequeue events */
> > + for (i = 0; i < NUM_WORKERS; i++) {
> > + pids[i] = fork();
> > + SCX_FAIL_IF(pids[i] < 0, "Failed to fork worker %d", i);
> > +
> > + if (pids[i] == 0) {
> > + worker_fn(i);
> > + /* Should not reach here */
> > + exit(1);
> > + }
> > + }
> > +
> > + /*
> > + * Run an "affinity hammer" so that some property changes hit tasks
> > + * while they are still in BPF custody (e.g. in user DSQ or BPF queue),
> > + * triggering SCX_DEQ_SCHED_CHANGE dequeues in scenarios 2, 3, 6 and 7.
>
> Not true for 3, right?
Oh yes, this selftest has been changed so many times that I was sure I
forgot to update some comments (also, scenario 7 doesn't exist anymore).
Thanks!
-Andrea
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-10 21:26 ` [PATCH 2/2] selftests/sched_ext: Add test to validate " Andrea Righi
@ 2026-02-12 17:15 ` Christian Loehle
2026-02-12 18:25 ` Andrea Righi
0 siblings, 1 reply; 41+ messages in thread
From: Christian Loehle @ 2026-02-12 17:15 UTC (permalink / raw)
To: Andrea Righi, Tejun Heo, David Vernet, Changwoo Min
Cc: Kuba Piecuch, Emil Tsalapatis, Daniel Hodges, sched-ext, linux-kernel
On 2/10/26 21:26, Andrea Righi wrote:
> Add a new kselftest to validate that the new ops.dequeue() semantics
> work correctly for all task lifecycle scenarios, including the
> distinction between terminal DSQs (where BPF scheduler is done with the
> task), user DSQs (where BPF scheduler manages the task lifecycle) and
> BPF data structures, regardless of which event performs the dispatch.
>
> The test validates the following scenarios:
>
> - From ops.select_cpu():
> - scenario 0 (local DSQ): tasks dispatched to the local DSQ bypass
> the BPF scheduler entirely; they never enter BPF custody, so
> ops.dequeue() is not called,
> - scenario 1 (global DSQ): tasks dispatched to SCX_DSQ_GLOBAL also
> bypass the BPF scheduler, like the local DSQ; ops.dequeue() is
> not called,
> - scenario 2 (user DSQ): tasks dispatched to user DSQs from
> ops.select_cpu(): tasks enter BPF scheduler's custody with full
> enqueue/dequeue lifecycle tracking and state machine validation,
> expects 1:1 enqueue/dequeue pairing,
>
> - From ops.enqueue():
> - scenario 3 (local DSQ): same behavior as scenario 0,
> - scenario 4 (global DSQ): same behavior as scenario 1,
> - scenario 5 (user DSQ): same behavior as scenario 2,
> - scenario 6 (BPF internal queue): tasks are stored in a BPF queue
> from ops.enqueue() and consumed from ops.dispatch(); similarly to
> scenario 5, tasks enter BPF scheduler's custody with full
> lifecycle tracking and 1:1 enqueue/dequeue validation.
>
> This verifies that:
> - terminal DSQ dispatch (local, global) don't trigger ops.dequeue(),
> - tasks dispatched to user DSQs, either from ops.select_cpu() or
> ops.enqueue(), enter BPF scheduler's custody and have exact 1:1
> enqueue/dequeue pairing,
> - tasks stored to internal BPF data structures from ops.enqueue() enter
> BPF scheduler's custody and have exact 1:1 enqueue/dequeue pairing,
> - dispatch dequeues have no flags (normal workflow),
> - property change dequeues have the %SCX_DEQ_SCHED_CHANGE flag set,
> - no duplicate enqueues or invalid state transitions are happening.
>
> Cc: Tejun Heo <tj@kernel.org>
> Cc: Emil Tsalapatis <emil@etsalapatis.com>
> Cc: Kuba Piecuch <jpiecuch@google.com>
> Signed-off-by: Andrea Righi <arighi@nvidia.com>
> ---
> tools/testing/selftests/sched_ext/Makefile | 1 +
> .../testing/selftests/sched_ext/dequeue.bpf.c | 368 ++++++++++++++++++
> tools/testing/selftests/sched_ext/dequeue.c | 265 +++++++++++++
> 3 files changed, 634 insertions(+)
> create mode 100644 tools/testing/selftests/sched_ext/dequeue.bpf.c
> create mode 100644 tools/testing/selftests/sched_ext/dequeue.c
>
> diff --git a/tools/testing/selftests/sched_ext/Makefile b/tools/testing/selftests/sched_ext/Makefile
> index 5fe45f9c5f8fd..764e91edabf93 100644
> --- a/tools/testing/selftests/sched_ext/Makefile
> +++ b/tools/testing/selftests/sched_ext/Makefile
> @@ -161,6 +161,7 @@ all_test_bpfprogs := $(foreach prog,$(wildcard *.bpf.c),$(INCLUDE_DIR)/$(patsubs
>
> auto-test-targets := \
> create_dsq \
> + dequeue \
> enq_last_no_enq_fails \
> ddsp_bogus_dsq_fail \
> ddsp_vtimelocal_fail \
> diff --git a/tools/testing/selftests/sched_ext/dequeue.bpf.c b/tools/testing/selftests/sched_ext/dequeue.bpf.c
> new file mode 100644
> index 0000000000000..d9d12f14cd673
> --- /dev/null
> +++ b/tools/testing/selftests/sched_ext/dequeue.bpf.c
> @@ -0,0 +1,368 @@
> +// SPDX-License-Identifier: GPL-2.0
> +/*
> + * A scheduler that validates ops.dequeue() is called correctly:
> + * - Tasks dispatched to terminal DSQs (local, global) bypass the BPF
> + * scheduler entirely: no ops.dequeue() should be called
> + * - Tasks dispatched to user DSQs from ops.enqueue() enter BPF custody:
> + * ops.dequeue() must be called when they leave custody
> + * - Every ops.enqueue() dispatch to non-terminal DSQs is followed by
> + * exactly one ops.dequeue() (validate 1:1 pairing and state machine)
> + *
> + * Copyright (c) 2026 NVIDIA Corporation.
> + */
> +
> +#include <scx/common.bpf.h>
> +
> +#define SHARED_DSQ 0
> +
> +/*
> + * BPF internal queue.
> + *
> + * Tasks are stored here and consumed from ops.dispatch(), validating that
> + * tasks on BPF internal structures still get ops.dequeue() when they
> + * leave.
> + */
> +struct {
> + __uint(type, BPF_MAP_TYPE_QUEUE);
> + __uint(max_entries, 32768);
> + __type(value, s32);
> +} global_queue SEC(".maps");
> +
> +char _license[] SEC("license") = "GPL";
> +
> +UEI_DEFINE(uei);
> +
> +/*
> + * Counters to track the lifecycle of tasks:
> + * - enqueue_cnt: Number of times ops.enqueue() was called
> + * - dequeue_cnt: Number of times ops.dequeue() was called (any type)
> + * - dispatch_dequeue_cnt: Number of regular dispatch dequeues (no flag)
> + * - change_dequeue_cnt: Number of property change dequeues
> + * - bpf_queue_full: Number of times the BPF internal queue was full
> + */
> +u64 enqueue_cnt, dequeue_cnt, dispatch_dequeue_cnt, change_dequeue_cnt, bpf_queue_full;
> +
> +/*
> + * Test scenarios:
> + * 0) Dispatch to local DSQ from ops.select_cpu() (terminal DSQ, bypasses BPF
> + * scheduler, no dequeue callbacks)
> + * 1) Dispatch to global DSQ from ops.select_cpu() (terminal DSQ, bypasses BPF
> + * scheduler, no dequeue callbacks)
> + * 2) Dispatch to shared user DSQ from ops.select_cpu() (enters BPF scheduler,
> + * dequeue callbacks expected)
> + * 3) Dispatch to local DSQ from ops.enqueue() (terminal DSQ, bypasses BPF
> + * scheduler, no dequeue callbacks)
> + * 4) Dispatch to global DSQ from ops.enqueue() (terminal DSQ, bypasses BPF
> + * scheduler, no dequeue callbacks)
> + * 5) Dispatch to shared user DSQ from ops.enqueue() (enters BPF scheduler,
> + * dequeue callbacks expected)
> + * 6) BPF internal queue from ops.enqueue(): store task PIDs in ops.enqueue(),
> + * consume in ops.dispatch() and dispatch to local DSQ (validates dequeue
> + * for tasks stored in internal BPF data structures)
> + */
> +u32 test_scenario;
> +
> +/*
> + * Per-task state to track lifecycle and validate workflow semantics.
> + * State transitions:
> + * NONE -> ENQUEUED (on enqueue)
> + * ENQUEUED -> DISPATCHED (on dispatch dequeue)
> + * DISPATCHED -> NONE (on property change dequeue or re-enqueue)
> + * ENQUEUED -> NONE (on property change dequeue before dispatch)
> + */
> +enum task_state {
> + TASK_NONE = 0,
> + TASK_ENQUEUED,
> + TASK_DISPATCHED,
> +};
> +
> +struct task_ctx {
> + enum task_state state; /* Current state in the workflow */
> + u64 enqueue_seq; /* Sequence number for debugging */
> +};
> +
> +struct {
> + __uint(type, BPF_MAP_TYPE_TASK_STORAGE);
> + __uint(map_flags, BPF_F_NO_PREALLOC);
> + __type(key, int);
> + __type(value, struct task_ctx);
> +} task_ctx_stor SEC(".maps");
> +
> +static struct task_ctx *try_lookup_task_ctx(struct task_struct *p)
> +{
> + return bpf_task_storage_get(&task_ctx_stor, p, 0, 0);
> +}
> +
> +s32 BPF_STRUCT_OPS(dequeue_select_cpu, struct task_struct *p,
> + s32 prev_cpu, u64 wake_flags)
> +{
> + struct task_ctx *tctx;
> + s32 pid = p->pid;
> +> + tctx = try_lookup_task_ctx(p);
> + if (!tctx)
> + return prev_cpu;
> +
> + switch (test_scenario) {
> + case 0:
> + /*
> + * Direct dispatch to the local DSQ.
> + *
> + * Task bypasses BPF scheduler entirely: no enqueue
> + * tracking, no ops.dequeue() callbacks.
> + */
> + scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, 0);
> + tctx->state = TASK_DISPATCHED;
> + break;
> + case 1:
> + /*
> + * Direct dispatch to the global DSQ.
> + *
> + * Task bypasses BPF scheduler entirely: no enqueue
> + * tracking, no ops.dequeue() callbacks.
> + */
> + scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, 0);
> + tctx->state = TASK_DISPATCHED;
> + break;
> + case 2:
> + /*
> + * Dispatch to shared a user DSQ.
> + *
> + * Task enters BPF scheduler management: track
> + * enqueue/dequeue lifecycle and validate state
> + * transitions.
> + */
> + if (tctx->state == TASK_ENQUEUED)
> + scx_bpf_error("%d (%s): enqueue while in ENQUEUED state seq=%llu",
> + p->pid, p->comm, tctx->enqueue_seq);
> +
> + scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, 0);
> +
> + __sync_fetch_and_add(&enqueue_cnt, 1);
> +
> + tctx->state = TASK_ENQUEUED;
> + tctx->enqueue_seq++;
> + break;
> + }
> +
> + return prev_cpu;
> +}
> +
> +void BPF_STRUCT_OPS(dequeue_enqueue, struct task_struct *p, u64 enq_flags)
> +{
> + struct task_ctx *tctx;
> + s32 pid = p->pid;
unused
> +
> + tctx = try_lookup_task_ctx(p);
> + if (!tctx)
> + return;
> +
> + switch (test_scenario) {
> + case 3:
> + /*
> + * Direct dispatch to the local DSQ.
> + *
> + * Task bypasses BPF scheduler entirely: no enqueue
> + * tracking, no ops.dequeue() callbacks.
> + */
> + scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, enq_flags);
> + break;
> + case 4:
> + /*
> + * Direct dispatch to the global DSQ.
> + *
> + * Task bypasses BPF scheduler entirely: no enqueue
> + * tracking, no ops.dequeue() callbacks.
> + */
> + scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
> + break;
> + case 5:
> + /*
> + * Dispatch to shared user DSQ.
> + *
> + * Task enters BPF scheduler management: track
> + * enqueue/dequeue lifecycle and validate state
> + * transitions.
> + */
> + if (tctx->state == TASK_ENQUEUED)
> + scx_bpf_error("%d (%s): enqueue while in ENQUEUED state seq=%llu",
> + p->pid, p->comm, tctx->enqueue_seq);
> +
> + scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, enq_flags);
> +
> + __sync_fetch_and_add(&enqueue_cnt, 1);
> +
> + tctx->state = TASK_ENQUEUED;
> + tctx->enqueue_seq++;
> + break;
> + case 6:
> + /*
> + * Store task in BPF internal queue.
> + *
> + * Task enters BPF scheduler management: track
> + * enqueue/dequeue lifecycle and validate state
> + * transitions.
> + */
> + if (tctx->state == TASK_ENQUEUED)
> + scx_bpf_error("%d (%s): enqueue while in ENQUEUED state seq=%llu",
> + p->pid, p->comm, tctx->enqueue_seq);
> +
> + if (bpf_map_push_elem(&global_queue, &pid, 0)) {
> + scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
> + __sync_fetch_and_add(&bpf_queue_full, 1);
> +
> + tctx->state = TASK_DISPATCHED;
> + } else {
> + __sync_fetch_and_add(&enqueue_cnt, 1);
> +
> + tctx->state = TASK_ENQUEUED;
> + tctx->enqueue_seq++;
> + }
> + break;
> + default:
> + /* For all other scenarios, dispatch to the global DSQ */
> + scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
> + tctx->state = TASK_DISPATCHED;
> + break;
> + }
> +
> + scx_bpf_kick_cpu(scx_bpf_task_cpu(p), SCX_KICK_IDLE);
> +}
> +
> +void BPF_STRUCT_OPS(dequeue_dequeue, struct task_struct *p, u64 deq_flags)
> +{
> + struct task_ctx *tctx;
> +
> + __sync_fetch_and_add(&dequeue_cnt, 1);
> +
> + tctx = try_lookup_task_ctx(p);
> + if (!tctx)
> + return;
> +
> + /*
> + * For scenarios 0, 1, 3, and 4 (terminal DSQs: local and global),
> + * ops.dequeue() should never be called because tasks bypass the
> + * BPF scheduler entirely. If we get here, it's a kernel bug.
> + */
> + if (test_scenario == 0 || test_scenario == 3) {
> + scx_bpf_error("%d (%s): dequeue called for local DSQ scenario",
> + p->pid, p->comm);
> + return;
> + }
> +
> + if (test_scenario == 1 || test_scenario == 4) {
> + scx_bpf_error("%d (%s): dequeue called for global DSQ scenario",
> + p->pid, p->comm);
> + return;
> + }
> +
> + if (deq_flags & SCX_DEQ_SCHED_CHANGE) {
> + /*
> + * Property change interrupting the workflow. Valid from
> + * both ENQUEUED and DISPATCHED states. Transitions task
> + * back to NONE state.
> + */
> + __sync_fetch_and_add(&change_dequeue_cnt, 1);
> +
> + /* Validate state transition */
> + if (tctx->state != TASK_ENQUEUED && tctx->state != TASK_DISPATCHED)
> + scx_bpf_error("%d (%s): invalid property change dequeue state=%d seq=%llu",
> + p->pid, p->comm, tctx->state, tctx->enqueue_seq);
> +
> + /* Transition back to NONE: task outside scheduler control */
> + tctx->state = TASK_NONE;
> + } else {
> + /*
> + * Regular dispatch dequeue: normal workflow step. Valid
> + * only from ENQUEUED state (after enqueue, before dispatch
> + * dequeue). Transitions to DISPATCHED state.
> + */
> + __sync_fetch_and_add(&dispatch_dequeue_cnt, 1);
> +
> + /*
> + * Dispatch dequeue should not have %SCX_DEQ_SCHED_CHANGE
> + * flag.
> + */
> + if (deq_flags & SCX_DEQ_SCHED_CHANGE)
> + scx_bpf_error("%d (%s): SCX_DEQ_SCHED_CHANGE in dispatch dequeue seq=%llu",
> + p->pid, p->comm, tctx->enqueue_seq);
> +
> + /*
> + * Must be in ENQUEUED state.
> + */
> + if (tctx->state != TASK_ENQUEUED)
> + scx_bpf_error("%d (%s): dispatch dequeue from state %d seq=%llu",
> + p->pid, p->comm, tctx->state, tctx->enqueue_seq);
> +
> + /*
> + * Transition to DISPATCHED: normal cycle completed
> + * dispatch.
> + */
> + tctx->state = TASK_DISPATCHED;
> + }
> +}
> +
> +void BPF_STRUCT_OPS(dequeue_dispatch, s32 cpu, struct task_struct *prev)
> +{
> + if (test_scenario == 6) {
> + struct task_struct *p;
> + s32 pid;
> +
> + if (bpf_map_pop_elem(&global_queue, &pid))
> + return;
> +
> + p = bpf_task_from_pid(pid);
> + if (!p)
> + return;
> +
> + if (!bpf_cpumask_test_cpu(cpu, p->cpus_ptr))
> + cpu = scx_bpf_task_cpu(p);
> +
> + scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL_ON | cpu, SCX_SLICE_DFL, 0);
> + bpf_task_release(p);
> + } else {
> + scx_bpf_dsq_move_to_local(SHARED_DSQ);
> + }
> +}
> +
> +s32 BPF_STRUCT_OPS(dequeue_init_task, struct task_struct *p,
> + struct scx_init_task_args *args)
> +{
> + struct task_ctx *tctx;
> +
> + tctx = bpf_task_storage_get(&task_ctx_stor, p, 0,
> + BPF_LOCAL_STORAGE_GET_F_CREATE);
> + if (!tctx)
> + return -ENOMEM;
> +
> + return 0;
> +}
> +
> +s32 BPF_STRUCT_OPS_SLEEPABLE(dequeue_init)
> +{
> + s32 ret;
> +
> + ret = scx_bpf_create_dsq(SHARED_DSQ, -1);
> + if (ret)
> + return ret;
> +
> + return 0;
> +}
> +
> +void BPF_STRUCT_OPS(dequeue_exit, struct scx_exit_info *ei)
> +{
> + UEI_RECORD(uei, ei);
> +}
> +
> +SEC(".struct_ops.link")
> +struct sched_ext_ops dequeue_ops = {
> + .select_cpu = (void *)dequeue_select_cpu,
> + .enqueue = (void *)dequeue_enqueue,
> + .dequeue = (void *)dequeue_dequeue,
> + .dispatch = (void *)dequeue_dispatch,
> + .init_task = (void *)dequeue_init_task,
> + .init = (void *)dequeue_init,
> + .exit = (void *)dequeue_exit,
> + .timeout_ms = 5000,
> + .name = "dequeue_test",
> +};
> diff --git a/tools/testing/selftests/sched_ext/dequeue.c b/tools/testing/selftests/sched_ext/dequeue.c
> new file mode 100644
> index 0000000000000..8bc9d263aa05c
> --- /dev/null
> +++ b/tools/testing/selftests/sched_ext/dequeue.c
> @@ -0,0 +1,265 @@
> +// SPDX-License-Identifier: GPL-2.0
> +/*
> + * Copyright (c) 2025 NVIDIA Corporation.
> + */
> +#define _GNU_SOURCE
> +#include <stdio.h>
> +#include <unistd.h>
> +#include <signal.h>
> +#include <time.h>
> +#include <bpf/bpf.h>
> +#include <scx/common.h>
> +#include <sys/wait.h>
> +#include <sched.h>
> +#include <pthread.h>
> +#include "scx_test.h"
> +#include "dequeue.bpf.skel.h"
> +
> +#define NUM_WORKERS 8
> +#define AFFINITY_HAMMER_MS 50
> +
> +/*
> + * Worker function that creates enqueue/dequeue events via CPU work and
> + * sleeping. Property-change dequeues are triggered by the affinity hammer
> + * thread (external sched_setaffinity on worker PIDs).
> + */
> +static void worker_fn(int id)
> +{
> + int i;
> + volatile int sum = 0;
> +
> + for (i = 0; i < 1000; i++) {
> + int j;
> +
> + /* Do some work to trigger scheduling events */
> + for (j = 0; j < 10000; j++)
> + sum += j;
> +
> + /* Sleep to trigger dequeue */
> + usleep(1000 + (id * 100));
> + }
> +
> + exit(0);
> +}
> +
> +/*
> + * Property-change dequeues only happen when a task gets a property change
> + * while still in the queue. This thread changes workers' affinity from
> + * outside so that some changes hit tasks while they are still in the
> + * queue.
> + */
> +static void *affinity_hammer_fn(void *arg)
> +{
> + pid_t *pids = arg;
> + cpu_set_t cpuset;
> + int i, n = NUM_WORKERS;
> + struct timespec ts = { .tv_sec = 0, .tv_nsec = 1000000 }; /* 1ms */
> +
> + for (i = 0; i < (AFFINITY_HAMMER_MS * 1000 / 100); i++) {
> + int w = i % n;
> + int cpu = (i / n) % 4;
> +
> + CPU_ZERO(&cpuset);
> + CPU_SET(cpu, &cpuset);
> + sched_setaffinity(pids[w], sizeof(cpuset), &cpuset);
> + nanosleep(&ts, NULL);
> + }
> +
> + return NULL;
> +}
> +
> +static enum scx_test_status run_scenario(struct dequeue *skel, u32 scenario,
> + const char *scenario_name)
> +{
> + struct bpf_link *link;
> + pid_t pids[NUM_WORKERS];
> + pthread_t hammer;
> +
> + int i, status;
> + u64 enq_start, deq_start,
> + dispatch_deq_start, change_deq_start, bpf_queue_full_start;
> + u64 enq_delta, deq_delta,
> + dispatch_deq_delta, change_deq_delta, bpf_queue_full_delta;
> +
> + /* Set the test scenario */
> + skel->bss->test_scenario = scenario;
> +
> + /* Record starting counts */
> + enq_start = skel->bss->enqueue_cnt;
> + deq_start = skel->bss->dequeue_cnt;
> + dispatch_deq_start = skel->bss->dispatch_dequeue_cnt;
> + change_deq_start = skel->bss->change_dequeue_cnt;
> + bpf_queue_full_start = skel->bss->bpf_queue_full;
> +
> + link = bpf_map__attach_struct_ops(skel->maps.dequeue_ops);
> + SCX_FAIL_IF(!link, "Failed to attach struct_ops for scenario %s", scenario_name);
> +
> + /* Fork worker processes to generate enqueue/dequeue events */
> + for (i = 0; i < NUM_WORKERS; i++) {
> + pids[i] = fork();
> + SCX_FAIL_IF(pids[i] < 0, "Failed to fork worker %d", i);
> +
> + if (pids[i] == 0) {
> + worker_fn(i);
> + /* Should not reach here */
> + exit(1);
> + }
> + }
> +
> + /*
> + * Run an "affinity hammer" so that some property changes hit tasks
> + * while they are still in BPF custody (e.g. in user DSQ or BPF queue),
> + * triggering SCX_DEQ_SCHED_CHANGE dequeues in scenarios 2, 3, 6 and 7.
Not true for 3, right?
> + */
> + SCX_FAIL_IF(pthread_create(&hammer, NULL, affinity_hammer_fn, pids) != 0,
> + "Failed to create affinity hammer thread");
> + pthread_join(hammer, NULL);
> +
> + /* Wait for all workers to complete */
> + for (i = 0; i < NUM_WORKERS; i++) {
> + SCX_FAIL_IF(waitpid(pids[i], &status, 0) != pids[i],
> + "Failed to wait for worker %d", i);
> + SCX_FAIL_IF(status != 0, "Worker %d exited with status %d", i, status);
> + }
> +
> + bpf_link__destroy(link);
> +
> + SCX_EQ(skel->data->uei.kind, EXIT_KIND(SCX_EXIT_UNREG));
> +
> + /* Calculate deltas */
> + enq_delta = skel->bss->enqueue_cnt - enq_start;
> + deq_delta = skel->bss->dequeue_cnt - deq_start;
> + dispatch_deq_delta = skel->bss->dispatch_dequeue_cnt - dispatch_deq_start;
> + change_deq_delta = skel->bss->change_dequeue_cnt - change_deq_start;
> + bpf_queue_full_delta = skel->bss->bpf_queue_full - bpf_queue_full_start;
> +
> + printf("%s:\n", scenario_name);
> + printf(" enqueues: %lu\n", (unsigned long)enq_delta);
> + printf(" dequeues: %lu (dispatch: %lu, property_change: %lu)\n",
> + (unsigned long)deq_delta,
> + (unsigned long)dispatch_deq_delta,
> + (unsigned long)change_deq_delta);
> + printf(" BPF queue full: %lu\n", (unsigned long)bpf_queue_full_delta);
> +
> + /*
> + * Validate enqueue/dequeue lifecycle tracking.
> + *
> + * For scenarios 0, 1, 3, 4 (local and global DSQs from
> + * ops.select_cpu() and ops.enqueue()), both enqueues and dequeues
> + * should be 0 because tasks bypass the BPF scheduler entirely:
> + * tasks never enter BPF scheduler's custody.
> + *
> + * For scenarios 2, 5, 6 (user DSQ or BPF internal queue) we expect
> + * both enqueues and dequeues.
> + *
> + * The BPF code does strict state machine validation with
> + * scx_bpf_error() to ensure the workflow semantics are correct.
> + *
> + * If we reach this point without errors, the semantics are
> + * validated correctly.
> + */
> + if (scenario == 0 || scenario == 1 ||
> + scenario == 3 || scenario == 4) {
> + /* Tasks bypass BPF scheduler completely */
> + SCX_EQ(enq_delta, 0);
> + SCX_EQ(deq_delta, 0);
> + SCX_EQ(dispatch_deq_delta, 0);
> + SCX_EQ(change_deq_delta, 0);
> + } else {
> + /*
> + * User DSQ from ops.enqueue() or ops.select_cpu(): tasks
> + * enter BPF scheduler's custody.
> + *
> + * Also validate 1:1 enqueue/dequeue pairing.
> + */
> + SCX_GT(enq_delta, 0);
> + SCX_GT(deq_delta, 0);
> + SCX_EQ(enq_delta, deq_delta);
> + }
> +
> + return SCX_TEST_PASS;
> +}
> +
> +static enum scx_test_status setup(void **ctx)
> +{
> + struct dequeue *skel;
> +
> + skel = dequeue__open();
> + SCX_FAIL_IF(!skel, "Failed to open skel");
> + SCX_ENUM_INIT(skel);
> + SCX_FAIL_IF(dequeue__load(skel), "Failed to load skel");
> +
> + *ctx = skel;
> +
> + return SCX_TEST_PASS;
> +}
> +
> +static enum scx_test_status run(void *ctx)
> +{
> + struct dequeue *skel = ctx;
> + enum scx_test_status status;
> +
> + status = run_scenario(skel, 0, "Scenario 0: Local DSQ from ops.select_cpu()");
> + if (status != SCX_TEST_PASS)
> + return status;
> +
> + status = run_scenario(skel, 1, "Scenario 1: Global DSQ from ops.select_cpu()");
> + if (status != SCX_TEST_PASS)
> + return status;
> +
> + status = run_scenario(skel, 2, "Scenario 2: User DSQ from ops.select_cpu()");
> + if (status != SCX_TEST_PASS)
> + return status;
> +
> + status = run_scenario(skel, 3, "Scenario 3: Local DSQ from ops.enqueue()");
> + if (status != SCX_TEST_PASS)
> + return status;
> +
> + status = run_scenario(skel, 4, "Scenario 4: Global DSQ from ops.enqueue()");
> + if (status != SCX_TEST_PASS)
> + return status;
> +
> + status = run_scenario(skel, 5, "Scenario 5: User DSQ from ops.enqueue()");
> + if (status != SCX_TEST_PASS)
> + return status;
> +
> + status = run_scenario(skel, 6, "Scenario 6: BPF queue from ops.enqueue()");
> + if (status != SCX_TEST_PASS)
> + return status;
> +
> + printf("\n=== Summary ===\n");
> + printf("Total enqueues: %lu\n", (unsigned long)skel->bss->enqueue_cnt);
> + printf("Total dequeues: %lu\n", (unsigned long)skel->bss->dequeue_cnt);
> + printf(" Dispatch dequeues: %lu (no flag, normal workflow)\n",
> + (unsigned long)skel->bss->dispatch_dequeue_cnt);
> + printf(" Property change dequeues: %lu (SCX_DEQ_SCHED_CHANGE flag)\n",
> + (unsigned long)skel->bss->change_dequeue_cnt);
> + printf(" BPF queue full: %lu\n",
> + (unsigned long)skel->bss->bpf_queue_full);
> + printf("\nAll scenarios passed - no state machine violations detected\n");
> + printf("-> Validated: Local DSQ dispatch bypasses BPF scheduler\n");
> + printf("-> Validated: Global DSQ dispatch bypasses BPF scheduler\n");
> + printf("-> Validated: User DSQ dispatch triggers ops.dequeue() callbacks\n");
> + printf("-> Validated: Dispatch dequeues have no flags (normal workflow)\n");
> + printf("-> Validated: Property change dequeues have SCX_DEQ_SCHED_CHANGE flag\n");
> + printf("-> Validated: No duplicate enqueues or invalid state transitions\n");
> +
> + return SCX_TEST_PASS;
> +}
> +
> +static void cleanup(void *ctx)
> +{
> + struct dequeue *skel = ctx;
> +
> + dequeue__destroy(skel);
> +}
> +
> +struct scx_test dequeue_test = {
> + .name = "dequeue",
> + .description = "Verify ops.dequeue() semantics",
> + .setup = setup,
> + .run = run,
> + .cleanup = cleanup,
> +};
> +
> +REGISTER_SCX_TEST(&dequeue_test)
^ permalink raw reply [flat|nested] 41+ messages in thread
* [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-10 21:26 [PATCHSET v8] sched_ext: Fix " Andrea Righi
@ 2026-02-10 21:26 ` Andrea Righi
2026-02-12 17:15 ` Christian Loehle
0 siblings, 1 reply; 41+ messages in thread
From: Andrea Righi @ 2026-02-10 21:26 UTC (permalink / raw)
To: Tejun Heo, David Vernet, Changwoo Min
Cc: Kuba Piecuch, Emil Tsalapatis, Christian Loehle, Daniel Hodges,
sched-ext, linux-kernel
Add a new kselftest to validate that the new ops.dequeue() semantics
work correctly for all task lifecycle scenarios, including the
distinction between terminal DSQs (where BPF scheduler is done with the
task), user DSQs (where BPF scheduler manages the task lifecycle) and
BPF data structures, regardless of which event performs the dispatch.
The test validates the following scenarios:
- From ops.select_cpu():
- scenario 0 (local DSQ): tasks dispatched to the local DSQ bypass
the BPF scheduler entirely; they never enter BPF custody, so
ops.dequeue() is not called,
- scenario 1 (global DSQ): tasks dispatched to SCX_DSQ_GLOBAL also
bypass the BPF scheduler, like the local DSQ; ops.dequeue() is
not called,
- scenario 2 (user DSQ): tasks dispatched to user DSQs from
ops.select_cpu(): tasks enter BPF scheduler's custody with full
enqueue/dequeue lifecycle tracking and state machine validation,
expects 1:1 enqueue/dequeue pairing,
- From ops.enqueue():
- scenario 3 (local DSQ): same behavior as scenario 0,
- scenario 4 (global DSQ): same behavior as scenario 1,
- scenario 5 (user DSQ): same behavior as scenario 2,
- scenario 6 (BPF internal queue): tasks are stored in a BPF queue
from ops.enqueue() and consumed from ops.dispatch(); similarly to
scenario 5, tasks enter BPF scheduler's custody with full
lifecycle tracking and 1:1 enqueue/dequeue validation.
This verifies that:
- terminal DSQ dispatch (local, global) don't trigger ops.dequeue(),
- tasks dispatched to user DSQs, either from ops.select_cpu() or
ops.enqueue(), enter BPF scheduler's custody and have exact 1:1
enqueue/dequeue pairing,
- tasks stored to internal BPF data structures from ops.enqueue() enter
BPF scheduler's custody and have exact 1:1 enqueue/dequeue pairing,
- dispatch dequeues have no flags (normal workflow),
- property change dequeues have the %SCX_DEQ_SCHED_CHANGE flag set,
- no duplicate enqueues or invalid state transitions are happening.
Cc: Tejun Heo <tj@kernel.org>
Cc: Emil Tsalapatis <emil@etsalapatis.com>
Cc: Kuba Piecuch <jpiecuch@google.com>
Signed-off-by: Andrea Righi <arighi@nvidia.com>
---
tools/testing/selftests/sched_ext/Makefile | 1 +
.../testing/selftests/sched_ext/dequeue.bpf.c | 368 ++++++++++++++++++
tools/testing/selftests/sched_ext/dequeue.c | 265 +++++++++++++
3 files changed, 634 insertions(+)
create mode 100644 tools/testing/selftests/sched_ext/dequeue.bpf.c
create mode 100644 tools/testing/selftests/sched_ext/dequeue.c
diff --git a/tools/testing/selftests/sched_ext/Makefile b/tools/testing/selftests/sched_ext/Makefile
index 5fe45f9c5f8fd..764e91edabf93 100644
--- a/tools/testing/selftests/sched_ext/Makefile
+++ b/tools/testing/selftests/sched_ext/Makefile
@@ -161,6 +161,7 @@ all_test_bpfprogs := $(foreach prog,$(wildcard *.bpf.c),$(INCLUDE_DIR)/$(patsubs
auto-test-targets := \
create_dsq \
+ dequeue \
enq_last_no_enq_fails \
ddsp_bogus_dsq_fail \
ddsp_vtimelocal_fail \
diff --git a/tools/testing/selftests/sched_ext/dequeue.bpf.c b/tools/testing/selftests/sched_ext/dequeue.bpf.c
new file mode 100644
index 0000000000000..d9d12f14cd673
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/dequeue.bpf.c
@@ -0,0 +1,368 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * A scheduler that validates ops.dequeue() is called correctly:
+ * - Tasks dispatched to terminal DSQs (local, global) bypass the BPF
+ * scheduler entirely: no ops.dequeue() should be called
+ * - Tasks dispatched to user DSQs from ops.enqueue() enter BPF custody:
+ * ops.dequeue() must be called when they leave custody
+ * - Every ops.enqueue() dispatch to non-terminal DSQs is followed by
+ * exactly one ops.dequeue() (validate 1:1 pairing and state machine)
+ *
+ * Copyright (c) 2026 NVIDIA Corporation.
+ */
+
+#include <scx/common.bpf.h>
+
+#define SHARED_DSQ 0
+
+/*
+ * BPF internal queue.
+ *
+ * Tasks are stored here and consumed from ops.dispatch(), validating that
+ * tasks on BPF internal structures still get ops.dequeue() when they
+ * leave.
+ */
+struct {
+ __uint(type, BPF_MAP_TYPE_QUEUE);
+ __uint(max_entries, 32768);
+ __type(value, s32);
+} global_queue SEC(".maps");
+
+char _license[] SEC("license") = "GPL";
+
+UEI_DEFINE(uei);
+
+/*
+ * Counters to track the lifecycle of tasks:
+ * - enqueue_cnt: Number of times ops.enqueue() was called
+ * - dequeue_cnt: Number of times ops.dequeue() was called (any type)
+ * - dispatch_dequeue_cnt: Number of regular dispatch dequeues (no flag)
+ * - change_dequeue_cnt: Number of property change dequeues
+ * - bpf_queue_full: Number of times the BPF internal queue was full
+ */
+u64 enqueue_cnt, dequeue_cnt, dispatch_dequeue_cnt, change_dequeue_cnt, bpf_queue_full;
+
+/*
+ * Test scenarios:
+ * 0) Dispatch to local DSQ from ops.select_cpu() (terminal DSQ, bypasses BPF
+ * scheduler, no dequeue callbacks)
+ * 1) Dispatch to global DSQ from ops.select_cpu() (terminal DSQ, bypasses BPF
+ * scheduler, no dequeue callbacks)
+ * 2) Dispatch to shared user DSQ from ops.select_cpu() (enters BPF scheduler,
+ * dequeue callbacks expected)
+ * 3) Dispatch to local DSQ from ops.enqueue() (terminal DSQ, bypasses BPF
+ * scheduler, no dequeue callbacks)
+ * 4) Dispatch to global DSQ from ops.enqueue() (terminal DSQ, bypasses BPF
+ * scheduler, no dequeue callbacks)
+ * 5) Dispatch to shared user DSQ from ops.enqueue() (enters BPF scheduler,
+ * dequeue callbacks expected)
+ * 6) BPF internal queue from ops.enqueue(): store task PIDs in ops.enqueue(),
+ * consume in ops.dispatch() and dispatch to local DSQ (validates dequeue
+ * for tasks stored in internal BPF data structures)
+ */
+u32 test_scenario;
+
+/*
+ * Per-task state to track lifecycle and validate workflow semantics.
+ * State transitions:
+ * NONE -> ENQUEUED (on enqueue)
+ * ENQUEUED -> DISPATCHED (on dispatch dequeue)
+ * DISPATCHED -> NONE (on property change dequeue or re-enqueue)
+ * ENQUEUED -> NONE (on property change dequeue before dispatch)
+ */
+enum task_state {
+ TASK_NONE = 0,
+ TASK_ENQUEUED,
+ TASK_DISPATCHED,
+};
+
+struct task_ctx {
+ enum task_state state; /* Current state in the workflow */
+ u64 enqueue_seq; /* Sequence number for debugging */
+};
+
+struct {
+ __uint(type, BPF_MAP_TYPE_TASK_STORAGE);
+ __uint(map_flags, BPF_F_NO_PREALLOC);
+ __type(key, int);
+ __type(value, struct task_ctx);
+} task_ctx_stor SEC(".maps");
+
+static struct task_ctx *try_lookup_task_ctx(struct task_struct *p)
+{
+ return bpf_task_storage_get(&task_ctx_stor, p, 0, 0);
+}
+
+s32 BPF_STRUCT_OPS(dequeue_select_cpu, struct task_struct *p,
+ s32 prev_cpu, u64 wake_flags)
+{
+ struct task_ctx *tctx;
+ s32 pid = p->pid;
+
+ tctx = try_lookup_task_ctx(p);
+ if (!tctx)
+ return prev_cpu;
+
+ switch (test_scenario) {
+ case 0:
+ /*
+ * Direct dispatch to the local DSQ.
+ *
+ * Task bypasses BPF scheduler entirely: no enqueue
+ * tracking, no ops.dequeue() callbacks.
+ */
+ scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, 0);
+ tctx->state = TASK_DISPATCHED;
+ break;
+ case 1:
+ /*
+ * Direct dispatch to the global DSQ.
+ *
+ * Task bypasses BPF scheduler entirely: no enqueue
+ * tracking, no ops.dequeue() callbacks.
+ */
+ scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, 0);
+ tctx->state = TASK_DISPATCHED;
+ break;
+ case 2:
+ /*
+ * Dispatch to shared a user DSQ.
+ *
+ * Task enters BPF scheduler management: track
+ * enqueue/dequeue lifecycle and validate state
+ * transitions.
+ */
+ if (tctx->state == TASK_ENQUEUED)
+ scx_bpf_error("%d (%s): enqueue while in ENQUEUED state seq=%llu",
+ p->pid, p->comm, tctx->enqueue_seq);
+
+ scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, 0);
+
+ __sync_fetch_and_add(&enqueue_cnt, 1);
+
+ tctx->state = TASK_ENQUEUED;
+ tctx->enqueue_seq++;
+ break;
+ }
+
+ return prev_cpu;
+}
+
+void BPF_STRUCT_OPS(dequeue_enqueue, struct task_struct *p, u64 enq_flags)
+{
+ struct task_ctx *tctx;
+ s32 pid = p->pid;
+
+ tctx = try_lookup_task_ctx(p);
+ if (!tctx)
+ return;
+
+ switch (test_scenario) {
+ case 3:
+ /*
+ * Direct dispatch to the local DSQ.
+ *
+ * Task bypasses BPF scheduler entirely: no enqueue
+ * tracking, no ops.dequeue() callbacks.
+ */
+ scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, enq_flags);
+ break;
+ case 4:
+ /*
+ * Direct dispatch to the global DSQ.
+ *
+ * Task bypasses BPF scheduler entirely: no enqueue
+ * tracking, no ops.dequeue() callbacks.
+ */
+ scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
+ break;
+ case 5:
+ /*
+ * Dispatch to shared user DSQ.
+ *
+ * Task enters BPF scheduler management: track
+ * enqueue/dequeue lifecycle and validate state
+ * transitions.
+ */
+ if (tctx->state == TASK_ENQUEUED)
+ scx_bpf_error("%d (%s): enqueue while in ENQUEUED state seq=%llu",
+ p->pid, p->comm, tctx->enqueue_seq);
+
+ scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, enq_flags);
+
+ __sync_fetch_and_add(&enqueue_cnt, 1);
+
+ tctx->state = TASK_ENQUEUED;
+ tctx->enqueue_seq++;
+ break;
+ case 6:
+ /*
+ * Store task in BPF internal queue.
+ *
+ * Task enters BPF scheduler management: track
+ * enqueue/dequeue lifecycle and validate state
+ * transitions.
+ */
+ if (tctx->state == TASK_ENQUEUED)
+ scx_bpf_error("%d (%s): enqueue while in ENQUEUED state seq=%llu",
+ p->pid, p->comm, tctx->enqueue_seq);
+
+ if (bpf_map_push_elem(&global_queue, &pid, 0)) {
+ scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
+ __sync_fetch_and_add(&bpf_queue_full, 1);
+
+ tctx->state = TASK_DISPATCHED;
+ } else {
+ __sync_fetch_and_add(&enqueue_cnt, 1);
+
+ tctx->state = TASK_ENQUEUED;
+ tctx->enqueue_seq++;
+ }
+ break;
+ default:
+ /* For all other scenarios, dispatch to the global DSQ */
+ scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
+ tctx->state = TASK_DISPATCHED;
+ break;
+ }
+
+ scx_bpf_kick_cpu(scx_bpf_task_cpu(p), SCX_KICK_IDLE);
+}
+
+void BPF_STRUCT_OPS(dequeue_dequeue, struct task_struct *p, u64 deq_flags)
+{
+ struct task_ctx *tctx;
+
+ __sync_fetch_and_add(&dequeue_cnt, 1);
+
+ tctx = try_lookup_task_ctx(p);
+ if (!tctx)
+ return;
+
+ /*
+ * For scenarios 0, 1, 3, and 4 (terminal DSQs: local and global),
+ * ops.dequeue() should never be called because tasks bypass the
+ * BPF scheduler entirely. If we get here, it's a kernel bug.
+ */
+ if (test_scenario == 0 || test_scenario == 3) {
+ scx_bpf_error("%d (%s): dequeue called for local DSQ scenario",
+ p->pid, p->comm);
+ return;
+ }
+
+ if (test_scenario == 1 || test_scenario == 4) {
+ scx_bpf_error("%d (%s): dequeue called for global DSQ scenario",
+ p->pid, p->comm);
+ return;
+ }
+
+ if (deq_flags & SCX_DEQ_SCHED_CHANGE) {
+ /*
+ * Property change interrupting the workflow. Valid from
+ * both ENQUEUED and DISPATCHED states. Transitions task
+ * back to NONE state.
+ */
+ __sync_fetch_and_add(&change_dequeue_cnt, 1);
+
+ /* Validate state transition */
+ if (tctx->state != TASK_ENQUEUED && tctx->state != TASK_DISPATCHED)
+ scx_bpf_error("%d (%s): invalid property change dequeue state=%d seq=%llu",
+ p->pid, p->comm, tctx->state, tctx->enqueue_seq);
+
+ /* Transition back to NONE: task outside scheduler control */
+ tctx->state = TASK_NONE;
+ } else {
+ /*
+ * Regular dispatch dequeue: normal workflow step. Valid
+ * only from ENQUEUED state (after enqueue, before dispatch
+ * dequeue). Transitions to DISPATCHED state.
+ */
+ __sync_fetch_and_add(&dispatch_dequeue_cnt, 1);
+
+ /*
+ * Dispatch dequeue should not have %SCX_DEQ_SCHED_CHANGE
+ * flag.
+ */
+ if (deq_flags & SCX_DEQ_SCHED_CHANGE)
+ scx_bpf_error("%d (%s): SCX_DEQ_SCHED_CHANGE in dispatch dequeue seq=%llu",
+ p->pid, p->comm, tctx->enqueue_seq);
+
+ /*
+ * Must be in ENQUEUED state.
+ */
+ if (tctx->state != TASK_ENQUEUED)
+ scx_bpf_error("%d (%s): dispatch dequeue from state %d seq=%llu",
+ p->pid, p->comm, tctx->state, tctx->enqueue_seq);
+
+ /*
+ * Transition to DISPATCHED: normal cycle completed
+ * dispatch.
+ */
+ tctx->state = TASK_DISPATCHED;
+ }
+}
+
+void BPF_STRUCT_OPS(dequeue_dispatch, s32 cpu, struct task_struct *prev)
+{
+ if (test_scenario == 6) {
+ struct task_struct *p;
+ s32 pid;
+
+ if (bpf_map_pop_elem(&global_queue, &pid))
+ return;
+
+ p = bpf_task_from_pid(pid);
+ if (!p)
+ return;
+
+ if (!bpf_cpumask_test_cpu(cpu, p->cpus_ptr))
+ cpu = scx_bpf_task_cpu(p);
+
+ scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL_ON | cpu, SCX_SLICE_DFL, 0);
+ bpf_task_release(p);
+ } else {
+ scx_bpf_dsq_move_to_local(SHARED_DSQ);
+ }
+}
+
+s32 BPF_STRUCT_OPS(dequeue_init_task, struct task_struct *p,
+ struct scx_init_task_args *args)
+{
+ struct task_ctx *tctx;
+
+ tctx = bpf_task_storage_get(&task_ctx_stor, p, 0,
+ BPF_LOCAL_STORAGE_GET_F_CREATE);
+ if (!tctx)
+ return -ENOMEM;
+
+ return 0;
+}
+
+s32 BPF_STRUCT_OPS_SLEEPABLE(dequeue_init)
+{
+ s32 ret;
+
+ ret = scx_bpf_create_dsq(SHARED_DSQ, -1);
+ if (ret)
+ return ret;
+
+ return 0;
+}
+
+void BPF_STRUCT_OPS(dequeue_exit, struct scx_exit_info *ei)
+{
+ UEI_RECORD(uei, ei);
+}
+
+SEC(".struct_ops.link")
+struct sched_ext_ops dequeue_ops = {
+ .select_cpu = (void *)dequeue_select_cpu,
+ .enqueue = (void *)dequeue_enqueue,
+ .dequeue = (void *)dequeue_dequeue,
+ .dispatch = (void *)dequeue_dispatch,
+ .init_task = (void *)dequeue_init_task,
+ .init = (void *)dequeue_init,
+ .exit = (void *)dequeue_exit,
+ .timeout_ms = 5000,
+ .name = "dequeue_test",
+};
diff --git a/tools/testing/selftests/sched_ext/dequeue.c b/tools/testing/selftests/sched_ext/dequeue.c
new file mode 100644
index 0000000000000..8bc9d263aa05c
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/dequeue.c
@@ -0,0 +1,265 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Copyright (c) 2025 NVIDIA Corporation.
+ */
+#define _GNU_SOURCE
+#include <stdio.h>
+#include <unistd.h>
+#include <signal.h>
+#include <time.h>
+#include <bpf/bpf.h>
+#include <scx/common.h>
+#include <sys/wait.h>
+#include <sched.h>
+#include <pthread.h>
+#include "scx_test.h"
+#include "dequeue.bpf.skel.h"
+
+#define NUM_WORKERS 8
+#define AFFINITY_HAMMER_MS 50
+
+/*
+ * Worker function that creates enqueue/dequeue events via CPU work and
+ * sleeping. Property-change dequeues are triggered by the affinity hammer
+ * thread (external sched_setaffinity on worker PIDs).
+ */
+static void worker_fn(int id)
+{
+ int i;
+ volatile int sum = 0;
+
+ for (i = 0; i < 1000; i++) {
+ int j;
+
+ /* Do some work to trigger scheduling events */
+ for (j = 0; j < 10000; j++)
+ sum += j;
+
+ /* Sleep to trigger dequeue */
+ usleep(1000 + (id * 100));
+ }
+
+ exit(0);
+}
+
+/*
+ * Property-change dequeues only happen when a task gets a property change
+ * while still in the queue. This thread changes workers' affinity from
+ * outside so that some changes hit tasks while they are still in the
+ * queue.
+ */
+static void *affinity_hammer_fn(void *arg)
+{
+ pid_t *pids = arg;
+ cpu_set_t cpuset;
+ int i, n = NUM_WORKERS;
+ struct timespec ts = { .tv_sec = 0, .tv_nsec = 1000000 }; /* 1ms */
+
+ for (i = 0; i < (AFFINITY_HAMMER_MS * 1000 / 100); i++) {
+ int w = i % n;
+ int cpu = (i / n) % 4;
+
+ CPU_ZERO(&cpuset);
+ CPU_SET(cpu, &cpuset);
+ sched_setaffinity(pids[w], sizeof(cpuset), &cpuset);
+ nanosleep(&ts, NULL);
+ }
+
+ return NULL;
+}
+
+static enum scx_test_status run_scenario(struct dequeue *skel, u32 scenario,
+ const char *scenario_name)
+{
+ struct bpf_link *link;
+ pid_t pids[NUM_WORKERS];
+ pthread_t hammer;
+
+ int i, status;
+ u64 enq_start, deq_start,
+ dispatch_deq_start, change_deq_start, bpf_queue_full_start;
+ u64 enq_delta, deq_delta,
+ dispatch_deq_delta, change_deq_delta, bpf_queue_full_delta;
+
+ /* Set the test scenario */
+ skel->bss->test_scenario = scenario;
+
+ /* Record starting counts */
+ enq_start = skel->bss->enqueue_cnt;
+ deq_start = skel->bss->dequeue_cnt;
+ dispatch_deq_start = skel->bss->dispatch_dequeue_cnt;
+ change_deq_start = skel->bss->change_dequeue_cnt;
+ bpf_queue_full_start = skel->bss->bpf_queue_full;
+
+ link = bpf_map__attach_struct_ops(skel->maps.dequeue_ops);
+ SCX_FAIL_IF(!link, "Failed to attach struct_ops for scenario %s", scenario_name);
+
+ /* Fork worker processes to generate enqueue/dequeue events */
+ for (i = 0; i < NUM_WORKERS; i++) {
+ pids[i] = fork();
+ SCX_FAIL_IF(pids[i] < 0, "Failed to fork worker %d", i);
+
+ if (pids[i] == 0) {
+ worker_fn(i);
+ /* Should not reach here */
+ exit(1);
+ }
+ }
+
+ /*
+ * Run an "affinity hammer" so that some property changes hit tasks
+ * while they are still in BPF custody (e.g. in user DSQ or BPF queue),
+ * triggering SCX_DEQ_SCHED_CHANGE dequeues in scenarios 2, 3, 6 and 7.
+ */
+ SCX_FAIL_IF(pthread_create(&hammer, NULL, affinity_hammer_fn, pids) != 0,
+ "Failed to create affinity hammer thread");
+ pthread_join(hammer, NULL);
+
+ /* Wait for all workers to complete */
+ for (i = 0; i < NUM_WORKERS; i++) {
+ SCX_FAIL_IF(waitpid(pids[i], &status, 0) != pids[i],
+ "Failed to wait for worker %d", i);
+ SCX_FAIL_IF(status != 0, "Worker %d exited with status %d", i, status);
+ }
+
+ bpf_link__destroy(link);
+
+ SCX_EQ(skel->data->uei.kind, EXIT_KIND(SCX_EXIT_UNREG));
+
+ /* Calculate deltas */
+ enq_delta = skel->bss->enqueue_cnt - enq_start;
+ deq_delta = skel->bss->dequeue_cnt - deq_start;
+ dispatch_deq_delta = skel->bss->dispatch_dequeue_cnt - dispatch_deq_start;
+ change_deq_delta = skel->bss->change_dequeue_cnt - change_deq_start;
+ bpf_queue_full_delta = skel->bss->bpf_queue_full - bpf_queue_full_start;
+
+ printf("%s:\n", scenario_name);
+ printf(" enqueues: %lu\n", (unsigned long)enq_delta);
+ printf(" dequeues: %lu (dispatch: %lu, property_change: %lu)\n",
+ (unsigned long)deq_delta,
+ (unsigned long)dispatch_deq_delta,
+ (unsigned long)change_deq_delta);
+ printf(" BPF queue full: %lu\n", (unsigned long)bpf_queue_full_delta);
+
+ /*
+ * Validate enqueue/dequeue lifecycle tracking.
+ *
+ * For scenarios 0, 1, 3, 4 (local and global DSQs from
+ * ops.select_cpu() and ops.enqueue()), both enqueues and dequeues
+ * should be 0 because tasks bypass the BPF scheduler entirely:
+ * tasks never enter BPF scheduler's custody.
+ *
+ * For scenarios 2, 5, 6 (user DSQ or BPF internal queue) we expect
+ * both enqueues and dequeues.
+ *
+ * The BPF code does strict state machine validation with
+ * scx_bpf_error() to ensure the workflow semantics are correct.
+ *
+ * If we reach this point without errors, the semantics are
+ * validated correctly.
+ */
+ if (scenario == 0 || scenario == 1 ||
+ scenario == 3 || scenario == 4) {
+ /* Tasks bypass BPF scheduler completely */
+ SCX_EQ(enq_delta, 0);
+ SCX_EQ(deq_delta, 0);
+ SCX_EQ(dispatch_deq_delta, 0);
+ SCX_EQ(change_deq_delta, 0);
+ } else {
+ /*
+ * User DSQ from ops.enqueue() or ops.select_cpu(): tasks
+ * enter BPF scheduler's custody.
+ *
+ * Also validate 1:1 enqueue/dequeue pairing.
+ */
+ SCX_GT(enq_delta, 0);
+ SCX_GT(deq_delta, 0);
+ SCX_EQ(enq_delta, deq_delta);
+ }
+
+ return SCX_TEST_PASS;
+}
+
+static enum scx_test_status setup(void **ctx)
+{
+ struct dequeue *skel;
+
+ skel = dequeue__open();
+ SCX_FAIL_IF(!skel, "Failed to open skel");
+ SCX_ENUM_INIT(skel);
+ SCX_FAIL_IF(dequeue__load(skel), "Failed to load skel");
+
+ *ctx = skel;
+
+ return SCX_TEST_PASS;
+}
+
+static enum scx_test_status run(void *ctx)
+{
+ struct dequeue *skel = ctx;
+ enum scx_test_status status;
+
+ status = run_scenario(skel, 0, "Scenario 0: Local DSQ from ops.select_cpu()");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 1, "Scenario 1: Global DSQ from ops.select_cpu()");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 2, "Scenario 2: User DSQ from ops.select_cpu()");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 3, "Scenario 3: Local DSQ from ops.enqueue()");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 4, "Scenario 4: Global DSQ from ops.enqueue()");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 5, "Scenario 5: User DSQ from ops.enqueue()");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 6, "Scenario 6: BPF queue from ops.enqueue()");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ printf("\n=== Summary ===\n");
+ printf("Total enqueues: %lu\n", (unsigned long)skel->bss->enqueue_cnt);
+ printf("Total dequeues: %lu\n", (unsigned long)skel->bss->dequeue_cnt);
+ printf(" Dispatch dequeues: %lu (no flag, normal workflow)\n",
+ (unsigned long)skel->bss->dispatch_dequeue_cnt);
+ printf(" Property change dequeues: %lu (SCX_DEQ_SCHED_CHANGE flag)\n",
+ (unsigned long)skel->bss->change_dequeue_cnt);
+ printf(" BPF queue full: %lu\n",
+ (unsigned long)skel->bss->bpf_queue_full);
+ printf("\nAll scenarios passed - no state machine violations detected\n");
+ printf("-> Validated: Local DSQ dispatch bypasses BPF scheduler\n");
+ printf("-> Validated: Global DSQ dispatch bypasses BPF scheduler\n");
+ printf("-> Validated: User DSQ dispatch triggers ops.dequeue() callbacks\n");
+ printf("-> Validated: Dispatch dequeues have no flags (normal workflow)\n");
+ printf("-> Validated: Property change dequeues have SCX_DEQ_SCHED_CHANGE flag\n");
+ printf("-> Validated: No duplicate enqueues or invalid state transitions\n");
+
+ return SCX_TEST_PASS;
+}
+
+static void cleanup(void *ctx)
+{
+ struct dequeue *skel = ctx;
+
+ dequeue__destroy(skel);
+}
+
+struct scx_test dequeue_test = {
+ .name = "dequeue",
+ .description = "Verify ops.dequeue() semantics",
+ .setup = setup,
+ .run = run,
+ .cleanup = cleanup,
+};
+
+REGISTER_SCX_TEST(&dequeue_test)
--
2.53.0
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-10 0:42 ` Tejun Heo
@ 2026-02-10 7:29 ` Andrea Righi
0 siblings, 0 replies; 41+ messages in thread
From: Andrea Righi @ 2026-02-10 7:29 UTC (permalink / raw)
To: Tejun Heo
Cc: Emil Tsalapatis, David Vernet, Changwoo Min, Kuba Piecuch,
Christian Loehle, Daniel Hodges, sched-ext, linux-kernel
On Mon, Feb 09, 2026 at 02:42:04PM -1000, Tejun Heo wrote:
> Hello, Andrea.
>
> On Mon, Feb 09, 2026 at 11:22:19PM +0100, Andrea Righi wrote:
> ...
> > Ok, what you're saying is that a direct dispatch from ops.select_cpu() is
> > just a shortcut for work that would otherwise happen at the head of
> > ops.enqueue().
> >
> > So, while ops.select_cpu() itself is not "being in scheduler custody", the
> > semantic operation of dispatching a task is still the scheduler taking
> > control of the task. As a result, a dispatch to a user DSQ from
> > ops.select_cpu() should be treated the same as a dispatch to a user DSQ
> > from ops.enqueue() for the purpose of triggering ops.dequeue(). The fact
> > that this happens in ops.select_cpu() rather than ops.enqueue() is an
> > implementation detail, not a semantic boundary.
>
> Yes.
>
> > Under this interpretation, storing a task in BPF internal data structures
> > from ops.select_cpu() should not trigger ops.dequeue(), since the task has
> > not been put under scheduler control yet. However, dispatching a task to a
>
> Also, ops.select_cpu() putting the task in a BPF struct doesn't affect
> what's happening in the enqueue path. ops.enqueue() will still be invoked
> and the task will be transferred to BPF side iff ops.enqueue() does not
> perform a direct dispatch. Imagine the following (unlikely but possible)
> scenario:
>
> CPU A CPU B
>
> ops.select_cpu() puts task in a BPF
> data structure
> ops.dispatch() sees the task, dequeues it and
> dispatches it to CPU B's local DSQ.
>
> finish_dispatch() runs but the task is still
> SCX_OPSS_NONE and dispatch attempt is ignored.
>
> ops.enqueue() runs and returns without
> doing anything. Task transitions to
> SCX_OPSS_QUEUED.
>
> Afterwards, the kernel considers the task to be owned by BPF but the BPF
> side thinks the task has already been dispatched. It just doesn't make much
> sense to do BPF enqueue operation from ops.select_cpu(). The only reason it
> works for direct dispatch is because the kernel defers the operation to the
> enqueue time behind the scene.
Makes sense. Storing a task in a BPF internal data structure from
ops.select_cpu() doesn't prevent ops.enqueue() from being called and it can
introduce racy behavior. So, putting a task in a BPF queue at that point is
just extra overhead and provides no real benefit.
In other words, the only thing that makes sense for ops.select_cpu() is
direct dispatch, attempting any other form of enqueue from there is
pointless because the ops.enqueue() path will be invoked anyway.
We should probably document this behavior to make it explicit.
Thanks,
-Andrea
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-09 22:22 ` Andrea Righi
@ 2026-02-10 0:42 ` Tejun Heo
2026-02-10 7:29 ` Andrea Righi
0 siblings, 1 reply; 41+ messages in thread
From: Tejun Heo @ 2026-02-10 0:42 UTC (permalink / raw)
To: Andrea Righi
Cc: Emil Tsalapatis, David Vernet, Changwoo Min, Kuba Piecuch,
Christian Loehle, Daniel Hodges, sched-ext, linux-kernel
Hello, Andrea.
On Mon, Feb 09, 2026 at 11:22:19PM +0100, Andrea Righi wrote:
...
> Ok, what you're saying is that a direct dispatch from ops.select_cpu() is
> just a shortcut for work that would otherwise happen at the head of
> ops.enqueue().
>
> So, while ops.select_cpu() itself is not "being in scheduler custody", the
> semantic operation of dispatching a task is still the scheduler taking
> control of the task. As a result, a dispatch to a user DSQ from
> ops.select_cpu() should be treated the same as a dispatch to a user DSQ
> from ops.enqueue() for the purpose of triggering ops.dequeue(). The fact
> that this happens in ops.select_cpu() rather than ops.enqueue() is an
> implementation detail, not a semantic boundary.
Yes.
> Under this interpretation, storing a task in BPF internal data structures
> from ops.select_cpu() should not trigger ops.dequeue(), since the task has
> not been put under scheduler control yet. However, dispatching a task to a
Also, ops.select_cpu() putting the task in a BPF struct doesn't affect
what's happening in the enqueue path. ops.enqueue() will still be invoked
and the task will be transferred to BPF side iff ops.enqueue() does not
perform a direct dispatch. Imagine the following (unlikely but possible)
scenario:
CPU A CPU B
ops.select_cpu() puts task in a BPF
data structure
ops.dispatch() sees the task, dequeues it and
dispatches it to CPU B's local DSQ.
finish_dispatch() runs but the task is still
SCX_OPSS_NONE and dispatch attempt is ignored.
ops.enqueue() runs and returns without
doing anything. Task transitions to
SCX_OPSS_QUEUED.
Afterwards, the kernel considers the task to be owned by BPF but the BPF
side thinks the task has already been dispatched. It just doesn't make much
sense to do BPF enqueue operation from ops.select_cpu(). The only reason it
works for direct dispatch is because the kernel defers the operation to the
enqueue time behind the scene.
Thanks.
--
tejun
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-09 20:10 ` Tejun Heo
@ 2026-02-09 22:22 ` Andrea Righi
2026-02-10 0:42 ` Tejun Heo
0 siblings, 1 reply; 41+ messages in thread
From: Andrea Righi @ 2026-02-09 22:22 UTC (permalink / raw)
To: Tejun Heo
Cc: Emil Tsalapatis, David Vernet, Changwoo Min, Kuba Piecuch,
Christian Loehle, Daniel Hodges, sched-ext, linux-kernel
On Mon, Feb 09, 2026 at 10:10:30AM -1000, Tejun Heo wrote:
> Hello,
>
> On Mon, Feb 09, 2026 at 08:17:24PM +0100, Andrea Righi wrote:
> > Agreed. And just to be clear, for the purpose of triggering ops.dequeue(),
> > **all** direct dispatches from ops.select_cpu() should be consistently
> > ignored, including dispatches to user DSQs. I'll update this behavior in
> > the next version, because this one treats direct dispatches to user DSQs
> > from ops.select_cpu() as if the task is in the scheduler's custody, which
> > shouldn't be the case for consistency.
>
> I'm not sure about that. ops.select_cpu() doing direct dispatch is just a
> shortcut and should be treated like the same operation being done at the
> head of ops.enqueue(). That's what's happening semantically and I think we
> should stick with what's happening underneath - ie. make ops.select_cpu()'s
> shortcut the special case, not whether tasks in a user DSQ get ops.dequeue()
> or not.
Ok, what you're saying is that a direct dispatch from ops.select_cpu() is
just a shortcut for work that would otherwise happen at the head of
ops.enqueue().
So, while ops.select_cpu() itself is not "being in scheduler custody", the
semantic operation of dispatching a task is still the scheduler taking
control of the task. As a result, a dispatch to a user DSQ from
ops.select_cpu() should be treated the same as a dispatch to a user DSQ
from ops.enqueue() for the purpose of triggering ops.dequeue(). The fact
that this happens in ops.select_cpu() rather than ops.enqueue() is an
implementation detail, not a semantic boundary.
Under this interpretation, storing a task in BPF internal data structures
from ops.select_cpu() should not trigger ops.dequeue(), since the task has
not been put under scheduler control yet. However, dispatching a task to a
user DSQ, regardless of whether it happens from ops.select_cpu() or
ops.enqueue(), does explicitly place the task in the scheduler's custody.
If this matches what you mean, then I agree with this approach.
Thanks,
-Andrea
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-09 19:17 ` Andrea Righi
@ 2026-02-09 20:10 ` Tejun Heo
2026-02-09 22:22 ` Andrea Righi
0 siblings, 1 reply; 41+ messages in thread
From: Tejun Heo @ 2026-02-09 20:10 UTC (permalink / raw)
To: Andrea Righi
Cc: Emil Tsalapatis, David Vernet, Changwoo Min, Kuba Piecuch,
Christian Loehle, Daniel Hodges, sched-ext, linux-kernel
Hello,
On Mon, Feb 09, 2026 at 08:17:24PM +0100, Andrea Righi wrote:
> Agreed. And just to be clear, for the purpose of triggering ops.dequeue(),
> **all** direct dispatches from ops.select_cpu() should be consistently
> ignored, including dispatches to user DSQs. I'll update this behavior in
> the next version, because this one treats direct dispatches to user DSQs
> from ops.select_cpu() as if the task is in the scheduler's custody, which
> shouldn't be the case for consistency.
I'm not sure about that. ops.select_cpu() doing direct dispatch is just a
shortcut and should be treated like the same operation being done at the
head of ops.enqueue(). That's what's happening semantically and I think we
should stick with what's happening underneath - ie. make ops.select_cpu()'s
shortcut the special case, not whether tasks in a user DSQ get ops.dequeue()
or not.
Thanks.
--
tejun
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-09 17:23 ` Tejun Heo
@ 2026-02-09 19:17 ` Andrea Righi
2026-02-09 20:10 ` Tejun Heo
0 siblings, 1 reply; 41+ messages in thread
From: Andrea Righi @ 2026-02-09 19:17 UTC (permalink / raw)
To: Tejun Heo
Cc: Emil Tsalapatis, David Vernet, Changwoo Min, Kuba Piecuch,
Christian Loehle, Daniel Hodges, sched-ext, linux-kernel
On Mon, Feb 09, 2026 at 07:23:28AM -1000, Tejun Heo wrote:
> Hello,
>
> On Mon, Feb 09, 2026 at 04:43:20PM +0100, Andrea Righi wrote:
> > > I agree with going with option 1.
>
> I think this is the only way. The only reason this is a bit murky is because
> we allow direct dispatching from ops.select_cpu() but if you look at how
> that's implemented it doesn't really bypass enqueue path. The task still has
> to enter the enqueue path (as that's when the rq lock is grabbed and task
> state can be updated) while already knowing what to do in the enqueue path.
> I don't think it make sense to consider a task to be in the BPF sched's
> custody before it has passed through enqueue. Note that you can't even set
> the flag - the flag field is protected by the task's rq lock.
Agreed. And just to be clear, for the purpose of triggering ops.dequeue(),
**all** direct dispatches from ops.select_cpu() should be consistently
ignored, including dispatches to user DSQs. I'll update this behavior in
the next version, because this one treats direct dispatches to user DSQs
from ops.select_cpu() as if the task is in the scheduler's custody, which
shouldn't be the case for consistency.
>
> > > For the select_cpu() edge case, how about introducing an explicit
> > > kfunc scx_place_in_bpf_custody() later? Placing a task in BPF custody
> > > during select_cpu() is already pretty niche, so we can assume the
> > > scheduler writer knows what they're doing. In that case, let's let
> > > _them_ decide when in select_cpu() the task is considered "in BPF".
> > > They can also do their own locking to avoid races with locking on
> > > the task context. This keeps the state machine clean for the average
> > > scheduler while still handling the edge case. DYT that would work?
> >
> > Yeah, I was also considering introducing dedicated kfuncs so that the BPF
> > scheduler can explicitly manage the "in BPF custody" state, decoupling the
> > notion of BPF custody from ops.enqueue(). With such interface, a scheduler
> > could do something like:
> >
> > ops.select_cpu()
> > {
> > s32 pid = p->pid;
> >
> > scx_bpf_enter_custody(p);
> > if (!bpf_map_push_elem(&bpf_queue, &pid, 0)) {
> > set_task_state(TASK_ENQUEUED);
> > } else {
> > scx_bpf_exit_custody(p);
> > set_task_state(TASK_NONE);
> > }
> >
> > return prev_cpu;
> > }
> >
> > On the implementation side, entering / leaving BPF custody is essentially
> > setting / clearing SCX_TASK_IN_BPF, with the scheduler taking full
> > responsibility for ensuring the flag is managed consistently: you set the
> > flag => ops.dequeue() is called when the task leaves custody, you clear the
> > flag => fallback to the default custody behavior.
> >
> > But I think this is something to explore in the future, for now I'd go with
> > the easier way first. :)
>
> We should just not do it.
Ack.
Thanks,
-Andrea
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-09 15:43 ` Andrea Righi
@ 2026-02-09 17:23 ` Tejun Heo
2026-02-09 19:17 ` Andrea Righi
0 siblings, 1 reply; 41+ messages in thread
From: Tejun Heo @ 2026-02-09 17:23 UTC (permalink / raw)
To: Andrea Righi
Cc: Emil Tsalapatis, David Vernet, Changwoo Min, Kuba Piecuch,
Christian Loehle, Daniel Hodges, sched-ext, linux-kernel
Hello,
On Mon, Feb 09, 2026 at 04:43:20PM +0100, Andrea Righi wrote:
> > I agree with going with option 1.
I think this is the only way. The only reason this is a bit murky is because
we allow direct dispatching from ops.select_cpu() but if you look at how
that's implemented it doesn't really bypass enqueue path. The task still has
to enter the enqueue path (as that's when the rq lock is grabbed and task
state can be updated) while already knowing what to do in the enqueue path.
I don't think it make sense to consider a task to be in the BPF sched's
custody before it has passed through enqueue. Note that you can't even set
the flag - the flag field is protected by the task's rq lock.
> > For the select_cpu() edge case, how about introducing an explicit
> > kfunc scx_place_in_bpf_custody() later? Placing a task in BPF custody
> > during select_cpu() is already pretty niche, so we can assume the
> > scheduler writer knows what they're doing. In that case, let's let
> > _them_ decide when in select_cpu() the task is considered "in BPF".
> > They can also do their own locking to avoid races with locking on
> > the task context. This keeps the state machine clean for the average
> > scheduler while still handling the edge case. DYT that would work?
>
> Yeah, I was also considering introducing dedicated kfuncs so that the BPF
> scheduler can explicitly manage the "in BPF custody" state, decoupling the
> notion of BPF custody from ops.enqueue(). With such interface, a scheduler
> could do something like:
>
> ops.select_cpu()
> {
> s32 pid = p->pid;
>
> scx_bpf_enter_custody(p);
> if (!bpf_map_push_elem(&bpf_queue, &pid, 0)) {
> set_task_state(TASK_ENQUEUED);
> } else {
> scx_bpf_exit_custody(p);
> set_task_state(TASK_NONE);
> }
>
> return prev_cpu;
> }
>
> On the implementation side, entering / leaving BPF custody is essentially
> setting / clearing SCX_TASK_IN_BPF, with the scheduler taking full
> responsibility for ensuring the flag is managed consistently: you set the
> flag => ops.dequeue() is called when the task leaves custody, you clear the
> flag => fallback to the default custody behavior.
>
> But I think this is something to explore in the future, for now I'd go with
> the easier way first. :)
We should just not do it.
Thanks.
--
tejun
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-09 15:00 ` Emil Tsalapatis
@ 2026-02-09 15:43 ` Andrea Righi
2026-02-09 17:23 ` Tejun Heo
0 siblings, 1 reply; 41+ messages in thread
From: Andrea Righi @ 2026-02-09 15:43 UTC (permalink / raw)
To: Emil Tsalapatis
Cc: Tejun Heo, David Vernet, Changwoo Min, Kuba Piecuch,
Christian Loehle, Daniel Hodges, sched-ext, linux-kernel
On Mon, Feb 09, 2026 at 10:00:40AM -0500, Emil Tsalapatis wrote:
> On Mon Feb 9, 2026 at 5:20 AM EST, Andrea Righi wrote:
> > On Sun, Feb 08, 2026 at 09:08:38PM +0100, Andrea Righi wrote:
> >> On Sun, Feb 08, 2026 at 12:59:36PM -0500, Emil Tsalapatis wrote:
> >> > On Sun Feb 8, 2026 at 8:55 AM EST, Andrea Righi wrote:
> >> > > On Sun, Feb 08, 2026 at 11:26:13AM +0100, Andrea Righi wrote:
> >> > >> On Sun, Feb 08, 2026 at 10:02:41AM +0100, Andrea Righi wrote:
> >> > >> ...
> >> > >> > > >> > - From ops.select_cpu():
> >> > >> > > >> > - scenario 0 (local DSQ): tasks dispatched to the local DSQ bypass
> >> > >> > > >> > the BPF scheduler entirely; they never enter BPF custody, so
> >> > >> > > >> > ops.dequeue() is not called,
> >> > >> > > >> > - scenario 1 (global DSQ): tasks dispatched to SCX_DSQ_GLOBAL also
> >> > >> > > >> > bypass the BPF scheduler, like the local DSQ; ops.dequeue() is
> >> > >> > > >> > not called,
> >> > >> > > >> > - scenario 2 (user DSQ): tasks enter BPF scheduler custody with full
> >> > >> > > >> > enqueue/dequeue lifecycle tracking and state machine validation
> >> > >> > > >> > (expects 1:1 enqueue/dequeue pairing).
> >> > >> > > >>
> >> > >> > > >> Could you add a note here about why there's no equivalent to scenario 6?
> >> > >> > > >> The differentiating factor between that and scenario 2 (nonterminal queue) is
> >> > >> > > >> that scx_dsq_insert_commit() is called regardless of whether the queue is terminal.
> >> > >> > > >> And this makes sense since for non-DSQ queues the BPF scheduler can do its
> >> > >> > > >> own tracking of enqueue/dequeue (plus it does not make too much sense to
> >> > >> > > >> do BPF-internal enqueueing in select_cpu).
> >> > >> > > >>
> >> > >> > > >> What do you think? If the above makes sense, maybe we should spell it out
> >> > >> > > >> in the documentation too. Maybe also add it makes no sense to enqueue
> >> > >> > > >> in an internal BPF structure from select_cpu - the task is not yet
> >> > >> > > >> enqueued, and would have to go through enqueue anyway.
> >> > >> > > >
> >> > >> > > > Oh, I just didn't think about it, we can definitely add to ops.select_cpu()
> >> > >> > > > a scenario equivalent to scenario 6 (push task to the BPF queue).
> >> > >> > > >
> >> > >> > > > From a practical standpoint the benefits are questionable, but in the scope
> >> > >> > > > of the kselftest I think it makes sense to better validate the entire state
> >> > >> > > > machine in all cases. I'll add this scenario as well.
> >> > >> > > >
> >> > >> > >
> >> > >> > > That makes sense! Let's add it for completeness. Even if it doesn't make
> >> > >> > > sense right now that may change in the future. For example, if we end
> >> > >> > > up finding a good reason to add the task into an internal structure from
> >> > >> > > .select_cpu(), we may allow the task to be explicitly marked as being in
> >> > >> > > the BPF scheduler's custody from a kfunc. Right now we can't do that
> >> > >> > > from select_cpu() unless we direct dispatch IIUC.
> >> > >> >
> >> > >> > Ok, I'll send a new patch later with the new scenario included. It should
> >> > >> > work already (if done properly in the test case), I think we don't need to
> >> > >> > change anything in the kernel.
> >> > >>
> >> > >> Actually I take that back. The internal BPF queue from ops.select_cpu()
> >> > >> scenario is a bit tricky, because when we return from ops.select_cpu()
> >> > >> without p->scx.ddsp_dsq_id being set, we don't know if the scheduler added
> >> > >> the task to an internal BPF queue or simply did nothing.
> >> > >>
> >> > >> We need to add some special logic here, preferably without introducing
> >> > >> overhead just to handle this particular (really uncommon) case. I'll take a
> >> > >> look.
> >> > >
> >> > > The more I think about this, the more it feels wrong to consider a task as
> >> > > being "in BPF scheduler custody" if it is stored in a BPF internal data
> >> > > structure from ops.select_cpu().
> >> > >
> >> > > At the point where ops.select_cpu() runs, the task has not yet entered the
> >> > > BPF scheduler's queues. While it is technically possible to stash the task
> >> > > in some BPF-managed structure from there, doing so should not imply full
> >> > > scheduler custody.
> >> > >
> >> > > In particular, we should not trigger ops.dequeue(), because the task has
> >> > > not reached the "enqueue" stage of its lifecycle. ops.select_cpu() is
> >> > > effectively a pre-enqueue hook, primarily intended as a fast path to bypass
> >> > > the scheduler altogether. As such, triggering ops.dequeue() in this case
> >> > > would not make sense IMHO.
> >> > >
> >> > > I think it would make more sense to document this behavior explicitly and
> >> > > leave the kselftest as is.
> >> > >
> >> > > Thoughts?
> >> >
> >> > I am going back and forth on this but I think the problem is that the enqueue()
> >> > and dequeue() BPF callbacks we have are not actually symmetrical?
> >> >
> >> > 1) ops.enqueue() is "sched-ext specific work for the scheduler core's enqueue
> >> > method". This is independent on whether the task ends up in BPF custody or not.
> >> > It could be in a terminal DSQ, a non-terminal DSQ, or a BPF data structure.
> >> >
> >> > 2) ops.dequeue() is "remove task from BPF custody". E.g., it is used by the
> >> > BPF scheduler to signal whether it should keep a task within its
> >> > internal tracking structures.
> >> >
> >> > So the edge case of ops.select_cpu() placing the task in BPF custody is
> >> > currently valid. The way I see it, we have two choices in terms of
> >> > semantics:
> >> >
> >> > 1) ops.dequeue() must be the equivalent of ops.enqueue(). If the BPF
> >> > scheduler writer decides to place a task into BPF custody during the
> >> > ops.select_cpu() that's on them. ops.select_cpu() is supposed to be a
> >> > pure function providing a hint, anyway. Using it to place a task into
> >> > BPF is a bit of an abuse even if allowed.
> >> >
> >> > 2) We interpret ops.dequeue() to mean "dequeue from the BPF scheduler".
> >> > In that case we allow the edge case and interpret ops.dequeue() as "the
> >> > function that must be called to clear the NEEDS_DEQ/IN_BPF flag", not as
> >> > the complement of ops.enqueue(). In most cases both will be true, and in
> >> > the cases where not then it's up to the scheduler writer to understand
> >> > the nuance.
> >> >
> >> > I think while 2) is cleaner, it is more involved and honestly kinda
> >> > speculative. However, I think it's fair game since once we settle on
> >> > the semantics it will be more difficult to change them. Which one do you
> >> > think makes more sense?
> >>
> >> Yeah, I'm also going back and forth on this.
> >>
> >> Honestly from a pure theoretical perspective, option (1) feels cleaner to
> >> me: when ops.select_cpu() runs, the task has not entered the BPF scheduler
> >> yet. If we trigger ops.dequeue() in this case, we end up with tasks that
> >> are "leaving" the scheduler without ever having entered it, which feels
> >> like a violation of the lifecycle model.
> >>
> >> However, from a practical perspective, it's probably more convenient to
> >> trigger ops.dequeue() also for tasks that are stored in BPF data structures
> >> or user DSQs from ops.select_cpu() as well. If we don't allow that, we
> >> can't just silently ignore the behavior and it's also pretty hard to
> >> reliably detect and trigger an error for this kind of "abuse" at runtime.
> >> That means it could easily turn into a source of subtle bugs in the future,
> >> and I don't think documentation alone would be sufficient to prevent that
> >> (the "don't do that" rules are always fragile).
> >>
> >> Therefore, at the moment I'm more inclined to go with option (2), as it
> >> provides better robustness and gives schedulers more flexibility.
> >
> > I'm running into a number of headaches and corner cases if we go with
> > option (2)... One of them is the following.
> >
> > Assume we push tasks into a BPF queue from ops.select_cpu() and pop them
> > from ops.dispatch(). The following scenario can happen:
> >
> > CPU0 CPU1
> > ---- ----
> > ops.select_cpu()
> > bpf_map_push_elem(&queue, &pid, 0)
> > ops.dispatch()
> > bpf_map_pop_elem(&queue, &pid)
> > scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL_ON | dst_cpu)
> > ==> ops.dequeue() is not triggered!
> > p->scx.flags |= SCX_TASK_IN_BPF
> >
> > To fix this, we would need to always set SCX_TASK_IN_BPF before calling
> > ops.select_cpu(), and then clear it again if the task is directly
> > dispatched to a terminal DSQ from ops.select_cpu().
> >
> > However, doing so introduces further problems. In particular, we may end up
> > triggering spurious ops.dequeue() callbacks, which means we would then need
> > to distinguish whether a task entered BPF custody via ops.select_cpu() or
> > via ops.enqueue(), and handle the two cases differently. Which is also racy
> > and leads to additional locking and complexity.
> >
> > At that point, it starts to feel like we're over-complicating the design to
> > support a scenario that is both uncommon and of questionable practical
> > value.
> >
> > Given that, I'd suggest proceeding incrementally: for now, we go with
> > option (1), which looks doable without major changes and it probably fixes
> > the ops.dequeue() semantics for the majority of use cases (which is already
> > a significant improvement over the current state). Once that is in place,
> > we can revisit the "store tasks in internal BPF data structures from
> > ops.select_cpu()" scenario and see if it's worth supporting it in a cleaner
> > way. WDYT?
> >
>
> I agree with going with option 1.
>
> For the select_cpu() edge case, how about introducing an explicit
> kfunc scx_place_in_bpf_custody() later? Placing a task in BPF custody
> during select_cpu() is already pretty niche, so we can assume the
> scheduler writer knows what they're doing. In that case, let's let
> _them_ decide when in select_cpu() the task is considered "in BPF".
> They can also do their own locking to avoid races with locking on
> the task context. This keeps the state machine clean for the average
> scheduler while still handling the edge case. DYT that would work?
Yeah, I was also considering introducing dedicated kfuncs so that the BPF
scheduler can explicitly manage the "in BPF custody" state, decoupling the
notion of BPF custody from ops.enqueue(). With such interface, a scheduler
could do something like:
ops.select_cpu()
{
s32 pid = p->pid;
scx_bpf_enter_custody(p);
if (!bpf_map_push_elem(&bpf_queue, &pid, 0)) {
set_task_state(TASK_ENQUEUED);
} else {
scx_bpf_exit_custody(p);
set_task_state(TASK_NONE);
}
return prev_cpu;
}
On the implementation side, entering / leaving BPF custody is essentially
setting / clearing SCX_TASK_IN_BPF, with the scheduler taking full
responsibility for ensuring the flag is managed consistently: you set the
flag => ops.dequeue() is called when the task leaves custody, you clear the
flag => fallback to the default custody behavior.
But I think this is something to explore in the future, for now I'd go with
the easier way first. :)
Thanks,
-Andrea
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-09 10:20 ` Andrea Righi
@ 2026-02-09 15:00 ` Emil Tsalapatis
2026-02-09 15:43 ` Andrea Righi
0 siblings, 1 reply; 41+ messages in thread
From: Emil Tsalapatis @ 2026-02-09 15:00 UTC (permalink / raw)
To: Andrea Righi
Cc: Tejun Heo, David Vernet, Changwoo Min, Kuba Piecuch,
Christian Loehle, Daniel Hodges, sched-ext, linux-kernel
On Mon Feb 9, 2026 at 5:20 AM EST, Andrea Righi wrote:
> On Sun, Feb 08, 2026 at 09:08:38PM +0100, Andrea Righi wrote:
>> On Sun, Feb 08, 2026 at 12:59:36PM -0500, Emil Tsalapatis wrote:
>> > On Sun Feb 8, 2026 at 8:55 AM EST, Andrea Righi wrote:
>> > > On Sun, Feb 08, 2026 at 11:26:13AM +0100, Andrea Righi wrote:
>> > >> On Sun, Feb 08, 2026 at 10:02:41AM +0100, Andrea Righi wrote:
>> > >> ...
>> > >> > > >> > - From ops.select_cpu():
>> > >> > > >> > - scenario 0 (local DSQ): tasks dispatched to the local DSQ bypass
>> > >> > > >> > the BPF scheduler entirely; they never enter BPF custody, so
>> > >> > > >> > ops.dequeue() is not called,
>> > >> > > >> > - scenario 1 (global DSQ): tasks dispatched to SCX_DSQ_GLOBAL also
>> > >> > > >> > bypass the BPF scheduler, like the local DSQ; ops.dequeue() is
>> > >> > > >> > not called,
>> > >> > > >> > - scenario 2 (user DSQ): tasks enter BPF scheduler custody with full
>> > >> > > >> > enqueue/dequeue lifecycle tracking and state machine validation
>> > >> > > >> > (expects 1:1 enqueue/dequeue pairing).
>> > >> > > >>
>> > >> > > >> Could you add a note here about why there's no equivalent to scenario 6?
>> > >> > > >> The differentiating factor between that and scenario 2 (nonterminal queue) is
>> > >> > > >> that scx_dsq_insert_commit() is called regardless of whether the queue is terminal.
>> > >> > > >> And this makes sense since for non-DSQ queues the BPF scheduler can do its
>> > >> > > >> own tracking of enqueue/dequeue (plus it does not make too much sense to
>> > >> > > >> do BPF-internal enqueueing in select_cpu).
>> > >> > > >>
>> > >> > > >> What do you think? If the above makes sense, maybe we should spell it out
>> > >> > > >> in the documentation too. Maybe also add it makes no sense to enqueue
>> > >> > > >> in an internal BPF structure from select_cpu - the task is not yet
>> > >> > > >> enqueued, and would have to go through enqueue anyway.
>> > >> > > >
>> > >> > > > Oh, I just didn't think about it, we can definitely add to ops.select_cpu()
>> > >> > > > a scenario equivalent to scenario 6 (push task to the BPF queue).
>> > >> > > >
>> > >> > > > From a practical standpoint the benefits are questionable, but in the scope
>> > >> > > > of the kselftest I think it makes sense to better validate the entire state
>> > >> > > > machine in all cases. I'll add this scenario as well.
>> > >> > > >
>> > >> > >
>> > >> > > That makes sense! Let's add it for completeness. Even if it doesn't make
>> > >> > > sense right now that may change in the future. For example, if we end
>> > >> > > up finding a good reason to add the task into an internal structure from
>> > >> > > .select_cpu(), we may allow the task to be explicitly marked as being in
>> > >> > > the BPF scheduler's custody from a kfunc. Right now we can't do that
>> > >> > > from select_cpu() unless we direct dispatch IIUC.
>> > >> >
>> > >> > Ok, I'll send a new patch later with the new scenario included. It should
>> > >> > work already (if done properly in the test case), I think we don't need to
>> > >> > change anything in the kernel.
>> > >>
>> > >> Actually I take that back. The internal BPF queue from ops.select_cpu()
>> > >> scenario is a bit tricky, because when we return from ops.select_cpu()
>> > >> without p->scx.ddsp_dsq_id being set, we don't know if the scheduler added
>> > >> the task to an internal BPF queue or simply did nothing.
>> > >>
>> > >> We need to add some special logic here, preferably without introducing
>> > >> overhead just to handle this particular (really uncommon) case. I'll take a
>> > >> look.
>> > >
>> > > The more I think about this, the more it feels wrong to consider a task as
>> > > being "in BPF scheduler custody" if it is stored in a BPF internal data
>> > > structure from ops.select_cpu().
>> > >
>> > > At the point where ops.select_cpu() runs, the task has not yet entered the
>> > > BPF scheduler's queues. While it is technically possible to stash the task
>> > > in some BPF-managed structure from there, doing so should not imply full
>> > > scheduler custody.
>> > >
>> > > In particular, we should not trigger ops.dequeue(), because the task has
>> > > not reached the "enqueue" stage of its lifecycle. ops.select_cpu() is
>> > > effectively a pre-enqueue hook, primarily intended as a fast path to bypass
>> > > the scheduler altogether. As such, triggering ops.dequeue() in this case
>> > > would not make sense IMHO.
>> > >
>> > > I think it would make more sense to document this behavior explicitly and
>> > > leave the kselftest as is.
>> > >
>> > > Thoughts?
>> >
>> > I am going back and forth on this but I think the problem is that the enqueue()
>> > and dequeue() BPF callbacks we have are not actually symmetrical?
>> >
>> > 1) ops.enqueue() is "sched-ext specific work for the scheduler core's enqueue
>> > method". This is independent on whether the task ends up in BPF custody or not.
>> > It could be in a terminal DSQ, a non-terminal DSQ, or a BPF data structure.
>> >
>> > 2) ops.dequeue() is "remove task from BPF custody". E.g., it is used by the
>> > BPF scheduler to signal whether it should keep a task within its
>> > internal tracking structures.
>> >
>> > So the edge case of ops.select_cpu() placing the task in BPF custody is
>> > currently valid. The way I see it, we have two choices in terms of
>> > semantics:
>> >
>> > 1) ops.dequeue() must be the equivalent of ops.enqueue(). If the BPF
>> > scheduler writer decides to place a task into BPF custody during the
>> > ops.select_cpu() that's on them. ops.select_cpu() is supposed to be a
>> > pure function providing a hint, anyway. Using it to place a task into
>> > BPF is a bit of an abuse even if allowed.
>> >
>> > 2) We interpret ops.dequeue() to mean "dequeue from the BPF scheduler".
>> > In that case we allow the edge case and interpret ops.dequeue() as "the
>> > function that must be called to clear the NEEDS_DEQ/IN_BPF flag", not as
>> > the complement of ops.enqueue(). In most cases both will be true, and in
>> > the cases where not then it's up to the scheduler writer to understand
>> > the nuance.
>> >
>> > I think while 2) is cleaner, it is more involved and honestly kinda
>> > speculative. However, I think it's fair game since once we settle on
>> > the semantics it will be more difficult to change them. Which one do you
>> > think makes more sense?
>>
>> Yeah, I'm also going back and forth on this.
>>
>> Honestly from a pure theoretical perspective, option (1) feels cleaner to
>> me: when ops.select_cpu() runs, the task has not entered the BPF scheduler
>> yet. If we trigger ops.dequeue() in this case, we end up with tasks that
>> are "leaving" the scheduler without ever having entered it, which feels
>> like a violation of the lifecycle model.
>>
>> However, from a practical perspective, it's probably more convenient to
>> trigger ops.dequeue() also for tasks that are stored in BPF data structures
>> or user DSQs from ops.select_cpu() as well. If we don't allow that, we
>> can't just silently ignore the behavior and it's also pretty hard to
>> reliably detect and trigger an error for this kind of "abuse" at runtime.
>> That means it could easily turn into a source of subtle bugs in the future,
>> and I don't think documentation alone would be sufficient to prevent that
>> (the "don't do that" rules are always fragile).
>>
>> Therefore, at the moment I'm more inclined to go with option (2), as it
>> provides better robustness and gives schedulers more flexibility.
>
> I'm running into a number of headaches and corner cases if we go with
> option (2)... One of them is the following.
>
> Assume we push tasks into a BPF queue from ops.select_cpu() and pop them
> from ops.dispatch(). The following scenario can happen:
>
> CPU0 CPU1
> ---- ----
> ops.select_cpu()
> bpf_map_push_elem(&queue, &pid, 0)
> ops.dispatch()
> bpf_map_pop_elem(&queue, &pid)
> scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL_ON | dst_cpu)
> ==> ops.dequeue() is not triggered!
> p->scx.flags |= SCX_TASK_IN_BPF
>
> To fix this, we would need to always set SCX_TASK_IN_BPF before calling
> ops.select_cpu(), and then clear it again if the task is directly
> dispatched to a terminal DSQ from ops.select_cpu().
>
> However, doing so introduces further problems. In particular, we may end up
> triggering spurious ops.dequeue() callbacks, which means we would then need
> to distinguish whether a task entered BPF custody via ops.select_cpu() or
> via ops.enqueue(), and handle the two cases differently. Which is also racy
> and leads to additional locking and complexity.
>
> At that point, it starts to feel like we're over-complicating the design to
> support a scenario that is both uncommon and of questionable practical
> value.
>
> Given that, I'd suggest proceeding incrementally: for now, we go with
> option (1), which looks doable without major changes and it probably fixes
> the ops.dequeue() semantics for the majority of use cases (which is already
> a significant improvement over the current state). Once that is in place,
> we can revisit the "store tasks in internal BPF data structures from
> ops.select_cpu()" scenario and see if it's worth supporting it in a cleaner
> way. WDYT?
>
I agree with going with option 1.
For the select_cpu() edge case, how about introducing an explicit
kfunc scx_place_in_bpf_custody() later? Placing a task in BPF custody
during select_cpu() is already pretty niche, so we can assume the
scheduler writer knows what they're doing. In that case, let's let
_them_ decide when in select_cpu() the task is considered "in BPF".
They can also do their own locking to avoid races with locking on
the task context. This keeps the state machine clean for the average
scheduler while still handling the edge case. DYT that would work?
> Thanks,
> -Andrea
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-08 20:08 ` Andrea Righi
@ 2026-02-09 10:20 ` Andrea Righi
2026-02-09 15:00 ` Emil Tsalapatis
0 siblings, 1 reply; 41+ messages in thread
From: Andrea Righi @ 2026-02-09 10:20 UTC (permalink / raw)
To: Emil Tsalapatis
Cc: Tejun Heo, David Vernet, Changwoo Min, Kuba Piecuch,
Christian Loehle, Daniel Hodges, sched-ext, linux-kernel
On Sun, Feb 08, 2026 at 09:08:38PM +0100, Andrea Righi wrote:
> On Sun, Feb 08, 2026 at 12:59:36PM -0500, Emil Tsalapatis wrote:
> > On Sun Feb 8, 2026 at 8:55 AM EST, Andrea Righi wrote:
> > > On Sun, Feb 08, 2026 at 11:26:13AM +0100, Andrea Righi wrote:
> > >> On Sun, Feb 08, 2026 at 10:02:41AM +0100, Andrea Righi wrote:
> > >> ...
> > >> > > >> > - From ops.select_cpu():
> > >> > > >> > - scenario 0 (local DSQ): tasks dispatched to the local DSQ bypass
> > >> > > >> > the BPF scheduler entirely; they never enter BPF custody, so
> > >> > > >> > ops.dequeue() is not called,
> > >> > > >> > - scenario 1 (global DSQ): tasks dispatched to SCX_DSQ_GLOBAL also
> > >> > > >> > bypass the BPF scheduler, like the local DSQ; ops.dequeue() is
> > >> > > >> > not called,
> > >> > > >> > - scenario 2 (user DSQ): tasks enter BPF scheduler custody with full
> > >> > > >> > enqueue/dequeue lifecycle tracking and state machine validation
> > >> > > >> > (expects 1:1 enqueue/dequeue pairing).
> > >> > > >>
> > >> > > >> Could you add a note here about why there's no equivalent to scenario 6?
> > >> > > >> The differentiating factor between that and scenario 2 (nonterminal queue) is
> > >> > > >> that scx_dsq_insert_commit() is called regardless of whether the queue is terminal.
> > >> > > >> And this makes sense since for non-DSQ queues the BPF scheduler can do its
> > >> > > >> own tracking of enqueue/dequeue (plus it does not make too much sense to
> > >> > > >> do BPF-internal enqueueing in select_cpu).
> > >> > > >>
> > >> > > >> What do you think? If the above makes sense, maybe we should spell it out
> > >> > > >> in the documentation too. Maybe also add it makes no sense to enqueue
> > >> > > >> in an internal BPF structure from select_cpu - the task is not yet
> > >> > > >> enqueued, and would have to go through enqueue anyway.
> > >> > > >
> > >> > > > Oh, I just didn't think about it, we can definitely add to ops.select_cpu()
> > >> > > > a scenario equivalent to scenario 6 (push task to the BPF queue).
> > >> > > >
> > >> > > > From a practical standpoint the benefits are questionable, but in the scope
> > >> > > > of the kselftest I think it makes sense to better validate the entire state
> > >> > > > machine in all cases. I'll add this scenario as well.
> > >> > > >
> > >> > >
> > >> > > That makes sense! Let's add it for completeness. Even if it doesn't make
> > >> > > sense right now that may change in the future. For example, if we end
> > >> > > up finding a good reason to add the task into an internal structure from
> > >> > > .select_cpu(), we may allow the task to be explicitly marked as being in
> > >> > > the BPF scheduler's custody from a kfunc. Right now we can't do that
> > >> > > from select_cpu() unless we direct dispatch IIUC.
> > >> >
> > >> > Ok, I'll send a new patch later with the new scenario included. It should
> > >> > work already (if done properly in the test case), I think we don't need to
> > >> > change anything in the kernel.
> > >>
> > >> Actually I take that back. The internal BPF queue from ops.select_cpu()
> > >> scenario is a bit tricky, because when we return from ops.select_cpu()
> > >> without p->scx.ddsp_dsq_id being set, we don't know if the scheduler added
> > >> the task to an internal BPF queue or simply did nothing.
> > >>
> > >> We need to add some special logic here, preferably without introducing
> > >> overhead just to handle this particular (really uncommon) case. I'll take a
> > >> look.
> > >
> > > The more I think about this, the more it feels wrong to consider a task as
> > > being "in BPF scheduler custody" if it is stored in a BPF internal data
> > > structure from ops.select_cpu().
> > >
> > > At the point where ops.select_cpu() runs, the task has not yet entered the
> > > BPF scheduler's queues. While it is technically possible to stash the task
> > > in some BPF-managed structure from there, doing so should not imply full
> > > scheduler custody.
> > >
> > > In particular, we should not trigger ops.dequeue(), because the task has
> > > not reached the "enqueue" stage of its lifecycle. ops.select_cpu() is
> > > effectively a pre-enqueue hook, primarily intended as a fast path to bypass
> > > the scheduler altogether. As such, triggering ops.dequeue() in this case
> > > would not make sense IMHO.
> > >
> > > I think it would make more sense to document this behavior explicitly and
> > > leave the kselftest as is.
> > >
> > > Thoughts?
> >
> > I am going back and forth on this but I think the problem is that the enqueue()
> > and dequeue() BPF callbacks we have are not actually symmetrical?
> >
> > 1) ops.enqueue() is "sched-ext specific work for the scheduler core's enqueue
> > method". This is independent on whether the task ends up in BPF custody or not.
> > It could be in a terminal DSQ, a non-terminal DSQ, or a BPF data structure.
> >
> > 2) ops.dequeue() is "remove task from BPF custody". E.g., it is used by the
> > BPF scheduler to signal whether it should keep a task within its
> > internal tracking structures.
> >
> > So the edge case of ops.select_cpu() placing the task in BPF custody is
> > currently valid. The way I see it, we have two choices in terms of
> > semantics:
> >
> > 1) ops.dequeue() must be the equivalent of ops.enqueue(). If the BPF
> > scheduler writer decides to place a task into BPF custody during the
> > ops.select_cpu() that's on them. ops.select_cpu() is supposed to be a
> > pure function providing a hint, anyway. Using it to place a task into
> > BPF is a bit of an abuse even if allowed.
> >
> > 2) We interpret ops.dequeue() to mean "dequeue from the BPF scheduler".
> > In that case we allow the edge case and interpret ops.dequeue() as "the
> > function that must be called to clear the NEEDS_DEQ/IN_BPF flag", not as
> > the complement of ops.enqueue(). In most cases both will be true, and in
> > the cases where not then it's up to the scheduler writer to understand
> > the nuance.
> >
> > I think while 2) is cleaner, it is more involved and honestly kinda
> > speculative. However, I think it's fair game since once we settle on
> > the semantics it will be more difficult to change them. Which one do you
> > think makes more sense?
>
> Yeah, I'm also going back and forth on this.
>
> Honestly from a pure theoretical perspective, option (1) feels cleaner to
> me: when ops.select_cpu() runs, the task has not entered the BPF scheduler
> yet. If we trigger ops.dequeue() in this case, we end up with tasks that
> are "leaving" the scheduler without ever having entered it, which feels
> like a violation of the lifecycle model.
>
> However, from a practical perspective, it's probably more convenient to
> trigger ops.dequeue() also for tasks that are stored in BPF data structures
> or user DSQs from ops.select_cpu() as well. If we don't allow that, we
> can't just silently ignore the behavior and it's also pretty hard to
> reliably detect and trigger an error for this kind of "abuse" at runtime.
> That means it could easily turn into a source of subtle bugs in the future,
> and I don't think documentation alone would be sufficient to prevent that
> (the "don't do that" rules are always fragile).
>
> Therefore, at the moment I'm more inclined to go with option (2), as it
> provides better robustness and gives schedulers more flexibility.
I'm running into a number of headaches and corner cases if we go with
option (2)... One of them is the following.
Assume we push tasks into a BPF queue from ops.select_cpu() and pop them
from ops.dispatch(). The following scenario can happen:
CPU0 CPU1
---- ----
ops.select_cpu()
bpf_map_push_elem(&queue, &pid, 0)
ops.dispatch()
bpf_map_pop_elem(&queue, &pid)
scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL_ON | dst_cpu)
==> ops.dequeue() is not triggered!
p->scx.flags |= SCX_TASK_IN_BPF
To fix this, we would need to always set SCX_TASK_IN_BPF before calling
ops.select_cpu(), and then clear it again if the task is directly
dispatched to a terminal DSQ from ops.select_cpu().
However, doing so introduces further problems. In particular, we may end up
triggering spurious ops.dequeue() callbacks, which means we would then need
to distinguish whether a task entered BPF custody via ops.select_cpu() or
via ops.enqueue(), and handle the two cases differently. Which is also racy
and leads to additional locking and complexity.
At that point, it starts to feel like we're over-complicating the design to
support a scenario that is both uncommon and of questionable practical
value.
Given that, I'd suggest proceeding incrementally: for now, we go with
option (1), which looks doable without major changes and it probably fixes
the ops.dequeue() semantics for the majority of use cases (which is already
a significant improvement over the current state). Once that is in place,
we can revisit the "store tasks in internal BPF data structures from
ops.select_cpu()" scenario and see if it's worth supporting it in a cleaner
way. WDYT?
Thanks,
-Andrea
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-08 17:59 ` Emil Tsalapatis
@ 2026-02-08 20:08 ` Andrea Righi
2026-02-09 10:20 ` Andrea Righi
0 siblings, 1 reply; 41+ messages in thread
From: Andrea Righi @ 2026-02-08 20:08 UTC (permalink / raw)
To: Emil Tsalapatis
Cc: Tejun Heo, David Vernet, Changwoo Min, Kuba Piecuch,
Christian Loehle, Daniel Hodges, sched-ext, linux-kernel
On Sun, Feb 08, 2026 at 12:59:36PM -0500, Emil Tsalapatis wrote:
> On Sun Feb 8, 2026 at 8:55 AM EST, Andrea Righi wrote:
> > On Sun, Feb 08, 2026 at 11:26:13AM +0100, Andrea Righi wrote:
> >> On Sun, Feb 08, 2026 at 10:02:41AM +0100, Andrea Righi wrote:
> >> ...
> >> > > >> > - From ops.select_cpu():
> >> > > >> > - scenario 0 (local DSQ): tasks dispatched to the local DSQ bypass
> >> > > >> > the BPF scheduler entirely; they never enter BPF custody, so
> >> > > >> > ops.dequeue() is not called,
> >> > > >> > - scenario 1 (global DSQ): tasks dispatched to SCX_DSQ_GLOBAL also
> >> > > >> > bypass the BPF scheduler, like the local DSQ; ops.dequeue() is
> >> > > >> > not called,
> >> > > >> > - scenario 2 (user DSQ): tasks enter BPF scheduler custody with full
> >> > > >> > enqueue/dequeue lifecycle tracking and state machine validation
> >> > > >> > (expects 1:1 enqueue/dequeue pairing).
> >> > > >>
> >> > > >> Could you add a note here about why there's no equivalent to scenario 6?
> >> > > >> The differentiating factor between that and scenario 2 (nonterminal queue) is
> >> > > >> that scx_dsq_insert_commit() is called regardless of whether the queue is terminal.
> >> > > >> And this makes sense since for non-DSQ queues the BPF scheduler can do its
> >> > > >> own tracking of enqueue/dequeue (plus it does not make too much sense to
> >> > > >> do BPF-internal enqueueing in select_cpu).
> >> > > >>
> >> > > >> What do you think? If the above makes sense, maybe we should spell it out
> >> > > >> in the documentation too. Maybe also add it makes no sense to enqueue
> >> > > >> in an internal BPF structure from select_cpu - the task is not yet
> >> > > >> enqueued, and would have to go through enqueue anyway.
> >> > > >
> >> > > > Oh, I just didn't think about it, we can definitely add to ops.select_cpu()
> >> > > > a scenario equivalent to scenario 6 (push task to the BPF queue).
> >> > > >
> >> > > > From a practical standpoint the benefits are questionable, but in the scope
> >> > > > of the kselftest I think it makes sense to better validate the entire state
> >> > > > machine in all cases. I'll add this scenario as well.
> >> > > >
> >> > >
> >> > > That makes sense! Let's add it for completeness. Even if it doesn't make
> >> > > sense right now that may change in the future. For example, if we end
> >> > > up finding a good reason to add the task into an internal structure from
> >> > > .select_cpu(), we may allow the task to be explicitly marked as being in
> >> > > the BPF scheduler's custody from a kfunc. Right now we can't do that
> >> > > from select_cpu() unless we direct dispatch IIUC.
> >> >
> >> > Ok, I'll send a new patch later with the new scenario included. It should
> >> > work already (if done properly in the test case), I think we don't need to
> >> > change anything in the kernel.
> >>
> >> Actually I take that back. The internal BPF queue from ops.select_cpu()
> >> scenario is a bit tricky, because when we return from ops.select_cpu()
> >> without p->scx.ddsp_dsq_id being set, we don't know if the scheduler added
> >> the task to an internal BPF queue or simply did nothing.
> >>
> >> We need to add some special logic here, preferably without introducing
> >> overhead just to handle this particular (really uncommon) case. I'll take a
> >> look.
> >
> > The more I think about this, the more it feels wrong to consider a task as
> > being "in BPF scheduler custody" if it is stored in a BPF internal data
> > structure from ops.select_cpu().
> >
> > At the point where ops.select_cpu() runs, the task has not yet entered the
> > BPF scheduler's queues. While it is technically possible to stash the task
> > in some BPF-managed structure from there, doing so should not imply full
> > scheduler custody.
> >
> > In particular, we should not trigger ops.dequeue(), because the task has
> > not reached the "enqueue" stage of its lifecycle. ops.select_cpu() is
> > effectively a pre-enqueue hook, primarily intended as a fast path to bypass
> > the scheduler altogether. As such, triggering ops.dequeue() in this case
> > would not make sense IMHO.
> >
> > I think it would make more sense to document this behavior explicitly and
> > leave the kselftest as is.
> >
> > Thoughts?
>
> I am going back and forth on this but I think the problem is that the enqueue()
> and dequeue() BPF callbacks we have are not actually symmetrical?
>
> 1) ops.enqueue() is "sched-ext specific work for the scheduler core's enqueue
> method". This is independent on whether the task ends up in BPF custody or not.
> It could be in a terminal DSQ, a non-terminal DSQ, or a BPF data structure.
>
> 2) ops.dequeue() is "remove task from BPF custody". E.g., it is used by the
> BPF scheduler to signal whether it should keep a task within its
> internal tracking structures.
>
> So the edge case of ops.select_cpu() placing the task in BPF custody is
> currently valid. The way I see it, we have two choices in terms of
> semantics:
>
> 1) ops.dequeue() must be the equivalent of ops.enqueue(). If the BPF
> scheduler writer decides to place a task into BPF custody during the
> ops.select_cpu() that's on them. ops.select_cpu() is supposed to be a
> pure function providing a hint, anyway. Using it to place a task into
> BPF is a bit of an abuse even if allowed.
>
> 2) We interpret ops.dequeue() to mean "dequeue from the BPF scheduler".
> In that case we allow the edge case and interpret ops.dequeue() as "the
> function that must be called to clear the NEEDS_DEQ/IN_BPF flag", not as
> the complement of ops.enqueue(). In most cases both will be true, and in
> the cases where not then it's up to the scheduler writer to understand
> the nuance.
>
> I think while 2) is cleaner, it is more involved and honestly kinda
> speculative. However, I think it's fair game since once we settle on
> the semantics it will be more difficult to change them. Which one do you
> think makes more sense?
Yeah, I'm also going back and forth on this.
Honestly from a pure theoretical perspective, option (1) feels cleaner to
me: when ops.select_cpu() runs, the task has not entered the BPF scheduler
yet. If we trigger ops.dequeue() in this case, we end up with tasks that
are "leaving" the scheduler without ever having entered it, which feels
like a violation of the lifecycle model.
However, from a practical perspective, it's probably more convenient to
trigger ops.dequeue() also for tasks that are stored in BPF data structures
or user DSQs from ops.select_cpu() as well. If we don't allow that, we
can't just silently ignore the behavior and it's also pretty hard to
reliably detect and trigger an error for this kind of "abuse" at runtime.
That means it could easily turn into a source of subtle bugs in the future,
and I don't think documentation alone would be sufficient to prevent that
(the "don't do that" rules are always fragile).
Therefore, at the moment I'm more inclined to go with option (2), as it
provides better robustness and gives schedulers more flexibility.
-Andrea
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-08 13:55 ` Andrea Righi
@ 2026-02-08 17:59 ` Emil Tsalapatis
2026-02-08 20:08 ` Andrea Righi
0 siblings, 1 reply; 41+ messages in thread
From: Emil Tsalapatis @ 2026-02-08 17:59 UTC (permalink / raw)
To: Andrea Righi
Cc: Tejun Heo, David Vernet, Changwoo Min, Kuba Piecuch,
Christian Loehle, Daniel Hodges, sched-ext, linux-kernel
On Sun Feb 8, 2026 at 8:55 AM EST, Andrea Righi wrote:
> On Sun, Feb 08, 2026 at 11:26:13AM +0100, Andrea Righi wrote:
>> On Sun, Feb 08, 2026 at 10:02:41AM +0100, Andrea Righi wrote:
>> ...
>> > > >> > - From ops.select_cpu():
>> > > >> > - scenario 0 (local DSQ): tasks dispatched to the local DSQ bypass
>> > > >> > the BPF scheduler entirely; they never enter BPF custody, so
>> > > >> > ops.dequeue() is not called,
>> > > >> > - scenario 1 (global DSQ): tasks dispatched to SCX_DSQ_GLOBAL also
>> > > >> > bypass the BPF scheduler, like the local DSQ; ops.dequeue() is
>> > > >> > not called,
>> > > >> > - scenario 2 (user DSQ): tasks enter BPF scheduler custody with full
>> > > >> > enqueue/dequeue lifecycle tracking and state machine validation
>> > > >> > (expects 1:1 enqueue/dequeue pairing).
>> > > >>
>> > > >> Could you add a note here about why there's no equivalent to scenario 6?
>> > > >> The differentiating factor between that and scenario 2 (nonterminal queue) is
>> > > >> that scx_dsq_insert_commit() is called regardless of whether the queue is terminal.
>> > > >> And this makes sense since for non-DSQ queues the BPF scheduler can do its
>> > > >> own tracking of enqueue/dequeue (plus it does not make too much sense to
>> > > >> do BPF-internal enqueueing in select_cpu).
>> > > >>
>> > > >> What do you think? If the above makes sense, maybe we should spell it out
>> > > >> in the documentation too. Maybe also add it makes no sense to enqueue
>> > > >> in an internal BPF structure from select_cpu - the task is not yet
>> > > >> enqueued, and would have to go through enqueue anyway.
>> > > >
>> > > > Oh, I just didn't think about it, we can definitely add to ops.select_cpu()
>> > > > a scenario equivalent to scenario 6 (push task to the BPF queue).
>> > > >
>> > > > From a practical standpoint the benefits are questionable, but in the scope
>> > > > of the kselftest I think it makes sense to better validate the entire state
>> > > > machine in all cases. I'll add this scenario as well.
>> > > >
>> > >
>> > > That makes sense! Let's add it for completeness. Even if it doesn't make
>> > > sense right now that may change in the future. For example, if we end
>> > > up finding a good reason to add the task into an internal structure from
>> > > .select_cpu(), we may allow the task to be explicitly marked as being in
>> > > the BPF scheduler's custody from a kfunc. Right now we can't do that
>> > > from select_cpu() unless we direct dispatch IIUC.
>> >
>> > Ok, I'll send a new patch later with the new scenario included. It should
>> > work already (if done properly in the test case), I think we don't need to
>> > change anything in the kernel.
>>
>> Actually I take that back. The internal BPF queue from ops.select_cpu()
>> scenario is a bit tricky, because when we return from ops.select_cpu()
>> without p->scx.ddsp_dsq_id being set, we don't know if the scheduler added
>> the task to an internal BPF queue or simply did nothing.
>>
>> We need to add some special logic here, preferably without introducing
>> overhead just to handle this particular (really uncommon) case. I'll take a
>> look.
>
> The more I think about this, the more it feels wrong to consider a task as
> being "in BPF scheduler custody" if it is stored in a BPF internal data
> structure from ops.select_cpu().
>
> At the point where ops.select_cpu() runs, the task has not yet entered the
> BPF scheduler's queues. While it is technically possible to stash the task
> in some BPF-managed structure from there, doing so should not imply full
> scheduler custody.
>
> In particular, we should not trigger ops.dequeue(), because the task has
> not reached the "enqueue" stage of its lifecycle. ops.select_cpu() is
> effectively a pre-enqueue hook, primarily intended as a fast path to bypass
> the scheduler altogether. As such, triggering ops.dequeue() in this case
> would not make sense IMHO.
>
> I think it would make more sense to document this behavior explicitly and
> leave the kselftest as is.
>
> Thoughts?
I am going back and forth on this but I think the problem is that the enqueue()
and dequeue() BPF callbacks we have are not actually symmetrical?
1) ops.enqueue() is "sched-ext specific work for the scheduler core's enqueue
method". This is independent on whether the task ends up in BPF custody or not.
It could be in a terminal DSQ, a non-terminal DSQ, or a BPF data structure.
2) ops.dequeue() is "remove task from BPF custody". E.g., it is used by the
BPF scheduler to signal whether it should keep a task within its
internal tracking structures.
So the edge case of ops.select_cpu() placing the task in BPF custody is
currently valid. The way I see it, we have two choices in terms of
semantics:
1) ops.dequeue() must be the equivalent of ops.enqueue(). If the BPF
scheduler writer decides to place a task into BPF custody during the
ops.select_cpu() that's on them. ops.select_cpu() is supposed to be a
pure function providing a hint, anyway. Using it to place a task into
BPF is a bit of an abuse even if allowed.
2) We interpret ops.dequeue() to mean "dequeue from the BPF scheduler".
In that case we allow the edge case and interpret ops.dequeue() as "the
function that must be called to clear the NEEDS_DEQ/IN_BPF flag", not as
the complement of ops.enqueue(). In most cases both will be true, and in
the cases where not then it's up to the scheduler writer to understand
the nuance.
I think while 2) is cleaner, it is more involved and honestly kinda
speculative. However, I think it's fair game since once we settle on
the semantics it will be more difficult to change them. Which one do you
think makes more sense?
>
> Thanks,
> -Andrea
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-08 10:26 ` Andrea Righi
@ 2026-02-08 13:55 ` Andrea Righi
2026-02-08 17:59 ` Emil Tsalapatis
0 siblings, 1 reply; 41+ messages in thread
From: Andrea Righi @ 2026-02-08 13:55 UTC (permalink / raw)
To: Emil Tsalapatis
Cc: Tejun Heo, David Vernet, Changwoo Min, Kuba Piecuch,
Christian Loehle, Daniel Hodges, sched-ext, linux-kernel
On Sun, Feb 08, 2026 at 11:26:13AM +0100, Andrea Righi wrote:
> On Sun, Feb 08, 2026 at 10:02:41AM +0100, Andrea Righi wrote:
> ...
> > > >> > - From ops.select_cpu():
> > > >> > - scenario 0 (local DSQ): tasks dispatched to the local DSQ bypass
> > > >> > the BPF scheduler entirely; they never enter BPF custody, so
> > > >> > ops.dequeue() is not called,
> > > >> > - scenario 1 (global DSQ): tasks dispatched to SCX_DSQ_GLOBAL also
> > > >> > bypass the BPF scheduler, like the local DSQ; ops.dequeue() is
> > > >> > not called,
> > > >> > - scenario 2 (user DSQ): tasks enter BPF scheduler custody with full
> > > >> > enqueue/dequeue lifecycle tracking and state machine validation
> > > >> > (expects 1:1 enqueue/dequeue pairing).
> > > >>
> > > >> Could you add a note here about why there's no equivalent to scenario 6?
> > > >> The differentiating factor between that and scenario 2 (nonterminal queue) is
> > > >> that scx_dsq_insert_commit() is called regardless of whether the queue is terminal.
> > > >> And this makes sense since for non-DSQ queues the BPF scheduler can do its
> > > >> own tracking of enqueue/dequeue (plus it does not make too much sense to
> > > >> do BPF-internal enqueueing in select_cpu).
> > > >>
> > > >> What do you think? If the above makes sense, maybe we should spell it out
> > > >> in the documentation too. Maybe also add it makes no sense to enqueue
> > > >> in an internal BPF structure from select_cpu - the task is not yet
> > > >> enqueued, and would have to go through enqueue anyway.
> > > >
> > > > Oh, I just didn't think about it, we can definitely add to ops.select_cpu()
> > > > a scenario equivalent to scenario 6 (push task to the BPF queue).
> > > >
> > > > From a practical standpoint the benefits are questionable, but in the scope
> > > > of the kselftest I think it makes sense to better validate the entire state
> > > > machine in all cases. I'll add this scenario as well.
> > > >
> > >
> > > That makes sense! Let's add it for completeness. Even if it doesn't make
> > > sense right now that may change in the future. For example, if we end
> > > up finding a good reason to add the task into an internal structure from
> > > .select_cpu(), we may allow the task to be explicitly marked as being in
> > > the BPF scheduler's custody from a kfunc. Right now we can't do that
> > > from select_cpu() unless we direct dispatch IIUC.
> >
> > Ok, I'll send a new patch later with the new scenario included. It should
> > work already (if done properly in the test case), I think we don't need to
> > change anything in the kernel.
>
> Actually I take that back. The internal BPF queue from ops.select_cpu()
> scenario is a bit tricky, because when we return from ops.select_cpu()
> without p->scx.ddsp_dsq_id being set, we don't know if the scheduler added
> the task to an internal BPF queue or simply did nothing.
>
> We need to add some special logic here, preferably without introducing
> overhead just to handle this particular (really uncommon) case. I'll take a
> look.
The more I think about this, the more it feels wrong to consider a task as
being "in BPF scheduler custody" if it is stored in a BPF internal data
structure from ops.select_cpu().
At the point where ops.select_cpu() runs, the task has not yet entered the
BPF scheduler's queues. While it is technically possible to stash the task
in some BPF-managed structure from there, doing so should not imply full
scheduler custody.
In particular, we should not trigger ops.dequeue(), because the task has
not reached the "enqueue" stage of its lifecycle. ops.select_cpu() is
effectively a pre-enqueue hook, primarily intended as a fast path to bypass
the scheduler altogether. As such, triggering ops.dequeue() in this case
would not make sense IMHO.
I think it would make more sense to document this behavior explicitly and
leave the kselftest as is.
Thoughts?
Thanks,
-Andrea
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-08 9:02 ` Andrea Righi
@ 2026-02-08 10:26 ` Andrea Righi
2026-02-08 13:55 ` Andrea Righi
0 siblings, 1 reply; 41+ messages in thread
From: Andrea Righi @ 2026-02-08 10:26 UTC (permalink / raw)
To: Emil Tsalapatis
Cc: Tejun Heo, David Vernet, Changwoo Min, Kuba Piecuch,
Christian Loehle, Daniel Hodges, sched-ext, linux-kernel
On Sun, Feb 08, 2026 at 10:02:41AM +0100, Andrea Righi wrote:
...
> > >> > - From ops.select_cpu():
> > >> > - scenario 0 (local DSQ): tasks dispatched to the local DSQ bypass
> > >> > the BPF scheduler entirely; they never enter BPF custody, so
> > >> > ops.dequeue() is not called,
> > >> > - scenario 1 (global DSQ): tasks dispatched to SCX_DSQ_GLOBAL also
> > >> > bypass the BPF scheduler, like the local DSQ; ops.dequeue() is
> > >> > not called,
> > >> > - scenario 2 (user DSQ): tasks enter BPF scheduler custody with full
> > >> > enqueue/dequeue lifecycle tracking and state machine validation
> > >> > (expects 1:1 enqueue/dequeue pairing).
> > >>
> > >> Could you add a note here about why there's no equivalent to scenario 6?
> > >> The differentiating factor between that and scenario 2 (nonterminal queue) is
> > >> that scx_dsq_insert_commit() is called regardless of whether the queue is terminal.
> > >> And this makes sense since for non-DSQ queues the BPF scheduler can do its
> > >> own tracking of enqueue/dequeue (plus it does not make too much sense to
> > >> do BPF-internal enqueueing in select_cpu).
> > >>
> > >> What do you think? If the above makes sense, maybe we should spell it out
> > >> in the documentation too. Maybe also add it makes no sense to enqueue
> > >> in an internal BPF structure from select_cpu - the task is not yet
> > >> enqueued, and would have to go through enqueue anyway.
> > >
> > > Oh, I just didn't think about it, we can definitely add to ops.select_cpu()
> > > a scenario equivalent to scenario 6 (push task to the BPF queue).
> > >
> > > From a practical standpoint the benefits are questionable, but in the scope
> > > of the kselftest I think it makes sense to better validate the entire state
> > > machine in all cases. I'll add this scenario as well.
> > >
> >
> > That makes sense! Let's add it for completeness. Even if it doesn't make
> > sense right now that may change in the future. For example, if we end
> > up finding a good reason to add the task into an internal structure from
> > .select_cpu(), we may allow the task to be explicitly marked as being in
> > the BPF scheduler's custody from a kfunc. Right now we can't do that
> > from select_cpu() unless we direct dispatch IIUC.
>
> Ok, I'll send a new patch later with the new scenario included. It should
> work already (if done properly in the test case), I think we don't need to
> change anything in the kernel.
Actually I take that back. The internal BPF queue from ops.select_cpu()
scenario is a bit tricky, because when we return from ops.select_cpu()
without p->scx.ddsp_dsq_id being set, we don't know if the scheduler added
the task to an internal BPF queue or simply did nothing.
We need to add some special logic here, preferably without introducing
overhead just to handle this particular (really uncommon) case. I'll take a
look.
Thanks,
-Andrea
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-08 5:11 ` Emil Tsalapatis
@ 2026-02-08 9:02 ` Andrea Righi
2026-02-08 10:26 ` Andrea Righi
0 siblings, 1 reply; 41+ messages in thread
From: Andrea Righi @ 2026-02-08 9:02 UTC (permalink / raw)
To: Emil Tsalapatis
Cc: Tejun Heo, David Vernet, Changwoo Min, Kuba Piecuch,
Christian Loehle, Daniel Hodges, sched-ext, linux-kernel
On Sun, Feb 08, 2026 at 12:11:11AM -0500, Emil Tsalapatis wrote:
> On Sat Feb 7, 2026 at 4:16 AM EST, Andrea Righi wrote:
> > Hi Emil,
> >
>
> Hi Andrea,
>
> > On Fri, Feb 06, 2026 at 03:10:55PM -0500, Emil Tsalapatis wrote:
> >> On Fri Feb 6, 2026 at 8:54 AM EST, Andrea Righi wrote:
> >>
> >> Hi Andrea,
> >>
> >> > Add a new kselftest to validate that the new ops.dequeue() semantics
> >> > work correctly for all task lifecycle scenarios, including the
> >> > distinction between terminal DSQs (where BPF scheduler is done with the
> >> > task), user DSQs (where BPF scheduler manages the task lifecycle) and
> >> > BPF data structures, regardless of which event performs the dispatch.
> >> >
> >> > The test validates the following scenarios:
> >> >
> >> > - From ops.select_cpu():
> >> > - scenario 0 (local DSQ): tasks dispatched to the local DSQ bypass
> >> > the BPF scheduler entirely; they never enter BPF custody, so
> >> > ops.dequeue() is not called,
> >> > - scenario 1 (global DSQ): tasks dispatched to SCX_DSQ_GLOBAL also
> >> > bypass the BPF scheduler, like the local DSQ; ops.dequeue() is
> >> > not called,
> >> > - scenario 2 (user DSQ): tasks enter BPF scheduler custody with full
> >> > enqueue/dequeue lifecycle tracking and state machine validation
> >> > (expects 1:1 enqueue/dequeue pairing).
> >>
> >> Could you add a note here about why there's no equivalent to scenario 6?
> >> The differentiating factor between that and scenario 2 (nonterminal queue) is
> >> that scx_dsq_insert_commit() is called regardless of whether the queue is terminal.
> >> And this makes sense since for non-DSQ queues the BPF scheduler can do its
> >> own tracking of enqueue/dequeue (plus it does not make too much sense to
> >> do BPF-internal enqueueing in select_cpu).
> >>
> >> What do you think? If the above makes sense, maybe we should spell it out
> >> in the documentation too. Maybe also add it makes no sense to enqueue
> >> in an internal BPF structure from select_cpu - the task is not yet
> >> enqueued, and would have to go through enqueue anyway.
> >
> > Oh, I just didn't think about it, we can definitely add to ops.select_cpu()
> > a scenario equivalent to scenario 6 (push task to the BPF queue).
> >
> > From a practical standpoint the benefits are questionable, but in the scope
> > of the kselftest I think it makes sense to better validate the entire state
> > machine in all cases. I'll add this scenario as well.
> >
>
> That makes sense! Let's add it for completeness. Even if it doesn't make
> sense right now that may change in the future. For example, if we end
> up finding a good reason to add the task into an internal structure from
> .select_cpu(), we may allow the task to be explicitly marked as being in
> the BPF scheduler's custody from a kfunc. Right now we can't do that
> from select_cpu() unless we direct dispatch IIUC.
Ok, I'll send a new patch later with the new scenario included. It should
work already (if done properly in the test case), I think we don't need to
change anything in the kernel.
We may need
https://lore.kernel.org/all/20260203230639.1259869-1-arighi@nvidia.com to
avoid hitting potential affinity issues, but that's a separate thing.
I was also considerinng to include this in the series (which I have a v2),
but it's not strictly related to ops.dequeue() and it actually depends on
this ops.dequeue() change, so I think it makes sense to fix this later as a
separate patch.
>
> >>
> >> >
> >> > - From ops.enqueue():
> >> > - scenario 3 (local DSQ): same behavior as scenario 0,
> >> > - scenario 4 (global DSQ): same behavior as scenario 1,
> >> > - scenario 5 (user DSQ): same behavior as scenario 2,
> >> > - scenario 6 (BPF internal queue): tasks are stored in a BPF queue
> >> > in ops.enqueue() and consumed in ops.dispatch(); they remain in
> >> > BPF custody until dispatch, with full lifecycle tracking and 1:1
> >> > enqueue/dequeue validation.
> >> >
> >> > This verifies that:
> >> > - terminal DSQ dispatch (local, global) don't trigger ops.dequeue(),
> >> > - user DSQ / internal BPF data structure dispatch has exact 1:1
> >> > ops.enqueue()/dequeue() pairing,
> >> > - dispatch dequeues have no flags (normal workflow),
> >> > - property change dequeues have the %SCX_DEQ_SCHED_CHANGE flag set,
> >> > - no duplicate enqueues or invalid state transitions are happening,
> >> > - ops.enqueue() and ops.select_cpu() dispatch paths behave identically.
> >> >
> >> > Cc: Tejun Heo <tj@kernel.org>
> >> > Cc: Emil Tsalapatis <emil@etsalapatis.com>
> >> > Cc: Kuba Piecuch <jpiecuch@google.com>
> >> > Signed-off-by: Andrea Righi <arighi@nvidia.com>
> >> > ---
> >> > tools/testing/selftests/sched_ext/Makefile | 1 +
> >> > .../testing/selftests/sched_ext/dequeue.bpf.c | 403 ++++++++++++++++++
> >> > tools/testing/selftests/sched_ext/dequeue.c | 258 +++++++++++
> >> > 3 files changed, 662 insertions(+)
> >> > create mode 100644 tools/testing/selftests/sched_ext/dequeue.bpf.c
> >> > create mode 100644 tools/testing/selftests/sched_ext/dequeue.c
> >> >
> >> > diff --git a/tools/testing/selftests/sched_ext/Makefile b/tools/testing/selftests/sched_ext/Makefile
> >> > index 5fe45f9c5f8fd..764e91edabf93 100644
> >> > --- a/tools/testing/selftests/sched_ext/Makefile
> >> > +++ b/tools/testing/selftests/sched_ext/Makefile
> >> > @@ -161,6 +161,7 @@ all_test_bpfprogs := $(foreach prog,$(wildcard *.bpf.c),$(INCLUDE_DIR)/$(patsubs
> >> >
> >> > auto-test-targets := \
> >> > create_dsq \
> >> > + dequeue \
> >> > enq_last_no_enq_fails \
> >> > ddsp_bogus_dsq_fail \
> >> > ddsp_vtimelocal_fail \
> >> > diff --git a/tools/testing/selftests/sched_ext/dequeue.bpf.c b/tools/testing/selftests/sched_ext/dequeue.bpf.c
> >> > new file mode 100644
> >> > index 0000000000000..4ba657ba1bff5
> >> > --- /dev/null
> >> > +++ b/tools/testing/selftests/sched_ext/dequeue.bpf.c
> >> > @@ -0,0 +1,403 @@
> >> > +// SPDX-License-Identifier: GPL-2.0
> >> > +/*
> >> > + * A scheduler that validates ops.dequeue() is called correctly:
> >> > + * - Tasks dispatched to terminal DSQs (local, global) bypass the BPF
> >> > + * scheduler entirely: no ops.dequeue() should be called
> >> > + * - Tasks dispatched to user DSQs enter BPF custody: ops.dequeue() must be
> >> > + * called when they leave custody
> >> > + * - Every ops.enqueue() for non-terminal DSQs is followed by exactly one
> >> > + * ops.dequeue() (validate 1:1 pairing and state machine)
> >> > + *
> >> > + * Copyright (c) 2026 NVIDIA Corporation.
> >> > + */
> >> > +
> >> > +#include <scx/common.bpf.h>
> >> > +
> >> > +#define SHARED_DSQ 0
> >> > +
> >> > +/*
> >> > + * Scenario 6: BPF internal queue. Tasks are stored here from ops.enqueue()
> >> > + * and consumed from ops.dispatch(), validating that tasks not on a user DSQ
> >> > + * (only on BPF internal structures) still get ops.dequeue() when they leave.
> >> > + */
> >> > +struct {
> >> > + __uint(type, BPF_MAP_TYPE_QUEUE);
> >> > + __uint(max_entries, 4096);
> >>
> >> Nit: Can we make this larger? I don't think there's any downsides. I know
> >> there's a mitigation for if the queue gets full, please see nit below.
> >
> > Sure, like 32768?
> >
> > Or we can keep it like this so we can potentially test also the fallback
> > path sometimes (mixed BPF queue dispatches + built-in DSQ dispatches).
> >
>
> 32K makes sense. If we keep the fallback, maybe we can just add a
> WARN_ON_ONCE() equivalent that it is being triggered so that we make
> sure we don't trigger it every single time (e.g. because the BPF queue
> is misbehaving)?
Maybe we can add a bpf_queue_full_cnt counter, similarly to the other task
lifecycle counters and print it to stdout as the result of the test.
In this way, the fallback won't be considered a critical failure and we
have this metric included in the output if we need this information.
>
> >>
> >> > + __type(value, s32);
> >> > +} global_queue SEC(".maps");
> >> > +
> >> > +char _license[] SEC("license") = "GPL";
> >> > +
> >> > +UEI_DEFINE(uei);
> >> > +
> >> > +/*
> >> > + * Counters to track the lifecycle of tasks:
> >> > + * - enqueue_cnt: Number of times ops.enqueue() was called
> >> > + * - dequeue_cnt: Number of times ops.dequeue() was called (any type)
> >> > + * - dispatch_dequeue_cnt: Number of regular dispatch dequeues (no flag)
> >> > + * - change_dequeue_cnt: Number of property change dequeues
> >> > + */
> >> > +u64 enqueue_cnt, dequeue_cnt, dispatch_dequeue_cnt, change_dequeue_cnt;
> >> > +
> >> > +/*
> >> > + * Test scenarios (0-2: ops.select_cpu(), 3-6: ops.enqueue()):
> >> > + * 0) Dispatch to local DSQ from ops.select_cpu() (terminal DSQ, bypasses BPF
> >> > + * scheduler, no dequeue callbacks)
> >> > + * 1) Dispatch to global DSQ from ops.select_cpu() (terminal DSQ, bypasses BPF
> >> > + * scheduler, no dequeue callbacks)
> >> > + * 2) Dispatch to shared user DSQ from ops.select_cpu() (enters BPF scheduler,
> >> > + * dequeue callbacks expected)
> >> > + * 3) Dispatch to local DSQ from ops.enqueue() (terminal DSQ, bypasses BPF
> >> > + * scheduler, no dequeue callbacks)
> >> > + * 4) Dispatch to global DSQ from ops.enqueue() (terminal DSQ, bypasses BPF
> >> > + * scheduler, no dequeue callbacks)
> >> > + * 5) Dispatch to shared user DSQ from ops.enqueue() (enters BPF scheduler,
> >> > + * dequeue callbacks expected)
> >> > + * 6) BPF internal queue: store task PIDs in ops.enqueue(), consume in
> >> > + * ops.dispatch() and dispatch to local DSQ (validates dequeue for tasks
> >> > + * in BPF custody but not on a user DSQ)
> >> > + */
> >> > +u32 test_scenario;
> >> > +
> >> > +/*
> >> > + * Per-task state to track lifecycle and validate workflow semantics.
> >> > + * State transitions:
> >> > + * NONE -> ENQUEUED (on enqueue)
> >> > + * ENQUEUED -> DISPATCHED (on dispatch dequeue)
> >> > + * DISPATCHED -> NONE (on property change dequeue or re-enqueue)
> >> > + * ENQUEUED -> NONE (on property change dequeue before dispatch)
> >> > + */
> >> > +enum task_state {
> >> > + TASK_NONE = 0,
> >> > + TASK_ENQUEUED,
> >> > + TASK_DISPATCHED,
> >> > +};
> >> > +
> >> > +struct task_ctx {
> >> > + enum task_state state; /* Current state in the workflow */
> >> > + u64 enqueue_seq; /* Sequence number for debugging */
> >> > +};
> >> > +
> >> > +struct {
> >> > + __uint(type, BPF_MAP_TYPE_TASK_STORAGE);
> >> > + __uint(map_flags, BPF_F_NO_PREALLOC);
> >> > + __type(key, int);
> >> > + __type(value, struct task_ctx);
> >> > +} task_ctx_stor SEC(".maps");
> >> > +
> >> > +static struct task_ctx *try_lookup_task_ctx(struct task_struct *p)
> >> > +{
> >> > + return bpf_task_storage_get(&task_ctx_stor, p, 0, 0);
> >> > +}
> >> > +
> >> > +s32 BPF_STRUCT_OPS(dequeue_select_cpu, struct task_struct *p,
> >> > + s32 prev_cpu, u64 wake_flags)
> >> > +{
> >> > + struct task_ctx *tctx;
> >> > +
> >> > + tctx = try_lookup_task_ctx(p);
> >> > + if (!tctx)
> >> > + return prev_cpu;
> >> > +
> >> > + switch (test_scenario) {
> >> > + case 0:
> >> > + /*
> >> > + * Scenario 0: Direct dispatch to local DSQ from select_cpu.
> >> > + *
> >> > + * Task bypasses BPF scheduler entirely: no enqueue
> >> > + * tracking, no dequeue callbacks. Behavior should be
> >> > + * identical to scenario 3.
> >> > + */
> >> > + scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, 0);
> >> > + return prev_cpu;
> >> > +
> >> > + case 1:
> >> > + /*
> >> > + * Scenario 1: Direct dispatch to global DSQ from select_cpu.
> >> > + *
> >> > + * Like scenario 0, task bypasses BPF scheduler entirely.
> >> > + * Behavior should be identical to scenario 4.
> >> > + */
> >> > + scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, 0);
> >> > + return prev_cpu;
> >> > +
> >> > + case 2:
> >> > + /*
> >> > + * Scenario 2: Dispatch to shared user DSQ from select_cpu.
> >> > + *
> >> > + * Task enters BPF scheduler management: track
> >> > + * enqueue/dequeue lifecycle and validate state transitions.
> >> > + * Behavior should be identical to scenario 5.
> >> > + */
> >> > + __sync_fetch_and_add(&enqueue_cnt, 1);
> >> > +
> >> > + /*
> >> > + * Validate state transition: enqueue is only valid from
> >> > + * NONE or DISPATCHED states. Getting enqueue while in
> >> > + * ENQUEUED state indicates a missing dequeue.
> >> > + */
> >> > + if (tctx->state == TASK_ENQUEUED)
> >> > + scx_bpf_error("%d (%s): enqueue while in ENQUEUED state seq=%llu",
> >> > + p->pid, p->comm, tctx->enqueue_seq);
> >> > +
> >> > + /* Transition to ENQUEUED state */
> >> > + tctx->state = TASK_ENQUEUED;
> >> > + tctx->enqueue_seq++;
> >> > +
> >> > + scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, 0);
> >> > + return prev_cpu;
> >> > +
> >> > + default:
> >> > + /*
> >> > + * Force all tasks through ops.enqueue().
> >> > + */
> >> > + return prev_cpu;
> >> > + }
> >> > +}
> >> > +
> >> > +void BPF_STRUCT_OPS(dequeue_enqueue, struct task_struct *p, u64 enq_flags)
> >> > +{
> >> > + struct task_ctx *tctx;
> >> > +
> >> > + tctx = try_lookup_task_ctx(p);
> >> > + if (!tctx)
> >> > + return;
> >> > +
> >> > + switch (test_scenario) {
> >> > + case 3:
> >> > + /*
> >> > + * Scenario 3: Direct dispatch to the local DSQ.
> >> > + *
> >> > + * Task bypasses BPF scheduler entirely: no enqueue
> >> > + * tracking, no dequeue callbacks. Don't increment counters
> >> > + * or validate state since the task never enters BPF
> >> > + * scheduler management.
> >> > + */
> >> > + scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, enq_flags);
> >> > + break;
> >> > +
> >> > + case 4:
> >> > + /*
> >> > + * Scenario 4: Direct dispatch to the global DSQ.
> >> > + *
> >> > + * Like scenario 3, task bypasses BPF scheduler entirely.
> >> > + * SCX_DSQ_GLOBAL is a terminal DSQ, tasks dispatched to it
> >> > + * leave BPF custody immediately, so no dequeue callbacks
> >> > + * should be triggered.
> >> > + */
> >> > + scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
> >> > + break;
> >> > +
> >> > + case 5:
> >> > + /*
> >> > + * Scenario 5: Dispatch to shared user DSQ.
> >> > + *
> >> > + * Task enters BPF scheduler management: track
> >> > + * enqueue/dequeue lifecycle and validate state
> >> > + * transitions.
> >> > + */
> >> > + __sync_fetch_and_add(&enqueue_cnt, 1);
> >> > +
> >> > + /*
> >> > + * Validate state transition: enqueue is only valid from
> >> > + * NONE or DISPATCHED states. Getting enqueue while in
> >> > + * ENQUEUED state indicates a missing dequeue (or stale state
> >> > + * from a previous scenario when the scheduler was unregistered
> >> > + * with tasks still on a DSQ). Reset and proceed to avoid false
> >> > + * positives across scenario switches.
> >> > + */
> >> > + if (tctx->state == TASK_ENQUEUED)
> >> > + tctx->state = TASK_NONE;
> >> > +
> >> > + /* Transition to ENQUEUED state */
> >> > + tctx->state = TASK_ENQUEUED;
> >> > + tctx->enqueue_seq++;
> >> > +
> >> > + scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, enq_flags);
> >> > + break;
> >> > +
> >> > + case 6:
> >> > + /*
> >> > + * Scenario 6: Store task in BPF internal queue. Task enters
> >> > + * BPF custody (kernel sets SCX_TASK_NEED_DEQ). When
> >> > + * ops.dispatch() later pops and inserts to local DSQ,
> >> > + * ops.dequeue() must be called.
> >> > + *
> >> > + * If the queue is full, fallback to local DSQ. The task still
> >> > + * goes through QUEUED in the kernel and gets ops.dequeue()
> >> > + * when moved to the terminal DSQ, so we track it the same.
> >> > + *
> >> > + * If state is already ENQUEUED (e.g. task was on a DSQ when
> >> > + * the scheduler was unregistered in a previous scenario),
> >> > + * reset to NONE and proceed to avoid false positives.
> >> > + */
> >> > + {
> >> > + s32 pid = p->pid;
> >> > +
> >> > + if (tctx->state == TASK_ENQUEUED)
> >> > + tctx->state = TASK_NONE;
> >> > +
> >> > + tctx->state = TASK_ENQUEUED;
> >> > + tctx->enqueue_seq++;
> >> > +
> >> > + /* Queue full: fallback to the global DSQ */
> >> Nit: Can we remove this fallback? This silently changes the behavior of
> >> the test, and even though it makes sense to avoid overflowing the queue,
> >> it causes the test to succeed even if for some reason the
> >> bpf_map_push_elem fails. Why not just bump the queue number to a
> >> reasonably large number amount instead?
> >
> > Hm... but if for any reason we overflow the queue we'd get a false positive
> > error: task is ignored, we trigger a stall and it looks like something is
> > wrong in ops.dequeue(). WDYT?
> >
>
> I agree, but if we bump the queue size to a large number the probability
> of that is nonexistent: I think these test make sense to run in CI-like
> environments where there's few processes anyway, so if a queue is large
> enough there will not be enough tasks to overflow it anyway. This is an
> assumption we make for dsp_local_on, too. Maybe we can keep the fallback
> but warn when it's used (see above)?
I think keeping the fallback + report the counter of "queue full" events is
the best option.
Thanks,
-Andrea
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-07 9:16 ` Andrea Righi
@ 2026-02-08 5:11 ` Emil Tsalapatis
2026-02-08 9:02 ` Andrea Righi
0 siblings, 1 reply; 41+ messages in thread
From: Emil Tsalapatis @ 2026-02-08 5:11 UTC (permalink / raw)
To: Andrea Righi
Cc: Tejun Heo, David Vernet, Changwoo Min, Kuba Piecuch,
Christian Loehle, Daniel Hodges, sched-ext, linux-kernel
On Sat Feb 7, 2026 at 4:16 AM EST, Andrea Righi wrote:
> Hi Emil,
>
Hi Andrea,
> On Fri, Feb 06, 2026 at 03:10:55PM -0500, Emil Tsalapatis wrote:
>> On Fri Feb 6, 2026 at 8:54 AM EST, Andrea Righi wrote:
>>
>> Hi Andrea,
>>
>> > Add a new kselftest to validate that the new ops.dequeue() semantics
>> > work correctly for all task lifecycle scenarios, including the
>> > distinction between terminal DSQs (where BPF scheduler is done with the
>> > task), user DSQs (where BPF scheduler manages the task lifecycle) and
>> > BPF data structures, regardless of which event performs the dispatch.
>> >
>> > The test validates the following scenarios:
>> >
>> > - From ops.select_cpu():
>> > - scenario 0 (local DSQ): tasks dispatched to the local DSQ bypass
>> > the BPF scheduler entirely; they never enter BPF custody, so
>> > ops.dequeue() is not called,
>> > - scenario 1 (global DSQ): tasks dispatched to SCX_DSQ_GLOBAL also
>> > bypass the BPF scheduler, like the local DSQ; ops.dequeue() is
>> > not called,
>> > - scenario 2 (user DSQ): tasks enter BPF scheduler custody with full
>> > enqueue/dequeue lifecycle tracking and state machine validation
>> > (expects 1:1 enqueue/dequeue pairing).
>>
>> Could you add a note here about why there's no equivalent to scenario 6?
>> The differentiating factor between that and scenario 2 (nonterminal queue) is
>> that scx_dsq_insert_commit() is called regardless of whether the queue is terminal.
>> And this makes sense since for non-DSQ queues the BPF scheduler can do its
>> own tracking of enqueue/dequeue (plus it does not make too much sense to
>> do BPF-internal enqueueing in select_cpu).
>>
>> What do you think? If the above makes sense, maybe we should spell it out
>> in the documentation too. Maybe also add it makes no sense to enqueue
>> in an internal BPF structure from select_cpu - the task is not yet
>> enqueued, and would have to go through enqueue anyway.
>
> Oh, I just didn't think about it, we can definitely add to ops.select_cpu()
> a scenario equivalent to scenario 6 (push task to the BPF queue).
>
> From a practical standpoint the benefits are questionable, but in the scope
> of the kselftest I think it makes sense to better validate the entire state
> machine in all cases. I'll add this scenario as well.
>
That makes sense! Let's add it for completeness. Even if it doesn't make
sense right now that may change in the future. For example, if we end
up finding a good reason to add the task into an internal structure from
.select_cpu(), we may allow the task to be explicitly marked as being in
the BPF scheduler's custody from a kfunc. Right now we can't do that
from select_cpu() unless we direct dispatch IIUC.
>>
>> >
>> > - From ops.enqueue():
>> > - scenario 3 (local DSQ): same behavior as scenario 0,
>> > - scenario 4 (global DSQ): same behavior as scenario 1,
>> > - scenario 5 (user DSQ): same behavior as scenario 2,
>> > - scenario 6 (BPF internal queue): tasks are stored in a BPF queue
>> > in ops.enqueue() and consumed in ops.dispatch(); they remain in
>> > BPF custody until dispatch, with full lifecycle tracking and 1:1
>> > enqueue/dequeue validation.
>> >
>> > This verifies that:
>> > - terminal DSQ dispatch (local, global) don't trigger ops.dequeue(),
>> > - user DSQ / internal BPF data structure dispatch has exact 1:1
>> > ops.enqueue()/dequeue() pairing,
>> > - dispatch dequeues have no flags (normal workflow),
>> > - property change dequeues have the %SCX_DEQ_SCHED_CHANGE flag set,
>> > - no duplicate enqueues or invalid state transitions are happening,
>> > - ops.enqueue() and ops.select_cpu() dispatch paths behave identically.
>> >
>> > Cc: Tejun Heo <tj@kernel.org>
>> > Cc: Emil Tsalapatis <emil@etsalapatis.com>
>> > Cc: Kuba Piecuch <jpiecuch@google.com>
>> > Signed-off-by: Andrea Righi <arighi@nvidia.com>
>> > ---
>> > tools/testing/selftests/sched_ext/Makefile | 1 +
>> > .../testing/selftests/sched_ext/dequeue.bpf.c | 403 ++++++++++++++++++
>> > tools/testing/selftests/sched_ext/dequeue.c | 258 +++++++++++
>> > 3 files changed, 662 insertions(+)
>> > create mode 100644 tools/testing/selftests/sched_ext/dequeue.bpf.c
>> > create mode 100644 tools/testing/selftests/sched_ext/dequeue.c
>> >
>> > diff --git a/tools/testing/selftests/sched_ext/Makefile b/tools/testing/selftests/sched_ext/Makefile
>> > index 5fe45f9c5f8fd..764e91edabf93 100644
>> > --- a/tools/testing/selftests/sched_ext/Makefile
>> > +++ b/tools/testing/selftests/sched_ext/Makefile
>> > @@ -161,6 +161,7 @@ all_test_bpfprogs := $(foreach prog,$(wildcard *.bpf.c),$(INCLUDE_DIR)/$(patsubs
>> >
>> > auto-test-targets := \
>> > create_dsq \
>> > + dequeue \
>> > enq_last_no_enq_fails \
>> > ddsp_bogus_dsq_fail \
>> > ddsp_vtimelocal_fail \
>> > diff --git a/tools/testing/selftests/sched_ext/dequeue.bpf.c b/tools/testing/selftests/sched_ext/dequeue.bpf.c
>> > new file mode 100644
>> > index 0000000000000..4ba657ba1bff5
>> > --- /dev/null
>> > +++ b/tools/testing/selftests/sched_ext/dequeue.bpf.c
>> > @@ -0,0 +1,403 @@
>> > +// SPDX-License-Identifier: GPL-2.0
>> > +/*
>> > + * A scheduler that validates ops.dequeue() is called correctly:
>> > + * - Tasks dispatched to terminal DSQs (local, global) bypass the BPF
>> > + * scheduler entirely: no ops.dequeue() should be called
>> > + * - Tasks dispatched to user DSQs enter BPF custody: ops.dequeue() must be
>> > + * called when they leave custody
>> > + * - Every ops.enqueue() for non-terminal DSQs is followed by exactly one
>> > + * ops.dequeue() (validate 1:1 pairing and state machine)
>> > + *
>> > + * Copyright (c) 2026 NVIDIA Corporation.
>> > + */
>> > +
>> > +#include <scx/common.bpf.h>
>> > +
>> > +#define SHARED_DSQ 0
>> > +
>> > +/*
>> > + * Scenario 6: BPF internal queue. Tasks are stored here from ops.enqueue()
>> > + * and consumed from ops.dispatch(), validating that tasks not on a user DSQ
>> > + * (only on BPF internal structures) still get ops.dequeue() when they leave.
>> > + */
>> > +struct {
>> > + __uint(type, BPF_MAP_TYPE_QUEUE);
>> > + __uint(max_entries, 4096);
>>
>> Nit: Can we make this larger? I don't think there's any downsides. I know
>> there's a mitigation for if the queue gets full, please see nit below.
>
> Sure, like 32768?
>
> Or we can keep it like this so we can potentially test also the fallback
> path sometimes (mixed BPF queue dispatches + built-in DSQ dispatches).
>
32K makes sense. If we keep the fallback, maybe we can just add a
WARN_ON_ONCE() equivalent that it is being triggered so that we make
sure we don't trigger it every single time (e.g. because the BPF queue
is misbehaving)?
>>
>> > + __type(value, s32);
>> > +} global_queue SEC(".maps");
>> > +
>> > +char _license[] SEC("license") = "GPL";
>> > +
>> > +UEI_DEFINE(uei);
>> > +
>> > +/*
>> > + * Counters to track the lifecycle of tasks:
>> > + * - enqueue_cnt: Number of times ops.enqueue() was called
>> > + * - dequeue_cnt: Number of times ops.dequeue() was called (any type)
>> > + * - dispatch_dequeue_cnt: Number of regular dispatch dequeues (no flag)
>> > + * - change_dequeue_cnt: Number of property change dequeues
>> > + */
>> > +u64 enqueue_cnt, dequeue_cnt, dispatch_dequeue_cnt, change_dequeue_cnt;
>> > +
>> > +/*
>> > + * Test scenarios (0-2: ops.select_cpu(), 3-6: ops.enqueue()):
>> > + * 0) Dispatch to local DSQ from ops.select_cpu() (terminal DSQ, bypasses BPF
>> > + * scheduler, no dequeue callbacks)
>> > + * 1) Dispatch to global DSQ from ops.select_cpu() (terminal DSQ, bypasses BPF
>> > + * scheduler, no dequeue callbacks)
>> > + * 2) Dispatch to shared user DSQ from ops.select_cpu() (enters BPF scheduler,
>> > + * dequeue callbacks expected)
>> > + * 3) Dispatch to local DSQ from ops.enqueue() (terminal DSQ, bypasses BPF
>> > + * scheduler, no dequeue callbacks)
>> > + * 4) Dispatch to global DSQ from ops.enqueue() (terminal DSQ, bypasses BPF
>> > + * scheduler, no dequeue callbacks)
>> > + * 5) Dispatch to shared user DSQ from ops.enqueue() (enters BPF scheduler,
>> > + * dequeue callbacks expected)
>> > + * 6) BPF internal queue: store task PIDs in ops.enqueue(), consume in
>> > + * ops.dispatch() and dispatch to local DSQ (validates dequeue for tasks
>> > + * in BPF custody but not on a user DSQ)
>> > + */
>> > +u32 test_scenario;
>> > +
>> > +/*
>> > + * Per-task state to track lifecycle and validate workflow semantics.
>> > + * State transitions:
>> > + * NONE -> ENQUEUED (on enqueue)
>> > + * ENQUEUED -> DISPATCHED (on dispatch dequeue)
>> > + * DISPATCHED -> NONE (on property change dequeue or re-enqueue)
>> > + * ENQUEUED -> NONE (on property change dequeue before dispatch)
>> > + */
>> > +enum task_state {
>> > + TASK_NONE = 0,
>> > + TASK_ENQUEUED,
>> > + TASK_DISPATCHED,
>> > +};
>> > +
>> > +struct task_ctx {
>> > + enum task_state state; /* Current state in the workflow */
>> > + u64 enqueue_seq; /* Sequence number for debugging */
>> > +};
>> > +
>> > +struct {
>> > + __uint(type, BPF_MAP_TYPE_TASK_STORAGE);
>> > + __uint(map_flags, BPF_F_NO_PREALLOC);
>> > + __type(key, int);
>> > + __type(value, struct task_ctx);
>> > +} task_ctx_stor SEC(".maps");
>> > +
>> > +static struct task_ctx *try_lookup_task_ctx(struct task_struct *p)
>> > +{
>> > + return bpf_task_storage_get(&task_ctx_stor, p, 0, 0);
>> > +}
>> > +
>> > +s32 BPF_STRUCT_OPS(dequeue_select_cpu, struct task_struct *p,
>> > + s32 prev_cpu, u64 wake_flags)
>> > +{
>> > + struct task_ctx *tctx;
>> > +
>> > + tctx = try_lookup_task_ctx(p);
>> > + if (!tctx)
>> > + return prev_cpu;
>> > +
>> > + switch (test_scenario) {
>> > + case 0:
>> > + /*
>> > + * Scenario 0: Direct dispatch to local DSQ from select_cpu.
>> > + *
>> > + * Task bypasses BPF scheduler entirely: no enqueue
>> > + * tracking, no dequeue callbacks. Behavior should be
>> > + * identical to scenario 3.
>> > + */
>> > + scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, 0);
>> > + return prev_cpu;
>> > +
>> > + case 1:
>> > + /*
>> > + * Scenario 1: Direct dispatch to global DSQ from select_cpu.
>> > + *
>> > + * Like scenario 0, task bypasses BPF scheduler entirely.
>> > + * Behavior should be identical to scenario 4.
>> > + */
>> > + scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, 0);
>> > + return prev_cpu;
>> > +
>> > + case 2:
>> > + /*
>> > + * Scenario 2: Dispatch to shared user DSQ from select_cpu.
>> > + *
>> > + * Task enters BPF scheduler management: track
>> > + * enqueue/dequeue lifecycle and validate state transitions.
>> > + * Behavior should be identical to scenario 5.
>> > + */
>> > + __sync_fetch_and_add(&enqueue_cnt, 1);
>> > +
>> > + /*
>> > + * Validate state transition: enqueue is only valid from
>> > + * NONE or DISPATCHED states. Getting enqueue while in
>> > + * ENQUEUED state indicates a missing dequeue.
>> > + */
>> > + if (tctx->state == TASK_ENQUEUED)
>> > + scx_bpf_error("%d (%s): enqueue while in ENQUEUED state seq=%llu",
>> > + p->pid, p->comm, tctx->enqueue_seq);
>> > +
>> > + /* Transition to ENQUEUED state */
>> > + tctx->state = TASK_ENQUEUED;
>> > + tctx->enqueue_seq++;
>> > +
>> > + scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, 0);
>> > + return prev_cpu;
>> > +
>> > + default:
>> > + /*
>> > + * Force all tasks through ops.enqueue().
>> > + */
>> > + return prev_cpu;
>> > + }
>> > +}
>> > +
>> > +void BPF_STRUCT_OPS(dequeue_enqueue, struct task_struct *p, u64 enq_flags)
>> > +{
>> > + struct task_ctx *tctx;
>> > +
>> > + tctx = try_lookup_task_ctx(p);
>> > + if (!tctx)
>> > + return;
>> > +
>> > + switch (test_scenario) {
>> > + case 3:
>> > + /*
>> > + * Scenario 3: Direct dispatch to the local DSQ.
>> > + *
>> > + * Task bypasses BPF scheduler entirely: no enqueue
>> > + * tracking, no dequeue callbacks. Don't increment counters
>> > + * or validate state since the task never enters BPF
>> > + * scheduler management.
>> > + */
>> > + scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, enq_flags);
>> > + break;
>> > +
>> > + case 4:
>> > + /*
>> > + * Scenario 4: Direct dispatch to the global DSQ.
>> > + *
>> > + * Like scenario 3, task bypasses BPF scheduler entirely.
>> > + * SCX_DSQ_GLOBAL is a terminal DSQ, tasks dispatched to it
>> > + * leave BPF custody immediately, so no dequeue callbacks
>> > + * should be triggered.
>> > + */
>> > + scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
>> > + break;
>> > +
>> > + case 5:
>> > + /*
>> > + * Scenario 5: Dispatch to shared user DSQ.
>> > + *
>> > + * Task enters BPF scheduler management: track
>> > + * enqueue/dequeue lifecycle and validate state
>> > + * transitions.
>> > + */
>> > + __sync_fetch_and_add(&enqueue_cnt, 1);
>> > +
>> > + /*
>> > + * Validate state transition: enqueue is only valid from
>> > + * NONE or DISPATCHED states. Getting enqueue while in
>> > + * ENQUEUED state indicates a missing dequeue (or stale state
>> > + * from a previous scenario when the scheduler was unregistered
>> > + * with tasks still on a DSQ). Reset and proceed to avoid false
>> > + * positives across scenario switches.
>> > + */
>> > + if (tctx->state == TASK_ENQUEUED)
>> > + tctx->state = TASK_NONE;
>> > +
>> > + /* Transition to ENQUEUED state */
>> > + tctx->state = TASK_ENQUEUED;
>> > + tctx->enqueue_seq++;
>> > +
>> > + scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, enq_flags);
>> > + break;
>> > +
>> > + case 6:
>> > + /*
>> > + * Scenario 6: Store task in BPF internal queue. Task enters
>> > + * BPF custody (kernel sets SCX_TASK_NEED_DEQ). When
>> > + * ops.dispatch() later pops and inserts to local DSQ,
>> > + * ops.dequeue() must be called.
>> > + *
>> > + * If the queue is full, fallback to local DSQ. The task still
>> > + * goes through QUEUED in the kernel and gets ops.dequeue()
>> > + * when moved to the terminal DSQ, so we track it the same.
>> > + *
>> > + * If state is already ENQUEUED (e.g. task was on a DSQ when
>> > + * the scheduler was unregistered in a previous scenario),
>> > + * reset to NONE and proceed to avoid false positives.
>> > + */
>> > + {
>> > + s32 pid = p->pid;
>> > +
>> > + if (tctx->state == TASK_ENQUEUED)
>> > + tctx->state = TASK_NONE;
>> > +
>> > + tctx->state = TASK_ENQUEUED;
>> > + tctx->enqueue_seq++;
>> > +
>> > + /* Queue full: fallback to the global DSQ */
>> Nit: Can we remove this fallback? This silently changes the behavior of
>> the test, and even though it makes sense to avoid overflowing the queue,
>> it causes the test to succeed even if for some reason the
>> bpf_map_push_elem fails. Why not just bump the queue number to a
>> reasonably large number amount instead?
>
> Hm... but if for any reason we overflow the queue we'd get a false positive
> error: task is ignored, we trigger a stall and it looks like something is
> wrong in ops.dequeue(). WDYT?
>
I agree, but if we bump the queue size to a large number the probability
of that is nonexistent: I think these test make sense to run in CI-like
environments where there's few processes anyway, so if a queue is large
enough there will not be enough tasks to overflow it anyway. This is an
assumption we make for dsp_local_on, too. Maybe we can keep the fallback
but warn when it's used (see above)?
> Thanks,
> -Andrea
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-06 20:10 ` Emil Tsalapatis
@ 2026-02-07 9:16 ` Andrea Righi
2026-02-08 5:11 ` Emil Tsalapatis
0 siblings, 1 reply; 41+ messages in thread
From: Andrea Righi @ 2026-02-07 9:16 UTC (permalink / raw)
To: Emil Tsalapatis
Cc: Tejun Heo, David Vernet, Changwoo Min, Kuba Piecuch,
Christian Loehle, Daniel Hodges, sched-ext, linux-kernel
Hi Emil,
On Fri, Feb 06, 2026 at 03:10:55PM -0500, Emil Tsalapatis wrote:
> On Fri Feb 6, 2026 at 8:54 AM EST, Andrea Righi wrote:
>
> Hi Andrea,
>
> > Add a new kselftest to validate that the new ops.dequeue() semantics
> > work correctly for all task lifecycle scenarios, including the
> > distinction between terminal DSQs (where BPF scheduler is done with the
> > task), user DSQs (where BPF scheduler manages the task lifecycle) and
> > BPF data structures, regardless of which event performs the dispatch.
> >
> > The test validates the following scenarios:
> >
> > - From ops.select_cpu():
> > - scenario 0 (local DSQ): tasks dispatched to the local DSQ bypass
> > the BPF scheduler entirely; they never enter BPF custody, so
> > ops.dequeue() is not called,
> > - scenario 1 (global DSQ): tasks dispatched to SCX_DSQ_GLOBAL also
> > bypass the BPF scheduler, like the local DSQ; ops.dequeue() is
> > not called,
> > - scenario 2 (user DSQ): tasks enter BPF scheduler custody with full
> > enqueue/dequeue lifecycle tracking and state machine validation
> > (expects 1:1 enqueue/dequeue pairing).
>
> Could you add a note here about why there's no equivalent to scenario 6?
> The differentiating factor between that and scenario 2 (nonterminal queue) is
> that scx_dsq_insert_commit() is called regardless of whether the queue is terminal.
> And this makes sense since for non-DSQ queues the BPF scheduler can do its
> own tracking of enqueue/dequeue (plus it does not make too much sense to
> do BPF-internal enqueueing in select_cpu).
>
> What do you think? If the above makes sense, maybe we should spell it out
> in the documentation too. Maybe also add it makes no sense to enqueue
> in an internal BPF structure from select_cpu - the task is not yet
> enqueued, and would have to go through enqueue anyway.
Oh, I just didn't think about it, we can definitely add to ops.select_cpu()
a scenario equivalent to scenario 6 (push task to the BPF queue).
From a practical standpoint the benefits are questionable, but in the scope
of the kselftest I think it makes sense to better validate the entire state
machine in all cases. I'll add this scenario as well.
>
> >
> > - From ops.enqueue():
> > - scenario 3 (local DSQ): same behavior as scenario 0,
> > - scenario 4 (global DSQ): same behavior as scenario 1,
> > - scenario 5 (user DSQ): same behavior as scenario 2,
> > - scenario 6 (BPF internal queue): tasks are stored in a BPF queue
> > in ops.enqueue() and consumed in ops.dispatch(); they remain in
> > BPF custody until dispatch, with full lifecycle tracking and 1:1
> > enqueue/dequeue validation.
> >
> > This verifies that:
> > - terminal DSQ dispatch (local, global) don't trigger ops.dequeue(),
> > - user DSQ / internal BPF data structure dispatch has exact 1:1
> > ops.enqueue()/dequeue() pairing,
> > - dispatch dequeues have no flags (normal workflow),
> > - property change dequeues have the %SCX_DEQ_SCHED_CHANGE flag set,
> > - no duplicate enqueues or invalid state transitions are happening,
> > - ops.enqueue() and ops.select_cpu() dispatch paths behave identically.
> >
> > Cc: Tejun Heo <tj@kernel.org>
> > Cc: Emil Tsalapatis <emil@etsalapatis.com>
> > Cc: Kuba Piecuch <jpiecuch@google.com>
> > Signed-off-by: Andrea Righi <arighi@nvidia.com>
> > ---
> > tools/testing/selftests/sched_ext/Makefile | 1 +
> > .../testing/selftests/sched_ext/dequeue.bpf.c | 403 ++++++++++++++++++
> > tools/testing/selftests/sched_ext/dequeue.c | 258 +++++++++++
> > 3 files changed, 662 insertions(+)
> > create mode 100644 tools/testing/selftests/sched_ext/dequeue.bpf.c
> > create mode 100644 tools/testing/selftests/sched_ext/dequeue.c
> >
> > diff --git a/tools/testing/selftests/sched_ext/Makefile b/tools/testing/selftests/sched_ext/Makefile
> > index 5fe45f9c5f8fd..764e91edabf93 100644
> > --- a/tools/testing/selftests/sched_ext/Makefile
> > +++ b/tools/testing/selftests/sched_ext/Makefile
> > @@ -161,6 +161,7 @@ all_test_bpfprogs := $(foreach prog,$(wildcard *.bpf.c),$(INCLUDE_DIR)/$(patsubs
> >
> > auto-test-targets := \
> > create_dsq \
> > + dequeue \
> > enq_last_no_enq_fails \
> > ddsp_bogus_dsq_fail \
> > ddsp_vtimelocal_fail \
> > diff --git a/tools/testing/selftests/sched_ext/dequeue.bpf.c b/tools/testing/selftests/sched_ext/dequeue.bpf.c
> > new file mode 100644
> > index 0000000000000..4ba657ba1bff5
> > --- /dev/null
> > +++ b/tools/testing/selftests/sched_ext/dequeue.bpf.c
> > @@ -0,0 +1,403 @@
> > +// SPDX-License-Identifier: GPL-2.0
> > +/*
> > + * A scheduler that validates ops.dequeue() is called correctly:
> > + * - Tasks dispatched to terminal DSQs (local, global) bypass the BPF
> > + * scheduler entirely: no ops.dequeue() should be called
> > + * - Tasks dispatched to user DSQs enter BPF custody: ops.dequeue() must be
> > + * called when they leave custody
> > + * - Every ops.enqueue() for non-terminal DSQs is followed by exactly one
> > + * ops.dequeue() (validate 1:1 pairing and state machine)
> > + *
> > + * Copyright (c) 2026 NVIDIA Corporation.
> > + */
> > +
> > +#include <scx/common.bpf.h>
> > +
> > +#define SHARED_DSQ 0
> > +
> > +/*
> > + * Scenario 6: BPF internal queue. Tasks are stored here from ops.enqueue()
> > + * and consumed from ops.dispatch(), validating that tasks not on a user DSQ
> > + * (only on BPF internal structures) still get ops.dequeue() when they leave.
> > + */
> > +struct {
> > + __uint(type, BPF_MAP_TYPE_QUEUE);
> > + __uint(max_entries, 4096);
>
> Nit: Can we make this larger? I don't think there's any downsides. I know
> there's a mitigation for if the queue gets full, please see nit below.
Sure, like 32768?
Or we can keep it like this so we can potentially test also the fallback
path sometimes (mixed BPF queue dispatches + built-in DSQ dispatches).
>
> > + __type(value, s32);
> > +} global_queue SEC(".maps");
> > +
> > +char _license[] SEC("license") = "GPL";
> > +
> > +UEI_DEFINE(uei);
> > +
> > +/*
> > + * Counters to track the lifecycle of tasks:
> > + * - enqueue_cnt: Number of times ops.enqueue() was called
> > + * - dequeue_cnt: Number of times ops.dequeue() was called (any type)
> > + * - dispatch_dequeue_cnt: Number of regular dispatch dequeues (no flag)
> > + * - change_dequeue_cnt: Number of property change dequeues
> > + */
> > +u64 enqueue_cnt, dequeue_cnt, dispatch_dequeue_cnt, change_dequeue_cnt;
> > +
> > +/*
> > + * Test scenarios (0-2: ops.select_cpu(), 3-6: ops.enqueue()):
> > + * 0) Dispatch to local DSQ from ops.select_cpu() (terminal DSQ, bypasses BPF
> > + * scheduler, no dequeue callbacks)
> > + * 1) Dispatch to global DSQ from ops.select_cpu() (terminal DSQ, bypasses BPF
> > + * scheduler, no dequeue callbacks)
> > + * 2) Dispatch to shared user DSQ from ops.select_cpu() (enters BPF scheduler,
> > + * dequeue callbacks expected)
> > + * 3) Dispatch to local DSQ from ops.enqueue() (terminal DSQ, bypasses BPF
> > + * scheduler, no dequeue callbacks)
> > + * 4) Dispatch to global DSQ from ops.enqueue() (terminal DSQ, bypasses BPF
> > + * scheduler, no dequeue callbacks)
> > + * 5) Dispatch to shared user DSQ from ops.enqueue() (enters BPF scheduler,
> > + * dequeue callbacks expected)
> > + * 6) BPF internal queue: store task PIDs in ops.enqueue(), consume in
> > + * ops.dispatch() and dispatch to local DSQ (validates dequeue for tasks
> > + * in BPF custody but not on a user DSQ)
> > + */
> > +u32 test_scenario;
> > +
> > +/*
> > + * Per-task state to track lifecycle and validate workflow semantics.
> > + * State transitions:
> > + * NONE -> ENQUEUED (on enqueue)
> > + * ENQUEUED -> DISPATCHED (on dispatch dequeue)
> > + * DISPATCHED -> NONE (on property change dequeue or re-enqueue)
> > + * ENQUEUED -> NONE (on property change dequeue before dispatch)
> > + */
> > +enum task_state {
> > + TASK_NONE = 0,
> > + TASK_ENQUEUED,
> > + TASK_DISPATCHED,
> > +};
> > +
> > +struct task_ctx {
> > + enum task_state state; /* Current state in the workflow */
> > + u64 enqueue_seq; /* Sequence number for debugging */
> > +};
> > +
> > +struct {
> > + __uint(type, BPF_MAP_TYPE_TASK_STORAGE);
> > + __uint(map_flags, BPF_F_NO_PREALLOC);
> > + __type(key, int);
> > + __type(value, struct task_ctx);
> > +} task_ctx_stor SEC(".maps");
> > +
> > +static struct task_ctx *try_lookup_task_ctx(struct task_struct *p)
> > +{
> > + return bpf_task_storage_get(&task_ctx_stor, p, 0, 0);
> > +}
> > +
> > +s32 BPF_STRUCT_OPS(dequeue_select_cpu, struct task_struct *p,
> > + s32 prev_cpu, u64 wake_flags)
> > +{
> > + struct task_ctx *tctx;
> > +
> > + tctx = try_lookup_task_ctx(p);
> > + if (!tctx)
> > + return prev_cpu;
> > +
> > + switch (test_scenario) {
> > + case 0:
> > + /*
> > + * Scenario 0: Direct dispatch to local DSQ from select_cpu.
> > + *
> > + * Task bypasses BPF scheduler entirely: no enqueue
> > + * tracking, no dequeue callbacks. Behavior should be
> > + * identical to scenario 3.
> > + */
> > + scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, 0);
> > + return prev_cpu;
> > +
> > + case 1:
> > + /*
> > + * Scenario 1: Direct dispatch to global DSQ from select_cpu.
> > + *
> > + * Like scenario 0, task bypasses BPF scheduler entirely.
> > + * Behavior should be identical to scenario 4.
> > + */
> > + scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, 0);
> > + return prev_cpu;
> > +
> > + case 2:
> > + /*
> > + * Scenario 2: Dispatch to shared user DSQ from select_cpu.
> > + *
> > + * Task enters BPF scheduler management: track
> > + * enqueue/dequeue lifecycle and validate state transitions.
> > + * Behavior should be identical to scenario 5.
> > + */
> > + __sync_fetch_and_add(&enqueue_cnt, 1);
> > +
> > + /*
> > + * Validate state transition: enqueue is only valid from
> > + * NONE or DISPATCHED states. Getting enqueue while in
> > + * ENQUEUED state indicates a missing dequeue.
> > + */
> > + if (tctx->state == TASK_ENQUEUED)
> > + scx_bpf_error("%d (%s): enqueue while in ENQUEUED state seq=%llu",
> > + p->pid, p->comm, tctx->enqueue_seq);
> > +
> > + /* Transition to ENQUEUED state */
> > + tctx->state = TASK_ENQUEUED;
> > + tctx->enqueue_seq++;
> > +
> > + scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, 0);
> > + return prev_cpu;
> > +
> > + default:
> > + /*
> > + * Force all tasks through ops.enqueue().
> > + */
> > + return prev_cpu;
> > + }
> > +}
> > +
> > +void BPF_STRUCT_OPS(dequeue_enqueue, struct task_struct *p, u64 enq_flags)
> > +{
> > + struct task_ctx *tctx;
> > +
> > + tctx = try_lookup_task_ctx(p);
> > + if (!tctx)
> > + return;
> > +
> > + switch (test_scenario) {
> > + case 3:
> > + /*
> > + * Scenario 3: Direct dispatch to the local DSQ.
> > + *
> > + * Task bypasses BPF scheduler entirely: no enqueue
> > + * tracking, no dequeue callbacks. Don't increment counters
> > + * or validate state since the task never enters BPF
> > + * scheduler management.
> > + */
> > + scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, enq_flags);
> > + break;
> > +
> > + case 4:
> > + /*
> > + * Scenario 4: Direct dispatch to the global DSQ.
> > + *
> > + * Like scenario 3, task bypasses BPF scheduler entirely.
> > + * SCX_DSQ_GLOBAL is a terminal DSQ, tasks dispatched to it
> > + * leave BPF custody immediately, so no dequeue callbacks
> > + * should be triggered.
> > + */
> > + scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
> > + break;
> > +
> > + case 5:
> > + /*
> > + * Scenario 5: Dispatch to shared user DSQ.
> > + *
> > + * Task enters BPF scheduler management: track
> > + * enqueue/dequeue lifecycle and validate state
> > + * transitions.
> > + */
> > + __sync_fetch_and_add(&enqueue_cnt, 1);
> > +
> > + /*
> > + * Validate state transition: enqueue is only valid from
> > + * NONE or DISPATCHED states. Getting enqueue while in
> > + * ENQUEUED state indicates a missing dequeue (or stale state
> > + * from a previous scenario when the scheduler was unregistered
> > + * with tasks still on a DSQ). Reset and proceed to avoid false
> > + * positives across scenario switches.
> > + */
> > + if (tctx->state == TASK_ENQUEUED)
> > + tctx->state = TASK_NONE;
> > +
> > + /* Transition to ENQUEUED state */
> > + tctx->state = TASK_ENQUEUED;
> > + tctx->enqueue_seq++;
> > +
> > + scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, enq_flags);
> > + break;
> > +
> > + case 6:
> > + /*
> > + * Scenario 6: Store task in BPF internal queue. Task enters
> > + * BPF custody (kernel sets SCX_TASK_NEED_DEQ). When
> > + * ops.dispatch() later pops and inserts to local DSQ,
> > + * ops.dequeue() must be called.
> > + *
> > + * If the queue is full, fallback to local DSQ. The task still
> > + * goes through QUEUED in the kernel and gets ops.dequeue()
> > + * when moved to the terminal DSQ, so we track it the same.
> > + *
> > + * If state is already ENQUEUED (e.g. task was on a DSQ when
> > + * the scheduler was unregistered in a previous scenario),
> > + * reset to NONE and proceed to avoid false positives.
> > + */
> > + {
> > + s32 pid = p->pid;
> > +
> > + if (tctx->state == TASK_ENQUEUED)
> > + tctx->state = TASK_NONE;
> > +
> > + tctx->state = TASK_ENQUEUED;
> > + tctx->enqueue_seq++;
> > +
> > + /* Queue full: fallback to the global DSQ */
> Nit: Can we remove this fallback? This silently changes the behavior of
> the test, and even though it makes sense to avoid overflowing the queue,
> it causes the test to succeed even if for some reason the
> bpf_map_push_elem fails. Why not just bump the queue number to a
> reasonably large number amount instead?
Hm... but if for any reason we overflow the queue we'd get a false positive
error: task is ignored, we trigger a stall and it looks like something is
wrong in ops.dequeue(). WDYT?
Thanks,
-Andrea
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-06 13:54 ` [PATCH 2/2] selftests/sched_ext: Add test to validate " Andrea Righi
@ 2026-02-06 20:10 ` Emil Tsalapatis
2026-02-07 9:16 ` Andrea Righi
0 siblings, 1 reply; 41+ messages in thread
From: Emil Tsalapatis @ 2026-02-06 20:10 UTC (permalink / raw)
To: Andrea Righi, Tejun Heo, David Vernet, Changwoo Min
Cc: Kuba Piecuch, Christian Loehle, Daniel Hodges, sched-ext, linux-kernel
On Fri Feb 6, 2026 at 8:54 AM EST, Andrea Righi wrote:
Hi Andrea,
> Add a new kselftest to validate that the new ops.dequeue() semantics
> work correctly for all task lifecycle scenarios, including the
> distinction between terminal DSQs (where BPF scheduler is done with the
> task), user DSQs (where BPF scheduler manages the task lifecycle) and
> BPF data structures, regardless of which event performs the dispatch.
>
> The test validates the following scenarios:
>
> - From ops.select_cpu():
> - scenario 0 (local DSQ): tasks dispatched to the local DSQ bypass
> the BPF scheduler entirely; they never enter BPF custody, so
> ops.dequeue() is not called,
> - scenario 1 (global DSQ): tasks dispatched to SCX_DSQ_GLOBAL also
> bypass the BPF scheduler, like the local DSQ; ops.dequeue() is
> not called,
> - scenario 2 (user DSQ): tasks enter BPF scheduler custody with full
> enqueue/dequeue lifecycle tracking and state machine validation
> (expects 1:1 enqueue/dequeue pairing).
Could you add a note here about why there's no equivalent to scenario 6?
The differentiating factor between that and scenario 2 (nonterminal queue) is
that scx_dsq_insert_commit() is called regardless of whether the queue is terminal.
And this makes sense since for non-DSQ queues the BPF scheduler can do its
own tracking of enqueue/dequeue (plus it does not make too much sense to
do BPF-internal enqueueing in select_cpu).
What do you think? If the above makes sense, maybe we should spell it out
in the documentation too. Maybe also add it makes no sense to enqueue
in an internal BPF structure from select_cpu - the task is not yet
enqueued, and would have to go through enqueue anyway.
>
> - From ops.enqueue():
> - scenario 3 (local DSQ): same behavior as scenario 0,
> - scenario 4 (global DSQ): same behavior as scenario 1,
> - scenario 5 (user DSQ): same behavior as scenario 2,
> - scenario 6 (BPF internal queue): tasks are stored in a BPF queue
> in ops.enqueue() and consumed in ops.dispatch(); they remain in
> BPF custody until dispatch, with full lifecycle tracking and 1:1
> enqueue/dequeue validation.
>
> This verifies that:
> - terminal DSQ dispatch (local, global) don't trigger ops.dequeue(),
> - user DSQ / internal BPF data structure dispatch has exact 1:1
> ops.enqueue()/dequeue() pairing,
> - dispatch dequeues have no flags (normal workflow),
> - property change dequeues have the %SCX_DEQ_SCHED_CHANGE flag set,
> - no duplicate enqueues or invalid state transitions are happening,
> - ops.enqueue() and ops.select_cpu() dispatch paths behave identically.
>
> Cc: Tejun Heo <tj@kernel.org>
> Cc: Emil Tsalapatis <emil@etsalapatis.com>
> Cc: Kuba Piecuch <jpiecuch@google.com>
> Signed-off-by: Andrea Righi <arighi@nvidia.com>
> ---
> tools/testing/selftests/sched_ext/Makefile | 1 +
> .../testing/selftests/sched_ext/dequeue.bpf.c | 403 ++++++++++++++++++
> tools/testing/selftests/sched_ext/dequeue.c | 258 +++++++++++
> 3 files changed, 662 insertions(+)
> create mode 100644 tools/testing/selftests/sched_ext/dequeue.bpf.c
> create mode 100644 tools/testing/selftests/sched_ext/dequeue.c
>
> diff --git a/tools/testing/selftests/sched_ext/Makefile b/tools/testing/selftests/sched_ext/Makefile
> index 5fe45f9c5f8fd..764e91edabf93 100644
> --- a/tools/testing/selftests/sched_ext/Makefile
> +++ b/tools/testing/selftests/sched_ext/Makefile
> @@ -161,6 +161,7 @@ all_test_bpfprogs := $(foreach prog,$(wildcard *.bpf.c),$(INCLUDE_DIR)/$(patsubs
>
> auto-test-targets := \
> create_dsq \
> + dequeue \
> enq_last_no_enq_fails \
> ddsp_bogus_dsq_fail \
> ddsp_vtimelocal_fail \
> diff --git a/tools/testing/selftests/sched_ext/dequeue.bpf.c b/tools/testing/selftests/sched_ext/dequeue.bpf.c
> new file mode 100644
> index 0000000000000..4ba657ba1bff5
> --- /dev/null
> +++ b/tools/testing/selftests/sched_ext/dequeue.bpf.c
> @@ -0,0 +1,403 @@
> +// SPDX-License-Identifier: GPL-2.0
> +/*
> + * A scheduler that validates ops.dequeue() is called correctly:
> + * - Tasks dispatched to terminal DSQs (local, global) bypass the BPF
> + * scheduler entirely: no ops.dequeue() should be called
> + * - Tasks dispatched to user DSQs enter BPF custody: ops.dequeue() must be
> + * called when they leave custody
> + * - Every ops.enqueue() for non-terminal DSQs is followed by exactly one
> + * ops.dequeue() (validate 1:1 pairing and state machine)
> + *
> + * Copyright (c) 2026 NVIDIA Corporation.
> + */
> +
> +#include <scx/common.bpf.h>
> +
> +#define SHARED_DSQ 0
> +
> +/*
> + * Scenario 6: BPF internal queue. Tasks are stored here from ops.enqueue()
> + * and consumed from ops.dispatch(), validating that tasks not on a user DSQ
> + * (only on BPF internal structures) still get ops.dequeue() when they leave.
> + */
> +struct {
> + __uint(type, BPF_MAP_TYPE_QUEUE);
> + __uint(max_entries, 4096);
Nit: Can we make this larger? I don't think there's any downsides. I know
there's a mitigation for if the queue gets full, please see nit below.
> + __type(value, s32);
> +} global_queue SEC(".maps");
> +
> +char _license[] SEC("license") = "GPL";
> +
> +UEI_DEFINE(uei);
> +
> +/*
> + * Counters to track the lifecycle of tasks:
> + * - enqueue_cnt: Number of times ops.enqueue() was called
> + * - dequeue_cnt: Number of times ops.dequeue() was called (any type)
> + * - dispatch_dequeue_cnt: Number of regular dispatch dequeues (no flag)
> + * - change_dequeue_cnt: Number of property change dequeues
> + */
> +u64 enqueue_cnt, dequeue_cnt, dispatch_dequeue_cnt, change_dequeue_cnt;
> +
> +/*
> + * Test scenarios (0-2: ops.select_cpu(), 3-6: ops.enqueue()):
> + * 0) Dispatch to local DSQ from ops.select_cpu() (terminal DSQ, bypasses BPF
> + * scheduler, no dequeue callbacks)
> + * 1) Dispatch to global DSQ from ops.select_cpu() (terminal DSQ, bypasses BPF
> + * scheduler, no dequeue callbacks)
> + * 2) Dispatch to shared user DSQ from ops.select_cpu() (enters BPF scheduler,
> + * dequeue callbacks expected)
> + * 3) Dispatch to local DSQ from ops.enqueue() (terminal DSQ, bypasses BPF
> + * scheduler, no dequeue callbacks)
> + * 4) Dispatch to global DSQ from ops.enqueue() (terminal DSQ, bypasses BPF
> + * scheduler, no dequeue callbacks)
> + * 5) Dispatch to shared user DSQ from ops.enqueue() (enters BPF scheduler,
> + * dequeue callbacks expected)
> + * 6) BPF internal queue: store task PIDs in ops.enqueue(), consume in
> + * ops.dispatch() and dispatch to local DSQ (validates dequeue for tasks
> + * in BPF custody but not on a user DSQ)
> + */
> +u32 test_scenario;
> +
> +/*
> + * Per-task state to track lifecycle and validate workflow semantics.
> + * State transitions:
> + * NONE -> ENQUEUED (on enqueue)
> + * ENQUEUED -> DISPATCHED (on dispatch dequeue)
> + * DISPATCHED -> NONE (on property change dequeue or re-enqueue)
> + * ENQUEUED -> NONE (on property change dequeue before dispatch)
> + */
> +enum task_state {
> + TASK_NONE = 0,
> + TASK_ENQUEUED,
> + TASK_DISPATCHED,
> +};
> +
> +struct task_ctx {
> + enum task_state state; /* Current state in the workflow */
> + u64 enqueue_seq; /* Sequence number for debugging */
> +};
> +
> +struct {
> + __uint(type, BPF_MAP_TYPE_TASK_STORAGE);
> + __uint(map_flags, BPF_F_NO_PREALLOC);
> + __type(key, int);
> + __type(value, struct task_ctx);
> +} task_ctx_stor SEC(".maps");
> +
> +static struct task_ctx *try_lookup_task_ctx(struct task_struct *p)
> +{
> + return bpf_task_storage_get(&task_ctx_stor, p, 0, 0);
> +}
> +
> +s32 BPF_STRUCT_OPS(dequeue_select_cpu, struct task_struct *p,
> + s32 prev_cpu, u64 wake_flags)
> +{
> + struct task_ctx *tctx;
> +
> + tctx = try_lookup_task_ctx(p);
> + if (!tctx)
> + return prev_cpu;
> +
> + switch (test_scenario) {
> + case 0:
> + /*
> + * Scenario 0: Direct dispatch to local DSQ from select_cpu.
> + *
> + * Task bypasses BPF scheduler entirely: no enqueue
> + * tracking, no dequeue callbacks. Behavior should be
> + * identical to scenario 3.
> + */
> + scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, 0);
> + return prev_cpu;
> +
> + case 1:
> + /*
> + * Scenario 1: Direct dispatch to global DSQ from select_cpu.
> + *
> + * Like scenario 0, task bypasses BPF scheduler entirely.
> + * Behavior should be identical to scenario 4.
> + */
> + scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, 0);
> + return prev_cpu;
> +
> + case 2:
> + /*
> + * Scenario 2: Dispatch to shared user DSQ from select_cpu.
> + *
> + * Task enters BPF scheduler management: track
> + * enqueue/dequeue lifecycle and validate state transitions.
> + * Behavior should be identical to scenario 5.
> + */
> + __sync_fetch_and_add(&enqueue_cnt, 1);
> +
> + /*
> + * Validate state transition: enqueue is only valid from
> + * NONE or DISPATCHED states. Getting enqueue while in
> + * ENQUEUED state indicates a missing dequeue.
> + */
> + if (tctx->state == TASK_ENQUEUED)
> + scx_bpf_error("%d (%s): enqueue while in ENQUEUED state seq=%llu",
> + p->pid, p->comm, tctx->enqueue_seq);
> +
> + /* Transition to ENQUEUED state */
> + tctx->state = TASK_ENQUEUED;
> + tctx->enqueue_seq++;
> +
> + scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, 0);
> + return prev_cpu;
> +
> + default:
> + /*
> + * Force all tasks through ops.enqueue().
> + */
> + return prev_cpu;
> + }
> +}
> +
> +void BPF_STRUCT_OPS(dequeue_enqueue, struct task_struct *p, u64 enq_flags)
> +{
> + struct task_ctx *tctx;
> +
> + tctx = try_lookup_task_ctx(p);
> + if (!tctx)
> + return;
> +
> + switch (test_scenario) {
> + case 3:
> + /*
> + * Scenario 3: Direct dispatch to the local DSQ.
> + *
> + * Task bypasses BPF scheduler entirely: no enqueue
> + * tracking, no dequeue callbacks. Don't increment counters
> + * or validate state since the task never enters BPF
> + * scheduler management.
> + */
> + scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, enq_flags);
> + break;
> +
> + case 4:
> + /*
> + * Scenario 4: Direct dispatch to the global DSQ.
> + *
> + * Like scenario 3, task bypasses BPF scheduler entirely.
> + * SCX_DSQ_GLOBAL is a terminal DSQ, tasks dispatched to it
> + * leave BPF custody immediately, so no dequeue callbacks
> + * should be triggered.
> + */
> + scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
> + break;
> +
> + case 5:
> + /*
> + * Scenario 5: Dispatch to shared user DSQ.
> + *
> + * Task enters BPF scheduler management: track
> + * enqueue/dequeue lifecycle and validate state
> + * transitions.
> + */
> + __sync_fetch_and_add(&enqueue_cnt, 1);
> +
> + /*
> + * Validate state transition: enqueue is only valid from
> + * NONE or DISPATCHED states. Getting enqueue while in
> + * ENQUEUED state indicates a missing dequeue (or stale state
> + * from a previous scenario when the scheduler was unregistered
> + * with tasks still on a DSQ). Reset and proceed to avoid false
> + * positives across scenario switches.
> + */
> + if (tctx->state == TASK_ENQUEUED)
> + tctx->state = TASK_NONE;
> +
> + /* Transition to ENQUEUED state */
> + tctx->state = TASK_ENQUEUED;
> + tctx->enqueue_seq++;
> +
> + scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, enq_flags);
> + break;
> +
> + case 6:
> + /*
> + * Scenario 6: Store task in BPF internal queue. Task enters
> + * BPF custody (kernel sets SCX_TASK_NEED_DEQ). When
> + * ops.dispatch() later pops and inserts to local DSQ,
> + * ops.dequeue() must be called.
> + *
> + * If the queue is full, fallback to local DSQ. The task still
> + * goes through QUEUED in the kernel and gets ops.dequeue()
> + * when moved to the terminal DSQ, so we track it the same.
> + *
> + * If state is already ENQUEUED (e.g. task was on a DSQ when
> + * the scheduler was unregistered in a previous scenario),
> + * reset to NONE and proceed to avoid false positives.
> + */
> + {
> + s32 pid = p->pid;
> +
> + if (tctx->state == TASK_ENQUEUED)
> + tctx->state = TASK_NONE;
> +
> + tctx->state = TASK_ENQUEUED;
> + tctx->enqueue_seq++;
> +
> + /* Queue full: fallback to the global DSQ */
Nit: Can we remove this fallback? This silently changes the behavior of
the test, and even though it makes sense to avoid overflowing the queue,
it causes the test to succeed even if for some reason the
bpf_map_push_elem fails. Why not just bump the queue number to a
reasonably large number amount instead?
> + if (bpf_map_push_elem(&global_queue, &pid, 0))
> + scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
> + else
> + __sync_fetch_and_add(&enqueue_cnt, 1);
> +
> + scx_bpf_kick_cpu(scx_bpf_task_cpu(p), SCX_KICK_IDLE);
> + }
> + break;
> +
> + default:
> + /* For scenarios 0-2 dispatch to the global DSQ */
> + scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
> + }
> +}
> +
> +void BPF_STRUCT_OPS(dequeue_dequeue, struct task_struct *p, u64 deq_flags)
> +{
> + struct task_ctx *tctx;
> +
> + __sync_fetch_and_add(&dequeue_cnt, 1);
> +
> + tctx = try_lookup_task_ctx(p);
> + if (!tctx)
> + return;
> +
> + /*
> + * For scenarios 0, 1, 3, and 4 (terminal DSQs: local and global),
> + * ops.dequeue() should never be called because tasks bypass the
> + * BPF scheduler entirely. If we get here, it's a kernel bug.
> + */
> + if (test_scenario == 0 || test_scenario == 3) {
> + scx_bpf_error("%d (%s): dequeue called for local DSQ scenario - kernel bug!",
> + p->pid, p->comm);
> + return;
> + }
> + if (test_scenario == 1 || test_scenario == 4) {
> + scx_bpf_error("%d (%s): dequeue called for global DSQ scenario - kernel bug!",
> + p->pid, p->comm);
> + return;
> + }
> +
> + /*
> + * Validate state: dequeue should only happen from ENQUEUED or
> + * DISPATCHED states. Getting dequeue from NONE indicates a bug.
> + */
> + if (tctx->state == TASK_NONE) {
> + scx_bpf_error("%d (%s): dequeue from NONE state seq=%llu",
> + p->pid, p->comm, tctx->enqueue_seq);
> + return;
> + }
> +
> + if (deq_flags & SCX_DEQ_SCHED_CHANGE) {
> + /*
> + * Property change interrupting the workflow. Valid from
> + * both ENQUEUED and DISPATCHED states. Transitions task
> + * back to NONE state.
> + */
> + __sync_fetch_and_add(&change_dequeue_cnt, 1);
> +
> + /* Validate state transition */
> + if (tctx->state != TASK_ENQUEUED && tctx->state != TASK_DISPATCHED)
> + scx_bpf_error("%d (%s): invalid property change dequeue state=%d seq=%llu",
> + p->pid, p->comm, tctx->state, tctx->enqueue_seq);
> +
> + /* Transition back to NONE: task outside scheduler control */
> + tctx->state = TASK_NONE;
> + } else {
> + /*
> + * Regular dispatch dequeue: normal workflow step. Valid
> + * only from ENQUEUED state (after enqueue, before dispatch
> + * dequeue). Transitions to DISPATCHED state.
> + */
> + __sync_fetch_and_add(&dispatch_dequeue_cnt, 1);
> +
> + /*
> + * Dispatch dequeue should not have %SCX_DEQ_SCHED_CHANGE
> + * flag.
> + */
> + if (deq_flags & SCX_DEQ_SCHED_CHANGE)
> + scx_bpf_error("%d (%s): SCX_DEQ_SCHED_CHANGE in dispatch dequeue seq=%llu",
> + p->pid, p->comm, tctx->enqueue_seq);
> +
> + /*
> + * Must be in ENQUEUED state.
> + */
> + if (tctx->state != TASK_ENQUEUED)
> + scx_bpf_error("%d (%s): dispatch dequeue from state %d seq=%llu",
> + p->pid, p->comm, tctx->state, tctx->enqueue_seq);
> +
> + /*
> + * Transition to DISPATCHED: normal cycle completed
> + * dispatch.
> + */
> + tctx->state = TASK_DISPATCHED;
> + }
> +}
> +
> +void BPF_STRUCT_OPS(dequeue_dispatch, s32 cpu, struct task_struct *prev)
> +{
> + if (test_scenario == 6) {
> + s32 pid;
> + struct task_struct *p;
> +
> + if (bpf_map_pop_elem(&global_queue, &pid))
> + return;
> +
> + p = bpf_task_from_pid(pid);
> + if (!p)
> + return;
> +
> + if (!bpf_cpumask_test_cpu(cpu, p->cpus_ptr))
> + cpu = scx_bpf_task_cpu(p);
> +
> + scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL_ON | cpu, SCX_SLICE_DFL, 0);
> + bpf_task_release(p);
> + } else {
> + scx_bpf_dsq_move_to_local(SHARED_DSQ);
> + }
> +}
> +
> +s32 BPF_STRUCT_OPS(dequeue_init_task, struct task_struct *p,
> + struct scx_init_task_args *args)
> +{
> + struct task_ctx *tctx;
> +
> + tctx = bpf_task_storage_get(&task_ctx_stor, p, 0,
> + BPF_LOCAL_STORAGE_GET_F_CREATE);
> + if (!tctx)
> + return -ENOMEM;
> +
> + return 0;
> +}
> +
> +s32 BPF_STRUCT_OPS_SLEEPABLE(dequeue_init)
> +{
> + s32 ret;
> +
> + ret = scx_bpf_create_dsq(SHARED_DSQ, -1);
> + if (ret)
> + return ret;
> +
> + return 0;
> +}
> +
> +void BPF_STRUCT_OPS(dequeue_exit, struct scx_exit_info *ei)
> +{
> + UEI_RECORD(uei, ei);
> +}
> +
> +SEC(".struct_ops.link")
> +struct sched_ext_ops dequeue_ops = {
> + .select_cpu = (void *)dequeue_select_cpu,
> + .enqueue = (void *)dequeue_enqueue,
> + .dequeue = (void *)dequeue_dequeue,
> + .dispatch = (void *)dequeue_dispatch,
> + .init_task = (void *)dequeue_init_task,
> + .init = (void *)dequeue_init,
> + .exit = (void *)dequeue_exit,
> + .name = "dequeue_test",
> +};
> diff --git a/tools/testing/selftests/sched_ext/dequeue.c b/tools/testing/selftests/sched_ext/dequeue.c
> new file mode 100644
> index 0000000000000..b0add2a516ab8
> --- /dev/null
> +++ b/tools/testing/selftests/sched_ext/dequeue.c
> @@ -0,0 +1,258 @@
> +// SPDX-License-Identifier: GPL-2.0
> +/*
> + * Copyright (c) 2025 NVIDIA Corporation.
> + */
> +#define _GNU_SOURCE
> +#include <stdio.h>
> +#include <unistd.h>
> +#include <signal.h>
> +#include <time.h>
> +#include <bpf/bpf.h>
> +#include <scx/common.h>
> +#include <sys/wait.h>
> +#include <sched.h>
> +#include <pthread.h>
> +#include "scx_test.h"
> +#include "dequeue.bpf.skel.h"
> +
> +#define NUM_WORKERS 8
> +#define AFFINITY_HAMMER_MS 50 /* How long affinity hammer runs (scenario 6) */
> +
> +/*
> + * Worker function that creates enqueue/dequeue events via CPU work and
> + * sleeping. Property-change dequeues are triggered by the affinity hammer
> + * thread (external sched_setaffinity on worker PIDs).
> + */
> +static void worker_fn(int id)
> +{
> + int i;
> + volatile int sum = 0;
> +
> + for (i = 0; i < 1000; i++) {
> + int j;
> +
> + /* Do some work to trigger scheduling events */
> + for (j = 0; j < 10000; j++)
> + sum += j;
> +
> + /* Sleep to trigger dequeue */
> + usleep(1000 + (id * 100));
> + }
> +
> + exit(0);
> +}
> +
> +/*
> + * For scenario 6, tasks sit in the BPF queue until dispatch consumes them.
> + * Property-change dequeues only happen when a task gets a property change
> + * while still in the queue (SCX_OPSS_QUEUED), not after it has been
> + * dispatched. This thread changes workers' affinity from outside so that
> + * some changes hit tasks while they are still in the queue.
> + */
> +static void *affinity_hammer_fn(void *arg)
> +{
> + pid_t *pids = arg;
> + cpu_set_t cpuset;
> + int i, n = NUM_WORKERS;
> + struct timespec ts = { .tv_sec = 0, .tv_nsec = 1000000 }; /* 1ms */
> +
> + for (i = 0; i < (AFFINITY_HAMMER_MS * 1000 / 100); i++) {
> + int w = i % n;
> + int cpu = (i / n) % 4;
> +
> + CPU_ZERO(&cpuset);
> + CPU_SET(cpu, &cpuset);
> + sched_setaffinity(pids[w], sizeof(cpuset), &cpuset);
> + nanosleep(&ts, NULL);
> + }
> + return NULL;
> +}
> +
> +static enum scx_test_status run_scenario(struct dequeue *skel, u32 scenario,
> + const char *scenario_name)
> +{
> + struct bpf_link *link;
> + pid_t pids[NUM_WORKERS];
> + pthread_t hammer;
> +
> + int i, status;
> + u64 enq_start, deq_start, dispatch_deq_start, change_deq_start;
> + u64 enq_delta, deq_delta, dispatch_deq_delta, change_deq_delta;
> +
> + /* Set the test scenario */
> + skel->bss->test_scenario = scenario;
> +
> + /* Record starting counts */
> + enq_start = skel->bss->enqueue_cnt;
> + deq_start = skel->bss->dequeue_cnt;
> + dispatch_deq_start = skel->bss->dispatch_dequeue_cnt;
> + change_deq_start = skel->bss->change_dequeue_cnt;
> +
> + link = bpf_map__attach_struct_ops(skel->maps.dequeue_ops);
> + SCX_FAIL_IF(!link, "Failed to attach struct_ops for scenario %s", scenario_name);
> +
> + /* Fork worker processes to generate enqueue/dequeue events */
> + for (i = 0; i < NUM_WORKERS; i++) {
> + pids[i] = fork();
> + SCX_FAIL_IF(pids[i] < 0, "Failed to fork worker %d", i);
> +
> + if (pids[i] == 0) {
> + worker_fn(i);
> + /* Should not reach here */
> + exit(1);
> + }
> + }
> +
> + /*
> + * Run an "affinity hammer" so that some property changes hit tasks
> + * while they are still in BPF custody (e.g. in user DSQ or BPF queue),
> + * triggering SCX_DEQ_SCHED_CHANGE dequeues in scenarios 2, 5 and 6.
> + */
> + SCX_FAIL_IF(pthread_create(&hammer, NULL, affinity_hammer_fn, pids) != 0,
> + "Failed to create affinity hammer thread");
> +
> + /* Wait for all workers to complete */
> + for (i = 0; i < NUM_WORKERS; i++) {
> + SCX_FAIL_IF(waitpid(pids[i], &status, 0) != pids[i],
> + "Failed to wait for worker %d", i);
> + SCX_FAIL_IF(status != 0, "Worker %d exited with status %d", i, status);
> + }
> +
> + pthread_join(hammer, NULL);
> +
> + bpf_link__destroy(link);
> +
> + SCX_EQ(skel->data->uei.kind, EXIT_KIND(SCX_EXIT_UNREG));
> +
> + /* Calculate deltas */
> + enq_delta = skel->bss->enqueue_cnt - enq_start;
> + deq_delta = skel->bss->dequeue_cnt - deq_start;
> + dispatch_deq_delta = skel->bss->dispatch_dequeue_cnt - dispatch_deq_start;
> + change_deq_delta = skel->bss->change_dequeue_cnt - change_deq_start;
> +
> + printf("%s:\n", scenario_name);
> + printf(" enqueues: %lu\n", (unsigned long)enq_delta);
> + printf(" dequeues: %lu (dispatch: %lu, property_change: %lu)\n",
> + (unsigned long)deq_delta,
> + (unsigned long)dispatch_deq_delta,
> + (unsigned long)change_deq_delta);
> +
> + /*
> + * Validate enqueue/dequeue lifecycle tracking.
> + *
> + * For scenarios 0, 1, 3, 4 (local and global DSQs from
> + * ops.select_cpu() and ops.enqueue()), both enqueues and dequeues
> + * should be 0 because tasks bypass the BPF scheduler entirely: tasks
> + * never enter BPF scheduler's custody.
> + *
> + * For scenarios 2, 5, and 6 (user DSQ or BPF internal queue from
> + * ops.select_cpu() / ops.enqueue()), we expect both enqueues and
> + * dequeues.
> + *
> + * The BPF code does strict state machine validation with
> + * scx_bpf_error() to ensure the workflow semantics are correct. If
> + * we reach this point without errors, the semantics are validated
> + * correctly.
> + */
> + if (scenario == 0 || scenario == 1 || scenario == 3 || scenario == 4) {
> + /* Terminal DSQs: tasks bypass BPF scheduler completely */
> + SCX_EQ(enq_delta, 0);
> + SCX_EQ(deq_delta, 0);
> + SCX_EQ(dispatch_deq_delta, 0);
> + SCX_EQ(change_deq_delta, 0);
> + } else {
> + /* User DSQ: tasks enter BPF scheduler's custody */
> + SCX_GT(enq_delta, 0);
> + SCX_GT(deq_delta, 0);
> + /* Validate 1:1 enqueue/dequeue pairing */
> + SCX_EQ(enq_delta, deq_delta);
> + }
> +
> + return SCX_TEST_PASS;
> +}
> +
> +static enum scx_test_status setup(void **ctx)
> +{
> + struct dequeue *skel;
> +
> + skel = dequeue__open();
> + SCX_FAIL_IF(!skel, "Failed to open skel");
> + SCX_ENUM_INIT(skel);
> + SCX_FAIL_IF(dequeue__load(skel), "Failed to load skel");
> +
> + *ctx = skel;
> +
> + return SCX_TEST_PASS;
> +}
> +
> +static enum scx_test_status run(void *ctx)
> +{
> + struct dequeue *skel = ctx;
> + enum scx_test_status status;
> +
> + /* Scenarios 0-2: ops.select_cpu(), 3-6: ops.enqueue() */
> + status = run_scenario(skel, 0, "Scenario 0: Local DSQ from ops.select_cpu()");
> + if (status != SCX_TEST_PASS)
> + return status;
> +
> + status = run_scenario(skel, 1, "Scenario 1: Global DSQ from ops.select_cpu()");
> + if (status != SCX_TEST_PASS)
> + return status;
> +
> + status = run_scenario(skel, 2, "Scenario 2: User DSQ from ops.select_cpu()");
> + if (status != SCX_TEST_PASS)
> + return status;
> +
> + status = run_scenario(skel, 3, "Scenario 3: Local DSQ from ops.enqueue()");
> + if (status != SCX_TEST_PASS)
> + return status;
> +
> + status = run_scenario(skel, 4, "Scenario 4: Global DSQ from ops.enqueue()");
> + if (status != SCX_TEST_PASS)
> + return status;
> +
> + status = run_scenario(skel, 5, "Scenario 5: User DSQ from ops.enqueue()");
> + if (status != SCX_TEST_PASS)
> + return status;
> +
> + status = run_scenario(skel, 6,
> + "Scenario 6: BPF internal queue from ops.enqueue(), consumed in ops.dispatch()");
> + if (status != SCX_TEST_PASS)
> + return status;
> +
> + printf("\n=== Summary ===\n");
> + printf("Total enqueues: %lu\n", (unsigned long)skel->bss->enqueue_cnt);
> + printf("Total dequeues: %lu\n", (unsigned long)skel->bss->dequeue_cnt);
> + printf(" Dispatch dequeues: %lu (no flag, normal workflow)\n",
> + (unsigned long)skel->bss->dispatch_dequeue_cnt);
> + printf(" Property change dequeues: %lu (SCX_DEQ_SCHED_CHANGE flag)\n",
> + (unsigned long)skel->bss->change_dequeue_cnt);
> + printf("\nAll scenarios passed - no state machine violations detected\n");
> + printf("-> Validated: Local DSQ dispatch bypasses BPF scheduler (both paths)\n");
> + printf("-> Validated: Global DSQ dispatch bypasses BPF scheduler (both paths)\n");
> + printf("-> Validated: User DSQ dispatch triggers dequeue callbacks (both paths)\n");
> + printf("-> Validated: BPF internal queue triggers dequeue when leaving custody\n");
> + printf("-> Validated: ops.enqueue() and ops.select_cpu() behave identically\n");
> + printf("-> Validated: Dispatch dequeues have no flags (normal workflow)\n");
> + printf("-> Validated: Property change dequeues have SCX_DEQ_SCHED_CHANGE flag\n");
> + printf("-> Validated: No duplicate enqueues or invalid state transitions\n");
> +
> + return SCX_TEST_PASS;
> +}
> +
> +static void cleanup(void *ctx)
> +{
> + struct dequeue *skel = ctx;
> +
> + dequeue__destroy(skel);
> +}
> +
> +struct scx_test dequeue_test = {
> + .name = "dequeue",
> + .description = "Verify ops.dequeue() semantics",
> + .setup = setup,
> + .run = run,
> + .cleanup = cleanup,
> +};
> +
> +REGISTER_SCX_TEST(&dequeue_test)
^ permalink raw reply [flat|nested] 41+ messages in thread
* [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-06 13:54 [PATCHSET v7] sched_ext: Fix " Andrea Righi
@ 2026-02-06 13:54 ` Andrea Righi
2026-02-06 20:10 ` Emil Tsalapatis
0 siblings, 1 reply; 41+ messages in thread
From: Andrea Righi @ 2026-02-06 13:54 UTC (permalink / raw)
To: Tejun Heo, David Vernet, Changwoo Min
Cc: Kuba Piecuch, Emil Tsalapatis, Christian Loehle, Daniel Hodges,
sched-ext, linux-kernel
Add a new kselftest to validate that the new ops.dequeue() semantics
work correctly for all task lifecycle scenarios, including the
distinction between terminal DSQs (where BPF scheduler is done with the
task), user DSQs (where BPF scheduler manages the task lifecycle) and
BPF data structures, regardless of which event performs the dispatch.
The test validates the following scenarios:
- From ops.select_cpu():
- scenario 0 (local DSQ): tasks dispatched to the local DSQ bypass
the BPF scheduler entirely; they never enter BPF custody, so
ops.dequeue() is not called,
- scenario 1 (global DSQ): tasks dispatched to SCX_DSQ_GLOBAL also
bypass the BPF scheduler, like the local DSQ; ops.dequeue() is
not called,
- scenario 2 (user DSQ): tasks enter BPF scheduler custody with full
enqueue/dequeue lifecycle tracking and state machine validation
(expects 1:1 enqueue/dequeue pairing).
- From ops.enqueue():
- scenario 3 (local DSQ): same behavior as scenario 0,
- scenario 4 (global DSQ): same behavior as scenario 1,
- scenario 5 (user DSQ): same behavior as scenario 2,
- scenario 6 (BPF internal queue): tasks are stored in a BPF queue
in ops.enqueue() and consumed in ops.dispatch(); they remain in
BPF custody until dispatch, with full lifecycle tracking and 1:1
enqueue/dequeue validation.
This verifies that:
- terminal DSQ dispatch (local, global) don't trigger ops.dequeue(),
- user DSQ / internal BPF data structure dispatch has exact 1:1
ops.enqueue()/dequeue() pairing,
- dispatch dequeues have no flags (normal workflow),
- property change dequeues have the %SCX_DEQ_SCHED_CHANGE flag set,
- no duplicate enqueues or invalid state transitions are happening,
- ops.enqueue() and ops.select_cpu() dispatch paths behave identically.
Cc: Tejun Heo <tj@kernel.org>
Cc: Emil Tsalapatis <emil@etsalapatis.com>
Cc: Kuba Piecuch <jpiecuch@google.com>
Signed-off-by: Andrea Righi <arighi@nvidia.com>
---
tools/testing/selftests/sched_ext/Makefile | 1 +
.../testing/selftests/sched_ext/dequeue.bpf.c | 403 ++++++++++++++++++
tools/testing/selftests/sched_ext/dequeue.c | 258 +++++++++++
3 files changed, 662 insertions(+)
create mode 100644 tools/testing/selftests/sched_ext/dequeue.bpf.c
create mode 100644 tools/testing/selftests/sched_ext/dequeue.c
diff --git a/tools/testing/selftests/sched_ext/Makefile b/tools/testing/selftests/sched_ext/Makefile
index 5fe45f9c5f8fd..764e91edabf93 100644
--- a/tools/testing/selftests/sched_ext/Makefile
+++ b/tools/testing/selftests/sched_ext/Makefile
@@ -161,6 +161,7 @@ all_test_bpfprogs := $(foreach prog,$(wildcard *.bpf.c),$(INCLUDE_DIR)/$(patsubs
auto-test-targets := \
create_dsq \
+ dequeue \
enq_last_no_enq_fails \
ddsp_bogus_dsq_fail \
ddsp_vtimelocal_fail \
diff --git a/tools/testing/selftests/sched_ext/dequeue.bpf.c b/tools/testing/selftests/sched_ext/dequeue.bpf.c
new file mode 100644
index 0000000000000..4ba657ba1bff5
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/dequeue.bpf.c
@@ -0,0 +1,403 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * A scheduler that validates ops.dequeue() is called correctly:
+ * - Tasks dispatched to terminal DSQs (local, global) bypass the BPF
+ * scheduler entirely: no ops.dequeue() should be called
+ * - Tasks dispatched to user DSQs enter BPF custody: ops.dequeue() must be
+ * called when they leave custody
+ * - Every ops.enqueue() for non-terminal DSQs is followed by exactly one
+ * ops.dequeue() (validate 1:1 pairing and state machine)
+ *
+ * Copyright (c) 2026 NVIDIA Corporation.
+ */
+
+#include <scx/common.bpf.h>
+
+#define SHARED_DSQ 0
+
+/*
+ * Scenario 6: BPF internal queue. Tasks are stored here from ops.enqueue()
+ * and consumed from ops.dispatch(), validating that tasks not on a user DSQ
+ * (only on BPF internal structures) still get ops.dequeue() when they leave.
+ */
+struct {
+ __uint(type, BPF_MAP_TYPE_QUEUE);
+ __uint(max_entries, 4096);
+ __type(value, s32);
+} global_queue SEC(".maps");
+
+char _license[] SEC("license") = "GPL";
+
+UEI_DEFINE(uei);
+
+/*
+ * Counters to track the lifecycle of tasks:
+ * - enqueue_cnt: Number of times ops.enqueue() was called
+ * - dequeue_cnt: Number of times ops.dequeue() was called (any type)
+ * - dispatch_dequeue_cnt: Number of regular dispatch dequeues (no flag)
+ * - change_dequeue_cnt: Number of property change dequeues
+ */
+u64 enqueue_cnt, dequeue_cnt, dispatch_dequeue_cnt, change_dequeue_cnt;
+
+/*
+ * Test scenarios (0-2: ops.select_cpu(), 3-6: ops.enqueue()):
+ * 0) Dispatch to local DSQ from ops.select_cpu() (terminal DSQ, bypasses BPF
+ * scheduler, no dequeue callbacks)
+ * 1) Dispatch to global DSQ from ops.select_cpu() (terminal DSQ, bypasses BPF
+ * scheduler, no dequeue callbacks)
+ * 2) Dispatch to shared user DSQ from ops.select_cpu() (enters BPF scheduler,
+ * dequeue callbacks expected)
+ * 3) Dispatch to local DSQ from ops.enqueue() (terminal DSQ, bypasses BPF
+ * scheduler, no dequeue callbacks)
+ * 4) Dispatch to global DSQ from ops.enqueue() (terminal DSQ, bypasses BPF
+ * scheduler, no dequeue callbacks)
+ * 5) Dispatch to shared user DSQ from ops.enqueue() (enters BPF scheduler,
+ * dequeue callbacks expected)
+ * 6) BPF internal queue: store task PIDs in ops.enqueue(), consume in
+ * ops.dispatch() and dispatch to local DSQ (validates dequeue for tasks
+ * in BPF custody but not on a user DSQ)
+ */
+u32 test_scenario;
+
+/*
+ * Per-task state to track lifecycle and validate workflow semantics.
+ * State transitions:
+ * NONE -> ENQUEUED (on enqueue)
+ * ENQUEUED -> DISPATCHED (on dispatch dequeue)
+ * DISPATCHED -> NONE (on property change dequeue or re-enqueue)
+ * ENQUEUED -> NONE (on property change dequeue before dispatch)
+ */
+enum task_state {
+ TASK_NONE = 0,
+ TASK_ENQUEUED,
+ TASK_DISPATCHED,
+};
+
+struct task_ctx {
+ enum task_state state; /* Current state in the workflow */
+ u64 enqueue_seq; /* Sequence number for debugging */
+};
+
+struct {
+ __uint(type, BPF_MAP_TYPE_TASK_STORAGE);
+ __uint(map_flags, BPF_F_NO_PREALLOC);
+ __type(key, int);
+ __type(value, struct task_ctx);
+} task_ctx_stor SEC(".maps");
+
+static struct task_ctx *try_lookup_task_ctx(struct task_struct *p)
+{
+ return bpf_task_storage_get(&task_ctx_stor, p, 0, 0);
+}
+
+s32 BPF_STRUCT_OPS(dequeue_select_cpu, struct task_struct *p,
+ s32 prev_cpu, u64 wake_flags)
+{
+ struct task_ctx *tctx;
+
+ tctx = try_lookup_task_ctx(p);
+ if (!tctx)
+ return prev_cpu;
+
+ switch (test_scenario) {
+ case 0:
+ /*
+ * Scenario 0: Direct dispatch to local DSQ from select_cpu.
+ *
+ * Task bypasses BPF scheduler entirely: no enqueue
+ * tracking, no dequeue callbacks. Behavior should be
+ * identical to scenario 3.
+ */
+ scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, 0);
+ return prev_cpu;
+
+ case 1:
+ /*
+ * Scenario 1: Direct dispatch to global DSQ from select_cpu.
+ *
+ * Like scenario 0, task bypasses BPF scheduler entirely.
+ * Behavior should be identical to scenario 4.
+ */
+ scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, 0);
+ return prev_cpu;
+
+ case 2:
+ /*
+ * Scenario 2: Dispatch to shared user DSQ from select_cpu.
+ *
+ * Task enters BPF scheduler management: track
+ * enqueue/dequeue lifecycle and validate state transitions.
+ * Behavior should be identical to scenario 5.
+ */
+ __sync_fetch_and_add(&enqueue_cnt, 1);
+
+ /*
+ * Validate state transition: enqueue is only valid from
+ * NONE or DISPATCHED states. Getting enqueue while in
+ * ENQUEUED state indicates a missing dequeue.
+ */
+ if (tctx->state == TASK_ENQUEUED)
+ scx_bpf_error("%d (%s): enqueue while in ENQUEUED state seq=%llu",
+ p->pid, p->comm, tctx->enqueue_seq);
+
+ /* Transition to ENQUEUED state */
+ tctx->state = TASK_ENQUEUED;
+ tctx->enqueue_seq++;
+
+ scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, 0);
+ return prev_cpu;
+
+ default:
+ /*
+ * Force all tasks through ops.enqueue().
+ */
+ return prev_cpu;
+ }
+}
+
+void BPF_STRUCT_OPS(dequeue_enqueue, struct task_struct *p, u64 enq_flags)
+{
+ struct task_ctx *tctx;
+
+ tctx = try_lookup_task_ctx(p);
+ if (!tctx)
+ return;
+
+ switch (test_scenario) {
+ case 3:
+ /*
+ * Scenario 3: Direct dispatch to the local DSQ.
+ *
+ * Task bypasses BPF scheduler entirely: no enqueue
+ * tracking, no dequeue callbacks. Don't increment counters
+ * or validate state since the task never enters BPF
+ * scheduler management.
+ */
+ scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, enq_flags);
+ break;
+
+ case 4:
+ /*
+ * Scenario 4: Direct dispatch to the global DSQ.
+ *
+ * Like scenario 3, task bypasses BPF scheduler entirely.
+ * SCX_DSQ_GLOBAL is a terminal DSQ, tasks dispatched to it
+ * leave BPF custody immediately, so no dequeue callbacks
+ * should be triggered.
+ */
+ scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
+ break;
+
+ case 5:
+ /*
+ * Scenario 5: Dispatch to shared user DSQ.
+ *
+ * Task enters BPF scheduler management: track
+ * enqueue/dequeue lifecycle and validate state
+ * transitions.
+ */
+ __sync_fetch_and_add(&enqueue_cnt, 1);
+
+ /*
+ * Validate state transition: enqueue is only valid from
+ * NONE or DISPATCHED states. Getting enqueue while in
+ * ENQUEUED state indicates a missing dequeue (or stale state
+ * from a previous scenario when the scheduler was unregistered
+ * with tasks still on a DSQ). Reset and proceed to avoid false
+ * positives across scenario switches.
+ */
+ if (tctx->state == TASK_ENQUEUED)
+ tctx->state = TASK_NONE;
+
+ /* Transition to ENQUEUED state */
+ tctx->state = TASK_ENQUEUED;
+ tctx->enqueue_seq++;
+
+ scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, enq_flags);
+ break;
+
+ case 6:
+ /*
+ * Scenario 6: Store task in BPF internal queue. Task enters
+ * BPF custody (kernel sets SCX_TASK_NEED_DEQ). When
+ * ops.dispatch() later pops and inserts to local DSQ,
+ * ops.dequeue() must be called.
+ *
+ * If the queue is full, fallback to local DSQ. The task still
+ * goes through QUEUED in the kernel and gets ops.dequeue()
+ * when moved to the terminal DSQ, so we track it the same.
+ *
+ * If state is already ENQUEUED (e.g. task was on a DSQ when
+ * the scheduler was unregistered in a previous scenario),
+ * reset to NONE and proceed to avoid false positives.
+ */
+ {
+ s32 pid = p->pid;
+
+ if (tctx->state == TASK_ENQUEUED)
+ tctx->state = TASK_NONE;
+
+ tctx->state = TASK_ENQUEUED;
+ tctx->enqueue_seq++;
+
+ /* Queue full: fallback to the global DSQ */
+ if (bpf_map_push_elem(&global_queue, &pid, 0))
+ scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
+ else
+ __sync_fetch_and_add(&enqueue_cnt, 1);
+
+ scx_bpf_kick_cpu(scx_bpf_task_cpu(p), SCX_KICK_IDLE);
+ }
+ break;
+
+ default:
+ /* For scenarios 0-2 dispatch to the global DSQ */
+ scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
+ }
+}
+
+void BPF_STRUCT_OPS(dequeue_dequeue, struct task_struct *p, u64 deq_flags)
+{
+ struct task_ctx *tctx;
+
+ __sync_fetch_and_add(&dequeue_cnt, 1);
+
+ tctx = try_lookup_task_ctx(p);
+ if (!tctx)
+ return;
+
+ /*
+ * For scenarios 0, 1, 3, and 4 (terminal DSQs: local and global),
+ * ops.dequeue() should never be called because tasks bypass the
+ * BPF scheduler entirely. If we get here, it's a kernel bug.
+ */
+ if (test_scenario == 0 || test_scenario == 3) {
+ scx_bpf_error("%d (%s): dequeue called for local DSQ scenario - kernel bug!",
+ p->pid, p->comm);
+ return;
+ }
+ if (test_scenario == 1 || test_scenario == 4) {
+ scx_bpf_error("%d (%s): dequeue called for global DSQ scenario - kernel bug!",
+ p->pid, p->comm);
+ return;
+ }
+
+ /*
+ * Validate state: dequeue should only happen from ENQUEUED or
+ * DISPATCHED states. Getting dequeue from NONE indicates a bug.
+ */
+ if (tctx->state == TASK_NONE) {
+ scx_bpf_error("%d (%s): dequeue from NONE state seq=%llu",
+ p->pid, p->comm, tctx->enqueue_seq);
+ return;
+ }
+
+ if (deq_flags & SCX_DEQ_SCHED_CHANGE) {
+ /*
+ * Property change interrupting the workflow. Valid from
+ * both ENQUEUED and DISPATCHED states. Transitions task
+ * back to NONE state.
+ */
+ __sync_fetch_and_add(&change_dequeue_cnt, 1);
+
+ /* Validate state transition */
+ if (tctx->state != TASK_ENQUEUED && tctx->state != TASK_DISPATCHED)
+ scx_bpf_error("%d (%s): invalid property change dequeue state=%d seq=%llu",
+ p->pid, p->comm, tctx->state, tctx->enqueue_seq);
+
+ /* Transition back to NONE: task outside scheduler control */
+ tctx->state = TASK_NONE;
+ } else {
+ /*
+ * Regular dispatch dequeue: normal workflow step. Valid
+ * only from ENQUEUED state (after enqueue, before dispatch
+ * dequeue). Transitions to DISPATCHED state.
+ */
+ __sync_fetch_and_add(&dispatch_dequeue_cnt, 1);
+
+ /*
+ * Dispatch dequeue should not have %SCX_DEQ_SCHED_CHANGE
+ * flag.
+ */
+ if (deq_flags & SCX_DEQ_SCHED_CHANGE)
+ scx_bpf_error("%d (%s): SCX_DEQ_SCHED_CHANGE in dispatch dequeue seq=%llu",
+ p->pid, p->comm, tctx->enqueue_seq);
+
+ /*
+ * Must be in ENQUEUED state.
+ */
+ if (tctx->state != TASK_ENQUEUED)
+ scx_bpf_error("%d (%s): dispatch dequeue from state %d seq=%llu",
+ p->pid, p->comm, tctx->state, tctx->enqueue_seq);
+
+ /*
+ * Transition to DISPATCHED: normal cycle completed
+ * dispatch.
+ */
+ tctx->state = TASK_DISPATCHED;
+ }
+}
+
+void BPF_STRUCT_OPS(dequeue_dispatch, s32 cpu, struct task_struct *prev)
+{
+ if (test_scenario == 6) {
+ s32 pid;
+ struct task_struct *p;
+
+ if (bpf_map_pop_elem(&global_queue, &pid))
+ return;
+
+ p = bpf_task_from_pid(pid);
+ if (!p)
+ return;
+
+ if (!bpf_cpumask_test_cpu(cpu, p->cpus_ptr))
+ cpu = scx_bpf_task_cpu(p);
+
+ scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL_ON | cpu, SCX_SLICE_DFL, 0);
+ bpf_task_release(p);
+ } else {
+ scx_bpf_dsq_move_to_local(SHARED_DSQ);
+ }
+}
+
+s32 BPF_STRUCT_OPS(dequeue_init_task, struct task_struct *p,
+ struct scx_init_task_args *args)
+{
+ struct task_ctx *tctx;
+
+ tctx = bpf_task_storage_get(&task_ctx_stor, p, 0,
+ BPF_LOCAL_STORAGE_GET_F_CREATE);
+ if (!tctx)
+ return -ENOMEM;
+
+ return 0;
+}
+
+s32 BPF_STRUCT_OPS_SLEEPABLE(dequeue_init)
+{
+ s32 ret;
+
+ ret = scx_bpf_create_dsq(SHARED_DSQ, -1);
+ if (ret)
+ return ret;
+
+ return 0;
+}
+
+void BPF_STRUCT_OPS(dequeue_exit, struct scx_exit_info *ei)
+{
+ UEI_RECORD(uei, ei);
+}
+
+SEC(".struct_ops.link")
+struct sched_ext_ops dequeue_ops = {
+ .select_cpu = (void *)dequeue_select_cpu,
+ .enqueue = (void *)dequeue_enqueue,
+ .dequeue = (void *)dequeue_dequeue,
+ .dispatch = (void *)dequeue_dispatch,
+ .init_task = (void *)dequeue_init_task,
+ .init = (void *)dequeue_init,
+ .exit = (void *)dequeue_exit,
+ .name = "dequeue_test",
+};
diff --git a/tools/testing/selftests/sched_ext/dequeue.c b/tools/testing/selftests/sched_ext/dequeue.c
new file mode 100644
index 0000000000000..b0add2a516ab8
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/dequeue.c
@@ -0,0 +1,258 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Copyright (c) 2025 NVIDIA Corporation.
+ */
+#define _GNU_SOURCE
+#include <stdio.h>
+#include <unistd.h>
+#include <signal.h>
+#include <time.h>
+#include <bpf/bpf.h>
+#include <scx/common.h>
+#include <sys/wait.h>
+#include <sched.h>
+#include <pthread.h>
+#include "scx_test.h"
+#include "dequeue.bpf.skel.h"
+
+#define NUM_WORKERS 8
+#define AFFINITY_HAMMER_MS 50 /* How long affinity hammer runs (scenario 6) */
+
+/*
+ * Worker function that creates enqueue/dequeue events via CPU work and
+ * sleeping. Property-change dequeues are triggered by the affinity hammer
+ * thread (external sched_setaffinity on worker PIDs).
+ */
+static void worker_fn(int id)
+{
+ int i;
+ volatile int sum = 0;
+
+ for (i = 0; i < 1000; i++) {
+ int j;
+
+ /* Do some work to trigger scheduling events */
+ for (j = 0; j < 10000; j++)
+ sum += j;
+
+ /* Sleep to trigger dequeue */
+ usleep(1000 + (id * 100));
+ }
+
+ exit(0);
+}
+
+/*
+ * For scenario 6, tasks sit in the BPF queue until dispatch consumes them.
+ * Property-change dequeues only happen when a task gets a property change
+ * while still in the queue (SCX_OPSS_QUEUED), not after it has been
+ * dispatched. This thread changes workers' affinity from outside so that
+ * some changes hit tasks while they are still in the queue.
+ */
+static void *affinity_hammer_fn(void *arg)
+{
+ pid_t *pids = arg;
+ cpu_set_t cpuset;
+ int i, n = NUM_WORKERS;
+ struct timespec ts = { .tv_sec = 0, .tv_nsec = 1000000 }; /* 1ms */
+
+ for (i = 0; i < (AFFINITY_HAMMER_MS * 1000 / 100); i++) {
+ int w = i % n;
+ int cpu = (i / n) % 4;
+
+ CPU_ZERO(&cpuset);
+ CPU_SET(cpu, &cpuset);
+ sched_setaffinity(pids[w], sizeof(cpuset), &cpuset);
+ nanosleep(&ts, NULL);
+ }
+ return NULL;
+}
+
+static enum scx_test_status run_scenario(struct dequeue *skel, u32 scenario,
+ const char *scenario_name)
+{
+ struct bpf_link *link;
+ pid_t pids[NUM_WORKERS];
+ pthread_t hammer;
+
+ int i, status;
+ u64 enq_start, deq_start, dispatch_deq_start, change_deq_start;
+ u64 enq_delta, deq_delta, dispatch_deq_delta, change_deq_delta;
+
+ /* Set the test scenario */
+ skel->bss->test_scenario = scenario;
+
+ /* Record starting counts */
+ enq_start = skel->bss->enqueue_cnt;
+ deq_start = skel->bss->dequeue_cnt;
+ dispatch_deq_start = skel->bss->dispatch_dequeue_cnt;
+ change_deq_start = skel->bss->change_dequeue_cnt;
+
+ link = bpf_map__attach_struct_ops(skel->maps.dequeue_ops);
+ SCX_FAIL_IF(!link, "Failed to attach struct_ops for scenario %s", scenario_name);
+
+ /* Fork worker processes to generate enqueue/dequeue events */
+ for (i = 0; i < NUM_WORKERS; i++) {
+ pids[i] = fork();
+ SCX_FAIL_IF(pids[i] < 0, "Failed to fork worker %d", i);
+
+ if (pids[i] == 0) {
+ worker_fn(i);
+ /* Should not reach here */
+ exit(1);
+ }
+ }
+
+ /*
+ * Run an "affinity hammer" so that some property changes hit tasks
+ * while they are still in BPF custody (e.g. in user DSQ or BPF queue),
+ * triggering SCX_DEQ_SCHED_CHANGE dequeues in scenarios 2, 5 and 6.
+ */
+ SCX_FAIL_IF(pthread_create(&hammer, NULL, affinity_hammer_fn, pids) != 0,
+ "Failed to create affinity hammer thread");
+
+ /* Wait for all workers to complete */
+ for (i = 0; i < NUM_WORKERS; i++) {
+ SCX_FAIL_IF(waitpid(pids[i], &status, 0) != pids[i],
+ "Failed to wait for worker %d", i);
+ SCX_FAIL_IF(status != 0, "Worker %d exited with status %d", i, status);
+ }
+
+ pthread_join(hammer, NULL);
+
+ bpf_link__destroy(link);
+
+ SCX_EQ(skel->data->uei.kind, EXIT_KIND(SCX_EXIT_UNREG));
+
+ /* Calculate deltas */
+ enq_delta = skel->bss->enqueue_cnt - enq_start;
+ deq_delta = skel->bss->dequeue_cnt - deq_start;
+ dispatch_deq_delta = skel->bss->dispatch_dequeue_cnt - dispatch_deq_start;
+ change_deq_delta = skel->bss->change_dequeue_cnt - change_deq_start;
+
+ printf("%s:\n", scenario_name);
+ printf(" enqueues: %lu\n", (unsigned long)enq_delta);
+ printf(" dequeues: %lu (dispatch: %lu, property_change: %lu)\n",
+ (unsigned long)deq_delta,
+ (unsigned long)dispatch_deq_delta,
+ (unsigned long)change_deq_delta);
+
+ /*
+ * Validate enqueue/dequeue lifecycle tracking.
+ *
+ * For scenarios 0, 1, 3, 4 (local and global DSQs from
+ * ops.select_cpu() and ops.enqueue()), both enqueues and dequeues
+ * should be 0 because tasks bypass the BPF scheduler entirely: tasks
+ * never enter BPF scheduler's custody.
+ *
+ * For scenarios 2, 5, and 6 (user DSQ or BPF internal queue from
+ * ops.select_cpu() / ops.enqueue()), we expect both enqueues and
+ * dequeues.
+ *
+ * The BPF code does strict state machine validation with
+ * scx_bpf_error() to ensure the workflow semantics are correct. If
+ * we reach this point without errors, the semantics are validated
+ * correctly.
+ */
+ if (scenario == 0 || scenario == 1 || scenario == 3 || scenario == 4) {
+ /* Terminal DSQs: tasks bypass BPF scheduler completely */
+ SCX_EQ(enq_delta, 0);
+ SCX_EQ(deq_delta, 0);
+ SCX_EQ(dispatch_deq_delta, 0);
+ SCX_EQ(change_deq_delta, 0);
+ } else {
+ /* User DSQ: tasks enter BPF scheduler's custody */
+ SCX_GT(enq_delta, 0);
+ SCX_GT(deq_delta, 0);
+ /* Validate 1:1 enqueue/dequeue pairing */
+ SCX_EQ(enq_delta, deq_delta);
+ }
+
+ return SCX_TEST_PASS;
+}
+
+static enum scx_test_status setup(void **ctx)
+{
+ struct dequeue *skel;
+
+ skel = dequeue__open();
+ SCX_FAIL_IF(!skel, "Failed to open skel");
+ SCX_ENUM_INIT(skel);
+ SCX_FAIL_IF(dequeue__load(skel), "Failed to load skel");
+
+ *ctx = skel;
+
+ return SCX_TEST_PASS;
+}
+
+static enum scx_test_status run(void *ctx)
+{
+ struct dequeue *skel = ctx;
+ enum scx_test_status status;
+
+ /* Scenarios 0-2: ops.select_cpu(), 3-6: ops.enqueue() */
+ status = run_scenario(skel, 0, "Scenario 0: Local DSQ from ops.select_cpu()");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 1, "Scenario 1: Global DSQ from ops.select_cpu()");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 2, "Scenario 2: User DSQ from ops.select_cpu()");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 3, "Scenario 3: Local DSQ from ops.enqueue()");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 4, "Scenario 4: Global DSQ from ops.enqueue()");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 5, "Scenario 5: User DSQ from ops.enqueue()");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 6,
+ "Scenario 6: BPF internal queue from ops.enqueue(), consumed in ops.dispatch()");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ printf("\n=== Summary ===\n");
+ printf("Total enqueues: %lu\n", (unsigned long)skel->bss->enqueue_cnt);
+ printf("Total dequeues: %lu\n", (unsigned long)skel->bss->dequeue_cnt);
+ printf(" Dispatch dequeues: %lu (no flag, normal workflow)\n",
+ (unsigned long)skel->bss->dispatch_dequeue_cnt);
+ printf(" Property change dequeues: %lu (SCX_DEQ_SCHED_CHANGE flag)\n",
+ (unsigned long)skel->bss->change_dequeue_cnt);
+ printf("\nAll scenarios passed - no state machine violations detected\n");
+ printf("-> Validated: Local DSQ dispatch bypasses BPF scheduler (both paths)\n");
+ printf("-> Validated: Global DSQ dispatch bypasses BPF scheduler (both paths)\n");
+ printf("-> Validated: User DSQ dispatch triggers dequeue callbacks (both paths)\n");
+ printf("-> Validated: BPF internal queue triggers dequeue when leaving custody\n");
+ printf("-> Validated: ops.enqueue() and ops.select_cpu() behave identically\n");
+ printf("-> Validated: Dispatch dequeues have no flags (normal workflow)\n");
+ printf("-> Validated: Property change dequeues have SCX_DEQ_SCHED_CHANGE flag\n");
+ printf("-> Validated: No duplicate enqueues or invalid state transitions\n");
+
+ return SCX_TEST_PASS;
+}
+
+static void cleanup(void *ctx)
+{
+ struct dequeue *skel = ctx;
+
+ dequeue__destroy(skel);
+}
+
+struct scx_test dequeue_test = {
+ .name = "dequeue",
+ .description = "Verify ops.dequeue() semantics",
+ .setup = setup,
+ .run = run,
+ .cleanup = cleanup,
+};
+
+REGISTER_SCX_TEST(&dequeue_test)
--
2.53.0
^ permalink raw reply [flat|nested] 41+ messages in thread
* [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-05 15:32 [PATCHSET v6] sched_ext: Fix " Andrea Righi
@ 2026-02-05 15:32 ` Andrea Righi
0 siblings, 0 replies; 41+ messages in thread
From: Andrea Righi @ 2026-02-05 15:32 UTC (permalink / raw)
To: Tejun Heo, David Vernet, Changwoo Min
Cc: Kuba Piecuch, Emil Tsalapatis, Christian Loehle, Daniel Hodges,
sched-ext, linux-kernel
Add a new kselftest to validate that the new ops.dequeue() semantics
work correctly for all task lifecycle scenarios, including the
distinction between terminal DSQs (where BPF scheduler is done with the
task) and user DSQs (where BPF scheduler manages the task lifecycle),
regardless of which callback performs the dispatch.
The test validates 6 scenarios:
- from ops.enqueue():
- scenario 0 (local DSQ): tasks dispatched to local DSQs bypass BPF
scheduler entirely, they never enter BPF custody, so no
ops.dequeue() should be called,
- scenario 1 (global DSQ): tasks dispatched to SCX_DSQ_GLOBAL also
bypass BPF scheduler, like local DSQs, no ops.dequeue() should be
called,
- scenario 2 (user DSQ): tasks enter BPF scheduler custody with full
enqueue/dequeue lifecycle tracking and state machine validation
(expects 1:1 enqueue/dequeue pairing).
- from ops.select_cpu():
- scenario 3 (local DSQ): identical behavior to scenario 0,
- scenario 4 (global DSQ): identical behavior to scenario 1,
- scenario 5 (user DSQ): identical behavior to scenario 2.
This verifies that:
- terminal DSQ dispatch (local, global) don't trigger ops.dequeue(),
- user DSQ dispatch has exact 1:1 ops.enqueue()/dequeue() pairing,
- dispatch dequeues have no flags (normal workflow),
- property change dequeues have the %SCX_DEQ_SCHED_CHANGE flag set,
- no duplicate enqueues or invalid state transitions are happening,
- ops.enqueue() and ops.select_cpu() dispatch paths behave identically.
Cc: Tejun Heo <tj@kernel.org>
Cc: Emil Tsalapatis <emil@etsalapatis.com>
Cc: Kuba Piecuch <jpiecuch@google.com>
Signed-off-by: Andrea Righi <arighi@nvidia.com>
---
tools/testing/selftests/sched_ext/Makefile | 1 +
.../testing/selftests/sched_ext/dequeue.bpf.c | 334 ++++++++++++++++++
tools/testing/selftests/sched_ext/dequeue.c | 222 ++++++++++++
3 files changed, 557 insertions(+)
create mode 100644 tools/testing/selftests/sched_ext/dequeue.bpf.c
create mode 100644 tools/testing/selftests/sched_ext/dequeue.c
diff --git a/tools/testing/selftests/sched_ext/Makefile b/tools/testing/selftests/sched_ext/Makefile
index 5fe45f9c5f8fd..764e91edabf93 100644
--- a/tools/testing/selftests/sched_ext/Makefile
+++ b/tools/testing/selftests/sched_ext/Makefile
@@ -161,6 +161,7 @@ all_test_bpfprogs := $(foreach prog,$(wildcard *.bpf.c),$(INCLUDE_DIR)/$(patsubs
auto-test-targets := \
create_dsq \
+ dequeue \
enq_last_no_enq_fails \
ddsp_bogus_dsq_fail \
ddsp_vtimelocal_fail \
diff --git a/tools/testing/selftests/sched_ext/dequeue.bpf.c b/tools/testing/selftests/sched_ext/dequeue.bpf.c
new file mode 100644
index 0000000000000..9b1950737a014
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/dequeue.bpf.c
@@ -0,0 +1,334 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * A scheduler that validates ops.dequeue() is called correctly:
+ * - Tasks dispatched to terminal DSQs (local, global) bypass the BPF
+ * scheduler entirely: no ops.dequeue() should be called
+ * - Tasks dispatched to user DSQs enter BPF custody: ops.dequeue() must be
+ * called when they leave custody
+ * - Every ops.enqueue() for non-terminal DSQs is followed by exactly one
+ * ops.dequeue() (validate 1:1 pairing and state machine)
+ *
+ * Copyright (c) 2026 NVIDIA Corporation.
+ */
+
+#include <scx/common.bpf.h>
+
+#define SHARED_DSQ 0
+
+char _license[] SEC("license") = "GPL";
+
+UEI_DEFINE(uei);
+
+/*
+ * Counters to track the lifecycle of tasks:
+ * - enqueue_cnt: Number of times ops.enqueue() was called
+ * - dequeue_cnt: Number of times ops.dequeue() was called (any type)
+ * - dispatch_dequeue_cnt: Number of regular dispatch dequeues (no flag)
+ * - change_dequeue_cnt: Number of property change dequeues
+ */
+u64 enqueue_cnt, dequeue_cnt, dispatch_dequeue_cnt, change_dequeue_cnt;
+
+/*
+ * Test scenarios:
+ * 0) Dispatch to local DSQ from ops.enqueue() (terminal DSQ, bypasses BPF
+ * scheduler, no dequeue callbacks)
+ * 1) Dispatch to global DSQ from ops.enqueue() (terminal DSQ, bypasses BPF
+ * scheduler, no dequeue callbacks)
+ * 2) Dispatch to shared user DSQ from ops.enqueue() (enters BPF scheduler,
+ * dequeue callbacks expected)
+ * 3) Dispatch to local DSQ from ops.select_cpu() (terminal DSQ, bypasses BPF
+ * scheduler, no dequeue callbacks)
+ * 4) Dispatch to global DSQ from ops.select_cpu() (terminal DSQ, bypasses
+ * BPF scheduler, no dequeue callbacks)
+ * 5) Dispatch to shared user DSQ from ops.select_cpu() (enters BPF scheduler,
+ * dequeue callbacks expected)
+ */
+u32 test_scenario;
+
+/*
+ * Per-task state to track lifecycle and validate workflow semantics.
+ * State transitions:
+ * NONE -> ENQUEUED (on enqueue)
+ * ENQUEUED -> DISPATCHED (on dispatch dequeue)
+ * DISPATCHED -> NONE (on property change dequeue or re-enqueue)
+ * ENQUEUED -> NONE (on property change dequeue before dispatch)
+ */
+enum task_state {
+ TASK_NONE = 0,
+ TASK_ENQUEUED,
+ TASK_DISPATCHED,
+};
+
+struct task_ctx {
+ enum task_state state; /* Current state in the workflow */
+ u64 enqueue_seq; /* Sequence number for debugging */
+};
+
+struct {
+ __uint(type, BPF_MAP_TYPE_TASK_STORAGE);
+ __uint(map_flags, BPF_F_NO_PREALLOC);
+ __type(key, int);
+ __type(value, struct task_ctx);
+} task_ctx_stor SEC(".maps");
+
+static struct task_ctx *try_lookup_task_ctx(struct task_struct *p)
+{
+ return bpf_task_storage_get(&task_ctx_stor, p, 0, 0);
+}
+
+s32 BPF_STRUCT_OPS(dequeue_select_cpu, struct task_struct *p,
+ s32 prev_cpu, u64 wake_flags)
+{
+ struct task_ctx *tctx;
+
+ tctx = try_lookup_task_ctx(p);
+ if (!tctx)
+ return prev_cpu;
+
+ switch (test_scenario) {
+ case 3:
+ /*
+ * Scenario 3: Direct dispatch to local DSQ from select_cpu.
+ *
+ * Task bypasses BPF scheduler entirely: no enqueue
+ * tracking, no dequeue callbacks. Behavior should be
+ * identical to scenario 0.
+ */
+ scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, 0);
+ return prev_cpu;
+
+ case 4:
+ /*
+ * Scenario 4: Direct dispatch to global DSQ from select_cpu.
+ *
+ * Like scenario 3, task bypasses BPF scheduler entirely.
+ * Behavior should be identical to scenario 1.
+ */
+ scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, 0);
+ return prev_cpu;
+
+ case 5:
+ /*
+ * Scenario 5: Dispatch to shared user DSQ from select_cpu.
+ *
+ * Task enters BPF scheduler management: track
+ * enqueue/dequeue lifecycle and validate state transitions.
+ * Behavior should be identical to scenario 2.
+ */
+ __sync_fetch_and_add(&enqueue_cnt, 1);
+
+ /*
+ * Validate state transition: enqueue is only valid from
+ * NONE or DISPATCHED states. Getting enqueue while in
+ * ENQUEUED state indicates a missing dequeue.
+ */
+ if (tctx->state == TASK_ENQUEUED)
+ scx_bpf_error("%d (%s): enqueue while in ENQUEUED state seq=%llu",
+ p->pid, p->comm, tctx->enqueue_seq);
+
+ /* Transition to ENQUEUED state */
+ tctx->state = TASK_ENQUEUED;
+ tctx->enqueue_seq++;
+
+ scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, 0);
+ return prev_cpu;
+
+ default:
+ /* For scenarios 0-2, bounce to ops.enqueue() */
+ return prev_cpu;
+ }
+}
+
+void BPF_STRUCT_OPS(dequeue_enqueue, struct task_struct *p, u64 enq_flags)
+{
+ struct task_ctx *tctx;
+
+ tctx = try_lookup_task_ctx(p);
+ if (!tctx)
+ return;
+
+ switch (test_scenario) {
+ case 0:
+ /*
+ * Scenario 0: Direct dispatch to the local DSQ.
+ *
+ * Task bypasses BPF scheduler entirely: no enqueue
+ * tracking, no dequeue callbacks. Don't increment counters
+ * or validate state since the task never enters BPF
+ * scheduler management.
+ */
+ scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, enq_flags);
+ break;
+
+ case 1:
+ /*
+ * Scenario 1: Direct dispatch to the global DSQ.
+ *
+ * Like scenario 0, task bypasses BPF scheduler entirely.
+ * SCX_DSQ_GLOBAL is a terminal DSQ, tasks dispatched to it
+ * leave BPF custody immediately, so no dequeue callbacks
+ * should be triggered.
+ */
+ scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
+ break;
+
+ case 2:
+ /*
+ * Scenario 2: Dispatch to shared user DSQ.
+ *
+ * Task enters BPF scheduler management: track
+ * enqueue/dequeue lifecycle and validate state
+ * transitions.
+ */
+ __sync_fetch_and_add(&enqueue_cnt, 1);
+
+ /*
+ * Validate state transition: enqueue is only valid from
+ * NONE or DISPATCHED states. Getting enqueue while in
+ * ENQUEUED state indicates a missing dequeue.
+ */
+ if (tctx->state == TASK_ENQUEUED)
+ scx_bpf_error("%d (%s): enqueue while in ENQUEUED state seq=%llu",
+ p->pid, p->comm, tctx->enqueue_seq);
+
+ /* Transition to ENQUEUED state */
+ tctx->state = TASK_ENQUEUED;
+ tctx->enqueue_seq++;
+
+ scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, enq_flags);
+ break;
+ default:
+ /* For scenarios 3-5 dispatch to the global DSQ */
+ scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
+ }
+}
+
+void BPF_STRUCT_OPS(dequeue_dequeue, struct task_struct *p, u64 deq_flags)
+{
+ struct task_ctx *tctx;
+
+ __sync_fetch_and_add(&dequeue_cnt, 1);
+
+ tctx = try_lookup_task_ctx(p);
+ if (!tctx)
+ return;
+
+ /*
+ * For scenarios 0, 1, 3, and 4 (terminal DSQs: local and global),
+ * ops.dequeue() should never be called because tasks bypass the
+ * BPF scheduler entirely. If we get here, it's a kernel bug. We
+ * don't track enqueues for these scenarios, so tctx->enqueue_seq
+ * will be 0.
+ */
+ if (test_scenario == 0 || test_scenario == 3) {
+ scx_bpf_error("%d (%s): dequeue called for local DSQ scenario - kernel bug!",
+ p->pid, p->comm);
+ return;
+ }
+ if (test_scenario == 1 || test_scenario == 4) {
+ scx_bpf_error("%d (%s): dequeue called for global DSQ scenario - kernel bug!",
+ p->pid, p->comm);
+ return;
+ }
+
+ /*
+ * Validate state: dequeue should only happen from ENQUEUED or
+ * DISPATCHED states. Getting dequeue from NONE indicates a bug.
+ */
+ if (tctx->state == TASK_NONE) {
+ scx_bpf_error("%d (%s): dequeue from NONE state seq=%llu",
+ p->pid, p->comm, tctx->enqueue_seq);
+ return;
+ }
+
+ if (deq_flags & SCX_DEQ_SCHED_CHANGE) {
+ /*
+ * Property change interrupting the workflow. Valid from
+ * both ENQUEUED and DISPATCHED states. Transitions task
+ * back to NONE state.
+ */
+ __sync_fetch_and_add(&change_dequeue_cnt, 1);
+
+ /* Validate state transition */
+ if (tctx->state != TASK_ENQUEUED && tctx->state != TASK_DISPATCHED)
+ scx_bpf_error("%d (%s): invalid property change dequeue state=%d seq=%llu",
+ p->pid, p->comm, tctx->state, tctx->enqueue_seq);
+
+ /* Transition back to NONE: task outside scheduler control */
+ tctx->state = TASK_NONE;
+ } else {
+ /*
+ * Regular dispatch dequeue: normal workflow step. Valid
+ * only from ENQUEUED state (after enqueue, before dispatch
+ * dequeue). Transitions to DISPATCHED state.
+ */
+ __sync_fetch_and_add(&dispatch_dequeue_cnt, 1);
+
+ /*
+ * Dispatch dequeue should not have %SCX_DEQ_SCHED_CHANGE
+ * flag.
+ */
+ if (deq_flags & SCX_DEQ_SCHED_CHANGE)
+ scx_bpf_error("%d (%s): SCX_DEQ_SCHED_CHANGE in dispatch dequeue seq=%llu",
+ p->pid, p->comm, tctx->enqueue_seq);
+
+ /*
+ * Must be in ENQUEUED state.
+ */
+ if (tctx->state != TASK_ENQUEUED)
+ scx_bpf_error("%d (%s): dispatch dequeue from state %d seq=%llu",
+ p->pid, p->comm, tctx->state, tctx->enqueue_seq);
+
+ /*
+ * Transition to DISPATCHED: normal cycle completed
+ * dispatch.
+ */
+ tctx->state = TASK_DISPATCHED;
+ }
+}
+
+void BPF_STRUCT_OPS(dequeue_dispatch, s32 cpu, struct task_struct *prev)
+{
+ scx_bpf_dsq_move_to_local(SHARED_DSQ);
+}
+
+s32 BPF_STRUCT_OPS(dequeue_init_task, struct task_struct *p,
+ struct scx_init_task_args *args)
+{
+ struct task_ctx *tctx;
+
+ tctx = bpf_task_storage_get(&task_ctx_stor, p, 0,
+ BPF_LOCAL_STORAGE_GET_F_CREATE);
+ if (!tctx)
+ return -ENOMEM;
+
+ return 0;
+}
+
+s32 BPF_STRUCT_OPS_SLEEPABLE(dequeue_init)
+{
+ s32 ret;
+
+ ret = scx_bpf_create_dsq(SHARED_DSQ, -1);
+ if (ret)
+ return ret;
+
+ return 0;
+}
+
+void BPF_STRUCT_OPS(dequeue_exit, struct scx_exit_info *ei)
+{
+ UEI_RECORD(uei, ei);
+}
+
+SEC(".struct_ops.link")
+struct sched_ext_ops dequeue_ops = {
+ .select_cpu = (void *)dequeue_select_cpu,
+ .enqueue = (void *)dequeue_enqueue,
+ .dequeue = (void *)dequeue_dequeue,
+ .dispatch = (void *)dequeue_dispatch,
+ .init_task = (void *)dequeue_init_task,
+ .init = (void *)dequeue_init,
+ .exit = (void *)dequeue_exit,
+ .name = "dequeue_test",
+};
diff --git a/tools/testing/selftests/sched_ext/dequeue.c b/tools/testing/selftests/sched_ext/dequeue.c
new file mode 100644
index 0000000000000..02655b9525ffe
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/dequeue.c
@@ -0,0 +1,222 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Copyright (c) 2025 NVIDIA Corporation.
+ */
+#define _GNU_SOURCE
+#include <stdio.h>
+#include <unistd.h>
+#include <signal.h>
+#include <bpf/bpf.h>
+#include <scx/common.h>
+#include <sys/wait.h>
+#include <sched.h>
+#include <pthread.h>
+#include "scx_test.h"
+#include "dequeue.bpf.skel.h"
+
+#define NUM_WORKERS 8
+
+/*
+ * Worker function that creates enqueue/dequeue events. It alternates
+ * between CPU work, sleeping, and affinity changes to trigger dequeues.
+ */
+static void worker_fn(int id)
+{
+ cpu_set_t cpuset;
+ int i;
+ volatile int sum = 0;
+
+ for (i = 0; i < 1000; i++) {
+ int j;
+
+ /* Do some work to trigger scheduling events */
+ for (j = 0; j < 10000; j++)
+ sum += j;
+
+ /* Change affinity to trigger dequeue */
+ if (i % 10 == 0) {
+ CPU_ZERO(&cpuset);
+ /* Rotate through the first 4 CPUs */
+ CPU_SET(i % 4, &cpuset);
+ sched_setaffinity(0, sizeof(cpuset), &cpuset);
+ }
+
+ /* Do additional work */
+ for (j = 0; j < 10000; j++)
+ sum += j;
+
+ /* Sleep to trigger dequeue */
+ usleep(1000 + (id * 100));
+ }
+
+ exit(0);
+}
+
+static enum scx_test_status run_scenario(struct dequeue *skel, u32 scenario,
+ const char *scenario_name)
+{
+ struct bpf_link *link;
+ pid_t pids[NUM_WORKERS];
+ int i, status;
+ u64 enq_start, deq_start, dispatch_deq_start, change_deq_start;
+ u64 enq_delta, deq_delta, dispatch_deq_delta, change_deq_delta;
+
+ /* Set the test scenario */
+ skel->bss->test_scenario = scenario;
+
+ /* Record starting counts */
+ enq_start = skel->bss->enqueue_cnt;
+ deq_start = skel->bss->dequeue_cnt;
+ dispatch_deq_start = skel->bss->dispatch_dequeue_cnt;
+ change_deq_start = skel->bss->change_dequeue_cnt;
+
+ link = bpf_map__attach_struct_ops(skel->maps.dequeue_ops);
+ SCX_FAIL_IF(!link, "Failed to attach struct_ops for scenario %s", scenario_name);
+
+ /* Fork worker processes to generate enqueue/dequeue events */
+ for (i = 0; i < NUM_WORKERS; i++) {
+ pids[i] = fork();
+ SCX_FAIL_IF(pids[i] < 0, "Failed to fork worker %d", i);
+
+ if (pids[i] == 0) {
+ worker_fn(i);
+ /* Should not reach here */
+ exit(1);
+ }
+ }
+
+ /* Wait for all workers to complete */
+ for (i = 0; i < NUM_WORKERS; i++) {
+ SCX_FAIL_IF(waitpid(pids[i], &status, 0) != pids[i],
+ "Failed to wait for worker %d", i);
+ SCX_FAIL_IF(status != 0, "Worker %d exited with status %d", i, status);
+ }
+
+ bpf_link__destroy(link);
+
+ SCX_EQ(skel->data->uei.kind, EXIT_KIND(SCX_EXIT_UNREG));
+
+ /* Calculate deltas */
+ enq_delta = skel->bss->enqueue_cnt - enq_start;
+ deq_delta = skel->bss->dequeue_cnt - deq_start;
+ dispatch_deq_delta = skel->bss->dispatch_dequeue_cnt - dispatch_deq_start;
+ change_deq_delta = skel->bss->change_dequeue_cnt - change_deq_start;
+
+ printf("%s:\n", scenario_name);
+ printf(" enqueues: %lu\n", (unsigned long)enq_delta);
+ printf(" dequeues: %lu (dispatch: %lu, property_change: %lu)\n",
+ (unsigned long)deq_delta,
+ (unsigned long)dispatch_deq_delta,
+ (unsigned long)change_deq_delta);
+
+ /*
+ * Validate enqueue/dequeue lifecycle tracking.
+ *
+ * For scenarios 0, 1, 3, 4 (local and global DSQs from both
+ * ops.enqueue() and ops.select_cpu()), both enqueues and dequeues
+ * should be 0 because tasks bypass the BPF scheduler entirely: tasks
+ * never enter BPF scheduler's custody.
+ *
+ * For scenarios 2 and 5 (user DSQ from both ops.enqueue() and
+ * ops.select_cpu()), we expect both enqueues and dequeues.
+ *
+ * The BPF code does strict state machine validation with
+ * scx_bpf_error() to ensure the workflow semantics are correct. If
+ * we reach this point without errors, the semantics are validated
+ * correctly.
+ */
+ if (scenario == 0 || scenario == 1 || scenario == 3 || scenario == 4) {
+ /* Terminal DSQs: tasks bypass BPF scheduler completely */
+ SCX_EQ(enq_delta, 0);
+ SCX_EQ(deq_delta, 0);
+ SCX_EQ(dispatch_deq_delta, 0);
+ SCX_EQ(change_deq_delta, 0);
+ } else {
+ /* User DSQ: tasks enter BPF scheduler's custody */
+ SCX_GT(enq_delta, 0);
+ SCX_GT(deq_delta, 0);
+ /* Validate 1:1 enqueue/dequeue pairing */
+ SCX_EQ(enq_delta, deq_delta);
+ }
+
+ return SCX_TEST_PASS;
+}
+
+static enum scx_test_status setup(void **ctx)
+{
+ struct dequeue *skel;
+
+ skel = dequeue__open();
+ SCX_FAIL_IF(!skel, "Failed to open skel");
+ SCX_ENUM_INIT(skel);
+ SCX_FAIL_IF(dequeue__load(skel), "Failed to load skel");
+
+ *ctx = skel;
+
+ return SCX_TEST_PASS;
+}
+
+static enum scx_test_status run(void *ctx)
+{
+ struct dequeue *skel = ctx;
+ enum scx_test_status status;
+
+ status = run_scenario(skel, 0, "Scenario 0: Local DSQ from ops.enqueue()");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 1, "Scenario 1: Global DSQ from ops.enqueue()");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 2, "Scenario 2: User DSQ from ops.enqueue()");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 3, "Scenario 3: Local DSQ from ops.select_cpu()");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 4, "Scenario 4: Global DSQ from ops.select_cpu()");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 5, "Scenario 5: User DSQ from ops.select_cpu()");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ printf("\n=== Summary ===\n");
+ printf("Total enqueues: %lu\n", (unsigned long)skel->bss->enqueue_cnt);
+ printf("Total dequeues: %lu\n", (unsigned long)skel->bss->dequeue_cnt);
+ printf(" Dispatch dequeues: %lu (no flag, normal workflow)\n",
+ (unsigned long)skel->bss->dispatch_dequeue_cnt);
+ printf(" Property change dequeues: %lu (SCX_DEQ_SCHED_CHANGE flag)\n",
+ (unsigned long)skel->bss->change_dequeue_cnt);
+ printf("\nAll scenarios passed - no state machine violations detected\n");
+ printf("-> Validated: Local DSQ dispatch bypasses BPF scheduler (both paths)\n");
+ printf("-> Validated: Global DSQ dispatch bypasses BPF scheduler (both paths)\n");
+ printf("-> Validated: User DSQ dispatch triggers dequeue callbacks (both paths)\n");
+ printf("-> Validated: ops.enqueue() and ops.select_cpu() behave identically\n");
+ printf("-> Validated: Dispatch dequeues have no flags (normal workflow)\n");
+ printf("-> Validated: Property change dequeues have SCX_DEQ_SCHED_CHANGE flag\n");
+ printf("-> Validated: No duplicate enqueues or invalid state transitions\n");
+
+ return SCX_TEST_PASS;
+}
+
+static void cleanup(void *ctx)
+{
+ struct dequeue *skel = ctx;
+
+ dequeue__destroy(skel);
+}
+
+struct scx_test dequeue_test = {
+ .name = "dequeue",
+ .description = "Verify ops.dequeue() semantics",
+ .setup = setup,
+ .run = run,
+ .cleanup = cleanup,
+};
+
+REGISTER_SCX_TEST(&dequeue_test)
--
2.53.0
^ permalink raw reply [flat|nested] 41+ messages in thread
* [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-02-04 16:05 [PATCHSET v5] sched_ext: Fix " Andrea Righi
@ 2026-02-04 16:05 ` Andrea Righi
0 siblings, 0 replies; 41+ messages in thread
From: Andrea Righi @ 2026-02-04 16:05 UTC (permalink / raw)
To: Tejun Heo, David Vernet, Changwoo Min
Cc: Kuba Piecuch, Emil Tsalapatis, Christian Loehle, Daniel Hodges,
sched-ext, linux-kernel
Add a new kselftest to validate the following scenarios:
- scenario 0 (Local DSQ): tasks dispatched to local DSQs bypass BPF
scheduler entirely, they never enter BPF custody, so no
ops.dequeue() should be called,
- scenario 1 (Global DSQ): tasks dispatched to SCX_DSQ_GLOBAL also
bypass BPF scheduler, like local DSQs, no ops.dequeue() should be
called,
- scenario 2 (User DSQ): tasks enter BPF scheduler custody with full
enqueue/dequeue lifecycle tracking and state machine validation
(expects 1:1 enqueue/dequeue pairing).
The test validates that:
- terminal DSQ dispatch (local, global) don't trigger ops.dequeue(),
- user DSQ dispatch has exact 1:1 ops.enqueue()/dequeue() pairing,
- dispatch dequeues have no flags (normal workflow),
- property change dequeues have the %SCX_DEQ_SCHED_CHANGE flag set,
- no duplicate enqueues or invalid state transitions are happening.
This validates that the new ops.dequeue() semantics work correctly for
all task lifecycle scenarios, including the distinction between terminal
DSQs (where BPF scheduler is done with the task) and user DSQs (where
BPF scheduler manages the task lifecycle).
Cc: Tejun Heo <tj@kernel.org>
Cc: Emil Tsalapatis <emil@etsalapatis.com>
Cc: Kuba Piecuch <jpiecuch@google.com>
Signed-off-by: Andrea Righi <arighi@nvidia.com>
---
tools/testing/selftests/sched_ext/Makefile | 1 +
.../testing/selftests/sched_ext/dequeue.bpf.c | 269 ++++++++++++++++++
tools/testing/selftests/sched_ext/dequeue.c | 207 ++++++++++++++
3 files changed, 477 insertions(+)
create mode 100644 tools/testing/selftests/sched_ext/dequeue.bpf.c
create mode 100644 tools/testing/selftests/sched_ext/dequeue.c
diff --git a/tools/testing/selftests/sched_ext/Makefile b/tools/testing/selftests/sched_ext/Makefile
index 5fe45f9c5f8fd..764e91edabf93 100644
--- a/tools/testing/selftests/sched_ext/Makefile
+++ b/tools/testing/selftests/sched_ext/Makefile
@@ -161,6 +161,7 @@ all_test_bpfprogs := $(foreach prog,$(wildcard *.bpf.c),$(INCLUDE_DIR)/$(patsubs
auto-test-targets := \
create_dsq \
+ dequeue \
enq_last_no_enq_fails \
ddsp_bogus_dsq_fail \
ddsp_vtimelocal_fail \
diff --git a/tools/testing/selftests/sched_ext/dequeue.bpf.c b/tools/testing/selftests/sched_ext/dequeue.bpf.c
new file mode 100644
index 0000000000000..5d736ffadb4c8
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/dequeue.bpf.c
@@ -0,0 +1,269 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * A scheduler that validates ops.dequeue() is called correctly:
+ * - Tasks dispatched to terminal DSQs (local, global) bypass the BPF
+ * scheduler entirely: no ops.dequeue() should be called
+ * - Tasks dispatched to user DSQs enter BPF custody: ops.dequeue() must be
+ * called when they leave custody
+ * - Every ops.enqueue() for non-terminal DSQs is followed by exactly one
+ * ops.dequeue() (validate 1:1 pairing and state machine)
+ *
+ * Copyright (c) 2026 NVIDIA Corporation.
+ */
+
+#include <scx/common.bpf.h>
+
+#define SHARED_DSQ 0
+
+char _license[] SEC("license") = "GPL";
+
+UEI_DEFINE(uei);
+
+/*
+ * Counters to track the lifecycle of tasks:
+ * - enqueue_cnt: Number of times ops.enqueue() was called
+ * - dequeue_cnt: Number of times ops.dequeue() was called (any type)
+ * - dispatch_dequeue_cnt: Number of regular dispatch dequeues (no flag)
+ * - change_dequeue_cnt: Number of property change dequeues
+ */
+u64 enqueue_cnt, dequeue_cnt, dispatch_dequeue_cnt, change_dequeue_cnt;
+
+/*
+ * Test scenarios:
+ * 0) Dispatch to local DSQ (terminal DSQ, bypasses BPF scheduler, no
+ * dequeue callbacks)
+ * 1) Dispatch to global DSQ (terminal DSQ, bypasses BPF scheduler, no
+ * dequeue callbacks)
+ * 2) Dispatch to shared user DSQ (enters BPF scheduler, dequeue callbacks
+ * expected)
+ */
+u32 test_scenario;
+
+/*
+ * Per-task state to track lifecycle and validate workflow semantics.
+ * State transitions:
+ * NONE -> ENQUEUED (on enqueue)
+ * ENQUEUED -> DISPATCHED (on dispatch dequeue)
+ * DISPATCHED -> NONE (on property change dequeue or re-enqueue)
+ * ENQUEUED -> NONE (on property change dequeue before dispatch)
+ */
+enum task_state {
+ TASK_NONE = 0,
+ TASK_ENQUEUED,
+ TASK_DISPATCHED,
+};
+
+struct task_ctx {
+ enum task_state state; /* Current state in the workflow */
+ u64 enqueue_seq; /* Sequence number for debugging */
+};
+
+struct {
+ __uint(type, BPF_MAP_TYPE_TASK_STORAGE);
+ __uint(map_flags, BPF_F_NO_PREALLOC);
+ __type(key, int);
+ __type(value, struct task_ctx);
+} task_ctx_stor SEC(".maps");
+
+static struct task_ctx *try_lookup_task_ctx(struct task_struct *p)
+{
+ return bpf_task_storage_get(&task_ctx_stor, p, 0, 0);
+}
+
+s32 BPF_STRUCT_OPS(dequeue_select_cpu, struct task_struct *p,
+ s32 prev_cpu, u64 wake_flags)
+{
+ /* Always bounce to ops.enqueue() */
+ return prev_cpu;
+}
+
+void BPF_STRUCT_OPS(dequeue_enqueue, struct task_struct *p, u64 enq_flags)
+{
+ struct task_ctx *tctx;
+
+ tctx = try_lookup_task_ctx(p);
+ if (!tctx)
+ return;
+
+ switch (test_scenario) {
+ case 0:
+ /*
+ * Scenario 0: Direct dispatch to the local DSQ.
+ *
+ * Task bypasses BPF scheduler entirely: no enqueue
+ * tracking, no dequeue callbacks. Don't increment counters
+ * or validate state since the task never enters BPF
+ * scheduler management.
+ */
+ scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, enq_flags);
+ break;
+
+ case 1:
+ /*
+ * Scenario 1: Direct dispatch to the global DSQ.
+ *
+ * Like scenario 0, task bypasses BPF scheduler entirely.
+ * SCX_DSQ_GLOBAL is a terminal DSQ, tasks dispatched to it
+ * leave BPF custody immediately, so no dequeue callbacks
+ * should be triggered.
+ */
+ scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
+ break;
+
+ case 2:
+ /*
+ * Scenario 2: Dispatch to shared user DSQ.
+ *
+ * Task enters BPF scheduler management: track
+ * enqueue/dequeue lifecycle and validate state
+ * transitions.
+ */
+ __sync_fetch_and_add(&enqueue_cnt, 1);
+
+ /*
+ * Validate state transition: enqueue is only valid from
+ * NONE or DISPATCHED states. Getting enqueue while in
+ * ENQUEUED state indicates a missing dequeue.
+ */
+ if (tctx->state == TASK_ENQUEUED)
+ scx_bpf_error("%d (%s): enqueue while in ENQUEUED state seq=%llu",
+ p->pid, p->comm, tctx->enqueue_seq);
+
+ /* Transition to ENQUEUED state */
+ tctx->state = TASK_ENQUEUED;
+ tctx->enqueue_seq++;
+
+ scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, enq_flags);
+ break;
+ }
+}
+
+void BPF_STRUCT_OPS(dequeue_dequeue, struct task_struct *p, u64 deq_flags)
+{
+ struct task_ctx *tctx;
+
+ __sync_fetch_and_add(&dequeue_cnt, 1);
+
+ tctx = try_lookup_task_ctx(p);
+ if (!tctx)
+ return;
+
+ /*
+ * For scenarios 0 and 1 (terminal DSQs: local and global),
+ * ops.dequeue() should never be called because tasks bypass the
+ * BPF scheduler entirely. If we get here, it's a kernel bug. We
+ * don't track enqueues for these scenarios, so tctx->enqueue_seq
+ * will be 0.
+ */
+ if (test_scenario == 0) {
+ scx_bpf_error("%d (%s): dequeue called for local DSQ scenario - kernel bug!",
+ p->pid, p->comm);
+ return;
+ }
+ if (test_scenario == 1) {
+ scx_bpf_error("%d (%s): dequeue called for global DSQ scenario - kernel bug!",
+ p->pid, p->comm);
+ return;
+ }
+
+ /*
+ * Validate state: dequeue should only happen from ENQUEUED or
+ * DISPATCHED states. Getting dequeue from NONE indicates a bug.
+ */
+ if (tctx->state == TASK_NONE) {
+ scx_bpf_error("%d (%s): dequeue from NONE state seq=%llu",
+ p->pid, p->comm, tctx->enqueue_seq);
+ return;
+ }
+
+ if (deq_flags & SCX_DEQ_SCHED_CHANGE) {
+ /*
+ * Property change interrupting the workflow. Valid from
+ * both ENQUEUED and DISPATCHED states. Transitions task
+ * back to NONE state.
+ */
+ __sync_fetch_and_add(&change_dequeue_cnt, 1);
+
+ /* Validate state transition */
+ if (tctx->state != TASK_ENQUEUED && tctx->state != TASK_DISPATCHED)
+ scx_bpf_error("%d (%s): invalid property change dequeue state=%d seq=%llu",
+ p->pid, p->comm, tctx->state, tctx->enqueue_seq);
+
+ /* Transition back to NONE: task outside scheduler control */
+ tctx->state = TASK_NONE;
+ } else {
+ /*
+ * Regular dispatch dequeue: normal workflow step. Valid
+ * only from ENQUEUED state (after enqueue, before dispatch
+ * dequeue). Transitions to DISPATCHED state.
+ */
+ __sync_fetch_and_add(&dispatch_dequeue_cnt, 1);
+
+ /*
+ * Dispatch dequeue should not have %SCX_DEQ_SCHED_CHANGE
+ * flag.
+ */
+ if (deq_flags & SCX_DEQ_SCHED_CHANGE)
+ scx_bpf_error("%d (%s): SCX_DEQ_SCHED_CHANGE in dispatch dequeue seq=%llu",
+ p->pid, p->comm, tctx->enqueue_seq);
+
+ /*
+ * Must be in ENQUEUED state.
+ */
+ if (tctx->state != TASK_ENQUEUED)
+ scx_bpf_error("%d (%s): dispatch dequeue from state %d seq=%llu",
+ p->pid, p->comm, tctx->state, tctx->enqueue_seq);
+
+ /*
+ * Transition to DISPATCHED: normal cycle completed
+ * dispatch.
+ */
+ tctx->state = TASK_DISPATCHED;
+ }
+}
+
+void BPF_STRUCT_OPS(dequeue_dispatch, s32 cpu, struct task_struct *prev)
+{
+ scx_bpf_dsq_move_to_local(SHARED_DSQ);
+}
+
+s32 BPF_STRUCT_OPS(dequeue_init_task, struct task_struct *p,
+ struct scx_init_task_args *args)
+{
+ struct task_ctx *tctx;
+
+ tctx = bpf_task_storage_get(&task_ctx_stor, p, 0,
+ BPF_LOCAL_STORAGE_GET_F_CREATE);
+ if (!tctx)
+ return -ENOMEM;
+
+ return 0;
+}
+
+s32 BPF_STRUCT_OPS_SLEEPABLE(dequeue_init)
+{
+ s32 ret;
+
+ ret = scx_bpf_create_dsq(SHARED_DSQ, -1);
+ if (ret)
+ return ret;
+
+ return 0;
+}
+
+void BPF_STRUCT_OPS(dequeue_exit, struct scx_exit_info *ei)
+{
+ UEI_RECORD(uei, ei);
+}
+
+SEC(".struct_ops.link")
+struct sched_ext_ops dequeue_ops = {
+ .select_cpu = (void *)dequeue_select_cpu,
+ .enqueue = (void *)dequeue_enqueue,
+ .dequeue = (void *)dequeue_dequeue,
+ .dispatch = (void *)dequeue_dispatch,
+ .init_task = (void *)dequeue_init_task,
+ .init = (void *)dequeue_init,
+ .exit = (void *)dequeue_exit,
+ .name = "dequeue_test",
+};
diff --git a/tools/testing/selftests/sched_ext/dequeue.c b/tools/testing/selftests/sched_ext/dequeue.c
new file mode 100644
index 0000000000000..0ce59299a1b37
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/dequeue.c
@@ -0,0 +1,207 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Copyright (c) 2025 NVIDIA Corporation.
+ */
+#define _GNU_SOURCE
+#include <stdio.h>
+#include <unistd.h>
+#include <signal.h>
+#include <bpf/bpf.h>
+#include <scx/common.h>
+#include <sys/wait.h>
+#include <sched.h>
+#include <pthread.h>
+#include "scx_test.h"
+#include "dequeue.bpf.skel.h"
+
+#define NUM_WORKERS 8
+
+/*
+ * Worker function that creates enqueue/dequeue events. It alternates
+ * between CPU work, sleeping, and affinity changes to trigger dequeues.
+ */
+static void worker_fn(int id)
+{
+ cpu_set_t cpuset;
+ int i;
+ volatile int sum = 0;
+
+ for (i = 0; i < 1000; i++) {
+ int j;
+
+ /* Do some work to trigger scheduling events */
+ for (j = 0; j < 10000; j++)
+ sum += j;
+
+ /* Change affinity to trigger dequeue */
+ if (i % 10 == 0) {
+ CPU_ZERO(&cpuset);
+ /* Rotate through the first 4 CPUs */
+ CPU_SET(i % 4, &cpuset);
+ sched_setaffinity(0, sizeof(cpuset), &cpuset);
+ }
+
+ /* Do additional work */
+ for (j = 0; j < 10000; j++)
+ sum += j;
+
+ /* Sleep to trigger dequeue */
+ usleep(1000 + (id * 100));
+ }
+
+ exit(0);
+}
+
+static enum scx_test_status run_scenario(struct dequeue *skel, u32 scenario,
+ const char *scenario_name)
+{
+ struct bpf_link *link;
+ pid_t pids[NUM_WORKERS];
+ int i, status;
+ u64 enq_start, deq_start, dispatch_deq_start, change_deq_start;
+ u64 enq_delta, deq_delta, dispatch_deq_delta, change_deq_delta;
+
+ /* Set the test scenario */
+ skel->bss->test_scenario = scenario;
+
+ /* Record starting counts */
+ enq_start = skel->bss->enqueue_cnt;
+ deq_start = skel->bss->dequeue_cnt;
+ dispatch_deq_start = skel->bss->dispatch_dequeue_cnt;
+ change_deq_start = skel->bss->change_dequeue_cnt;
+
+ link = bpf_map__attach_struct_ops(skel->maps.dequeue_ops);
+ SCX_FAIL_IF(!link, "Failed to attach struct_ops for scenario %s", scenario_name);
+
+ /* Fork worker processes to generate enqueue/dequeue events */
+ for (i = 0; i < NUM_WORKERS; i++) {
+ pids[i] = fork();
+ SCX_FAIL_IF(pids[i] < 0, "Failed to fork worker %d", i);
+
+ if (pids[i] == 0) {
+ worker_fn(i);
+ /* Should not reach here */
+ exit(1);
+ }
+ }
+
+ /* Wait for all workers to complete */
+ for (i = 0; i < NUM_WORKERS; i++) {
+ SCX_FAIL_IF(waitpid(pids[i], &status, 0) != pids[i],
+ "Failed to wait for worker %d", i);
+ SCX_FAIL_IF(status != 0, "Worker %d exited with status %d", i, status);
+ }
+
+ bpf_link__destroy(link);
+
+ SCX_EQ(skel->data->uei.kind, EXIT_KIND(SCX_EXIT_UNREG));
+
+ /* Calculate deltas */
+ enq_delta = skel->bss->enqueue_cnt - enq_start;
+ deq_delta = skel->bss->dequeue_cnt - deq_start;
+ dispatch_deq_delta = skel->bss->dispatch_dequeue_cnt - dispatch_deq_start;
+ change_deq_delta = skel->bss->change_dequeue_cnt - change_deq_start;
+
+ printf("%s:\n", scenario_name);
+ printf(" enqueues: %lu\n", (unsigned long)enq_delta);
+ printf(" dequeues: %lu (dispatch: %lu, property_change: %lu)\n",
+ (unsigned long)deq_delta,
+ (unsigned long)dispatch_deq_delta,
+ (unsigned long)change_deq_delta);
+
+ /*
+ * Validate enqueue/dequeue lifecycle tracking.
+ *
+ * For scenarios 0 and 1 (local and global DSQs), both enqueues and
+ * dequeues should be 0 because tasks bypass the BPF scheduler
+ * entirely: tasks never enter BPF scheduler's custody.
+ *
+ * For scenario 2 (user DSQ), we expect both enqueues and dequeues.
+ *
+ * The BPF code does strict state machine validation with
+ * scx_bpf_error() to ensure the workflow semantics are correct. If
+ * we reach this point without errors, the semantics are validated
+ * correctly.
+ */
+ if (scenario == 0 || scenario == 1) {
+ /* Terminal DSQs: tasks bypass BPF scheduler completely */
+ SCX_EQ(enq_delta, 0);
+ SCX_EQ(deq_delta, 0);
+ SCX_EQ(dispatch_deq_delta, 0);
+ SCX_EQ(change_deq_delta, 0);
+ } else {
+ /* User DSQ: tasks enter BPF scheduler's custody */
+ SCX_GT(enq_delta, 0);
+ SCX_GT(deq_delta, 0);
+ /* Validate 1:1 enqueue/dequeue pairing */
+ SCX_EQ(enq_delta, deq_delta);
+ }
+
+ return SCX_TEST_PASS;
+}
+
+static enum scx_test_status setup(void **ctx)
+{
+ struct dequeue *skel;
+
+ skel = dequeue__open();
+ SCX_FAIL_IF(!skel, "Failed to open skel");
+ SCX_ENUM_INIT(skel);
+ SCX_FAIL_IF(dequeue__load(skel), "Failed to load skel");
+
+ *ctx = skel;
+
+ return SCX_TEST_PASS;
+}
+
+static enum scx_test_status run(void *ctx)
+{
+ struct dequeue *skel = ctx;
+ enum scx_test_status status;
+
+ status = run_scenario(skel, 0, "Local DSQ (terminal, direct dispatch)");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 1, "Global DSQ (terminal, SCX_DSQ_GLOBAL)");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 2, "User DSQ (non-terminal, BPF custody)");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ printf("\n=== Summary ===\n");
+ printf("Total enqueues: %lu\n", (unsigned long)skel->bss->enqueue_cnt);
+ printf("Total dequeues: %lu\n", (unsigned long)skel->bss->dequeue_cnt);
+ printf(" Dispatch dequeues: %lu (no flag, normal workflow)\n",
+ (unsigned long)skel->bss->dispatch_dequeue_cnt);
+ printf(" Property change dequeues: %lu (SCX_DEQ_SCHED_CHANGE flag)\n",
+ (unsigned long)skel->bss->change_dequeue_cnt);
+ printf("\nAll scenarios passed - no state machine violations detected\n");
+ printf("-> Validated: Local DSQ dispatch bypasses BPF scheduler\n");
+ printf("-> Validated: Global DSQ dispatch bypasses BPF scheduler\n");
+ printf("-> Validated: User DSQ dispatch triggers dequeue callbacks\n");
+ printf("-> Validated: Dispatch dequeues have no flags (normal workflow)\n");
+ printf("-> Validated: Property change dequeues have SCX_DEQ_SCHED_CHANGE flag\n");
+ printf("-> Validated: No duplicate enqueues or invalid state transitions\n");
+
+ return SCX_TEST_PASS;
+}
+
+static void cleanup(void *ctx)
+{
+ struct dequeue *skel = ctx;
+
+ dequeue__destroy(skel);
+}
+
+struct scx_test dequeue_test = {
+ .name = "dequeue",
+ .description = "Verify ops.dequeue() semantics",
+ .setup = setup,
+ .run = run,
+ .cleanup = cleanup,
+};
+
+REGISTER_SCX_TEST(&dequeue_test)
--
2.53.0
^ permalink raw reply [flat|nested] 41+ messages in thread
* Re: [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-01-26 8:41 ` [PATCH 2/2] selftests/sched_ext: Add test to validate " Andrea Righi
@ 2026-01-27 16:53 ` Emil Tsalapatis
0 siblings, 0 replies; 41+ messages in thread
From: Emil Tsalapatis @ 2026-01-27 16:53 UTC (permalink / raw)
To: Andrea Righi, Tejun Heo, David Vernet, Changwoo Min
Cc: Kuba Piecuch, Christian Loehle, Daniel Hodges, sched-ext, linux-kernel
On Mon Jan 26, 2026 at 3:41 AM EST, Andrea Righi wrote:
> Add a new kselftest to validate that:
> - every enqueue is followed by proper dequeue,
> - dispatch dequeues happen exactly once per enqueue,
> - property change dequeues (%SCX_DEQ_SCHED_CHANGE) only happen for
> property changes,
> - no duplicate enqueues without proper dequeue.
>
> Test scenarios:
> - direct dispatch to local DSQ (%SCX_DSQ_LOCAL),
> - dispatch to user DSQ,
> - explicit property changes via sched_setaffinity().
>
> This validates that the new ops.dequeue() semantics work correctly for
> all task lifecycle scenarios.
>
> Cc: Tejun Heo <tj@kernel.org>
> Cc: Emil Tsalapatis <emil@etsalapatis.com>
> Cc: Kuba Piecuch <jpiecuch@google.com>
> Signed-off-by: Andrea Righi <arighi@nvidia.com>
Reviewed-by: Emil Tsalapatis <emil@etsalapatis.com>
> ---
> tools/testing/selftests/sched_ext/Makefile | 1 +
> .../testing/selftests/sched_ext/dequeue.bpf.c | 209 ++++++++++++++++++
> tools/testing/selftests/sched_ext/dequeue.c | 182 +++++++++++++++
> 3 files changed, 392 insertions(+)
> create mode 100644 tools/testing/selftests/sched_ext/dequeue.bpf.c
> create mode 100644 tools/testing/selftests/sched_ext/dequeue.c
>
> diff --git a/tools/testing/selftests/sched_ext/Makefile b/tools/testing/selftests/sched_ext/Makefile
> index 5fe45f9c5f8fd..764e91edabf93 100644
> --- a/tools/testing/selftests/sched_ext/Makefile
> +++ b/tools/testing/selftests/sched_ext/Makefile
> @@ -161,6 +161,7 @@ all_test_bpfprogs := $(foreach prog,$(wildcard *.bpf.c),$(INCLUDE_DIR)/$(patsubs
>
> auto-test-targets := \
> create_dsq \
> + dequeue \
> enq_last_no_enq_fails \
> ddsp_bogus_dsq_fail \
> ddsp_vtimelocal_fail \
> diff --git a/tools/testing/selftests/sched_ext/dequeue.bpf.c b/tools/testing/selftests/sched_ext/dequeue.bpf.c
> new file mode 100644
> index 0000000000000..8b2f792cf7d8b
> --- /dev/null
> +++ b/tools/testing/selftests/sched_ext/dequeue.bpf.c
> @@ -0,0 +1,209 @@
> +// SPDX-License-Identifier: GPL-2.0
> +/*
> + * A scheduler that validates ops.dequeue() is called correctly:
> + * - For tasks on BPF data structures (not yet dispatched)
> + * - For tasks already on DSQs (local or shared)
> + * - That every ops.enqueue() is followed by ops.dequeue()
> + *
> + * Copyright (c) 2025 NVIDIA Corporation.
> + */
> +
> +#include <scx/common.bpf.h>
> +
> +#define SHARED_DSQ 0
> +
> +char _license[] SEC("license") = "GPL";
> +
> +UEI_DEFINE(uei);
> +
> +/*
> + * Counters to track the lifecycle of tasks:
> + * - enqueue_cnt: Number of times ops.enqueue() was called
> + * - dequeue_cnt: Number of times ops.dequeue() was called (any type)
> + * - dispatch_dequeue_cnt: Number of regular dispatch dequeues (no flag)
> + * - change_dequeue_cnt: Number of property change dequeues (%SCX_DEQ_SCHED_CHANGE)
> + */
> +u64 enqueue_cnt, dequeue_cnt, dispatch_dequeue_cnt, change_dequeue_cnt;
> +
> +/*
> + * Test scenarios:
> + * - 0: Dispatch to local DSQ
> + * - 1: Dispatch to shared DSQ
> + */
> +u32 test_scenario;
> +
> +/*
> + * Per-task state to track lifecycle and validate workflow semantics.
> + * State transitions:
> + * NONE -> ENQUEUED (on enqueue)
> + * ENQUEUED -> DISPATCHED (on dispatch dequeue)
> + * DISPATCHED -> NONE (on property change dequeue or re-enqueue)
> + * ENQUEUED -> NONE (on property change dequeue before dispatch)
> + */
> +enum task_state {
> + TASK_NONE = 0, /* Task is outside scheduler control */
> + TASK_ENQUEUED, /* ops.enqueue() called, waiting for dequeue */
> + TASK_DISPATCHED, /* Dispatch dequeue received, can get property change or re-enqueue */
> +};
> +
> +struct task_ctx {
> + enum task_state state; /* Current state in the workflow */
> + u64 enqueue_seq; /* Sequence number for debugging */
> +};
> +
> +struct {
> + __uint(type, BPF_MAP_TYPE_TASK_STORAGE);
> + __uint(map_flags, BPF_F_NO_PREALLOC);
> + __type(key, int);
> + __type(value, struct task_ctx);
> +} task_ctx_stor SEC(".maps");
> +
> +static struct task_ctx *try_lookup_task_ctx(struct task_struct *p)
> +{
> + return bpf_task_storage_get(&task_ctx_stor, p, 0, 0);
> +}
> +
> +s32 BPF_STRUCT_OPS(dequeue_select_cpu, struct task_struct *p,
> + s32 prev_cpu, u64 wake_flags)
> +{
> + /* Always bounce to ops.enqueue() */
> + return prev_cpu;
> +}
> +
> +void BPF_STRUCT_OPS(dequeue_enqueue, struct task_struct *p, u64 enq_flags)
> +{
> + struct task_ctx *tctx;
> +
> + __sync_fetch_and_add(&enqueue_cnt, 1);
> +
> + tctx = try_lookup_task_ctx(p);
> + if (!tctx)
> + return;
> +
> + /*
> + * Validate state transition: enqueue is only valid from NONE or
> + * DISPATCHED states. Getting enqueue while in ENQUEUED state
> + * indicates a missing dequeue.
> + */
> + if (tctx->state == TASK_ENQUEUED)
> + scx_bpf_error("%d (%s): enqueue while in ENQUEUED state (seq %llu)",
> + p->pid, p->comm, tctx->enqueue_seq);
> +
> + /* Transition to ENQUEUED state */
> + tctx->state = TASK_ENQUEUED;
> + tctx->enqueue_seq++;
> +
> + switch (test_scenario) {
> + case 0:
> + /* Scenario 0: Direct dispatch to the local DSQ */
> + scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, enq_flags);
> + break;
> +
> + case 1:
> + /* Scenario 1: Dispatch to shared DSQ */
> + scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, enq_flags);
> + break;
> + }
> +}
> +
> +void BPF_STRUCT_OPS(dequeue_dequeue, struct task_struct *p, u64 deq_flags)
> +{
> + struct task_ctx *tctx;
> +
> + __sync_fetch_and_add(&dequeue_cnt, 1);
> +
> + tctx = try_lookup_task_ctx(p);
> + if (!tctx)
> + return;
> +
> + /*
> + * Validate state: dequeue should only happen from ENQUEUED or
> + * DISPATCHED states. Getting dequeue from NONE indicates a bug.
> + */
> + if (tctx->state == TASK_NONE)
> + scx_bpf_error("%d (%s): dequeue from NONE state (seq %llu)",
> + p->pid, p->comm, tctx->enqueue_seq);
> +
> + if (deq_flags & SCX_DEQ_SCHED_CHANGE) {
> + /*
> + * Async dequeue: property change interrupting the workflow.
> + * Valid from both ENQUEUED and DISPATCHED states.
> + * Transitions task back to NONE state.
> + */
> + __sync_fetch_and_add(&change_dequeue_cnt, 1);
> +
> + /* Validate state transition */
> + if (tctx->state != TASK_ENQUEUED && tctx->state != TASK_DISPATCHED)
> + scx_bpf_error("%d (%s): property change dequeue from invalid state %d (seq %llu)",
> + p->pid, p->comm, tctx->state, tctx->enqueue_seq);
> +
> + /* Transition back to NONE - task outside scheduler control */
> + tctx->state = TASK_NONE;
> + } else {
> + /*
> + * Regular dispatch dequeue: normal workflow step.
> + * Valid only from ENQUEUED state (after enqueue, before dispatch dequeue).
> + * Transitions to DISPATCHED state.
> + */
> + __sync_fetch_and_add(&dispatch_dequeue_cnt, 1);
> +
> + /* Validate: dispatch dequeue should NOT have %SCX_DEQ_SCHED_CHANGE flag */
> + if (deq_flags & SCX_DEQ_SCHED_CHANGE)
> + scx_bpf_error("%d (%s): SCX_DEQ_SCHED_CHANGE in dispatch dequeue (seq %llu)",
> + p->pid, p->comm, tctx->enqueue_seq);
> +
> + /* Must be in ENQUEUED state */
> + if (tctx->state != TASK_ENQUEUED)
> + scx_bpf_error("%d (%s): dispatch dequeue from state %d (seq %llu)",
> + p->pid, p->comm, tctx->state, tctx->enqueue_seq);
> +
> + /* Transition to DISPATCHED - normal cycle completed dispatch */
> + tctx->state = TASK_DISPATCHED;
> + }
> +}
> +
> +void BPF_STRUCT_OPS(dequeue_dispatch, s32 cpu, struct task_struct *prev)
> +{
> + scx_bpf_dsq_move_to_local(SHARED_DSQ);
> +}
> +
> +s32 BPF_STRUCT_OPS(dequeue_init_task, struct task_struct *p,
> + struct scx_init_task_args *args)
> +{
> + struct task_ctx *tctx;
> +
> + tctx = bpf_task_storage_get(&task_ctx_stor, p, 0,
> + BPF_LOCAL_STORAGE_GET_F_CREATE);
> + if (!tctx)
> + return -ENOMEM;
> +
> + return 0;
> +}
> +
> +s32 BPF_STRUCT_OPS_SLEEPABLE(dequeue_init)
> +{
> + s32 ret;
> +
> + ret = scx_bpf_create_dsq(SHARED_DSQ, -1);
> + if (ret)
> + return ret;
> +
> + return 0;
> +}
> +
> +void BPF_STRUCT_OPS(dequeue_exit, struct scx_exit_info *ei)
> +{
> + UEI_RECORD(uei, ei);
> +}
> +
> +SEC(".struct_ops.link")
> +struct sched_ext_ops dequeue_ops = {
> + .select_cpu = (void *)dequeue_select_cpu,
> + .enqueue = (void *)dequeue_enqueue,
> + .dequeue = (void *)dequeue_dequeue,
> + .dispatch = (void *)dequeue_dispatch,
> + .init_task = (void *)dequeue_init_task,
> + .init = (void *)dequeue_init,
> + .exit = (void *)dequeue_exit,
> + .name = "dequeue_test",
> +};
> diff --git a/tools/testing/selftests/sched_ext/dequeue.c b/tools/testing/selftests/sched_ext/dequeue.c
> new file mode 100644
> index 0000000000000..6861257d79b47
> --- /dev/null
> +++ b/tools/testing/selftests/sched_ext/dequeue.c
> @@ -0,0 +1,182 @@
> +// SPDX-License-Identifier: GPL-2.0
> +/*
> + * Copyright (c) 2025 NVIDIA Corporation.
> + */
> +#define _GNU_SOURCE
> +#include <stdio.h>
> +#include <unistd.h>
> +#include <signal.h>
> +#include <bpf/bpf.h>
> +#include <scx/common.h>
> +#include <sys/wait.h>
> +#include <sched.h>
> +#include <pthread.h>
> +#include "scx_test.h"
> +#include "dequeue.bpf.skel.h"
> +
> +#define NUM_WORKERS 8
> +
> +/*
> + * Worker function that creates enqueue/dequeue events. It alternates
> + * between CPU work, sleeping, and affinity changes to trigger dequeues.
> + */
> +static void worker_fn(int id)
> +{
> + cpu_set_t cpuset;
> + int i;
> + volatile int sum = 0;
> +
> + for (i = 0; i < 1000; i++) {
> + int j;
> +
> + /* Do some work to trigger scheduling events */
> + for (j = 0; j < 10000; j++)
> + sum += j;
> +
> + /* Change affinity to trigger dequeue */
> + if (i % 10 == 0) {
> + CPU_ZERO(&cpuset);
> + /* Rotate through the first 4 CPUs */
> + CPU_SET(i % 4, &cpuset);
> + sched_setaffinity(0, sizeof(cpuset), &cpuset);
> + }
> +
> + /* Do additional work */
> + for (j = 0; j < 10000; j++)
> + sum += j;
> +
> + /* Sleep to trigger dequeue */
> + usleep(1000 + (id * 100));
> + }
> +
> + exit(0);
> +}
> +
> +static enum scx_test_status run_scenario(struct dequeue *skel, u32 scenario,
> + const char *scenario_name)
> +{
> + struct bpf_link *link;
> + pid_t pids[NUM_WORKERS];
> + int i, status;
> + u64 enq_start, deq_start, dispatch_deq_start, change_deq_start;
> + u64 enq_delta, deq_delta, dispatch_deq_delta, change_deq_delta;
> +
> + /* Set the test scenario */
> + skel->bss->test_scenario = scenario;
> +
> + /* Record starting counts */
> + enq_start = skel->bss->enqueue_cnt;
> + deq_start = skel->bss->dequeue_cnt;
> + dispatch_deq_start = skel->bss->dispatch_dequeue_cnt;
> + change_deq_start = skel->bss->change_dequeue_cnt;
> +
> + link = bpf_map__attach_struct_ops(skel->maps.dequeue_ops);
> + SCX_FAIL_IF(!link, "Failed to attach struct_ops for scenario %s", scenario_name);
> +
> + /* Fork worker processes to generate enqueue/dequeue events */
> + for (i = 0; i < NUM_WORKERS; i++) {
> + pids[i] = fork();
> + SCX_FAIL_IF(pids[i] < 0, "Failed to fork worker %d", i);
> +
> + if (pids[i] == 0) {
> + worker_fn(i);
> + /* Should not reach here */
> + exit(1);
> + }
> + }
> +
> + /* Wait for all workers to complete */
> + for (i = 0; i < NUM_WORKERS; i++) {
> + SCX_FAIL_IF(waitpid(pids[i], &status, 0) != pids[i],
> + "Failed to wait for worker %d", i);
> + SCX_FAIL_IF(status != 0, "Worker %d exited with status %d", i, status);
> + }
> +
> + bpf_link__destroy(link);
> +
> + SCX_EQ(skel->data->uei.kind, EXIT_KIND(SCX_EXIT_UNREG));
> +
> + /* Calculate deltas */
> + enq_delta = skel->bss->enqueue_cnt - enq_start;
> + deq_delta = skel->bss->dequeue_cnt - deq_start;
> + dispatch_deq_delta = skel->bss->dispatch_dequeue_cnt - dispatch_deq_start;
> + change_deq_delta = skel->bss->change_dequeue_cnt - change_deq_start;
> +
> + printf("%s:\n", scenario_name);
> + printf(" enqueues: %lu\n", (unsigned long)enq_delta);
> + printf(" dequeues: %lu (dispatch: %lu, property_change: %lu)\n",
> + (unsigned long)deq_delta,
> + (unsigned long)dispatch_deq_delta,
> + (unsigned long)change_deq_delta);
> +
> + /*
> + * Validate that we got enqueue and dequeue events.
> + * The BPF code does strict state machine validation with scx_bpf_error()
> + * to ensure the workflow semantics are correct. If we reach here without
> + * errors, the semantics are validated correctly.
> + */
> + SCX_GT(enq_delta, 0);
> + SCX_GT(deq_delta, 0);
> +
> + return SCX_TEST_PASS;
> +}
> +
> +static enum scx_test_status setup(void **ctx)
> +{
> + struct dequeue *skel;
> +
> + skel = dequeue__open();
> + SCX_FAIL_IF(!skel, "Failed to open skel");
> + SCX_ENUM_INIT(skel);
> + SCX_FAIL_IF(dequeue__load(skel), "Failed to load skel");
> +
> + *ctx = skel;
> +
> + return SCX_TEST_PASS;
> +}
> +
> +static enum scx_test_status run(void *ctx)
> +{
> + struct dequeue *skel = ctx;
> + enum scx_test_status status;
> +
> + status = run_scenario(skel, 0, "Local DSQ");
> + if (status != SCX_TEST_PASS)
> + return status;
> +
> + status = run_scenario(skel, 1, "User DSQ");
> + if (status != SCX_TEST_PASS)
> + return status;
> +
> + printf("\n=== Summary ===\n");
> + printf("Total enqueues: %lu\n", (unsigned long)skel->bss->enqueue_cnt);
> + printf("Total dequeues: %lu\n", (unsigned long)skel->bss->dequeue_cnt);
> + printf(" Dispatch dequeues: %lu (no flag, normal workflow)\n",
> + (unsigned long)skel->bss->dispatch_dequeue_cnt);
> + printf(" Property change dequeues: %lu (SCX_DEQ_SCHED_CHANGE flag)\n",
> + (unsigned long)skel->bss->change_dequeue_cnt);
> + printf("\nAll scenarios passed - no state machine violations detected\n");
> + printf("-> Validated: Correct state transitions (NONE -> ENQUEUED -> DISPATCHED)\n");
> + printf("-> Validated: Dispatch dequeues have no flags (normal workflow)\n");
> + printf("-> Validated: Async dequeues have SCX_DEQ_SCHED_CHANGE flag (interruptions)\n");
> + printf("-> Validated: No duplicate enqueues or invalid state transitions\n");
> +
> + return SCX_TEST_PASS;
> +}
> +
> +static void cleanup(void *ctx)
> +{
> + struct dequeue *skel = ctx;
> +
> + dequeue__destroy(skel);
> +}
> +
> +struct scx_test dequeue_test = {
> + .name = "dequeue",
> + .description = "Verify that ops.enqueue() is balanced with ops.dequeue()",
> + .setup = setup,
> + .run = run,
> + .cleanup = cleanup,
> +};
> +
> +REGISTER_SCX_TEST(&dequeue_test)
^ permalink raw reply [flat|nested] 41+ messages in thread
* [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-01-26 8:41 [PATCHSET v3 sched_ext/for-6.20] sched_ext: Fix " Andrea Righi
@ 2026-01-26 8:41 ` Andrea Righi
2026-01-27 16:53 ` Emil Tsalapatis
0 siblings, 1 reply; 41+ messages in thread
From: Andrea Righi @ 2026-01-26 8:41 UTC (permalink / raw)
To: Tejun Heo, David Vernet, Changwoo Min
Cc: Kuba Piecuch, Christian Loehle, Daniel Hodges, sched-ext,
linux-kernel, Emil Tsalapatis
Add a new kselftest to validate that:
- every enqueue is followed by proper dequeue,
- dispatch dequeues happen exactly once per enqueue,
- property change dequeues (%SCX_DEQ_SCHED_CHANGE) only happen for
property changes,
- no duplicate enqueues without proper dequeue.
Test scenarios:
- direct dispatch to local DSQ (%SCX_DSQ_LOCAL),
- dispatch to user DSQ,
- explicit property changes via sched_setaffinity().
This validates that the new ops.dequeue() semantics work correctly for
all task lifecycle scenarios.
Cc: Tejun Heo <tj@kernel.org>
Cc: Emil Tsalapatis <emil@etsalapatis.com>
Cc: Kuba Piecuch <jpiecuch@google.com>
Signed-off-by: Andrea Righi <arighi@nvidia.com>
---
tools/testing/selftests/sched_ext/Makefile | 1 +
.../testing/selftests/sched_ext/dequeue.bpf.c | 209 ++++++++++++++++++
tools/testing/selftests/sched_ext/dequeue.c | 182 +++++++++++++++
3 files changed, 392 insertions(+)
create mode 100644 tools/testing/selftests/sched_ext/dequeue.bpf.c
create mode 100644 tools/testing/selftests/sched_ext/dequeue.c
diff --git a/tools/testing/selftests/sched_ext/Makefile b/tools/testing/selftests/sched_ext/Makefile
index 5fe45f9c5f8fd..764e91edabf93 100644
--- a/tools/testing/selftests/sched_ext/Makefile
+++ b/tools/testing/selftests/sched_ext/Makefile
@@ -161,6 +161,7 @@ all_test_bpfprogs := $(foreach prog,$(wildcard *.bpf.c),$(INCLUDE_DIR)/$(patsubs
auto-test-targets := \
create_dsq \
+ dequeue \
enq_last_no_enq_fails \
ddsp_bogus_dsq_fail \
ddsp_vtimelocal_fail \
diff --git a/tools/testing/selftests/sched_ext/dequeue.bpf.c b/tools/testing/selftests/sched_ext/dequeue.bpf.c
new file mode 100644
index 0000000000000..8b2f792cf7d8b
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/dequeue.bpf.c
@@ -0,0 +1,209 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * A scheduler that validates ops.dequeue() is called correctly:
+ * - For tasks on BPF data structures (not yet dispatched)
+ * - For tasks already on DSQs (local or shared)
+ * - That every ops.enqueue() is followed by ops.dequeue()
+ *
+ * Copyright (c) 2025 NVIDIA Corporation.
+ */
+
+#include <scx/common.bpf.h>
+
+#define SHARED_DSQ 0
+
+char _license[] SEC("license") = "GPL";
+
+UEI_DEFINE(uei);
+
+/*
+ * Counters to track the lifecycle of tasks:
+ * - enqueue_cnt: Number of times ops.enqueue() was called
+ * - dequeue_cnt: Number of times ops.dequeue() was called (any type)
+ * - dispatch_dequeue_cnt: Number of regular dispatch dequeues (no flag)
+ * - change_dequeue_cnt: Number of property change dequeues (%SCX_DEQ_SCHED_CHANGE)
+ */
+u64 enqueue_cnt, dequeue_cnt, dispatch_dequeue_cnt, change_dequeue_cnt;
+
+/*
+ * Test scenarios:
+ * - 0: Dispatch to local DSQ
+ * - 1: Dispatch to shared DSQ
+ */
+u32 test_scenario;
+
+/*
+ * Per-task state to track lifecycle and validate workflow semantics.
+ * State transitions:
+ * NONE -> ENQUEUED (on enqueue)
+ * ENQUEUED -> DISPATCHED (on dispatch dequeue)
+ * DISPATCHED -> NONE (on property change dequeue or re-enqueue)
+ * ENQUEUED -> NONE (on property change dequeue before dispatch)
+ */
+enum task_state {
+ TASK_NONE = 0, /* Task is outside scheduler control */
+ TASK_ENQUEUED, /* ops.enqueue() called, waiting for dequeue */
+ TASK_DISPATCHED, /* Dispatch dequeue received, can get property change or re-enqueue */
+};
+
+struct task_ctx {
+ enum task_state state; /* Current state in the workflow */
+ u64 enqueue_seq; /* Sequence number for debugging */
+};
+
+struct {
+ __uint(type, BPF_MAP_TYPE_TASK_STORAGE);
+ __uint(map_flags, BPF_F_NO_PREALLOC);
+ __type(key, int);
+ __type(value, struct task_ctx);
+} task_ctx_stor SEC(".maps");
+
+static struct task_ctx *try_lookup_task_ctx(struct task_struct *p)
+{
+ return bpf_task_storage_get(&task_ctx_stor, p, 0, 0);
+}
+
+s32 BPF_STRUCT_OPS(dequeue_select_cpu, struct task_struct *p,
+ s32 prev_cpu, u64 wake_flags)
+{
+ /* Always bounce to ops.enqueue() */
+ return prev_cpu;
+}
+
+void BPF_STRUCT_OPS(dequeue_enqueue, struct task_struct *p, u64 enq_flags)
+{
+ struct task_ctx *tctx;
+
+ __sync_fetch_and_add(&enqueue_cnt, 1);
+
+ tctx = try_lookup_task_ctx(p);
+ if (!tctx)
+ return;
+
+ /*
+ * Validate state transition: enqueue is only valid from NONE or
+ * DISPATCHED states. Getting enqueue while in ENQUEUED state
+ * indicates a missing dequeue.
+ */
+ if (tctx->state == TASK_ENQUEUED)
+ scx_bpf_error("%d (%s): enqueue while in ENQUEUED state (seq %llu)",
+ p->pid, p->comm, tctx->enqueue_seq);
+
+ /* Transition to ENQUEUED state */
+ tctx->state = TASK_ENQUEUED;
+ tctx->enqueue_seq++;
+
+ switch (test_scenario) {
+ case 0:
+ /* Scenario 0: Direct dispatch to the local DSQ */
+ scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, enq_flags);
+ break;
+
+ case 1:
+ /* Scenario 1: Dispatch to shared DSQ */
+ scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, enq_flags);
+ break;
+ }
+}
+
+void BPF_STRUCT_OPS(dequeue_dequeue, struct task_struct *p, u64 deq_flags)
+{
+ struct task_ctx *tctx;
+
+ __sync_fetch_and_add(&dequeue_cnt, 1);
+
+ tctx = try_lookup_task_ctx(p);
+ if (!tctx)
+ return;
+
+ /*
+ * Validate state: dequeue should only happen from ENQUEUED or
+ * DISPATCHED states. Getting dequeue from NONE indicates a bug.
+ */
+ if (tctx->state == TASK_NONE)
+ scx_bpf_error("%d (%s): dequeue from NONE state (seq %llu)",
+ p->pid, p->comm, tctx->enqueue_seq);
+
+ if (deq_flags & SCX_DEQ_SCHED_CHANGE) {
+ /*
+ * Async dequeue: property change interrupting the workflow.
+ * Valid from both ENQUEUED and DISPATCHED states.
+ * Transitions task back to NONE state.
+ */
+ __sync_fetch_and_add(&change_dequeue_cnt, 1);
+
+ /* Validate state transition */
+ if (tctx->state != TASK_ENQUEUED && tctx->state != TASK_DISPATCHED)
+ scx_bpf_error("%d (%s): property change dequeue from invalid state %d (seq %llu)",
+ p->pid, p->comm, tctx->state, tctx->enqueue_seq);
+
+ /* Transition back to NONE - task outside scheduler control */
+ tctx->state = TASK_NONE;
+ } else {
+ /*
+ * Regular dispatch dequeue: normal workflow step.
+ * Valid only from ENQUEUED state (after enqueue, before dispatch dequeue).
+ * Transitions to DISPATCHED state.
+ */
+ __sync_fetch_and_add(&dispatch_dequeue_cnt, 1);
+
+ /* Validate: dispatch dequeue should NOT have %SCX_DEQ_SCHED_CHANGE flag */
+ if (deq_flags & SCX_DEQ_SCHED_CHANGE)
+ scx_bpf_error("%d (%s): SCX_DEQ_SCHED_CHANGE in dispatch dequeue (seq %llu)",
+ p->pid, p->comm, tctx->enqueue_seq);
+
+ /* Must be in ENQUEUED state */
+ if (tctx->state != TASK_ENQUEUED)
+ scx_bpf_error("%d (%s): dispatch dequeue from state %d (seq %llu)",
+ p->pid, p->comm, tctx->state, tctx->enqueue_seq);
+
+ /* Transition to DISPATCHED - normal cycle completed dispatch */
+ tctx->state = TASK_DISPATCHED;
+ }
+}
+
+void BPF_STRUCT_OPS(dequeue_dispatch, s32 cpu, struct task_struct *prev)
+{
+ scx_bpf_dsq_move_to_local(SHARED_DSQ);
+}
+
+s32 BPF_STRUCT_OPS(dequeue_init_task, struct task_struct *p,
+ struct scx_init_task_args *args)
+{
+ struct task_ctx *tctx;
+
+ tctx = bpf_task_storage_get(&task_ctx_stor, p, 0,
+ BPF_LOCAL_STORAGE_GET_F_CREATE);
+ if (!tctx)
+ return -ENOMEM;
+
+ return 0;
+}
+
+s32 BPF_STRUCT_OPS_SLEEPABLE(dequeue_init)
+{
+ s32 ret;
+
+ ret = scx_bpf_create_dsq(SHARED_DSQ, -1);
+ if (ret)
+ return ret;
+
+ return 0;
+}
+
+void BPF_STRUCT_OPS(dequeue_exit, struct scx_exit_info *ei)
+{
+ UEI_RECORD(uei, ei);
+}
+
+SEC(".struct_ops.link")
+struct sched_ext_ops dequeue_ops = {
+ .select_cpu = (void *)dequeue_select_cpu,
+ .enqueue = (void *)dequeue_enqueue,
+ .dequeue = (void *)dequeue_dequeue,
+ .dispatch = (void *)dequeue_dispatch,
+ .init_task = (void *)dequeue_init_task,
+ .init = (void *)dequeue_init,
+ .exit = (void *)dequeue_exit,
+ .name = "dequeue_test",
+};
diff --git a/tools/testing/selftests/sched_ext/dequeue.c b/tools/testing/selftests/sched_ext/dequeue.c
new file mode 100644
index 0000000000000..6861257d79b47
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/dequeue.c
@@ -0,0 +1,182 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Copyright (c) 2025 NVIDIA Corporation.
+ */
+#define _GNU_SOURCE
+#include <stdio.h>
+#include <unistd.h>
+#include <signal.h>
+#include <bpf/bpf.h>
+#include <scx/common.h>
+#include <sys/wait.h>
+#include <sched.h>
+#include <pthread.h>
+#include "scx_test.h"
+#include "dequeue.bpf.skel.h"
+
+#define NUM_WORKERS 8
+
+/*
+ * Worker function that creates enqueue/dequeue events. It alternates
+ * between CPU work, sleeping, and affinity changes to trigger dequeues.
+ */
+static void worker_fn(int id)
+{
+ cpu_set_t cpuset;
+ int i;
+ volatile int sum = 0;
+
+ for (i = 0; i < 1000; i++) {
+ int j;
+
+ /* Do some work to trigger scheduling events */
+ for (j = 0; j < 10000; j++)
+ sum += j;
+
+ /* Change affinity to trigger dequeue */
+ if (i % 10 == 0) {
+ CPU_ZERO(&cpuset);
+ /* Rotate through the first 4 CPUs */
+ CPU_SET(i % 4, &cpuset);
+ sched_setaffinity(0, sizeof(cpuset), &cpuset);
+ }
+
+ /* Do additional work */
+ for (j = 0; j < 10000; j++)
+ sum += j;
+
+ /* Sleep to trigger dequeue */
+ usleep(1000 + (id * 100));
+ }
+
+ exit(0);
+}
+
+static enum scx_test_status run_scenario(struct dequeue *skel, u32 scenario,
+ const char *scenario_name)
+{
+ struct bpf_link *link;
+ pid_t pids[NUM_WORKERS];
+ int i, status;
+ u64 enq_start, deq_start, dispatch_deq_start, change_deq_start;
+ u64 enq_delta, deq_delta, dispatch_deq_delta, change_deq_delta;
+
+ /* Set the test scenario */
+ skel->bss->test_scenario = scenario;
+
+ /* Record starting counts */
+ enq_start = skel->bss->enqueue_cnt;
+ deq_start = skel->bss->dequeue_cnt;
+ dispatch_deq_start = skel->bss->dispatch_dequeue_cnt;
+ change_deq_start = skel->bss->change_dequeue_cnt;
+
+ link = bpf_map__attach_struct_ops(skel->maps.dequeue_ops);
+ SCX_FAIL_IF(!link, "Failed to attach struct_ops for scenario %s", scenario_name);
+
+ /* Fork worker processes to generate enqueue/dequeue events */
+ for (i = 0; i < NUM_WORKERS; i++) {
+ pids[i] = fork();
+ SCX_FAIL_IF(pids[i] < 0, "Failed to fork worker %d", i);
+
+ if (pids[i] == 0) {
+ worker_fn(i);
+ /* Should not reach here */
+ exit(1);
+ }
+ }
+
+ /* Wait for all workers to complete */
+ for (i = 0; i < NUM_WORKERS; i++) {
+ SCX_FAIL_IF(waitpid(pids[i], &status, 0) != pids[i],
+ "Failed to wait for worker %d", i);
+ SCX_FAIL_IF(status != 0, "Worker %d exited with status %d", i, status);
+ }
+
+ bpf_link__destroy(link);
+
+ SCX_EQ(skel->data->uei.kind, EXIT_KIND(SCX_EXIT_UNREG));
+
+ /* Calculate deltas */
+ enq_delta = skel->bss->enqueue_cnt - enq_start;
+ deq_delta = skel->bss->dequeue_cnt - deq_start;
+ dispatch_deq_delta = skel->bss->dispatch_dequeue_cnt - dispatch_deq_start;
+ change_deq_delta = skel->bss->change_dequeue_cnt - change_deq_start;
+
+ printf("%s:\n", scenario_name);
+ printf(" enqueues: %lu\n", (unsigned long)enq_delta);
+ printf(" dequeues: %lu (dispatch: %lu, property_change: %lu)\n",
+ (unsigned long)deq_delta,
+ (unsigned long)dispatch_deq_delta,
+ (unsigned long)change_deq_delta);
+
+ /*
+ * Validate that we got enqueue and dequeue events.
+ * The BPF code does strict state machine validation with scx_bpf_error()
+ * to ensure the workflow semantics are correct. If we reach here without
+ * errors, the semantics are validated correctly.
+ */
+ SCX_GT(enq_delta, 0);
+ SCX_GT(deq_delta, 0);
+
+ return SCX_TEST_PASS;
+}
+
+static enum scx_test_status setup(void **ctx)
+{
+ struct dequeue *skel;
+
+ skel = dequeue__open();
+ SCX_FAIL_IF(!skel, "Failed to open skel");
+ SCX_ENUM_INIT(skel);
+ SCX_FAIL_IF(dequeue__load(skel), "Failed to load skel");
+
+ *ctx = skel;
+
+ return SCX_TEST_PASS;
+}
+
+static enum scx_test_status run(void *ctx)
+{
+ struct dequeue *skel = ctx;
+ enum scx_test_status status;
+
+ status = run_scenario(skel, 0, "Local DSQ");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 1, "User DSQ");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ printf("\n=== Summary ===\n");
+ printf("Total enqueues: %lu\n", (unsigned long)skel->bss->enqueue_cnt);
+ printf("Total dequeues: %lu\n", (unsigned long)skel->bss->dequeue_cnt);
+ printf(" Dispatch dequeues: %lu (no flag, normal workflow)\n",
+ (unsigned long)skel->bss->dispatch_dequeue_cnt);
+ printf(" Property change dequeues: %lu (SCX_DEQ_SCHED_CHANGE flag)\n",
+ (unsigned long)skel->bss->change_dequeue_cnt);
+ printf("\nAll scenarios passed - no state machine violations detected\n");
+ printf("-> Validated: Correct state transitions (NONE -> ENQUEUED -> DISPATCHED)\n");
+ printf("-> Validated: Dispatch dequeues have no flags (normal workflow)\n");
+ printf("-> Validated: Async dequeues have SCX_DEQ_SCHED_CHANGE flag (interruptions)\n");
+ printf("-> Validated: No duplicate enqueues or invalid state transitions\n");
+
+ return SCX_TEST_PASS;
+}
+
+static void cleanup(void *ctx)
+{
+ struct dequeue *skel = ctx;
+
+ dequeue__destroy(skel);
+}
+
+struct scx_test dequeue_test = {
+ .name = "dequeue",
+ .description = "Verify that ops.enqueue() is balanced with ops.dequeue()",
+ .setup = setup,
+ .run = run,
+ .cleanup = cleanup,
+};
+
+REGISTER_SCX_TEST(&dequeue_test)
--
2.52.0
^ permalink raw reply [flat|nested] 41+ messages in thread
* [PATCH 2/2] selftests/sched_ext: Add test to validate ops.dequeue() semantics
2026-01-21 12:25 [PATCHSET v2 sched_ext/for-6.20] sched_ext: Fix " Andrea Righi
@ 2026-01-21 12:25 ` Andrea Righi
0 siblings, 0 replies; 41+ messages in thread
From: Andrea Righi @ 2026-01-21 12:25 UTC (permalink / raw)
To: Tejun Heo, David Vernet, Changwoo Min
Cc: Emil Tsalapatis, Daniel Hodges, sched-ext, linux-kernel
Add a new kselftest to validate that:
- every enqueue is followed by proper dequeue,
- dispatch dequeues happen exactly once per enqueue,
- async dequeues (SCX_DEQ_ASYNC) only happen for property changes,
- no duplicate enqueues without proper dequeue.
Test scenarios:
- direct dispatch to local DSQ (SCX_DSQ_LOCAL),
- dispatch to user DSQ,
- explicit property changes via sched_setaffinity().
This validates that the new ops.dequeue() semantics work correctly for
all task lifecycle scenarios.
Cc: Tejun Heo <tj@kernel.org>
Cc: Emil Tsalapatis <emil@etsalapatis.com>
Signed-off-by: Andrea Righi <arighi@nvidia.com>
---
tools/testing/selftests/sched_ext/Makefile | 1 +
.../testing/selftests/sched_ext/dequeue.bpf.c | 209 ++++++++++++++++++
tools/testing/selftests/sched_ext/dequeue.c | 182 +++++++++++++++
3 files changed, 392 insertions(+)
create mode 100644 tools/testing/selftests/sched_ext/dequeue.bpf.c
create mode 100644 tools/testing/selftests/sched_ext/dequeue.c
diff --git a/tools/testing/selftests/sched_ext/Makefile b/tools/testing/selftests/sched_ext/Makefile
index 2c601a7eaff5f..2815a875bde2f 100644
--- a/tools/testing/selftests/sched_ext/Makefile
+++ b/tools/testing/selftests/sched_ext/Makefile
@@ -161,6 +161,7 @@ all_test_bpfprogs := $(foreach prog,$(wildcard *.bpf.c),$(INCLUDE_DIR)/$(patsubs
auto-test-targets := \
create_dsq \
+ dequeue \
enq_last_no_enq_fails \
ddsp_bogus_dsq_fail \
ddsp_vtimelocal_fail \
diff --git a/tools/testing/selftests/sched_ext/dequeue.bpf.c b/tools/testing/selftests/sched_ext/dequeue.bpf.c
new file mode 100644
index 0000000000000..f7c4643a5e8de
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/dequeue.bpf.c
@@ -0,0 +1,209 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * A scheduler that validates ops.dequeue() is called correctly:
+ * - For tasks on BPF data structures (not yet dispatched)
+ * - For tasks already on DSQs (local or shared)
+ * - That every ops.enqueue() is followed by ops.dequeue()
+ *
+ * Copyright (c) 2025 NVIDIA Corporation.
+ */
+
+#include <scx/common.bpf.h>
+
+#define SHARED_DSQ 0
+
+char _license[] SEC("license") = "GPL";
+
+UEI_DEFINE(uei);
+
+/*
+ * Counters to track the lifecycle of tasks:
+ * - enqueue_cnt: Number of times ops.enqueue() was called
+ * - dequeue_cnt: Number of times ops.dequeue() was called (any type)
+ * - dispatch_dequeue_cnt: Number of regular dispatch dequeues (no flag)
+ * - async_dequeue_cnt: Number of async dequeues (SCX_DEQ_ASYNC)
+ */
+u64 enqueue_cnt, dequeue_cnt, dispatch_dequeue_cnt, async_dequeue_cnt;
+
+/*
+ * Test scenarios:
+ * - 0: Dispatch to local DSQ
+ * - 1: Dispatch to shared DSQ
+ */
+u32 test_scenario;
+
+/*
+ * Per-task state to track lifecycle and validate workflow semantics.
+ * State transitions:
+ * NONE -> ENQUEUED (on enqueue)
+ * ENQUEUED -> DISPATCHED (on dispatch dequeue)
+ * DISPATCHED -> NONE (on async dequeue or re-enqueue)
+ * ENQUEUED -> NONE (on async dequeue before dispatch)
+ */
+enum task_state {
+ TASK_NONE = 0, /* Task is outside scheduler control */
+ TASK_ENQUEUED, /* ops.enqueue() called, waiting for dequeue */
+ TASK_DISPATCHED, /* Dispatch dequeue received, can get async or re-enqueue */
+};
+
+struct task_ctx {
+ enum task_state state; /* Current state in the workflow */
+ u64 enqueue_seq; /* Sequence number for debugging */
+};
+
+struct {
+ __uint(type, BPF_MAP_TYPE_TASK_STORAGE);
+ __uint(map_flags, BPF_F_NO_PREALLOC);
+ __type(key, int);
+ __type(value, struct task_ctx);
+} task_ctx_stor SEC(".maps");
+
+static struct task_ctx *try_lookup_task_ctx(struct task_struct *p)
+{
+ return bpf_task_storage_get(&task_ctx_stor, p, 0, 0);
+}
+
+s32 BPF_STRUCT_OPS(dequeue_select_cpu, struct task_struct *p,
+ s32 prev_cpu, u64 wake_flags)
+{
+ /* Always bounce to ops.enqueue() */
+ return prev_cpu;
+}
+
+void BPF_STRUCT_OPS(dequeue_enqueue, struct task_struct *p, u64 enq_flags)
+{
+ struct task_ctx *tctx;
+
+ __sync_fetch_and_add(&enqueue_cnt, 1);
+
+ tctx = try_lookup_task_ctx(p);
+ if (!tctx)
+ return;
+
+ /*
+ * Validate state transition: enqueue is only valid from NONE or
+ * DISPATCHED states. Getting enqueue while in ENQUEUED state
+ * indicates a missing dequeue.
+ */
+ if (tctx->state == TASK_ENQUEUED)
+ scx_bpf_error("%d (%s): enqueue while in ENQUEUED state (seq %llu)",
+ p->pid, p->comm, tctx->enqueue_seq);
+
+ /* Transition to ENQUEUED state */
+ tctx->state = TASK_ENQUEUED;
+ tctx->enqueue_seq++;
+
+ switch (test_scenario) {
+ case 0:
+ /* Scenario 0: Direct dispatch to the local DSQ */
+ scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, enq_flags);
+ break;
+
+ case 1:
+ /* Scenario 1: Dispatch to shared DSQ */
+ scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, enq_flags);
+ break;
+ }
+}
+
+void BPF_STRUCT_OPS(dequeue_dequeue, struct task_struct *p, u64 deq_flags)
+{
+ struct task_ctx *tctx;
+
+ __sync_fetch_and_add(&dequeue_cnt, 1);
+
+ tctx = try_lookup_task_ctx(p);
+ if (!tctx)
+ return;
+
+ /*
+ * Validate state: dequeue should only happen from ENQUEUED or
+ * DISPATCHED states. Getting dequeue from NONE indicates a bug.
+ */
+ if (tctx->state == TASK_NONE)
+ scx_bpf_error("%d (%s): dequeue from NONE state (seq %llu)",
+ p->pid, p->comm, tctx->enqueue_seq);
+
+ if (deq_flags & SCX_DEQ_ASYNC) {
+ /*
+ * Async dequeue: property change interrupting the workflow.
+ * Valid from both ENQUEUED and DISPATCHED states.
+ * Transitions task back to NONE state.
+ */
+ __sync_fetch_and_add(&async_dequeue_cnt, 1);
+
+ /* Validate state transition */
+ if (tctx->state != TASK_ENQUEUED && tctx->state != TASK_DISPATCHED)
+ scx_bpf_error("%d (%s): async dequeue from invalid state %d (seq %llu)",
+ p->pid, p->comm, tctx->state, tctx->enqueue_seq);
+
+ /* Transition back to NONE - task outside scheduler control */
+ tctx->state = TASK_NONE;
+ } else {
+ /*
+ * Regular dispatch dequeue: normal workflow step.
+ * Valid only from ENQUEUED state (after enqueue, before dispatch dequeue).
+ * Transitions to DISPATCHED state.
+ */
+ __sync_fetch_and_add(&dispatch_dequeue_cnt, 1);
+
+ /* Validate: dispatch dequeue should NOT have SCX_DEQ_ASYNC flag */
+ if (deq_flags & SCX_DEQ_ASYNC)
+ scx_bpf_error("%d (%s): SCX_DEQ_ASYNC in dispatch dequeue (seq %llu)",
+ p->pid, p->comm, tctx->enqueue_seq);
+
+ /* Must be in ENQUEUED state */
+ if (tctx->state != TASK_ENQUEUED)
+ scx_bpf_error("%d (%s): dispatch dequeue from state %d (seq %llu)",
+ p->pid, p->comm, tctx->state, tctx->enqueue_seq);
+
+ /* Transition to DISPATCHED - normal cycle completed dispatch */
+ tctx->state = TASK_DISPATCHED;
+ }
+}
+
+void BPF_STRUCT_OPS(dequeue_dispatch, s32 cpu, struct task_struct *prev)
+{
+ scx_bpf_dsq_move_to_local(SHARED_DSQ);
+}
+
+s32 BPF_STRUCT_OPS(dequeue_init_task, struct task_struct *p,
+ struct scx_init_task_args *args)
+{
+ struct task_ctx *tctx;
+
+ tctx = bpf_task_storage_get(&task_ctx_stor, p, 0,
+ BPF_LOCAL_STORAGE_GET_F_CREATE);
+ if (!tctx)
+ return -ENOMEM;
+
+ return 0;
+}
+
+s32 BPF_STRUCT_OPS_SLEEPABLE(dequeue_init)
+{
+ s32 ret;
+
+ ret = scx_bpf_create_dsq(SHARED_DSQ, -1);
+ if (ret)
+ return ret;
+
+ return 0;
+}
+
+void BPF_STRUCT_OPS(dequeue_exit, struct scx_exit_info *ei)
+{
+ UEI_RECORD(uei, ei);
+}
+
+SEC(".struct_ops.link")
+struct sched_ext_ops dequeue_ops = {
+ .select_cpu = (void *)dequeue_select_cpu,
+ .enqueue = (void *)dequeue_enqueue,
+ .dequeue = (void *)dequeue_dequeue,
+ .dispatch = (void *)dequeue_dispatch,
+ .init_task = (void *)dequeue_init_task,
+ .init = (void *)dequeue_init,
+ .exit = (void *)dequeue_exit,
+ .name = "dequeue_test",
+};
diff --git a/tools/testing/selftests/sched_ext/dequeue.c b/tools/testing/selftests/sched_ext/dequeue.c
new file mode 100644
index 0000000000000..07de94957d366
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/dequeue.c
@@ -0,0 +1,182 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Copyright (c) 2025 NVIDIA Corporation.
+ */
+#define _GNU_SOURCE
+#include <stdio.h>
+#include <unistd.h>
+#include <signal.h>
+#include <bpf/bpf.h>
+#include <scx/common.h>
+#include <sys/wait.h>
+#include <sched.h>
+#include <pthread.h>
+#include "scx_test.h"
+#include "dequeue.bpf.skel.h"
+
+#define NUM_WORKERS 8
+
+/*
+ * Worker function that creates enqueue/dequeue events. It alternates
+ * between CPU work, sleeping, and affinity changes to trigger dequeues.
+ */
+static void worker_fn(int id)
+{
+ cpu_set_t cpuset;
+ int i;
+ volatile int sum = 0;
+
+ for (i = 0; i < 1000; i++) {
+ int j;
+
+ /* Do some work to trigger scheduling events */
+ for (j = 0; j < 10000; j++)
+ sum += j;
+
+ /* Change affinity to trigger dequeue */
+ if (i % 10 == 0) {
+ CPU_ZERO(&cpuset);
+ /* Rotate through the first 4 CPUs */
+ CPU_SET(i % 4, &cpuset);
+ sched_setaffinity(0, sizeof(cpuset), &cpuset);
+ }
+
+ /* Do additional work */
+ for (j = 0; j < 10000; j++)
+ sum += j;
+
+ /* Sleep to trigger dequeue */
+ usleep(1000 + (id * 100));
+ }
+
+ exit(0);
+}
+
+static enum scx_test_status run_scenario(struct dequeue *skel, u32 scenario,
+ const char *scenario_name)
+{
+ struct bpf_link *link;
+ pid_t pids[NUM_WORKERS];
+ int i, status;
+ u64 enq_start, deq_start, dispatch_deq_start, async_deq_start;
+ u64 enq_delta, deq_delta, dispatch_deq_delta, async_deq_delta;
+
+ /* Set the test scenario */
+ skel->bss->test_scenario = scenario;
+
+ /* Record starting counts */
+ enq_start = skel->bss->enqueue_cnt;
+ deq_start = skel->bss->dequeue_cnt;
+ dispatch_deq_start = skel->bss->dispatch_dequeue_cnt;
+ async_deq_start = skel->bss->async_dequeue_cnt;
+
+ link = bpf_map__attach_struct_ops(skel->maps.dequeue_ops);
+ SCX_FAIL_IF(!link, "Failed to attach struct_ops for scenario %s", scenario_name);
+
+ /* Fork worker processes to generate enqueue/dequeue events */
+ for (i = 0; i < NUM_WORKERS; i++) {
+ pids[i] = fork();
+ SCX_FAIL_IF(pids[i] < 0, "Failed to fork worker %d", i);
+
+ if (pids[i] == 0) {
+ worker_fn(i);
+ /* Should not reach here */
+ exit(1);
+ }
+ }
+
+ /* Wait for all workers to complete */
+ for (i = 0; i < NUM_WORKERS; i++) {
+ SCX_FAIL_IF(waitpid(pids[i], &status, 0) != pids[i],
+ "Failed to wait for worker %d", i);
+ SCX_FAIL_IF(status != 0, "Worker %d exited with status %d", i, status);
+ }
+
+ bpf_link__destroy(link);
+
+ SCX_EQ(skel->data->uei.kind, EXIT_KIND(SCX_EXIT_UNREG));
+
+ /* Calculate deltas */
+ enq_delta = skel->bss->enqueue_cnt - enq_start;
+ deq_delta = skel->bss->dequeue_cnt - deq_start;
+ dispatch_deq_delta = skel->bss->dispatch_dequeue_cnt - dispatch_deq_start;
+ async_deq_delta = skel->bss->async_dequeue_cnt - async_deq_start;
+
+ printf("%s:\n", scenario_name);
+ printf(" enqueues: %lu\n", (unsigned long)enq_delta);
+ printf(" dequeues: %lu (dispatch: %lu, async: %lu)\n",
+ (unsigned long)deq_delta,
+ (unsigned long)dispatch_deq_delta,
+ (unsigned long)async_deq_delta);
+
+ /*
+ * Validate that we got enqueue and dequeue events.
+ * The BPF code does strict state machine validation with scx_bpf_error()
+ * to ensure the workflow semantics are correct. If we reach here without
+ * errors, the semantics are validated correctly.
+ */
+ SCX_GT(enq_delta, 0);
+ SCX_GT(deq_delta, 0);
+
+ return SCX_TEST_PASS;
+}
+
+static enum scx_test_status setup(void **ctx)
+{
+ struct dequeue *skel;
+
+ skel = dequeue__open();
+ SCX_FAIL_IF(!skel, "Failed to open skel");
+ SCX_ENUM_INIT(skel);
+ SCX_FAIL_IF(dequeue__load(skel), "Failed to load skel");
+
+ *ctx = skel;
+
+ return SCX_TEST_PASS;
+}
+
+static enum scx_test_status run(void *ctx)
+{
+ struct dequeue *skel = ctx;
+ enum scx_test_status status;
+
+ status = run_scenario(skel, 0, "Local DSQ");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_scenario(skel, 1, "User DSQ");
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ printf("\n=== Summary ===\n");
+ printf("Total enqueues: %lu\n", (unsigned long)skel->bss->enqueue_cnt);
+ printf("Total dequeues: %lu\n", (unsigned long)skel->bss->dequeue_cnt);
+ printf(" Dispatch dequeues: %lu (no flag, normal workflow)\n",
+ (unsigned long)skel->bss->dispatch_dequeue_cnt);
+ printf(" Async dequeues: %lu (SCX_DEQ_ASYNC flag, property changes)\n",
+ (unsigned long)skel->bss->async_dequeue_cnt);
+ printf("\nAll scenarios passed - no state machine violations detected\n");
+ printf("-> Validated: Correct state transitions (NONE -> ENQUEUED -> DISPATCHED)\n");
+ printf("-> Validated: Dispatch dequeues have no flags (normal workflow)\n");
+ printf("-> Validated: Async dequeues have SCX_DEQ_ASYNC flag (interruptions)\n");
+ printf("-> Validated: No duplicate enqueues or invalid state transitions\n");
+
+ return SCX_TEST_PASS;
+}
+
+static void cleanup(void *ctx)
+{
+ struct dequeue *skel = ctx;
+
+ dequeue__destroy(skel);
+}
+
+struct scx_test dequeue_test = {
+ .name = "dequeue",
+ .description = "Verify that ops.enqueue() is balanced with ops.dequeue()",
+ .setup = setup,
+ .run = run,
+ .cleanup = cleanup,
+};
+
+REGISTER_SCX_TEST(&dequeue_test)
--
2.52.0
^ permalink raw reply [flat|nested] 41+ messages in thread
end of thread, other threads:[~2026-02-12 18:25 UTC | newest]
Thread overview: 41+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-02-01 9:08 [PATCHSET v4 sched_ext/for-6.20] sched_ext: Fix ops.dequeue() semantics Andrea Righi
2026-02-01 9:08 ` [PATCH 1/2] " Andrea Righi
2026-02-01 22:47 ` Christian Loehle
2026-02-02 7:45 ` Andrea Righi
2026-02-02 9:26 ` Andrea Righi
2026-02-02 10:02 ` Christian Loehle
2026-02-02 15:32 ` Andrea Righi
2026-02-02 10:09 ` Christian Loehle
2026-02-02 13:59 ` Kuba Piecuch
2026-02-04 9:36 ` Andrea Righi
2026-02-04 9:51 ` Kuba Piecuch
2026-02-02 11:56 ` Kuba Piecuch
2026-02-04 10:11 ` Andrea Righi
2026-02-04 10:33 ` Kuba Piecuch
2026-02-01 9:08 ` [PATCH 2/2] selftests/sched_ext: Add test to validate " Andrea Righi
-- strict thread matches above, loose matches on Subject: below --
2026-02-10 21:26 [PATCHSET v8] sched_ext: Fix " Andrea Righi
2026-02-10 21:26 ` [PATCH 2/2] selftests/sched_ext: Add test to validate " Andrea Righi
2026-02-12 17:15 ` Christian Loehle
2026-02-12 18:25 ` Andrea Righi
2026-02-06 13:54 [PATCHSET v7] sched_ext: Fix " Andrea Righi
2026-02-06 13:54 ` [PATCH 2/2] selftests/sched_ext: Add test to validate " Andrea Righi
2026-02-06 20:10 ` Emil Tsalapatis
2026-02-07 9:16 ` Andrea Righi
2026-02-08 5:11 ` Emil Tsalapatis
2026-02-08 9:02 ` Andrea Righi
2026-02-08 10:26 ` Andrea Righi
2026-02-08 13:55 ` Andrea Righi
2026-02-08 17:59 ` Emil Tsalapatis
2026-02-08 20:08 ` Andrea Righi
2026-02-09 10:20 ` Andrea Righi
2026-02-09 15:00 ` Emil Tsalapatis
2026-02-09 15:43 ` Andrea Righi
2026-02-09 17:23 ` Tejun Heo
2026-02-09 19:17 ` Andrea Righi
2026-02-09 20:10 ` Tejun Heo
2026-02-09 22:22 ` Andrea Righi
2026-02-10 0:42 ` Tejun Heo
2026-02-10 7:29 ` Andrea Righi
2026-02-05 15:32 [PATCHSET v6] sched_ext: Fix " Andrea Righi
2026-02-05 15:32 ` [PATCH 2/2] selftests/sched_ext: Add test to validate " Andrea Righi
2026-02-04 16:05 [PATCHSET v5] sched_ext: Fix " Andrea Righi
2026-02-04 16:05 ` [PATCH 2/2] selftests/sched_ext: Add test to validate " Andrea Righi
2026-01-26 8:41 [PATCHSET v3 sched_ext/for-6.20] sched_ext: Fix " Andrea Righi
2026-01-26 8:41 ` [PATCH 2/2] selftests/sched_ext: Add test to validate " Andrea Righi
2026-01-27 16:53 ` Emil Tsalapatis
2026-01-21 12:25 [PATCHSET v2 sched_ext/for-6.20] sched_ext: Fix " Andrea Righi
2026-01-21 12:25 ` [PATCH 2/2] selftests/sched_ext: Add test to validate " Andrea Righi
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
all inboxes | Powered by JetHome®