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