tangle_benchmark_report.rs (13718B)
1 #![forbid(unsafe_code)] 2 3 use std::env; 4 use std::fs; 5 use std::path::{Path, PathBuf}; 6 use std::process::Command; 7 use std::time::{SystemTime, UNIX_EPOCH}; 8 use tangle_bench::{ 9 BenchDatasetConfig, BenchmarkProfile, BenchmarkProfileName, BenchmarkRunReport, 10 BenchmarkThresholds, 11 }; 12 use tangle_runtime::nip11::supported_nips_for_group_capability; 13 14 #[derive(Debug)] 15 struct BenchmarkReportArgs { 16 output_root: PathBuf, 17 run_id: String, 18 profile: BenchmarkProfile, 19 } 20 21 fn main() { 22 match run() { 23 Ok(Some(artifact_dir)) => println!("{}", path_string(&artifact_dir)), 24 Ok(None) => {} 25 Err(error) => { 26 eprintln!("{error}"); 27 std::process::exit(1); 28 } 29 } 30 } 31 32 fn run() -> Result<Option<PathBuf>, String> { 33 let Some(args) = BenchmarkReportArgs::parse(env::args().skip(1))? else { 34 println!("{}", help_text()); 35 return Ok(None); 36 }; 37 let artifact_dir = args.output_root.join(&args.run_id); 38 fs::create_dir_all(&artifact_dir).map_err(|error| error.to_string())?; 39 40 let report = BenchmarkRunReport::run(args.profile)?; 41 let dataset_path = artifact_dir.join("dataset-events.jsonl"); 42 fs::write( 43 &dataset_path, 44 report 45 .dataset() 46 .source_events_jsonl() 47 .map_err(|error| error.to_string())?, 48 ) 49 .map_err(|error| error.to_string())?; 50 51 let mut summary = report.summary_json(&args.run_id, &artifact_dir); 52 let supported_nips = supported_nips_for_group_capability(true); 53 let supported_nips_count = supported_nips.len(); 54 summary["supported_nips_audit"] = serde_json::json!({ 55 "groups_enabled": true, 56 "supported_nips": supported_nips, 57 "count": supported_nips_count 58 }); 59 summary["run_identity"] = serde_json::json!({ 60 "git_commit": git_full_commit(), 61 "git_commit_short": git_short_commit(), 62 "rust_toolchain": rust_toolchain(), 63 "host_profile": host_profile(), 64 "os": env::consts::OS, 65 "arch": env::consts::ARCH 66 }); 67 summary["host_hardware"] = serde_json::json!({ 68 "cpu_model": cpu_model(), 69 "cpu_parallelism": cpu_parallelism(), 70 "memory_bytes": memory_bytes() 71 }); 72 73 let summary_path = artifact_dir.join("summary.json"); 74 let raw = serde_json::to_string_pretty(&summary).map_err(|error| error.to_string())?; 75 fs::write(&summary_path, format!("{raw}\n")).map_err(|error| error.to_string())?; 76 Ok(Some(artifact_dir)) 77 } 78 79 impl BenchmarkReportArgs { 80 fn parse(args: impl IntoIterator<Item = String>) -> Result<Option<Self>, String> { 81 let mut output_root = PathBuf::from(".local/tangle/benchmarks"); 82 let mut run_id = None; 83 let mut profile_name = BenchmarkProfileName::VirtualRelayTenancy; 84 let mut config = BenchDatasetConfig::smoke(); 85 let mut dataset_overridden = false; 86 let mut thresholds_json = None; 87 let mut target_hardware_evidence = None; 88 let mut args = args.into_iter(); 89 while let Some(arg) = args.next() { 90 match arg.as_str() { 91 "--output-root" => { 92 output_root = PathBuf::from(require_value("--output-root", args.next())?); 93 } 94 "--run-id" => { 95 run_id = Some(require_value("--run-id", args.next())?); 96 } 97 "--profile" => { 98 profile_name = 99 BenchmarkProfileName::parse(&require_value("--profile", args.next())?)?; 100 } 101 "--thresholds-json" => { 102 thresholds_json = Some(PathBuf::from(require_value( 103 "--thresholds-json", 104 args.next(), 105 )?)); 106 } 107 "--target-hardware-evidence" => { 108 target_hardware_evidence = 109 Some(require_value("--target-hardware-evidence", args.next())?); 110 } 111 "--group-count" => { 112 config.group_count = parse_count("--group-count", args.next())?; 113 dataset_overridden = true; 114 } 115 "--public-events-per-group" => { 116 config.public_events_per_group = 117 parse_count("--public-events-per-group", args.next())?; 118 dataset_overridden = true; 119 } 120 "--private-events-per-group" => { 121 config.private_events_per_group = 122 parse_count("--private-events-per-group", args.next())?; 123 dataset_overridden = true; 124 } 125 "--public-note-count" => { 126 config.public_note_count = parse_count("--public-note-count", args.next())?; 127 dataset_overridden = true; 128 } 129 "--member-count" => { 130 config.member_count = parse_count("--member-count", args.next())?; 131 dataset_overridden = true; 132 } 133 "--help" => return Ok(None), 134 other => return Err(format!("unsupported argument `{other}`")), 135 } 136 } 137 let run_id = run_id.unwrap_or_else(default_run_id); 138 validate_run_id(&run_id)?; 139 if dataset_overridden && profile_name != BenchmarkProfileName::Smoke { 140 return Err( 141 "dataset size overrides are only supported with the smoke profile".to_owned(), 142 ); 143 } 144 let mut profile = BenchmarkProfile::from_name(profile_name); 145 if dataset_overridden { 146 profile = profile.with_dataset_config(config)?; 147 } 148 if let Some(path) = thresholds_json { 149 let raw = fs::read_to_string(&path) 150 .map_err(|error| format!("failed to read thresholds JSON: {error}"))?; 151 let thresholds = BenchmarkThresholds::from_json_str(&raw)?; 152 profile = 153 profile.with_thresholds(thresholds, format!("file:{}", path_string(&path)))?; 154 } 155 if let Some(evidence) = target_hardware_evidence { 156 profile = profile.with_target_hardware_evidence(evidence)?; 157 } 158 profile.validate_for_run()?; 159 Ok(Some(Self { 160 output_root, 161 run_id, 162 profile, 163 })) 164 } 165 } 166 167 fn require_value(name: &'static str, value: Option<String>) -> Result<String, String> { 168 value.ok_or_else(|| format!("{name} requires a value")) 169 } 170 171 fn parse_count(name: &'static str, value: Option<String>) -> Result<usize, String> { 172 let raw = require_value(name, value)?; 173 raw.parse::<usize>() 174 .map_err(|error| format!("{name} must be a non-negative integer: {error}")) 175 } 176 177 fn validate_run_id(run_id: &str) -> Result<(), String> { 178 if run_id.is_empty() || run_id.contains('/') || run_id.contains('\\') || run_id.contains("..") { 179 return Err("run id must be a single relative path segment".to_owned()); 180 } 181 Ok(()) 182 } 183 184 fn default_run_id() -> String { 185 format!("local-{}-{}", unix_seconds(), git_short_commit()) 186 } 187 188 fn unix_seconds() -> u64 { 189 SystemTime::now() 190 .duration_since(UNIX_EPOCH) 191 .map(|duration| duration.as_secs()) 192 .unwrap_or(0) 193 } 194 195 fn git_short_commit() -> String { 196 command_text("git", &["rev-parse", "--short", "HEAD"]).unwrap_or_else(|| "unknown".to_owned()) 197 } 198 199 fn git_full_commit() -> String { 200 command_text("git", &["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_owned()) 201 } 202 203 fn rust_toolchain() -> String { 204 command_text("rustc", &["--version"]).unwrap_or_else(|| "unknown".to_owned()) 205 } 206 207 fn host_profile() -> String { 208 let os = env::consts::OS; 209 let arch = env::consts::ARCH; 210 format!("{os}-{arch}") 211 } 212 213 fn cpu_model() -> String { 214 command_text("sysctl", &["-n", "machdep.cpu.brand_string"]) 215 .or_else(cpu_model_from_proc) 216 .unwrap_or_else(|| "unknown".to_owned()) 217 } 218 219 fn cpu_parallelism() -> u64 { 220 std::thread::available_parallelism() 221 .map(|value| value.get().try_into().expect("parallelism fits in u64")) 222 .unwrap_or(0) 223 } 224 225 fn memory_bytes() -> Option<u64> { 226 command_text("sysctl", &["-n", "hw.memsize"]) 227 .and_then(|value| value.parse::<u64>().ok()) 228 .or_else(memory_bytes_from_proc) 229 } 230 231 fn cpu_model_from_proc() -> Option<String> { 232 let raw = fs::read_to_string("/proc/cpuinfo").ok()?; 233 raw.lines() 234 .find_map(|line| line.strip_prefix("model name")) 235 .and_then(|value| { 236 value 237 .split_once(':') 238 .map(|(_, model)| model.trim().to_owned()) 239 }) 240 .filter(|value| !value.is_empty()) 241 } 242 243 fn memory_bytes_from_proc() -> Option<u64> { 244 let raw = fs::read_to_string("/proc/meminfo").ok()?; 245 raw.lines() 246 .find_map(|line| line.strip_prefix("MemTotal:")) 247 .and_then(|value| value.split_whitespace().next()) 248 .and_then(|value| value.parse::<u64>().ok()) 249 .and_then(|kib| kib.checked_mul(1024)) 250 } 251 252 fn command_text(command: &str, args: &[&str]) -> Option<String> { 253 Command::new(command) 254 .args(args) 255 .output() 256 .ok() 257 .filter(|output| output.status.success()) 258 .and_then(|output| String::from_utf8(output.stdout).ok()) 259 .map(|value| value.trim().to_owned()) 260 .filter(|value| !value.is_empty()) 261 } 262 263 fn path_string(path: &Path) -> String { 264 path.to_string_lossy().into_owned() 265 } 266 267 fn help_text() -> String { 268 [ 269 "usage: tangle-benchmark-report [--output-root PATH] [--run-id ID] [--profile smoke|virtual-relay-tenancy|medium|large-smoke|proof-10m|proof-large-group|proof-join-storm|proof-slow-client]", 270 " [--thresholds-json PATH] [--target-hardware-evidence TEXT]", 271 " [--group-count COUNT] [--public-events-per-group COUNT]", 272 " [--private-events-per-group COUNT] [--public-note-count COUNT]", 273 " [--member-count COUNT]", 274 ] 275 .join("\n") 276 } 277 278 #[cfg(test)] 279 mod tests { 280 use super::BenchmarkReportArgs; 281 use tangle_bench::{BenchDatasetConfig, BenchmarkProfileName}; 282 283 #[test] 284 fn benchmark_report_args_default_to_virtual_relay_tenancy_profile() { 285 let args = BenchmarkReportArgs::parse(["--run-id".to_owned(), "unit".to_owned()]) 286 .expect("parse") 287 .expect("args"); 288 289 assert_eq!( 290 args.profile.name(), 291 BenchmarkProfileName::VirtualRelayTenancy 292 ); 293 assert_eq!(args.profile.dataset_config(), BenchDatasetConfig::smoke()); 294 assert_eq!( 295 args.profile.threshold_source(), 296 "builtin:virtual-relay-tenancy" 297 ); 298 } 299 300 #[test] 301 fn benchmark_report_args_reject_unknown_profile() { 302 let error = BenchmarkReportArgs::parse([ 303 "--profile".to_owned(), 304 "tiny".to_owned(), 305 "--run-id".to_owned(), 306 "unit".to_owned(), 307 ]) 308 .expect_err("unknown profile"); 309 310 assert!(error.contains("unknown benchmark profile")); 311 } 312 313 #[test] 314 fn benchmark_report_args_reject_dataset_overrides_for_non_smoke_profiles() { 315 let error = BenchmarkReportArgs::parse([ 316 "--profile".to_owned(), 317 "medium".to_owned(), 318 "--group-count".to_owned(), 319 "3".to_owned(), 320 "--run-id".to_owned(), 321 "unit".to_owned(), 322 ]) 323 .expect_err("non-smoke override"); 324 325 assert!(error.contains("dataset size overrides")); 326 } 327 328 #[test] 329 fn benchmark_report_args_accept_large_smoke_target_hardware_evidence_without_proof_claim() { 330 let args = BenchmarkReportArgs::parse([ 331 "--profile".to_owned(), 332 "large-smoke".to_owned(), 333 "--target-hardware-evidence".to_owned(), 334 "target-hardware:bench-node-001".to_owned(), 335 "--run-id".to_owned(), 336 "unit".to_owned(), 337 ]) 338 .expect("parse") 339 .expect("args"); 340 341 assert_eq!(args.profile.name(), BenchmarkProfileName::LargeSmoke); 342 assert!(!args.profile.proof_claim_eligible()); 343 } 344 345 #[test] 346 fn benchmark_report_args_require_hardware_evidence_for_proof_profiles() { 347 for profile in [ 348 "proof-10m", 349 "proof-large-group", 350 "proof-join-storm", 351 "proof-slow-client", 352 ] { 353 let error = BenchmarkReportArgs::parse([ 354 "--profile".to_owned(), 355 profile.to_owned(), 356 "--run-id".to_owned(), 357 "unit".to_owned(), 358 ]) 359 .expect_err("proof profile requires evidence"); 360 361 assert!(error.contains("target hardware evidence is required")); 362 } 363 } 364 365 #[test] 366 fn benchmark_report_args_accept_proof_profile_with_hardware_evidence() { 367 let args = BenchmarkReportArgs::parse([ 368 "--profile".to_owned(), 369 "proof-10m".to_owned(), 370 "--target-hardware-evidence".to_owned(), 371 "target-hardware:proof-node-001".to_owned(), 372 "--run-id".to_owned(), 373 "unit".to_owned(), 374 ]) 375 .expect("parse") 376 .expect("args"); 377 378 assert_eq!(args.profile.name(), BenchmarkProfileName::Proof10m); 379 assert!(args.profile.proof_claim_eligible()); 380 } 381 382 #[test] 383 fn benchmark_report_args_reject_production_profile_alias() { 384 let error = BenchmarkReportArgs::parse([ 385 "--profile".to_owned(), 386 "production".to_owned(), 387 "--run-id".to_owned(), 388 "unit".to_owned(), 389 ]) 390 .expect_err("production profile removed"); 391 392 assert!(error.contains("unknown benchmark profile")); 393 } 394 }