Skip to main content

scx_mlfq/
config.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//! Userspace configuration, validated scheduling constants written into
9//! BPF rodata.
10//!
11//! `Config` is the single validated set of scheduling constants; the BPF side
12//! reads them from `const volatile` rodata globals declared in
13//! `src/bpf/main.bpf.c` (see `src/bpf/intf.h` for the compile-time
14//! defaults, which are the source of truth for every value here).
15//!
16//! The scheduler is knob-free. The production path uses `Config::default()`
17//! validated by `Config::validate()`. `ConfigBuilder` exists only to drive
18//! the validation contract from the unit tests.
19
20use anyhow::Context;
21use anyhow::Result;
22use anyhow::bail;
23
24/// Time units matching `enum mlfq_consts` in `src/bpf/intf.h`.
25const NSEC_PER_USEC: u64 = 1_000;
26const NSEC_PER_MSEC: u64 = 1_000_000;
27const NSEC_PER_SEC: u64 = 1_000_000_000;
28
29/*
30 * Defaults must match `enum mlfq_consts` in `src/bpf/intf.h`; the BPF
31 * compile-time values are the contract. The defaults are therefore derived
32 * from the bindgen-generated constants (the same source `topology.rs`
33 * cross-checks its constants against), so an intf.h change propagates here
34 * automatically and the `defaults_match_intf_h` test pins the binding.
35 * SHORT_SLEEP_NS is the one explicit value and the test pins it to the
36 * intf.h constant.
37 */
38
39/// Per-queue request sizes.
40const Q1_SLICE_NS: u64 = crate::bpf_intf::mlfq_consts_MLFQ_Q1_SLICE_NS as u64;
41const Q2_SLICE_NS: u64 = crate::bpf_intf::mlfq_consts_MLFQ_Q2_SLICE_NS as u64;
42const Q3_SLICE_NS: u64 = crate::bpf_intf::mlfq_consts_MLFQ_Q3_SLICE_NS as u64;
43
44/// EMA gauge ceiling.
45const BUDGET_MAX_NS: u64 = crate::bpf_intf::mlfq_consts_MLFQ_BUDGET_MAX_NS as u64;
46
47/// Climb aggressiveness, fixed.
48const ALPHA: u64 = crate::bpf_intf::mlfq_consts_MLFQ_ALPHA as u64;
49
50/// Classification thresholds.
51const T_L_NS: u64 = crate::bpf_intf::mlfq_consts_MLFQ_T_L_NS as u64;
52const T_H_NS: u64 = crate::bpf_intf::mlfq_consts_MLFQ_T_H_NS as u64;
53
54/// EMA decay half-life.
55const EMA_HALF_LIFE_NS: u64 = crate::bpf_intf::mlfq_consts_MLFQ_EMA_HALF_LIFE_NS as u64;
56
57/// Aging period.
58const AGING_PERIOD_NS: u64 = crate::bpf_intf::mlfq_consts_MLFQ_AGING_PERIOD_NS as u64;
59
60/// Short-sleep boost window. Periodic wakeup cadences such as the 60 Hz
61/// frame interval stay interactive. The per-task boost rate limit bounds
62/// the churn. The value is set against the slowest common cadence, so
63/// faster refresh rates, which sleep less per frame, fall inside the
64/// window as well.
65const SHORT_SLEEP_NS: u64 = 32 * NSEC_PER_MSEC;
66const SHORT_SLEEP_RATE_LIMIT_NS: u64 =
67    crate::bpf_intf::mlfq_consts_MLFQ_SHORT_SLEEP_RATE_LIMIT_NS as u64;
68const HYSTERESIS_SLEEP_NS: u64 = crate::bpf_intf::mlfq_consts_MLFQ_HYSTERESIS_SLEEP_NS as u64;
69
70/// A sleep longer than this collapses the gauge.
71const LONG_SLEEP_NS: u64 = crate::bpf_intf::mlfq_consts_MLFQ_LONG_SLEEP_NS as u64;
72
73/// Minimum residency before a same-queue wakeup may preempt the running
74/// task. Zero, the default, makes the interactive rule unconditional.
75const SAMEQ_PREEMPT_MIN_RUN_NS: u64 =
76    crate::bpf_intf::mlfq_consts_MLFQ_SAMEQ_PREEMPT_MIN_RUN_NS as u64;
77
78/// Slice cap for a preempting wakeup, in nsecs. The displaced task
79/// resumes at the next scheduling event once the cap expires.
80const PREEMPT_SLICE_NS: u64 = crate::bpf_intf::mlfq_consts_MLFQ_PREEMPT_SLICE_NS as u64;
81
82/// Dispatch quotas.
83const Q1_QUOTA: u32 = crate::bpf_intf::mlfq_consts_MLFQ_Q1_QUOTA;
84const Q2_QUOTA: u32 = crate::bpf_intf::mlfq_consts_MLFQ_Q2_QUOTA;
85const DISPATCH_MAX_BATCH: u32 = crate::bpf_intf::mlfq_consts_MLFQ_DISPATCH_MAX_BATCH;
86
87/// Drain interval of the realtime-takeover evacuation, nsecs.
88const RTDL_DRAIN_INTERVAL_NS: u64 = crate::bpf_intf::mlfq_consts_MLFQ_RTDL_DRAIN_INTERVAL_NS as u64;
89
90/// Tree band edges, the rodata bases of the effective adaptation values.
91const TREE_T_INT_NS: u64 = crate::bpf_intf::mlfq_consts_MLFQ_TREE_T_INT_NS as u64;
92const TREE_T_BOUND_NS: u64 = crate::bpf_intf::mlfq_consts_MLFQ_TREE_T_BOUND_NS as u64;
93
94/// Validated scheduling constants.
95///
96/// Every field maps to a `const volatile` rodata global in
97/// `src/bpf/main.bpf.c`; field names match the BPF globals 1:1 so
98/// `Config::apply()` is a mechanical write-through.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct Config {
101    /// Q1 request size (interactive), nsecs.
102    pub q1_slice_ns: u64,
103    /// Q2 request size (default), nsecs.
104    pub q2_slice_ns: u64,
105    /// Q3 request size (batch), nsecs.
106    pub q3_slice_ns: u64,
107    /// EMA gauge ceiling, nsecs.
108    pub budget_max_ns: u64,
109    /// EMA climb aggressiveness (fixed).
110    pub alpha: u64,
111    /// Interactive threshold T_L, nsecs.
112    pub t_l_ns: u64,
113    /// CPU-bound threshold T_H, nsecs.
114    pub t_h_ns: u64,
115    /// EMA decay half-life, nsecs.
116    pub ema_half_life_ns: u64,
117    /// Global aging period, nsecs.
118    pub aging_period_ns: u64,
119    /// Short-sleep boost window, nsecs.
120    pub short_sleep_ns: u64,
121    /// Per-task short-sleep boost rate limit, nsecs.
122    pub short_sleep_rate_limit_ns: u64,
123    /// Sleep counted as "short" for the wake_cnt hysteresis, nsecs.
124    pub hysteresis_sleep_ns: u64,
125    /// Sleep beyond which the gauge collapses, nsecs.
126    pub long_sleep_ns: u64,
127    /// Minimum residency before a same-queue wakeup may preempt, nsecs.
128    /// Zero, the default, makes the interactive rule unconditional.
129    pub sameq_preempt_min_run_ns: u64,
130    /// Slice cap for a preempting wakeup, nsecs.
131    pub preempt_slice_ns: u64,
132    /// Q1 dispatch quota per dispatch() call.
133    pub q1_quota: u32,
134    /// Q2 dispatch quota per dispatch() call.
135    pub q2_quota: u32,
136    /// Dispatch-loop bound.
137    pub dispatch_max_batch: u32,
138    /// Drain interval of the realtime-takeover evacuation, nsecs.
139    pub rtdl_drain_interval_ns: u64,
140    /// Tree Q1/Q2 band edge, the base of the effective value, nsecs.
141    pub tree_t_int_ns: u64,
142    /// Tree Q2/Q3 band edge, the base of the effective value, nsecs.
143    pub tree_t_bound_ns: u64,
144    /// Master gate of the threshold adaptation (false = fixed thresholds).
145    pub adapt_enabled: bool,
146}
147
148impl Default for Config {
149    /// Compile-time defaults from `src/bpf/intf.h`.
150    fn default() -> Self {
151        Self {
152            q1_slice_ns: Q1_SLICE_NS,
153            q2_slice_ns: Q2_SLICE_NS,
154            q3_slice_ns: Q3_SLICE_NS,
155            budget_max_ns: BUDGET_MAX_NS,
156            alpha: ALPHA,
157            t_l_ns: T_L_NS,
158            t_h_ns: T_H_NS,
159            ema_half_life_ns: EMA_HALF_LIFE_NS,
160            aging_period_ns: AGING_PERIOD_NS,
161            short_sleep_ns: SHORT_SLEEP_NS,
162            short_sleep_rate_limit_ns: SHORT_SLEEP_RATE_LIMIT_NS,
163            hysteresis_sleep_ns: HYSTERESIS_SLEEP_NS,
164            long_sleep_ns: LONG_SLEEP_NS,
165            sameq_preempt_min_run_ns: SAMEQ_PREEMPT_MIN_RUN_NS,
166            preempt_slice_ns: PREEMPT_SLICE_NS,
167            q1_quota: Q1_QUOTA,
168            q2_quota: Q2_QUOTA,
169            dispatch_max_batch: DISPATCH_MAX_BATCH,
170            rtdl_drain_interval_ns: RTDL_DRAIN_INTERVAL_NS,
171            tree_t_int_ns: TREE_T_INT_NS,
172            tree_t_bound_ns: TREE_T_BOUND_NS,
173            adapt_enabled: true,
174        }
175    }
176}
177
178impl Config {
179    /// Validate the configuration against the invariants the BPF side
180    /// relies on (`src/bpf/intf.h`).
181    ///
182    /// An invalid configuration is a programming error, not a runtime
183    /// condition: the production path validates `Config::default()` before
184    /// any value is written into rodata, and the unit tests drive the same
185    /// contract through `ConfigBuilder::build()`.
186    pub fn validate(&self) -> Result<()> {
187        if self.q1_slice_ns == 0 || self.q2_slice_ns == 0 || self.q3_slice_ns == 0 {
188            bail!(
189                "queue slices must be non-zero (got Q1={} Q2={} Q3={})",
190                self.q1_slice_ns,
191                self.q2_slice_ns,
192                self.q3_slice_ns
193            );
194        }
195        if self.budget_max_ns == 0 {
196            bail!("budget_max must be non-zero");
197        }
198        if self.alpha == 0 {
199            bail!("alpha must be non-zero");
200        }
201        if self.t_l_ns == 0 {
202            bail!("T_L must be non-zero");
203        }
204        if self.t_l_ns >= self.t_h_ns {
205            bail!(
206                "T_L ({}) must be strictly below T_H ({})",
207                self.t_l_ns,
208                self.t_h_ns
209            );
210        }
211        if self.t_h_ns >= self.budget_max_ns {
212            bail!(
213                "T_H ({}) must be strictly below BUDGET_MAX ({})",
214                self.t_h_ns,
215                self.budget_max_ns
216            );
217        }
218        if self.ema_half_life_ns == 0 {
219            bail!("EMA half-life must be non-zero");
220        }
221        if self.aging_period_ns == 0 {
222            bail!("aging period must be non-zero");
223        }
224        if self.short_sleep_ns == 0 {
225            bail!("short-sleep window must be non-zero");
226        }
227        if self.short_sleep_rate_limit_ns == 0 {
228            bail!("short-sleep rate limit must be non-zero");
229        }
230        if self.hysteresis_sleep_ns == 0 {
231            bail!("hysteresis sleep window must be non-zero");
232        }
233        if self.long_sleep_ns == 0 {
234            bail!("long-sleep window must be non-zero");
235        }
236        if self.q1_quota == 0 || self.q2_quota == 0 {
237            bail!(
238                "dispatch quotas must be non-zero (got Q1={} Q2={})",
239                self.q1_quota,
240                self.q2_quota
241            );
242        }
243        if self.dispatch_max_batch == 0 {
244            bail!("dispatch_max_batch must be non-zero");
245        }
246        /*
247         * dispatch() serves Q1 up to its quota, then Q2 up to its quota,
248         * then the Q3 remainder within dispatch_max_batch.
249         * If the quotas consume the whole batch, Q3 never runs on a busy
250         * system. The Q3 starvation bound depends on this slack.
251         */
252        if u64::from(self.q1_quota) + u64::from(self.q2_quota) >= u64::from(self.dispatch_max_batch)
253        {
254            bail!(
255                "Q1+Q2 quotas ({}+{}) must leave headroom for Q3 within the dispatch batch ({})",
256                self.q1_quota,
257                self.q2_quota,
258                self.dispatch_max_batch
259            );
260        }
261        /*
262         * The kernel's per-dispatch() move budget is set from the ops table
263         * (.dispatch_max_batch = MLFQ_DISPATCH_MAX_BATCH in main.bpf.c).
264         * A userspace batch larger than that constant would make the BPF
265         * dispatch loops try to move more tasks than the kernel allows per
266         * call, so the rodata value must never exceed it.
267         */
268        if self.dispatch_max_batch > crate::bpf_intf::mlfq_consts_MLFQ_DISPATCH_MAX_BATCH {
269            bail!(
270                "dispatch_max_batch ({}) exceeds the ops-table bound ({})",
271                self.dispatch_max_batch,
272                crate::bpf_intf::mlfq_consts_MLFQ_DISPATCH_MAX_BATCH
273            );
274        }
275        if self.rtdl_drain_interval_ns == 0 {
276            bail!("rtdl drain interval must be non-zero");
277        }
278        if self.tree_t_int_ns == 0 {
279            bail!("tree T_INT must be non-zero");
280        }
281        if self.tree_t_int_ns >= self.tree_t_bound_ns {
282            bail!(
283                "tree T_INT ({}) must be strictly below T_BOUND ({})",
284                self.tree_t_int_ns,
285                self.tree_t_bound_ns
286            );
287        }
288        Ok(())
289    }
290
291    /// Write the validated constants into the BPF object's rodata section.
292    ///
293    /// Must be called on the opened, not-yet-loaded skeleton, before
294    /// `scx_ops_load!()`. The rodata section becomes read-only after load.
295    pub fn apply(&self, skel: &mut crate::bpf_skel::OpenBpfSkel<'_>) -> Result<()> {
296        let rodata = skel
297            .maps
298            .rodata_data
299            .as_mut()
300            .context("rodata missing, the BPF object has no .rodata section")?;
301        rodata.mlfq_q1_slice_ns = self.q1_slice_ns;
302        rodata.mlfq_q2_slice_ns = self.q2_slice_ns;
303        rodata.mlfq_q3_slice_ns = self.q3_slice_ns;
304        rodata.mlfq_budget_max_ns = self.budget_max_ns;
305        rodata.mlfq_alpha = self.alpha;
306        rodata.mlfq_t_l_ns = self.t_l_ns;
307        rodata.mlfq_t_h_ns = self.t_h_ns;
308        rodata.mlfq_ema_half_life_ns = self.ema_half_life_ns;
309        rodata.mlfq_aging_period_ns = self.aging_period_ns;
310        rodata.mlfq_short_sleep_ns = self.short_sleep_ns;
311        rodata.mlfq_short_sleep_rate_limit_ns = self.short_sleep_rate_limit_ns;
312        rodata.mlfq_hysteresis_sleep_ns = self.hysteresis_sleep_ns;
313        rodata.mlfq_long_sleep_ns = self.long_sleep_ns;
314        rodata.mlfq_sameq_preempt_min_run_ns = self.sameq_preempt_min_run_ns;
315        rodata.mlfq_preempt_slice_ns = self.preempt_slice_ns;
316        rodata.mlfq_q1_quota = self.q1_quota;
317        rodata.mlfq_q2_quota = self.q2_quota;
318        rodata.mlfq_dispatch_max_batch = self.dispatch_max_batch;
319        rodata.mlfq_rtdl_drain_interval_ns = self.rtdl_drain_interval_ns;
320        rodata.mlfq_tree_t_int_ns = self.tree_t_int_ns;
321        rodata.mlfq_tree_t_bound_ns = self.tree_t_bound_ns;
322        rodata.mlfq_adapt_enabled = self.adapt_enabled;
323        Ok(())
324    }
325
326    /// One-line summary of the applied constants for the startup log.
327    pub fn describe(&self) -> String {
328        format!(
329            "slices: Q1={}us Q2={}us Q3={}us, T_L={}us, T_H={}us, \
330             budget_max={}us, alpha={}, ema_half_life={}us, aging_period={}s, \
331             short_sleep={}us, ss_rate_limit={}us, hysteresis_sleep={}us, \
332             long_sleep={}ms, sameq_min_run={}us, preempt_slice={}us, \
333             rtdl_drain_interval={}us, quotas: Q1={} Q2={} max_batch={}, \
334             tree_bands: T_INT={}us T_BOUND={}us, adapt_enabled={}",
335            self.q1_slice_ns / NSEC_PER_USEC,
336            self.q2_slice_ns / NSEC_PER_USEC,
337            self.q3_slice_ns / NSEC_PER_USEC,
338            self.t_l_ns / NSEC_PER_USEC,
339            self.t_h_ns / NSEC_PER_USEC,
340            self.budget_max_ns / NSEC_PER_USEC,
341            self.alpha,
342            self.ema_half_life_ns / NSEC_PER_USEC,
343            self.aging_period_ns / NSEC_PER_SEC,
344            self.short_sleep_ns / NSEC_PER_USEC,
345            self.short_sleep_rate_limit_ns / NSEC_PER_USEC,
346            self.hysteresis_sleep_ns / NSEC_PER_USEC,
347            self.long_sleep_ns / NSEC_PER_MSEC,
348            self.sameq_preempt_min_run_ns / NSEC_PER_USEC,
349            self.preempt_slice_ns / NSEC_PER_USEC,
350            self.rtdl_drain_interval_ns / NSEC_PER_USEC,
351            self.q1_quota,
352            self.q2_quota,
353            self.dispatch_max_batch,
354            self.tree_t_int_ns / NSEC_PER_USEC,
355            self.tree_t_bound_ns / NSEC_PER_USEC,
356            self.adapt_enabled,
357        )
358    }
359}
360
361/// Builder for `Config`, assembled from optional setters.
362///
363/// Every setter is optional; unset fields fall back to the `intf.h`
364/// defaults. `build()` validates the result and returns an error for any
365/// configuration that would break a BPF invariant.
366///
367/// Production code never uses this type: the scheduler is deliberately
368/// knob-free, so `main.rs` applies `Config::default()` directly. The
369/// builder lives under `#[cfg(test)]` and exercises every field through
370/// the validation contract.
371#[cfg(test)]
372#[derive(Debug, Clone, Default)]
373pub struct ConfigBuilder {
374    q1_slice_ns: Option<u64>,
375    q2_slice_ns: Option<u64>,
376    q3_slice_ns: Option<u64>,
377    budget_max_ns: Option<u64>,
378    alpha: Option<u64>,
379    t_l_ns: Option<u64>,
380    t_h_ns: Option<u64>,
381    ema_half_life_ns: Option<u64>,
382    aging_period_ns: Option<u64>,
383    short_sleep_ns: Option<u64>,
384    short_sleep_rate_limit_ns: Option<u64>,
385    hysteresis_sleep_ns: Option<u64>,
386    long_sleep_ns: Option<u64>,
387    sameq_preempt_min_run_ns: Option<u64>,
388    preempt_slice_ns: Option<u64>,
389    q1_quota: Option<u32>,
390    q2_quota: Option<u32>,
391    dispatch_max_batch: Option<u32>,
392    rtdl_drain_interval_ns: Option<u64>,
393    tree_t_int_ns: Option<u64>,
394    tree_t_bound_ns: Option<u64>,
395    adapt_enabled: Option<bool>,
396}
397
398/*
399 * The setters assemble a Config from the intf.h defaults and run it
400 * through build()'s validation. Test-only. The production path always
401 * uses Config::default().
402 */
403#[cfg(test)]
404// The builder exposes a setter for every tunable for completeness; the
405// binary and the tests currently wire only a subset of them.
406#[allow(dead_code)]
407impl ConfigBuilder {
408    /// Set the Q1 (interactive) request size in nsecs.
409    pub fn q1_slice_ns(mut self, v: u64) -> Self {
410        self.q1_slice_ns = Some(v);
411        self
412    }
413
414    /// Set the Q2 (default) request size in nsecs.
415    pub fn q2_slice_ns(mut self, v: u64) -> Self {
416        self.q2_slice_ns = Some(v);
417        self
418    }
419
420    /// Set the Q3 (batch) request size in nsecs.
421    pub fn q3_slice_ns(mut self, v: u64) -> Self {
422        self.q3_slice_ns = Some(v);
423        self
424    }
425
426    /// Set the EMA gauge ceiling in nsecs.
427    pub fn budget_max_ns(mut self, v: u64) -> Self {
428        self.budget_max_ns = Some(v);
429        self
430    }
431
432    /// Set the EMA climb aggressiveness.
433    pub fn alpha(mut self, v: u64) -> Self {
434        self.alpha = Some(v);
435        self
436    }
437
438    /// Set the interactive threshold T_L in nsecs.
439    pub fn t_l_ns(mut self, v: u64) -> Self {
440        self.t_l_ns = Some(v);
441        self
442    }
443
444    /// Set the CPU-bound threshold T_H in nsecs.
445    pub fn t_h_ns(mut self, v: u64) -> Self {
446        self.t_h_ns = Some(v);
447        self
448    }
449
450    /// Set the EMA decay half-life in nsecs.
451    pub fn ema_half_life_ns(mut self, v: u64) -> Self {
452        self.ema_half_life_ns = Some(v);
453        self
454    }
455
456    /// Set the global aging period in nsecs.
457    pub fn aging_period_ns(mut self, v: u64) -> Self {
458        self.aging_period_ns = Some(v);
459        self
460    }
461
462    /// Set the short-sleep boost window in nsecs.
463    pub fn short_sleep_ns(mut self, v: u64) -> Self {
464        self.short_sleep_ns = Some(v);
465        self
466    }
467
468    /// Set the per-task short-sleep boost rate limit in nsecs.
469    pub fn short_sleep_rate_limit_ns(mut self, v: u64) -> Self {
470        self.short_sleep_rate_limit_ns = Some(v);
471        self
472    }
473
474    /// Set the sleep counted as "short" for the wake_cnt hysteresis in nsecs.
475    pub fn hysteresis_sleep_ns(mut self, v: u64) -> Self {
476        self.hysteresis_sleep_ns = Some(v);
477        self
478    }
479
480    /// Set the long-sleep gauge-collapse window in nsecs.
481    pub fn long_sleep_ns(mut self, v: u64) -> Self {
482        self.long_sleep_ns = Some(v);
483        self
484    }
485
486    /// Set the same-queue preemption minimum residency in nsecs.
487    pub fn sameq_preempt_min_run_ns(mut self, v: u64) -> Self {
488        self.sameq_preempt_min_run_ns = Some(v);
489        self
490    }
491
492    /// Set the preempting-wakeup slice cap in nsecs.
493    pub fn preempt_slice_ns(mut self, v: u64) -> Self {
494        self.preempt_slice_ns = Some(v);
495        self
496    }
497
498    /// Set the Q1 dispatch quota.
499    pub fn q1_quota(mut self, v: u32) -> Self {
500        self.q1_quota = Some(v);
501        self
502    }
503
504    /// Set the Q2 dispatch quota.
505    pub fn q2_quota(mut self, v: u32) -> Self {
506        self.q2_quota = Some(v);
507        self
508    }
509
510    /// Set the dispatch-loop bound.
511    pub fn dispatch_max_batch(mut self, v: u32) -> Self {
512        self.dispatch_max_batch = Some(v);
513        self
514    }
515
516    /// Set the realtime-takeover drain interval in nsecs.
517    pub fn rtdl_drain_interval_ns(mut self, v: u64) -> Self {
518        self.rtdl_drain_interval_ns = Some(v);
519        self
520    }
521
522    /// Set the tree Q1/Q2 band edge (the base of the effective value).
523    pub fn tree_t_int_ns(mut self, v: u64) -> Self {
524        self.tree_t_int_ns = Some(v);
525        self
526    }
527
528    /// Set the tree Q2/Q3 band edge (the base of the effective value).
529    pub fn tree_t_bound_ns(mut self, v: u64) -> Self {
530        self.tree_t_bound_ns = Some(v);
531        self
532    }
533
534    /// Set the threshold-adaptation master gate.
535    pub fn adapt_enabled(mut self, v: bool) -> Self {
536        self.adapt_enabled = Some(v);
537        self
538    }
539
540    /// Assemble and validate the configuration.
541    ///
542    /// Returns an error if any invariant is violated; the resulting
543    /// `Config` is guaranteed valid.
544    pub fn build(self) -> Result<Config> {
545        let defaults = Config::default();
546        let cfg = Config {
547            q1_slice_ns: self.q1_slice_ns.unwrap_or(defaults.q1_slice_ns),
548            q2_slice_ns: self.q2_slice_ns.unwrap_or(defaults.q2_slice_ns),
549            q3_slice_ns: self.q3_slice_ns.unwrap_or(defaults.q3_slice_ns),
550            budget_max_ns: self.budget_max_ns.unwrap_or(defaults.budget_max_ns),
551            alpha: self.alpha.unwrap_or(defaults.alpha),
552            t_l_ns: self.t_l_ns.unwrap_or(defaults.t_l_ns),
553            t_h_ns: self.t_h_ns.unwrap_or(defaults.t_h_ns),
554            ema_half_life_ns: self.ema_half_life_ns.unwrap_or(defaults.ema_half_life_ns),
555            aging_period_ns: self.aging_period_ns.unwrap_or(defaults.aging_period_ns),
556            short_sleep_ns: self.short_sleep_ns.unwrap_or(defaults.short_sleep_ns),
557            short_sleep_rate_limit_ns: self
558                .short_sleep_rate_limit_ns
559                .unwrap_or(defaults.short_sleep_rate_limit_ns),
560            hysteresis_sleep_ns: self
561                .hysteresis_sleep_ns
562                .unwrap_or(defaults.hysteresis_sleep_ns),
563            long_sleep_ns: self.long_sleep_ns.unwrap_or(defaults.long_sleep_ns),
564            sameq_preempt_min_run_ns: self
565                .sameq_preempt_min_run_ns
566                .unwrap_or(defaults.sameq_preempt_min_run_ns),
567            preempt_slice_ns: self.preempt_slice_ns.unwrap_or(defaults.preempt_slice_ns),
568            q1_quota: self.q1_quota.unwrap_or(defaults.q1_quota),
569            q2_quota: self.q2_quota.unwrap_or(defaults.q2_quota),
570            dispatch_max_batch: self
571                .dispatch_max_batch
572                .unwrap_or(defaults.dispatch_max_batch),
573            rtdl_drain_interval_ns: self
574                .rtdl_drain_interval_ns
575                .unwrap_or(defaults.rtdl_drain_interval_ns),
576            tree_t_int_ns: self.tree_t_int_ns.unwrap_or(defaults.tree_t_int_ns),
577            tree_t_bound_ns: self.tree_t_bound_ns.unwrap_or(defaults.tree_t_bound_ns),
578            adapt_enabled: self.adapt_enabled.unwrap_or(defaults.adapt_enabled),
579        };
580        cfg.validate()?;
581        Ok(cfg)
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    #[test]
590    fn defaults_match_intf_h() {
591        /*
592         * Cross-check the Config defaults against the bindgen-generated
593         * enum mlfq_consts constants, the way topology.rs cross-checks its
594         * constants: intf.h is the single source of truth, and this test
595         * pins the binding so a default that diverges from the BPF side
596         * (or a constant that stops existing) fails here.
597         */
598        use crate::bpf_intf::{
599            mlfq_consts_MLFQ_AGING_PERIOD_NS, mlfq_consts_MLFQ_ALPHA,
600            mlfq_consts_MLFQ_BUDGET_MAX_NS, mlfq_consts_MLFQ_DISPATCH_MAX_BATCH,
601            mlfq_consts_MLFQ_EMA_HALF_LIFE_NS, mlfq_consts_MLFQ_HYSTERESIS_SLEEP_NS,
602            mlfq_consts_MLFQ_LONG_SLEEP_NS, mlfq_consts_MLFQ_PREEMPT_SLICE_NS,
603            mlfq_consts_MLFQ_Q1_QUOTA, mlfq_consts_MLFQ_Q1_SLICE_NS, mlfq_consts_MLFQ_Q2_QUOTA,
604            mlfq_consts_MLFQ_Q2_SLICE_NS, mlfq_consts_MLFQ_Q3_SLICE_NS,
605            mlfq_consts_MLFQ_RTDL_DRAIN_INTERVAL_NS, mlfq_consts_MLFQ_SAMEQ_PREEMPT_MIN_RUN_NS,
606            mlfq_consts_MLFQ_SHORT_SLEEP_NS, mlfq_consts_MLFQ_SHORT_SLEEP_RATE_LIMIT_NS,
607            mlfq_consts_MLFQ_T_H_NS, mlfq_consts_MLFQ_T_L_NS, mlfq_consts_MLFQ_TREE_T_BOUND_NS,
608            mlfq_consts_MLFQ_TREE_T_INT_NS,
609        };
610
611        let cfg = Config::default();
612        cfg.validate().unwrap();
613        assert_eq!(cfg.q1_slice_ns, mlfq_consts_MLFQ_Q1_SLICE_NS as u64);
614        assert_eq!(cfg.q2_slice_ns, mlfq_consts_MLFQ_Q2_SLICE_NS as u64);
615        assert_eq!(cfg.q3_slice_ns, mlfq_consts_MLFQ_Q3_SLICE_NS as u64);
616        assert_eq!(cfg.budget_max_ns, mlfq_consts_MLFQ_BUDGET_MAX_NS as u64);
617        assert_eq!(cfg.alpha, mlfq_consts_MLFQ_ALPHA as u64);
618        assert_eq!(cfg.t_l_ns, mlfq_consts_MLFQ_T_L_NS as u64);
619        assert_eq!(cfg.t_h_ns, mlfq_consts_MLFQ_T_H_NS as u64);
620        assert_eq!(
621            cfg.ema_half_life_ns,
622            mlfq_consts_MLFQ_EMA_HALF_LIFE_NS as u64
623        );
624        assert_eq!(cfg.aging_period_ns, mlfq_consts_MLFQ_AGING_PERIOD_NS as u64);
625        assert_eq!(cfg.short_sleep_ns, mlfq_consts_MLFQ_SHORT_SLEEP_NS as u64);
626        assert_eq!(
627            cfg.short_sleep_rate_limit_ns,
628            mlfq_consts_MLFQ_SHORT_SLEEP_RATE_LIMIT_NS as u64
629        );
630        assert_eq!(
631            cfg.hysteresis_sleep_ns,
632            mlfq_consts_MLFQ_HYSTERESIS_SLEEP_NS as u64
633        );
634        assert_eq!(cfg.long_sleep_ns, mlfq_consts_MLFQ_LONG_SLEEP_NS as u64);
635        assert_eq!(
636            cfg.sameq_preempt_min_run_ns,
637            mlfq_consts_MLFQ_SAMEQ_PREEMPT_MIN_RUN_NS as u64
638        );
639        assert_eq!(
640            cfg.preempt_slice_ns,
641            mlfq_consts_MLFQ_PREEMPT_SLICE_NS as u64
642        );
643        assert_eq!(cfg.q1_quota, mlfq_consts_MLFQ_Q1_QUOTA);
644        assert_eq!(cfg.q2_quota, mlfq_consts_MLFQ_Q2_QUOTA);
645        assert_eq!(cfg.dispatch_max_batch, mlfq_consts_MLFQ_DISPATCH_MAX_BATCH);
646        assert_eq!(
647            cfg.rtdl_drain_interval_ns,
648            mlfq_consts_MLFQ_RTDL_DRAIN_INTERVAL_NS as u64
649        );
650        assert_eq!(cfg.tree_t_int_ns, mlfq_consts_MLFQ_TREE_T_INT_NS as u64);
651        assert_eq!(cfg.tree_t_bound_ns, mlfq_consts_MLFQ_TREE_T_BOUND_NS as u64);
652        assert!(cfg.adapt_enabled, "the adaptation ships enabled");
653    }
654
655    #[test]
656    fn builder_defaults_equal_config_defaults() {
657        let cfg = ConfigBuilder::default().build().unwrap();
658        assert_eq!(cfg, Config::default());
659    }
660
661    #[test]
662    fn builder_overrides_individual_fields() {
663        let cfg = ConfigBuilder::default()
664            .q1_slice_ns(500_000)
665            .build()
666            .unwrap();
667        assert_eq!(cfg.q1_slice_ns, 500_000);
668        assert_eq!(cfg.q2_slice_ns, Config::default().q2_slice_ns);
669    }
670
671    #[test]
672    fn builder_overrides_sameq_min_run() {
673        let cfg = ConfigBuilder::default()
674            .sameq_preempt_min_run_ns(250_000)
675            .build()
676            .unwrap();
677        assert_eq!(cfg.sameq_preempt_min_run_ns, 250_000);
678        assert_eq!(cfg.q1_slice_ns, Config::default().q1_slice_ns);
679    }
680
681    #[test]
682    fn builder_overrides_preempt_slice() {
683        let cfg = ConfigBuilder::default()
684            .preempt_slice_ns(100_000)
685            .build()
686            .unwrap();
687        assert_eq!(cfg.preempt_slice_ns, 100_000);
688        assert_eq!(cfg.q1_slice_ns, Config::default().q1_slice_ns);
689    }
690
691    #[test]
692    fn rejects_zero_slices() {
693        assert!(ConfigBuilder::default().q1_slice_ns(0).build().is_err());
694        assert!(ConfigBuilder::default().q2_slice_ns(0).build().is_err());
695        assert!(ConfigBuilder::default().q3_slice_ns(0).build().is_err());
696    }
697
698    #[test]
699    fn rejects_t_l_at_or_above_t_h() {
700        assert!(ConfigBuilder::default().t_l_ns(2_000_000).build().is_err());
701        assert!(ConfigBuilder::default().t_l_ns(3_000_000).build().is_err());
702    }
703
704    #[test]
705    fn rejects_t_h_at_or_above_budget_max() {
706        assert!(ConfigBuilder::default().t_h_ns(6_000_000).build().is_err());
707        assert!(ConfigBuilder::default().t_h_ns(7_000_000).build().is_err());
708    }
709
710    #[test]
711    fn rejects_zero_quotas() {
712        assert!(ConfigBuilder::default().q1_quota(0).build().is_err());
713        assert!(ConfigBuilder::default().q2_quota(0).build().is_err());
714        assert!(
715            ConfigBuilder::default()
716                .dispatch_max_batch(0)
717                .build()
718                .is_err()
719        );
720    }
721
722    #[test]
723    fn rejects_quotas_consuming_the_batch() {
724        // Q1+Q2 must leave headroom for Q3 within dispatch_max_batch.
725        let cfg = ConfigBuilder::default()
726            .q1_quota(16)
727            .q2_quota(16)
728            .dispatch_max_batch(32)
729            .build();
730        assert!(cfg.is_err());
731    }
732
733    #[test]
734    fn rejects_dispatch_batch_above_ops_bound() {
735        // The rodata batch must never exceed the ops-table bound
736        // (.dispatch_max_batch = MLFQ_DISPATCH_MAX_BATCH in main.bpf.c).
737        let cfg = ConfigBuilder::default()
738            .dispatch_max_batch(crate::bpf_intf::mlfq_consts_MLFQ_DISPATCH_MAX_BATCH + 1)
739            .build();
740        assert!(cfg.is_err());
741        // At the ops-table bound itself the config is valid.
742        let cfg = ConfigBuilder::default()
743            .dispatch_max_batch(crate::bpf_intf::mlfq_consts_MLFQ_DISPATCH_MAX_BATCH)
744            .build();
745        assert!(cfg.is_ok());
746    }
747
748    #[test]
749    fn rejects_zero_aging_period() {
750        assert!(ConfigBuilder::default().aging_period_ns(0).build().is_err());
751    }
752
753    #[test]
754    fn rejects_tree_bands_out_of_order() {
755        assert!(ConfigBuilder::default().tree_t_int_ns(0).build().is_err());
756        let cfg = ConfigBuilder::default()
757            .tree_t_int_ns(3_000_000)
758            .tree_t_bound_ns(3_000_000)
759            .build();
760        assert!(cfg.is_err());
761        let cfg = ConfigBuilder::default()
762            .tree_t_int_ns(4_000_000)
763            .tree_t_bound_ns(3_000_000)
764            .build();
765        assert!(cfg.is_err());
766        // The default band pair is valid.
767        assert!(ConfigBuilder::default().build().is_ok());
768    }
769
770    #[test]
771    fn adapt_gate_flag_round_trips() {
772        let cfg = ConfigBuilder::default()
773            .adapt_enabled(true)
774            .build()
775            .unwrap();
776        assert!(cfg.adapt_enabled);
777        let cfg = ConfigBuilder::default().build().unwrap();
778        assert!(cfg.adapt_enabled);
779    }
780
781    #[test]
782    fn describe_is_stable() {
783        let cfg = Config::default();
784        let s = cfg.describe();
785        assert!(s.contains("slices: Q1=1000us Q2=2000us Q3=4000us"));
786        assert!(s.contains("T_L=250us, T_H=2000us"));
787        assert!(s.contains("aging_period=1s"));
788        assert!(s.contains("rtdl_drain_interval=1000us"));
789        assert!(s.contains("quotas: Q1=4 Q2=8 max_batch=32"));
790        assert!(s.contains("tree_bands: T_INT=1000us T_BOUND=3000us"));
791        assert!(s.contains("adapt_enabled=true"));
792    }
793}