windows_topology_sys/topology.rs
1// Copyright (c) 2026 Mike Grier
2//! Assembling a [`MachineMemoryTopology`] from discovered relations.
3
4use std::collections::{BTreeMap, BTreeSet};
5use std::io;
6
7use crate::EnumerationAnomaly;
8use crate::cpu_set::CpuSet;
9use crate::domain::{Domain, DomainKind, Processor, ProcessorFacts, ProcessorId};
10use crate::observation::{AttributeObservation, Observation, ProcessorAttribute, Source};
11use crate::observed::Observed;
12use crate::processor_set::ProcessorSet;
13use crate::provenance::Provenance;
14use crate::relation::{self, Relations};
15/// How many passes [`MachineMemoryTopology::discover`] makes before concluding
16/// that a disagreement is genuine.
17///
18/// Three, because the bound's job is to separate "the machine changed while we
19/// were looking" from "these two APIs disagree", and one repeat already does
20/// that: a hot-add is not repeated microseconds later. The third pass is
21/// slack for two changes landing in quick succession, and the cost of being
22/// wrong in this direction is one more cheap enumeration, where the cost of
23/// being wrong in the other is reporting a transient as genuine.
24const COHERENCE_ATTEMPTS: u32 = 3;
25
26/// How the two Win32 sources agreed when a topology was collected.
27///
28/// [`MachineMemoryTopology::discover`] reads the relationship walk and the
29/// CPU-set enumeration in two separate calls, and Windows offers no
30/// transactional way to read them together -- so the pair may describe
31/// different instants. [D-16](../DESIGN-NOTES.md#d-16) is the decision this
32/// type implements: retry to remove the transient cases, then represent
33/// whatever survives.
34///
35/// **What is compared is which processors exist**, because that is the
36/// disagreement a second pass can actually settle. A processor hot-added or
37/// removed between the two calls appears in one source and not the other, and
38/// is not hot-added again a microsecond later. Disagreements about how
39/// processors are *grouped* -- which core, which node -- are deliberately not
40/// retried: [D-17](../DESIGN-NOTES.md#d-17) establishes those are expected in
41/// the field and persistent, so re-reading cannot settle them and they are
42/// carried as separate per-source observations instead.
43///
44/// # Serialized spelling
45///
46/// `snake_case`, like [`Provenance`](crate::Provenance),
47/// [`Source`](crate::Source) and [`ProcessorAttribute`](crate::ProcessorAttribute),
48/// and like the lowercase names `Domain`'s hand-written serializer emits for
49/// [`DomainKind`](crate::DomainKind). Every serialized name this crate produces
50/// is lowercase; this one was derived without the attribute and so spelled its
51/// variants `NotCollected` and `Disagreed` until that was corrected.
52#[derive(Clone, Debug, Default, PartialEq, Eq)]
53#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
54#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
55pub enum Coherence {
56 /// Not collected from a running system, so the question does not arise.
57 ///
58 /// A hand-built or deserialized topology takes this: nothing was read
59 /// twice, so there is nothing to have agreed. The default for the same
60 /// reason.
61 #[default]
62 NotCollected,
63 /// Both sources described the same processors on the pass this topology
64 /// was built from.
65 Agreed,
66 /// They never agreed within the retry bound, so the disagreement is
67 /// genuine rather than transient, and is reported rather than hidden.
68 ///
69 /// Per [D-16](../DESIGN-NOTES.md#d-16), exhausting the bound is not a
70 /// failure to collect -- it is the *conclusion* that the disagreement is
71 /// real. The topology is returned, and this says what differed on the
72 /// final pass so a caller can decide what that means for its own use.
73 Disagreed {
74 /// Processors the relationship walk reported and CPU Sets did not.
75 walk_only: Vec<ProcessorId>,
76 /// Processors CPU Sets reported and the relationship walk did not.
77 ///
78 /// **These are absent from [`MachineMemoryTopology::processors`]**,
79 /// which carries the walk's view alone. This field is where they are
80 /// named, and reading it is the only way to learn they exist -- see
81 /// [D-26](../DESIGN-NOTES.md#d-26) for why they are reported here
82 /// rather than synthesized into a list that has one source.
83 cpu_sets_only: Vec<ProcessorId>,
84 /// How many passes were made before giving up.
85 attempts: u32,
86 },
87}
88
89/// A processor, cache, and memory topology: a set of processors and the
90/// domains that relate them.
91///
92/// Built either by [`MachineMemoryTopology::discover`] from the running system, by hand,
93/// or (with the `serde` feature) by deserializing a fed-in description.
94///
95/// The JSON shape this produces and accepts is explicitly not covered by this
96/// crate's semver contract -- see [`Domain`]'s documentation and D-8 in
97/// `DESIGN-NOTES.md`.
98#[derive(Clone, Debug, Default, PartialEq)]
99#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
100pub struct MachineMemoryTopology {
101 /// Every logical processor, including one for each inactive slot up to a
102 /// group's maximum processor count.
103 ///
104 /// **As the relationship walk reported them.** A processor that only the
105 /// CPU-set enumeration saw is *not* here, and that is a real omission
106 /// rather than an oversight: this list has one source, and inventing an
107 /// entry for a processor the walk never described would put a `Processor`
108 /// here with no `core`, no class and no domain membership -- a fabricated
109 /// record of exactly the kind [`Observed`] exists to prevent.
110 ///
111 /// The disagreement is not lost, it is [reported](Self::coherence):
112 /// [`Coherence::Disagreed`] names such processors in its `cpu_sets_only`
113 /// field. A caller that needs them can read them there, and one that
114 /// ignores coherence sees the walk's view, which is what this field has
115 /// always been.
116 ///
117 /// Reaching that state takes a disagreement surviving every pass of
118 /// [`Self::discover`]'s retry, so it is not the ordinary case -- but the
119 /// ordinary case is not what a topology crate is for. See
120 /// [D-26](../DESIGN-NOTES.md#d-26).
121 pub processors: Vec<Processor>,
122 /// Every domain.
123 pub domains: Vec<Domain>,
124 /// What `GetSystemCpuSetInformation` reported, as **its own observation**. ///
125 /// Windows describes processors through two APIs, and this is the second
126 /// one. It is not a more convenient spelling of [`Self::domains`]: it
127 /// carries facts the relationship walk has no equivalent for -- whether a
128 /// processor is parked, whether it is allocated to *this* process, the
129 /// scheduler's own last-level-cache grouping, a scheduling class and an
130 /// allocation tag.
131 ///
132 /// It also **duplicates** some facts, deliberately and without
133 /// reconciliation. `CoreIndex`, `NumaNodeIndex` and `EfficiencyClass` also
134 /// appear, derived differently, in `domains` and [`Self::processors`]. The
135 /// two paths can disagree -- under a hypervisor, or where one is stale --
136 /// and merging them here would silently pick a winner and destroy the
137 /// disagreement, which is the only thing a second observer is *for*. Which
138 /// of them a consumer should believe, and what to do when they differ, is a
139 /// decision that has not been taken.
140 ///
141 /// `None` means **not observed**, which is not the same as observed-and-
142 /// empty: a hand-built or deserialized topology has not asked the running
143 /// system, and a consumer must be able to tell that from a machine that
144 /// genuinely reported nothing.
145 #[cfg_attr(feature = "serde", serde(default))]
146 pub cpu_sets: Option<Vec<CpuSet>>,
147 /// What each source said about each processor's attributes.
148 ///
149 /// [D-18](../DESIGN-NOTES.md)'s second subject kind, and the one relation
150 /// unification cannot reach: two sources describing the same core agree on
151 /// `(kind, membership)` even when they disagree about a number hanging off
152 /// it, so that disagreement has nowhere to live among the relation's own
153 /// observations.
154 ///
155 /// Never reduced, for the same reason relations keep both observations:
156 /// collapsing them would destroy the disagreement, which is the only thing
157 /// a second observer is for. Use
158 /// [`Self::attribute_conflicts`] to find the subjects the sources did not
159 /// agree on.
160 ///
161 /// Empty for a hand-built or deserialized topology -- nobody asked, which
162 /// is [`Observed::NotObserved`](crate::Observed::NotObserved)'s meaning
163 /// carried up to a collection.
164 #[cfg_attr(feature = "serde", serde(default, skip))]
165 pub processor_attributes: Vec<AttributeObservation>,
166 /// Where this content came from.
167 ///
168 /// **Defaults to [`Provenance::Synthetic`]**, so a topology built by hand
169 /// or by [`Default`] is tainted unless its author says otherwise. Only
170 /// [`Self::discover`] produces [`Provenance::Measured`], and
171 /// deserialization can never produce it -- see
172 /// [`Provenance`] for why the default points this way.
173 #[cfg_attr(
174 feature = "serde",
175 serde(
176 default,
177 deserialize_with = "crate::provenance::deserialize_downgraded"
178 )
179 )]
180 pub provenance: Provenance,
181 /// Records the enumeration could not decode, if any.
182 ///
183 /// **Empty on every healthy machine**, and empty for a hand-built or
184 /// deserialized topology, which asked nothing. A non-empty list means a
185 /// buffer Windows returned did not describe what it claimed to -- a
186 /// defective hypervisor, a driver corrupting memory -- and it carries the
187 /// byte offset and the reason so that is diagnosable from the data rather
188 /// than from a debugger.
189 ///
190 /// Per [D-24](../DESIGN-NOTES.md#d-24) this is *recorded* rather than
191 /// thrown: a malformed record is not evidence that this crate reached an
192 /// inconsistent state, so it must not panic, and dropping it silently would
193 /// leave a consumer unable to tell a truncated enumeration from a small
194 /// machine. Whatever decoded before the anomaly is still present in the
195 /// fields above and is still correct.
196 ///
197 /// **Written out, never read back in**, for exactly the reason
198 /// [`Self::coherence`] is not: an anomaly is a fact about *an enumeration*,
199 /// and a deserialized topology performed none. A description is welcome to
200 /// carry these so a human reading a dump can see how the run that produced
201 /// it went, but a file cannot *establish* that a buffer walk hit a
202 /// malformed record any more than it can establish that the walk observed
203 /// anything at all (D-12).
204 ///
205 /// This was `serde(default)` until 2026-09-04, which let a document assert
206 /// anomalies into a topology that had asked nothing -- contradicting the
207 /// first paragraph of this very comment, which has always said the list is
208 /// empty for a deserialized topology.
209 #[cfg_attr(feature = "serde", serde(skip_deserializing))]
210 pub enumeration_anomalies: Vec<EnumerationAnomaly>,
211 /// Whether the two Win32 sources described the same machine when this was
212 /// collected.
213 ///
214 /// Stated rather than implied, per [D-16](../DESIGN-NOTES.md#d-16): a
215 /// reader cannot infer from the data's shape how far its parts may be
216 /// *correlated* with each other, and that is a different question from
217 /// whether any single part is accurate.
218 ///
219 /// **Written out, never read back in.** A description is welcome to carry
220 /// this so a human reading a dump can see how the run that produced it
221 /// went, but a file cannot *establish* that two enumerations agreed --
222 /// exactly as it cannot establish that the relationship walk observed
223 /// anything (D-12). So deserialization always yields
224 /// [`Coherence::NotCollected`], which is the true answer for a topology
225 /// nobody collected, and a description claiming `Agreed` gains nothing by
226 /// saying so.
227 #[cfg_attr(feature = "serde", serde(skip_deserializing))]
228 pub coherence: Coherence,
229}
230
231impl MachineMemoryTopology {
232 /// Discover the running system's topology.
233 ///
234 /// # Errors
235 ///
236 /// Returns any error from the underlying `GetLogicalProcessorInformationEx`
237 /// or `GetSystemCpuSetInformation` calls.
238 pub fn discover() -> io::Result<Self> {
239 // D-16's retry. Both calls are whole-machine enumerations and cheap, so
240 // a repeat costs almost nothing, and it is not plausible that several
241 // passes in a row fail to find a coherent set.
242 //
243 // Bounded, and the bound is the point: exhausting it is not a failure
244 // to collect, it is the conclusion that the disagreement is genuine.
245 // The topology is returned either way, with `coherence` saying which
246 // happened.
247 let mut last = None;
248 for attempt in 1..=COHERENCE_ATTEMPTS {
249 let (topology, cpu_sets) = Self::collect_once()?;
250 let (walk_only, cpu_sets_only) = topology.processor_divergence(&cpu_sets);
251 if walk_only.is_empty() && cpu_sets_only.is_empty() {
252 return Ok(topology.finish(cpu_sets, Coherence::Agreed));
253 }
254 last = Some((topology, cpu_sets, walk_only, cpu_sets_only, attempt));
255 }
256
257 // Every pass disagreed. What survives a retry is not transience, so it
258 // is represented rather than retried further or refused over.
259 let (topology, cpu_sets, walk_only, cpu_sets_only, attempts) =
260 last.expect("COHERENCE_ATTEMPTS is a non-zero constant, so the loop ran at least once");
261 Ok(topology.finish(
262 cpu_sets,
263 Coherence::Disagreed {
264 walk_only,
265 cpu_sets_only,
266 attempts,
267 },
268 ))
269 }
270
271 /// One pass over both sources, in the order the fold needs them.
272 fn collect_once() -> io::Result<(Self, Vec<CpuSet>)> {
273 let relations = relation::discover()?;
274 let mut topology = Self::from_relations(relations);
275 // Both are cheap reads of the running system, so both belong to
276 // discovery -- neither is a measurement in the sense that would make it
277 // expensive or optional.
278 // The walk's per-processor claims, recorded before the fold so both
279 // sources' claims about one processor sit side by side (D-18).
280 topology.record_walk_attributes();
281 let (cpu_sets, cpu_set_anomaly) = crate::cpu_set::enumerate()?;
282 topology.enumeration_anomalies.extend(cpu_set_anomaly);
283 // Returned unfolded, because the coherence question is asked of the two
284 // sources *before* they are combined -- once folded, which source said
285 // what about a processor's existence is no longer a question the shape
286 // can answer.
287 Ok((topology, cpu_sets))
288 }
289
290 /// Combine the pass's two sources and state how they agreed.
291 fn finish(mut self, cpu_sets: Vec<CpuSet>, coherence: Coherence) -> Self {
292 // Folded into the relation set, and *also* kept verbatim. Not a
293 // contradiction: D-19's unified view is presented **in addition to**
294 // the individual per-source ones, so a caller wanting what CPU Sets
295 // said, in its own shape, still has it.
296 self.fold_in_cpu_sets(&cpu_sets);
297 self.cpu_sets = Some(cpu_sets);
298 self.coherence = coherence;
299 // The one place in the crate that may claim this is the machine you are
300 // on, because it is the one place that asked the operating system.
301 self.provenance = Provenance::Measured;
302 self
303 }
304
305 /// Which processors one source reported and the other did not.
306 ///
307 /// Existence only. See [`Coherence`] for why grouping disagreements are
308 /// deliberately outside this comparison.
309 fn processor_divergence(&self, cpu_sets: &[CpuSet]) -> (Vec<ProcessorId>, Vec<ProcessorId>) {
310 // Only processors the walk calls active are comparable: it reports a
311 // slot for every position up to a group's maximum, including ones no
312 // processor occupies, and CPU Sets reports only real processors. A raw
313 // set comparison would therefore diverge on every machine with an
314 // inactive slot, which is a difference in what the two APIs enumerate
315 // rather than a difference about the machine.
316 let walk: BTreeSet<ProcessorId> = self
317 .processors
318 .iter()
319 .filter(|processor| processor.online)
320 .map(|processor| processor.id)
321 .collect();
322 let sets: BTreeSet<ProcessorId> = cpu_sets
323 .iter()
324 .map(|set| ProcessorId {
325 group: set.group,
326 number: set.logical_processor_index,
327 })
328 .collect();
329 (
330 walk.difference(&sets).copied().collect(),
331 sets.difference(&walk).copied().collect(),
332 )
333 }
334
335 /// Record what the CPU-set enumeration says about relations, unifying with
336 /// what the relationship walk already reported. ///
337 /// # What is folded, and what deliberately is not
338 ///
339 /// Only **core** and **NUMA node** membership. Those are the two facts both
340 /// Windows APIs describe, so they are the two where "two sources, one
341 /// relation" can arise at all.
342 ///
343 /// `LastLevelCacheIndex` is **not** folded. Per D-14 it answers a different
344 /// question from the derived cache partitioning -- measured on the
345 /// development host it reports one LLC group where the derivation finds
346 /// eight L2 partitions, and neither is wrong -- so under D-15 it is a
347 /// *different relation*, not a second observation of the same one. Folding
348 /// it into `Cache` would assert an agreement that was never claimed.
349 ///
350 /// `EfficiencyClass` is not folded as a *relation* either, because it is a
351 /// **per-processor attribute** rather than a membership -- D-18's other
352 /// subject kind, tracked as `M3+.1.4`. It is read only when CPU Sets
353 /// reports a core the walk did not, where it supplies that new relation's
354 /// attribute rather than a fabricated one.
355 ///
356 /// # How a relation is matched
357 ///
358 /// By `(kind, membership)` per D-15 -- but *kind* here means which kind, not
359 /// its attributes. Two sources reporting the same core over the same
360 /// processors have observed one relation even if they disagree about its
361 /// efficiency class; that disagreement is an attribute conflict, which is
362 /// the subject `M3+.1.4` covers rather than a reason to treat them as two
363 /// relations.
364 fn fold_in_cpu_sets(&mut self, cpu_sets: &[CpuSet]) {
365 // `CoreIndex` is documented as relative to the record's `Group`, so it
366 // is not unique across a multi-group machine: group 0 core 0 and group 1
367 // core 0 are different cores that share an index. Keying on the index
368 // alone folded them into one cross-group `Core` domain, corrupting both
369 // core membership and the efficiency class derived from it. Raised in
370 // PR #56 review. `NumaNodeIndex` is *not* group-relative -- node numbers
371 // are machine-wide -- so it keys on itself.
372 let cores = Self::grouped_by(cpu_sets, |set| {
373 (u32::from(set.group) << 8) | u32::from(set.core_index)
374 });
375 let nodes = Self::grouped_by(cpu_sets, |set| u32::from(set.numa_node_index));
376
377 self.fold_memberships(
378 &cores,
379 |kind| matches!(kind, DomainKind::Core { .. }),
380 |members| DomainKind::Core {
381 // Derived from the membership rather than reported: CPU Sets
382 // has no SMT field, and a core with more than one logical
383 // processor is what the flag means.
384 simultaneous_multithreading: members.len() > 1,
385 // Taken from what CPU Sets actually reported for these
386 // processors, never fabricated. Defaulting this to `0` would
387 // reinvent the `Processor::capacity` sentinel the reshape
388 // exists to remove -- `0` is a legitimate class, so a stand-in
389 // would be indistinguishable from a real value.
390 efficiency_class: members
391 .iter()
392 .map(|set| set.efficiency_class)
393 .max()
394 .unwrap_or_default(),
395 },
396 |members| members.first().map_or(0, |set| u32::from(set.core_index)),
397 );
398 self.fold_memberships(
399 &nodes,
400 |kind| matches!(kind, DomainKind::Memory { .. }),
401 |_| DomainKind::Memory {
402 memory_bytes: Observed::NotObserved,
403 },
404 |members| {
405 members
406 .first()
407 .map_or(0, |set| u32::from(set.numa_node_index))
408 },
409 );
410 // The attribute subject, which relation unification cannot reach: both
411 // sources report an efficiency class for the same processor, and they
412 // agree about the core while possibly disagreeing about this (D-18).
413 for set in cpu_sets {
414 self.processor_attributes.push(AttributeObservation::new(
415 ProcessorId {
416 group: set.group,
417 number: set.logical_processor_index,
418 },
419 ProcessorAttribute::EfficiencyClass,
420 u32::from(set.efficiency_class),
421 Source::CpuSets,
422 ));
423 }
424 }
425
426 /// Record the relationship walk's per-processor attribute claims.
427 ///
428 /// The walk attaches an efficiency class to a *core*, so its claim about a
429 /// processor is its core's value -- fanned out here rather than left for a
430 /// consumer to re-derive, which is the reconstruction this model exists to
431 /// stop.
432 fn record_walk_attributes(&mut self) {
433 let claims: Vec<AttributeObservation> = self
434 .domains
435 .iter()
436 .filter(|domain| domain.observed_by(Source::RelationshipWalk))
437 .filter_map(|domain| match domain.kind {
438 DomainKind::Core {
439 efficiency_class, ..
440 } => Some((efficiency_class, &domain.processors)),
441 _ => None,
442 })
443 .flat_map(|(efficiency_class, processors)| {
444 processors.iter().map(move |(group, number)| {
445 AttributeObservation::new(
446 ProcessorId { group, number },
447 ProcessorAttribute::EfficiencyClass,
448 u32::from(efficiency_class),
449 Source::RelationshipWalk,
450 )
451 })
452 })
453 .collect();
454 self.processor_attributes.extend(claims);
455 }
456
457 /// The `(processor, attribute)` subjects the sources did not agree on.
458 ///
459 /// Empty is the ordinary answer. A non-empty one is the **attribute
460 /// conflict** [D-17](../DESIGN-NOTES.md) says to expect in the field and to
461 /// record rather than refuse over -- firmware tables are populated
462 /// incrementally, and the places to meet this are hardware nobody here has.
463 ///
464 /// Reported rather than resolved: picking a winner would destroy the
465 /// disagreement, and on a hybrid part the choice decides whether a
466 /// processor is treated as a performance or an efficiency core.
467 #[must_use]
468 pub fn attribute_conflicts(&self) -> Vec<(ProcessorId, ProcessorAttribute)> {
469 let mut claims: BTreeMap<(ProcessorId, ProcessorAttribute), Vec<u32>> = BTreeMap::new();
470 for observation in &self.processor_attributes {
471 claims
472 .entry(observation.subject())
473 .or_default()
474 .push(observation.value);
475 }
476 claims
477 .into_iter()
478 .filter(|(_, values)| {
479 let mut distinct = values.clone();
480 distinct.sort_unstable();
481 distinct.dedup();
482 distinct.len() > 1
483 })
484 .map(|(subject, _)| subject)
485 .collect()
486 }
487
488 /// Group the CPU-set records by whatever `key` names, keeping the records
489 /// themselves so a new relation's attributes come from what was reported.
490 fn grouped_by(
491 cpu_sets: &[CpuSet],
492 key: impl Fn(&CpuSet) -> u32,
493 ) -> BTreeMap<u32, Vec<&CpuSet>> {
494 let mut grouped: BTreeMap<u32, Vec<&CpuSet>> = BTreeMap::new();
495 for set in cpu_sets {
496 grouped.entry(key(set)).or_default().push(set);
497 }
498 grouped
499 }
500
501 /// Attach a CPU-sets observation to each matching relation, adding one
502 /// where no relation of that kind covers the same processors.
503 fn fold_memberships(
504 &mut self,
505 grouped: &BTreeMap<u32, Vec<&CpuSet>>,
506 is_kind: impl Fn(&DomainKind) -> bool,
507 make_kind: impl Fn(&[&CpuSet]) -> DomainKind,
508 label_of: impl Fn(&[&CpuSet]) -> u32,
509 ) {
510 for members in grouped.values() {
511 let mut processors = ProcessorSet::empty();
512 for set in members {
513 processors.insert(set.group, set.logical_processor_index);
514 }
515
516 // The label is the source's own, taken from the records rather than
517 // from the grouping key: those differ wherever the key has to carry
518 // more than the label to be unique, which is the group-relative
519 // `CoreIndex` case below.
520 let observation = Observation::new(Source::CpuSets, label_of(members));
521 match self
522 .domains
523 .iter_mut()
524 .find(|domain| is_kind(&domain.kind) && domain.processors == processors)
525 {
526 // One relation, observed twice. The labels differ and both are
527 // kept, which is the whole of D-15.
528 Some(domain) => domain.observations.push(observation),
529 // A relation only CPU Sets reported. Recorded rather than
530 // dropped: the walk not describing it is a fact about the walk,
531 // not evidence the relation is not there.
532 None => self.domains.push(Domain {
533 kind: make_kind(members),
534 processors,
535 observations: vec![observation],
536 }),
537 }
538 }
539 }
540
541 fn from_relations(relations: Relations) -> Self {
542 let mut domains = Vec::new();
543
544 for group in &relations.groups {
545 domains.push(Domain {
546 kind: DomainKind::Group,
547 processors: group.active_processors.clone(),
548 observations: vec![Observation::new(
549 Source::RelationshipWalk,
550 u32::from(group.group),
551 )],
552 });
553 }
554 for (index, package) in relations.packages.iter().enumerate() {
555 domains.push(Domain {
556 kind: DomainKind::Package,
557 processors: package.processors.clone(),
558 observations: vec![Observation::new(Source::RelationshipWalk, index as u32)],
559 });
560 }
561 for (index, die) in relations.dies.iter().enumerate() {
562 domains.push(Domain {
563 kind: DomainKind::Die,
564 processors: die.processors.clone(),
565 observations: vec![Observation::new(Source::RelationshipWalk, index as u32)],
566 });
567 }
568 for (index, module) in relations.modules.iter().enumerate() {
569 domains.push(Domain {
570 kind: DomainKind::Module,
571 processors: module.processors.clone(),
572 observations: vec![Observation::new(Source::RelationshipWalk, index as u32)],
573 });
574 }
575 for (index, core) in relations.cores.iter().enumerate() {
576 domains.push(Domain {
577 kind: DomainKind::Core {
578 simultaneous_multithreading: core.simultaneous_multithreading,
579 efficiency_class: core.efficiency_class,
580 },
581 processors: core.processors.clone(),
582 observations: vec![Observation::new(Source::RelationshipWalk, index as u32)],
583 });
584 }
585 for (index, cache) in relations.caches.iter().enumerate() {
586 domains.push(Domain {
587 kind: DomainKind::Cache {
588 level: cache.level,
589 associativity: cache.associativity,
590 line_size: cache.line_size,
591 size_bytes: cache.cache_size,
592 cache_type: cache.cache_type,
593 },
594 processors: cache.processors.clone(),
595 observations: vec![Observation::new(Source::RelationshipWalk, index as u32)],
596 });
597 }
598 for node in &relations.numa_nodes {
599 domains.push(Domain {
600 kind: DomainKind::Memory {
601 memory_bytes: Observed::NotObserved,
602 },
603 processors: node.processors.clone(),
604 observations: vec![Observation::new(Source::RelationshipWalk, node.node_number)],
605 });
606 }
607
608 let processors = Self::processors_from(&relations, &domains);
609 Self {
610 processors,
611 domains,
612 cpu_sets: None,
613 processor_attributes: Vec::new(),
614 // Synthetic, not measured: this is a pure transform of whatever
615 // relations it was handed, and cannot know where they came from.
616 // `discover` stamps the claim because `discover` is what read the
617 // machine -- so if this ever gains a second caller, that caller
618 // does not silently inherit an assertion it has not earned.
619 provenance: Provenance::Synthetic,
620 // For the same reason: one set of relations is one source, and
621 // coherence is a statement about two agreeing. `discover` sets this
622 // once it has both.
623 coherence: Coherence::NotCollected,
624 // Carried, not re-derived: whatever the walk could not decode is a
625 // fact about the enumeration that produced these relations, and a
626 // pure transform is the wrong place to lose it.
627 enumeration_anomalies: relations.anomalies,
628 }
629 }
630
631 /// One `Processor` entry per slot up to each group's maximum processor
632 /// count, online or not -- Windows only reports core/cache/node relations
633 /// for active processors, so an inactive slot's capacity falls back to
634 /// `0` rather than being invented.
635 fn processors_from(relations: &Relations, domains: &[Domain]) -> Vec<Processor> {
636 let mut result = Vec::new();
637 for group_info in &relations.groups {
638 for number in 0..group_info.maximum_processor_count {
639 let id = ProcessorId {
640 group: group_info.group,
641 number,
642 };
643 let online = group_info
644 .active_processors
645 .contains(group_info.group, number);
646 let capacity = online
647 .then(|| {
648 domains.iter().find_map(|d| match d.kind {
649 DomainKind::Core {
650 efficiency_class, ..
651 } if d.processors.contains(group_info.group, number) => {
652 Some(u32::from(efficiency_class))
653 }
654 _ => None,
655 })
656 })
657 .flatten()
658 .unwrap_or(0);
659 result.push(Processor {
660 id,
661 online,
662 capacity,
663 });
664 }
665 }
666 result
667 }
668
669 /// The processor named by `id`, if this topology has one.
670 #[must_use]
671 pub fn processor(&self, id: ProcessorId) -> Option<&Processor> {
672 self.processors.iter().find(|p| p.id == id)
673 }
674
675 /// Every domain of kind [`DomainKind::Group`].
676 pub fn groups(&self) -> impl Iterator<Item = &Domain> {
677 self.domains
678 .iter()
679 .filter(|d| matches!(d.kind, DomainKind::Group))
680 }
681
682 /// Every domain of kind [`DomainKind::Package`].
683 pub fn packages(&self) -> impl Iterator<Item = &Domain> {
684 self.domains
685 .iter()
686 .filter(|d| matches!(d.kind, DomainKind::Package))
687 }
688
689 /// Every domain of kind [`DomainKind::Core`].
690 pub fn cores(&self) -> impl Iterator<Item = &Domain> {
691 self.domains
692 .iter()
693 .filter(|d| matches!(d.kind, DomainKind::Core { .. }))
694 }
695
696 /// Every cache domain, at any level.
697 pub fn caches(&self) -> impl Iterator<Item = &Domain> {
698 self.domains
699 .iter()
700 .filter(|d| matches!(d.kind, DomainKind::Cache { .. }))
701 }
702
703 /// Every cache domain at exactly `level`.
704 pub fn caches_at_level(&self, level: u8) -> impl Iterator<Item = &Domain> {
705 self.domains.iter().filter(
706 move |d| matches!(&d.kind, DomainKind::Cache { level: found, .. } if *found == level),
707 )
708 }
709
710 /// Every cache level this machine reports, ascending, without repeats.
711 ///
712 /// Derived from what the topology actually contains rather than from a
713 /// fixed ceiling. [`DomainKind::Cache`]'s `level` is a `u8`, so a caller
714 /// that sweeps a hard-coded `1..=4` silently reports a partitioning L5 as
715 /// absent -- a wrong answer that looks like a confident one.
716 pub fn cache_levels(&self) -> Vec<u8> {
717 let mut levels: Vec<u8> = self
718 .caches()
719 .filter_map(|d| match &d.kind {
720 DomainKind::Cache { level, .. } => Some(*level),
721 _ => None,
722 })
723 .collect();
724 levels.sort_unstable();
725 levels.dedup();
726 levels
727 }
728
729 /// The distinct processor partitions the caches at `level` form.
730 ///
731 /// # Why this is not [`Self::caches_at_level`]
732 ///
733 /// Windows reports one relationship per *cache*, not per partition, and a
734 /// level is routinely reported more than once over the very same
735 /// processors. Measured on the eight-core development host rather than
736 /// reasoned about: L1 arrives as eight `data` domains **plus** eight
737 /// `instruction` domains covering exactly the same eight processor pairs.
738 ///
739 /// Counting relationships therefore claims sixteen L1 partitions where the
740 /// machine has eight. Two consequences, both silent: a partition count that
741 /// is a whole multiple too large, and -- on a machine whose L1i and L1d are
742 /// its only two cache domains -- a level reported as *partitioning* when it
743 /// divides nothing at all.
744 ///
745 /// Deduplication is by processor set, which is the thing a caller
746 /// partitioning work actually cares about; the first domain covering each
747 /// distinct set is kept, so the returned ids are stable for a topology.
748 ///
749 /// **Distinct is not disjoint.** Two sets that overlap without being equal
750 /// both survive this, so the result is a set of domains rather than a
751 /// proven partition. [`Self::outermost_partitioning_cache`] is where that
752 /// stronger property is required and checked.
753 ///
754 /// **A domain covering no processors is dropped**, because it partitions
755 /// nothing and every consumer of this list wants pieces of the machine. It
756 /// would otherwise survive both filters here and in
757 /// [`Self::outermost_partitioning_cache`]: it is not *equal* to any
758 /// non-empty set, so deduplication keeps it, and it is disjoint from
759 /// everything vacuously, so the pairwise check passes it. A level with one
760 /// real cache plus one empty domain would then count two partitions and be
761 /// reported as dividing a machine it does not divide. Contrast
762 /// [`Self::memory_domains`], which deliberately keeps a processor-less
763 /// domain because a memory domain with no CPUs is real hardware (D-5); a
764 /// *cache* over no processors is not.
765 pub fn cache_partitions_at_level(&self, level: u8) -> Vec<&Domain> {
766 let mut partitions: Vec<&Domain> = Vec::new();
767 for domain in self
768 .caches_at_level(level)
769 .filter(|domain| !domain.processors.is_empty())
770 {
771 if !partitions
772 .iter()
773 .any(|kept| kept.processors == domain.processors)
774 {
775 partitions.push(domain);
776 }
777 }
778 partitions
779 }
780
781 /// The outermost cache level that actually divides this machine, together
782 /// with the distinct partitions it forms.
783 ///
784 /// A level whose caches all cover the same processors partitions nothing --
785 /// a fully shared L3 is one domain spanning everything -- so it is never a
786 /// candidate however far out it sits. `None` means no reported level
787 /// divides the machine, which is a real answer and not a failure: a
788 /// single-core host is one partition by every measure.
789 ///
790 /// This is deliberately not "level 3". A shipping ARM64 laptop measured
791 /// during the 2026-08-30 session reports **no L3 at all**, with two L2
792 /// domains of six processors forming the real cluster boundary.
793 ///
794 /// # A level must be a partition, not merely a set of domains
795 ///
796 /// [`Self::cache_partitions_at_level`] deduplicates by equal processor set,
797 /// which is what the measured case needs (L1i and L1d cover identical
798 /// sets). That alone does **not** make the result a partition: two distinct
799 /// sets can still overlap. Real hardware does not do this, but a
800 /// `MachineMemoryTopology` is deliberately constructible by hand and by deserialization
801 /// (see [`Provenance`](crate::Provenance)), so this method cannot assume
802 /// hardware produced it -- and a caller splitting work across overlapping
803 /// "partitions" double-counts the processors in the intersection and
804 /// overwrites their domain assignment, silently.
805 ///
806 /// So a level qualifies only when its distinct sets are **pairwise
807 /// disjoint**. Full coverage of the online processors is deliberately *not*
808 /// required: a processor with no cache reported at a level is a gap in what
809 /// the firmware said, not evidence that the domains which *were* reported
810 /// overlap, and rejecting the level would discard a true boundary over it.
811 ///
812 /// # "Outermost" is decided by inclusion, not by the level number
813 ///
814 /// Level groups the relations into candidate partitions -- that is reading
815 /// what the source said. It does **not** order them, because "a higher
816 /// number is coarser" is asserted structure of the kind `M2+.2` forbids,
817 /// and a machine that reports L2 over six processors and no L3 at all is
818 /// the counterexample this crate has actually measured.
819 ///
820 /// A candidate is coarser than another when **every block of the other is
821 /// contained in one of its blocks**, which is checkable against the very
822 /// memberships Windows reported. On ordinary hardware the two agree; where
823 /// they disagree, the firmware numbering is the one that is wrong.
824 ///
825 /// Defined here, in the crate that owns the topology, so that every
826 /// consumer asks the same question rather than restating the rule and
827 /// drifting from it.
828 pub fn outermost_partitioning_cache(&self) -> Option<(u8, Vec<&Domain>)> {
829 let candidates: Vec<(u8, Vec<&Domain>)> = self
830 .cache_levels()
831 .into_iter()
832 .filter_map(|level| {
833 let blocks = self.cache_partitions_at_level(level);
834 (blocks.len() > 1 && Self::are_pairwise_disjoint(&blocks))
835 .then_some((level, blocks))
836 })
837 .collect();
838
839 // Coarsest by inclusion: the candidate no other candidate is coarser
840 // than. Where two candidates describe the *same* partition -- identical
841 // blocks under different levels, which is the ordinary case for an L1
842 // and L2 that split a machine the same way -- neither refines the other,
843 // so the tie is broken by taking the **higher level**. Where they
844 // describe *different* partitions neither of which refines the other,
845 // there is no outermost one and the answer is `None`; see below.
846 //
847 // That tie-break reads the source's own labelling of one boundary and is
848 // not the level ordering `M2+.2` forbids: distinct partitions are still
849 // ordered by inclusion, and level decides only which of two names for
850 // the identical partition is the outer one.
851 // Two candidates can be *incomparable* rather than identical: disjoint
852 // partitions `{0,1}/{2,3}` and `{0,2}/{1,3}` each survive the filter,
853 // neither refines the other, and they are not the same partition. There
854 // is then no outermost partitioning cache, and answering with the
855 // higher-numbered level would invent one -- reintroducing exactly the
856 // level ordering `M2+.2` forbids, in the one case where it changes the
857 // answer. `None` is the honest result, and per D-13 the caller already
858 // has to handle it.
859 let maximal: Vec<_> = candidates
860 .iter()
861 .filter(|candidate| {
862 !candidates
863 .iter()
864 .any(|other| Self::refines(&candidate.1, &other.1))
865 })
866 .collect();
867 let first = maximal.first()?;
868 if !maximal
869 .iter()
870 .all(|other| Self::same_partition(&first.1, &other.1))
871 {
872 return None;
873 }
874 maximal
875 .iter()
876 .max_by_key(|(level, _)| *level)
877 .map(|(level, blocks)| (*level, blocks.clone()))
878 }
879
880 /// Whether two candidate partitions have exactly the same blocks.
881 ///
882 /// The identical case `refines` deliberately excludes: an L1 and an L2 that
883 /// split the machine the same way are one boundary under two names, which is
884 /// the ordinary case on real hardware and the only case the level tie-break
885 /// is allowed to decide.
886 fn same_partition(left: &[&Domain], right: &[&Domain]) -> bool {
887 left.len() == right.len()
888 && left
889 .iter()
890 .all(|block| right.iter().any(|o| o.processors == block.processors))
891 }
892
893 /// Whether every block of `finer` sits inside some block of `coarser`, and
894 /// the two are not the same partition.
895 ///
896 /// The refinement order over candidate partitions, derived from the same
897 /// membership inclusion the granularity order uses.
898 fn refines(finer: &[&Domain], coarser: &[&Domain]) -> bool {
899 let all_contained = finer.iter().all(|block| {
900 coarser
901 .iter()
902 .any(|outer| block.processors.is_subset(&outer.processors))
903 });
904 let same = finer.len() == coarser.len()
905 && finer
906 .iter()
907 .all(|block| coarser.iter().any(|o| o.processors == block.processors));
908 all_contained && !same
909 }
910
911 /// Whether no two of these domains claim the same processor.
912 ///
913 /// Quadratic on purpose: the input is one cache level's distinct domains,
914 /// which is a handful even on a large machine, and pairwise intersection
915 /// asks the question directly rather than through a set type this crate
916 /// would otherwise not need.
917 fn are_pairwise_disjoint(domains: &[&Domain]) -> bool {
918 domains.iter().enumerate().all(|(i, left)| {
919 domains[i + 1..]
920 .iter()
921 .all(|right| left.processors.is_disjoint(&right.processors))
922 })
923 }
924
925 /// Everything this crate knows about one processor, without sentinels.
926 ///
927 /// The shard-set surface `M4+.2` calls for: what a caller needs to decide
928 /// whether a processor may host work, gathered so the answer is asked for
929 /// once rather than assembled differently by each consumer.
930 ///
931 /// Every field says which absence it means (D-13), and **none uses a
932 /// sentinel**. That is the point rather than a nicety:
933 /// [`Processor::capacity`] spells "offline", "in no core", and "efficiency
934 /// class zero" as the same `0`, and the third is every processor on every
935 /// non-hybrid machine -- so the stand-in collides with the overwhelmingly
936 /// common real value and no careful caller can tell them apart.
937 #[must_use]
938 pub fn shard_set(&self) -> Vec<ProcessorFacts<'_>> {
939 self.processors
940 .iter()
941 .map(|processor| {
942 let core = self.cores().find(|d| {
943 d.processors
944 .contains(processor.id.group, processor.id.number)
945 });
946 let cpu_set = self.cpu_sets.as_ref().and_then(|sets| {
947 sets.iter().find(|s| {
948 s.group == processor.id.group
949 && s.logical_processor_index == processor.id.number
950 })
951 });
952 ProcessorFacts {
953 id: processor.id,
954 online: processor.online,
955 core,
956 simultaneous_multithreading: match core.map(|d| &d.kind) {
957 Some(DomainKind::Core {
958 simultaneous_multithreading,
959 ..
960 }) => Observed::Known(*simultaneous_multithreading),
961 _ => Observed::NotObserved,
962 },
963 efficiency_class: match core.map(|d| &d.kind) {
964 Some(DomainKind::Core {
965 efficiency_class, ..
966 }) => Observed::Known(*efficiency_class),
967 _ => Observed::NotObserved,
968 },
969 parked: cpu_set.map_or(Observed::NotObserved, |s| Observed::Known(s.parked)),
970 allocated_to_this_process: cpu_set.map_or(Observed::NotObserved, |s| {
971 Observed::Known(s.allocated_to_target_process)
972 }),
973 memory_domain: self.memory_domain_of(processor.id),
974 }
975 })
976 .collect()
977 }
978
979 /// Which memory domain `processor` allocates from.
980 ///
981 /// [`Observed::Known`] with the domain, or [`Observed::NotObserved`] when
982 /// no memory domain names it -- the **unplaced** case, which is deliberately
983 /// not collapsed into "node 0".
984 ///
985 /// # Why the unplaced case gets its own answer
986 ///
987 /// An unknown *cache* domain costs an optimisation. An unknown *memory*
988 /// domain has no honest fallback at all: the pool has to be allocated
989 /// somewhere, and guessing means quietly allocating remote memory for the
990 /// life of the process. `windows-placement-probe` already encodes this
991 /// asymmetry -- it refuses a missing NUMA node while tolerating a missing
992 /// cache domain -- and this method is where that distinction becomes the
993 /// model's rather than each consumer's.
994 ///
995 /// [`Observed::Absent`] is never returned: a memory domain covering no
996 /// processors is a real shape (D-5), but "this processor belongs to no
997 /// node" is a gap in what the firmware said, not a positive statement that
998 /// it has no memory.
999 #[must_use]
1000 pub fn memory_domain_of(&self, processor: ProcessorId) -> Observed<&Domain> {
1001 self.memory_domains()
1002 .find(|domain| {
1003 domain
1004 .processors
1005 .contains(processor.group, processor.number)
1006 })
1007 .map_or(Observed::NotObserved, Observed::Known)
1008 }
1009
1010 /// Every processor no memory domain names.
1011 ///
1012 /// The set a caller must decide about before allocating anything, and
1013 /// empty on a machine whose firmware covered every processor. Offered so
1014 /// the question is asked once rather than rediscovered per allocation site.
1015 #[must_use]
1016 pub fn unplaced_processors(&self) -> Vec<ProcessorId> {
1017 self.processors
1018 .iter()
1019 .filter(|processor| !self.memory_domain_of(processor.id).was_observed())
1020 .map(|processor| processor.id)
1021 .collect()
1022 }
1023
1024 /// Every memory domain, including one with no processors (D-5).
1025 pub fn memory_domains(&self) -> impl Iterator<Item = &Domain> {
1026 self.domains
1027 .iter()
1028 .filter(|d| matches!(d.kind, DomainKind::Memory { .. }))
1029 }
1030}
1031
1032#[cfg(test)]
1033mod tests;