Skip to main content

hyperlane_performance/report/
fn.rs

1use super::*;
2
3/// Reads raw measurement rows from a JSON Lines file.
4///
5/// # Arguments
6///
7/// - `&Path` - The raw measurement file.
8///
9/// # Returns
10///
11/// - `Result<Vec<MeasurementRow>, String>` - The rows, or the read or parse error.
12fn load_rows(path: &Path) -> Result<Vec<MeasurementRow>, String> {
13    let contents: String = match fs::read_to_string(path) {
14        Ok(contents) => contents,
15        Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
16        Err(error) => return Err(format!("could not read {}: {error}", path.display())),
17    };
18    let mut rows: Vec<MeasurementRow> = Vec::new();
19    for (index, line) in contents.lines().enumerate() {
20        let trimmed: &str = line.trim();
21        if trimmed.is_empty() {
22            continue;
23        }
24        match from_str::<MeasurementRow>(trimmed) {
25            Ok(row) => rows.push(row),
26            Err(error) => {
27                return Err(format!(
28                    "invalid measurement on line {}: {error}",
29                    index + 1
30                ));
31            }
32        }
33    }
34    Ok(rows)
35}
36
37/// Groups measurement rows by workload cell, variant and repeat.
38///
39/// # Arguments
40///
41/// - `Vec<MeasurementRow>` - The rows to group.
42///
43/// # Returns
44///
45/// - `Cells` - The grouped measurements.
46fn group_rows(rows: Vec<MeasurementRow>) -> Cells {
47    let mut grouped: Cells = BTreeMap::new();
48    for row in rows {
49        let MeasurementRow {
50            variant,
51            protocol,
52            mode,
53            connections,
54            repeat,
55            result,
56        } = row;
57        let cell: Cell = Cell {
58            protocol,
59            mode,
60            connections,
61        };
62        grouped
63            .entry(cell)
64            .or_default()
65            .entry(variant)
66            .or_default()
67            .insert(repeat, result);
68    }
69    grouped
70}
71
72/// Computes the median of a slice of floating-point values.
73///
74/// # Arguments
75///
76/// - `&[f64]` - The values.
77///
78/// # Returns
79///
80/// - `f64` - The median, or zero for an empty slice.
81fn median(values: &[f64]) -> f64 {
82    if values.is_empty() {
83        return 0.0;
84    }
85    let mut sorted: Vec<f64> = values.to_vec();
86    sorted.sort_by(|first: &f64, second: &f64| first.total_cmp(second));
87    let middle: usize = sorted.len() / 2;
88    if sorted.len() % 2 == 1 {
89        return sorted.get(middle).copied().unwrap_or(0.0);
90    }
91    let lower: f64 = sorted.get(middle - 1).copied().unwrap_or(0.0);
92    let upper: f64 = sorted.get(middle).copied().unwrap_or(0.0);
93    (lower + upper) / 2.0
94}
95
96/// Finds the smallest and largest values in a nonempty slice.
97///
98/// # Arguments
99///
100/// - `&[f64]` - The values.
101///
102/// # Returns
103///
104/// - `(f64, f64)` - The minimum and maximum, or zeroes for an empty slice.
105fn extrema(values: &[f64]) -> (f64, f64) {
106    let Some(first) = values.first() else {
107        return (0.0, 0.0);
108    };
109    let mut minimum: f64 = *first;
110    let mut maximum: f64 = *first;
111    for value in values.iter().copied() {
112        minimum = minimum.min(value);
113        maximum = maximum.max(value);
114    }
115    (minimum, maximum)
116}
117
118/// Formats one variant's absolute measurements.
119///
120/// # Arguments
121///
122/// - `&str` - The variant label.
123/// - `&Repeats` - The variant results keyed by repeat.
124///
125/// # Returns
126///
127/// - `String` - The formatted report line.
128fn describe(label: &str, results: &Repeats) -> String {
129    let mut rps: Vec<f64> = Vec::new();
130    let mut p50: Vec<f64> = Vec::new();
131    let mut p99: Vec<f64> = Vec::new();
132    let mut errors: u64 = 0;
133    for result in results.values() {
134        rps.push(result.rps);
135        p50.push(result.p50_us as f64);
136        p99.push(result.p99_us as f64);
137        errors += result.errors;
138    }
139    let (worst, best) = extrema(&rps);
140    format!(
141        "  {label:>10}  rps med={:>10.1}  best={best:>10.1}  worst={worst:>10.1}  p50={:>7.0}us  p99={:>8.0}us  errors={errors}\n",
142        median(&rps),
143        median(&p50),
144        median(&p99),
145    )
146}
147
148/// Computes the verdict for one paired workload cell.
149///
150/// # Arguments
151///
152/// - `&Repeats` - The baseline results keyed by repeat.
153/// - `&Repeats` - The candidate results keyed by repeat.
154/// - `&Repeats` - The control results keyed by repeat.
155///
156/// # Returns
157///
158/// - `(&'static str, String)` - The verdict and its reason.
159fn verdict_for(
160    baseline: &Repeats,
161    candidate: &Repeats,
162    control: &Repeats,
163) -> (&'static str, String) {
164    let mut deltas: Vec<f64> = Vec::new();
165    let mut shared: usize = 0;
166    for (repeat, base) in baseline {
167        if let Some(cand) = candidate.get(repeat) {
168            shared += 1;
169            if base.rps > 0.0 {
170                deltas.push((cand.rps - base.rps) / base.rps * 100.0);
171            }
172        }
173    }
174    if shared == 0 {
175        return (INCONCLUSIVE_VERDICT, NOT_PAIRED_REASON.to_owned());
176    }
177    if deltas.is_empty() {
178        return (INCONCLUSIVE_VERDICT, NO_USABLE_PAIR_REASON.to_owned());
179    }
180    let median_delta: f64 = median(&deltas);
181    let (minimum_delta, maximum_delta) = extrema(&deltas);
182    let spread: f64 = maximum_delta - minimum_delta;
183    let detail: String =
184        format!("median paired difference {median_delta:+.2}%, spread {spread:.1} points");
185    if control.len() > 1 {
186        let control_rps: Vec<f64> = control
187            .values()
188            .map(|result: &MeasurementResult| result.rps)
189            .collect();
190        let (minimum_control, maximum_control) = extrema(&control_rps);
191        if maximum_control > 0.0 {
192            let control_spread: f64 = (maximum_control - minimum_control) / maximum_control * 100.0;
193            if control_spread > CONTROL_TOLERANCE {
194                return (
195                    INCONCLUSIVE_VERDICT,
196                    format!("the control moved {control_spread:.1}%, the machine changed"),
197                );
198            }
199        }
200    }
201    if median_delta.abs() <= spread {
202        return (WITHIN_NOISE_VERDICT, detail);
203    }
204    if median_delta >= IMPROVEMENT_THRESHOLD {
205        return (IMPROVEMENT_VERDICT, detail);
206    }
207    if median_delta <= REGRESSION_THRESHOLD {
208        return (REGRESSION_VERDICT, detail);
209    }
210    (WITHIN_NOISE_VERDICT, detail)
211}
212
213/// Renders a raw JSON Lines measurement file as a performance report.
214///
215/// # Arguments
216///
217/// - `&Path` - The raw measurement file.
218///
219/// # Returns
220///
221/// - `Result<String, String>` - The complete report, or a broken measurement error.
222pub fn render_report(path: &Path) -> Result<String, String> {
223    let rows: Vec<MeasurementRow> = load_rows(path)?;
224    if rows.is_empty() {
225        return Err(NO_MEASUREMENTS_ERROR.to_owned());
226    }
227    let grouped: Cells = group_rows(rows);
228    let empty: Repeats = BTreeMap::new();
229    let mut report: String = String::new();
230    report.push_str(REPORT_HEADER);
231    for (cell, variants) in grouped {
232        report.push_str(&format!(
233            "\n{} / {} / concurrency {}\n",
234            cell.protocol, cell.mode, cell.connections
235        ));
236        for variant in [BASELINE_VARIANT, CANDIDATE_VARIANT, CONTROL_VARIANT] {
237            if let Some(results) = variants.get(variant)
238                && !results.is_empty()
239            {
240                report.push_str(&describe(variant, results));
241            }
242        }
243        let baseline: &Repeats = variants.get(BASELINE_VARIANT).unwrap_or(&empty);
244        let candidate: &Repeats = variants.get(CANDIDATE_VARIANT).unwrap_or(&empty);
245        let control: &Repeats = variants.get(CONTROL_VARIANT).unwrap_or(&empty);
246        if baseline.is_empty() || candidate.is_empty() {
247            report.push_str(&format!("  incomplete: {NOT_PAIRED_REASON}\n"));
248            continue;
249        }
250        let (verdict, reason) = verdict_for(baseline, candidate, control);
251        report.push_str(&format!("  => {verdict}: {reason}\n"));
252    }
253    report.push_str(REPORT_FOOTER);
254    Ok(report)
255}