ktstr/ctprof_compare/aggregate.rs
1//! Per-group aggregate value carrier and the merge primitives
2//! that compose it.
3//!
4//! Two layers:
5//!
6//! 1. [`Aggregated`] — closed enum of the per-metric aggregate
7//! shapes a thread group produces: [`Aggregated::Sum`] /
8//! [`Aggregated::Max`] for the numeric reduction rules,
9//! [`Aggregated::OrdinalRange`] for `[min, max]` interval rules
10//! over signed kernel ordinals, [`Aggregated::Mode`] for the
11//! categorical mode-with-tally aggregation, and
12//! [`Aggregated::Affinity`] for the cpuset-cardinality summary.
13//! Each variant carries the shape its rule's accessor produced
14//! after the cross-thread reduction. `numeric()` projects to
15//! `Option<f64>` for the delta-math / sort path; categorical
16//! rules return `None`. The [`std::fmt::Display`] impl is
17//! consumed by [`super::scale::format_value_cell`] when the
18//! rule's ladder is [`super::ScaleLadder::None`].
19//!
20//! 2. [`merge_aggregated_into`] — the per-variant merge primitive
21//! that composes summaries when N candidate groups collapse
22//! onto one baseline-keyed row (the N:1 fudge merge): Sum
23//! counters add, Max peaks take max-of-maxes (NOT a sum —
24//! summing peaks invents peaks taller than any thread
25//! observed), OrdinalRange unions the bounds, Mode unions the
26//! per-value tally maps, Affinity widens the cardinality bounds
27//! and collapses the exact CPU list to `None` on any mismatch.
28//! Variant mismatches are silently dropped — every group's
29//! metrics map shares the same [`super::CTPROF_METRICS`] keys,
30//! so the fall-through arm is defense-in-depth.
31//!
32//! The [`AffinitySummary`] CPU-set cardinality carrier and the
33//! [`format_cpu_range`] runs-collapsing renderer travel with
34//! [`Aggregated::Affinity`] because the renderer is consumed only
35//! by the Affinity arm of the [`std::fmt::Display`] impl.
36
37use std::collections::BTreeMap;
38use std::fmt;
39
40/// Aggregated metric value for a single [`super::ThreadGroup`].
41///
42/// Carries both a numeric projection (used for delta math and
43/// sort order) and a display form. Not every rule produces a
44/// numeric — the categorical rules
45/// ([`super::AggRule::Mode`] / [`super::AggRule::ModeChar`] /
46/// [`super::AggRule::ModeBool`]) aggregate to a string, which has no
47/// scalar — so the numeric is optional and rows without one
48/// fall to the bottom of the default sort.
49#[derive(Debug, Clone)]
50#[non_exhaustive]
51pub enum Aggregated {
52 /// Group-wide sum produced by the
53 /// [`super::AggRule::SumCount`] / [`super::AggRule::SumNs`] /
54 /// [`super::AggRule::SumTicks`] / [`super::AggRule::SumBytes`] rules. The
55 /// dispatch unwraps the typed newtype's inner `u64` after
56 /// the [`crate::metric_types::Summable::sum_across`]
57 /// reduction; storage stays u64 to preserve full precision
58 /// across the entire schedstats / byte / tick range with
59 /// no lossy cast at aggregation time. The render-time
60 /// auto-scale ladder is derived from the typed accessor
61 /// newtype via [`super::AggRule::ladder`] — there is no
62 /// separate `unit` tag on the metric def.
63 Sum(u64),
64 /// Group-wide maximum produced by the
65 /// [`super::AggRule::MaxPeak`] / [`super::AggRule::MaxGaugeNs`] /
66 /// [`super::AggRule::MaxGaugeCount`] rules. Distinct variant from
67 /// `Sum` so a downstream consumer that wants to surface
68 /// "the worst single thread" rather than "the
69 /// summed-across-threads value" can match without name-
70 /// matching against the metric registry. Storage is u64 to
71 /// preserve full ns precision across the entire schedstats
72 /// range (no `as f64` lossy cast at aggregation time).
73 Max(u64),
74 /// Group-wide `[min, max]` interval produced by the
75 /// [`super::AggRule::RangeI32`] / [`super::AggRule::RangeU32`] rules.
76 /// Both bounds widen to `i64` at the dispatch boundary
77 /// (`i64::from(OrdinalI32.0)` / `i64::from(OrdinalU32.0)`)
78 /// — `OrdinalI32` carries a signed kernel-side range
79 /// (`nice` includes negative values) and `OrdinalU32` fits
80 /// into `i64` losslessly, so a single signed scalar
81 /// represents both ordinal widths without losing the sign
82 /// from `OrdinalI32` or wrapping the magnitude from
83 /// `OrdinalU32`. Delta math takes the midpoint
84 /// (`(min + max) / 2`) so a one-sided shift surfaces in
85 /// the rendered delta column.
86 OrdinalRange {
87 min: i64,
88 max: i64,
89 },
90 /// Categorical aggregate carrying per-value counts across
91 /// the bucket. `tallies` maps each observed value to its
92 /// occurrence count; `total` is the bucket size (count of
93 /// every contributor, including any empty-string fallbacks).
94 /// The mode (most-frequent value) is derived on demand via
95 /// [`Aggregated::mode_value`] / [`Aggregated::mode_count`]
96 /// so cross-bucket merges (N:1 fudge) compose correctly:
97 /// unioning two buckets' tallies gives the true cross-bucket
98 /// frequency for each value, not just the per-bucket
99 /// max-count. Empty buckets surface as `tallies: empty,
100 /// total: bucket_size`; the renderer handles the empty case
101 /// by emitting `~` in place of the mode value.
102 Mode {
103 tallies: BTreeMap<String, usize>,
104 total: usize,
105 },
106 Affinity(AffinitySummary),
107 /// The metric's capture-gated family was NOT measured for any thread in the
108 /// group — the taskstats genetlink query failed for every thread (the
109 /// query-level `ThreadState::taskstats_measured` flag is false: CONFIG_TASKSTATS
110 /// off / no CAP_NET_ADMIN / query raced exit), or — for the jemalloc byte
111 /// counters — no thread's per-thread probe READ succeeded (non-jemalloc
112 /// process, probe could not attach, or the read failed) — distinct from a
113 /// measured zero. (A sub-family disabled while the query still succeeds — e.g.
114 /// CONFIG_TASK_XACCT off, or the `kernel.task_delayacct` sysctl off, with the
115 /// other family on — now IS Absent: the measured predicate ANDs the per-thread
116 /// query-Ok with the per-sub-family active flag baked from host probes. See the
117 /// `taskstats_measured` field doc.) Produced by [fn@super::aggregate]'s
118 /// caller when the family has a
119 /// measured predicate, the bucket is non-empty, and no contributing thread
120 /// captured it. `numeric()` returns `None`, so a derived metric consuming it
121 /// (e.g. `total_offcpu_delay_ns`, `live_heap_estimate`) short-circuits to
122 /// absent rather than computing from a sentinel `0`, and the renderer prints
123 /// "-" rather than "0". A MEASURED zero stays `Sum(0)` and renders "0".
124 Absent,
125}
126
127/// CPU-affinity aggregation result.
128///
129/// `uniform` is `Some(cpus)` when every thread in the group shared
130/// the same allowed set; otherwise heterogeneous and the renderer
131/// emits "N-M cpus (mixed)".
132#[derive(Debug, Clone)]
133#[non_exhaustive]
134pub struct AffinitySummary {
135 pub min_cpus: usize,
136 pub max_cpus: usize,
137 pub uniform: Option<Vec<u32>>,
138}
139
140impl Aggregated {
141 /// Scalar projection for delta math. `None` when the rule
142 /// produces no meaningful scalar (categorical mode, affinity
143 /// with heterogeneous cpusets).
144 pub fn numeric(&self) -> Option<f64> {
145 match self {
146 Aggregated::Sum(v) => Some(*v as f64),
147 Aggregated::Max(v) => Some(*v as f64),
148 Aggregated::OrdinalRange { min, max } => {
149 // Midpoint: keeps a min→max shift on one end visible
150 // in the delta without privileging either bound.
151 Some((*min as f64 + *max as f64) / 2.0)
152 }
153 Aggregated::Mode { .. } => None,
154 // Capture-gated family not measured for the group → no scalar, so a
155 // consuming derived metric short-circuits to absent ("-").
156 Aggregated::Absent => None,
157 Aggregated::Affinity(s) => {
158 // Number of allowed CPUs is the natural scalar. When
159 // the group is uniform, `min_cpus == max_cpus`; when
160 // heterogeneous, midpoint parallels OrdinalRange.
161 Some((s.min_cpus as f64 + s.max_cpus as f64) / 2.0)
162 }
163 }
164 }
165
166 /// Construct an `Aggregated::Mode` from a single
167 /// (value, count, total) triple, common in test fixtures
168 /// and the single-bucket aggregate path. Equivalent to
169 /// inserting `(value, count)` into a fresh tally map.
170 /// `count == 0` produces an empty tally (the empty-bucket
171 /// shape), preserving the historical contract that an
172 /// empty Mode renders as `~` while still carrying a
173 /// well-defined `total` for the rendered "(count/total)"
174 /// suffix.
175 pub fn mode_single(value: impl Into<String>, count: usize, total: usize) -> Aggregated {
176 let mut tallies = BTreeMap::new();
177 if count > 0 {
178 tallies.insert(value.into(), count);
179 }
180 Aggregated::Mode { tallies, total }
181 }
182
183 /// The most-frequent value tracked by an `Aggregated::Mode`,
184 /// or the empty string when the tallies map is empty (an
185 /// empty bucket whose `total` may still be non-zero, e.g.
186 /// when every contributor produced no categorical sample).
187 /// Ties on count are broken by lexicographic order on the
188 /// VALUE — the lex-smallest key wins. Deterministic across
189 /// runs and matches the historical Mode contract under the
190 /// old `value, count` shape.
191 pub fn mode_value(&self) -> &str {
192 match self {
193 Aggregated::Mode { tallies, .. } => {
194 // BTreeMap iterates keys in lex order. Use a
195 // strict-greater fold so on a count tie the
196 // first key encountered (lex-smallest) wins;
197 // `max_by_key` returns the last-seen at a tie,
198 // which would invert the lex order.
199 let mut best: Option<(&str, usize)> = None;
200 for (k, c) in tallies {
201 match best {
202 None => best = Some((k.as_str(), *c)),
203 Some((_, bc)) if *c > bc => best = Some((k.as_str(), *c)),
204 _ => {}
205 }
206 }
207 best.map(|(v, _)| v).unwrap_or("")
208 }
209 _ => "",
210 }
211 }
212
213 /// The frequency count of the mode value. Zero when the
214 /// tallies map is empty (no contributor produced a
215 /// categorical sample). Same tie-break as
216 /// [`Self::mode_value`].
217 pub fn mode_count(&self) -> usize {
218 match self {
219 Aggregated::Mode { tallies, .. } => tallies.values().copied().max().unwrap_or(0),
220 _ => 0,
221 }
222 }
223}
224
225impl fmt::Display for Aggregated {
226 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227 match self {
228 Aggregated::Sum(v) => write!(f, "{v}"),
229 Aggregated::Max(v) => write!(f, "{v}"),
230 Aggregated::OrdinalRange { min, max } => {
231 if min == max {
232 write!(f, "{min}")
233 } else {
234 write!(f, "{min}..{max}")
235 }
236 }
237 Aggregated::Mode { tallies, total } => {
238 // Tie-break: lex-smallest value wins on count
239 // ties. Mirrors `Aggregated::mode_value`'s
240 // strict-greater fold.
241 let mut best: Option<(&str, usize)> = None;
242 for (k, c) in tallies {
243 match best {
244 None => best = Some((k.as_str(), *c)),
245 Some((_, bc)) if *c > bc => best = Some((k.as_str(), *c)),
246 _ => {}
247 }
248 }
249 let value = best.map(|(v, _)| v).unwrap_or("~");
250 let count = best.map(|(_, c)| c).unwrap_or(0);
251 if count == *total && count > 0 {
252 write!(f, "{value}")
253 } else {
254 write!(f, "{value} ({count}/{total})")
255 }
256 }
257 Aggregated::Affinity(s) => {
258 if let Some(cpus) = &s.uniform {
259 let n = cpus.len();
260 let range = format_cpu_range(cpus);
261 write!(f, "{n} cpus ({range})")
262 } else if s.min_cpus == s.max_cpus {
263 write!(f, "{} cpus (mixed)", s.min_cpus)
264 } else {
265 write!(f, "{}-{} cpus (mixed)", s.min_cpus, s.max_cpus)
266 }
267 }
268 // Not-measured marker. The unit-suffixed render path
269 // (`scale::format_value_cell`) special-cases Absent to a bare "-"
270 // (no unit ladder); this bare Display is the fallback for any other
271 // consumer, so it carries no unit either.
272 Aggregated::Absent => write!(f, "-"),
273 }
274 }
275}
276
277/// Merge `val` into `existing` for the N:1 fudge aggregation
278/// (multiple candidate groups collapsed into one baseline-keyed
279/// row). Mirrors the canonical [fn@super::aggregate] semantics on the
280/// merged-set rather than re-aggregating the per-thread inputs:
281/// per-group summaries are all the merge has access to, so the
282/// merge composes summaries directly.
283///
284/// Per variant:
285/// - [`Aggregated::Sum`]: monotone counters add.
286/// - [`Aggregated::Max`]: peaks take max-of-maxes (NOT a sum —
287/// wait_max / sleep_max / exec_max are per-thread peaks; summing
288/// would invent peaks taller than any thread observed).
289/// - [`Aggregated::OrdinalRange`]: union the bounds.
290/// - [`Aggregated::Mode`]: union the per-value tally maps and
291/// sum the totals. The mode (most-frequent value) is derived
292/// on demand from the merged tallies, so cross-bucket
293/// frequencies stay accurate — a value that appears in N
294/// buckets accumulates its true total count, not just the
295/// largest single-bucket count.
296/// - [`Aggregated::Affinity`]: cardinality bounds widen —
297/// `min_cpus` becomes the smallest of any merged summary's
298/// `min_cpus`, `max_cpus` the largest. The exact CPU list
299/// (`uniform`) survives only when every merged summary
300/// already carried the same `Some(cpus)` list; mismatch or
301/// any `None` collapses to `None`.
302///
303/// Variant mismatches between `existing` and `val` are silently
304/// dropped — a fudged pair must agree on the metric's typed
305/// rule because every group's metrics map shares the same
306/// [`super::CTPROF_METRICS`] keys, so the fall-through arm is
307/// defense-in-depth rather than a runtime-reachable case.
308pub(super) fn merge_aggregated_into(existing: &mut Aggregated, val: &Aggregated) {
309 match (existing, val) {
310 (Aggregated::Sum(s), Aggregated::Sum(v)) => {
311 // saturating_add to match the canonical aggregation
312 // (`Summable::sum_across`, metric_types.rs): a corrupt or
313 // hostile reading that would push the merged sum past
314 // u64::MAX saturates rather than wrapping (release) or
315 // panicking (debug). This N:1 fudge merge composes
316 // already-aggregated Sum values, so the overflow is
317 // reachable on adversarial input.
318 *s = (*s).saturating_add(*v);
319 }
320 (Aggregated::Max(m), Aggregated::Max(v)) => {
321 *m = (*m).max(*v);
322 }
323 (
324 Aggregated::OrdinalRange { min, max },
325 Aggregated::OrdinalRange {
326 min: vmin,
327 max: vmax,
328 },
329 ) => {
330 *min = (*min).min(*vmin);
331 *max = (*max).max(*vmax);
332 }
333 (
334 Aggregated::Mode { tallies: et, total },
335 Aggregated::Mode {
336 tallies: vt,
337 total: vtot,
338 },
339 ) => {
340 // saturating_add on the tally totals/counts for parity
341 // with the codebase's other counter merges (frac_pair's
342 // saturating_add, aggregate_finite's checked_add) — a
343 // tally is bounded by the sample population today, but
344 // unchecked `+=` would panic in debug / wrap in release if
345 // that ever changed, inconsistent with sibling sites.
346 *total = (*total).saturating_add(*vtot);
347 for (k, c) in vt {
348 let e = et.entry(k.clone()).or_insert(0);
349 *e = (*e).saturating_add(*c);
350 }
351 }
352 (Aggregated::Affinity(es), Aggregated::Affinity(vs)) => {
353 es.min_cpus = es.min_cpus.min(vs.min_cpus);
354 es.max_cpus = es.max_cpus.max(vs.max_cpus);
355 es.uniform = match (&es.uniform, &vs.uniform) {
356 (Some(a), Some(b)) if a == b => Some(a.clone()),
357 _ => None,
358 };
359 }
360 // Not-measured marker — measured wins. An Absent `existing` is replaced
361 // by `val` (so a measured group's value survives a fudge merge with an
362 // unmeasured one; both-Absent stays Absent since `val` is also Absent);
363 // an Absent `val` contributes nothing to a measured `existing`. This
364 // keeps a fudged row's measured-ness as "any contributor measured it",
365 // matching the aggregate-level rule.
366 (existing_slot @ Aggregated::Absent, v) => *existing_slot = v.clone(),
367 (_, Aggregated::Absent) => {}
368 _ => {}
369 }
370}
371
372/// Render a CPU set as a comma-separated list of contiguous-run
373/// ranges (`a-b`), matching the kernel's cpuset display
374/// convention (`cat cpuset.cpus` emits `0-3,8`). Assumes the
375/// input is sorted ascending — capture layer
376/// (`crate::ctprof::ThreadState::cpu_affinity`) stores
377/// sorted cpusets. Empty input returns an empty string. Used
378/// only by `Aggregated`'s `Display::fmt` Affinity arm; kept here so the
379/// renderer and its helper travel together.
380pub(super) fn format_cpu_range(cpus: &[u32]) -> String {
381 // Collapse contiguous runs to `a-b`, join with commas. Assumes
382 // sorted ascending; capture layer stores sorted cpusets.
383 if cpus.is_empty() {
384 return String::new();
385 }
386 let mut out = String::new();
387 let mut start = cpus[0];
388 let mut prev = cpus[0];
389 for &c in &cpus[1..] {
390 if c == prev + 1 {
391 prev = c;
392 continue;
393 }
394 if !out.is_empty() {
395 out.push(',');
396 }
397 if start == prev {
398 out.push_str(&start.to_string());
399 } else {
400 out.push_str(&format!("{start}-{prev}"));
401 }
402 start = c;
403 prev = c;
404 }
405 if !out.is_empty() {
406 out.push(',');
407 }
408 if start == prev {
409 out.push_str(&start.to_string());
410 } else {
411 out.push_str(&format!("{start}-{prev}"));
412 }
413 out
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 /// The N:1 fudge merge composes already-aggregated `Sum(u64)`
421 /// values; merging two `u64::MAX` must saturate (matching
422 /// `Summable::sum_across`), not wrap in release or panic in debug.
423 #[test]
424 fn merge_sum_saturates_at_u64_max() {
425 let mut existing = Aggregated::Sum(u64::MAX);
426 merge_aggregated_into(&mut existing, &Aggregated::Sum(u64::MAX));
427 let Aggregated::Sum(s) = existing else {
428 panic!("expected Sum variant");
429 };
430 assert_eq!(s, u64::MAX, "merged Sum must saturate, not wrap");
431 }
432}