mirror of https://lore.kernel.org/lkml/
 help / color / mirror / Atom feed
From: Kuba Piecuch <jpiecuch@google.com>
To: Tejun Heo <tj@kernel.org>, David Vernet <void@manifault.com>,
	Andrea Righi <arighi@nvidia.com>,
	 Changwoo Min <changwoo@igalia.com>
Cc: Kuba Piecuch <jpiecuch@google.com>,
	Peter Zijlstra <peterz@infradead.org>,
	sched-ext@lists.linux.dev,  linux-kernel@vger.kernel.org
Subject: [RFC PATCH 5/5] selftests/sched_ext: Add cpu_avail tests
Date: Sun, 18 Oct 2026 12:14:49 +0000	[thread overview]
Message-ID: <935eb08e4c7ebce012b4f1c61406a0a53b016862.1792248922.git.jpiecuch@google.com> (raw)
In-Reply-To: <cover.1792248922.git.jpiecuch@google.com>

Add tests verifying that ops.cpu_acquire() and ops.cpu_release() are
invoked on every change of a CPU's availability to the BPF scheduler,
and only then.

The cpu_avail BPF scheduler tracks the state reported by the callbacks
per CPU, flagging double acquires and releases as well as callbacks
between ops.cpu_offline() and ops.cpu_online(). A sched_switch
tracepoint program checks after every pick that the reported state
matches the incoming task. In full switch mode, a CPU must be released
iff the task about to run has an RT or deadline priority, which covers
the stop task and PI-boosted tasks too. In partial mode, tasks without
the SCHED_EXT policy release it as well. The checker skips a CPU
while userspace hotplugs it, as the kernel releases the CPU before
ops.cpu_offline() updates the BPF side.

cpu_avail runs the following scenarios on top of the invariant checker:

- idle_to_rt: an RT task waking up on an idle CPU releases it, with the
  callback invoked from the idle task, and the CPU is acquired again
  when the RT task sleeps.
- class_change: switching a running SCX task to SCHED_FIFO from another
  CPU releases its CPU remotely with SCX_CPU_PREEMPT_CLASS_CHANGE and
  switching it back acquires the CPU. If the task happened not to be
  running at that moment, e.g. because the stopper preempted it, the
  switch is retried once.
- remote_reenq: same as class_change with tasks queued on the target
  CPU's local DSQ and ops.cpu_release() calling the legacy, synchronous
  scx_bpf_reenqueue_local(). The queued tasks must be re-enqueued.
- pi_boost: an SCX task holding a PI mutex is boosted by an RT waiter on
  another CPU, which releases its CPU until it's deboosted.
- churn: RT tasks, SCX tasks and class changes on all CPUs at once.
- hotplug: no callbacks while the CPU is offline and an acquire after it
  comes back online.
- partial: in partial mode, idle -> fair and a fair task preempting an
  SCX task release the CPU with SCX_CPU_PREEMPT_FAIR, and
  a running SCHED_EXT task switching to SCHED_OTHER releases it with
  SCX_CPU_PREEMPT_CLASS_CHANGE.
- reload: every scheduler instance gets an initial acquire for every
  online CPU.

cpu_avail_core needs SMT and runs SCX tasks with different core-sched
cookies on two siblings. The resulting forced idle must not release the
CPUs. An RT task sharing the other sibling's cookie must release and
re-acquire its CPU.

Also add the cpu_acquire/release() stubs back to the maximal scheduler
now that they are no longer deprecated.

Signed-off-by: Kuba Piecuch <jpiecuch@google.com>
---
 tools/testing/selftests/sched_ext/Makefile    |   2 +
 .../selftests/sched_ext/cpu_avail.bpf.c       | 218 ++++++
 tools/testing/selftests/sched_ext/cpu_avail.c | 717 ++++++++++++++++++
 tools/testing/selftests/sched_ext/cpu_avail.h | 237 ++++++
 .../selftests/sched_ext/cpu_avail_core.c      | 256 +++++++
 .../testing/selftests/sched_ext/maximal.bpf.c |  10 +
 6 files changed, 1440 insertions(+)
 create mode 100644 tools/testing/selftests/sched_ext/cpu_avail.bpf.c
 create mode 100644 tools/testing/selftests/sched_ext/cpu_avail.c
 create mode 100644 tools/testing/selftests/sched_ext/cpu_avail.h
 create mode 100644 tools/testing/selftests/sched_ext/cpu_avail_core.c

diff --git a/tools/testing/selftests/sched_ext/Makefile b/tools/testing/selftests/sched_ext/Makefile
index af919a56a4f4..c0861bd33d3a 100644
--- a/tools/testing/selftests/sched_ext/Makefile
+++ b/tools/testing/selftests/sched_ext/Makefile
@@ -162,6 +162,8 @@ endef
 all_test_bpfprogs := $(foreach prog,$(wildcard *.bpf.c),$(INCLUDE_DIR)/$(patsubst %.c,%.skel.h,$(prog)))
 
 auto-test-targets :=			\
+	cpu_avail			\
+	cpu_avail_core			\
 	create_dsq			\
 	dequeue				\
 	dequeue_iter			\
