bench.rs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  1. //! `sm121 bench` — run `tool-eval-bench` against a live serve and land the
  2. //! reports in `recipes/<lane>/reports/`. The tool needs a scratch cwd (it
  3. //! writes `runs/<date>/<run>.md` relative to cwd), an absolute `--json-file`
  4. //! path, and the `.md` copied into the lane — this command does the
  5. //! babysitting. Campaign convention (seed 42, hardmode, perf, c1,2) is the
  6. //! default; `sm121 bench -d` prints the exact command.
  7. use std::fs;
  8. use std::path::PathBuf;
  9. use anyhow::Result;
  10. use crate::bench_target::{pick_head, run_tool, sanitize, served_model};
  11. use crate::config::{Config, load_recipe, select_cluster_by_name};
  12. /// Fields of the `sm121 bench` subcommand (mirrored by the clap variant in
  13. /// `cli.rs`). Served name/port come from the recipe; the tool is on PATH.
  14. #[derive(Clone)]
  15. pub struct Args {
  16. pub recipe: String,
  17. pub cluster: String,
  18. pub label: Option<String>,
  19. pub concurrency: String,
  20. pub seed: u64,
  21. /// Scenario streams run concurrently (tool `--parallel`; None = serial).
  22. pub parallel: Option<u32>,
  23. /// Skip the scenario phase (tool `--perf-only`).
  24. pub perf_only: bool,
  25. /// Run the context-depth suite (16K..256K) after the main pass — off by
  26. /// default (a full sweep takes ~45 min on a 2x lane).
  27. pub depth: bool,
  28. /// Local tokenizer source for llama-benchy (tool `--tokenizer`): a
  29. /// tokenizer.json or a dir holding one. Needed when the served model
  30. /// id is not resolvable in the local HF cache (custom export lanes).
  31. pub tokenizer: Option<String>,
  32. /// When set, the tool-eval-bench invocation runs on this host over SSH
  33. /// (the local machine only orchestrates), keeping the bench client's own
  34. /// network (e.g. a WiFi laptop) off the measurement path. The tokenizer
  35. /// is uploaded to the host; the report + run package land locally. The
  36. /// host must have `tool-eval-bench` on PATH and reach the serve.
  37. pub ssh_host: Option<String>,
  38. /// Bench target node within the cluster (default: rank 0). Use when the
  39. /// serve was launched with `serve --node <n>` — a pinned/solo run may
  40. /// live on a non-rank-0 node, and benching the wrong head measures a
  41. /// different (or no) engine.
  42. pub node: Option<String>,
  43. /// Disable thinking/reasoning in the harness (tool `--no-think`, sets
  44. /// `enable_thinking=false`). Needed for thinking-by-default models (Ling,
  45. /// GLM-4.5-style) whose long default reasoning blows the per-turn timeout
  46. /// and excludes most scenarios.
  47. pub no_think: bool,
  48. /// Per-turn request timeout seconds forwarded to the harness (tool
  49. /// `--timeout`). Raise above the 120 s default for slow/thinking models.
  50. pub timeout: Option<u64>,
  51. /// Job cancellation flag (web bench window). `run_tool` polls it and
  52. /// kills the harness child; None for the CLI.
  53. pub cancel: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
  54. }
  55. /// Served model name from the recipe's `--served-model-name` arg.
  56. /// Run the bench and land `recipes/<lane>/reports/<stamp>_<label>.{md,json,log}`.
  57. pub async fn run(config: &Config, a: &Args, dry_run: bool) -> Result<()> {
  58. let recipe = load_recipe(&a.recipe, &config.recipe_sources).await?;
  59. let file = crate::config::recipe_file(&a.recipe, &config.recipe_sources)
  60. .ok_or_else(|| anyhow::anyhow!("recipe file not found for '{}'", a.recipe))?
  61. .canonicalize()?;
  62. let cluster = select_cluster_by_name(config, &a.cluster)?;
  63. let head = pick_head(&cluster.nodes, &cluster.name, a.node.as_deref())?;
  64. // FLARE serves on its own fixed API port (`[flare].http_port`), not the
  65. // cluster default the docker lanes use.
  66. let port = if recipe.engine == "flare" {
  67. crate::native::flare::http_port(&recipe)
  68. } else {
  69. recipe.port
  70. };
  71. let model = served_model(&recipe).ok_or_else(|| {
  72. anyhow::anyhow!(
  73. "recipe '{}' has no --served-model-name in args — add it to the recipe",
  74. a.recipe
  75. )
  76. })?;
  77. let base_url = format!("http://{}:{}/v1", head.ssh_ip, port);
  78. let stamp = crate::stamp::utc_stamp();
  79. // Label: required for quality runs, optional for perf-only (default `perf`).
  80. let label = match (&a.label, a.perf_only) {
  81. (Some(l), _) => sanitize(l),
  82. (None, true) => String::from("perf"),
  83. (None, false) => {
  84. anyhow::bail!("--label is required for a quality run (the campaign record)")
  85. }
  86. };
  87. let stem = format!("{stamp}_{label}");
  88. let reports_dir = file
  89. .parent()
  90. .ok_or_else(|| anyhow::anyhow!("no parent dir for {}", file.display()))?
  91. .join("reports");
  92. let log_path = reports_dir.join(format!("{stamp}_{label}.log"));
  93. let ssh_host = a.ssh_host.as_deref();
  94. // The local scratch dir (the report reads the md + json from here). For
  95. // the remote host the tool writes to a remote scratch dir; its md/json are
  96. // downloaded here after the run so the local report generation is unchanged.
  97. let scratch = dirs::home_dir()
  98. .ok_or_else(|| anyhow::anyhow!("no home dir"))?
  99. .join(".cache/sm121/bench")
  100. .join(&stem);
  101. // The scratch dir the tool writes to (local by default, or the remote host
  102. // when `--ssh-host` is set — the bench client's network then stays off the
  103. // measurement path).
  104. let tool_scratch = if ssh_host.is_some() {
  105. PathBuf::from(format!("/tmp/sm121-bench-{stem}"))
  106. } else {
  107. scratch.clone()
  108. };
  109. // The local json path (the report reads it).
  110. let json_path = reports_dir.join(format!("{stamp}_{label}.json"));
  111. // The json path the tool writes to (local by default, or the remote host).
  112. let tool_json = if ssh_host.is_some() {
  113. tool_scratch.join(format!("{stem}.json"))
  114. } else {
  115. json_path.clone()
  116. };
  117. let bin = "tool-eval-bench".to_string();
  118. // The tokenizer path the tool uses (local by default, or the remote
  119. // scratch dir when `--ssh-host` is set — the tokenizer is uploaded there).
  120. let tool_tokenizer = if ssh_host.is_some() {
  121. a.tokenizer.as_ref().map(|tok| {
  122. let name = tok.rsplit('/').next().unwrap_or("tokenizer");
  123. tool_scratch.join(name).to_string_lossy().to_string()
  124. })
  125. } else {
  126. a.tokenizer.clone()
  127. };
  128. let depth_scratch = tool_scratch.with_file_name(format!("{stem}_depth"));
  129. let depth_args =
  130. crate::bench_depth::depth_args(&base_url, &model, a.seed, tool_tokenizer.as_ref());
  131. let mut args = vec![
  132. "--base-url".into(),
  133. base_url.clone(),
  134. "--model".into(),
  135. model.clone(),
  136. "--seed".into(),
  137. a.seed.to_string(),
  138. "--hardmode".into(),
  139. "--perf".into(),
  140. // Warm-prefix decode: without it llama-benchy's default branch
  141. // serializes dual 67K prefills (c2 collapses to 13.7 t/s — an
  142. // admission artifact, not the engine; 2026-09-29 diagnosis).
  143. "--benchy-args=--enable-prefix-caching".into(),
  144. "--concurrency".into(),
  145. a.concurrency.clone(),
  146. "--json-file".into(),
  147. tool_json.to_string_lossy().to_string(),
  148. ];
  149. if let Some(p) = a.parallel {
  150. args.push("--parallel".into());
  151. args.push(p.to_string());
  152. }
  153. if a.perf_only {
  154. args.push("--perf-only".into());
  155. }
  156. if let Some(tok) = &tool_tokenizer {
  157. args.push("--tokenizer".into());
  158. args.push(tok.clone());
  159. }
  160. if a.no_think {
  161. args.push("--no-think".into());
  162. }
  163. if let Some(t) = a.timeout {
  164. args.push("--timeout".into());
  165. args.push(t.to_string());
  166. }
  167. if dry_run {
  168. println!("bench: cwd {}", tool_scratch.display());
  169. println!("bench: {} {}", bin, args.join(" "));
  170. println!("bench: depth suite: {} {}", bin, depth_args.join(" "));
  171. println!(
  172. "bench: reports -> {} (run package: {}.recipe.toml + {}.manifest.json)",
  173. reports_dir.display(),
  174. stem,
  175. stem
  176. );
  177. return Ok(());
  178. }
  179. if let Some(host) = ssh_host {
  180. crate::bench_remote::prepare(host, &tool_scratch, &a.tokenizer)?;
  181. } else {
  182. fs::create_dir_all(&scratch)?;
  183. }
  184. fs::create_dir_all(&reports_dir)?;
  185. eprintln!("bench: waiting for {base_url} ...");
  186. crate::readiness::wait_ready(&base_url, crate::readiness::BENCH_WAIT_SECS).await?;
  187. eprintln!("bench: API ready — running {} (seed {})", bin, a.seed);
  188. let code = run_tool(
  189. &bin,
  190. &args,
  191. &tool_scratch,
  192. &log_path,
  193. false,
  194. ssh_host,
  195. a.cancel.as_ref(),
  196. )?;
  197. if code != 0 {
  198. if a.cancel
  199. .as_ref()
  200. .is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
  201. {
  202. anyhow::bail!("bench: cancelled");
  203. }
  204. anyhow::bail!(
  205. "bench: tool exited with {code} (log: {})",
  206. log_path.display()
  207. );
  208. }
  209. // Phase 2 — context-depth suite (16K..256K @ c2, 2 repeats): appended
  210. // to the same log so `parse_perf_rows` picks its rows up too. A depth
  211. // failure must not cost the finished quality run — warn, keep going.
  212. // Opt-in (`--depth`): a full sweep takes ~45 min on a 2x lane.
  213. if a.depth {
  214. eprintln!(
  215. "bench: depth suite: {} @ c{}, {} repeats{}",
  216. crate::bench_depth::DEPTHS,
  217. crate::bench_depth::CONCURRENCY,
  218. crate::bench_depth::RUNS,
  219. if a.tokenizer.is_some() {
  220. " (+tokenizer)"
  221. } else {
  222. ""
  223. }
  224. );
  225. fs::create_dir_all(&depth_scratch)?;
  226. let dcode = run_tool(
  227. &bin,
  228. &depth_args,
  229. &depth_scratch,
  230. &log_path,
  231. true,
  232. ssh_host,
  233. a.cancel.as_ref(),
  234. )?;
  235. if dcode != 0 {
  236. if a.cancel
  237. .as_ref()
  238. .is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
  239. {
  240. anyhow::bail!("bench: cancelled in depth suite");
  241. }
  242. eprintln!(
  243. "bench: WARNING depth suite exited with {dcode} — main report unaffected ({})",
  244. log_path.display()
  245. );
  246. }
  247. } else {
  248. eprintln!("bench: depth suite skipped (opt-in: --depth)");
  249. }
  250. // Remote host: download the json + the md from the remote scratch dir so
  251. // the local report generation is unchanged.
  252. if let Some(host) = ssh_host {
  253. crate::bench_remote::collect(
  254. host,
  255. &tool_json,
  256. &json_path,
  257. &tool_scratch,
  258. &scratch,
  259. !a.perf_only,
  260. )?;
  261. }
  262. let md_src = crate::bench_io::latest_md(&scratch)
  263. .ok_or_else(|| anyhow::anyhow!("no report .md under {}", scratch.join("runs").display()))?;
  264. let md_dest = reports_dir.join(format!("{stamp}_{label}.md"));
  265. fs::copy(&md_src, &md_dest)?;
  266. // The `--json-file` dispatch path drops the perf section from the md —
  267. // re-attach it from the llama-benchy table in the log.
  268. if crate::bench_report::ensure_throughput_section(&md_dest, &log_path).is_ok_and(|m| m) {
  269. eprintln!("bench: appended the perf table to the report md");
  270. }
  271. // Summary: final score from the JSON + the perf table from the log.
  272. // Perf-only runs have no quality score and the tool writes no JSON —
  273. // the run package is skipped for them (the campaign run is the record).
  274. let j = match a.perf_only {
  275. true => {
  276. eprintln!("bench: perf-only run (no quality score, no run package)");
  277. None
  278. }
  279. false => {
  280. let j: serde_json::Value = serde_json::from_str(&fs::read_to_string(&json_path)?)?;
  281. let sc = &j["scores"];
  282. let final_score = sc["final_score"].as_u64();
  283. if let Some(score) = final_score {
  284. let completion = sc["completion_rate"]
  285. .as_f64()
  286. .map(|v| v.to_string())
  287. .unwrap_or_default();
  288. eprintln!(
  289. "bench: final score {score} / {} ({} points), completion {completion}",
  290. sc["max_points"], sc["total_points"]
  291. );
  292. } else {
  293. eprintln!("bench: perf-only run (no quality score)");
  294. }
  295. Some(j)
  296. }
  297. };
  298. for line in fs::read_to_string(&log_path)?.lines() {
  299. if line.contains("pp2048") && line.contains("tg128") {
  300. println!("{line}");
  301. }
  302. }
  303. if let Some(j) = &j {
  304. crate::bench_artifacts::write_run_package(&crate::bench_artifacts::RunPackage {
  305. config,
  306. cluster: &cluster,
  307. recipe: &recipe,
  308. recipe_file: &file,
  309. stem: stem.as_str(),
  310. reports_dir: &reports_dir,
  311. bench_args: &args,
  312. bench_json: j,
  313. })
  314. .await?;
  315. }
  316. eprintln!("bench: reports landed in {}", reports_dir.display());
  317. Ok(())
  318. }
  319. #[cfg(test)]
  320. mod tests {
  321. use super::*;
  322. use crate::config::{Node, Recipe};
  323. use std::process::{Command, Stdio};
  324. use std::sync::{Arc, Mutex};
  325. fn node(name: &str, rank: u32) -> Node {
  326. Node {
  327. name: name.to_string(),
  328. ssh_ip: "192.0.2.1".into(),
  329. ib_ip: None,
  330. rank,
  331. host_key: None,
  332. entity: None,
  333. }
  334. }
  335. #[test]
  336. fn pick_head_defaults_to_rank0() {
  337. let nodes = vec![node("a.lan", 2), node("b.lan", 0), node("c.lan", 1)];
  338. assert_eq!(pick_head(&nodes, "c", None).unwrap().name, "b.lan");
  339. }
  340. #[test]
  341. fn pick_head_node_override_tolerates_lan_suffix() {
  342. let nodes = vec![node("a.lan", 2), node("b.lan", 0)];
  343. assert_eq!(pick_head(&nodes, "c", Some("a")).unwrap().name, "a.lan");
  344. assert_eq!(pick_head(&nodes, "c", Some("b.lan")).unwrap().name, "b.lan");
  345. assert!(pick_head(&nodes, "c", Some("zzz")).is_err());
  346. }
  347. /// Regression: the pump must not treat a not-yet-written pipe as EOF.
  348. /// `read()` into a zero-length buffer returns 0, which the old pump
  349. /// treated as EOF — closing the pipe and killing the child on its first
  350. /// stderr write with EPIPE (silent exit 120 in CPython).
  351. #[test]
  352. fn pump_keeps_pipe_open_until_child_exits() {
  353. let tmp =
  354. std::env::temp_dir().join(format!("sm121-bench-pump-test-{}", std::process::id()));
  355. fs::create_dir_all(&tmp).unwrap();
  356. let log = tmp.join("pump.log");
  357. let mut child = Command::new("sh")
  358. .args(["-c", "sleep 0.3; echo late-output >&2"])
  359. .stdout(Stdio::piped())
  360. .stderr(Stdio::piped())
  361. .spawn()
  362. .unwrap();
  363. let out = child.stdout.take().unwrap();
  364. let err = child.stderr.take().unwrap();
  365. let f = Arc::new(Mutex::new(fs::File::create(&log).unwrap()));
  366. let f1 = f.clone();
  367. let t1 = std::thread::spawn(move || crate::bench_io::pump(out, false, f1));
  368. let t2 = std::thread::spawn(move || crate::bench_io::pump(err, false, f));
  369. let status = child.wait().unwrap();
  370. t1.join().unwrap();
  371. t2.join().unwrap();
  372. let body = fs::read_to_string(&log).unwrap();
  373. assert_eq!(status.code(), Some(0), "child must not hit EPIPE: {body}");
  374. assert!(body.contains("late-output"), "piped stderr lost: {body:?}");
  375. let _ = fs::remove_dir_all(&tmp);
  376. }
  377. #[test]
  378. fn sanitize_replaces_bad_chars() {
  379. assert_eq!(sanitize("arm B: 2x"), "arm-B--2x");
  380. assert_eq!(sanitize("ok_name-2.v3"), "ok_name-2.v3");
  381. }
  382. /// The non-interactive env must reach the child through the `script`
  383. /// pty wrapper — rich reads TTY_COMPATIBLE from the child's env. Also:
  384. /// `stty -onlcr` must hold, so the saved log is LF-only (CRLF made the
  385. /// report viewer render every line twice).
  386. #[test]
  387. fn run_tool_passes_non_interactive_env() {
  388. let tmp = std::env::temp_dir().join(format!("sm121-bench-env-test-{}", std::process::id()));
  389. fs::create_dir_all(&tmp).unwrap();
  390. let log = tmp.join("env.log");
  391. let code = run_tool(
  392. "sh",
  393. &[
  394. "-c".to_string(),
  395. "echo TTY=$TTY_COMPATIBLE NO=$NO_COLOR".to_string(),
  396. ],
  397. &tmp,
  398. &log,
  399. false,
  400. None,
  401. None,
  402. )
  403. .unwrap();
  404. let body = fs::read_to_string(&log).unwrap();
  405. let _ = fs::remove_dir_all(&tmp);
  406. assert_eq!(code, 0, "exit code, log: {body:?}");
  407. assert!(body.contains("TTY=0"), "TTY_COMPATIBLE lost: {body:?}");
  408. assert!(body.contains("NO=1"), "NO_COLOR lost: {body:?}");
  409. assert!(!body.contains('\r'), "pty ONLCR leaked CRLF: {body:?}");
  410. }
  411. /// The append mode must not truncate the main phase's log — the depth
  412. /// suite shares it so one report carries both tables.
  413. #[test]
  414. fn run_tool_append_keeps_prior_output() {
  415. let tmp =
  416. std::env::temp_dir().join(format!("sm121-bench-append-test-{}", std::process::id()));
  417. fs::create_dir_all(&tmp).unwrap();
  418. let log = tmp.join("append.log");
  419. let run = |a: &str, append| {
  420. run_tool(
  421. "sh",
  422. &["-c".into(), a.into()],
  423. &tmp,
  424. &log,
  425. append,
  426. None,
  427. None,
  428. )
  429. .unwrap()
  430. };
  431. assert_eq!(run("echo main", false), 0);
  432. assert!(fs::read_to_string(&log).unwrap().contains("main\n"));
  433. assert_eq!(run("echo depth", true), 0);
  434. let body = fs::read_to_string(&log).unwrap();
  435. let (i1, i2) = (body.find("main\n"), body.find("depth\n"));
  436. assert!(
  437. i1.is_some() && i2.is_some_and(|d| i1.unwrap() < d),
  438. "append truncated the log: {body:?}"
  439. );
  440. let _ = fs::remove_dir_all(&tmp);
  441. }
  442. #[test]
  443. fn served_model_parsed_from_args() {
  444. let content = "name = \"t\"\ndocker_image = \"x:1\"\nmodel = \"org/m\"\nargs = [\"--served-model-name\", \"glm53\"]\n";
  445. let r: Recipe = toml::from_str(content).expect("recipe toml");
  446. assert_eq!(served_model(&r).as_deref(), Some("glm53"));
  447. let mut r2 = r.clone();
  448. r2.args = crate::pre_serve::RecipeArgs::List(vec!["--trust-remote-code".into()]);
  449. assert!(served_model(&r2).is_none());
  450. }
  451. }