1use crate::{RepoConfig, symbolic::Sidecar};
2use eyre::Result;
3use serde::{Deserialize, Serialize};
4use std::{
5 collections::{BTreeMap, HashMap},
6 path::Path,
7 process::Command,
8 thread,
9};
10
11#[derive(Debug, Deserialize, Serialize)]
13pub struct HyperfineResult {
14 #[serde(skip_serializing)]
15 pub command: String,
16 pub mean: f64,
17 pub stddev: Option<f64>,
18 pub median: f64,
19 pub user: f64,
20 pub system: f64,
21 pub min: f64,
22 pub max: f64,
23 pub times: Vec<f64>,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub exit_codes: Option<Vec<i32>>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub parameters: Option<HashMap<String, serde_json::Value>>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub symbolic: Option<SymbolicBenchmarkSummary>,
30 #[serde(skip)]
31 pub symbolic_sidecar: Option<Sidecar>,
32}
33
34#[derive(Clone, Debug, Default, Deserialize, Serialize)]
36pub struct SymbolicBenchmarkSummary {
37 pub tests: usize,
38 pub passed: usize,
39 pub failed: usize,
40 pub incomplete: usize,
41 pub paths: u64,
42 pub solver_queries: u64,
43 pub smt_queries: u64,
44 pub sat_queries: u64,
45 pub model_queries: u64,
46 pub sat_cache_hits: u64,
47 pub model_cache_hits: u64,
48 pub heuristic_witnesses: u64,
49 pub solver_time_ms: u64,
50 pub smt_input_bytes: u64,
51 pub smt_max_query_bytes: u64,
52 pub smt_build_time_ms: u64,
53 pub smt_max_query_time_ms: u64,
54}
55
56#[derive(Debug, Deserialize, Serialize)]
58pub struct HyperfineOutput {
59 pub results: Vec<HyperfineResult>,
60}
61
62#[derive(Debug, Default)]
64pub struct BenchmarkResults {
65 pub data: HashMap<String, HashMap<String, HashMap<String, HyperfineResult>>>,
67 pub baseline_version: Option<String>,
69 pub version_details: HashMap<String, String>,
71}
72
73#[derive(Debug, Serialize)]
75pub struct CommonBenchmarkResult {
76 pub schema_version: u8,
77 pub repo: String,
78 pub commit: String,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub pr: Option<u64>,
81 pub runner: RunnerMetadata,
82 pub benchmarks: Vec<CommonBenchmark>,
83}
84
85#[derive(Debug, Serialize)]
86pub struct RunnerMetadata {
87 pub os: &'static str,
88 pub arch: &'static str,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub image: Option<String>,
91 pub logical_cpus: usize,
92}
93
94impl Default for RunnerMetadata {
95 fn default() -> Self {
96 Self {
97 os: std::env::consts::OS,
98 arch: std::env::consts::ARCH,
99 image: std::env::var("ImageOS").ok(),
100 logical_cpus: thread::available_parallelism().map_or(1, |n| n.get()),
101 }
102 }
103}
104
105#[derive(Debug, Serialize)]
106pub struct CommonBenchmark {
107 pub name: String,
108 pub wall_time: Metric,
109 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
110 pub counters: BTreeMap<String, Metric>,
111 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
112 pub solver: BTreeMap<String, Metric>,
113}
114
115#[derive(Debug, Serialize)]
116pub struct Metric {
117 pub value: f64,
118 pub unit: &'static str,
119 pub statistic: &'static str,
120}
121
122impl BenchmarkResults {
123 pub fn new() -> Self {
124 Self::default()
125 }
126
127 pub fn set_baseline_version(&mut self, version: String) {
128 self.baseline_version = Some(version);
129 }
130
131 pub fn add_result(
132 &mut self,
133 benchmark: &str,
134 version: &str,
135 repo: &str,
136 result: HyperfineResult,
137 ) {
138 self.data
139 .entry(benchmark.to_string())
140 .or_default()
141 .entry(version.to_string())
142 .or_default()
143 .insert(repo.to_string(), result);
144 }
145
146 pub fn add_version_details(&mut self, version: &str, details: String) {
147 self.version_details.insert(version.to_string(), details);
148 }
149
150 pub fn generate_json_summary(&self, versions: &[String]) -> HashMap<String, &HyperfineResult> {
156 let mut summary = HashMap::new();
157 for (benchmark_name, version_data) in &self.data {
158 for version in versions {
159 if let Some(repo_data) = version_data.get(version) {
160 for (repo_name, result) in repo_data {
161 let key = format!("{benchmark_name}/{repo_name}");
162 summary.insert(key, result);
163 }
164 }
165 }
166 }
167 summary
168 }
169
170 pub fn generate_common_result(
172 &self,
173 version: &str,
174 repo: String,
175 commit: String,
176 pr: Option<u64>,
177 ) -> CommonBenchmarkResult {
178 let mut benchmarks = Vec::new();
179 for (benchmark_name, version_data) in &self.data {
180 if let Some(repo_data) = version_data.get(version) {
181 for (repo_name, result) in repo_data {
182 let mut counters = BTreeMap::new();
183 let mut solver = BTreeMap::new();
184 if let Some(symbolic) = &result.symbolic {
185 for (name, value) in [
186 ("tests", symbolic.tests as f64),
187 ("passed", symbolic.passed as f64),
188 ("failed", symbolic.failed as f64),
189 ("incomplete", symbolic.incomplete as f64),
190 ("symbolic_paths", symbolic.paths as f64),
191 ] {
192 counters.insert(name.to_string(), Metric::total(value, "count"));
193 }
194 for (name, value, unit) in [
195 ("queries", symbolic.solver_queries as f64, "count"),
196 ("smt_queries", symbolic.smt_queries as f64, "count"),
197 ("sat_queries", symbolic.sat_queries as f64, "count"),
198 ("model_queries", symbolic.model_queries as f64, "count"),
199 ("sat_cache_hits", symbolic.sat_cache_hits as f64, "count"),
200 ("model_cache_hits", symbolic.model_cache_hits as f64, "count"),
201 ("heuristic_witnesses", symbolic.heuristic_witnesses as f64, "count"),
202 ("time", symbolic.solver_time_ms as f64 / 1000.0, "second"),
203 ] {
204 if value != 0.0 {
205 solver.insert(name.to_string(), Metric::total(value, unit));
206 }
207 }
208 if let Some(metrics) = result
209 .symbolic_sidecar
210 .as_ref()
211 .and_then(|sidecar| {
212 sidecar
213 .samples
214 .iter()
215 .find(|sample| sample.wall_time_seconds == result.median)
216 })
217 .map(|sample| &sample.run.metrics)
218 {
219 for (name, value, unit, divisor) in [
220 ("smt_input_size", metrics.smt_input_bytes, "byte", 1.0),
221 ("smt_build_time", metrics.smt_build_time_ms, "second", 1000.0),
222 ] {
223 if let Some(value) = value {
224 solver.insert(
225 name.to_string(),
226 Metric::total(value as f64 / divisor, unit),
227 );
228 }
229 }
230 for (name, value, unit, divisor) in [
231 ("smt_max_query_size", metrics.smt_max_query_bytes, "byte", 1.0),
232 (
233 "smt_max_query_time",
234 metrics.smt_max_query_time_ms,
235 "second",
236 1000.0,
237 ),
238 ] {
239 if let Some(value) = value {
240 solver.insert(
241 name.to_string(),
242 Metric::max(value as f64 / divisor, unit),
243 );
244 }
245 }
246 }
247 }
248 benchmarks.push(CommonBenchmark {
249 name: format!("{benchmark_name}/{repo_name}"),
250 wall_time: Metric::mean(result.mean, "second"),
251 counters,
252 solver,
253 });
254 }
255 }
256 }
257 benchmarks.sort_unstable_by(|a, b| a.name.cmp(&b.name));
258
259 CommonBenchmarkResult {
260 schema_version: 1,
261 repo,
262 commit,
263 pr,
264 runner: RunnerMetadata::default(),
265 benchmarks,
266 }
267 }
268
269 pub fn generate_markdown(&self, versions: &[String], repos: &[RepoConfig]) -> String {
270 let mut output = String::new();
271
272 output.push_str("# Foundry Benchmark Results\n\n");
274 output.push_str(&format!(
275 "**Date**: {}\n\n",
276 chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
277 ));
278
279 output.push_str("## Summary\n\n");
281 let mut repos_with_results = std::collections::HashSet::new();
283 for version_data in self.data.values() {
284 for repo_data in version_data.values() {
285 for repo_name in repo_data.keys() {
286 repos_with_results.insert(repo_name.clone());
287 }
288 }
289 }
290 output.push_str(&format!(
291 "Benchmarked {} Foundry versions across {} repositories.\n\n",
292 versions.len(),
293 repos_with_results.len()
294 ));
295
296 output.push_str("### Repositories Tested\n\n");
298 for (i, repo) in repos.iter().enumerate() {
299 output.push_str(&format!(
300 "{}. [{}/{}](https://github.com/{}/{})\n",
301 i + 1,
302 repo.org,
303 repo.repo,
304 repo.org,
305 repo.repo
306 ));
307 }
308 output.push('\n');
309
310 output.push_str("### Foundry Versions\n\n");
312 for version in versions {
313 if let Some(details) = self.version_details.get(version) {
314 output.push_str(&format!("- **{version}**: {}\n", details.trim()));
315 } else {
316 output.push_str(&format!("- {version}\n"));
317 }
318 }
319 output.push('\n');
320
321 for (benchmark_name, version_data) in &self.data {
323 output.push_str(&self.generate_benchmark_table(
324 benchmark_name,
325 version_data,
326 versions,
327 repos,
328 ));
329 }
330
331 output.push_str("## System Information\n\n");
333 output.push_str(&format!("- **OS**: {}\n", std::env::consts::OS));
334 output.push_str(&format!(
335 "- **CPU**: {}\n",
336 thread::available_parallelism().map_or(1, |n| n.get())
337 ));
338 output.push_str(&format!(
339 "- **Rustc**: {}\n",
340 get_rustc_version().unwrap_or_else(|_| "unknown".to_string())
341 ));
342
343 output
344 }
345
346 fn generate_benchmark_table(
350 &self,
351 benchmark_name: &str,
352 version_data: &HashMap<String, HashMap<String, HyperfineResult>>,
353 versions: &[String],
354 repos: &[RepoConfig],
355 ) -> String {
356 let mut output = String::new();
357
358 output.push_str(&format!("## {}\n\n", format_benchmark_name(benchmark_name)));
360
361 output.push_str("| Repository |");
363 for version in versions {
364 output.push_str(&format!(" {version} |"));
365 }
366 output.push('\n');
367
368 output.push_str("|------------|");
370 for _ in versions {
371 output.push_str("----------|");
372 }
373 output.push('\n');
374
375 output.push_str(&generate_table_rows(version_data, versions, repos));
377 output.push('\n');
378
379 output
380 }
381}
382
383impl Metric {
384 const fn mean(value: f64, unit: &'static str) -> Self {
385 Self { value, unit, statistic: "mean" }
386 }
387
388 const fn total(value: f64, unit: &'static str) -> Self {
389 Self { value, unit, statistic: "total" }
390 }
391
392 const fn max(value: f64, unit: &'static str) -> Self {
393 Self { value, unit, statistic: "max" }
394 }
395}
396
397fn generate_table_rows(
402 version_data: &HashMap<String, HashMap<String, HyperfineResult>>,
403 versions: &[String],
404 repos: &[RepoConfig],
405) -> String {
406 let mut output = String::new();
407
408 for repo in repos {
409 output.push_str(&format!("| {} |", repo.name));
410
411 for version in versions {
412 let cell_content = get_benchmark_cell_content(version_data, version, &repo.name);
413 output.push_str(&format!(" {cell_content} |"));
414 }
415
416 output.push('\n');
417 }
418
419 output
420}
421
422fn get_benchmark_cell_content(
429 version_data: &HashMap<String, HashMap<String, HyperfineResult>>,
430 version: &str,
431 repo_name: &str,
432) -> String {
433 if let Some(repo_data) = version_data.get(version) &&
435 let Some(result) = repo_data.get(repo_name)
437 {
438 if let Some(symbolic) = &result.symbolic {
439 let status = format_symbolic_status(symbolic);
440 let has_smt_size_metrics = symbolic.smt_queries == 0
441 || symbolic.smt_input_bytes != 0
442 || symbolic.smt_max_query_bytes != 0;
443 let smt_input_bytes = if has_smt_size_metrics {
444 format_bytes(symbolic.smt_input_bytes)
445 } else {
446 "n/a".to_string()
447 };
448 let smt_max_query_bytes = if has_smt_size_metrics {
449 format_bytes(symbolic.smt_max_query_bytes)
450 } else {
451 "n/a".to_string()
452 };
453 return format!(
454 "{}<br/>{}, solver {}ms<br/>SMT: {} queries, {} total, {} max",
455 format_duration_seconds(result.mean),
456 status,
457 symbolic.solver_time_ms,
458 symbolic.smt_queries,
459 smt_input_bytes,
460 smt_max_query_bytes
461 );
462 }
463 return format_duration_seconds(result.mean);
464 }
465
466 "N/A".to_string()
467}
468
469pub fn versioned_summary_filename(json_output: &Path, version: &str) -> String {
472 let stem =
473 json_output.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
474 let stem = if stem.is_empty() { "summary" } else { &stem };
475 match json_output.extension() {
476 Some(ext) => format!("{stem}-{version}.{}", ext.to_string_lossy()),
477 None => format!("{stem}-{version}"),
478 }
479}
480
481pub fn format_benchmark_name(name: &str) -> String {
482 match name {
483 "forge_test" => "Forge Test",
484 "forge_build_no_cache" => "Forge Build (No Cache)",
485 "forge_build_with_cache" => "Forge Build (With Cache)",
486 "forge_fuzz_test" => "Forge Fuzz Test",
487 "forge_coverage" => "Forge Coverage",
488 "forge_isolate_test" => "Forge Test (Isolated)",
489 "forge_symbolic_test" => "Forge Symbolic Test",
490 _ => name,
491 }
492 .to_string()
493}
494
495fn format_symbolic_status(symbolic: &SymbolicBenchmarkSummary) -> String {
496 let mut parts = Vec::new();
497 if symbolic.passed != 0 {
498 parts.push(format!("{} pass", symbolic.passed));
499 }
500 if symbolic.incomplete != 0 {
501 parts.push(format!("{} incomplete", symbolic.incomplete));
502 }
503 if symbolic.failed != 0 {
504 parts.push(format!("{} fail", symbolic.failed));
505 }
506 if parts.is_empty() { format!("{} tests", symbolic.tests) } else { parts.join(", ") }
507}
508
509fn format_bytes(bytes: u64) -> String {
510 const KIB: f64 = 1024.0;
511 const MIB: f64 = KIB * 1024.0;
512 let bytes = bytes as f64;
513 if bytes < KIB {
514 format!("{bytes:.0} B")
515 } else if bytes < MIB {
516 format!("{:.1} KiB", bytes / KIB)
517 } else {
518 format!("{:.1} MiB", bytes / MIB)
519 }
520}
521
522pub fn format_duration_seconds(seconds: f64) -> String {
523 if seconds < 0.001 {
524 format!("{:.2} ms", seconds * 1000.0)
525 } else if seconds < 1.0 {
526 format!("{seconds:.3} s")
527 } else if seconds < 60.0 {
528 format!("{seconds:.2} s")
529 } else {
530 let minutes = (seconds / 60.0).floor();
531 let remaining_seconds = seconds % 60.0;
532 format!("{minutes:.0}m {remaining_seconds:.1}s")
533 }
534}
535
536pub fn get_rustc_version() -> Result<String> {
537 let output = Command::new("rustc").arg("--version").output()?;
538
539 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545
546 fn hyperfine_result(command: &str, mean: f64) -> HyperfineResult {
547 HyperfineResult {
548 command: command.to_string(),
549 mean,
550 stddev: Some(0.02),
551 median: mean,
552 user: mean * 0.9,
553 system: mean * 0.1,
554 min: mean - 0.05,
555 max: mean + 0.05,
556 times: vec![mean - 0.01, mean, mean + 0.01],
557 exit_codes: None,
558 parameters: None,
559 symbolic: None,
560 symbolic_sidecar: None,
561 }
562 }
563
564 #[test]
565 fn versioned_summary_filename_inserts_version() {
566 assert_eq!(
567 versioned_summary_filename(Path::new("forge_test_bench.json"), "local"),
568 "forge_test_bench-local.json"
569 );
570 assert_eq!(
571 versioned_summary_filename(Path::new("summary.json"), "stable"),
572 "summary-stable.json"
573 );
574 assert_eq!(versioned_summary_filename(Path::new("summary"), "nightly"), "summary-nightly");
575 }
576
577 #[test]
578 fn json_summaries_are_isolated_by_version() {
579 let mut results = BenchmarkResults::new();
580 results.add_result("forge_test", "master", "solady", hyperfine_result("forge test", 1.0));
581 results.add_result("forge_test", "local", "solady", hyperfine_result("forge test", 2.0));
582
583 let dir = tempfile::tempdir().unwrap();
584 let output = Path::new("summary.json");
585 for version in ["master", "local"] {
586 let summary = results.generate_json_summary(&[version.to_string()]);
587 let path = dir.path().join(versioned_summary_filename(output, version));
588 std::fs::write(path, serde_json::to_vec(&summary).unwrap()).unwrap();
589 }
590
591 let master: serde_json::Value =
592 serde_json::from_slice(&std::fs::read(dir.path().join("summary-master.json")).unwrap())
593 .unwrap();
594 let local: serde_json::Value =
595 serde_json::from_slice(&std::fs::read(dir.path().join("summary-local.json")).unwrap())
596 .unwrap();
597 assert_eq!(master["forge_test/solady"]["mean"], 1.0);
598 assert_eq!(local["forge_test/solady"]["mean"], 2.0);
599 }
600
601 #[test]
602 fn json_summary_includes_symbolic_counters() {
603 let mut results = BenchmarkResults::new();
604
605 let mut symbolic_run = hyperfine_result("forge test --symbolic --json", 1.2345);
607 symbolic_run.symbolic = Some(SymbolicBenchmarkSummary {
608 tests: 3,
609 passed: 3,
610 failed: 0,
611 incomplete: 0,
612 paths: 42,
613 solver_queries: 100,
614 smt_queries: 80,
615 sat_queries: 5,
616 model_queries: 2,
617 sat_cache_hits: 1,
618 model_cache_hits: 0,
619 heuristic_witnesses: 0,
620 solver_time_ms: 1234,
621 smt_input_bytes: 5000,
622 smt_max_query_bytes: 900,
623 smt_build_time_ms: 12,
624 smt_max_query_time_ms: 34,
625 });
626 results.add_result("forge_symbolic_test", "local", "solady", symbolic_run);
627
628 results.add_result("forge_test", "local", "solady", hyperfine_result("forge test", 2.5));
630
631 let summary = results.generate_json_summary(&["local".to_string()]);
632 let json = serde_json::to_string_pretty(&summary).unwrap();
633
634 assert!(!json.contains("\"command\""));
636 assert!(json.contains("\"times\""));
637
638 let symbolic = &summary["forge_symbolic_test/solady"];
640 assert_eq!(symbolic.mean, 1.2345);
641 let counters = symbolic.symbolic.as_ref().expect("symbolic counters present");
642 assert_eq!(counters.solver_queries, 100);
643 assert_eq!(counters.smt_input_bytes, 5000);
644 assert_eq!(counters.passed, 3);
645
646 let plain = &summary["forge_test/solady"];
648 assert_eq!(plain.mean, 2.5);
649 assert!(plain.symbolic.is_none());
650
651 let common = results.generate_common_result(
652 "local",
653 "foundry-rs/foundry".to_string(),
654 "0123456789abcdef".to_string(),
655 None,
656 );
657 let common = serde_json::to_value(common).unwrap();
658 assert_eq!(common["schema_version"], 1);
659 assert_eq!(common["repo"], "foundry-rs/foundry");
660 assert_eq!(common["benchmarks"][0]["name"], "forge_symbolic_test/solady");
661 assert_eq!(common["benchmarks"][0]["wall_time"]["unit"], "second");
662 assert_eq!(common["benchmarks"][0]["solver"]["queries"]["value"], 100.0);
663 assert!(common.get("pr").is_none());
664 }
665}