Custom Linux Kernel Scheduling in Userspace: A Practical Guide to sched_ext (scx) in Rust

Custom Linux Kernel Scheduling in Userspace: A Practical Guide to sched_ext (scx) in Rust

by Joshua Edward McLaughlin Cox

For decades, modifying the Linux kernel’s CPU scheduler (CFS or the newer EEVDF—Earliest Eligible Virtual Deadline First) required patching kernel C source trees (kernel/sched/fair.c), recompiling vmlinuz, rebooting bare-metal servers, and praying a subtle race condition or spinlock inversion didn’t deadlock a 128-core socket in production.

With the mainline merge of sched_ext (CONFIG_SCHED_CLASS_EXT) in Linux 6.12, the kernel scheduling architecture underwent a historic paradigm shift. Just as eBPF and XDP transformed packet filtering and io_uring rewrote asynchronous I/O, sched_ext turns the kernel scheduler into a safely programmable subsystem.

sched_ext exposes a complete sched_ext_ops eBPF struct_ops interface that allows operators to hot-load, test, and swap custom CPU scheduling algorithms dynamically at runtime—written in eBPF C or userspace Rust (scx)—with an automatic kernel watchdog fallback that reverts to standard EEVDF within milliseconds if your custom scheduler ever stalls. Test the live sched_ext Tail-Latency & Watchdog Simulator at the bottom of this dispatch to compare EEVDF, scx_bpfland, scx_lavd, and custom Rustland policies under all-core saturation.


1. How sched_ext Fits Into the Linux Scheduler Hierarchy

The Linux kernel processes threads through an ordered hierarchy of scheduling classes (struct sched_class). On every context switch (__schedule()), the kernel iterates from top to bottom and invokes pick_next_task() on each class until one returns a runnable struct task_struct. Each class has strict priority over the classes beneath it:

sched_ext Kernel Scheduler Hierarchy and DSQ Pipeline

+-------------------------------------------------------------------------------+
|                   LINUX KERNEL SCHEDULING CLASS HIERARCHY                     |
+-------------------------------------------------------------------------------+
|                                                                               |
|  1. stop_sched_class      (Highest Priority: CPU migration / hotplug stoppers)|
|           |                                                                   |
|           v                                                                   |
|  2. dl_sched_class        (SCHED_DEADLINE: Earliest Deadline First RT tasks)  |
|           |                                                                   |
|           v                                                                   |
|  3. rt_sched_class        (SCHED_FIFO / SCHED_RR: POSIX Real-Time threads)    |
|           |                                                                   |
|           v                                                                   |
|  4. fair_sched_class      (EEVDF: Default SCHED_NORMAL tasks when scx off)    |
|           |                                                                   |
|           v                                                                   |
|  5. ext_sched_class <==== [ sched_ext (scx) BPF struct_ops Hook ]             |
|           |               * Intercepts all SCHED_NORMAL / SCHED_EXT tasks     |
|           |               * Custom Dispatch Queues (DSQs) per-LLC / per-NUMA  |
|           |               * Automatic SysRq / Watchdog fallback to EEVDF      |
|           v                                                                   |
|  6. idle_sched_class      (Lowest Priority: per-CPU swapper/idle loop)        |
|                                                                               |
+-------------------------------------------------------------------------------+

Partial (SCHED_EXT) vs. Full (SCHED_NORMAL) Takeover

sched_ext supports two distinct operational modes depending on the flags set in sched_ext_ops.flags:

  1. Full System Takeover (Default): When a sched_ext BPF program is loaded via bpf_link_create() without SCX_OPS_SWITCH_PARTIAL, all normal tasks (SCHED_NORMAL, SCHED_BATCH, SCHED_IDLE) are transparently migrated from fair_sched_class (EEVDF) into ext_sched_class.
  2. Selective / Partial Takeover (SCX_OPS_SWITCH_PARTIAL): When SCX_OPS_SWITCH_PARTIAL is set, standard system daemons remain governed by EEVDF, and only threads that explicitly invoke sched_setscheduler(pid, SCHED_EXT, ...) (policy 7) are routed into your custom BPF scheduler.

The Kernel Watchdog & Zero-Downtime Eviction

