bench.rs 16 KB

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