ktstr/ctprof_compare/compare.rs
1//! End-to-end comparison pipeline: groups, fudges, derives,
2//! sorts, and lifts the [`crate::ctprof::CtprofSnapshot`] pair into a
3//! materialized [`super::CtprofDiff`].
4//!
5//! The pipeline:
6//!
7//! 1. [`compare`] is the public orchestrator. It builds per-side
8//! thread groups via [`super::build_groups`] (with
9//! [`super::build_cgroup_key_map`] for cgroup-mode tightening),
10//! flattens cgroup-stats per [`flatten_cgroup_stats`] when
11//! grouping by cgroup, fudges baseline-only / candidate-only
12//! cgroups together via the Jaccard thread-population overlap
13//! rule, emits one [`super::DiffRow`] per `(matched group,
14//! metric)` pair, builds derived rows per
15//! [`super::CTPROF_DERIVED_METRICS`], and finally hands the
16//! populated [`super::CtprofDiff`] back. Group keys present
17//! only on one side surface in
18//! [`super::CtprofDiff::only_baseline`] /
19//! [`super::CtprofDiff::only_candidate`] AFTER fudging
20//! consumed any pairs that joined.
21//!
22//! 2. [`emit_fudged_rows`] handles the N:1 fudge merge: every
23//! candidate group matched to a single baseline group has its
24//! metrics merged via `aggregate::merge_aggregated_into`; one
25//! row per metric is then emitted with the merged candidate
26//! side. Display key carries the `[fudged: <leaf>]` marker
27//! so the renderer can flag the merged row visually.
28//!
29//! 3. [`build_derived_row`] computes one [`super::DerivedRow`]
30//! per matched group per derivation. `None` propagates when
31//! inputs are missing (CONFIG gate not set, jemalloc not
32//! linked) or denominator is zero.
33//!
34//! 4. [`sort_diff_rows_by_keys`] applies the `--sort-by` multi-key
35//! sort, ranking groups lexicographically per the
36//! [`super::SortKey`] tuple before re-ordering the row vec.
37//! Within a group, registry order is preserved as a stable
38//! tiebreak.
39//!
40//! 5. [`flatten_cgroup_stats`] collapses cgroup paths via
41//! glob-pattern flatten + auto-normalize key map; per-
42//! controller merges live in [`super::cgroup_merge`].
43//!
44//! Pointer-hash identity on [`super::DiffRow`] in downstream
45//! consumers means the entire pipeline passes
46//! `&super::CtprofDiff` by reference — never by value — so row
47//! addresses stay stable across renderer passes.
48
49use std::collections::BTreeMap;
50
51use crate::ctprof::{CgroupStats, CtprofSnapshot};
52
53use super::{
54 Aggregated, CTPROF_DERIVED_METRICS, CTPROF_METRICS, CompareOptions, CtprofDiff,
55 DerivedMetricDef, DerivedRow, DiffRow, FudgedPair, GroupBy, SortKey, ThreadGroup,
56 aggregate::merge_aggregated_into,
57 cgroup_merge::{merge_cgroup_cpu, merge_cgroup_memory, merge_cgroup_pids, merge_psi},
58 format_value_cell,
59 groups::{
60 build_cgroup_key_map, build_groups, build_row, collect_smaps_rollup,
61 collect_smaps_rollup_hierarchical, compile_flatten_patterns, flatten_cgroup_path,
62 },
63 pattern::{pattern_counts_union, pattern_display_label, pattern_key},
64};
65
66/// Apply a multi-key sort to `rows` per `sort_keys`. Computes a
67/// per-group sort tuple by looking up the requested metrics'
68/// deltas from the existing rows, ranks groups lexicographically
69/// (with per-key direction), then orders rows by
70/// `(group_rank, metric_registry_idx)` so rows for a given
71/// group cluster together in registry order. Mirrors the default
72/// sort's stability guarantee (within a group, registry order is
73/// preserved; across groups, deterministic by tuple).
74///
75/// Missing values (a group has no row for the named metric, or
76/// the row's `delta` is `None` because the metric is categorical
77/// — even though `parse_sort_by` now rejects categorical
78/// metrics at the CLI boundary, a programmatic caller can still
79/// construct a [`SortKey`] over a `Mode*` metric directly) are
80/// treated as `f64::NEG_INFINITY` for descending sort and
81/// `f64::INFINITY` for ascending sort — they sink to the bottom
82/// either way.
83///
84/// Caller must supply at least one sort key — an empty slice is a
85/// programming error (the empty-spec case is handled at the
86/// caller via the `sort_by.is_empty()` branch in
87/// [`compare`] / `write_show`).
88pub(super) fn sort_diff_rows_by_keys(
89 rows: &mut [DiffRow],
90 derived_rows: &mut [DerivedRow],
91 sort_keys: &[SortKey],
92) {
93 debug_assert!(
94 !sort_keys.is_empty(),
95 "sort_diff_rows_by_keys called with empty sort_keys; \
96 caller must short-circuit before invoking the multi-key \
97 sort path",
98 );
99 use std::collections::{BTreeMap, BTreeSet};
100 // metric name → registry index (for stable within-group
101 // ordering after sort). Both sides are `&'static str` so this
102 // map is allocation-free at the key layer.
103 let metric_idx: BTreeMap<&'static str, usize> = CTPROF_METRICS
104 .iter()
105 .enumerate()
106 .map(|(i, m)| (m.name, i))
107 .collect();
108 let derived_idx: BTreeMap<&'static str, usize> = CTPROF_DERIVED_METRICS
109 .iter()
110 .enumerate()
111 .map(|(i, m)| (m.name, i))
112 .collect();
113 // group_key → (metric_name → delta). The inner key is
114 // `&'static str` borrowed from `row.metric_name` (itself a
115 // `&'static str` pointing into `CTPROF_METRICS.name` or
116 // `CTPROF_DERIVED_METRICS.name`), so no per-row
117 // allocation is needed for the metric axis. Derived deltas
118 // populate the same map; sort_by treats primary and derived
119 // names uniformly for ranking.
120 let mut group_metrics: BTreeMap<String, BTreeMap<&'static str, f64>> = BTreeMap::new();
121 for row in rows.iter() {
122 if let Some(d) = row.delta {
123 group_metrics
124 .entry(row.group_key.clone())
125 .or_default()
126 .insert(row.metric_name, d);
127 }
128 }
129 for row in derived_rows.iter() {
130 if let Some(d) = row.delta {
131 group_metrics
132 .entry(row.group_key.clone())
133 .or_default()
134 .insert(row.metric_name, d);
135 }
136 }
137 // Unique group set: every key from group_metrics PLUS every
138 // group_key from `rows` that had no numeric delta (every row
139 // was Mode/etc). BTreeSet handles dedup-on-insert without a
140 // separate sort+dedup pass.
141 let mut unique_groups: BTreeSet<String> = group_metrics.keys().cloned().collect();
142 for row in rows.iter() {
143 unique_groups.insert(row.group_key.clone());
144 }
145 for row in derived_rows.iter() {
146 unique_groups.insert(row.group_key.clone());
147 }
148 // Precompute (group_key, sort_tuple) pairs once. Avoids
149 // recomputing the tuple inside the comparator on every
150 // comparison; with N groups and a non-trivial tuple this
151 // saves O(N log N) tuple builds.
152 let mut groups_with_tuples: Vec<(String, Vec<f64>)> = unique_groups
153 .into_iter()
154 .map(|g| {
155 let metrics = group_metrics.get(&g);
156 let tuple: Vec<f64> = sort_keys
157 .iter()
158 .map(|k| {
159 metrics
160 .and_then(|m| m.get(k.metric).copied())
161 .unwrap_or(if k.descending {
162 f64::NEG_INFINITY
163 } else {
164 f64::INFINITY
165 })
166 })
167 .collect();
168 (g, tuple)
169 })
170 .collect();
171 // Sort with the precomputed tuples: comparator does only
172 // O(sort_keys.len()) f64 comparisons per call, no map
173 // lookups.
174 groups_with_tuples.sort_by(|(ga, ta), (gb, tb)| {
175 for (i, key) in sort_keys.iter().enumerate() {
176 let (va, vb) = (ta[i], tb[i]);
177 let ord = if key.descending {
178 vb.partial_cmp(&va).unwrap_or(std::cmp::Ordering::Equal)
179 } else {
180 va.partial_cmp(&vb).unwrap_or(std::cmp::Ordering::Equal)
181 };
182 if ord != std::cmp::Ordering::Equal {
183 return ord;
184 }
185 }
186 // Final tie-break: ascending group_key for determinism.
187 ga.cmp(gb)
188 });
189 let group_ranks: BTreeMap<String, usize> = groups_with_tuples
190 .into_iter()
191 .enumerate()
192 .map(|(i, (g, _))| (g, i))
193 .collect();
194 rows.sort_by(|a, b| {
195 let ra = group_ranks.get(&a.group_key).copied().unwrap_or(usize::MAX);
196 let rb = group_ranks.get(&b.group_key).copied().unwrap_or(usize::MAX);
197 ra.cmp(&rb).then_with(|| {
198 let ia = metric_idx.get(a.metric_name).copied().unwrap_or(usize::MAX);
199 let ib = metric_idx.get(b.metric_name).copied().unwrap_or(usize::MAX);
200 ia.cmp(&ib)
201 })
202 });
203 derived_rows.sort_by(|a, b| {
204 let ra = group_ranks.get(&a.group_key).copied().unwrap_or(usize::MAX);
205 let rb = group_ranks.get(&b.group_key).copied().unwrap_or(usize::MAX);
206 ra.cmp(&rb).then_with(|| {
207 let ia = derived_idx
208 .get(a.metric_name)
209 .copied()
210 .unwrap_or(usize::MAX);
211 let ib = derived_idx
212 .get(b.metric_name)
213 .copied()
214 .unwrap_or(usize::MAX);
215 ia.cmp(&ib)
216 })
217 });
218}
219
220/// Compute one [`DerivedRow`] for a matched group. Called per
221/// derivation in [`compare`]; the produced row carries `None`
222/// values when the formula's inputs are missing or the
223/// denominator is zero on either side.
224pub(super) fn build_derived_row(
225 key: &str,
226 display_key: &str,
227 n_a: usize,
228 n_b: usize,
229 def: &DerivedMetricDef,
230 metrics_a: &BTreeMap<String, Aggregated>,
231 metrics_b: &BTreeMap<String, Aggregated>,
232) -> DerivedRow {
233 let baseline = (def.compute)(metrics_a);
234 let candidate = (def.compute)(metrics_b);
235 let (delta, delta_pct) = match (baseline, candidate) {
236 (Some(a), Some(b)) => {
237 let va = a.as_f64();
238 let vb = b.as_f64();
239 let d = vb - va;
240 // Suppress delta_pct for ratio rows per the design
241 // call: `+20%` on a `[0, 1]` ratio is misleading.
242 let pct = if def.is_ratio {
243 None
244 } else if va.abs() > f64::EPSILON {
245 Some(d / va)
246 } else {
247 None
248 };
249 (Some(d), pct)
250 }
251 _ => (None, None),
252 };
253 DerivedRow {
254 group_key: key.to_string(),
255 display_key: display_key.to_string(),
256 thread_count_a: n_a,
257 thread_count_b: n_b,
258 metric_name: def.name,
259 metric_ladder: def.ladder,
260 is_ratio: def.is_ratio,
261 baseline,
262 candidate,
263 delta,
264 delta_pct,
265 sort_by_cell: None,
266 sort_by_delta: None,
267 }
268}
269
270/// Emit one DiffRow per CTPROF_METRICS entry (and one DerivedRow
271/// per CTPROF_DERIVED_METRICS entry) for each fudged baseline
272/// key, with candidate values aggregated across the N matched
273/// candidate groups (N:1 merge). The shared display key
274/// `[fudged]` flags the row in the renderer.
275///
276/// `matches` keys are baseline group keys; the values are the
277/// list of candidate group keys that matched the baseline. A
278/// missing baseline group skips the entry; a missing candidate
279/// group is skipped from the merge but does not abort. Values
280/// from each ckey are merged via [`merge_aggregated_into`].
281pub(super) fn emit_fudged_rows(
282 diff: &mut CtprofDiff,
283 matches: &BTreeMap<String, Vec<String>>,
284 groups_a: &BTreeMap<String, ThreadGroup>,
285 groups_b: &BTreeMap<String, ThreadGroup>,
286) {
287 for (bkey, ckeys) in matches {
288 let Some(ga) = groups_a.get(bkey) else {
289 continue;
290 };
291 let mut merged_metrics: BTreeMap<String, Aggregated> = BTreeMap::new();
292 let mut merged_thread_count: usize = 0;
293 for ckey in ckeys {
294 let Some(gb) = groups_b.get(ckey) else {
295 continue;
296 };
297 merged_thread_count += gb.thread_count;
298 for (name, val) in &gb.metrics {
299 let entry = merged_metrics.entry(name.clone());
300 match entry {
301 std::collections::btree_map::Entry::Vacant(e) => {
302 e.insert(val.clone());
303 }
304 std::collections::btree_map::Entry::Occupied(mut e) => {
305 let existing = e.get_mut();
306 merge_aggregated_into(existing, val);
307 }
308 }
309 }
310 }
311 // Display key for the fudged row: include the
312 // baseline cgroup leaf so multiple fudged pairs in the
313 // same diff stay distinguishable. `[fudged]` alone
314 // would render N rows that all look identical when
315 // viewed by an operator scanning the table; appending
316 // the leaf path component (the bcg's last `/`-segment)
317 // disambiguates without bloating the column.
318 let bcg = bkey.split_once('\x00').map_or(bkey.as_str(), |(cg, _)| cg);
319 let leaf = bcg.rsplit_once('/').map_or(bcg, |(_, l)| l);
320 let display_key = if leaf.is_empty() {
321 "[fudged]".to_string()
322 } else {
323 format!("[fudged: {leaf}]")
324 };
325 for metric in CTPROF_METRICS {
326 let Some(a) = ga.metrics.get(metric.name).cloned() else {
327 continue;
328 };
329 let Some(b) = merged_metrics.get(metric.name).cloned() else {
330 continue;
331 };
332 diff.rows.push(build_row(
333 bkey,
334 &display_key,
335 ga.thread_count,
336 merged_thread_count,
337 metric,
338 a,
339 b,
340 None,
341 ));
342 }
343 for def in CTPROF_DERIVED_METRICS {
344 diff.derived_rows.push(build_derived_row(
345 bkey,
346 &display_key,
347 ga.thread_count,
348 merged_thread_count,
349 def,
350 &ga.metrics,
351 &merged_metrics,
352 ));
353 }
354 }
355}
356
357/// Read-only context threaded through the phase helpers of
358/// [`compare`]. Holds borrows of the snapshot pair, the resolved
359/// options, and the per-side thread groups so each phase signature
360/// stays short. Built once by [`compare`] after
361/// [`build_compare_groups`] returns the owned groups (kept as
362/// orchestrator locals so the borrows in this struct stay valid).
363struct CompareCtx<'a> {
364 baseline: &'a CtprofSnapshot,
365 candidate: &'a CtprofSnapshot,
366 opts: &'a CompareOptions,
367 /// Resolved grouping axis (`opts.group_by.0`), cached so the
368 /// phases don't re-unwrap the default wrapper.
369 group_by: GroupBy,
370 /// Compiled `cgroup_flatten` glob patterns.
371 flatten: &'a [glob::Pattern],
372 /// Auto-normalize cgroup-key map, present only under
373 /// `GroupBy::Cgroup | GroupBy::All` with `no_cg_normalize`
374 /// unset.
375 cgroup_key_map: Option<&'a BTreeMap<String, String>>,
376 groups_a: &'a BTreeMap<String, ThreadGroup>,
377 groups_b: &'a BTreeMap<String, ThreadGroup>,
378 /// Per-snapshot "now" for lifetime calc: newest candidate
379 /// thread's `start_time_clock_ticks`.
380 now_b: u64,
381}
382
383/// Build both sides' thread groups plus the auto-normalize cgroup
384/// key map. Resolves the grouping axis, seeds the per-thread
385/// frequency-count gate (only under `GroupBy::Comm | GroupBy::Pcomm`
386/// with normalization on), and runs [`build_groups`] for each side.
387/// Returns `(group_by, cgroup_key_map, groups_a, groups_b)` by
388/// value so [`compare`] can keep them as locals and borrow them
389/// into [`CompareCtx`]. `flatten` is computed by the caller and
390/// passed in so the patterns outlive the returned groups.
391fn build_compare_groups(
392 baseline: &CtprofSnapshot,
393 candidate: &CtprofSnapshot,
394 opts: &CompareOptions,
395 flatten: &[glob::Pattern],
396) -> CompareGroups {
397 let group_by = opts.group_by.0;
398 // For `GroupBy::Comm` and `GroupBy::Pcomm`, the frequency gate
399 // that promotes a pattern_key from per-thread literal to a
400 // clustered bucket must be evaluated against the UNION of both
401 // snapshots' threads — otherwise a pattern that has 1 thread
402 // in baseline + 3 threads in candidate would join under a
403 // `worker-{N}` key on the candidate side but a literal
404 // `worker-7` key on the baseline side, and the row would
405 // surface as only-in-candidate. Computing the count from
406 // the union ensures the same key is used on both sides.
407 //
408 // The Pcomm path is structurally identical: process names that
409 // share a normalized skeleton across snapshots (e.g. ephemeral
410 // worker pools whose pcomm differs only by a digit suffix)
411 // collapse into one bucket, keyed by the skeleton. The accessor
412 // selects which `ThreadState` field feeds the count — `t.comm`
413 // for Comm, `t.pcomm` for Pcomm — so one helper covers both
414 // axes.
415 //
416 // Skipped when `no_thread_normalize` is set — under literal
417 // grouping, the key IS the comm/pcomm and there is no
418 // promotion gate to evaluate.
419 // Pattern_counts is only consulted by [`build_groups`] under
420 // GroupBy::Pcomm / GroupBy::Comm (as the singleton-revert
421 // gate). GroupBy::All uses the compound `cg\x00pcomm\x00comm`
422 // key and normalizes both pcomm and comm through `pattern_key`
423 // unconditionally — there is no singleton revert under All
424 // because every thread already disambiguates by cgroup +
425 // process. Skipping the seeding under All saves the
426 // baseline+candidate scan when neither side will read it.
427 let pattern_counts: Option<BTreeMap<String, usize>> = match (group_by, opts.no_thread_normalize)
428 {
429 (GroupBy::Comm, false) => Some(pattern_counts_union(baseline, candidate, |t| {
430 t.comm.as_str()
431 })),
432 (GroupBy::Pcomm, false) => Some(pattern_counts_union(baseline, candidate, |t| {
433 t.pcomm.as_str()
434 })),
435 _ => None,
436 };
437 let cgroup_key_map: Option<BTreeMap<String, String>> =
438 if matches!(group_by, GroupBy::Cgroup | GroupBy::All) && !opts.no_cg_normalize {
439 Some(build_cgroup_key_map(baseline, candidate, flatten))
440 } else {
441 None
442 };
443 let groups_a = build_groups(
444 baseline,
445 group_by,
446 flatten,
447 pattern_counts.as_ref(),
448 cgroup_key_map.as_ref(),
449 opts.no_thread_normalize,
450 );
451 let groups_b = build_groups(
452 candidate,
453 group_by,
454 flatten,
455 pattern_counts.as_ref(),
456 cgroup_key_map.as_ref(),
457 opts.no_thread_normalize,
458 );
459 (group_by, cgroup_key_map, groups_a, groups_b)
460}
461
462/// Emit one [`DiffRow`] per `(matched group, metric)` plus one
463/// [`DerivedRow`] per derivation, and populate
464/// [`CtprofDiff::only_baseline`] / [`CtprofDiff::only_candidate`]
465/// with the keys present on exactly one side. Display labels for
466/// pattern-aware groupings union both sides' members and run grex;
467/// every other grouping echoes the join key.
468fn emit_matched_rows(diff: &mut CtprofDiff, ctx: &CompareCtx) {
469 for (key, group_a) in ctx.groups_a {
470 let Some(group_b) = ctx.groups_b.get(key) else {
471 diff.only_baseline.push(key.clone());
472 continue;
473 };
474 // Render label: pattern grouping (Comm or Pcomm under
475 // auto-normalize) unions baseline+candidate members and
476 // runs grex over the result; every other grouping just
477 // echoes the join key. Computed once per matched group,
478 // reused across every metric row built off it.
479 let pattern_axis_active =
480 matches!(ctx.group_by, GroupBy::Comm | GroupBy::Pcomm) && !ctx.opts.no_thread_normalize;
481 let display_key = if pattern_axis_active {
482 let mut union: Vec<String> = group_a.members.clone();
483 union.extend(group_b.members.iter().cloned());
484 union.sort();
485 union.dedup();
486 pattern_display_label(key, &union)
487 } else {
488 key.clone()
489 };
490 // uptime_pct filled in second pass after all groups processed
491
492 for metric in CTPROF_METRICS {
493 let Some(a) = group_a.metrics.get(metric.name).cloned() else {
494 continue;
495 };
496 let Some(b) = group_b.metrics.get(metric.name).cloned() else {
497 continue;
498 };
499 diff.rows.push(build_row(
500 key,
501 &display_key,
502 group_a.thread_count,
503 group_b.thread_count,
504 metric,
505 a,
506 b,
507 None, // uptime_pct filled in second pass
508 ));
509 }
510 // Derived metrics: one row per derivation per matched
511 // group. Each row carries `None`-valued sides when the
512 // formula's inputs are missing or the denominator is
513 // zero — operator sees `-` rather than a synthesized
514 // zero or NaN.
515 for def in CTPROF_DERIVED_METRICS {
516 diff.derived_rows.push(build_derived_row(
517 key,
518 &display_key,
519 group_a.thread_count,
520 group_b.thread_count,
521 def,
522 &group_a.metrics,
523 &group_b.metrics,
524 ));
525 }
526 }
527 for key in ctx.groups_b.keys() {
528 if !ctx.groups_a.contains_key(key) {
529 diff.only_candidate.push(key.clone());
530 }
531 }
532}
533
534/// Extract the cgroup prefix (the `cg` portion) from a compound
535/// `cg\x00pcomm\x00comm` group key. Used throughout the fudge
536/// phase to group one-sided keys by their owning cgroup.
537fn cg_prefix(key: &str) -> &str {
538 key.split_once('\x00').map_or(key, |(cg, _)| cg)
539}
540
541/// `(pcomm, comm)` thread-type set for one cgroup prefix. Fudge
542/// matches cgroups by Jaccard similarity over these sets.
543type TypeSet = std::collections::BTreeSet<(String, String)>;
544
545/// Grouping outcome from [`build_compare_groups`]: the resolved
546/// `GroupBy`, an optional cgroup-fudge map, and the per-side thread
547/// groups (baseline, candidate).
548type CompareGroups = (
549 GroupBy,
550 Option<BTreeMap<String, String>>,
551 BTreeMap<String, ThreadGroup>,
552 BTreeMap<String, ThreadGroup>,
553);
554
555/// Fudge-match outcome from [`fudge_match_prefixes`]: the matched
556/// `(baseline_cg, candidate_cg)` prefix pairs plus the per-prefix
557/// thread-type sets for each side (baseline, candidate).
558type FudgeMatch = (
559 Vec<(String, String)>,
560 BTreeMap<String, TypeSet>,
561 BTreeMap<String, TypeSet>,
562);
563
564/// Match baseline-only cgroup prefixes to candidate-only cgroup
565/// prefixes by Jaccard similarity (≥ 0.90, overlap ≥ 10) over
566/// their `(pcomm, comm)` thread-type sets, and return both the
567/// matched `(baseline_cg, candidate_cg)` pairs and the per-prefix
568/// type sets (reused downstream for the residual report). Excludes
569/// any prefix that already has a key matched on both sides
570/// (`matched_prefixes`).
571fn fudge_match_prefixes(diff: &CtprofDiff, ctx: &CompareCtx) -> FudgeMatch {
572 // Collect thread types per CGROUP PREFIX (not per compound key).
573 let mut cg_types_a: BTreeMap<String, TypeSet> = BTreeMap::new();
574 let mut cg_types_b: BTreeMap<String, TypeSet> = BTreeMap::new();
575
576 // Collect cgroup prefixes that appear in BOTH groups (already matched).
577 // These must be excluded from fudging.
578 let matched_prefixes: std::collections::BTreeSet<String> = ctx
579 .groups_a
580 .keys()
581 .filter(|k| ctx.groups_b.contains_key(*k))
582 .map(|k| cg_prefix(k).to_string())
583 .collect();
584
585 // Collect unique cgroup prefixes from one-sided keys,
586 // skipping any prefix that already has matched keys.
587 let mut cg_prefixes_a: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
588 let mut cg_prefixes_b: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
589 for key in &diff.only_baseline {
590 let pfx = cg_prefix(key).to_string();
591 if !matched_prefixes.contains(&pfx) {
592 cg_prefixes_a.insert(pfx);
593 }
594 }
595 for key in &diff.only_candidate {
596 let pfx = cg_prefix(key).to_string();
597 if !matched_prefixes.contains(&pfx) {
598 cg_prefixes_b.insert(pfx);
599 }
600 }
601
602 // Populate thread types per cgroup prefix from snapshots.
603 for t in &ctx.baseline.threads {
604 let cg = flatten_cgroup_path(&t.cgroup, ctx.flatten);
605 let cg_key = match ctx.cgroup_key_map.and_then(|m| m.get(&cg)) {
606 Some(k) => k.clone(),
607 None => cg,
608 };
609 if cg_prefixes_a.contains(&cg_key) {
610 cg_types_a
611 .entry(cg_key)
612 .or_default()
613 .insert((pattern_key(&t.pcomm), pattern_key(&t.comm)));
614 }
615 }
616 for t in &ctx.candidate.threads {
617 let cg = flatten_cgroup_path(&t.cgroup, ctx.flatten);
618 let cg_key = match ctx.cgroup_key_map.and_then(|m| m.get(&cg)) {
619 Some(k) => k.clone(),
620 None => cg,
621 };
622 if cg_prefixes_b.contains(&cg_key) {
623 cg_types_b
624 .entry(cg_key)
625 .or_default()
626 .insert((pattern_key(&t.pcomm), pattern_key(&t.comm)));
627 }
628 }
629
630 // Match cgroup prefixes by Jaccard similarity. Each
631 // candidate finds its best baseline match independently —
632 // multiple candidates can match the same baseline (N
633 // vs 1 baseline).
634 let mut fudged_cg: Vec<(String, String)> = Vec::new(); // (baseline_cg, candidate_cg)
635
636 for ccg in &cg_prefixes_b {
637 let Some(set_b) = cg_types_b.get(ccg) else {
638 continue;
639 };
640 if set_b.len() < 10 {
641 continue;
642 }
643 let mut best: Option<(&str, f64, usize)> = None;
644 for bcg in &cg_prefixes_a {
645 let Some(set_a) = cg_types_a.get(bcg) else {
646 continue;
647 };
648 let intersection = set_a.intersection(set_b).count();
649 if intersection < 10 {
650 continue;
651 }
652 let union = set_a.union(set_b).count();
653 let jaccard = intersection as f64 / union as f64;
654 if jaccard >= 0.90 && best.is_none_or(|(_, bj, _)| jaccard > bj) {
655 best = Some((bcg.as_str(), jaccard, intersection));
656 }
657 }
658 if let Some((bcg, _jaccard, _overlap)) = best {
659 fudged_cg.push((bcg.to_string(), ccg.clone()));
660 }
661 }
662
663 (fudged_cg, cg_types_a, cg_types_b)
664}
665
666/// Tracks which one-sided keys have been consumed by fudging (so
667/// the cascade pass and the post-fudge `retain` drop them) and
668/// the per-baseline cascade root + child count for the report.
669#[derive(Default)]
670struct FudgeRemap {
671 remove_baseline: std::collections::BTreeSet<String>,
672 remove_candidate: std::collections::BTreeSet<String>,
673 cascade_counts: BTreeMap<String, usize>,
674 cascade_roots: BTreeMap<(String, String), (String, String)>,
675}
676
677/// Apply the direct (suffix-exact) remap and then the cascade
678/// remap for every fudged cgroup pair, emitting aggregated rows
679/// for both. Records consumed keys + cascade metadata in
680/// [`FudgeRemap`] and appends every joined `(bkey, ckey)` to
681/// `fudged_key_pairs`. Mirrors the two `emit_fudged_rows` calls of
682/// the original block: first the direct suffix matches, then the
683/// cascaded children.
684fn fudge_cascade(
685 diff: &mut CtprofDiff,
686 ctx: &CompareCtx,
687 fudged_cg: &[(String, String)],
688 fudged_key_pairs: &mut Vec<(String, String)>,
689) -> FudgeRemap {
690 // For each fudged cgroup pair, remap ALL compound keys sharing
691 // that prefix. Match baseline keys to candidate keys by their
692 // pcomm\x00comm suffix.
693 let mut remap = FudgeRemap::default();
694
695 // Collect all (baseline_key, candidate_key) pairs across
696 // all fudge pairs. Multiple candidate keys can map to the
697 // same baseline key (N:1).
698 let mut fudge_matches: BTreeMap<String, Vec<String>> = BTreeMap::new(); // bkey → [ckeys]
699 for (bcg, ccg) in fudged_cg {
700 let b_keys: Vec<&String> = diff
701 .only_baseline
702 .iter()
703 .filter(|k| cg_prefix(k) == bcg.as_str())
704 .collect();
705 let c_keys: Vec<&String> = diff
706 .only_candidate
707 .iter()
708 .filter(|k| cg_prefix(k) == ccg.as_str())
709 .collect();
710 let c_suffix_map: BTreeMap<&str, &String> = c_keys
711 .iter()
712 .map(|k| {
713 let suffix = k.split_once('\x00').map_or("", |(_, s)| s);
714 (suffix, *k)
715 })
716 .collect();
717 for bkey in &b_keys {
718 let b_suffix = bkey.split_once('\x00').map_or("", |(_, s)| s);
719 if let Some(ckey) = c_suffix_map.get(b_suffix) {
720 remap.remove_baseline.insert((*bkey).clone());
721 remap.remove_candidate.insert((*ckey).clone());
722 fudged_key_pairs.push(((*bkey).clone(), (*ckey).clone()));
723 fudge_matches
724 .entry((*bkey).clone())
725 .or_default()
726 .push((*ckey).clone());
727 }
728 }
729 }
730 // Emit one row per baseline key with candidate values
731 // aggregated across all N matched candidate groups.
732 emit_fudged_rows(diff, &fudge_matches, ctx.groups_a, ctx.groups_b);
733
734 // Cascade: for each fudged cgroup pair, compute cascade
735 // roots by stripping the longest common suffix (by /
736 // segments) from the pair. Use those shorter roots for
737 // starts_with matching, not the full fudged paths.
738 let mut cascade_matches: BTreeMap<String, Vec<String>> = BTreeMap::new();
739 for (bcg, ccg) in fudged_cg {
740 let b_segs: Vec<&str> = bcg.split('/').collect();
741 let c_segs: Vec<&str> = ccg.split('/').collect();
742 let common_suffix_len = b_segs
743 .iter()
744 .rev()
745 .zip(c_segs.iter().rev())
746 .take_while(|(a, b)| a == b)
747 .count();
748 let b_root: String = b_segs[..b_segs.len().saturating_sub(common_suffix_len)].join("/");
749 let c_root: String = c_segs[..c_segs.len().saturating_sub(common_suffix_len)].join("/");
750 let b_root = if b_root.is_empty() {
751 bcg.clone()
752 } else {
753 b_root
754 };
755 let c_root = if c_root.is_empty() {
756 ccg.clone()
757 } else {
758 c_root
759 };
760
761 remap
762 .cascade_roots
763 .insert((bcg.clone(), ccg.clone()), (b_root.clone(), c_root.clone()));
764
765 // Boundary-checked prefix match: accept either an
766 // exact root match (tail.is_empty()) or a child path
767 // (tail starts with '/'). Bare `starts_with` would
768 // false-match siblings — `/svc-extra` would slip
769 // through `b_root=/svc`. The non-matching keys would
770 // ultimately fail downstream lookup (c_by_suffix
771 // applies the same boundary filter), but skipping them
772 // here keeps the cascade scan consistent with the
773 // remap rule and avoids needless work.
774 let is_root_or_child = |cg: &str, root: &str| {
775 let Some(tail) = cg.strip_prefix(root) else {
776 return false;
777 };
778 tail.is_empty() || tail.starts_with('/')
779 };
780 let remaining_b: Vec<String> = diff
781 .only_baseline
782 .iter()
783 .filter(|k| {
784 !remap.remove_baseline.contains(*k)
785 && is_root_or_child(cg_prefix(k), b_root.as_str())
786 })
787 .cloned()
788 .collect();
789 let remaining_c: Vec<String> = diff
790 .only_candidate
791 .iter()
792 .filter(|k| {
793 !remap.remove_candidate.contains(*k)
794 && is_root_or_child(cg_prefix(k), c_root.as_str())
795 })
796 .cloned()
797 .collect();
798 // Filter_map (not map) so boundary-rejected entries
799 // drop instead of all colliding at the empty-string
800 // key in the BTreeMap. Combined with the upstream
801 // is_root_or_child filter on remaining_c, every entry
802 // here should already qualify; this stays defensive
803 // against future filter regressions.
804 let c_by_suffix: BTreeMap<String, &String> = remaining_c
805 .iter()
806 .filter_map(|k| {
807 let child_cg = cg_prefix(k);
808 let tail = &child_cg[c_root.len()..];
809 if !tail.is_empty() && !tail.starts_with('/') {
810 return None;
811 }
812 let rewritten = format!("{b_root}{tail}");
813 let suffix = k.split_once('\x00').map_or("", |(_, s)| s);
814 Some((format!("{rewritten}\x00{suffix}"), k))
815 })
816 .collect();
817 for bkey in &remaining_b {
818 if let Some(ckey) = c_by_suffix.get(bkey) {
819 remap.remove_baseline.insert(bkey.clone());
820 remap.remove_candidate.insert((*ckey).clone());
821 fudged_key_pairs.push((bkey.clone(), (*ckey).clone()));
822 *remap.cascade_counts.entry(bcg.clone()).or_insert(0) += 1;
823 cascade_matches
824 .entry(bkey.clone())
825 .or_default()
826 .push((*ckey).clone());
827 }
828 }
829 }
830
831 // Emit aggregated rows for cascaded children (same N:1 merge).
832 emit_fudged_rows(diff, &cascade_matches, ctx.groups_a, ctx.groups_b);
833
834 remap
835}
836
837/// Build the [`FudgedPair`] report vector for every matched cgroup
838/// pair: overlap / Jaccard over the two thread-type sets, residuals
839/// computed against the per-bcg / per-ccg UNION of counterpart sets
840/// (so a type missing from every counterpart surfaces once), and
841/// the cascade child count + roots from [`FudgeRemap`].
842fn build_fudged_pairs(
843 fudged_cg: &[(String, String)],
844 cg_types_a: &BTreeMap<String, TypeSet>,
845 cg_types_b: &BTreeMap<String, TypeSet>,
846 remap: &FudgeRemap,
847) -> Vec<FudgedPair> {
848 // Store fudge report per cgroup pair.
849 //
850 // Residual deduplication for N:1: when one bcg is matched
851 // against multiple ccgs, the per-pair baseline_residual =
852 // (set_a - set_b_for_this_pair) over-reports thread types
853 // that are missing from EVERY ccg — they appear N times,
854 // once per pair. Same for candidate_residual when one ccg
855 // is matched against multiple bcgs (M:1 in the other
856 // direction). Compute residuals against the UNION of all
857 // counterpart sets so a missing-on-every-side type
858 // surfaces exactly once across the bcg's pairs.
859 let mut union_b_for_bcg: BTreeMap<String, TypeSet> = BTreeMap::new();
860 let mut union_a_for_ccg: BTreeMap<String, TypeSet> = BTreeMap::new();
861 for (bcg, ccg) in fudged_cg {
862 if let Some(sb) = cg_types_b.get(ccg) {
863 union_b_for_bcg
864 .entry(bcg.clone())
865 .or_default()
866 .extend(sb.iter().cloned());
867 }
868 if let Some(sa) = cg_types_a.get(bcg) {
869 union_a_for_ccg
870 .entry(ccg.clone())
871 .or_default()
872 .extend(sa.iter().cloned());
873 }
874 }
875 fudged_cg
876 .iter()
877 .map(|(bcg, ccg)| {
878 let set_a = cg_types_a.get(bcg).cloned().unwrap_or_default();
879 let set_b = cg_types_b.get(ccg).cloned().unwrap_or_default();
880 // Residuals against the per-bcg / per-ccg unions
881 // so a thread type missing from every counterpart
882 // candidate appears exactly once.
883 let union_b = union_b_for_bcg.get(bcg).cloned().unwrap_or_default();
884 let union_a = union_a_for_ccg.get(ccg).cloned().unwrap_or_default();
885 let residual_a: Vec<String> = set_a
886 .difference(&union_b)
887 .map(|(p, c)| format!("{p}:{c}"))
888 .collect();
889 let residual_b: Vec<String> = set_b
890 .difference(&union_a)
891 .map(|(p, c)| format!("{p}:{c}"))
892 .collect();
893 let intersection = set_a.intersection(&set_b).count();
894 let union = set_a.union(&set_b).count();
895 FudgedPair {
896 baseline_cgroup: bcg.clone(),
897 candidate_cgroup: ccg.clone(),
898 overlap: intersection,
899 jaccard: if union > 0 {
900 intersection as f64 / union as f64
901 } else {
902 0.0
903 },
904 baseline_residual: residual_a,
905 candidate_residual: residual_b,
906 cascaded_children: remap.cascade_counts.get(bcg).copied().unwrap_or(0),
907 baseline_root: remap
908 .cascade_roots
909 .get(&(bcg.clone(), ccg.clone()))
910 .map(|(b, _)| b.clone())
911 .unwrap_or_else(|| bcg.clone()),
912 candidate_root: remap
913 .cascade_roots
914 .get(&(bcg.clone(), ccg.clone()))
915 .map(|(_, c)| c.clone())
916 .unwrap_or_else(|| ccg.clone()),
917 }
918 })
919 .collect()
920}
921
922/// Content-based cgroup fudging: join one-sided groups whose
923/// thread populations overlap (cgroup path changed but workload is
924/// the same). Runs only under `GroupBy::All` with both unmatched
925/// lists non-empty; otherwise returns an empty `fudged_key_pairs`
926/// and leaves `diff` untouched. On the active path it emits the
927/// merged rows (direct + cascade), prunes the joined keys from
928/// `only_baseline` / `only_candidate`, populates
929/// [`CtprofDiff::fudged_pairs`], and returns the `(bkey, ckey)`
930/// pairs consumed downstream by [`fill_uptime_pct`] and
931/// [`attach_enrichment`].
932fn apply_cgroup_fudge(diff: &mut CtprofDiff, ctx: &CompareCtx) -> Vec<(String, String)> {
933 let mut fudged_key_pairs: Vec<(String, String)> = Vec::new();
934 if ctx.group_by != GroupBy::All
935 || diff.only_baseline.is_empty()
936 || diff.only_candidate.is_empty()
937 {
938 return fudged_key_pairs;
939 }
940
941 let (fudged_cg, cg_types_a, cg_types_b) = fudge_match_prefixes(diff, ctx);
942
943 let remap = fudge_cascade(diff, ctx, &fudged_cg, &mut fudged_key_pairs);
944
945 diff.only_baseline
946 .retain(|k| !remap.remove_baseline.contains(k));
947 diff.only_candidate
948 .retain(|k| !remap.remove_candidate.contains(k));
949
950 diff.fudged_pairs = build_fudged_pairs(&fudged_cg, &cg_types_a, &cg_types_b, &remap);
951
952 fudged_key_pairs
953}
954
955/// Second pass: fill [`DiffRow::uptime_pct`]. Computes each
956/// matched group's average candidate-side thread lifetime
957/// (`now_b - avg_start_ticks`), folds the fudged pairs' candidate
958/// lifetimes into their baseline key (averaged across the N
959/// matched candidates), then expresses each as a percentage of the
960/// longest-lived group.
961fn fill_uptime_pct(diff: &mut CtprofDiff, ctx: &CompareCtx, fudged_key_pairs: &[(String, String)]) {
962 let mut group_lifetime: BTreeMap<String, u64> = BTreeMap::new();
963 for (key, group_b) in ctx.groups_b {
964 if ctx.groups_a.contains_key(key) {
965 group_lifetime.insert(
966 key.clone(),
967 ctx.now_b.saturating_sub(group_b.avg_start_ticks),
968 );
969 }
970 }
971 let mut fudge_lt_sum: BTreeMap<String, (u64, u64)> = BTreeMap::new();
972 for (bkey, ckey) in fudged_key_pairs {
973 if let Some(gb) = ctx.groups_b.get(ckey) {
974 let lt = ctx.now_b.saturating_sub(gb.avg_start_ticks);
975 let entry = fudge_lt_sum.entry(bkey.clone()).or_insert((0, 0));
976 entry.0 += lt;
977 entry.1 += 1;
978 }
979 }
980 for (bkey, (sum, count)) in &fudge_lt_sum {
981 if *count > 0 {
982 group_lifetime.insert(bkey.clone(), sum / count);
983 }
984 }
985 let max_lifetime = group_lifetime.values().copied().max().unwrap_or(1).max(1);
986 for row in &mut diff.rows {
987 if let Some(<) = group_lifetime.get(&row.group_key) {
988 row.uptime_pct = Some(lt as f64 / max_lifetime as f64 * 100.0);
989 }
990 }
991}
992
993/// Order the diff + derived rows. With an empty `sort_by`, applies
994/// the default `|delta_pct|`-descending sort (ties broken by
995/// ascending group_key) to both row vecs. With a non-empty
996/// `sort_by`, routes through [`sort_diff_rows_by_keys`] for the
997/// multi-key rank, then fills [`DiffRow::sort_by_cell`] /
998/// `sort_by_delta` (mirrored onto the derived rows) from the
999/// primary sort metric's per-group cell.
1000fn sort_diff(diff: &mut CtprofDiff, sort_by: &[SortKey]) {
1001 if sort_by.is_empty() {
1002 // Default: stable-sort by descending |delta_pct|, ties
1003 // broken by ascending group_key + registry order of
1004 // metric. Apply the same shape to derived_rows so the
1005 // `## Derived metrics` section ranks by salient delta
1006 // rather than registry order — matches the operator's
1007 // expectation that the most-changed row sits at the
1008 // top of every section.
1009 diff.rows.sort_by(|a, b| {
1010 b.sort_key()
1011 .partial_cmp(&a.sort_key())
1012 .unwrap_or(std::cmp::Ordering::Equal)
1013 .then_with(|| a.group_key.cmp(&b.group_key))
1014 });
1015 diff.derived_rows.sort_by(|a, b| {
1016 b.sort_key()
1017 .partial_cmp(&a.sort_key())
1018 .unwrap_or(std::cmp::Ordering::Equal)
1019 .then_with(|| a.group_key.cmp(&b.group_key))
1020 });
1021 } else {
1022 // Multi-key sort: rank groups by tuple of named-metric
1023 // deltas, sort rows by (group_rank, metric_registry_idx).
1024 sort_diff_rows_by_keys(&mut diff.rows, &mut diff.derived_rows, sort_by);
1025
1026 // Fill sort_by_cell: for each group, find the sort metric's
1027 // row and format its baseline→candidate (delta%).
1028 let sort_metric = sort_by.first().map(|sk| sk.metric);
1029 diff.sort_metric_name = sort_metric;
1030 if let Some(metric_name) = sort_metric {
1031 let mut group_cells: BTreeMap<String, (String, Option<f64>)> = BTreeMap::new();
1032 for row in &diff.rows {
1033 if row.metric_name == metric_name && !group_cells.contains_key(&row.group_key) {
1034 let b = format_value_cell(&row.baseline, row.metric_ladder);
1035 let c = format_value_cell(&row.candidate, row.metric_ladder);
1036 let pct = match row.delta_pct {
1037 Some(p) => format!(" ({:+.1}%)", p * 100.0),
1038 None => String::new(),
1039 };
1040 group_cells.insert(
1041 row.group_key.clone(),
1042 (format!("{b}\u{2192}{c}{pct}"), row.delta),
1043 );
1044 }
1045 }
1046 for row in &mut diff.rows {
1047 if let Some((cell, delta)) = group_cells.get(&row.group_key) {
1048 row.sort_by_cell = Some(cell.clone());
1049 row.sort_by_delta = *delta;
1050 }
1051 }
1052 // Mirror the SortBy fill onto derived_rows so the
1053 // derived section's SortBy column carries the sort
1054 // metric's per-group cell instead of "-". Same group
1055 // keying (group_cells is keyed by group_key, which
1056 // is the join key shared by primary and derived rows
1057 // for the same bucket).
1058 for row in &mut diff.derived_rows {
1059 if let Some((cell, delta)) = group_cells.get(&row.group_key) {
1060 row.sort_by_cell = Some(cell.clone());
1061 row.sort_by_delta = *delta;
1062 }
1063 }
1064 }
1065 }
1066}
1067
1068/// Re-key candidate-side smaps data so fudged pairs join the
1069/// baseline side. Fudge pairs a baseline cgroup with a candidate
1070/// cgroup, but smaps keys are `cg\x00pcomm` — different cg means no
1071/// join. For each fudge pair (processed most-specific-root-first),
1072/// scan candidate smaps keys under the pair's `candidate_root`,
1073/// strip that root to a relative child path, then re-key under the
1074/// SAME pair's `baseline_root`. Restricts to candidate cgroups that
1075/// were actually fudge-matched (`fudged_key_pairs`) and sums values
1076/// when multiple candidates map to one baseline key.
1077fn remap_fudged_smaps(diff: &mut CtprofDiff, fudged_key_pairs: &[(String, String)]) {
1078 // Build a set of candidate cgroup paths that were
1079 // actually fudge-matched (extracted from the candidate
1080 // half of fudged_key_pairs). The smaps remap MUST
1081 // restrict to these paths — without the gate, any smaps
1082 // key that happens to share a prefix with a fudge pair's
1083 // candidate_root is remapped, even if its compound-key
1084 // counterpart was never fudge-matched. Sub-cgroups under
1085 // a cascade root that didn't actually match would have
1086 // their smaps data silently re-keyed under the baseline_root.
1087 let fudged_cg_set: std::collections::BTreeSet<&str> = fudged_key_pairs
1088 .iter()
1089 .map(|(_, ckey)| ckey.split_once('\x00').map_or(ckey.as_str(), |(cg, _)| cg))
1090 .collect();
1091 let mut sorted_pairs: Vec<&FudgedPair> = diff.fudged_pairs.iter().collect();
1092 sorted_pairs.sort_by(|a, b| b.candidate_root.len().cmp(&a.candidate_root.len()));
1093 for fp in sorted_pairs {
1094 let br = &fp.baseline_root;
1095 let cr = &fp.candidate_root;
1096 let cr_slash = format!("{cr}/");
1097 let cr_nul = format!("{cr}\x00");
1098 // (relative_child_path + \x00 + pcomm) → summed values,
1099 // SCOPED to this fudge pair so pairs with different
1100 // baseline_roots don't share a remap accumulator.
1101 let mut summed_by_rel: BTreeMap<String, BTreeMap<String, u64>> = BTreeMap::new();
1102 // Fudge runs only under GroupBy::All, which routes
1103 // through `collect_smaps_rollup_hierarchical` and
1104 // produces keys shaped `cgroup\x00pcomm` for every
1105 // entry (see [`collect_smaps_rollup_inner`] under
1106 // `compound_cgroup=true`). A bare-cgroup key
1107 // (no `\x00`) cannot appear here, so the in-root
1108 // filter only needs to consider the `/`-bounded
1109 // (cr_slash) and `\x00`-bounded (cr_nul) prefixes.
1110 let keys: Vec<String> = diff
1111 .smaps_rollup_b
1112 .keys()
1113 .filter(|k| {
1114 let in_root = k.starts_with(&cr_slash) || k.starts_with(&cr_nul);
1115 if !in_root {
1116 return false;
1117 }
1118 // Gate on actual fudge match: the smaps key's
1119 // cg_path must appear in the fudged_cg_set.
1120 let cg_path = k.split_once('\x00').map_or(k.as_str(), |(cg, _)| cg);
1121 fudged_cg_set.contains(cg_path)
1122 })
1123 .cloned()
1124 .collect();
1125 for k in keys {
1126 if let Some(val) = diff.smaps_rollup_b.remove(&k) {
1127 // Split smaps key: cg_path \x00 pcomm. The
1128 // unwrap_or fallback would only fire on a
1129 // key with no `\x00`, which cannot reach
1130 // here (see filter above) — kept defensive.
1131 let (cg_path, pcomm) = k.split_once('\x00').unwrap_or((&k, ""));
1132 // Strip candidate root to get relative child path.
1133 // `cg_path == cr.as_str()` covers the exact-root
1134 // hit (e.g. `/svc-a\x00pcomm` against root
1135 // `/svc-a`); the strip_prefix branch covers
1136 // child paths.
1137 let child = if cg_path == cr.as_str() {
1138 ""
1139 } else if let Some(rest) = cg_path.strip_prefix(&cr_slash) {
1140 rest
1141 } else {
1142 continue;
1143 };
1144 let rel_key = format!("{child}\x00{pcomm}");
1145 let entry = summed_by_rel.entry(rel_key).or_default();
1146 for (field, v) in &val {
1147 let slot = entry.entry(field.clone()).or_insert(0);
1148 *slot = slot.saturating_add(*v);
1149 }
1150 }
1151 }
1152 // Rebuild this pair's baseline-side keys and insert
1153 // summed data under THIS pair's baseline_root.
1154 for (rel_key, summed) in summed_by_rel {
1155 let (child, pcomm) = rel_key.split_once('\x00').unwrap_or((&rel_key, ""));
1156 let base_key = if child.is_empty() {
1157 format!("{br}\x00{pcomm}")
1158 } else {
1159 format!("{br}/{child}\x00{pcomm}")
1160 };
1161 diff.smaps_rollup_b.insert(base_key, summed);
1162 }
1163 }
1164}
1165
1166/// Attach the non-row enrichment data to `diff`: per-cgroup stats
1167/// (only under `GroupBy::Cgroup`), host PSI snapshots,
1168/// per-process smaps rollups (hierarchical under `GroupBy::All`,
1169/// flat otherwise), the fudged-smaps re-key
1170/// ([`remap_fudged_smaps`]), and the global sched_ext sysfs
1171/// snapshot.
1172fn attach_enrichment(
1173 diff: &mut CtprofDiff,
1174 ctx: &CompareCtx,
1175 fudged_key_pairs: &[(String, String)],
1176) {
1177 if ctx.group_by == GroupBy::Cgroup {
1178 diff.cgroup_stats_a =
1179 flatten_cgroup_stats(&ctx.baseline.cgroup_stats, ctx.flatten, ctx.cgroup_key_map);
1180 diff.cgroup_stats_b =
1181 flatten_cgroup_stats(&ctx.candidate.cgroup_stats, ctx.flatten, ctx.cgroup_key_map);
1182 }
1183
1184 diff.host_psi_a = ctx.baseline.psi;
1185 diff.host_psi_b = ctx.candidate.psi;
1186
1187 if ctx.group_by == GroupBy::All {
1188 diff.smaps_rollup_a = collect_smaps_rollup_hierarchical(
1189 ctx.baseline,
1190 ctx.opts.no_thread_normalize,
1191 ctx.flatten,
1192 ctx.cgroup_key_map,
1193 );
1194 diff.smaps_rollup_b = collect_smaps_rollup_hierarchical(
1195 ctx.candidate,
1196 ctx.opts.no_thread_normalize,
1197 ctx.flatten,
1198 ctx.cgroup_key_map,
1199 );
1200 } else {
1201 diff.smaps_rollup_a = collect_smaps_rollup(ctx.baseline, ctx.opts.no_thread_normalize);
1202 diff.smaps_rollup_b = collect_smaps_rollup(ctx.candidate, ctx.opts.no_thread_normalize);
1203 }
1204
1205 remap_fudged_smaps(diff, fudged_key_pairs);
1206
1207 diff.sched_ext_a = ctx.baseline.sched_ext.clone();
1208 diff.sched_ext_b = ctx.candidate.sched_ext.clone();
1209}
1210
1211/// Compare two snapshots and produce a [`CtprofDiff`]. Sequences
1212/// the comparison phases in data-flow order: build the per-side
1213/// thread groups, emit matched + one-sided rows, fudge one-sided
1214/// cgroups together (producing the `fudged_key_pairs` consumed by
1215/// the uptime and enrichment phases), sort the one-sided lists,
1216/// fill uptime%, order the rows, and attach enrichment. The
1217/// fudged-pair `(bkey, ckey)` threading is the only cross-phase
1218/// data dependency: built by `apply_cgroup_fudge`, read by
1219/// `fill_uptime_pct` and `attach_enrichment`.
1220pub fn compare(
1221 baseline: &CtprofSnapshot,
1222 candidate: &CtprofSnapshot,
1223 opts: &CompareOptions,
1224) -> CtprofDiff {
1225 let flatten = compile_flatten_patterns(&opts.cgroup_flatten);
1226 let (group_by, cgroup_key_map, groups_a, groups_b) =
1227 build_compare_groups(baseline, candidate, opts, &flatten);
1228
1229 // Compute per-snapshot "now" for lifetime calculation:
1230 // newest thread's start_time approximates capture time in ticks.
1231 let now_b = candidate
1232 .threads
1233 .iter()
1234 .map(|t| t.start_time_clock_ticks)
1235 .max()
1236 .unwrap_or(0);
1237
1238 let ctx = CompareCtx {
1239 baseline,
1240 candidate,
1241 opts,
1242 group_by,
1243 flatten: &flatten,
1244 cgroup_key_map: cgroup_key_map.as_ref(),
1245 groups_a: &groups_a,
1246 groups_b: &groups_b,
1247 now_b,
1248 };
1249
1250 let mut diff = CtprofDiff::default();
1251
1252 emit_matched_rows(&mut diff, &ctx);
1253
1254 // Content-based cgroup fudging: match one-sided groups by
1255 // thread population overlap when cgroup paths differ but
1256 // the workload is the same (e.g. service re-deployed to a
1257 // new cgroup path between snapshots). The returned pairs are
1258 // read by the uptime and enrichment phases below.
1259 let fudged_key_pairs = apply_cgroup_fudge(&mut diff, &ctx);
1260
1261 diff.only_baseline.sort();
1262 diff.only_candidate.sort();
1263
1264 fill_uptime_pct(&mut diff, &ctx, &fudged_key_pairs);
1265
1266 sort_diff(&mut diff, &opts.sort_by);
1267
1268 attach_enrichment(&mut diff, &ctx, &fudged_key_pairs);
1269
1270 diff
1271}
1272
1273pub fn flatten_cgroup_stats(
1274 stats: &BTreeMap<String, CgroupStats>,
1275 patterns: &[glob::Pattern],
1276 cgroup_key_map: Option<&BTreeMap<String, String>>,
1277) -> BTreeMap<String, CgroupStats> {
1278 // When multiple input paths flatten to the same key, the
1279 // merge is per-controller and per-field-class:
1280 //
1281 // - **Counters** (`usage_usec`, `nr_throttled`,
1282 // `throttled_usec`, `pids.current`, `memory.events` map
1283 // values, AND counter-shaped `memory.stat` keys
1284 // (workingset_*, pgfault, pgmajfault, pgsteal_*, etc.)):
1285 // saturating_add. Cumulative across the merged bucket.
1286 // - **Instantaneous values / gauges** (`memory.current` AND
1287 // gauge-shaped `memory.stat` keys per
1288 // [`MEMORY_STAT_GAUGE_KEYS`]: anon, file, slab,
1289 // active_anon, etc.): max. Summing point-in-time pool
1290 // sizes overstates the merged-bucket gauge. Counter vs
1291 // gauge dispatch lives in [`merge_memory_stat`].
1292 // - **Limits** (`memory.max`, `memory.high`, `pids.max`,
1293 // `cpu.max` quota, `cpu.weight`, `cpu.weight.nice`):
1294 // max-for-limits via [`merge_max_option`]. `None` ("no
1295 // limit") propagates when EITHER side is unbounded — the
1296 // merged bucket is unbounded if any contributor is, since
1297 // no synthesized cap reflects the actual kernel-enforced
1298 // reality.
1299 // - **Floors** (`memory.low`, `memory.min`): min-for-floors
1300 // via [`merge_min_option`]. `None` ("no floor")
1301 // propagates when EITHER side has no floor — the merged
1302 // bucket is only as protected as its weakest contributor,
1303 // for the same reason. The literal "max" token (full
1304 // protection) parses to `Some(u64::MAX)` per
1305 // [`parse_floor_value`] and merges via min-for-floors,
1306 // correctly yielding the smaller concrete floor when one
1307 // contributor has full protection and another has a
1308 // numeric floor.
1309 // - **PSI**: avg fields max-across, total_usec
1310 // saturating_add (per [`merge_psi`]).
1311 //
1312 // When `cgroup_key_map` is provided (auto-normalize is on),
1313 // each post-flatten path is further mapped to its final
1314 // tightened key — so the enrichment table renders against
1315 // the same labels as thread groups. When absent, the
1316 // post-flatten path itself is the key (matches the legacy
1317 // behavior with glob-only flatten).
1318 // First-iteration-replace semantics: the first contributor
1319 // for a key is inserted verbatim (clone). Subsequent
1320 // contributors are merged in via the per-domain merge fns.
1321 // Using `or_default()` + merge here would synthesize a
1322 // CgroupStats whose `Option<u64>` limits are all None and
1323 // None-poison every `merge_max_option`/`merge_min_option`
1324 // call against the first real contributor — yielding `None`
1325 // for limits/floors even when every contributor has a
1326 // concrete value. The replace-on-first / merge-on-rest split
1327 // ensures None-poisoning fires only when contributors
1328 // genuinely disagree (one None, one Some), never when
1329 // merging the synthetic seed.
1330 let mut out: BTreeMap<String, CgroupStats> = BTreeMap::new();
1331 for (path, cs) in stats {
1332 let post_flatten = flatten_cgroup_path(path, patterns);
1333 let key = match cgroup_key_map.and_then(|m| m.get(&post_flatten)) {
1334 Some(k) => k.clone(),
1335 None => post_flatten,
1336 };
1337 match out.get_mut(&key) {
1338 None => {
1339 out.insert(key, cs.clone());
1340 }
1341 Some(agg) => {
1342 merge_cgroup_cpu(&mut agg.cpu, &cs.cpu);
1343 merge_cgroup_memory(&mut agg.memory, &cs.memory);
1344 merge_cgroup_pids(&mut agg.pids, &cs.pids);
1345 agg.psi = merge_psi(agg.psi, cs.psi);
1346 }
1347 }
1348 }
1349 out
1350}