What prevents a buggy loop or starvation bug in a custom scheduler from freezing the host?

  • BPF Verifier Guarantees: Every sched_ext_ops callback is verified at load time for bounded loops, valid kfunc reference tracking (scx_bpf_task_cgroup(), bpf_cpumask_create()), and memory safety.
  • Per-Task Stall Watchdog: The kernel tracks every runnable task’s last execution timestamp. If any runnable task fails to be scheduled onto a CPU within sched_ext_ops.timeout_ms (default 30,000 ms, configurable down to sub-second intervals), or if an operator triggers SysRq+S (echo S > /proc/sysrq-trigger), the kernel invokes scx_ops_error(), detaches the BPF struct_ops link, and atomically evacuates all queued tasks back to fair_sched_class (EEVDF) with zero process loss.

2. Core Primitives: Dispatch Queues (DSQs) and sched_ext_ops

To decouple scheduling policy from low-level kernel runqueue locking (struct rq), sched_ext introduces a universal primitive called the Dispatch Queue (DSQ):

  • Local DSQs (SCX_DSQ_LOCAL / SCX_DSQ_LOCAL_ON | cpu): Every logical CPU core has a built-in local DSQ. Tasks dispatched to a CPU’s local DSQ execute immediately on that specific hardware thread as soon as its current task yields or exhausts its slice.
  • Global DSQ (SCX_DSQ_GLOBAL): A built-in shared FIFO queue that any idle CPU can pull from when its local DSQ is empty.
  • Custom DSQs (scx_bpf_create_dsq(dsq_id, node_id)): Schedulers can dynamically allocate arbitrary NUMA-aware custom DSQs—for example, one DSQ per L3 Last-Level Cache (LLC / AMD CCD) domain or a dedicated high-priority DSQ for latency-critical inference/audio threads—and order tasks inside them either FIFO (scx_bpf_dsq_insert) or by a 64-bit virtual time key (vtime) (scx_bpf_dsq_insert_vtime).

Every sched_ext scheduler implements a table of callbacks in struct sched_ext_ops:

Callback Hook When It Fires Primary Responsibility
init() / exit(info) Scheduler load / unload Allocate custom per-LLC / per-NUMA DSQs (scx_bpf_create_dsq) and record exit telemetry
select_cpu(p, prev_cpu, wake_flags) Task p is waking up Pick an idle CPU sharing the same L2/L3 cache (prev_cpu) and directly insert into SCX_DSQ_LOCAL
enqueue(p, enq_flags) Task p enters runnable state Insert p into a custom DSQ (scx_bpf_dsq_insert_vtime) or forward to userspace ring buffer
dispatch(cpu, prev) cpu’s local DSQ is empty Pull the next optimal task from custom DSQs (scx_bpf_dsq_move_to_local(dsq_id))
running(p) / stopping(p, runnable) Task begins or ends CPU slice Account consumed execution time (p->scx.dsq_vtime) and hardware performance counters
update_idle(cpu, idle) CPU enters or exits idle state Maintain custom BPF bitmasks of idle SMT cores vs. fully idle physical cores

Under the Hood: The Kernel-Side eBPF struct_ops Shim

Whether you write a pure-BPF scheduler (scx_bpfland) or a hybrid userspace Rust scheduler (scx_rustland), the kernel interface is anchored by a C SEC(".struct_ops.link") definition. Here is the foundational eBPF C path demonstrating how select_cpu fast-paths idle L3 cache siblings before falling back to a vtime-ordered custom DSQ:

#include <scx/common.bpf.h>

char _license[] SEC("license") = "GPL";

#define SHARED_LLC_DSQ_ID 0x1000
#define DEFAULT_SLICE_NS  2000000ULL /* 2ms crisp time slice */

private(TEKRO) struct bpf_cpumask __kptr *primary_llc_mask;
static u64 vtime_now;

s32 BPF_STRUCT_OPS(tekro_select_cpu, struct task_struct *p, s32 prev_cpu, u64 wake_flags)
{
    bool is_idle = false;
    /* Attempt to pick an idle SMT/L3 cache-warm sibling CPU */
    s32 cpu = scx_bpf_select_cpu_dfl(p, prev_cpu, wake_flags, &is_idle);

    if (is_idle) {
        /* Fast-path: CPU is completely idle, dispatch directly to its local DSQ! */
        scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, DEFAULT_SLICE_NS, 0);
    }
    return cpu;
}

