Skip to main content

forge/bench/
report.rs

1// #t(file: rust_alloc_in_loop) CLI binary, building chart data in loops is fine.
2// #t(file: rust_hardcoded_url) commit URLs are constructed for the report.
3
4use anyhow::{Context, Result};
5use maud::{Markup, html};
6use wowlab_fs::{atomic, directory, path::Path};
7
8use super::{
9    charts,
10    types::{BenchHistory, BenchRun},
11};
12use crate::report::{Page, chart_box};
13
14const GITHUB_REPO: &str = "https://github.com/legacy3/wowlab";
15const BENCH_JS: &str = include_str!("report.js");
16
17const TIMESTAMP_DATE_LEN: usize = 10;
18
19struct RunCharts {
20    throughput: String,
21    dps: String,
22    scatter: String,
23    scaling_throughput: Option<String>,
24    scaling_multiplier: Option<String>,
25    scaling_efficiency: Option<String>,
26    scaling_marginal: Option<String>,
27}
28
29fn run_label(run: &BenchRun, index: usize, total: usize) -> String {
30    let age = total - index;
31    let tag = if age == 1 {
32        " (latest)".to_string()
33    } else {
34        format!(" (-{} runs)", age - 1)
35    };
36    let date_end = TIMESTAMP_DATE_LEN.min(run.timestamp.len());
37    // BOUNDS: date_end is clamped to timestamp length.
38    let date = &run.timestamp[..date_end];
39
40    format!(
41        "v{} {}@{} {}{}",
42        run.engine_version, run.git_branch, run.git_hash, date, tag
43    )
44}
45
46fn build_run_charts(run: &BenchRun) -> Result<RunCharts> {
47    let single = BenchHistory {
48        runs: vec![run.clone()],
49    };
50
51    let scaling =
52        |f: fn(&[super::types::ScalingResult]) -> Result<String>| -> Result<Option<String>> {
53            if run.scaling.is_empty() {
54                Ok(None)
55            } else {
56                f(&run.scaling).map(Some)
57            }
58        };
59
60    Ok(RunCharts {
61        throughput: charts::throughput(&single)?,
62        dps: charts::dps(&single)?,
63        scatter: charts::throughput_vs_dps(&single)?,
64        scaling_throughput: scaling(charts::scaling_throughput)?,
65        scaling_multiplier: scaling(charts::scaling_multiplier)?,
66        scaling_efficiency: scaling(charts::scaling_efficiency)?,
67        scaling_marginal: scaling(charts::scaling_marginal_gain)?,
68    })
69}
70
71fn render_meta(run: &BenchRun) -> Markup {
72    let sys = &run.system;
73    let cpu = if sys.cpu_model.is_empty() {
74        "Unknown"
75    } else {
76        &sys.cpu_model
77    };
78
79    let items: Vec<(&str, String)> = vec![
80        ("Engine", format!("v{}", run.engine_version)),
81        ("Git", format!("{}@{}", run.git_branch, run.git_hash)),
82        ("CPU", cpu.to_string()),
83        ("Cores", format!("{}P + {}E", sys.p_cores, sys.e_cores)),
84        ("Rustc", run.rustc_version.clone()),
85    ];
86
87    html! {
88        div class="field is-grouped is-grouped-multiline mb-4" {
89            @for (label, value) in &items {
90                div class="control" {
91                    div class="tags has-addons" {
92                        span class="tag is-dark" { (label) }
93                        span class="tag is-info is-light" { (value) }
94                    }
95                }
96            }
97        }
98    }
99}
100
101fn render_table(run: &BenchRun, prev: Option<&BenchRun>) -> Markup {
102    let mut sorted = run.results.clone();
103
104    sorted.sort_by(|a, b| {
105        b.iterations_per_sec
106            .partial_cmp(&a.iterations_per_sec)
107            .unwrap_or(std::cmp::Ordering::Equal)
108    });
109
110    html! {
111        div class="table-container" {
112            table class="table is-hoverable is-fullwidth is-narrow is-striped" {
113                caption class="is-sr-only" { "Per-spec throughput results, latest run" }
114                thead {
115                    tr {
116                        th scope="col" { "Spec" }
117                        th scope="col" class="has-text-right" { "Iter/s" }
118                        th scope="col" class="has-text-right" { "vs Prev" }
119                        th scope="col" class="has-text-right" { "Mean DPS" }
120                        th scope="col" class="has-text-right" { "Time (ms)" }
121                        th scope="col" class="has-text-right" { "Iterations" }
122                    }
123                }
124                tbody {
125                    @for r in &sorted {
126                        @let prev_ips = prev.and_then(|p| {
127                            p.results.iter()
128                                .find(|pr| pr.slug == r.slug)
129                                .map(|pr| pr.iterations_per_sec)
130                        });
131                        @let (delta_text, delta_cls) = format_delta(r.iterations_per_sec, prev_ips);
132                        tr {
133                            td { (r.slug) }
134                            td class="has-text-right" { (format!("{:.0}", r.iterations_per_sec)) }
135                            td class=(format!("has-text-right {delta_cls}")) { (delta_text) }
136                            td class="has-text-right" { (format!("{:.0}", r.mean_dps)) }
137                            td class="has-text-right" { (r.elapsed_ms) }
138                            td class="has-text-right" { (r.iterations) }
139                        }
140                    }
141                }
142            }
143        }
144    }
145}
146
147fn format_delta(current: f64, previous: Option<f64>) -> (String, &'static str) {
148    match previous {
149        Some(old) if old > 0.0 => {
150            let pct = ((current - old) / old) * wowlab_types::constants::HUNDRED;
151            let sign = if pct > 0.0 { "+" } else { "" };
152            let cls = if pct.abs() < 1.0 {
153                "neutral"
154            } else if pct > 0.0 {
155                "pos"
156            } else {
157                "neg"
158            };
159
160            (format!("{sign}{pct:.1}%"), cls)
161        }
162        _ => ("-".to_string(), "neutral"),
163    }
164}
165
166fn run_chart_data(run_charts: &RunCharts, commit_url: &str) -> serde_json::Value {
167    serde_json::json!({
168        "commitUrl": commit_url,
169        "throughput": serde_json::from_str::<serde_json::Value>(&run_charts.throughput).unwrap_or_default(),
170        "dps": serde_json::from_str::<serde_json::Value>(&run_charts.dps).unwrap_or_default(),
171        "scatter": serde_json::from_str::<serde_json::Value>(&run_charts.scatter).unwrap_or_default(),
172        "scalingThroughput": run_charts.scaling_throughput.as_deref()
173            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()),
174        "scalingMultiplier": run_charts.scaling_multiplier.as_deref()
175            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()),
176        "scalingEfficiency": run_charts.scaling_efficiency.as_deref()
177            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()),
178        "scalingMarginal": run_charts.scaling_marginal.as_deref()
179            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()),
180    })
181}
182
183struct RunView {
184    index: usize,
185    label: String,
186    meta: Markup,
187    table: Markup,
188    chart_data: serde_json::Value,
189    has_scaling: bool,
190}
191
192fn build_run_views(history: &BenchHistory) -> Result<Vec<RunView>> {
193    let total = history.runs.len();
194    let mut views = Vec::new();
195
196    for (i, run) in history.runs.iter().enumerate() {
197        let prev = i.checked_sub(1).and_then(|index| history.runs.get(index));
198        let label = run_label(run, i, total);
199        let meta = render_meta(run);
200        let table = render_table(run, prev);
201        let run_charts = build_run_charts(run)?;
202        let commit_url = format!("{}/tree/{}", GITHUB_REPO, run.git_hash);
203        let chart_data = run_chart_data(&run_charts, &commit_url);
204
205        views.push(RunView {
206            index: i,
207            label,
208            meta,
209            table,
210            chart_data,
211            has_scaling: !run.scaling.is_empty(),
212        });
213    }
214
215    Ok(views)
216}
217
218fn render_header_extras(run_views: &[RunView]) -> Markup {
219    let latest = run_views.len().saturating_sub(1);
220
221    html! {
222        div class="field is-grouped mb-3" {
223            div class="control" {
224                div class="select is-small" {
225                    select #run-select
226                        aria-label="Select benchmark run"
227                        onchange="benchSelectRun(this.value)" {
228                        @for v in run_views.iter().rev() {
229                            option value=(v.index) selected[v.index == latest] { (v.label) }
230                        }
231                    }
232                }
233            }
234            div class="control" {
235                a #commit-link
236                    class="is-size-7 has-text-weak"
237                    href=""
238                    target="_blank"
239                    rel="noopener noreferrer" { "" }
240            }
241        }
242    }
243}
244
245fn render_overview(run_views: &[RunView]) -> Markup {
246    let latest = run_views.len().saturating_sub(1);
247
248    html! {
249        @for v in run_views {
250            div class="run-view" data-run=(v.index) hidden[v.index != latest] {
251                (v.meta)
252                div class="columns" {
253                    div class="column is-half" {
254                        (chart_box("Throughput", "chart", "throughput"))
255                    }
256                    div class="column is-half" {
257                        (chart_box("DPS", "chart", "dps"))
258                    }
259                }
260                (chart_box("Throughput vs DPS", "chart", "scatter"))
261            }
262        }
263    }
264}
265
266fn render_scaling(run_views: &[RunView]) -> Markup {
267    let latest = run_views.len().saturating_sub(1);
268
269    html! {
270        @for v in run_views {
271            div class="run-view" data-run=(v.index) hidden[v.index != latest] {
272                @if v.has_scaling {
273                    (chart_box("Scaling: Throughput", "chart-tall", "scalingThroughput"))
274                    div class="columns" {
275                        div class="column is-half" {
276                            (chart_box("Multiplier", "chart", "scalingMultiplier"))
277                        }
278                        div class="column is-half" {
279                            (chart_box("Efficiency", "chart", "scalingEfficiency"))
280                        }
281                    }
282                    (chart_box("Marginal Gain", "chart", "scalingMarginal"))
283                } @else {
284                    p class="has-text-grey" { "No scaling data for this run." }
285                }
286            }
287        }
288    }
289}
290
291fn render_history() -> Markup {
292    html! {
293        div class="box" {
294            p class="chart-label" { "Throughput Over Time" }
295            div #historyChart class="chart-tall" {}
296        }
297        div class="box" {
298            p class="chart-label" { "DPS Over Time" }
299            div #dpsHistoryChart class="chart-tall" {}
300        }
301    }
302}
303
304fn render_details(run_views: &[RunView]) -> Markup {
305    let latest = run_views.len().saturating_sub(1);
306
307    html! {
308        @for v in run_views {
309            div class="run-view" data-run=(v.index) hidden[v.index != latest] {
310                div class="box" {
311                    (v.table.clone())
312                }
313            }
314        }
315    }
316}
317
318pub(crate) fn generate(output_path: &Path, history: &BenchHistory) -> Result<()> {
319    if let Some(parent) = output_path.parent() {
320        directory::ensure(parent)
321            .with_context(|| format!("failed to create directory {}", parent.display()))?;
322    }
323
324    let history_chart_json = charts::history_lines(history)?;
325    let dps_history_chart_json = charts::dps_history_lines(history)?;
326    let run_views = build_run_views(history)?;
327
328    let mut page = Page::new(
329        "Engine Benchmark Report",
330        "Performance tracking across specs and commits",
331    )
332    .footer_command("cargo forge bench")
333    .header_extras(render_header_extras(&run_views))
334    .tab("overview", "Overview")
335    .tab("scaling", "Scaling")
336    .tab("history", "History")
337    .tab("details", "Details")
338    .section("overview", render_overview(&run_views))
339    .section("scaling", render_scaling(&run_views))
340    .section("history", render_history())
341    .section("details", render_details(&run_views));
342
343    for v in &run_views {
344        page = page.data_script(format!("run-data-{}", v.index), v.chart_data.to_string());
345    }
346
347    page = page
348        .data_script("history-data", history_chart_json)
349        .data_script("dps-history-data", dps_history_chart_json)
350        .extra_js(BENCH_JS);
351    let rendered = page.render();
352
353    atomic::replace(output_path, rendered)
354        .with_context(|| format!("failed to write report to {}", output_path.display()))?;
355
356    Ok(())
357}