Skip to main content

scx_mlfq/
main.rs

1// SPDX-License-Identifier: GPL-2.0
2//
3// Copyright (c) 2026 Galih Tama <galpt@v.recipes>
4//
5// This software may be used and distributed according to the terms of the GNU
6// General Public License version 2.
7
8//! scx_mlfq, a Multilevel Feedback Queue scheduler for sched_ext.
9//!
10//! Per-CPU, virtual-time-ordered user DSQs (Q1/Q2/Q3 per CPU) over an EEVDF
11//! virtual-time substrate. Tasks are classified into queues by a regression
12//! tree that predicts the next CPU burst from per-task features (see
13//! mlfq_tree.rs), with the EMA interactivity gauge as a tree feature and the
14//! fallback before the first model. The wakeup path is promotion-only,
15//! through the tree, the short-sleep and I/O boost and the band hysteresis.
16//! Demotion flows through the run-out gate. See README.md for the design
17//! overview.
18
19mod bpf_skel;
20pub use bpf_skel::*;
21pub mod bpf_intf;
22pub use bpf_intf::*;
23
24mod alloc;
25mod config;
26mod mlfq_tree;
27mod stats;
28mod topology;
29
30#[cfg(feature = "count_alloc")]
31#[global_allocator]
32static ALLOC: alloc::TrackingAllocator = alloc::TrackingAllocator;
33
34mod webui;
35
36use std::collections::HashMap;
37use std::collections::HashSet;
38use std::collections::VecDeque;
39use std::mem::MaybeUninit;
40use std::mem::size_of;
41use std::os::fd::AsFd;
42use std::os::fd::AsRawFd;
43use std::sync::Arc;
44use std::sync::atomic::AtomicBool;
45use std::sync::atomic::Ordering;
46use std::time::Duration;
47
48use anyhow::Result;
49use clap::CommandFactory;
50use clap::Parser;
51use clap_complete::Shell;
52use clap_complete::generate;
53use crossbeam::channel::RecvTimeoutError;
54use libbpf_rs::AsRawLibbpf;
55use libbpf_rs::MapCore;
56use log::info;
57use scx_stats::prelude::*;
58use scx_utils::UserExitInfo;
59use scx_utils::build_id;
60use scx_utils::compat;
61use scx_utils::libbpf_clap_opts::LibbpfOpts;
62use scx_utils::pm;
63use scx_utils::scx_ops_attach;
64use scx_utils::scx_ops_load;
65use scx_utils::scx_ops_open;
66use scx_utils::try_set_rlimit_infinity;
67use scx_utils::uei_exited;
68use scx_utils::uei_report;
69
70use config::Config;
71use mlfq_tree::FitScratch;
72use mlfq_tree::TreeSample;
73use stats::Metrics;
74
75const SCHEDULER_NAME: &str = "scx_mlfq";
76
77/* Time units from src/bpf/intf.h, used for the gauge unit conversions. */
78const NSEC_PER_USEC: u64 = crate::bpf_intf::mlfq_consts_NSEC_PER_USEC as u64;
79
80/* MLFQ tree daemon tuning, from src/bpf/intf.h. */
81const MLFQ_TREE_MAX_NODES: usize = crate::bpf_intf::mlfq_consts_MLFQ_TREE_MAX_NODES as usize;
82const MLFQ_TREE_MAX_DEPTH: usize = crate::bpf_intf::mlfq_consts_MLFQ_TREE_MAX_DEPTH as usize;
83const MLFQ_TREE_MIN_SAMPLES: usize = crate::bpf_intf::mlfq_consts_MLFQ_TREE_MIN_SAMPLES as usize;
84
85/* Compile-time bounds from src/bpf/intf.h, used by the web metrics. */
86const MLFQ_MAX_CPUS: usize = crate::bpf_intf::mlfq_consts_MLFQ_MAX_CPUS as usize;
87
88/*
89 * Web-UI runnable gauges. The BPF side maintains `mlfq_llc_runnable`,
90 * `mlfq_queue_runnable` and `mlfq_llc_idle` in the bss block directly
91 * before `mlfq_stats` (the declaration order keeps the published-tree
92 * control line isolated, see main.bpf.c); the generated bss type carries
93 * the arrays, so the web metrics read them as typed fields.
94 */
95
96/*
97 * Training-window cap, in samples. Eight retrain generations at the
98 * 2048-sample minimum; a sliding window keeps the model pinned to the
99 * recent workload instead of a lifetime aggregate. Daemon-side tuning
100 * constant, not a user knob.
101 */
102const MLFQ_TREE_WINDOW_MAX: usize = 16384;
103
104/*
105 * Per-pid share cap of the training window. The BPF-side emission budget
106 * is per task (MLFQ_TREE_PER_TASK_LIMIT_NS), so a process with enough
107 * threads can still fill the whole window with its own samples and
108 * over-fit the tree to its own behavior; the daemon therefore caps each
109 * pid at ~5% of the window (MLFQ_TREE_WINDOW_MAX / 20), drops the excess
110 * at ingest and counts the drops separately. Daemon-side security
111 * constant, not a user knob.
112 */
113const MLFQ_TREE_PER_PID_CAP: u32 = (MLFQ_TREE_WINDOW_MAX / 20) as u32;
114
115/*
116 * Minimum number of distinct pids the fit slice must contain before a
117 * model may be published: a tree fit on samples from a handful of pids
118 * would over-fit those tasks' behavior. A rejected model keeps the
119 * previous one committed, and the retrain cadence already prevents a
120 * rejection from turning into a per-sample retrain storm. Daemon-side
121 * constant, not a user knob.
122 */
123const MLFQ_TREE_MIN_PIDS: usize = 8;
124
125/* CART growth caps for the daemon's training runs. */
126const MLFQ_TREE_MIN_LEAF: usize = 32;
127const MLFQ_TREE_RETRAIN_INTERVAL: Duration = Duration::from_secs(60);
128
129/*
130 * PM QoS idle-resume-latency cap in microseconds, applied to
131 * /dev/cpu_dma_latency for the duration of the run. 10 us bans the deep
132 * core and package C-states (18 us and 350 us exits on the target) while
133 * keeping C1 (1 us), so wakeup latency is not dominated by deep-state
134 * exits. An environmental power/latency tradeoff made automatically, not
135 * a user knob.
136 */
137const MLFQ_IDLE_RESUME_LATENCY_US: i32 = 10;
138
139fn full_version() -> String {
140    build_id::full_version(env!("CARGO_PKG_VERSION"))
141}
142
143#[derive(Debug, Parser)]
144#[command(name = SCHEDULER_NAME, version, disable_version_flag = true)]
145struct Opts {
146    /// Enable periodic statistics monitoring at the given interval.
147    #[clap(long)]
148    stats: Option<f64>,
149
150    /// Run in statistics monitoring mode; the scheduler is not launched.
151    #[clap(long)]
152    monitor: Option<f64>,
153
154    /// Enable verbose libbpf/BPF debug logging.
155    #[clap(short = 'd', long, action = clap::ArgAction::SetTrue)]
156    debug: bool,
157
158    /// Enable verbose output, including libbpf details.
159    #[clap(short = 'v', long, action = clap::ArgAction::SetTrue)]
160    verbose: bool,
161
162    /// Size of the exit dump buffer in bytes; the kernel fills it with
163    /// per-CPU and per-task state when the scheduler exits on an error.
164    #[clap(long, default_value = "1048576")]
165    exit_dump_len: u32,
166
167    /// Print scheduler version and exit.
168    #[clap(short = 'V', long, action = clap::ArgAction::SetTrue)]
169    version: bool,
170
171    /// Show descriptions for statistics.
172    #[clap(long)]
173    help_stats: bool,
174
175    /// Generate shell completions and exit.
176    #[clap(long, value_name = "SHELL", hide = true)]
177    completions: Option<Shell>,
178
179    /// Disable the loopback web UI. The UI binds [::1]:50005 (falling
180    /// back to 127.0.0.1:50005, then to the /tmp/scx_mlfq.sock unix
181    /// socket when the loader sandbox blocks TCP) and is unauthenticated:
182    /// the loopback address is the localhost trust boundary, and the
183    /// counters it exposes are already world-readable through the stats
184    /// server. This flag skips the thread entirely.
185    #[clap(long = "no-webui", action = clap::ArgAction::SetTrue)]
186    no_webui: bool,
187
188    #[clap(flatten, next_help_heading = "Libbpf Options")]
189    libbpf: LibbpfOpts,
190}
191
192/// Metadata of the committed MLFQ tree model, reported to the stats
193/// server and the exit log. Defaults describe the untrained state.
194#[derive(Clone, Copy, Debug, Default)]
195struct ModelMeta {
196    /// Monotonic publish generation; 0 while untrained.
197    generation: u64,
198    /// Training samples behind the committed model (the fit slice).
199    nr_samples: usize,
200    /// Nodes of the committed tree.
201    nr_nodes: usize,
202    /// MAE of the tree on the held-out slice of its training window, in
203    /// microseconds.
204    mae_tree_us: u64,
205    /// MAE of the per-sample EMA baseline on the same held-out slice, in
206    /// microseconds.
207    mae_ema_us: u64,
208    /// Pearson correlation of the tree predictions and the labels on the
209    /// held-out slice.
210    corr: f64,
211}
212
213/// The Scheduler facade owns the loaded skeleton, the struct_ops link, the
214/// stats server and the MLFQ tree daemon state; drives the run loop
215/// until shutdown or UEI exit.
216struct Scheduler<'a> {
217    skel: BpfSkel<'a>,
218    struct_ops: Option<libbpf_rs::Link>,
219    stats_server: StatsServer<(), Metrics>,
220    /*
221     * Web UI plumbing: the metrics sender (None when --no-webui, in
222     * which case the webui thread is never spawned and the metrics are
223     * never collected) and the once-per-attach per-CPU static seed
224     * (freq, LLC, SMT) the web metrics merge the dynamic BPF state into.
225     */
226    webui_tx: Option<crossbeam::channel::Sender<stats::WebMetrics>>,
227    webui_join: Option<std::thread::JoinHandle<()>>,
228    cpu_static: Vec<stats::PerCpuMetrics>,
229    /*
230     * Per-CPU current-frequency cache for the web UI, refreshed from
231     * sysfs at most once per second in the run loop. The values ride
232     * in the pushed snapshots, so a snapshot never reaches the UI with
233     * a zero current frequency because a refresh was throttled.
234     */
235    cur_freq_khz: Vec<u64>,
236    freq_read_at: Option<std::time::Instant>,
237    started_at: std::time::Instant,
238    /*
239     * PM QoS idle-latency constraint on /dev/cpu_dma_latency, held for
240     * the run; closing the file restores the previous constraint. The
241     * field is never read: the file is held for its Drop side effect,
242     * which releases the constraint on every exit path.
243     */
244    #[expect(dead_code)]
245    pm_qos_fd: Option<std::fs::File>,
246    /*
247     * MLFQ tree daemon state: the sample ring buffer, the parsed-sample
248     * channel the ring-buffer callback fills, the sliding training
249     * window, the retrain cadence, the committed-model metadata and the
250     * training worker channels (the fit runs off the main loop; the
251     * publish stays on it).
252     */
253    rb_mgr: libbpf_rs::RingBuffer<'static>,
254    sample_rx: crossbeam::channel::Receiver<TreeSample>,
255    window: VecDeque<TreeSample>,
256    /*
257     * Per-pid accounting of the training window: the counts track the
258     * admitted samples of each pid so no single task can own more than
259     * MLFQ_TREE_PER_PID_CAP of the window, and the drop counter records
260     * the samples the cap rejected at ingest.
261     */
262    pid_counts: HashMap<u32, u32>,
263    tree_samples_cap_dropped: u64,
264    last_train_at: Option<std::time::Instant>,
265    train_tx: crossbeam::channel::Sender<Vec<TreeSample>>,
266    train_rx: crossbeam::channel::Receiver<Result<TrainResult, anyhow::Error>>,
267    model: ModelMeta,
268    // Zero-allocation reuse buffers. All Vecs are pre-reserved to their
269    // maximum capacity at init and reused via clear+extend, so the 100 ms
270    // hot path never triggers a heap allocation after the first iteration.
271    train_snapshot_buf: Vec<TreeSample>,
272    web_statics_buf: Vec<Option<stats::PerCpuMetrics>>,
273    web_per_cpu_buf: Vec<stats::PerCpuMetrics>,
274    #[allow(dead_code)]
275    op_lat_buf: Vec<u64>,
276    wakeup_raw_buf: Vec<u8>,
277    op_lat_raw_buf: Vec<u8>,
278}
279
280impl<'a> Scheduler<'a> {
281    fn init(
282        opts: &'a Opts,
283        open_object: &'a mut MaybeUninit<libbpf_rs::OpenObject>,
284        shutdown: Arc<AtomicBool>,
285    ) -> Result<Self> {
286        try_set_rlimit_infinity();
287
288        let mut skel_builder = BpfSkelBuilder::default();
289        skel_builder.obj_builder.debug(opts.debug || opts.verbose);
290
291        let open_opts = opts.libbpf.clone().into_bpf_open_opts();
292        let mut skel = scx_ops_open!(skel_builder, open_object, mlfq_ops, open_opts)?;
293
294        // Write the validated constants into rodata before load; the rodata
295        // section becomes read-only once the object is loaded.
296        let config = Config::default();
297        config.validate()?;
298        config.apply(&mut skel)?;
299        info!("Config: {}", config.describe());
300
301        // Hybrid-capacity, cache-domain and NUMA placement data also goes
302        // into rodata pre-load.
303        let topology_plan = topology::init_topology(&mut skel)?;
304
305        /*
306         * Ops flags: honor exiting tasks, receive the SCX_ENQ_LAST enqueue
307         * for the last runnable task on a CPU, never migrate
308         * migration-disabled tasks, and allow queued-wakeup selection of
309         * idle CPUs (the idle-CPU fast path depends on the latter two).
310         *
311         * The built-in idle tracking is kept (SCX_OPS_KEEP_BUILTIN_IDLE)
312         * and ops.update_idle is registered to maintain the scheduler's
313         * own idle-CPU count, which lets select_cpu() skip its idle scans
314         * when the system is saturated. The flag gates the callback: a
315         * registered update_idle without the flag would disable the
316         * kernel's built-in idle tracking that scx_bpf_pick_idle_cpu()
317         * and scx_bpf_test_and_clear_cpu_idle() rely on, so on kernels
318         * without the flag the callback is left unregistered and the
319         * lean path stays off (mlfq_idle_tracking remains 0).
320         */
321        let mut flags = *compat::SCX_OPS_ENQ_EXITING
322            | *compat::SCX_OPS_ENQ_LAST
323            | *compat::SCX_OPS_ENQ_MIGRATION_DISABLED
324            | *compat::SCX_OPS_ALLOW_QUEUED_WAKEUP;
325        if *compat::SCX_OPS_KEEP_BUILTIN_IDLE != 0 {
326            flags |= *compat::SCX_OPS_KEEP_BUILTIN_IDLE;
327            skel.maps
328                .rodata_data
329                .as_mut()
330                .expect("rodata missing, the BPF object has no .rodata section")
331                .mlfq_idle_tracking = 1;
332        } else {
333            skel.struct_ops.mlfq_ops_mut().update_idle = std::ptr::null_mut();
334        }
335        skel.struct_ops.mlfq_ops_mut().flags = flags;
336
337        /*
338         * Error exits capture the per-CPU and per-task state dump into
339         * the exit report; without a buffer the kernel skips the dump
340         * entirely, so a stall or a placement failure would leave no
341         * evidence of where the task was parked.
342         */
343        skel.struct_ops.mlfq_ops_mut().exit_dump_len = opts.exit_dump_len;
344
345        /*
346         * The sched_switch hook tracks realtime-class occupancy and
347         * attempts the takeover drain. It is needed on every kernel.
348         * The occupancy flag drives placement even where the drain
349         * cannot run, so the optional tracepoint program is
350         * force-enabled here; the evacuation branches inside are
351         * ksym-gated and self-prune on kernels without the reenqueue
352         * kfuncs. The flip side of forcing it is that a kernel which
353         * rejects the hook at verification fails the whole load
354         * instead of degrading gracefully: the kfunc calls it makes
355         * have been in the tracing kfunc set since 6.18, but any new
356         * kernel that drops one of them must be tested before release.
357         */
358        unsafe {
359            libbpf_rs::libbpf_sys::bpf_program__set_autoload(
360                skel.progs.mlfq_sched_switch.as_libbpf_object().as_ptr(),
361                true,
362            );
363        }
364        /*
365         * GPU tracepoints: raw tracepoints without BTF, optional.
366         * SEC("tracepoint/...") without "?" and without tp_btf uses the
367         * raw tracepoint and does not require BTF for module tracepoints.
368         * Keep them optional by disabling autoload when tracefs is absent,
369         * so load never hard-fails. The three handlers are amdgpu_cs,
370         * amdgpu_cs_ioctl and gpu_scheduler/drm_sched_job_queue, covering
371         * AMD and nouveau (gpu_sched).
372         */
373        {
374            let has = |p1: &str, p2: &str| {
375                std::path::Path::new(p1).exists() || std::path::Path::new(p2).exists()
376            };
377            if !has(
378                "/sys/kernel/debug/tracing/events/amdgpu/amdgpu_cs",
379                "/sys/kernel/tracing/events/amdgpu/amdgpu_cs",
380            ) {
381                skel.progs.mlfq_amdgpu_cs.set_autoload(false);
382            }
383            if !has(
384                "/sys/kernel/debug/tracing/events/amdgpu/amdgpu_cs_ioctl",
385                "/sys/kernel/tracing/events/amdgpu/amdgpu_cs_ioctl",
386            ) {
387                skel.progs.mlfq_amdgpu_cs_ioctl.set_autoload(false);
388            }
389            if !has(
390                "/sys/kernel/debug/tracing/events/gpu_scheduler/drm_sched_job_queue",
391                "/sys/kernel/tracing/events/gpu_scheduler/drm_sched_job_queue",
392            ) {
393                skel.progs.mlfq_gpu_sched_queue.set_autoload(false);
394            }
395        }
396
397        let mut skel = scx_ops_load!(skel, mlfq_ops, uei)?;
398
399        // The membership bitmaps are written after load (the maps are only
400        // available on the loaded object). An unpopulated primary bitmap
401        // falls back to all-primary behavior; an empty LLC bitmap yields
402        // no idle candidate there, so a failure only degrades the
403        // placement hint.
404        if let Err(e) = topology::write_primary_bitmap(&mut skel, &topology_plan.capacity) {
405            log::warn!(
406                "failed to write the primary bitmap, falling back to all-primary placement: {e:#}"
407            );
408        }
409        if let Err(e) = topology::write_llc_bitmaps(&mut skel, &topology_plan.llcs) {
410            log::warn!("failed to write the LLC bitmaps, disabling LLC-aware placement: {e:#}");
411        }
412        // The per-LLC CPU lists feed the dispatch Tier-A same-LLC steal
413        // scan. A list-write failure degrades *stealing* only: the
414        // placement bitmaps above stay live, so the two fallbacks are
415        // independent and the scheduler keeps its placement hints.
416        if let Err(e) = topology::write_llc_cpu_lists(&mut skel, &topology_plan.llcs) {
417            log::warn!(
418                "failed to write the per-LLC CPU lists, disabling LLC-aware stealing: {e:#}"
419            );
420        }
421
422        // GPU tracepoint availability: seed the BPF mask from actual
423        // attach success (autoload after load), not just tracefs existence,
424        // so the web UI reflects whether the handlers are really attached.
425        // The BPF handlers also OR the bits on every gpu_submit bump, so
426        // the mask stays correct even if the probe races a first event.
427        {
428            let mut mask: u32 = 0;
429            if skel.progs.mlfq_amdgpu_cs.autoload() {
430                mask |= crate::bpf_intf::MLFQ_GPU_TRACE_AMDGPU_CS;
431            }
432            if skel.progs.mlfq_amdgpu_cs_ioctl.autoload() {
433                mask |= crate::bpf_intf::MLFQ_GPU_TRACE_AMDGPU_CS_IOCTL;
434            }
435            if skel.progs.mlfq_gpu_sched_queue.autoload() {
436                mask |= crate::bpf_intf::MLFQ_GPU_TRACE_GPU_SCHED;
437            }
438            if mask != 0
439                && let Some(bss) = skel.maps.bss_data.as_mut()
440            {
441                bss.mlfq_gpu_trace_mask |= mask;
442            }
443        }
444
445        let struct_ops = scx_ops_attach!(skel, mlfq_ops)?;
446
447        /*
448         * PM QoS: hold a global idle-resume-latency constraint on
449         * /dev/cpu_dma_latency for the duration of the run, so the
450         * cpuidle governor keeps the CPUs in the shallowest idle states
451         * that fit the cap and wakeup latency is not dominated by the
452         * deep C-state exits. Closing the file on exit restores the
453         * previous latency. The capability check and the write are
454         * best-effort: the scheduler must run regardless, and the BPF-side
455         * placement remains the fallback on a system without PM QoS.
456         */
457        let pm_qos_fd = if pm::cpu_idle_resume_latency_supported() {
458            match pm::update_global_idle_resume_latency(MLFQ_IDLE_RESUME_LATENCY_US) {
459                Ok(f) => {
460                    info!(
461                        "PM QoS idle resume latency held at {}us",
462                        MLFQ_IDLE_RESUME_LATENCY_US
463                    );
464                    Some(f)
465                }
466                Err(e) => {
467                    log::warn!("failed to set the PM QoS idle resume latency: {e:#}");
468                    None
469                }
470            }
471        } else {
472            log::warn!(
473                "PM QoS idle resume latency is not supported; the constraint is not applied"
474            );
475            None
476        };
477
478        let stats_server = StatsServer::new(stats::server_data()).launch()?;
479
480        /*
481         * The web UI: a small bounded metrics channel (capacity 16;
482         * try_send drops a frame when the buffer is full, so the run
483         * loop never blocks and the buffer never grows) feeding a
484         * detached server thread that exits on the shared shutdown
485         * flag. The per-CPU static seed is captured once here; with
486         * --no-webui neither the thread nor the seed exist.
487         */
488        let (webui_tx, webui_join): (
489            Option<crossbeam::channel::Sender<stats::WebMetrics>>,
490            Option<std::thread::JoinHandle<()>>,
491        ) = if opts.no_webui {
492            (None, None)
493        } else {
494            let (tx, rx) = crossbeam::channel::bounded::<stats::WebMetrics>(16);
495            let shutdown = shutdown.clone();
496            let jh = std::thread::spawn(move || {
497                webui::start(rx, shutdown);
498            });
499            (Some(tx), Some(jh))
500        };
501        let cpu_static = if opts.no_webui {
502            Vec::new()
503        } else {
504            topology::web_cpu_static()
505        };
506
507        /*
508         * The training-sample ring buffer: the callback parses each
509         * record as the mlfq_tree_sample mirror and forwards it into a
510         * bounded channel the run loop drains. try_send drops the sample
511         * when the channel is full, which the ring-buffer backpressure
512         * absorbs first. TreeSample is a repr(C) POD mirroring the
513         * 84-byte BPF record (1.3.11 ABI), so the parse is a plain byte
514         * reinterpretation. The record's version tag is checked before
515         * the record is admitted, so a record from a foreign producer or
516         * a mismatched build is dropped instead of misread.
517         */
518        let (sample_tx, sample_rx) = crossbeam::channel::bounded(4096);
519        let mut rb_builder = libbpf_rs::RingBufferBuilder::new();
520        rb_builder.add(&skel.maps.mlfq_samples, move |data| {
521            if data.len() < size_of::<TreeSample>() {
522                return 0;
523            }
524            // SAFETY: TreeSample is a repr(C) mirror of the 84-byte
525            // mlfq_tree_sample the stopping path submits; reading the
526            // record as the struct is a plain byte reinterpretation of
527            // integer fields.
528            let s = unsafe { std::ptr::read_unaligned(data.as_ptr().cast::<TreeSample>()) };
529            if !mlfq_tree::sample_version_matches(&s) {
530                return 0;
531            }
532            let _ = sample_tx.try_send(s);
533            0
534        })?;
535        let rb_mgr = rb_builder.build()?;
536
537        /*
538         * The training worker: the fit and the metrics computation run on
539         * a dedicated thread so a retrain never stalls the main loop's
540         * stats and drain cadence. The main loop hands over a snapshot
541         * of the window (the window is only mutated by the ingest on the
542         * main thread, so taking a snapshot cannot race with the ingest) and
543         * the worker sends the result back over a channel. try_send drops
544         * a kick when the previous fit is still in flight, which the 60 s
545         * cadence makes rare.
546         */
547        let (train_tx, train_rx) = spawn_train_worker();
548
549        Ok(Self {
550            skel,
551            struct_ops: Some(struct_ops),
552            stats_server,
553            webui_tx,
554            webui_join,
555            cpu_static,
556            cur_freq_khz: Vec::with_capacity(MLFQ_MAX_CPUS),
557            freq_read_at: None,
558            started_at: std::time::Instant::now(),
559            pm_qos_fd,
560            rb_mgr,
561            sample_rx,
562            window: VecDeque::with_capacity(MLFQ_TREE_WINDOW_MAX),
563            pid_counts: HashMap::with_capacity(2048),
564            tree_samples_cap_dropped: 0,
565            last_train_at: None,
566            train_tx,
567            train_rx,
568            model: ModelMeta::default(),
569            train_snapshot_buf: Vec::with_capacity(MLFQ_TREE_WINDOW_MAX),
570            web_statics_buf: Vec::with_capacity(MLFQ_MAX_CPUS),
571            web_per_cpu_buf: Vec::with_capacity(MLFQ_MAX_CPUS),
572            op_lat_buf: Vec::with_capacity(
573                crate::bpf_intf::mlfq_op_lat_slots_MLFQ_OP_LAT_OPS as usize
574                    * crate::bpf_intf::mlfq_op_lat_consts_MLFQ_OP_LAT_BUCKETS as usize,
575            ),
576            wakeup_raw_buf: Vec::with_capacity(16 * MLFQ_MAX_CPUS),
577            op_lat_raw_buf: Vec::with_capacity(64 * MLFQ_MAX_CPUS),
578        })
579    }
580
581    fn get_metrics(&mut self) -> Metrics {
582        let op_lat = self.read_op_lat();
583        let wakeup_total = self.read_wakeup_total();
584        let bss_data = self
585            .skel
586            .maps
587            .bss_data
588            .as_ref()
589            .expect("bss_data missing, the BPF object has no .bss section");
590        let s = &bss_data.mlfq_stats;
591        let g = &bss_data.mlfq_sys_gauge;
592        let a = &bss_data.mlfq_adapt_state;
593        Metrics {
594            on_cpu: s.on_cpu,
595            total_runtime: s.total_runtime,
596            uptime_ns: self.started_at.elapsed().as_nanos() as u64,
597            q1_placements: s.q1_placements,
598            q2_placements: s.q2_placements,
599            q3_placements: s.q3_placements,
600            promotions: s.promotions,
601            demotions: s.demotions,
602            aging_boosts: s.aging_boosts,
603            short_sleep_boosts: s.short_sleep_boosts,
604            preemption_kicks: s.preemption_kicks,
605            cpuperf_boosts: s.cpuperf_boosts,
606            steals: s.steals,
607            steals_same_llc: s.steals_same_llc,
608            steals_cross_llc: s.steals_cross_llc,
609            keep_running: s.keep_running,
610            enq_no_tctx: s.enq_no_tctx,
611            enq_bad_weight: s.enq_bad_weight,
612            enq_no_deadline: s.enq_no_deadline,
613            enq_fastpath: s.enq_fastpath,
614            enq_regular: s.enq_regular,
615            enq_pinned_idle: s.enq_pinned_idle,
616            enq_pinned_busy: s.enq_pinned_busy,
617            enq_pinned_global: s.enq_pinned_global,
618            tree_inference: s.tree_inference,
619            tree_fallback: s.tree_fallback,
620            tree_disagree: s.tree_disagree,
621            tree_samples_emitted: s.tree_samples_emitted,
622            tree_samples_dropped: s.tree_samples_dropped,
623            rt_takeovers: s.rt_takeovers,
624            rt_evacuations: s.rt_evacuations,
625            rt_redirects: s.rt_redirects,
626            rt_reenqs: s.rt_reenqs,
627            op_lat,
628            tree_samples_cap_dropped: self.tree_samples_cap_dropped,
629            tree_model_generation: self.model.generation,
630            tree_model_nodes: self.model.nr_nodes as u64,
631            tree_model_samples: self.model.nr_samples as u64,
632            tree_mae_tree_us: self.model.mae_tree_us,
633            tree_mae_ema_us: self.model.mae_ema_us,
634            tree_corr_milli: (self.model.corr * 1000.0).round() as i64,
635            sys_lat_ema_us: g.lat_ema / NSEC_PER_USEC,
636            sys_rate_ema: g.rate_ema,
637            t_l_eff_us: a.t_l_eff_ns / NSEC_PER_USEC,
638            t_h_eff_us: a.t_h_eff_ns / NSEC_PER_USEC,
639            t_int_eff_us: a.t_int_eff_ns / NSEC_PER_USEC,
640            t_bnd_eff_us: a.t_bnd_eff_ns / NSEC_PER_USEC,
641            guard_eff_us: a.guard_eff_ns / NSEC_PER_USEC,
642            adapt_shift: a.shift_fp,
643            wakeup_total,
644            adapt_steps: u64::from(g.adapt_steps),
645        }
646    }
647
648    /// Web-metrics snapshot. The raw scheduler counters plus the per-CPU
649    /// state and the runnable gauges, pushed to the web UI every run-loop
650    /// iteration. Gauges only, no interval deltas.
651    fn get_web_metrics(&mut self) -> stats::WebMetrics {
652        // Refresh the per-CPU current frequencies at most once per
653        // second, so the sysfs reads cannot grow with the push cadence.
654        // Copy the CPU count first so the bss_data borrow does not overlap
655        // the later mutable borrow for get_metrics.
656        let nr_cpus_bss = self
657            .skel
658            .maps
659            .bss_data
660            .as_ref()
661            .expect("bss_data missing, the BPF object has no .bss section")
662            .nr_cpu_ids as usize;
663        let now = std::time::Instant::now();
664        if self
665            .freq_read_at
666            .is_none_or(|t| now.duration_since(t).as_secs() >= 1)
667        {
668            let nr = nr_cpus_bss.min(MLFQ_MAX_CPUS);
669            self.cur_freq_khz.clear();
670            self.cur_freq_khz.reserve(nr);
671            for cpu in 0..nr {
672                self.cur_freq_khz
673                    .push(topology::current_freq_khz(cpu as u32));
674            }
675            self.freq_read_at = Some(now);
676        }
677        let bss_data = self
678            .skel
679            .maps
680            .bss_data
681            .as_ref()
682            .expect("bss_data missing, the BPF object has no .bss section");
683
684        // Merge the per-CPU dynamic state (running queue, running pid,
685        // realtime occupancy) from the per-CPU maps into the once-per-
686        // attach static seed (freq, LLC, SMT). One entry per CPU in
687        // bss_data.nr_cpu_ids, capped at MLFQ_MAX_CPUS.
688        let nr_cpus = (bss_data.nr_cpu_ids as usize).min(MLFQ_MAX_CPUS);
689        // Reuse the statics scratch buffer. Capacity is MLFQ_MAX_CPUS, so
690        // no allocation after the first iteration.
691        self.web_statics_buf.clear();
692        self.web_statics_buf.resize_with(nr_cpus, || None);
693        for s in &self.cpu_static {
694            if (s.id as usize) < nr_cpus {
695                self.web_statics_buf[s.id as usize] = Some(s.clone());
696            }
697        }
698        self.web_per_cpu_buf.clear();
699        for (cpu, static_entry) in self.web_statics_buf.iter().enumerate().take(nr_cpus) {
700            let mut entry = static_entry.clone().unwrap_or_default();
701            entry.id = cpu as u32;
702            entry.cur_freq_khz = self.cur_freq_khz.get(cpu).copied().unwrap_or(0);
703
704            let state = self.read_cpu_state_noalloc(cpu);
705            entry.running_queue = state.running_queue;
706            entry.running_pid = state.running_pid;
707            entry.running_gpu_submit = state.running_gpu_submit;
708
709            let rt = self.read_rtdl_state_noalloc(cpu);
710            entry.rt_occupied = rt.flags & crate::bpf_intf::MLFQ_RTDL_OCCUPIED != 0;
711            self.web_per_cpu_buf.push(entry);
712        }
713        // Move the scratch buffer into per_cpu without allocating a new Vec
714        // by swapping. The scratch is left empty but retains capacity.
715        let mut per_cpu = Vec::with_capacity(nr_cpus);
716        std::mem::swap(&mut per_cpu, &mut self.web_per_cpu_buf);
717
718        // Copy the BSS gauges before the mutable borrow for get_metrics
719        // so the borrow checker sees no overlap.
720        let gpu_submit_total = bss_data.mlfq_gpu_submit_total;
721        let gpu_trace_mask = bss_data.mlfq_gpu_trace_mask;
722        let stats = self.get_metrics();
723        stats::WebMetrics {
724            stats,
725            per_cpu,
726            queue_runnable: self.read_queue_runnable(),
727            llc_runnable: self.read_llc_runnable(),
728            gpu_submit_total,
729            gpu_trace_mask,
730        }
731    }
732
733    /// Read one CPU's dynamic state from the per-CPU array map. A failed
734    /// lookup or an unexpected value size yields an all-zero state.
735    #[allow(dead_code)]
736    fn read_cpu_state(&self, cpu: usize) -> mlfq_cpu_state {
737        let key = (cpu as u32).to_ne_bytes();
738        match self
739            .skel
740            .maps
741            .cpu_state_stor
742            .lookup(&key, libbpf_rs::MapFlags::ANY)
743        {
744            Ok(Some(bytes)) if bytes.len() >= size_of::<mlfq_cpu_state>() => {
745                // SAFETY: mlfq_cpu_state is the repr(C) bindgen mirror of
746                // the map's value type; reading the value bytes as the
747                // struct is a plain reinterpretation of integer fields.
748                unsafe { std::ptr::read_unaligned(bytes.as_ptr().cast::<mlfq_cpu_state>()) }
749            }
750            _ => mlfq_cpu_state {
751                running_queue: 0,
752                running_pid: 0,
753                steal_scan_off: 0,
754                cpu_ema: 0,
755                cpu_ema_at: 0,
756                running_deadline: 0,
757                run_start_at: 0,
758                running_gpu_submit: 0,
759                pad2: 0,
760            },
761        }
762    }
763
764    /// Read one CPU's realtime-occupancy state from the per-CPU array
765    /// map; a failed lookup yields an all-zero state (not occupied).
766    #[allow(dead_code)]
767    fn read_rtdl_state(&self, cpu: usize) -> mlfq_rtdl_state {
768        let key = (cpu as u32).to_ne_bytes();
769        match self
770            .skel
771            .maps
772            .rtdl_state_stor
773            .lookup(&key, libbpf_rs::MapFlags::ANY)
774        {
775            Ok(Some(bytes)) if bytes.len() >= size_of::<mlfq_rtdl_state>() => {
776                // SAFETY: as in read_cpu_state: a repr(C) mirror of the
777                // map's value type.
778                unsafe { std::ptr::read_unaligned(bytes.as_ptr().cast::<mlfq_rtdl_state>()) }
779            }
780            _ => mlfq_rtdl_state {
781                flags: 0,
782                pad: 0,
783                last_drain_at: 0,
784            },
785        }
786    }
787
788    /// Tracked runnable tasks per queue (index 0 unused, 1..3 = Q1..Q3).
789    ///
790    /// The gauge is the BPF-side `mlfq_queue_runnable` bss array, which
791    /// counts the runnable tasks placed in each queue's DSQs (the
792    /// accounting contract is in `intf.h` next to the counters). Read
793    /// through the generated bss type, so a layout change is a compile
794    /// error rather than a silent misread.
795    fn read_queue_runnable(&self) -> Vec<u64> {
796        let bss_data = self
797            .skel
798            .maps
799            .bss_data
800            .as_ref()
801            .expect("bss_data missing, the BPF object has no .bss section");
802        bss_data
803            .mlfq_queue_runnable
804            .iter()
805            .map(|v| *v as u64)
806            .collect()
807    }
808
809    /// Tracked runnable tasks per LLC domain (MLFQ_MAX_LLCS entries).
810    /// Same contract and access path as `read_queue_runnable`.
811    fn read_llc_runnable(&self) -> Vec<u64> {
812        let bss_data = self
813            .skel
814            .maps
815            .bss_data
816            .as_ref()
817            .expect("bss_data missing, the BPF object has no .bss section");
818        bss_data
819            .mlfq_llc_runnable
820            .iter()
821            .map(|v| *v as u64)
822            .collect()
823    }
824
825    fn exited(&self) -> bool {
826        uei_exited!(&self.skel, uei)
827    }
828
829    /// Stack-based read of per-CPU state without heap allocation.
830    /// Uses the raw bpf_map_lookup_elem syscall with a stack buffer, so the
831    /// 100 ms web snapshot does not allocate one Vec per CPU.
832    fn read_cpu_state_noalloc(&self, cpu: usize) -> mlfq_cpu_state {
833        if cpu >= MLFQ_MAX_CPUS {
834            return mlfq_cpu_state {
835                running_queue: 0,
836                running_pid: 0,
837                steal_scan_off: 0,
838                cpu_ema: 0,
839                cpu_ema_at: 0,
840                running_deadline: 0,
841                run_start_at: 0,
842                running_gpu_submit: 0,
843                pad2: 0,
844            };
845        }
846        let fd = self.skel.maps.cpu_state_stor.as_fd().as_raw_fd();
847        let key = cpu as u32;
848        let mut out = std::mem::MaybeUninit::<mlfq_cpu_state>::uninit();
849        let ret = unsafe {
850            libbpf_rs::libbpf_sys::bpf_map_lookup_elem(
851                fd,
852                &key as *const _ as *const std::ffi::c_void,
853                out.as_mut_ptr() as *mut std::ffi::c_void,
854            )
855        };
856        if ret == 0 {
857            unsafe { out.assume_init() }
858        } else {
859            mlfq_cpu_state {
860                running_queue: 0,
861                running_pid: 0,
862                steal_scan_off: 0,
863                cpu_ema: 0,
864                cpu_ema_at: 0,
865                running_deadline: 0,
866                run_start_at: 0,
867                running_gpu_submit: 0,
868                pad2: 0,
869            }
870        }
871    }
872
873    /// Stack-based read of RTDL state without heap allocation.
874    fn read_rtdl_state_noalloc(&self, cpu: usize) -> mlfq_rtdl_state {
875        if cpu >= MLFQ_MAX_CPUS {
876            return mlfq_rtdl_state {
877                flags: 0,
878                pad: 0,
879                last_drain_at: 0,
880            };
881        }
882        let fd = self.skel.maps.rtdl_state_stor.as_fd().as_raw_fd();
883        let key = cpu as u32;
884        let mut out = std::mem::MaybeUninit::<mlfq_rtdl_state>::uninit();
885        let ret = unsafe {
886            libbpf_rs::libbpf_sys::bpf_map_lookup_elem(
887                fd,
888                &key as *const _ as *const std::ffi::c_void,
889                out.as_mut_ptr() as *mut std::ffi::c_void,
890            )
891        };
892        if ret == 0 {
893            unsafe { out.assume_init() }
894        } else {
895            mlfq_rtdl_state {
896                flags: 0,
897                pad: 0,
898                last_drain_at: 0,
899            }
900        }
901    }
902
903    /// Sum the per-CPU op-latency histogram into a flat per-op vector
904    /// (MLFQ_OP_LAT_OPS x MLFQ_OP_LAT_BUCKETS entries, op-major). The
905    /// map is per-CPU so the BPF charges never contend. A failed lookup
906    /// or an unexpected value size yields zeros for that entry.
907    #[allow(clippy::chunks_exact_to_as_chunks)]
908    fn read_op_lat(&mut self) -> Vec<u64> {
909        let nr_ops = crate::bpf_intf::mlfq_op_lat_slots_MLFQ_OP_LAT_OPS as usize;
910        let buckets = crate::bpf_intf::mlfq_op_lat_consts_MLFQ_OP_LAT_BUCKETS as usize;
911        // Reuse the scratch buffer. Capacity is pre-reserved, so this
912        // does not allocate after the first call.
913        self.op_lat_buf.clear();
914        self.op_lat_buf.resize(nr_ops * buckets, 0);
915        let nr_cpus = (self
916            .skel
917            .maps
918            .bss_data
919            .as_ref()
920            .map(|b| b.nr_cpu_ids as usize)
921            .unwrap_or(1))
922        .min(MLFQ_MAX_CPUS);
923        // Raw per-cpu read: reuse a raw buffer for the per-CPU values
924        // and sum without allocating Vec<Vec<u8>>.
925        let value_size = 8 * buckets;
926        let raw_size = value_size * nr_cpus;
927        self.op_lat_raw_buf.clear();
928        self.op_lat_raw_buf.resize(raw_size, 0);
929        for op in 0..nr_ops {
930            let key = (op as u32).to_ne_bytes();
931            let fd = self.skel.maps.mlfq_op_lat.as_fd().as_raw_fd();
932            let ret = unsafe {
933                libbpf_rs::libbpf_sys::bpf_map_lookup_elem(
934                    fd,
935                    &key as *const _ as *const std::ffi::c_void,
936                    self.op_lat_raw_buf.as_mut_ptr() as *mut std::ffi::c_void,
937                )
938            };
939            if ret != 0 {
940                continue;
941            }
942            for cpu in 0..nr_cpus {
943                let base = cpu * value_size;
944                for b in 0..buckets {
945                    let off = base + b * 8;
946                    let slot = &self.op_lat_raw_buf[off..off + 8];
947                    let v = u64::from_ne_bytes(slot.try_into().expect("8-byte slot"));
948                    self.op_lat_buf[op * buckets + b] =
949                        self.op_lat_buf[op * buckets + b].wrapping_add(v);
950                }
951            }
952        }
953        // Return a clone that reuses the Vec allocation via clone,
954        // but the clone is unavoidable because Metrics owns the Vec.
955        // The scratch retains capacity, so the next call does not allocate.
956        self.op_lat_buf.clone()
957    }
958
959    /// Sum the per-CPU lifetime wakeup totals from the mlfq_wakeup_stats
960    /// map. Each CPU's total is bumped atomically on the wakeup path, so
961    /// this read is tear-free, and the u64 slots cannot wrap. A failed
962    /// lookup yields zero. The observation-only contract holds, so the
963    /// totals grow even while the adaptation is disabled.
964    fn read_wakeup_total(&mut self) -> u64 {
965        let nr_cpus = (self
966            .skel
967            .maps
968            .bss_data
969            .as_ref()
970            .map(|b| b.nr_cpu_ids as usize)
971            .unwrap_or(1))
972        .min(MLFQ_MAX_CPUS);
973        let value_size = std::mem::size_of::<crate::bpf_intf::mlfq_wakeup_counters>();
974        let raw_size = value_size * nr_cpus;
975        self.wakeup_raw_buf.clear();
976        self.wakeup_raw_buf.resize(raw_size, 0);
977        let fd = self.skel.maps.mlfq_wakeup_stats.as_fd().as_raw_fd();
978        let key = 0u32.to_ne_bytes();
979        let ret = unsafe {
980            libbpf_rs::libbpf_sys::bpf_map_lookup_elem(
981                fd,
982                &key as *const _ as *const std::ffi::c_void,
983                self.wakeup_raw_buf.as_mut_ptr() as *mut std::ffi::c_void,
984            )
985        };
986        if ret != 0 {
987            return 0;
988        }
989        let mut total = 0u64;
990        for cpu in 0..nr_cpus {
991            let base = cpu * value_size;
992            let slot = &self.wakeup_raw_buf[base..base + 8];
993            let v = u64::from_ne_bytes(slot.try_into().expect("8-byte total"));
994            total = total.wrapping_add(v);
995        }
996        total
997    }
998
999    fn run(&mut self, shutdown: Arc<AtomicBool>) -> Result<UserExitInfo> {
1000        let (res_ch, req_ch) = self.stats_server.channels();
1001
1002        while !shutdown.load(Ordering::Relaxed) && !self.exited() {
1003            /*
1004             * Drain the training-sample ring buffer (the callback
1005             * forwards the parsed records into the sample channel) and
1006             * fold them into the training window before serving the
1007             * next stats request.
1008             */
1009            self.rb_mgr.consume()?;
1010            while let Ok(s) = self.sample_rx.try_recv() {
1011                self.ingest_sample(s);
1012            }
1013            self.poll_train_results();
1014
1015            match req_ch.recv_timeout(Duration::from_millis(100)) {
1016                Ok(()) => {
1017                    // Push a web snapshot on the stats request too, so
1018                    // the UI cadence is the run-loop cadence, not the
1019                    // browser's 1 s poll.
1020                    let web = self.get_web_metrics();
1021                    if let Some(ref tx) = self.webui_tx {
1022                        let _ = tx.try_send(web);
1023                    }
1024                    res_ch.send(self.get_metrics())?
1025                }
1026                Err(RecvTimeoutError::Timeout) => {
1027                    let web = self.get_web_metrics();
1028                    if let Some(ref tx) = self.webui_tx {
1029                        let _ = tx.try_send(web);
1030                    }
1031                }
1032                Err(e) => Err(e)?,
1033            }
1034        }
1035
1036        /* One final drain so the exit report sees the latest samples. */
1037        self.rb_mgr.consume()?;
1038        while let Ok(s) = self.sample_rx.try_recv() {
1039            self.ingest_sample(s);
1040        }
1041        self.poll_train_results();
1042
1043        let m = self.get_metrics();
1044        log::info!(
1045            "mlfq exit counters: Q1={} Q2={} Q3={} fastpath={} regular={} pin_idle={} pin_busy={} pin_global={} drop_tctx={} drop_weight={} drop_deadline={} promotions={} demotions={} aging_boosts={} short_sleep_boosts={} cpuperf_boosts={} preempt_kicks={} runtime={} on_cpu={} steals={} steals_same_llc={} steals_cross_llc={} keep_running={} rt_takeovers={} rt_evacuations={} rt_redirects={} rt_reenqs={} tree gen={} nodes={} samples={} mae={}us ema_mae={}us corr={:.3} tree_inf={} tree_fallback={} tree_disagree={} tree_emitted={} tree_dropped={} tree_cap_dropped={} wakeups={} adapt_steps={}",
1046            m.q1_placements,
1047            m.q2_placements,
1048            m.q3_placements,
1049            m.enq_fastpath,
1050            m.enq_regular,
1051            m.enq_pinned_idle,
1052            m.enq_pinned_busy,
1053            m.enq_pinned_global,
1054            m.enq_no_tctx,
1055            m.enq_bad_weight,
1056            m.enq_no_deadline,
1057            m.promotions,
1058            m.demotions,
1059            m.aging_boosts,
1060            m.short_sleep_boosts,
1061            m.cpuperf_boosts,
1062            m.preemption_kicks,
1063            m.total_runtime,
1064            m.on_cpu,
1065            m.steals,
1066            m.steals_same_llc,
1067            m.steals_cross_llc,
1068            m.keep_running,
1069            m.rt_takeovers,
1070            m.rt_evacuations,
1071            m.rt_redirects,
1072            m.rt_reenqs,
1073            m.tree_model_generation,
1074            m.tree_model_nodes,
1075            m.tree_model_samples,
1076            m.tree_mae_tree_us,
1077            m.tree_mae_ema_us,
1078            self.model.corr,
1079            m.tree_inference,
1080            m.tree_fallback,
1081            m.tree_disagree,
1082            m.tree_samples_emitted,
1083            m.tree_samples_dropped,
1084            m.tree_samples_cap_dropped,
1085            m.wakeup_total,
1086            m.adapt_steps
1087        );
1088        let _ = self.struct_ops.take();
1089        uei_report!(&self.skel, uei)
1090    }
1091
1092    /// Fold one emitted sample into the sliding training window and
1093    /// kick a retrain on the cadence. On the first window that reaches
1094    /// the minimum training size, then every MLFQ_TREE_RETRAIN_INTERVAL.
1095    ///
1096    /// The window admits at most MLFQ_TREE_PER_PID_CAP samples per pid. A pid that already holds its share is dropped here and counted in tree_samples_cap_dropped. The cap check runs before
1097    /// the window accounting, so a rejected sample never disturbs the
1098    /// per-pid counts, and the eviction bookkeeping below decrements the
1099    /// pid of the sample the window actually pops.
1100    fn ingest_sample(&mut self, s: TreeSample) {
1101        if !tree_admit_pid(&mut self.pid_counts, s.pid) {
1102            self.tree_samples_cap_dropped += 1;
1103            return;
1104        }
1105        if self.window.len() == MLFQ_TREE_WINDOW_MAX {
1106            let evicted = self
1107                .window
1108                .pop_front()
1109                .expect("a full window pops the oldest sample");
1110            tree_evict_pid(&mut self.pid_counts, evicted.pid);
1111        }
1112        self.window.push_back(s);
1113
1114        let due = match self.last_train_at {
1115            None => self.window.len() >= MLFQ_TREE_MIN_SAMPLES,
1116            Some(t) => {
1117                t.elapsed() >= MLFQ_TREE_RETRAIN_INTERVAL
1118                    && self.window.len() >= MLFQ_TREE_MIN_SAMPLES
1119            }
1120        };
1121        if due {
1122            self.kick_training();
1123        }
1124    }
1125
1126    /// Hand a snapshot of the window to the training worker.
1127    ///
1128    /// The retrain cadence counts every kick, so a rejected model cannot
1129    /// turn into a per-sample retrain storm. try_send drops the kick when
1130    /// the worker is still busy with the previous fit, which the 60 s
1131    /// cadence makes rare.
1132    fn kick_training(&mut self) {
1133        self.last_train_at = Some(std::time::Instant::now());
1134        // Reuse the snapshot buffer. The buffer is cleared and refilled
1135        // from the window; the capacity stays at MLFQ_TREE_WINDOW_MAX, so
1136        // no allocation after the first kick.
1137        self.train_snapshot_buf.clear();
1138        self.train_snapshot_buf.extend(self.window.iter().copied());
1139        // Move the buffer into the channel without allocating a new Vec
1140        // by swapping with an empty Vec that retains the channel's
1141        // previously sent capacity. The swap leaves the scratch empty
1142        // but with the same capacity for the next kick.
1143        let mut snapshot = Vec::new();
1144        std::mem::swap(&mut snapshot, &mut self.train_snapshot_buf);
1145        // Restore the scratch capacity for the next kick.
1146        self.train_snapshot_buf = Vec::with_capacity(MLFQ_TREE_WINDOW_MAX);
1147        if self.train_tx.try_send(snapshot).is_err() {
1148            log::warn!("MLFQ tree training already in flight, skipping this retrain");
1149        }
1150    }
1151
1152    /// Collect the finished fits from the training worker and apply the
1153    /// publish quality gate to each.
1154    fn poll_train_results(&mut self) {
1155        while let Ok(res) = self.train_rx.try_recv() {
1156            match res {
1157                Ok(r) => self.apply_train_result(r),
1158                Err(e) => {
1159                    log::warn!("MLFQ tree training failed, keeping the previous model: {e:#}");
1160                }
1161            }
1162        }
1163    }
1164
1165    /// Commit a finished fit when it passes validation and the publish
1166    /// quality gate; otherwise keep the previous model.
1167    ///
1168    /// The gate and the meta computation are pure functions in
1169    /// mlfq_tree (mlfq_tree::should_publish, mlfq_tree::tree_meta), which
1170    /// the unit tests cover.
1171    fn apply_train_result(&mut self, res: TrainResult) {
1172        if let Err(e) = mlfq_tree::serialize_validate(&res.tree) {
1173            log::warn!("MLFQ tree training failed validation, keeping the previous model: {e}");
1174            return;
1175        }
1176
1177        let new_gen = self.model.generation + 1;
1178        /*
1179         * A tree fit on the behavior of a handful of tasks would
1180         * over-fit them, so the publish requires at least
1181         * MLFQ_TREE_MIN_PIDS distinct pids in the fit slice; a rejected
1182         * model keeps the previous one committed, and the retrain
1183         * cadence prevents a rejection from becoming a retrain storm.
1184         */
1185        if res.nr_pids_train < MLFQ_TREE_MIN_PIDS {
1186            log::info!(
1187                "MLFQ tree gen {} rejected: fit slice has only {} distinct pids (< {} required), keeping the previous model",
1188                new_gen,
1189                res.nr_pids_train,
1190                MLFQ_TREE_MIN_PIDS
1191            );
1192            return;
1193        }
1194        let published_corr = if self.model.generation == 0 {
1195            None
1196        } else {
1197            Some(self.model.corr)
1198        };
1199        if !mlfq_tree::should_publish(res.mae_tree, res.mae_ema, res.corr, published_corr) {
1200            log::info!(
1201                "MLFQ tree gen {} rejected: holdout MAE_tree={:.1}us > MAE_ema={:.1}us or corr {:.3} below floor 0.30 or not above published {:.3}, keeping the previous model",
1202                new_gen,
1203                res.mae_tree / 1e3,
1204                res.mae_ema / 1e3,
1205                res.corr,
1206                published_corr.unwrap_or(0.0)
1207            );
1208            return;
1209        }
1210
1211        if let Err(e) = self.publish_tree(&res.tree, new_gen) {
1212            log::warn!("MLFQ tree publish failed, keeping the previous model: {e:#}");
1213            return;
1214        }
1215
1216        self.model = ModelMeta {
1217            generation: new_gen,
1218            nr_samples: res.nr_train,
1219            nr_nodes: res.tree.nodes.len(),
1220            mae_tree_us: (res.mae_tree / 1e3).round() as u64,
1221            mae_ema_us: (res.mae_ema / 1e3).round() as u64,
1222            corr: res.corr,
1223        };
1224        info!(
1225            "MLFQ tree model gen {}, nodes {}, fit {} samples (holdout {}), MAE_tree={}us MAE_ema={}us corr={:.3}",
1226            new_gen,
1227            res.tree.nodes.len(),
1228            res.nr_train,
1229            res.holdout_len,
1230            self.model.mae_tree_us,
1231            self.model.mae_ema_us,
1232            self.model.corr
1233        );
1234    }
1235
1236    /// Publish a validated tree into the inactive map entry and commit
1237    /// the meta last.
1238    ///
1239    /// The full map value is written (live nodes at the front, zeroed
1240    /// tail), so a shrinking tree never leaves stale nodes behind the
1241    /// new node count. A release fence orders the map-value write before
1242    /// the meta write, which flips the active entry, bumps the
1243    /// generation and sets the trained bit: a BPF reader that loaded the
1244    /// meta once sees either the old tree or the fully committed new
1245    /// one, never a partially written one.
1246    ///
1247    /// The protocol is sound at the 60 s publish cadence: a reader could
1248    /// only observe a torn tree if two publishes completed within one
1249    /// tree walk, and each walk is a few dozen memory reads while a
1250    /// publish moves up to 2048 nodes, so two consecutive publishes
1251    /// cannot complete inside one walk. The consequence of the
1252    /// theoretical race is one mispredicted burst, which the queue-band
1253    /// nets absorb. The walk masks every index to the buffer bound, so
1254    /// it is never a memory-safety issue.
1255    fn publish_tree(&mut self, tree: &mlfq_tree::SerializedTree, new_gen: u64) -> Result<()> {
1256        let old_meta = {
1257            let bss = self
1258                .skel
1259                .maps
1260                .bss_data
1261                .as_ref()
1262                .expect("bss_data missing, the BPF object has no .bss section");
1263            bss.mlfq_tree_ctrl.meta
1264        };
1265        let old_gen = old_meta >> crate::bpf_intf::MLFQ_TREE_META_GENERATION_SHIFT;
1266        // Monotonic generation check: new gen must exceed old, fail otherwise.
1267        if new_gen <= old_gen && old_gen != 0 {
1268            anyhow::bail!(
1269                "monotonic gen violation: new {} <= old {}",
1270                new_gen,
1271                old_gen
1272            );
1273        }
1274        let old_active = (old_meta >> 1) & 1;
1275        let new_active = 1 - old_active;
1276        let key = (new_active as u32).to_ne_bytes();
1277
1278        let node_bytes = unsafe {
1279            std::slice::from_raw_parts(
1280                tree.nodes.as_ptr().cast::<u8>(),
1281                tree.nodes.len() * size_of::<mlfq_tree::TreeNode>(),
1282            )
1283        };
1284        let mut buf = vec![0u8; size_of::<mlfq_tree_store>()];
1285        buf[..node_bytes.len()].copy_from_slice(node_bytes);
1286        self.skel
1287            .maps
1288            .mlfq_tree_map
1289            .update(&key, &buf, libbpf_rs::MapFlags::ANY)?;
1290
1291        // Order the map-value write before the meta commit.
1292        std::sync::atomic::fence(Ordering::Release);
1293
1294        {
1295            let bss = self
1296                .skel
1297                .maps
1298                .bss_data
1299                .as_mut()
1300                .expect("bss_data missing, the BPF object has no .bss section");
1301            bss.mlfq_tree_ctrl.meta = mlfq_tree::tree_meta(new_gen, tree.nodes.len(), new_active);
1302        }
1303        Ok(())
1304    }
1305}
1306
1307impl Drop for Scheduler<'_> {
1308    fn drop(&mut self) {
1309        /*
1310         * Dropping pm_qos_fd closes the /dev/cpu_dma_latency fd, which
1311         * makes the kernel drop the PM QoS request and restore the
1312         * previous idle-latency constraint. The field drop below does
1313         * this on every exit path.
1314         */
1315        /*
1316         * Join the web UI thread so its unblock-write flag (see
1317         * webui.rs) is visible before the caller's restore decision;
1318         * the thread exits within its poll interval of the shutdown
1319         * flag, so the join is bounded.
1320         */
1321        if let Some(jh) = self.webui_join.take() {
1322            let _ = jh.join();
1323        }
1324        info!("Unregister {SCHEDULER_NAME} scheduler");
1325    }
1326}
1327
1328/// Result of one training run, handed back from the worker thread. The
1329/// metrics are computed on the held-out slice so the publish gate and
1330/// the reported numbers describe out-of-sample error.
1331struct TrainResult {
1332    tree: mlfq_tree::SerializedTree,
1333    /// Samples the tree was fit on.
1334    nr_train: usize,
1335    /// Distinct pids in the fit slice, the concentration input for the per-pid cap.
1336    nr_pids_train: usize,
1337    /// Samples of the held-out evaluation slice.
1338    holdout_len: usize,
1339    /// Tree MAE on the holdout, in nsecs.
1340    mae_tree: f64,
1341    /// Exact per-sample EMA-baseline MAE on the holdout, in nsecs.
1342    mae_ema: f64,
1343    /// Pearson correlation of tree predictions and labels on the holdout.
1344    corr: f64,
1345}
1346
1347/// Split the window into the fit slice (first 90%) and the held-out
1348/// evaluation slice (last 10%). The daemon only trains once the window
1349/// holds MLFQ_TREE_MIN_SAMPLES samples, so the holdout is never empty in
1350/// production; a window too small for a meaningful 90/10 split (< 20
1351/// samples) is an error, which the caller treats as a skipped training
1352/// round (log + keep the previous model) instead of silently training
1353/// and evaluating on the same slice.
1354fn split_holdout(samples: &[TreeSample]) -> Result<(&[TreeSample], &[TreeSample]), String> {
1355    if samples.len() >= 20 {
1356        let cut = samples.len() - samples.len() / 10;
1357        Ok(samples.split_at(cut))
1358    } else {
1359        Err(format!(
1360            "training window holds only {} samples; {} are needed for a 90/10 holdout split",
1361            samples.len(),
1362            20
1363        ))
1364    }
1365}
1366
1367/// Admit one sample of `pid` into the window under the per-pid cap. Returns true when the sample is admitted and the pid's count is incremented, false when the pid already holds its
1368/// MLFQ_TREE_PER_PID_CAP share and the caller must drop the sample.
1369/// Entries are pruned on eviction, not here: a pid at the cap keeps its
1370/// entry until the window ages its samples out.
1371fn tree_admit_pid(counts: &mut HashMap<u32, u32>, pid: u32) -> bool {
1372    let c = counts.entry(pid).or_insert(0);
1373    if *c >= MLFQ_TREE_PER_PID_CAP {
1374        return false;
1375    }
1376    *c += 1;
1377    true
1378}
1379
1380/// Remove one sample of `pid` from the per-pid accounting when the
1381/// window evicts its oldest sample, pruning the entry when the count
1382/// reaches zero so the map cannot grow with retired pids.
1383fn tree_evict_pid(counts: &mut HashMap<u32, u32>, pid: u32) {
1384    if let Some(c) = counts.get_mut(&pid) {
1385        *c -= 1;
1386        if *c == 0 {
1387            counts.remove(&pid);
1388        }
1389    }
1390}
1391
1392/// Number of distinct pids in a slice, the concentration input for the per-pid cap.
1393fn tree_distinct_pids(samples: &[TreeSample]) -> usize {
1394    let mut seen = HashSet::new();
1395    for s in samples {
1396        seen.insert(s.pid);
1397    }
1398    seen.len()
1399}
1400
1401/// Fit a tree and compute the holdout metrics, on the training worker.
1402///
1403/// The tree is fit on the first 90% of the window and evaluated on the
1404/// last 10%, so the reported MAE and the publish gate describe
1405/// out-of-sample error. The EMA baseline is exact. Each sample's
1406/// prediction is the captured gauge `feats.ema` (the post-decay gauge at
1407/// the capture, as emitted by the BPF side), so
1408/// `mae_ema = mean(|feats.ema - label|)` on the same holdout slice and
1409/// the tree comparison is honest.
1410#[allow(dead_code)]
1411fn train_model(samples: &[TreeSample]) -> Result<TrainResult, String> {
1412    let mut scratch = FitScratch::new();
1413    train_model_with_scratch(samples, &mut scratch)
1414}
1415
1416/// Fit a tree and compute holdout metrics reusing the scratch arena.
1417/// The scratch buffers are cleared in place, so the second call with the
1418/// same window size does not allocate.
1419fn train_model_with_scratch(
1420    samples: &[TreeSample],
1421    scratch: &mut FitScratch,
1422) -> Result<TrainResult, String> {
1423    let (train, holdout) = split_holdout(samples)?;
1424
1425    let tree = mlfq_tree::fit_with_scratch(
1426        train,
1427        MLFQ_TREE_MAX_DEPTH,
1428        MLFQ_TREE_MIN_LEAF,
1429        MLFQ_TREE_MAX_NODES,
1430        mlfq_tree::DEFAULT_MIN_REL_VAR_REDUCTION,
1431        scratch,
1432    );
1433    mlfq_tree::serialize_validate(&tree).map_err(|e| {
1434        // A tree that fails the walk invariants must never be
1435        // published; the previous model stays committed.
1436        format!("tree failed validation: {e}")
1437    })?;
1438
1439    // Reuse the scratch buffers for the holdout predictions. The
1440    // buffers are cleared and refilled, so capacity is retained.
1441    scratch.actuals.clear();
1442    scratch.actuals.extend(holdout.iter().map(|s| s.label_ns));
1443    let actuals = &scratch.actuals;
1444    scratch.preds.clear();
1445    for s in holdout {
1446        let feats = s.feats;
1447        scratch.preds.push(mlfq_tree::predict(&tree, &feats));
1448    }
1449    let preds = &scratch.preds;
1450    // Weighted holdout: recency weights of the full window, tail slice
1451    // corresponds to the holdout; recent samples dominate.
1452    scratch.weights_full.clear();
1453    mlfq_tree::sample_weights_into(samples.len(), &mut scratch.weights_full);
1454    let holdout_weights = &scratch.weights_full[train.len()..];
1455    scratch.ema_preds.clear();
1456    scratch
1457        .ema_preds
1458        .extend(holdout.iter().map(|s| s.feats.ema));
1459    let ema_preds = &scratch.ema_preds;
1460    let mae_tree = mlfq_tree::weighted_holdout_mae(preds, actuals, holdout_weights);
1461    let mae_ema = mlfq_tree::weighted_holdout_mae(ema_preds, actuals, holdout_weights);
1462    let corr = mlfq_tree::pearson(preds, actuals);
1463
1464    Ok(TrainResult {
1465        tree,
1466        nr_train: train.len(),
1467        nr_pids_train: tree_distinct_pids(train),
1468        holdout_len: holdout.len(),
1469        mae_tree,
1470        mae_ema,
1471        corr,
1472    })
1473}
1474
1475/// Spawn the training worker and return its job and result channels.
1476///
1477/// The worker owns no scheduler state. It receives a snapshot of the
1478/// window (the window is only mutated by the ingest on the main thread,
1479/// so handing over a snapshot cannot race with the ingest), fits the tree
1480/// and computes the holdout metrics, and sends the result back. The
1481/// publish stays on the main thread. Dropping the job sender closes the
1482/// channel and the worker exits on its next `recv()`.
1483fn spawn_train_worker() -> (
1484    crossbeam::channel::Sender<Vec<TreeSample>>,
1485    crossbeam::channel::Receiver<Result<TrainResult, anyhow::Error>>,
1486) {
1487    let (job_tx, job_rx) = crossbeam::channel::bounded::<Vec<TreeSample>>(1);
1488    let (res_tx, res_rx) = crossbeam::channel::bounded::<Result<TrainResult, anyhow::Error>>(1);
1489    std::thread::spawn(move || {
1490        let mut scratch = FitScratch::new();
1491        while let Ok(samples) = job_rx.recv() {
1492            let res = train_model_with_scratch(&samples, &mut scratch).map_err(anyhow::Error::msg);
1493            if res_tx.send(res).is_err() {
1494                break;
1495            }
1496        }
1497    });
1498    (job_tx, res_rx)
1499}
1500
1501fn main() -> Result<()> {
1502    let opts = Opts::parse();
1503
1504    if let Some(shell) = opts.completions {
1505        generate(
1506            shell,
1507            &mut Opts::command(),
1508            SCHEDULER_NAME,
1509            &mut std::io::stdout(),
1510        );
1511        return Ok(());
1512    }
1513
1514    let monitor_only = opts.monitor.is_some();
1515
1516    // Print the version before any other work, so `scx_mlfq -V` works
1517    // without attaching anything.
1518    if opts.version {
1519        println!("{} {}", SCHEDULER_NAME, full_version());
1520        return Ok(());
1521    }
1522
1523    if opts.help_stats {
1524        stats::server_data().describe_meta(&mut std::io::stdout(), None)?;
1525        return Ok(());
1526    }
1527
1528    if !monitor_only {
1529        simplelog::TermLogger::init(
1530            if opts.debug {
1531                simplelog::LevelFilter::Debug
1532            } else {
1533                simplelog::LevelFilter::Info
1534            },
1535            simplelog::Config::default(),
1536            simplelog::TerminalMode::Stderr,
1537            simplelog::ColorChoice::Auto,
1538        )?;
1539
1540        // The SMT annotation is appended when the host exposes it. An
1541        // unreadable knob omits the suffix rather than guessing.
1542        let smt_suffix = match topology::smt_enabled() {
1543            Some(true) => " SMT on",
1544            Some(false) => " SMT off",
1545            None => "",
1546        };
1547        info!("{} {}{}", SCHEDULER_NAME, full_version(), smt_suffix);
1548        info!(
1549            "scheduler options: {}",
1550            std::env::args().skip(1).collect::<Vec<_>>().join(" ")
1551        );
1552    }
1553
1554    let shutdown = Arc::new(AtomicBool::new(false));
1555    let shutdown_clone = shutdown.clone();
1556
1557    ctrlc::set_handler(move || {
1558        shutdown_clone.store(true, Ordering::Relaxed);
1559    })?;
1560
1561    if let Some(intv) = opts.monitor.or(opts.stats) {
1562        let monitor_shutdown = shutdown.clone();
1563        let jh = std::thread::spawn(move || {
1564            if let Err(err) = stats::monitor(Duration::from_secs_f64(intv), monitor_shutdown) {
1565                log::warn!("stats monitor thread finished with error: {err}");
1566            }
1567        });
1568
1569        if monitor_only {
1570            let _ = jh.join();
1571            return Ok(());
1572        }
1573    }
1574
1575    let mut open_object = MaybeUninit::<libbpf_rs::OpenObject>::uninit();
1576    loop {
1577        let mut sched = Scheduler::init(&opts, &mut open_object, shutdown.clone())?;
1578        if !sched.run(shutdown.clone())?.should_restart() {
1579            break;
1580        }
1581        // Give the kernel time to finish the previous detachment before
1582        // re-initializing the BPF object for the next incarnation.
1583        std::thread::sleep(Duration::from_millis(100));
1584    }
1585
1586    /*
1587     * If this run wrote the runtime loader-sandbox unblock (see
1588     * webui.rs), restore it now: the drop-in is per-boot state under
1589     * /run, so it must be undone once, at the final exit after the
1590     * restart loop, not on every internal restart. The web UI thread
1591     * of the last incarnation is joined first, so its unblock-write
1592     * flag is visible before the restore decision (the thread exits
1593     * within its poll interval of the shutdown flag).
1594     */
1595    webui::restore_loader_sandbox();
1596
1597    info!("Scheduler exited");
1598
1599    Ok(())
1600}
1601
1602#[cfg(test)]
1603mod math_test {
1604    use std::process::Command;
1605
1606    fn host_cc() -> String {
1607        if let Ok(cc) = std::env::var("CC") {
1608            return cc;
1609        }
1610        // CI ships clang-19. Prefer it, then fall back to the system compiler.
1611        for cand in ["clang", "cc", "gcc"] {
1612            if Command::new(cand).arg("--version").status().is_ok() {
1613                return cand.to_string();
1614            }
1615        }
1616        "cc".to_string()
1617    }
1618
1619    #[test]
1620    fn mlfq_pure_math_native_harness() {
1621        let manifest_dir = env!("CARGO_MANIFEST_DIR");
1622        let src = format!("{manifest_dir}/src/bpf/mlfq_math_test.c");
1623        let intf_dir = format!("{manifest_dir}/src/bpf");
1624        let out_dir = std::env::temp_dir().join("scx_mlfq_math_test");
1625        std::fs::create_dir_all(&out_dir).unwrap();
1626        let exe = out_dir.join("mlfq_math_test");
1627
1628        let status = Command::new(host_cc())
1629            .args(["-O2", "-Wall", "-Werror", "-std=c11"])
1630            .args(["-I", &intf_dir])
1631            .arg(&src)
1632            .args(["-o", exe.to_str().unwrap()])
1633            .status()
1634            .expect("failed to compile mlfq_math_test.c");
1635
1636        assert!(status.success(), "native harness failed to compile");
1637
1638        let output = Command::new(&exe)
1639            .output()
1640            .expect("failed to run the native harness");
1641
1642        let stdout = String::from_utf8_lossy(&output.stdout);
1643        print!("{stdout}");
1644
1645        assert!(
1646            output.status.success(),
1647            "native harness failed:\n{stdout}{}",
1648            String::from_utf8_lossy(&output.stderr)
1649        );
1650        assert!(
1651            stdout.contains("All tests passed"),
1652            "native harness did not report success"
1653        );
1654    }
1655
1656    /// Minimal test sample; pid and label are the only fields the
1657    /// daemon-side pure helpers inspect.
1658    fn mk_sample(pid: u32, label_ns: u64) -> crate::mlfq_tree::TreeSample {
1659        crate::mlfq_tree::TreeSample {
1660            pid,
1661            version: crate::mlfq_tree::MLFQ_TREE_SAMPLE_VERSION,
1662            queue: 1,
1663            feats: crate::mlfq_tree::TreeFeats::default(),
1664            label_ns,
1665        }
1666    }
1667
1668    #[test]
1669    fn split_holdout_cut_and_rejection() {
1670        let samples: Vec<_> = (0..100).map(|i| mk_sample(i as u32, i as u64)).collect();
1671
1672        // The 90/10 cut: 100 samples split into 90 + 10.
1673        let (train, holdout) = crate::split_holdout(&samples).unwrap();
1674        assert_eq!(train.len(), 90);
1675        assert_eq!(holdout.len(), 10);
1676        let first_train = train[0].label_ns;
1677        let first_holdout = holdout[0].label_ns;
1678        assert_eq!(first_train, 0);
1679        assert_eq!(first_holdout, 90);
1680
1681        // The 20-sample minimum splits 18 + 2.
1682        let (train, holdout) = crate::split_holdout(&samples[..20]).unwrap();
1683        assert_eq!(train.len(), 18);
1684        assert_eq!(holdout.len(), 2);
1685
1686        // A window below the minimum is an error (a skipped training
1687        // round), not a degenerate same-slice train/holdout fallback.
1688        assert!(crate::split_holdout(&samples[..19]).is_err());
1689        assert!(crate::split_holdout(&[]).is_err());
1690    }
1691
1692    #[test]
1693    fn pid_count_cap_evict_and_prune() {
1694        let mut counts = std::collections::HashMap::new();
1695
1696        // The cap admits MLFQ_TREE_PER_PID_CAP samples of one pid...
1697        for _ in 0..crate::MLFQ_TREE_PER_PID_CAP {
1698            assert!(crate::tree_admit_pid(&mut counts, 7));
1699        }
1700        // ...and rejects the next one, which the caller counts separately.
1701        assert!(!crate::tree_admit_pid(&mut counts, 7));
1702        // Other pids are unaffected by pid 7's cap.
1703        assert!(crate::tree_admit_pid(&mut counts, 8));
1704
1705        // Evicting one pid 7 sample opens the slot again.
1706        crate::tree_evict_pid(&mut counts, 7);
1707        assert!(crate::tree_admit_pid(&mut counts, 7));
1708
1709        // Evicting the remaining pid 7 samples prunes the entry, so the
1710        // map cannot grow with retired pids.
1711        for _ in 0..crate::MLFQ_TREE_PER_PID_CAP {
1712            crate::tree_evict_pid(&mut counts, 7);
1713        }
1714        assert!(!counts.contains_key(&7));
1715        assert_eq!(counts.get(&8), Some(&1));
1716    }
1717
1718    #[test]
1719    fn distinct_pids_concentration_gate() {
1720        assert_eq!(crate::tree_distinct_pids(&[]), 0);
1721        assert_eq!(
1722            crate::tree_distinct_pids(&[mk_sample(1, 0), mk_sample(1, 1), mk_sample(2, 2)]),
1723            2
1724        );
1725
1726        // The publish gate requires MLFQ_TREE_MIN_PIDS distinct pids in
1727        // the fit slice; one fewer is rejected, the minimum passes.
1728        let few: Vec<_> = (0..crate::MLFQ_TREE_MIN_PIDS - 1)
1729            .map(|i| mk_sample(i as u32, i as u64))
1730            .collect();
1731        assert!(crate::tree_distinct_pids(&few) < crate::MLFQ_TREE_MIN_PIDS);
1732        let enough: Vec<_> = (0..crate::MLFQ_TREE_MIN_PIDS)
1733            .map(|i| mk_sample(i as u32, i as u64))
1734            .collect();
1735        assert!(crate::tree_distinct_pids(&enough) >= crate::MLFQ_TREE_MIN_PIDS);
1736    }
1737}