void BPF_STRUCT_OPS(tekro_enqueue, struct task_struct *p, u64 enq_flags)
{
    u64 vtime = p->scx.dsq_vtime;
    u64 floor = vtime_now > DEFAULT_SLICE_NS ? vtime_now - DEFAULT_SLICE_NS : 0;

    /* Clamp sleeping task vtime so interactive wakes get immediate boost without starving batch */
    if (time_before(vtime, floor))
        vtime = floor;

    scx_bpf_dsq_insert_vtime(p, SHARED_LLC_DSQ_ID, DEFAULT_SLICE_NS, vtime, enq_flags);
}

void BPF_STRUCT_OPS(tekro_dispatch, s32 cpu, struct task_struct *prev)
{
    /* Pull the lowest-vtime task from our custom LLC DSQ into this CPU's local queue */
    scx_bpf_dsq_move_to_local(SHARED_LLC_DSQ_ID);
}

void BPF_STRUCT_OPS(tekro_stopping, struct task_struct *p, bool runnable)
{
    /* Charge virtual runtime inversely proportional to task weight (nice value) */
    u64 consumed_ns = DEFAULT_SLICE_NS - p->scx.slice;
    p->scx.dsq_vtime += consumed_ns * 100 / p->scx.weight;
    vtime_now = p->scx.dsq_vtime;
}

s32 BPF_STRUCT_OPS_SLEEPABLE(tekro_init)
{
    return scx_bpf_create_dsq(SHARED_LLC_DSQ_ID, -1);
}

SCX_OPS_DEFINE(tekro_ops,
               .select_cpu = (void *)tekro_select_cpu,
               .enqueue    = (void *)tekro_enqueue,
               .dispatch   = (void *)tekro_dispatch,
               .stopping   = (void *)tekro_stopping,
               .init       = (void *)tekro_init,
               .name       = "tekro_llc_vtime");

3. Writing a Latency-Aware Rust Scheduler with scx_rustland_core

While pure eBPF is ideal for ultra-fast in-kernel decisions, complex topological algorithms, machine-learning heuristics, or rich telemetry structures can quickly hit the eBPF verifier’s instruction complexity limits.

With the scx Rust ecosystem (scx_utils and scx_rustland_core), you don’t need to fight the BPF verifier for complex data structures. A thin eBPF shim forwards task enqueue/dequeue events over a lockless shared-memory ring buffer (BPF_MAP_TYPE_RINGBUF / USER_RINGBUF) to a privileged Rust userspace binary, which makes scheduling decisions using the full Rust standard library!

Why Allocator::lock_memory() is Mandatory in Userspace Schedulers

When a userspace Rust process acts as the system CPU scheduler, avoiding page faults and self-deadlock is existential:

  1. If the Rust scheduler process itself page-faults on swapped memory or waits on a dirty page writeback, the kernel spawns or wakes a kswapd / NVMe block I/O completion thread (SCHED_NORMAL).
  2. That kernel/block thread must be scheduled by the userspace Rust scheduler—which is currently blocked waiting for the page fault to resolve!
  3. To prevent this classic deadlock:
    • Allocator::lock_memory() invokes mlockall(MCL_CURRENT | MCL_FUTURE) and pre-faults heap arenas so the Rust process can never page-fault.
    • The BPF shim automatically intercepts the userspace scheduler’s own PID (usersched_pid) and kernel kworker threads, dispatching them directly to SCX_DSQ_LOCAL without round-tripping through userspace.

Complete Userspace Rust Scheduler Implementation

Here is a complete, minimal latency-first scheduler in Rust that orders tasks by virtual deadline (vtime) and prioritizes interactive/low-runtime threads (such as network packet handlers, game loops, or token-streaming workers) over batch background compilers (cargo, gcc):

$$\text{vtime}{\text{next}} = \max\Big(\text{vtime}{\text{task}},; \text{vtime}{\min} - 2 \cdot S{\text{slice}}\Big) + \frac{\Delta t_{\text{exec}} \times 100}{W_{\text{task}}}$$

use scx_rustland_core::{Allocator, BpfScheduler, DispatchedTask, RL_CPU_ANY};
use std::collections::BTreeSet;
use std::cmp::Ordering;

#[derive(Eq, PartialEq, Clone, Debug)]
struct TaskItem {
    pid: i32,
    cpu: i32,
    vtime: u64,
    weight: u64,
}

// Order tasks strictly by virtual deadline (lowest vtime runs first)
impl Ord for TaskItem {
    fn cmp(&self, other: &Self) -> Ordering {
        self.vtime
            .cmp(&other.vtime)
            .then_with(|| self.pid.cmp(&other.pid))
    }
}

