1use std::{
2 fs::File,
3 io::{self, ErrorKind, Write},
4 path::{Path, PathBuf},
5};
6
7use anyhow::anyhow;
8use anyhow::{bail, Result};
9use itertools::Itertools;
10use plotly::{
11 common::{Font, LegendGroupTitle, Title},
12 layout::{Axis, Legend},
13 Configuration, Layout, Plot,
14};
15
16use crate::{
18 data::{MeasurementData, MeasurementSummary},
19 measurement_retrieval::{self, Commit, MeasurementReducer},
20 serialization::{serialize_single, DELIMITER},
21};
22use git_perf_cli_types::ReductionFunc;
23
24trait Reporter<'a> {
25 fn add_commits(&mut self, hashes: &'a [Commit]);
26 fn add_trace(
27 &mut self,
28 indexed_measurements: Vec<(usize, &'a MeasurementData)>,
29 measurement_name: &str,
30 group_value: Option<&String>,
31 );
32 fn add_summarized_trace(
33 &mut self,
34 indexed_measurements: Vec<(usize, MeasurementSummary)>,
35 measurement_name: &str,
36 group_value: Option<&String>,
37 );
38 fn as_bytes(&self) -> Vec<u8>;
39}
40
41struct PlotlyReporter {
42 plot: Plot,
43 size: usize,
45}
46
47impl PlotlyReporter {
48 fn new() -> PlotlyReporter {
49 let config = Configuration::default().responsive(true).fill_frame(true);
50 let mut plot = Plot::new();
51 plot.set_configuration(config);
52 PlotlyReporter { plot, size: 0 }
53 }
54
55 fn convert_to_x_y(&self, indexed_measurements: Vec<(usize, f64)>) -> (Vec<usize>, Vec<f64>) {
56 indexed_measurements
57 .iter()
58 .map(|(i, m)| (self.size - i - 1, m))
59 .unzip()
60 }
61}
62
63impl<'a> Reporter<'a> for PlotlyReporter {
64 fn add_commits(&mut self, commits: &'a [Commit]) {
65 let enumerated_commits = commits.iter().rev().enumerate();
66 self.size = commits.len();
67
68 let (commit_nrs, short_hashes): (Vec<_>, Vec<_>) = enumerated_commits
69 .map(|(n, c)| (n as f64, c.commit[..6].to_owned()))
70 .unzip();
71 let x_axis = Axis::new()
72 .tick_values(commit_nrs)
73 .tick_text(short_hashes)
74 .tick_angle(45.0)
75 .tick_font(Font::new().family("monospace"));
76 let layout = Layout::new()
77 .title(Title::new("Performance Measurements"))
78 .x_axis(x_axis)
79 .legend(
80 Legend::new()
81 .group_click(plotly::layout::GroupClick::ToggleItem)
82 .orientation(plotly::common::Orientation::Horizontal),
83 );
84
85 self.plot.set_layout(layout);
86 }
87
88 fn add_trace(
89 &mut self,
90 indexed_measurements: Vec<(usize, &'a MeasurementData)>,
91 measurement_name: &str,
92 group_value: Option<&String>,
93 ) {
94 let (x, y) = self.convert_to_x_y(
95 indexed_measurements
96 .into_iter()
97 .map(|(i, m)| (i, m.val))
98 .collect_vec(),
99 );
100
101 let trace = plotly::BoxPlot::new_xy(x, y);
102
103 let trace = if let Some(group_value) = group_value {
104 trace
105 .name(group_value)
106 .legend_group(measurement_name)
107 .legend_group_title(LegendGroupTitle::new(measurement_name))
108 } else {
109 trace.name(measurement_name)
110 };
111
112 self.plot.add_trace(trace);
113 }
114
115 fn add_summarized_trace(
116 &mut self,
117 indexed_measurements: Vec<(usize, MeasurementSummary)>,
118 measurement_name: &str,
119 group_value: Option<&String>,
120 ) {
121 let (x, y) = self.convert_to_x_y(
122 indexed_measurements
123 .into_iter()
124 .map(|(i, m)| (i, m.val))
125 .collect_vec(),
126 );
127
128 let trace = plotly::Scatter::new(x, y).name(measurement_name);
129
130 let trace = if let Some(group_value) = group_value {
131 trace
132 .name(group_value)
133 .legend_group(measurement_name)
134 .legend_group_title(LegendGroupTitle::new(measurement_name))
135 } else {
136 trace.name(measurement_name)
137 };
138
139 self.plot.add_trace(trace);
140 }
141
142 fn as_bytes(&self) -> Vec<u8> {
143 self.plot.to_html().as_bytes().to_vec()
144 }
145}
146
147struct CsvReporter<'a> {
148 hashes: Vec<String>,
149 indexed_measurements: Vec<(usize, &'a MeasurementData)>,
150}
151
152impl CsvReporter<'_> {
153 fn new() -> Self {
154 CsvReporter {
155 hashes: Vec::new(),
156 indexed_measurements: Vec::new(),
157 }
158 }
159}
160
161impl<'a> Reporter<'a> for CsvReporter<'a> {
162 fn add_commits(&mut self, hashes: &'a [Commit]) {
163 self.hashes = hashes.iter().map(|c| c.commit.to_owned()).collect();
164 }
165
166 fn add_trace(
167 &mut self,
168 indexed_measurements: Vec<(usize, &'a MeasurementData)>,
169 _measurement_name: &str,
170 _group_value: Option<&String>,
171 ) {
172 self.indexed_measurements
173 .extend_from_slice(indexed_measurements.as_slice());
174 }
175
176 fn as_bytes(&self) -> Vec<u8> {
177 self.indexed_measurements
178 .iter()
179 .map(|(index, measurement_data)| {
180 let ser_measurement = serialize_single(measurement_data, "\t");
181 let commit = &self.hashes[*index];
182 format!("{commit}{DELIMITER}{ser_measurement}")
183 })
184 .join("")
185 .into_bytes()
186 }
187
188 fn add_summarized_trace(
189 &mut self,
190 _indexed_measurements: Vec<(usize, MeasurementSummary)>,
191 _measurement_name: &str,
192 _group_value: Option<&String>,
193 ) {
194 todo!()
195 }
196}
197
198struct ReporterFactory {}
199
200impl ReporterFactory {
201 fn from_file_name(path: &Path) -> Option<Box<dyn Reporter<'_> + '_>> {
202 if path == Path::new("-") {
203 return Some(Box::new(CsvReporter::new()) as Box<dyn Reporter<'_>>);
204 }
205 let mut res = None;
206 if let Some(ext) = path.extension() {
207 let extension = ext.to_ascii_lowercase().into_string().unwrap();
208 res = match extension.as_str() {
209 "html" => Some(Box::new(PlotlyReporter::new()) as Box<dyn Reporter<'_>>),
210 "csv" => Some(Box::new(CsvReporter::new()) as Box<dyn Reporter<'_>>),
211 _ => None,
212 }
213 }
214 res
215 }
216}
217pub fn report(
219 output: PathBuf,
220 separate_by: Option<String>,
221 num_commits: usize,
222 measurement_names: &[String],
223 key_values: &[(String, String)],
224 aggregate_by: Option<ReductionFunc>,
225) -> Result<()> {
226 let commits: Vec<Commit> = measurement_retrieval::walk_commits(num_commits)?.try_collect()?;
227
228 let mut plot =
229 ReporterFactory::from_file_name(&output).ok_or(anyhow!("Could not infer output format"))?;
230
231 plot.add_commits(&commits);
232
233 let relevant = |m: &MeasurementData| {
234 if !measurement_names.is_empty() && !measurement_names.contains(&m.name) {
235 return false;
236 }
237 key_values
239 .iter()
240 .all(|(k, v)| m.key_values.get(k).map(|mv| v == mv).unwrap_or(false))
241 };
242
243 let relevant_measurements = commits
244 .iter()
245 .map(|commit| commit.measurements.iter().filter(|m| relevant(m)));
246
247 let unique_measurement_names: Vec<_> = relevant_measurements
248 .clone()
249 .flat_map(|m| m.map(|m| &m.name))
250 .unique()
251 .collect();
252
253 if unique_measurement_names.is_empty() {
254 bail!("No performance measurements found.")
255 }
256
257 for measurement_name in unique_measurement_names {
258 let filtered_measurements = relevant_measurements
259 .clone()
260 .map(|ms| ms.filter(|m| m.name == *measurement_name));
261
262 let group_values = if let Some(separate_by) = &separate_by {
263 filtered_measurements
264 .clone()
265 .flat_map(|ms| {
266 ms.flat_map(|m| {
267 m.key_values
268 .iter()
269 .filter(|(k, _v)| *k == separate_by)
270 .map(|(_k, v)| v)
271 })
272 })
273 .unique()
274 .map(|val| (Some(separate_by), Some(val)))
275 .collect_vec()
276 } else {
277 vec![(None, None)]
278 };
279
280 if group_values.is_empty() {
281 bail!("Invalid separator supplied, no measurements.")
282 }
283
284 for (group_key, group_value) in group_values {
285 let group_measurements = filtered_measurements.clone().map(|ms| {
286 ms.filter(|m| {
287 group_key
288 .map(|gk| m.key_values.get(gk) == group_value)
289 .unwrap_or(true)
290 })
291 });
292
293 if let Some(reduction_func) = aggregate_by {
294 let trace_measurements = group_measurements
295 .clone()
296 .enumerate()
297 .flat_map(move |(i, ms)| {
298 ms.reduce_by(reduction_func)
299 .into_iter()
300 .map(move |m| (i, m))
301 })
302 .collect_vec();
303 plot.add_summarized_trace(trace_measurements, measurement_name, group_value);
304 } else {
305 let trace_measurements: Vec<_> = group_measurements
306 .clone()
307 .enumerate()
308 .flat_map(|(i, ms)| ms.map(move |m| (i, m)))
309 .collect();
310 plot.add_trace(trace_measurements, measurement_name, group_value);
311 }
312 }
313 }
314
315 if output == Path::new("-") {
319 match io::stdout().write_all(&plot.as_bytes()) {
320 Err(e) if e.kind() == ErrorKind::BrokenPipe => Ok(()),
321 res => res,
322 }?;
323 } else {
324 File::create(&output)?.write_all(&plot.as_bytes())?;
325 }
326
327 Ok(())
328}