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}