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}