bench.rs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  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::path::{Path, PathBuf};
  13. use std::process::Stdio;
  14. use std::sync::{Arc, Mutex};
  15. use anyhow::Result;
  16. use crate::config::{Config, Recipe, load_recipe, select_cluster_by_name};
  17. /// Fields of the `sm121 bench` subcommand (mirrored by the clap variant in
  18. /// `cli.rs`). Served model name and port come from the recipe
  19. /// (`--served-model-name` in args / `port`); the bench binary is looked up on PATH.
  20. #[derive(Clone)]
  21. pub struct Args {
  22. pub recipe: String,
  23. pub cluster: String,
  24. pub label: String,
  25. pub concurrency: String,
  26. pub seed: u64,
  27. /// Scenario streams run concurrently (tool `--parallel`). None = the
  28. /// tool default (serial), preserving earlier lanes' comparability.
  29. pub parallel: Option<u32>,
  30. /// Skip the scenario phase (tool `--perf-only`).
  31. pub perf_only: bool,
  32. /// Skip the context-depth suite (16K..256K) — run only the main pass
  33. /// (scenarios + the typical-depth perf rows).
  34. pub no_depth: 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. /// When set, the tool-eval-bench invocation runs on this host over SSH
  40. /// (the local machine only orchestrates), keeping the bench client's own
  41. /// network (e.g. a WiFi laptop) off the measurement path. The tokenizer
  42. /// is uploaded to the host; the report + run package land locally. The
  43. /// host must have `tool-eval-bench` on PATH and reach the serve.
  44. pub ssh_host: Option<String>,
  45. }
  46. /// Served model name from the recipe's `--served-model-name` arg.
  47. fn served_model(recipe: &Recipe) -> Option<String> {
  48. let args = recipe.args.to_vec();
  49. args.iter()
  50. .position(|s| s == "--served-model-name")
  51. .and_then(|i| args.get(i + 1))
  52. .cloned()
  53. }
  54. /// Keep the label filesystem-safe.
  55. fn sanitize(label: &str) -> String {
  56. label
  57. .chars()
  58. .map(|c| {
  59. if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
  60. c
  61. } else {
  62. '-'
  63. }
  64. })
  65. .collect()
  66. }
  67. /// Run the bench tool in `cwd`, teeing stdout+stderr to the console and the
  68. /// log file; returns the exit code.
  69. ///
  70. /// `append_log = false` truncates the log (main campaign phase); `true`
  71. /// appends (the depth-suite phase shares one log so its rows land in the
  72. /// report's Throughput table).
  73. ///
  74. /// The child runs under a pty (`script -q /dev/null`) instead of raw pipes:
  75. /// tool-eval-bench's rich Live display deadlocks on a non-tty stdout pipe
  76. /// right after the perf phase (deterministic on the 2x lane; 4x runs got
  77. /// lucky) while it runs clean on a tty (2026-09-09). The wrapper is
  78. /// macOS-only (BSD `script`); elsewhere we fall back to the direct exec.
  79. ///
  80. /// The pty makes the tool think it is interactive — rich redraws its
  81. /// spinner/progress bars in place (\r soup on one line) that once leaked
  82. /// into the saved .log (glm53-flash-eugr-2x, 2026-09-12).
  83. /// `TTY_COMPATIBLE=0` (rich's own knob) renders non-interactively (bars
  84. /// silent, one final frame) while the pty keeps the deadlock workaround;
  85. /// `NO_COLOR=1` covers non-rich output.
  86. fn run_tool(
  87. bin: &str,
  88. args: &[String],
  89. cwd: &Path,
  90. log_path: &Path,
  91. append_log: bool,
  92. ssh_host: Option<&str>,
  93. ) -> Result<i32> {
  94. let file = Arc::new(Mutex::new(if append_log {
  95. fs::OpenOptions::new()
  96. .create(true)
  97. .append(true)
  98. .open(log_path)?
  99. } else {
  100. fs::File::create(log_path)?
  101. }));
  102. // The command: local (the TTY wrapper or direct exec) or remote (SSH to
  103. // the host — a pty via `ssh -t` keeps the rich Live display from
  104. // deadlocking on the non-tty pipe; `stty -onlcr` keeps the log LF-only;
  105. // TTY_COMPATIBLE/NO_COLOR match the local run). For the remote host the
  106. // `cwd` is a remote path embedded in the shell string, so the local
  107. // current_dir/env are skipped.
  108. let mut cmd = if let Some(host) = ssh_host {
  109. crate::bench_remote::remote_command(host, cwd, bin, args)
  110. } else if cfg!(target_os = "macos")
  111. && 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. if ssh_host.is_none() {
  139. cmd.current_dir(cwd);
  140. cmd.env("TTY_COMPATIBLE", "0");
  141. cmd.env("NO_COLOR", "1");
  142. }
  143. cmd.stdout(Stdio::piped());
  144. cmd.stderr(Stdio::piped());
  145. let mut child = cmd.spawn()?;
  146. let stdout_pipe = child.stdout.take().expect("stdout pipe");
  147. let stderr_pipe = child.stderr.take().expect("stderr pipe");
  148. let f1 = file.clone();
  149. let t1 = std::thread::spawn(move || crate::bench_io::pump(stdout_pipe, true, f1));
  150. let t2 = std::thread::spawn(move || crate::bench_io::pump(stderr_pipe, false, file));
  151. let status = child.wait()?;
  152. t1.join().ok();
  153. t2.join().ok();
  154. Ok(status.code().unwrap_or(-1))
  155. }
  156. /// Run the bench and land `recipes/<lane>/reports/<stamp>_<label>.{md,json,log}`.
  157. pub async fn run(config: &Config, a: &Args, dry_run: bool) -> Result<()> {
  158. let recipe = load_recipe(&a.recipe, &config.recipe_sources).await?;
  159. let file = crate::config::recipe_file(&a.recipe, &config.recipe_sources)
  160. .ok_or_else(|| anyhow::anyhow!("recipe file not found for '{}'", a.recipe))?
  161. .canonicalize()?;
  162. let cluster = select_cluster_by_name(config, &a.cluster)?;
  163. let head = match cluster.nodes.iter().find(|n| n.rank == 0) {
  164. Some(n) => n,
  165. None => cluster
  166. .nodes
  167. .first()
  168. .ok_or_else(|| anyhow::anyhow!("cluster '{}' has no nodes", cluster.name))?,
  169. };
  170. let port = recipe.port;
  171. let model = served_model(&recipe).ok_or_else(|| {
  172. anyhow::anyhow!(
  173. "recipe '{}' has no --served-model-name in args — add it to the recipe",
  174. a.recipe
  175. )
  176. })?;
  177. let base_url = format!("http://{}:{}/v1", head.ssh_ip, port);
  178. let stamp = crate::stamp::utc_stamp();
  179. let label = sanitize(&a.label);
  180. let stem = format!("{stamp}_{label}");
  181. let reports_dir = file
  182. .parent()
  183. .ok_or_else(|| anyhow::anyhow!("no parent dir for {}", file.display()))?
  184. .join("reports");
  185. let log_path = reports_dir.join(format!("{stamp}_{label}.log"));
  186. let ssh_host = a.ssh_host.as_deref();
  187. // The local scratch dir (the report reads the md + json from here). For
  188. // the remote host the tool writes to a remote scratch dir; its md/json are
  189. // downloaded here after the run so the local report generation is unchanged.
  190. let scratch = dirs::home_dir()
  191. .ok_or_else(|| anyhow::anyhow!("no home dir"))?
  192. .join(".cache")
  193. .join("sm121")
  194. .join("bench")
  195. .join(format!("{stamp}_{label}"));
  196. // The scratch dir the tool writes to (local by default, or the remote host
  197. // when `--ssh-host` is set — the bench client's network then stays off the
  198. // measurement path).
  199. let tool_scratch = if ssh_host.is_some() {
  200. PathBuf::from(format!("/tmp/sm121-bench-{stem}"))
  201. } else {
  202. scratch.clone()
  203. };
  204. // The local json path (the report reads it).
  205. let json_path = reports_dir.join(format!("{stamp}_{label}.json"));
  206. // The json path the tool writes to (local by default, or the remote host).
  207. let tool_json = if ssh_host.is_some() {
  208. tool_scratch.join(format!("{stem}.json"))
  209. } else {
  210. json_path.clone()
  211. };
  212. let bin = "tool-eval-bench".to_string();
  213. // The tokenizer path the tool uses (local by default, or the remote
  214. // scratch dir when `--ssh-host` is set — the tokenizer is uploaded there).
  215. let tool_tokenizer = if ssh_host.is_some() {
  216. a.tokenizer.as_ref().map(|tok| {
  217. let name = tok.rsplit('/').next().unwrap_or("tokenizer");
  218. tool_scratch.join(name).to_string_lossy().to_string()
  219. })
  220. } else {
  221. a.tokenizer.clone()
  222. };
  223. let depth_scratch = tool_scratch.with_file_name(format!("{stem}_depth"));
  224. let depth_args =
  225. crate::bench_depth::depth_args(&base_url, &model, a.seed, tool_tokenizer.as_ref());
  226. let mut args = vec![
  227. "--base-url".into(),
  228. base_url.clone(),
  229. "--model".into(),
  230. model.clone(),
  231. "--seed".into(),
  232. a.seed.to_string(),
  233. "--hardmode".into(),
  234. "--perf".into(),
  235. // Depth rows must decode a WARM prefix: llama-benchy's default
  236. // branch concatenates context+prompt into one 67K request whose
  237. // serialized dual prefills measured 13.7/5.6/2.1 t/s at c2 — an
  238. // admission artifact, not the engine (diagnosis 2026-09-29,
  239. // wire capture + this flag flipping c2 13.7 → 52.1 in one run).
  240. "--benchy-args=--enable-prefix-caching".into(),
  241. "--concurrency".into(),
  242. a.concurrency.clone(),
  243. "--json-file".into(),
  244. tool_json.to_string_lossy().to_string(),
  245. ];
  246. if let Some(p) = a.parallel {
  247. args.push("--parallel".into());
  248. args.push(p.to_string());
  249. }
  250. if a.perf_only {
  251. args.push("--perf-only".into());
  252. }
  253. if let Some(tok) = &tool_tokenizer {
  254. args.push("--tokenizer".into());
  255. args.push(tok.clone());
  256. }
  257. if dry_run {
  258. println!("bench: cwd {}", tool_scratch.display());
  259. println!("bench: {} {}", bin, args.join(" "));
  260. println!("bench: depth suite: {} {}", bin, depth_args.join(" "));
  261. println!("bench: reports -> {}", reports_dir.display());
  262. println!(
  263. "bench: run package -> {}.recipe.toml + {}.manifest.json",
  264. stem, stem
  265. );
  266. return Ok(());
  267. }
  268. if let Some(host) = ssh_host {
  269. crate::bench_remote::prepare(host, &tool_scratch, &a.tokenizer)?;
  270. } else {
  271. fs::create_dir_all(&scratch)?;
  272. }
  273. fs::create_dir_all(&reports_dir)?;
  274. eprintln!("bench: waiting for {base_url} ...");
  275. crate::readiness::wait_ready(&base_url, crate::readiness::BENCH_WAIT_SECS).await?;
  276. eprintln!("bench: API ready — running {} (seed {})", bin, a.seed);
  277. let code = run_tool(&bin, &args, &tool_scratch, &log_path, false, ssh_host)?;
  278. if code != 0 {
  279. anyhow::bail!(
  280. "bench: tool exited with {code} (log: {})",
  281. log_path.display()
  282. );
  283. }
  284. // Phase 2 — context-depth suite (16K..256K @ c2, 2 repeats): appended
  285. // to the same log so `parse_perf_rows` picks its rows up too. A depth
  286. // failure must not cost the finished quality run — warn, keep going.
  287. // `--no-depth` skips the whole phase (quick-grasp campaigns).
  288. if !a.no_depth {
  289. eprintln!(
  290. "bench: depth suite: {} @ c{}, {} repeats{}",
  291. crate::bench_depth::DEPTHS,
  292. crate::bench_depth::CONCURRENCY,
  293. crate::bench_depth::RUNS,
  294. if a.tokenizer.is_some() {
  295. " (+tokenizer)"
  296. } else {
  297. ""
  298. }
  299. );
  300. fs::create_dir_all(&depth_scratch)?;
  301. let dcode = run_tool(&bin, &depth_args, &depth_scratch, &log_path, true, ssh_host)?;
  302. if dcode != 0 {
  303. eprintln!(
  304. "bench: WARNING depth suite exited with {dcode} — main report unaffected ({})",
  305. log_path.display()
  306. );
  307. }
  308. } else {
  309. eprintln!("bench: depth suite skipped (--no-depth)");
  310. }
  311. // Remote host: download the json + the md from the remote scratch dir so
  312. // the local report generation is unchanged.
  313. if let Some(host) = ssh_host {
  314. crate::bench_remote::collect(host, &tool_json, &json_path, &tool_scratch, &scratch)?;
  315. }
  316. let md_src = crate::bench_io::latest_md(&scratch)
  317. .ok_or_else(|| anyhow::anyhow!("no report .md under {}", scratch.join("runs").display()))?;
  318. let md_dest = reports_dir.join(format!("{stamp}_{label}.md"));
  319. fs::copy(&md_src, &md_dest)?;
  320. // The `--json-file` dispatch path drops the perf section from the md —
  321. // re-attach it from the llama-benchy table in the log.
  322. if crate::bench_report::ensure_throughput_section(&md_dest, &log_path).is_ok_and(|m| m) {
  323. eprintln!("bench: appended the perf table to the report md");
  324. }
  325. // Summary: final score from the JSON + the perf table from the log.
  326. // Perf-only runs have no quality score and the tool writes no JSON —
  327. // the run package is skipped for them (the campaign run is the record).
  328. let j = match a.perf_only {
  329. true => {
  330. eprintln!("bench: perf-only run (no quality score, no run package)");
  331. None
  332. }
  333. false => {
  334. let j: serde_json::Value = serde_json::from_str(&fs::read_to_string(&json_path)?)?;
  335. let sc = &j["scores"];
  336. let final_score = sc["final_score"].as_u64();
  337. if let Some(score) = final_score {
  338. let completion = sc["completion_rate"]
  339. .as_f64()
  340. .map(|v| v.to_string())
  341. .unwrap_or_default();
  342. eprintln!(
  343. "bench: final score {score} / {} ({} points), completion {completion}",
  344. sc["max_points"], sc["total_points"]
  345. );
  346. } else {
  347. eprintln!("bench: perf-only run (no quality score)");
  348. }
  349. Some(j)
  350. }
  351. };
  352. for line in fs::read_to_string(&log_path)?.lines() {
  353. if line.contains("pp2048") && line.contains("tg128") {
  354. println!("{line}");
  355. }
  356. }
  357. if let Some(j) = &j {
  358. crate::bench_artifacts::write_run_package(&crate::bench_artifacts::RunPackage {
  359. config,
  360. cluster: &cluster,
  361. recipe: &recipe,
  362. recipe_file: &file,
  363. stem: stem.as_str(),
  364. reports_dir: &reports_dir,
  365. bench_args: &args,
  366. bench_json: j,
  367. })
  368. .await?;
  369. }
  370. eprintln!("bench: reports landed in {}", reports_dir.display());
  371. Ok(())
  372. }
  373. #[cfg(test)]
  374. mod tests {
  375. use super::*;
  376. use std::process::Command;
  377. /// Regression: the pump must not treat a not-yet-written pipe as EOF.
  378. /// `read()` into a zero-length buffer returns 0, which the old pump
  379. /// treated as EOF — closing the pipe and killing the child on its first
  380. /// stderr write with EPIPE (silent exit 120 in CPython).
  381. #[test]
  382. fn pump_keeps_pipe_open_until_child_exits() {
  383. let tmp =
  384. std::env::temp_dir().join(format!("sm121-bench-pump-test-{}", std::process::id()));
  385. fs::create_dir_all(&tmp).unwrap();
  386. let log = tmp.join("pump.log");
  387. let mut child = Command::new("sh")
  388. .args(["-c", "sleep 0.3; echo late-output >&2"])
  389. .stdout(Stdio::piped())
  390. .stderr(Stdio::piped())
  391. .spawn()
  392. .unwrap();
  393. let out = child.stdout.take().unwrap();
  394. let err = child.stderr.take().unwrap();
  395. let f = Arc::new(Mutex::new(fs::File::create(&log).unwrap()));
  396. let f1 = f.clone();
  397. let t1 = std::thread::spawn(move || crate::bench_io::pump(out, false, f1));
  398. let t2 = std::thread::spawn(move || crate::bench_io::pump(err, false, f));
  399. let status = child.wait().unwrap();
  400. t1.join().unwrap();
  401. t2.join().unwrap();
  402. let body = fs::read_to_string(&log).unwrap();
  403. assert_eq!(status.code(), Some(0), "child must not hit EPIPE: {body}");
  404. assert!(body.contains("late-output"), "piped stderr lost: {body:?}");
  405. let _ = fs::remove_dir_all(&tmp);
  406. }
  407. #[test]
  408. fn sanitize_replaces_bad_chars() {
  409. assert_eq!(sanitize("arm B: 2x"), "arm-B--2x");
  410. assert_eq!(sanitize("ok_name-2.v3"), "ok_name-2.v3");
  411. }
  412. /// The non-interactive env must reach the child through the `script`
  413. /// pty wrapper — rich reads TTY_COMPATIBLE from the child's env. Also:
  414. /// `stty -onlcr` must hold, so the saved log is LF-only (CRLF made the
  415. /// report viewer render every line twice).
  416. #[test]
  417. fn run_tool_passes_non_interactive_env() {
  418. let tmp = std::env::temp_dir().join(format!("sm121-bench-env-test-{}", std::process::id()));
  419. fs::create_dir_all(&tmp).unwrap();
  420. let log = tmp.join("env.log");
  421. let code = run_tool(
  422. "sh",
  423. &[
  424. "-c".to_string(),
  425. "echo TTY=$TTY_COMPATIBLE NO=$NO_COLOR".to_string(),
  426. ],
  427. &tmp,
  428. &log,
  429. false,
  430. None,
  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. /// The append mode must not truncate the main phase's log — the depth
  441. /// suite shares it so one report carries both tables.
  442. #[test]
  443. fn run_tool_append_keeps_prior_output() {
  444. let tmp =
  445. std::env::temp_dir().join(format!("sm121-bench-append-test-{}", std::process::id()));
  446. fs::create_dir_all(&tmp).unwrap();
  447. let log = tmp.join("append.log");
  448. let run = |a: &str, append| {
  449. run_tool("sh", &["-c".into(), a.into()], &tmp, &log, append, None).unwrap()
  450. };
  451. assert_eq!(run("echo main", false), 0);
  452. assert!(fs::read_to_string(&log).unwrap().contains("main\n"));
  453. assert_eq!(run("echo depth", true), 0);
  454. let body = fs::read_to_string(&log).unwrap();
  455. let (i1, i2) = (body.find("main\n"), body.find("depth\n"));
  456. assert!(
  457. i1.is_some() && i2.is_some_and(|d| i1.unwrap() < d),
  458. "append truncated the log: {body:?}"
  459. );
  460. let _ = fs::remove_dir_all(&tmp);
  461. }
  462. #[test]
  463. fn served_model_parsed_from_args() {
  464. let content = "name = \"t\"\ndocker_image = \"x:1\"\nmodel = \"org/m\"\nargs = [\"--served-model-name\", \"glm53\"]\n";
  465. let r: Recipe = toml::from_str(content).expect("recipe toml");
  466. assert_eq!(served_model(&r).as_deref(), Some("glm53"));
  467. let mut r2 = r.clone();
  468. r2.args = crate::pre_serve::RecipeArgs::List(vec!["--trust-remote-code".into()]);
  469. assert!(served_model(&r2).is_none());
  470. }
  471. }