axiolid_backend_cpu/
topology.rs

1//! Cache and core-topology detection for tuning, not for capability gating.
2//!
3//! # Why this is separate from `CpuFeatures`
4//!
5//! `CpuFeatures` answers *what a machine can execute* -- a wrong answer there
6//! is a crash. This module answers *what shape the machine is* -- a wrong
7//! answer here is only a bad tuning choice. The two failure modes differ by
8//! orders of magnitude, so they stay separate types.
9//!
10//! # Why every field is optional
11//!
12//! Detection reads sysfs, which is absent on some targets and restricted in
13//! some containers. An unknown cache size is reported as `None`, never as a
14//! plausible default: a fabricated 32 KiB that is really 64 KiB produces a
15//! tuning decision made on a number nobody measured. Callers must supply
16//! their own fallback explicitly, so the guess is visible at the call site.
17//!
18//! Detection is Linux sysfs plus `available_parallelism`: no dependency, no
19//! `unsafe`, no CPUID, and the same code on x86_64 and aarch64. Other
20//! targets report no caches and undetermined core heterogeneity.
21//!
22//! # Evidence this matters
23//!
24//! A radix sort in the mesh audit executed 18.9% fewer instructions than the
25//! comparison sort it replaced and ran 2.2x slower, because scattering into
26//! 256 buckets missed L1 four times as often. Instruction count could not see
27//! it; cache size explains it. That is the class of decision this module
28//! exists to inform.
29
30/// One cache level's measured geometry.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct CacheLevel {
33    /// Level number: 1, 2, or 3.
34    pub level: u8,
35    /// Total size in bytes.
36    pub bytes: usize,
37    /// Cache line size in bytes.
38    pub line_bytes: usize,
39    /// How many logical CPUs share this level.
40    ///
41    /// A shared last level means per-thread working sets contend; a private
42    /// L2 means they do not. Work-splitting decisions need this, not just
43    /// the size.
44    pub shared_by: usize,
45}
46
47/// The measured shape of the host machine.
48///
49/// Every field is optional because an unmeasured value must stay visibly
50/// absent rather than silently defaulted.
51#[derive(Debug, Clone, PartialEq, Eq, Default)]
52pub struct CpuTopology {
53    /// Logical CPUs usable by this process, honouring CPU affinity.
54    pub logical_cpus: Option<usize>,
55    /// Data and unified cache levels, ascending by level.
56    pub caches: Vec<CacheLevel>,
57    /// Whether cores have differing capability (P/E cores, Snapdragon X).
58    ///
59    /// `None` means undetermined, which is not the same as `Some(false)`.
60    /// Even work splitting is wrong on a heterogeneous machine, so the
61    /// distinction matters.
62    pub heterogeneous_cores: Option<bool>,
63}
64
65impl CpuTopology {
66    /// Detect the host's shape. Unavailable values stay `None`.
67    pub fn detect() -> Self {
68        Self {
69            logical_cpus: detect_logical_cpus(),
70            caches: detect_caches(),
71            heterogeneous_cores: detect_heterogeneous(),
72        }
73    }
74
75    /// Bytes in the given cache level, if measured.
76    pub fn cache_bytes(&self, level: u8) -> Option<usize> {
77        self.caches
78            .iter()
79            .find(|cache| cache.level == level)
80            .map(|cache| cache.bytes)
81    }
82
83    /// Bytes in the largest measured cache level.
84    pub fn last_level_bytes(&self) -> Option<usize> {
85        self.caches.iter().map(|cache| cache.bytes).max()
86    }
87
88    /// Cache line size, if measured.
89    pub fn line_bytes(&self) -> Option<usize> {
90        self.caches.first().map(|cache| cache.line_bytes)
91    }
92
93    /// Whether a working set of `bytes` fits in the given level.
94    ///
95    /// `None` when the level was not measured -- the caller must decide what
96    /// to do without the number rather than receive a fabricated `false`.
97    pub fn fits_in_cache(&self, bytes: usize, level: u8) -> Option<bool> {
98        self.cache_bytes(level).map(|capacity| bytes <= capacity)
99    }
100}
101
102/// Logical CPUs available to this process.
103///
104/// `available_parallelism` honours cgroup limits and CPU affinity, so a
105/// container pinned to 2 cores of a 20-core host reports 2. Reading the raw
106/// core count would oversubscribe such a machine badly.
107fn detect_logical_cpus() -> Option<usize> {
108    std::thread::available_parallelism().ok().map(Into::into)
109}
110
111/// Parse a sysfs size string such as `32K`, `4096K`, or `16M`.
112fn parse_size(text: &str) -> Option<usize> {
113    let text = text.trim();
114    let (digits, multiplier) = match text.as_bytes().last()? {
115        b'K' => (&text[..text.len() - 1], 1024),
116        b'M' => (&text[..text.len() - 1], 1024 * 1024),
117        b'G' => (&text[..text.len() - 1], 1024 * 1024 * 1024),
118        _ => (text, 1),
119    };
120    digits.parse::<usize>().ok()?.checked_mul(multiplier)
121}
122
123/// Count entries in a sysfs CPU list such as `0-19` or `0,4,8`.
124fn parse_cpu_list(text: &str) -> Option<usize> {
125    let mut total = 0usize;
126    for part in text.trim().split(',') {
127        if part.is_empty() {
128            continue;
129        }
130        match part.split_once('-') {
131            Some((low, high)) => {
132                let low: usize = low.trim().parse().ok()?;
133                let high: usize = high.trim().parse().ok()?;
134                total += high.checked_sub(low)?.checked_add(1)?;
135            }
136            None => total += 1,
137        }
138    }
139    (total > 0).then_some(total)
140}
141
142/// Read data and unified cache levels from sysfs.
143///
144/// Instruction caches are skipped: they say nothing about a data working
145/// set, which is what tuning decisions are about.
146#[cfg(target_os = "linux")]
147fn detect_caches() -> Vec<CacheLevel> {
148    use std::fs::read_to_string;
149
150    let mut caches = Vec::new();
151    for index in 0..16 {
152        let base = format!("/sys/devices/system/cpu/cpu0/cache/index{index}");
153        let Ok(kind) = read_to_string(format!("{base}/type")) else {
154            break;
155        };
156        let kind = kind.trim();
157        if kind != "Data" && kind != "Unified" {
158            continue;
159        }
160        let level = read_to_string(format!("{base}/level"))
161            .ok()
162            .and_then(|text| text.trim().parse::<u8>().ok());
163        let bytes = read_to_string(format!("{base}/size"))
164            .ok()
165            .and_then(|text| parse_size(&text));
166        let line_bytes = read_to_string(format!("{base}/coherency_line_size"))
167            .ok()
168            .and_then(|text| text.trim().parse::<usize>().ok());
169        let shared_by = read_to_string(format!("{base}/shared_cpu_list"))
170            .ok()
171            .and_then(|text| parse_cpu_list(&text));
172        // A partially-read level is dropped rather than completed with
173        // invented values.
174        if let (Some(level), Some(bytes), Some(line_bytes), Some(shared_by)) =
175            (level, bytes, line_bytes, shared_by)
176        {
177            caches.push(CacheLevel {
178                level,
179                bytes,
180                line_bytes,
181                shared_by,
182            });
183        }
184    }
185    caches.sort_unstable_by_key(|cache| cache.level);
186    caches.dedup_by_key(|cache| cache.level);
187    caches
188}
189
190/// Cache geometry is unavailable off Linux; callers see an empty list.
191#[cfg(not(target_os = "linux"))]
192fn detect_caches() -> Vec<CacheLevel> {
193    Vec::new()
194}
195
196/// Whether the machine has cores of differing capability.
197///
198/// Two independent signals, because neither alone covers both vendors:
199///
200/// - `cpu/types/` exists on Intel hybrid parts (P/E cores).
201/// - Differing `cpu_capacity` values cover Arm big.LITTLE, which is how a
202///   Snapdragon X presents its heterogeneous cores.
203///
204/// Returns `None` when neither signal is readable, because "undetermined"
205/// and "homogeneous" lead to different splitting choices.
206#[cfg(target_os = "linux")]
207fn detect_heterogeneous() -> Option<bool> {
208    use std::fs::read_to_string;
209
210    if std::path::Path::new("/sys/devices/system/cpu/types").is_dir() {
211        return Some(true);
212    }
213
214    let mut capacities = Vec::new();
215    for cpu in 0..256 {
216        let path = format!("/sys/devices/system/cpu/cpu{cpu}/cpu_capacity");
217        if !std::path::Path::new(&path).exists() {
218            // CPU numbering is dense from 0; the first gap ends the scan.
219            if cpu == 0 {
220                return None;
221            }
222            break;
223        }
224        if let Some(value) = read_to_string(&path)
225            .ok()
226            .and_then(|text| text.trim().parse::<u32>().ok())
227        {
228            capacities.push(value);
229        }
230    }
231    if capacities.is_empty() {
232        return None;
233    }
234    let first = capacities[0];
235    Some(capacities.iter().any(|value| *value != first))
236}
237
238/// Core heterogeneity is unavailable off Linux.
239#[cfg(not(target_os = "linux"))]
240fn detect_heterogeneous() -> Option<bool> {
241    None
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn sizes_parse_with_and_without_suffixes() {
250        assert_eq!(parse_size("32K"), Some(32 * 1024));
251        assert_eq!(parse_size("4096K"), Some(4096 * 1024));
252        assert_eq!(parse_size("16M"), Some(16 * 1024 * 1024));
253        assert_eq!(parse_size("512"), Some(512));
254        assert_eq!(parse_size(""), None);
255        assert_eq!(parse_size("garbage"), None);
256    }
257
258    #[test]
259    fn cpu_lists_count_ranges_and_singletons() {
260        assert_eq!(parse_cpu_list("0-19"), Some(20));
261        assert_eq!(parse_cpu_list("0"), Some(1));
262        assert_eq!(parse_cpu_list("0,4,8"), Some(3));
263        assert_eq!(parse_cpu_list("0-3,8-11"), Some(8));
264        assert_eq!(parse_cpu_list(""), None);
265    }
266
267    /// A descending range must not silently wrap into a huge count.
268    #[test]
269    fn a_malformed_range_refuses_rather_than_wrapping() {
270        assert_eq!(parse_cpu_list("19-0"), None);
271    }
272
273    /// An unmeasured level must report absence, never a plausible default.
274    #[test]
275    fn an_unmeasured_cache_level_answers_none() {
276        let topology = CpuTopology::default();
277        assert_eq!(topology.cache_bytes(1), None);
278        assert_eq!(topology.last_level_bytes(), None);
279        assert_eq!(topology.fits_in_cache(1024, 1), None);
280    }
281
282    #[test]
283    fn fit_queries_compare_against_the_measured_capacity() {
284        let topology = CpuTopology {
285            logical_cpus: Some(8),
286            caches: vec![CacheLevel {
287                level: 1,
288                bytes: 32 * 1024,
289                line_bytes: 64,
290                shared_by: 1,
291            }],
292            heterogeneous_cores: Some(false),
293        };
294        assert_eq!(topology.fits_in_cache(32 * 1024, 1), Some(true));
295        assert_eq!(topology.fits_in_cache(32 * 1024 + 1, 1), Some(false));
296        assert_eq!(topology.fits_in_cache(1024, 2), None);
297    }
298
299    /// Detection must never invent values on the host it runs on.
300    ///
301    /// This asserts internal consistency rather than specific sizes, so it
302    /// stays true on any machine including CI containers.
303    #[test]
304    fn detection_is_self_consistent_on_this_host() {
305        let topology = CpuTopology::detect();
306        if let Some(cpus) = topology.logical_cpus {
307            assert!(cpus >= 1, "a usable machine has at least one CPU");
308        }
309        let mut previous = 0usize;
310        for cache in &topology.caches {
311            assert!(cache.bytes > 0, "a reported cache has a size");
312            assert!(cache.line_bytes > 0, "a reported cache has a line size");
313            assert!(cache.shared_by >= 1, "a cache is shared by >= 1 CPU");
314            assert!(
315                cache.bytes >= previous,
316                "cache levels grow with level: L{} is {} bytes after {previous}",
317                cache.level,
318                cache.bytes,
319            );
320            previous = cache.bytes;
321        }
322    }
323}