impl PartialOrd for TaskItem {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

const SLICE_NS: u64 = 2_000_000; // 2ms crisp time slice for low tail-latency

fn main() -> anyhow::Result<()> {
    // 1. Lock scheduler pages into physical RAM so our userspace scheduler never page-faults
    Allocator::lock_memory();

    let mut bpf = BpfScheduler::init(
        SLICE_NS,
        false, // exit_on_watchdog
        "tekro_latency_scx",
    )?;

    let mut runqueue: BTreeSet<TaskItem> = BTreeSet::new();
    let mut min_vtime: u64 = 0;

    loop {
        // 2. Drain all newly waking/enqueued tasks from the kernel BPF ringbuffer
        while let Ok(Some(task)) = bpf.dequeue_task() {
            // Clamp waking task vtime so long-sleeping tasks get an immediate interactive boost
            // without starving existing tasks indefinitely
            let floor = min_vtime.saturating_sub(SLICE_NS * 2);
            let vtime = task.vtime.max(floor);

            runqueue.insert(TaskItem {
                pid: task.pid,
                cpu: task.cpu,
                vtime,
                weight: task.weight.max(1),
            });
        }

        // 3. Dispatch tasks from our BTreeSet back to kernel local CPU DSQs
        let nr_keep_dispatched = 32;
        for _ in 0..nr_keep_dispatched {
            let Some(next) = runqueue.pop_first() else { break };
            min_vtime = next.vtime;

            let mut dispatched = DispatchedTask::new(next.pid);
            // Prefer keeping task on its previous L2/L3 cache-warm CPU if idle
            dispatched.cpu = bpf.select_cpu(next.pid, next.cpu, 0).unwrap_or(RL_CPU_ANY);
            dispatched.slice_ns = SLICE_NS;
            // Charge virtual runtime inversely proportional to task weight
            dispatched.vtime = next.vtime + (SLICE_NS * 100 / next.weight);

            if bpf.dispatch_task(&dispatched).is_err() {
                // Re-insert if kernel dispatch ring is momentarily full
                runqueue.insert(next);
                break;
            }
        }

        // 4. Notify kernel and sleep briefly until next scheduling tick
        bpf.notify_complete(runqueue.len() as u64);
    }
}

4. Multi-CCX L3 Cache Locality & Production scx Schedulers

On modern chiplet processors—such as AMD Ryzen 9 7950X / 9950X (2 Core Complex Dies / CCDs, each with 8 cores sharing an isolated 32 MB L3 cache) or AMD EPYC Genoa / Turin (up to 12–16 CCDs per socket)—migrating a thread across CCD boundaries over the Infinity Fabric incurs a ~65–75ns cache coherency penalty compared to a ~10ns intra-CCD L3 hit.

Multi-CCX L3 Cache Locality & Tail Latency Benchmark

Before writing your own scheduler from scratch, the official sched-ext/scx project provides battle-tested schedulers optimized for distinct hardware topologies and workload profiles:

  1. scx_lavd (Latency-Aware Virtual Deadline): Designed for interactive workloads, low-latency audio/video, and real-time microservices under heavy background compilation. It dynamically computes a Latency Criticality Score for every thread based on its wakeup frequency ($f_{\text{wake}}$), wait frequency ($f_{\text{wait}}$), and runtime burstiness:

    $$\text{Latency}{\text{crit}}(p) = f{\text{wake}}(p) \times f_{\text{wait}}(p) \times \frac{1}{\overline{T_{\text{runtime}}(p)}}$$

    Threads with high $\text{Latency}_{\text{crit}}(p)$ receive earlier virtual deadlines and stay pinned inside their warm L3 CCD domain (Core Compaction), while batch compiler threads are quarantined to secondary CCDs.

  2. scx_bpfland: A pure-BPF vtime scheduler that classifies interactive vs. batch threads using voluntary context switch rates (nvcsw) with strict L2/L3 cache locality awareness.

  3. scx_rusty: A multi-CCX / multi-NUMA load-balancing scheduler designed for dense AMD EPYC and dual-socket Xeon servers. A fast in-kernel BPF dispatcher handles per-CCD DSQs while a userspace Rust load balancer periodically inspects DSQ saturation and migrates tasks across LLC domains only when work imbalance exceeds configurable thresholds.

