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        ("Backend", format!("{:?}", run.backend)),
86        (
87            "Features",
88            if run.features.is_empty() {
89                "none".to_string()
90            } else {
91                run.features.join(", ")
92            },
93        ),
94    ];
95
96    html! {
97        div class="field is-grouped is-grouped-multiline mb-4" {
98            @for (label, value) in &items {
99                div class="control" {
100                    div class="tags has-addons" {
101                        span class="tag is-dark" { (label) }
102                        span class="tag is-info is-light" { (value) }
103                    }
104                }
105            }
106        }
107    }
108}
109
110fn render_table(run: &BenchRun, prev: Option<&BenchRun>) -> Markup {
111    let mut sorted = run.results.clone();
112
113    sorted.sort_by(|a, b| {
114        b.median_iterations_per_sec
115            .partial_cmp(&a.median_iterations_per_sec)
116            .unwrap_or(std::cmp::Ordering::Equal)
117    });
118
119    html! {
120        div class="table-container" {
121            table class="table is-hoverable is-fullwidth is-narrow is-striped" {
122                caption class="is-sr-only" { "Per-spec throughput results, latest run" }
123                thead {
124                    tr {
125                        th scope="col" { "Spec" }
126                        th scope="col" { "Encounter" }
127                        th scope="col" class="has-text-right" { "Median iter/s" }
128                        th scope="col" class="has-text-right" { "Mean iter/s" }
129                        th scope="col" class="has-text-right" { "Std dev iter/s" }
130                        th scope="col" class="has-text-right" { "vs Prev" }
131                        th scope="col" class="has-text-right" { "Mean DPS" }
132                        th scope="col" class="has-text-right" { "Events/iter" }
133                        th scope="col" class="has-text-right" { "Rotations/iter" }
134                        th scope="col" class="has-text-right" { "Damage/iter" }
135                        th scope="col" class="has-text-right" { "Auras/iter" }
136                        th scope="col" class="has-text-right" { "Allocations/iter" }
137                        th scope="col" { "Fingerprint" }
138                    }
139                }
140                tbody {
141                    @for r in &sorted {
142                        @let prev_ips = prev.and_then(|p| {
143                            p.results.iter()
144                                .find(|pr| pr.slug == r.slug)
145                                .map(|pr| pr.median_iterations_per_sec)
146                        });
147                        @let (delta_text, delta_cls) = format_delta(r.median_iterations_per_sec, prev_ips);
148                        @let normalized = normalized_counts(r);
149                        tr {
150                            td { (r.slug) }
151                            td { (r.encounter_fixture) }
152                            td class="has-text-right" { (format!("{:.1}", r.median_iterations_per_sec)) }
153                            td class="has-text-right" { (format!("{:.1}", r.mean_iterations_per_sec)) }
154                            td class="has-text-right" { (format!("{:.1}", r.population_stddev_iterations_per_sec)) }
155                            td class=(format!("has-text-right {delta_cls}")) { (delta_text) }
156                            td class="has-text-right" { (format!("{:.0}", r.mean_dps)) }
157                            td class="has-text-right" { (format!("{:.2}", normalized.events)) }
158                            td class="has-text-right" { (format!("{:.2}", normalized.rotations)) }
159                            td class="has-text-right" { (format!("{:.2}", normalized.damage)) }
160                            td class="has-text-right" { (format!("{:.2}", normalized.auras)) }
161                            td class="has-text-right" { (format!("{:.2}", normalized.allocations)) }
162                            td { code { (r.semantic_fingerprint) } }
163                        }
164                    }
165                }
166            }
167        }
168    }
169}
170
171struct NormalizedCounts {
172    events: f64,
173    rotations: f64,
174    damage: f64,
175    auras: f64,
176    allocations: f64,
177}
178
179fn normalized_counts(result: &super::types::SpecBenchResult) -> NormalizedCounts {
180    let sample_count = u64::try_from(result.samples.len()).unwrap_or(u64::MAX);
181    let denominator = f64::from(result.iterations) * count_as_f64(sample_count);
182
183    if denominator <= f64::EPSILON {
184        return NormalizedCounts {
185            events: 0.0,
186            rotations: 0.0,
187            damage: 0.0,
188            auras: 0.0,
189            allocations: 0.0,
190        };
191    }
192
193    NormalizedCounts {
194        events: result
195            .samples
196            .iter()
197            .map(|sample| count_as_f64(sample.counters.events))
198            .sum::<f64>()
199            / denominator,
200        rotations: result
201            .samples
202            .iter()
203            .map(|sample| count_as_f64(sample.counters.rotation_evaluations))
204            .sum::<f64>()
205            / denominator,
206        damage: result
207            .samples
208            .iter()
209            .map(|sample| count_as_f64(sample.counters.damage_events))
210            .sum::<f64>()
211            / denominator,
212        auras: result
213            .samples
214            .iter()
215            .map(|sample| count_as_f64(sample.counters.aura_events))
216            .sum::<f64>()
217            / denominator,
218        allocations: result
219            .samples
220            .iter()
221            .filter_map(|sample| sample.allocations)
222            .map(|allocations| count_as_f64(allocations.count))
223            .sum::<f64>()
224            / denominator,
225    }
226}
227
228#[expect(
229    clippy::cast_precision_loss,
230    reason = "integer benchmark counters are rendered as approximate floating-point rates"
231)]
232const fn count_as_f64(count: u64) -> f64 {
233    count as f64
234}
235
236fn format_delta(current: f64, previous: Option<f64>) -> (String, &'static str) {
237    match previous {
238        Some(old) if old > 0.0 => {
239            let pct = ((current - old) / old) * wowlab_types::constants::HUNDRED;
240            let sign = if pct > 0.0 { "+" } else { "" };
241            let cls = if pct.abs() < 1.0 {
242                "neutral"
243            } else if pct > 0.0 {
244                "pos"
245            } else {
246                "neg"
247            };
248
249            (format!("{sign}{pct:.1}%"), cls)
250        }
251        _ => ("-".to_string(), "neutral"),
252    }
253}
254
255fn run_chart_data(run_charts: &RunCharts, commit_url: &str) -> serde_json::Value {
256    serde_json::json!({
257        "commitUrl": commit_url,
258        "throughput": serde_json::from_str::<serde_json::Value>(&run_charts.throughput).unwrap_or_default(),
259        "dps": serde_json::from_str::<serde_json::Value>(&run_charts.dps).unwrap_or_default(),
260        "scatter": serde_json::from_str::<serde_json::Value>(&run_charts.scatter).unwrap_or_default(),
261        "scalingThroughput": run_charts.scaling_throughput.as_deref()
262            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()),
263        "scalingMultiplier": run_charts.scaling_multiplier.as_deref()
264            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()),
265        "scalingEfficiency": run_charts.scaling_efficiency.as_deref()
266            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()),
267        "scalingMarginal": run_charts.scaling_marginal.as_deref()
268            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()),
269    })
270}
271
272struct RunView {
273    index: usize,
274    label: String,
275    meta: Markup,
276    table: Markup,
277    chart_data: serde_json::Value,
278    has_scaling: bool,
279}
280
281fn build_run_views(history: &BenchHistory) -> Result<Vec<RunView>> {
282    let total = history.runs.len();
283    let mut views = Vec::new();
284
285    for (i, run) in history.runs.iter().enumerate() {
286        let prev = i.checked_sub(1).and_then(|index| history.runs.get(index));
287        let label = run_label(run, i, total);
288        let meta = render_meta(run);
289        let table = render_table(run, prev);
290        let run_charts = build_run_charts(run)?;
291        let commit_url = format!("{}/tree/{}", GITHUB_REPO, run.git_hash);
292        let chart_data = run_chart_data(&run_charts, &commit_url);
293
294        views.push(RunView {
295            index: i,
296            label,
297            meta,
298            table,
299            chart_data,
300            has_scaling: !run.scaling.is_empty(),
301        });
302    }
303
304    Ok(views)
305}
306
307fn render_header_extras(run_views: &[RunView]) -> Markup {
308    let latest = run_views.len().saturating_sub(1);
309
310    html! {
311        div class="field is-grouped mb-3" {
312            div class="control" {
313                div class="select is-small" {
314                    select #run-select
315                        aria-label="Select benchmark run"
316                        onchange="benchSelectRun(this.value)" {
317                        @for v in run_views.iter().rev() {
318                            option value=(v.index) selected[v.index == latest] { (v.label) }
319                        }
320                    }
321                }
322            }
323            div class="control" {
324                a #commit-link
325                    class="is-size-7 has-text-weak"
326                    href=""
327                    target="_blank"
328                    rel="noopener noreferrer" { "" }
329            }
330        }
331    }
332}
333
334fn render_overview(run_views: &[RunView]) -> Markup {
335    let latest = run_views.len().saturating_sub(1);
336
337    html! {
338        @for v in run_views {
339            div class="run-view" data-run=(v.index) hidden[v.index != latest] {
340                (v.meta)
341                div class="columns" {
342                    div class="column is-half" {
343                        (chart_box("Throughput", "chart", "throughput"))
344                    }
345                    div class="column is-half" {
346                        (chart_box("DPS", "chart", "dps"))
347                    }
348                }
349                (chart_box("Throughput vs DPS", "chart", "scatter"))
350            }
351        }
352    }
353}
354
355fn render_scaling(run_views: &[RunView]) -> Markup {
356    let latest = run_views.len().saturating_sub(1);
357
358    html! {
359        @for v in run_views {
360            div class="run-view" data-run=(v.index) hidden[v.index != latest] {
361                @if v.has_scaling {
362                    (chart_box("Scaling: Throughput", "chart-tall", "scalingThroughput"))
363                    div class="columns" {
364                        div class="column is-half" {
365                            (chart_box("Multiplier", "chart", "scalingMultiplier"))
366                        }
367                        div class="column is-half" {
368                            (chart_box("Efficiency", "chart", "scalingEfficiency"))
369                        }
370                    }
371                    (chart_box("Marginal Gain", "chart", "scalingMarginal"))
372                } @else {
373                    p class="has-text-grey" { "No scaling data for this run." }
374                }
375            }
376        }
377    }
378}
379
380fn render_history() -> Markup {
381    html! {
382        div class="box" {
383            p class="chart-label" { "Throughput Over Time" }
384            div #historyChart class="chart-tall" {}
385        }
386        div class="box" {
387            p class="chart-label" { "DPS Over Time" }
388            div #dpsHistoryChart class="chart-tall" {}
389        }
390    }
391}
392
393fn render_details(run_views: &[RunView]) -> Markup {
394    let latest = run_views.len().saturating_sub(1);
395
396    html! {
397        @for v in run_views {
398            div class="run-view" data-run=(v.index) hidden[v.index != latest] {
399                div class="box" {
400                    (v.table.clone())
401                }
402            }
403        }
404    }
405}
406
407pub(crate) fn generate(output_path: &Path, history: &BenchHistory) -> Result<()> {
408    if let Some(parent) = output_path.parent() {
409        directory::ensure(parent)
410            .with_context(|| format!("failed to create directory {}", parent.display()))?;
411    }
412
413    let history_chart_json = charts::history_lines(history)?;
414    let dps_history_chart_json = charts::dps_history_lines(history)?;
415    let run_views = build_run_views(history)?;
416
417    let mut page = Page::new(
418        "Engine Benchmark Report",
419        "Performance tracking across specs and commits",
420    )
421    .footer_command("cargo forge bench")
422    .header_extras(render_header_extras(&run_views))
423    .tab("overview", "Overview")
424    .tab("scaling", "Scaling")
425    .tab("history", "History")
426    .tab("details", "Details")
427    .section("overview", render_overview(&run_views))
428    .section("scaling", render_scaling(&run_views))
429    .section("history", render_history())
430    .section("details", render_details(&run_views));
431
432    for v in &run_views {
433        page = page.data_script(format!("run-data-{}", v.index), v.chart_data.to_string());
434    }
435
436    page = page
437        .data_script("history-data", history_chart_json)
438        .data_script("dps-history-data", dps_history_chart_json)
439        .extra_js(BENCH_JS);
440    let rendered = page.render();
441
442    atomic::replace(output_path, rendered)
443        .with_context(|| format!("failed to write report to {}", output_path.display()))?;
444
445    Ok(())
446}