Skip to main content

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