hyperlane_performance/report/
fn.rs1use super::*;
2
3fn 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
37fn 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
72fn 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
96fn 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
118fn 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
148fn 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
213pub 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}