ktstr/assert/types.rs
1use super::*;
2
3thread_local! {
4 /// Thread-local active phase label. Set by the [`PhaseGuard`]
5 /// scope helper at scenario-driver `run_step` entry and read by
6 /// [`AssertDetail::new`] / [`PassDetail::binary`] /
7 /// [`PassDetail::unary`] / [`InfoNote::new`] producers so every
8 /// detail constructed under a guarded scope auto-stamps its
9 /// `phase` field with the active label without the producer
10 /// having to thread context through every `with_phase` chain.
11 /// `None` outside any guarded scope (boot, BASELINE settle,
12 /// non-scenario test fixtures).
13 static ACTIVE_PHASE: RefCell<Option<std::borrow::Cow<'static, str>>> =
14 const { RefCell::new(None) };
15}
16
17/// Snapshot the active phase label installed by the most recent
18/// [`PhaseGuard::install`] on this thread. `None` outside any
19/// guarded scope. Construction sites for [`AssertDetail`] /
20/// [`PassDetail`] / [`InfoNote`] call this to auto-stamp the
21/// `phase` field; the test author can still override via the
22/// builder `with_phase(...)` chain when an explicit value is
23/// preferred.
24pub fn current_phase_label() -> Option<std::borrow::Cow<'static, str>> {
25 ACTIVE_PHASE.with(|p| p.borrow().clone())
26}
27
28/// RAII scope guard for the `ACTIVE_PHASE` thread-local. Install
29/// at scenario-driver `run_step` entry; the guard's `Drop` restores
30/// the prior phase label, supporting cleanly-nested scenario
31/// dispatch (sub-scenarios layer over a parent's phase context
32/// without leaking).
33///
34/// ```ignore
35/// let _guard = PhaseGuard::install_step(0); // Step[0] → "Step[0]"
36/// // ... apply_ops + hold, every assert constructed here stamps
37/// // phase = Some("Step[0]") automatically ...
38/// // drop on scope exit restores the prior label (BASELINE outside
39/// // any nested Step).
40/// ```
41#[must_use = "PhaseGuard restores the prior phase on Drop — bind it to a local"]
42pub struct PhaseGuard {
43 /// The phase label that was active before this guard installed.
44 /// Restored on Drop so nested guards stack cleanly.
45 previous: Option<std::borrow::Cow<'static, str>>,
46}
47
48impl PhaseGuard {
49 /// Install `label` as the active phase. Captures the
50 /// previously-active label for restoration on Drop. Use
51 /// [`Self::install_step`] / [`Self::install_baseline`] for the
52 /// scenario-driver call sites — they produce the standard
53 /// `"Step[k]"` / `"BASELINE"` labels matching the rest of the
54 /// pipeline.
55 pub fn install(label: impl Into<std::borrow::Cow<'static, str>>) -> Self {
56 let previous = ACTIVE_PHASE.with(|p| p.replace(Some(label.into())));
57 Self { previous }
58 }
59
60 /// Convenience: install the `"Step[k]"` label for the
61 /// `zero_indexed`-th scenario Step. Matches the label
62 /// [`PhaseBucket`] embeds + the [`Phase::step`] display
63 /// (`Step[0]`, `Step[1]`, ...).
64 pub fn install_step(zero_indexed: u16) -> Self {
65 Self::install(format!("Step[{}]", zero_indexed))
66 }
67
68 /// Convenience: install the `"BASELINE"` label for the
69 /// pre-first-Step settle window. Matches the label
70 /// [`PhaseBucket`] uses for `step_index = 0`.
71 pub fn install_baseline() -> Self {
72 Self::install(std::borrow::Cow::Borrowed("BASELINE"))
73 }
74}
75
76impl Drop for PhaseGuard {
77 fn drop(&mut self) {
78 ACTIVE_PHASE.with(|p| {
79 *p.borrow_mut() = self.previous.take();
80 });
81 }
82}
83
84/// Per-VMA entry parsed from `/proc/self/numa_maps`.
85#[derive(Debug, Clone, Default)]
86pub struct NumaMapsEntry {
87 /// Virtual address of the VMA.
88 pub addr: u64,
89 /// Per-node page counts (node_id -> page_count).
90 pub node_pages: BTreeMap<usize, u64>,
91}
92
93/// Parse `/proc/self/numa_maps` content into per-VMA entries.
94///
95/// Each line has the format:
96/// `<hex_addr> <policy> [key=val ...]`
97/// where per-node page counts appear as `N<node>=<count>`.
98pub fn parse_numa_maps(content: &str) -> Vec<NumaMapsEntry> {
99 let mut entries = Vec::new();
100 for line in content.lines() {
101 let line = line.trim();
102 if line.is_empty() {
103 continue;
104 }
105 let mut parts = line.split_whitespace();
106 let addr = match parts.next().and_then(|s| u64::from_str_radix(s, 16).ok()) {
107 Some(a) => a,
108 None => continue,
109 };
110 // Skip policy field.
111 let _ = parts.next();
112
113 let mut entry = NumaMapsEntry {
114 addr,
115 ..Default::default()
116 };
117
118 for token in parts {
119 if let Some(rest) = token.strip_prefix('N')
120 && let Some((node_str, count_str)) = rest.split_once('=')
121 && let (Ok(node), Ok(count)) = (node_str.parse::<usize>(), count_str.parse::<u64>())
122 {
123 *entry.node_pages.entry(node).or_insert(0) += count;
124 }
125 }
126
127 if !entry.node_pages.is_empty() {
128 entries.push(entry);
129 }
130 }
131 entries
132}
133
134/// Compute page locality fraction from parsed numa_maps entries.
135///
136/// Returns the fraction of pages residing on any node in
137/// `expected_nodes` (0.0-1.0). Returns 0.0 when no pages are observed
138/// — a zero-allocation workload is not vacuously local; reporting 1.0
139/// would let `min_page_locality` thresholds silently pass on broken
140/// runs that produced no NUMA signal. The expected node set is the
141/// cgroup's cpuset NUMA-node set (the nodes whose CPUs the worker is
142/// confined to), supplied by the caller — see `assert_cgroup` in
143/// `crate::assert::plan` / `crate::assert::reductions`, not the
144/// worker's [`MemPolicy`](crate::workload::MemPolicy).
145pub fn page_locality(entries: &[NumaMapsEntry], expected_nodes: &BTreeSet<usize>) -> f64 {
146 let mut total: u64 = 0;
147 let mut local: u64 = 0;
148 for entry in entries {
149 for (&node, &count) in &entry.node_pages {
150 total += count;
151 if expected_nodes.contains(&node) {
152 local += count;
153 }
154 }
155 }
156 if total > 0 {
157 local as f64 / total as f64
158 } else {
159 0.0
160 }
161}
162
163/// Extract `numa_pages_migrated` from `/proc/vmstat` content.
164///
165/// Returns `None` if the counter is not present. The counter is
166/// cumulative; callers diff pre- and post-workload snapshots to
167/// get migration count during the test.
168pub fn parse_vmstat_numa_pages_migrated(content: &str) -> Option<u64> {
169 for line in content.lines() {
170 let line = line.trim();
171 if let Some(rest) = line.strip_prefix("numa_pages_migrated") {
172 let rest = rest.trim();
173 if let Ok(v) = rest.parse::<u64>() {
174 return Some(v);
175 }
176 }
177 }
178 None
179}
180
181pub(crate) fn gap_threshold_ms() -> u64 {
182 // Unoptimized debug builds have higher scheduling overhead.
183 if cfg!(debug_assertions) { 3000 } else { 2000 }
184}
185
186pub(crate) fn spread_threshold_pct() -> f64 {
187 // Debug builds in small VMs (especially under EEVDF) show higher
188 // spread than optimized builds under sched_ext schedulers.
189 if cfg!(debug_assertions) { 35.0 } else { 15.0 }
190}