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::size_of;
40use std::mem::MaybeUninit;
41use std::os::fd::AsFd;
42use std::os::fd::AsRawFd;
43use std::sync::atomic::AtomicBool;
44use std::sync::atomic::Ordering;
45use std::sync::Arc;
46use std::time::Duration;
47
48use anyhow::Result;
49use clap::CommandFactory;
50use clap::Parser;
51use clap_complete::generate;
52use clap_complete::Shell;
53use crossbeam::channel::RecvTimeoutError;
54use libbpf_rs::AsRawLibbpf;
55use libbpf_rs::MapCore;
56use log::info;
57use scx_stats::prelude::*;
58use scx_utils::build_id;
59use scx_utils::compat;
60use scx_utils::libbpf_clap_opts::LibbpfOpts;
61use scx_utils::pm;
62use scx_utils::scx_ops_attach;
63use scx_utils::scx_ops_load;
64use scx_utils::scx_ops_open;
65use scx_utils::try_set_rlimit_infinity;
66use scx_utils::uei_exited;
67use scx_utils::uei_report;
68use scx_utils::UserExitInfo;
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                if let Some(bss) = skel.maps.bss_data.as_mut() {
440                    bss.mlfq_gpu_trace_mask |= mask;
441                }
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, m.q2_placements, m.q3_placements, m.enq_fastpath,
1047            m.enq_regular, m.enq_pinned_idle, m.enq_pinned_busy,
1048            m.enq_pinned_global, m.enq_no_tctx, m.enq_bad_weight,
1049            m.enq_no_deadline, m.promotions, m.demotions, m.aging_boosts,
1050            m.short_sleep_boosts, m.cpuperf_boosts, m.preemption_kicks,
1051            m.total_runtime, m.on_cpu, m.steals, m.steals_same_llc,
1052            m.steals_cross_llc, m.keep_running,
1053            m.rt_takeovers, m.rt_evacuations, m.rt_redirects, m.rt_reenqs,
1054            m.tree_model_generation, m.tree_model_nodes, m.tree_model_samples,
1055            m.tree_mae_tree_us, m.tree_mae_ema_us, self.model.corr,
1056            m.tree_inference, m.tree_fallback, m.tree_disagree,
1057            m.tree_samples_emitted, m.tree_samples_dropped,
1058            m.tree_samples_cap_dropped, m.wakeup_total, m.adapt_steps
1059        );
1060        let _ = self.struct_ops.take();
1061        uei_report!(&self.skel, uei)
1062    }
1063
1064    /// Fold one emitted sample into the sliding training window and
1065    /// kick a retrain on the cadence. On the first window that reaches
1066    /// the minimum training size, then every MLFQ_TREE_RETRAIN_INTERVAL.
1067    ///
1068    /// 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
1069    /// the window accounting, so a rejected sample never disturbs the
1070    /// per-pid counts, and the eviction bookkeeping below decrements the
1071    /// pid of the sample the window actually pops.
1072    fn ingest_sample(&mut self, s: TreeSample) {
1073        if !tree_admit_pid(&mut self.pid_counts, s.pid) {
1074            self.tree_samples_cap_dropped += 1;
1075            return;
1076        }
1077        if self.window.len() == MLFQ_TREE_WINDOW_MAX {
1078            let evicted = self
1079                .window
1080                .pop_front()
1081                .expect("a full window pops the oldest sample");
1082            tree_evict_pid(&mut self.pid_counts, evicted.pid);
1083        }
1084        self.window.push_back(s);
1085
1086        let due = match self.last_train_at {
1087            None => self.window.len() >= MLFQ_TREE_MIN_SAMPLES,
1088            Some(t) => {
1089                t.elapsed() >= MLFQ_TREE_RETRAIN_INTERVAL
1090                    && self.window.len() >= MLFQ_TREE_MIN_SAMPLES
1091            }
1092        };
1093        if due {
1094            self.kick_training();
1095        }
1096    }
1097
1098    /// Hand a snapshot of the window to the training worker.
1099    ///
1100    /// The retrain cadence counts every kick, so a rejected model cannot
1101    /// turn into a per-sample retrain storm. try_send drops the kick when
1102    /// the worker is still busy with the previous fit, which the 60 s
1103    /// cadence makes rare.
1104    fn kick_training(&mut self) {
1105        self.last_train_at = Some(std::time::Instant::now());
1106        // Reuse the snapshot buffer. The buffer is cleared and refilled
1107        // from the window; the capacity stays at MLFQ_TREE_WINDOW_MAX, so
1108        // no allocation after the first kick.
1109        self.train_snapshot_buf.clear();
1110        self.train_snapshot_buf.extend(self.window.iter().copied());
1111        // Move the buffer into the channel without allocating a new Vec
1112        // by swapping with an empty Vec that retains the channel's
1113        // previously sent capacity. The swap leaves the scratch empty
1114        // but with the same capacity for the next kick.
1115        let mut snapshot = Vec::new();
1116        std::mem::swap(&mut snapshot, &mut self.train_snapshot_buf);
1117        // Restore the scratch capacity for the next kick.
1118        self.train_snapshot_buf = Vec::with_capacity(MLFQ_TREE_WINDOW_MAX);
1119        if self.train_tx.try_send(snapshot).is_err() {
1120            log::warn!("MLFQ tree training already in flight, skipping this retrain");
1121        }
1122    }
1123
1124    /// Collect the finished fits from the training worker and apply the
1125    /// publish quality gate to each.
1126    fn poll_train_results(&mut self) {
1127        while let Ok(res) = self.train_rx.try_recv() {
1128            match res {
1129                Ok(r) => self.apply_train_result(r),
1130                Err(e) => {
1131                    log::warn!("MLFQ tree training failed, keeping the previous model: {e:#}");
1132                }
1133            }
1134        }
1135    }
1136
1137    /// Commit a finished fit when it passes validation and the publish
1138    /// quality gate; otherwise keep the previous model.
1139    ///
1140    /// The gate and the meta computation are pure functions in
1141    /// mlfq_tree (mlfq_tree::should_publish, mlfq_tree::tree_meta), which
1142    /// the unit tests cover.
1143    fn apply_train_result(&mut self, res: TrainResult) {
1144        if let Err(e) = mlfq_tree::serialize_validate(&res.tree) {
1145            log::warn!("MLFQ tree training failed validation, keeping the previous model: {e}");
1146            return;
1147        }
1148
1149        let gen = self.model.generation + 1;
1150        /*
1151         * A tree fit on the behavior of a handful of tasks would
1152         * over-fit them, so the publish requires at least
1153         * MLFQ_TREE_MIN_PIDS distinct pids in the fit slice; a rejected
1154         * model keeps the previous one committed, and the retrain
1155         * cadence prevents a rejection from becoming a retrain storm.
1156         */
1157        if res.nr_pids_train < MLFQ_TREE_MIN_PIDS {
1158            log::info!(
1159                "MLFQ tree gen {} rejected: fit slice has only {} distinct pids (< {} required), keeping the previous model",
1160                gen, res.nr_pids_train, MLFQ_TREE_MIN_PIDS
1161            );
1162            return;
1163        }
1164        let published_corr = if self.model.generation == 0 {
1165            None
1166        } else {
1167            Some(self.model.corr)
1168        };
1169        if !mlfq_tree::should_publish(res.mae_tree, res.mae_ema, res.corr, published_corr) {
1170            log::info!(
1171                "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",
1172                gen,
1173                res.mae_tree / 1e3,
1174                res.mae_ema / 1e3,
1175                res.corr,
1176                published_corr.unwrap_or(0.0)
1177            );
1178            return;
1179        }
1180
1181        if let Err(e) = self.publish_tree(&res.tree, gen) {
1182            log::warn!("MLFQ tree publish failed, keeping the previous model: {e:#}");
1183            return;
1184        }
1185
1186        self.model = ModelMeta {
1187            generation: gen,
1188            nr_samples: res.nr_train,
1189            nr_nodes: res.tree.nodes.len(),
1190            mae_tree_us: (res.mae_tree / 1e3).round() as u64,
1191            mae_ema_us: (res.mae_ema / 1e3).round() as u64,
1192            corr: res.corr,
1193        };
1194        info!(
1195            "MLFQ tree model gen {}, nodes {}, fit {} samples (holdout {}), MAE_tree={}us MAE_ema={}us corr={:.3}",
1196            gen,
1197            res.tree.nodes.len(),
1198            res.nr_train,
1199            res.holdout_len,
1200            self.model.mae_tree_us,
1201            self.model.mae_ema_us,
1202            self.model.corr
1203        );
1204    }
1205
1206    /// Publish a validated tree into the inactive map entry and commit
1207    /// the meta last.
1208    ///
1209    /// The full map value is written (live nodes at the front, zeroed
1210    /// tail), so a shrinking tree never leaves stale nodes behind the
1211    /// new node count. A release fence orders the map-value write before
1212    /// the meta write, which flips the active entry, bumps the
1213    /// generation and sets the trained bit: a BPF reader that loaded the
1214    /// meta once sees either the old tree or the fully committed new
1215    /// one, never a partially written one.
1216    ///
1217    /// The protocol is sound at the 60 s publish cadence: a reader could
1218    /// only observe a torn tree if two publishes completed within one
1219    /// tree walk, and each walk is a few dozen memory reads while a
1220    /// publish moves up to 2048 nodes, so two consecutive publishes
1221    /// cannot complete inside one walk. The consequence of the
1222    /// theoretical race is one mispredicted burst, which the queue-band
1223    /// nets absorb. The walk masks every index to the buffer bound, so
1224    /// it is never a memory-safety issue.
1225    fn publish_tree(&mut self, tree: &mlfq_tree::SerializedTree, gen: u64) -> Result<()> {
1226        let old_meta = {
1227            let bss = self
1228                .skel
1229                .maps
1230                .bss_data
1231                .as_ref()
1232                .expect("bss_data missing, the BPF object has no .bss section");
1233            bss.mlfq_tree_ctrl.meta
1234        };
1235        let old_gen = old_meta >> crate::bpf_intf::MLFQ_TREE_META_GENERATION_SHIFT;
1236        // Monotonic generation check: new gen must exceed old, fail otherwise.
1237        if gen <= old_gen && old_gen != 0 {
1238            anyhow::bail!("monotonic gen violation: new {} <= old {}", gen, old_gen);
1239        }
1240        let old_active = (old_meta >> 1) & 1;
1241        let new_active = 1 - old_active;
1242        let key = (new_active as u32).to_ne_bytes();
1243
1244        let node_bytes = unsafe {
1245            std::slice::from_raw_parts(
1246                tree.nodes.as_ptr().cast::<u8>(),
1247                tree.nodes.len() * size_of::<mlfq_tree::TreeNode>(),
1248            )
1249        };
1250        let mut buf = vec![0u8; size_of::<mlfq_tree_store>()];
1251        buf[..node_bytes.len()].copy_from_slice(node_bytes);
1252        self.skel
1253            .maps
1254            .mlfq_tree_map
1255            .update(&key, &buf, libbpf_rs::MapFlags::ANY)?;
1256
1257        // Order the map-value write before the meta commit.
1258        std::sync::atomic::fence(Ordering::Release);
1259
1260        {
1261            let bss = self
1262                .skel
1263                .maps
1264                .bss_data
1265                .as_mut()
1266                .expect("bss_data missing, the BPF object has no .bss section");
1267            bss.mlfq_tree_ctrl.meta = mlfq_tree::tree_meta(gen, tree.nodes.len(), new_active);
1268        }
1269        Ok(())
1270    }
1271}
1272
1273impl Drop for Scheduler<'_> {
1274    fn drop(&mut self) {
1275        /*
1276         * Dropping pm_qos_fd closes the /dev/cpu_dma_latency fd, which
1277         * makes the kernel drop the PM QoS request and restore the
1278         * previous idle-latency constraint. The field drop below does
1279         * this on every exit path.
1280         */
1281        /*
1282         * Join the web UI thread so its unblock-write flag (see
1283         * webui.rs) is visible before the caller's restore decision;
1284         * the thread exits within its poll interval of the shutdown
1285         * flag, so the join is bounded.
1286         */
1287        if let Some(jh) = self.webui_join.take() {
1288            let _ = jh.join();
1289        }
1290        info!("Unregister {SCHEDULER_NAME} scheduler");
1291    }
1292}
1293
1294/// Result of one training run, handed back from the worker thread. The
1295/// metrics are computed on the held-out slice so the publish gate and
1296/// the reported numbers describe out-of-sample error.
1297struct TrainResult {
1298    tree: mlfq_tree::SerializedTree,
1299    /// Samples the tree was fit on.
1300    nr_train: usize,
1301    /// Distinct pids in the fit slice, the concentration input for the per-pid cap.
1302    nr_pids_train: usize,
1303    /// Samples of the held-out evaluation slice.
1304    holdout_len: usize,
1305    /// Tree MAE on the holdout, in nsecs.
1306    mae_tree: f64,
1307    /// Exact per-sample EMA-baseline MAE on the holdout, in nsecs.
1308    mae_ema: f64,
1309    /// Pearson correlation of tree predictions and labels on the holdout.
1310    corr: f64,
1311}
1312
1313/// Split the window into the fit slice (first 90%) and the held-out
1314/// evaluation slice (last 10%). The daemon only trains once the window
1315/// holds MLFQ_TREE_MIN_SAMPLES samples, so the holdout is never empty in
1316/// production; a window too small for a meaningful 90/10 split (< 20
1317/// samples) is an error, which the caller treats as a skipped training
1318/// round (log + keep the previous model) instead of silently training
1319/// and evaluating on the same slice.
1320fn split_holdout(samples: &[TreeSample]) -> Result<(&[TreeSample], &[TreeSample]), String> {
1321    if samples.len() >= 20 {
1322        let cut = samples.len() - samples.len() / 10;
1323        Ok(samples.split_at(cut))
1324    } else {
1325        Err(format!(
1326            "training window holds only {} samples; {} are needed for a 90/10 holdout split",
1327            samples.len(),
1328            20
1329        ))
1330    }
1331}
1332
1333/// 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
1334/// MLFQ_TREE_PER_PID_CAP share and the caller must drop the sample.
1335/// Entries are pruned on eviction, not here: a pid at the cap keeps its
1336/// entry until the window ages its samples out.
1337fn tree_admit_pid(counts: &mut HashMap<u32, u32>, pid: u32) -> bool {
1338    let c = counts.entry(pid).or_insert(0);
1339    if *c >= MLFQ_TREE_PER_PID_CAP {
1340        return false;
1341    }
1342    *c += 1;
1343    true
1344}
1345
1346/// Remove one sample of `pid` from the per-pid accounting when the
1347/// window evicts its oldest sample, pruning the entry when the count
1348/// reaches zero so the map cannot grow with retired pids.
1349fn tree_evict_pid(counts: &mut HashMap<u32, u32>, pid: u32) {
1350    if let Some(c) = counts.get_mut(&pid) {
1351        *c -= 1;
1352        if *c == 0 {
1353            counts.remove(&pid);
1354        }
1355    }
1356}
1357
1358/// Number of distinct pids in a slice, the concentration input for the per-pid cap.
1359fn tree_distinct_pids(samples: &[TreeSample]) -> usize {
1360    let mut seen = HashSet::new();
1361    for s in samples {
1362        seen.insert(s.pid);
1363    }
1364    seen.len()
1365}
1366
1367/// Fit a tree and compute the holdout metrics, on the training worker.
1368///
1369/// The tree is fit on the first 90% of the window and evaluated on the
1370/// last 10%, so the reported MAE and the publish gate describe
1371/// out-of-sample error. The EMA baseline is exact. Each sample's
1372/// prediction is the captured gauge `feats.ema` (the post-decay gauge at
1373/// the capture, as emitted by the BPF side), so
1374/// `mae_ema = mean(|feats.ema - label|)` on the same holdout slice and
1375/// the tree comparison is honest.
1376#[allow(dead_code)]
1377fn train_model(samples: &[TreeSample]) -> Result<TrainResult, String> {
1378    let mut scratch = FitScratch::new();
1379    train_model_with_scratch(samples, &mut scratch)
1380}
1381
1382/// Fit a tree and compute holdout metrics reusing the scratch arena.
1383/// The scratch buffers are cleared in place, so the second call with the
1384/// same window size does not allocate.
1385fn train_model_with_scratch(
1386    samples: &[TreeSample],
1387    scratch: &mut FitScratch,
1388) -> Result<TrainResult, String> {
1389    let (train, holdout) = split_holdout(samples)?;
1390
1391    let tree = mlfq_tree::fit_with_scratch(
1392        train,
1393        MLFQ_TREE_MAX_DEPTH,
1394        MLFQ_TREE_MIN_LEAF,
1395        MLFQ_TREE_MAX_NODES,
1396        mlfq_tree::DEFAULT_MIN_REL_VAR_REDUCTION,
1397        scratch,
1398    );
1399    mlfq_tree::serialize_validate(&tree).map_err(|e| {
1400        // A tree that fails the walk invariants must never be
1401        // published; the previous model stays committed.
1402        format!("tree failed validation: {e}")
1403    })?;
1404
1405    // Reuse the scratch buffers for the holdout predictions. The
1406    // buffers are cleared and refilled, so capacity is retained.
1407    scratch.actuals.clear();
1408    scratch.actuals.extend(holdout.iter().map(|s| s.label_ns));
1409    let actuals = &scratch.actuals;
1410    scratch.preds.clear();
1411    for s in holdout {
1412        let feats = s.feats;
1413        scratch.preds.push(mlfq_tree::predict(&tree, &feats));
1414    }
1415    let preds = &scratch.preds;
1416    // Weighted holdout: recency weights of the full window, tail slice
1417    // corresponds to the holdout; recent samples dominate.
1418    scratch.weights_full.clear();
1419    mlfq_tree::sample_weights_into(samples.len(), &mut scratch.weights_full);
1420    let holdout_weights = &scratch.weights_full[train.len()..];
1421    scratch.ema_preds.clear();
1422    scratch
1423        .ema_preds
1424        .extend(holdout.iter().map(|s| s.feats.ema));
1425    let ema_preds = &scratch.ema_preds;
1426    let mae_tree = mlfq_tree::weighted_holdout_mae(preds, actuals, holdout_weights);
1427    let mae_ema = mlfq_tree::weighted_holdout_mae(ema_preds, actuals, holdout_weights);
1428    let corr = mlfq_tree::pearson(preds, actuals);
1429
1430    Ok(TrainResult {
1431        tree,
1432        nr_train: train.len(),
1433        nr_pids_train: tree_distinct_pids(train),
1434        holdout_len: holdout.len(),
1435        mae_tree,
1436        mae_ema,
1437        corr,
1438    })
1439}
1440
1441/// Spawn the training worker and return its job and result channels.
1442///
1443/// The worker owns no scheduler state. It receives a snapshot of the
1444/// window (the window is only mutated by the ingest on the main thread,
1445/// so handing over a snapshot cannot race with the ingest), fits the tree
1446/// and computes the holdout metrics, and sends the result back. The
1447/// publish stays on the main thread. Dropping the job sender closes the
1448/// channel and the worker exits on its next `recv()`.
1449fn spawn_train_worker() -> (
1450    crossbeam::channel::Sender<Vec<TreeSample>>,
1451    crossbeam::channel::Receiver<Result<TrainResult, anyhow::Error>>,
1452) {
1453    let (job_tx, job_rx) = crossbeam::channel::bounded::<Vec<TreeSample>>(1);
1454    let (res_tx, res_rx) = crossbeam::channel::bounded::<Result<TrainResult, anyhow::Error>>(1);
1455    std::thread::spawn(move || {
1456        let mut scratch = FitScratch::new();
1457        while let Ok(samples) = job_rx.recv() {
1458            let res = train_model_with_scratch(&samples, &mut scratch).map_err(anyhow::Error::msg);
1459            if res_tx.send(res).is_err() {
1460                break;
1461            }
1462        }
1463    });
1464    (job_tx, res_rx)
1465}
1466
1467fn main() -> Result<()> {
1468    let opts = Opts::parse();
1469
1470    if let Some(shell) = opts.completions {
1471        generate(
1472            shell,
1473            &mut Opts::command(),
1474            SCHEDULER_NAME,
1475            &mut std::io::stdout(),
1476        );
1477        return Ok(());
1478    }
1479
1480    let monitor_only = opts.monitor.is_some();
1481
1482    // Print the version before any other work, so `scx_mlfq -V` works
1483    // without attaching anything.
1484    if opts.version {
1485        println!("{} {}", SCHEDULER_NAME, full_version());
1486        return Ok(());
1487    }
1488
1489    if opts.help_stats {
1490        stats::server_data().describe_meta(&mut std::io::stdout(), None)?;
1491        return Ok(());
1492    }
1493
1494    if !monitor_only {
1495        simplelog::TermLogger::init(
1496            if opts.debug {
1497                simplelog::LevelFilter::Debug
1498            } else {
1499                simplelog::LevelFilter::Info
1500            },
1501            simplelog::Config::default(),
1502            simplelog::TerminalMode::Stderr,
1503            simplelog::ColorChoice::Auto,
1504        )?;
1505
1506        // The SMT annotation is appended when the host exposes it. An
1507        // unreadable knob omits the suffix rather than guessing.
1508        let smt_suffix = match topology::smt_enabled() {
1509            Some(true) => " SMT on",
1510            Some(false) => " SMT off",
1511            None => "",
1512        };
1513        info!("{} {}{}", SCHEDULER_NAME, full_version(), smt_suffix);
1514        info!(
1515            "scheduler options: {}",
1516            std::env::args().skip(1).collect::<Vec<_>>().join(" ")
1517        );
1518    }
1519
1520    let shutdown = Arc::new(AtomicBool::new(false));
1521    let shutdown_clone = shutdown.clone();
1522
1523    ctrlc::set_handler(move || {
1524        shutdown_clone.store(true, Ordering::Relaxed);
1525    })?;
1526
1527    if let Some(intv) = opts.monitor.or(opts.stats) {
1528        let monitor_shutdown = shutdown.clone();
1529        let jh = std::thread::spawn(move || {
1530            if let Err(err) = stats::monitor(Duration::from_secs_f64(intv), monitor_shutdown) {
1531                log::warn!("stats monitor thread finished with error: {err}");
1532            }
1533        });
1534
1535        if monitor_only {
1536            let _ = jh.join();
1537            return Ok(());
1538        }
1539    }
1540
1541    let mut open_object = MaybeUninit::<libbpf_rs::OpenObject>::uninit();
1542    loop {
1543        let mut sched = Scheduler::init(&opts, &mut open_object, shutdown.clone())?;
1544        if !sched.run(shutdown.clone())?.should_restart() {
1545            break;
1546        }
1547        // Give the kernel time to finish the previous detachment before
1548        // re-initializing the BPF object for the next incarnation.
1549        std::thread::sleep(Duration::from_millis(100));
1550    }
1551
1552    /*
1553     * If this run wrote the runtime loader-sandbox unblock (see
1554     * webui.rs), restore it now: the drop-in is per-boot state under
1555     * /run, so it must be undone once, at the final exit after the
1556     * restart loop, not on every internal restart. The web UI thread
1557     * of the last incarnation is joined first, so its unblock-write
1558     * flag is visible before the restore decision (the thread exits
1559     * within its poll interval of the shutdown flag).
1560     */
1561    webui::restore_loader_sandbox();
1562
1563    info!("Scheduler exited");
1564
1565    Ok(())
1566}
1567
1568#[cfg(test)]
1569mod math_test {
1570    use std::process::Command;
1571
1572    fn host_cc() -> String {
1573        if let Ok(cc) = std::env::var("CC") {
1574            return cc;
1575        }
1576        // CI ships clang-19. Prefer it, then fall back to the system compiler.
1577        for cand in ["clang", "cc", "gcc"] {
1578            if Command::new(cand).arg("--version").status().is_ok() {
1579                return cand.to_string();
1580            }
1581        }
1582        "cc".to_string()
1583    }
1584
1585    #[test]
1586    fn mlfq_pure_math_native_harness() {
1587        let manifest_dir = env!("CARGO_MANIFEST_DIR");
1588        let src = format!("{manifest_dir}/src/bpf/mlfq_math_test.c");
1589        let intf_dir = format!("{manifest_dir}/src/bpf");
1590        let out_dir = std::env::temp_dir().join("scx_mlfq_math_test");
1591        std::fs::create_dir_all(&out_dir).unwrap();
1592        let exe = out_dir.join("mlfq_math_test");
1593
1594        let status = Command::new(host_cc())
1595            .args(["-O2", "-Wall", "-Werror", "-std=c11"])
1596            .args(["-I", &intf_dir])
1597            .arg(&src)
1598            .args(["-o", exe.to_str().unwrap()])
1599            .status()
1600            .expect("failed to compile mlfq_math_test.c");
1601
1602        assert!(status.success(), "native harness failed to compile");
1603
1604        let output = Command::new(&exe)
1605            .output()
1606            .expect("failed to run the native harness");
1607
1608        let stdout = String::from_utf8_lossy(&output.stdout);
1609        print!("{stdout}");
1610
1611        assert!(
1612            output.status.success(),
1613            "native harness failed:\n{stdout}{}",
1614            String::from_utf8_lossy(&output.stderr)
1615        );
1616        assert!(
1617            stdout.contains("All tests passed"),
1618            "native harness did not report success"
1619        );
1620    }
1621
1622    /// Minimal test sample; pid and label are the only fields the
1623    /// daemon-side pure helpers inspect.
1624    fn mk_sample(pid: u32, label_ns: u64) -> crate::mlfq_tree::TreeSample {
1625        crate::mlfq_tree::TreeSample {
1626            pid,
1627            version: crate::mlfq_tree::MLFQ_TREE_SAMPLE_VERSION,
1628            queue: 1,
1629            feats: crate::mlfq_tree::TreeFeats::default(),
1630            label_ns,
1631        }
1632    }
1633
1634    #[test]
1635    fn split_holdout_cut_and_rejection() {
1636        let samples: Vec<_> = (0..100).map(|i| mk_sample(i as u32, i as u64)).collect();
1637
1638        // The 90/10 cut: 100 samples split into 90 + 10.
1639        let (train, holdout) = crate::split_holdout(&samples).unwrap();
1640        assert_eq!(train.len(), 90);
1641        assert_eq!(holdout.len(), 10);
1642        let first_train = train[0].label_ns;
1643        let first_holdout = holdout[0].label_ns;
1644        assert_eq!(first_train, 0);
1645        assert_eq!(first_holdout, 90);
1646
1647        // The 20-sample minimum splits 18 + 2.
1648        let (train, holdout) = crate::split_holdout(&samples[..20]).unwrap();
1649        assert_eq!(train.len(), 18);
1650        assert_eq!(holdout.len(), 2);
1651
1652        // A window below the minimum is an error (a skipped training
1653        // round), not a degenerate same-slice train/holdout fallback.
1654        assert!(crate::split_holdout(&samples[..19]).is_err());
1655        assert!(crate::split_holdout(&[]).is_err());
1656    }
1657
1658    #[test]
1659    fn pid_count_cap_evict_and_prune() {
1660        let mut counts = std::collections::HashMap::new();
1661
1662        // The cap admits MLFQ_TREE_PER_PID_CAP samples of one pid...
1663        for _ in 0..crate::MLFQ_TREE_PER_PID_CAP {
1664            assert!(crate::tree_admit_pid(&mut counts, 7));
1665        }
1666        // ...and rejects the next one, which the caller counts separately.
1667        assert!(!crate::tree_admit_pid(&mut counts, 7));
1668        // Other pids are unaffected by pid 7's cap.
1669        assert!(crate::tree_admit_pid(&mut counts, 8));
1670
1671        // Evicting one pid 7 sample opens the slot again.
1672        crate::tree_evict_pid(&mut counts, 7);
1673        assert!(crate::tree_admit_pid(&mut counts, 7));
1674
1675        // Evicting the remaining pid 7 samples prunes the entry, so the
1676        // map cannot grow with retired pids.
1677        for _ in 0..crate::MLFQ_TREE_PER_PID_CAP {
1678            crate::tree_evict_pid(&mut counts, 7);
1679        }
1680        assert!(!counts.contains_key(&7));
1681        assert_eq!(counts.get(&8), Some(&1));
1682    }
1683
1684    #[test]
1685    fn distinct_pids_concentration_gate() {
1686        assert_eq!(crate::tree_distinct_pids(&[]), 0);
1687        assert_eq!(
1688            crate::tree_distinct_pids(&[mk_sample(1, 0), mk_sample(1, 1), mk_sample(2, 2)]),
1689            2
1690        );
1691
1692        // The publish gate requires MLFQ_TREE_MIN_PIDS distinct pids in
1693        // the fit slice; one fewer is rejected, the minimum passes.
1694        let few: Vec<_> = (0..crate::MLFQ_TREE_MIN_PIDS - 1)
1695            .map(|i| mk_sample(i as u32, i as u64))
1696            .collect();
1697        assert!(crate::tree_distinct_pids(&few) < crate::MLFQ_TREE_MIN_PIDS);
1698        let enough: Vec<_> = (0..crate::MLFQ_TREE_MIN_PIDS)
1699            .map(|i| mk_sample(i as u32, i as u64))
1700            .collect();
1701        assert!(crate::tree_distinct_pids(&enough) >= crate::MLFQ_TREE_MIN_PIDS);
1702    }
1703}