Skip to main content

scx_cidland/
main.rs

1// SPDX-License-Identifier: GPL-2.0
2//
3// Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES.
4//
5// This software may be used and distributed according to the terms of the
6// GNU General Public License version 2.
7
8mod bpf_skel;
9pub use bpf_skel::*;
10pub mod bpf_intf;
11pub use bpf_intf::*;
12
13mod stats;
14
15use std::mem::MaybeUninit;
16use std::sync::atomic::AtomicBool;
17use std::sync::atomic::Ordering;
18use std::sync::Arc;
19use std::time::Duration;
20
21use anyhow::bail;
22use anyhow::Context;
23use anyhow::Result;
24use clap::Parser;
25use crossbeam::channel::RecvTimeoutError;
26use libbpf_rs::skel::Skel;
27use libbpf_rs::OpenObject;
28use libbpf_rs::ProgramInput;
29use log::debug;
30use log::info;
31use log::warn;
32use scx_arena::ArenaLib;
33use scx_stats::prelude::*;
34use scx_utils::build_id;
35use scx_utils::compat;
36use scx_utils::get_primary_cpus;
37use scx_utils::libbpf_clap_opts::LibbpfOpts;
38use scx_utils::scx_ops_attach;
39use scx_utils::scx_ops_cid_load;
40use scx_utils::scx_ops_cid_open;
41use scx_utils::try_set_rlimit_infinity;
42use scx_utils::uei_exited;
43use scx_utils::uei_report;
44use scx_utils::Powermode;
45use scx_utils::Topology;
46use scx_utils::UserExitInfo;
47use scx_utils::NR_CPUS_POSSIBLE;
48use scx_utils::NR_CPU_IDS;
49use stats::Metrics;
50
51const SCHEDULER_NAME: &str = "scx_cidland";
52
53/// Run a SEC("syscall") program with @args as its context.
54///
55/// Despite the name this is not a test run, it's the supported way of invoking
56/// a syscall program from userspace.
57fn run_syscall_prog<T>(prog: &libbpf_rs::ProgramMut<'_>, args: &mut T) -> Result<()> {
58    let input = ProgramInput {
59        context_in: Some(unsafe {
60            std::slice::from_raw_parts_mut(args as *mut T as *mut u8, std::mem::size_of::<T>())
61        }),
62        ..Default::default()
63    };
64
65    let output = prog.test_run(input)?;
66    if output.return_value != 0 {
67        bail!(
68            "{} returned {}",
69            prog.name().to_string_lossy(),
70            output.return_value as i32
71        );
72    }
73
74    Ok(())
75}
76
77/// scx_cidland: a cid-based, topology-aware scheduler.
78///
79/// Rather than raw CPU numbers, this scheduler addresses CPUs by their cid
80/// (topological CPU ID), a dense id space where the CPUs of a core, of an LLC
81/// and of a NUMA node occupy contiguous ranges. Idle CPU selection is a plain
82/// range scan over a bitmap of idle cids, preferring a fully idle core in the
83/// LLC the task last ran on.
84///
85/// Tasks that can't be dispatched to an idle cid are queued to a single shared
86/// DSQ, ordered by a virtual deadline that prioritizes tasks which sleep often
87/// and run in short bursts, and consumed by the first cid that runs out of
88/// work.
89///
90/// This requires a kernel with cid-form sched_ext support (struct
91/// sched_ext_ops_cid).
92#[derive(Debug, Parser)]
93struct Opts {
94    /// Time slice assigned to each task in microseconds.
95    #[clap(short = 's', long, default_value = "1000")]
96    slice_us: u64,
97
98    /// Maximum time slice credit, in microseconds, that a task can accumulate
99    /// while sleeping.
100    ///
101    /// Larger values give a bigger priority boost to tasks that sleep a lot,
102    /// at the cost of fairness towards CPU intensive tasks.
103    #[clap(short = 'l', long, default_value = "20000")]
104    slice_lag_us: u64,
105
106    /// Specifies a group of CPUs to be preferred when looking for an idle CPU.
107    ///
108    /// Accepts a comma-separated list of CPUs or ranges (e.g. 0-3,8-11), or one
109    /// of the following keywords:
110    ///
111    /// "performance" = prioritize the fastest CPUs,
112    /// "powersave" = prioritize the slowest CPUs,
113    /// "turbo" = prioritize the CPUs with the highest max frequency,
114    /// "all" = all CPUs assigned to the primary domain.
115    ///
116    /// This is a preference, not an isolation mechanism: tasks still overflow
117    /// to the other CPUs when the primary domain has nothing idle to offer.
118    ///
119    /// By default all CPUs are used.
120    #[clap(short = 'm', long, value_name = "CPU_LIST")]
121    primary_domain: Option<String>,
122
123    /// Exit debug dump buffer length. 0 indicates default.
124    #[clap(long, default_value = "0")]
125    exit_dump_len: u32,
126
127    /// Enable stats monitoring with the specified interval.
128    #[clap(long)]
129    stats: Option<f64>,
130
131    /// Run in stats monitoring mode with the specified interval. Scheduler
132    /// is not launched.
133    #[clap(long)]
134    monitor: Option<f64>,
135
136    /// Enable verbose output, including libbpf details.
137    #[clap(short = 'v', long, action = clap::ArgAction::SetTrue)]
138    verbose: bool,
139
140    /// Print scheduler version and exit.
141    #[clap(short = 'V', long, action = clap::ArgAction::SetTrue)]
142    version: bool,
143
144    /// Show descriptions for statistics.
145    #[clap(long)]
146    help_stats: bool,
147
148    #[clap(flatten, next_help_heading = "Libbpf Options")]
149    pub libbpf: LibbpfOpts,
150}
151
152/// Resolve the --primary-domain argument: either one of the topology keywords
153/// or an explicit CPU list.
154fn parse_primary_domain(arg: &str) -> Result<Vec<usize>> {
155    let mode = match arg {
156        "performance" => Some(Powermode::Performance),
157        "powersave" => Some(Powermode::Powersave),
158        "turbo" => Some(Powermode::Turbo),
159        "all" => Some(Powermode::Any),
160        _ => None,
161    };
162
163    let Some(mode) = mode else {
164        return parse_cpu_list(arg);
165    };
166
167    let mut cpus = get_primary_cpus(mode).context("detecting the primary CPUs")?;
168    if cpus.is_empty() {
169        bail!("no CPU matches \"{arg}\" on this system");
170    }
171    cpus.sort_unstable();
172    cpus.dedup();
173
174    Ok(cpus)
175}
176
177/// Parse a comma-separated list of CPUs and ranges, e.g. "0-3,8,10-11".
178fn parse_cpu_list(arg: &str) -> Result<Vec<usize>> {
179    let mut cpus = Vec::new();
180
181    for token in arg.split(',') {
182        let token = token.trim();
183
184        if token.is_empty() {
185            continue;
186        }
187
188        if let Some((start, end)) = token.split_once('-') {
189            let start: usize = start
190                .trim()
191                .parse()
192                .with_context(|| format!("invalid range start in {token:?}"))?;
193            let end: usize = end
194                .trim()
195                .parse()
196                .with_context(|| format!("invalid range end in {token:?}"))?;
197            if start > end {
198                bail!("invalid range {token:?}");
199            }
200            cpus.extend(start..=end);
201        } else {
202            cpus.push(
203                token
204                    .parse()
205                    .with_context(|| format!("invalid cpu id {token:?}"))?,
206            );
207        }
208    }
209
210    if cpus.is_empty() {
211        bail!("no CPU specified");
212    }
213    cpus.sort_unstable();
214    cpus.dedup();
215
216    Ok(cpus)
217}
218
219struct Scheduler<'a> {
220    _arenalib: ArenaLib,
221    skel: BpfSkel<'a>,
222    struct_ops: Option<libbpf_rs::Link>,
223    stats_server: StatsServer<(), Metrics>,
224}
225
226impl<'a> Scheduler<'a> {
227    fn init(opts: &'a Opts, open_object: &'a mut MaybeUninit<OpenObject>) -> Result<Self> {
228        try_set_rlimit_infinity();
229
230        if opts.slice_us == 0 {
231            bail!("--slice-us must be greater than 0");
232        }
233
234        let topo = Topology::new().context("detecting system topology")?;
235        info!(
236            "{} {} ({} CPUs, {} LLCs)",
237            SCHEDULER_NAME,
238            build_id::full_version(env!("CARGO_PKG_VERSION")),
239            *NR_CPUS_POSSIBLE,
240            topo.all_llcs.len(),
241        );
242
243        // Initialize BPF connector.
244        let mut skel_builder = BpfSkelBuilder::default();
245        skel_builder.obj_builder.debug(opts.verbose);
246        let open_opts = opts.libbpf.clone().into_bpf_open_opts();
247        let mut skel = scx_ops_cid_open!(skel_builder, open_object, cidland_ops, open_opts)
248            .context("opening BPF skeleton (does this kernel support cid-form sched_ext?)")?;
249
250        skel.struct_ops.cidland_ops_mut().exit_dump_len = opts.exit_dump_len;
251
252        let rodata = skel
253            .maps
254            .rodata_data
255            .as_mut()
256            .expect("rodata_data missing after skel open");
257        rodata.slice_ns = opts.slice_us * 1000;
258        rodata.slice_lag = opts.slice_lag_us * 1000;
259
260        // Define the primary scheduling domain, in cpu space: the BPF side
261        // translates it to cids once the kernel has built the cid layout. The
262        // mask itself is handed over after load, see below.
263        let mut primary_cpus: Vec<usize> = Vec::new();
264        if let Some(ref domain) = opts.primary_domain {
265            let cpus = parse_primary_domain(domain).context("parsing primary domain")?;
266
267            if let Some(cpu) = cpus.iter().find(|cpu| **cpu >= *NR_CPU_IDS) {
268                bail!(
269                    "primary domain cpu {} exceeds nr_cpu_ids {}",
270                    cpu,
271                    *NR_CPU_IDS
272                );
273            }
274            if cpus.len() < *NR_CPU_IDS {
275                info!("primary domain: {:?}", cpus);
276                primary_cpus = cpus;
277                rodata.primary_all = false;
278            }
279        }
280
281        // Set scheduler flags.
282        //
283        // SCX_OPS_BUILTIN_IDLE_PER_NODE is intentionally left out: cid-form
284        // schedulers can't use the built-in idle tracking at all, this one
285        // does its own via ops.update_idle().
286        skel.struct_ops.cidland_ops_mut().flags = *compat::SCX_OPS_ENQ_EXITING
287            | *compat::SCX_OPS_ENQ_LAST
288            | *compat::SCX_OPS_ENQ_MIGRATION_DISABLED
289            | *compat::SCX_OPS_ALLOW_QUEUED_WAKEUP;
290        info!(
291            "scheduler flags: {:#x}",
292            skel.struct_ops.cidland_ops_mut().flags
293        );
294
295        // Load and attach the scheduler.
296        let mut skel = scx_ops_cid_load!(skel, cidland_ops, uei).context("loading BPF skeleton")?;
297
298        // Bring up the arena: this sizes everything that is indexed by cid
299        // and the per-task contexts. It has to happen before the scheduler is
300        // visible to the kernel, so it sits between load and attach.
301        //
302        // The cid space is num_possible_cpus() wide, so the CPU count is all
303        // the BPF side needs to size itself.
304        let nr_cpus = (*NR_CPU_IDS).max(*NR_CPUS_POSSIBLE);
305        let mut args = types::cidland_arena_args {
306            nr_cpus: nr_cpus as u64,
307        };
308        run_syscall_prog(&skel.progs.cidland_arena_init, &mut args)
309            .context("running cidland_arena_init")?;
310
311        // Hand over the primary domain a word at a time, so that nothing on
312        // either side has to cap the number of CPUs.
313        let mut words = vec![0u64; nr_cpus.div_ceil(64)];
314        for cpu in &primary_cpus {
315            words[cpu / 64] |= 1u64 << (cpu % 64);
316        }
317        for (idx, word) in words.iter().enumerate() {
318            if *word == 0 {
319                continue;
320            }
321            let mut args = types::cidland_primary_args {
322                idx: idx as u64,
323                word: *word,
324            };
325            run_syscall_prog(&skel.progs.cidland_set_primary_word, &mut args)
326                .context("running cidland_set_primary_word")?;
327        }
328
329        // The BPF side has a scheduler-specific initialization path, but the
330        // allocator still needs ArenaLib's userspace services. In particular,
331        // scx_task_free_rcu() relies on its reclaim daemon to return exited
332        // task contexts to the allocator.
333        let arenalib =
334            ArenaLib::start(skel.object_mut()).context("starting arena userspace services")?;
335
336        let struct_ops = Some(scx_ops_attach!(skel, cidland_ops).context("attaching scheduler")?);
337        let stats_server = StatsServer::new(stats::server_data()).launch()?;
338
339        Ok(Self {
340            _arenalib: arenalib,
341            skel,
342            struct_ops,
343            stats_server,
344        })
345    }
346
347    fn get_metrics(&self) -> Metrics {
348        let bss_data = self
349            .skel
350            .maps
351            .bss_data
352            .as_ref()
353            .expect("bss_data missing after skel load");
354        Metrics {
355            nr_direct_dispatches: bss_data.nr_direct_dispatches,
356            nr_shared_enqueues: bss_data.nr_shared_enqueues,
357            nr_idle_kicks: bss_data.nr_idle_kicks,
358            nr_local_llc: bss_data.nr_local_llc,
359            nr_remote_llc: bss_data.nr_remote_llc,
360        }
361    }
362
363    fn exited(&mut self) -> bool {
364        uei_exited!(&self.skel, uei)
365    }
366
367    fn run(&mut self, shutdown: Arc<AtomicBool>) -> Result<UserExitInfo> {
368        let (res_ch, req_ch) = self.stats_server.channels();
369        while !shutdown.load(Ordering::Relaxed) && !self.exited() {
370            match req_ch.recv_timeout(Duration::from_secs(1)) {
371                Ok(()) => res_ch.send(self.get_metrics())?,
372                Err(RecvTimeoutError::Timeout) => {}
373                Err(e) => Err(e)?,
374            }
375        }
376
377        let _ = self.struct_ops.take();
378        uei_report!(&self.skel, uei)
379    }
380}
381
382impl Drop for Scheduler<'_> {
383    fn drop(&mut self) {
384        info!("Unregister {SCHEDULER_NAME} scheduler");
385    }
386}
387
388fn main() -> Result<()> {
389    let opts = Opts::parse();
390
391    if opts.version {
392        println!(
393            "{} {}",
394            SCHEDULER_NAME,
395            build_id::full_version(env!("CARGO_PKG_VERSION"))
396        );
397        return Ok(());
398    }
399
400    if opts.help_stats {
401        stats::server_data().describe_meta(&mut std::io::stdout(), None)?;
402        return Ok(());
403    }
404
405    let loglevel = simplelog::LevelFilter::Info;
406
407    let mut lcfg = simplelog::ConfigBuilder::new();
408    lcfg.set_time_offset_to_local()
409        .expect("Failed to set local time offset")
410        .set_time_level(simplelog::LevelFilter::Error)
411        .set_location_level(simplelog::LevelFilter::Off)
412        .set_target_level(simplelog::LevelFilter::Off)
413        .set_thread_level(simplelog::LevelFilter::Off);
414    simplelog::TermLogger::init(
415        loglevel,
416        lcfg.build(),
417        simplelog::TerminalMode::Stderr,
418        simplelog::ColorChoice::Auto,
419    )?;
420
421    let shutdown = Arc::new(AtomicBool::new(false));
422    let shutdown_clone = shutdown.clone();
423    ctrlc::set_handler(move || {
424        shutdown_clone.store(true, Ordering::Relaxed);
425    })
426    .context("Error setting Ctrl-C handler")?;
427
428    if let Some(intv) = opts.monitor.or(opts.stats) {
429        let shutdown_copy = shutdown.clone();
430        let jh = std::thread::spawn(move || {
431            match stats::monitor(Duration::from_secs_f64(intv), shutdown_copy) {
432                Ok(_) => debug!("stats monitor thread finished successfully"),
433                Err(error_object) => {
434                    warn!("stats monitor thread finished because of an error {error_object}")
435                }
436            }
437        });
438        if opts.monitor.is_some() {
439            let _ = jh.join();
440            return Ok(());
441        }
442    }
443
444    let mut open_object = MaybeUninit::uninit();
445    loop {
446        let mut sched = Scheduler::init(&opts, &mut open_object)?;
447        if !sched.run(shutdown.clone())?.should_restart() {
448            break;
449        }
450    }
451
452    Ok(())
453}