Skip to main content

forge/profile/
report.rs

1#![expect(
2    clippy::cast_possible_truncation,
3    clippy::cast_sign_loss,
4    reason = "clamped non-negative percentages are converted to bounded text bar widths"
5)]
6
7use tabled::Tabled;
8use wowlab_common::output::{self, fmt_duration, fmt_number, fmt_pct, truncate};
9
10use super::{
11    analyze::{ProfileAnalysis, clean_name},
12    categories,
13    monitor::{HostStats, RuntimeStats},
14};
15
16const BAR_WIDTH: usize = 40;
17const MAX_FUNC_NAME_LEN: usize = 55;
18const MAX_CATEGORY_FUNC_NAME_LEN: usize = 65;
19const MAX_HOTSPOT_NAME_LEN: usize = 70;
20const MAX_CALLER_NAME_LEN: usize = 60;
21const MAX_TOP_HOTSPOT_NAME_LEN: usize = 75;
22const MAX_CATEGORY_FUNCS: usize = 5;
23const MAX_HOTSPOT_CONTEXTS: usize = 10;
24const MAX_CALLER_CALLEE: usize = 3;
25const MAX_SOURCE_FILES: usize = 15;
26
27#[derive(Tabled)]
28struct HotFunctionRow {
29    #[tabled(rename = "Self%")]
30    self_pct: String,
31    #[tabled(rename = "Total%")]
32    total_pct: String,
33    #[tabled(rename = "Samples")]
34    samples: String,
35    #[tabled(rename = "Category")]
36    category: String,
37    #[tabled(rename = "Function")]
38    name: String,
39}
40
41#[derive(Tabled)]
42// #t(rust_similar_structs) bottleneck and hot-function rows are independent report tables with different columns
43struct BottleneckRow {
44    #[tabled(rename = "Self%")]
45    self_pct: String,
46    #[tabled(rename = "Total%")]
47    total_pct: String,
48    #[tabled(rename = "Category")]
49    category: String,
50    #[tabled(rename = "Function")]
51    name: String,
52}
53
54#[derive(Tabled)]
55struct SourceFileRow {
56    #[tabled(rename = "%")]
57    pct: String,
58    #[tabled(rename = "File")]
59    path: String,
60}
61
62#[derive(Tabled)]
63struct SubsystemRow {
64    #[tabled(rename = "%")]
65    pct: String,
66    #[tabled(rename = "Bar")]
67    bar: String,
68    #[tabled(rename = "Subsystem")]
69    name: String,
70}
71
72pub(super) fn print_text(result: &ProfileAnalysis, host: &HostStats, process: &RuntimeStats) {
73    output::separator();
74    output::header("Engine Profile Results");
75    output::blank();
76
77    output::kv("Spec", &result.spec);
78    output::kv_fmt("Iterations", fmt_number(f64::from(result.iterations)));
79    output::kv("Duration", &fmt_duration(result.duration_sec));
80    output::kv_fmt(
81        "Throughput",
82        format!("{} sims/sec", fmt_number(result.throughput)),
83    );
84    output::kv_fmt("Samples", result.total_samples);
85    output::kv_fmt(
86        "Unresolved",
87        format!("{} symbols", result.unresolved_symbols),
88    );
89    output::blank();
90
91    print_machine(host);
92    print_process(process);
93    print_observations(&result.observations);
94    print_subsystem_breakdown(&result.categories);
95    print_category_functions(result);
96    print_bottlenecks(&result.bottlenecks);
97    print_hot_functions(&result.functions);
98    print_hotspot_context(&result.hotspot_context);
99    print_source_files(&result.source_files);
100    print_top_hotspots(&result.functions);
101}
102
103fn print_machine(host: &HostStats) {
104    if host.platform.is_empty() {
105        return;
106    }
107
108    output::subheader("Machine");
109    output::kv("CPU", &host.cpu_model);
110    output::kv_fmt(
111        "Cores",
112        format!(
113            "{} physical / {} logical",
114            host.physical_cpus, host.logical_cpus
115        ),
116    );
117    output::kv_fmt("Memory", format!("{:.1} GB", host.memory_gb));
118    output::blank();
119}
120
121fn print_process(process: &RuntimeStats) {
122    if process.sample_count == 0 {
123        return;
124    }
125
126    output::subheader("Process (clean run)");
127    output::kv_fmt(
128        "CPU",
129        format!(
130            "{:.0}% avg / {:.0}% peak ({} samples)",
131            process.proc_cpu_pct_avg, process.proc_cpu_pct_max, process.sample_count
132        ),
133    );
134    output::kv_fmt(
135        "RSS",
136        format!(
137            "{:.1} MB avg / {:.1} MB peak",
138            process.rss_mb_avg, process.rss_mb_max
139        ),
140    );
141
142    if process.threads_max > 0 {
143        output::kv_fmt(
144            "Threads",
145            format!(
146                "{:.0} avg / {} peak",
147                process.threads_avg, process.threads_max
148            ),
149        );
150    }
151
152    output::blank();
153}
154
155fn print_observations(observations: &[String]) {
156    if observations.is_empty() {
157        return;
158    }
159
160    output::subheader("Observations");
161
162    for obs in observations {
163        output::warning(obs);
164    }
165
166    output::blank();
167}
168
169fn print_subsystem_breakdown(categories: &[(String, f64)]) {
170    output::subheader("Time Breakdown by Subsystem");
171    let rows: Vec<SubsystemRow> = categories
172        .iter()
173        .filter(|(cat, _)| cat != "stdlib")
174        .map(|(cat, pct)| {
175            let bar_len = (*pct as usize).min(BAR_WIDTH);
176
177            SubsystemRow {
178                pct: format!("{pct:.1}%"),
179                bar: format!("{}{}", "#".repeat(bar_len), ".".repeat(BAR_WIDTH - bar_len)),
180                name: cat.clone(),
181            }
182        })
183        .collect();
184
185    output::table(rows);
186    output::blank();
187}
188
189fn print_category_functions(result: &ProfileAnalysis) {
190    output::subheader("Per-Category Function Breakdown");
191
192    for (cat, pct) in &result.categories {
193        if cat == "stdlib" {
194            continue;
195        }
196
197        let Some(funcs) = result.category_functions.get(cat) else {
198            continue;
199        };
200
201        if funcs.is_empty() {
202            continue;
203        }
204
205        let desc = categories::description(cat);
206
207        // #t(rust_alloc_in_loop) category header formatted once per category
208        output::info(&format!("  [{}] {}  {}", cat, fmt_pct(*pct / 100.0), desc));
209
210        // #t(block: rust_alloc_in_loop) per-function detail lines, bounded by MAX_CATEGORY_FUNCS
211        for f in funcs.iter().take(MAX_CATEGORY_FUNCS) {
212            output::detail(&format!(
213                "{} self  {} total  {}",
214                fmt_pct(f.self_pct / 100.0),
215                fmt_pct(f.total_pct / 100.0),
216                truncate(&f.name, MAX_CATEGORY_FUNC_NAME_LEN),
217            ));
218        }
219
220        output::blank();
221    }
222}
223
224fn print_bottlenecks(bottlenecks: &[super::analyze::Function]) {
225    if bottlenecks.is_empty() {
226        return;
227    }
228
229    output::subheader("Bottleneck Parents (high total%, low self%)");
230    let rows: Vec<BottleneckRow> = bottlenecks
231        .iter()
232        .map(|f| BottleneckRow {
233            self_pct: format!("{:.1}", f.self_pct),
234            total_pct: format!("{:.1}", f.total_pct),
235            category: f.category.clone(),
236            name: truncate(&clean_name(&f.name), MAX_FUNC_NAME_LEN),
237        })
238        .collect();
239
240    output::table(rows);
241    output::blank();
242}
243
244fn print_hot_functions(functions: &[super::analyze::Function]) {
245    output::subheader("Hot Functions (excluding stdlib)");
246    let rows: Vec<HotFunctionRow> = functions
247        .iter()
248        .map(|f| HotFunctionRow {
249            self_pct: format!("{:.1}", f.self_pct),
250            total_pct: format!("{:.1}", f.total_pct),
251            samples: f.self_samples.to_string(),
252            category: f.category.clone(),
253            name: truncate(&clean_name(&f.name), MAX_FUNC_NAME_LEN),
254        })
255        .collect();
256
257    output::table(rows);
258    output::blank();
259}
260
261// #t(fn: rust_alloc_in_loop) formatting caller/callee detail lines per hotspot
262fn print_hotspot_context(contexts: &[super::analyze::HotspotContext]) {
263    if contexts.is_empty() {
264        return;
265    }
266
267    output::subheader("Hotspot Context (callers/callees)");
268
269    for ctx in contexts.iter().take(MAX_HOTSPOT_CONTEXTS) {
270        output::info(&format!(
271            "  [{} self, {} total] {}",
272            fmt_pct(ctx.self_pct / 100.0),
273            fmt_pct(ctx.total_pct / 100.0),
274            truncate(&ctx.name, MAX_HOTSPOT_NAME_LEN),
275        ));
276
277        for c in ctx.callers.iter().take(MAX_CALLER_CALLEE) {
278            output::detail(&format!(
279                "<- {}  {}",
280                fmt_pct(c.pct / 100.0),
281                truncate(&c.name, MAX_CALLER_NAME_LEN)
282            ));
283        }
284
285        for c in ctx.callees.iter().take(MAX_CALLER_CALLEE) {
286            output::detail(&format!(
287                "-> {}  {}",
288                fmt_pct(c.pct / 100.0),
289                truncate(&c.name, MAX_CALLER_NAME_LEN)
290            ));
291        }
292
293        output::blank();
294    }
295}
296
297fn print_source_files(files: &[(String, f64)]) {
298    if files.is_empty() {
299        return;
300    }
301
302    output::subheader("Hot Source Files");
303    let rows: Vec<SourceFileRow> = files
304        .iter()
305        .take(MAX_SOURCE_FILES)
306        .map(|(path, pct)| SourceFileRow {
307            pct: format!("{pct:.1}%"),
308            path: path.clone(),
309        })
310        .collect();
311
312    output::table(rows);
313    output::blank();
314}
315
316fn print_top_hotspots(functions: &[super::analyze::Function]) {
317    output::separator();
318    output::header("Top 10 Hotspots");
319
320    // #t(block: rust_alloc_in_loop) report formatting, bounded by analysis results
321    for (i, f) in functions.iter().take(MAX_HOTSPOT_CONTEXTS).enumerate() {
322        output::info(&format!(
323            "  {:>2}. [{}] [{}] {}",
324            i + 1,
325            fmt_pct(f.self_pct / 100.0),
326            f.category,
327            truncate(&clean_name(&f.name), MAX_TOP_HOTSPOT_NAME_LEN),
328        ));
329    }
330
331    output::blank();
332}
333
334pub(super) fn print_json(result: &ProfileAnalysis, host: &HostStats, process: &RuntimeStats) {
335    #[derive(serde::Serialize)]
336    struct JsonOutput<'a> {
337        host: &'a HostStats,
338        process: &'a RuntimeStats,
339        #[serde(flatten)]
340        analysis: &'a ProfileAnalysis,
341        category_descriptions: wowlab_types::sim::FastMap<&'static str, &'static str>,
342    }
343
344    let descriptions: wowlab_types::sim::FastMap<&str, &str> = categories::CATEGORIES
345        .iter()
346        .map(|c| (c.name, c.description))
347        .chain([
348            ("stdlib", categories::description("stdlib")),
349            ("other", categories::description("other")),
350        ])
351        .collect();
352
353    output::json(&JsonOutput {
354        host,
355        process,
356        analysis: result,
357        category_descriptions: descriptions,
358    });
359}
360
361pub(super) fn print_bench_summary(bench_output: &str) {
362    output::separator();
363    output::header("Benchmark Results");
364    output::blank();
365
366    for line in bench_output.lines() {
367        output::detail(line);
368    }
369
370    output::blank();
371}