1use crate::{
2 capture, hardware, metrics, report,
3 suite::{self, Case, Configuration, Suite},
4 util,
5};
6use anyhow::{Context, Result, ensure};
7use clap::{Args, ValueEnum};
8use serde_json::{Value, json};
9use std::{
10 fs,
11 path::{Path, PathBuf},
12};
13
14mod model;
15use model::Model;
16
17#[derive(Args)]
18pub struct Options {
19 pub model_dir: PathBuf,
21 #[arg(long)]
23 pub root: Option<PathBuf>,
24 #[arg(long, default_value = "benchmarks/suite.json")]
25 pub suite: PathBuf,
26 #[arg(long)]
27 pub output: Option<PathBuf>,
28 #[arg(long)]
29 pub configs: Option<String>,
30 #[arg(long)]
31 pub cases: Option<String>,
32 #[arg(long)]
33 pub rounds: Option<usize>,
34 #[arg(long)]
35 pub case_cap: Vec<String>,
36 #[arg(long)]
37 pub binary: Option<PathBuf>,
38 #[arg(long)]
39 pub resume: bool,
40 #[arg(long)]
41 pub build_stores: bool,
42 #[arg(long)]
43 pub allow_battery: bool,
44 #[arg(long)]
45 pub dry_run: bool,
46 #[arg(long)]
48 pub update_readme: bool,
49 #[arg(long)]
51 pub note: Option<String>,
52 #[arg(long)]
54 pub archive: bool,
55 #[arg(long, value_enum)]
58 pub mode: Option<Mode>,
59}
60
61#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
62pub enum Mode {
63 Light,
64 Heavy,
65}
66
67pub const LIGHT_CASES: &str = "code,prose,prefill-long";
69
70pub fn redact_args(args: &mut [Value], config_args: usize) {
72 for (arg, replacement) in args.iter_mut().zip(["<binary>", "<model>"]) {
73 *arg = json!(replacement);
74 }
75
76 let root_flag = 3 + config_args;
77
78 if args.get(root_flag).is_some_and(|arg| arg == "--root")
79 && let Some(value) = args.get_mut(root_flag + 1)
80 {
81 *value = json!("<root>");
82 }
83}
84
85fn path_replacements(
87 binary: &Path,
88 model: &Model,
89 root: Option<&Path>,
90) -> Vec<(String, &'static str)> {
91 [
92 (Some(binary), "<binary>"),
93 (Some(model.directory.as_path()), "<model>"),
94 (model.source_directory.as_deref(), "<model>"),
95 (root, "<root>"),
96 ]
97 .into_iter()
98 .filter_map(|(path, label)| path.map(|p| (p.to_string_lossy().into_owned(), label)))
99 .collect()
100}
101
102fn redact_paths(text: &str, replacements: &[(String, &str)]) -> String {
103 let mut text = text.to_owned();
104
105 for (path, replacement) in replacements {
106 if !path.is_empty() {
107 text = text.replace(path, replacement);
108 }
109 }
110
111 text
112}
113
114fn redact_saved_metadata(report: &mut Value, binary: &Path, model: &Model, root: Option<&Path>) {
115 let saved_binary = report["provenance"]["binary"]
116 .as_str()
117 .unwrap_or("")
118 .to_owned();
119 let saved_model = report["provenance"]["model"]
120 .as_str()
121 .unwrap_or("")
122 .to_owned();
123 let mut replacements = path_replacements(binary, model, root);
124
125 replacements.insert(0, (saved_binary, "<binary>"));
126 replacements.insert(0, (saved_model, "<model>"));
127
128 let configurations = report["configurations"].clone();
129
130 for key in ["runs", "previous_attempts"] {
133 if let Some(runs) = report.get_mut(key).and_then(Value::as_array_mut) {
134 for run in runs {
135 let config_args = configurations
136 .as_array()
137 .and_then(|configs| {
138 configs
139 .iter()
140 .find(|config| config["id"] == run["configuration"])
141 })
142 .and_then(|config| config["args"].as_array())
143 .map_or(0, Vec::len);
144
145 if let Some(args) = run["args"].as_array_mut() {
146 redact_args(args, config_args);
147 }
148
149 if let Some(error) = run["error"].as_str() {
150 run["error"] = json!(redact_paths(error, &replacements));
151 }
152 }
153 }
154 }
155
156 let provenance = &mut report["provenance"];
157 provenance["binary"] = json!("<binary>");
158 provenance["model"] = json!("<model>");
159
160 if let Some(platform) = provenance["platform"].as_str() {
161 let fields: Vec<_> = platform.split_whitespace().collect();
162
163 if fields.first() == Some(&"Darwin") && fields.len() > 3 {
166 provenance["platform"] = json!(format!(
167 "{} {} {}",
168 fields[0],
169 fields[2],
170 fields.last().unwrap()
171 ));
172 }
173 }
174
175 if let Some(settings) = report.get_mut("settings") {
176 settings["suite"] = json!("<suite>");
177 }
178}
179
180fn set_caps(cases: &mut [Case], overrides: &[String]) -> Result<()> {
181 for value in overrides {
182 let (id, limit) = value
183 .split_once('=')
184 .context("--case-cap requires ID=TOKENS")?;
185 let limit = limit.parse()?;
186
187 ensure!(limit > 0, "token cap must be positive");
188
189 cases
190 .iter_mut()
191 .find(|c| c.id == id)
192 .with_context(|| format!("unknown case: {id}"))?
193 .max_tokens = limit;
194 }
195
196 Ok(())
197}
198
199fn check_stores(model: &Model, configs: &[Configuration], allow_build: bool) -> Result<()> {
200 ensure!(
201 model.precisions.contains(&4),
202 "model must already be packed"
203 );
204
205 for config in configs {
206 let Some(bits) = config.store_bits else {
207 continue;
208 };
209 let present = model.precisions.contains(&bits);
210
211 ensure!(
212 present || allow_build,
213 "{bits}-bit store missing; select cached configurations or pass --build-stores"
214 );
215
216 if !present {
217 eprintln!("Allowing first-use {bits}-bit construction, reported in load time.");
218 }
219 }
220
221 Ok(())
222}
223
224pub fn raise_saved_caps(out: &Path, report: &mut Value, signature: Value) -> Result<()> {
225 let revision = json!({"previous_signature":report["signature"],"utc_changed":util::utc()?,"reason":"Raised answer safety caps; prompts, context, binary, and model unchanged."});
226
227 append(report, "suite_revisions", revision);
228
229 let runs = std::mem::take(report["runs"].as_array_mut().context("report runs")?);
230
231 for mut record in runs {
232 let args = record["args"].as_array().context("sample args")?;
233 let pos = args
234 .iter()
235 .position(|a| a == "--max-tokens")
236 .context("sample token cap")?;
237 let old_cap = args[pos + 1]
238 .as_str()
239 .context("sample cap value")?
240 .parse::<u64>()?;
241 let cap = signature["cases"]
242 .as_array()
243 .context("signature cases")?
244 .iter()
245 .find(|c| c["id"] == record["case"])
246 .context("case missing")?["max_tokens"]
247 .as_u64()
248 .context("token cap")?;
249
250 if record["status"] != "incomplete" || cap <= old_cap {
251 append(report, "runs", record);
252
253 continue;
254 }
255
256 let archive = format!(
257 "attempts/{}-cap-{old_cap}.txt",
258 record["id"].as_str().context("sample id")?
259 );
260
261 fs::create_dir_all(out.join("attempts"))?;
262 fs::rename(
263 out.join(record["output"].as_str().context("sample output")?),
264 out.join(&archive),
265 )?;
266
267 record["output"] = json!(archive);
268
269 append(report, "previous_attempts", record);
270 }
271
272 report["cases"] = signature["cases"].clone();
273 report["signature"] = signature;
274
275 Ok(())
276}
277
278pub fn append(report: &mut Value, key: &str, value: Value) {
279 report
280 .as_object_mut()
281 .unwrap()
282 .entry(key.to_owned())
283 .or_insert_with(|| json!([]))
284 .as_array_mut()
285 .unwrap()
286 .push(value);
287}
288
289fn record_result(
290 out: &Path,
291 config: &Configuration,
292 case: &Case,
293 capture: &capture::Captured,
294 record: &mut Value,
295 allow_build: bool,
296 metal_limit_gb: f64,
297) -> Result<()> {
298 if let Some(cycle) = &capture.cycle {
299 record["status"] = json!("cycling");
300
301 anyhow::bail!(
302 "stopped after four repeated word blocks: {}",
303 cycle["example"]
304 );
305 }
306
307 ensure!(
308 capture.code == 0,
309 "engine exited {}: {}",
310 capture.code,
311 capture
312 .stderr
313 .chars()
314 .rev()
315 .take(3000)
316 .collect::<String>()
317 .chars()
318 .rev()
319 .collect::<String>()
320 );
321
322 let m = metrics::parse(&capture.stderr)?;
323 record["metrics"] = m.clone();
324
325 ensure!(
326 m["metal_gb"].as_f64().unwrap() <= metal_limit_gb,
327 "reported Metal allocations exceeded this machine's {metal_limit_gb:.1} GB of memory"
328 );
329 ensure!(
330 capture.stable_power(),
331 "power source changed during this sample"
332 );
333 ensure!(
334 allow_build || m["store_build_seconds"].is_null(),
335 "unexpected store construction; use --build-stores"
336 );
337
338 let reason = if m["output_tokens"].as_u64().unwrap() >= case.max_tokens as u64 {
339 "length"
340 } else {
341 "eos"
342 };
343 record["finish_reason"] = json!(reason);
344
345 if case.stop == "eos" && reason != "eos" {
346 record["status"] = json!("incomplete");
347
348 anyhow::bail!("incomplete answer: reached the token safety cap before EOS");
349 }
350
351 ensure!(
352 case.stop != "length" || reason == "length",
353 "fixed-length decode did not reach its token count"
354 );
355
356 if case.kind == "svg" {
357 record["status"] = json!("invalid_svg");
358 let text = fs::read_to_string(out.join(record["output"].as_str().unwrap()))?;
359 let (svg, elements) = metrics::extract_svg(&text)?;
360 let path = format!("pelicans/{}.svg", config.id);
361
362 fs::write(out.join(&path), format!("{svg}\n"))?;
363
364 record["svg"] = json!(path);
365 record["svg_elements"] = json!(elements);
366 record["within_element_budget"] = json!(elements <= 120);
367 }
368
369 record["status"] = json!("ok");
370
371 Ok(())
372}
373
374struct Run<'a> {
375 binary: &'a Path,
376 model: &'a Model,
377 out: &'a Path,
378 options: &'a Options,
379 max_ctx: usize,
380 memory_gb: f64,
382}
383
384impl Run<'_> {
385 fn sample(&self, config: &Configuration, case: &Case, round: usize) -> Result<Value> {
386 let id = format!("r{}-{}-{}", round + 1, case.id, config.id);
387 let output = format!("outputs/{id}.txt");
388 let mut args = vec![
389 self.binary.display().to_string(),
390 self.model.reference.clone(),
391 case.prompt(),
392 ];
393
394 args.extend(config.args.clone());
395
396 if let Some(root) = &self.options.root {
397 args.extend(["--root".into(), root.display().to_string()]);
398 }
399
400 args.extend([
401 "--max-tokens".into(),
402 case.max_tokens.to_string(),
403 "--max-ctx".into(),
404 case.max_ctx.unwrap_or(self.max_ctx).to_string(),
405 ]);
406
407 if case.stop == "length" {
408 args.push("--no-eos".into());
409 }
410
411 eprintln!("[{id}] starting");
412
413 let current = Model::inspect(
415 self.binary,
416 self.model.reference.as_ref(),
417 self.options.root.as_deref(),
418 )?;
419 let present = config
420 .store_bits
421 .is_none_or(|bits| current.precisions.contains(&bits));
422 let c = capture::run(
423 &args,
424 &self.out.join(&output),
425 self.options.allow_battery,
426 None,
427 )?;
428 let mut record = json!({
429 "id": id,
430 "configuration": config.id,
431 "case": case.id,
432 "round": round + 1,
433 "args": args,
434 "output": output,
435 "power_before": c.power_before,
436 "power_after": c.power_after,
437 "power_samples": c.power_samples,
438 "status": "failed",
439 "store_present_before": present,
440 "wall_seconds": c.wall_seconds,
441 "exit_code": c.code,
442 });
443
444 redact_args(record["args"].as_array_mut().unwrap(), config.args.len());
445
446 if let Some(cycle) = &c.cycle {
447 record["cycle"] = cycle.clone();
448 }
449
450 if let Err(error) = record_result(
451 self.out,
452 config,
453 case,
454 &c,
455 &mut record,
456 self.options.build_stores,
457 self.memory_gb,
458 ) {
459 let replacements =
460 path_replacements(self.binary, self.model, self.options.root.as_deref());
461 record["error"] = json!(redact_paths(&error.to_string(), &replacements));
462 }
463
464 eprintln!(
465 "[{id}] {}, tg/s {}",
466 record["status"], record["metrics"]["tg_s"]
467 );
468
469 Ok(record)
470 }
471 fn new_report(
472 &self,
473 signature: Value,
474 configs: &[Configuration],
475 cases: &[Case],
476 ) -> Result<Value> {
477 let Self {
478 binary,
479 model,
480 options,
481 max_ctx,
482 ..
483 } = self;
484
485 let status = util::output(&["git", "status", "--porcelain"])?;
486
487 eprintln!("Describing hardware and sampling store reads.");
488
489 Ok(json!({
490 "version": 1,
491 "signature": signature,
492 "configurations": configs,
493 "cases": cases,
494 "runs": [],
495 "settings": {
496 "suite": "<suite>",
497 "mode": options.mode.map(|m| format!("{m:?}").to_lowercase()),
498 "max_ctx": max_ctx,
499 "case_caps": options.case_cap,
500 "build_stores": options.build_stores,
501 "binary_override": options.binary.is_some(),
502 "pool": "adaptive unless a configuration passes --pool-gb; the server TOML is not read",
503 "environment": "CHERENKOV_* variables are removed from every sample",
504 },
505 "provenance": {
506 "commit": util::output(&["git", "rev-parse", "HEAD"])?,
507 "dirty": !status.is_empty(),
508 "git_status": status,
509 "source_sha256": util::source_digest()?,
510 "binary_sha256": util::digest(binary)?,
511 "binary": "<binary>",
512 "model": "<model>",
513 "model_metadata_sha256": signature["model_metadata_sha256"],
514 "utc_started": util::utc()?,
515 "platform": util::output(&["uname", "-srm"])?,
516 "hardware": util::output(&["sysctl", "-n", "machdep.cpu.brand_string"])?,
517 "memory_bytes": util::output(&["sysctl", "-n", "hw.memsize"])?,
518 "rustc": util::output(&["rustc", "--version"])?,
519 "power": capture::power(),
520 "hardware_detail": hardware::describe(&model.directory),
521 "note": options.note,
522 "method": "fresh processes; rotated interleaved rounds; separate load/prefill/decode; no check; no prefix cache; complete answers through EOS; SVG phase after timings"
523 }
524 }))
525 }
526
527 fn prepare_report(
528 &self,
529 signature: Value,
530 configs: &[Configuration],
531 cases: &[Case],
532 ) -> Result<Value> {
533 let Self {
534 out,
535 options,
536 binary,
537 model,
538 ..
539 } = self;
540
541 if options.resume {
542 let mut report = util::json(&out.join("report.json"))?;
543
544 model.migrate_signature(&mut report["signature"]);
545
546 if let Some(revisions) = report["suite_revisions"].as_array_mut() {
547 for revision in revisions {
548 model.migrate_signature(&mut revision["previous_signature"]);
549 }
550 }
551
552 ensure!(
553 report["signature"]["model_id"].is_string(),
554 "cannot resume: saved report has no matching model identity; choose a new output directory"
555 );
556
557 if report["signature"] != signature {
558 ensure!(
559 suite::only_higher_caps(&report["signature"], &signature),
560 "cannot resume: binary, suite, model, or selections changed"
561 );
562 raise_saved_caps(out, &mut report, signature)?;
563 }
564
565 redact_saved_metadata(&mut report, binary, model, options.root.as_deref());
566
567 return Ok(report);
568 }
569
570 ensure!(
571 !out.exists() || fs::read_dir(out)?.next().is_none(),
572 "output directory is not empty; choose another or --resume"
573 );
574 fs::create_dir_all(out)?;
575
576 self.new_report(signature, configs, cases)
577 }
578}
579
580fn resolve_model(options: &Options) -> Result<(PathBuf, Model)> {
582 if options.binary.is_none() {
583 util::build()?;
584 }
585
586 let binary = options
587 .binary
588 .clone()
589 .unwrap_or_else(|| util::root().join("target/release/cherenkov"))
590 .canonicalize()?;
591 let model = Model::inspect(
592 &binary,
593 options.model_dir.as_os_str(),
594 options.root.as_deref(),
595 )?;
596
597 Ok((binary, model))
598}
599
600pub fn run(options: Options) -> Result<()> {
601 let suite: Suite = serde_json::from_value(util::json(&options.suite)?)?;
602 let light = options.mode == Some(Mode::Light);
603 let mut configs = suite::select(&suite.configurations, options.configs.as_deref(), |c| &c.id)?;
604
605 let resolved = if !options.dry_run || (light && options.configs.is_none()) {
606 Some(resolve_model(&options)?)
607 } else {
608 None
609 };
610
611 if light && options.configs.is_none() {
613 let (_, model) = resolved
614 .as_ref()
615 .context("light mode requires model inspection")?;
616
617 configs.retain(|c| {
618 c.store_bits
619 .is_none_or(|bits| model.precisions.contains(&bits))
620 });
621 }
622
623 let case_selection = options.cases.as_deref().or(light.then_some(LIGHT_CASES));
624 let mut cases = suite::select(&suite.cases, case_selection, |c| &c.id)?;
625
626 set_caps(&mut cases, &options.case_cap)?;
627
628 let rounds = options
629 .rounds
630 .unwrap_or(if light { 1 } else { suite.rounds });
631
632 ensure!(
633 rounds > 0 && !configs.is_empty() && !cases.is_empty(),
634 "rounds and selections must be nonempty"
635 );
636
637 let jobs = suite::schedule(&configs, &cases, rounds);
638
639 if options.dry_run {
640 let plan: Vec<_> = jobs
641 .iter()
642 .map(|&(round, config, case)| {
643 json!({
644 "round": round + 1,
645 "config": configs[config].id,
646 "case": cases[case].id,
647 "max_tokens": cases[case].max_tokens,
648 })
649 })
650 .collect();
651
652 println!("{}", serde_json::to_string_pretty(&plan)?);
653
654 return Ok(());
655 }
656
657 let (binary, model) = resolved.context("benchmark model was not resolved")?;
658
659 check_stores(&model, &configs, options.build_stores)?;
660
661 let default_name = match options.mode {
662 Some(Mode::Light) => format!("{}-light", util::utc()?),
663 _ => util::utc()?,
664 };
665 let out = util::absolute(
666 &options
667 .output
668 .clone()
669 .unwrap_or(util::root().join("results").join(default_name)),
670 )?;
671 let metadata = model.metadata()?;
672 let memory_gb = util::output(&["sysctl", "-n", "hw.memsize"])?
673 .parse::<f64>()
674 .context("hw.memsize")?
675 / 1e9;
676
677 let signature = json!({
678 "model_metadata_sha256": metadata,
679 "binary_sha256": util::digest(&binary)?,
680 "suite_sha256": util::digest(&options.suite)?,
681 "model": "<model>",
682 "model_id": model.reference,
683 "configs": configs,
684 "cases": cases,
685 "rounds": rounds,
686 "allow_battery": options.allow_battery,
687 });
688 let runner = Run {
689 binary: &binary,
690 model: &model,
691 out: &out,
692 options: &options,
693 max_ctx: suite.max_ctx,
694 memory_gb,
695 };
696 let mut report = runner.prepare_report(signature, &configs, &cases)?;
697
698 fs::create_dir_all(out.join("outputs"))?;
699 fs::create_dir_all(out.join("pelicans"))?;
700 report::write(&out, &report)?;
701
702 for (r, c, t) in jobs {
703 let id = format!("r{}-{}-{}", r + 1, cases[t].id, configs[c].id);
704
705 if report["runs"].as_array().unwrap().iter().any(|v| {
706 v["id"] == id
707 && matches!(
708 v["status"].as_str(),
709 Some("ok" | "incomplete" | "invalid_svg" | "cycling")
710 )
711 }) {
712 continue;
713 }
714
715 let record = runner.sample(&configs[c], &cases[t], r)?;
716 let failed = record["status"] == "failed";
717 let error = record["error"].clone();
718
719 report["runs"]
720 .as_array_mut()
721 .unwrap()
722 .retain(|v| v["id"] != id);
723 append(&mut report, "runs", record);
724 report::write(&out, &report)?;
725 ensure!(
726 !failed,
727 "{error}; outputs saved in {}; fix and --resume",
728 out.display()
729 );
730 }
731
732 report["utc_finished"] = json!(util::utc()?);
733
734 report::write(&out, &report)?;
735
736 if options.update_readme {
737 crate::readme::update(&out)?;
738 }
739
740 println!("{}", out.join("gallery.html").display());
741
742 if options.archive || options.mode.is_some() {
743 let zip = hardware::archive(&out)?;
744
745 println!("{}", zip.display());
746 eprintln!("Attach the archive to a pull request or issue to share this run.");
747 }
748
749 Ok(())
750}