  4. scx_layered: Meta’s production fleet scheduler that allows operators to define declarative JSON/YAML scheduling layers (matched by cgroup v2 path, process name, or UID) and guarantee dedicated LLC cache domains or preemption priority to critical service tiers.

  5. scx_flash: Designed for multimedia and real-time token/frame streaming workloads requiring deterministic packet pacing.


5. Benchmark: EEVDF vs. scx_lavd Under 100% All-Core Load

We tested a 16-core / 32-thread AMD Ryzen 9 7950X workstation (Dual 8-core CCDs, 64 MB total L3 cache, Linux 6.12.8) running a real-time WebSocket/gRPC microservice benchmark (wrk2 at 25,000 req/s) while simultaneously saturating all 32 hardware threads with a background make -j32 Linux kernel compilation:

Scheduler (Linux 6.12.8) Background Build Time L3 Cache Hit Rate Service p50 Latency Service p99 Latency Service p99.9 Latency
Default Kernel EEVDF 58.4s 71.2% 1.8 ms 28.4 ms 114.2 ms
scx_bpfland (Pure BPF) 59.8s (+2.3%) 94.6% 1.1 ms 5.6 ms 14.8 ms (-87%)
scx_rustland (Userspace Rust) 60.4s (+3.4%) 95.1% 0.9 ms 4.1 ms 9.2 ms (-92%)
scx_lavd (Latency-Critical) 60.1s (+2.9%) 97.8% 0.8 ms 2.9 ms 6.4 ms (-94%)

Trading less than 3% of batch background compile throughput, scx_lavd slashed p99.9 tail latency by 94% (from 114.2 ms down to 6.4 ms) and boosted L3 cache hit rate from 71.2% to 97.8%—without a single kernel reboot, isolcpus boot flag, or manual taskset wrapper.


6. Operational Deployment & Observability Playbook

To deploy and monitor sched_ext schedulers in production on Linux 6.12+ nodes:

# 1. Verify your running kernel was compiled with CONFIG_SCHED_CLASS_EXT=y
zgrep CONFIG_SCHED_CLASS_EXT /proc/config.gz || grep CONFIG_SCHED_CLASS_EXT /boot/config-$(uname -r)
# Expected: CONFIG_SCHED_CLASS_EXT=y

# 2. Hot-load scx_lavd in performance mode (or run via scx_loader.service DBus daemon)
sudo scx_lavd --performance &

# 3. Verify sched_ext is enabled and inspect the active struct_ops scheduler
cat /sys/kernel/sched_ext/state
# Output: enabled

cat /sys/kernel/sched_ext/root/ops
# Output: lavd

# 4. Monitor live DSQ depths and BPF struct_ops link attachment with bpftool
sudo bpftool struct_ops show name lavd

If you ever need to emergency-evict a custom scheduler during an incident without waiting for the watchdog timeout, either send SIGINT (Ctrl+C) to the userspace loader or trigger the kernel SysRq hook (echo S | sudo tee /proc/sysrq-trigger), and all threads will instantaneously return to EEVDF.

// TACTICAL SIMULATION // KERNEL SCHED_EXT DSQ LAB

Interactive sched_ext (scx) Tail-Latency & Watchdog Simulator

Hot-swap between Linux EEVDF, scx_bpfland, scx_lavd, and custom Rustland schedulers under variable all-core build saturation, tune DSQ time slices, and inject BPF watchdog stalls in real time.

1. Select Active Kernel Scheduling Policy (struct_ops)/sys/kernel/sched_ext/state: enabled
Background Load (make -j):32 Threads
4 (Light)32 (100% Core Sat)64 (2x Oversub)
DSQ Time Slice (SLICE_NS):2.0 ms
0.5ms (Ultra-Crisp)2.0ms (Default)6.0ms (Batch)
Service p50 Latency
0.94 ms
Interactive Wakeup Path
Service p99 Latency
4.3 ms
Slice: 2,000,000 ns
Service p99.9 Tail
13.3 ms
-93% vs Default EEVDF
L3 Cache Hit / Build
94.7% (58.8s)
CCD Local DSQ Pinned

// ABOUT THE AUTHOR

JC

Joshua Edward McLaughlin Cox

Technomancer & Systems Architect

Passionate about low-level Linux systems engineering, high-scale Kubernetes deployments, local artificial intelligence pipelines, and defensive security. Building robust, sovereign computing environments that stand the test of time.