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;