1pub mod budget;
21pub mod contract;
22pub mod executables;
23mod num;
24pub mod record;
25pub mod render;
26
27use {
28 budget::evaluate,
29 clap::{Parser, Subcommand},
30 contract::{BudgetError, Contract, contract_digest},
31 executables::{BenchExecutable, ExecutableManifest, manifest_from_cargo_messages, verify_pass},
32 record::{PerformanceRecord, assemble},
33 regex::Regex,
34 render::render_markdown,
35 serde::{Deserialize, Serialize},
36 std::{
37 collections::BTreeMap,
38 env,
39 fs::{self, File},
40 io::{self, BufRead, BufReader, Write},
41 path::{Path, PathBuf},
42 sync::LazyLock,
43 time::SystemTime,
44 },
45 thiserror::Error,
46 time::format_description::well_known::Rfc3339,
47};
48
49static BENCHER_REGEX: LazyLock<Result<Regex, regex::Error>> = LazyLock::new(|| {
50 Regex::new(
51 r"test\s+([\w\/\-\.]+)\s+\.\.\.\s+bench:\s+([\d,]+)\s+ns\/iter\s+\(\+\/-\s+([\d,]+)\)",
52 )
53});
54
55#[derive(Debug, Error)]
57#[non_exhaustive]
58pub enum ParseBenchesError {
59 #[error("directory '{0}' does not exist")]
61 DirectoryNotFound(PathBuf),
62
63 #[error("no benchmark output files (*.txt) found in '{0}'")]
65 NoTxtFiles(PathBuf),
66
67 #[error("parsed 0 benchmark metrics from '{0}'")]
69 NoMetricsParsed(PathBuf),
70
71 #[error("duplicate benchmark metric '{name}' found in '{file}'")]
73 DuplicateBenchmark {
74 name: String,
76 file: PathBuf,
78 },
79
80 #[error("failed to parse numeric benchmark value: {0}")]
82 InvalidNumber(String),
83
84 #[error("bencher line pattern: {0}")]
86 Pattern(#[source] regex::Error),
87
88 #[error("I/O error: {0}")]
90 Io(#[from] io::Error),
91
92 #[error("JSON error: {0}")]
94 Json(#[from] serde_json::Error),
95
96 #[error("{side} needs at least 3 repeated benchmark summaries, got {count}")]
98 TooFewSamples {
99 side: &'static str,
101 count: usize,
103 },
104
105 #[error("reference and candidate benchmark sets differ")]
107 BenchmarkSetMismatch,
108
109 #[error("invalid sample for benchmark '{0}'")]
111 InvalidSample(String),
112
113 #[error("benchmark summaries contain no metrics")]
115 EmptyBenchmarkSet,
116
117 #[error("minimum tolerance must be a finite non-negative ratio")]
119 InvalidTolerance,
120
121 #[error("cargo built no bench executable")]
123 NoBenchExecutables,
124
125 #[error("the cargo log names no bench executable that ran")]
127 NoExecutableRan,
128
129 #[error("executables were built from {manifest} but the pass is labelled {pass}")]
131 CommitMismatch {
132 manifest: String,
134 pass: String,
136 },
137
138 #[error("pass ran '{0}', which its side did not prebuild")]
140 UnrecordedExecutable(PathBuf),
141
142 #[error("executable '{0}' changed after it was prebuilt")]
144 ExecutableChanged(PathBuf),
145
146 #[error("{0} summaries do not record which executables ran")]
148 MissingExecutableIdentity(&'static str),
149
150 #[error("{0} passes ran different executables")]
152 ExecutableDrift(&'static str),
153
154 #[error(
156 "reference and candidate both ran '{0}'; they must build into separate target directories"
157 )]
158 SharedExecutable(PathBuf),
159
160 #[error(transparent)]
162 Budget(#[from] BudgetError),
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167pub struct BenchmarkMetric {
168 pub ns_per_iter: f64,
170 pub variance_ns: f64,
172}
173
174#[derive(Debug, Clone, PartialEq)]
177pub struct NamedMetric {
178 pub name: String,
180 pub metric: BenchmarkMetric,
182}
183
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
186pub struct BenchmarkSummary {
187 pub suite: String,
189 pub commit: String,
191 pub timestamp: String,
193 pub benchmarks: BTreeMap<String, BenchmarkMetric>,
195 #[serde(default)]
197 pub executables: Vec<BenchExecutable>,
198}
199
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202pub struct BenchmarkComparison {
203 pub reference_median_ns: f64,
205 pub candidate_median_ns: f64,
207 pub ratio: f64,
209 pub tolerance: f64,
211 pub passed: bool,
214}
215
216#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
218pub struct BenchmarkVerdict {
219 pub reference_commits: Vec<String>,
221 pub candidate_commits: Vec<String>,
223 pub reference_executables: Vec<BenchExecutable>,
225 pub candidate_executables: Vec<BenchExecutable>,
227 pub benchmarks: BTreeMap<String, BenchmarkComparison>,
229 pub passed: bool,
231}
232
233#[must_use]
236pub fn median(mut values: Vec<f64>) -> f64 {
237 values.sort_by(f64::total_cmp);
238 let middle = values.len() / 2;
239 let lower = middle.checked_sub(1).and_then(|lower| values.get(lower));
240 match (lower, values.get(middle)) {
241 (Some(&lower), Some(&upper)) if values.len().is_multiple_of(2) => {
242 f64::midpoint(lower, upper)
243 }
244 (_, Some(&upper)) => upper,
245 (_, None) => f64::NAN,
246 }
247}
248
249#[must_use]
252pub fn relative_mad(values: &[f64], center: f64) -> f64 {
253 median(values.iter().map(|value| (value - center).abs()).collect()) / center
254}
255
256#[must_use]
259pub fn calibrated_tolerance(
260 floor: f64,
261 multiplier: f64,
262 reference_spread: f64,
263 candidate_spread: f64,
264) -> f64 {
265 floor.max(multiplier * reference_spread.max(candidate_spread))
266}
267
268fn side_executables(
270 side: &'static str,
271 summaries: &[BenchmarkSummary],
272) -> Result<Vec<BenchExecutable>, ParseBenchesError> {
273 let Some(first) = summaries.first().map(|summary| &summary.executables) else {
274 return Err(ParseBenchesError::TooFewSamples { side, count: 0 });
275 };
276 if first.is_empty() {
277 return Err(ParseBenchesError::MissingExecutableIdentity(side));
278 }
279 if summaries
280 .iter()
281 .any(|summary| summary.executables != *first)
282 {
283 return Err(ParseBenchesError::ExecutableDrift(side));
284 }
285 Ok(first.clone())
286}
287
288pub fn compare_summaries(
309 reference: &[BenchmarkSummary],
310 candidate: &[BenchmarkSummary],
311 minimum_tolerance: f64,
312) -> Result<BenchmarkVerdict, ParseBenchesError> {
313 if !minimum_tolerance.is_finite() || minimum_tolerance < 0.0 {
314 return Err(ParseBenchesError::InvalidTolerance);
315 }
316 for (side, summaries) in [("reference", reference), ("candidate", candidate)] {
317 if summaries.len() < 3 {
318 return Err(ParseBenchesError::TooFewSamples {
319 side,
320 count: summaries.len(),
321 });
322 }
323 }
324 let reference_names: Vec<_> = reference
325 .first()
326 .ok_or(ParseBenchesError::TooFewSamples {
327 side: "reference",
328 count: 0,
329 })?
330 .benchmarks
331 .keys()
332 .collect();
333 if reference_names.is_empty() {
334 return Err(ParseBenchesError::EmptyBenchmarkSet);
335 }
336 if reference.iter().any(|summary| {
337 summary
338 .benchmarks
339 .keys()
340 .ne(reference_names.iter().copied())
341 }) || candidate.iter().any(|summary| {
342 summary
343 .benchmarks
344 .keys()
345 .ne(reference_names.iter().copied())
346 }) {
347 return Err(ParseBenchesError::BenchmarkSetMismatch);
348 }
349 let reference_executables = side_executables("reference", reference)?;
350 let candidate_executables = side_executables("candidate", candidate)?;
351 if let Some(shared) = reference_executables.iter().find(|reference| {
352 candidate_executables
353 .iter()
354 .any(|candidate| candidate.path == reference.path)
355 }) {
356 return Err(ParseBenchesError::SharedExecutable(shared.path.clone()));
357 }
358
359 let mut benchmarks = BTreeMap::new();
360 for name in reference_names {
361 let side_values = |summaries: &[BenchmarkSummary]| {
363 summaries
364 .iter()
365 .map(|summary| {
366 summary
367 .benchmarks
368 .get(name)
369 .map(|metric| metric.ns_per_iter)
370 })
371 .collect::<Option<Vec<_>>>()
372 .ok_or(ParseBenchesError::BenchmarkSetMismatch)
373 };
374 let reference_values = side_values(reference)?;
375 let candidate_values = side_values(candidate)?;
376 let mut values = reference_values.iter().chain(&candidate_values);
377 if values.clone().all(|value| *value == 0.0) {
378 benchmarks.insert(name.clone(), BenchmarkComparison {
379 reference_median_ns: 0.0,
380 candidate_median_ns: 0.0,
381 ratio: 1.0,
382 tolerance: minimum_tolerance,
383 passed: true,
384 });
385 continue;
386 }
387 if values.any(|value| !value.is_finite() || *value <= 0.0) {
388 return Err(ParseBenchesError::InvalidSample(name.clone()));
389 }
390 let reference_median_ns = median(reference_values.clone());
391 let candidate_median_ns = median(candidate_values.clone());
392 let tolerance = calibrated_tolerance(
393 minimum_tolerance,
394 3.0,
395 relative_mad(&reference_values, reference_median_ns),
396 relative_mad(&candidate_values, candidate_median_ns),
397 );
398 let ratio = candidate_median_ns / reference_median_ns;
399 let slowest_reference = reference_values.iter().copied().fold(f64::MIN, f64::max);
400 let fastest_candidate = candidate_values.iter().copied().fold(f64::MAX, f64::min);
401 benchmarks.insert(name.clone(), BenchmarkComparison {
402 reference_median_ns,
403 candidate_median_ns,
404 ratio,
405 tolerance,
406 passed: ratio <= 1.0 + tolerance || fastest_candidate <= slowest_reference,
407 });
408 }
409 let passed = benchmarks.values().all(|benchmark| benchmark.passed);
410 Ok(BenchmarkVerdict {
411 reference_commits: reference
412 .iter()
413 .map(|summary| summary.commit.clone())
414 .collect(),
415 candidate_commits: candidate
416 .iter()
417 .map(|summary| summary.commit.clone())
418 .collect(),
419 reference_executables,
420 candidate_executables,
421 benchmarks,
422 passed,
423 })
424}
425
426pub fn read_summaries(
433 directory: &Path,
434 side: &'static str,
435) -> Result<Vec<BenchmarkSummary>, ParseBenchesError> {
436 if !directory.is_dir() {
437 return Err(ParseBenchesError::DirectoryNotFound(
438 directory.to_path_buf(),
439 ));
440 }
441 let mut paths = Vec::new();
442 for entry in fs::read_dir(directory)? {
443 let path = entry?.path();
444 if path.extension().and_then(|extension| extension.to_str()) == Some("json") {
445 paths.push(path);
446 }
447 }
448 paths.sort();
449 if paths.len() < 3 {
450 return Err(ParseBenchesError::TooFewSamples {
451 side,
452 count: paths.len(),
453 });
454 }
455 paths
456 .into_iter()
457 .map(|path| Ok(serde_json::from_reader(File::open(path)?)?))
458 .collect()
459}
460
461#[derive(Parser, Debug, Clone)]
463#[command(
464 name = "krabka-parse-benches",
465 about = "Parses Criterion benchmark output in bencher format into a structured JSON summary"
466)]
467pub struct Args {
468 #[arg(long, default_value = "bench-results")]
470 pub results_dir: PathBuf,
471
472 #[arg(long)]
474 pub output: Option<PathBuf>,
475
476 #[arg(long, default_value = "krabka-broker")]
478 pub suite: String,
479
480 #[arg(long)]
482 pub commit: Option<String>,
483
484 #[arg(long, requires = "candidate_dir")]
486 pub reference_dir: Option<PathBuf>,
487
488 #[arg(long, requires = "reference_dir")]
490 pub candidate_dir: Option<PathBuf>,
491
492 #[arg(long, requires = "cargo_log")]
496 pub executables: Option<PathBuf>,
497
498 #[arg(long, requires = "executables")]
501 pub cargo_log: Option<PathBuf>,
502
503 #[arg(long, requires = "executables")]
506 pub source_dir: Option<PathBuf>,
507
508 #[arg(long, default_value_t = 3.0)]
510 pub minimum_tolerance_percent: f64,
511
512 #[command(subcommand)]
514 pub command: Option<Command>,
515}
516
517#[derive(Subcommand, Debug, Clone)]
519pub enum Command {
520 Executables {
523 cargo_messages: PathBuf,
525 #[arg(long)]
527 commit: String,
528 #[arg(long)]
530 output: PathBuf,
531 },
532 CheckContract {
534 contract: PathBuf,
536 },
537 Assemble {
539 artifact_dir: PathBuf,
541 #[arg(long)]
543 output: PathBuf,
544 },
545 Evaluate {
547 record: PathBuf,
549 #[arg(long)]
551 contract: PathBuf,
552 #[arg(long)]
554 output: PathBuf,
555 #[arg(long)]
557 markdown: Option<PathBuf>,
558 },
559}
560
561pub fn parse_bencher_line(line: &str) -> Result<Option<NamedMetric>, ParseBenchesError> {
571 let regex = BENCHER_REGEX
572 .as_ref()
573 .map_err(|error| ParseBenchesError::Pattern(error.clone()))?;
574 let Some(caps) = regex.captures(line) else {
575 return Ok(None);
576 };
577
578 let name = caps[1].to_string();
579 let ns_raw = caps[2].replace(',', "");
580 let variance_raw = caps[3].replace(',', "");
581
582 let ns_per_iter: f64 = ns_raw
583 .parse()
584 .map_err(|_| ParseBenchesError::InvalidNumber(caps[2].to_string()))?;
585 let variance_ns: f64 = variance_raw
586 .parse()
587 .map_err(|_| ParseBenchesError::InvalidNumber(caps[3].to_string()))?;
588
589 Ok(Some(NamedMetric {
590 name,
591 metric: BenchmarkMetric {
592 ns_per_iter,
593 variance_ns,
594 },
595 }))
596}
597
598pub fn parse_bencher_file(
605 path: &Path,
606 benchmarks: &mut BTreeMap<String, BenchmarkMetric>,
607) -> Result<(), ParseBenchesError> {
608 let file = File::open(path)?;
609 let reader = BufReader::new(file);
610
611 for line in reader.lines() {
612 let line = line?;
613 if let Some(NamedMetric { name, metric }) = parse_bencher_line(&line)?
614 && benchmarks.insert(name.clone(), metric).is_some()
615 {
616 return Err(ParseBenchesError::DuplicateBenchmark {
617 name,
618 file: path.to_path_buf(),
619 });
620 }
621 }
622
623 Ok(())
624}
625
626pub fn parse_benchmark_dir(
636 results_dir: &Path,
637) -> Result<BTreeMap<String, BenchmarkMetric>, ParseBenchesError> {
638 if !results_dir.exists() || !results_dir.is_dir() {
639 return Err(ParseBenchesError::DirectoryNotFound(
640 results_dir.to_path_buf(),
641 ));
642 }
643
644 let mut txt_files = Vec::new();
645 for entry in fs::read_dir(results_dir)? {
646 let entry = entry?;
647 let path = entry.path();
648 if path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("txt") {
649 txt_files.push(path);
650 }
651 }
652
653 if txt_files.is_empty() {
654 return Err(ParseBenchesError::NoTxtFiles(results_dir.to_path_buf()));
655 }
656
657 txt_files.sort();
658
659 let mut benchmarks = BTreeMap::new();
660 for file_path in &txt_files {
661 parse_bencher_file(file_path, &mut benchmarks)?;
662 }
663
664 if benchmarks.is_empty() {
665 return Err(ParseBenchesError::NoMetricsParsed(
666 results_dir.to_path_buf(),
667 ));
668 }
669
670 Ok(benchmarks)
671}
672
673#[must_use]
675pub fn format_rfc3339_utc(time: SystemTime) -> String {
676 let offset_time = time::OffsetDateTime::from(time)
677 .replace_nanosecond(0)
678 .unwrap_or_else(|_| time::OffsetDateTime::from(time));
679 offset_time
680 .format(&Rfc3339)
681 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned())
682}
683
684#[must_use]
686pub fn resolve_commit_sha(commit_arg: Option<&str>, env_sha: Option<&str>) -> String {
687 let raw = commit_arg
688 .and_then(|s| {
689 let t = s.trim();
690 (!t.is_empty()).then_some(t)
691 })
692 .or_else(|| {
693 let s = env_sha?;
694 let t = s.trim();
695 (!t.is_empty()).then_some(t)
696 });
697
698 let Some(trimmed) = raw else {
699 return "unknown".to_owned();
700 };
701
702 trimmed.to_owned()
703}
704
705pub fn generate_summary(args: &Args) -> Result<BenchmarkSummary, ParseBenchesError> {
711 let benchmarks = parse_benchmark_dir(&args.results_dir)?;
712 let env_sha = env::var("GITHUB_SHA").ok();
713 let commit = resolve_commit_sha(args.commit.as_deref(), env_sha.as_deref());
714 let timestamp = format_rfc3339_utc(SystemTime::now());
715 let executables = match (&args.executables, &args.cargo_log) {
716 (Some(manifest), Some(cargo_log)) => {
717 let manifest: ExecutableManifest = serde_json::from_reader(File::open(manifest)?)?;
718 let source_dir = args.source_dir.clone().unwrap_or_default();
719 verify_pass(
720 &manifest,
721 &fs::read_to_string(cargo_log)?,
722 &source_dir,
723 &commit,
724 )?
725 }
726 _ => Vec::new(),
727 };
728
729 Ok(BenchmarkSummary {
730 suite: args.suite.clone(),
731 commit,
732 timestamp,
733 benchmarks,
734 executables,
735 })
736}
737
738pub fn run_from_args(args: &Args) -> Result<BenchmarkSummary, ParseBenchesError> {
744 let summary = generate_summary(args)?;
745
746 let out_path = args
747 .output
748 .clone()
749 .unwrap_or_else(|| args.results_dir.join("broker-benchmarks.json"));
750
751 if let Some(parent) = out_path.parent() {
752 fs::create_dir_all(parent)?;
753 }
754
755 let json_bytes = serde_json::to_vec_pretty(&summary)?;
756 let mut file = File::create(&out_path)?;
757 file.write_all(&json_bytes)?;
758 file.write_all(b"\n")?;
759
760 Ok(summary)
761}
762
763fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), BudgetError> {
764 if let Some(parent) = path.parent() {
765 fs::create_dir_all(parent)?;
766 }
767 let mut bytes = serde_json::to_vec_pretty(value)?;
768 bytes.push(b'\n');
769 fs::write(path, bytes)?;
770 Ok(())
771}
772
773pub fn run_command(command: &Command) -> Result<(bool, String), ParseBenchesError> {
784 match command {
785 Command::Executables {
786 cargo_messages,
787 commit,
788 output,
789 } => {
790 let manifest =
791 manifest_from_cargo_messages(&fs::read_to_string(cargo_messages)?, commit)?;
792 write_json(output, &manifest)?;
793 Ok((
794 true,
795 format!(
796 "{} bench executables of {commit} recorded in {}",
797 manifest.executables.len(),
798 output.display()
799 ),
800 ))
801 }
802 Command::CheckContract { contract } => {
803 let bytes = fs::read(contract)?;
804 let parsed = Contract::from_json(&bytes)?;
805 Ok((
806 true,
807 format!(
808 "{}: {} cells, {} rounds, sha256 {}",
809 parsed.name,
810 parsed.cells.len(),
811 parsed.rounds,
812 contract_digest(&bytes)
813 ),
814 ))
815 }
816 Command::Assemble {
817 artifact_dir,
818 output,
819 } => {
820 let record = assemble(artifact_dir)?;
821 write_json(output, &record)?;
822 Ok((
823 true,
824 format!(
825 "{} trials written to {}",
826 record.trials.len(),
827 output.display()
828 ),
829 ))
830 }
831 Command::Evaluate {
832 record,
833 contract,
834 output,
835 markdown,
836 } => {
837 let contract_bytes = fs::read(contract)?;
838 let parsed = Contract::from_json(&contract_bytes)?;
839 let record = PerformanceRecord::from_json(&fs::read(record)?)?;
840 let verdict = evaluate(&parsed, &contract_bytes, &record)?;
841 write_json(output, &verdict)?;
842 if let Some(markdown) = markdown {
843 if let Some(parent) = markdown.parent() {
844 fs::create_dir_all(parent)?;
845 }
846 fs::write(markdown, render_markdown(&parsed, &record, &verdict))?;
847 }
848 let summary = if verdict.passed {
849 format!("performance budget passed: {}", output.display())
850 } else {
851 format!(
852 "performance budget rejected ({} reasons): {}",
853 verdict.rejections.len(),
854 output.display()
855 )
856 };
857 Ok((verdict.passed, summary))
858 }
859 }
860}