git_perf/
reporting.rs

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
16// TODO(kaihowl) find central place for the data structures
17use 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    // TODO(kaihowl) hack until we can auto_range 'reverse' the axis in plotly directly
44    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}
217// TODO(kaihowl) needs more fine grained output e2e tests
218pub 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        // TODO(kaihowl) express this and the audit-fn equivalent as subset relations
238        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    // TODO(kaihowl) fewer than the -n specified measurements appear in plot (old problem, even in
316    // python)
317
318    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}