Skip to main content

foundry_bench/
results.rs

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/// Hyperfine benchmark result
12#[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/// Aggregated symbolic counters for one benchmark run.
35#[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/// Hyperfine JSON output format
57#[derive(Debug, Deserialize, Serialize)]
58pub struct HyperfineOutput {
59    pub results: Vec<HyperfineResult>,
60}
61
62/// Aggregated benchmark results
63#[derive(Debug, Default)]
64pub struct BenchmarkResults {
65    /// Map of benchmark_name -> version -> repo -> result
66    pub data: HashMap<String, HashMap<String, HashMap<String, HyperfineResult>>>,
67    /// Track the baseline version for comparison
68    pub baseline_version: Option<String>,
69    /// Map of version name -> full version details
70    pub version_details: HashMap<String, String>,
71}
72
73/// Common benchmark result format.
74#[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    /// Generate a JSON summary mapping `"benchmark/repo"` to its full
151    /// [`HyperfineResult`], including wall-time statistics and, when available,
152    /// aggregated symbolic solver counters.
153    ///
154    /// Consumed by the nightly regression comparison script.
155    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    /// Generate one version's results in the common benchmark schema.
171    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        // Header
273        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        // Summary
280        output.push_str("## Summary\n\n");
281        // Count actual repos that have results
282        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        // Repositories tested
297        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        // Versions tested
311        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        // Results for each benchmark type
322        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        // System info
332        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    /// Generate a complete markdown table for a single benchmark type
347    ///
348    /// This includes the section header, table header, separator, and all rows
349    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        // Section header
359        output.push_str(&format!("## {}\n\n", format_benchmark_name(benchmark_name)));
360
361        // Create table header
362        output.push_str("| Repository |");
363        for version in versions {
364            output.push_str(&format!(" {version} |"));
365        }
366        output.push('\n');
367
368        // Table separator
369        output.push_str("|------------|");
370        for _ in versions {
371            output.push_str("----------|");
372        }
373        output.push('\n');
374
375        // Table rows
376        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
397/// Generate table rows for benchmark results
398///
399/// This function creates the markdown table rows for each repository,
400/// showing the benchmark results for each version.
401fn 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
422/// Get the content for a single benchmark table cell
423///
424/// Returns the formatted duration or "N/A" if no data is available.
425/// The nested if-let statements handle the following cases:
426/// 1. Check if version data exists
427/// 2. Check if repository data exists for this version
428fn get_benchmark_cell_content(
429    version_data: &HashMap<String, HashMap<String, HyperfineResult>>,
430    version: &str,
431    repo_name: &str,
432) -> String {
433    // Check if we have data for this version
434    if let Some(repo_data) = version_data.get(version) &&
435    // Check if we have data for this repository
436        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
469/// Insert `version` before the extension of the `--json-output` filename, e.g.
470/// `summary.json` + `local` -> `summary-local.json`.
471pub 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        // A symbolic run carries the aggregated solver counters.
606        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        // A plain run has no symbolic block, so `symbolic` is skipped.
629        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        // The serialized entry omits `command` but keeps every timing field.
635        assert!(!json.contains("\"command\""));
636        assert!(json.contains("\"times\""));
637
638        // Symbolic run exposes wall-time stats and every solver counter.
639        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        // Plain run keeps timing stats but omits the symbolic block.
647        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}