diff --git a/tools/testing/selftests/sched_ext/cpu_avail.bpf.c b/tools/testing/selftests/sched_ext/cpu_avail.bpf.c
new file mode 100644
index 000000000000..d0052d1a18a0
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/cpu_avail.bpf.c
@@ -0,0 +1,218 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Track ops.cpu_acquire/release() and verify that they reliably reflect
+ * whether each CPU is available to the BPF scheduler.
+ *
+ * A CPU is available while it runs an SCX task or the idle task. In full
+ * switch mode, every task with a normal priority is an SCX task and a CPU must
+ * be released iff the task running on it has an RT or deadline priority. This
+ * includes the stop task and PI-boosted tasks. In partial mode, only
+ * tasks with a normal priority and the SCHED_EXT policy are SCX tasks.
+ *
+ * The sched_switch tracepoint fires after the pick with the rq locked and
+ * checks that the state reported through the callbacks matches the incoming
+ * task.
+ */
+
+#include <scx/common.bpf.h>
+
+char _license[] SEC("license") = "GPL";
+
+#define MAX_CPUS	1024
+#define NR_REASONS	8
+#define MAX_RT_PRIO	100
+#define SCHED_EXT	7
+
+/* set by userspace */
+const volatile u32 nr_cpus;
+u32 hog_tgid;			/* tasks of this tgid get an infinite slice */
+bool reenq_enabled;		/* cpu_release() of reenq_cpu calls v1 reenqueue */
+s32 reenq_cpu;
+bool check_enabled;		/* enables the sched_switch invariant checker */
+bool partial;			/* cpu_avail_partial_ops is attached */
+bool hp_busy;			/* hp_cpu is being hotplugged, don't check it */
+s32 hp_cpu;
+
+/* state as reported by the callbacks, every CPU starts released */
+bool acquired[MAX_CPUS];
+bool offline[MAX_CPUS];
+/* don't check the invariant until the first acquire after ops.cpu_online() */
+bool online_pending[MAX_CPUS];
+
+u64 nr_acquires[MAX_CPUS];
+u64 nr_releases[MAX_CPUS];
+u64 nr_reasons[MAX_CPUS][NR_REASONS];
+u64 nr_remote_releases[MAX_CPUS];	/* invoked from another CPU */
+u64 nr_idle_releases[MAX_CPUS];		/* invoked from the idle task */
+u64 nr_idle_switches[MAX_CPUS];		/* context switches to idle */
+u64 nr_hog_fair_releases[MAX_CPUS];	/* FAIR releases preempting the hog */
+
+u64 nr_dbl_acquires;
+u64 nr_dbl_releases;
+u64 nr_offline_cbs;			/* between cpu_offline() and cpu_online() */
+u64 nr_checks;
+u64 nr_violations;
+s32 violation_cpu;
+s32 violation_pid;
+s32 violation_prio;
+bool violation_acquired;
+
+/* legacy v1 scx_bpf_reenqueue_local() results */
+u64 nr_reenq_calls;
+u64 nr_reenq_remote;			/* invoked from another CPU */
+u64 nr_reenq_tasks;
+u64 nr_reenq_remote_tasks;		/* tasks re-enqueued by remote calls */
+u64 nr_reenq_enqueues;			/* ops.enqueue() with SCX_ENQ_REENQ */
+
+static __always_inline bool cpu_ok(s32 cpu)
+{
+	return cpu >= 0 && cpu < MAX_CPUS && cpu < nr_cpus;
+}
+
+void BPF_STRUCT_OPS(cpu_avail_enqueue, struct task_struct *p, u64 enq_flags)
+{
+	u64 slice = SCX_SLICE_DFL;
+
+	if (enq_flags & SCX_ENQ_REENQ)
+		__sync_fetch_and_add(&nr_reenq_enqueues, 1);
+
+	if (hog_tgid && p->tgid == hog_tgid)
+		slice = SCX_SLICE_INF;
+
+	/* queue pinned tasks on the local DSQ of the CPU they're enqueued on */
+	if (p->nr_cpus_allowed == 1)
+		scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, slice, enq_flags);
+	else
+		scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, slice, enq_flags);
+}
+
+void BPF_STRUCT_OPS(cpu_avail_cpu_acquire, s32 cpu,
+		    struct scx_cpu_acquire_args *args)
+{
+	if (!cpu_ok(cpu))
+		return;
+
+	if (offline[cpu])
+		__sync_fetch_and_add(&nr_offline_cbs, 1);
+	if (acquired[cpu])
+		__sync_fetch_and_add(&nr_dbl_acquires, 1);
+
+	acquired[cpu] = true;
+	online_pending[cpu] = false;
+	__sync_fetch_and_add(&nr_acquires[cpu], 1);
+}
+
+void BPF_STRUCT_OPS(cpu_avail_cpu_release, s32 cpu,
+		    struct scx_cpu_release_args *args)
+{
+	struct task_struct *cur = bpf_get_current_task_btf();
+	bool remote = bpf_get_smp_processor_id() != cpu;
+	u32 reason = args->reason;
+
+	if (!cpu_ok(cpu))
+		return;
+
+	if (offline[cpu])
+		__sync_fetch_and_add(&nr_offline_cbs, 1);
+	if (!acquired[cpu])
+		__sync_fetch_and_add(&nr_dbl_releases, 1);
+
+	acquired[cpu] = false;
+	__sync_fetch_and_add(&nr_releases[cpu], 1);
+	if (reason < NR_REASONS)
+		__sync_fetch_and_add(&nr_reasons[cpu][reason], 1);
+
+	if (remote)
+		__sync_fetch_and_add(&nr_remote_releases[cpu], 1);
+	else if (cur->pid == 0)
+		__sync_fetch_and_add(&nr_idle_releases[cpu], 1);
+	else if (hog_tgid && cur->tgid == hog_tgid &&
+		 reason == SCX_CPU_PREEMPT_FAIR)
+		__sync_fetch_and_add(&nr_hog_fair_releases[cpu], 1);
+
+	if (reenq_enabled && cpu == reenq_cpu) {
+		u32 nr = scx_bpf_reenqueue_local___v1();
+
+		__sync_fetch_and_add(&nr_reenq_calls, 1);
+		__sync_fetch_and_add(&nr_reenq_tasks, nr);
+		if (remote) {
+			__sync_fetch_and_add(&nr_reenq_remote, 1);
+			__sync_fetch_and_add(&nr_reenq_remote_tasks, nr);
+		}
+	}
+}
+
+void BPF_STRUCT_OPS(cpu_avail_cpu_online, s32 cpu)
+{
+	if (!cpu_ok(cpu))
+		return;
+
+	offline[cpu] = false;
+	online_pending[cpu] = true;
+}
+
+void BPF_STRUCT_OPS(cpu_avail_cpu_offline, s32 cpu)
+{
+	if (!cpu_ok(cpu))
+		return;
+
+	/* ops.cpu_offline() implies release */
+	acquired[cpu] = false;
+	offline[cpu] = true;
+}
+
+SEC("tp_btf/sched_switch")
+int BPF_PROG(cpu_avail_sched_switch, bool preempt, struct task_struct *prev,
+	     struct task_struct *next, unsigned int prev_state)
+{
+	s32 cpu = bpf_get_smp_processor_id();
+	bool avail;
+
+	if (!cpu_ok(cpu))
+		return 0;
+
+	if (next->pid == 0)
+		__sync_fetch_and_add(&nr_idle_switches[cpu], 1);
+
+	if (!check_enabled || offline[cpu] || online_pending[cpu] ||
+	    (hp_busy && cpu == hp_cpu))
+		return 0;
+
+	avail = next->pid == 0 ||
+		(next->prio >= MAX_RT_PRIO &&
+		 (!partial || next->policy == SCHED_EXT));
+
+	__sync_fetch_and_add(&nr_checks, 1);
+	if (acquired[cpu] != avail &&
+	    __sync_fetch_and_add(&nr_violations, 1) == 0) {
+		violation_cpu = cpu;
+		violation_pid = next->pid;
+		violation_prio = next->prio;
+		violation_acquired = acquired[cpu];
+	}
+
+	return 0;
+}
+
+SEC(".struct_ops.link")
+struct sched_ext_ops cpu_avail_ops = {
+	.enqueue		= (void *)cpu_avail_enqueue,
+	.cpu_acquire		= (void *)cpu_avail_cpu_acquire,
+	.cpu_release		= (void *)cpu_avail_cpu_release,
+	.cpu_online		= (void *)cpu_avail_cpu_online,
+	.cpu_offline		= (void *)cpu_avail_cpu_offline,
+	.name			= "cpu_avail",
+	.timeout_ms		= 10000,
+};
+
+SEC(".struct_ops.link")
+struct sched_ext_ops cpu_avail_partial_ops = {
+	.enqueue		= (void *)cpu_avail_enqueue,
+	.cpu_acquire		= (void *)cpu_avail_cpu_acquire,
+	.cpu_release		= (void *)cpu_avail_cpu_release,
+	.cpu_online		= (void *)cpu_avail_cpu_online,
+	.cpu_offline		= (void *)cpu_avail_cpu_offline,
+	.flags			= SCX_OPS_SWITCH_PARTIAL,
+	.name			= "cpu_avail_partial",
+	.timeout_ms		= 10000,
+};
diff --git a/tools/testing/selftests/sched_ext/cpu_avail.c b/tools/testing/selftests/sched_ext/cpu_avail.c
new file mode 100644
index 000000000000..3796f94b05a9
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/cpu_avail.c
@@ -0,0 +1,717 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Verify that ops.cpu_acquire/release() are invoked on every change of a
+ * CPU's availability to the BPF scheduler, and only then.
+ */
+#define _GNU_SOURCE
+#include <pthread.h>
+#include <stdatomic.h>
+#include "cpu_avail.h"
+
+#define NR_RELOADS	5
+
+struct cpu_avail_ctx {
+	struct cpu_avail *skel;
+	struct bpf_link *link;
+	cpu_set_t orig_mask;
+	int nr_cpus;
+	int ctl_cpu;		/* where the test itself runs */
+	int tgt_cpu;		/* the CPU being tested */
+};
+
+/* poll @cond for up to @ms milliseconds */
+#define WAIT_FOR(cond, ms)						\
+({									\
+	u64 __end = now_ns() + (ms) * 1000000ULL;			\
+	bool __ok;							\
+									\
+	while (!(__ok = (cond)) && now_ns() < __end)			\
+		sleep_ms(1);						\
+	__ok;								\
+})
+
+/*
+ * An RT task waking up on an idle CPU must release it and the CPU must be
+ * acquired again when the RT task goes back to sleep. This never goes
+ * through the SCX class.
+ */
+static enum scx_test_status test_idle_to_rt(struct cpu_avail_ctx *ctx)
+{
+	struct cpu_avail__bss *bss = ctx->skel->bss;
+	struct task_spec spec = {
+		.cpu = ctx->tgt_cpu, .policy = SCHED_FIFO, .prio = 10,
+		.period_us = 5000, .run_us = 500, .duration_ms = 500,
+	};
+	int cpu = ctx->tgt_cpu;
+	u64 rt = bss->nr_reasons[cpu][REASON(RT)];
+	u64 idle = bss->nr_idle_releases[cpu];
+	u64 acq = bss->nr_acquires[cpu];
+	pid_t pid;
+
+	pid = spawn_task(&spec);
+	SCX_FAIL_IF(pid < 0, "Failed to fork (%d)", errno);
+	SCX_FAIL_IF(!wait_task(pid), "RT task failed");
+
+	SCX_GT(bss->nr_reasons[cpu][REASON(RT)], rt);
+	SCX_GT(bss->nr_idle_releases[cpu], idle);
+	SCX_GT(bss->nr_acquires[cpu], acq);
+	SCX_ASSERT(WAIT_FOR(bss->acquired[cpu], 100));
+
+	return SCX_TEST_PASS;
+}
+
+/* @policy is the hog's SCX policy, SCHED_OTHER or SCHED_EXT */
+static pid_t spawn_hog(struct cpu_avail_ctx *ctx, int policy)
+{
+	struct task_spec spec = { .cpu = ctx->tgt_cpu, .policy = policy };
+	int go_fd;
+	pid_t pid;
+
+	pid = fork_task(&spec, &go_fd);
+	if (pid < 0)
+		return pid;
+
+	/* infinite slice so that it stays current until it changes class */
+	ctx->skel->bss->hog_tgid = pid;
+	start_task(go_fd);
+	return pid;
+}
+
+static void stop_hog(struct cpu_avail_ctx *ctx, pid_t pid)
+{
+	kill_task(pid);
+	ctx->skel->bss->hog_tgid = 0;
+}
+
+/*
+ * Switch @hog, which runs on the target CPU, from @scx_policy to @policy from
+ * the control CPU and wait for the resulting remote CLASS_CHANGE release. The
+ * hog is normally current but something else, e.g. the stopper, may have
+ * preempted it at that moment, making the release local. If so, switch it
+ * back and retry once.
+ */
+static bool hog_leave_scx(struct cpu_avail_ctx *ctx, pid_t hog, int scx_policy,
+			  int policy, int prio)
+{
+	struct cpu_avail__bss *bss = ctx->skel->bss;
+	int cpu = ctx->tgt_cpu, i;
+	u64 cc, remote;
+
+	for (i = 0; i < 2; i++) {
+		if (!WAIT_FOR(bss->acquired[cpu], 500)) {
+			SCX_ERR("CPU %d not acquired", cpu);
+			return false;
+		}
+		sleep_ms(50);
+
+		cc = bss->nr_reasons[cpu][REASON(CLASS_CHANGE)];
+		remote = bss->nr_remote_releases[cpu];
+		if (set_policy(hog, policy, prio)) {
+			SCX_ERR("Failed to set policy %d (%d)", policy, errno);
+			return false;
+		}
+		if (WAIT_FOR(bss->nr_reasons[cpu][REASON(CLASS_CHANGE)] > cc, 100) &&
+		    bss->nr_remote_releases[cpu] > remote)
+			return true;
+
+		fprintf(stderr, "No remote CLASS_CHANGE release (%lu -> %lu, %lu -> %lu)%s\n",
+			cc, bss->nr_reasons[cpu][REASON(CLASS_CHANGE)],
+			remote, bss->nr_remote_releases[cpu],
+			i ? "" : ", retrying");
+		if (set_policy(hog, scx_policy, 0)) {
+			SCX_ERR("Failed to set policy %d (%d)", scx_policy, errno);
+			return false;
+		}
+	}
+
+	return false;
+}
+
+/*
+ * The running SCX task (policy @scx_policy) switching to @policy through
+ * sched_setscheduler() from another CPU must release the CPU without a
+ * context switch, and the switch back must acquire it.
+ */
+static enum scx_test_status class_change(struct cpu_avail_ctx *ctx,
+					 int scx_policy, int policy, int prio)
+{
+	struct cpu_avail__bss *bss = ctx->skel->bss;
+	int cpu = ctx->tgt_cpu;
+	enum scx_test_status ret = SCX_TEST_FAIL;
+	u64 acq;
+	pid_t hog;
+
+	hog = spawn_hog(ctx, scx_policy);
+	SCX_FAIL_IF(hog < 0, "Failed to fork (%d)", errno);
+
+	if (!hog_leave_scx(ctx, hog, scx_policy, policy, prio))
+		goto out;
+	sleep_ms(20);
+
+	acq = bss->nr_acquires[cpu];
+	if (set_policy(hog, scx_policy, 0)) {
+		SCX_ERR("Failed to set policy %d (%d)", scx_policy, errno);
+		goto out;
+	}
+	if (!WAIT_FOR(bss->nr_acquires[cpu] > acq && bss->acquired[cpu], 100)) {
+		SCX_ERR("CPU %d not acquired after switching back", cpu);
+		goto out;
+	}
+
+	ret = SCX_TEST_PASS;
+out:
+	stop_hog(ctx, hog);
+	return ret;
+}
+
+static enum scx_test_status test_class_change(struct cpu_avail_ctx *ctx)
+{
+	return class_change(ctx, SCHED_OTHER, SCHED_FIFO, 1);
+}
+
+/*
+ * Same as test_class_change() but with tasks queued on the target CPU's local
+ * DSQ and ops.cpu_release() calling the legacy scx_bpf_reenqueue_local().
+ * The tasks must be re-enqueued from the target CPU's local DSQ even though
+ * ops.cpu_release() runs on another CPU.
+ */
+static enum scx_test_status test_remote_reenq(struct cpu_avail_ctx *ctx)
+{
+	struct cpu_avail__bss *bss = ctx->skel->bss;
+	struct task_spec spec = { .cpu = ctx->tgt_cpu, .policy = SCHED_OTHER };
+	enum scx_test_status ret = SCX_TEST_FAIL;
+	pid_t hog, waiters[2] = { -1, -1 };
+	u64 remote, tasks, enqs;
+	int i;
+
+	hog = spawn_hog(ctx, SCHED_OTHER);
+	SCX_FAIL_IF(hog < 0, "Failed to fork (%d)", errno);
+
+	if (!WAIT_FOR(bss->acquired[ctx->tgt_cpu], 500)) {
+		SCX_ERR("CPU %d not acquired", ctx->tgt_cpu);
+		goto out;
+	}
+	sleep_ms(50);
+
+	/* these queue up behind the hog on the target CPU's local DSQ */
+	for (i = 0; i < 2; i++) {
+		waiters[i] = spawn_task(&spec);
+		if (waiters[i] < 0) {
+			SCX_ERR("Failed to fork (%d)", errno);
+			goto out;
+		}
+	}
+	sleep_ms(100);
+
+	/* a first attempt may release and re-enqueue locally, count remote only */
+	remote = bss->nr_reenq_remote;
+	tasks = bss->nr_reenq_remote_tasks;
+	enqs = bss->nr_reenq_enqueues;
+
+	bss->reenq_cpu = ctx->tgt_cpu;
+	bss->reenq_enabled = true;
+	if (!hog_leave_scx(ctx, hog, SCHED_OTHER, SCHED_FIFO, 1))
+		goto out;
+	bss->reenq_enabled = false;
+
+	if (bss->nr_reenq_remote <= remote ||
+	    bss->nr_reenq_remote_tasks <= tasks ||
+	    bss->nr_reenq_enqueues <= enqs) {
+		SCX_ERR("Bad reenq: remote %lu->%lu tasks %lu->%lu enqs %lu->%lu",
+			remote, bss->nr_reenq_remote, tasks,
+			bss->nr_reenq_remote_tasks, enqs, bss->nr_reenq_enqueues);
+		goto out;
+	}
+
+	ret = SCX_TEST_PASS;
+out:
+	bss->reenq_enabled = false;
+	for (i = 0; i < 2; i++)
+		kill_task(waiters[i]);
+	stop_hog(ctx, hog);
+	return ret;
+}
+
+struct pi_ctx {
+	pthread_mutex_t mutex;
+	atomic_bool pinned;
+	atomic_bool held;
+	int owner_cpu;
+	int waiter_cpu;
+	bool failed;
+};
+
+static void *pi_owner_fn(void *arg)
+{
+	struct pi_ctx *pi = arg;
+
+	if (pin_to_cpu(0, pi->owner_cpu)) {
+		pi->failed = true;
+		atomic_store(&pi->pinned, true);
+		atomic_store(&pi->held, true);
+		return NULL;
+	}
+	atomic_store(&pi->pinned, true);
+
+	pthread_mutex_lock(&pi->mutex);
+	atomic_store(&pi->held, true);
+	spin_us(300 * 1000);
+	pthread_mutex_unlock(&pi->mutex);
+	return NULL;
+}
+
+static void *pi_waiter_fn(void *arg)
+{
+	struct pi_ctx *pi = arg;
+	struct sched_param param = { .sched_priority = 10 };
+
+	if (pin_to_cpu(0, pi->waiter_cpu) ||
+	    pthread_setschedparam(pthread_self(), SCHED_FIFO, &param)) {
+		pi->failed = true;
+		return NULL;
+	}
+
+	while (!atomic_load(&pi->held))
+		;
+	/* boosts the owner, which runs on another CPU */
+	pthread_mutex_lock(&pi->mutex);
+	pthread_mutex_unlock(&pi->mutex);
+	return NULL;
+}
+
+static int run_pi(int owner_cpu, int waiter_cpu)
+{
+	struct pi_ctx pi = { .owner_cpu = owner_cpu, .waiter_cpu = waiter_cpu };
+	pthread_mutexattr_t attr;
+	pthread_t owner, waiter;
+
+	pthread_mutexattr_init(&attr);
+	if (pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT) ||
+	    pthread_mutex_init(&pi.mutex, &attr))
+		return EXIT_FAILURE;
+
+	if (pthread_create(&owner, NULL, pi_owner_fn, &pi))
+		return EXIT_FAILURE;
+
+	/*
+	 * The threads start on the waiter's CPU. Don't let the busy-waiting RT
+	 * waiter starve the owner before it has moved to its own CPU.
+	 */
+	while (!atomic_load(&pi.pinned))
+		usleep(1000);
+
+	if (pthread_create(&waiter, NULL, pi_waiter_fn, &pi)) {
+		pthread_join(owner, NULL);
+		return EXIT_FAILURE;
+	}
+	pthread_join(waiter, NULL);
+	pthread_join(owner, NULL);
+
+	return pi.failed ? EXIT_FAILURE : EXIT_SUCCESS;
+}
+
+/*
+ * An SCX task holding a PI mutex is boosted to RT by an RT waiter on another
+ * CPU. Its CPU must be released while it's boosted and acquired again once
+ * it's deboosted.
+ */
+static enum scx_test_status test_pi_boost(struct cpu_avail_ctx *ctx)
+{
+	struct cpu_avail__bss *bss = ctx->skel->bss;
+	int cpu = ctx->tgt_cpu;
+	u64 cc = bss->nr_reasons[cpu][REASON(CLASS_CHANGE)];
+	u64 rt = bss->nr_reasons[cpu][REASON(RT)];
+	u64 acq = bss->nr_acquires[cpu];
+	pid_t pid;
+
+	pid = fork();
+	SCX_FAIL_IF(pid < 0, "Failed to fork (%d)", errno);
+	if (!pid)
+		exit(run_pi(ctx->tgt_cpu, ctx->ctl_cpu));
+
+	SCX_FAIL_IF(!wait_task(pid), "PI test failed");
+
+	/* boosting a running owner releases with CLASS_CHANGE, a queued one RT */
+	SCX_GT(bss->nr_reasons[cpu][REASON(CLASS_CHANGE)] - cc +
+	       bss->nr_reasons[cpu][REASON(RT)] - rt, 0);
+	SCX_GT(bss->nr_acquires[cpu], acq);
+	SCX_ASSERT(WAIT_FOR(bss->acquired[cpu], 100));
+
+	return SCX_TEST_PASS;
+}
+
+/*
+ * Run RT tasks, SCX tasks and sched_setscheduler() class changes on all CPUs
+ * at once. The sched_switch checker must not find any inconsistencies.
+ */
+static enum scx_test_status test_churn(struct cpu_avail_ctx *ctx)
+{
+	struct cpu_avail__bss *bss = ctx->skel->bss;
+	struct task_spec spinner = { .cpu = -1, .policy = SCHED_OTHER };
+	struct task_spec rt = {
+		.policy = SCHED_FIFO, .prio = 20,
+		.period_us = 1000, .run_us = 200, .duration_ms = 1500,
+	};
+	enum scx_test_status ret = SCX_TEST_FAIL;
+	int nr_spinners = 0, nr_rts = 0, cpu, i;
+	pid_t *spinners, *rts;
+	u64 checks = bss->nr_checks;
+	u64 end;
+
+	spinners = calloc(ctx->nr_cpus, sizeof(pid_t));
+	rts = calloc(ctx->nr_cpus, sizeof(pid_t));
+	if (!spinners || !rts) {
+		SCX_ERR("Failed to allocate");
+		goto out;
+	}
+
+	for (cpu = 0; cpu < ctx->nr_cpus; cpu++) {
+		if (!cpu_is_online(cpu))
+			continue;
+
+		spinners[nr_spinners] = spawn_task(&spinner);
+		if (spinners[nr_spinners] < 0) {
+			SCX_ERR("Failed to fork (%d)", errno);
+			goto out;
+		}
+		nr_spinners++;
+
+		rt.cpu = cpu;
+		rts[nr_rts] = spawn_task(&rt);
+		if (rts[nr_rts] < 0) {
+			SCX_ERR("Failed to fork (%d)", errno);
+			goto out;
+		}
+		nr_rts++;
+	}
+
+	/* flip a spinner between SCX and RT while the RT tasks run */
+	end = now_ns() + 1500 * 1000000ULL;
+	for (i = 0; now_ns() < end; i++) {
+		if (set_policy(spinners[0], i % 2 ? SCHED_OTHER : SCHED_FIFO,
+			       i % 2 ? 0 : 1)) {
+			SCX_ERR("Failed to set policy (%d)", errno);
+			goto out;
+		}
+		sleep_ms(2);
+	}
+
+	for (i = 0; i < nr_rts; i++) {
+		if (!wait_task(rts[i])) {
+			SCX_ERR("RT task failed");
+			rts[i] = -1;
+			goto out;
+		}
+		rts[i] = -1;
+	}
+
+	if (bss->nr_checks <= checks || bss->nr_violations) {
+		SCX_ERR("checks %lu -> %lu, violations %lu", checks,
+			bss->nr_checks, bss->nr_violations);
+		goto out;
+	}
+
+	ret = SCX_TEST_PASS;
+out:
+	for (i = 0; i < nr_rts; i++)
+		kill_task(rts[i]);
+	for (i = 0; i < nr_spinners; i++)
+		kill_task(spinners[i]);
+	free(spinners);
+	free(rts);
+	return ret;
+}
+
+/*
+ * Neither callback may be invoked between ops.cpu_offline() and
+ * ops.cpu_online(), and the CPU must be acquired after ops.cpu_online().
+ */
+static enum scx_test_status test_hotplug(struct cpu_avail_ctx *ctx)
+{
+	struct cpu_avail__bss *bss = ctx->skel->bss;
+	enum scx_test_status ret = SCX_TEST_FAIL;
+	int cpu = ctx->tgt_cpu;
+	char path[64];
+	u64 acq;
+
+	snprintf(path, sizeof(path), "/sys/devices/system/cpu/cpu%d/online", cpu);
+	if (access(path, W_OK)) {
+		fprintf(stderr, "CPU %d can't be offlined, skipping hotplug\n", cpu);
+		return SCX_TEST_PASS;
+	}
+
+	/*
+	 * The kernel releases the CPU right before ops.cpu_offline(), which
+	 * updates the BPF side without holding the rq lock. Don't check the
+	 * CPU while it's being hotplugged.
+	 */
+	bss->hp_cpu = cpu;
+	bss->hp_busy = true;
+
+	acq = bss->nr_acquires[cpu];
+	if (file_write_long(path, 0)) {
+		SCX_ERR("Failed to offline CPU %d", cpu);
+		goto out;
+	}
+	if (!bss->offline[cpu]) {
+		SCX_ERR("ops.cpu_offline() not called for CPU %d", cpu);
+		goto out;
+	}
+	sleep_ms(100);
+
+	if (file_write_long(path, 1)) {
+		SCX_ERR("Failed to online CPU %d", cpu);
+		goto out;
+	}
+	if (bss->offline[cpu] ||
+	    !WAIT_FOR(bss->nr_acquires[cpu] > acq && bss->acquired[cpu], 500)) {
+		SCX_ERR("CPU %d not acquired after onlining", cpu);
+		goto out;
+	}
+	ret = SCX_TEST_PASS;
+out:
+	bss->hp_busy = false;
+	if (ret != SCX_TEST_PASS)
+		return ret;
+	SCX_EQ(bss->nr_offline_cbs, 0);
+	return SCX_TEST_PASS;
+}
+
+/*
+ * In partial mode, only SCHED_EXT tasks are SCX tasks and fair tasks
+ * take the CPU away from the BPF scheduler: idle -> fair, SCX -> fair
+ * preemption and an SCX task switching to SCHED_OTHER must release the CPU
+ * with SCX_CPU_PREEMPT_FAIR or SCX_CPU_PREEMPT_CLASS_CHANGE.
+ */
+static enum scx_test_status partial_scenarios(struct cpu_avail_ctx *ctx)
+{
+	struct cpu_avail__bss *bss = ctx->skel->bss;
+	struct task_spec fair = {
+		.cpu = ctx->tgt_cpu, .policy = SCHED_OTHER,
+		.period_us = 5000, .run_us = 500, .duration_ms = 300,
+	};
+	int cpu = ctx->tgt_cpu;
+	enum scx_test_status ret;
+	u64 rels, idle, acq;
+	pid_t pid, hog;
+
+	/* idle -> fair -> idle */
+	fprintf(stderr, "cpu_avail: partial idle_to_fair on CPU %d\n", cpu);
+	rels = bss->nr_reasons[cpu][REASON(FAIR)];
+	idle = bss->nr_idle_releases[cpu];
+	acq = bss->nr_acquires[cpu];
+	pid = spawn_task(&fair);
+	SCX_FAIL_IF(pid < 0, "Failed to fork (%d)", errno);
+	SCX_FAIL_IF(!wait_task(pid), "fair task failed");
+	SCX_GT(bss->nr_reasons[cpu][REASON(FAIR)], rels);
+	SCX_GT(bss->nr_idle_releases[cpu], idle);
+	SCX_GT(bss->nr_acquires[cpu], acq);
+	SCX_ASSERT(WAIT_FOR(bss->acquired[cpu], 100));
+
+	/* a fair task preempting an SCX task */
+	fprintf(stderr, "cpu_avail: partial scx_to_fair on CPU %d\n", cpu);
+	hog = spawn_hog(ctx, SCHED_EXT);
+	SCX_FAIL_IF(hog < 0, "Failed to fork (%d)", errno);
+	if (!WAIT_FOR(bss->acquired[cpu], 500)) {
+		stop_hog(ctx, hog);
+		SCX_FAIL("CPU %d not acquired", cpu);
+	}
+	/* only count releases from put_prev_task_scx() of the hog */
+	rels = bss->nr_hog_fair_releases[cpu];
+	acq = bss->nr_acquires[cpu];
+	pid = spawn_task(&fair);
+	if (pid < 0 || !wait_task(pid)) {
+		stop_hog(ctx, hog);
+		SCX_FAIL("fair task failed");
+	}
+	stop_hog(ctx, hog);
+	SCX_GT(bss->nr_hog_fair_releases[cpu], rels);
+	SCX_GT(bss->nr_acquires[cpu], acq);
+
+	/* SCHED_EXT -> SCHED_OTHER on a running task */
+	fprintf(stderr, "cpu_avail: partial class_change on CPU %d\n", cpu);
+	ret = class_change(ctx, SCHED_EXT, SCHED_OTHER, 0);
+	if (ret != SCX_TEST_PASS)
+		return ret;
+
+	return check_consistency(ctx->skel);
+}
+
+static enum scx_test_status test_partial(struct cpu_avail_ctx *ctx)
+{
+	struct cpu_avail *main_skel = ctx->skel;
+	enum scx_test_status ret;
+	struct bpf_link *link;
+
+	ctx->skel = cpu_avail_open_load();
+	if (!ctx->skel) {
+		ctx->skel = main_skel;
+		SCX_FAIL("Failed to open and load skel");
+	}
+
+	ctx->skel->bss->partial = true;
+	link = bpf_map__attach_struct_ops(ctx->skel->maps.cpu_avail_partial_ops);
+	if (!link) {
+		ret = SCX_TEST_FAIL;
+		SCX_ERR("Failed to attach scheduler");
+	} else {
+		ctx->skel->bss->check_enabled = true;
+		ret = partial_scenarios(ctx);
+		bpf_link__destroy(link);
+	}
+
+	cpu_avail__destroy(ctx->skel);
+	ctx->skel = main_skel;
+	return ret;
+}
+
+/*
+ * Every scheduler instance starts with all CPUs released and must see an
+ * initial ops.cpu_acquire() for every online CPU, regardless of what the
+ * previous instance left behind.
+ */
+static enum scx_test_status test_reload(struct cpu_avail_ctx *ctx)
+{
+	enum scx_test_status ret;
+	int i, cpu;
+
+	for (i = 0; i < NR_RELOADS; i++) {
+		struct cpu_avail *skel;
+		struct bpf_link *link;
+
+		skel = cpu_avail_open_load();
+		SCX_FAIL_IF(!skel, "Failed to open and load skel");
+
+		link = bpf_map__attach_struct_ops(skel->maps.cpu_avail_ops);
+		if (!link) {
+			cpu_avail__destroy(skel);
+			SCX_FAIL("Failed to attach scheduler");
+		}
+		skel->bss->check_enabled = true;
+		sleep_ms(100);
+
+		ret = SCX_TEST_PASS;
+		for (cpu = 0; cpu < ctx->nr_cpus; cpu++) {
+			if (cpu_is_online(cpu) && !skel->bss->nr_acquires[cpu]) {
+				SCX_ERR("reload %d: CPU %d not acquired", i, cpu);
+				ret = SCX_TEST_FAIL;
+			}
+		}
+		if (ret == SCX_TEST_PASS)
+			ret = check_consistency(skel);
+
+		bpf_link__destroy(link);
+		cpu_avail__destroy(skel);
+		if (ret != SCX_TEST_PASS)
+			return ret;
+	}
+
+	return SCX_TEST_PASS;
+}
+
+static enum scx_test_status setup(void **ctxp)
+{
+	struct cpu_avail_ctx *ctx;
+	int cpu;
+
+	ctx = calloc(1, sizeof(*ctx));
+	SCX_FAIL_IF(!ctx, "Failed to allocate");
+
+	ctx->nr_cpus = libbpf_num_possible_cpus();
+	ctx->ctl_cpu = -1;
+	ctx->tgt_cpu = -1;
+	for (cpu = 0; cpu < ctx->nr_cpus; cpu++) {
+		if (!cpu_is_online(cpu))
+			continue;
+		if (ctx->ctl_cpu < 0)
+			ctx->ctl_cpu = cpu;
+		else
+			ctx->tgt_cpu = cpu;
+	}
+	if (ctx->tgt_cpu < 0) {
+		fprintf(stderr, "Needs at least 2 online CPUs\n");
+		free(ctx);
+		return SCX_TEST_SKIP;
+	}
+
+	if (sched_getaffinity(0, sizeof(ctx->orig_mask), &ctx->orig_mask) ||
+	    pin_to_cpu(0, ctx->ctl_cpu)) {
+		SCX_ERR("Failed to pin to CPU %d", ctx->ctl_cpu);
+		free(ctx);
+		return SCX_TEST_FAIL;
+	}
+
+	ctx->skel = cpu_avail_open_load();
+	if (!ctx->skel) {
+		sched_setaffinity(0, sizeof(ctx->orig_mask), &ctx->orig_mask);
+		free(ctx);
+		SCX_FAIL("Failed to open and load skel");
+	}
+
+	*ctxp = ctx;
+	return SCX_TEST_PASS;
+}
+
+static enum scx_test_status run(void *arg)
+{
+	struct cpu_avail_ctx *ctx = arg;
+	static const struct {
+		const char *name;
+		enum scx_test_status (*fn)(struct cpu_avail_ctx *ctx);
+	} tests[] = {
+		{ "idle_to_rt", test_idle_to_rt },
+		{ "class_change", test_class_change },
+		{ "remote_reenq", test_remote_reenq },
+		{ "pi_boost", test_pi_boost },
+		{ "churn", test_churn },
+		{ "hotplug", test_hotplug },
+	};
+	enum scx_test_status ret;
+	int i;
+
+	ctx->link = bpf_map__attach_struct_ops(ctx->skel->maps.cpu_avail_ops);
+	SCX_FAIL_IF(!ctx->link, "Failed to attach scheduler");
+	ctx->skel->bss->check_enabled = true;
+
+	for (i = 0; i < ARRAY_SIZE(tests); i++) {
+		fprintf(stderr, "cpu_avail: %s on CPU %d\n", tests[i].name,
+			ctx->tgt_cpu);
+		ret = tests[i].fn(ctx);
+		if (ret != SCX_TEST_PASS)
+			return ret;
+	}
+
+	ret = check_consistency(ctx->skel);
+	if (ret != SCX_TEST_PASS)
+		return ret;
+
+	bpf_link__destroy(ctx->link);
+	ctx->link = NULL;
+	ctx->skel->bss->check_enabled = false;
+
+	ret = test_partial(ctx);
+	if (ret != SCX_TEST_PASS)
+		return ret;
+
+	fprintf(stderr, "cpu_avail: reload\n");
+	return test_reload(ctx);
+}
+
+static void cleanup(void *arg)
+{
+	struct cpu_avail_ctx *ctx = arg;
+
+	bpf_link__destroy(ctx->link);
+	cpu_avail__destroy(ctx->skel);
+	sched_setaffinity(0, sizeof(ctx->orig_mask), &ctx->orig_mask);
+	free(ctx);
+}
+
+struct scx_test cpu_avail = {
+	.name = "cpu_avail",
+	.description = "Verify that ops.cpu_acquire/release() track CPU availability",
+	.setup = setup,
+	.run = run,
+	.cleanup = cleanup,
+};
+REGISTER_SCX_TEST(&cpu_avail)
diff --git a/tools/testing/selftests/sched_ext/cpu_avail.h b/tools/testing/selftests/sched_ext/cpu_avail.h
new file mode 100644
index 000000000000..97659e1061b8
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/cpu_avail.h
@@ -0,0 +1,237 @@
+/* SPDX-License-Identifier: GPL-2.0 */
+/*
+ * Helpers shared by the cpu_avail and cpu_avail_core tests.
+ */
+#ifndef __CPU_AVAIL_H__
+#define __CPU_AVAIL_H__
+
+#include <bpf/bpf.h>
+#include <errno.h>
+#include <sched.h>
+#include <signal.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+#include <scx/common.h>
+#include "cpu_avail.bpf.skel.h"
+#include "scx_test.h"
+#include "util.h"
+
+#ifndef ARRAY_SIZE
+#define ARRAY_SIZE(x)	(sizeof(x) / sizeof((x)[0]))
+#endif
+
+#ifndef SCHED_EXT
+#define SCHED_EXT	7
+#endif
+
+#define REASON(__name)							\
+	__COMPAT_ENUM_OR_ZERO("scx_cpu_preempt_reason", "SCX_CPU_PREEMPT_" #__name)
+
+static inline u64 now_ns(void)
+{
+	struct timespec ts;
+
+	clock_gettime(CLOCK_MONOTONIC, &ts);
+	return ts.tv_sec * 1000000000ULL + ts.tv_nsec;
+}
+
+static inline void spin_us(u64 us)
+{
+	u64 end = now_ns() + us * 1000;
+
+	while (now_ns() < end)
+		;
+}
+
+static inline void sleep_ms(u64 ms)
+{
+	usleep(ms * 1000);
+}
+
+static inline int pin_to_cpu(pid_t pid, int cpu)
+{
+	cpu_set_t mask;
+
+	CPU_ZERO(&mask);
+	CPU_SET(cpu, &mask);
+	return sched_setaffinity(pid, sizeof(mask), &mask);
+}
+
+static inline int set_policy(pid_t pid, int policy, int prio)
+{
+	struct sched_param param = { .sched_priority = prio };
+
+	return sched_setscheduler(pid, policy, &param);
+}
+
+static inline bool cpu_is_online(int cpu)
+{
+	char path[64];
+
+	snprintf(path, sizeof(path), "/sys/devices/system/cpu/cpu%d", cpu);
+	if (access(path, F_OK))
+		return false;
+
+	/* CPUs which can't be offlined don't have the file */
+	snprintf(path, sizeof(path), "/sys/devices/system/cpu/cpu%d/online", cpu);
+	if (access(path, F_OK))
+		return true;
+
+	return file_read_long(path) > 0;
+}
+
+/*
+ * What a forked task does once it's been released. @period_us == 0 means spin
+ * forever. Otherwise, sleep for @period_us - @run_us and then spin for @run_us
+ * in a loop for @duration_ms.
+ */
+struct task_spec {
+	int cpu;		/* -1 for no affinity */
+	int policy;
+	int prio;
+	u64 period_us;
+	u64 run_us;
+	u64 duration_ms;
+};
+
+static inline void task_body(const struct task_spec *spec)
+{
+	u64 end;
+
+	if (!spec->period_us) {
+		while (true)
+			spin_us(1000);
+	}
+
+	end = now_ns() + spec->duration_ms * 1000000ULL;
+	while (now_ns() < end) {
+		usleep(spec->period_us - spec->run_us);
+		spin_us(spec->run_us);
+	}
+}
+
+/*
+ * Fork a task according to @spec. The child blocks until start_task() is
+ * called on the returned pid so that the caller can set things up for it
+ * first. Returns the child's pid, or -1 on failure. @go_fd receives the write
+ * end of the pipe that releases the child.
+ */
+static inline pid_t fork_task(const struct task_spec *spec, int *go_fd)
+{
+	int fds[2];
+	pid_t pid;
+	char c;
+
+	if (pipe(fds))
+		return -1;
+
+	pid = fork();
+	if (pid < 0) {
+		close(fds[0]);
+		close(fds[1]);
+		return -1;
+	}
+
+	if (!pid) {
+		close(fds[1]);
+		if (read(fds[0], &c, 1) != 1)
+			exit(EXIT_FAILURE);
+		close(fds[0]);
+		if (spec->cpu >= 0 && pin_to_cpu(0, spec->cpu))
+			exit(EXIT_FAILURE);
+		if (spec->policy != SCHED_OTHER &&
+		    set_policy(0, spec->policy, spec->prio))
+			exit(EXIT_FAILURE);
+		task_body(spec);
+		exit(EXIT_SUCCESS);
+	}
+
+	close(fds[0]);
+	*go_fd = fds[1];
+	return pid;
+}
+
+static inline void start_task(int go_fd)
+{
+	char c = 1;
+
+	if (write(go_fd, &c, 1) != 1)
+		perror("write");
+	close(go_fd);
+}
+
+static inline pid_t spawn_task(const struct task_spec *spec)
+{
+	int go_fd;
+	pid_t pid;
+
+	pid = fork_task(spec, &go_fd);
+	if (pid > 0)
+		start_task(go_fd);
+	return pid;
+}
+
+static inline bool wait_task(pid_t pid)
+{
+	int status;
+
+	if (waitpid(pid, &status, 0) != pid)
+		return false;
+	return WIFEXITED(status) && WEXITSTATUS(status) == EXIT_SUCCESS;
+}
+
+static inline void kill_task(pid_t pid)
+{
+	if (pid <= 0)
+		return;
+	kill(pid, SIGKILL);
+	waitpid(pid, NULL, 0);
+}
+
+static inline struct cpu_avail *cpu_avail_open_load(void)
+{
+	struct cpu_avail *skel;
+
+	skel = cpu_avail__open();
+	if (!skel)
+		return NULL;
+	SCX_ENUM_INIT(skel);
+	skel->rodata->nr_cpus = libbpf_num_possible_cpus();
+	if (cpu_avail__load(skel))
+		goto err;
+
+	/* attach the sched_switch checker, a struct_ops is attached later */
+	bpf_map__set_autoattach(skel->maps.cpu_avail_ops, false);
+	bpf_map__set_autoattach(skel->maps.cpu_avail_partial_ops, false);
+	if (cpu_avail__attach(skel))
+		goto err;
+
+	return skel;
+err:
+	cpu_avail__destroy(skel);
+	return NULL;
+}
+
+/* common checks on the state accumulated by the BPF side */
+static inline enum scx_test_status check_consistency(struct cpu_avail *skel)
+{
+	struct cpu_avail__bss *bss = skel->bss;
+
+	if (bss->nr_violations)
+		SCX_ERR("first violation: cpu=%d pid=%d prio=%d acquired=%d",
+			bss->violation_cpu, bss->violation_pid,
+			bss->violation_prio, bss->violation_acquired);
+	SCX_EQ(bss->nr_violations, 0);
+	SCX_GT(bss->nr_checks, 0);
+	SCX_EQ(bss->nr_dbl_acquires, 0);
+	SCX_EQ(bss->nr_dbl_releases, 0);
+	SCX_EQ(bss->nr_offline_cbs, 0);
+	return SCX_TEST_PASS;
+}
+
+#endif /* __CPU_AVAIL_H__ */
diff --git a/tools/testing/selftests/sched_ext/cpu_avail_core.c b/tools/testing/selftests/sched_ext/cpu_avail_core.c
new file mode 100644
index 000000000000..971c4d37eb78
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/cpu_avail_core.c
@@ -0,0 +1,256 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Verify ops.cpu_acquire/release() under core scheduling. A CPU which is
+ * forced idle stays available to the BPF scheduler, while an RT task with a
+ * matching cookie running on it releases it.
+ */
+#define _GNU_SOURCE
+#include <sys/prctl.h>
+#include "cpu_avail.h"
+
+#ifndef PR_SCHED_CORE
+#define PR_SCHED_CORE			62
+#endif
+#ifndef PR_SCHED_CORE_CREATE
+#define PR_SCHED_CORE_CREATE		1
+#endif
+#ifndef PR_SCHED_CORE_SHARE_FROM
+#define PR_SCHED_CORE_SHARE_FROM	3
+#endif
+#ifndef PR_SCHED_CORE_SCOPE_THREAD
+#define PR_SCHED_CORE_SCOPE_THREAD	0
+#endif
+
+struct core_ctx {
+	struct cpu_avail *skel;
+	struct bpf_link *link;
+	cpu_set_t orig_mask;
+	int sib[2];		/* SMT siblings under test */
+	int ctl_cpu;		/* where the test itself runs, not a sibling */
+};
+
+static bool find_siblings(int cpu, int sib[2])
+{
+	char path[128], buf[64];
+	FILE *f;
+	bool ret;
+
+	snprintf(path, sizeof(path),
+		 "/sys/devices/system/cpu/cpu%d/topology/thread_siblings_list", cpu);
+	f = fopen(path, "r");
+	if (!f)
+		return false;
+	ret = fgets(buf, sizeof(buf), f) &&
+	      sscanf(buf, "%d%*[,-]%d", &sib[0], &sib[1]) == 2 &&
+	      sib[0] != sib[1];
+	fclose(f);
+	return ret;
+}
+
+static int core_cookie_create(pid_t pid)
+{
+	return prctl(PR_SCHED_CORE, PR_SCHED_CORE_CREATE, pid,
+		     PR_SCHED_CORE_SCOPE_THREAD, 0);
+}
+
+/* fork a spinner pinned to @cpu with a cookie of its own */
+static pid_t spawn_cookie_spinner(int cpu)
+{
+	struct task_spec spec = { .cpu = cpu, .policy = SCHED_OTHER };
+	int go_fd;
+	pid_t pid;
+
+	pid = fork_task(&spec, &go_fd);
+	if (pid < 0)
+		return pid;
+
+	if (core_cookie_create(pid)) {
+		int err = errno;
+
+		close(go_fd);
+		kill_task(pid);
+		errno = err;
+		return -1;
+	}
+
+	start_task(go_fd);
+	return pid;
+}
+
+/* fork a periodic RT task pinned to @cpu sharing @cookie_pid's cookie */
+static pid_t spawn_cookie_rt(int cpu, pid_t cookie_pid)
+{
+	struct task_spec spec = {
+		.cpu = cpu, .policy = SCHED_FIFO, .prio = 10,
+		.period_us = 5000, .run_us = 1000, .duration_ms = 1000,
+	};
+	pid_t pid;
+
+	pid = fork();
+	if (pid)
+		return pid;
+
+	if (prctl(PR_SCHED_CORE, PR_SCHED_CORE_SHARE_FROM, cookie_pid,
+		  PR_SCHED_CORE_SCOPE_THREAD, 0))
+		exit(EXIT_FAILURE);
+	if (pin_to_cpu(0, spec.cpu) || set_policy(0, spec.policy, spec.prio))
+		exit(EXIT_FAILURE);
+	task_body(&spec);
+	exit(EXIT_SUCCESS);
+}
+
+static enum scx_test_status setup(void **ctxp)
+{
+	struct core_ctx *ctx;
+	int nr_cpus = libbpf_num_possible_cpus();
+	int cpu;
+
+	ctx = calloc(1, sizeof(*ctx));
+	SCX_FAIL_IF(!ctx, "Failed to allocate");
+
+	ctx->ctl_cpu = -1;
+	for (cpu = 0; cpu < nr_cpus; cpu++) {
+		if (cpu_is_online(cpu) && find_siblings(cpu, ctx->sib))
+			break;
+	}
+	if (cpu == nr_cpus || !cpu_is_online(ctx->sib[0]) ||
+	    !cpu_is_online(ctx->sib[1])) {
+		fprintf(stderr, "No online SMT siblings\n");
+		free(ctx);
+		return SCX_TEST_SKIP;
+	}
+
+	for (cpu = 0; cpu < nr_cpus; cpu++) {
+		if (cpu_is_online(cpu) && cpu != ctx->sib[0] && cpu != ctx->sib[1]) {
+			ctx->ctl_cpu = cpu;
+			break;
+		}
+	}
+	if (ctx->ctl_cpu < 0) {
+		fprintf(stderr, "No online CPU outside of the SMT siblings\n");
+		free(ctx);
+		return SCX_TEST_SKIP;
+	}
+
+	/* the test itself shouldn't share the core */
+	if (sched_getaffinity(0, sizeof(ctx->orig_mask), &ctx->orig_mask) ||
+	    pin_to_cpu(0, ctx->ctl_cpu)) {
+		SCX_ERR("Failed to pin to CPU %d", ctx->ctl_cpu);
+		free(ctx);
+		return SCX_TEST_FAIL;
+	}
+
+	ctx->skel = cpu_avail_open_load();
+	if (!ctx->skel) {
+		sched_setaffinity(0, sizeof(ctx->orig_mask), &ctx->orig_mask);
+		free(ctx);
+		SCX_FAIL("Failed to open and load skel");
+	}
+
+	*ctxp = ctx;
+	return SCX_TEST_PASS;
+}
+
+static enum scx_test_status run(void *arg)
+{
+	struct core_ctx *ctx = arg;
+	struct cpu_avail__bss *bss = ctx->skel->bss;
+	enum scx_test_status ret = SCX_TEST_FAIL;
+	pid_t spinners[2] = { -1, -1 }, rt = -1;
+	u64 unknown[2], idle[2], rels[2], rt_rels, acqs;
+	int *sib = ctx->sib, i;
+
+	ctx->link = bpf_map__attach_struct_ops(ctx->skel->maps.cpu_avail_ops);
+	SCX_FAIL_IF(!ctx->link, "Failed to attach scheduler");
+	bss->check_enabled = true;
+
+	fprintf(stderr, "cpu_avail_core: siblings %d,%d\n", sib[0], sib[1]);
+
+	/* SCX tasks with different cookies on the siblings force idle */
+	for (i = 0; i < 2; i++) {
+		spinners[i] = spawn_cookie_spinner(sib[i]);
+		if (spinners[i] < 0) {
+			if (errno == EINVAL || errno == ENODEV) {
+				fprintf(stderr, "Core scheduling not supported (%d)\n",
+					errno);
+				ret = SCX_TEST_SKIP;
+			} else {
+				SCX_ERR("Failed to fork (%d)", errno);
+			}
+			goto out;
+		}
+	}
+	sleep_ms(100);
+
+	for (i = 0; i < 2; i++) {
+		unknown[i] = bss->nr_reasons[sib[i]][REASON(UNKNOWN)];
+		idle[i] = bss->nr_idle_switches[sib[i]];
+		rels[i] = bss->nr_releases[sib[i]];
+	}
+	sleep_ms(1000);
+
+	for (i = 0; i < 2; i++) {
+		fprintf(stderr, "cpu_avail_core: CPU %d: %lu idle switches, %lu releases\n",
+			sib[i], bss->nr_idle_switches[sib[i]] - idle[i],
+			bss->nr_releases[sib[i]] - rels[i]);
+		if (bss->nr_reasons[sib[i]][REASON(UNKNOWN)] != unknown[i]) {
+			SCX_ERR("CPU %d released while forced idle", sib[i]);
+			goto out;
+		}
+	}
+	if (bss->nr_idle_switches[sib[0]] == idle[0] &&
+	    bss->nr_idle_switches[sib[1]] == idle[1]) {
+		SCX_ERR("No forced idle observed");
+		goto out;
+	}
+
+	/* an RT task matching sib[0]'s cookie runs on sib[1] */
+	rt_rels = bss->nr_reasons[sib[1]][REASON(RT)];
+	acqs = bss->nr_acquires[sib[1]];
+	rt = spawn_cookie_rt(sib[1], spinners[0]);
+	if (rt < 0) {
+		SCX_ERR("Failed to fork (%d)", errno);
+		goto out;
+	}
+	if (!wait_task(rt)) {
+		rt = -1;
+		SCX_ERR("RT task failed");
+		goto out;
+	}
+	rt = -1;
+
+	fprintf(stderr, "cpu_avail_core: CPU %d: %lu RT releases, %lu acquires\n",
+		sib[1], bss->nr_reasons[sib[1]][REASON(RT)] - rt_rels,
+		bss->nr_acquires[sib[1]] - acqs);
+	if (bss->nr_reasons[sib[1]][REASON(RT)] == rt_rels ||
+	    bss->nr_acquires[sib[1]] == acqs) {
+		SCX_ERR("CPU %d not released and acquired around the RT task", sib[1]);
+		goto out;
+	}
+
+	ret = check_consistency(ctx->skel);
+out:
+	kill_task(rt);
+	for (i = 0; i < 2; i++)
+		kill_task(spinners[i]);
+	return ret;
+}
+
+static void cleanup(void *arg)
+{
+	struct core_ctx *ctx = arg;
+
+	bpf_link__destroy(ctx->link);
+	cpu_avail__destroy(ctx->skel);
+	sched_setaffinity(0, sizeof(ctx->orig_mask), &ctx->orig_mask);
+	free(ctx);
+}
+
+struct scx_test cpu_avail_core = {
+	.name = "cpu_avail_core",
+	.description = "Verify ops.cpu_acquire/release() under core scheduling",
+	.setup = setup,
+	.run = run,
+	.cleanup = cleanup,
+};
+REGISTER_SCX_TEST(&cpu_avail_core)
diff --git a/tools/testing/selftests/sched_ext/maximal.bpf.c b/tools/testing/selftests/sched_ext/maximal.bpf.c
index 04a369078aac..c2ee5437df65 100644
--- a/tools/testing/selftests/sched_ext/maximal.bpf.c
+++ b/tools/testing/selftests/sched_ext/maximal.bpf.c
@@ -67,6 +67,14 @@ void BPF_STRUCT_OPS(maximal_set_cpumask, struct task_struct *p,
 void BPF_STRUCT_OPS(maximal_update_idle, s32 cpu, bool idle)
 {}
 
+void BPF_STRUCT_OPS(maximal_cpu_acquire, s32 cpu,
+		    struct scx_cpu_acquire_args *args)
+{}
+
+void BPF_STRUCT_OPS(maximal_cpu_release, s32 cpu,
+		    struct scx_cpu_release_args *args)
+{}
+
 SEC("tp_btf/sched_switch")
 int BPF_PROG(maximal_sched_switch, bool preempt, struct task_struct *prev,
 	     struct task_struct *next, unsigned int prev_state)
@@ -149,6 +157,8 @@ struct sched_ext_ops maximal_ops = {
 	.set_weight		= (void *) maximal_set_weight,
 	.set_cpumask		= (void *) maximal_set_cpumask,
 	.update_idle		= (void *) maximal_update_idle,
+	.cpu_acquire		= (void *) maximal_cpu_acquire,
+	.cpu_release		= (void *) maximal_cpu_release,
 	.cpu_online		= (void *) maximal_cpu_online,
 	.cpu_offline		= (void *) maximal_cpu_offline,
 	.init_task		= (void *) maximal_init_task,
-- 
2.56.0.176.g3575e65682-goog


  parent reply	other threads:[~2026-10-18 12:15 UTC|newest]

Thread overview: 8+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-18 12:14 [RFC PATCH sched_ext/for-7.4 0/5] sched_ext: Make ops.cpu_acquire/release() reliable Kuba Piecuch
2026-10-18 12:14 ` [RFC PATCH 1/5] sched_ext: Make legacy scx_bpf_reenqueue_local() target the locked rq Kuba Piecuch
2026-10-18 12:14 ` [RFC PATCH 2/5] sched_ext: Emit ops.cpu_acquire/release() on actual CPU transitions Kuba Piecuch
2026-10-18 12:14 ` [RFC PATCH 3/5] sched_ext: Release the CPU when its running task leaves SCX Kuba Piecuch
2026-10-18 12:14 ` [RFC PATCH 4/5] sched_ext: Un-deprecate ops.cpu_acquire/release() Kuba Piecuch
2026-10-18 12:14 ` Kuba Piecuch [this message]
2026-10-19 14:48 ` [RFC PATCH sched_ext/for-7.4 0/5] sched_ext: Make ops.cpu_acquire/release() reliable Kuba Piecuch
2026-10-20  0:31   ` Tejun Heo

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=935eb08e4c7ebce012b4f1c61406a0a53b016862.1792248922.git.jpiecuch@google.com \
    --to=jpiecuch@google.com \
    --cc=arighi@nvidia.com \
    --cc=changwoo@igalia.com \
    --cc=linux-kernel@vger.kernel.org \
    --cc=peterz@infradead.org \
    --cc=sched-ext@lists.linux.dev \
    --cc=tj@kernel.org \
    --cc=void@manifault.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is 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®