use rpki::data_model::manifest::ManifestObject; use std::path::{Path, PathBuf}; use std::time::Instant; fn default_samples_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests/benchmark/selected_der") } fn read_samples(dir: &Path) -> Vec { let mut out = Vec::new(); let rd = std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read_dir {}: {e}", dir.display())); for ent in rd.flatten() { let path = ent.path(); if path.extension().and_then(|s| s.to_str()) != Some("mft") { continue; } let name = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("unknown") .to_string(); out.push(Sample { name, path }); } out.sort_by(|a, b| a.name.cmp(&b.name)); out } #[derive(Clone, Debug)] struct Sample { name: String, path: PathBuf, } fn env_u64(name: &str, default: u64) -> u64 { std::env::var(name) .ok() .and_then(|s| s.parse::().ok()) .unwrap_or(default) } fn env_u64_opt(name: &str) -> Option { std::env::var(name) .ok() .and_then(|s| s.parse::().ok()) } fn env_bool(name: &str) -> bool { matches!( std::env::var(name).as_deref(), Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes") | Ok("YES") ) } fn env_string(name: &str) -> Option { std::env::var(name).ok().filter(|s| !s.trim().is_empty()) } fn escape_md(s: &str) -> String { s.replace('|', "\\|").replace('\n', " ") } fn create_parent_dirs(path: &Path) { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).unwrap_or_else(|e| { panic!("create_dir_all {}: {e}", parent.display()); }); } } #[test] #[ignore = "manual performance benchmark; prints Markdown table"] fn manifest_decode_profile_benchmark_selected_der() { let dir = env_string("BENCH_DIR") .map(PathBuf::from) .unwrap_or_else(default_samples_dir); let sample_filter = env_string("BENCH_SAMPLE"); let fixed_iters = env_u64_opt("BENCH_ITERS"); let warmup_iters = env_u64("BENCH_WARMUP_ITERS", 100); let rounds = env_u64("BENCH_ROUNDS", 5); let min_round_ms = env_u64("BENCH_MIN_ROUND_MS", 200); let max_adaptive_iters = env_u64("BENCH_MAX_ITERS", 1_000_000); let verbose = env_bool("BENCH_VERBOSE"); let out_md = env_string("BENCH_OUT_MD").map(|p| PathBuf::from(p)); let out_json = env_string("BENCH_OUT_JSON").map(|p| PathBuf::from(p)); if let Some(n) = fixed_iters { assert!(n >= 1, "BENCH_ITERS must be >= 1"); } assert!(rounds >= 1, "BENCH_ROUNDS must be >= 1"); assert!(min_round_ms >= 1, "BENCH_MIN_ROUND_MS must be >= 1"); assert!(max_adaptive_iters >= 1, "BENCH_MAX_ITERS must be >= 1"); let mut samples = read_samples(&dir); assert!( !samples.is_empty(), "no .mft files found under: {}", dir.display() ); if let Some(filter) = sample_filter.as_deref() { samples.retain(|s| s.name == filter); assert!(!samples.is_empty(), "no sample matched BENCH_SAMPLE={filter}"); } println!("# Manifest decode + profile validate benchmark (debug build)"); println!(); println!("- dir: {}", dir.display()); if let Some(n) = fixed_iters { println!("- iters: {} (fixed)", n); } else { println!( "- warmup: {} iters, rounds: {}, min_round: {}ms (adaptive iters, max {})", warmup_iters, rounds, min_round_ms, max_adaptive_iters ); } if let Some(filter) = sample_filter.as_deref() { println!("- sample: {}", filter); } if verbose { println!("- verbose: true"); } if let Some(p) = out_md.as_ref() { println!("- out_md: {}", p.display()); } if let Some(p) = out_json.as_ref() { println!("- out_json: {}", p.display()); } println!(); println!("Samples:"); for s in &samples { println!("- {}", s.name); } println!(); println!("| sample | file_count | avg ns/op | ops/s |"); println!("|---|---:|---:|---:|"); let mut rows: Vec = Vec::with_capacity(samples.len()); for s in samples { let bytes = std::fs::read(&s.path).unwrap_or_else(|e| panic!("read {}: {e}", s.path.display())); let file_count = ManifestObject::decode_der(bytes.as_slice()) .unwrap_or_else(|e| panic!("decode {}: {e}", s.name)) .manifest .file_count(); // Warm-up: exercise the exact decode path but don't time it. for _ in 0..warmup_iters { let input = std::hint::black_box(bytes.as_slice()); let decoded = ManifestObject::decode_der(input).expect("decode"); std::hint::black_box(decoded); } let mut per_round_ns_per_op = Vec::with_capacity(rounds as usize); for round in 0..rounds { let iters = if let Some(n) = fixed_iters { n } else { choose_iters_adaptive( bytes.as_slice(), min_round_ms, max_adaptive_iters, ) }; let start = Instant::now(); for _ in 0..iters { let input = std::hint::black_box(bytes.as_slice()); let decoded = ManifestObject::decode_der(input).expect("decode"); std::hint::black_box(decoded); } let elapsed = start.elapsed(); let total_ns = elapsed.as_secs_f64() * 1e9; let ns_per_op = total_ns / (iters as f64); per_round_ns_per_op.push(ns_per_op); if verbose { println!( "# {} round {}: iters={} total_ms={:.2} ns/op={:.2}", s.name, round + 1, iters, elapsed.as_secs_f64() * 1e3, ns_per_op ); } } let avg_ns = per_round_ns_per_op.iter().sum::() / (per_round_ns_per_op.len() as f64); let ops_per_sec = 1e9_f64 / avg_ns; println!( "| {} | {} | {:.2} | {:.2} |", s.name, file_count, avg_ns, ops_per_sec ); rows.push(ResultRow { sample: s.name, file_count, avg_ns_per_op: avg_ns, ops_per_sec, }); } if out_md.is_some() || out_json.is_some() { let timestamp_utc = time::OffsetDateTime::now_utc().format(&time::format_description::well_known::Rfc3339) .unwrap_or_else(|_| "unknown".to_string()); let cfg = RunConfig { dir: dir.display().to_string(), sample: sample_filter, fixed_iters, warmup_iters, rounds, min_round_ms, max_adaptive_iters, timestamp_utc, }; if let Some(path) = out_md { let md = render_markdown(&cfg, &rows); write_text_file(&path, &md); eprintln!("Wrote {}", path.display()); } if let Some(path) = out_json { let json = serde_json::to_string_pretty(&BenchmarkOutput { config: cfg, rows }) .expect("serialize json"); write_text_file(&path, &json); eprintln!("Wrote {}", path.display()); } } } fn choose_iters_adaptive(bytes: &[u8], min_round_ms: u64, max_iters: u64) -> u64 { let min_secs = (min_round_ms as f64) / 1e3; let mut iters: u64 = 1; loop { let start = Instant::now(); for _ in 0..iters { let input = std::hint::black_box(bytes); let decoded = ManifestObject::decode_der(input).expect("decode"); std::hint::black_box(decoded); } let elapsed = start.elapsed().as_secs_f64(); if elapsed >= min_secs { return iters; } if iters >= max_iters { return iters; } iters = (iters.saturating_mul(2)).min(max_iters); } } fn render_markdown(cfg: &RunConfig, rows: &[ResultRow]) -> String { let mut out = String::new(); out.push_str("# Manifest decode + profile validate benchmark (debug build)\n\n"); out.push_str(&format!("- timestamp_utc: {}\n", cfg.timestamp_utc)); out.push_str(&format!("- dir: `{}`\n", cfg.dir)); if let Some(s) = cfg.sample.as_deref() { out.push_str(&format!("- sample: `{}`\n", s)); } if let Some(n) = cfg.fixed_iters { out.push_str(&format!("- iters: {} (fixed)\n", n)); } else { out.push_str(&format!( "- warmup: {} iters, rounds: {}, min_round: {}ms (adaptive iters, max {})\n", cfg.warmup_iters, cfg.rounds, cfg.min_round_ms, cfg.max_adaptive_iters )); } out.push('\n'); out.push_str("| sample | file_count | avg ns/op | ops/s |\n"); out.push_str("|---|---:|---:|---:|\n"); for r in rows { out.push_str(&format!( "| {} | {} | {:.2} | {:.2} |\n", escape_md(&r.sample), r.file_count, r.avg_ns_per_op, r.ops_per_sec )); } out } fn write_text_file(path: &Path, content: &str) { create_parent_dirs(path); std::fs::write(path, content).unwrap_or_else(|e| panic!("write {}: {e}", path.display())); } #[derive(Clone, Debug, serde::Serialize)] struct RunConfig { dir: String, sample: Option, fixed_iters: Option, warmup_iters: u64, rounds: u64, min_round_ms: u64, max_adaptive_iters: u64, timestamp_utc: String, } #[derive(Clone, Debug, serde::Serialize)] struct ResultRow { sample: String, file_count: usize, avg_ns_per_op: f64, ops_per_sec: f64, } #[derive(Clone, Debug, serde::Serialize)] struct BenchmarkOutput { config: RunConfig, rows: Vec, }