ktstr/ctprof_compare/runner.rs
1//! CLI entry-point orchestration: parsers, top-level command
2//! dispatchers, args struct, and the `DisplayOptions` shape that
3//! `write_diff` consumes.
4//!
5//! Three layers:
6//!
7//! 1. CLI parsers — [`parse_sort_by`] (the sole runner-side
8//! parser; [`super::parse_columns`] / [`super::parse_sections`] /
9//! [`super::parse_metrics`] live in `columns.rs` because they
10//! parse user-visible filter dimensions).
11//!
12//! 2. CLI args + display options — [`CtprofCompareArgs`]
13//! (`ctprof compare` flags surface) and [`DisplayOptions`]
14//! (the renderer's plumbed-as-one-struct config bundle).
15//!
16//! 3. Top-level dispatch — [`run_compare`] / [`run_metric_list`]
17//! drive the `ctprof compare` and `ctprof list-metrics` paths
18//! end-to-end. [`write_metric_list`] / [`print_metric_list`]
19//! emit the metric registry. [`print_diff`] is the post-compare
20//! print convenience that wraps [`super::write_diff`] +
21//! [`limit_sections`] / [`flush_section`].
22
23use std::fmt;
24use std::path::Path;
25
26use anyhow::Context;
27
28use crate::ctprof::CtprofSnapshot;
29
30use super::{
31 AggRule, CTPROF_DERIVED_METRICS, CTPROF_METRICS, Column, CompareOptions, CtprofDiff,
32 CtprofMetricDef, DisplayFormat, GroupBy, Section, SortKey,
33 columns::{compare_columns_for, show_columns_default},
34 compare, metric_tags, parse_columns, parse_metrics, parse_sections,
35 warn_cgroup_only_sections_under_non_cgroup, write_diff,
36};
37
38/// Parse a `--sort-by` CLI value into a list of [`SortKey`]s.
39/// Spec format: `metric1[:dir1],metric2[:dir2],...` where each
40/// `metric` is a name from [`CTPROF_METRICS`] or
41/// [`CTPROF_DERIVED_METRICS`] and `dir` is `asc` or `desc`
42/// (case-insensitive — `:DESC`, `:Asc`, `:asc` all work).
43/// Direction defaults to `desc` (largest delta first — operator
44/// "show me the largest changes" default).
45///
46/// Whitespace around the metric name and around the direction
47/// is trimmed independently, so `wait_sum : desc` and
48/// `wait_sum:desc` produce identical [`SortKey`] values.
49///
50/// Each parsed [`SortKey`] stores the matched registry name as
51/// `&'static str` (not a copy of the user's input), so downstream
52/// equality with [`CtprofMetricDef::name`] or
53/// `DerivedMetricDef::name` is a content-equality check
54/// (`str::eq`) over the same registry-owned bytes — no per-key
55/// allocation outlives this call. The two registries are
56/// disjoint, so a name resolves unambiguously to one or the
57/// other.
58///
59/// Sorts groups by their aggregated metric values under whatever
60/// `--group-by` axis is in effect. The same spec works under
61/// every grouping (pcomm / cgroup / comm / comm-exact) — group
62/// rank reflects the per-group aggregate (sum, max, etc. per
63/// the metric's [`AggRule`]) of the named metric, OR the
64/// per-group derived value for entries from
65/// [`CTPROF_DERIVED_METRICS`].
66///
67/// Examples:
68/// - `"wait_sum"` → one key, descending.
69/// - `"wait_sum:asc"` → one key, ascending.
70/// - `"wait_sum:desc,run_time_ns:desc"` → two keys, both
71/// descending; lexicographic.
72/// - `"avg_wait_ns:desc"` → one key referencing a derived
73/// metric, descending.
74/// - `""` → empty Vec (caller falls back to default sort).
75///
76/// Errors:
77/// - Unknown metric name (not in [`CTPROF_METRICS`] AND not
78/// in [`CTPROF_DERIVED_METRICS`]).
79/// - Categorical metric name (one whose [`AggRule`] is
80/// [`AggRule::Mode`] / [`AggRule::ModeChar`] /
81/// [`AggRule::ModeBool`] — string- / char- / bool-valued, no
82/// scalar to sort by). The default sort already places mode
83/// rows last under the `delta_pct` ladder; sorting BY a mode
84/// metric would silently degrade to alphabetical group order.
85/// - Duplicate metric name across two entries (e.g.
86/// `--sort-by wait_sum,wait_sum`). The second key would never
87/// contribute to the lex ordering, so it's an operator typo
88/// rather than a meaningful spec.
89/// - Direction string other than `asc` / `desc`.
90/// - Empty token between commas (e.g. `"a,,b"`).
91pub fn parse_sort_by(spec: &str) -> anyhow::Result<Vec<SortKey>> {
92 if spec.is_empty() {
93 return Ok(Vec::new());
94 }
95 // Build a `name → &'static CtprofMetricDef` index so the
96 // lookup returns the canonical registry pointer (for storing
97 // in SortKey) AND the AggRule (for the categorical-reject
98 // check).
99 let registry: std::collections::BTreeMap<&'static str, &'static CtprofMetricDef> =
100 CTPROF_METRICS.iter().map(|m| (m.name, m)).collect();
101 let mut out: Vec<SortKey> = Vec::new();
102 let mut seen: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
103 for entry in spec.split(',') {
104 let entry = entry.trim();
105 if entry.is_empty() {
106 anyhow::bail!(
107 "empty entry in --sort-by spec {spec:?}; \
108 entries are comma-separated and must be non-empty"
109 );
110 }
111 let (metric, descending) = match entry.split_once(':') {
112 Some((m, dir)) => {
113 // Trim both sides (`"wait_sum : DESC"` → metric
114 // `"wait_sum"` and direction `"DESC"`) and lowercase
115 // the direction so `:DESC` / `:Asc` / `:asc` are
116 // accepted equivalently. Operator-typed CLI input
117 // is forgiving about case; the canonical form
118 // stored in [`SortKey`] is still derived from the
119 // matched ascii literal.
120 let dir_norm = dir.trim().to_ascii_lowercase();
121 match dir_norm.as_str() {
122 "desc" => (m, true),
123 "asc" => (m, false),
124 _ => anyhow::bail!(
125 "invalid direction {dir:?} in --sort-by entry \
126 {entry:?}; expected `asc` or `desc`"
127 ),
128 }
129 }
130 None => (entry, true),
131 };
132 let metric = metric.trim();
133 // Resolve the input name against either the primary
134 // registry or the derived registry. The two namespaces
135 // are disjoint (the registry_and_derived_names_disjoint
136 // test pins this), so a name resolves unambiguously.
137 // The categorical-reject check applies only to primary
138 // metrics (derived metrics never go through AggRule).
139 let resolved_name: Option<&'static str> = if let Some(def) = registry.get(metric).copied() {
140 if matches!(
141 def.rule,
142 AggRule::Mode(_) | AggRule::ModeChar(_) | AggRule::ModeBool(_),
143 ) {
144 anyhow::bail!(
145 "metric {metric:?} is categorical (no numeric value to sort by); \
146 --sort-by accepts only metrics whose AggRule yields a scalar \
147 (Sum*, Max*, Range*, or Affinity)"
148 );
149 }
150 Some(def.name)
151 } else {
152 CTPROF_DERIVED_METRICS
153 .iter()
154 .find(|d| d.name == metric)
155 .map(|d| d.name)
156 };
157 let Some(canonical) = resolved_name else {
158 // Sorted comma-separated list keeps the diagnostic
159 // copy-pasteable — operator can grep the names
160 // without parsing BTreeSet debug syntax. The
161 // rendered table cells append `[tag]` suffixes (e.g.
162 // `wait_sum [non-ext] [SCHEDSTATS]`), but
163 // `--sort-by` accepts only the bare metric name; if
164 // the operator pasted the rendered cell verbatim
165 // the trailing bracket would land here, hence the
166 // explicit hint.
167 let mut valid: Vec<&'static str> = registry.keys().copied().collect();
168 for d in CTPROF_DERIVED_METRICS {
169 valid.push(d.name);
170 }
171 valid.sort();
172 let valid = valid.join(", ");
173 anyhow::bail!(
174 "unknown metric {metric:?} in --sort-by spec {spec:?}; \
175 use the bare metric name, not the rendered cell with \
176 [tag] suffixes; must be one of: {valid}",
177 );
178 };
179 if !seen.insert(canonical) {
180 anyhow::bail!(
181 "duplicate metric {metric:?} in --sort-by spec {spec:?}; \
182 each metric may appear at most once across all sort keys"
183 );
184 }
185 out.push(SortKey {
186 metric: canonical,
187 descending,
188 });
189 }
190 Ok(out)
191}
192
193/// Aggregate display options for the renderer. Plumbed as a
194/// single struct through [`write_diff`] so a future addition
195/// lands in one place without growing every signature. The
196/// show-side entry (`write_show` in `src/bin/ktstr.rs`) keeps
197/// a flatter signature for historical reasons but mirrors the
198/// same field semantics — `--wrap`, `--sections`, `--metrics`
199/// reach show via `wrap` / `sections` / `metrics` parameters
200/// that share the same
201/// helpers (`new_wrapped_table`, [`Section::cli_name`]).
202#[derive(Debug, Clone, Default)]
203#[non_exhaustive]
204pub struct DisplayOptions {
205 /// Format shorthand. Default [`DisplayFormat::Full`].
206 pub format: DisplayFormat,
207 /// User-supplied column override; empty Vec means "use the
208 /// format's default column set." Set via [`parse_columns`].
209 pub columns: Vec<Column>,
210 /// When `true`, tables render with comfy-table's
211 /// [`comfy_table::ContentArrangement::Dynamic`] layout
212 /// (terminal-width-aware cell wrapping). When `false`,
213 /// tables render with `ContentArrangement::Disabled` — the
214 /// prior shape, where columns can spill past the terminal
215 /// edge. Default `false` keeps existing operator workflows
216 /// untouched until the flag is opted into.
217 pub wrap: bool,
218 /// User-supplied section filter; empty Vec means "render
219 /// every section that has data" — the unfiltered default.
220 /// Non-empty restricts the rendered output to the listed
221 /// sections only. Set via [`parse_sections`].
222 pub sections: Vec<Section>,
223 /// User-supplied per-metric row filter; empty Vec means
224 /// "render every metric in the primary + derived sections"
225 /// — the unfiltered default. Non-empty restricts the
226 /// rendered rows to the listed metric names (which must
227 /// be in [`CTPROF_METRICS`] or
228 /// [`CTPROF_DERIVED_METRICS`]). Set via
229 /// [`parse_metrics`].
230 ///
231 /// Distinct from [`Self::sections`]: sections gate whole
232 /// sub-tables (primary, derived, cgroup-stats, …);
233 /// metrics gate individual ROWS within the primary and
234 /// derived sub-tables. The two compose — naming
235 /// `--sections primary` and `--metrics run_time_ns` shows
236 /// a single primary row.
237 pub metrics: Vec<&'static str>,
238 /// Maximum rendered lines per section. Sections whose table
239 /// output exceeds this limit are truncated with a notice.
240 /// `0` means unlimited (no truncation). Populated from the
241 /// CLI `--limit` arg (default `500`); the struct's `Default`
242 /// is `0` (unlimited).
243 pub section_line_limit: usize,
244}
245
246/// Arguments for the `ktstr ctprof compare` subcommand.
247#[derive(Debug, clap::Args)]
248pub struct CtprofCompareArgs {
249 /// Baseline snapshot (`.ctprof.zst`) from `ktstr ctprof capture -o`.
250 pub baseline: std::path::PathBuf,
251 /// Candidate snapshot (`.ctprof.zst`) from `ktstr ctprof capture -o`.
252 pub candidate: std::path::PathBuf,
253 /// Grouping key. `pcomm` aggregates per process
254 /// name with token-based pattern normalization (so
255 /// `worker-{0..N}` parent processes cluster into one
256 /// `worker-{N}` bucket); `cgroup` per cgroup path; `comm`
257 /// aggregates threads by NAME PATTERN under the same
258 /// token-based normalizer (digits, hex,
259 /// alpha-prefix-digits collapse into placeholders so
260 /// `tokio-worker-{0..N}` and `kworker/u8:7` cluster); use
261 /// `--no-thread-normalize` to disable that collapse and group
262 /// by literal `comm` / `pcomm` instead. `comm-exact`
263 /// disables thread-axis normalization only — its smaps keys
264 /// stay pcomm-pattern normalized — unlike `comm
265 /// --no-thread-normalize`, which also keys smaps literally
266 /// per-PID; see `GroupBy::CommExact`.
267 ///
268 /// Under `all` (default): also activates fudging — pairs
269 /// of cgroups with renamed-but-identical thread populations
270 /// (Jaccard similarity ≥ 0.90 over (pcomm, comm) thread
271 /// types, both sides ≥ 10 distinct types) are joined for
272 /// diffing instead of surfacing as orphans. Fudged rows
273 /// render with a `[fudged: <leaf>]` marker; the
274 /// `## Fudged cgroup matches` section at the bottom of the
275 /// output details the matched pairs and their
276 /// jaccard / overlap / cascade roots / residuals.
277 #[arg(long, value_enum, default_value_t = GroupBy::All, help_heading = "Grouping")]
278 pub group_by: GroupBy,
279 /// Glob patterns that collapse dynamic cgroup path segments
280 /// so structurally-equivalent cgroups across runs group
281 /// together. Example:
282 /// `--cgroup-flatten '/kubepods/*/workload'` treats different
283 /// pod IDs as the same group. Repeatable. Independent of
284 /// `--no-cg-normalize`: explicit globs apply first, then
285 /// auto-normalize runs unless disabled.
286 #[arg(long, help_heading = "Grouping")]
287 pub cgroup_flatten: Vec<String>,
288 /// Disable token-based pattern normalization across every
289 /// name-family axis: `--group-by comm`, `--group-by pcomm`,
290 /// AND the `## smaps_rollup` per-process keying (which
291 /// normalizes by the pcomm pattern by default — see
292 /// `collect_smaps_rollup`). With this flag set:
293 /// threads / processes group by their literal name; smaps
294 /// rows preserve their per-PID identity (`pcomm[tgid]`
295 /// instead of the normalized pcomm pattern). The
296 /// digit/hex/alpha-prefix placeholders are bypassed on every
297 /// axis. Has no effect under `--group-by comm-exact`
298 /// (already literal) or `--group-by cgroup`. Mirror of
299 /// `--no-cg-normalize` for the cgroup axis.
300 #[arg(long, help_heading = "Grouping")]
301 pub no_thread_normalize: bool,
302 /// Disable token-based pattern normalization for the cgroup
303 /// axis (`--group-by cgroup`). Cgroup paths group by literal
304 /// post-`--cgroup-flatten` path — Layer 1 (systemd template
305 /// `@<id>.service` → `@{I}.service`), Layer 2 (token
306 /// normalization), and Layer 3 (tighten) are all bypassed.
307 /// Has no effect under any other grouping.
308 #[arg(long, help_heading = "Grouping")]
309 pub no_cg_normalize: bool,
310 /// Multi-key sort spec for the diff rows. Format:
311 /// `metric1[:dir1],metric2[:dir2],...` where each `metric` is
312 /// one of the primary or derived metric names (run
313 /// `ctprof metric-list` for the full vocabulary) and
314 /// `dir` is `asc` or `desc` (default `desc`). Groups rank by
315 /// the tuple (`metric1_delta`, `metric2_delta`, ...) under
316 /// lexicographic order with per-key direction; rows within a
317 /// group keep registry order. Empty (the default) keeps the
318 /// "biggest |delta_pct|" sort. Examples:
319 /// - `--sort-by wait_sum:desc,run_time_ns:desc` — rank by
320 /// the largest scheduler-wait deltas first, breaking ties
321 /// by run-time delta.
322 /// - `--sort-by hiwater_rss_bytes:desc` — rank by the
323 /// largest peak-RSS growth across the snapshot. Useful
324 /// for memory-leak investigations.
325 /// - `--sort-by avg_wait_ns:asc` — rank by smallest average
326 /// wait time first; surfaces the most-improved processes.
327 ///
328 /// Affects only the per-thread metric table and the
329 /// derived-metrics section. The `## smaps_rollup`
330 /// sub-table sorts process rows independently by absolute
331 /// Rss delta descending (tiebreak: max-Rss, then name; see
332 /// `sorted_smaps_process_keys`); a future flag could expose that knob,
333 /// but `--sort-by` does not propagate to it today.
334 ///
335 /// Parsed by [`parse_sort_by`] into [`CompareOptions::sort_by`].
336 #[arg(long, default_value = "", help_heading = "Display")]
337 pub sort_by: String,
338 /// Per-row column layout. `full` emits the
339 /// seven-column form; `delta-only` drops baseline +
340 /// candidate; `no-pct` drops the percentage column;
341 /// `arrow` collapses baseline / candidate into one
342 /// `baseline -> candidate` cell paired with separate Delta
343 /// and Pct columns; `pct-only` keeps just the percentage.
344 /// `arrow` (default) collapses baseline / candidate into
345 /// one cell paired with Delta and Pct columns.
346 /// `--columns` (below) overrides the format's default
347 /// column set when both are present.
348 #[arg(long, value_enum, default_value_t = DisplayFormat::Arrow, help_heading = "Display")]
349 pub display_format: DisplayFormat,
350 /// Comma-separated column names to render. Empty (the
351 /// default) means "use the column set selected by
352 /// --display-format." Valid names: `group`, `threads`,
353 /// `metric`, `baseline`, `candidate`, `delta`, `%`,
354 /// `arrow`, `tags`, `uptime`. Order in the spec is the
355 /// rendered order.
356 /// Example: `--columns metric,delta,%`. Applies to the
357 /// `primary` section's per-metric table only; secondary
358 /// tables (cgroup-stats, smaps-rollup, etc.) have fixed
359 /// column shapes and ignore this flag.
360 #[arg(long, default_value = "", help_heading = "Display")]
361 pub columns: String,
362 /// Comma-separated section names to render. Empty (the
363 /// default) renders every section that has data. When
364 /// non-empty, restricts output to the listed sub-tables —
365 /// every section not named is suppressed before its
366 /// data-availability gate runs. Valid names: `primary`,
367 /// `taskstats-delay`, `derived`, `cgroup-stats`,
368 /// `cgroup-limits`, `memory-stat`, `memory-events`,
369 /// `pressure`, `host-pressure`, `smaps-rollup`,
370 /// `sched-ext`. Useful for narrowing a wide compare to one
371 /// area of interest. Example:
372 /// `--sections primary,host-pressure`.
373 #[arg(long, default_value = "", help_heading = "Filter")]
374 pub sections: String,
375 /// Comma-separated metric names to render. Empty (the
376 /// default) renders every metric in the primary and
377 /// derived sub-tables. When non-empty, restricts the
378 /// rendered ROWS to the listed names — names must come
379 /// from the `ctprof metric-list` vocabulary
380 /// (CTPROF_METRICS or CTPROF_DERIVED_METRICS).
381 /// Useful for zooming on a specific counter family
382 /// without computing every metric: `--metrics
383 /// run_time_ns,wait_sum,affine_success_ratio`. Composes
384 /// with `--sections` — naming `--sections primary
385 /// --metrics run_time_ns` shows a single primary row.
386 #[arg(long, default_value = "", help_heading = "Filter")]
387 pub metrics: String,
388 /// Wrap table cells to fit the terminal width. Off by
389 /// default — wide tables can spill past the terminal edge,
390 /// matching the prior shell-pipeline-friendly layout. When
391 /// set, cells too wide for the available width wrap inside
392 /// the cell rather than overflowing, at the cost of taller
393 /// rows. The wrap kicks in only when stdout is a tty (the
394 /// terminal width is unknown otherwise); when piped to a
395 /// file or another command, the flag is silently dropped
396 /// and output stays unwrapped so awk/grep pipelines see
397 /// the same byte sequence as without the flag.
398 #[arg(long, help_heading = "Display")]
399 pub wrap: bool,
400 /// Maximum rendered lines per section. Sections whose table
401 /// output exceeds this limit are truncated with a notice
402 /// showing the number of hidden lines. Applies independently
403 /// to each sub-table (primary, derived, smaps-rollup, etc.).
404 /// `0` disables truncation entirely. Default `500`.
405 #[arg(long, default_value_t = 500, help_heading = "Display")]
406 pub limit: usize,
407}
408
409/// Entry point for the compare CLI. Parses `--sort-by` first,
410/// then loads both snapshots, computes the diff, prints the
411/// table, and returns `0` on success. Exits non-zero only on
412/// I/O or parse errors; a non-empty diff is data, not a
413/// failure.
414///
415/// Order is deliberate: `parse_sort_by` runs before the
416/// snapshot loads so an operator typo in the spec (`--sort-by
417/// not_a_real_metric`) fails fast without waiting on disk I/O.
418/// Without this ordering the operator pays for two snapshot
419/// loads only to hit the parser error after — and an
420/// integration test driving a malformed spec against
421/// non-existent snapshot paths would surface the load failure
422/// instead of the parser failure (the path the test actually
423/// pins).
424pub fn run_compare(args: &CtprofCompareArgs) -> anyhow::Result<i32> {
425 let sort_by = parse_sort_by(&args.sort_by)
426 .with_context(|| format!("parse --sort-by {:?}", args.sort_by))?;
427 // Parse --columns alongside --sort-by so a malformed spec
428 // surfaces before the snapshot loads. compare_side: true
429 // for the diff renderer. --sections / --metrics share the
430 // same fail-fast contract — an unknown name should not pay
431 // for two snapshot loads before failing.
432 let columns = parse_columns(&args.columns, true)
433 .with_context(|| format!("parse --columns {:?}", args.columns))?;
434 let sections = parse_sections(&args.sections)
435 .with_context(|| format!("parse --sections {:?}", args.sections))?;
436 let metrics = parse_metrics(&args.metrics)
437 .with_context(|| format!("parse --metrics {:?}", args.metrics))?;
438
439 // Warn the operator if any explicitly-named section is
440 // cgroup-only but the requested grouping isn't cgroup —
441 // those sections would silently render zero rows under the
442 // outer GroupBy::Cgroup gate in `write_diff` otherwise.
443 // The warning fires before snapshot load so the operator
444 // sees it immediately, not after a long disk-I/O wait.
445 warn_cgroup_only_sections_under_non_cgroup(§ions, args.group_by);
446
447 let baseline = CtprofSnapshot::load(&args.baseline)
448 .with_context(|| format!("load baseline {}", args.baseline.display()))?;
449 let candidate = CtprofSnapshot::load(&args.candidate)
450 .with_context(|| format!("load candidate {}", args.candidate.display()))?;
451
452 let display = DisplayOptions {
453 format: args.display_format,
454 columns,
455 wrap: args.wrap,
456 sections,
457 metrics,
458 section_line_limit: args.limit,
459 };
460
461 let opts = CompareOptions {
462 group_by: args.group_by.into(),
463 cgroup_flatten: args.cgroup_flatten.clone(),
464 no_thread_normalize: args.no_thread_normalize,
465 no_cg_normalize: args.no_cg_normalize,
466 sort_by,
467 };
468 let diff = compare(&baseline, &candidate, &opts);
469 print_diff(
470 &diff,
471 &args.baseline,
472 &args.candidate,
473 args.group_by,
474 &display,
475 );
476 Ok(0)
477}
478
479/// Render the metric-list discovery output: a tag legend
480/// (sched_class / config_gates / `[dead]`) followed by a per-metric
481/// table whose rows show `name | tags | description`. Tag legend
482/// is keyed off the closed-set vocabulary the registry pin test
483/// guards (`registry_tag_vocabulary_is_closed`), so adding a new
484/// allowed class or gate fails the test until both the legend
485/// and the closed-set table are updated together.
486///
487/// Splits rendering from I/O so tests can drive the formatter
488/// into a `String` buffer; the public [`run_metric_list`] entry
489/// point is the print wrapper.
490pub fn write_metric_list<W: fmt::Write>(w: &mut W) -> fmt::Result {
491 write_tag_legend(w)?;
492 write_sections_table(w)?;
493 write_metrics_table(w)?;
494 write_derived_metrics_table(w)?;
495 Ok(())
496}
497
498/// Emit the `## Tag legend` section (sched_class / config_gates /
499/// `status:` `[dead]`) and its trailing blank-line separator.
500fn write_tag_legend<W: fmt::Write>(w: &mut W) -> fmt::Result {
501 writeln!(w, "## Tag legend")?;
502 writeln!(w)?;
503 writeln!(w, "sched_class:")?;
504 writeln!(
505 w,
506 " [cfs-only] metric increments only inside CFS-class call paths (kernel/sched/fair.c);"
507 )?;
508 writeln!(w, " zero under sched_ext / RT / DL / IDLE.")?;
509 writeln!(
510 w,
511 " [non-ext] metric is written by the schedstat sleep/wait family wrappers"
512 )?;
513 writeln!(
514 w,
515 " (kernel/sched/stats.c); CFS / RT / DL accumulate, sched_ext bypasses."
516 )?;
517 writeln!(
518 w,
519 " [fair-policy] metric emits only when fair_policy(p->policy) is true:"
520 )?;
521 writeln!(
522 w,
523 " SCHED_NORMAL, SCHED_BATCH, AND SCHED_EXT under CONFIG_SCHED_CLASS_EXT."
524 )?;
525 writeln!(w)?;
526 writeln!(
527 w,
528 "config_gates (compact form; full kconfig symbol prefixed with CONFIG_):"
529 )?;
530 writeln!(
531 w,
532 " [SCHED_INFO] requires CONFIG_SCHED_INFO; gates the sched_info_* counters"
533 )?;
534 writeln!(
535 w,
536 " surfaced via /proc/<tid>/schedstat (run_time_ns, wait_time_ns,"
537 )?;
538 writeln!(w, " timeslices).")?;
539 writeln!(
540 w,
541 " [SCHEDSTATS] requires CONFIG_SCHEDSTATS; gates every __schedstat_* /"
542 )?;
543 writeln!(
544 w,
545 " schedstat_* macro call (kernel/sched/stats.h:75-82)."
546 )?;
547 writeln!(
548 w,
549 " [SCHED_CORE] requires CONFIG_SCHED_CORE; gates the core-scheduling"
550 )?;
551 writeln!(
552 w,
553 " subsystem (core_forceidle_sum)."
554 )?;
555 writeln!(
556 w,
557 " [SCHED_CLASS_EXT] requires CONFIG_SCHED_CLASS_EXT; without it no task can"
558 )?;
559 writeln!(w, " land on the sched_ext class.")?;
560 writeln!(
561 w,
562 " [TASK_DELAY_ACCT] requires CONFIG_TASK_DELAY_ACCT AND runtime delayacct=on"
563 )?;
564 writeln!(
565 w,
566 " (boot param or kernel.task_delayacct sysctl)."
567 )?;
568 writeln!(
569 w,
570 " [TASK_IO_ACCOUNTING] requires CONFIG_TASK_IO_ACCOUNTING; gates /proc/<tid>/io."
571 )?;
572 writeln!(
573 w,
574 " [TASKSTATS] requires CONFIG_TASKSTATS; gates the netlink TASKSTATS family"
575 )?;
576 writeln!(
577 w,
578 " (kernel/taskstats.c) used by the taskstats delay-accounting"
579 )?;
580 writeln!(
581 w,
582 " and hiwater_rss/hiwater_vm capture path. Calls also need"
583 )?;
584 writeln!(w, " CAP_NET_ADMIN.")?;
585 writeln!(
586 w,
587 " [TASK_XACCT] requires CONFIG_TASK_XACCT; gates extended accounting fields"
588 )?;
589 writeln!(
590 w,
591 " (hiwater_rss, hiwater_vm) populated by xacct_add_tsk."
592 )?;
593 writeln!(w)?;
594 writeln!(w, "status:")?;
595 writeln!(
596 w,
597 " [dead] kernel exposes the counter via /proc but never increments it; always"
598 )?;
599 writeln!(
600 w,
601 " reads zero. Surfaced for forward-compat parity with the kernel's"
602 )?;
603 writeln!(w, " exposure surface.")?;
604 writeln!(w)?;
605 Ok(())
606}
607
608/// Emit the `## Sections` vocabulary table and its trailing
609/// blank-line separator.
610fn write_sections_table<W: fmt::Write>(w: &mut W) -> fmt::Result {
611 // Sections vocabulary table — discovery companion to the
612 // `--sections` CLI flag. Lists every Section variant in
613 // rendering order with its CLI name and a short description
614 // of what it renders. Operators reading the rendered table
615 // see `--sections primary,host-pressure` (or whatever) in
616 // their compare/show invocation and need a way to learn
617 // which sub-tables those tokens correspond to without
618 // jumping to source. This section closes that loop.
619 writeln!(w, "## Sections")?;
620 writeln!(w)?;
621 let mut sections_table = crate::cli::new_table();
622 sections_table.set_header(vec!["section", "rendered heading", "description"]);
623 for section in Section::ALL {
624 let (heading, desc) = match section {
625 Section::Primary => (
626 "(no heading; first table)",
627 "Per-thread metric table — the primary aggregated rows EXCLUDING the taskstats genetlink rows (those carry the `taskstats-delay` tag).",
628 ),
629 Section::TaskstatsDelay => (
630 "(rendered inside the primary table)",
631 "Taskstats genetlink-sourced rows — eight delay-accounting categories (cpu/blkio/swapin/freepages/thrashing/compact/wpcopy/irq × count/total/max/min) plus hiwater_rss_bytes / hiwater_vm_bytes. Per-row filter inside the primary table.",
632 ),
633 Section::Derived => (
634 "## Derived metrics",
635 "Computed metrics derived from the primary registry (ratios, averages, signed differences).",
636 ),
637 Section::CgroupStats => (
638 "(no heading; cgroup-stats table)",
639 "Per-cgroup CPU + memory enrichment from cpu.stat / memory.current. Requires --group-by cgroup.",
640 ),
641 Section::Limits => (
642 "## Cgroup limits / knobs",
643 "Operator-set cgroup configuration — cpu.max, cpu.weight, memory.max, memory.high, pids.*. Requires --group-by cgroup.",
644 ),
645 Section::MemoryStat => (
646 "## memory.stat",
647 "Kernel-emitted memory.stat counters per cgroup. Requires --group-by cgroup.",
648 ),
649 Section::MemoryEvents => (
650 "## memory.events",
651 "Pressure-event counters from memory.events per cgroup. Requires --group-by cgroup.",
652 ),
653 Section::Pressure => (
654 "## Pressure / <resource>",
655 "Per-cgroup PSI sub-tables — one per resource (cpu / memory / io / irq). Requires --group-by cgroup.",
656 ),
657 Section::HostPressure => (
658 "## Host pressure / <resource>",
659 "System-level PSI sub-tables from /proc/pressure/<resource>.",
660 ),
661 Section::Smaps => (
662 "## smaps_rollup",
663 "Per-process memory-mapping summary from /proc/<pid>/smaps_rollup (Rss / Pss / private / shared / swap). Compare-side keys default to per-pcomm-pattern aggregates (`worker-{N}`); pass `--no-thread-normalize` to switch back to literal `pcomm[tgid]` per-PID rows. Under default normalization, byte counts per (pcomm-pattern, key) pair are field-summed across all PIDs sharing the same pcomm skeleton.",
664 ),
665 Section::SchedExt => (
666 "## sched_ext",
667 "Global sched_ext sysfs state — state, switch_all, nr_rejected, hotplug_seq, enable_seq.",
668 ),
669 };
670 sections_table.add_row(vec![
671 section.cli_name().to_string(),
672 heading.to_string(),
673 desc.to_string(),
674 ]);
675 }
676 writeln!(w, "{sections_table}")?;
677 writeln!(w)?;
678 Ok(())
679}
680
681/// Emit the `## Metrics` registry table and its trailing
682/// blank-line separator.
683fn write_metrics_table<W: fmt::Write>(w: &mut W) -> fmt::Result {
684 writeln!(w, "## Metrics")?;
685 writeln!(w)?;
686 let mut table = crate::cli::new_table();
687 table.set_header(vec!["metric", "tags", "description"]);
688 for m in CTPROF_METRICS {
689 // metric_tags renders only the bracketed
690 // sched_class / [dead] / config_gate suffixes into the
691 // `tags` column — keeps the table scannable. When the
692 // metric has no tags, the cell is empty.
693 let tags = metric_tags(m);
694 table.add_row(vec![m.name.to_string(), tags, m.description.to_string()]);
695 }
696 writeln!(w, "{table}")?;
697 writeln!(w)?;
698 Ok(())
699}
700
701/// Emit the `## Derived metrics` table.
702fn write_derived_metrics_table<W: fmt::Write>(w: &mut W) -> fmt::Result {
703 writeln!(w, "## Derived metrics")?;
704 writeln!(w)?;
705 let mut dt = crate::cli::new_table();
706 dt.set_header(vec!["metric", "unit", "inputs", "description"]);
707 for d in CTPROF_DERIVED_METRICS {
708 // Ladder is the source of truth — `ratio` and unit
709 // suffixes both fall out of `ScaleLadder::base_unit`
710 // (with an explicit override for ratio rows where
711 // is_ratio is true and the ladder is None).
712 let unit_cell = if d.is_ratio {
713 "ratio".to_string()
714 } else {
715 d.ladder.base_unit().to_string()
716 };
717 dt.add_row(vec![
718 d.name.to_string(),
719 unit_cell,
720 d.inputs.join(", "),
721 d.description.to_string(),
722 ]);
723 }
724 writeln!(w, "{dt}")?;
725 Ok(())
726}
727
728/// Print the metric-list discovery output to stdout. Thin
729/// wrapper over [`write_metric_list`] so the CLI keeps the
730/// one-line call ergonomics; tests drive the writer into a
731/// `String` buffer.
732pub fn print_metric_list() {
733 let mut out = String::new();
734 // Infallible: writing into a String cannot fail.
735 let _ = write_metric_list(&mut out);
736 print!("{out}");
737}
738
739/// Entry point for the `ctprof metric-list` subcommand.
740/// Always returns `Ok(0)` — discovery output is informational
741/// and never fails.
742pub fn run_metric_list() -> anyhow::Result<i32> {
743 print_metric_list();
744 Ok(0)
745}
746
747/// Render [`CtprofDiff`] as a table on stdout. Thin wrapper
748/// over [`write_diff`] so the non-test caller keeps the
749/// ergonomics of a one-line call; tests drive [`write_diff`]
750/// into a `String` buffer.
751pub fn print_diff(
752 diff: &CtprofDiff,
753 baseline_path: &Path,
754 candidate_path: &Path,
755 group_by: GroupBy,
756 display: &DisplayOptions,
757) {
758 let mut out = String::new();
759 // Infallible: writing into a String cannot fail.
760 let _ = write_diff(
761 &mut out,
762 diff,
763 baseline_path,
764 candidate_path,
765 group_by,
766 display,
767 );
768 if display.section_line_limit > 0 {
769 print!("{}", limit_sections(&out, display.section_line_limit));
770 } else {
771 print!("{out}");
772 }
773}
774
775/// Truncate each `## <heading>` section to at most `limit` lines.
776/// Sections are delimited by lines starting with `## `. Content
777/// before the first section header passes through untruncated
778/// (typically the file-path header row).
779pub fn limit_sections(output: &str, limit: usize) -> String {
780 let mut result = String::with_capacity(output.len());
781 let mut section_lines: Vec<&str> = Vec::new();
782 let mut section_header: Option<&str> = None;
783
784 for line in output.lines() {
785 if line.starts_with("## ") {
786 flush_section(&mut result, section_header, §ion_lines, limit);
787 section_lines.clear();
788 section_header = Some(line);
789 } else if section_header.is_some() {
790 section_lines.push(line);
791 } else {
792 result.push_str(line);
793 result.push('\n');
794 }
795 }
796 flush_section(&mut result, section_header, §ion_lines, limit);
797 result
798}
799
800fn flush_section(result: &mut String, header: Option<&str>, lines: &[&str], limit: usize) {
801 let Some(header) = header else { return };
802 result.push_str(header);
803 result.push('\n');
804 if lines.len() <= limit {
805 for line in lines {
806 result.push_str(line);
807 result.push('\n');
808 }
809 } else {
810 for line in &lines[..limit] {
811 result.push_str(line);
812 result.push('\n');
813 }
814 result.push_str(&format!(
815 "... {} more lines truncated (use --limit 0 for unlimited)\n",
816 lines.len() - limit,
817 ));
818 }
819}
820
821impl DisplayOptions {
822 /// Resolved compare-side column set: `columns` if
823 /// non-empty, otherwise `compare_columns_for` over
824 /// `format`. `--columns` always wins over the format
825 /// shorthand (explicit > shorthand) per the design call.
826 pub fn resolved_compare_columns(&self) -> Vec<Column> {
827 if self.columns.is_empty() {
828 compare_columns_for(self.format)
829 } else {
830 self.columns.clone()
831 }
832 }
833
834 /// Resolved show-side column set: `columns` if non-empty,
835 /// otherwise `show_columns_default`.
836 pub fn resolved_show_columns(&self) -> Vec<Column> {
837 if self.columns.is_empty() {
838 show_columns_default()
839 } else {
840 self.columns.clone()
841 }
842 }
843
844 /// Returns `true` when `section` should render under the
845 /// current filter. Empty `sections` means "every section
846 /// renders" (the default — no filter applied), matching
847 /// [`parse_sections`]'s empty-input semantic. Non-empty
848 /// `sections` restricts rendering to the named entries.
849 pub fn is_section_enabled(&self, section: Section) -> bool {
850 self.sections.is_empty() || self.sections.contains(§ion)
851 }
852
853 /// Returns `true` when the metric named `name` should
854 /// render under the current row-level filter. Empty
855 /// `metrics` means "every metric renders" — the
856 /// unfiltered default mirroring
857 /// [`Self::is_section_enabled`]. Non-empty restricts
858 /// rendering to the listed names. The comparison is on
859 /// the metric's `&'static str` name (so a registry-name
860 /// pointer or any byte-equal string both match).
861 pub fn is_metric_enabled(&self, name: &str) -> bool {
862 self.metrics.is_empty() || self.metrics.contains(&name)
863 }
864
865 /// Construct a comfy-table builder honouring the
866 /// [`wrap`](Self::wrap) flag: terminal-width-aware
867 /// `Dynamic` arrangement when `wrap` is true, otherwise the
868 /// existing borderless, disabled-arrangement layout via
869 /// [`crate::cli::new_table`]. Single source of truth so
870 /// every section in [`write_diff`] honours `--wrap` without
871 /// per-call-site `if` branching. The show-side renderer
872 /// (`write_show` in `src/bin/ktstr.rs`) calls the underlying
873 /// helpers directly through the same branch.
874 pub fn new_table(&self) -> comfy_table::Table {
875 if self.wrap {
876 crate::cli::new_wrapped_table()
877 } else {
878 crate::cli::new_table()
879 }
880 }
881
882 /// Create a table constrained to the given max content widths.
883 /// Heading rows wider than data get auto-truncated by comfy_table
884 /// with its built-in "..." indicator.
885 pub fn new_constrained_table(&self, max_widths: &[u16]) -> comfy_table::Table {
886 let mut t = self.new_table();
887 // Create dummy columns so constraints can be set.
888 // Columns are auto-created when the header is added later,
889 // but we need them NOW for set_constraint. Adding a dummy
890 // header row with the right column count, then replacing
891 // it when the real header is set.
892 let dummy: Vec<&str> = (0..max_widths.len()).map(|_| "").collect();
893 t.set_header(dummy);
894 for (i, &w) in max_widths.iter().enumerate() {
895 if let Some(col) = t.column_mut(i) {
896 col.set_constraint(comfy_table::ColumnConstraint::UpperBoundary(
897 comfy_table::Width::Fixed(w),
898 ));
899 }
900 }
901 t
902 }
903}