Skip to main content

tenferro_cpu/
topology.rs

1#[cfg(any(test, target_os = "linux"))]
2use std::collections::BTreeSet;
3use std::fmt;
4use std::sync::Arc;
5
6use thiserror::Error;
7
8/// An operating-system logical CPU identifier.
9///
10/// # Examples
11///
12/// ```
13/// use tenferro_cpu::CpuId;
14///
15/// assert_eq!(CpuId::new(3).as_usize(), 3);
16/// ```
17#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
18pub struct CpuId(usize);
19
20impl CpuId {
21    /// Create an identifier from the operating-system logical CPU number.
22    ///
23    /// # Examples
24    ///
25    /// ```
26    /// use tenferro_cpu::CpuId;
27    ///
28    /// assert_eq!(CpuId::new(5).as_usize(), 5);
29    /// ```
30    pub const fn new(id: usize) -> Self {
31        Self(id)
32    }
33
34    /// Return the operating-system logical CPU number.
35    ///
36    /// # Examples
37    ///
38    /// ```
39    /// use tenferro_cpu::CpuId;
40    ///
41    /// assert_eq!(CpuId::new(7).as_usize(), 7);
42    /// ```
43    pub const fn as_usize(self) -> usize {
44        self.0
45    }
46}
47
48impl fmt::Display for CpuId {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        self.0.fmt(f)
51    }
52}
53
54/// An operating-system NUMA node identifier.
55///
56/// Node IDs are preserved as reported by the OS and may be sparse.
57///
58/// # Examples
59///
60/// ```
61/// use tenferro_cpu::NumaNodeId;
62///
63/// assert_eq!(NumaNodeId::new(4).as_usize(), 4);
64/// ```
65#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
66pub struct NumaNodeId(usize);
67
68impl NumaNodeId {
69    /// Create an identifier from the operating-system NUMA node number.
70    ///
71    /// # Examples
72    ///
73    /// ```
74    /// use tenferro_cpu::NumaNodeId;
75    ///
76    /// assert_eq!(NumaNodeId::new(2).as_usize(), 2);
77    /// ```
78    pub const fn new(id: usize) -> Self {
79        Self(id)
80    }
81
82    /// Return the operating-system NUMA node number.
83    ///
84    /// # Examples
85    ///
86    /// ```
87    /// use tenferro_cpu::NumaNodeId;
88    ///
89    /// assert_eq!(NumaNodeId::new(9).as_usize(), 9);
90    /// ```
91    pub const fn as_usize(self) -> usize {
92        self.0
93    }
94}
95
96impl fmt::Display for NumaNodeId {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        self.0.fmt(f)
99    }
100}
101
102/// Failure to construct a non-empty CPU set.
103///
104/// # Examples
105///
106/// ```
107/// use tenferro_cpu::{CpuId, CpuSet, CpuSetError};
108///
109/// assert_eq!(CpuSet::new(Vec::<CpuId>::new()), Err(CpuSetError::Empty));
110/// ```
111#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
112pub enum CpuSetError {
113    /// A CPU execution domain cannot be empty.
114    #[error("CPU set is empty")]
115    Empty,
116}
117
118/// A sorted, deduplicated, non-empty set of logical CPUs.
119///
120/// # Examples
121///
122/// ```
123/// use tenferro_cpu::{CpuId, CpuSet};
124///
125/// let cpus = CpuSet::new([CpuId::new(3), CpuId::new(1), CpuId::new(3)])?;
126/// assert_eq!(cpus.as_usize_vec(), vec![1, 3]);
127/// # Ok::<(), tenferro_cpu::CpuSetError>(())
128/// ```
129#[derive(Clone, Debug, PartialEq, Eq, Hash)]
130pub struct CpuSet {
131    cpus: Arc<[CpuId]>,
132}
133
134impl CpuSet {
135    pub(crate) fn singleton(cpu: CpuId) -> Self {
136        Self {
137            cpus: Arc::from([cpu]),
138        }
139    }
140
141    /// Construct a sorted and deduplicated CPU set.
142    ///
143    /// # Examples
144    ///
145    /// ```
146    /// use tenferro_cpu::{CpuId, CpuSet};
147    ///
148    /// let cpus = CpuSet::new([CpuId::new(2), CpuId::new(0)])?;
149    /// assert_eq!(cpus.as_usize_vec(), vec![0, 2]);
150    /// # Ok::<(), tenferro_cpu::CpuSetError>(())
151    /// ```
152    ///
153    /// # Errors
154    ///
155    /// Returns [`CpuSetError::Empty`] when the iterator yields no CPUs.
156    pub fn new(cpus: impl IntoIterator<Item = CpuId>) -> Result<Self, CpuSetError> {
157        let mut cpus: Vec<_> = cpus.into_iter().collect();
158        cpus.sort_unstable();
159        cpus.dedup();
160        if cpus.is_empty() {
161            return Err(CpuSetError::Empty);
162        }
163        Ok(Self { cpus: cpus.into() })
164    }
165
166    /// Return the number of logical CPUs in this set.
167    ///
168    /// # Examples
169    ///
170    /// ```
171    /// use tenferro_cpu::{CpuId, CpuSet};
172    ///
173    /// assert_eq!(CpuSet::new([CpuId::new(0), CpuId::new(1)])?.len(), 2);
174    /// # Ok::<(), tenferro_cpu::CpuSetError>(())
175    /// ```
176    pub fn len(&self) -> usize {
177        self.cpus.len()
178    }
179
180    /// Report whether this set is empty.
181    ///
182    /// A successfully constructed `CpuSet` is never empty.
183    ///
184    /// # Examples
185    ///
186    /// ```
187    /// use tenferro_cpu::{CpuId, CpuSet};
188    ///
189    /// assert!(!CpuSet::new([CpuId::new(0)])?.is_empty());
190    /// # Ok::<(), tenferro_cpu::CpuSetError>(())
191    /// ```
192    pub fn is_empty(&self) -> bool {
193        self.cpus.is_empty()
194    }
195
196    /// Return the sorted logical CPU identifiers.
197    ///
198    /// # Examples
199    ///
200    /// ```
201    /// use tenferro_cpu::{CpuId, CpuSet};
202    ///
203    /// let cpus = CpuSet::new([CpuId::new(4), CpuId::new(2)])?;
204    /// assert_eq!(cpus.as_slice(), &[CpuId::new(2), CpuId::new(4)]);
205    /// # Ok::<(), tenferro_cpu::CpuSetError>(())
206    /// ```
207    pub fn as_slice(&self) -> &[CpuId] {
208        &self.cpus
209    }
210
211    /// Copy the sorted logical CPU numbers into a vector.
212    ///
213    /// # Examples
214    ///
215    /// ```
216    /// use tenferro_cpu::{CpuId, CpuSet};
217    ///
218    /// let cpus = CpuSet::new([CpuId::new(6), CpuId::new(5)])?;
219    /// assert_eq!(cpus.as_usize_vec(), vec![5, 6]);
220    /// # Ok::<(), tenferro_cpu::CpuSetError>(())
221    /// ```
222    pub fn as_usize_vec(&self) -> Vec<usize> {
223        self.cpus.iter().map(|cpu| cpu.as_usize()).collect()
224    }
225
226    /// Report whether the logical CPU belongs to this set.
227    ///
228    /// # Examples
229    ///
230    /// ```
231    /// use tenferro_cpu::{CpuId, CpuSet};
232    ///
233    /// let cpus = CpuSet::new([CpuId::new(1), CpuId::new(3)])?;
234    /// assert!(cpus.contains(CpuId::new(3)));
235    /// # Ok::<(), tenferro_cpu::CpuSetError>(())
236    /// ```
237    pub fn contains(&self, cpu: CpuId) -> bool {
238        self.cpus.binary_search(&cpu).is_ok()
239    }
240
241    pub(crate) fn overlaps(&self, other: &Self) -> bool {
242        let (mut left, mut right) = (0, 0);
243        while left < self.len() && right < other.len() {
244            match self.cpus[left].cmp(&other.cpus[right]) {
245                std::cmp::Ordering::Less => left += 1,
246                std::cmp::Ordering::Greater => right += 1,
247                std::cmp::Ordering::Equal => return true,
248            }
249        }
250        false
251    }
252
253    #[cfg(any(test, target_os = "linux"))]
254    fn intersection(&self, other: &Self) -> Option<Self> {
255        let mut intersection = Vec::with_capacity(self.len().min(other.len()));
256        let (mut left, mut right) = (0, 0);
257        while left < self.len() && right < other.len() {
258            match self.cpus[left].cmp(&other.cpus[right]) {
259                std::cmp::Ordering::Less => left += 1,
260                std::cmp::Ordering::Greater => right += 1,
261                std::cmp::Ordering::Equal => {
262                    intersection.push(self.cpus[left]);
263                    left += 1;
264                    right += 1;
265                }
266            }
267        }
268        (!intersection.is_empty()).then_some(Self {
269            cpus: intersection.into(),
270        })
271    }
272}
273
274/// One usable OS NUMA node and its process-allowed logical CPUs.
275///
276/// # Examples
277///
278/// ```
279/// use tenferro_cpu::{CpuId, CpuNode, CpuSet, NumaNodeId};
280///
281/// let node = CpuNode::new(NumaNodeId::new(2), CpuSet::new([CpuId::new(8)])?);
282/// assert_eq!(node.id(), NumaNodeId::new(2));
283/// # Ok::<(), tenferro_cpu::CpuSetError>(())
284/// ```
285#[derive(Clone, Debug, PartialEq, Eq)]
286pub struct CpuNode {
287    id: NumaNodeId,
288    cpus: CpuSet,
289}
290
291impl CpuNode {
292    /// Construct a usable NUMA node description.
293    ///
294    /// # Examples
295    ///
296    /// ```
297    /// use tenferro_cpu::{CpuId, CpuNode, CpuSet, NumaNodeId};
298    ///
299    /// let node = CpuNode::new(NumaNodeId::new(7), CpuSet::new([CpuId::new(12)])?);
300    /// assert_eq!(node.id(), NumaNodeId::new(7));
301    /// # Ok::<(), tenferro_cpu::CpuSetError>(())
302    /// ```
303    pub fn new(id: NumaNodeId, cpus: CpuSet) -> Self {
304        Self { id, cpus }
305    }
306
307    /// Return the sparse operating-system NUMA node ID.
308    ///
309    /// # Examples
310    ///
311    /// ```
312    /// use tenferro_cpu::{CpuId, CpuNode, CpuSet, NumaNodeId};
313    ///
314    /// let node = CpuNode::new(NumaNodeId::new(3), CpuSet::new([CpuId::new(0)])?);
315    /// assert_eq!(node.id().as_usize(), 3);
316    /// # Ok::<(), tenferro_cpu::CpuSetError>(())
317    /// ```
318    pub fn id(&self) -> NumaNodeId {
319        self.id
320    }
321
322    /// Return this node's process-allowed logical CPUs.
323    ///
324    /// # Examples
325    ///
326    /// ```
327    /// use tenferro_cpu::{CpuId, CpuNode, CpuSet, NumaNodeId};
328    ///
329    /// let node = CpuNode::new(NumaNodeId::new(0), CpuSet::new([CpuId::new(4)])?);
330    /// assert!(node.cpus().contains(CpuId::new(4)));
331    /// # Ok::<(), tenferro_cpu::CpuSetError>(())
332    /// ```
333    pub fn cpus(&self) -> &CpuSet {
334        &self.cpus
335    }
336}
337
338/// Failure to canonicalize discovered NUMA topology.
339///
340/// # Examples
341///
342/// ```
343/// use tenferro_cpu::{CpuTopologyError, NumaNodeId};
344///
345/// let error = CpuTopologyError::DuplicateNode { node: NumaNodeId::new(2) };
346/// assert!(error.to_string().contains("2"));
347/// ```
348#[derive(Clone, Debug, Error, PartialEq, Eq)]
349pub enum CpuTopologyError {
350    /// The process affinity mask contained no logical CPU.
351    #[error("the process-allowed CPU set is empty")]
352    EmptyAllowedCpuSet,
353    /// The process CPU affinity mask could not be obtained.
354    #[error("the process CPU affinity mask is unavailable")]
355    AffinityUnavailable,
356    /// A Linux sysfs CPU-list value was malformed or unreasonably large.
357    #[error("invalid Linux CPU list {list:?}: {reason}")]
358    InvalidCpuList {
359        /// The original sysfs CPU-list text.
360        list: String,
361        /// A stable human-readable parsing reason.
362        reason: &'static str,
363    },
364    /// Discovery reported the same OS NUMA node more than once.
365    #[error("NUMA node {node} was discovered more than once")]
366    DuplicateNode {
367        /// The duplicated OS NUMA node ID.
368        node: NumaNodeId,
369    },
370    /// Two usable NUMA nodes contained at least one common logical CPU.
371    #[error("NUMA nodes {first} and {second} overlap on CPUs {cpus:?}")]
372    OverlappingNodes {
373        /// The first overlapping OS node ID.
374        first: NumaNodeId,
375        /// The second overlapping OS node ID.
376        second: NumaNodeId,
377        /// Logical CPUs present in both usable node sets.
378        cpus: CpuSet,
379    },
380}
381
382#[cfg(any(test, target_os = "linux"))]
383pub(crate) trait TopologySource {
384    fn allowed_cpus(&self) -> Result<CpuSet, CpuTopologyError>;
385
386    fn numa_node_cpu_lists(&self) -> Result<Option<Vec<(NumaNodeId, String)>>, CpuTopologyError>;
387}
388
389#[cfg(any(test, target_os = "linux"))]
390pub(crate) fn discover_from(source: &impl TopologySource) -> Result<CpuTopology, CpuTopologyError> {
391    let allowed_cpus = source.allowed_cpus()?;
392    let Some(node_cpu_lists) = source.numa_node_cpu_lists()? else {
393        return Ok(CpuTopology::all_allowed(allowed_cpus));
394    };
395    let nodes = node_cpu_lists
396        .into_iter()
397        .map(|(id, cpus)| parse_linux_cpu_list(&cpus).map(|cpus| (id, cpus)))
398        .collect::<Result<Vec<_>, _>>()?;
399    CpuTopology::from_discovered(allowed_cpus, nodes)
400}
401
402#[cfg(any(test, target_os = "linux"))]
403pub(crate) fn parse_linux_cpu_list(input: &str) -> Result<CpuSet, CpuTopologyError> {
404    const MAX_PARSED_CPUS: usize = 1 << 20;
405
406    let invalid = |reason| CpuTopologyError::InvalidCpuList {
407        list: input.to_owned(),
408        reason,
409    };
410    let input = input.trim();
411    if input.is_empty() {
412        return Err(invalid("list is empty"));
413    }
414    let mut cpus = Vec::new();
415    for component in input.split(',') {
416        let component = component.trim();
417        if component.is_empty() {
418            return Err(invalid("empty list component"));
419        }
420        if let Some((start, end)) = component.split_once('-') {
421            if end.contains('-') {
422                return Err(invalid("range contains more than one separator"));
423            }
424            let start = start
425                .parse::<usize>()
426                .map_err(|_| invalid("range start is not a CPU number"))?;
427            let end = end
428                .parse::<usize>()
429                .map_err(|_| invalid("range end is not a CPU number"))?;
430            let span = end
431                .checked_sub(start)
432                .and_then(|distance| distance.checked_add(1))
433                .ok_or_else(|| invalid("range is reversed or overflows"))?;
434            if span > MAX_PARSED_CPUS || cpus.len().saturating_add(span) > MAX_PARSED_CPUS {
435                return Err(invalid("list contains too many CPUs"));
436            }
437            cpus.extend((start..=end).map(CpuId::new));
438        } else {
439            let cpu = component
440                .parse::<usize>()
441                .map_err(|_| invalid("component is not a CPU number"))?;
442            cpus.push(CpuId::new(cpu));
443            if cpus.len() > MAX_PARSED_CPUS {
444                return Err(invalid("list contains too many CPUs"));
445            }
446        }
447    }
448    CpuSet::new(cpus).map_err(|_| invalid("list is empty"))
449}
450
451/// Discover the process-visible CPU and NUMA topology.
452///
453/// Linux uses `/sys/devices/system/node` intersected with the process affinity
454/// mask. Other platforms, and Linux systems without readable NUMA node files,
455/// expose only the all-allowed execution domain.
456///
457/// # Examples
458///
459/// ```
460/// let topology = tenferro_cpu::discover_cpu_topology()?;
461/// assert!(!topology.allowed_cpus().is_empty());
462/// # Ok::<(), tenferro_cpu::CpuTopologyError>(())
463/// ```
464///
465/// # Errors
466///
467/// Returns [`CpuTopologyError`] when the process affinity or NUMA topology
468/// cannot be read or contains inconsistent CPU domains.
469pub fn discover_cpu_topology() -> Result<CpuTopology, CpuTopologyError> {
470    #[cfg(target_os = "linux")]
471    {
472        discover_from(&LinuxTopologySource)
473    }
474    #[cfg(not(target_os = "linux"))]
475    {
476        let allowed = crate::process_cpu_affinity().unwrap_or_else(|| {
477            CpuSet::new((0..crate::available_parallelism()).map(CpuId::new))
478                .unwrap_or_else(|_| CpuSet::singleton(CpuId::new(0)))
479        });
480        Ok(CpuTopology::all_allowed(allowed))
481    }
482}
483
484#[cfg(target_os = "linux")]
485struct LinuxTopologySource;
486
487#[cfg(target_os = "linux")]
488impl TopologySource for LinuxTopologySource {
489    fn allowed_cpus(&self) -> Result<CpuSet, CpuTopologyError> {
490        crate::process_cpu_affinity().ok_or(CpuTopologyError::AffinityUnavailable)
491    }
492
493    fn numa_node_cpu_lists(&self) -> Result<Option<Vec<(NumaNodeId, String)>>, CpuTopologyError> {
494        let entries = match std::fs::read_dir("/sys/devices/system/node") {
495            Ok(entries) => entries,
496            Err(_) => return Ok(None),
497        };
498        let mut nodes = Vec::new();
499        for entry in entries {
500            let entry = match entry {
501                Ok(entry) => entry,
502                Err(_) => return Ok(None),
503            };
504            let name = entry.file_name();
505            let Some(name) = name.to_str() else {
506                continue;
507            };
508            let Some(id) = name.strip_prefix("node") else {
509                continue;
510            };
511            if id.is_empty() || !id.bytes().all(|byte| byte.is_ascii_digit()) {
512                continue;
513            }
514            let Ok(id) = id.parse::<usize>() else {
515                continue;
516            };
517            let cpus = match std::fs::read_to_string(entry.path().join("cpulist")) {
518                Ok(cpus) => cpus,
519                Err(_) => return Ok(None),
520            };
521            nodes.push((NumaNodeId::new(id), cpus));
522        }
523        if nodes.is_empty() {
524            Ok(None)
525        } else {
526            nodes.sort_unstable_by_key(|(id, _)| *id);
527            Ok(Some(nodes))
528        }
529    }
530}
531
532/// Process-visible CPU topology used for execution placement.
533///
534/// Each usable NUMA node contains only CPUs also present in the process affinity
535/// mask. Empty nodes are omitted and OS node IDs are not renumbered.
536///
537/// # Examples
538///
539/// ```
540/// use tenferro_cpu::{CpuId, CpuSet, CpuTopology};
541///
542/// let topology = CpuTopology::all_allowed(CpuSet::new([CpuId::new(0)])?);
543/// assert_eq!(topology.allowed_cpus().len(), 1);
544/// assert!(!topology.has_numa_nodes());
545/// # Ok::<(), tenferro_cpu::CpuSetError>(())
546/// ```
547#[derive(Clone, Debug, PartialEq, Eq)]
548pub struct CpuTopology {
549    allowed_cpus: CpuSet,
550    nodes: Vec<CpuNode>,
551}
552
553impl CpuTopology {
554    /// Construct a topology with only the all-allowed execution domain.
555    ///
556    /// This is the portable fallback when NUMA discovery is unavailable.
557    ///
558    /// # Examples
559    ///
560    /// ```
561    /// use tenferro_cpu::{CpuId, CpuSet, CpuTopology};
562    ///
563    /// let topology = CpuTopology::all_allowed(CpuSet::new([CpuId::new(2)])?);
564    /// assert!(topology.nodes().is_empty());
565    /// # Ok::<(), tenferro_cpu::CpuSetError>(())
566    /// ```
567    pub fn all_allowed(allowed_cpus: CpuSet) -> Self {
568        Self {
569            allowed_cpus,
570            nodes: Vec::new(),
571        }
572    }
573
574    #[cfg(any(test, target_os = "linux"))]
575    pub(crate) fn from_discovered(
576        allowed_cpus: CpuSet,
577        nodes: impl IntoIterator<Item = (NumaNodeId, CpuSet)>,
578    ) -> Result<Self, CpuTopologyError> {
579        if allowed_cpus.is_empty() {
580            return Err(CpuTopologyError::EmptyAllowedCpuSet);
581        }
582        let mut usable = Vec::new();
583        let mut seen_node_ids = BTreeSet::new();
584        for (id, discovered_cpus) in nodes {
585            if !seen_node_ids.insert(id) {
586                return Err(CpuTopologyError::DuplicateNode { node: id });
587            }
588            if let Some(cpus) = discovered_cpus.intersection(&allowed_cpus) {
589                usable.push(CpuNode::new(id, cpus));
590            }
591        }
592        usable.sort_unstable_by_key(CpuNode::id);
593        for (index, left) in usable.iter().enumerate() {
594            for right in usable.iter().skip(index + 1) {
595                if let Some(cpus) = left.cpus.intersection(&right.cpus) {
596                    return Err(CpuTopologyError::OverlappingNodes {
597                        first: left.id,
598                        second: right.id,
599                        cpus,
600                    });
601                }
602            }
603        }
604        Ok(Self {
605            allowed_cpus,
606            nodes: usable,
607        })
608    }
609
610    /// Return the complete process affinity CPU set.
611    ///
612    /// # Examples
613    ///
614    /// ```
615    /// use tenferro_cpu::{CpuId, CpuSet, CpuTopology};
616    ///
617    /// let topology = CpuTopology::all_allowed(CpuSet::new([CpuId::new(1)])?);
618    /// assert!(topology.allowed_cpus().contains(CpuId::new(1)));
619    /// # Ok::<(), tenferro_cpu::CpuSetError>(())
620    /// ```
621    pub fn allowed_cpus(&self) -> &CpuSet {
622        &self.allowed_cpus
623    }
624
625    /// Return usable NUMA nodes ordered by their sparse OS node IDs.
626    ///
627    /// # Examples
628    ///
629    /// ```
630    /// use tenferro_cpu::{CpuId, CpuSet, CpuTopology};
631    ///
632    /// let topology = CpuTopology::all_allowed(CpuSet::new([CpuId::new(0)])?);
633    /// assert!(topology.nodes().is_empty());
634    /// # Ok::<(), tenferro_cpu::CpuSetError>(())
635    /// ```
636    pub fn nodes(&self) -> &[CpuNode] {
637        &self.nodes
638    }
639
640    /// Copy the usable sparse operating-system NUMA node IDs.
641    ///
642    /// # Examples
643    ///
644    /// ```
645    /// use tenferro_cpu::{CpuId, CpuSet, CpuTopology};
646    ///
647    /// let topology = CpuTopology::all_allowed(CpuSet::new([CpuId::new(0)])?);
648    /// assert!(topology.node_ids().is_empty());
649    /// # Ok::<(), tenferro_cpu::CpuSetError>(())
650    /// ```
651    pub fn node_ids(&self) -> Vec<NumaNodeId> {
652        self.nodes.iter().map(CpuNode::id).collect()
653    }
654
655    /// Look up a usable node by its original OS node ID.
656    ///
657    /// # Examples
658    ///
659    /// ```
660    /// use tenferro_cpu::{CpuId, CpuSet, CpuTopology, NumaNodeId};
661    ///
662    /// let topology = CpuTopology::all_allowed(CpuSet::new([CpuId::new(0)])?);
663    /// assert!(topology.node(NumaNodeId::new(0)).is_none());
664    /// # Ok::<(), tenferro_cpu::CpuSetError>(())
665    /// ```
666    pub fn node(&self, id: NumaNodeId) -> Option<&CpuNode> {
667        self.nodes
668            .binary_search_by_key(&id, CpuNode::id)
669            .ok()
670            .map(|index| &self.nodes[index])
671    }
672
673    /// Report whether NUMA discovery produced any usable node domains.
674    ///
675    /// # Examples
676    ///
677    /// ```
678    /// use tenferro_cpu::{CpuId, CpuSet, CpuTopology};
679    ///
680    /// let topology = CpuTopology::all_allowed(CpuSet::new([CpuId::new(0)])?);
681    /// assert!(!topology.has_numa_nodes());
682    /// # Ok::<(), tenferro_cpu::CpuSetError>(())
683    /// ```
684    pub fn has_numa_nodes(&self) -> bool {
685        !self.nodes.is_empty()
686    }
687}
688
689#[cfg(test)]
690mod tests;