1use 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 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}