1use crate::results::{HyperfineOutput, HyperfineResult};
2use eyre::{Result, WrapErr};
3use foundry_common::{sh_eprintln, sh_println};
4use foundry_compilers::project_util::TempProject;
5use foundry_test_utils::util::clone_remote;
6use once_cell::sync::Lazy;
7use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
8use std::{
9 env,
10 path::{Path, PathBuf},
11 process::Command,
12 str::FromStr,
13};
14
15pub mod results;
16
17pub const RUNS: u32 = 5;
19
20#[derive(Debug, Clone)]
22pub struct RepoConfig {
23 pub name: String,
24 pub org: String,
25 pub repo: String,
26 pub rev: String,
27}
28
29impl FromStr for RepoConfig {
30 type Err = eyre::Error;
31
32 fn from_str(spec: &str) -> Result<Self> {
33 let parts: Vec<&str> = spec.splitn(2, ':').collect();
35 let repo_path = parts[0];
36 let custom_rev = parts.get(1).copied();
37
38 let path_parts: Vec<&str> = repo_path.split('/').collect();
40 if path_parts.len() != 2 {
41 eyre::bail!("Invalid repo format '{}'. Expected 'org/repo' or 'org/repo:rev'", spec);
42 }
43
44 let org = path_parts[0];
45 let repo = path_parts[1];
46
47 let existing_config = BENCHMARK_REPOS.iter().find(|r| r.org == org && r.repo == repo);
49
50 let config = if let Some(existing) = existing_config {
51 let mut config = existing.clone();
53 if let Some(rev) = custom_rev {
54 config.rev = rev.to_string();
55 }
56 config
57 } else {
58 RepoConfig {
61 name: format!("{org}-{repo}"),
62 org: org.to_string(),
63 repo: repo.to_string(),
64 rev: custom_rev.unwrap_or("main").to_string(),
65 }
66 };
67
68 let _ = sh_println!("Parsed repo spec '{spec}' -> {config:?}");
69 Ok(config)
70 }
71}
72
73pub fn default_benchmark_repos() -> Vec<RepoConfig> {
75 vec![
76 RepoConfig {
77 name: "ithacaxyz-account".to_string(),
78 org: "ithacaxyz".to_string(),
79 repo: "account".to_string(),
80 rev: "main".to_string(),
81 },
82 RepoConfig {
83 name: "solady".to_string(),
84 org: "Vectorized".to_string(),
85 repo: "solady".to_string(),
86 rev: "main".to_string(),
87 },
88 ]
89}
90
91pub static BENCHMARK_REPOS: Lazy<Vec<RepoConfig>> = Lazy::new(default_benchmark_repos);
93
94pub static FOUNDRY_VERSIONS: &[&str] = &["stable", "nightly"];
110
111pub struct BenchmarkProject {
113 pub name: String,
114 pub temp_project: TempProject,
115 pub root_path: PathBuf,
116}
117
118impl BenchmarkProject {
119 #[allow(unused_must_use)]
121 pub fn setup(config: &RepoConfig) -> Result<Self> {
122 let temp_project =
123 TempProject::dapptools().wrap_err("Failed to create temporary project")?;
124
125 let root_path = temp_project.root().to_path_buf();
127 let root = root_path.to_str().unwrap();
128
129 for entry in std::fs::read_dir(&root_path)? {
131 let entry = entry?;
132 let path = entry.path();
133 if path.is_dir() {
134 std::fs::remove_dir_all(&path).ok();
135 } else {
136 std::fs::remove_file(&path).ok();
137 }
138 }
139
140 let repo_url = format!("https://github.com/{}/{}.git", config.org, config.repo);
142 clone_remote(&repo_url, root);
143
144 if !config.rev.is_empty() && config.rev != "main" && config.rev != "master" {
146 let status = Command::new("git")
147 .current_dir(root)
148 .args(["checkout", &config.rev])
149 .status()
150 .wrap_err("Failed to checkout revision")?;
151
152 if !status.success() {
153 eyre::bail!("Git checkout failed for {}", config.name);
154 }
155 }
156
157 Self::install_npm_dependencies(&root_path)?;
160
161 sh_println!(" ✅ Project {} setup complete at {}", config.name, root);
162 Ok(BenchmarkProject { name: config.name.to_string(), root_path, temp_project })
163 }
164
165 #[allow(unused_must_use)]
167 fn install_npm_dependencies(root: &Path) -> Result<()> {
168 if root.join("package.json").exists() {
169 sh_println!(" 📦 Running npm install...");
170 let status = Command::new("npm")
171 .current_dir(root)
172 .args(["install"])
173 .stdout(std::process::Stdio::inherit())
174 .stderr(std::process::Stdio::inherit())
175 .status()
176 .wrap_err("Failed to run npm install")?;
177
178 if !status.success() {
179 sh_println!(
180 " ⚠️ Warning: npm install failed with exit code: {:?}",
181 status.code()
182 );
183 } else {
184 sh_println!(" ✅ npm install completed successfully");
185 }
186 }
187 Ok(())
188 }
189
190 #[allow(clippy::too_many_arguments)]
211 fn hyperfine(
212 &self,
213 benchmark_name: &str,
214 version: &str,
215 command: &str,
216 runs: u32,
217 setup: Option<&str>,
218 prepare: Option<&str>,
219 conclude: Option<&str>,
220 verbose: bool,
221 ) -> Result<HyperfineResult> {
222 let temp_dir = std::env::temp_dir();
225 let json_dir =
226 temp_dir.join("foundry-bench").join(benchmark_name).join(version).join(&self.name);
227 std::fs::create_dir_all(&json_dir)?;
228
229 let json_path = json_dir.join(format!("{benchmark_name}.json"));
230
231 let mut hyperfine_cmd = Command::new("hyperfine");
233 hyperfine_cmd
234 .current_dir(&self.root_path)
235 .arg("--runs")
236 .arg(runs.to_string())
237 .arg("--export-json")
238 .arg(&json_path)
239 .arg("--shell=bash");
240
241 if let Some(setup_cmd) = setup {
243 hyperfine_cmd.arg("--setup").arg(setup_cmd);
244 }
245
246 if let Some(prepare_cmd) = prepare {
248 hyperfine_cmd.arg("--prepare").arg(prepare_cmd);
249 }
250
251 if let Some(conclude_cmd) = conclude {
253 hyperfine_cmd.arg("--conclude").arg(conclude_cmd);
254 }
255
256 if verbose {
257 hyperfine_cmd.arg("--show-output");
258 hyperfine_cmd.stderr(std::process::Stdio::inherit());
259 hyperfine_cmd.stdout(std::process::Stdio::inherit());
260 }
261
262 hyperfine_cmd.arg(command);
264
265 let status = hyperfine_cmd.status().wrap_err("Failed to run hyperfine")?;
266 if !status.success() {
267 eyre::bail!("Hyperfine failed for command: {}", command);
268 }
269
270 let json_content = std::fs::read_to_string(json_path)?;
272 let output: HyperfineOutput = serde_json::from_str(&json_content)?;
273
274 output.results.into_iter().next().ok_or_else(|| eyre::eyre!("No results from hyperfine"))
276 }
277
278 pub fn bench_forge_test(
280 &self,
281 version: &str,
282 runs: u32,
283 verbose: bool,
284 ) -> Result<HyperfineResult> {
285 self.hyperfine(
287 "forge_test",
288 version,
289 "forge test",
290 runs,
291 Some("forge build"),
292 None,
293 None,
294 verbose,
295 )
296 }
297
298 pub fn bench_forge_build_with_cache(
300 &self,
301 version: &str,
302 runs: u32,
303 verbose: bool,
304 ) -> Result<HyperfineResult> {
305 self.hyperfine(
306 "forge_build_with_cache",
307 version,
308 "FOUNDRY_LINT_LINT_ON_BUILD=false forge build",
309 runs,
310 None,
311 Some("forge build"),
312 None,
313 verbose,
314 )
315 }
316
317 pub fn bench_forge_build_no_cache(
319 &self,
320 version: &str,
321 runs: u32,
322 verbose: bool,
323 ) -> Result<HyperfineResult> {
324 self.hyperfine(
326 "forge_build_no_cache",
327 version,
328 "FOUNDRY_LINT_LINT_ON_BUILD=false forge build",
329 runs,
330 Some("forge clean"),
331 None,
332 Some("forge clean"),
333 verbose,
334 )
335 }
336
337 pub fn bench_forge_fuzz_test(
339 &self,
340 version: &str,
341 runs: u32,
342 verbose: bool,
343 ) -> Result<HyperfineResult> {
344 self.hyperfine(
346 "forge_fuzz_test",
347 version,
348 r#"forge test --match-test "test[^(]*\([^)]+\)""#,
349 runs,
350 Some("forge build"),
351 None,
352 None,
353 verbose,
354 )
355 }
356
357 pub fn bench_forge_coverage(
359 &self,
360 version: &str,
361 runs: u32,
362 verbose: bool,
363 ) -> Result<HyperfineResult> {
364 self.hyperfine(
367 "forge_coverage",
368 version,
369 "forge coverage --ir-minimum",
370 runs,
371 None,
372 None,
373 None,
374 verbose,
375 )
376 }
377
378 pub fn bench_forge_isolate_test(
380 &self,
381 version: &str,
382 runs: u32,
383 verbose: bool,
384 ) -> Result<HyperfineResult> {
385 self.hyperfine(
387 "forge_isolate_test",
388 version,
389 "forge test --isolate",
390 runs,
391 Some("forge build"),
392 None,
393 None,
394 verbose,
395 )
396 }
397
398 pub fn root(&self) -> &Path {
400 &self.root_path
401 }
402
403 pub fn run(
405 &self,
406 benchmark: &str,
407 version: &str,
408 runs: u32,
409 verbose: bool,
410 ) -> Result<HyperfineResult> {
411 match benchmark {
412 "forge_test" => self.bench_forge_test(version, runs, verbose),
413 "forge_build_no_cache" => self.bench_forge_build_no_cache(version, runs, verbose),
414 "forge_build_with_cache" => self.bench_forge_build_with_cache(version, runs, verbose),
415 "forge_fuzz_test" => self.bench_forge_fuzz_test(version, runs, verbose),
416 "forge_coverage" => self.bench_forge_coverage(version, runs, verbose),
417 "forge_isolate_test" => self.bench_forge_isolate_test(version, runs, verbose),
418 _ => eyre::bail!("Unknown benchmark: {}", benchmark),
419 }
420 }
421}
422
423#[allow(unused_must_use)]
425pub fn switch_foundry_version(version: &str) -> Result<()> {
426 let output = Command::new("foundryup")
427 .args(["--use", version])
428 .output()
429 .wrap_err("Failed to run foundryup")?;
430
431 let stderr = String::from_utf8_lossy(&output.stderr);
433 if stderr.contains("command failed") && stderr.contains("forge --version") {
434 eyre::bail!(
435 "Foundry binaries maybe corrupted. Please reinstall by running `foundryup --install <version>`"
436 );
437 }
438
439 if !output.status.success() {
440 sh_eprintln!("foundryup stderr: {stderr}");
441 eyre::bail!("Failed to switch to foundry version: {}", version);
442 }
443
444 sh_println!(" Successfully switched to version: {version}");
445 Ok(())
446}
447
448pub fn get_forge_version() -> Result<String> {
450 let output = Command::new("forge")
451 .args(["--version"])
452 .output()
453 .wrap_err("Failed to get forge version")?;
454
455 if !output.status.success() {
456 eyre::bail!("forge --version failed");
457 }
458
459 let version =
460 String::from_utf8(output.stdout).wrap_err("Invalid UTF-8 in forge version output")?;
461
462 Ok(version.lines().next().unwrap_or("unknown").to_string())
463}
464
465pub fn get_forge_version_details() -> Result<String> {
467 let output = Command::new("forge")
468 .args(["--version"])
469 .output()
470 .wrap_err("Failed to get forge version")?;
471
472 if !output.status.success() {
473 eyre::bail!("forge --version failed");
474 }
475
476 let full_output =
477 String::from_utf8(output.stdout).wrap_err("Invalid UTF-8 in forge version output")?;
478
479 let lines: Vec<&str> = full_output.lines().collect();
481 if lines.len() >= 3 {
482 let version = lines[0].trim();
484 let commit = lines[1].trim().replace("Commit SHA: ", "");
485 let timestamp = lines[2].trim().replace("Build Timestamp: ", "");
486
487 let short_commit = &commit[..7]; let date = timestamp.split('T').next().unwrap_or(×tamp);
490
491 Ok(format!("{version} ({short_commit} {date})"))
492 } else {
493 Ok(lines.first().unwrap_or(&"unknown").to_string())
495 }
496}
497
498pub fn get_benchmark_versions() -> Vec<String> {
506 if let Ok(versions_env) = env::var("FOUNDRY_BENCH_VERSIONS") {
507 versions_env.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
508 } else {
509 FOUNDRY_VERSIONS.iter().map(|&s| s.to_string()).collect()
510 }
511}
512
513pub fn setup_benchmark_repos() -> Vec<(RepoConfig, BenchmarkProject)> {
515 let repos = if let Ok(repos_env) = env::var("FOUNDRY_BENCH_REPOS") {
517 repos_env
520 .split(',')
521 .map(|s| s.trim())
522 .filter(|s| !s.is_empty())
523 .map(|s| s.parse::<RepoConfig>())
524 .collect::<Result<Vec<_>>>()
525 .expect("Failed to parse FOUNDRY_BENCH_REPOS")
526 } else {
527 BENCHMARK_REPOS.clone()
528 };
529
530 repos
531 .par_iter()
532 .map(|repo_config| {
533 let project = BenchmarkProject::setup(repo_config).expect("Failed to setup project");
534 (repo_config.clone(), project)
535 })
536 .collect()
537}