ktstr/ctprof_compare/scale.rs
1//! Auto-scale ladder dispatch and the cell-formatting helpers that
2//! consume it.
3//!
4//! Two layers:
5//!
6//! 1. [`ScaleLadder`] — closed enumeration of unit families
7//! (ns, µs, Bytes, Ticks, Unitless, None) and the [`auto_scale`]
8//! free function that maps an `(f64, ladder)` pair to a
9//! `(scaled_value, scaled_unit)` pair. The ladder choice flows
10//! from [`super::AggRule::ladder`] for primary metrics and from
11//! [`super::DerivedMetricDef::ladder`] for derived metrics; the
12//! cgroup-stats render path passes a ladder directly. A
13//! type-system-mismatch between an `AggRule` variant and its
14//! declared ladder is a compile error rather than a silent
15//! pass-through, because the dispatch is a closed match.
16//!
17//! 2. The `format_*` helpers (`format_value_cell`,
18//! `format_scaled_u64`, `format_derived_value_cell`,
19//! `format_derived_delta_cell`, `format_optional_limit`,
20//! `format_cpu_max`, `cgroup_optional_limit_cell`,
21//! `cgroup_limits_cell`, `format_delta_cell`) — render-only
22//! entry points that consume an [`super::Aggregated`] / scalar
23//! plus a ladder and produce the `String` cell that feeds
24//! `comfy_table` rows in the parent module's `write_diff`
25//! path and the `ktstr` binary's `write_show` path.
26//!
27//! All of this is pure formatting; no underlying numeric values
28//! used for sort order or delta math are mutated here.
29
30use super::{Aggregated, DerivedValue};
31
32/// Closed enumeration of auto-scale ladders driving format
33/// dispatch.
34///
35/// Picks the unit family up the type system rather than a free-form
36/// `&'static str` tag. Each `AggRule` variant maps to exactly one
37/// ladder via `AggRule::ladder`; each [`super::DerivedMetricDef`] entry
38/// carries a ladder via [`super::DerivedMetricDef::ladder`]; the cgroup-
39/// level render path passes a ladder directly. A registry typo or
40/// drift between accessor newtype and ladder choice fails to compile
41/// at the registry edit site rather than silently routing through
42/// an "unknown unit" pass-through arm at render time.
43///
44/// The six ladder variants and their step-up rules:
45/// - [`Ns`](Self::Ns): ns → µs (×1e3) → ms (×1e6) → s (×1e9).
46/// Decimal prefixes — SI time, not binary. Used for
47/// `AggRule::SumNs` (cumulative ns counters),
48/// `AggRule::MaxPeak` (lifetime ns high-water marks),
49/// `AggRule::MaxGaugeNs` (instantaneous ns gauges), and
50/// the `"ns"` derived-metric ladder.
51/// - [`Us`](Self::Us): µs → ms (×1e3) → s (×1e6). Decimal SI
52/// prefixes. The cgroup `cpu_usage_usec` and `throttled_usec`
53/// fields are reported by the kernel in microseconds; this
54/// ladder scales them up the same way the `Ns` ladder scales
55/// nanoseconds.
56/// - [`Bytes`](Self::Bytes): B → KiB → MiB → GiB → TiB. IEC binary
57/// prefixes (×1024) for byte counts. Used for
58/// `AggRule::SumBytes`, `AggRule::MaxPeakBytes`, and any
59/// byte-typed derived metric.
60/// - [`Ticks`](Self::Ticks): ticks → Kticks (×1e3) → Mticks (×1e6).
61/// Decimal prefixes for clock-tick counts
62/// (`utime_clock_ticks`, `stime_clock_ticks`); the unit
63/// itself is opaque (the kernel's `USER_HZ` rate is
64/// host-dependent), so an SI prefix is the most we can
65/// promise.
66/// - [`Unitless`](Self::Unitless): "" → K → M → G. Decimal
67/// prefixes for non-dimensional counters (wakeups, migrations,
68/// csw, syscall counts). Used for `AggRule::SumCount` and
69/// `AggRule::MaxGaugeCount`.
70/// - [`None`](Self::None): no ladder — values render as the bare
71/// integer with no unit suffix and no scaling. Used for
72/// `AggRule::Mode` / `AggRule::ModeChar` /
73/// `AggRule::ModeBool` (categorical strings),
74/// `AggRule::RangeI32` / `AggRule::RangeU32` (bounded
75/// ordinals), and `AggRule::Affinity` (cpuset summaries) —
76/// the [`Aggregated`] [`std::fmt::Display`] impl handles render for
77/// these directly.
78///
79/// The threshold for stepping up is `|value| >= next_scale`.
80/// Sign is preserved through scaling (negative deltas pass
81/// through). Zero stays at base unit.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83pub enum ScaleLadder {
84 Ns,
85 Us,
86 Bytes,
87 Ticks,
88 Unitless,
89 None,
90}
91
92impl ScaleLadder {
93 /// Base unit string for this ladder — what `auto_scale`
94 /// returns for a value at the bottom of the ladder. Used by
95 /// the format helpers to detect whether a value stepped up
96 /// (`auto_scale(v).1 != ladder.base_unit()` ⇒ stepped up,
97 /// render with the scaled unit; equal ⇒ no step-up, render
98 /// the bare integer with the base unit suffix).
99 pub fn base_unit(&self) -> &'static str {
100 match self {
101 ScaleLadder::Ns => "ns",
102 ScaleLadder::Us => "µs",
103 ScaleLadder::Bytes => "B",
104 ScaleLadder::Ticks => "ticks",
105 ScaleLadder::Unitless | ScaleLadder::None => "",
106 }
107 }
108}
109
110/// Auto-scale a numeric value to a more readable magnitude based
111/// on its [`ScaleLadder`]. Returns the scaled value paired with
112/// the scaled unit string.
113///
114/// This is render-only; the underlying numeric values used for
115/// sort order and delta math are untouched.
116///
117/// Dispatches on a closed [`ScaleLadder`] enum rather than a
118/// free-form unit string. The mapping from
119/// `AggRule` / [`super::DerivedMetricDef`] / cgroup-render call site
120/// to [`ScaleLadder`] lives at the type level — see
121/// `AggRule::ladder` and [`super::DerivedMetricDef::ladder`] — so a
122/// registry typo can no longer fall through an `other =>
123/// pass-through` arm and silently render the unscaled value.
124pub(super) fn auto_scale(value: f64, ladder: ScaleLadder) -> (f64, &'static str) {
125 let abs = value.abs();
126 match ladder {
127 ScaleLadder::Ns => {
128 if abs >= 1e9 {
129 (value / 1e9, "s")
130 } else if abs >= 1e6 {
131 (value / 1e6, "ms")
132 } else if abs >= 1e3 {
133 (value / 1e3, "µs")
134 } else {
135 (value, "ns")
136 }
137 }
138 ScaleLadder::Us => {
139 if abs >= 1e6 {
140 (value / 1e6, "s")
141 } else if abs >= 1e3 {
142 (value / 1e3, "ms")
143 } else {
144 (value, "µs")
145 }
146 }
147 ScaleLadder::Bytes => {
148 const KIB: f64 = 1024.0;
149 const MIB: f64 = 1024.0 * KIB;
150 const GIB: f64 = 1024.0 * MIB;
151 const TIB: f64 = 1024.0 * GIB;
152 if abs >= TIB {
153 (value / TIB, "TiB")
154 } else if abs >= GIB {
155 (value / GIB, "GiB")
156 } else if abs >= MIB {
157 (value / MIB, "MiB")
158 } else if abs >= KIB {
159 (value / KIB, "KiB")
160 } else {
161 (value, "B")
162 }
163 }
164 ScaleLadder::Ticks => {
165 if abs >= 1e6 {
166 (value / 1e6, "Mticks")
167 } else if abs >= 1e3 {
168 (value / 1e3, "Kticks")
169 } else {
170 (value, "ticks")
171 }
172 }
173 ScaleLadder::Unitless => {
174 if abs >= 1e9 {
175 (value / 1e9, "G")
176 } else if abs >= 1e6 {
177 (value / 1e6, "M")
178 } else if abs >= 1e3 {
179 (value / 1e3, "K")
180 } else {
181 (value, "")
182 }
183 }
184 ScaleLadder::None => (value, ""),
185 }
186}
187
188/// Format a per-row baseline / candidate cell for [`super::write_diff`].
189/// Numeric aggregates ([`Aggregated::Sum`] / [`Aggregated::Max`])
190/// run through `auto_scale` so large values render in a
191/// readable magnitude (`1.235ms` instead of `1234567ns`). When
192/// the scaled unit equals the ladder's base unit (no step-up was
193/// triggered), the original integer value is rendered verbatim
194/// — this avoids polluting small numbers with a `.000` suffix.
195/// Non-numeric aggregates (`OrdinalRange`, `Mode`, `Affinity`)
196/// fall through to the [`Aggregated`] [`std::fmt::Display`] impl
197/// unchanged because no scaling applies; the ladder is
198/// [`ScaleLadder::None`] for these and the suffix is empty.
199pub fn format_value_cell(agg: &Aggregated, ladder: ScaleLadder) -> String {
200 match agg {
201 Aggregated::Sum(v) => format_scaled_u64(*v, ladder),
202 Aggregated::Max(v) => format_scaled_u64(*v, ladder),
203 // Not-measured: a bare "-" with no unit-ladder suffix — the family was
204 // never captured, so "0ns" / "0 B" would misread as a measured zero.
205 Aggregated::Absent => "-".to_string(),
206 _ => format!("{agg}{}", ladder.base_unit()),
207 }
208}
209
210/// Auto-scale a `u64` value at the given ladder and render it as
211/// a cell. Helper for [`format_value_cell`] — the Sum and Max
212/// arms share this exact logic. Also used by the `ctprof
213/// show` renderer for the cgroup-stats secondary table, where
214/// each scalar stands alone (no baseline/candidate pair to fold
215/// into a delta cell).
216pub fn format_scaled_u64(v: u64, ladder: ScaleLadder) -> String {
217 let (scaled, scaled_unit) = auto_scale(v as f64, ladder);
218 if scaled_unit == ladder.base_unit() {
219 // No step-up — render the original integer to preserve
220 // exact precision (auto_scale's f64 round-trip is
221 // identity below the threshold, but the integer form is
222 // shorter and avoids the `.000` suffix).
223 format!("{v}{}", ladder.base_unit())
224 } else {
225 format!("{scaled:.3}{scaled_unit}")
226 }
227}
228
229/// Format a derived-metric value cell for the `## Derived metrics`
230/// table. Ratio rows (`is_ratio: true`, [`ScaleLadder::None`])
231/// render with three decimals (`0.873`); ns / B / ticks ladders
232/// route through the same auto-scale ladder as the main table.
233/// Negative values (e.g. a negative `live_heap_estimate`) carry
234/// their explicit minus sign through the format.
235pub fn format_derived_value_cell(v: DerivedValue, ladder: ScaleLadder, is_ratio: bool) -> String {
236 let value = v.as_f64();
237 if is_ratio {
238 return format!("{value:.3}");
239 }
240 let (scaled, scaled_unit) = auto_scale(value, ladder);
241 if scaled_unit == ladder.base_unit() {
242 // No ladder step-up — render two decimals to preserve
243 // the fractional precision derived averages carry (e.g.
244 // wait_sum=1234 ns / wait_count=10 = 123.40 ns). The
245 // primary-table integer formatter (format_scaled_u64)
246 // strips fractions because its inputs ARE integers; the
247 // derived path's inputs are `f64` divisions, so two
248 // decimals keep the signal intact.
249 format!("{value:.2}{}", ladder.base_unit())
250 } else {
251 format!("{scaled:.3}{scaled_unit}")
252 }
253}
254
255/// Format the signed delta cell for a derived row. Mirrors
256/// [`format_derived_value_cell`] but always carries an explicit
257/// `+`/`-` sign so the operator can read directionality at a
258/// glance. Ratios render with three decimals (`+0.100` is +10pp);
259/// other ladders route through `auto_scale` and pick up the
260/// scaled unit suffix.
261pub fn format_derived_delta_cell(d: f64, ladder: ScaleLadder, is_ratio: bool) -> String {
262 if is_ratio {
263 return format!("{d:+.3}");
264 }
265 let (scaled, scaled_unit) = auto_scale(d, ladder);
266 if scaled_unit == ladder.base_unit() {
267 format!("{d:+.2}{}", ladder.base_unit())
268 } else {
269 format!("{scaled:+.3}{scaled_unit}")
270 }
271}
272
273/// Render an `Option<u64>` cgroup limit as either `max` (no
274/// limit / kernel emitted the literal `max` token) or the
275/// auto-scaled value. Used for `memory.max`, `memory.high`,
276/// `pids.max`.
277/// Mirrors the kernel's own display: `cat memory.max` prints
278/// `max` when no cap is set, a u64 byte count otherwise.
279pub fn format_optional_limit(v: Option<u64>, ladder: ScaleLadder) -> String {
280 match v {
281 Some(n) => format_scaled_u64(n, ladder),
282 None => "max".to_string(),
283 }
284}
285
286/// Render a `cpu.max` pair as `<quota>/<period>` where quota is
287/// either `max` (no cap) or the auto-scaled µs value. Period is
288/// always present (default 100_000 µs per
289/// `default_bw_period_us()` at `kernel/sched/sched.h:441`). The
290/// `<quota>/<period>` separator is THIS crate's display
291/// convention — the kernel itself emits raw integers in
292/// `cat cpu.max` (space-separated, no auto-scale); we
293/// auto-scale via [`format_scaled_u64`] for human-friendly
294/// output, which also widens the visual delimiter from the
295/// kernel's space to a slash.
296pub fn format_cpu_max(quota: Option<u64>, period_us: u64) -> String {
297 let q = match quota {
298 Some(q) => format_scaled_u64(q, ScaleLadder::Us),
299 None => "max".to_string(),
300 };
301 let p = format_scaled_u64(period_us, ScaleLadder::Us);
302 format!("{q}/{p}")
303}
304
305/// Render a baseline → candidate cell for an `Option<u64>`
306/// LIMIT (e.g. `memory.max`, `memory.high`, `pids.max`). `None`
307/// reads as `max` (no limit) per [`format_optional_limit`]; a
308/// step from concrete to `max` between snapshots renders as
309/// `<value> → max`.
310pub fn cgroup_optional_limit_cell(
311 baseline: Option<u64>,
312 candidate: Option<u64>,
313 ladder: ScaleLadder,
314) -> String {
315 let bl = format_optional_limit(baseline, ladder);
316 let cd = format_optional_limit(candidate, ladder);
317 if baseline == candidate {
318 // No diff — render once. Avoids the `max → max` redundancy
319 // and keeps the limits column scannable when nothing
320 // changed.
321 return bl;
322 }
323 format!("{bl} → {cd}")
324}
325
326/// Render a baseline → candidate cell for `cpu.max`
327/// `(quota, period)` pairs. When both pairs are equal, renders
328/// once via [`format_cpu_max`]; otherwise renders as
329/// `<a> → <b>`. Mirrors [`cgroup_optional_limit_cell`]'s
330/// equality-collapse policy.
331pub fn cgroup_limits_cell(
332 baseline: Option<(Option<u64>, u64)>,
333 candidate: Option<(Option<u64>, u64)>,
334) -> String {
335 let render = |pair: Option<(Option<u64>, u64)>| match pair {
336 Some((q, p)) => format_cpu_max(q, p),
337 None => "-".to_string(),
338 };
339 let bl = render(baseline);
340 let cd = render(candidate);
341 if bl == cd {
342 return bl;
343 }
344 format!("{bl} → {cd}")
345}
346
347/// Format a per-row delta cell for [`super::write_diff`]. Routes the
348/// signed numeric delta through [`auto_scale`] so a large delta
349/// renders in a readable magnitude with the matching prefix
350/// applied to the ladder's base unit. Sign is preserved (rendered
351/// with `+` or `-`). When no step-up was triggered AND the delta
352/// is integer-valued, the cell renders as the bare signed integer
353/// to match [`format_value_cell`]'s short-circuit (so `+5ns`
354/// instead of `+5.000ns`); otherwise the scaled f64 renders with
355/// 3 decimals.
356pub(super) fn format_delta_cell(delta: f64, ladder: ScaleLadder) -> String {
357 let (scaled, scaled_unit) = auto_scale(delta, ladder);
358 if scaled_unit == ladder.base_unit() && delta.fract() == 0.0 {
359 format!("{:+}{scaled_unit}", delta as i64)
360 } else {
361 format!("{scaled:+.3}{scaled_unit}")
362 }
363}