bench.rs 14 KB

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