1use std::collections::HashMap;
10use std::io::Write;
11use std::time::Duration;
12
13pub struct Profiler {
15 file: String,
17 line_ns: HashMap<(String, usize), u64>,
19 sub_stack: Vec<String>,
21 folded_ns: HashMap<String, u64>,
23 sub_inclusive_ns: HashMap<String, u64>,
25}
26
27impl Profiler {
28 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 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 pub fn enter_sub(&mut self, name: &str) {
45 self.sub_stack.push(name.to_string());
46 }
47 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 pub fn print_report(&mut self) {
65 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 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 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 let time_suffix_len = 10;
122 let pct_prefix_len = 8;
123
124 eprintln!("\x1b[1;97m── stryke --flame: {} ──\x1b[0m", self.file);
126 eprintln!();
127
128 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 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 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" } else if pct >= 30.0 {
235 "\x1b[1;93m" } else if pct >= 10.0 {
237 "\x1b[33m" } else {
239 "\x1b[32m" }
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 #[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 #[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 #[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}