Skip to main content

foundry_bench/
lib.rs

1//! Foundry benchmark runner.
2
3use crate::results::{HyperfineOutput, HyperfineResult};
4use eyre::{Result, WrapErr};
5use foundry_common::{sh_eprintln, sh_println};
6use foundry_compilers::project_util::TempProject;
7use foundry_test_utils::util::clone_remote;
8use once_cell::sync::Lazy;
9use std::{
10    path::{Path, PathBuf},
11    process::Command,
12    str::FromStr,
13};
14
15pub mod results;
16
17/// Default number of runs for benchmarks
18pub const RUNS: u32 = 5;
19
20/// Configuration for repositories to benchmark
21#[derive(Debug, Clone)]
22pub struct RepoConfig {
23    pub name: String,
24    pub org: String,
25    pub repo: String,
26    pub rev: String,
27    /// Optional extra arguments appended to every benchmark command for this
28    /// repo (e.g. `--nmc BrokenTest` to skip a broken test contract).
29    pub extra_args: Option<String>,
30}
31
32impl FromStr for RepoConfig {
33    type Err = eyre::Error;
34
35    /// Parse a repo spec of the form `org/repo[:rev][ <extra args...>]`.
36    ///
37    /// Anything after the first whitespace is treated as extra arguments
38    /// appended to every benchmark command for this repo.
39    fn from_str(spec: &str) -> Result<Self> {
40        let spec = spec.trim();
41        // Anything after the first whitespace is per-repo extra args.
42        let (head, extra_args) = match spec.split_once(char::is_whitespace) {
43            Some((head, rest)) => (head, Some(rest.trim().to_string())),
44            None => (spec, None),
45        };
46
47        let (repo_path, custom_rev) = match head.split_once(':') {
48            Some((path, rev)) => (path, Some(rev)),
49            None => (head, None),
50        };
51
52        let (org, repo) = repo_path.split_once('/').ok_or_else(|| {
53            eyre::eyre!("Invalid repo format '{spec}'. Expected 'org/repo' or 'org/repo:rev'")
54        })?;
55
56        // Inherit defaults from BENCHMARK_REPOS when available, otherwise build
57        // a fresh config. Custom rev / extra args always override.
58        let mut config = BENCHMARK_REPOS
59            .iter()
60            .find(|r| r.org == org && r.repo == repo)
61            .cloned()
62            .unwrap_or_else(|| Self {
63                name: format!("{org}-{repo}"),
64                org: org.to_string(),
65                repo: repo.to_string(),
66                rev: "main".to_string(),
67                extra_args: None,
68            });
69
70        if let Some(rev) = custom_rev {
71            config.rev = rev.to_string();
72        }
73        config.extra_args = extra_args;
74
75        let _ = sh_println!("Parsed repo spec '{spec}' -> {config:?}");
76        Ok(config)
77    }
78}
79
80/// Available repositories for benchmarking
81pub fn default_benchmark_repos() -> Vec<RepoConfig> {
82    vec![
83        RepoConfig {
84            name: "ithacaxyz-account".to_string(),
85            org: "ithacaxyz".to_string(),
86            repo: "account".to_string(),
87            rev: "main".to_string(),
88            extra_args: None,
89        },
90        RepoConfig {
91            name: "solady".to_string(),
92            org: "Vectorized".to_string(),
93            repo: "solady".to_string(),
94            rev: "main".to_string(),
95            extra_args: None,
96        },
97    ]
98}
99
100// Keep a lazy static for compatibility
101pub static BENCHMARK_REPOS: Lazy<Vec<RepoConfig>> = Lazy::new(default_benchmark_repos);
102
103/// Foundry versions to benchmark
104///
105/// To add more versions for comparison, install them first:
106/// ```bash
107/// foundryup --install stable
108/// foundryup --install nightly
109/// foundryup --install v0.2.0  # Example specific version
110/// ```
111///
112/// Then add the version strings to this array. Supported formats:
113/// - "stable" - Latest stable release
114/// - "nightly" - Latest nightly build
115/// - "v0.2.0" - Specific version tag
116/// - "commit-hash" - Specific commit hash
117/// - "nightly-rev" - Nightly build with specific revision
118pub static FOUNDRY_VERSIONS: &[&str] = &["stable", "nightly"];
119
120/// A benchmark project that represents a cloned repository ready for testing
121pub struct BenchmarkProject {
122    pub name: String,
123    pub temp_project: TempProject,
124    pub root_path: PathBuf,
125    /// Optional extra arguments appended to every benchmark command.
126    pub extra_args: Option<String>,
127}
128
129impl BenchmarkProject {
130    /// Set up a benchmark project by cloning the repository
131    #[allow(unused_must_use)]
132    pub fn setup(config: &RepoConfig) -> Result<Self> {
133        let temp_project =
134            TempProject::dapptools().wrap_err("Failed to create temporary project")?;
135
136        // Get root path before clearing
137        let root_path = temp_project.root().to_path_buf();
138        let root = root_path.to_str().unwrap();
139
140        // Remove all files in the directory
141        for entry in std::fs::read_dir(&root_path)? {
142            let entry = entry?;
143            let path = entry.path();
144            if path.is_dir() {
145                std::fs::remove_dir_all(&path).ok();
146            } else {
147                std::fs::remove_file(&path).ok();
148            }
149        }
150
151        // Clone the repository
152        let repo_url = format!("https://github.com/{}/{}.git", config.org, config.repo);
153        clone_remote(&repo_url, root, true);
154
155        // Checkout specific revision if provided
156        if !config.rev.is_empty() && config.rev != "main" && config.rev != "master" {
157            let status = Command::new("git")
158                .current_dir(root)
159                .args(["checkout", &config.rev])
160                .status()
161                .wrap_err("Failed to checkout revision")?;
162
163            if !status.success() {
164                eyre::bail!("Git checkout failed for {}", config.name);
165            }
166        }
167
168        // Git submodules are already cloned via --recursive flag
169        // But npm dependencies still need to be installed
170        Self::install_npm_dependencies(&root_path)?;
171
172        sh_println!("  ✅ Project {} setup complete at {}", config.name, root);
173        Ok(Self {
174            name: config.name.clone(),
175            root_path,
176            temp_project,
177            extra_args: config.extra_args.clone(),
178        })
179    }
180
181    /// Append `self.extra_args` to a benchmark shell command, if any.
182    fn cmd(&self, base: &str) -> String {
183        match self.extra_args.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
184            Some(extra) => format!("{base} {extra}"),
185            None => base.to_string(),
186        }
187    }
188
189    /// Install npm dependencies if package.json exists
190    #[allow(unused_must_use)]
191    fn install_npm_dependencies(root: &Path) -> Result<()> {
192        if root.join("package.json").exists() {
193            sh_println!("  📦 Running npm install...");
194            let status = Command::new("npm")
195                .current_dir(root)
196                .args(["install"])
197                .stdout(std::process::Stdio::inherit())
198                .stderr(std::process::Stdio::inherit())
199                .status()
200                .wrap_err("Failed to run npm install")?;
201
202            if status.success() {
203                sh_println!("  ✅ npm install completed successfully");
204            } else {
205                sh_println!(
206                    "  ⚠️  Warning: npm install failed with exit code: {:?}",
207                    status.code()
208                );
209            }
210        }
211        Ok(())
212    }
213
214    /// Run a command with hyperfine and return the results
215    ///
216    /// # Arguments
217    /// * `benchmark_name` - Name of the benchmark for organizing output
218    /// * `version` - Foundry version being benchmarked
219    /// * `command` - The command to benchmark
220    /// * `runs` - Number of runs to perform
221    /// * `setup` - Optional setup command to run before the benchmark series (e.g., "forge build")
222    /// * `prepare` - Optional prepare command to run before each timing run (e.g., "forge clean")
223    /// * `conclude` - Optional conclude command to run after each timing run (e.g., cleanup)
224    /// * `verbose` - Whether to show command output
225    ///
226    /// # Hyperfine flags used:
227    /// * `--runs` - Number of timing runs
228    /// * `--setup` - Execute before the benchmark series (not before each run)
229    /// * `--prepare` - Execute before each timing run
230    /// * `--conclude` - Execute after each timing run
231    /// * `--export-json` - Export results to JSON for parsing
232    /// * `--shell=bash` - Use bash for shell command execution
233    /// * `--show-output` - Show command output (when verbose)
234    #[allow(clippy::too_many_arguments)]
235    fn hyperfine(
236        &self,
237        benchmark_name: &str,
238        version: &str,
239        command: &str,
240        runs: u32,
241        setup: Option<&str>,
242        prepare: Option<&str>,
243        conclude: Option<&str>,
244        verbose: bool,
245    ) -> Result<HyperfineResult> {
246        // Create structured temp directory for JSON output
247        // Format: <temp_dir>/<benchmark_name>/<version>/<repo_name>/<benchmark_name>.json
248        let temp_dir = std::env::temp_dir();
249        let json_dir =
250            temp_dir.join("foundry-bench").join(benchmark_name).join(version).join(&self.name);
251        std::fs::create_dir_all(&json_dir)?;
252
253        let json_path = json_dir.join(format!("{benchmark_name}.json"));
254
255        // Build hyperfine command
256        let mut hyperfine_cmd = Command::new("hyperfine");
257        hyperfine_cmd
258            .current_dir(&self.root_path)
259            .arg("--runs")
260            .arg(runs.to_string())
261            .arg("--export-json")
262            .arg(&json_path)
263            .arg("--shell=bash");
264
265        // Add optional setup command
266        if let Some(setup_cmd) = setup {
267            hyperfine_cmd.arg("--setup").arg(setup_cmd);
268        }
269
270        // Add optional prepare command
271        if let Some(prepare_cmd) = prepare {
272            hyperfine_cmd.arg("--prepare").arg(prepare_cmd);
273        }
274
275        // Add optional conclude command
276        if let Some(conclude_cmd) = conclude {
277            hyperfine_cmd.arg("--conclude").arg(conclude_cmd);
278        }
279
280        if verbose {
281            hyperfine_cmd.arg("--show-output");
282            hyperfine_cmd.stderr(std::process::Stdio::inherit());
283            hyperfine_cmd.stdout(std::process::Stdio::inherit());
284        }
285
286        // Add the benchmark command last
287        hyperfine_cmd.arg(command);
288
289        let status = hyperfine_cmd.status().wrap_err("Failed to run hyperfine")?;
290        if !status.success() {
291            eyre::bail!("Hyperfine failed for command: {}", command);
292        }
293
294        // Read and parse the JSON output
295        let json_content = std::fs::read_to_string(json_path)?;
296        let output: HyperfineOutput = serde_json::from_str(&json_content)?;
297
298        // Extract the first result (we only run one command at a time)
299        output.results.into_iter().next().ok_or_else(|| eyre::eyre!("No results from hyperfine"))
300    }
301
302    /// Benchmark forge test
303    pub fn bench_forge_test(
304        &self,
305        version: &str,
306        runs: u32,
307        verbose: bool,
308    ) -> Result<HyperfineResult> {
309        // Build before running tests
310        self.hyperfine(
311            "forge_test",
312            version,
313            &self.cmd("forge test"),
314            runs,
315            Some("forge build"),
316            None,
317            None,
318            verbose,
319        )
320    }
321
322    /// Benchmark forge build with cache
323    pub fn bench_forge_build_with_cache(
324        &self,
325        version: &str,
326        runs: u32,
327        verbose: bool,
328    ) -> Result<HyperfineResult> {
329        self.hyperfine(
330            "forge_build_with_cache",
331            version,
332            &self.cmd("FOUNDRY_LINT_LINT_ON_BUILD=false forge build"),
333            runs,
334            None,
335            Some("forge build"),
336            None,
337            verbose,
338        )
339    }
340
341    /// Benchmark forge build without cache
342    pub fn bench_forge_build_no_cache(
343        &self,
344        version: &str,
345        runs: u32,
346        verbose: bool,
347    ) -> Result<HyperfineResult> {
348        // Clean before each timing run
349        self.hyperfine(
350            "forge_build_no_cache",
351            version,
352            &self.cmd("FOUNDRY_LINT_LINT_ON_BUILD=false forge build"),
353            runs,
354            Some("forge clean"),
355            None,
356            Some("forge clean"),
357            verbose,
358        )
359    }
360
361    /// Benchmark forge fuzz tests
362    pub fn bench_forge_fuzz_test(
363        &self,
364        version: &str,
365        runs: u32,
366        verbose: bool,
367    ) -> Result<HyperfineResult> {
368        // Build before running fuzz tests
369        self.hyperfine(
370            "forge_fuzz_test",
371            version,
372            &self.cmd(r#"forge test --match-test "test[^(]*\([^)]+\)""#),
373            runs,
374            Some("forge build"),
375            None,
376            None,
377            verbose,
378        )
379    }
380
381    /// Benchmark forge coverage
382    pub fn bench_forge_coverage(
383        &self,
384        version: &str,
385        runs: u32,
386        verbose: bool,
387    ) -> Result<HyperfineResult> {
388        // No setup needed, forge coverage builds internally
389        // Use --ir-minimum to avoid "Stack too deep" errors
390        self.hyperfine(
391            "forge_coverage",
392            version,
393            &self.cmd("forge coverage --ir-minimum"),
394            runs,
395            None,
396            None,
397            None,
398            verbose,
399        )
400    }
401
402    /// Benchmark forge test with --isolate flag
403    pub fn bench_forge_isolate_test(
404        &self,
405        version: &str,
406        runs: u32,
407        verbose: bool,
408    ) -> Result<HyperfineResult> {
409        // Build before running tests
410        self.hyperfine(
411            "forge_isolate_test",
412            version,
413            &self.cmd("forge test --isolate"),
414            runs,
415            Some("forge build"),
416            None,
417            None,
418            verbose,
419        )
420    }
421
422    /// Get the root path of the project
423    pub fn root(&self) -> &Path {
424        &self.root_path
425    }
426
427    /// Run a specific benchmark by name
428    pub fn run(
429        &self,
430        benchmark: &str,
431        version: &str,
432        runs: u32,
433        verbose: bool,
434    ) -> Result<HyperfineResult> {
435        match benchmark {
436            "forge_test" => self.bench_forge_test(version, runs, verbose),
437            "forge_build_no_cache" => self.bench_forge_build_no_cache(version, runs, verbose),
438            "forge_build_with_cache" => self.bench_forge_build_with_cache(version, runs, verbose),
439            "forge_fuzz_test" => self.bench_forge_fuzz_test(version, runs, verbose),
440            "forge_coverage" => self.bench_forge_coverage(version, runs, verbose),
441            "forge_isolate_test" => self.bench_forge_isolate_test(version, runs, verbose),
442            _ => eyre::bail!("Unknown benchmark: {}", benchmark),
443        }
444    }
445}
446
447/// The workspace root, embedded at compile time.
448/// `benches/` is one level below the workspace root.
449const WORKSPACE_ROOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/..");
450
451/// Switch to a specific foundry version.
452///
453/// The special keyword `local` builds and activates the current workspace via
454/// `foundryup --path <workspace>` instead of `foundryup --use`.
455#[allow(unused_must_use)]
456pub fn switch_foundry_version(version: &str) -> Result<()> {
457    if version == "local" {
458        return install_local_version();
459    }
460
461    let output = Command::new("foundryup")
462        .args(["--use", version])
463        .output()
464        .wrap_err("Failed to run foundryup")?;
465
466    // Check if the error is about forge --version failing
467    let stderr = String::from_utf8_lossy(&output.stderr);
468    if stderr.contains("command failed") && stderr.contains("forge --version") {
469        eyre::bail!(
470            "Foundry binaries maybe corrupted. Please reinstall by running `foundryup --install <version>`"
471        );
472    }
473
474    if !output.status.success() {
475        sh_eprintln!("foundryup stderr: {stderr}");
476        eyre::bail!("Failed to switch to foundry version: {}", version);
477    }
478
479    sh_println!("  Successfully switched to version: {version}");
480    Ok(())
481}
482
483/// Build and activate the local workspace via `foundryup --path`.
484/// Uses cargo's incremental compilation so re-runs are fast.
485#[allow(unused_must_use)]
486pub fn install_local_version() -> Result<()> {
487    let workspace =
488        std::fs::canonicalize(WORKSPACE_ROOT).wrap_err("Failed to resolve workspace root")?;
489    sh_println!("  Building local workspace at {}", workspace.display());
490
491    let status = Command::new("foundryup")
492        .args(["--path", workspace.to_str().unwrap()])
493        .status()
494        .wrap_err("Failed to run foundryup --path")?;
495
496    if !status.success() {
497        eyre::bail!("foundryup --path failed for local workspace");
498    }
499
500    sh_println!("  Successfully activated local build");
501    Ok(())
502}
503
504/// Get the current forge version
505pub fn get_forge_version() -> Result<String> {
506    let output = Command::new("forge")
507        .args(["--version"])
508        .output()
509        .wrap_err("Failed to get forge version")?;
510
511    if !output.status.success() {
512        eyre::bail!("forge --version failed");
513    }
514
515    let version =
516        String::from_utf8(output.stdout).wrap_err("Invalid UTF-8 in forge version output")?;
517
518    Ok(version.lines().next().unwrap_or("unknown").to_string())
519}
520
521/// Get the full forge version details including commit hash and date
522pub fn get_forge_version_details() -> Result<String> {
523    let output = Command::new("forge")
524        .args(["--version"])
525        .output()
526        .wrap_err("Failed to get forge version")?;
527
528    if !output.status.success() {
529        eyre::bail!("forge --version failed");
530    }
531
532    let full_output =
533        String::from_utf8(output.stdout).wrap_err("Invalid UTF-8 in forge version output")?;
534
535    // Extract relevant lines and format them
536    let lines: Vec<&str> = full_output.lines().collect();
537    if lines.len() >= 3 {
538        // Extract version, commit, and timestamp
539        let version = lines[0].trim();
540        let commit = lines[1].trim().replace("Commit SHA: ", "");
541        let timestamp = lines[2].trim().replace("Build Timestamp: ", "");
542
543        // Format as: "forge 1.2.3-nightly (51650ea 2025-06-27)"
544        let short_commit = &commit[..7]; // First 7 chars of commit hash
545        let date = timestamp.split('T').next().unwrap_or(&timestamp);
546
547        Ok(format!("{version} ({short_commit} {date})"))
548    } else {
549        // Fallback to just the first line if format is unexpected
550        Ok(lines.first().unwrap_or(&"unknown").to_string())
551    }
552}