Skip to main content

stryke/
profiler.rs

1//! Wall-clock profiler for `stryke --profile`.
2//!
3//! **Per-statement line times** and [`Profiler::enter_sub`] / [`Profiler::exit_sub`]
4//! around subroutine bodies.
5//!
6//! **Bytecode VM**: per-opcode wall time is charged to that opcode's source line; `Call` / `Return`
7//! add inclusive subroutine samples (Cranelift JIT is disabled while profiling).
8
9use std::collections::HashMap;
10use std::io::Write;
11use std::time::Duration;
12
13/// Line- and sub-level timings (nanoseconds).
14pub struct Profiler {
15    /// `file` field.
16    file: String,
17    /// `line_ns` field.
18    line_ns: HashMap<(String, usize), u64>,
19    /// `sub_stack` field.
20    sub_stack: Vec<String>,
21    /// Collapsed stacks `a;b;c` → total ns (flamegraph.pl folded input).
22    folded_ns: HashMap<String, u64>,
23    /// Per-subroutine name → inclusive time (ns).
24    sub_inclusive_ns: HashMap<String, u64>,
25}
26
27impl Profiler {
28    /// `new` — see implementation.
29    pub fn new(file: impl Into<String>) -> Self {
30        Self {
31            file: file.into(),
32            line_ns: HashMap::new(),
33            sub_stack: Vec::new(),
34            folded_ns: HashMap::new(),
35            sub_inclusive_ns: HashMap::new(),
36        }
37    }
38    /// `on_line` — see implementation.
39    pub fn on_line(&mut self, file: &str, line: usize, dt: Duration) {
40        let ns = dt.as_nanos() as u64;
41        *self.line_ns.entry((file.to_string(), line)).or_insert(0) += ns;
42    }
43    /// `enter_sub` — see implementation.
44    pub fn enter_sub(&mut self, name: &str) {
45        self.sub_stack.push(name.to_string());
46    }
47    /// `exit_sub` — see implementation.
48    pub fn exit_sub(&mut self, dt: Duration) {
49        let ns = dt.as_nanos() as u64;
50        let Some(name) = self.sub_stack.pop() else {
51            return;
52        };
53        *self.sub_inclusive_ns.entry(name.clone()).or_insert(0) += ns;
54        let prefix = self.sub_stack.join(";");
55        let full = if prefix.is_empty() {
56            name
57        } else {
58            format!("{};{}", prefix, name)
59        };
60        *self.folded_ns.entry(full).or_insert(0) += ns;
61    }
62
63    /// stderr: folded stacks (flamegraph.pl) + line totals + sub totals.
64    pub fn print_report(&mut self) {
65        // Incomplete enter/exit pairs (e.g. `die` before `return`) would confuse folded output.
66        self.sub_stack.clear();
67
68        eprintln!("# stryke --profile: collapsed stacks (name stack → ns); feed to flamegraph.pl");
69        let mut stacks: Vec<_> = self.folded_ns.iter().collect();
70        stacks.sort_by(|a, b| b.1.cmp(a.1));
71        for (k, ns) in stacks.iter() {
72            eprintln!("{} {}", k, ns);
73        }
74
75        eprintln!("# stryke --profile: lines (file:line → total ns)");
76        let mut lines: Vec<_> = self.line_ns.iter().collect();
77        lines.sort_by(|a, b| b.1.cmp(a.1));
78        for ((f, ln), ns) in lines.iter() {
79            eprintln!("{}:{} {}", f, ln, ns);
80        }
81
82        eprintln!("# stryke --profile: subs (name → inclusive ns)");
83        let mut subs: Vec<_> = self.sub_inclusive_ns.iter().collect();
84        subs.sort_by(|a, b| b.1.cmp(a.1));
85        for (name, ns) in subs {
86            eprintln!("{} {}", name, ns);
87        }
88        eprintln!("# profile script: {}", self.file);
89    }
90
91    /// Render an SVG flamegraph to `writer` using the collected folded stacks.
92    pub fn render_flame_svg<W: Write>(&mut self, writer: W) -> std::io::Result<()> {
93        self.sub_stack.clear();
94
95        let lines: Vec<String> = self
96            .folded_ns
97            .iter()
98            .map(|(stack, ns)| format!("{} {}", stack, ns))
99            .collect();
100        let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
101
102        let mut opts = inferno::flamegraph::Options::default();
103        opts.title = format!("stryke --flame: {}", self.file);
104        opts.count_name = "ns".to_string();
105        opts.colors = inferno::flamegraph::color::Palette::Basic(
106            inferno::flamegraph::color::BasicPalette::Hot,
107        );
108        inferno::flamegraph::from_lines(&mut opts, line_refs, writer)
109    }
110
111    /// Render a colored terminal flamegraph to stderr.
112    ///
113    /// Shows: (1) per-sub inclusive bars sorted hottest-first,
114    /// (2) per-stack-frame bars with call depth indentation,
115    /// (3) hottest source lines.
116    pub fn render_flame_tty(&mut self) {
117        self.sub_stack.clear();
118        let total_ns = self.folded_ns.values().copied().max().unwrap_or(1);
119        let term_width = term_width();
120        // reserve columns: "100.0%  " (8) + name (dynamic) + " " + bar + " 999.9ms"
121        let time_suffix_len = 10;
122        let pct_prefix_len = 8;
123
124        // ── header ──────────────────────────────────────────────────
125        eprintln!("\x1b[1;97m── stryke --flame: {} ──\x1b[0m", self.file);
126        eprintln!();
127
128        // ── subroutine inclusive time (flat) ─────────────────────────
129        if !self.sub_inclusive_ns.is_empty() {
130            eprintln!("\x1b[1;97m  Subroutines (inclusive)\x1b[0m");
131            let mut subs: Vec<_> = self.sub_inclusive_ns.iter().collect();
132            subs.sort_by(|a, b| b.1.cmp(a.1));
133            let max_name = subs.iter().map(|(n, _)| n.len()).max().unwrap_or(4).min(40);
134            let bar_budget =
135                term_width.saturating_sub(pct_prefix_len + max_name + 2 + time_suffix_len);
136            for (name, &ns) in &subs {
137                let pct = ns as f64 / total_ns as f64 * 100.0;
138                let bar_len = (ns as f64 / total_ns as f64 * bar_budget as f64) as usize;
139                let color = heat_color(pct);
140                let display_name = if name.len() > 40 {
141                    format!("…{}", &name[name.len() - 39..])
142                } else {
143                    name.to_string()
144                };
145                eprintln!(
146                    "  {:>5.1}%  {:<width$} {}{}\x1b[0m {}",
147                    pct,
148                    display_name,
149                    color,
150                    "█".repeat(bar_len.max(1)),
151                    format_ns(ns),
152                    width = max_name,
153                );
154            }
155            eprintln!();
156        }
157
158        // ── call stacks (tree-style) ────────────────────────────────
159        if !self.folded_ns.is_empty() {
160            eprintln!("\x1b[1;97m  Call stacks\x1b[0m");
161            let mut stacks: Vec<_> = self.folded_ns.iter().collect();
162            stacks.sort_by(|a, b| b.1.cmp(a.1));
163            let max_show = 20;
164            for (stack, &ns) in stacks.iter().take(max_show) {
165                let pct = ns as f64 / total_ns as f64 * 100.0;
166                let depth = stack.matches(';').count();
167                let leaf = stack.rsplit(';').next().unwrap_or(stack);
168                let indent = "  ".repeat(depth);
169                let display = format!("{}{}", indent, leaf);
170                let name_width = display.len().min(50);
171                let bar_budget =
172                    term_width.saturating_sub(pct_prefix_len + name_width + 2 + time_suffix_len);
173                let bar_len = (ns as f64 / total_ns as f64 * bar_budget as f64) as usize;
174                let color = heat_color(pct);
175                eprintln!(
176                    "  {:>5.1}%  {:<width$} {}{}\x1b[0m {}",
177                    pct,
178                    display,
179                    color,
180                    "█".repeat(bar_len.max(1)),
181                    format_ns(ns),
182                    width = name_width,
183                );
184            }
185            if stacks.len() > max_show {
186                eprintln!("  … and {} more stacks", stacks.len() - max_show);
187            }
188            eprintln!();
189        }
190
191        // ── hottest source lines ────────────────────────────────────
192        if !self.line_ns.is_empty() {
193            eprintln!("\x1b[1;97m  Hot lines\x1b[0m");
194            let mut lines: Vec<_> = self.line_ns.iter().collect();
195            lines.sort_by(|a, b| b.1.cmp(a.1));
196            let max_show = 10;
197            let line_total: u64 = lines.iter().map(|(_, &ns)| ns).sum();
198            for ((f, ln), &ns) in lines.iter().take(max_show) {
199                let pct = ns as f64 / line_total as f64 * 100.0;
200                let color = heat_color(pct);
201                eprintln!(
202                    "  {:>5.1}%  {}{}:{}\x1b[0m  {}",
203                    pct,
204                    color,
205                    f,
206                    ln,
207                    format_ns(ns),
208                );
209            }
210        }
211        eprintln!();
212    }
213}
214
215fn term_width() -> usize {
216    #[cfg(unix)]
217    {
218        let mut ws = libc::winsize {
219            ws_row: 0,
220            ws_col: 0,
221            ws_xpixel: 0,
222            ws_ypixel: 0,
223        };
224        if unsafe { libc::ioctl(2, libc::TIOCGWINSZ, &mut ws) } == 0 && ws.ws_col > 0 {
225            return ws.ws_col as usize;
226        }
227    }
228    80
229}
230
231fn heat_color(pct: f64) -> &'static str {
232    if pct >= 60.0 {
233        "\x1b[1;91m" // bright red
234    } else if pct >= 30.0 {
235        "\x1b[1;93m" // bright yellow
236    } else if pct >= 10.0 {
237        "\x1b[33m" // yellow
238    } else {
239        "\x1b[32m" // green
240    }
241}
242
243fn format_ns(ns: u64) -> String {
244    if ns >= 1_000_000_000 {
245        format!("{:.1}s", ns as f64 / 1e9)
246    } else if ns >= 1_000_000 {
247        format!("{:.1}ms", ns as f64 / 1e6)
248    } else if ns >= 1_000 {
249        format!("{:.1}µs", ns as f64 / 1e3)
250    } else {
251        format!("{}ns", ns)
252    }
253}
254
255#[cfg(test)]
256impl Profiler {
257    fn line_total_ns(&self, file: &str, line: usize) -> u64 {
258        self.line_ns
259            .get(&(file.to_string(), line))
260            .copied()
261            .unwrap_or(0)
262    }
263
264    fn folded_total_ns(&self, key: &str) -> u64 {
265        self.folded_ns.get(key).copied().unwrap_or(0)
266    }
267
268    fn sub_inclusive_total_ns(&self, name: &str) -> u64 {
269        self.sub_inclusive_ns.get(name).copied().unwrap_or(0)
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use std::time::Duration;
277
278    #[test]
279    fn on_line_accumulates_per_file_line() {
280        let mut p = Profiler::new("a.pl");
281        p.on_line("a.pl", 2, Duration::from_nanos(100));
282        p.on_line("a.pl", 2, Duration::from_nanos(50));
283        assert_eq!(p.line_total_ns("a.pl", 2), 150);
284    }
285
286    #[test]
287    fn exit_sub_nested_stack_folded_keys() {
288        let mut p = Profiler::new("a.pl");
289        p.enter_sub("outer");
290        p.enter_sub("inner");
291        p.exit_sub(Duration::from_nanos(7));
292        assert_eq!(p.sub_inclusive_total_ns("inner"), 7);
293        assert_eq!(p.folded_total_ns("outer;inner"), 7);
294        p.exit_sub(Duration::from_nanos(11));
295        assert_eq!(p.sub_inclusive_total_ns("outer"), 11);
296        assert_eq!(p.folded_total_ns("outer"), 11);
297    }
298
299    #[test]
300    fn exit_sub_without_matching_enter_is_silent() {
301        let mut p = Profiler::new("a.pl");
302        p.exit_sub(Duration::from_nanos(1));
303        assert_eq!(p.sub_inclusive_total_ns("nope"), 0);
304    }
305
306    // ── format_ns boundaries ─────────────────────────────────────────
307
308    #[test]
309    fn format_ns_sub_microsecond_uses_ns_suffix() {
310        assert_eq!(format_ns(0), "0ns");
311        assert_eq!(format_ns(1), "1ns");
312        assert_eq!(format_ns(999), "999ns");
313    }
314
315    #[test]
316    fn format_ns_microsecond_band_uses_us_one_decimal() {
317        assert_eq!(format_ns(1_000), "1.0µs");
318        assert_eq!(format_ns(1_500), "1.5µs");
319        assert_eq!(format_ns(999_999), "1000.0µs");
320    }
321
322    #[test]
323    fn format_ns_millisecond_band_uses_ms_one_decimal() {
324        assert_eq!(format_ns(1_000_000), "1.0ms");
325        assert_eq!(format_ns(2_500_000), "2.5ms");
326        assert_eq!(format_ns(999_999_999), "1000.0ms");
327    }
328
329    #[test]
330    fn format_ns_second_band_uses_s_one_decimal() {
331        assert_eq!(format_ns(1_000_000_000), "1.0s");
332        assert_eq!(format_ns(2_500_000_000), "2.5s");
333    }
334
335    // ── heat_color bands ─────────────────────────────────────────────
336
337    #[test]
338    fn heat_color_green_under_ten_pct() {
339        assert_eq!(heat_color(0.0), "\x1b[32m");
340        assert_eq!(heat_color(5.0), "\x1b[32m");
341        assert_eq!(heat_color(9.999), "\x1b[32m");
342    }
343
344    #[test]
345    fn heat_color_yellow_band_10_to_30() {
346        assert_eq!(heat_color(10.0), "\x1b[33m");
347        assert_eq!(heat_color(29.999), "\x1b[33m");
348    }
349
350    #[test]
351    fn heat_color_bright_yellow_band_30_to_60() {
352        assert_eq!(heat_color(30.0), "\x1b[1;93m");
353        assert_eq!(heat_color(59.999), "\x1b[1;93m");
354    }
355
356    #[test]
357    fn heat_color_red_band_at_or_above_60() {
358        assert_eq!(heat_color(60.0), "\x1b[1;91m");
359        assert_eq!(heat_color(100.0), "\x1b[1;91m");
360        assert_eq!(heat_color(1e6), "\x1b[1;91m");
361    }
362
363    // ── Profiler aggregation behaviors ───────────────────────────────
364
365    #[test]
366    fn on_line_distinct_files_kept_separate() {
367        let mut p = Profiler::new("first.pl");
368        p.on_line("first.pl", 1, Duration::from_nanos(10));
369        p.on_line("second.pl", 1, Duration::from_nanos(20));
370        assert_eq!(p.line_total_ns("first.pl", 1), 10);
371        assert_eq!(p.line_total_ns("second.pl", 1), 20);
372        assert_eq!(p.line_total_ns("first.pl", 2), 0);
373    }
374
375    #[test]
376    fn enter_exit_three_deep_folds_full_path() {
377        let mut p = Profiler::new("a.pl");
378        p.enter_sub("a");
379        p.enter_sub("b");
380        p.enter_sub("c");
381        p.exit_sub(Duration::from_nanos(3));
382        assert_eq!(p.folded_total_ns("a;b;c"), 3);
383        p.exit_sub(Duration::from_nanos(5));
384        assert_eq!(p.folded_total_ns("a;b"), 5);
385        p.exit_sub(Duration::from_nanos(7));
386        assert_eq!(p.folded_total_ns("a"), 7);
387        assert_eq!(p.sub_inclusive_total_ns("a"), 7);
388        assert_eq!(p.sub_inclusive_total_ns("b"), 5);
389        assert_eq!(p.sub_inclusive_total_ns("c"), 3);
390    }
391
392    #[test]
393    fn repeated_calls_to_same_sub_accumulate() {
394        let mut p = Profiler::new("a.pl");
395        p.enter_sub("f");
396        p.exit_sub(Duration::from_nanos(4));
397        p.enter_sub("f");
398        p.exit_sub(Duration::from_nanos(6));
399        assert_eq!(p.sub_inclusive_total_ns("f"), 10);
400        assert_eq!(p.folded_total_ns("f"), 10);
401    }
402}