bench.rs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. //! `sm121 bench` — run `tool-eval-bench` against a live serve and land the
  2. //! reports in the repo.
  3. //!
  4. //! The bench tool needs babysitting this command does instead of the
  5. //! operator: it must run in a scratch cwd (it writes
  6. //! `runs/<date>/<run>.md` relative to cwd), needs an absolute `--json-file`
  7. //! path, and the `.md` report must be copied into
  8. //! `recipes/<lane>/reports/<UTC-date>_<label>.{md,json,log}` by hand.
  9. //! Campaign convention (seed 42, hardmode, perf, concurrency 1,2) is the
  10. //! default; `sm121 bench -d` prints the exact command.
  11. use std::fs;
  12. use std::io::{Read, Write};
  13. use std::path::{Path, PathBuf};
  14. use std::process::Stdio;
  15. use std::sync::{Arc, Mutex};
  16. use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
  17. use anyhow::Result;
  18. use crate::ansi::strip_ansi_step;
  19. use crate::config::{
  20. Config, Recipe, default_config_dir, load_recipe, local_path, select_cluster_by_name,
  21. };
  22. /// Fields of the `sm121 bench` subcommand (mirrored by the clap variant in
  23. /// `cli.rs`). Served model name and port come from the recipe
  24. /// (`--served-model-name` in args / `port`); the bench binary is looked up on PATH.
  25. #[derive(Clone)]
  26. pub struct Args {
  27. pub recipe: String,
  28. pub cluster: String,
  29. pub label: String,
  30. pub concurrency: String,
  31. pub seed: u64,
  32. }
  33. /// How long to poll the API for readiness before giving up.
  34. const READY_TIMEOUT_SECS: u64 = 900;
  35. /// Readiness poll interval.
  36. const READY_POLL_SECS: u64 = 5;
  37. /// Locate the recipe file (same search order as [`load_recipe`]) so reports
  38. /// land next to it.
  39. fn recipe_file(recipe_name: &str, sources: &[String]) -> Option<PathBuf> {
  40. let mut paths: Vec<PathBuf> = vec![
  41. PathBuf::from(format!("recipes/{}/recipe.toml", recipe_name)),
  42. PathBuf::from(format!("{}/recipe.toml", recipe_name)),
  43. ];
  44. for src in sources {
  45. paths.push(local_path(src).join(recipe_name).join("recipe.toml"));
  46. }
  47. if let Some(dir) = default_config_dir() {
  48. paths.push(dir.join("recipes").join(recipe_name).join("recipe.toml"));
  49. }
  50. paths.into_iter().find(|p| p.is_file())
  51. }
  52. /// Served model name from the recipe's `--served-model-name` arg.
  53. fn served_model(recipe: &Recipe) -> Option<String> {
  54. let args = recipe.args.to_vec();
  55. args.iter()
  56. .position(|s| s == "--served-model-name")
  57. .and_then(|i| args.get(i + 1))
  58. .cloned()
  59. }
  60. /// `YYYY-MM-DD_HHMMSS` in UTC (no chrono dependency).
  61. fn utc_stamp() -> String {
  62. let secs: i64 = SystemTime::now()
  63. .duration_since(UNIX_EPOCH)
  64. .map(|d| d.as_secs() as i64)
  65. .unwrap_or(0);
  66. let (h, m, s) = ((secs % 86400) / 3600, (secs % 3600) / 60, secs % 60);
  67. let (y, mo, d) = civil_date(secs / 86400);
  68. format!(
  69. "{y:04}-{mo:02}-{d:02}_{h:02}{m:02}{s:02}",
  70. y = y,
  71. mo = mo,
  72. d = d
  73. )
  74. }
  75. /// Days since 1970-01-01 → (year, month, day); Howard Hinnant's civil_from_days.
  76. fn civil_date(z: i64) -> (i64, i64, i64) {
  77. let z = z + 719_468;
  78. let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
  79. let doe = z - era * 146_097;
  80. let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
  81. let y = yoe + era * 400;
  82. let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
  83. let mp = (5 * doy + 2) / 153;
  84. let d = doy - (153 * mp + 2) / 5 + 1;
  85. let mo = if mp < 10 { mp + 3 } else { mp - 9 };
  86. let y = if mo <= 2 { y + 1 } else { y };
  87. (y, mo, d)
  88. }
  89. /// Keep the label filesystem-safe.
  90. fn sanitize(label: &str) -> String {
  91. label
  92. .chars()
  93. .map(|c| {
  94. if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
  95. c
  96. } else {
  97. '-'
  98. }
  99. })
  100. .collect()
  101. }
  102. /// Poll `GET <base>/models` until it answers (engine up).
  103. async fn wait_ready(base_url: &str) -> Result<()> {
  104. let client = reqwest::Client::builder()
  105. .timeout(Duration::from_secs(10))
  106. .build()?;
  107. let deadline = Instant::now() + Duration::from_secs(READY_TIMEOUT_SECS);
  108. let mut since_last_note = Instant::now();
  109. loop {
  110. match client.get(format!("{base_url}/models")).send().await {
  111. Ok(r) if r.status().is_success() => return Ok(()),
  112. Ok(_) => {}
  113. Err(_) => {}
  114. }
  115. if since_last_note.elapsed() >= Duration::from_secs(30) {
  116. since_last_note = Instant::now();
  117. eprintln!(
  118. "bench: waiting for API at {base_url} (up to {} s)...",
  119. READY_TIMEOUT_SECS
  120. );
  121. }
  122. if Instant::now() > deadline {
  123. anyhow::bail!("bench: API at {base_url} not ready after {READY_TIMEOUT_SECS} s");
  124. }
  125. tokio::time::sleep(Duration::from_secs(READY_POLL_SECS)).await;
  126. }
  127. }
  128. /// Tee one output pipe to the console and the log file.
  129. ///
  130. /// The console copy keeps the child's ANSI colors; the file copy is
  131. /// stripped (state machine below) so the saved .log stays readable in
  132. /// `sm121 report` and under grep/less.
  133. ///
  134. /// The read buffer must not be empty: `read()` on a zero-length slice
  135. /// returns 0 (interpreted as EOF), so an empty `Vec` would silently sever
  136. /// the pipe — the child then dies on its first stderr write with EPIPE
  137. /// (silent exit 120 in CPython).
  138. fn pump<R: Read>(mut reader: R, to_console: bool, file: Arc<Mutex<fs::File>>) {
  139. let mut buf = vec![0u8; 8192];
  140. let mut carry = Vec::new(); // trailing partial escape across chunks
  141. let mut clean = Vec::new();
  142. loop {
  143. let n = match reader.read(&mut buf) {
  144. Ok(n) => n,
  145. Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
  146. Err(e) => panic!("reading bench output pipe: {e}"),
  147. };
  148. if n == 0 {
  149. break;
  150. }
  151. if to_console {
  152. std::io::stdout().write_all(&buf[..n]).ok();
  153. }
  154. carry.extend_from_slice(&buf[..n]);
  155. strip_ansi_step(&mut carry, &mut clean);
  156. if !clean.is_empty() {
  157. if let Ok(mut f) = file.lock() {
  158. f.write_all(&clean).ok();
  159. }
  160. clean.clear();
  161. }
  162. }
  163. }
  164. /// Run the bench tool in `cwd`, teeing stdout+stderr to the console and the
  165. /// log file; returns the exit code.
  166. ///
  167. /// The child runs under a pty (`script -q /dev/null`) instead of raw pipes:
  168. /// tool-eval-bench's rich Live display deadlocks on a non-tty stdout pipe
  169. /// right after the perf phase (deterministic on the 2x lane; 4x runs got
  170. /// lucky) while it runs clean on a tty, exactly as in an interactive
  171. /// terminal (2026-09-09). `script` is BSD/macOS form (the launcher is a
  172. /// Mac tool); if it is absent we fall back to the direct exec.
  173. ///
  174. /// The pty makes the tool think it is interactive — rich redraws its
  175. /// spinner/progress bars in place (\r soup on one line) and that soup
  176. /// leaked into the saved .log, even shadowing real parser cells
  177. /// (glm53-flash-eugr-2x, 2026-09-12). `TTY_COMPATIBLE=0` is rich's own
  178. /// knob for this: the console renders non-interactively (progress bars
  179. /// stay silent during the run, one final frame at the end) while the pty
  180. /// keeps the deadlock workaround intact. `NO_COLOR=1` covers non-rich
  181. /// output. Verified locally under `script`: 1.3 KB/8 CR of soup → 141
  182. /// bytes/1 static final frame.
  183. fn run_tool(bin: &str, args: &[String], cwd: &Path, log_path: &Path) -> Result<i32> {
  184. let file = Arc::new(Mutex::new(fs::File::create(log_path)?));
  185. let mut cmd = if std::process::Command::new("/usr/bin/script")
  186. .arg("-q")
  187. .arg("/dev/null")
  188. .arg("true")
  189. .output()
  190. .is_ok()
  191. {
  192. // Wrap in `sh` + `stty -onlcr`: the macOS pty's ONLCR would turn every
  193. // `\n` the tool writes into `\r\n` on disk, and the report viewer
  194. // then renders each line twice (blank line after every line).
  195. // `stty -onlcr` disables the post-processing at the source; `exec`
  196. // replaces the shell so the tool keeps the pty and the exit code.
  197. let mut c = std::process::Command::new("/usr/bin/script");
  198. c.arg("-q");
  199. c.arg("/dev/null");
  200. c.arg("sh");
  201. c.arg("-c");
  202. c.arg("stty -onlcr 2>/dev/null; exec \"$@\"");
  203. c.arg("sh"); // $0 inside the -c script
  204. c.arg(bin);
  205. c.args(args);
  206. c
  207. } else {
  208. let mut c = std::process::Command::new(bin);
  209. c.args(args);
  210. c
  211. };
  212. cmd.current_dir(cwd);
  213. cmd.env("TTY_COMPATIBLE", "0");
  214. cmd.env("NO_COLOR", "1");
  215. cmd.stdout(Stdio::piped());
  216. cmd.stderr(Stdio::piped());
  217. let mut child = cmd.spawn()?;
  218. let stdout_pipe = child.stdout.take().expect("stdout pipe");
  219. let stderr_pipe = child.stderr.take().expect("stderr pipe");
  220. let f1 = file.clone();
  221. let t1 = std::thread::spawn(move || pump(stdout_pipe, true, f1));
  222. let t2 = std::thread::spawn(move || pump(stderr_pipe, false, file));
  223. let status = child.wait()?;
  224. t1.join().ok();
  225. t2.join().ok();
  226. Ok(status.code().unwrap_or(-1))
  227. }
  228. /// Newest `.md` under `scratch/runs/` (the tool's `runs/<date>/<run>.md`).
  229. fn latest_md(scratch: &Path) -> Option<PathBuf> {
  230. let runs = scratch.join("runs");
  231. let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
  232. let mut stack = vec![runs];
  233. while let Some(dir) = stack.pop() {
  234. let entries = fs::read_dir(dir).ok()?;
  235. for e in entries.flatten() {
  236. let p = e.path();
  237. if p.is_dir() {
  238. stack.push(p);
  239. } else if p.extension().is_some_and(|x| x == "md") {
  240. let t = e
  241. .metadata()
  242. .and_then(|m| m.modified())
  243. .ok()
  244. .unwrap_or(SystemTime::UNIX_EPOCH);
  245. if best.as_ref().is_none_or(|(bt, _)| t > *bt) {
  246. best = Some((t, p));
  247. }
  248. }
  249. }
  250. }
  251. best.map(|(_, p)| p)
  252. }
  253. /// Run the bench and land `recipes/<lane>/reports/<stamp>_<label>.{md,json,log}`.
  254. pub async fn run(config: &Config, a: &Args, dry_run: bool) -> Result<()> {
  255. let recipe = load_recipe(&a.recipe, &config.recipe_sources).await?;
  256. let file = recipe_file(&a.recipe, &config.recipe_sources)
  257. .ok_or_else(|| anyhow::anyhow!("recipe file not found for '{}'", a.recipe))?
  258. .canonicalize()?;
  259. let cluster = select_cluster_by_name(config, &a.cluster)?;
  260. let head = match cluster.nodes.iter().find(|n| n.rank == 0) {
  261. Some(n) => n,
  262. None => cluster
  263. .nodes
  264. .first()
  265. .ok_or_else(|| anyhow::anyhow!("cluster '{}' has no nodes", cluster.name))?,
  266. };
  267. let port = recipe.port;
  268. let model = served_model(&recipe).ok_or_else(|| {
  269. anyhow::anyhow!(
  270. "recipe '{}' has no --served-model-name in args — add it to the recipe",
  271. a.recipe
  272. )
  273. })?;
  274. let base_url = format!("http://{}:{}/v1", head.ssh_ip, port);
  275. let stamp = utc_stamp();
  276. let label = sanitize(&a.label);
  277. let stem = format!("{stamp}_{label}");
  278. let reports_dir = file
  279. .parent()
  280. .ok_or_else(|| anyhow::anyhow!("no parent dir for {}", file.display()))?
  281. .join("reports");
  282. let json_path = reports_dir.join(format!("{stamp}_{label}.json"));
  283. let log_path = reports_dir.join(format!("{stamp}_{label}.log"));
  284. let scratch = dirs::home_dir()
  285. .ok_or_else(|| anyhow::anyhow!("no home dir"))?
  286. .join(".cache")
  287. .join("sm121")
  288. .join("bench")
  289. .join(format!("{stamp}_{label}"));
  290. let bin = "tool-eval-bench".to_string();
  291. let args = vec![
  292. "--base-url".into(),
  293. base_url.clone(),
  294. "--model".into(),
  295. model.clone(),
  296. "--seed".into(),
  297. a.seed.to_string(),
  298. "--hardmode".into(),
  299. "--perf".into(),
  300. "--concurrency".into(),
  301. a.concurrency.clone(),
  302. "--json-file".into(),
  303. json_path.to_string_lossy().to_string(),
  304. ];
  305. if dry_run {
  306. println!("bench: cwd {}", scratch.display());
  307. println!("bench: {} {}", bin, args.join(" "));
  308. println!("bench: reports -> {}", reports_dir.display());
  309. println!(
  310. "bench: run package -> {}.recipe.toml + {}.manifest.json",
  311. stem, stem
  312. );
  313. return Ok(());
  314. }
  315. fs::create_dir_all(&scratch)?;
  316. fs::create_dir_all(&reports_dir)?;
  317. eprintln!("bench: waiting for {base_url} ...");
  318. wait_ready(&base_url).await?;
  319. eprintln!("bench: API ready — running {} (seed {})", bin, a.seed);
  320. let code = run_tool(&bin, &args, &scratch, &log_path)?;
  321. if code != 0 {
  322. anyhow::bail!(
  323. "bench: tool exited with {code} (log: {})",
  324. log_path.display()
  325. );
  326. }
  327. let md_src = latest_md(&scratch)
  328. .ok_or_else(|| anyhow::anyhow!("no report .md under {}", scratch.join("runs").display()))?;
  329. let md_dest = reports_dir.join(format!("{stamp}_{label}.md"));
  330. fs::copy(&md_src, &md_dest)?;
  331. // Summary: final score from the JSON + the perf table from the log.
  332. let j: serde_json::Value = serde_json::from_str(&fs::read_to_string(&json_path)?)?;
  333. let sc = &j["scores"];
  334. let completion = sc["completion_rate"]
  335. .as_f64()
  336. .map(|v| v.to_string())
  337. .unwrap_or_default();
  338. eprintln!(
  339. "bench: final score {} / {} ({} points), completion {completion}",
  340. sc["final_score"], sc["max_points"], sc["total_points"]
  341. );
  342. for line in fs::read_to_string(&log_path)?.lines() {
  343. if line.contains("pp2048") && line.contains("tg128") {
  344. println!("{line}");
  345. }
  346. }
  347. crate::bench_artifacts::write_run_package(&crate::bench_artifacts::RunPackage {
  348. config,
  349. cluster: &cluster,
  350. recipe: &recipe,
  351. recipe_file: &file,
  352. stem: stem.as_str(),
  353. reports_dir: &reports_dir,
  354. bench_args: &args,
  355. bench_json: &j,
  356. })
  357. .await?;
  358. eprintln!("bench: reports landed in {}", reports_dir.display());
  359. Ok(())
  360. }
  361. #[cfg(test)]
  362. mod tests {
  363. use super::*;
  364. use std::process::Command;
  365. #[test]
  366. fn stamp_format() {
  367. let s = utc_stamp();
  368. assert_eq!(s.len(), 17, "{s}");
  369. assert_eq!(&s[4..5], "-");
  370. assert_eq!(&s[7..8], "-");
  371. assert_eq!(&s[10..11], "_");
  372. }
  373. #[test]
  374. fn civil_date_known_values() {
  375. assert_eq!(civil_date(0), (1970, 1, 1));
  376. assert_eq!(civil_date(19_723), (2024, 1, 1));
  377. assert_eq!(civil_date(20_818), (2026, 12, 31));
  378. }
  379. /// Regression: the pump must not treat a not-yet-written pipe as EOF.
  380. /// `read()` into a zero-length buffer returns 0, which the old pump
  381. /// treated as EOF — closing the pipe and killing the child on its first
  382. /// stderr write with EPIPE (silent exit 120 in CPython).
  383. #[test]
  384. fn pump_keeps_pipe_open_until_child_exits() {
  385. let tmp =
  386. std::env::temp_dir().join(format!("sm121-bench-pump-test-{}", std::process::id()));
  387. fs::create_dir_all(&tmp).unwrap();
  388. let log = tmp.join("pump.log");
  389. let mut child = Command::new("sh")
  390. .args(["-c", "sleep 0.3; echo late-output >&2"])
  391. .stdout(Stdio::piped())
  392. .stderr(Stdio::piped())
  393. .spawn()
  394. .unwrap();
  395. let out = child.stdout.take().unwrap();
  396. let err = child.stderr.take().unwrap();
  397. let f = Arc::new(Mutex::new(fs::File::create(&log).unwrap()));
  398. let f1 = f.clone();
  399. let t1 = std::thread::spawn(move || pump(out, false, f1));
  400. let t2 = std::thread::spawn(move || pump(err, false, f));
  401. let status = child.wait().unwrap();
  402. t1.join().unwrap();
  403. t2.join().unwrap();
  404. let body = fs::read_to_string(&log).unwrap();
  405. assert_eq!(status.code(), Some(0), "child must not hit EPIPE: {body}");
  406. assert!(body.contains("late-output"), "piped stderr lost: {body:?}");
  407. let _ = fs::remove_dir_all(&tmp);
  408. }
  409. #[test]
  410. fn sanitize_replaces_bad_chars() {
  411. assert_eq!(sanitize("arm B: 2x"), "arm-B--2x");
  412. assert_eq!(sanitize("ok_name-2.v3"), "ok_name-2.v3");
  413. }
  414. /// The non-interactive env must reach the child through the `script`
  415. /// pty wrapper — rich reads TTY_COMPATIBLE from the child's env. Also:
  416. /// `stty -onlcr` must hold, so the saved log is LF-only (CRLF made the
  417. /// report viewer render every line twice).
  418. #[test]
  419. fn run_tool_passes_non_interactive_env() {
  420. let tmp = std::env::temp_dir().join(format!("sm121-bench-env-test-{}", std::process::id()));
  421. fs::create_dir_all(&tmp).unwrap();
  422. let log = tmp.join("env.log");
  423. let code = run_tool(
  424. "sh",
  425. &[
  426. "-c".to_string(),
  427. "echo TTY=$TTY_COMPATIBLE NO=$NO_COLOR".to_string(),
  428. ],
  429. &tmp,
  430. &log,
  431. )
  432. .unwrap();
  433. let body = fs::read_to_string(&log).unwrap();
  434. let _ = fs::remove_dir_all(&tmp);
  435. assert_eq!(code, 0, "exit code, log: {body:?}");
  436. assert!(body.contains("TTY=0"), "TTY_COMPATIBLE lost: {body:?}");
  437. assert!(body.contains("NO=1"), "NO_COLOR lost: {body:?}");
  438. assert!(!body.contains('\r'), "pty ONLCR leaked CRLF: {body:?}");
  439. }
  440. #[test]
  441. fn served_model_parsed_from_args() {
  442. let content = fs::read_to_string("recipes/glm53-flash-r27-spark/recipe.toml")
  443. .expect("repo recipe for test");
  444. let r: Recipe = toml::from_str(&content).expect("recipe toml");
  445. assert_eq!(served_model(&r).as_deref(), Some("glm53"));
  446. let mut r2 = r.clone();
  447. r2.args = crate::pre_serve::RecipeArgs::List(vec!["--trust-remote-code".into()]);
  448. assert!(served_model(&r2).is_none());
  449. }
  450. }