Skip to main content

forge/bench/
charts.rs

1#![expect(
2    clippy::cast_precision_loss,
3    reason = "bounded thread counts are converted to chart coordinates"
4)]
5
6use std::collections::BTreeSet;
7
8use anyhow::{Context, Result};
9use charming::{
10    Chart,
11    component::{
12        Aria, Axis, DataZoom, DataZoomType, Grid, Legend, Toolbox,
13        toolbox::{DataView, Feature, Restore, SaveAsImage},
14    },
15    element::{
16        AreaStyle, AxisType, ColorBy, LineStyle, LineStyleType, SymbolSize, Tooltip, Trigger,
17    },
18    series::{Bar, Line, Scatter},
19};
20
21fn standard_aria() -> Aria {
22    Aria::new().enabled(true)
23}
24
25use super::types::{BenchHistory, ScalingResult};
26
27const TIMESTAMP_DATE_LEN: usize = 10;
28const SCALING_AREA_OPACITY: f64 = 0.06;
29const REFERENCE_LINE_OPACITY: f64 = 0.5;
30const SCATTER_SYMBOL_SIZE: f64 = 14.0;
31
32fn to_json(chart: &Chart) -> Result<String> {
33    serde_json::to_string(chart).context("failed to serialize chart")
34}
35
36fn standard_toolbox() -> Toolbox {
37    Toolbox::new().feature(
38        Feature::new()
39            .save_as_image(SaveAsImage::new())
40            .restore(Restore::new())
41            .data_view(DataView::new().read_only(true)),
42    )
43}
44
45fn scaling_chart_base(y_name: &str, thread_counts: Vec<String>) -> Chart {
46    Chart::new()
47        .aria(standard_aria())
48        .tooltip(Tooltip::new().trigger(Trigger::Axis))
49        .legend(Legend::new().bottom("0%"))
50        .grid(Grid::new().left("8%").right("3%").bottom("30%").top("8%"))
51        .toolbox(standard_toolbox())
52        .x_axis(
53            Axis::new()
54                .type_(AxisType::Category)
55                .name("Threads")
56                .data(thread_counts),
57        )
58        .y_axis(Axis::new().type_(AxisType::Value).name(y_name))
59}
60
61fn horizontal_bar(name: &str, labels: Vec<String>, values: Vec<f64>) -> Chart {
62    Chart::new()
63        .aria(standard_aria())
64        .tooltip(Tooltip::new().trigger(Trigger::Axis))
65        .grid(Grid::new().left("20%").right("5%").bottom("5%").top("5%"))
66        .toolbox(standard_toolbox())
67        .x_axis(Axis::new().type_(AxisType::Value))
68        .y_axis(Axis::new().type_(AxisType::Category).data(labels))
69        .series(Bar::new().name(name).data(values).color_by(ColorBy::Data))
70}
71
72fn bar_chart(
73    history: &BenchHistory,
74    name: &str,
75    extract: impl Fn(&super::types::SpecBenchResult) -> f64,
76) -> Result<String> {
77    let latest = history.runs.last().context("no runs")?;
78    let mut sorted = latest.results.clone();
79
80    sorted.sort_by(|a, b| {
81        extract(a)
82            .partial_cmp(&extract(b))
83            .unwrap_or(std::cmp::Ordering::Equal)
84    });
85
86    to_json(&horizontal_bar(
87        name,
88        sorted.iter().map(|r| r.slug.clone()).collect(),
89        sorted.iter().map(extract).collect(),
90    ))
91}
92
93pub(super) fn throughput(history: &BenchHistory) -> Result<String> {
94    bar_chart(history, "iter/s", |r| r.iterations_per_sec)
95}
96
97pub(super) fn dps(history: &BenchHistory) -> Result<String> {
98    bar_chart(history, "DPS", |r| r.mean_dps)
99}
100
101fn history_chart(
102    history: &BenchHistory,
103    y_name: &str,
104    extract: impl Fn(&super::types::SpecBenchResult) -> f64,
105) -> Result<String> {
106    let all_slugs: BTreeSet<String> = history
107        .runs
108        .iter()
109        .flat_map(|run| run.results.iter().map(|r| r.slug.clone()))
110        .collect();
111
112    let labels: Vec<String> = history
113        .runs
114        .iter()
115        .map(|r| {
116            let date_end = TIMESTAMP_DATE_LEN.min(r.timestamp.len());
117
118            format!("{} ({})", r.git_hash, &r.timestamp[..date_end])
119        })
120        .collect();
121
122    let mut chart = Chart::new()
123        .aria(standard_aria())
124        .tooltip(Tooltip::new().trigger(Trigger::Axis))
125        .legend(Legend::new().bottom("0%"))
126        .grid(Grid::new().left("8%").right("3%").bottom("25%").top("5%"))
127        .toolbox(standard_toolbox())
128        .data_zoom(DataZoom::new().type_(DataZoomType::Inside))
129        .data_zoom(DataZoom::new().type_(DataZoomType::Slider).bottom("12%"))
130        .x_axis(Axis::new().type_(AxisType::Category).data(labels))
131        .y_axis(Axis::new().type_(AxisType::Value).name(y_name));
132
133    for slug in &all_slugs {
134        let values: Vec<f64> = history
135            .runs
136            .iter()
137            .map(|run| {
138                run.results
139                    .iter()
140                    .find(|r| r.slug == *slug)
141                    .map_or(f64::NAN, &extract)
142            })
143            .collect();
144
145        chart = chart.series(
146            Line::new()
147                .name(slug.as_str())
148                .smooth(false)
149                .show_symbol(false)
150                .data(values),
151        );
152    }
153
154    to_json(&chart)
155}
156
157pub(super) fn history_lines(history: &BenchHistory) -> Result<String> {
158    history_chart(history, "iter/s", |r| r.iterations_per_sec)
159}
160
161pub(super) fn dps_history_lines(history: &BenchHistory) -> Result<String> {
162    history_chart(history, "DPS", |r| r.mean_dps)
163}
164
165pub(super) fn scaling_throughput(scaling: &[ScalingResult]) -> Result<String> {
166    if scaling.is_empty() {
167        return Ok("{}".to_string());
168    }
169
170    let thread_counts: Vec<String> = scaling[0]
171        .points
172        .iter()
173        .map(|p| p.threads.to_string())
174        .collect();
175
176    let mut chart = scaling_chart_base("iter/s", thread_counts);
177
178    for s in scaling {
179        let values: Vec<f64> = s.points.iter().map(|p| p.iterations_per_sec).collect();
180
181        chart = chart.series(
182            Line::new()
183                .name(&s.slug)
184                .data(values)
185                .area_style(AreaStyle::new().opacity(SCALING_AREA_OPACITY)),
186        );
187    }
188
189    if let Some(first) = scaling.first() {
190        if let Some(baseline) = first.points.first() {
191            let ideal: Vec<f64> = first
192                .points
193                .iter()
194                .map(|p| baseline.iterations_per_sec * p.threads as f64)
195                .collect();
196
197            chart = chart.series(
198                Line::new().name("Ideal (linear)").data(ideal).line_style(
199                    LineStyle::new()
200                        .type_(LineStyleType::Dashed)
201                        .opacity(REFERENCE_LINE_OPACITY),
202                ),
203            );
204        }
205    }
206
207    to_json(&chart)
208}
209
210pub(super) fn scaling_multiplier(scaling: &[ScalingResult]) -> Result<String> {
211    if scaling.is_empty() {
212        return Ok("{}".to_string());
213    }
214
215    let thread_counts: Vec<String> = scaling[0]
216        .points
217        .iter()
218        .map(|p| p.threads.to_string())
219        .collect();
220
221    let mut chart = scaling_chart_base("Multiplier (x)", thread_counts);
222
223    for s in scaling {
224        let values: Vec<f64> = s.points.iter().map(|p| p.multiplier).collect();
225
226        chart = chart.series(
227            Line::new()
228                .name(&s.slug)
229                .data(values)
230                .area_style(AreaStyle::new().opacity(SCALING_AREA_OPACITY)),
231        );
232    }
233
234    if let Some(first) = scaling.first() {
235        let ideal: Vec<f64> = first.points.iter().map(|p| p.threads as f64).collect();
236
237        chart = chart.series(
238            Line::new().name("Ideal (linear)").data(ideal).line_style(
239                LineStyle::new()
240                    .type_(LineStyleType::Dashed)
241                    .opacity(REFERENCE_LINE_OPACITY),
242            ),
243        );
244    }
245
246    to_json(&chart)
247}
248
249pub(super) fn scaling_efficiency(scaling: &[ScalingResult]) -> Result<String> {
250    if scaling.is_empty() {
251        return Ok("{}".to_string());
252    }
253
254    let thread_counts: Vec<String> = scaling[0]
255        .points
256        .iter()
257        .map(|p| p.threads.to_string())
258        .collect();
259
260    let mut chart = scaling_chart_base("Efficiency (%)", thread_counts);
261
262    for s in scaling {
263        let values: Vec<f64> = s
264            .points
265            .iter()
266            .map(|p| p.efficiency * wowlab_types::constants::HUNDRED)
267            .collect();
268
269        chart = chart.series(
270            Line::new()
271                .name(&s.slug)
272                .data(values)
273                .area_style(AreaStyle::new().opacity(SCALING_AREA_OPACITY)),
274        );
275    }
276
277    to_json(&chart)
278}
279
280pub(super) fn scaling_marginal_gain(scaling: &[ScalingResult]) -> Result<String> {
281    if scaling.is_empty() {
282        return Ok("{}".to_string());
283    }
284
285    let thread_counts: Vec<String> = scaling[0]
286        .points
287        .iter()
288        .map(|p| p.threads.to_string())
289        .collect();
290
291    let mut chart = scaling_chart_base("Marginal Gain (%)", thread_counts);
292
293    for s in scaling {
294        let values: Vec<f64> = s
295            .points
296            .iter()
297            .map(|p| p.marginal_gain * wowlab_types::constants::HUNDRED)
298            .collect();
299
300        chart = chart.series(Bar::new().name(&s.slug).data(values));
301    }
302
303    to_json(&chart)
304}
305
306pub(super) fn throughput_vs_dps(history: &BenchHistory) -> Result<String> {
307    let latest = history.runs.last().context("no runs")?;
308
309    let mut chart = Chart::new()
310        .aria(standard_aria())
311        .tooltip(Tooltip::new().trigger(Trigger::Item))
312        .legend(Legend::new().bottom("0%"))
313        .grid(Grid::new().left("8%").right("3%").bottom("25%").top("5%"))
314        .toolbox(standard_toolbox())
315        .x_axis(Axis::new().type_(AxisType::Value).name("Iterations/sec"))
316        .y_axis(Axis::new().type_(AxisType::Value).name("Mean DPS"));
317
318    for r in &latest.results {
319        chart = chart.series(
320            Scatter::new()
321                .name(&r.slug)
322                .symbol_size(SymbolSize::Number(SCATTER_SYMBOL_SIZE))
323                .data(vec![vec![r.iterations_per_sec, r.mean_dps]]),
324        );
325    }
326
327    to_json(&chart)
328}