Skip to main content

scx_mitosis/
main.rs

1// Copyright (c) Meta Platforms, Inc. and affiliates.
2
3// This software may be used and distributed according to the terms of the
4// GNU General Public License version 2.
5
6#[allow(clippy::unwrap_used)]
7mod bpf_skel;
8pub use bpf_skel::*;
9pub mod bpf_intf;
10mod cell_manager;
11mod stats;
12mod topology;
13mod undefok_flags;
14
15use cell_manager::{CellManager, CpuAssignment, CpuRecipient};
16
17use std::cmp::max;
18use std::collections::{HashMap, HashSet};
19use std::fmt;
20use std::fmt::Display;
21use std::mem::MaybeUninit;
22use std::os::fd::AsFd;
23use std::sync::atomic::AtomicBool;
24use std::sync::atomic::AtomicU32;
25use std::sync::atomic::Ordering;
26use std::sync::Arc;
27use std::time::Duration;
28use std::time::Instant;
29
30use anyhow::anyhow;
31use anyhow::bail;
32use anyhow::Context;
33use anyhow::Result;
34use clap::Parser;
35use libbpf_rs::MapCore as _;
36use libbpf_rs::OpenObject;
37use libbpf_rs::ProgramInput;
38use nix::sys::epoll::{Epoll, EpollCreateFlags, EpollEvent, EpollFlags, EpollTimeout};
39use nix::sys::eventfd::EventFd;
40use scx_stats::prelude::*;
41use scx_utils::build_id;
42use scx_utils::compat;
43use scx_utils::init_libbpf_logging;
44use scx_utils::libbpf_clap_opts::LibbpfOpts;
45use scx_utils::scx_enums;
46use scx_utils::scx_ops_attach;
47use scx_utils::scx_ops_load;
48use scx_utils::scx_ops_open;
49use scx_utils::uei_exited;
50use scx_utils::uei_report;
51use scx_utils::Cpumask;
52use scx_utils::Topology;
53use scx_utils::TopologyArgs;
54use scx_utils::UserExitInfo;
55use scx_utils::NR_CPUS_POSSIBLE;
56use tracing::{debug, info, trace, warn};
57use tracing_subscriber::filter::EnvFilter;
58
59use stats::CellMetrics;
60use stats::Metrics;
61use topology::MitosisTopology;
62
63const SCHEDULER_NAME: &str = "scx_mitosis";
64const MAX_CELLS: usize = bpf_intf::consts_MAX_CELLS as usize;
65const MAX_SUBCELLS_PER_CELL: usize = bpf_intf::consts_MAX_SUBCELLS_PER_CELL as usize;
66const NR_CSTATS: usize = bpf_intf::cell_stat_idx_NR_CSTATS as usize;
67/// Epoll token for inotify events (cgroup creation/destruction)
68const INOTIFY_TOKEN: u64 = 1;
69/// Epoll token for stats request wakeups
70const STATS_TOKEN: u64 = 2;
71
72fn parse_ewma_factor(s: &str) -> Result<f64, String> {
73    let v: f64 = s.parse().map_err(|e| format!("{e}"))?;
74    if !(0.0..=1.0).contains(&v) {
75        return Err(format!("value {v} not in range 0.0..=1.0"));
76    }
77    Ok(v)
78}
79
80/// scx_mitosis: A dynamic affinity scheduler
81///
82/// Cgroups are assigned to a dynamic number of Cells which are assigned to a
83/// dynamic set of CPUs. The BPF part does simple vtime scheduling for each subcell.
84///
85/// Userspace makes the dynamic decisions of which Cells should be merged or
86/// split and which CPUs they should be assigned to.
87#[derive(Debug, Parser)]
88#[command(after_long_help = r#"VIRTUAL LLC CONFIGURATION:
89    --virt-llc requires --enable-llc-awareness to affect DSQ creation.
90    MIN-MAX specifies the target range of physical cores per virtual LLC.
91    For each physical LLC and core type, the smallest exact divisor in the
92    range is preferred; otherwise, the size with the smallest remainder is
93    used, favoring smaller sizes. Remaining cores join the last partition.
94
95    Example: On a 32-core physical LLC, --virt-llc=8-8 creates four virtual
96    LLCs, and therefore four LLC DSQs per cell."#)]
97struct Opts {
98    /// Deprecated, noop, use RUST_LOG or --log-level instead.
99    #[clap(short = 'v', long, action = clap::ArgAction::Count)]
100    verbose: u8,
101
102    /// Specify the logging level. Accepts rust's envfilter syntax for modular
103    /// logging: https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#example-syntax. Examples: ["info", "warn,tokio=info"]
104    #[clap(long, default_value = "info")]
105    log_level: String,
106
107    /// Exit debug dump buffer length. 0 indicates default.
108    #[clap(long, default_value = "0")]
109    exit_dump_len: u32,
110
111    /// Interval to report monitoring information
112    #[clap(long, default_value = "1")]
113    monitor_interval_s: u64,
114
115    /// Run in stats monitoring mode with the specified interval. Scheduler
116    /// is not launched.
117    #[clap(long)]
118    monitor: Option<f64>,
119
120    /// Print scheduler version and exit.
121    #[clap(short = 'V', long, action = clap::ArgAction::SetTrue)]
122    version: bool,
123
124    /// Optional run ID for tracking scheduler instances.
125    #[clap(long)]
126    run_id: Option<u64>,
127
128    /// Ignore the listed long flags if present. Accepts a comma-separated list
129    /// of names without the leading `--`, for example
130    /// `--undefok=reconfiguration-interval-s,rebalance-cpus-interval-s`.
131    #[clap(long, value_delimiter = ',')]
132    undefok: Vec<String>,
133
134    /// Enable workaround for exiting tasks with offline cgroups during scheduler load.
135    /// This works around a kernel bug where tasks can be initialized with cgroups that
136    /// were never initialized. Disable this once the kernel bug is fixed.
137    #[clap(long, default_value = "true", action = clap::ArgAction::Set)]
138    exiting_task_workaround: bool,
139
140    /// Disable SCX cgroup callbacks (for when CPU cgroup controller is disabled).
141    /// Uses tracepoints and cgroup iteration instead.
142    #[clap(long, action = clap::ArgAction::SetTrue)]
143    cpu_controller_disabled: bool,
144
145    /// Reject tasks with multi-CPU pinning that doesn't cover the entire cell.
146    /// By default, these tasks are allowed but may have degraded performance.
147    #[clap(long, action = clap::ArgAction::SetTrue)]
148    reject_multicpu_pinning: bool,
149
150    /// Enable LLC-awareness. This will populate the scheduler's LLC maps and cause it
151    /// to use LLC-aware scheduling. Required for --virt-llc to affect DSQ creation.
152    #[clap(long, action = clap::ArgAction::SetTrue)]
153    enable_llc_awareness: bool,
154
155    /// Deprecated, noop. LLC-aware mode always scans sibling LLC DSQs.
156    #[clap(long, action = clap::ArgAction::SetTrue)]
157    enable_work_stealing: bool,
158
159    /// Parent cgroup path whose direct children become cells.
160    /// Scheduler startup requires this unless running in --monitor or --version mode.
161    /// Example: --cell-parent-cgroup /workloads
162    #[clap(long, required_unless_present_any = ["monitor", "version"])]
163    cell_parent_cgroup: Option<String>,
164
165    /// Exact directory name of a direct child cgroup to exclude from cell creation
166    /// (excluded cgroups remain in cell 0). Matched against the directory basename,
167    /// not the full path. Can be specified multiple times. Requires --cell-parent-cgroup.
168    /// Example: --cell-exclude systemd-workaround.service
169    #[clap(long)]
170    cell_exclude: Vec<String>,
171
172    /// Reserve up to this many CPUs for cell 0 (the root/catch-all cell)
173    /// before child cpusets are applied (the holdout). A non-zero value keeps
174    /// cell 0 from being starved to zero when child cgroups' cpusets cover
175    /// every CPU. The reservation never takes a child cell's last CPU, so cell 0
176    /// may receive fewer than requested. 0 (the default) disables the holdout;
177    /// the assignment then bails if cell 0 would receive no CPUs.
178    #[clap(long, default_value_t = 0)]
179    cell0_min_cpus: usize,
180
181    /// Enable CPU borrowing: cells can use idle CPUs from other cells.
182    /// Only meaningful with --cell-parent-cgroup and multiple cells.
183    #[clap(long, action = clap::ArgAction::SetTrue)]
184    enable_borrowing: bool,
185
186    /// Use lockless scx_bpf_dsq_peek() instead of the default iterator-based peek.
187    #[clap(long, action = clap::ArgAction::SetTrue)]
188    use_lockless_peek: bool,
189
190    /// Enable demand-based CPU rebalancing between cells.
191    #[clap(long, action = clap::ArgAction::SetTrue)]
192    enable_rebalancing: bool,
193
194    /// Utilization spread (max - min) that triggers rebalancing (default: 20%)
195    #[clap(long, default_value = "20.0")]
196    rebalance_threshold: f64,
197
198    /// Minimum seconds between rebalancing events (default: 5)
199    #[clap(long, default_value = "5")]
200    rebalance_cooldown_s: u64,
201
202    /// EWMA smoothing factor for demand tracking. Higher = more responsive (default: 0.3)
203    #[clap(long, default_value = "0.3", value_parser = parse_ewma_factor)]
204    demand_smoothing: f64,
205
206    /// Dynamically reassign multi-CPU affinitized tasks on each wake: prefer an
207    /// idle CPU within the mask, fall back to random. Redistribute at enqueue if
208    /// the target CPU already has queued work.
209    #[clap(long, action = clap::ArgAction::SetTrue)]
210    dynamic_affinity_cpu_selection: bool,
211
212    /// Enable slice shrinking for CPU-pinned tasks. Uses per-task EWMA
213    /// runtime to shrink the running task's slice when pinned waiters are queued.
214    #[clap(long, action = clap::ArgAction::SetTrue)]
215    enable_slice_shrinking: bool,
216
217    /// Upper bound for shrink limit (us). Used when the proportional
218    /// value (avg_runtime * K) exceeds it.
219    #[clap(long, default_value = "4000")]
220    slice_shrink_max_us: u64,
221
222    /// Minimum shrink limit (us). Slices are never shrunk below this value.
223    /// In practice, the resolution here is determined by the kernel's
224    /// tick period.
225    #[clap(long, default_value = "500")]
226    slice_shrink_min_us: u64,
227
228    #[clap(flatten, next_help_heading = "Topology Options")]
229    topology: TopologyArgs,
230
231    #[clap(flatten, next_help_heading = "Libbpf Options")]
232    pub libbpf: LibbpfOpts,
233}
234
235// The subset of cstats we care about.
236// Local + Default + Hi + Lo = Total Decisions
237// Affinity violations are not queue decisions, but
238// will be calculated separately and reported as a percent of the total
239const QUEUE_STATS_IDX: [bpf_intf::cell_stat_idx; 4] = [
240    bpf_intf::cell_stat_idx_CSTAT_LOCAL,
241    bpf_intf::cell_stat_idx_CSTAT_CPU_DSQ,
242    bpf_intf::cell_stat_idx_CSTAT_CELL_DSQ,
243    bpf_intf::cell_stat_idx_CSTAT_BORROWED,
244];
245
246// Per cell book-keeping
247#[derive(Debug)]
248struct Cell {
249    cpus: Cpumask,
250    subcells: Vec<Subcell>,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq)]
254struct Subcell {
255    id: u32,
256    primary: Cpumask,
257    borrowable: Option<Cpumask>,
258}
259
260impl Cell {
261    fn new() -> Self {
262        Self {
263            cpus: Cpumask::new(),
264            subcells: vec![Subcell::new(0)],
265        }
266    }
267}
268
269impl Subcell {
270    fn new(id: u32) -> Self {
271        Self {
272            id,
273            primary: Cpumask::new(),
274            borrowable: None,
275        }
276    }
277}
278
279struct Scheduler<'a> {
280    skel: BpfSkel<'a>,
281    monitor_interval: Duration,
282    cells: HashMap<u32, Cell>,
283    // These are the per-cell cstats.
284    // Note these are accumulated across all CPUs.
285    prev_cell_stats: [[u64; NR_CSTATS]; MAX_CELLS],
286    // Per-cell running_ns tracking for demand metrics
287    prev_cell_running_ns: [u64; MAX_CELLS],
288    prev_cell_own_ns: [u64; MAX_CELLS],
289    prev_cell_lent_ns: [u64; MAX_CELLS],
290    metrics: Metrics,
291    stats_server: Option<StatsServer<(), Metrics>>,
292    last_configuration_seq: Option<u32>,
293    /// Last observed cpuset_seq for cpuset change detection
294    last_cpuset_seq: u32,
295    /// Cell manager for the cgroup passed via --cell-parent-cgroup.
296    cell_manager: CellManager,
297    /// Whether CPU borrowing is enabled
298    enable_borrowing: bool,
299    /// Whether demand-based rebalancing is enabled
300    enable_rebalancing: bool,
301    /// Utilization spread threshold for triggering rebalancing
302    rebalance_threshold: f64,
303    /// Minimum duration between rebalancing events
304    rebalance_cooldown: Duration,
305    /// EWMA smoothing factor for demand tracking
306    demand_smoothing: f64,
307    /// EWMA-smoothed utilization per cell
308    smoothed_util: [f64; MAX_CELLS],
309    /// Last time rebalancing was performed
310    last_rebalance: Instant,
311    /// Number of rebalancing events
312    rebalance_count: u64,
313    /// Epoll instance for waiting on multiple fds (inotify, stats wakeup)
314    epoll: Epoll,
315    /// EventFd to wake up main loop when stats are requested
316    stats_waker: EventFd,
317}
318
319struct DistributionStats {
320    total_decisions: u64,
321    share_of_decisions_pct: f64,
322    local_q_pct: f64,
323    cpu_q_pct: f64,
324    cell_q_pct: f64,
325    borrowed_pct: f64,
326    affn_viol_pct: f64,
327    steal_pct: f64,
328    pin_skip_pct: f64,
329
330    // for formatting
331    global_queue_decisions: u64,
332}
333
334impl Display for DistributionStats {
335    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
336        // This makes the output easier to read by improving column alignment. First, it guarantees that within a
337        // given logging interval, the global and cell queueing decision counts print at the same width.
338        // Second, it reduces variance in column width between logging intervals. 5 is simply a heuristic.
339        const MIN_DECISIONS_WIDTH: usize = 5;
340        let descisions_width = if self.global_queue_decisions > 0 {
341            max(
342                MIN_DECISIONS_WIDTH,
343                (self.global_queue_decisions as f64).log10().ceil() as usize,
344            )
345        } else {
346            MIN_DECISIONS_WIDTH
347        };
348        write!(
349            f,
350            "{:width$} {:5.1}% | Local:{:4.1}% From: CPU:{:4.1}% Cell:{:4.1}% Borrow:{:4.1}% | V:{:4.1}% S:{:4.1}% PS:{:4.1}%",
351            self.total_decisions,
352            self.share_of_decisions_pct,
353            self.local_q_pct,
354            self.cpu_q_pct,
355            self.cell_q_pct,
356            self.borrowed_pct,
357            self.affn_viol_pct,
358            self.steal_pct,
359            self.pin_skip_pct,
360            width = descisions_width,
361        )
362    }
363}
364
365impl<'a> Scheduler<'a> {
366    fn managed_cell_parent<'b>(opts: &'b Opts) -> Result<&'b str> {
367        opts.cell_parent_cgroup
368            .as_deref()
369            .ok_or_else(|| anyhow!("--cell-parent-cgroup is required to run the scheduler"))
370    }
371
372    fn validate_args(_opts: &Opts) -> Result<()> {
373        Ok(())
374    }
375
376    fn init(opts: &Opts, open_object: &'a mut MaybeUninit<OpenObject>) -> Result<Self> {
377        Self::validate_args(opts).context("validating scheduler options")?;
378
379        let topology = Topology::with_args(&opts.topology).context("detecting system topology")?;
380
381        let nr_llc = topology.all_llcs.len().max(1);
382
383        let mut skel_builder = BpfSkelBuilder::default();
384        skel_builder
385            .obj_builder
386            .debug(opts.log_level.contains("trace"));
387        init_libbpf_logging(None);
388        info!(
389            "Running scx_mitosis (build ID: {})",
390            build_id::full_version(env!("CARGO_PKG_VERSION"))
391        );
392
393        let open_opts = opts.libbpf.clone().into_bpf_open_opts();
394        let mut skel = scx_ops_open!(skel_builder, open_object, mitosis, open_opts)
395            .context("opening BPF skeleton")?;
396
397        skel.struct_ops.mitosis_mut().exit_dump_len = opts.exit_dump_len;
398
399        let rodata = skel
400            .maps
401            .rodata_data
402            .as_mut()
403            .expect("BUG: rodata_data missing after skel open");
404
405        rodata.slice_ns = scx_enums.SCX_SLICE_DFL;
406        rodata.exiting_task_workaround_enabled = opts.exiting_task_workaround;
407        rodata.cpu_controller_disabled = opts.cpu_controller_disabled;
408        rodata.dynamic_affinity_cpu_selection = opts.dynamic_affinity_cpu_selection;
409
410        // Slice shrinking configuration
411        if opts.slice_shrink_min_us >= opts.slice_shrink_max_us {
412            bail!(
413                "--slice-shrink-min-us ({}) must be less than --slice-shrink-max-us ({})",
414                opts.slice_shrink_min_us,
415                opts.slice_shrink_max_us
416            );
417        }
418        rodata.enable_slice_shrinking = opts.enable_slice_shrinking;
419        rodata.slice_shrink_max_ns = opts.slice_shrink_max_us * 1_000;
420        // K=2: in the proportional region, a pinned task waits at most 2x its historical runtime
421        rodata.slice_shrink_multiplier = 2;
422        rodata.slice_shrink_min_ns = opts.slice_shrink_min_us * 1_000;
423
424        rodata.nr_possible_cpus = *NR_CPUS_POSSIBLE as u32;
425        for cpu in topology.all_cpus.keys() {
426            rodata.all_cpus[cpu / 8] |= 1 << (cpu % 8);
427        }
428
429        rodata.reject_multicpu_pinning = opts.reject_multicpu_pinning;
430
431        // Set nr_llc in rodata
432        rodata.nr_llc = nr_llc as u32;
433        rodata.enable_llc_awareness = opts.enable_llc_awareness;
434
435        rodata.enable_borrowing = opts.enable_borrowing;
436        rodata.use_lockless_peek = opts.use_lockless_peek;
437
438        match *compat::SCX_OPS_ALLOW_QUEUED_WAKEUP {
439            0 => info!("Kernel does not support queued wakeup optimization."),
440            v => skel.struct_ops.mitosis_mut().flags |= v,
441        }
442
443        let mitosis_topology = MitosisTopology::new(&topology);
444        mitosis_topology
445            .apply(&mut skel)
446            .context("mitosis_topology.apply")?;
447
448        let skel = scx_ops_load!(skel, mitosis, uei).context("loading BPF skeleton")?;
449
450        let stats_server = StatsServer::new(stats::server_data())
451            .launch()
452            .context("launching stats server")?;
453
454        let parent_cgroup = Self::managed_cell_parent(opts)?;
455        let exclude: HashSet<String> = opts.cell_exclude.iter().cloned().collect();
456        let cell_manager = CellManager::new(
457            parent_cgroup,
458            MAX_CELLS as u32,
459            topology.span.clone(),
460            exclude,
461            opts.cell0_min_cpus,
462            mitosis_topology.cpu_to_llc.into_iter().collect(),
463        )
464        .with_context(|| format!("initializing cell manager for cgroup {}", parent_cgroup))?;
465
466        // Create epoll instance for event-driven main loop
467        let epoll = Epoll::new(EpollCreateFlags::empty()).context("creating epoll instance")?;
468
469        // Create eventfd for stats wakeup (non-blocking, semaphore mode)
470        let stats_waker = EventFd::from_value_and_flags(
471            0,
472            nix::sys::eventfd::EfdFlags::EFD_NONBLOCK | nix::sys::eventfd::EfdFlags::EFD_SEMAPHORE,
473        )
474        .context("creating stats-waker eventfd")?;
475
476        // Register stats_waker with epoll
477        epoll
478            .add(
479                &stats_waker,
480                EpollEvent::new(EpollFlags::EPOLLIN, STATS_TOKEN),
481            )
482            .context("registering stats-waker with epoll")?;
483
484        epoll
485            .add(
486                &cell_manager,
487                EpollEvent::new(EpollFlags::EPOLLIN, INOTIFY_TOKEN),
488            )
489            .context("registering cell manager inotify with epoll")?;
490
491        Ok(Self {
492            skel,
493            monitor_interval: Duration::from_secs(opts.monitor_interval_s),
494            cells: HashMap::new(),
495            prev_cell_stats: [[0; NR_CSTATS]; MAX_CELLS],
496            prev_cell_running_ns: [0; MAX_CELLS],
497            prev_cell_own_ns: [0; MAX_CELLS],
498            prev_cell_lent_ns: [0; MAX_CELLS],
499            metrics: Metrics::default(),
500            stats_server: Some(stats_server),
501            last_configuration_seq: None,
502            last_cpuset_seq: 0,
503            cell_manager,
504            enable_borrowing: opts.enable_borrowing,
505            enable_rebalancing: opts.enable_rebalancing,
506            rebalance_threshold: opts.rebalance_threshold,
507            rebalance_cooldown: Duration::from_secs(opts.rebalance_cooldown_s),
508            demand_smoothing: opts.demand_smoothing,
509            smoothed_util: [0.0; MAX_CELLS],
510            last_rebalance: Instant::now(),
511            rebalance_count: 0,
512            epoll,
513            stats_waker,
514        })
515    }
516
517    fn run(&mut self, shutdown: Arc<AtomicBool>) -> Result<UserExitInfo> {
518        let struct_ops = scx_ops_attach!(self.skel, mitosis).context("attaching BPF scheduler")?;
519
520        info!("Mitosis Scheduler Attached. Run `scx_mitosis --monitor` for metrics.");
521
522        // Apply initial cell configuration if CellManager is active
523        self.apply_initial_cells()
524            .context("applying initial cell configuration")?;
525
526        let (res_ch, req_ch) = self
527            .stats_server
528            .as_ref()
529            .expect("BUG: stats_server missing after init")
530            .channels();
531
532        // Spawn thread to bridge stats requests to eventfd.
533        // The thread exits when the channel closes (stats_server dropped).
534        // Clone the eventfd so the thread owns its own handle to the same kernel object.
535        let stats_waker_fd = self
536            .stats_waker
537            .as_fd()
538            .try_clone_to_owned()
539            .context("cloning stats-waker fd for bridge thread")?;
540        let stats_waker = unsafe { EventFd::from_owned_fd(stats_waker_fd) };
541        let stats_bridge = std::thread::spawn(move || {
542            while req_ch.recv().is_ok() {
543                // Wake up main loop via eventfd
544                let _ = stats_waker.write(1);
545            }
546        });
547
548        while !shutdown.load(Ordering::Relaxed) && !uei_exited!(&self.skel, uei) {
549            let mut events = [EpollEvent::empty(); 1];
550            let timeout = EpollTimeout::try_from(self.monitor_interval).with_context(|| {
551                format!(
552                    "monitor_interval {:?} exceeds maximum epoll timeout",
553                    self.monitor_interval,
554                )
555            })?;
556
557            match self.epoll.wait(&mut events, timeout) {
558                Ok(n) => {
559                    for event in &events[..n] {
560                        match event.data() {
561                            INOTIFY_TOKEN => {
562                                // Cgroup event - process immediately
563                                self.process_cell_events()
564                                    .context("processing cell manager events")?;
565                            }
566                            STATS_TOKEN => {
567                                // Stats request - drain eventfd and send metrics
568                                let _ = self.stats_waker.read();
569                                res_ch
570                                    .send(self.get_metrics())
571                                    .context("sending metrics response")?;
572                            }
573                            _ => {}
574                        }
575                    }
576                }
577                Err(nix::errno::Errno::EINTR) => continue,
578                Err(e) => return Err(e.into()),
579            }
580
581            // Periodic work on every iteration
582            self.refresh_bpf_cells()
583                .context("refreshing BPF cell state")?;
584            self.check_cpuset_changes()
585                .context("checking cpuset changes")?;
586            self.collect_metrics().context("collecting metrics")?;
587
588            if self.enable_rebalancing {
589                self.maybe_rebalance().context("running rebalance check")?;
590            }
591        }
592
593        drop(struct_ops);
594        // Drop stats_server to close the channel, allowing stats_bridge to exit
595        drop(self.stats_server.take());
596        let _ = stats_bridge.join();
597        info!("Unregister {SCHEDULER_NAME} scheduler");
598        uei_report!(&self.skel, uei)
599    }
600
601    /// Apply initial cell assignments discovered at startup
602    fn apply_initial_cells(&mut self) -> Result<()> {
603        let cpu_assignments = self
604            .compute_and_apply_cell_config(&[])
605            .context("computing initial cell configuration")?;
606
607        info!(
608            "Applied initial cell configuration: {}",
609            self.cell_manager.format_cell_config(&cpu_assignments)
610        );
611
612        Ok(())
613    }
614
615    /// Process cell manager events (new/destroyed cgroups)
616    fn process_cell_events(&mut self) -> Result<()> {
617        let (num_new, num_destroyed, new_cell_ids, destroyed_cell_ids) = {
618            let (new_cells, destroyed_cells) = self
619                .cell_manager
620                .process_events()
621                .context("processing inotify events")?;
622
623            if new_cells.is_empty() && destroyed_cells.is_empty() {
624                return Ok(());
625            }
626
627            let new_ids: Vec<u32> = new_cells.iter().map(|(_, cell_id)| *cell_id).collect();
628            (
629                new_cells.len(),
630                destroyed_cells.len(),
631                new_ids,
632                destroyed_cells,
633            )
634        };
635
636        // Clear smoothed_util for destroyed cells so stale data doesn't
637        // leak if the cell ID is reused later.
638        for &cell_id in &destroyed_cell_ids {
639            self.smoothed_util[cell_id as usize] = 0.0;
640        }
641
642        let cpu_assignments = self
643            .compute_and_apply_cell_config(&new_cell_ids)
644            .context("recomputing cell configuration for new cgroups")?;
645
646        info!(
647            "Cell config updated ({} new, {} destroyed): {}",
648            num_new,
649            num_destroyed,
650            self.cell_manager.format_cell_config(&cpu_assignments)
651        );
652
653        Ok(())
654    }
655
656    /// Compute cell configuration from CellManager and apply it to BPF.
657    ///
658    /// When rebalancing is enabled and there is existing utilization data,
659    /// uses demand-weighted CPU assignment instead of equal-weight. New cells
660    /// (listed in `new_cell_ids`) are seeded to the average smoothed_util of
661    /// existing cells so they start with a fair share rather than zero.
662    ///
663    /// Returns the CPU assignments for use with `format_cell_config`.
664    fn compute_and_apply_cell_config(
665        &mut self,
666        new_cell_ids: &[u32],
667    ) -> Result<Vec<CpuAssignment>> {
668        let (cell_assignments, cpu_assignments, subcell_assignments) = {
669            let active_cell_ids: Vec<u32> = self
670                .cell_manager
671                .get_cell_assignments()
672                .iter()
673                .map(|(_, cell_id)| *cell_id)
674                .collect();
675            // Cell 0 is always active
676            let all_cell_ids: Vec<u32> = std::iter::once(0)
677                .chain(active_cell_ids.iter().copied())
678                .collect();
679
680            let cpu_assignments = if self.enable_rebalancing {
681                // Check if any existing (non-new) cell has utilization data
682                let new_set: HashSet<u32> = new_cell_ids.iter().copied().collect();
683                let existing_utils: Vec<f64> = all_cell_ids
684                    .iter()
685                    .filter(|id| !new_set.contains(id))
686                    .map(|&id| self.smoothed_util[id as usize])
687                    .collect();
688
689                let has_data = existing_utils.iter().any(|&u| u > 0.0);
690
691                if has_data {
692                    // Seed new cells to the average utilization of existing cells
693                    let avg_util: f64 =
694                        existing_utils.iter().sum::<f64>() / existing_utils.len().max(1) as f64;
695                    for &id in new_cell_ids {
696                        self.smoothed_util[id as usize] = avg_util;
697                        info!(
698                            "Seeded new cell {} smoothed_util to average {:.1}%",
699                            id, avg_util
700                        );
701                    }
702
703                    // Build demand map from smoothed_util for all active cells
704                    let cell_demands: HashMap<u32, f64> = all_cell_ids
705                        .iter()
706                        .map(|&id| (id, self.smoothed_util[id as usize]))
707                        .collect();
708
709                    self.cell_manager
710                        .compute_demand_cpu_assignments(&cell_demands, self.enable_borrowing)
711                        .context("computing demand-weighted CPU assignments")?
712                } else {
713                    // No utilization data yet (e.g., initial startup) — equal weight
714                    self.cell_manager
715                        .compute_cpu_assignments(self.enable_borrowing)
716                        .context("computing equal-weight CPU assignments (no utilization data)")?
717                }
718            } else {
719                self.cell_manager
720                    .compute_cpu_assignments(self.enable_borrowing)
721                    .context("computing equal-weight CPU assignments (rebalancing disabled)")?
722            };
723
724            // TODO(kkd): Plug in demand weighted subcell assignments once
725            // supported.
726            let subcell_assignments = self.compute_subcell_assignments(&cpu_assignments)?;
727
728            (
729                self.cell_manager.get_cell_assignments(),
730                cpu_assignments,
731                subcell_assignments,
732            )
733        };
734
735        self.apply_cell_config(&cell_assignments, &cpu_assignments, &subcell_assignments)
736            .context("applying cell configuration to BPF")?;
737
738        Ok(cpu_assignments)
739    }
740
741    /// Check if rebalancing should be triggered and apply demand-weighted CPU assignments.
742    fn maybe_rebalance(&mut self) -> Result<()> {
743        // Check cooldown
744        if self.last_rebalance.elapsed() < self.rebalance_cooldown {
745            return Ok(());
746        }
747
748        // Compute min/max smoothed utilization across active cells
749        let active_cells: Vec<u32> = self.cells.keys().copied().collect();
750        if active_cells.len() < 2 {
751            return Ok(());
752        }
753
754        let mut min_util = f64::MAX;
755        let mut max_util = f64::MIN;
756        for &cell_id in &active_cells {
757            let util = self.smoothed_util[cell_id as usize];
758            if util < min_util {
759                min_util = util;
760            }
761            if util > max_util {
762                max_util = util;
763            }
764        }
765
766        let spread = max_util - min_util;
767        if spread < self.rebalance_threshold {
768            return Ok(());
769        }
770
771        // Build demand map from smoothed utilization
772        let cell_demands: HashMap<u32, f64> = active_cells
773            .iter()
774            .map(|&cell_id| (cell_id, self.smoothed_util[cell_id as usize]))
775            .collect();
776
777        // Compute new assignments and check if they differ from current
778        let (cell_assignments, cpu_assignments, subcell_assignments) = {
779            let cpu_assignments = self
780                .cell_manager
781                .compute_demand_cpu_assignments(&cell_demands, self.enable_borrowing)
782                .context("computing demand-weighted CPU assignments for rebalance")?;
783
784            let changed = cpu_assignments.iter().any(|a| {
785                self.cells
786                    .get(&a.id)
787                    .map_or(true, |cell| cell.cpus != a.primary)
788            });
789
790            // TODO(kkd): Need logic to check changed demand assignments for
791            // subcells as well once demand weighted allocations work.
792
793            if !changed {
794                return Ok(());
795            }
796
797            let subcell_assignments = self.compute_subcell_assignments(&cpu_assignments)?;
798
799            (
800                self.cell_manager.get_cell_assignments(),
801                cpu_assignments,
802                subcell_assignments,
803            )
804        };
805
806        self.apply_cell_config(&cell_assignments, &cpu_assignments, &subcell_assignments)
807            .context("applying rebalanced cell configuration to BPF")?;
808
809        self.last_rebalance = Instant::now();
810        self.rebalance_count += 1;
811        self.metrics.rebalance_count = self.rebalance_count;
812
813        info!(
814            "Rebalanced CPUs (spread={:.1}%, count={}): {}",
815            spread,
816            self.rebalance_count,
817            self.cell_manager.format_cell_config(&cpu_assignments)
818        );
819
820        Ok(())
821    }
822
823    fn compute_subcell_assignments(
824        &self,
825        cell_cpu_assignments: &[CpuAssignment],
826    ) -> Result<Vec<Vec<CpuAssignment>>> {
827        cell_cpu_assignments
828            .iter()
829            .map(|cell_assignment| {
830                let recipients: Vec<CpuRecipient> = self
831                    .cells
832                    .get(&cell_assignment.id)
833                    .map(|cell| {
834                        cell.subcells
835                            .iter()
836                            .map(|subcell| {
837                                CpuRecipient::unpinned(subcell.id, 1.0, &cell_assignment.primary)
838                            })
839                            .collect()
840                    })
841                    .unwrap_or_else(|| {
842                        vec![CpuRecipient::unpinned(0, 1.0, &cell_assignment.primary)]
843                    });
844
845                CellManager::compute_subcell_cpu_assignments(
846                    &cell_assignment.primary,
847                    &recipients,
848                    self.enable_borrowing,
849                )
850            })
851            .collect()
852    }
853
854    fn refresh_bpf_subcells(&mut self, active_cells: &HashSet<u32>) -> Result<()> {
855        for cell_id in active_cells {
856            let bpf_cell = read_bpf_cell(&self.skel, *cell_id)?;
857            let subcells: Vec<Subcell> = bpf_cell
858                .subcells
859                .iter()
860                .filter(|subcell| subcell.in_use != 0)
861                .map(|subcell| {
862                    Ok(Subcell {
863                        id: subcell.id,
864                        primary: read_cpumask_from_bytes(&subcell.primary.mask)?,
865                        borrowable: if self.enable_borrowing {
866                            Some(read_cpumask_from_bytes(&subcell.borrowable.mask)?)
867                        } else {
868                            None
869                        },
870                    })
871                })
872                .collect::<Result<_>>()?;
873
874            if subcells.is_empty() {
875                bail!("Cell {} has no active subcells in BPF state", cell_id);
876            }
877
878            let cell = self.cells.get_mut(cell_id).ok_or_else(|| {
879                anyhow::anyhow!("Cell {} missing during subcell refresh", cell_id)
880            })?;
881            cell.subcells = subcells;
882        }
883
884        Ok(())
885    }
886
887    /// Apply cell configuration to BPF.
888    ///
889    /// Writes the cell and CPU assignments to the BPF config struct and triggers
890    /// the BPF program to apply the configuration.
891    fn apply_cell_config(
892        &mut self,
893        cell_assignments: &[(u64, u32)],
894        cpu_assignments: &[CpuAssignment],
895        subcell_assignments: &[Vec<CpuAssignment>],
896    ) -> Result<()> {
897        let bss_data = self
898            .skel
899            .maps
900            .bss_data
901            .as_mut()
902            .expect("bss_data must be available after scheduler load");
903
904        let config = &mut bss_data.cell_config;
905
906        // Zero out the config struct. This is necessary because:
907        // 1. Cell IDs can be sparse (e.g., cells 0, 2, 3 if cell 1 was destroyed)
908        // 2. We only write cpumasks for active cells, leaving gaps unwritten
909        // 3. BPF iterates 0..num_cells and applies each cpumask
910        // 4. Without zeroing, a gap (e.g., cell 1) would have a stale cpumask,
911        //    causing CPUs to be assigned to an unused cell
912        // Safety: cell_config is a plain data struct with no Drop impl
913        unsafe {
914            std::ptr::write_bytes(
915                config as *mut _ as *mut u8,
916                0,
917                std::mem::size_of_val(config),
918            );
919        }
920
921        if subcell_assignments.len() != cpu_assignments.len() {
922            bail!(
923                "Mismatched subcell assignments: {} cell assignments vs {} subcell groups",
924                cpu_assignments.len(),
925                subcell_assignments.len()
926            );
927        }
928
929        if cell_assignments.len() > bpf_intf::consts_MAX_CELLS as usize {
930            bail!(
931                "Too many cell assignments: {} > MAX_CELLS ({})",
932                cell_assignments.len(),
933                bpf_intf::consts_MAX_CELLS
934            );
935        }
936        config.num_cell_assignments = cell_assignments.len() as u32;
937
938        for cell_id in 0..MAX_CELLS {
939            for subcell_id in 0..MAX_SUBCELLS_PER_CELL {
940                config.subcells[cell_id][subcell_id].id = subcell_id as u32;
941            }
942        }
943
944        for (i, (cgid, cell_id)) in cell_assignments.iter().enumerate() {
945            config.assignments[i].cgid = *cgid;
946            config.assignments[i].cell_id = *cell_id;
947        }
948
949        // Set cell cpumasks and borrowable cpumasks
950        let mut max_cell_id: u32 = 0;
951        for (a, cell_subcells) in cpu_assignments.iter().zip(subcell_assignments.iter()) {
952            validate_subcell_assignments(a, cell_subcells, self.enable_borrowing)?;
953
954            if a.id >= bpf_intf::consts_MAX_CELLS {
955                bail!(
956                    "Cell ID {} exceeds MAX_CELLS ({})",
957                    a.id,
958                    bpf_intf::consts_MAX_CELLS
959                );
960            }
961            max_cell_id = max_cell_id.max(a.id + 1);
962
963            write_cpumask_to_config(&a.primary, &mut config.cpumasks[a.id as usize].mask);
964
965            if let Some(ref borrowable) = a.borrowable {
966                write_cpumask_to_config(
967                    borrowable,
968                    &mut config.borrowable_cpumasks[a.id as usize].mask,
969                );
970            }
971
972            for subcell in cell_subcells {
973                let slot = subcell.id as usize;
974                config.subcells[a.id as usize][slot].id = subcell.id;
975                config.subcells[a.id as usize][slot].in_use = 1;
976                write_cpumask_to_config(
977                    &subcell.primary,
978                    &mut config.subcells[a.id as usize][slot].primary.mask,
979                );
980                if let Some(ref borrowable) = subcell.borrowable {
981                    write_cpumask_to_config(
982                        borrowable,
983                        &mut config.subcells[a.id as usize][slot].borrowable.mask,
984                    );
985                }
986            }
987        }
988        config.num_cells = max_cell_id;
989
990        // Trigger the BPF program to apply the configuration
991        let prog = &mut self.skel.progs.apply_cell_config;
992        let out = prog
993            .test_run(ProgramInput::default())
994            .context("Failed to run apply_cell_config BPF program")?;
995        if out.return_value != 0 {
996            bail!(
997                "apply_cell_config BPF program returned error {} (num_assignments={}, num_cells={})",
998                out.return_value as i32,
999                cell_assignments.len(),
1000                cpu_assignments.len()
1001            );
1002        }
1003
1004        Ok(())
1005    }
1006
1007    fn get_metrics(&self) -> Metrics {
1008        self.metrics.clone()
1009    }
1010
1011    fn calculate_distribution_stats(
1012        &self,
1013        queue_counts: &[u64; QUEUE_STATS_IDX.len()],
1014        global_queue_decisions: u64,
1015        scope_queue_decisions: u64,
1016        scope_affn_viols: u64,
1017        scope_steals: u64,
1018        scope_pin_skips: u64,
1019    ) -> Result<DistributionStats> {
1020        // First % on the line: share of global work
1021        // We know global_queue_decisions is non-zero.
1022        let share_of_global =
1023            100.0 * (scope_queue_decisions as f64) / (global_queue_decisions as f64);
1024
1025        // Each queue's % of the scope total
1026        let queue_pct = if scope_queue_decisions == 0 {
1027            debug!("No queue decisions in scope, zeroing out queue distribution");
1028            [0.0; QUEUE_STATS_IDX.len()]
1029        } else {
1030            core::array::from_fn(|i| {
1031                100.0 * (queue_counts[i] as f64) / (scope_queue_decisions as f64)
1032            })
1033        };
1034
1035        // These are summed differently for the global and per-cell totals.
1036        let affinity_violations_percent = if scope_queue_decisions == 0 {
1037            debug!("No queue decisions in scope, zeroing out affinity violations");
1038            0.0
1039        } else {
1040            100.0 * (scope_affn_viols as f64) / (scope_queue_decisions as f64)
1041        };
1042
1043        let steal_pct = if scope_queue_decisions == 0 {
1044            0.0
1045        } else {
1046            100.0 * (scope_steals as f64) / (scope_queue_decisions as f64)
1047        };
1048
1049        let pin_skip_pct = if scope_queue_decisions == 0 {
1050            0.0
1051        } else {
1052            100.0 * (scope_pin_skips as f64) / (scope_queue_decisions as f64)
1053        };
1054
1055        const EXPECTED_QUEUES: usize = 4;
1056        if queue_pct.len() != EXPECTED_QUEUES {
1057            bail!(
1058                "Expected {} queues, got {}",
1059                EXPECTED_QUEUES,
1060                queue_pct.len()
1061            );
1062        }
1063
1064        return Ok(DistributionStats {
1065            total_decisions: scope_queue_decisions,
1066            share_of_decisions_pct: share_of_global,
1067            local_q_pct: queue_pct[0],
1068            cpu_q_pct: queue_pct[1],
1069            cell_q_pct: queue_pct[2],
1070            borrowed_pct: queue_pct[3],
1071            affn_viol_pct: affinity_violations_percent,
1072            steal_pct,
1073            pin_skip_pct,
1074            global_queue_decisions,
1075        });
1076    }
1077
1078    // Queue stats for the whole node
1079    fn update_and_log_global_queue_stats(
1080        &mut self,
1081        global_queue_decisions: u64,
1082        cell_stats_delta: &[[u64; NR_CSTATS]; MAX_CELLS],
1083    ) -> Result<()> {
1084        // Get total of each queue summed over all cells
1085        let mut queue_counts = [0; QUEUE_STATS_IDX.len()];
1086        for cells in 0..MAX_CELLS {
1087            for (i, stat) in QUEUE_STATS_IDX.iter().enumerate() {
1088                queue_counts[i] += cell_stats_delta[cells][*stat as usize];
1089            }
1090        }
1091
1092        let prefix = "Total Decisions:";
1093
1094        // Here we want to sum the affinity violations over all cells.
1095        let scope_affn_viols: u64 = cell_stats_delta
1096            .iter()
1097            .map(|&cell| cell[bpf_intf::cell_stat_idx_CSTAT_AFFN_VIOL as usize])
1098            .sum::<u64>();
1099
1100        // Sum steals over all cells
1101        let scope_steals: u64 = cell_stats_delta
1102            .iter()
1103            .map(|&cell| cell[bpf_intf::cell_stat_idx_CSTAT_STEAL as usize])
1104            .sum::<u64>();
1105
1106        // Sum pin skips over all cells
1107        let scope_pin_skips: u64 = cell_stats_delta
1108            .iter()
1109            .map(|&cell| cell[bpf_intf::cell_stat_idx_CSTAT_PIN_SKIP as usize])
1110            .sum::<u64>();
1111
1112        // Special case where the number of scope decisions == number global decisions
1113        let stats = self
1114            .calculate_distribution_stats(
1115                &queue_counts,
1116                global_queue_decisions,
1117                global_queue_decisions,
1118                scope_affn_viols,
1119                scope_steals,
1120                scope_pin_skips,
1121            )
1122            .context("calculating global queue distribution stats")?;
1123
1124        self.metrics.update(&stats);
1125
1126        // Slice shrink stats bypass DistributionStats — they're raw event counts
1127        let sum = |idx: usize| -> u64 { cell_stats_delta.iter().map(|c| c[idx]).sum() };
1128        self.metrics.drain_cnt = sum(bpf_intf::cell_stat_idx_CSTAT_DRAIN_CNT as usize);
1129        self.metrics.drain_affn_cnt = sum(bpf_intf::cell_stat_idx_CSTAT_DRAIN_AFFN_CNT as usize);
1130        self.metrics.slice_shrink_max =
1131            sum(bpf_intf::cell_stat_idx_CSTAT_SLICE_SHRINK_MAX as usize);
1132        self.metrics.slice_shrink_proportional =
1133            sum(bpf_intf::cell_stat_idx_CSTAT_SLICE_SHRINK_PROPORTIONAL as usize);
1134        self.metrics.slice_shrink_min =
1135            sum(bpf_intf::cell_stat_idx_CSTAT_SLICE_SHRINK_MIN as usize);
1136        self.metrics.slice_shrink = self.metrics.slice_shrink_max
1137            + self.metrics.slice_shrink_proportional
1138            + self.metrics.slice_shrink_min;
1139
1140        trace!("{} {}", prefix, stats);
1141
1142        Ok(())
1143    }
1144
1145    // Print out the per-cell stats
1146    fn update_and_log_cell_queue_stats(
1147        &mut self,
1148        global_queue_decisions: u64,
1149        cell_stats_delta: &[[u64; NR_CSTATS]; MAX_CELLS],
1150    ) -> Result<()> {
1151        for cell in 0..MAX_CELLS {
1152            let cell_queue_decisions = QUEUE_STATS_IDX
1153                .iter()
1154                .map(|&stat| cell_stats_delta[cell][stat as usize])
1155                .sum::<u64>();
1156
1157            // FIXME: This should really query if the cell is enabled or not.
1158            if cell_queue_decisions == 0 {
1159                continue;
1160            }
1161
1162            let mut queue_counts = [0; QUEUE_STATS_IDX.len()];
1163            for (i, &stat) in QUEUE_STATS_IDX.iter().enumerate() {
1164                queue_counts[i] = cell_stats_delta[cell][stat as usize];
1165            }
1166
1167            const MIN_CELL_WIDTH: usize = 2;
1168            let cell_width: usize = max(MIN_CELL_WIDTH, (MAX_CELLS as f64).log10().ceil() as usize);
1169
1170            let prefix = format!("        Cell {:width$}:", cell, width = cell_width);
1171
1172            // Sum affinity violations for this cell
1173            let scope_affn_viols: u64 =
1174                cell_stats_delta[cell][bpf_intf::cell_stat_idx_CSTAT_AFFN_VIOL as usize];
1175
1176            // Steals for this cell
1177            let scope_steals: u64 =
1178                cell_stats_delta[cell][bpf_intf::cell_stat_idx_CSTAT_STEAL as usize];
1179
1180            // Pin skips for this cell
1181            let scope_pin_skips: u64 =
1182                cell_stats_delta[cell][bpf_intf::cell_stat_idx_CSTAT_PIN_SKIP as usize];
1183
1184            let stats = self
1185                .calculate_distribution_stats(
1186                    &queue_counts,
1187                    global_queue_decisions,
1188                    cell_queue_decisions,
1189                    scope_affn_viols,
1190                    scope_steals,
1191                    scope_pin_skips,
1192                )
1193                .with_context(|| {
1194                    format!("calculating queue distribution stats for cell {}", cell)
1195                })?;
1196
1197            let cell_metrics = self.metrics.cells.entry(cell as u32).or_default();
1198            cell_metrics.update(&stats);
1199
1200            // Raw event counts bypass DistributionStats.
1201            cell_metrics.drain_cnt =
1202                cell_stats_delta[cell][bpf_intf::cell_stat_idx_CSTAT_DRAIN_CNT as usize];
1203            cell_metrics.drain_affn_cnt =
1204                cell_stats_delta[cell][bpf_intf::cell_stat_idx_CSTAT_DRAIN_AFFN_CNT as usize];
1205            cell_metrics.slice_shrink_max =
1206                cell_stats_delta[cell][bpf_intf::cell_stat_idx_CSTAT_SLICE_SHRINK_MAX as usize];
1207            cell_metrics.slice_shrink_proportional = cell_stats_delta[cell]
1208                [bpf_intf::cell_stat_idx_CSTAT_SLICE_SHRINK_PROPORTIONAL as usize];
1209            cell_metrics.slice_shrink_min =
1210                cell_stats_delta[cell][bpf_intf::cell_stat_idx_CSTAT_SLICE_SHRINK_MIN as usize];
1211            cell_metrics.slice_shrink = cell_metrics.slice_shrink_max
1212                + cell_metrics.slice_shrink_proportional
1213                + cell_metrics.slice_shrink_min;
1214
1215            trace!("{} {}", prefix, stats);
1216        }
1217        Ok(())
1218    }
1219
1220    fn update_drain_metrics(&mut self, cell_stats_delta: &[[u64; NR_CSTATS]; MAX_CELLS]) {
1221        let mut total = 0;
1222        let mut affn_total = 0;
1223
1224        for cell in 0..MAX_CELLS {
1225            let drain_cnt =
1226                cell_stats_delta[cell][bpf_intf::cell_stat_idx_CSTAT_DRAIN_CNT as usize];
1227            let drain_affn_cnt =
1228                cell_stats_delta[cell][bpf_intf::cell_stat_idx_CSTAT_DRAIN_AFFN_CNT as usize];
1229            total += drain_cnt;
1230            affn_total += drain_affn_cnt;
1231
1232            if let Some(cell_metrics) = self.metrics.cells.get_mut(&(cell as u32)) {
1233                cell_metrics.drain_cnt = drain_cnt;
1234                cell_metrics.drain_affn_cnt = drain_affn_cnt;
1235            } else if drain_cnt > 0 || drain_affn_cnt > 0 {
1236                let cell_metrics = self.metrics.cells.entry(cell as u32).or_default();
1237                cell_metrics.drain_cnt = drain_cnt;
1238                cell_metrics.drain_affn_cnt = drain_affn_cnt;
1239            }
1240        }
1241
1242        self.metrics.drain_cnt = total;
1243        self.metrics.drain_affn_cnt = affn_total;
1244    }
1245
1246    fn log_all_queue_stats(
1247        &mut self,
1248        cell_stats_delta: &[[u64; NR_CSTATS]; MAX_CELLS],
1249    ) -> Result<()> {
1250        // Get total decisions
1251        let global_queue_decisions: u64 = cell_stats_delta
1252            .iter()
1253            .flat_map(|cell| QUEUE_STATS_IDX.iter().map(|&idx| cell[idx as usize]))
1254            .sum();
1255
1256        self.update_drain_metrics(cell_stats_delta);
1257
1258        if global_queue_decisions == 0 {
1259            if self.metrics.drain_cnt == 0 {
1260                warn!("No queueing decisions made globally");
1261            }
1262            return Ok(());
1263        }
1264
1265        self.update_and_log_global_queue_stats(global_queue_decisions, &cell_stats_delta)
1266            .context("updating global queue stats")?;
1267
1268        self.update_and_log_cell_queue_stats(global_queue_decisions, &cell_stats_delta)
1269            .context("updating per-cell queue stats")?;
1270
1271        Ok(())
1272    }
1273
1274    fn calculate_cell_stat_delta(
1275        &mut self,
1276        cpu_ctxs: &[bpf_intf::cpu_ctx],
1277    ) -> Result<[[u64; NR_CSTATS]; MAX_CELLS]> {
1278        let mut cell_stats_delta = [[0 as u64; NR_CSTATS]; MAX_CELLS];
1279
1280        // Loop over cells and stats first, then CPU contexts
1281        // TODO: We should loop over the in_use cells only.
1282        for cell in 0..MAX_CELLS {
1283            for stat in 0..NR_CSTATS {
1284                let mut cur_cell_stat = 0;
1285
1286                // Accumulate stats from all CPUs
1287                for cpu_ctx in cpu_ctxs.iter() {
1288                    cur_cell_stat += cpu_ctx.cstats[cell][stat];
1289                }
1290
1291                // Calculate delta and update previous stat
1292                cell_stats_delta[cell][stat] = cur_cell_stat - self.prev_cell_stats[cell][stat];
1293                self.prev_cell_stats[cell][stat] = cur_cell_stat;
1294            }
1295        }
1296        Ok(cell_stats_delta)
1297    }
1298
1299    /// Collect metrics and out various debugging data like per cell stats, per-cpu stats, etc.
1300    fn collect_metrics(&mut self) -> Result<()> {
1301        let cpu_ctxs = read_cpu_ctxs(&self.skel).context("reading per-CPU contexts for metrics")?;
1302
1303        let cell_stats_delta = self
1304            .calculate_cell_stat_delta(&cpu_ctxs)
1305            .context("calculating cell stat deltas")?;
1306
1307        self.log_all_queue_stats(&cell_stats_delta)
1308            .context("logging queue stats")?;
1309
1310        // Mirror the sticky holdout flag on every collection, independent of the
1311        // zero-decisions early return inside log_all_queue_stats above.
1312        self.metrics.enforced_holdout = self.cell_manager.enforced_holdout() as u64;
1313
1314        self.collect_demand_metrics(&cpu_ctxs)
1315            .context("collecting demand metrics")?;
1316
1317        for (cell_id, cell) in &self.cells {
1318            trace!("CELL[{}]: {}", cell_id, cell.cpus);
1319        }
1320
1321        for (cell_id, cell) in self.cells.iter() {
1322            // Assume we have a CellMetrics entry if we have a known cell
1323            self.metrics
1324                .cells
1325                .entry(*cell_id)
1326                .and_modify(|cell_metrics| {
1327                    cell_metrics.num_cpus = cell.cpus.weight() as u32;
1328                    cell_metrics.cgroup_path = self.cell_manager.cgroup_path_for_cell(*cell_id);
1329                });
1330        }
1331        self.metrics.num_cells = self.cells.len() as u32;
1332
1333        Ok(())
1334    }
1335
1336    /// Compute per-cell demand metrics (utilization, borrowed, lent) from BPF running_ns counters.
1337    fn collect_demand_metrics(&mut self, cpu_ctxs: &[bpf_intf::cpu_ctx]) -> Result<()> {
1338        // Per-cell cumulative counters derived from BPF per-CPU running_ns:
1339        //   total_running_ns[c] = total time tasks in cell c ran (on any CPU)
1340        //   on_own_ns[c]        = time tasks in cell c ran on CPUs owned by cell c
1341        //   lent_ns[c]          = time foreign tasks ran on CPUs owned by cell c
1342        let mut total_running_ns = [0u64; MAX_CELLS];
1343        let mut on_own_ns = [0u64; MAX_CELLS];
1344        let mut lent_ns = [0u64; MAX_CELLS];
1345
1346        for cpu_ctx in cpu_ctxs.iter() {
1347            let owner = cpu_ctx.cell as usize;
1348            for cell in 0..MAX_CELLS {
1349                let ns = cpu_ctx.running_ns[cell];
1350                total_running_ns[cell] += ns;
1351                if owner == cell {
1352                    on_own_ns[cell] += ns;
1353                }
1354            }
1355            if owner >= MAX_CELLS {
1356                bail!(
1357                    "CPU has invalid cell assignment {} (MAX_CELLS={})",
1358                    owner,
1359                    MAX_CELLS
1360                );
1361            }
1362            // Lent time: non-owner cell tasks running on this CPU
1363            let total_on_cpu: u64 = cpu_ctx.running_ns.iter().sum();
1364            let owner_on_cpu = cpu_ctx.running_ns[owner];
1365            lent_ns[owner] += total_on_cpu.saturating_sub(owner_on_cpu);
1366        }
1367
1368        // Compute deltas since last collection interval
1369        let interval_ns = self.monitor_interval.as_nanos() as u64;
1370
1371        let mut global_running_delta = 0u64;
1372        let mut global_borrowed_delta = 0u64;
1373        let mut global_lent_delta = 0u64;
1374        let mut global_capacity = 0u64;
1375
1376        for cell in 0..MAX_CELLS {
1377            let delta_running =
1378                total_running_ns[cell].saturating_sub(self.prev_cell_running_ns[cell]);
1379            let delta_on_own = on_own_ns[cell].saturating_sub(self.prev_cell_own_ns[cell]);
1380            let delta_lent = lent_ns[cell].saturating_sub(self.prev_cell_lent_ns[cell]);
1381
1382            self.prev_cell_running_ns[cell] = total_running_ns[cell];
1383            self.prev_cell_own_ns[cell] = on_own_ns[cell];
1384            self.prev_cell_lent_ns[cell] = lent_ns[cell];
1385
1386            if delta_running == 0 && delta_lent == 0 {
1387                continue;
1388            }
1389
1390            // Borrowed = ran somewhere other than own CPUs
1391            let delta_borrowed = delta_running.saturating_sub(delta_on_own);
1392
1393            // After a cell is destroyed, BPF may still report residual
1394            // running_ns from the previous interval. Skip stale cells.
1395            let Some(cell_info) = self.cells.get(&(cell as u32)) else {
1396                continue;
1397            };
1398
1399            let nr_cpus = cell_info.cpus.weight() as u64;
1400            if nr_cpus == 0 {
1401                bail!("Cell {} has 0 CPUs assigned", cell);
1402            }
1403
1404            // capacity = total available CPU-time this interval
1405            let capacity = nr_cpus * interval_ns;
1406            // util: fraction of own capacity consumed by own tasks
1407            let util_pct = 100.0 * (delta_running as f64) / (capacity as f64);
1408            // demand_borrow: fraction of running time that was borrowed from other cells
1409            let demand_borrow_pct = if delta_running > 0 {
1410                100.0 * (delta_borrowed as f64) / (delta_running as f64)
1411            } else {
1412                0.0
1413            };
1414            // lent: fraction of own capacity used by foreign tasks
1415            let lent_pct = 100.0 * (delta_lent as f64) / (capacity as f64);
1416
1417            // Update EWMA-smoothed utilization
1418            if self.enable_rebalancing {
1419                self.smoothed_util[cell] = self.demand_smoothing * util_pct
1420                    + (1.0 - self.demand_smoothing) * self.smoothed_util[cell];
1421            }
1422
1423            self.metrics
1424                .cells
1425                .entry(cell as u32)
1426                .or_default()
1427                .update_demand(util_pct, demand_borrow_pct, lent_pct);
1428
1429            // Update smoothed_util_pct in metrics
1430            if self.enable_rebalancing {
1431                self.metrics
1432                    .cells
1433                    .entry(cell as u32)
1434                    .or_default()
1435                    .smoothed_util_pct = self.smoothed_util[cell];
1436            }
1437
1438            global_running_delta = global_running_delta.saturating_add(delta_running);
1439            global_borrowed_delta = global_borrowed_delta.saturating_add(delta_borrowed);
1440            global_lent_delta = global_lent_delta.saturating_add(delta_lent);
1441            global_capacity = global_capacity.saturating_add(capacity);
1442        }
1443
1444        let global_util_pct = if global_capacity > 0 {
1445            100.0 * (global_running_delta as f64) / (global_capacity as f64)
1446        } else {
1447            0.0
1448        };
1449        let global_borrow_pct = if global_running_delta > 0 {
1450            100.0 * (global_borrowed_delta as f64) / (global_running_delta as f64)
1451        } else {
1452            0.0
1453        };
1454        let global_lent_pct = if global_capacity > 0 {
1455            100.0 * (global_lent_delta as f64) / (global_capacity as f64)
1456        } else {
1457            0.0
1458        };
1459
1460        self.metrics
1461            .update_demand(global_util_pct, global_borrow_pct, global_lent_pct);
1462
1463        Ok(())
1464    }
1465
1466    /// Write applied_cpuset_seq to BSS, closing the rejection-skip window.
1467    fn update_applied_cpuset_seq(&mut self) {
1468        unsafe {
1469            let ptr = &mut self
1470                .skel
1471                .maps
1472                .bss_data
1473                .as_mut()
1474                .expect("BUG: bss_data missing after scheduler load")
1475                .applied_cpuset_seq as *mut u32;
1476            std::ptr::write_volatile(ptr, self.last_cpuset_seq);
1477        }
1478    }
1479
1480    /// Check if any cell's cpuset was modified and recompute if so.
1481    fn check_cpuset_changes(&mut self) -> Result<()> {
1482        let current_seq = unsafe {
1483            let ptr = &self
1484                .skel
1485                .maps
1486                .bss_data
1487                .as_ref()
1488                .expect("BUG: bss_data missing after scheduler load")
1489                .cpuset_seq as *const u32;
1490            (ptr as *const AtomicU32)
1491                .as_ref()
1492                .expect("BUG: cpuset_seq pointer cast yielded null")
1493                .load(Ordering::Acquire)
1494        };
1495
1496        if current_seq == self.last_cpuset_seq {
1497            return Ok(());
1498        }
1499        self.last_cpuset_seq = current_seq;
1500
1501        if !self
1502            .cell_manager
1503            .refresh_cpusets()
1504            .context("refreshing cell cpusets")?
1505        {
1506            // seq changed but no cpusets on our cells changed
1507            self.update_applied_cpuset_seq();
1508            return Ok(());
1509        }
1510
1511        let cpu_assignments = self
1512            .compute_and_apply_cell_config(&[])
1513            .context("recomputing cell configuration after cpuset change")?;
1514        self.update_applied_cpuset_seq();
1515        info!(
1516            "Cpuset change detected, recomputed config: {}",
1517            self.cell_manager.format_cell_config(&cpu_assignments)
1518        );
1519        Ok(())
1520    }
1521
1522    fn refresh_bpf_cells(&mut self) -> Result<()> {
1523        let applied_configuration = unsafe {
1524            let ptr = &self
1525                .skel
1526                .maps
1527                .bss_data
1528                .as_ref()
1529                .expect("BUG: bss_data missing after scheduler load")
1530                .applied_configuration_seq as *const u32;
1531            (ptr as *const std::sync::atomic::AtomicU32)
1532                .as_ref()
1533                .expect("BUG: applied_configuration_seq pointer cast yielded null")
1534                .load(std::sync::atomic::Ordering::Acquire)
1535        };
1536        if self
1537            .last_configuration_seq
1538            .is_some_and(|seq| applied_configuration == seq)
1539        {
1540            return Ok(());
1541        }
1542        // collect all cpus per cell.
1543        let mut cell_to_cpus: HashMap<u32, Cpumask> = HashMap::new();
1544        let cpu_ctxs =
1545            read_cpu_ctxs(&self.skel).context("reading per-CPU contexts for BPF cell refresh")?;
1546        for (i, cpu_ctx) in cpu_ctxs.iter().enumerate() {
1547            cell_to_cpus
1548                .entry(cpu_ctx.cell)
1549                .or_insert_with(|| Cpumask::new())
1550                .set_cpu(i)
1551                .expect("set cpu in existing mask");
1552        }
1553
1554        // Create cells we don't have yet, drop cells that are no longer in use.
1555        // If we continue to drop cell metrics once a cell is removed, we'll need to make sure we
1556        // flush metrics for a cell before we remove it completely.
1557        //
1558        // IMPORTANT: We determine which cells exist based on CPU assignments (which are
1559        // synchronized by applied_configuration_seq), NOT by reading the in_use field
1560        // separately. This avoids a TOCTOU race where a cell's in_use is set before
1561        // CPUs are assigned.
1562
1563        // Cell 0 (root cell) always exists even if it has no CPUs temporarily
1564        let cells_with_cpus: HashSet<u32> = cell_to_cpus.keys().copied().collect();
1565        let mut active_cells = cells_with_cpus.clone();
1566        active_cells.insert(0);
1567
1568        for cell_idx in &active_cells {
1569            let cpus = cell_to_cpus
1570                .get(cell_idx)
1571                .cloned()
1572                .unwrap_or_else(|| Cpumask::new());
1573            self.cells.entry(*cell_idx).or_insert_with(Cell::new).cpus = cpus;
1574            self.metrics.cells.insert(*cell_idx, CellMetrics::default());
1575        }
1576
1577        // Remove cells that no longer have CPUs assigned
1578        self.cells.retain(|&k, _| active_cells.contains(&k));
1579        self.metrics.cells.retain(|&k, _| active_cells.contains(&k));
1580
1581        self.refresh_bpf_subcells(&active_cells)?;
1582
1583        self.last_configuration_seq = Some(applied_configuration);
1584
1585        Ok(())
1586    }
1587}
1588
1589fn validate_subcell_assignments(
1590    cell: &CpuAssignment,
1591    subcells: &[CpuAssignment],
1592    enable_borrowing: bool,
1593) -> Result<()> {
1594    if subcells.is_empty() {
1595        bail!("Cell {} has no subcell assignments", cell.id);
1596    }
1597    if subcells.len() > MAX_SUBCELLS_PER_CELL {
1598        bail!(
1599            "Cell {} has too many subcells: {} > MAX_SUBCELLS_PER_CELL ({})",
1600            cell.id,
1601            subcells.len(),
1602            MAX_SUBCELLS_PER_CELL
1603        );
1604    }
1605
1606    let mut seen = [false; MAX_SUBCELLS_PER_CELL];
1607    let mut primary_union = Cpumask::new();
1608
1609    for subcell in subcells {
1610        if subcell.id >= bpf_intf::consts_MAX_SUBCELLS_PER_CELL {
1611            bail!(
1612                "Subcell ID {} for cell {} exceeds MAX_SUBCELLS_PER_CELL ({})",
1613                subcell.id,
1614                cell.id,
1615                bpf_intf::consts_MAX_SUBCELLS_PER_CELL
1616            );
1617        }
1618
1619        let slot = subcell.id as usize;
1620        if seen[slot] {
1621            bail!("Duplicate subcell ID {} for cell {}", subcell.id, cell.id);
1622        }
1623        seen[slot] = true;
1624
1625        if subcell.primary.weight() == 0 {
1626            bail!(
1627                "Subcell {} in cell {} has no primary CPUs",
1628                subcell.id,
1629                cell.id
1630            );
1631        }
1632        if subcell.primary.and(&cell.primary.not()).weight() != 0 {
1633            bail!(
1634                "Subcell {} primary CPUs are outside cell {}",
1635                subcell.id,
1636                cell.id
1637            );
1638        }
1639        if primary_union.and(&subcell.primary).weight() != 0 {
1640            bail!(
1641                "Subcell {} overlaps another subcell in cell {}",
1642                subcell.id,
1643                cell.id
1644            );
1645        }
1646
1647        if enable_borrowing {
1648            let expected = cell.primary.and(&subcell.primary.not());
1649            let borrowable = subcell.borrowable.as_ref().ok_or_else(|| {
1650                anyhow::anyhow!(
1651                    "Subcell {} in cell {} is missing its borrowable mask",
1652                    subcell.id,
1653                    cell.id
1654                )
1655            })?;
1656            if borrowable != &expected {
1657                bail!(
1658                    "Subcell {} in cell {} has an invalid borrowable mask",
1659                    subcell.id,
1660                    cell.id
1661                );
1662            }
1663        } else if subcell.borrowable.is_some() {
1664            bail!(
1665                "Subcell {} in cell {} has borrowing disabled but a borrowable mask",
1666                subcell.id,
1667                cell.id
1668            );
1669        }
1670
1671        primary_union = primary_union.or(&subcell.primary);
1672    }
1673
1674    if primary_union != cell.primary {
1675        bail!("Subcell primary CPUs do not cover cell {}", cell.id);
1676    }
1677
1678    Ok(())
1679}
1680
1681fn write_cpumask_to_config(cpumask: &Cpumask, dest: &mut [u8]) {
1682    let raw_slice = cpumask.as_raw_slice();
1683    for (word_idx, word) in raw_slice.iter().enumerate() {
1684        let byte_start = word_idx * 8;
1685        let bytes = word.to_le_bytes();
1686        for (j, byte) in bytes.iter().enumerate() {
1687            let idx = byte_start + j;
1688            if idx < dest.len() {
1689                dest[idx] = *byte;
1690            }
1691        }
1692    }
1693}
1694
1695fn read_cpumask_from_bytes(src: &[u8]) -> Result<Cpumask> {
1696    let mut cpumask = Cpumask::new();
1697
1698    for (byte_idx, byte) in src.iter().copied().enumerate() {
1699        let mut bits = byte;
1700        while bits != 0 {
1701            let bit = bits.trailing_zeros() as usize;
1702            bits &= !(1u8 << bit);
1703            cpumask.set_cpu((byte_idx * 8) + bit)?;
1704        }
1705    }
1706
1707    Ok(cpumask)
1708}
1709
1710fn read_bpf_cell(skel: &BpfSkel, cell_id: u32) -> Result<bpf_intf::cell> {
1711    let raw = skel
1712        .maps
1713        .cells
1714        .lookup(&cell_id.to_ne_bytes(), libbpf_rs::MapFlags::ANY)
1715        .with_context(|| format!("Failed to lookup cell {}", cell_id))?
1716        .ok_or_else(|| anyhow::anyhow!("Cell {} missing from BPF map", cell_id))?;
1717
1718    if raw.len() != std::mem::size_of::<bpf_intf::cell>() {
1719        bail!(
1720            "Unexpected cell {} value size: {} != {}",
1721            cell_id,
1722            raw.len(),
1723            std::mem::size_of::<bpf_intf::cell>()
1724        );
1725    }
1726
1727    Ok(unsafe { std::ptr::read_unaligned(raw.as_ptr() as *const bpf_intf::cell) })
1728}
1729
1730fn read_cpu_ctxs(skel: &BpfSkel) -> Result<Vec<bpf_intf::cpu_ctx>> {
1731    let mut cpu_ctxs = vec![];
1732    let cpu_ctxs_vec = skel
1733        .maps
1734        .cpu_ctxs
1735        .lookup_percpu(&0u32.to_ne_bytes(), libbpf_rs::MapFlags::ANY)
1736        .context("Failed to lookup cpu_ctx")?
1737        .expect("BUG: cpu_ctxs lookup_percpu returned None for key 0");
1738    if cpu_ctxs_vec.len() < *NR_CPUS_POSSIBLE {
1739        bail!(
1740            "Percpu map returned {} entries but expected {}",
1741            cpu_ctxs_vec.len(),
1742            *NR_CPUS_POSSIBLE
1743        );
1744    }
1745    for cpu in 0..*NR_CPUS_POSSIBLE {
1746        cpu_ctxs.push(*unsafe {
1747            &*(cpu_ctxs_vec[cpu].as_slice().as_ptr() as *const bpf_intf::cpu_ctx)
1748        });
1749    }
1750    Ok(cpu_ctxs)
1751}
1752
1753fn run(opts: Opts) -> Result<()> {
1754    if opts.version {
1755        println!(
1756            "scx_mitosis {}",
1757            build_id::full_version(env!("CARGO_PKG_VERSION"))
1758        );
1759        return Ok(());
1760    }
1761
1762    let env_filter = EnvFilter::try_from_default_env()
1763        .or_else(|_| match EnvFilter::try_new(&opts.log_level) {
1764            Ok(filter) => Ok(filter),
1765            Err(e) => {
1766                eprintln!(
1767                    "invalid log envvar: {}, using info, err is: {}",
1768                    opts.log_level, e
1769                );
1770                EnvFilter::try_new("info")
1771            }
1772        })
1773        .unwrap_or_else(|_| EnvFilter::new("info"));
1774
1775    match tracing_subscriber::fmt()
1776        .with_env_filter(env_filter)
1777        .with_target(true)
1778        .with_thread_ids(true)
1779        .with_file(true)
1780        .with_line_number(true)
1781        .try_init()
1782    {
1783        Ok(()) => {}
1784        Err(e) => eprintln!("failed to init logger: {}", e),
1785    }
1786
1787    if opts.verbose > 0 {
1788        warn!("Setting verbose via -v is deprecated and will be an error in future releases.");
1789    }
1790
1791    debug!("opts={:?}", &opts);
1792
1793    if let Some(run_id) = opts.run_id {
1794        info!("scx_mitosis run_id: {}", run_id);
1795    }
1796
1797    let shutdown = Arc::new(AtomicBool::new(false));
1798    let shutdown_clone = shutdown.clone();
1799    ctrlc::set_handler(move || {
1800        shutdown_clone.store(true, Ordering::Relaxed);
1801    })
1802    .context("Error setting Ctrl-C handler")?;
1803
1804    if let Some(intv) = opts.monitor {
1805        let shutdown_clone = shutdown.clone();
1806        let jh = std::thread::spawn(move || {
1807            match stats::monitor(Duration::from_secs_f64(intv), shutdown_clone) {
1808                Ok(_) => {
1809                    debug!("stats monitor thread finished successfully")
1810                }
1811                Err(error_object) => {
1812                    warn!(
1813                        "stats monitor thread finished because of an error {}",
1814                        error_object
1815                    )
1816                }
1817            }
1818        });
1819        if opts.monitor.is_some() {
1820            let _ = jh.join();
1821            return Ok(());
1822        }
1823    }
1824
1825    let mut open_object = MaybeUninit::uninit();
1826    loop {
1827        let mut sched =
1828            Scheduler::init(&opts, &mut open_object).context("initializing scheduler")?;
1829        if !sched
1830            .run(shutdown.clone())
1831            .context("running scheduler main loop")?
1832            .should_restart()
1833        {
1834            break;
1835        }
1836    }
1837
1838    Ok(())
1839}
1840
1841fn main() -> Result<()> {
1842    let parsed = undefok_flags::parse_args::<Opts>()?;
1843    // Emit undefok notices before logger setup so ignored rollout flags are always visible.
1844    for undefok in &parsed.ignored_undefok_flags {
1845        eprintln!("warning: ignoring undefok flag --{}", undefok.long);
1846    }
1847    run(parsed.opts)
1848}
1849
1850#[cfg(test)]
1851mod tests {
1852    use super::{validate_subcell_assignments, CpuAssignment, Cpumask, Opts};
1853    use clap::Parser;
1854
1855    fn cpumask(cpus: &[usize]) -> Cpumask {
1856        let mut mask = Cpumask::new();
1857        for &cpu in cpus {
1858            mask.set_cpu(cpu).unwrap();
1859        }
1860        mask
1861    }
1862
1863    fn assignment(id: u32, primary: &[usize], borrowable: Option<&[usize]>) -> CpuAssignment {
1864        CpuAssignment {
1865            id,
1866            primary: cpumask(primary),
1867            borrowable: borrowable.map(cpumask),
1868        }
1869    }
1870
1871    #[test]
1872    fn requires_cell_parent_cgroup_for_scheduler_mode() {
1873        assert!(Opts::try_parse_from(["scx_mitosis"]).is_err());
1874    }
1875
1876    #[test]
1877    fn allows_monitor_without_cell_parent_cgroup() {
1878        assert!(Opts::try_parse_from(["scx_mitosis", "--monitor", "1"]).is_ok());
1879    }
1880
1881    #[test]
1882    fn allows_version_without_cell_parent_cgroup() {
1883        assert!(Opts::try_parse_from(["scx_mitosis", "--version"]).is_ok());
1884    }
1885
1886    #[test]
1887    fn accepts_complete_unpinned_subcell_partition() {
1888        let cell = assignment(3, &[0, 1, 2, 3], None);
1889        let subcells = [
1890            assignment(0, &[0, 1], Some(&[2, 3])),
1891            assignment(1, &[2, 3], Some(&[0, 1])),
1892        ];
1893
1894        validate_subcell_assignments(&cell, &subcells, true).unwrap();
1895    }
1896
1897    #[test]
1898    fn rejects_overlapping_subcell_primaries() {
1899        let cell = assignment(3, &[0, 1, 2, 3], None);
1900        let subcells = [
1901            assignment(0, &[0, 1, 2], None),
1902            assignment(1, &[2, 3], None),
1903        ];
1904
1905        assert!(validate_subcell_assignments(&cell, &subcells, false).is_err());
1906    }
1907
1908    #[test]
1909    fn rejects_incomplete_subcell_partition() {
1910        let cell = assignment(3, &[0, 1, 2, 3], None);
1911        let subcells = [assignment(0, &[0, 1, 2], None)];
1912
1913        assert!(validate_subcell_assignments(&cell, &subcells, false).is_err());
1914    }
1915}