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}