ktstr/vmm/
topology.rs

1/// CPU topology specification with NUMA memory topology.
2///
3/// Models the hierarchy: NUMA nodes → LLCs → cores → threads.
4///
5/// Each NUMA node owns a contiguous range of LLCs and a memory region.
6/// When `nodes` is `None` (the default), memory and LLCs are distributed
7/// uniformly across `numa_nodes` synthetic nodes with 10/20 distances.
8///
9/// Use [`new`](Self::new) for the simple uniform case, or
10/// [`with_nodes`](Self::with_nodes) for explicit per-node configuration.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub struct Topology {
13    /// Total number of last-level caches across the whole VM; must be
14    /// a multiple of `numa_nodes` when `nodes` is `None`.
15    pub llcs: u32,
16    /// Physical cores grouped into each LLC.
17    pub cores_per_llc: u32,
18    /// Hardware threads exposed per core (`1` = no SMT, `2` = SMT-2).
19    pub threads_per_core: u32,
20    /// Number of NUMA nodes.
21    pub numa_nodes: u32,
22    /// Per-node configuration. When `None`, LLCs and memory are
23    /// distributed uniformly. When `Some`, the slice length must
24    /// equal `numa_nodes` and the sum of all `NumaNode::llcs` must
25    /// equal `self.llcs`.
26    pub nodes: Option<&'static [NumaNode]>,
27    /// Inter-node distance matrix. When `None`, distances default to
28    /// 10 (local) / 20 (remote). When `Some`, the matrix dimension
29    /// must equal `numa_nodes`.
30    pub distances: Option<&'static NumaDistance>,
31}
32
33/// Per-NUMA-node configuration.
34///
35/// `llcs = 0` models a CXL memory-only node: the node has RAM but no
36/// CPUs or LLCs. Such nodes appear in the SRAT memory affinity table
37/// and SLIT distance matrix but contribute no CPU affinity entries.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub struct NumaNode {
40    /// Number of LLCs owned by this node. Zero means memory-only (CXL).
41    pub llcs: u32,
42    /// Memory attached to this node in MiB.
43    pub memory_mib: u32,
44    /// HMAT access latency in nanoseconds. `None` uses the default
45    /// (100ns for CPU-bearing, 300ns for memory-only). Emitted as
46    /// an SLLBI access_latency entry in the HMAT table by
47    /// `write_hmat` in `x86_64/acpi/mod.rs`. x86_64 only — aarch64 does
48    /// not expose an HMAT-equivalent to the guest.
49    pub latency_ns: Option<u32>,
50    /// HMAT read bandwidth in MB/s. `None` uses the default
51    /// (51200 MB/s for CPU-bearing, 20480 MB/s for memory-only).
52    /// Emitted as an SLLBI access_bandwidth entry in the HMAT table
53    /// by `write_hmat` in `x86_64/acpi/mod.rs`. x86_64 only.
54    pub bandwidth_mbs: Option<u32>,
55    /// HMAT Type 2 memory-side cache. `None` means no cache entry
56    /// is emitted for this node. x86_64 only.
57    pub mem_side_cache: Option<MemSideCache>,
58}
59
60/// HMAT Type 2 memory-side cache descriptor.
61///
62/// Models a hardware cache between the CPU and memory on this node
63/// (e.g. CXL HDM decoder cache, HBM cache). Emitted as an HMAT
64/// Memory Side Cache Information Structure.
65///
66/// `associativity` and `write_policy` occupy 4-bit nibbles in the
67/// HMAT cache_attributes field. Values above 15 are invalid.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69pub struct MemSideCache {
70    /// Cache size in bytes.
71    pub size: u64,
72    /// Cache associativity (0=none, 1=direct-mapped, 2=complex).
73    /// Must be <= 15 (4-bit field).
74    pub associativity: u8,
75    /// Write policy (0=none, 1=write-back, 2=write-through).
76    /// Must be <= 15 (4-bit field).
77    pub write_policy: u8,
78    /// Cache line size in bytes.
79    pub line_size: u16,
80}
81
82impl MemSideCache {
83    /// Const constructor with validation.
84    ///
85    /// # Panics
86    ///
87    /// Panics if `associativity > 15` or `write_policy > 15`
88    /// (4-bit HMAT nibble fields).
89    pub const fn new(size: u64, associativity: u8, write_policy: u8, line_size: u16) -> Self {
90        assert!(
91            associativity <= 15,
92            "MemSideCache: associativity must fit in 4 bits (0-15)"
93        );
94        assert!(
95            write_policy <= 15,
96            "MemSideCache: write_policy must fit in 4 bits (0-15)"
97        );
98        Self {
99            size,
100            associativity,
101            write_policy,
102            line_size,
103        }
104    }
105
106    /// Non-panicking validation.
107    pub fn validate(&self) -> Result<(), String> {
108        if self.associativity > 15 {
109            return Err(format!(
110                "associativity {} exceeds 4-bit maximum (15)",
111                self.associativity
112            ));
113        }
114        if self.write_policy > 15 {
115            return Err(format!(
116                "write_policy {} exceeds 4-bit maximum (15)",
117                self.write_policy
118            ));
119        }
120        Ok(())
121    }
122}
123
124impl NumaNode {
125    /// Const constructor.
126    pub const fn new(llcs: u32, memory_mib: u32) -> Self {
127        Self {
128            llcs,
129            memory_mib,
130            latency_ns: None,
131            bandwidth_mbs: None,
132            mem_side_cache: None,
133        }
134    }
135
136    /// Const constructor with HMAT attributes.
137    pub const fn with_hmat(
138        llcs: u32,
139        memory_mib: u32,
140        latency_ns: u32,
141        bandwidth_mbs: u32,
142    ) -> Self {
143        Self {
144            llcs,
145            memory_mib,
146            latency_ns: Some(latency_ns),
147            bandwidth_mbs: Some(bandwidth_mbs),
148            mem_side_cache: None,
149        }
150    }
151
152    /// Attach a memory-side cache descriptor.
153    pub const fn cache(mut self, cache: MemSideCache) -> Self {
154        self.mem_side_cache = Some(cache);
155        self
156    }
157
158    /// Whether this is a memory-only node (CXL: has RAM but no CPUs).
159    pub const fn is_memory_only(&self) -> bool {
160        self.llcs == 0
161    }
162}
163
164/// NxN inter-NUMA-node distance matrix.
165///
166/// Stored as a flat row-major array. ACPI SLIT requires diagonal = 10
167/// and off-diagonal > 10. ktstr additionally enforces symmetry.
168///
169/// Construct via [`NumaDistance::new`] which validates all invariants
170/// at const-eval time.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
172pub struct NumaDistance {
173    /// Number of NUMA nodes (matrix is `n x n`).
174    n: u32,
175    /// Row-major distance values. Length must be `n * n`.
176    entries: &'static [u8],
177}
178
179impl NumaDistance {
180    /// Const constructor with full validation.
181    ///
182    /// # Panics
183    ///
184    /// Panics if:
185    /// - `n == 0`
186    /// - `entries.len() != n * n`
187    /// - any diagonal entry is not 10
188    /// - any off-diagonal entry is not > 10
189    /// - the matrix is not symmetric
190    pub const fn new(n: u32, entries: &'static [u8]) -> Self {
191        assert!(n > 0, "NumaDistance: n must be > 0");
192        let expected = (n as usize) * (n as usize);
193        assert!(
194            entries.len() == expected,
195            "NumaDistance: entries.len() must equal n * n"
196        );
197        Self::validate_entries(n, entries);
198        Self { n, entries }
199    }
200
201    const fn validate_entries(n: u32, entries: &[u8]) {
202        let dim = n as usize;
203        let mut i = 0;
204        while i < dim {
205            let mut j = 0;
206            while j < dim {
207                let idx = i * dim + j;
208                if i == j {
209                    assert!(
210                        entries[idx] == 10,
211                        "NumaDistance: diagonal entry must be 10"
212                    );
213                } else {
214                    assert!(
215                        entries[idx] > 10,
216                        "NumaDistance: off-diagonal entry must be > 10"
217                    );
218                    let sym_idx = j * dim + i;
219                    assert!(
220                        entries[idx] == entries[sym_idx],
221                        "NumaDistance: matrix must be symmetric"
222                    );
223                }
224                j += 1;
225            }
226            i += 1;
227        }
228    }
229
230    /// Non-panicking validation.
231    pub fn validate(&self) -> Result<(), String> {
232        if self.n == 0 {
233            return Err("n must be > 0".into());
234        }
235        let expected = (self.n as usize) * (self.n as usize);
236        if self.entries.len() != expected {
237            return Err(format!(
238                "entries.len() ({}) must equal n * n ({})",
239                self.entries.len(),
240                expected
241            ));
242        }
243        let dim = self.n as usize;
244        for i in 0..dim {
245            for j in 0..dim {
246                let v = self.entries[i * dim + j];
247                if i == j {
248                    if v != 10 {
249                        return Err(format!("diagonal entry [{i}][{j}] is {v}, must be 10"));
250                    }
251                } else {
252                    if v <= 10 {
253                        return Err(format!(
254                            "off-diagonal entry [{i}][{j}] is {v}, must be > 10"
255                        ));
256                    }
257                    let sym = self.entries[j * dim + i];
258                    if v != sym {
259                        return Err(format!("asymmetric: [{i}][{j}]={v} != [{j}][{i}]={sym}"));
260                    }
261                }
262            }
263        }
264        Ok(())
265    }
266
267    /// Matrix dimension (number of NUMA nodes).
268    pub const fn dimension(&self) -> u32 {
269        self.n
270    }
271
272    /// Distance from node `i` to node `j`.
273    pub const fn distance(&self, i: u32, j: u32) -> u8 {
274        self.entries[(i as usize) * (self.n as usize) + (j as usize)]
275    }
276
277    /// Raw row-major entries.
278    pub const fn entries(&self) -> &[u8] {
279        self.entries
280    }
281}
282
283/// Formats as `{numa}n{llcs}l{cores}c{threads}t` — e.g. `1n2l4c2t` =
284/// 1 NUMA node, 2 LLCs, 4 cores/LLC, 2 threads/core.
285impl std::fmt::Display for Topology {
286    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287        write!(
288            f,
289            "{}n{}l{}c{}t",
290            self.numa_nodes, self.llcs, self.cores_per_llc, self.threads_per_core,
291        )
292    }
293}
294
295/// Error returned by [`Topology`]'s [`std::str::FromStr`] impl when the input does not
296/// match the `{numa}n{llcs}l{cores}c{threads}t` format or violates a
297/// Topology invariant (each component > 0, `llcs % numa_nodes == 0`,
298/// no `u32` overflow on total CPU count).
299#[derive(Debug, Clone, PartialEq, Eq, Hash, thiserror::Error)]
300pub enum TopologyParseError {
301    /// The input did not match `{numa}n{llcs}l{cores}c{threads}t`.
302    /// Carries the offending input for diagnostics.
303    #[error(
304        "topology string {0:?} does not match \
305         {{numa}}n{{llcs}}l{{cores}}c{{threads}}t — e.g. \"1n2l4c2t\""
306    )]
307    BadFormat(String),
308    /// A component (numa, llcs, cores, threads) was not a positive
309    /// integer (parse error, leading sign, zero).
310    #[error("topology component {0:?} must be a positive integer")]
311    BadComponent(String),
312    /// `llcs` is not a positive multiple of `numa_nodes`. Required by
313    /// [`Topology::new`] to evenly partition LLCs across NUMA nodes.
314    #[error("llcs ({llcs}) must be a positive multiple of numa_nodes ({numa_nodes})")]
315    NotMultiple { llcs: u32, numa_nodes: u32 },
316    /// The total CPU count (`llcs * cores_per_llc * threads_per_core`)
317    /// overflows `u32`. Required by [`Topology::new`] to ensure CPU
318    /// IDs fit in `u32`.
319    #[error("topology total CPU count overflows u32")]
320    Overflow,
321}
322
323/// Parse the [`Display`](#impl-Display-for-Topology) format back into a
324/// uniform `Topology` with `nodes = None` and `distances = None`.
325///
326/// # Lossy round-trip
327///
328/// Only topologies built via [`Topology::new`] (uniform layout, no
329/// per-node configuration, default 10/20 distances) round-trip cleanly
330/// through [`Display`](#impl-Display-for-Topology) → `FromStr`. A
331/// `Topology` built via [`Topology::with_nodes`] or chained with
332/// [`Topology::distances`] loses its per-node config + custom distance
333/// matrix through `Display` (which serializes only the 4 primitives);
334/// `FromStr` cannot reconstruct that information and produces a uniform
335/// `Topology` instead. Use the `Topology` value directly when full
336/// fidelity is required.
337impl std::str::FromStr for Topology {
338    type Err = TopologyParseError;
339
340    fn from_str(s: &str) -> Result<Self, Self::Err> {
341        let bad_format = || TopologyParseError::BadFormat(s.to_string());
342        let (numa_str, rest) = s.split_once('n').ok_or_else(bad_format)?;
343        let (llcs_str, rest) = rest.split_once('l').ok_or_else(bad_format)?;
344        let (cores_str, rest) = rest.split_once('c').ok_or_else(bad_format)?;
345        let (threads_str, tail) = rest.split_once('t').ok_or_else(bad_format)?;
346        if !tail.is_empty() {
347            return Err(bad_format());
348        }
349        let parse = |c: &str| -> Result<u32, TopologyParseError> {
350            let v: u32 = c
351                .parse()
352                .map_err(|_| TopologyParseError::BadComponent(c.to_string()))?;
353            if v == 0 {
354                return Err(TopologyParseError::BadComponent(c.to_string()));
355            }
356            Ok(v)
357        };
358        let numa_nodes = parse(numa_str)?;
359        let llcs = parse(llcs_str)?;
360        let cores_per_llc = parse(cores_str)?;
361        let threads_per_core = parse(threads_str)?;
362        if !llcs.is_multiple_of(numa_nodes) {
363            return Err(TopologyParseError::NotMultiple { llcs, numa_nodes });
364        }
365        let cpus_per_llc = cores_per_llc
366            .checked_mul(threads_per_core)
367            .ok_or(TopologyParseError::Overflow)?;
368        llcs.checked_mul(cpus_per_llc)
369            .ok_or(TopologyParseError::Overflow)?;
370        Ok(Topology {
371            llcs,
372            cores_per_llc,
373            threads_per_core,
374            numa_nodes,
375            nodes: None,
376            distances: None,
377        })
378    }
379}
380
381impl Topology {
382    /// Fallback topology used by
383    /// [`Payload::topology`](crate::test_support::Payload::topology)
384    /// for binary-kind payloads that have no scheduler-side topology
385    /// opinion. Matches the inline default in
386    /// [`KtstrTestEntry::DEFAULT`](crate::test_support::KtstrTestEntry::DEFAULT):
387    /// 1 NUMA node / 1 LLC / 2 cores / 1 thread (2 CPUs total), the
388    /// smallest VM shape that runs the harness meaningfully.
389    pub const DEFAULT_FOR_PAYLOAD: Topology = Topology {
390        llcs: 1,
391        cores_per_llc: 2,
392        threads_per_core: 1,
393        numa_nodes: 1,
394        nodes: None,
395        distances: None,
396    };
397
398    /// Validated const constructor for uniform topologies.
399    ///
400    /// Produces a topology where LLCs and memory are distributed evenly
401    /// across NUMA nodes, with default 10/20 distances.
402    ///
403    /// See [`validate`](Self::validate) for a non-panicking alternative.
404    ///
405    /// # Panics
406    ///
407    /// Panics if any invariant is violated:
408    /// - any of `llcs`, `cores_per_llc`, `threads_per_core`,
409    ///   `numa_nodes` is zero
410    /// - `llcs` is not divisible by `numa_nodes`
411    /// - total CPU count (`llcs * cores_per_llc * threads_per_core`)
412    ///   overflows `u32`
413    pub const fn new(
414        numa_nodes: u32,
415        llcs: u32,
416        cores_per_llc: u32,
417        threads_per_core: u32,
418    ) -> Self {
419        assert!(llcs > 0, "invalid Topology: llcs must be > 0");
420        assert!(
421            cores_per_llc > 0,
422            "invalid Topology: cores_per_llc must be > 0"
423        );
424        assert!(
425            threads_per_core > 0,
426            "invalid Topology: threads_per_core must be > 0"
427        );
428        assert!(numa_nodes > 0, "invalid Topology: numa_nodes must be > 0");
429        assert!(
430            llcs.is_multiple_of(numa_nodes),
431            "invalid Topology: llcs must be divisible by numa_nodes"
432        );
433        let cpus_per_llc = match cores_per_llc.checked_mul(threads_per_core) {
434            Some(v) => v,
435            None => panic!("invalid Topology: total CPU count overflows u32"),
436        };
437        match llcs.checked_mul(cpus_per_llc) {
438            Some(_) => {}
439            None => panic!("invalid Topology: total CPU count overflows u32"),
440        };
441        Topology {
442            llcs,
443            cores_per_llc,
444            threads_per_core,
445            numa_nodes,
446            nodes: None,
447            distances: None,
448        }
449    }
450
451    /// Const constructor with explicit per-node configuration.
452    ///
453    /// Total LLC count is computed from the sum of `NumaNode::llcs`
454    /// across all nodes. Memory-only nodes (llcs=0) are permitted.
455    ///
456    /// # Panics
457    ///
458    /// Panics if:
459    /// - `nodes` is empty
460    /// - `cores_per_llc == 0`
461    /// - `threads_per_core == 0`
462    /// - node LLC sum overflows `u32`
463    /// - a CPU-bearing node (`llcs > 0`) has `memory_mib == 0`
464    /// - total CPU count overflows `u32`
465    /// - no node has LLCs (at least one must have `llcs > 0`)
466    pub const fn with_nodes(
467        cores_per_llc: u32,
468        threads_per_core: u32,
469        nodes: &'static [NumaNode],
470    ) -> Self {
471        assert!(
472            !nodes.is_empty(),
473            "invalid Topology: nodes must not be empty"
474        );
475        assert!(
476            cores_per_llc > 0,
477            "invalid Topology: cores_per_llc must be > 0"
478        );
479        assert!(
480            threads_per_core > 0,
481            "invalid Topology: threads_per_core must be > 0"
482        );
483
484        let mut llcs: u32 = 0;
485        let mut i = 0;
486        while i < nodes.len() {
487            llcs = match llcs.checked_add(nodes[i].llcs) {
488                Some(v) => v,
489                None => panic!("invalid Topology: node LLC sum overflows u32"),
490            };
491            assert!(
492                !(nodes[i].llcs > 0 && nodes[i].memory_mib == 0),
493                "invalid Topology: CPU-bearing node has zero memory"
494            );
495            i += 1;
496        }
497        assert!(llcs > 0, "invalid Topology: total LLCs must be > 0");
498
499        let cpus_per_llc = match cores_per_llc.checked_mul(threads_per_core) {
500            Some(v) => v,
501            None => panic!("invalid Topology: total CPU count overflows u32"),
502        };
503        match llcs.checked_mul(cpus_per_llc) {
504            Some(_) => {}
505            None => panic!("invalid Topology: total CPU count overflows u32"),
506        };
507
508        Topology {
509            llcs,
510            cores_per_llc,
511            threads_per_core,
512            numa_nodes: nodes.len() as u32,
513            nodes: Some(nodes),
514            distances: None,
515        }
516    }
517
518    /// Attach a distance matrix.
519    ///
520    /// # Panics
521    ///
522    /// Panics if `distances.n != self.numa_nodes` — the matrix
523    /// dimension must equal the topology's NUMA node count.
524    pub const fn distances(mut self, distances: &'static NumaDistance) -> Self {
525        assert!(
526            distances.n == self.numa_nodes,
527            "invalid Topology: NumaDistance dimension must equal numa_nodes"
528        );
529        self.distances = Some(distances);
530        self
531    }
532
533    /// Non-panicking validation.
534    ///
535    /// Returns `Ok(())` if all invariants hold, or `Err` with a
536    /// description of the first violated invariant.
537    pub fn validate(&self) -> Result<(), String> {
538        if self.llcs == 0 {
539            return Err("llcs must be > 0".into());
540        }
541        if self.cores_per_llc == 0 {
542            return Err("cores_per_llc must be > 0".into());
543        }
544        if self.threads_per_core == 0 {
545            return Err("threads_per_core must be > 0".into());
546        }
547        if self.numa_nodes == 0 {
548            return Err("numa_nodes must be > 0".into());
549        }
550        if self
551            .cores_per_llc
552            .checked_mul(self.threads_per_core)
553            .and_then(|x| self.llcs.checked_mul(x))
554            .is_none()
555        {
556            return Err("total CPU count overflows u32".into());
557        }
558        match &self.nodes {
559            None => {
560                if !self.llcs.is_multiple_of(self.numa_nodes) {
561                    return Err(format!(
562                        "llcs ({}) must be divisible by numa_nodes ({})",
563                        self.llcs, self.numa_nodes,
564                    ));
565                }
566            }
567            Some(nodes) => {
568                if nodes.len() != self.numa_nodes as usize {
569                    return Err(format!(
570                        "nodes.len() ({}) must equal numa_nodes ({})",
571                        nodes.len(),
572                        self.numa_nodes,
573                    ));
574                }
575                let llc_sum: u32 = nodes.iter().map(|n| n.llcs).sum();
576                if llc_sum != self.llcs {
577                    return Err(format!(
578                        "sum of node LLCs ({llc_sum}) must equal total llcs ({})",
579                        self.llcs,
580                    ));
581                }
582                for (i, node) in nodes.iter().enumerate() {
583                    if node.llcs > 0 && node.memory_mib == 0 {
584                        return Err(format!("node {i} has {} LLCs but zero memory", node.llcs,));
585                    }
586                }
587            }
588        }
589        if let Some(d) = &self.distances {
590            if d.n != self.numa_nodes {
591                return Err(format!(
592                    "NumaDistance dimension ({}) must equal numa_nodes ({})",
593                    d.n, self.numa_nodes,
594                ));
595            }
596            d.validate()?;
597        }
598        Ok(())
599    }
600
601    /// Total vCPU count = `llcs * cores_per_llc * threads_per_core`.
602    pub fn total_cpus(&self) -> u32 {
603        self.llcs * self.cores_per_llc * self.threads_per_core
604    }
605
606    /// Number of LLC domains in the topology.
607    pub fn num_llcs(&self) -> u32 {
608        self.llcs
609    }
610
611    /// Number of NUMA nodes in the topology.
612    pub fn num_numa_nodes(&self) -> u32 {
613        self.numa_nodes
614    }
615
616    /// LLCs owned by NUMA node `node_id`.
617    ///
618    /// With explicit nodes, returns `nodes[node_id].llcs`.
619    /// With uniform distribution, returns `llcs / numa_nodes`.
620    pub fn llcs_in_node(&self, node_id: u32) -> u32 {
621        match &self.nodes {
622            Some(nodes) => nodes[node_id as usize].llcs,
623            None => self.llcs / self.numa_nodes,
624        }
625    }
626
627    /// LLCs per NUMA node (uniform distribution only).
628    ///
629    /// # Panics
630    ///
631    /// Panics if the topology uses explicit nodes (use
632    /// [`llcs_in_node`](Self::llcs_in_node) instead), if
633    /// `numa_nodes == 0`, or if `llcs` is not divisible by
634    /// `numa_nodes`.
635    pub fn llcs_per_numa_node(&self) -> u32 {
636        assert!(
637            self.nodes.is_none(),
638            "llcs_per_numa_node() requires uniform topology; use llcs_in_node() instead"
639        );
640        assert!(self.numa_nodes > 0, "numa_nodes must be > 0");
641        assert!(
642            self.llcs.is_multiple_of(self.numa_nodes),
643            "llcs ({}) must be divisible by numa_nodes ({})",
644            self.llcs,
645            self.numa_nodes,
646        );
647        self.llcs / self.numa_nodes
648    }
649
650    /// NUMA node that owns the given LLC index.
651    ///
652    /// With explicit nodes, walks the node list to find the owning node.
653    /// With uniform distribution, computes `llc_id / llcs_per_node`.
654    ///
655    /// Out-of-bounds `llc_id` (>= total LLCs): with explicit nodes,
656    /// saturates to the last node index; with uniform distribution, no
657    /// bounds check — returns `llc_id / llcs_per_node`, which may
658    /// exceed `numa_nodes - 1`.
659    pub fn numa_node_of(&self, llc_id: u32) -> u32 {
660        match &self.nodes {
661            Some(nodes) => {
662                let mut cumulative: u32 = 0;
663                for (i, node) in nodes.iter().enumerate() {
664                    cumulative += node.llcs;
665                    if llc_id < cumulative {
666                        return i as u32;
667                    }
668                }
669                (nodes.len() - 1) as u32
670            }
671            None => {
672                let per_node = self.llcs / self.numa_nodes;
673                llc_id / per_node
674            }
675        }
676    }
677
678    /// First LLC index owned by NUMA node `node_id`.
679    ///
680    /// Uniform topologies do not bounds-check and return
681    /// `node_id * llcs_per_node` for any input.
682    ///
683    /// # Panics
684    ///
685    /// Panics if `node_id > numa_nodes` for explicit-node topologies
686    /// (the walk would index past the end of the node slice).
687    pub fn first_llc_in_node(&self, node_id: u32) -> u32 {
688        match &self.nodes {
689            Some(nodes) => {
690                let mut offset: u32 = 0;
691                for i in 0..node_id as usize {
692                    offset += nodes[i].llcs;
693                }
694                offset
695            }
696            None => {
697                let per_node = self.llcs / self.numa_nodes;
698                node_id * per_node
699            }
700        }
701    }
702
703    /// Memory in MiB for NUMA node `node_id`.
704    ///
705    /// With explicit nodes, returns `nodes[node_id].memory_mib`.
706    /// With uniform distribution, returns `None` (caller must divide
707    /// total memory evenly).
708    pub fn node_memory_mib(&self, node_id: u32) -> Option<u32> {
709        self.nodes.map(|nodes| nodes[node_id as usize].memory_mib)
710    }
711
712    /// Total memory across all explicit nodes, or `None` for uniform.
713    pub fn total_node_memory_mib(&self) -> Option<u32> {
714        self.nodes
715            .map(|nodes| nodes.iter().map(|n| n.memory_mib).sum())
716    }
717
718    /// Distance from node `i` to node `j`.
719    ///
720    /// Returns the explicit distance if a matrix is attached,
721    /// otherwise 10 for local and 20 for remote.
722    pub fn distance(&self, i: u32, j: u32) -> u8 {
723        match &self.distances {
724            Some(d) => d.distance(i, j),
725            None => {
726                if i == j {
727                    10
728                } else {
729                    20
730                }
731            }
732        }
733    }
734
735    /// Whether any node is memory-only (CXL).
736    pub fn has_memory_only_nodes(&self) -> bool {
737        self.nodes
738            .is_some_and(|nodes| nodes.iter().any(|n| n.is_memory_only()))
739    }
740
741    /// Number of nodes that have CPUs (non-memory-only).
742    pub fn cpu_bearing_nodes(&self) -> u32 {
743        match &self.nodes {
744            Some(nodes) => nodes.iter().filter(|n| !n.is_memory_only()).count() as u32,
745            None => self.numa_nodes,
746        }
747    }
748
749    /// Decompose a logical CPU ID into (llc, core, thread).
750    pub fn decompose(&self, cpu_id: u32) -> (u32, u32, u32) {
751        let threads = self.threads_per_core;
752        let cores = self.cores_per_llc;
753        let thread_id = cpu_id % threads;
754        let core_id = (cpu_id / threads) % cores;
755        let llc_id = cpu_id / (threads * cores);
756        (llc_id, core_id, thread_id)
757    }
758}
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763
764    #[test]
765    fn topology_total_cpus() {
766        let t = Topology {
767            llcs: 2,
768            cores_per_llc: 4,
769            threads_per_core: 2,
770            numa_nodes: 1,
771            nodes: None,
772            distances: None,
773        };
774        assert_eq!(t.total_cpus(), 16);
775    }
776
777    #[test]
778    fn topology_num_llcs() {
779        let t = Topology {
780            llcs: 3,
781            cores_per_llc: 4,
782            threads_per_core: 2,
783            numa_nodes: 1,
784            nodes: None,
785            distances: None,
786        };
787        assert_eq!(t.num_llcs(), 3);
788    }
789
790    #[test]
791    fn decompose_simple() {
792        let t = Topology {
793            llcs: 2,
794            cores_per_llc: 2,
795            threads_per_core: 2,
796            numa_nodes: 1,
797            nodes: None,
798            distances: None,
799        };
800        assert_eq!(t.decompose(0), (0, 0, 0));
801        assert_eq!(t.decompose(1), (0, 0, 1));
802        assert_eq!(t.decompose(2), (0, 1, 0));
803        assert_eq!(t.decompose(3), (0, 1, 1));
804        assert_eq!(t.decompose(4), (1, 0, 0));
805        assert_eq!(t.decompose(5), (1, 0, 1));
806        assert_eq!(t.decompose(6), (1, 1, 0));
807        assert_eq!(t.decompose(7), (1, 1, 1));
808    }
809
810    #[test]
811    fn decompose_no_smt() {
812        let t = Topology {
813            llcs: 2,
814            cores_per_llc: 4,
815            threads_per_core: 1,
816            numa_nodes: 1,
817            nodes: None,
818            distances: None,
819        };
820        assert_eq!(t.decompose(0), (0, 0, 0));
821        assert_eq!(t.decompose(3), (0, 3, 0));
822        assert_eq!(t.decompose(4), (1, 0, 0));
823        assert_eq!(t.decompose(7), (1, 3, 0));
824    }
825
826    #[test]
827    fn decompose_single_llc() {
828        let t = Topology {
829            llcs: 1,
830            cores_per_llc: 4,
831            threads_per_core: 1,
832            numa_nodes: 1,
833            nodes: None,
834            distances: None,
835        };
836        assert_eq!(t.decompose(0), (0, 0, 0));
837        assert_eq!(t.decompose(3), (0, 3, 0));
838    }
839
840    #[test]
841    fn numa_node_of_single_node() {
842        let t = Topology {
843            llcs: 4,
844            cores_per_llc: 2,
845            threads_per_core: 1,
846            numa_nodes: 1,
847            nodes: None,
848            distances: None,
849        };
850        for llc in 0..4 {
851            assert_eq!(t.numa_node_of(llc), 0);
852        }
853    }
854
855    #[test]
856    fn numa_node_of_two_nodes() {
857        let t = Topology {
858            llcs: 4,
859            cores_per_llc: 2,
860            threads_per_core: 1,
861            numa_nodes: 2,
862            nodes: None,
863            distances: None,
864        };
865        assert_eq!(t.llcs_per_numa_node(), 2);
866        assert_eq!(t.numa_node_of(0), 0);
867        assert_eq!(t.numa_node_of(1), 0);
868        assert_eq!(t.numa_node_of(2), 1);
869        assert_eq!(t.numa_node_of(3), 1);
870    }
871
872    #[test]
873    fn numa_node_of_out_of_bounds_explicit_saturates() {
874        static TWO: [NumaNode; 2] = [NumaNode::new(2, 512), NumaNode::new(2, 512)];
875        let t = Topology::with_nodes(2, 1, &TWO);
876        // Total LLCs = 4. Any llc_id >= 4 must saturate to last node (1).
877        assert_eq!(t.numa_node_of(4), 1);
878        assert_eq!(t.numa_node_of(999), 1);
879    }
880
881    #[test]
882    fn numa_node_of_out_of_bounds_uniform_no_check() {
883        // Uniform: numa_nodes=2, llcs=4 => llcs_per_node=2.
884        // Per documented behavior: no bounds check, returns llc_id/2.
885        let t = Topology::new(2, 4, 2, 1);
886        assert_eq!(t.numa_node_of(100), 50);
887        assert!(t.numa_node_of(100) > t.numa_nodes - 1);
888    }
889
890    #[test]
891    fn num_numa_nodes() {
892        let t = Topology {
893            llcs: 6,
894            cores_per_llc: 4,
895            threads_per_core: 2,
896            numa_nodes: 3,
897            nodes: None,
898            distances: None,
899        };
900        assert_eq!(t.num_numa_nodes(), 3);
901        assert_eq!(t.llcs_per_numa_node(), 2);
902    }
903
904    #[test]
905    #[should_panic(expected = "llcs_per_numa_node() requires uniform topology")]
906    fn topology_llcs_per_numa_node_panics_on_explicit_nodes() {
907        static EXPLICIT: [NumaNode; 2] = [NumaNode::new(2, 512), NumaNode::new(2, 512)];
908        let t = Topology::with_nodes(2, 1, &EXPLICIT);
909        t.llcs_per_numa_node();
910    }
911
912    #[test]
913    #[should_panic(expected = "numa_nodes must be > 0")]
914    fn topology_llcs_per_numa_node_panics_on_zero_numa() {
915        // Direct construction bypasses Topology::new's numa_nodes>0 assert.
916        let t = Topology {
917            llcs: 2,
918            cores_per_llc: 1,
919            threads_per_core: 1,
920            numa_nodes: 0,
921            nodes: None,
922            distances: None,
923        };
924        t.llcs_per_numa_node();
925    }
926
927    #[test]
928    #[should_panic(expected = "must be divisible by numa_nodes")]
929    fn topology_llcs_per_numa_node_panics_on_indivisible() {
930        // 3 LLCs across 2 NUMA nodes — not evenly divisible.
931        let t = Topology {
932            llcs: 3,
933            cores_per_llc: 1,
934            threads_per_core: 1,
935            numa_nodes: 2,
936            nodes: None,
937            distances: None,
938        };
939        t.llcs_per_numa_node();
940    }
941
942    #[test]
943    fn new_valid() {
944        let t = Topology::new(2, 4, 2, 2);
945        assert_eq!(t.numa_nodes, 2);
946        assert_eq!(t.llcs, 4);
947        assert_eq!(t.cores_per_llc, 2);
948        assert_eq!(t.threads_per_core, 2);
949        assert!(t.nodes.is_none());
950        assert!(t.distances.is_none());
951    }
952
953    #[test]
954    fn new_single_everything() {
955        let t = Topology::new(1, 1, 1, 1);
956        assert_eq!(t.total_cpus(), 1);
957    }
958
959    #[test]
960    fn validate_valid() {
961        let t = Topology {
962            llcs: 4,
963            cores_per_llc: 2,
964            threads_per_core: 2,
965            numa_nodes: 2,
966            nodes: None,
967            distances: None,
968        };
969        assert!(t.validate().is_ok());
970    }
971
972    #[test]
973    fn validate_zero_llcs() {
974        let t = Topology {
975            llcs: 0,
976            cores_per_llc: 2,
977            threads_per_core: 1,
978            numa_nodes: 1,
979            nodes: None,
980            distances: None,
981        };
982        let err = t.validate().unwrap_err();
983        assert!(err.contains("llcs must be > 0"), "got: {err}");
984    }
985
986    #[test]
987    fn validate_zero_cores() {
988        let t = Topology {
989            llcs: 1,
990            cores_per_llc: 0,
991            threads_per_core: 1,
992            numa_nodes: 1,
993            nodes: None,
994            distances: None,
995        };
996        let err = t.validate().unwrap_err();
997        assert!(err.contains("cores_per_llc must be > 0"), "got: {err}");
998    }
999
1000    #[test]
1001    fn validate_zero_threads() {
1002        let t = Topology {
1003            llcs: 1,
1004            cores_per_llc: 2,
1005            threads_per_core: 0,
1006            numa_nodes: 1,
1007            nodes: None,
1008            distances: None,
1009        };
1010        let err = t.validate().unwrap_err();
1011        assert!(err.contains("threads_per_core must be > 0"), "got: {err}");
1012    }
1013
1014    #[test]
1015    fn validate_zero_numa_nodes() {
1016        let t = Topology {
1017            llcs: 1,
1018            cores_per_llc: 2,
1019            threads_per_core: 1,
1020            numa_nodes: 0,
1021            nodes: None,
1022            distances: None,
1023        };
1024        let err = t.validate().unwrap_err();
1025        assert!(err.contains("numa_nodes must be > 0"), "got: {err}");
1026    }
1027
1028    #[test]
1029    fn validate_llcs_not_divisible_by_numa() {
1030        let t = Topology {
1031            llcs: 3,
1032            cores_per_llc: 2,
1033            threads_per_core: 1,
1034            numa_nodes: 2,
1035            nodes: None,
1036            distances: None,
1037        };
1038        let err = t.validate().unwrap_err();
1039        assert!(err.contains("divisible"), "got: {err}");
1040    }
1041
1042    #[test]
1043    #[should_panic(expected = "invalid Topology: numa_nodes must be > 0")]
1044    fn topology_new_panics_on_zero_numa() {
1045        Topology::new(0, 2, 1, 1);
1046    }
1047
1048    #[test]
1049    #[should_panic(expected = "invalid Topology: llcs must be > 0")]
1050    fn topology_new_panics_on_zero_llcs() {
1051        Topology::new(1, 0, 2, 1);
1052    }
1053
1054    #[test]
1055    #[should_panic(expected = "invalid Topology: cores_per_llc must be > 0")]
1056    fn topology_new_panics_on_zero_cores() {
1057        Topology::new(1, 1, 0, 1);
1058    }
1059
1060    #[test]
1061    #[should_panic(expected = "invalid Topology: threads_per_core must be > 0")]
1062    fn topology_new_panics_on_zero_threads() {
1063        Topology::new(1, 1, 2, 0);
1064    }
1065
1066    #[test]
1067    #[should_panic(expected = "invalid Topology: llcs must be divisible by numa_nodes")]
1068    fn topology_new_panics_on_indivisible() {
1069        Topology::new(2, 3, 2, 1);
1070    }
1071
1072    #[test]
1073    #[should_panic(expected = "invalid Topology: total CPU count overflows u32")]
1074    fn topology_new_panics_on_llcs_times_cpus_overflow() {
1075        // llcs * cpus_per_llc overflows at L439 (cores*threads = 65536*2 succeeds at L435).
1076        Topology::new(1, 65536, 65536, 2);
1077    }
1078
1079    #[test]
1080    #[should_panic(expected = "invalid Topology: total CPU count overflows u32")]
1081    fn topology_new_panics_on_cores_times_threads_overflow() {
1082        // cores_per_llc * threads_per_core = 65536 * 65536 = 2^32 overflows
1083        // at L435 BEFORE the llcs multiplication at L439.
1084        Topology::new(1, 1, 65_536, 65_536);
1085    }
1086
1087    #[test]
1088    fn validate_overflow() {
1089        let t = Topology {
1090            llcs: 65536,
1091            cores_per_llc: 65536,
1092            threads_per_core: 2,
1093            numa_nodes: 1,
1094            nodes: None,
1095            distances: None,
1096        };
1097        let err = t.validate().unwrap_err();
1098        assert!(err.contains("overflows"), "got: {err}");
1099    }
1100
1101    #[test]
1102    fn display_format() {
1103        let t = Topology {
1104            llcs: 2,
1105            cores_per_llc: 4,
1106            threads_per_core: 2,
1107            numa_nodes: 1,
1108            nodes: None,
1109            distances: None,
1110        };
1111        assert_eq!(t.to_string(), "1n2l4c2t");
1112    }
1113
1114    #[test]
1115    fn display_format_multi_numa() {
1116        let t = Topology {
1117            llcs: 4,
1118            cores_per_llc: 8,
1119            threads_per_core: 2,
1120            numa_nodes: 2,
1121            nodes: None,
1122            distances: None,
1123        };
1124        assert_eq!(t.to_string(), "2n4l8c2t");
1125    }
1126
1127    // -- NumaNode tests --
1128
1129    #[test]
1130    fn numa_node_memory_only() {
1131        let n = NumaNode::new(0, 1024);
1132        assert!(n.is_memory_only());
1133    }
1134
1135    #[test]
1136    fn numa_node_with_cpus() {
1137        let n = NumaNode::new(2, 512);
1138        assert!(!n.is_memory_only());
1139    }
1140
1141    // -- NumaDistance tests --
1142
1143    #[test]
1144    fn numa_distance_single_node() {
1145        static D: NumaDistance = NumaDistance::new(1, &[10]);
1146        assert_eq!(D.dimension(), 1);
1147        assert_eq!(D.distance(0, 0), 10);
1148    }
1149
1150    #[test]
1151    fn numa_distance_two_nodes() {
1152        static D: NumaDistance = NumaDistance::new(2, &[10, 20, 20, 10]);
1153        assert_eq!(D.dimension(), 2);
1154        assert_eq!(D.distance(0, 0), 10);
1155        assert_eq!(D.distance(0, 1), 20);
1156        assert_eq!(D.distance(1, 0), 20);
1157        assert_eq!(D.distance(1, 1), 10);
1158    }
1159
1160    #[test]
1161    fn numa_distance_three_nodes_varied_weights() {
1162        static D: NumaDistance = NumaDistance::new(3, &[10, 20, 30, 20, 10, 40, 30, 40, 10]);
1163        assert_eq!(D.distance(0, 2), 30);
1164        assert_eq!(D.distance(1, 2), 40);
1165    }
1166
1167    #[test]
1168    fn numa_distance_validate_ok() {
1169        let d = NumaDistance {
1170            n: 2,
1171            entries: &[10, 20, 20, 10],
1172        };
1173        assert!(d.validate().is_ok());
1174    }
1175
1176    #[test]
1177    fn numa_distance_validate_bad_diagonal() {
1178        let d = NumaDistance {
1179            n: 2,
1180            entries: &[11, 20, 20, 10],
1181        };
1182        let err = d.validate().unwrap_err();
1183        assert!(err.contains("diagonal"), "got: {err}");
1184    }
1185
1186    #[test]
1187    fn numa_distance_validate_bad_offdiag() {
1188        let d = NumaDistance {
1189            n: 2,
1190            entries: &[10, 10, 10, 10],
1191        };
1192        let err = d.validate().unwrap_err();
1193        assert!(err.contains("off-diagonal"), "got: {err}");
1194    }
1195
1196    #[test]
1197    fn numa_distance_validate_asymmetric() {
1198        let d = NumaDistance {
1199            n: 2,
1200            entries: &[10, 20, 30, 10],
1201        };
1202        let err = d.validate().unwrap_err();
1203        assert!(err.contains("asymmetric"), "got: {err}");
1204    }
1205
1206    #[test]
1207    fn numa_distance_validate_wrong_size() {
1208        let d = NumaDistance {
1209            n: 2,
1210            entries: &[10, 20, 20],
1211        };
1212        let err = d.validate().unwrap_err();
1213        assert!(err.contains("n * n"), "got: {err}");
1214    }
1215
1216    // -- with_nodes tests --
1217
1218    static TWO_NODES: [NumaNode; 2] = [NumaNode::new(2, 512), NumaNode::new(2, 512)];
1219
1220    #[test]
1221    fn with_nodes_basic() {
1222        let t = Topology::with_nodes(4, 2, &TWO_NODES);
1223        assert_eq!(t.numa_nodes, 2);
1224        assert_eq!(t.llcs, 4);
1225        assert_eq!(t.total_cpus(), 32);
1226        assert!(t.nodes.is_some());
1227    }
1228
1229    #[test]
1230    fn with_nodes_numa_node_of() {
1231        let t = Topology::with_nodes(4, 2, &TWO_NODES);
1232        assert_eq!(t.numa_node_of(0), 0);
1233        assert_eq!(t.numa_node_of(1), 0);
1234        assert_eq!(t.numa_node_of(2), 1);
1235        assert_eq!(t.numa_node_of(3), 1);
1236    }
1237
1238    static ASYMMETRIC_NODES: [NumaNode; 2] = [NumaNode::new(1, 256), NumaNode::new(3, 768)];
1239
1240    #[test]
1241    fn with_nodes_asymmetric_llcs() {
1242        let t = Topology::with_nodes(2, 1, &ASYMMETRIC_NODES);
1243        assert_eq!(t.llcs_in_node(0), 1);
1244        assert_eq!(t.llcs_in_node(1), 3);
1245        assert_eq!(t.numa_node_of(0), 0);
1246        assert_eq!(t.numa_node_of(1), 1);
1247        assert_eq!(t.numa_node_of(2), 1);
1248        assert_eq!(t.numa_node_of(3), 1);
1249        assert_eq!(t.first_llc_in_node(0), 0);
1250        assert_eq!(t.first_llc_in_node(1), 1);
1251    }
1252
1253    #[test]
1254    fn with_nodes_memory() {
1255        let t = Topology::with_nodes(2, 1, &ASYMMETRIC_NODES);
1256        assert_eq!(t.node_memory_mib(0), Some(256));
1257        assert_eq!(t.node_memory_mib(1), Some(768));
1258        assert_eq!(t.total_node_memory_mib(), Some(1024));
1259    }
1260
1261    // -- CXL memory-only node tests --
1262
1263    static CXL_NODES: [NumaNode; 3] = [
1264        NumaNode::new(2, 512),
1265        NumaNode::new(2, 512),
1266        NumaNode::new(0, 1024),
1267    ];
1268
1269    #[test]
1270    fn cxl_memory_only_node() {
1271        let t = Topology::with_nodes(4, 1, &CXL_NODES);
1272        assert_eq!(t.numa_nodes, 3);
1273        assert!(t.has_memory_only_nodes());
1274        assert_eq!(t.cpu_bearing_nodes(), 2);
1275        assert_eq!(t.llcs_in_node(2), 0);
1276        assert_eq!(t.node_memory_mib(2), Some(1024));
1277    }
1278
1279    #[test]
1280    fn cxl_first_llc_in_memory_only_node() {
1281        let t = Topology::with_nodes(4, 1, &CXL_NODES);
1282        assert_eq!(t.first_llc_in_node(0), 0);
1283        assert_eq!(t.first_llc_in_node(1), 2);
1284        assert_eq!(t.first_llc_in_node(2), 4);
1285    }
1286
1287    static CXL_MIDDLE: [NumaNode; 3] = [
1288        NumaNode::new(2, 512),
1289        NumaNode::new(0, 256),
1290        NumaNode::new(2, 512),
1291    ];
1292
1293    #[test]
1294    fn numa_node_of_cxl_middle_node() {
1295        let t = Topology::with_nodes(4, 1, &CXL_MIDDLE);
1296        assert_eq!(t.numa_nodes, 3);
1297        assert_eq!(t.numa_node_of(0), 0);
1298        assert_eq!(t.numa_node_of(1), 0);
1299        assert_eq!(t.numa_node_of(2), 2);
1300        assert_eq!(t.numa_node_of(3), 2);
1301        assert!(t.has_memory_only_nodes());
1302        assert_eq!(t.cpu_bearing_nodes(), 2);
1303    }
1304
1305    #[test]
1306    fn first_llc_in_cxl_middle_node() {
1307        let t = Topology::with_nodes(4, 1, &CXL_MIDDLE);
1308        assert_eq!(t.first_llc_in_node(0), 0);
1309        assert_eq!(t.first_llc_in_node(1), 2);
1310        assert_eq!(t.first_llc_in_node(2), 2);
1311        assert_eq!(t.llcs_in_node(1), 0);
1312        assert_eq!(t.llcs_in_node(2), 2);
1313    }
1314
1315    static CXL_FIRST: [NumaNode; 3] = [
1316        NumaNode::new(0, 256),
1317        NumaNode::new(2, 512),
1318        NumaNode::new(2, 512),
1319    ];
1320
1321    #[test]
1322    fn cxl_first_node_numa_node_of() {
1323        let t = Topology::with_nodes(4, 1, &CXL_FIRST);
1324        assert_eq!(t.numa_node_of(0), 1);
1325        assert_eq!(t.numa_node_of(1), 1);
1326        assert_eq!(t.numa_node_of(2), 2);
1327        assert_eq!(t.numa_node_of(3), 2);
1328        assert_eq!(t.first_llc_in_node(0), 0);
1329        assert_eq!(t.first_llc_in_node(1), 0);
1330        assert_eq!(t.first_llc_in_node(2), 2);
1331    }
1332
1333    static MULTI_CXL: [NumaNode; 4] = [
1334        NumaNode::new(2, 512),
1335        NumaNode::new(0, 256),
1336        NumaNode::new(0, 256),
1337        NumaNode::new(2, 512),
1338    ];
1339
1340    #[test]
1341    fn multiple_consecutive_cxl_nodes() {
1342        let t = Topology::with_nodes(4, 1, &MULTI_CXL);
1343        assert_eq!(t.numa_nodes, 4);
1344        assert_eq!(t.cpu_bearing_nodes(), 2);
1345        assert_eq!(t.numa_node_of(0), 0);
1346        assert_eq!(t.numa_node_of(1), 0);
1347        assert_eq!(t.numa_node_of(2), 3);
1348        assert_eq!(t.numa_node_of(3), 3);
1349    }
1350
1351    static ASYMMETRIC_HEAVY: [NumaNode; 2] = [NumaNode::new(1, 256), NumaNode::new(7, 1792)];
1352
1353    #[test]
1354    fn highly_asymmetric_llcs() {
1355        let t = Topology::with_nodes(2, 1, &ASYMMETRIC_HEAVY);
1356        assert_eq!(t.numa_node_of(0), 0);
1357        assert_eq!(t.numa_node_of(1), 1);
1358        assert_eq!(t.numa_node_of(7), 1);
1359        assert_eq!(t.llcs_in_node(0), 1);
1360        assert_eq!(t.llcs_in_node(1), 7);
1361    }
1362
1363    static CXL_DIST: NumaDistance = NumaDistance::new(3, &[10, 20, 30, 20, 10, 25, 30, 25, 10]);
1364
1365    #[test]
1366    fn distance_with_cxl_middle() {
1367        let t = Topology::with_nodes(4, 1, &CXL_MIDDLE).distances(&CXL_DIST);
1368        assert_eq!(t.distance(1, 2), 25);
1369        assert_eq!(t.distance(0, 1), 20);
1370        assert!(t.validate().is_ok());
1371    }
1372
1373    // -- distances tests --
1374
1375    static DIST_2: NumaDistance = NumaDistance::new(2, &[10, 20, 20, 10]);
1376
1377    #[test]
1378    fn distances() {
1379        let t = Topology::new(2, 4, 2, 1).distances(&DIST_2);
1380        assert_eq!(t.distance(0, 0), 10);
1381        assert_eq!(t.distance(0, 1), 20);
1382        assert_eq!(t.distance(1, 0), 20);
1383        assert_eq!(t.distance(1, 1), 10);
1384    }
1385
1386    #[test]
1387    fn default_distances() {
1388        let t = Topology::new(2, 4, 2, 1);
1389        assert_eq!(t.distance(0, 0), 10);
1390        assert_eq!(t.distance(0, 1), 20);
1391    }
1392
1393    static DIST_3: NumaDistance = NumaDistance::new(3, &[10, 20, 30, 20, 10, 25, 30, 25, 10]);
1394
1395    #[test]
1396    fn with_nodes_then_distances() {
1397        let t = Topology::with_nodes(4, 1, &CXL_NODES).distances(&DIST_3);
1398        assert_eq!(t.distance(0, 2), 30);
1399        assert_eq!(t.distance(1, 2), 25);
1400        assert!(t.validate().is_ok());
1401    }
1402
1403    // -- Validation with nodes --
1404
1405    #[test]
1406    fn validate_with_nodes_ok() {
1407        let t = Topology::with_nodes(4, 2, &TWO_NODES);
1408        assert!(t.validate().is_ok());
1409    }
1410
1411    #[test]
1412    fn validate_with_nodes_llc_mismatch() {
1413        static BAD: [NumaNode; 2] = [NumaNode::new(1, 256), NumaNode::new(1, 256)];
1414        let t = Topology {
1415            llcs: 4,
1416            cores_per_llc: 2,
1417            threads_per_core: 1,
1418            numa_nodes: 2,
1419            nodes: Some(&BAD),
1420            distances: None,
1421        };
1422        let err = t.validate().unwrap_err();
1423        assert!(err.contains("sum of node LLCs"), "got: {err}");
1424    }
1425
1426    #[test]
1427    fn validate_with_nodes_count_mismatch() {
1428        let t = Topology {
1429            llcs: 4,
1430            cores_per_llc: 2,
1431            threads_per_core: 1,
1432            numa_nodes: 3,
1433            nodes: Some(&TWO_NODES),
1434            distances: None,
1435        };
1436        let err = t.validate().unwrap_err();
1437        assert!(err.contains("nodes.len()"), "got: {err}");
1438    }
1439
1440    #[test]
1441    fn validate_distance_dimension_mismatch() {
1442        static BAD_DIST: NumaDistance = NumaDistance::new(1, &[10]);
1443        let t = Topology {
1444            llcs: 4,
1445            cores_per_llc: 2,
1446            threads_per_core: 1,
1447            numa_nodes: 2,
1448            nodes: None,
1449            distances: Some(&BAD_DIST),
1450        };
1451        let err = t.validate().unwrap_err();
1452        assert!(err.contains("dimension"), "got: {err}");
1453    }
1454
1455    #[test]
1456    fn validate_cpu_node_zero_memory() {
1457        static BAD: [NumaNode; 2] = [NumaNode::new(2, 0), NumaNode::new(2, 512)];
1458        let t = Topology {
1459            llcs: 4,
1460            cores_per_llc: 2,
1461            threads_per_core: 1,
1462            numa_nodes: 2,
1463            nodes: Some(&BAD),
1464            distances: None,
1465        };
1466        let err = t.validate().unwrap_err();
1467        assert!(err.contains("zero memory"), "got: {err}");
1468    }
1469
1470    #[test]
1471    fn uniform_no_node_memory() {
1472        let t = Topology::new(2, 4, 2, 1);
1473        assert!(t.node_memory_mib(0).is_none());
1474        assert!(t.total_node_memory_mib().is_none());
1475    }
1476
1477    // -- const construction smoke tests --
1478
1479    const _CONST_TOPO: Topology = Topology::new(1, 2, 4, 2);
1480
1481    static _CONST_NODES: [NumaNode; 2] = [NumaNode::new(1, 256), NumaNode::new(1, 256)];
1482    const _CONST_WITH_NODES: Topology = Topology::with_nodes(4, 2, &_CONST_NODES);
1483
1484    static _CONST_DIST: NumaDistance = NumaDistance::new(2, &[10, 20, 20, 10]);
1485    const _CONST_WITH_DISTANCES: Topology = Topology::new(2, 2, 4, 2).distances(&_CONST_DIST);
1486
1487    #[test]
1488    fn const_construction_valid() {
1489        assert!(_CONST_TOPO.validate().is_ok());
1490        assert!(_CONST_WITH_NODES.validate().is_ok());
1491        assert!(_CONST_WITH_DISTANCES.validate().is_ok());
1492    }
1493
1494    // -- single node edge case --
1495
1496    #[test]
1497    fn single_node_topology() {
1498        let t = Topology::new(1, 1, 1, 1);
1499        assert_eq!(t.total_cpus(), 1);
1500        assert_eq!(t.numa_node_of(0), 0);
1501        assert_eq!(t.distance(0, 0), 10);
1502        assert!(!t.has_memory_only_nodes());
1503        assert_eq!(t.cpu_bearing_nodes(), 1);
1504    }
1505
1506    #[test]
1507    fn first_llc_in_node_uniform() {
1508        let t = Topology::new(2, 4, 2, 1);
1509        assert_eq!(t.first_llc_in_node(0), 0);
1510        assert_eq!(t.first_llc_in_node(1), 2);
1511    }
1512
1513    #[test]
1514    fn first_llc_in_node_at_numa_nodes_returns_total() {
1515        // Documented behavior: node_id == numa_nodes does NOT panic for
1516        // explicit nodes; the walk sums all llcs and returns the total.
1517        let t = Topology::with_nodes(2, 1, &TWO_NODES);
1518        assert_eq!(t.first_llc_in_node(2), 4);
1519    }
1520
1521    #[test]
1522    #[should_panic(expected = "index out of bounds")]
1523    fn topology_first_llc_in_node_panics_on_index_above_numa_nodes() {
1524        // Documented behavior: node_id > numa_nodes indexes past the
1525        // end of the node slice on the walk, panicking.
1526        let t = Topology::with_nodes(2, 1, &TWO_NODES);
1527        let _ = t.first_llc_in_node(3);
1528    }
1529
1530    // -- Topology FromStr / Display round-trip (uniform topologies) --
1531
1532    #[test]
1533    fn topology_fromstr_display_roundtrip_uniform() {
1534        let t = Topology::new(2, 4, 8, 2);
1535        let s = t.to_string();
1536        assert_eq!(s, "2n4l8c2t");
1537        let parsed: Topology = s.parse().unwrap();
1538        assert_eq!(parsed, t);
1539    }
1540
1541    #[test]
1542    fn topology_fromstr_minimal() {
1543        let parsed: Topology = "1n1l1c1t".parse().unwrap();
1544        assert_eq!(parsed, Topology::new(1, 1, 1, 1));
1545    }
1546
1547    #[test]
1548    fn topology_fromstr_default_for_payload_roundtrip() {
1549        // Uniform DEFAULT_FOR_PAYLOAD round-trips cleanly.
1550        let s = Topology::DEFAULT_FOR_PAYLOAD.to_string();
1551        let parsed: Topology = s.parse().unwrap();
1552        assert_eq!(parsed, Topology::DEFAULT_FOR_PAYLOAD);
1553    }
1554
1555    #[test]
1556    fn topology_fromstr_rejects_empty() {
1557        let err = "".parse::<Topology>().unwrap_err();
1558        assert!(matches!(err, TopologyParseError::BadFormat(s) if s.is_empty()));
1559    }
1560
1561    #[test]
1562    fn topology_fromstr_rejects_missing_separator() {
1563        assert!(matches!(
1564            "1n2l4c".parse::<Topology>(),
1565            Err(TopologyParseError::BadFormat(_))
1566        ));
1567        assert!(matches!(
1568            "1n2l4c2".parse::<Topology>(),
1569            Err(TopologyParseError::BadFormat(_))
1570        ));
1571    }
1572
1573    #[test]
1574    fn topology_fromstr_rejects_trailing_garbage() {
1575        assert!(matches!(
1576            "1n2l4c2tjunk".parse::<Topology>(),
1577            Err(TopologyParseError::BadFormat(_))
1578        ));
1579    }
1580
1581    #[test]
1582    fn topology_fromstr_rejects_zero_component() {
1583        for s in ["0n1l1c1t", "1n0l1c1t", "1n1l0c1t", "1n1l1c0t"] {
1584            assert!(
1585                matches!(
1586                    s.parse::<Topology>(),
1587                    Err(TopologyParseError::BadComponent(_))
1588                ),
1589                "expected BadComponent for {s}",
1590            );
1591        }
1592    }
1593
1594    #[test]
1595    fn topology_fromstr_rejects_non_numeric() {
1596        assert!(matches!(
1597            "Xn1l1c1t".parse::<Topology>(),
1598            Err(TopologyParseError::BadComponent(_))
1599        ));
1600        assert!(matches!(
1601            "1n-1l1c1t".parse::<Topology>(),
1602            Err(TopologyParseError::BadComponent(_))
1603        ));
1604    }
1605
1606    #[test]
1607    fn topology_fromstr_rejects_llcs_not_multiple_of_numa_nodes() {
1608        // 2 NUMA nodes but 3 LLCs is not divisible.
1609        let err = "2n3l1c1t".parse::<Topology>().unwrap_err();
1610        assert!(matches!(
1611            err,
1612            TopologyParseError::NotMultiple {
1613                llcs: 3,
1614                numa_nodes: 2
1615            }
1616        ));
1617    }
1618
1619    #[test]
1620    fn topology_fromstr_rejects_total_cpu_overflow() {
1621        // 2 * 65536 = 131072 LLCs, * 65536 * 1 threads = overflow u32.
1622        // Pick numbers that overflow: cores_per_llc * threads_per_core overflows u32 first.
1623        let err = "1n1l65536c65537t".parse::<Topology>().unwrap_err();
1624        assert!(matches!(err, TopologyParseError::Overflow));
1625    }
1626
1627    #[test]
1628    fn topology_hash_consistent_with_eq() {
1629        use std::collections::HashSet;
1630        let t1 = Topology::new(2, 4, 8, 2);
1631        let t2 = Topology::new(2, 4, 8, 2);
1632        let mut set: HashSet<Topology> = HashSet::new();
1633        set.insert(t1);
1634        assert!(set.contains(&t2));
1635    }
1636
1637    #[test]
1638    fn numa_node_hash_consistent_with_eq() {
1639        use std::collections::HashSet;
1640        let n1 = NumaNode {
1641            llcs: 2,
1642            memory_mib: 4096,
1643            latency_ns: Some(100),
1644            bandwidth_mbs: Some(51200),
1645            mem_side_cache: None,
1646        };
1647        let n2 = NumaNode {
1648            llcs: 2,
1649            memory_mib: 4096,
1650            latency_ns: Some(100),
1651            bandwidth_mbs: Some(51200),
1652            mem_side_cache: None,
1653        };
1654        let mut set: HashSet<NumaNode> = HashSet::new();
1655        set.insert(n1);
1656        assert!(set.contains(&n2));
1657    }
1658
1659    #[test]
1660    fn topology_fromstr_per_node_topology_is_lossy() {
1661        // A Topology built via with_nodes carries per-node configuration
1662        // that does NOT survive the Display → FromStr round-trip.
1663        // Documented lossy behavior — verify the parsed result has
1664        // nodes = None and distances = None even when the source had
1665        // explicit per-node config.
1666        let original = Topology::with_nodes(2, 1, &TWO_NODES);
1667        let s = original.to_string();
1668        let parsed: Topology = s.parse().unwrap();
1669        assert!(parsed.nodes.is_none(), "FromStr must produce nodes = None");
1670        assert!(
1671            parsed.distances.is_none(),
1672            "FromStr must produce distances = None"
1673        );
1674        // The 4 uniform primitives DO survive.
1675        assert_eq!(parsed.numa_nodes, original.numa_nodes);
1676        assert_eq!(parsed.llcs, original.llcs);
1677        assert_eq!(parsed.cores_per_llc, original.cores_per_llc);
1678        assert_eq!(parsed.threads_per_core, original.threads_per_core);
1679    }
1680
1681    // -- MemSideCache::new should_panic cluster --
1682
1683    #[test]
1684    #[should_panic(expected = "MemSideCache: associativity must fit in 4 bits")]
1685    fn mem_side_cache_new_panics_on_associativity_above_15() {
1686        MemSideCache::new(4096, 16, 1, 64);
1687    }
1688
1689    #[test]
1690    #[should_panic(expected = "MemSideCache: write_policy must fit in 4 bits")]
1691    fn mem_side_cache_new_panics_on_write_policy_above_15() {
1692        MemSideCache::new(4096, 8, 16, 64);
1693    }
1694
1695    // -- NumaDistance::new should_panic cluster (ACPI SLIT invariants) --
1696
1697    #[test]
1698    #[should_panic(expected = "NumaDistance: n must be > 0")]
1699    fn numa_distance_new_panics_on_zero_n() {
1700        static EMPTY: [u8; 0] = [];
1701        NumaDistance::new(0, &EMPTY);
1702    }
1703
1704    #[test]
1705    #[should_panic(expected = "NumaDistance: entries.len() must equal n * n")]
1706    fn numa_distance_new_panics_on_size_mismatch() {
1707        static THREE_ENTRIES: [u8; 3] = [10, 20, 10];
1708        // n=2 expects 4 entries, but only 3 supplied.
1709        NumaDistance::new(2, &THREE_ENTRIES);
1710    }
1711
1712    #[test]
1713    #[should_panic(expected = "NumaDistance: diagonal entry must be 10")]
1714    fn numa_distance_new_panics_on_nonten_diagonal() {
1715        // n=2 with diagonal[0][0] = 9 instead of 10.
1716        static BAD_DIAG: [u8; 4] = [9, 20, 20, 10];
1717        NumaDistance::new(2, &BAD_DIAG);
1718    }
1719
1720    #[test]
1721    #[should_panic(expected = "NumaDistance: off-diagonal entry must be > 10")]
1722    fn numa_distance_new_panics_on_offdiagonal_at_or_below_ten() {
1723        // n=2 with off-diagonal == 10 (must be > 10 per ACPI SLIT).
1724        static OFF_DIAG_TEN: [u8; 4] = [10, 10, 10, 10];
1725        NumaDistance::new(2, &OFF_DIAG_TEN);
1726    }
1727
1728    #[test]
1729    #[should_panic(expected = "NumaDistance: matrix must be symmetric")]
1730    fn numa_distance_new_panics_on_asymmetric_matrix() {
1731        // n=2 with entries[0][1] = 20 but entries[1][0] = 30.
1732        static ASYMMETRIC: [u8; 4] = [10, 20, 30, 10];
1733        NumaDistance::new(2, &ASYMMETRIC);
1734    }
1735
1736    // -- Topology::with_nodes should_panic cluster --
1737
1738    #[test]
1739    #[should_panic(expected = "invalid Topology: nodes must not be empty")]
1740    fn topology_with_nodes_panics_on_empty_nodes() {
1741        static EMPTY: [NumaNode; 0] = [];
1742        Topology::with_nodes(2, 1, &EMPTY);
1743    }
1744
1745    #[test]
1746    #[should_panic(expected = "invalid Topology: cores_per_llc must be > 0")]
1747    fn topology_with_nodes_panics_on_zero_cores() {
1748        static NODES: [NumaNode; 1] = [NumaNode::new(2, 512)];
1749        Topology::with_nodes(0, 1, &NODES);
1750    }
1751
1752    #[test]
1753    #[should_panic(expected = "invalid Topology: threads_per_core must be > 0")]
1754    fn topology_with_nodes_panics_on_zero_threads() {
1755        static NODES: [NumaNode; 1] = [NumaNode::new(2, 512)];
1756        Topology::with_nodes(2, 0, &NODES);
1757    }
1758
1759    #[test]
1760    #[should_panic(expected = "invalid Topology: node LLC sum overflows u32")]
1761    fn topology_with_nodes_panics_on_llc_sum_overflow() {
1762        // iter 0: accumulator = 0 + u32::MAX = u32::MAX (OK; node is
1763        // cpu-bearing with non-zero memory so the L491 check passes).
1764        // iter 1: u32::MAX.checked_add(1) = None → panic at L489.
1765        static NODES: [NumaNode; 2] = [NumaNode::new(u32::MAX, 512), NumaNode::new(1, 512)];
1766        Topology::with_nodes(1, 1, &NODES);
1767    }
1768
1769    #[test]
1770    #[should_panic(expected = "invalid Topology: CPU-bearing node has zero memory")]
1771    fn topology_with_nodes_panics_on_cpu_node_zero_memory() {
1772        // llcs > 0 with memory_mib == 0 is rejected.
1773        static BAD_MEM: [NumaNode; 1] = [NumaNode::new(2, 0)];
1774        Topology::with_nodes(2, 1, &BAD_MEM);
1775    }
1776
1777    #[test]
1778    #[should_panic(expected = "invalid Topology: total LLCs must be > 0")]
1779    fn topology_with_nodes_panics_on_no_llcs() {
1780        // All memory-only nodes (llcs == 0) — total LLCs is 0.
1781        static MEMORY_ONLY: [NumaNode; 1] = [NumaNode::new(0, 512)];
1782        Topology::with_nodes(2, 1, &MEMORY_ONLY);
1783    }
1784
1785    #[test]
1786    #[should_panic(expected = "invalid Topology: total CPU count overflows u32")]
1787    fn topology_with_nodes_panics_on_cpu_count_overflow() {
1788        // cores_per_llc * threads_per_core overflows u32.
1789        static NODES: [NumaNode; 1] = [NumaNode::new(1, 512)];
1790        Topology::with_nodes(65_536, 65_536, &NODES);
1791    }
1792
1793    // -- Topology::distances should_panic test --
1794
1795    #[test]
1796    #[should_panic(expected = "invalid Topology: NumaDistance dimension must equal numa_nodes")]
1797    fn topology_distances_panics_on_dimension_mismatch() {
1798        // Topology has numa_nodes = 2 (uniform); attach a 3x3 distance
1799        // matrix. distances.n = 3 != 2 = numa_nodes → panic.
1800        static D3X3: [u8; 9] = [10, 20, 20, 20, 10, 20, 20, 20, 10];
1801        static DIST: NumaDistance = NumaDistance::new(3, &D3X3);
1802        Topology::new(2, 2, 1, 1).distances(&DIST);
1803    }
1804}