ktstr/ctprof_compare/
diff_types.rs

1//! Per-row data carriers for the comparison output.
2//!
3//! Three layers. [`DiffRow`] / [`DerivedRow`] / [`CtprofDiff`]
4//! are consumed by the parent module's [`super::write_diff`]
5//! renderer; [`ThreadGroup`] additionally feeds the binary
6//! crate's show path (`write_show` in `src/bin/ktstr.rs`), which
7//! renders from [`super::Aggregated`] rather than the diff rows:
8//!
9//! 1. [`ThreadGroup`] — the per-axis aggregation result. One
10//!    instance per group key (pcomm / cgroup / comm / comm-exact
11//!    skeleton); carries the per-metric [`super::Aggregated`] map,
12//!    the cgroup v2 enrichment counters when grouping by cgroup,
13//!    the union of `comm` / `pcomm` member literals for
14//!    pattern-aware rendering, and the average start-time tick
15//!    used for the uptime% column.
16//!
17//! 2. [`DiffRow`] / [`DerivedRow`] — the per-`(group, metric)`
18//!    diff carriers. Each owns its baseline + candidate aggregate
19//!    plus the precomputed delta and pct cells. The
20//!    [`DiffRow::sort_key`] / [`DerivedRow::sort_key`] helpers
21//!    project to the `|delta_pct|`-descending ordering the
22//!    renderer uses by default; the multi-key sort in compare.rs
23//!    overrides this ordering when `--sort-by` is set.
24//!
25//! 3. [`CtprofDiff`] — the full comparison result. Aggregates the
26//!    per-thread diff rows ([`CtprofDiff::rows`]), the derived-metric
27//!    rows ([`CtprofDiff::derived_rows`]), the unmatched-keys lists
28//!    ([`CtprofDiff::only_baseline`] / [`CtprofDiff::only_candidate`])
29//!    AFTER fudging removes pairs joined via thread-population overlap,
30//!    the [`FudgedPair`] entries documenting matched cgroup
31//!    renames, the host PSI snapshots, the per-cgroup smaps_rollup
32//!    maps, and the global sched_ext sysfs snapshot. Consumed by
33//!    `write_diff` directly; downstream the hierarchical sort keys
34//!    a pointer-identity `BTreeMap<*const R, usize>` on each row's
35//!    address to recover its original rank (report/primary.rs),
36//!    which is valid because every consumer passes `&CtprofDiff` by
37//!    reference, never by value.
38//!
39//! The types in this module are pure data carriers — no rendering
40//! logic, no aggregation logic. Aggregation lives next door in
41//! [`mod@super::aggregate`]; rendering lives in the `render` and
42//! `report` submodules (and the show path in `src/bin/ktstr.rs`).
43//! mod.rs only wires the module tree.
44
45use std::collections::BTreeMap;
46
47use crate::ctprof::{CgroupStats, Psi};
48
49use super::{Aggregated, DerivedValue, ScaleLadder};
50
51/// Aggregated metrics for every thread matched by one group key.
52#[derive(Debug, Clone)]
53#[non_exhaustive]
54pub struct ThreadGroup {
55    pub key: String,
56    pub thread_count: usize,
57    /// Metric name → aggregated value. Entries are created for
58    /// every registered metric; absent keys signal a missed
59    /// aggregation step, not a skip.
60    pub metrics: BTreeMap<String, Aggregated>,
61    /// Only populated when grouping by cgroup — carries the cgroup
62    /// v2 enrichment counters (cpu.stat, memory.current) for that
63    /// path. Nested here so the renderer can surface them
64    /// alongside the thread-metric rows without a second lookup.
65    pub cgroup_stats: Option<CgroupStats>,
66    /// Distinct member literals contained in this bucket, sorted
67    /// ascending. The field carries `comm` literals under
68    /// [`super::GroupBy::Comm`] and `pcomm` literals under
69    /// [`super::GroupBy::Pcomm`] — both groupings feed the grex
70    /// display-label path the same way (each pattern-aware bucket
71    /// renders a regex over the union of its members across
72    /// baseline + candidate, only when built with the
73    /// `pretty-labels` feature; default builds render the join key
74    /// unchanged). Empty Vec for groupings that
75    /// render the join key directly: [`super::GroupBy::Cgroup`],
76    /// [`super::GroupBy::CommExact`], or pattern-aware groupings under
77    /// [`super::CompareOptions::no_thread_normalize`] where the join key
78    /// IS the literal name and there is nothing to expand into a
79    /// regex.
80    pub members: Vec<String>,
81    /// Average start_time_clock_ticks across group members.
82    /// Lower = older = the group has been alive longer on average.
83    pub avg_start_ticks: u64,
84}
85
86/// One row in the comparison table: `(group, metric)` pair with
87/// aggregated values from both sides.
88#[derive(Debug, Clone)]
89#[non_exhaustive]
90pub struct DiffRow {
91    /// Internal join key — deterministic across snapshots and
92    /// stable for tests / programmatic consumers. For pattern-
93    /// aggregated rows ([`super::GroupBy::Comm`] or [`super::GroupBy::Pcomm`]
94    /// with bucket size ≥ 2 under default normalization), this is
95    /// the token-normalized skeleton the bucket clusters on (e.g.
96    /// `kworker/{N}:{N}-mm_percpu_wq` for Comm,
97    /// `worker-{N}` for Pcomm); for every other grouping
98    /// (CommExact, Cgroup, or pattern-aware grouping under
99    /// [`super::CompareOptions::no_thread_normalize`]) it equals the
100    /// rendered display key.
101    pub group_key: String,
102    pub thread_count_a: usize,
103    pub thread_count_b: usize,
104    /// Relative uptime % for this group (candidate side).
105    /// 100% = as long-lived as the oldest group, 0% = just spawned.
106    pub uptime_pct: Option<f64>,
107    /// Sort-by metric cell: "baseline → candidate (delta%)" for
108    /// the metric specified by --sort-by. Same value for every
109    /// row in a group. None when no --sort-by is set.
110    pub sort_by_cell: Option<String>,
111    /// Sort metric's delta for this group (for coloring the SortBy column).
112    pub sort_by_delta: Option<f64>,
113    pub metric_name: &'static str,
114    /// Auto-scale ladder for the row's value/delta cells. Sourced
115    /// from `metric.rule.ladder()` at build time so the format
116    /// dispatch stays a closed match (no string-keyed
117    /// pass-through branch).
118    pub metric_ladder: ScaleLadder,
119    pub baseline: Aggregated,
120    pub candidate: Aggregated,
121    /// Signed candidate − baseline for numeric-capable rules.
122    pub delta: Option<f64>,
123    /// `delta / baseline` as a fraction. `None` when baseline is
124    /// zero or the row has no numeric projection.
125    pub delta_pct: Option<f64>,
126    /// Operator-facing rendering of the group key. Equals
127    /// `group_key` for non-pattern groupings; for [`super::GroupBy::Comm`]
128    /// or [`super::GroupBy::Pcomm`] pattern buckets containing ≥ 2
129    /// distinct member literals, this carries a grex-generated
130    /// regex over the union of baseline+candidate members so the
131    /// operator sees exactly which names landed in the bucket —
132    /// but only when built with the `pretty-labels` feature and
133    /// the regex is no longer than the key; otherwise (including
134    /// all default builds) it equals `group_key`.
135    pub display_key: String,
136}
137
138impl DiffRow {
139    /// Sort key for "biggest absolute delta %". Numeric rows
140    /// with a non-zero baseline sort by `|delta_pct|`; numeric
141    /// rows with a zero baseline sort by `|delta|` scaled by a
142    /// large constant so any non-zero candidate dominates
143    /// percent-based rows; non-numeric rows sink to the bottom.
144    pub(super) fn sort_key(&self) -> f64 {
145        if let Some(p) = self.delta_pct {
146            p.abs()
147        } else if let Some(d) = self.delta {
148            // Baseline was zero (delta_pct undefined) but candidate
149            // is some value — still a visible change. Inflate so it
150            // beats percent-only rows in the sort.
151            d.abs() * 1e9
152        } else {
153            f64::NEG_INFINITY
154        }
155    }
156}
157
158/// A pair of cgroup groups fudged together by thread population
159/// overlap. Fudging joins a baseline cgroup to a candidate cgroup
160/// when their per-cgroup thread-type sets share enough population
161/// (Jaccard similarity ≥ 0.90) — a renamed-but-otherwise-identical
162/// scope under a shifted path is rejoined for diffing instead of
163/// surfacing as separate orphans.
164///
165/// Fields are role-prefixed: `baseline_*` and `candidate_*` track
166/// the two sides of the pair; `overlap` / `jaccard` /
167/// `cascaded_children` are pair-level metrics.
168#[derive(Debug, Clone, Default)]
169#[non_exhaustive]
170pub struct FudgedPair {
171    /// Baseline cgroup path of the matched pair — full join key
172    /// from the baseline-side bucket. Format: an absolute cgroup
173    /// path (e.g. `/system.slice/foo.service`). The form mirrors
174    /// what [`super::build_groups`] writes for `super::GroupBy::Cgroup`.
175    pub baseline_cgroup: String,
176    /// Candidate cgroup path of the matched pair — full join key
177    /// from the candidate-side bucket. Same format as
178    /// [`Self::baseline_cgroup`].
179    pub candidate_cgroup: String,
180    /// Number of (pcomm, comm) thread types in the intersection
181    /// of the two sides' thread-type sets. Higher = stronger
182    /// match.
183    pub overlap: usize,
184    /// Jaccard similarity coefficient: `|A ∩ B| / |A ∪ B|` over
185    /// the thread-type sets. Range `[0.0, 1.0]`. Matching gate is
186    /// `jaccard >= 0.90` AND overlap (intersection) >= 10 (with
187    /// candidate set size >= 10).
188    pub jaccard: f64,
189    /// Thread types present in baseline but missing from the
190    /// UNION of every candidate matched against this baseline
191    /// (per-bcg dedup; see N:1 fudge merge). Each entry is
192    /// `pcomm:comm` formatted.
193    pub baseline_residual: Vec<String>,
194    /// Thread types present in candidate but missing from the
195    /// UNION of every baseline matched against this candidate.
196    /// Same format as [`Self::baseline_residual`].
197    pub candidate_residual: Vec<String>,
198    /// Count of cgroup descendants joined via cascade matching
199    /// under the shared longest-common-suffix root. Cascade
200    /// extends the fudge from the named pair down to children
201    /// that share the same suffix relative to their roots.
202    pub cascaded_children: usize,
203    /// Cascade root on the baseline side: longest common
204    /// path-segment suffix stripped from
205    /// [`Self::baseline_cgroup`]. Equal to
206    /// [`Self::baseline_cgroup`] when no suffix is shared.
207    /// Smaps remap re-keys candidate-side smaps data under this
208    /// root.
209    pub baseline_root: String,
210    /// Cascade root on the candidate side: longest common
211    /// path-segment suffix stripped from
212    /// [`Self::candidate_cgroup`]. Equal to
213    /// [`Self::candidate_cgroup`] when no suffix is shared.
214    pub candidate_root: String,
215}
216
217/// Full comparison result.
218#[derive(Debug, Clone, Default)]
219#[non_exhaustive]
220pub struct CtprofDiff {
221    pub sort_metric_name: Option<&'static str>,
222    pub rows: Vec<DiffRow>,
223    /// Group keys that appeared in the baseline snapshot but not
224    /// in the candidate, AFTER fudging removes pairs that joined
225    /// via thread-population overlap. Post-fudge survivors only
226    /// — keys that were rejoined to a candidate counterpart
227    /// move into [`Self::fudged_pairs`] and drop out of this
228    /// list.
229    pub only_baseline: Vec<String>,
230    /// Group keys that appeared in the candidate snapshot but not
231    /// in the baseline, AFTER fudging. Post-fudge survivors only;
232    /// same semantics as [`Self::only_baseline`] for the
233    /// candidate side.
234    pub only_candidate: Vec<String>,
235    /// Cgroup pairs joined together via thread-population overlap
236    /// (Jaccard ≥ 0.90 over (pcomm, comm) thread-type sets).
237    /// Each entry is one matched (baseline, candidate) cgroup
238    /// pair plus its overlap / Jaccard / residuals / cascade
239    /// metadata. Pairs are emitted by the fudge stage of
240    /// [`super::compare()`] and consumed by the renderer's "Fudged cgroup
241    /// matches" section. Empty except under [`super::GroupBy::All`]
242    /// (fudge runs only when `group_by == GroupBy::All`, matching
243    /// on the cgroup prefix of each compound key).
244    pub fudged_pairs: Vec<FudgedPair>,
245    /// Baseline-only cgroup-level enrichment rows, keyed by the
246    /// cgroup path (after flatten). Populated only for
247    /// [`super::GroupBy::Cgroup`].
248    pub cgroup_stats_a: BTreeMap<String, CgroupStats>,
249    /// Candidate-only cgroup-level enrichment rows, same shape.
250    pub cgroup_stats_b: BTreeMap<String, CgroupStats>,
251    /// Baseline host-level Pressure Stall Information snapshot.
252    /// Always populated (independent of `super::GroupBy`) — host-level
253    /// PSI surfaces above the per-thread table for any compare,
254    /// not just cgroup-grouped ones.
255    pub host_psi_a: Psi,
256    /// Candidate host-level PSI snapshot.
257    pub host_psi_b: Psi,
258    /// Baseline per-process smaps_rollup maps. Default
259    /// normalization keys by the token-normalized pcomm
260    /// (`pattern_key(&t.pcomm)`) — ephemeral PIDs across snapshots
261    /// collapse into one bucket per pcomm pattern (e.g.
262    /// `worker-{N}`), and the tgid is intentionally NOT part of
263    /// the key (every PID for a given pcomm pattern shares a
264    /// bucket; per-field byte counts SUM at
265    /// [`super::collect_smaps_rollup`] when multiple PIDs collapse).
266    /// Keys match the primary-table Pcomm group keys WHEN ≥2
267    /// processes share the same pattern (`firefox`,
268    /// `kworker/{N}:{N}`, `worker-{N}`, …). Singleton digit
269    /// pcomms diverge intentionally: the primary table reverts
270    /// the bucket key to the literal pcomm (e.g. `worker-7`)
271    /// when only one process matches the skeleton — see
272    /// [`super::build_groups`]'s singleton-revert gate — while smaps
273    /// stays normalized (`worker-{N}`) regardless of bucket
274    /// size, so cross-snapshot rows still join when PIDs are
275    /// ephemeral. The asymmetry is documented on
276    /// [`super::collect_smaps_rollup`] and is load-bearing for
277    /// memory-leak diffing across reboots; correlation between
278    /// the smaps row and the primary table happens via the
279    /// shared pcomm pattern, not always via byte-identical keys.
280    ///
281    /// With [`super::CompareOptions::no_thread_normalize`] set, keys
282    /// preserve the literal `pcomm[tgid]` shape so each PID stays
283    /// attributable to its specific process instance — the
284    /// `[tgid]` is preserved precisely so two distinct PIDs
285    /// sharing a pcomm don't collide within a snapshot. Rows
286    /// only join across snapshots when the same process instance
287    /// ran on both sides, which is the price of literal mode.
288    ///
289    /// Populated from the per-thread leader rows of the
290    /// snapshot (tid == tgid; see [`crate::ctprof::ThreadState::smaps_rollup_kib`]).
291    pub smaps_rollup_a: BTreeMap<String, BTreeMap<String, u64>>,
292    /// Candidate per-process smaps_rollup maps, same shape and
293    /// normalization rules as [`Self::smaps_rollup_a`].
294    pub smaps_rollup_b: BTreeMap<String, BTreeMap<String, u64>>,
295    /// Baseline global sched_ext sysfs snapshot. `None` when
296    /// the baseline kernel had no `/sys/kernel/sched_ext/`
297    /// directory (CONFIG_SCHED_CLASS_EXT=n build).
298    pub sched_ext_a: Option<crate::ctprof::SchedExtSysfs>,
299    /// Candidate global sched_ext sysfs snapshot, same shape.
300    pub sched_ext_b: Option<crate::ctprof::SchedExtSysfs>,
301    /// One row per `(matched group, derived metric)` pair. Each
302    /// derivation in [`super::CTPROF_DERIVED_METRICS`] consumes
303    /// already-aggregated input metrics from the group's
304    /// metrics map (see [`ThreadGroup::metrics`]) and produces a
305    /// scalar `f64` with its own unit. `None`-valued sides
306    /// signal "not computable" — either the input metric was
307    /// missing on that side (capture-time CONFIG gate not set,
308    /// jemalloc not linked) or the formula's denominator was
309    /// zero. Surfaced by [`super::write_diff`] in the dedicated
310    /// `## Derived metrics` section after the main table.
311    pub derived_rows: Vec<DerivedRow>,
312}
313
314/// One row in the derived-metrics table: `(matched group,
315/// derivation)` with the computed scalar from both sides.
316///
317/// Mirrors [`DiffRow`] in shape so the renderer can reuse the
318/// same `(group | threads | metric | baseline | candidate |
319/// delta | %)` column layout. The `%` column is suppressed for
320/// rows whose derivation is a ratio
321/// ([`super::DerivedMetricDef::is_ratio`] true) — absolute delta on a
322/// `[0, 1]` ratio is already in percentage points so a delta_pct
323/// readout would be confusing.
324#[derive(Debug, Clone)]
325#[non_exhaustive]
326pub struct DerivedRow {
327    pub group_key: String,
328    pub display_key: String,
329    pub thread_count_a: usize,
330    pub thread_count_b: usize,
331    pub metric_name: &'static str,
332    /// Auto-scale ladder for the row's value/delta cells. Mirrors
333    /// [`DiffRow::metric_ladder`]; sourced from
334    /// [`super::DerivedMetricDef::ladder`] at build time.
335    pub metric_ladder: ScaleLadder,
336    /// True when the derivation produces a ratio. Renderer
337    /// suppresses the `%` column for ratio rows.
338    pub is_ratio: bool,
339    /// `None` when the input metric was missing on this side or
340    /// the formula divides by zero.
341    pub baseline: Option<DerivedValue>,
342    /// `None` with the same semantics as [`Self::baseline`].
343    pub candidate: Option<DerivedValue>,
344    /// Signed candidate − baseline; `None` when either side is
345    /// `None`.
346    pub delta: Option<f64>,
347    /// `delta / baseline`; `None` when baseline is zero, either
348    /// side is `None`, OR the row is a ratio (suppressed for
349    /// ratios so a `0.5 → 0.6` row doesn't render as
350    /// `+20%` when the natural read is `+10pp`).
351    pub delta_pct: Option<f64>,
352    /// Pre-rendered cell string for the SortBy column under
353    /// `--sort-by`. Same value for every row in a group; `None`
354    /// when no `--sort-by` is set. Mirrors
355    /// [`DiffRow::sort_by_cell`].
356    pub sort_by_cell: Option<String>,
357    /// Sort metric's delta for this group, used to color the
358    /// SortBy column. Mirrors [`DiffRow::sort_by_delta`].
359    pub sort_by_delta: Option<f64>,
360}
361
362impl DerivedRow {
363    /// Sort key mirroring [`DiffRow::sort_key`] for default
364    /// `|delta_pct|`-descending ordering. Ratio rows have
365    /// `delta_pct == None` by design so they sort by their
366    /// absolute delta, scaled by `1e9` so a non-zero ratio
367    /// movement dominates a percent-based row whose baseline
368    /// happens to be zero.
369    pub(super) fn sort_key(&self) -> f64 {
370        if let Some(p) = self.delta_pct {
371            p.abs()
372        } else if let Some(d) = self.delta {
373            d.abs() * 1e9
374        } else {
375            f64::NEG_INFINITY
376        }
377    }
378}