bench_target.rs 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. //! Bench target resolution + the tool runner, split out of `bench.rs` at the
  2. //! 500-line cap: the served model name, the host node (`--node` / rank 0), a
  3. //! filesystem-safe label, and the pty-wrapped `tool-eval-bench` invocation
  4. //! that tees its output into the log file.
  5. use crate::config::Recipe;
  6. use anyhow::Result;
  7. use std::fs;
  8. use std::path::Path;
  9. use std::process::Stdio;
  10. use std::sync::atomic::{AtomicBool, Ordering};
  11. use std::sync::{Arc, Mutex};
  12. use std::thread;
  13. use std::time::Duration;
  14. pub(crate) fn served_model(recipe: &Recipe) -> Option<String> {
  15. let args = recipe.args.to_vec();
  16. // `--served-model-name` is the vLLM/SGLang spelling; TensorFold's HTTP app
  17. // takes `--name` instead (its 2x 1017 lane serves `deepseek-v4.1-flash`).
  18. ["--served-model-name", "--name"].iter().find_map(|flag| {
  19. args.iter()
  20. .position(|s| s == flag || s.starts_with(&format!("{flag}=")))
  21. .and_then(|i| match args.get(i) {
  22. Some(a) if a.contains('=') => a.split_once('=').map(|(_, v)| v.to_string()),
  23. _ => args.get(i + 1).cloned(),
  24. })
  25. })
  26. }
  27. /// Bench target node: the explicit `--node` override, else rank 0 (falling
  28. /// back to the first node). `--node` matters for serves pinned with
  29. /// `serve --node <n>` — the run's head may not be the cluster's rank 0
  30. /// (e.g. a solo Bolt on dgx3 inside `4nodes-switch` while dgx1 serves
  31. /// something else entirely).
  32. pub(crate) fn pick_head<'a>(
  33. nodes: &'a [crate::config::Node],
  34. cluster_name: &str,
  35. node: Option<&str>,
  36. ) -> anyhow::Result<&'a crate::config::Node> {
  37. match node {
  38. Some(name) => {
  39. let bare = name.strip_suffix(".lan").unwrap_or(name);
  40. nodes
  41. .iter()
  42. .find(|n| n.name.strip_suffix(".lan").unwrap_or(&n.name) == bare)
  43. .ok_or_else(|| {
  44. anyhow::anyhow!("node '{}' is not part of cluster '{}'", name, cluster_name)
  45. })
  46. }
  47. None => nodes
  48. .iter()
  49. .find(|n| n.rank == 0)
  50. .or_else(|| nodes.first())
  51. .ok_or_else(|| anyhow::anyhow!("cluster '{}' has no nodes", cluster_name)),
  52. }
  53. }
  54. /// Keep the label filesystem-safe.
  55. pub(crate) 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. pub(crate) 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. cancel: Option<&Arc<AtomicBool>>,
  94. ) -> Result<i32> {
  95. let file = Arc::new(Mutex::new(if append_log {
  96. fs::OpenOptions::new()
  97. .create(true)
  98. .append(true)
  99. .open(log_path)?
  100. } else {
  101. fs::File::create(log_path)?
  102. }));
  103. // The command: local (the TTY wrapper or direct exec) or remote (SSH to
  104. // the host — a pty via `ssh -t` keeps the rich Live display from
  105. // deadlocking on the non-tty pipe; `stty -onlcr` keeps the log LF-only;
  106. // TTY_COMPATIBLE/NO_COLOR match the local run). For the remote host the
  107. // `cwd` is a remote path embedded in the shell string, so the local
  108. // current_dir/env are skipped.
  109. let mut cmd = if let Some(host) = ssh_host {
  110. crate::bench_remote::remote_command(host, cwd, bin, args)
  111. } else if cfg!(target_os = "macos")
  112. && std::process::Command::new("/usr/bin/script")
  113. .arg("-q")
  114. .arg("/dev/null")
  115. .arg("true")
  116. .output()
  117. .is_ok()
  118. {
  119. // Wrap in `sh` + `stty -onlcr`: the macOS pty's ONLCR would turn every
  120. // `\n` the tool writes into `\r\n` on disk, and the report viewer
  121. // then renders each line twice (blank line after every line).
  122. // `stty -onlcr` disables the post-processing at the source; `exec`
  123. // replaces the shell so the tool keeps the pty and the exit code.
  124. let mut c = std::process::Command::new("/usr/bin/script");
  125. c.arg("-q");
  126. c.arg("/dev/null");
  127. c.arg("sh");
  128. c.arg("-c");
  129. c.arg("stty -onlcr 2>/dev/null; exec \"$@\"");
  130. c.arg("sh"); // $0 inside the -c script
  131. c.arg(bin);
  132. c.args(args);
  133. c
  134. } else {
  135. let mut c = std::process::Command::new(bin);
  136. c.args(args);
  137. c
  138. };
  139. if ssh_host.is_none() {
  140. cmd.current_dir(cwd);
  141. cmd.env("TTY_COMPATIBLE", "0");
  142. cmd.env("NO_COLOR", "1");
  143. }
  144. cmd.stdout(Stdio::piped());
  145. cmd.stderr(Stdio::piped());
  146. let mut child = cmd.spawn()?;
  147. let stdout_pipe = child.stdout.take().expect("stdout pipe");
  148. let stderr_pipe = child.stderr.take().expect("stderr pipe");
  149. let f1 = file.clone();
  150. let t1 = std::thread::spawn(move || crate::bench_io::pump(stdout_pipe, true, f1));
  151. let t2 = std::thread::spawn(move || crate::bench_io::pump(stderr_pipe, false, file));
  152. // Bench cancellation: poll the job's flag; on request kill the harness
  153. // child (the SSH child for a remote run, so the remote command drops too).
  154. let status = loop {
  155. if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
  156. let _ = child.kill();
  157. break child.wait()?;
  158. }
  159. if let Some(s) = child.try_wait()? {
  160. break s;
  161. }
  162. thread::sleep(Duration::from_millis(200));
  163. };
  164. t1.join().ok();
  165. t2.join().ok();
  166. Ok(status.code().unwrap_or(-1))
  167. }