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::Arc;
24use std::sync::atomic::AtomicBool;
25use std::sync::atomic::AtomicU32;
26use std::sync::atomic::Ordering;
27use std::time::Duration;
28use std::time::Instant;
29
30use anyhow::Context;
31use anyhow::Result;
32use anyhow::anyhow;
33use anyhow::bail;
34use clap::Parser;
35use libbpf_rs::OpenObject;
36use libbpf_rs::ProgramInput;
37use libbpf_rs::skel::Skel as _;
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::Cpumask;
43use scx_utils::NR_CPU_IDS;
44use scx_utils::NR_CPUS_POSSIBLE;
45use scx_utils::Topology;
46use scx_utils::UserExitInfo;
47use scx_utils::build_id;
48use scx_utils::compat;
49use scx_utils::init_libbpf_logging;
50use scx_utils::libbpf_clap_opts::LibbpfOpts;
51use scx_utils::scx_enums;
52use scx_utils::scx_ops_attach;
53use scx_utils::scx_ops_cid_load;
54use scx_utils::scx_ops_cid_open;
55use scx_utils::uei_exited;
56use scx_utils::uei_report;
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    /// Deprecated compatibility flag. Mitosis always uses tracepoint cgroup tracking.
132    #[allow(dead_code)]
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 + CPU + Cell + Borrowed + Sub = 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; 5] = [
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    bpf_intf::cell_stat_idx_CSTAT_SUB_DISPATCH,
233];
234
235// Per cell book-keeping
236#[derive(Debug)]
237struct Cell {
238    cpus: Cpumask,
239}
240
241struct Scheduler<'a> {
242    _arenalib: ArenaLib,
243    skel: BpfSkel<'a>,
244    monitor_interval: Duration,
245    cells: HashMap<u32, Cell>,
246    // These are the per-cell cstats.
247    // Note these are accumulated across all CPUs.
248    prev_cell_stats: [[u64; NR_CSTATS]; MAX_CELLS],
249    // Per-cell running_ns tracking for demand metrics
250    prev_cell_running_ns: [u64; MAX_CELLS],
251    prev_cell_own_ns: [u64; MAX_CELLS],
252    prev_cell_lent_ns: [u64; MAX_CELLS],
253    /// When the demand metrics were last sampled, the base of the next deltas
254    demand_sampled_at: Option<Instant>,
255    metrics: Metrics,
256    stats_server: Option<StatsServer<(), Metrics>>,
257    last_configuration_seq: Option<u32>,
258    /// Last observed cpuset_seq for cpuset change detection
259    last_cpuset_seq: u32,
260    /// Cell manager for the cgroup passed via --cell-parent-cgroup.
261    cell_manager: CellManager,
262    /// Whether CPU borrowing is enabled
263    enable_borrowing: bool,
264    /// Whether demand-based rebalancing is enabled
265    enable_rebalancing: bool,
266    /// Utilization spread threshold for triggering rebalancing
267    rebalance_threshold: f64,
268    /// Minimum duration between rebalancing events
269    rebalance_cooldown: Duration,
270    /// EWMA smoothing factor for demand tracking
271    demand_smoothing: f64,
272    /// EWMA-smoothed utilization per cell
273    smoothed_util: [f64; MAX_CELLS],
274    /// Last time rebalancing was performed
275    last_rebalance: Instant,
276    /// Number of rebalancing events
277    rebalance_count: u64,
278    /// Epoll instance for waiting on multiple fds (inotify, stats wakeup)
279    epoll: Epoll,
280    /// EventFd to wake up main loop when stats are requested
281    stats_waker: EventFd,
282}
283
284struct DistributionStats {
285    total_decisions: u64,
286    share_of_decisions_pct: f64,
287    local_q_pct: f64,
288    cpu_q_pct: f64,
289    cell_q_pct: f64,
290    borrowed_pct: f64,
291    sub_q_pct: f64,
292    affn_viol_pct: f64,
293    steal_pct: f64,
294    pin_skip_pct: f64,
295    borrow_bounce_pct: f64,
296
297    // for formatting
298    global_queue_decisions: u64,
299}
300
301impl Display for DistributionStats {
302    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
303        // This makes the output easier to read by improving column alignment. First, it guarantees that within a
304        // given logging interval, the global and cell queueing decision counts print at the same width.
305        // Second, it reduces variance in column width between logging intervals. 5 is simply a heuristic.
306        const MIN_DECISIONS_WIDTH: usize = 5;
307        let descisions_width = if self.global_queue_decisions > 0 {
308            max(
309                MIN_DECISIONS_WIDTH,
310                (self.global_queue_decisions as f64).log10().ceil() as usize,
311            )
312        } else {
313            MIN_DECISIONS_WIDTH
314        };
315        write!(
316            f,
317            "{:width$} {:5.1}% | Local:{:4.1}% From: CPU:{:4.1}% Cell:{:4.1}% Borrow:{:4.1}% Sub:{:4.1}% | V:{:4.1}% S:{:4.1}% PS:{:4.1}% Bounce:{:4.1}%",
318            self.total_decisions,
319            self.share_of_decisions_pct,
320            self.local_q_pct,
321            self.cpu_q_pct,
322            self.cell_q_pct,
323            self.borrowed_pct,
324            self.sub_q_pct,
325            self.affn_viol_pct,
326            self.steal_pct,
327            self.pin_skip_pct,
328            self.borrow_bounce_pct,
329            width = descisions_width,
330        )
331    }
332}
333
334impl<'a> Scheduler<'a> {
335    fn managed_cell_parent(opts: &Opts) -> Result<&str> {
336        opts.cell_parent_cgroup
337            .as_deref()
338            .ok_or_else(|| anyhow!("--cell-parent-cgroup is required to run the scheduler"))
339    }
340
341    fn validate_args(_opts: &Opts) -> Result<()> {
342        Ok(())
343    }
344
345    fn init(opts: &Opts, open_object: &'a mut MaybeUninit<OpenObject>) -> Result<Self> {
346        Self::validate_args(opts).context("validating scheduler options")?;
347
348        let topology = Topology::new().context("detecting system topology")?;
349
350        let mut skel_builder = BpfSkelBuilder::default();
351        skel_builder
352            .obj_builder
353            .debug(opts.log_level.contains("trace"));
354        init_libbpf_logging(None);
355        info!(
356            "Running scx_nitosis (build ID: {})",
357            build_id::full_version(env!("CARGO_PKG_VERSION"))
358        );
359
360        let open_opts = opts.libbpf.clone().into_bpf_open_opts();
361        let mut skel = scx_ops_cid_open!(skel_builder, open_object, mitosis, open_opts)
362            .context("opening BPF skeleton")?;
363
364        skel.struct_ops.mitosis_mut().exit_dump_len = opts.exit_dump_len;
365
366        let rodata = skel
367            .maps
368            .rodata_data
369            .as_mut()
370            .expect("BUG: rodata_data missing after skel open");
371
372        rodata.slice_ns = scx_enums.SCX_SLICE_DFL;
373        rodata.exiting_task_workaround_enabled = opts.exiting_task_workaround;
374        rodata.dynamic_affinity_cpu_selection = opts.dynamic_affinity_cpu_selection;
375
376        // Slice shrinking configuration
377        if opts.slice_shrink_min_us >= opts.slice_shrink_max_us {
378            bail!(
379                "--slice-shrink-min-us ({}) must be less than --slice-shrink-max-us ({})",
380                opts.slice_shrink_min_us,
381                opts.slice_shrink_max_us
382            );
383        }
384        rodata.enable_slice_shrinking = opts.enable_slice_shrinking;
385        rodata.slice_shrink_max_ns = opts.slice_shrink_max_us * 1_000;
386        // K=2: in the proportional region, a pinned task waits at most 2x its historical runtime
387        rodata.slice_shrink_multiplier = 2;
388        rodata.slice_shrink_min_ns = opts.slice_shrink_min_us * 1_000;
389
390        rodata.nr_possible_cpus = *NR_CPUS_POSSIBLE as u32;
391        rodata.nr_cpu_ids = *NR_CPU_IDS as u32;
392
393        rodata.reject_multicpu_pinning = opts.reject_multicpu_pinning;
394
395        rodata.enable_llc_awareness = opts.enable_llc_awareness;
396
397        rodata.enable_borrowing = opts.enable_borrowing;
398        rodata.use_lockless_peek = opts.use_lockless_peek;
399
400        match *compat::SCX_OPS_ALLOW_QUEUED_WAKEUP {
401            0 => info!("Kernel does not support queued wakeup optimization."),
402            v => skel.struct_ops.mitosis_mut().flags |= v,
403        }
404
405        let mitosis_topology = MitosisTopology::new(&topology);
406
407        let mut skel = scx_ops_cid_load!(skel, mitosis, uei).context("loading BPF skeleton")?;
408
409        // Set up the arena allocators and topology.
410        let task_size = std::mem::size_of::<types::task_ctx>();
411        let arenalib = ArenaLib::setup(
412            skel.object_mut(),
413            task_size,
414            scx_arena::CACHELINE_SIZE,
415            *NR_CPU_IDS,
416        )
417        .context("setting up arenas")?;
418
419        let stats_server = StatsServer::new(stats::server_data())
420            .launch()
421            .context("launching stats server")?;
422
423        let parent_cgroup = Self::managed_cell_parent(opts)?;
424        let exclude: HashSet<String> = opts.cell_exclude.iter().cloned().collect();
425        let cell_manager = CellManager::new(
426            parent_cgroup,
427            MAX_CELLS as u32,
428            topology.span.clone(),
429            exclude,
430            opts.cell0_min_cpus,
431            mitosis_topology.cpu_to_llc.into_iter().collect(),
432        )
433        .with_context(|| format!("initializing cell manager for cgroup {}", parent_cgroup))?;
434
435        // Create epoll instance for event-driven main loop
436        let epoll = Epoll::new(EpollCreateFlags::empty()).context("creating epoll instance")?;
437
438        // Create eventfd for stats wakeup (non-blocking, semaphore mode)
439        let stats_waker = EventFd::from_value_and_flags(
440            0,
441            nix::sys::eventfd::EfdFlags::EFD_NONBLOCK | nix::sys::eventfd::EfdFlags::EFD_SEMAPHORE,
442        )
443        .context("creating stats-waker eventfd")?;
444
445        // Register stats_waker with epoll
446        epoll
447            .add(
448                &stats_waker,
449                EpollEvent::new(EpollFlags::EPOLLIN, STATS_TOKEN),
450            )
451            .context("registering stats-waker with epoll")?;
452
453        epoll
454            .add(
455                &cell_manager,
456                EpollEvent::new(EpollFlags::EPOLLIN, INOTIFY_TOKEN),
457            )
458            .context("registering cell manager inotify with epoll")?;
459
460        Ok(Self {
461            _arenalib: arenalib,
462            skel,
463            monitor_interval: Duration::from_secs(opts.monitor_interval_s),
464            cells: HashMap::new(),
465            prev_cell_stats: [[0; NR_CSTATS]; MAX_CELLS],
466            prev_cell_running_ns: [0; MAX_CELLS],
467            prev_cell_own_ns: [0; MAX_CELLS],
468            prev_cell_lent_ns: [0; MAX_CELLS],
469            demand_sampled_at: None,
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_nitosis --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.release_detached_cells()
554                .context("releasing detached cells")?;
555            self.refresh_bpf_cells()
556                .context("refreshing BPF cell state")?;
557            self.check_cpuset_changes()
558                .context("checking cpuset changes")?;
559            self.collect_metrics().context("collecting metrics")?;
560
561            if self.enable_rebalancing {
562                self.maybe_rebalance().context("running rebalance check")?;
563            }
564        }
565
566        drop(struct_ops);
567        // Drop stats_server to close the channel, allowing stats_bridge to exit
568        drop(self.stats_server.take());
569        let _ = stats_bridge.join();
570        info!("Unregister {SCHEDULER_NAME} scheduler");
571        uei_report!(&self.skel, uei)
572    }
573
574    /// Apply initial cell assignments discovered at startup
575    fn apply_initial_cells(&mut self) -> Result<()> {
576        let cpu_assignments = self
577            .compute_and_apply_cell_config(&[])
578            .context("computing initial cell configuration")?;
579
580        info!(
581            "Applied initial cell configuration: {}",
582            self.cell_manager.format_cell_config(&cpu_assignments)
583        );
584
585        Ok(())
586    }
587
588    /// Process cell manager events (new/destroyed cgroups)
589    fn process_cell_events(&mut self) -> Result<()> {
590        let (num_new, num_destroyed, new_cell_ids, destroyed_cell_ids) = {
591            // the records decide whether a destroyed cell's ID is reused at
592            // once or waits for its sub-scheduler to detach
593            let (new_cells, destroyed_cells) = self
594                .cell_manager
595                .process_events(|| read_cell_sub_cgids(&self.skel).context("reading cell records"))
596                .context("processing inotify events")?;
597
598            if new_cells.is_empty() && destroyed_cells.is_empty() {
599                return Ok(());
600            }
601
602            let new_ids: Vec<u32> = new_cells.iter().map(|(_, cell_id)| *cell_id).collect();
603            (
604                new_cells.len(),
605                destroyed_cells.len(),
606                new_ids,
607                destroyed_cells,
608            )
609        };
610
611        // Clear smoothed_util for destroyed cells so stale data doesn't
612        // leak if the cell ID is reused later.
613        for &cell_id in &destroyed_cell_ids {
614            self.smoothed_util[cell_id as usize] = 0.0;
615        }
616
617        let cpu_assignments = self
618            .compute_and_apply_cell_config(&new_cell_ids)
619            .context("recomputing cell configuration for new cgroups")?;
620
621        info!(
622            "Cell config updated ({} new, {} destroyed): {}",
623            num_new,
624            num_destroyed,
625            self.cell_manager.format_cell_config(&cpu_assignments)
626        );
627
628        Ok(())
629    }
630
631    /// Free the cells whose sub-scheduler detached since the last pass and
632    /// re-apply the configuration, which clears their owners. A detach is not
633    /// a cgroup event, so inotify does not report it.
634    fn release_detached_cells(&mut self) -> Result<()> {
635        let sub_cgids = read_cell_sub_cgids(&self.skel).context("reading cell records")?;
636        let released = self.cell_manager.release_detached_cells(&sub_cgids);
637        if released == 0 {
638            return Ok(());
639        }
640
641        let cpu_assignments = self
642            .compute_and_apply_cell_config(&[])
643            .context("recomputing cell configuration after a detach")?;
644
645        info!(
646            "Released {} detached cell(s): {}",
647            released,
648            self.cell_manager.format_cell_config(&cpu_assignments)
649        );
650
651        Ok(())
652    }
653
654    /// Compute cell configuration from CellManager and apply it to BPF.
655    ///
656    /// When rebalancing is enabled and there is existing utilization data,
657    /// uses demand-weighted CPU assignment instead of equal-weight. New cells
658    /// (listed in `new_cell_ids`) are seeded to the average smoothed_util of
659    /// existing cells so they start with a fair share rather than zero.
660    ///
661    /// Returns the CPU assignments for use with `format_cell_config`.
662    fn compute_and_apply_cell_config(
663        &mut self,
664        new_cell_ids: &[u32],
665    ) -> Result<Vec<CpuAssignment>> {
666        let (cell_assignments, cpu_assignments) = {
667            let active_cell_ids: Vec<u32> = self
668                .cell_manager
669                .get_cell_assignments()
670                .iter()
671                .map(|(_, cell_id)| *cell_id)
672                .collect();
673            // Cell 0 is always active
674            let all_cell_ids: Vec<u32> = std::iter::once(0)
675                .chain(active_cell_ids.iter().copied())
676                .collect();
677
678            let cpu_assignments = if self.enable_rebalancing {
679                // Check if any existing (non-new) cell has utilization data
680                let new_set: HashSet<u32> = new_cell_ids.iter().copied().collect();
681                let existing_utils: Vec<f64> = all_cell_ids
682                    .iter()
683                    .filter(|id| !new_set.contains(id))
684                    .map(|&id| self.smoothed_util[id as usize])
685                    .collect();
686
687                let has_data = existing_utils.iter().any(|&u| u > 0.0);
688
689                if has_data {
690                    // Seed new cells to the average utilization of existing cells
691                    let avg_util: f64 =
692                        existing_utils.iter().sum::<f64>() / existing_utils.len().max(1) as f64;
693                    for &id in new_cell_ids {
694                        self.smoothed_util[id as usize] = avg_util;
695                        info!(
696                            "Seeded new cell {} smoothed_util to average {:.1}%",
697                            id, avg_util
698                        );
699                    }
700
701                    // Build demand map from smoothed_util for all active cells
702                    let cell_demands: HashMap<u32, f64> = all_cell_ids
703                        .iter()
704                        .map(|&id| (id, self.smoothed_util[id as usize]))
705                        .collect();
706
707                    self.cell_manager
708                        .compute_demand_cpu_assignments(&cell_demands, self.enable_borrowing)
709                        .context("computing demand-weighted CPU assignments")?
710                } else {
711                    // No utilization data yet (e.g., initial startup) — equal weight
712                    self.cell_manager
713                        .compute_cpu_assignments(self.enable_borrowing)
714                        .context("computing equal-weight CPU assignments (no utilization data)")?
715                }
716            } else {
717                self.cell_manager
718                    .compute_cpu_assignments(self.enable_borrowing)
719                    .context("computing equal-weight CPU assignments (rebalancing disabled)")?
720            };
721
722            (self.cell_manager.get_cell_assignments(), cpu_assignments)
723        };
724
725        self.apply_cell_config(&cell_assignments, &cpu_assignments)
726            .context("applying cell configuration to BPF")?;
727
728        Ok(cpu_assignments)
729    }
730
731    /// Check if rebalancing should be triggered and apply demand-weighted CPU assignments.
732    fn maybe_rebalance(&mut self) -> Result<()> {
733        // Check cooldown
734        if self.last_rebalance.elapsed() < self.rebalance_cooldown {
735            return Ok(());
736        }
737
738        // Compute min/max smoothed utilization across active cells
739        let active_cells: Vec<u32> = self.cells.keys().copied().collect();
740        if active_cells.len() < 2 {
741            return Ok(());
742        }
743
744        let mut min_util = f64::MAX;
745        let mut max_util = f64::MIN;
746        for &cell_id in &active_cells {
747            let util = self.smoothed_util[cell_id as usize];
748            if util < min_util {
749                min_util = util;
750            }
751            if util > max_util {
752                max_util = util;
753            }
754        }
755
756        let spread = max_util - min_util;
757        if spread < self.rebalance_threshold {
758            return Ok(());
759        }
760
761        // Build demand map from smoothed utilization
762        let cell_demands: HashMap<u32, f64> = active_cells
763            .iter()
764            .map(|&cell_id| (cell_id, self.smoothed_util[cell_id as usize]))
765            .collect();
766
767        // Compute new assignments and check if they differ from current
768        let (cell_assignments, cpu_assignments) = {
769            let cpu_assignments = self
770                .cell_manager
771                .compute_demand_cpu_assignments(&cell_demands, self.enable_borrowing)
772                .context("computing demand-weighted CPU assignments for rebalance")?;
773
774            let changed = cpu_assignments.iter().any(|a| {
775                self.cells
776                    .get(&a.id)
777                    .is_none_or(|cell| cell.cpus != a.primary)
778            });
779
780            if !changed {
781                return Ok(());
782            }
783
784            (self.cell_manager.get_cell_assignments(), cpu_assignments)
785        };
786
787        self.apply_cell_config(&cell_assignments, &cpu_assignments)
788            .context("applying rebalanced cell configuration to BPF")?;
789
790        self.last_rebalance = Instant::now();
791        self.rebalance_count += 1;
792        self.metrics.rebalance_count = self.rebalance_count;
793
794        info!(
795            "Rebalanced CPUs (spread={:.1}%, count={}): {}",
796            spread,
797            self.rebalance_count,
798            self.cell_manager.format_cell_config(&cpu_assignments)
799        );
800
801        Ok(())
802    }
803
804    /// Apply cell configuration to BPF.
805    ///
806    /// Writes the cell and CPU assignments to the BPF config struct and triggers
807    /// the BPF program to apply the configuration.
808    fn apply_cell_config(
809        &mut self,
810        cell_assignments: &[(u64, u32)],
811        cpu_assignments: &[CpuAssignment],
812    ) -> Result<()> {
813        let bss_data = self
814            .skel
815            .maps
816            .bss_data
817            .as_mut()
818            .expect("bss_data must be available after scheduler load");
819
820        let config = &mut bss_data.cell_config;
821
822        // Zero out the config struct. This is necessary because:
823        // 1. Cell IDs can be sparse (e.g., cells 0, 2, 3 if cell 1 was destroyed)
824        // 2. We only write cpumasks for active cells, leaving gaps unwritten
825        // 3. BPF iterates 0..num_cells and applies each cpumask
826        // 4. Without zeroing, a gap (e.g., cell 1) would have a stale cpumask,
827        //    causing CPUs to be assigned to an unused cell
828        // Safety: cell_config is a plain data struct with no Drop impl
829        unsafe {
830            std::ptr::write_bytes(
831                config as *mut _ as *mut u8,
832                0,
833                std::mem::size_of_val(config),
834            );
835        }
836
837        if cell_assignments.len() > bpf_intf::consts_MAX_CELLS as usize {
838            bail!(
839                "Too many cell assignments: {} > MAX_CELLS ({})",
840                cell_assignments.len(),
841                bpf_intf::consts_MAX_CELLS
842            );
843        }
844        config.num_cell_assignments = cell_assignments.len() as u32;
845
846        for (i, (cgid, cell_id)) in cell_assignments.iter().enumerate() {
847            config.assignments[i].cgid = *cgid;
848            config.assignments[i].cell_id = *cell_id;
849        }
850
851        // Set cell cpumasks and borrowable cpumasks
852        let mut max_cell_id: u32 = 0;
853        for a in cpu_assignments {
854            if a.id >= bpf_intf::consts_MAX_CELLS {
855                bail!(
856                    "Cell ID {} exceeds MAX_CELLS ({})",
857                    a.id,
858                    bpf_intf::consts_MAX_CELLS
859                );
860            }
861            max_cell_id = max_cell_id.max(a.id + 1);
862
863            write_cpumask_to_config(&a.primary, &mut config.cpumasks[a.id as usize].mask);
864
865            if let Some(ref borrowable) = a.borrowable {
866                write_cpumask_to_config(
867                    borrowable,
868                    &mut config.borrowable_cpumasks[a.id as usize].mask,
869                );
870            }
871        }
872        config.num_cells = max_cell_id;
873
874        // Trigger the BPF program to apply the configuration
875        let prog = &mut self.skel.progs.apply_cell_config;
876        let out = prog
877            .test_run(ProgramInput::default())
878            .context("Failed to run apply_cell_config BPF program")?;
879        if out.return_value != 0 {
880            bail!(
881                "apply_cell_config BPF program returned error {} (num_assignments={}, num_cells={})",
882                out.return_value as i32,
883                cell_assignments.len(),
884                cpu_assignments.len()
885            );
886        }
887
888        Ok(())
889    }
890
891    fn get_metrics(&self) -> Metrics {
892        self.metrics.clone()
893    }
894
895    fn calculate_distribution_stats(
896        &self,
897        queue_counts: &[u64; QUEUE_STATS_IDX.len()],
898        global_queue_decisions: u64,
899        scope_queue_decisions: u64,
900        scope_affn_viols: u64,
901        scope_steals: u64,
902        scope_pin_skips: u64,
903        scope_borrow_bounces: u64,
904    ) -> Result<DistributionStats> {
905        // First % on the line: share of global work
906        // We know global_queue_decisions is non-zero.
907        let share_of_global =
908            100.0 * (scope_queue_decisions as f64) / (global_queue_decisions as f64);
909
910        // Each queue's % of the scope total
911        let queue_pct = if scope_queue_decisions == 0 {
912            debug!("No queue decisions in scope, zeroing out queue distribution");
913            [0.0; QUEUE_STATS_IDX.len()]
914        } else {
915            core::array::from_fn(|i| {
916                100.0 * (queue_counts[i] as f64) / (scope_queue_decisions as f64)
917            })
918        };
919
920        // These are summed differently for the global and per-cell totals.
921        let affinity_violations_percent = if scope_queue_decisions == 0 {
922            debug!("No queue decisions in scope, zeroing out affinity violations");
923            0.0
924        } else {
925            100.0 * (scope_affn_viols as f64) / (scope_queue_decisions as f64)
926        };
927
928        let steal_pct = if scope_queue_decisions == 0 {
929            0.0
930        } else {
931            100.0 * (scope_steals as f64) / (scope_queue_decisions as f64)
932        };
933
934        let pin_skip_pct = if scope_queue_decisions == 0 {
935            0.0
936        } else {
937            100.0 * (scope_pin_skips as f64) / (scope_queue_decisions as f64)
938        };
939
940        let borrow_bounce_pct = if scope_queue_decisions == 0 {
941            0.0
942        } else {
943            100.0 * (scope_borrow_bounces as f64) / (scope_queue_decisions as f64)
944        };
945
946        const EXPECTED_QUEUES: usize = 5;
947        if queue_pct.len() != EXPECTED_QUEUES {
948            bail!(
949                "Expected {} queues, got {}",
950                EXPECTED_QUEUES,
951                queue_pct.len()
952            );
953        }
954
955        Ok(DistributionStats {
956            total_decisions: scope_queue_decisions,
957            share_of_decisions_pct: share_of_global,
958            local_q_pct: queue_pct[0],
959            cpu_q_pct: queue_pct[1],
960            cell_q_pct: queue_pct[2],
961            borrowed_pct: queue_pct[3],
962            sub_q_pct: queue_pct[4],
963            affn_viol_pct: affinity_violations_percent,
964            steal_pct,
965            pin_skip_pct,
966            borrow_bounce_pct,
967            global_queue_decisions,
968        })
969    }
970
971    // Queue stats for the whole node
972    fn update_and_log_global_queue_stats(
973        &mut self,
974        global_queue_decisions: u64,
975        cell_stats_delta: &[[u64; NR_CSTATS]; MAX_CELLS],
976    ) -> Result<()> {
977        // Get total of each queue summed over all cells
978        let mut queue_counts = [0; QUEUE_STATS_IDX.len()];
979        for cells in 0..MAX_CELLS {
980            for (i, stat) in QUEUE_STATS_IDX.iter().enumerate() {
981                queue_counts[i] += cell_stats_delta[cells][*stat as usize];
982            }
983        }
984
985        let prefix = "Total Decisions:";
986
987        // Here we want to sum the affinity violations over all cells.
988        let scope_affn_viols: u64 = cell_stats_delta
989            .iter()
990            .map(|&cell| cell[bpf_intf::cell_stat_idx_CSTAT_AFFN_VIOL as usize])
991            .sum::<u64>();
992
993        // Sum steals over all cells
994        let scope_steals: u64 = cell_stats_delta
995            .iter()
996            .map(|&cell| cell[bpf_intf::cell_stat_idx_CSTAT_STEAL as usize])
997            .sum::<u64>();
998
999        // Sum pin skips over all cells
1000        let scope_pin_skips: u64 = cell_stats_delta
1001            .iter()
1002            .map(|&cell| cell[bpf_intf::cell_stat_idx_CSTAT_PIN_SKIP as usize])
1003            .sum::<u64>();
1004
1005        // Sum borrow bounces over all cells
1006        let scope_borrow_bounces: u64 = cell_stats_delta
1007            .iter()
1008            .map(|&cell| cell[bpf_intf::cell_stat_idx_CSTAT_BORROW_BOUNCE as usize])
1009            .sum::<u64>();
1010
1011        // Special case where the number of scope decisions == number global decisions
1012        let stats = self
1013            .calculate_distribution_stats(
1014                &queue_counts,
1015                global_queue_decisions,
1016                global_queue_decisions,
1017                scope_affn_viols,
1018                scope_steals,
1019                scope_pin_skips,
1020                scope_borrow_bounces,
1021            )
1022            .context("calculating global queue distribution stats")?;
1023
1024        self.metrics.update(&stats);
1025
1026        // Slice shrink stats bypass DistributionStats — they're raw event counts
1027        let sum = |idx: usize| -> u64 { cell_stats_delta.iter().map(|c| c[idx]).sum() };
1028        self.metrics.drain_cnt = sum(bpf_intf::cell_stat_idx_CSTAT_DRAIN_CNT as usize);
1029        self.metrics.drain_affn_cnt = sum(bpf_intf::cell_stat_idx_CSTAT_DRAIN_AFFN_CNT as usize);
1030        self.metrics.slice_shrink_max =
1031            sum(bpf_intf::cell_stat_idx_CSTAT_SLICE_SHRINK_MAX as usize);
1032        self.metrics.slice_shrink_proportional =
1033            sum(bpf_intf::cell_stat_idx_CSTAT_SLICE_SHRINK_PROPORTIONAL as usize);
1034        self.metrics.slice_shrink_min =
1035            sum(bpf_intf::cell_stat_idx_CSTAT_SLICE_SHRINK_MIN as usize);
1036        self.metrics.slice_shrink = self.metrics.slice_shrink_max
1037            + self.metrics.slice_shrink_proportional
1038            + self.metrics.slice_shrink_min;
1039
1040        trace!("{} {}", prefix, stats);
1041
1042        Ok(())
1043    }
1044
1045    // Print out the per-cell stats
1046    fn update_and_log_cell_queue_stats(
1047        &mut self,
1048        global_queue_decisions: u64,
1049        cell_stats_delta: &[[u64; NR_CSTATS]; MAX_CELLS],
1050    ) -> Result<()> {
1051        for cell in 0..MAX_CELLS {
1052            let cell_queue_decisions = QUEUE_STATS_IDX
1053                .iter()
1054                .map(|&stat| cell_stats_delta[cell][stat as usize])
1055                .sum::<u64>();
1056
1057            // FIXME: This should really query if the cell is enabled or not.
1058            if cell_queue_decisions == 0 {
1059                continue;
1060            }
1061
1062            let mut queue_counts = [0; QUEUE_STATS_IDX.len()];
1063            for (i, &stat) in QUEUE_STATS_IDX.iter().enumerate() {
1064                queue_counts[i] = cell_stats_delta[cell][stat as usize];
1065            }
1066
1067            const MIN_CELL_WIDTH: usize = 2;
1068            let cell_width: usize = max(MIN_CELL_WIDTH, (MAX_CELLS as f64).log10().ceil() as usize);
1069
1070            let prefix = format!("        Cell {:width$}:", cell, width = cell_width);
1071
1072            // Sum affinity violations for this cell
1073            let scope_affn_viols: u64 =
1074                cell_stats_delta[cell][bpf_intf::cell_stat_idx_CSTAT_AFFN_VIOL as usize];
1075
1076            // Steals for this cell
1077            let scope_steals: u64 =
1078                cell_stats_delta[cell][bpf_intf::cell_stat_idx_CSTAT_STEAL as usize];
1079
1080            // Pin skips for this cell
1081            let scope_pin_skips: u64 =
1082                cell_stats_delta[cell][bpf_intf::cell_stat_idx_CSTAT_PIN_SKIP as usize];
1083
1084            // Borrow bounces for this cell
1085            let scope_borrow_bounces: u64 =
1086                cell_stats_delta[cell][bpf_intf::cell_stat_idx_CSTAT_BORROW_BOUNCE as usize];
1087
1088            let stats = self
1089                .calculate_distribution_stats(
1090                    &queue_counts,
1091                    global_queue_decisions,
1092                    cell_queue_decisions,
1093                    scope_affn_viols,
1094                    scope_steals,
1095                    scope_pin_skips,
1096                    scope_borrow_bounces,
1097                )
1098                .with_context(|| {
1099                    format!("calculating queue distribution stats for cell {}", cell)
1100                })?;
1101
1102            let cell_metrics = self.metrics.cells.entry(cell as u32).or_default();
1103            cell_metrics.update(&stats);
1104
1105            // Raw event counts bypass DistributionStats.
1106            cell_metrics.drain_cnt =
1107                cell_stats_delta[cell][bpf_intf::cell_stat_idx_CSTAT_DRAIN_CNT as usize];
1108            cell_metrics.drain_affn_cnt =
1109                cell_stats_delta[cell][bpf_intf::cell_stat_idx_CSTAT_DRAIN_AFFN_CNT as usize];
1110            cell_metrics.slice_shrink_max =
1111                cell_stats_delta[cell][bpf_intf::cell_stat_idx_CSTAT_SLICE_SHRINK_MAX as usize];
1112            cell_metrics.slice_shrink_proportional = cell_stats_delta[cell]
1113                [bpf_intf::cell_stat_idx_CSTAT_SLICE_SHRINK_PROPORTIONAL as usize];
1114            cell_metrics.slice_shrink_min =
1115                cell_stats_delta[cell][bpf_intf::cell_stat_idx_CSTAT_SLICE_SHRINK_MIN as usize];
1116            cell_metrics.slice_shrink = cell_metrics.slice_shrink_max
1117                + cell_metrics.slice_shrink_proportional
1118                + cell_metrics.slice_shrink_min;
1119
1120            trace!("{} {}", prefix, stats);
1121        }
1122        Ok(())
1123    }
1124
1125    fn update_drain_metrics(&mut self, cell_stats_delta: &[[u64; NR_CSTATS]; MAX_CELLS]) {
1126        let mut total = 0;
1127        let mut affn_total = 0;
1128
1129        for cell in 0..MAX_CELLS {
1130            let drain_cnt =
1131                cell_stats_delta[cell][bpf_intf::cell_stat_idx_CSTAT_DRAIN_CNT as usize];
1132            let drain_affn_cnt =
1133                cell_stats_delta[cell][bpf_intf::cell_stat_idx_CSTAT_DRAIN_AFFN_CNT as usize];
1134            total += drain_cnt;
1135            affn_total += drain_affn_cnt;
1136
1137            if let Some(cell_metrics) = self.metrics.cells.get_mut(&(cell as u32)) {
1138                cell_metrics.drain_cnt = drain_cnt;
1139                cell_metrics.drain_affn_cnt = drain_affn_cnt;
1140            } else if drain_cnt > 0 || drain_affn_cnt > 0 {
1141                let cell_metrics = self.metrics.cells.entry(cell as u32).or_default();
1142                cell_metrics.drain_cnt = drain_cnt;
1143                cell_metrics.drain_affn_cnt = drain_affn_cnt;
1144            }
1145        }
1146
1147        self.metrics.drain_cnt = total;
1148        self.metrics.drain_affn_cnt = affn_total;
1149    }
1150
1151    fn log_all_queue_stats(
1152        &mut self,
1153        cell_stats_delta: &[[u64; NR_CSTATS]; MAX_CELLS],
1154    ) -> Result<()> {
1155        // Get total decisions
1156        let global_queue_decisions: u64 = cell_stats_delta
1157            .iter()
1158            .flat_map(|cell| QUEUE_STATS_IDX.iter().map(|&idx| cell[idx as usize]))
1159            .sum();
1160
1161        self.update_drain_metrics(cell_stats_delta);
1162
1163        // a delegated cell reports its sub whether or not it had decisions
1164        let sub_cgids = read_cell_sub_cgids(&self.skel).context("reading cell records")?;
1165        for (cell, cell_metrics) in self.metrics.cells.iter_mut() {
1166            cell_metrics.sub_cgid = sub_cgids[*cell as usize];
1167        }
1168
1169        if global_queue_decisions == 0 {
1170            if self.metrics.drain_cnt == 0 {
1171                warn!("No queueing decisions made globally");
1172            }
1173            return Ok(());
1174        }
1175
1176        self.update_and_log_global_queue_stats(global_queue_decisions, cell_stats_delta)
1177            .context("updating global queue stats")?;
1178
1179        self.update_and_log_cell_queue_stats(global_queue_decisions, cell_stats_delta)
1180            .context("updating per-cell queue stats")?;
1181
1182        Ok(())
1183    }
1184
1185    fn calculate_cell_stat_delta(
1186        &mut self,
1187        cpu_ctxs: &[bpf_intf::cpu_ctx],
1188    ) -> Result<[[u64; NR_CSTATS]; MAX_CELLS]> {
1189        let mut cell_stats_delta = [[0_u64; NR_CSTATS]; MAX_CELLS];
1190
1191        // Loop over cells and stats first, then CPU contexts
1192        // TODO: We should loop over the in_use cells only.
1193        for cell in 0..MAX_CELLS {
1194            for stat in 0..NR_CSTATS {
1195                let mut cur_cell_stat = 0;
1196
1197                // Accumulate stats from all CPUs
1198                for cpu_ctx in cpu_ctxs.iter() {
1199                    cur_cell_stat += cpu_ctx.cstats[cell][stat];
1200                }
1201
1202                // Calculate delta and update previous stat
1203                cell_stats_delta[cell][stat] = cur_cell_stat - self.prev_cell_stats[cell][stat];
1204                self.prev_cell_stats[cell][stat] = cur_cell_stat;
1205            }
1206        }
1207        Ok(cell_stats_delta)
1208    }
1209
1210    /// Collect metrics and out various debugging data like per cell stats, per-cpu stats, etc.
1211    fn collect_metrics(&mut self) -> Result<()> {
1212        // close the open sub intervals so that the counters read below are
1213        // current, see sub_charge_cids()
1214        self.skel
1215            .progs
1216            .sub_charge_cids
1217            .test_run(ProgramInput::default())
1218            .context("running sub_charge_cids")?;
1219        // the demand deltas are measured up to here, see collect_demand_metrics()
1220        let sampled_at = Instant::now();
1221        let cpu_ctxs = read_cpu_ctxs(&self.skel).context("reading per-CPU contexts for metrics")?;
1222
1223        let cell_stats_delta = self
1224            .calculate_cell_stat_delta(&cpu_ctxs)
1225            .context("calculating cell stat deltas")?;
1226
1227        self.log_all_queue_stats(&cell_stats_delta)
1228            .context("logging queue stats")?;
1229
1230        // Mirror the sticky holdout flag on every collection, independent of the
1231        // zero-decisions early return inside log_all_queue_stats above.
1232        self.metrics.enforced_holdout = self.cell_manager.enforced_holdout() as u64;
1233
1234        self.collect_demand_metrics(&cpu_ctxs, sampled_at)
1235            .context("collecting demand metrics")?;
1236
1237        for (cell_id, cell) in &self.cells {
1238            trace!("CELL[{}]: {}", cell_id, cell.cpus);
1239        }
1240
1241        for (cell_id, cell) in self.cells.iter() {
1242            // Assume we have a CellMetrics entry if we have a known cell
1243            self.metrics
1244                .cells
1245                .entry(*cell_id)
1246                .and_modify(|cell_metrics| {
1247                    cell_metrics.num_cpus = cell.cpus.weight() as u32;
1248                    cell_metrics.cgroup_path = self.cell_manager.cgroup_path_for_cell(*cell_id);
1249                });
1250        }
1251        self.metrics.num_cells = self.cells.len() as u32;
1252
1253        Ok(())
1254    }
1255
1256    /// Compute per-cell demand metrics (utilization, borrowed, lent) from BPF running_ns counters.
1257    fn collect_demand_metrics(
1258        &mut self,
1259        cpu_ctxs: &[bpf_intf::cpu_ctx],
1260        sampled_at: Instant,
1261    ) -> Result<()> {
1262        // Deltas over the time that really passed: an iteration can take well
1263        // over the nominal interval and events wake the loop early. Let half
1264        // an interval build up before sampling again. sampled_at is taken right
1265        // after the sub intervals are closed, before the arena read.
1266        let half_interval = self.monitor_interval / 2;
1267        let interval_ns = match self.demand_sampled_at {
1268            Some(prev) if sampled_at.duration_since(prev) < half_interval => return Ok(()),
1269            Some(prev) => Some(sampled_at.duration_since(prev).as_nanos() as u64),
1270            None => None,
1271        };
1272        self.demand_sampled_at = Some(sampled_at);
1273
1274        // Per-cell cumulative counters derived from BPF per-CPU running_ns:
1275        //   total_running_ns[c] = total time tasks in cell c ran (on any CPU)
1276        //   on_own_ns[c]        = time tasks in cell c ran on CPUs owned by cell c
1277        //   lent_ns[c]          = time foreign tasks ran on CPUs owned by cell c
1278        let mut total_running_ns = [0u64; MAX_CELLS];
1279        let mut on_own_ns = [0u64; MAX_CELLS];
1280        let mut lent_ns = [0u64; MAX_CELLS];
1281
1282        // the subs' time on a cid is charged apart from our own tasks' time,
1283        // see sub_charge()
1284        for cpu_ctx in cpu_ctxs.iter() {
1285            let owner = cpu_ctx.cell as usize;
1286            let running_ns: [u64; MAX_CELLS] =
1287                std::array::from_fn(|cell| cpu_ctx.running_ns[cell] + cpu_ctx.sub_ns[cell]);
1288            for cell in 0..MAX_CELLS {
1289                let ns = running_ns[cell];
1290                total_running_ns[cell] += ns;
1291                if owner == cell {
1292                    on_own_ns[cell] += ns;
1293                }
1294            }
1295            if owner >= MAX_CELLS {
1296                bail!(
1297                    "CPU has invalid cell assignment {} (MAX_CELLS={})",
1298                    owner,
1299                    MAX_CELLS
1300                );
1301            }
1302            // Lent time: non-owner cell tasks running on this CPU
1303            let total_on_cpu: u64 = running_ns.iter().sum();
1304            let owner_on_cpu = running_ns[owner];
1305            lent_ns[owner] += total_on_cpu.saturating_sub(owner_on_cpu);
1306        }
1307
1308        // the first call only seeds the deltas
1309        let Some(interval_ns) = interval_ns else {
1310            self.prev_cell_running_ns = total_running_ns;
1311            self.prev_cell_own_ns = on_own_ns;
1312            self.prev_cell_lent_ns = lent_ns;
1313            return Ok(());
1314        };
1315
1316        let mut global_running_delta = 0u64;
1317        let mut global_borrowed_delta = 0u64;
1318        let mut global_lent_delta = 0u64;
1319        let mut global_capacity = 0u64;
1320
1321        for cell in 0..MAX_CELLS {
1322            let delta_running =
1323                total_running_ns[cell].saturating_sub(self.prev_cell_running_ns[cell]);
1324            let delta_on_own = on_own_ns[cell].saturating_sub(self.prev_cell_own_ns[cell]);
1325            let delta_lent = lent_ns[cell].saturating_sub(self.prev_cell_lent_ns[cell]);
1326
1327            self.prev_cell_running_ns[cell] = total_running_ns[cell];
1328            self.prev_cell_own_ns[cell] = on_own_ns[cell];
1329            self.prev_cell_lent_ns[cell] = lent_ns[cell];
1330
1331            // Borrowed = ran somewhere other than own CPUs
1332            let delta_borrowed = delta_running.saturating_sub(delta_on_own);
1333
1334            // After a cell is destroyed, BPF may still report residual
1335            // running_ns from the previous interval. Skip stale cells.
1336            let Some(cell_info) = self.cells.get(&(cell as u32)) else {
1337                continue;
1338            };
1339
1340            let nr_cpus = cell_info.cpus.weight() as u64;
1341            if nr_cpus == 0 {
1342                bail!("Cell {} has 0 CPUs assigned", cell);
1343            }
1344
1345            // capacity = total available CPU-time this interval
1346            let capacity = nr_cpus * interval_ns;
1347            // util: fraction of own capacity consumed by own tasks
1348            let util_pct = 100.0 * (delta_running as f64) / (capacity as f64);
1349            // demand_borrow: fraction of running time that was borrowed from other cells
1350            let demand_borrow_pct = if delta_running > 0 {
1351                100.0 * (delta_borrowed as f64) / (delta_running as f64)
1352            } else {
1353                0.0
1354            };
1355            // lent: fraction of own capacity used by foreign tasks
1356            let lent_pct = 100.0 * (delta_lent as f64) / (capacity as f64);
1357
1358            // Update EWMA-smoothed utilization
1359            if self.enable_rebalancing {
1360                self.smoothed_util[cell] = self.demand_smoothing * util_pct
1361                    + (1.0 - self.demand_smoothing) * self.smoothed_util[cell];
1362            }
1363
1364            self.metrics
1365                .cells
1366                .entry(cell as u32)
1367                .or_default()
1368                .update_demand(util_pct, demand_borrow_pct, lent_pct);
1369
1370            // Update smoothed_util_pct in metrics
1371            if self.enable_rebalancing {
1372                self.metrics
1373                    .cells
1374                    .entry(cell as u32)
1375                    .or_default()
1376                    .smoothed_util_pct = self.smoothed_util[cell];
1377            }
1378
1379            global_running_delta = global_running_delta.saturating_add(delta_running);
1380            global_borrowed_delta = global_borrowed_delta.saturating_add(delta_borrowed);
1381            global_lent_delta = global_lent_delta.saturating_add(delta_lent);
1382            global_capacity = global_capacity.saturating_add(capacity);
1383        }
1384
1385        let global_util_pct = if global_capacity > 0 {
1386            100.0 * (global_running_delta as f64) / (global_capacity as f64)
1387        } else {
1388            0.0
1389        };
1390        let global_borrow_pct = if global_running_delta > 0 {
1391            100.0 * (global_borrowed_delta as f64) / (global_running_delta as f64)
1392        } else {
1393            0.0
1394        };
1395        let global_lent_pct = if global_capacity > 0 {
1396            100.0 * (global_lent_delta as f64) / (global_capacity as f64)
1397        } else {
1398            0.0
1399        };
1400
1401        self.metrics
1402            .update_demand(global_util_pct, global_borrow_pct, global_lent_pct);
1403
1404        Ok(())
1405    }
1406
1407    /// Write applied_cpuset_seq to BSS, closing the rejection-skip window.
1408    fn update_applied_cpuset_seq(&mut self) {
1409        unsafe {
1410            let ptr = &mut self
1411                .skel
1412                .maps
1413                .bss_data
1414                .as_mut()
1415                .expect("BUG: bss_data missing after scheduler load")
1416                .applied_cpuset_seq as *mut u32;
1417            std::ptr::write_volatile(ptr, self.last_cpuset_seq);
1418        }
1419    }
1420
1421    /// Check if any cell's cpuset was modified and recompute if so.
1422    fn check_cpuset_changes(&mut self) -> Result<()> {
1423        let current_seq = unsafe {
1424            let ptr = &self
1425                .skel
1426                .maps
1427                .bss_data
1428                .as_ref()
1429                .expect("BUG: bss_data missing after scheduler load")
1430                .cpuset_seq as *const u32;
1431            (ptr as *const AtomicU32)
1432                .as_ref()
1433                .expect("BUG: cpuset_seq pointer cast yielded null")
1434                .load(Ordering::Acquire)
1435        };
1436
1437        if current_seq == self.last_cpuset_seq {
1438            return Ok(());
1439        }
1440        self.last_cpuset_seq = current_seq;
1441
1442        if !self
1443            .cell_manager
1444            .refresh_cpusets()
1445            .context("refreshing cell cpusets")?
1446        {
1447            // seq changed but no cpusets on our cells changed
1448            self.update_applied_cpuset_seq();
1449            return Ok(());
1450        }
1451
1452        let cpu_assignments = self
1453            .compute_and_apply_cell_config(&[])
1454            .context("recomputing cell configuration after cpuset change")?;
1455        self.update_applied_cpuset_seq();
1456        info!(
1457            "Cpuset change detected, recomputed config: {}",
1458            self.cell_manager.format_cell_config(&cpu_assignments)
1459        );
1460        Ok(())
1461    }
1462
1463    fn refresh_bpf_cells(&mut self) -> Result<()> {
1464        let applied_configuration = unsafe {
1465            let ptr = &self
1466                .skel
1467                .maps
1468                .bss_data
1469                .as_ref()
1470                .expect("BUG: bss_data missing after scheduler load")
1471                .applied_configuration_seq as *const u32;
1472            (ptr as *const std::sync::atomic::AtomicU32)
1473                .as_ref()
1474                .expect("BUG: applied_configuration_seq pointer cast yielded null")
1475                .load(std::sync::atomic::Ordering::Acquire)
1476        };
1477        if self
1478            .last_configuration_seq
1479            .is_some_and(|seq| applied_configuration == seq)
1480        {
1481            return Ok(());
1482        }
1483        // collect all cpus per cell.
1484        let mut cell_to_cpus: HashMap<u32, Cpumask> = HashMap::new();
1485        let cpu_ctxs =
1486            read_cpu_ctxs(&self.skel).context("reading per-CPU contexts for BPF cell refresh")?;
1487        for cpu_ctx in cpu_ctxs.iter() {
1488            /* the array is cid-indexed; translate to the cpu domain */
1489            cell_to_cpus
1490                .entry(cpu_ctx.cell)
1491                .or_insert_with(Cpumask::new)
1492                .set_cpu(cpu_ctx.cpu as usize)
1493                .expect("set cpu in existing mask");
1494        }
1495
1496        // Create cells we don't have yet, drop cells that are no longer in use.
1497        // If we continue to drop cell metrics once a cell is removed, we'll need to make sure we
1498        // flush metrics for a cell before we remove it completely.
1499        //
1500        // IMPORTANT: We determine which cells exist based on CPU assignments (which are
1501        // synchronized by applied_configuration_seq), NOT by reading the in_use field
1502        // separately. This avoids a TOCTOU race where a cell's in_use is set before
1503        // CPUs are assigned.
1504
1505        // Cell 0 (root cell) always exists even if it has no CPUs temporarily
1506        let cells_with_cpus: HashSet<u32> = cell_to_cpus.keys().copied().collect();
1507        let mut active_cells = cells_with_cpus.clone();
1508        active_cells.insert(0);
1509
1510        for cell_idx in &active_cells {
1511            let cpus = cell_to_cpus
1512                .get(cell_idx)
1513                .cloned()
1514                .unwrap_or_else(Cpumask::new);
1515            self.cells
1516                .entry(*cell_idx)
1517                .or_insert_with(|| Cell {
1518                    cpus: Cpumask::new(),
1519                })
1520                .cpus = cpus;
1521            self.metrics.cells.insert(*cell_idx, CellMetrics::default());
1522        }
1523
1524        // Remove cells that no longer have CPUs assigned
1525        self.cells.retain(|&k, _| active_cells.contains(&k));
1526        self.metrics.cells.retain(|&k, _| active_cells.contains(&k));
1527
1528        self.last_configuration_seq = Some(applied_configuration);
1529
1530        Ok(())
1531    }
1532}
1533
1534fn write_cpumask_to_config(cpumask: &Cpumask, dest: &mut [u8]) {
1535    let raw_slice = cpumask.as_raw_slice();
1536    for (word_idx, word) in raw_slice.iter().enumerate() {
1537        let byte_start = word_idx * 8;
1538        let bytes = word.to_le_bytes();
1539        for (j, byte) in bytes.iter().enumerate() {
1540            let idx = byte_start + j;
1541            if idx < dest.len() {
1542                dest[idx] = *byte;
1543            }
1544        }
1545    }
1546}
1547
1548/// Every cell's attached sub-scheduler cgroup ID as the arena records it, 0 when none.
1549fn read_cell_sub_cgids(skel: &BpfSkel) -> Result<Vec<u64>> {
1550    let bss = skel
1551        .maps
1552        .bss_data
1553        .as_ref()
1554        .context("bss_data not available")?;
1555    let ptr = bss.cells as *const bpf_intf::cell;
1556    if ptr.is_null() {
1557        bail!("cells arena array not initialized");
1558    }
1559    let mut cgids = Vec::with_capacity(MAX_CELLS);
1560    for cell in 0..MAX_CELLS {
1561        cgids.push(unsafe { std::ptr::addr_of!((*ptr.add(cell)).sub_cgid).read_volatile() });
1562    }
1563    Ok(cgids)
1564}
1565
1566fn read_cpu_ctxs(skel: &BpfSkel) -> Result<Vec<bpf_intf::cpu_ctx>> {
1567    let bss = skel
1568        .maps
1569        .bss_data
1570        .as_ref()
1571        .context("bss_data not available")?;
1572    let nr = bss.nr_cid_ctxs as usize;
1573    let ptr = bss.cpu_ctxs as *const bpf_intf::cpu_ctx;
1574    if ptr.is_null() || nr == 0 {
1575        bail!("cpu_ctxs arena array not initialized");
1576    }
1577    /*
1578     * The array lives in the arena, which is mapped into this process with the
1579     * BSS pointer as its user address. Reads race scheduler-side updates, the
1580     * same as the percpu map reads they replace.
1581     */
1582    let mut cpu_ctxs = Vec::with_capacity(nr);
1583    for cid in 0..nr {
1584        cpu_ctxs.push(unsafe { std::ptr::read_volatile(ptr.add(cid)) });
1585    }
1586    Ok(cpu_ctxs)
1587}
1588
1589fn run(opts: Opts) -> Result<()> {
1590    if opts.version {
1591        println!(
1592            "scx_nitosis {}",
1593            build_id::full_version(env!("CARGO_PKG_VERSION"))
1594        );
1595        return Ok(());
1596    }
1597
1598    let env_filter = EnvFilter::try_from_default_env()
1599        .or_else(|_| match EnvFilter::try_new(&opts.log_level) {
1600            Ok(filter) => Ok(filter),
1601            Err(e) => {
1602                eprintln!(
1603                    "invalid log envvar: {}, using info, err is: {}",
1604                    opts.log_level, e
1605                );
1606                EnvFilter::try_new("info")
1607            }
1608        })
1609        .unwrap_or_else(|_| EnvFilter::new("info"));
1610
1611    match tracing_subscriber::fmt()
1612        .with_env_filter(env_filter)
1613        .with_target(true)
1614        .with_thread_ids(true)
1615        .with_file(true)
1616        .with_line_number(true)
1617        .try_init()
1618    {
1619        Ok(()) => {}
1620        Err(e) => eprintln!("failed to init logger: {}", e),
1621    }
1622
1623    if opts.verbose > 0 {
1624        warn!("Setting verbose via -v is deprecated and will be an error in future releases.");
1625    }
1626
1627    debug!("opts={:?}", &opts);
1628
1629    if let Some(run_id) = opts.run_id {
1630        info!("scx_nitosis run_id: {}", run_id);
1631    }
1632
1633    let shutdown = Arc::new(AtomicBool::new(false));
1634    let shutdown_clone = shutdown.clone();
1635    ctrlc::set_handler(move || {
1636        shutdown_clone.store(true, Ordering::Relaxed);
1637    })
1638    .context("Error setting Ctrl-C handler")?;
1639
1640    if let Some(intv) = opts.monitor {
1641        let shutdown_clone = shutdown.clone();
1642        let jh = std::thread::spawn(move || {
1643            match stats::monitor(Duration::from_secs_f64(intv), shutdown_clone) {
1644                Ok(_) => {
1645                    debug!("stats monitor thread finished successfully")
1646                }
1647                Err(error_object) => {
1648                    warn!(
1649                        "stats monitor thread finished because of an error {}",
1650                        error_object
1651                    )
1652                }
1653            }
1654        });
1655        if opts.monitor.is_some() {
1656            let _ = jh.join();
1657            return Ok(());
1658        }
1659    }
1660
1661    let mut open_object = MaybeUninit::uninit();
1662    loop {
1663        let mut sched =
1664            Scheduler::init(&opts, &mut open_object).context("initializing scheduler")?;
1665        if !sched
1666            .run(shutdown.clone())
1667            .context("running scheduler main loop")?
1668            .should_restart()
1669        {
1670            break;
1671        }
1672    }
1673
1674    Ok(())
1675}
1676
1677fn main() -> Result<()> {
1678    let parsed = undefok_flags::parse_args::<Opts>()?;
1679    // Emit undefok notices before logger setup so ignored rollout flags are always visible.
1680    for undefok in &parsed.ignored_undefok_flags {
1681        eprintln!("warning: ignoring undefok flag --{}", undefok.long);
1682    }
1683    run(parsed.opts)
1684}
1685
1686#[cfg(test)]
1687mod tests {
1688    use super::Opts;
1689    use clap::Parser;
1690
1691    #[test]
1692    fn requires_cell_parent_cgroup_for_scheduler_mode() {
1693        assert!(Opts::try_parse_from(["scx_nitosis"]).is_err());
1694    }
1695
1696    #[test]
1697    fn allows_monitor_without_cell_parent_cgroup() {
1698        assert!(Opts::try_parse_from(["scx_nitosis", "--monitor", "1"]).is_ok());
1699    }
1700
1701    #[test]
1702    fn allows_version_without_cell_parent_cgroup() {
1703        assert!(Opts::try_parse_from(["scx_nitosis", "--version"]).is_ok());
1704    }
1705}