use std::fmt;
use std::path::Path;
use anyhow::Context;
use crate::ctprof::CtprofSnapshot;
use super::{
AggRule, CTPROF_DERIVED_METRICS, CTPROF_METRICS, Column, CompareOptions, CtprofDiff,
CtprofMetricDef, DisplayFormat, GroupBy, Section, SortKey,
columns::{compare_columns_for, show_columns_default},
compare, metric_tags, parse_columns, parse_metrics, parse_sections,
warn_cgroup_only_sections_under_non_cgroup, write_diff,
};
pub fn parse_sort_by(spec: &str) -> anyhow::Result<Vec<SortKey>> {
if spec.is_empty() {
return Ok(Vec::new());
}
let registry: std::collections::BTreeMap<&'static str, &'static CtprofMetricDef> =
CTPROF_METRICS.iter().map(|m| (m.name, m)).collect();
let mut out: Vec<SortKey> = Vec::new();
let mut seen: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
for entry in spec.split(',') {
let entry = entry.trim();
if entry.is_empty() {
anyhow::bail!(
"empty entry in --sort-by spec {spec:?}; \
entries are comma-separated and must be non-empty"
);
}
let (metric, descending) = match entry.split_once(':') {
Some((m, dir)) => {
let dir_norm = dir.trim().to_ascii_lowercase();
match dir_norm.as_str() {
"desc" => (m, true),
"asc" => (m, false),
_ => anyhow::bail!(
"invalid direction {dir:?} in --sort-by entry \
{entry:?}; expected `asc` or `desc`"
),
}
}
None => (entry, true),
};
let metric = metric.trim();
let resolved_name: Option<&'static str> = if let Some(def) = registry.get(metric).copied() {
if matches!(
def.rule,
AggRule::Mode(_) | AggRule::ModeChar(_) | AggRule::ModeBool(_),
) {
anyhow::bail!(
"metric {metric:?} is categorical (no numeric value to sort by); \
--sort-by accepts only metrics whose AggRule yields a scalar \
(Sum*, Max*, Range*, or Affinity)"
);
}
Some(def.name)
} else {
CTPROF_DERIVED_METRICS
.iter()
.find(|d| d.name == metric)
.map(|d| d.name)
};
let Some(canonical) = resolved_name else {
let mut valid: Vec<&'static str> = registry.keys().copied().collect();
for d in CTPROF_DERIVED_METRICS {
valid.push(d.name);
}
valid.sort();
let valid = valid.join(", ");
anyhow::bail!(
"unknown metric {metric:?} in --sort-by spec {spec:?}; \
use the bare metric name, not the rendered cell with \
[tag] suffixes; must be one of: {valid}",
);
};
if !seen.insert(canonical) {
anyhow::bail!(
"duplicate metric {metric:?} in --sort-by spec {spec:?}; \
each metric may appear at most once across all sort keys"
);
}
out.push(SortKey {
metric: canonical,
descending,
});
}
Ok(out)
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct DisplayOptions {
pub format: DisplayFormat,
pub columns: Vec<Column>,
pub wrap: bool,
pub sections: Vec<Section>,
pub metrics: Vec<&'static str>,
pub section_line_limit: usize,
}
#[derive(Debug, clap::Args)]
pub struct CtprofCompareArgs {
pub baseline: std::path::PathBuf,
pub candidate: std::path::PathBuf,
#[arg(long, value_enum, default_value_t = GroupBy::All, help_heading = "Grouping")]
pub group_by: GroupBy,
#[arg(long, help_heading = "Grouping")]
pub cgroup_flatten: Vec<String>,
#[arg(long, help_heading = "Grouping")]
pub no_thread_normalize: bool,
#[arg(long, help_heading = "Grouping")]
pub no_cg_normalize: bool,
#[arg(long, default_value = "", help_heading = "Display")]
pub sort_by: String,
#[arg(long, value_enum, default_value_t = DisplayFormat::Arrow, help_heading = "Display")]
pub display_format: DisplayFormat,
#[arg(long, default_value = "", help_heading = "Display")]
pub columns: String,
#[arg(long, default_value = "", help_heading = "Filter")]
pub sections: String,
#[arg(long, default_value = "", help_heading = "Filter")]
pub metrics: String,
#[arg(long, help_heading = "Display")]
pub wrap: bool,
#[arg(long, default_value_t = 500, help_heading = "Display")]
pub limit: usize,
}
pub fn run_compare(args: &CtprofCompareArgs) -> anyhow::Result<i32> {
let sort_by = parse_sort_by(&args.sort_by)
.with_context(|| format!("parse --sort-by {:?}", args.sort_by))?;
let columns = parse_columns(&args.columns, true)
.with_context(|| format!("parse --columns {:?}", args.columns))?;
let sections = parse_sections(&args.sections)
.with_context(|| format!("parse --sections {:?}", args.sections))?;
let metrics = parse_metrics(&args.metrics)
.with_context(|| format!("parse --metrics {:?}", args.metrics))?;
warn_cgroup_only_sections_under_non_cgroup(§ions, args.group_by);
let baseline = CtprofSnapshot::load(&args.baseline)
.with_context(|| format!("load baseline {}", args.baseline.display()))?;
let candidate = CtprofSnapshot::load(&args.candidate)
.with_context(|| format!("load candidate {}", args.candidate.display()))?;
let display = DisplayOptions {
format: args.display_format,
columns,
wrap: args.wrap,
sections,
metrics,
section_line_limit: args.limit,
};
let opts = CompareOptions {
group_by: args.group_by.into(),
cgroup_flatten: args.cgroup_flatten.clone(),
no_thread_normalize: args.no_thread_normalize,
no_cg_normalize: args.no_cg_normalize,
sort_by,
};
let diff = compare(&baseline, &candidate, &opts);
print_diff(
&diff,
&args.baseline,
&args.candidate,
args.group_by,
&display,
);
Ok(0)
}
pub fn write_metric_list<W: fmt::Write>(w: &mut W) -> fmt::Result {
writeln!(w, "## Tag legend")?;
writeln!(w)?;
writeln!(w, "sched_class:")?;
writeln!(
w,
" [cfs-only] metric increments only inside CFS-class call paths (kernel/sched/fair.c);"
)?;
writeln!(w, " zero under sched_ext / RT / DL / IDLE.")?;
writeln!(
w,
" [non-ext] metric is written by the schedstat sleep/wait family wrappers"
)?;
writeln!(
w,
" (kernel/sched/stats.c); CFS / RT / DL accumulate, sched_ext bypasses."
)?;
writeln!(
w,
" [fair-policy] metric emits only when fair_policy(p->policy) is true:"
)?;
writeln!(
w,
" SCHED_NORMAL, SCHED_BATCH, AND SCHED_EXT under CONFIG_SCHED_CLASS_EXT."
)?;
writeln!(w)?;
writeln!(
w,
"config_gates (compact form; full kconfig symbol prefixed with CONFIG_):"
)?;
writeln!(
w,
" [SCHED_INFO] requires CONFIG_SCHED_INFO; gates the sched_info_* counters"
)?;
writeln!(
w,
" surfaced via /proc/<tid>/schedstat (run_time_ns, wait_time_ns,"
)?;
writeln!(w, " timeslices).")?;
writeln!(
w,
" [SCHEDSTATS] requires CONFIG_SCHEDSTATS; gates every __schedstat_* /"
)?;
writeln!(
w,
" schedstat_* macro call (kernel/sched/stats.h:75-82)."
)?;
writeln!(
w,
" [SCHED_CORE] requires CONFIG_SCHED_CORE; gates the core-scheduling"
)?;
writeln!(
w,
" subsystem (core_forceidle_sum)."
)?;
writeln!(
w,
" [SCHED_CLASS_EXT] requires CONFIG_SCHED_CLASS_EXT; without it no task can"
)?;
writeln!(w, " land on the sched_ext class.")?;
writeln!(
w,
" [TASK_DELAY_ACCT] requires CONFIG_TASK_DELAY_ACCT AND runtime delayacct=on"
)?;
writeln!(
w,
" (boot param or kernel.task_delayacct sysctl)."
)?;
writeln!(
w,
" [TASK_IO_ACCOUNTING] requires CONFIG_TASK_IO_ACCOUNTING; gates /proc/<tid>/io."
)?;
writeln!(
w,
" [TASKSTATS] requires CONFIG_TASKSTATS; gates the netlink TASKSTATS family"
)?;
writeln!(
w,
" (kernel/taskstats.c) used by the taskstats delay-accounting"
)?;
writeln!(
w,
" and hiwater_rss/hiwater_vm capture path. Calls also need"
)?;
writeln!(w, " CAP_NET_ADMIN.")?;
writeln!(
w,
" [TASK_XACCT] requires CONFIG_TASK_XACCT; gates extended accounting fields"
)?;
writeln!(
w,
" (hiwater_rss, hiwater_vm) populated by xacct_add_tsk."
)?;
writeln!(w)?;
writeln!(w, "status:")?;
writeln!(
w,
" [dead] kernel exposes the counter via /proc but never increments it; always"
)?;
writeln!(
w,
" reads zero. Surfaced for forward-compat parity with the kernel's"
)?;
writeln!(w, " exposure surface.")?;
writeln!(w)?;
writeln!(w, "## Sections")?;
writeln!(w)?;
let mut sections_table = crate::cli::new_table();
sections_table.set_header(vec!["section", "rendered heading", "description"]);
for section in Section::ALL {
let (heading, desc) = match section {
Section::Primary => (
"(no heading; first table)",
"Per-thread metric table — the primary aggregated rows EXCLUDING the taskstats genetlink rows (those carry the `taskstats-delay` tag).",
),
Section::TaskstatsDelay => (
"(rendered inside the primary table)",
"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.",
),
Section::Derived => (
"## Derived metrics",
"Computed metrics derived from the primary registry (ratios, averages, signed differences).",
),
Section::CgroupStats => (
"(no heading; cgroup-stats table)",
"Per-cgroup CPU + memory enrichment from cpu.stat / memory.current. Requires --group-by cgroup.",
),
Section::Limits => (
"## Cgroup limits / knobs",
"Operator-set cgroup configuration — cpu.max, cpu.weight, memory.max, memory.high, pids.*. Requires --group-by cgroup.",
),
Section::MemoryStat => (
"## memory.stat",
"Kernel-emitted memory.stat counters per cgroup. Requires --group-by cgroup.",
),
Section::MemoryEvents => (
"## memory.events",
"Pressure-event counters from memory.events per cgroup. Requires --group-by cgroup.",
),
Section::Pressure => (
"## Pressure / <resource>",
"Per-cgroup PSI sub-tables — one per resource (cpu / memory / io / irq). Requires --group-by cgroup.",
),
Section::HostPressure => (
"## Host pressure / <resource>",
"System-level PSI sub-tables from /proc/pressure/<resource>.",
),
Section::Smaps => (
"## smaps_rollup",
"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.",
),
Section::SchedExt => (
"## sched_ext",
"Global sched_ext sysfs state — state, switch_all, nr_rejected, hotplug_seq, enable_seq.",
),
};
sections_table.add_row(vec![
section.cli_name().to_string(),
heading.to_string(),
desc.to_string(),
]);
}
writeln!(w, "{sections_table}")?;
writeln!(w)?;
writeln!(w, "## Metrics")?;
writeln!(w)?;
let mut table = crate::cli::new_table();
table.set_header(vec!["metric", "tags", "description"]);
for m in CTPROF_METRICS {
let tags = metric_tags(m);
table.add_row(vec![m.name.to_string(), tags, m.description.to_string()]);
}
writeln!(w, "{table}")?;
writeln!(w)?;
writeln!(w, "## Derived metrics")?;
writeln!(w)?;
let mut dt = crate::cli::new_table();
dt.set_header(vec!["metric", "unit", "inputs", "description"]);
for d in CTPROF_DERIVED_METRICS {
let unit_cell = if d.is_ratio {
"ratio".to_string()
} else {
d.ladder.base_unit().to_string()
};
dt.add_row(vec![
d.name.to_string(),
unit_cell,
d.inputs.join(", "),
d.description.to_string(),
]);
}
writeln!(w, "{dt}")?;
Ok(())
}
pub fn print_metric_list() {
let mut out = String::new();
let _ = write_metric_list(&mut out);
print!("{out}");
}
pub fn run_metric_list() -> anyhow::Result<i32> {
print_metric_list();
Ok(0)
}
pub fn print_diff(
diff: &CtprofDiff,
baseline_path: &Path,
candidate_path: &Path,
group_by: GroupBy,
display: &DisplayOptions,
) {
let mut out = String::new();
let _ = write_diff(
&mut out,
diff,
baseline_path,
candidate_path,
group_by,
display,
);
if display.section_line_limit > 0 {
print!("{}", limit_sections(&out, display.section_line_limit));
} else {
print!("{out}");
}
}
pub fn limit_sections(output: &str, limit: usize) -> String {
let mut result = String::with_capacity(output.len());
let mut section_lines: Vec<&str> = Vec::new();
let mut section_header: Option<&str> = None;
for line in output.lines() {
if line.starts_with("## ") {
flush_section(&mut result, section_header, §ion_lines, limit);
section_lines.clear();
section_header = Some(line);
} else if section_header.is_some() {
section_lines.push(line);
} else {
result.push_str(line);
result.push('\n');
}
}
flush_section(&mut result, section_header, §ion_lines, limit);
result
}
fn flush_section(result: &mut String, header: Option<&str>, lines: &[&str], limit: usize) {
let Some(header) = header else { return };
result.push_str(header);
result.push('\n');
if lines.len() <= limit {
for line in lines {
result.push_str(line);
result.push('\n');
}
} else {
for line in &lines[..limit] {
result.push_str(line);
result.push('\n');
}
result.push_str(&format!(
"... {} more lines truncated (use --limit 0 for unlimited)\n",
lines.len() - limit,
));
}
}
impl DisplayOptions {
pub fn resolved_compare_columns(&self) -> Vec<Column> {
if self.columns.is_empty() {
compare_columns_for(self.format)
} else {
self.columns.clone()
}
}
pub fn resolved_show_columns(&self) -> Vec<Column> {
if self.columns.is_empty() {
show_columns_default()
} else {
self.columns.clone()
}
}
pub fn is_section_enabled(&self, section: Section) -> bool {
self.sections.is_empty() || self.sections.contains(§ion)
}
pub fn is_metric_enabled(&self, name: &str) -> bool {
self.metrics.is_empty() || self.metrics.contains(&name)
}
pub fn new_table(&self) -> comfy_table::Table {
if self.wrap {
crate::cli::new_wrapped_table()
} else {
crate::cli::new_table()
}
}
pub fn new_constrained_table(&self, max_widths: &[u16]) -> comfy_table::Table {
let mut t = self.new_table();
let dummy: Vec<&str> = (0..max_widths.len()).map(|_| "").collect();
t.set_header(dummy);
for (i, &w) in max_widths.iter().enumerate() {
if let Some(col) = t.column_mut(i) {
col.set_constraint(comfy_table::ColumnConstraint::UpperBoundary(
comfy_table::Width::Fixed(w),
));
}
}
t
}
}