Skip to main content

scx_utils/
topology.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//! # SCX Topology
7//!
8//! A crate that allows schedulers to inspect and model the host's topology, in
9//! service of creating scheduling domains.
10//!
11//! A Topology is comprised of one or more Node objects, which themselves are
12//! comprised hierarchically of LLC -> Core -> Cpu objects respectively:
13//!```rust,ignore
14//!                                   Topology
15//!                                       |
16//! o--------------------------------o   ...   o----------------o---------------o
17//! |         Node                   |         |         Node                   |
18//! | ID      0                      |         | ID      1                      |
19//! | LLCs    <id, Llc>              |         | LLCs    <id, Llc>              |
20//! | Span    0x00000fffff00000fffff |         | Span    0xfffff00000fffff00000 |
21//! o--------------------------------o         o--------------------------------o
22//!                 \
23//!                  --------------------
24//!                                      \
25//! o--------------------------------o   ...   o--------------------------------o
26//! |             Llc                |         |             Llc                |
27//! | ID     0                       |         | ID     1                       |
28//! | Cores  <id, Core>              |         | Cores  <id, Core>              |
29//! | Span   0x00000ffc0000000ffc00  |         | Span   0x00000003ff00000003ff  |
30//! o--------------------------------o         o----------------o---------------o
31//!                                                             /
32//!                                        ---------------------
33//!                                       /
34//! o--------------------------------o   ...   o--------------------------------o
35//! |              Core              |         |              Core              |
36//! | ID     0                       |         | ID     9                       |
37//! | Cpus   <id, Cpu>               |         | Cpus   <id, Cpu>               |
38//! | Span   0x00000000010000000001  |         | Span   0x00000002000000000200  |
39//! o--------------------------------o         o----------------o---------------o
40//!                                                             /
41//!                                        ---------------------
42//!                                       /
43//! o--------------------------------o   ...   o---------------------------------o
44//! |              Cpu               |         |               Cpu               |
45//! | ID       9                     |         | ID       49                     |
46//! | online   1                     |         | online   1                      |
47//! | min_freq 400000                |         | min_freq 400000                 |
48//! | max_freq 5881000               |         | min_freq 5881000                |
49//! o--------------------------------o         o---------------------------------o
50//!```
51//! Every object contains a Cpumask that spans all CPUs in that point in the
52//! topological hierarchy.
53//!
54//! Creating Topology
55//! -----------------
56//!
57//! Topology objects are created using the static new function:
58//!
59//!```  
60//!     use scx_utils::Topology;
61//!     let top = Topology::new().unwrap();
62//!```
63//!
64//! Querying Topology
65//! -----------------
66//!
67//! With a created Topology, you can query the topological hierarchy using the
68//! set of accessor functions defined below. All objects in the topological
69//! hierarchy are entirely read-only. If the host topology were to change (due
70//! to e.g. hotplug), a new Topology object should be created.
71
72use crate::Cpumask;
73use crate::compat::ROOT_PREFIX;
74use crate::cpumask::read_cpulist;
75use crate::misc::find_best_split_size;
76use crate::misc::read_file_byte;
77use crate::misc::read_file_usize_vec;
78use crate::misc::read_from_file;
79use anyhow::Result;
80use anyhow::bail;
81use glob::glob;
82use log::debug;
83use log::info;
84use log::warn;
85use sscanf::sscanf;
86use std::cmp::min;
87use std::collections::{BTreeMap, BTreeSet};
88use std::io::Write;
89use std::path::Path;
90use std::sync::{Arc, OnceLock};
91
92#[cfg(feature = "gpu-topology")]
93use crate::gpu::{Gpu, GpuIndex, create_gpus};
94
95lazy_static::lazy_static! {
96    /// The maximum possible number of CPU IDs in the system. As mentioned
97    /// above, this is different than the number of possible CPUs on the
98    /// system (though very seldom is). This number may differ from the
99    /// number of possible CPUs on the system when e.g. there are fully
100    /// disabled CPUs in the middle of the range of possible CPUs (i.e. CPUs
101    /// that may not be onlined).
102    pub static ref NR_CPU_IDS: usize = read_cpu_ids().unwrap().last().unwrap() + 1;
103
104    /// The number of possible CPUs that may be active on the system. Note
105    /// that this value is separate from the number of possible _CPU IDs_ in
106    /// the system, as there may be gaps in what CPUs are allowed to be
107    /// onlined. For example, some BIOS implementations may report spans of
108    /// disabled CPUs that may not be onlined, whose IDs are lower than the
109    /// IDs of other CPUs that may be onlined.
110    pub static ref NR_CPUS_POSSIBLE: usize = libbpf_rs::num_possible_cpus().unwrap();
111
112    /// The range to search for when finding the number of physical cores
113    /// assigned to a partition to split a large number of cores that share
114    /// an LLC domain. The suggested split for the cores isn't a function of
115    /// the underlying hardware's capability, but rather some sane number
116    /// to help determine the number of CPUs that share the same DSQ.
117    pub static ref NR_PARTITION_MIN_CORES: usize = 2;
118    pub static ref NR_PARTITION_MAX_CORES: usize = 8;
119}
120
121#[derive(Debug, Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
122pub enum CoreType {
123    Big { turbo: bool },
124    Little,
125}
126
127#[derive(Debug, Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
128pub enum Powermode {
129    Turbo,
130    Performance,
131    Powersave,
132    Any,
133}
134
135/// Return the list of CPU IDs matching the requested power mode.
136///
137/// Selects CPUs from the system topology based on [`Powermode`]:
138/// - [`Powermode::Turbo`]: only turbo-capable big cores
139/// - [`Powermode::Performance`]: all big cores
140/// - [`Powermode::Powersave`]: only little cores
141/// - [`Powermode::Any`]: all CPUs
142pub fn get_primary_cpus(mode: Powermode) -> std::io::Result<Vec<usize>> {
143    let topo = Topology::new().unwrap();
144
145    let cpus: Vec<usize> = topo
146        .all_cores
147        .values()
148        .flat_map(|core| &core.cpus)
149        .filter_map(|(cpu_id, cpu)| match (&mode, &cpu.core_type) {
150            (Powermode::Turbo, CoreType::Big { turbo: true })
151            | (Powermode::Performance, CoreType::Big { .. })
152            | (Powermode::Powersave, CoreType::Little) => Some(*cpu_id),
153            (Powermode::Any, ..) => Some(*cpu_id),
154            _ => None,
155        })
156        .collect();
157
158    Ok(cpus)
159}
160
161#[derive(Debug, Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
162pub struct Cpu {
163    pub id: usize,
164    pub min_freq: usize,
165    pub max_freq: usize,
166    /// Base operational frqeuency. Only available on Intel Turbo Boost
167    /// CPUs. If not available, this will simply return maximum frequency.
168    pub base_freq: usize,
169    /// The best-effort guessing of cpu_capacity scaled to 1024.
170    pub cpu_capacity: usize,
171    /// The kernel scheduler's exact topology capacity, when exported by sysfs.
172    ///
173    /// Unlike [`Cpu::cpu_capacity`], this is never inferred from CPPC or CPU
174    /// frequency data. It is read only from cpuX/cpu_capacity and corresponds
175    /// to topology_get_cpu_scale().
176    pub kernel_cpu_capacity: Option<usize>,
177    pub smt_level: usize,
178    /// CPU idle resume latency
179    pub pm_qos_resume_latency_us: usize,
180    pub trans_lat_ns: usize,
181    pub l2_id: usize,
182    pub l3_id: usize,
183    /// Per-CPU cache size of all levels.
184    pub cache_size: usize,
185    pub core_type: CoreType,
186
187    /// Ancestor IDs.
188    pub core_id: usize,
189    pub llc_id: usize,
190    pub node_id: usize,
191    pub package_id: usize,
192    pub cluster_id: isize,
193}
194
195#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
196pub struct Core {
197    /// Monotonically increasing unique id
198    pub id: usize,
199    /// The sysfs value of core_id
200    pub kernel_id: usize,
201    pub cluster_id: isize,
202    pub cpus: BTreeMap<usize, Arc<Cpu>>,
203    /// Cpumask of all CPUs in this core.
204    pub span: Cpumask,
205    pub core_type: CoreType,
206
207    /// Ancestor IDs.
208    pub llc_id: usize,
209    pub node_id: usize,
210}
211
212#[derive(Debug, Clone)]
213pub struct Llc {
214    /// Monotonically increasing unique id
215    pub id: usize,
216    /// The kernel id of the llc
217    pub kernel_id: usize,
218    pub cores: BTreeMap<usize, Arc<Core>>,
219    /// Cpumask of all CPUs in this llc.
220    pub span: Cpumask,
221
222    /// Ancestor IDs.
223    pub node_id: usize,
224
225    /// Skip indices to access lower level members easily.
226    pub all_cpus: BTreeMap<usize, Arc<Cpu>>,
227}
228
229#[derive(Debug, Clone)]
230pub struct Node {
231    pub id: usize,
232    pub distance: Vec<usize>,
233    pub llcs: BTreeMap<usize, Arc<Llc>>,
234    /// Cpumask of all CPUs in this node.
235    pub span: Cpumask,
236
237    /// Skip indices to access lower level members easily.
238    pub all_cores: BTreeMap<usize, Arc<Core>>,
239    pub all_cpus: BTreeMap<usize, Arc<Cpu>>,
240
241    #[cfg(feature = "gpu-topology")]
242    pub gpus: BTreeMap<GpuIndex, Gpu>,
243}
244
245/// Source used to reconstruct scheduler-domain policy.
246#[derive(Debug, Clone, Copy, Eq, PartialEq)]
247pub enum SchedDomainSource {
248    /// Live scheduler-domain masks read from `/proc/schedstat`.
249    Schedstat,
250    /// Portable approximation built from sysfs topology.
251    Topology,
252}
253
254/// Scheduler-domain policy needed by userspace schedulers.
255#[derive(Debug, Clone, Copy, Eq, PartialEq)]
256pub struct SchedDomainInfo {
257    /// Size of the highest domain on which fork balancing is enabled.
258    pub fork_span: usize,
259    /// Size of the highest domain on which wake-affine is enabled.
260    pub wake_affine_span: usize,
261    /// Size of the lowest domain spanning every CPU-capacity class.
262    pub asym_capacity_span: usize,
263    /// Source used to reconstruct the domains.
264    pub source: SchedDomainSource,
265}
266
267const SCHEDSTAT_VERSION: usize = 17;
268const NUMA_RECLAIM_DISTANCE: usize = 30;
269
270#[derive(Debug)]
271pub struct Topology {
272    pub nodes: BTreeMap<usize, Node>,
273    /// Cpumask all CPUs in the system.
274    pub span: Cpumask,
275    /// True if SMT is enabled in the system, false otherwise.
276    pub smt_enabled: bool,
277
278    /// Skip indices to access lower level members easily.
279    pub all_llcs: BTreeMap<usize, Arc<Llc>>,
280    pub all_cores: BTreeMap<usize, Arc<Core>>,
281    pub all_cpus: BTreeMap<usize, Arc<Cpu>>,
282
283    // `/proc/schedstat` does not expose sched_domain::flags, but its masks are
284    // still useful when the statistics static key is disabled. Policy is
285    // reconstructed from these live masks and the same topology inputs used
286    // by the kernel. If schedstat is unavailable, topology masks are used.
287    sched_domains: OnceLock<Option<BTreeMap<usize, Vec<Cpumask>>>>,
288}
289
290impl Topology {
291    fn instantiate(span: Cpumask, mut nodes: BTreeMap<usize, Node>) -> Result<Self> {
292        // Build skip indices prefixed with all_ for easy lookups. As Arc
293        // objects can only be modified while there's only one reference,
294        // skip indices must be built from bottom to top.
295        let mut topo_llcs = BTreeMap::new();
296        let mut topo_cores = BTreeMap::new();
297        let mut topo_cpus = BTreeMap::new();
298
299        for node in nodes.values_mut() {
300            let mut node_cores = BTreeMap::new();
301            let mut node_cpus = BTreeMap::new();
302
303            for (&llc_id, llc) in node.llcs.iter_mut() {
304                let llc_mut = Arc::get_mut(llc).unwrap();
305                let mut llc_cpus = BTreeMap::new();
306
307                for (&core_id, core) in llc_mut.cores.iter_mut() {
308                    let core_mut = Arc::get_mut(core).unwrap();
309                    let smt_level = core_mut.cpus.len();
310
311                    for (&cpu_id, cpu) in core_mut.cpus.iter_mut() {
312                        let cpu_mut = Arc::get_mut(cpu).unwrap();
313                        cpu_mut.smt_level = smt_level;
314
315                        if topo_cpus
316                            .insert(cpu_id, cpu.clone())
317                            .or(node_cpus.insert(cpu_id, cpu.clone()))
318                            .or(llc_cpus.insert(cpu_id, cpu.clone()))
319                            .is_some()
320                        {
321                            bail!("Duplicate CPU ID {}", cpu_id);
322                        }
323                    }
324
325                    // Note that in some weird architectures, core ids can be
326                    // duplicated in different LLC domains.
327                    topo_cores
328                        .insert(core_id, core.clone())
329                        .or(node_cores.insert(core_id, core.clone()));
330                }
331
332                llc_mut.all_cpus = llc_cpus;
333
334                if topo_llcs.insert(llc_id, llc.clone()).is_some() {
335                    bail!("Duplicate LLC ID {}", llc_id);
336                }
337            }
338
339            node.all_cores = node_cores;
340            node.all_cpus = node_cpus;
341        }
342
343        Ok(Topology {
344            nodes,
345            span,
346            smt_enabled: is_smt_active().unwrap_or(false),
347            all_llcs: topo_llcs,
348            all_cores: topo_cores,
349            all_cpus: topo_cpus,
350            sched_domains: OnceLock::new(),
351        })
352    }
353
354    fn sched_domains(&self) -> Option<&BTreeMap<usize, Vec<Cpumask>>> {
355        self.sched_domains
356            .get_or_init(|| {
357                let path = format!("{}/proc/schedstat", *ROOT_PREFIX);
358                match std::fs::read_to_string(&path).and_then(|data| {
359                    parse_schedstat(&data).map_err(|err| std::io::Error::other(err.to_string()))
360                }) {
361                    Ok(domains)
362                        if self
363                            .all_cpus
364                            .keys()
365                            .all(|cpu| domains.get(cpu).is_some_and(|spans| !spans.is_empty())) =>
366                    {
367                        Some(domains)
368                    }
369                    Ok(_) => {
370                        debug!("{path} has incomplete scheduler domains; using topology fallback");
371                        None
372                    }
373                    Err(err) => {
374                        debug!("cannot use {path}: {err}; using topology fallback");
375                        None
376                    }
377                }
378            })
379            .as_ref()
380    }
381
382    /// Build a complete host Topology
383    pub fn new() -> Result<Topology> {
384        Self::with_virt_llcs(None)
385    }
386
387    pub fn with_virt_llcs(nr_cores_per_vllc: Option<(usize, usize)>) -> Result<Topology> {
388        let span = cpus_online()?;
389        let mut topo_ctx = TopoCtx::new();
390
391        // If the kernel is compiled with CONFIG_NUMA, then build a topology
392        // from the NUMA hierarchy in sysfs. Otherwise, just make a single
393        // default node of ID 0 which contains all cores.
394        let path = format!("{}/sys/devices/system/node", *ROOT_PREFIX);
395        let nodes = if Path::new(&path).exists() {
396            create_numa_nodes(&span, &mut topo_ctx, nr_cores_per_vllc)?
397        } else {
398            create_default_node(&span, &mut topo_ctx, false, nr_cores_per_vllc)?
399        };
400
401        Self::instantiate(span, nodes)
402    }
403
404    pub fn with_flattened_llc_node() -> Result<Topology> {
405        let span = cpus_online()?;
406        let mut topo_ctx = TopoCtx::new();
407        let nodes = create_default_node(&span, &mut topo_ctx, true, None)?;
408        Self::instantiate(span, nodes)
409    }
410
411    /// Build a topology with configuration from CLI arguments.
412    /// This method integrates with the TopologyArgs from the cli module to
413    /// create a topology based on command line parameters.
414    pub fn with_args(topology_args: &crate::cli::TopologyArgs) -> Result<Topology> {
415        // Validate the CLI arguments first
416        topology_args.validate()?;
417
418        // Get the virtual LLC configuration
419        let nr_cores_per_vllc = topology_args.get_nr_cores_per_vllc();
420
421        // Build topology with the specified configuration
422        Self::with_virt_llcs(nr_cores_per_vllc)
423    }
424
425    /// Get a vec of all GPUs on the hosts.
426    #[cfg(feature = "gpu-topology")]
427    pub fn gpus(&self) -> BTreeMap<GpuIndex, &Gpu> {
428        let mut gpus = BTreeMap::new();
429        for node in self.nodes.values() {
430            for (idx, gpu) in &node.gpus {
431                gpus.insert(*idx, gpu);
432            }
433        }
434        gpus
435    }
436
437    /// Returns whether the Topology has a hybrid architecture of big and little cores.
438    pub fn has_little_cores(&self) -> bool {
439        self.all_cores
440            .values()
441            .any(|c| c.core_type == CoreType::Little)
442    }
443
444    /// Returns a vector that maps the index of each logical CPU to the
445    /// sibling CPU. This represents the "next sibling" CPU within a package
446    /// in systems that support SMT. The sibling CPU is the other logical
447    /// CPU that shares the physical resources of the same physical core.
448    ///
449    /// Assuming each core holds exactly at most two cpus.
450    pub fn sibling_cpus(&self) -> Vec<i32> {
451        let mut sibling_cpu = vec![-1i32; *NR_CPUS_POSSIBLE];
452        for core in self.all_cores.values() {
453            let mut first = -1i32;
454            for &cpu in core.cpus.keys() {
455                if first < 0 {
456                    first = cpu as i32;
457                } else {
458                    sibling_cpu[first as usize] = cpu as i32;
459                    sibling_cpu[cpu] = first;
460                    break;
461                }
462            }
463        }
464        sibling_cpu
465    }
466
467    /// Count how many physical cores have at least one CPU set in the cpumask.
468    pub fn cpumask_nr_cores(&self, cpumask: &Cpumask) -> usize {
469        let mut count = 0;
470        for core in self.all_cores.values() {
471            if core.cpus.keys().any(|&cpu_id| cpumask.test_cpu(cpu_id)) {
472                count += 1;
473            }
474        }
475        count
476    }
477
478    /// Reconstruct scheduler-domain policy for `cpu_id`.
479    ///
480    /// Linux does not expose scheduler-domain flags through a stable ABI.
481    /// When schedstat v17 is available, use its live domain masks and apply
482    /// the kernel's NUMA-reclaim and capacity-class rules. The counters may
483    /// all be zero when `kernel.sched_schedstats=0`; that does not affect the
484    /// masks consumed here. If schedstat is absent or incompatible, construct
485    /// portable core, LLC, NUMA-node and system masks from sysfs instead.
486    pub fn sched_domain_info(&self, cpu_id: usize) -> Result<SchedDomainInfo> {
487        let cpu = self
488            .all_cpus
489            .get(&cpu_id)
490            .ok_or_else(|| anyhow::anyhow!("CPU {cpu_id} is not in the topology"))?;
491
492        let (mut domains, source) = match self.sched_domains().and_then(|all| all.get(&cpu_id)) {
493            Some(domains) => (domains.clone(), SchedDomainSource::Schedstat),
494            None => {
495                let mut domains = vec![
496                    self.all_cores[&cpu.core_id].span.clone(),
497                    self.all_llcs[&cpu.llc_id].span.clone(),
498                    self.nodes[&cpu.node_id].span.clone(),
499                    self.span.clone(),
500                ];
501                domains.sort_by_key(Cpumask::weight);
502                domains.dedup();
503                (domains, SchedDomainSource::Topology)
504            }
505        };
506        domains = domains
507            .into_iter()
508            .map(|span| span.and(&self.span))
509            .filter(|span| span.test_cpu(cpu_id))
510            .collect();
511        domains.sort_by_key(Cpumask::weight);
512        domains.dedup();
513
514        let balance_span = domains
515            .iter()
516            .filter(|span| self.within_numa_reclaim_distance(cpu.node_id, span))
517            .map(Cpumask::weight)
518            .max()
519            .unwrap_or(1);
520
521        let capacities: Option<BTreeMap<_, _>> = self
522            .all_cpus
523            .iter()
524            .map(|(&id, cpu)| Some((id, cpu.kernel_cpu_capacity?)))
525            .collect();
526        let asym_capacity_span = capacities
527            .as_ref()
528            .map(|capacities| smallest_full_capacity_span(&domains, capacities))
529            .unwrap_or(0);
530
531        Ok(SchedDomainInfo {
532            fork_span: balance_span,
533            wake_affine_span: balance_span,
534            asym_capacity_span,
535            source,
536        })
537    }
538
539    fn within_numa_reclaim_distance(&self, source_node: usize, span: &Cpumask) -> bool {
540        let Some(source) = self.nodes.get(&source_node) else {
541            return false;
542        };
543
544        span.iter().all(|cpu_id| {
545            self.all_cpus
546                .get(&cpu_id)
547                .and_then(|cpu| source.distance.get(cpu.node_id))
548                .is_some_and(|&distance| distance <= NUMA_RECLAIM_DISTANCE)
549        })
550    }
551
552    /// Format a cpumask as a topology-aware visual grid.
553    ///
554    /// Each physical core is represented by a single character:
555    /// - `░` = no CPUs set
556    /// - `▀` = first HT only (top half)
557    /// - `▄` = second HT only (bottom half)
558    /// - `█` = both HTs (or all HTs for >2-way SMT)
559    ///
560    /// Cores within an LLC are split into evenly-sized groups of at
561    /// most 8 with spaces. LLCs are separated by `|`. Wrapping
562    /// happens at LLC boundaries. One line per NUMA node (may wrap).
563    pub fn format_cpumask_grid<W: Write>(
564        &self,
565        w: &mut W,
566        cpumask: &Cpumask,
567        indent: &str,
568        max_width: usize,
569    ) -> Result<()> {
570        for node in self.nodes.values() {
571            // Build the core characters for each LLC in this node.
572            // Within each LLC, cores are grouped by 4 with spaces.
573            let mut llc_segments: Vec<(usize, String)> = Vec::new();
574
575            for llc in node.llcs.values() {
576                let mut seg = String::new();
577                let nr_cores = llc.cores.len();
578                let nr_groups = nr_cores.div_ceil(8);
579                let base = nr_cores / nr_groups;
580                let rem = nr_cores % nr_groups;
581                // First `rem` groups get base+1, rest get base
582                let mut next_break = if rem > 0 { base + 1 } else { base };
583                let mut group_idx = 0;
584                for (i, core) in llc.cores.values().enumerate() {
585                    if i > 0 && i == next_break {
586                        seg.push(' ');
587                        group_idx += 1;
588                        next_break += if group_idx < rem { base + 1 } else { base };
589                    }
590                    let nr_cpus = core.cpus.len();
591                    let cpu_ids: Vec<usize> = core.cpus.keys().copied().collect();
592                    let nr_set: usize = cpu_ids.iter().filter(|&&c| cpumask.test_cpu(c)).count();
593
594                    let ch = if nr_cpus == 1 {
595                        if nr_set > 0 { '█' } else { '░' }
596                    } else if nr_cpus == 2 {
597                        let first_set = cpumask.test_cpu(cpu_ids[0]);
598                        let second_set = cpumask.test_cpu(cpu_ids[1]);
599                        match (first_set, second_set) {
600                            (false, false) => '░',
601                            (true, false) => '▀',
602                            (false, true) => '▄',
603                            (true, true) => '█',
604                        }
605                    } else {
606                        // >2 HTs (e.g. 4-way SMT)
607                        if nr_set == 0 {
608                            '░'
609                        } else if nr_set == nr_cpus {
610                            '█'
611                        } else {
612                            '▄'
613                        }
614                    };
615                    seg.push(ch);
616                }
617                llc_segments.push((llc.id, seg));
618            }
619
620            if llc_segments.is_empty() {
621                continue;
622            }
623
624            // Build prefix: "N{id} L{first_llc}: "
625            let first_llc_id = llc_segments[0].0;
626            let prefix = format!("{}N{} L{:02}: ", indent, node.id, first_llc_id);
627            let prefix_width = prefix.chars().count();
628            let cont_indent = format!(
629                "{}{}",
630                indent,
631                " ".repeat(prefix_width - indent.chars().count())
632            );
633
634            // Join LLCs with "|", wrapping at LLC boundaries
635            let mut line = prefix.clone();
636            let mut first_llc = true;
637
638            for (_, seg) in &llc_segments {
639                let seg_width = seg.chars().count();
640                let separator = if first_llc { "" } else { "|" };
641                let sep_width = separator.chars().count();
642                let current_line_width = line.chars().count();
643
644                if !first_llc && current_line_width + sep_width + seg_width > max_width {
645                    writeln!(w, "{}", line)?;
646                    line = format!("{}{}", cont_indent, seg);
647                } else {
648                    line = format!("{}{}{}", line, separator, seg);
649                }
650                first_llc = false;
651            }
652            writeln!(w, "{}", line)?;
653        }
654        Ok(())
655    }
656
657    /// Format a cpumask header line with cpu count, core count, and range.
658    pub fn format_cpumask_header(&self, cpumask: &Cpumask, min_cpus: u32, max_cpus: u32) -> String {
659        let nr_cpus = cpumask.weight();
660        let nr_cores = self.cpumask_nr_cores(cpumask);
661        format!(
662            "cpus={:3}({:3}c) [{:3},{:3}]",
663            nr_cpus, nr_cores, min_cpus, max_cpus
664        )
665    }
666}
667
668fn parse_schedstat(data: &str) -> Result<BTreeMap<usize, Vec<Cpumask>>> {
669    let mut lines = data.lines();
670    let version = lines
671        .next()
672        .and_then(|line| line.strip_prefix("version "))
673        .ok_or_else(|| anyhow::anyhow!("missing schedstat version"))?
674        .parse::<usize>()?;
675    if version != SCHEDSTAT_VERSION {
676        bail!("unsupported schedstat version {version}");
677    }
678
679    let mut domains = BTreeMap::<usize, Vec<Cpumask>>::new();
680    let mut current_cpu = None;
681    for line in lines {
682        let mut fields = line.split_ascii_whitespace();
683        let Some(kind) = fields.next() else {
684            continue;
685        };
686        if let Some(cpu) = kind.strip_prefix("cpu") {
687            let cpu = cpu.parse::<usize>()?;
688            domains.entry(cpu).or_default();
689            current_cpu = Some(cpu);
690            continue;
691        }
692        if !kind.starts_with("domain") {
693            continue;
694        }
695
696        let cpu = current_cpu.ok_or_else(|| anyhow::anyhow!("domain before CPU record"))?;
697        let _name = fields
698            .next()
699            .ok_or_else(|| anyhow::anyhow!("missing scheduler-domain name"))?;
700        let mask = fields
701            .next()
702            .ok_or_else(|| anyhow::anyhow!("missing scheduler-domain mask"))?
703            .replace(',', "");
704        domains
705            .entry(cpu)
706            .or_default()
707            .push(Cpumask::from_str(&mask)?);
708    }
709
710    for spans in domains.values_mut() {
711        spans.sort_by_key(Cpumask::weight);
712        spans.dedup();
713    }
714    Ok(domains)
715}
716
717fn smallest_full_capacity_span(domains: &[Cpumask], capacities: &BTreeMap<usize, usize>) -> usize {
718    let all = capacities.values().copied().collect::<BTreeSet<_>>();
719    if all.len() <= 1 {
720        return 0;
721    }
722
723    domains
724        .iter()
725        .find(|span| {
726            span.iter()
727                .filter_map(|cpu| capacities.get(&cpu).copied())
728                .collect::<BTreeSet<_>>()
729                == all
730        })
731        .map(Cpumask::weight)
732        .unwrap_or(0)
733}
734
735/******************************************************
736 * Helper structs/functions for creating the Topology *
737 ******************************************************/
738/// TopoCtx is a helper struct used to build a topology.
739struct TopoCtx {
740    /// Mapping of NUMA node core ids
741    node_core_kernel_ids: BTreeMap<(usize, usize, usize), usize>,
742    /// Mapping of NUMA node LLC ids
743    node_llc_kernel_ids: BTreeMap<(usize, usize, usize), usize>,
744    /// Mapping of L2 ids
745    l2_ids: BTreeMap<String, usize>,
746    /// Mapping of L3 ids
747    l3_ids: BTreeMap<String, usize>,
748}
749
750impl TopoCtx {
751    fn new() -> TopoCtx {
752        let core_kernel_ids = BTreeMap::new();
753        let llc_kernel_ids = BTreeMap::new();
754        let l2_ids = BTreeMap::new();
755        let l3_ids = BTreeMap::new();
756        TopoCtx {
757            node_core_kernel_ids: core_kernel_ids,
758            node_llc_kernel_ids: llc_kernel_ids,
759            l2_ids,
760            l3_ids,
761        }
762    }
763}
764
765fn cpus_online() -> Result<Cpumask> {
766    let path = format!("{}/sys/devices/system/cpu/online", *ROOT_PREFIX);
767    let online = std::fs::read_to_string(path)?;
768    Cpumask::from_cpulist(&online)
769}
770
771fn get_cache_id(topo_ctx: &mut TopoCtx, cache_level_path: &Path, cache_level: usize) -> usize {
772    // Check if the cache id is already cached
773    let id_map = match cache_level {
774        2 => &mut topo_ctx.l2_ids,
775        3 => &mut topo_ctx.l3_ids,
776        _ => return usize::MAX,
777    };
778
779    let path = &cache_level_path.join("shared_cpu_list");
780    let key = match std::fs::read_to_string(path) {
781        Ok(key) => key,
782        Err(_) => return usize::MAX,
783    };
784
785    let id = *id_map.get(&key).unwrap_or(&usize::MAX);
786    if id != usize::MAX {
787        return id;
788    }
789
790    // In case of a cache miss, try to get the id from the sysfs first.
791    let id = read_from_file(&cache_level_path.join("id")).unwrap_or(usize::MAX);
792    if id != usize::MAX {
793        // Keep the id in the map
794        id_map.insert(key, id);
795        return id;
796    }
797
798    // If the id file does not exist, assign an id and keep it in the map.
799    let id = id_map.len();
800    id_map.insert(key, id);
801
802    id
803}
804
805fn get_per_cpu_cache_size(cache_path: &Path) -> Result<usize> {
806    let path_str = cache_path.to_str().unwrap();
807    let paths = glob(&(path_str.to_owned() + "/index[0-9]*"))?;
808    let mut tot_size = 0;
809
810    for index in paths.filter_map(Result::ok) {
811        // If there is no size information under sysfs (e.g., many ARM SoCs),
812        // give 1024 as a default value. 1024 is small enough compared to the
813        // real cache size of the CPU, but it is large enough to give a penalty
814        // when multiple CPUs share the cache.
815        let size = read_file_byte(&index.join("size")).unwrap_or(1024_usize);
816        let cpulist: String = read_from_file(&index.join("shared_cpu_list"))?;
817        let num_cpus = read_cpulist(&cpulist)?.len();
818        tot_size += size / num_cpus;
819    }
820
821    Ok(tot_size)
822}
823
824#[allow(clippy::too_many_arguments)]
825fn create_insert_cpu(
826    id: usize,
827    node: &mut Node,
828    online_mask: &Cpumask,
829    topo_ctx: &mut TopoCtx,
830    cs: &CapacitySource,
831    flatten_llc: bool,
832) -> Result<()> {
833    // CPU is offline. The Topology hierarchy is read-only, and assumes
834    // that hotplug will cause the scheduler to restart. Thus, we can
835    // just skip this CPU altogether.
836    if !online_mask.test_cpu(id) {
837        return Ok(());
838    }
839
840    let cpu_str = format!("{}/sys/devices/system/cpu/cpu{}", *ROOT_PREFIX, id);
841    let cpu_path = Path::new(&cpu_str);
842
843    // Physical core ID
844    let top_path = cpu_path.join("topology");
845    let core_kernel_id = read_from_file(&top_path.join("core_id"))?;
846    let package_id = read_from_file(&top_path.join("physical_package_id"))?;
847    let cluster_id = read_from_file(&top_path.join("cluster_id"))?;
848
849    // Evaluate L2, L3 and LLC cache IDs.
850    //
851    // Use ID 0 if we fail to detect the cache hierarchy. This seems to happen on certain SKUs, so
852    // if there's no cache information then we have no option but to assume a single unified cache
853    // per node.
854    let cache_path = cpu_path.join("cache");
855    let l2_id = get_cache_id(topo_ctx, &cache_path.join(format!("index{}", 2)), 2);
856    let l3_id = get_cache_id(topo_ctx, &cache_path.join(format!("index{}", 3)), 3);
857    let llc_kernel_id = if flatten_llc {
858        0
859    } else if l3_id == usize::MAX {
860        l2_id
861    } else {
862        l3_id
863    };
864
865    // Per-CPU cache size
866    let cache_size = get_per_cpu_cache_size(&cache_path).unwrap_or(0_usize);
867
868    // Min and max frequencies. If the kernel is not compiled with
869    // CONFIG_CPU_FREQ, just assume 0 for both frequencies.
870    let freq_path = cpu_path.join("cpufreq");
871    let min_freq = read_from_file(&freq_path.join("scaling_min_freq")).unwrap_or(0_usize);
872    let max_freq = read_from_file(&freq_path.join("scaling_max_freq")).unwrap_or(0_usize);
873    let base_freq = read_from_file(&freq_path.join("base_frequency")).unwrap_or(max_freq);
874    let trans_lat_ns =
875        read_from_file(&freq_path.join("cpuinfo_transition_latency")).unwrap_or(0_usize);
876
877    // CPU capacity. Keep the kernel scheduler's exported value separate from
878    // the best-effort estimate, whose source may instead be CPPC or cpufreq.
879    let kernel_cpu_capacity = read_from_file(&cpu_path.join("cpu_capacity"))
880        .ok()
881        .filter(|capacity| *capacity > 0);
882    let cap_path = cpu_path.join(cs.suffix.clone());
883    let rcap = read_from_file(&cap_path).unwrap_or(cs.max_rcap);
884    let cpu_capacity = (rcap * 1024) / cs.max_rcap;
885
886    // Power management
887    let power_path = cpu_path.join("power");
888    let pm_qos_resume_latency_us =
889        read_from_file(&power_path.join("pm_qos_resume_latency_us")).unwrap_or(0_usize);
890
891    let num_llcs = topo_ctx.node_llc_kernel_ids.len();
892    let llc_id = topo_ctx
893        .node_llc_kernel_ids
894        .entry((node.id, package_id, llc_kernel_id))
895        .or_insert(num_llcs);
896
897    let llc = node.llcs.entry(*llc_id).or_insert(Arc::new(Llc {
898        id: *llc_id,
899        cores: BTreeMap::new(),
900        span: Cpumask::new(),
901        all_cpus: BTreeMap::new(),
902
903        node_id: node.id,
904        kernel_id: llc_kernel_id,
905    }));
906    let llc_mut = Arc::get_mut(llc).unwrap();
907
908    let core_type = if cs.avg_rcap < cs.max_rcap && rcap == cs.max_rcap {
909        CoreType::Big { turbo: true }
910    } else if !cs.has_biglittle || rcap >= cs.avg_rcap {
911        CoreType::Big { turbo: false }
912    } else {
913        CoreType::Little
914    };
915
916    let num_cores = topo_ctx.node_core_kernel_ids.len();
917    let core_id = topo_ctx
918        .node_core_kernel_ids
919        .entry((node.id, package_id, core_kernel_id))
920        .or_insert(num_cores);
921
922    let core = llc_mut.cores.entry(*core_id).or_insert(Arc::new(Core {
923        id: *core_id,
924        cpus: BTreeMap::new(),
925        span: Cpumask::new(),
926        core_type: core_type.clone(),
927
928        llc_id: *llc_id,
929        node_id: node.id,
930        kernel_id: core_kernel_id,
931        cluster_id,
932    }));
933    let core_mut = Arc::get_mut(core).unwrap();
934
935    core_mut.cpus.insert(
936        id,
937        Arc::new(Cpu {
938            id,
939            min_freq,
940            max_freq,
941            base_freq,
942            cpu_capacity,
943            kernel_cpu_capacity,
944            smt_level: 0, // Will be initialized at instantiate().
945            pm_qos_resume_latency_us,
946            trans_lat_ns,
947            l2_id,
948            l3_id,
949            cache_size,
950            core_type: core_type.clone(),
951
952            core_id: *core_id,
953            llc_id: *llc_id,
954            node_id: node.id,
955            package_id,
956            cluster_id,
957        }),
958    );
959
960    if node.span.test_cpu(id) {
961        bail!("Node {} already had CPU {}", node.id, id);
962    }
963
964    // Update all of the devices' spans to include this CPU.
965    core_mut.span.set_cpu(id)?;
966    llc_mut.span.set_cpu(id)?;
967    node.span.set_cpu(id)?;
968
969    Ok(())
970}
971
972fn read_cpu_ids() -> Result<Vec<usize>> {
973    let mut cpu_ids = vec![];
974    let path = format!("{}/sys/devices/system/cpu/cpu[0-9]*", *ROOT_PREFIX);
975    let cpu_paths = glob(&path)?;
976    for cpu_path in cpu_paths.filter_map(Result::ok) {
977        let cpu_str = cpu_path.to_str().unwrap().trim();
978        if ROOT_PREFIX.is_empty() {
979            match sscanf!(cpu_str, "/sys/devices/system/cpu/cpu{usize}") {
980                Some(val) => cpu_ids.push(val),
981                None => {
982                    bail!("Failed to parse cpu ID {}", cpu_str);
983                }
984            }
985        } else {
986            match sscanf!(cpu_str, "{str}/sys/devices/system/cpu/cpu{usize}") {
987                Some((_, val)) => cpu_ids.push(val),
988                None => {
989                    bail!("Failed to parse cpu ID {}", cpu_str);
990                }
991            }
992        }
993    }
994    cpu_ids.sort();
995    Ok(cpu_ids)
996}
997
998struct CapacitySource {
999    /// Path suffix after /sys/devices/system/cpu/cpuX
1000    suffix: String,
1001    /// Average raw capacity value
1002    avg_rcap: usize,
1003    /// Maximum raw capacity value
1004    max_rcap: usize,
1005    /// Does a system have little cores?
1006    has_biglittle: bool,
1007}
1008
1009fn get_capacity_source() -> Option<CapacitySource> {
1010    // Sources for guessing cpu_capacity under /sys/devices/system/cpu/cpuX.
1011    // They should be ordered from the most precise to the least precise.
1012    let sources = [
1013        "cpufreq/amd_pstate_prefcore_ranking",
1014        "cpufreq/amd_pstate_highest_perf",
1015        "acpi_cppc/highest_perf",
1016        "cpu_capacity",
1017        "cpufreq/cpuinfo_max_freq",
1018    ];
1019
1020    // Find the most precise source for cpu_capacity estimation.
1021    let prefix = format!("{}/sys/devices/system/cpu/cpu0", *ROOT_PREFIX);
1022    let mut raw_capacity;
1023    let mut suffix = sources[sources.len() - 1];
1024    'outer: for src in sources {
1025        let path_str = [prefix.clone(), src.to_string()].join("/");
1026        let path = Path::new(&path_str);
1027        raw_capacity = read_from_file(path).unwrap_or(0_usize);
1028        if raw_capacity > 0 {
1029            // It would be an okay source...
1030            suffix = src;
1031            // But double-check if the source has meaningful information.
1032            let path = format!("{}/sys/devices/system/cpu/cpu[0-9]*", *ROOT_PREFIX);
1033            let cpu_paths = glob(&path).ok()?;
1034            for cpu_path in cpu_paths.filter_map(Result::ok) {
1035                let raw_capacity2 = read_from_file(&cpu_path.join(suffix)).unwrap_or(0_usize);
1036                if raw_capacity != raw_capacity2 {
1037                    break 'outer;
1038                }
1039            }
1040            // The source exists, but it tells that all CPUs have the same
1041            // capacity. Let's search more if there is any source that can
1042            // tell the capacity differences among CPUs. This can happen when
1043            // a buggy driver lies (e.g., "acpi_cppc/highest_perf").
1044        }
1045    }
1046
1047    // Find the max raw_capacity value for scaling to 1024.
1048    let mut max_rcap = 0;
1049    let mut min_rcap = usize::MAX;
1050    let mut avg_rcap = 0;
1051    let mut nr_cpus = 0;
1052    let mut has_biglittle = false;
1053    let path = format!("{}/sys/devices/system/cpu/cpu[0-9]*", *ROOT_PREFIX);
1054    let cpu_paths = glob(&path).ok()?;
1055    for cpu_path in cpu_paths.filter_map(Result::ok) {
1056        let rcap = read_from_file(&cpu_path.join(suffix)).unwrap_or(0_usize);
1057        if max_rcap < rcap {
1058            max_rcap = rcap;
1059        }
1060        if min_rcap > rcap {
1061            min_rcap = rcap;
1062        }
1063        avg_rcap += rcap;
1064        nr_cpus += 1;
1065    }
1066
1067    if nr_cpus == 0 || max_rcap == 0 {
1068        suffix = "";
1069        avg_rcap = 1024;
1070        max_rcap = 1024;
1071        warn!("CPU capacity information is not available under sysfs.");
1072    } else {
1073        avg_rcap /= nr_cpus;
1074        // We consider a system to have a heterogeneous CPU architecture only
1075        // when there is a significant capacity gap (e.g., 1.3x). CPU capacities
1076        // can still vary in a homogeneous architecture—for instance, due to
1077        // chip binning or when only a subset of CPUs supports turbo boost.
1078        //
1079        // Note that we need a more systematic approach to accurately detect
1080        // big/LITTLE architectures across various SoC designs. The current
1081        // approach, with a significant capacity difference, is somewhat ad-hoc.
1082        has_biglittle = max_rcap as f32 >= (1.3 * min_rcap as f32);
1083    }
1084
1085    Some(CapacitySource {
1086        suffix: suffix.to_string(),
1087        avg_rcap,
1088        max_rcap,
1089        has_biglittle,
1090    })
1091}
1092
1093fn is_smt_active() -> Option<bool> {
1094    let path = format!("{}/sys/devices/system/cpu/smt/active", *ROOT_PREFIX);
1095    let smt_on: u8 = read_from_file(Path::new(&path)).ok()?;
1096    Some(smt_on == 1)
1097}
1098
1099fn replace_with_virt_llcs(
1100    node: &mut Node,
1101    min_cores: usize,
1102    max_cores: usize,
1103    start_id: usize,
1104) -> Result<usize> {
1105    let mut next_id = start_id;
1106    let mut core_to_partition: BTreeMap<usize, usize> = BTreeMap::new();
1107    let mut partition_to_kernel_id: BTreeMap<usize, usize> = BTreeMap::new();
1108    let num_orig_llcs = node.llcs.len();
1109
1110    // First pass: determine core to partition mapping, partition to
1111    // kernel_id mapping, and total partitions needed
1112    for llc in node.llcs.values() {
1113        // Group cores by type (big/little) to partition separately
1114        let mut cores_by_type: BTreeMap<bool, Vec<usize>> = BTreeMap::new();
1115
1116        for (core_id, core) in llc.cores.iter() {
1117            let core_type = core.core_type == CoreType::Little;
1118            cores_by_type.entry(core_type).or_default().push(*core_id);
1119        }
1120
1121        for core_ids in cores_by_type.values() {
1122            let num_cores_in_bucket = core_ids.len();
1123
1124            // Find optimal partition size within specified range
1125            let best_split = find_best_split_size(num_cores_in_bucket, min_cores, max_cores);
1126            let num_partitions = num_cores_in_bucket / best_split;
1127
1128            // Assign cores to partitions within a group type
1129            for (bucket_idx, &core_id) in core_ids.iter().enumerate() {
1130                let partition_idx = min(bucket_idx / best_split, num_partitions - 1);
1131                let current_partition_id = next_id + partition_idx;
1132                core_to_partition.insert(core_id, current_partition_id);
1133                partition_to_kernel_id.insert(current_partition_id, llc.kernel_id);
1134            }
1135
1136            next_id += num_partitions;
1137        }
1138    }
1139
1140    // Create new virtual LLC structures based on partitioning found above
1141    let mut virt_llcs: BTreeMap<usize, Arc<Llc>> = BTreeMap::new();
1142
1143    for vllc_id in start_id..next_id {
1144        let kernel_id = partition_to_kernel_id.get(&vllc_id).copied().unwrap();
1145        virt_llcs.insert(
1146            vllc_id,
1147            Arc::new(Llc {
1148                id: vllc_id,
1149                kernel_id,
1150                cores: BTreeMap::new(),
1151                span: Cpumask::new(),
1152                node_id: node.id,
1153                all_cpus: BTreeMap::new(),
1154            }),
1155        );
1156    }
1157
1158    // Second pass: move cores to the appropriate new LLC based on partition
1159    for llc in node.llcs.values_mut() {
1160        for (core_id, core) in llc.cores.iter() {
1161            if let Some(&target_partition_id) = core_to_partition.get(core_id)
1162                && let Some(target_llc) = virt_llcs.get_mut(&target_partition_id)
1163            {
1164                let target_llc_mut = Arc::get_mut(target_llc).unwrap();
1165
1166                // Clone core and update its LLC ID to match new partition
1167                let mut new_core = (**core).clone();
1168                new_core.llc_id = target_partition_id;
1169
1170                // Update all CPUs within this core to reference new LLC ID
1171                let mut updated_cpus = BTreeMap::new();
1172                for (cpu_id, cpu) in new_core.cpus.iter() {
1173                    let mut new_cpu = (**cpu).clone();
1174                    new_cpu.llc_id = target_partition_id;
1175
1176                    // Add CPU to the virtual LLC's span
1177                    target_llc_mut.span.set_cpu(*cpu_id)?;
1178
1179                    updated_cpus.insert(*cpu_id, Arc::new(new_cpu));
1180                }
1181                new_core.cpus = updated_cpus;
1182
1183                // Add the updated core to the virtual LLC
1184                target_llc_mut.cores.insert(*core_id, Arc::new(new_core));
1185            }
1186        }
1187    }
1188
1189    // Replace original LLCs with virtual LLCs
1190    node.llcs = virt_llcs;
1191
1192    let num_virt_llcs = next_id - start_id;
1193    let vllc_sizes: Vec<usize> = node.llcs.values().map(|llc| llc.cores.len()).collect();
1194
1195    if vllc_sizes.is_empty() {
1196        return Ok(next_id);
1197    }
1198
1199    // Most vLLCs should have the same size, only the last one might differ
1200    let common_size = vllc_sizes[0];
1201    let last_size = *vllc_sizes.last().unwrap();
1202
1203    if common_size == last_size {
1204        info!(
1205            "Node {}: split {} LLC(s) into {} virtual LLCs with {} cores each",
1206            node.id, num_orig_llcs, num_virt_llcs, common_size
1207        );
1208    } else {
1209        info!(
1210            "Node {}: split {} LLC(s) into {} virtual LLCs with {} cores each (last with {})",
1211            node.id, num_orig_llcs, num_virt_llcs, common_size, last_size
1212        );
1213    }
1214
1215    Ok(next_id)
1216}
1217
1218fn create_default_node(
1219    online_mask: &Cpumask,
1220    topo_ctx: &mut TopoCtx,
1221    flatten_llc: bool,
1222    nr_cores_per_vllc: Option<(usize, usize)>,
1223) -> Result<BTreeMap<usize, Node>> {
1224    let mut nodes = BTreeMap::<usize, Node>::new();
1225
1226    let mut node = Node {
1227        id: 0,
1228        distance: vec![],
1229        llcs: BTreeMap::new(),
1230        span: Cpumask::new(),
1231        #[cfg(feature = "gpu-topology")]
1232        gpus: BTreeMap::new(),
1233        all_cores: BTreeMap::new(),
1234        all_cpus: BTreeMap::new(),
1235    };
1236
1237    #[cfg(feature = "gpu-topology")]
1238    {
1239        let system_gpus = create_gpus();
1240        if let Some(gpus) = system_gpus.get(&0) {
1241            for gpu in gpus {
1242                node.gpus.insert(gpu.index, gpu.clone());
1243            }
1244        }
1245    }
1246
1247    let path = format!("{}/sys/devices/system/cpu", *ROOT_PREFIX);
1248    if !Path::new(&path).exists() {
1249        bail!("/sys/devices/system/cpu sysfs node not found");
1250    }
1251
1252    let cs = get_capacity_source().unwrap();
1253    let cpu_ids = read_cpu_ids()?;
1254    for cpu_id in cpu_ids.iter() {
1255        create_insert_cpu(*cpu_id, &mut node, online_mask, topo_ctx, &cs, flatten_llc)?;
1256    }
1257
1258    if let Some((min_cores_val, max_cores_val)) = nr_cores_per_vllc {
1259        replace_with_virt_llcs(&mut node, min_cores_val, max_cores_val, 0)?;
1260    }
1261
1262    nodes.insert(node.id, node);
1263
1264    Ok(nodes)
1265}
1266
1267fn create_numa_nodes(
1268    online_mask: &Cpumask,
1269    topo_ctx: &mut TopoCtx,
1270    nr_cores_per_vllc: Option<(usize, usize)>,
1271) -> Result<BTreeMap<usize, Node>> {
1272    let mut nodes = BTreeMap::<usize, Node>::new();
1273    let mut next_virt_llc_id = 0;
1274
1275    #[cfg(feature = "gpu-topology")]
1276    let system_gpus = create_gpus();
1277
1278    let path = format!("{}/sys/devices/system/node/node*", *ROOT_PREFIX);
1279    let numa_paths = glob(&path)?;
1280    for numa_path in numa_paths.filter_map(Result::ok) {
1281        let numa_str = numa_path.to_str().unwrap().trim();
1282        let node_id = if ROOT_PREFIX.is_empty() {
1283            match sscanf!(numa_str, "/sys/devices/system/node/node{usize}") {
1284                Some(val) => val,
1285                None => {
1286                    bail!("Failed to parse NUMA node ID {}", numa_str);
1287                }
1288            }
1289        } else {
1290            match sscanf!(numa_str, "{str}/sys/devices/system/node/node{usize}") {
1291                Some((_, val)) => val,
1292                None => {
1293                    bail!("Failed to parse NUMA node ID {}", numa_str);
1294                }
1295            }
1296        };
1297
1298        let distance = read_file_usize_vec(
1299            Path::new(&format!(
1300                "{}/sys/devices/system/node/node{}/distance",
1301                *ROOT_PREFIX, node_id
1302            )),
1303            ' ',
1304        )?;
1305        let mut node = Node {
1306            id: node_id,
1307            distance,
1308            llcs: BTreeMap::new(),
1309            span: Cpumask::new(),
1310
1311            all_cores: BTreeMap::new(),
1312            all_cpus: BTreeMap::new(),
1313
1314            #[cfg(feature = "gpu-topology")]
1315            gpus: BTreeMap::new(),
1316        };
1317
1318        #[cfg(feature = "gpu-topology")]
1319        {
1320            if let Some(gpus) = system_gpus.get(&node_id) {
1321                for gpu in gpus {
1322                    node.gpus.insert(gpu.index, gpu.clone());
1323                }
1324            }
1325        }
1326
1327        let cpu_pattern = numa_path.join("cpu[0-9]*");
1328        let cpu_paths = glob(cpu_pattern.to_string_lossy().as_ref())?;
1329        let cs = get_capacity_source().unwrap();
1330        let mut cpu_ids = vec![];
1331        for cpu_path in cpu_paths.filter_map(Result::ok) {
1332            let cpu_str = cpu_path.to_str().unwrap().trim();
1333            let cpu_id = if ROOT_PREFIX.is_empty() {
1334                match sscanf!(cpu_str, "/sys/devices/system/node/node{usize}/cpu{usize}") {
1335                    Some((_, val)) => val,
1336                    None => {
1337                        bail!("Failed to parse cpu ID {}", cpu_str);
1338                    }
1339                }
1340            } else {
1341                match sscanf!(
1342                    cpu_str,
1343                    "{str}/sys/devices/system/node/node{usize}/cpu{usize}"
1344                ) {
1345                    Some((_, _, val)) => val,
1346                    None => {
1347                        bail!("Failed to parse cpu ID {}", cpu_str);
1348                    }
1349                }
1350            };
1351            cpu_ids.push(cpu_id);
1352        }
1353        cpu_ids.sort();
1354
1355        for cpu_id in cpu_ids {
1356            create_insert_cpu(cpu_id, &mut node, online_mask, topo_ctx, &cs, false)?;
1357        }
1358
1359        if let Some((min_cores_val, max_cores_val)) = nr_cores_per_vllc {
1360            next_virt_llc_id =
1361                replace_with_virt_llcs(&mut node, min_cores_val, max_cores_val, next_virt_llc_id)?;
1362        }
1363
1364        nodes.insert(node.id, node);
1365    }
1366    Ok(nodes)
1367}
1368
1369/// Test topology construction helpers.
1370///
1371/// Provides [`make_test_topo()`] for building synthetic [`Topology`] instances
1372/// with configurable node/LLC/core/HT counts, and [`mask_from_bits()`] for
1373/// building [`Cpumask`] values from a list of CPU IDs. Enable via the
1374/// `testutils` feature of `scx_utils`.
1375#[cfg(any(test, feature = "testutils"))]
1376pub mod testutils {
1377    use super::*;
1378    use crate::set_cpumask_test_width;
1379
1380    /// Create a [`Cpu`] with the given IDs and default frequencies/capacity.
1381    pub fn test_cpu(id: usize, core_id: usize, llc_id: usize, node_id: usize) -> Cpu {
1382        Cpu {
1383            id,
1384            core_id,
1385            llc_id,
1386            node_id,
1387            min_freq: 0,
1388            max_freq: 0,
1389            base_freq: 0,
1390            cpu_capacity: 1024,
1391            kernel_cpu_capacity: Some(1024),
1392            smt_level: 0, // filled by instantiate()
1393            pm_qos_resume_latency_us: 0,
1394            trans_lat_ns: 0,
1395            l2_id: 0,
1396            l3_id: llc_id,
1397            cache_size: 0,
1398            core_type: CoreType::Big { turbo: false },
1399            package_id: node_id,
1400            cluster_id: 0,
1401        }
1402    }
1403
1404    /// Create a [`Core`] from a set of CPUs with the given IDs.
1405    pub fn test_core(
1406        id: usize,
1407        cpus: BTreeMap<usize, Arc<Cpu>>,
1408        llc_id: usize,
1409        node_id: usize,
1410    ) -> Core {
1411        let mut span = Cpumask::new();
1412        for &cpu_id in cpus.keys() {
1413            span.set_cpu(cpu_id).unwrap();
1414        }
1415        Core {
1416            id,
1417            kernel_id: id,
1418            cluster_id: 0,
1419            cpus,
1420            span,
1421            core_type: CoreType::Big { turbo: false },
1422            llc_id,
1423            node_id,
1424        }
1425    }
1426
1427    /// Create an [`Llc`] from a set of cores with the given IDs.
1428    pub fn test_llc(id: usize, cores: BTreeMap<usize, Arc<Core>>, node_id: usize) -> Llc {
1429        let mut span = Cpumask::new();
1430        for core in cores.values() {
1431            for &cpu_id in core.cpus.keys() {
1432                span.set_cpu(cpu_id).unwrap();
1433            }
1434        }
1435        Llc {
1436            id,
1437            kernel_id: id,
1438            cores,
1439            span,
1440            node_id,
1441            all_cpus: BTreeMap::new(), // filled by instantiate()
1442        }
1443    }
1444
1445    /// Create a [`Node`] from a set of LLCs with the given IDs.
1446    pub fn test_node(id: usize, llcs: BTreeMap<usize, Arc<Llc>>, nr_nodes: usize) -> Node {
1447        let mut span = Cpumask::new();
1448        for llc in llcs.values() {
1449            for core in llc.cores.values() {
1450                for &cpu_id in core.cpus.keys() {
1451                    span.set_cpu(cpu_id).unwrap();
1452                }
1453            }
1454        }
1455        Node {
1456            id,
1457            distance: vec![10; nr_nodes],
1458            llcs,
1459            span,
1460            all_cores: BTreeMap::new(), // filled by instantiate()
1461            all_cpus: BTreeMap::new(),  // filled by instantiate()
1462            #[cfg(feature = "gpu-topology")]
1463            gpus: BTreeMap::new(),
1464        }
1465    }
1466
1467    /// Build a synthetic [`Topology`] with the specified dimensions.
1468    ///
1469    /// Returns `(topology, total_cpu_count)`. CPU IDs are assigned
1470    /// sequentially starting from 0: node 0's LLCs get the lowest IDs,
1471    /// then node 1, etc.
1472    ///
1473    /// Sets the Cpumask test width override to `total_cpus` so that all
1474    /// masks created during the test have consistent width.
1475    pub fn make_test_topo(
1476        nr_nodes: usize,
1477        llcs_per_node: usize,
1478        cores_per_llc: usize,
1479        hts_per_core: usize,
1480    ) -> (Topology, usize) {
1481        let total_cpus = nr_nodes * llcs_per_node * cores_per_llc * hts_per_core;
1482        set_cpumask_test_width(total_cpus);
1483
1484        let mut cpu_id = 0usize;
1485        let mut core_id = 0usize;
1486        let mut llc_id = 0usize;
1487        let mut nodes = BTreeMap::new();
1488
1489        for node_idx in 0..nr_nodes {
1490            let mut llcs = BTreeMap::new();
1491            for _ in 0..llcs_per_node {
1492                let mut cores = BTreeMap::new();
1493                for _ in 0..cores_per_llc {
1494                    let mut cpus = BTreeMap::new();
1495                    for _ in 0..hts_per_core {
1496                        cpus.insert(
1497                            cpu_id,
1498                            Arc::new(test_cpu(cpu_id, core_id, llc_id, node_idx)),
1499                        );
1500                        cpu_id += 1;
1501                    }
1502                    cores.insert(
1503                        core_id,
1504                        Arc::new(test_core(core_id, cpus, llc_id, node_idx)),
1505                    );
1506                    core_id += 1;
1507                }
1508                llcs.insert(llc_id, Arc::new(test_llc(llc_id, cores, node_idx)));
1509                llc_id += 1;
1510            }
1511            nodes.insert(node_idx, test_node(node_idx, llcs, nr_nodes));
1512        }
1513
1514        let mut span = Cpumask::new();
1515        for i in 0..total_cpus {
1516            span.set_cpu(i).unwrap();
1517        }
1518
1519        (Topology::instantiate(span, nodes).unwrap(), total_cpus)
1520    }
1521
1522    pub fn make_het_test_topo(
1523        cores_per_llc: &[&[usize]],
1524        hts_per_core: usize,
1525    ) -> (Topology, usize) {
1526        let nr_nodes = cores_per_llc.len();
1527
1528        let total_cpus = cores_per_llc
1529            .iter()
1530            .map(|node| node.iter().sum::<usize>())
1531            .sum::<usize>()
1532            * hts_per_core;
1533
1534        set_cpumask_test_width(total_cpus);
1535
1536        let mut cpu_id = 0usize;
1537        let mut core_id = 0usize;
1538        let mut llc_id = 0usize;
1539        let mut nodes = BTreeMap::new();
1540
1541        for (node_id, llcs_in_node) in cores_per_llc.iter().enumerate() {
1542            let mut llcs = BTreeMap::new();
1543
1544            for &cores_in_llc in *llcs_in_node {
1545                let mut cores = BTreeMap::new();
1546
1547                for _ in 0..cores_in_llc {
1548                    let mut cpus = BTreeMap::new();
1549
1550                    for _ in 0..hts_per_core {
1551                        cpus.insert(cpu_id, Arc::new(test_cpu(cpu_id, core_id, llc_id, node_id)));
1552                        cpu_id += 1;
1553                    }
1554
1555                    cores.insert(core_id, Arc::new(test_core(core_id, cpus, llc_id, node_id)));
1556                    core_id += 1;
1557                }
1558
1559                llcs.insert(llc_id, Arc::new(test_llc(llc_id, cores, node_id)));
1560                llc_id += 1;
1561            }
1562
1563            nodes.insert(node_id, test_node(node_id, llcs, nr_nodes));
1564        }
1565
1566        let mut span = Cpumask::new();
1567        for i in 0..total_cpus {
1568            span.set_cpu(i).unwrap();
1569        }
1570
1571        (Topology::instantiate(span, nodes).unwrap(), total_cpus)
1572    }
1573
1574    /// Create a [`Cpumask`] from a list of set CPU IDs.
1575    pub fn mask_from_bits(_total: usize, bits: &[usize]) -> Cpumask {
1576        let mut mask = Cpumask::new();
1577        for &b in bits {
1578            mask.set_cpu(b).unwrap();
1579        }
1580        mask
1581    }
1582}
1583
1584#[cfg(test)]
1585mod tests {
1586    use super::testutils::*;
1587    use super::*;
1588    use crate::set_cpumask_test_width;
1589
1590    fn grid_output(topo: &Topology, cpumask: &Cpumask) -> String {
1591        let mut buf = Vec::new();
1592        topo.format_cpumask_grid(&mut buf, cpumask, "    ", 80)
1593            .unwrap();
1594        String::from_utf8(buf).unwrap()
1595    }
1596
1597    #[test]
1598    fn test_parse_schedstat_domains() {
1599        set_cpumask_test_width(8);
1600        let data = concat!(
1601            "version 17\n",
1602            "timestamp 42\n",
1603            "cpu0 0 0 0 0 0 0 0 0 0\n",
1604            "domain0 SMT 03 0 0\n",
1605            "domain1 MC 0f 0 0\n",
1606            "cpu1 0 0 0 0 0 0 0 0 0\n",
1607            "domain0 SMT 03 0 0\n",
1608            "domain1 MC ff 0 0\n",
1609        );
1610        let domains = parse_schedstat(data).unwrap();
1611        assert_eq!(
1612            domains[&0].iter().map(Cpumask::weight).collect::<Vec<_>>(),
1613            [2, 4]
1614        );
1615        assert_eq!(
1616            domains[&1].iter().map(Cpumask::weight).collect::<Vec<_>>(),
1617            [2, 8]
1618        );
1619        assert!(parse_schedstat(&data.replace("version 17", "version 18")).is_err());
1620    }
1621
1622    #[test]
1623    fn test_capacity_domain_selection() {
1624        set_cpumask_test_width(8);
1625        let domains = vec![
1626            mask_from_bits(8, &[0, 1]),
1627            mask_from_bits(8, &[0, 1, 2, 3]),
1628            mask_from_bits(8, &[0, 1, 2, 3, 4, 5, 6, 7]),
1629        ];
1630        let capacities = (0..8)
1631            .map(|cpu| (cpu, if cpu < 4 { 512 } else { 1024 }))
1632            .collect();
1633        assert_eq!(smallest_full_capacity_span(&domains, &capacities), 8);
1634
1635        let capacities = (0..8).map(|cpu| (cpu, 1024)).collect();
1636        assert_eq!(smallest_full_capacity_span(&domains, &capacities), 0);
1637    }
1638
1639    #[test]
1640    fn test_topology_sched_domain_fallback() {
1641        let (mut topo, _) = make_test_topo(2, 1, 2, 1);
1642        topo.sched_domains.set(None).unwrap();
1643        topo.nodes.get_mut(&0).unwrap().distance = vec![10, 100];
1644        topo.nodes.get_mut(&1).unwrap().distance = vec![100, 10];
1645
1646        let info = topo.sched_domain_info(0).unwrap();
1647        assert_eq!(info.source, SchedDomainSource::Topology);
1648        assert_eq!(info.fork_span, 2);
1649        assert_eq!(info.wake_affine_span, 2);
1650        assert_eq!(info.asym_capacity_span, 0);
1651    }
1652
1653    #[test]
1654    fn test_grid_2node_2llc_3core_2ht() {
1655        // 2 nodes, 2 LLCs/node, 3 cores/LLC, 2 HTs/core = 24 CPUs
1656        let (topo, total) = make_test_topo(2, 2, 3, 2);
1657        assert_eq!(total, 24);
1658
1659        // Set some specific CPUs:
1660        // Node0 LLC0: core0(cpu0,1) core1(cpu2,3) core2(cpu4,5)
1661        // Node0 LLC1: core3(cpu6,7) core4(cpu8,9) core5(cpu10,11)
1662        // Node1 LLC2: core6(cpu12,13) core7(cpu14,15) core8(cpu16,17)
1663        // Node1 LLC3: core9(cpu18,19) core10(cpu20,21) core11(cpu22,23)
1664        //
1665        // Set: cpu1(core0 2nd HT), cpu2+3(core1 both), cpu12(core6 1st HT)
1666        let cpumask = mask_from_bits(total, &[1, 2, 3, 12]);
1667
1668        let output = grid_output(&topo, &cpumask);
1669        // Node0: LLC0=[▄ █ ░] LLC1=[░ ░ ░]
1670        // Node1: LLC2=[▀ ░ ░] LLC3=[░ ░ ░]
1671        assert!(output.contains("N0 L00:"));
1672        assert!(output.contains("N1 L02:"));
1673        // LLC0=▄█░, LLC1=░░░ separated by |
1674        assert!(output.contains("▄█░|░░░"));
1675        // LLC2=▀░░, LLC3=░░░ separated by |
1676        assert!(output.contains("▀░░|░░░"));
1677
1678        // Core count: cores 0,1,6 have at least one CPU set = 3
1679        assert_eq!(topo.cpumask_nr_cores(&cpumask), 3);
1680    }
1681
1682    #[test]
1683    fn test_grid_empty_cpumask() {
1684        let (topo, total) = make_test_topo(1, 2, 3, 2);
1685        let cpumask = mask_from_bits(total, &[]);
1686        let output = grid_output(&topo, &cpumask);
1687        // All chars should be ░
1688        assert!(!output.contains('█'));
1689        assert!(!output.contains('▀'));
1690        assert!(!output.contains('▄'));
1691        assert!(output.contains('░'));
1692        assert_eq!(topo.cpumask_nr_cores(&cpumask), 0);
1693    }
1694
1695    #[test]
1696    fn test_grid_full_cpumask() {
1697        let (topo, total) = make_test_topo(1, 2, 3, 2);
1698        let cpumask = mask_from_bits(total, &(0..total).collect::<Vec<_>>());
1699        let output = grid_output(&topo, &cpumask);
1700        // All chars should be █
1701        assert!(!output.contains('░'));
1702        assert!(!output.contains('▀'));
1703        assert!(!output.contains('▄'));
1704        assert!(output.contains('█'));
1705        assert_eq!(topo.cpumask_nr_cores(&cpumask), 6);
1706    }
1707
1708    #[test]
1709    fn test_grid_mixed_ht() {
1710        // 1 node, 1 LLC, 4 cores, 2 HTs = 8 CPUs
1711        let (topo, total) = make_test_topo(1, 1, 4, 2);
1712        // core0: cpu0,1  core1: cpu2,3  core2: cpu4,5  core3: cpu6,7
1713        // Set: cpu0 only (▀), cpu3 only (▄), cpu4+5 (█), none on core3 (░)
1714        let cpumask = mask_from_bits(total, &[0, 3, 4, 5]);
1715        let output = grid_output(&topo, &cpumask);
1716        assert!(output.contains('▀'));
1717        assert!(output.contains('▄'));
1718        assert!(output.contains('█'));
1719        assert!(output.contains('░'));
1720    }
1721
1722    #[test]
1723    fn test_grid_single_node() {
1724        let (topo, total) = make_test_topo(1, 1, 2, 2);
1725        let cpumask = mask_from_bits(total, &[0, 1]);
1726        let output = grid_output(&topo, &cpumask);
1727        assert!(output.contains("N0 L00:"));
1728        assert!(!output.contains("N1"));
1729    }
1730
1731    #[test]
1732    fn test_grid_overflow_wrap() {
1733        // 1 node, 12 LLCs, 4 cores each, 2 HTs = many characters
1734        // 12 LLCs grouped by 4 = 3 groups per line, should wrap
1735        let (topo, total) = make_test_topo(1, 12, 4, 2);
1736        let cpumask = mask_from_bits(total, &[0]);
1737        let mut buf = Vec::new();
1738        topo.format_cpumask_grid(&mut buf, &cpumask, "    ", 60)
1739            .unwrap();
1740        let output = String::from_utf8(buf).unwrap();
1741        // Should have multiple lines for node 0 due to wrapping
1742        let lines: Vec<&str> = output.lines().collect();
1743        assert!(
1744            lines.len() > 1,
1745            "Expected wrapping with narrow width, got {} lines",
1746            lines.len()
1747        );
1748    }
1749
1750    #[test]
1751    fn test_grid_smt_off() {
1752        // 1 node, 1 LLC, 4 cores, 1 HT = no SMT
1753        let (topo, total) = make_test_topo(1, 1, 4, 1);
1754        // core0: cpu0, core1: cpu1, core2: cpu2, core3: cpu3
1755        let cpumask = mask_from_bits(total, &[0, 2]);
1756        let output = grid_output(&topo, &cpumask);
1757        // Only █ and ░ should appear
1758        assert!(output.contains('█'));
1759        assert!(output.contains('░'));
1760        assert!(!output.contains('▀'));
1761        assert!(!output.contains('▄'));
1762    }
1763
1764    #[test]
1765    fn test_grid_4way_smt() {
1766        // 1 node, 1 LLC, 2 cores, 4 HTs = 8 CPUs
1767        let (topo, total) = make_test_topo(1, 1, 2, 4);
1768        // core0: cpu0-3, core1: cpu4-7
1769        // Set all of core0 → █, set 2 of core1 → ▄ (partial)
1770        let cpumask = mask_from_bits(total, &[0, 1, 2, 3, 4, 5]);
1771        let output = grid_output(&topo, &cpumask);
1772        assert!(output.contains('█')); // core0: all 4 set
1773        assert!(output.contains('▄')); // core1: partial (2 of 4)
1774    }
1775
1776    #[test]
1777    fn test_cpumask_header() {
1778        let (topo, total) = make_test_topo(1, 1, 4, 2);
1779        // 4 cores, 8 CPUs. Set cpu0,1,2 (2 cores touched)
1780        let cpumask = mask_from_bits(total, &[0, 1, 2]);
1781        let header = topo.format_cpumask_header(&cpumask, 5, 10);
1782        assert!(header.contains("cpus=  3(  2c)"));
1783        assert!(header.contains("[  5, 10]"));
1784    }
1785}