Skip to main content

corescout_substrate/
discovery.rs

1//! Turning a topology into the entities and relations the mirror reflects.
2//!
3//! # What this module decides
4//!
5//! The [`Topology`] the platform layer produces is a good description of a CPU
6//! and a poor foundation for a general representation: it has typed collections
7//! with different shapes, and reasoning about it means knowing which collection
8//! to look in. This module flattens it into one uniform entity list plus an edge
9//! list, losing nothing and gaining the ability to be traversed by something
10//! that has never heard of a NUMA node.
11//!
12//! # Ordering is part of the contract
13//!
14//! Entity rows are assigned in a deterministic order: machine, packages, NUMA
15//! nodes, physical cores, logical CPUs, caches, each sorted by natural index.
16//! Two runs against the same machine therefore produce the same row layout,
17//! which means a consumer can cache row indices for the lifetime of an epoch and
18//! a recorded snapshot can be compared against a later one directly.
19//!
20//! Sensors append their own entities after these, in sensor registration order,
21//! for the same reason.
22
23use std::path::PathBuf;
24
25use crate::platform::Platform;
26use crate::topology::{CacheKind, Topology};
27use corescout_core::error::Result;
28use corescout_mirror::entity::{keys, Entity, EntityClass};
29use corescout_mirror::relation::{Relation, RelationKind};
30
31/// Filesystem roots the observation layer reads from.
32///
33/// Injectable for exactly the reason the sysfs parser's roots are injectable:
34/// it lets every sensor be tested against a synthetic machine, on any host,
35/// including topologies nobody on the project owns.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Roots {
38    pub sys: PathBuf,
39    pub proc: PathBuf,
40}
41
42impl Default for Roots {
43    fn default() -> Self {
44        Roots::system()
45    }
46}
47
48impl Roots {
49    pub fn system() -> Roots {
50        Roots {
51            sys: PathBuf::from("/sys"),
52            proc: PathBuf::from("/proc"),
53        }
54    }
55
56    pub fn new(sys: impl Into<PathBuf>, proc: impl Into<PathBuf>) -> Roots {
57        Roots {
58            sys: sys.into(),
59            proc: proc.into(),
60        }
61    }
62}
63
64/// The structural machine: what exists, and where to look to observe it.
65#[derive(Debug, Clone)]
66pub struct Substrate {
67    pub topology: Topology,
68    pub roots: Roots,
69}
70
71impl Substrate {
72    /// Discover the substrate through a platform backend.
73    pub fn discover(platform: &dyn Platform) -> Result<Substrate> {
74        Ok(Substrate {
75            topology: platform.discover_topology()?,
76            roots: Roots::system(),
77        })
78    }
79
80    pub fn new(topology: Topology, roots: Roots) -> Substrate {
81        Substrate { topology, roots }
82    }
83
84    /// Re-read the machine's structure from the same roots.
85    ///
86    /// Used to detect hotplug: a CPU going offline changes the entity set, and
87    /// every cached row index in every consumer becomes wrong. Cheap enough to
88    /// call once a second, far too expensive to call every tick.
89    pub fn rediscover(&self) -> Result<Substrate> {
90        let sysfs = crate::platform::linux::sysfs::Sysfs::with_roots(
91            self.roots.sys.clone(),
92            self.roots.proc.clone(),
93        );
94        Ok(Substrate {
95            topology: sysfs.read_topology(None)?,
96            roots: self.roots.clone(),
97        })
98    }
99
100    /// Build the structural entity list and the static edges between them.
101    ///
102    /// "Static" means constant for the lifetime of an epoch: containment, SMT
103    /// siblinghood, cache membership and NUMA locality do not change while the
104    /// set of online CPUs is unchanged. Sensors add further entities and edges
105    /// on top of these.
106    pub fn structure(&self) -> (Vec<Entity>, Vec<Relation>) {
107        let t = &self.topology;
108        let mut entities = Vec::new();
109        let mut relations = Vec::new();
110
111        // Row 0 is always the machine. A consumer with no other knowledge can
112        // start there and walk outwards.
113        let machine = push(
114            &mut entities,
115            Entity::new(keys::machine(), EntityClass::Machine, None),
116        );
117
118        let mut packages: Vec<u32> = t.physical_cores.iter().map(|c| c.package_id).collect();
119        packages.sort_unstable();
120        packages.dedup();
121        let package_rows: Vec<(u32, u32)> = packages
122            .iter()
123            .map(|pkg| {
124                let row = push(
125                    &mut entities,
126                    Entity::new(keys::package(*pkg), EntityClass::Package, Some(*pkg)),
127                );
128                relations.push(Relation::new(machine, row, RelationKind::Contains));
129                (*pkg, row)
130            })
131            .collect();
132        let package_row = |pkg: u32| -> Option<u32> {
133            package_rows
134                .iter()
135                .find(|(p, _)| *p == pkg)
136                .map(|(_, r)| *r)
137        };
138
139        let numa_rows: Vec<(u32, u32)> = t
140            .numa_nodes
141            .iter()
142            .map(|node| {
143                let row = push(
144                    &mut entities,
145                    Entity::new(
146                        keys::numa_node(node.id),
147                        EntityClass::NumaNode,
148                        Some(node.id),
149                    ),
150                );
151                relations.push(Relation::new(machine, row, RelationKind::Contains));
152                (node.id, row)
153            })
154            .collect();
155
156        // Physical cores, then logical CPUs. Cores first so that a containment
157        // walk from the machine reaches a core before the CPUs inside it.
158        let mut core_rows: Vec<(u32, u32)> = Vec::new();
159        for core in &t.physical_cores {
160            let row = push(
161                &mut entities,
162                Entity::new(
163                    keys::physical_core(core.package_id, core.core_id),
164                    EntityClass::PhysicalCore,
165                    Some(core.core_id),
166                ),
167            );
168            if let Some(pkg) = package_row(core.package_id) {
169                relations.push(Relation::new(pkg, row, RelationKind::Contains));
170            }
171            core_rows.push((core.id, row));
172        }
173        let core_row =
174            |id: u32| -> Option<u32> { core_rows.iter().find(|(c, _)| *c == id).map(|(_, r)| *r) };
175
176        let mut cpu_rows: Vec<(u32, u32)> = Vec::new();
177        for cpu in t.logical_cpus.iter().filter(|c| c.online) {
178            let row = push(
179                &mut entities,
180                Entity::new(
181                    keys::logical_cpu(cpu.id),
182                    EntityClass::LogicalCpu,
183                    Some(cpu.id),
184                ),
185            );
186            if let Some(core) = core_row(cpu.physical) {
187                relations.push(Relation::new(core, row, RelationKind::Contains));
188            }
189            cpu_rows.push((cpu.id, row));
190        }
191        let cpu_row =
192            |id: u32| -> Option<u32> { cpu_rows.iter().find(|(c, _)| *c == id).map(|(_, r)| *r) };
193
194        // SMT siblinghood, emitted in both directions so a consumer never has
195        // to guess which way an edge was written.
196        for cpu in t.logical_cpus.iter().filter(|c| c.online) {
197            let Some(from) = cpu_row(cpu.id) else {
198                continue;
199            };
200            for sibling in &cpu.smt_siblings {
201                if let Some(to) = cpu_row(*sibling) {
202                    relations.push(Relation::new(from, to, RelationKind::SmtSibling));
203                }
204            }
205        }
206
207        // NUMA locality.
208        for (node_id, node_row) in &numa_rows {
209            let Some(node) = t.numa_nodes.iter().find(|n| n.id == *node_id) else {
210                continue;
211            };
212            for cpu in &node.cpus {
213                if let Some(row) = cpu_row(*cpu) {
214                    relations.push(Relation::new(*node_row, row, RelationKind::NumaLocal));
215                }
216            }
217        }
218
219        // Caches. Each becomes an entity, anchored to the lowest core id among
220        // its sharers so that its identity does not depend on how many of its
221        // CPUs happen to be online elsewhere in the machine.
222        for cache in &t.caches {
223            let anchor_cpu = cache.shared_cpus.iter().copied().min();
224            let Some(anchor_cpu) = anchor_cpu else {
225                continue;
226            };
227            let anchor_core = t.core_of_cpu(anchor_cpu);
228            let (package_id, anchor_core_id) = match anchor_core {
229                Some(core) => (core.package_id, core.core_id),
230                None => (0, anchor_cpu),
231            };
232            let kind = cache_kind_key(cache.kind);
233            let row = push(
234                &mut entities,
235                Entity::new(
236                    keys::cache(package_id, cache.level, kind, anchor_core_id),
237                    EntityClass::Cache,
238                    Some(cache.level as u32),
239                ),
240            );
241            if let Some(pkg) = package_row(package_id) {
242                relations.push(Relation::new(pkg, row, RelationKind::Contains));
243            }
244            for cpu in &cache.shared_cpus {
245                if let Some(cpu_row) = cpu_row(*cpu) {
246                    relations.push(
247                        Relation::new(row, cpu_row, RelationKind::CacheMember)
248                            .with(0, cache.level as f64)
249                            .with(1, cache.size_bytes.map(|b| b as f64).unwrap_or(f64::NAN))
250                            .with(2, cache.shared_cpus.len() as f64),
251                    );
252                }
253            }
254        }
255
256        (entities, relations)
257    }
258}
259
260fn push(entities: &mut Vec<Entity>, entity: Entity) -> u32 {
261    entities.push(entity);
262    (entities.len() - 1) as u32
263}
264
265/// Stable short name for a cache kind, used in natural keys. Changing these
266/// strings changes every cache entity's identity, so they are frozen.
267fn cache_kind_key(kind: CacheKind) -> &'static str {
268    match kind {
269        CacheKind::L1Data => "data",
270        CacheKind::L1Instruction => "instruction",
271        CacheKind::L2Unified | CacheKind::L3Unified => "unified",
272        CacheKind::Other => "other",
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::test_support::fake_topology;
280    use corescout_mirror::relation::RelationView;
281
282    fn structure() -> (Vec<Entity>, Vec<Relation>) {
283        Substrate::new(
284            fake_topology(),
285            Roots::new("/nonexistent/sys", "/nonexistent/proc"),
286        )
287        .structure()
288    }
289
290    #[test]
291    fn the_machine_is_always_row_zero() {
292        let (entities, _) = structure();
293        assert_eq!(entities[0].class_hint, EntityClass::Machine);
294    }
295
296    #[test]
297    fn every_structural_component_becomes_an_entity() {
298        let (entities, _) = structure();
299        let count = |class: EntityClass| entities.iter().filter(|e| e.class_hint == class).count();
300        assert_eq!(count(EntityClass::Machine), 1);
301        assert_eq!(count(EntityClass::Package), 1);
302        assert_eq!(count(EntityClass::NumaNode), 1);
303        assert_eq!(count(EntityClass::PhysicalCore), 2);
304        assert_eq!(count(EntityClass::LogicalCpu), 4);
305        assert_eq!(count(EntityClass::Cache), 3, "two L2s and one L3");
306    }
307
308    #[test]
309    fn entity_keys_are_unique() {
310        let (entities, _) = structure();
311        let mut keys: Vec<&str> = entities.iter().map(|e| e.key.as_str()).collect();
312        keys.sort_unstable();
313        let before = keys.len();
314        keys.dedup();
315        assert_eq!(keys.len(), before, "duplicate entity key");
316    }
317
318    #[test]
319    fn identity_is_derived_from_the_key() {
320        let (entities, _) = structure();
321        for entity in &entities {
322            assert_eq!(entity.id, corescout_mirror::EntityId::derive(&entity.key));
323        }
324    }
325
326    #[test]
327    fn containment_forms_a_tree_from_the_machine() {
328        let (entities, relations) = structure();
329        let view = RelationView::new(&relations);
330        // Every entity except the machine has exactly one container, except
331        // logical CPUs which are contained by their core only.
332        for row in 1..entities.len() as u32 {
333            let containers = view
334                .to(row)
335                .filter(|r| r.kind == RelationKind::Contains)
336                .count();
337            assert!(
338                containers <= 1,
339                "entity {} has {} containers",
340                entities[row as usize].key,
341                containers
342            );
343        }
344        assert!(view.from(0).count() >= 2, "the machine contains things");
345    }
346
347    #[test]
348    fn smt_siblings_are_symmetric_edges() {
349        let (entities, relations) = structure();
350        let view = RelationView::new(&relations);
351        let row_of = |key: &str| entities.iter().position(|e| e.key == key).unwrap() as u32;
352        let cpu0 = row_of("cpu/0");
353        let cpu2 = row_of("cpu/2");
354        assert!(view.connected(cpu0, cpu2, RelationKind::SmtSibling));
355        assert!(view.connected(cpu2, cpu0, RelationKind::SmtSibling));
356    }
357
358    #[test]
359    fn cache_membership_carries_level_and_size() {
360        let (entities, relations) = structure();
361        let view = RelationView::new(&relations);
362        let l3_row = entities
363            .iter()
364            .position(|e| e.key.contains("L3"))
365            .expect("an L3 entity") as u32;
366        let members: Vec<&Relation> = view
367            .from(l3_row)
368            .filter(|r| r.kind == RelationKind::CacheMember)
369            .collect();
370        assert_eq!(members.len(), 4, "the fixture L3 is shared by four CPUs");
371        assert_eq!(members[0].attributes[0], 3.0, "level in slot 0");
372        assert_eq!(
373            members[0].attributes[1],
374            (32 << 20) as f64,
375            "size in slot 1"
376        );
377        assert_eq!(members[0].attributes[2], 4.0, "sharer count in slot 2");
378    }
379
380    #[test]
381    fn offline_cpus_do_not_become_entities() {
382        // The mirror reflects what is, and an offline CPU is not running.
383        let mut topology = fake_topology();
384        topology.logical_cpus[3].online = false;
385        topology.online_cpus = vec![0, 1, 2];
386        topology.offline_cpus = vec![3];
387        let (entities, _) = Substrate::new(topology, Roots::system()).structure();
388        assert!(!entities.iter().any(|e| e.key == "cpu/3"));
389        assert!(entities.iter().any(|e| e.key == "cpu/1"));
390    }
391
392    #[test]
393    fn structure_is_deterministic_across_runs() {
394        // Row indices must be reproducible, or cached indices and recorded
395        // snapshots both break.
396        let (a_entities, a_relations) = structure();
397        let (b_entities, b_relations) = structure();
398        assert_eq!(a_entities, b_entities);
399        assert_eq!(a_relations.len(), b_relations.len());
400    }
401}