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}