bench_artifacts.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. //! Versioned run package for `sm121 bench`.
  2. //!
  3. //! Every bench run lands next to its reports in the recipe's `reports/`
  4. //! dir (same stem as the report triplet):
  5. //!
  6. //! - `<stem>.recipe.toml` — a byte-exact copy of the recipe file the
  7. //! serve was launched from, so the run's recipe is pinned even after
  8. //! the recipe (or its image pins) move on.
  9. //! - `<stem>.manifest.json` — provenance: recipe + config SHA-256, the
  10. //! launcher's git commit, the bench tool's version, the served model's
  11. //! resolved `refs/main` snapshot (the model reference floats, so the
  12. //! snapshot pins the run), and the served image's identity per node
  13. //! (docker id + `sm121.checksum` build label, captured over SSH at bench
  14. //! time).
  15. //!
  16. //! `sm121 report` reads the manifests back (see [`crate::report`]) and
  17. //! shows each run's provenance — and flags a run whose recipe has since
  18. //! changed (drift).
  19. use std::fs;
  20. use std::path::Path;
  21. use anyhow::Result;
  22. use sha2::{Digest, Sha256};
  23. use crate::config::{Cluster, Config, Recipe};
  24. use crate::ssh::run_ssh;
  25. /// SHA-256 of a byte slice as a 64-char lowercase hex string.
  26. pub fn sha256_hex(bytes: &[u8]) -> String {
  27. let digest = Sha256::digest(bytes);
  28. digest.iter().map(|b| format!("{b:02x}")).collect()
  29. }
  30. /// First 12 chars — the display width for hashes in the report UI.
  31. pub fn short12(s: &str) -> String {
  32. s.chars().take(12).collect()
  33. }
  34. /// UTC "YYYY-MM-DDTHH:MM:SSZ" for the manifest's capture time.
  35. fn utc_now_iso() -> String {
  36. let secs = std::time::SystemTime::now()
  37. .duration_since(std::time::UNIX_EPOCH)
  38. .map(|d| d.as_secs())
  39. .unwrap_or(0);
  40. let (y, m, d) = civil_date((secs / 86400) as i64);
  41. let rem = (secs % 86400) as u32;
  42. format!(
  43. "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
  44. y,
  45. m,
  46. d,
  47. rem / 3600,
  48. rem % 3600 / 60,
  49. rem % 60
  50. )
  51. }
  52. /// Days-since-civil conversion (Hansen/Clayton), same as `bench::civil_date`.
  53. fn civil_date(z: i64) -> (u32, u32, u32) {
  54. let z = z + 719_468;
  55. let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
  56. let doe = z - era * 146_097;
  57. let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
  58. let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
  59. let mp = (5 * doy + 2) / 153;
  60. let day = doy - (153 * mp + 2) / 5 + 1;
  61. let month = if mp < 10 { mp + 3 } else { mp - 9 };
  62. let year = era * 400 + yoe + if month <= 2 { 1 } else { 0 };
  63. (year as u32, month as u32, day as u32)
  64. }
  65. #[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
  66. pub struct ManifestRecipe {
  67. pub name: String,
  68. /// Path of the recipe file as resolved by the launcher.
  69. pub file: String,
  70. pub sha256: String,
  71. }
  72. #[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
  73. pub struct ManifestCluster {
  74. pub name: String,
  75. /// ssh_ip per node (node order from the cluster definition).
  76. pub nodes: Vec<String>,
  77. }
  78. #[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
  79. pub struct ManifestConfig {
  80. pub file: String,
  81. pub sha256: String,
  82. }
  83. #[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
  84. pub struct ManifestLauncher {
  85. /// Canonical path of the `sm121` binary that ran the bench.
  86. pub bin: String,
  87. /// `git rev-parse HEAD` of the repo the binary was built in (cwd),
  88. /// null when the cwd is not a git checkout.
  89. pub git_sha: Option<String>,
  90. }
  91. #[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
  92. pub struct ManifestBench {
  93. pub tool: String,
  94. /// `tool_eval_bench_version` from the run's JSON report.
  95. pub version: Option<String>,
  96. /// The exact `tool-eval-bench` arguments (base-url, model, seed, …).
  97. pub args: Vec<String>,
  98. pub final_score: Option<u32>,
  99. /// `total/max` points, e.g. "163/176".
  100. pub points: Option<String>,
  101. }
  102. /// Provenance of the served model. The recipe's `model` reference floats
  103. /// (the engine follows the HF cache's `refs/main` → latest snapshot), so the
  104. /// snapshot it actually resolved to at bench time is what makes a run
  105. /// reproducible. `snapshot` is null when `model` is a local path (no
  106. /// `org/name` id to resolve) or the repo isn't cached on the head node.
  107. #[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
  108. pub struct ManifestModel {
  109. /// The recipe's `model` field (an `org/name` repo id or a local path).
  110. pub id: String,
  111. /// The `refs/main` snapshot hash the id resolved to on the head node.
  112. pub snapshot: Option<String>,
  113. }
  114. /// One per bench run; the top-level shape of `<stem>.manifest.json`.
  115. #[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
  116. pub struct Manifest {
  117. pub schema_version: u32,
  118. /// `<YYYY-MM-DD>_<label>` run stem (report triplet base name).
  119. pub run: String,
  120. /// UTC ISO-8601 capture time.
  121. pub created_utc: String,
  122. pub recipe: ManifestRecipe,
  123. pub cluster: ManifestCluster,
  124. #[serde(default, skip_serializing_if = "Option::is_none")]
  125. pub config: Option<ManifestConfig>,
  126. pub launcher: ManifestLauncher,
  127. pub bench: ManifestBench,
  128. /// Docker image name → `{ "build_checksum": "…", "nodes": { name →
  129. /// { "id", "sm121_checksum" } } }`. Nodes without the image are
  130. /// absent from `nodes`.
  131. pub images: serde_json::Map<String, serde_json::Value>,
  132. /// The recipe `model` id and the `refs/main` snapshot it resolved to
  133. /// at bench time (the model reference floats; the snapshot pins the run).
  134. /// Absent in pre-model manifests (defaults to empty).
  135. #[serde(default)]
  136. pub models: Vec<ManifestModel>,
  137. }
  138. /// `docker image inspect` the served image on every cluster node and
  139. /// collect its id + `sm121.checksum` label. Nodes where the image is
  140. /// missing (not in the launch set) are skipped, not errors.
  141. pub async fn capture_images(config: &Config, cluster: &Cluster, image: &str) -> serde_json::Value {
  142. let mut nodes = serde_json::Map::new();
  143. let mut build_checksum = String::new();
  144. for n in &cluster.nodes {
  145. let cmd = format!(
  146. "docker image inspect --format '{{{{.Id}}}}|{{{{index .Config.Labels \"sm121.checksum\"}}}}' {image}"
  147. );
  148. let Ok(out) = run_ssh(config, n, cmd, false).await else {
  149. continue;
  150. };
  151. let out = out.trim();
  152. let (id, sum) = out
  153. .split_once('|')
  154. .map(|(a, b)| (a.to_string(), b.to_string()))
  155. .unwrap_or_else(|| (out.to_string(), String::new()));
  156. if build_checksum.is_empty() && !sum.is_empty() {
  157. build_checksum = sum.clone();
  158. }
  159. nodes.insert(
  160. n.name.clone(),
  161. serde_json::json!({ "id": id, "sm121_checksum": sum }),
  162. );
  163. }
  164. serde_json::json!({ "build_checksum": build_checksum, "nodes": nodes })
  165. }
  166. /// Resolve the recipe `model` id to its `refs/main` snapshot on the head
  167. /// node (the shared-NAS HF cache). Returns the snapshot hash, or `None`
  168. /// when the model is a local path (no `org/name` id to resolve) or the repo
  169. /// isn't cached there. Only `org/name` repo ids carry a `refs/main`.
  170. async fn capture_model_snapshot(
  171. config: &Config,
  172. node: &crate::config::Node,
  173. model: &str,
  174. ) -> Option<String> {
  175. if model.starts_with('/') || model.starts_with('~') || !model.contains('/') {
  176. return None;
  177. }
  178. let model_dir = crate::config::model_cache_dir_name(model);
  179. let hf_cache = config.resolved_hf_cache_dir();
  180. let refs_path = format!("{}/hub/{}/refs/main", hf_cache.display(), model_dir);
  181. let cmd = format!("cat {} 2>/dev/null", crate::docker::shq(&refs_path));
  182. let out = run_ssh(config, node, cmd, false).await.ok()?;
  183. let snap = out.trim();
  184. if snap.is_empty() {
  185. None
  186. } else {
  187. Some(snap.to_string())
  188. }
  189. }
  190. /// Inputs for [`write_run_package`] (bundled: the signature would
  191. /// otherwise trip `too_many_arguments`).
  192. pub struct RunPackage<'a> {
  193. pub config: &'a Config,
  194. pub cluster: &'a Cluster,
  195. pub recipe: &'a Recipe,
  196. pub recipe_file: &'a Path,
  197. /// `<YYYY-MM-DD>_<label>` run stem (report triplet base name).
  198. pub stem: &'a str,
  199. pub reports_dir: &'a Path,
  200. pub bench_args: &'a [String],
  201. pub bench_json: &'a serde_json::Value,
  202. }
  203. /// Write the run package (`<stem>.recipe.toml` + `<stem>.manifest.json`)
  204. /// into `reports_dir`. The recipe copy is the bytes of `recipe_file` as
  205. /// read at bench time; image identity is captured live per node.
  206. pub async fn write_run_package(p: &RunPackage<'_>) -> Result<()> {
  207. let RunPackage {
  208. config,
  209. cluster,
  210. recipe,
  211. recipe_file,
  212. stem,
  213. reports_dir,
  214. bench_args,
  215. bench_json,
  216. } = p;
  217. let bytes = fs::read(recipe_file)?;
  218. let recipe_sha = sha256_hex(&bytes);
  219. fs::write(reports_dir.join(format!("{stem}.recipe.toml")), &bytes)?;
  220. let cfg_path = crate::gconfig::resolve_config_path("config.toml");
  221. let manifest_config = fs::read_to_string(&cfg_path).ok().map(|s| ManifestConfig {
  222. file: cfg_path.display().to_string(),
  223. sha256: sha256_hex(s.as_bytes()),
  224. });
  225. let git_sha = std::process::Command::new("git")
  226. .args(["rev-parse", "HEAD"])
  227. .output()
  228. .ok()
  229. .filter(|o| o.status.success())
  230. .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
  231. let scores = &bench_json["scores"];
  232. let points = scores["total_points"]
  233. .as_u64()
  234. .and_then(|t| scores["max_points"].as_u64().map(|m| format!("{t}/{m}")));
  235. let mut images = serde_json::Map::new();
  236. if !recipe.docker_image.is_empty() {
  237. images.insert(
  238. recipe.docker_image.clone(),
  239. capture_images(config, cluster, &recipe.docker_image).await,
  240. );
  241. }
  242. // The model reference floats (engine follows refs/main); capture the
  243. // snapshot it resolved to on the head node so the run is reproducible.
  244. let mut models: Vec<ManifestModel> = Vec::new();
  245. if !recipe.model.is_empty()
  246. && let Some(head) = cluster.nodes.first()
  247. {
  248. models.push(ManifestModel {
  249. id: recipe.model.clone(),
  250. snapshot: capture_model_snapshot(config, head, &recipe.model).await,
  251. });
  252. }
  253. let m = Manifest {
  254. schema_version: 1,
  255. run: stem.to_string(),
  256. created_utc: utc_now_iso(),
  257. recipe: ManifestRecipe {
  258. name: recipe.name.clone(),
  259. file: recipe_file.display().to_string(),
  260. sha256: recipe_sha.clone(),
  261. },
  262. cluster: ManifestCluster {
  263. name: cluster.name.clone(),
  264. nodes: cluster.nodes.iter().map(|n| n.ssh_ip.clone()).collect(),
  265. },
  266. config: manifest_config,
  267. launcher: ManifestLauncher {
  268. bin: std::env::current_exe()
  269. .map(|p| p.display().to_string())
  270. .unwrap_or_default(),
  271. git_sha,
  272. },
  273. bench: ManifestBench {
  274. tool: "tool-eval-bench".into(),
  275. version: bench_json
  276. .get("tool_eval_bench_version")
  277. .and_then(|v| v.as_str())
  278. .map(String::from),
  279. args: bench_args.to_vec(),
  280. final_score: bench_json["final_score"].as_u64().map(|v| v as u32),
  281. points,
  282. },
  283. images,
  284. models,
  285. };
  286. let manifest_path = reports_dir.join(format!("{stem}.manifest.json"));
  287. fs::write(
  288. &manifest_path,
  289. format!("{}\n", serde_json::to_string_pretty(&m)?),
  290. )?;
  291. eprintln!(
  292. "bench: run package: {} + {}",
  293. reports_dir.join(format!("{stem}.recipe.toml")).display(),
  294. manifest_path.display()
  295. );
  296. Ok(())
  297. }
  298. #[cfg(test)]
  299. mod tests {
  300. use super::*;
  301. #[test]
  302. fn sha256_hex_known_value() {
  303. assert_eq!(
  304. sha256_hex(b"abc"),
  305. "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
  306. );
  307. }
  308. #[test]
  309. fn short12_truncates() {
  310. assert_eq!(short12("deadbeefcafe"), "deadbeefcafe");
  311. assert_eq!(short12("deadbeefcafe42"), "deadbeefcafe");
  312. }
  313. #[test]
  314. fn utc_now_iso_is_realistic() {
  315. let s = utc_now_iso();
  316. // YYYY-MM-DDTHH:MM:SSZ, and the year must be a sane calendar year
  317. // (regression: civil_date got raw SECONDS and produced year 4900778)
  318. let year: u32 = s[..4].parse().unwrap();
  319. assert!(
  320. (2020..2030).contains(&year),
  321. "year {year} out of range: {s}"
  322. );
  323. assert_eq!(s.len(), 20);
  324. assert!(s.ends_with('Z'));
  325. }
  326. #[test]
  327. fn old_manifest_without_models_deserializes() {
  328. // Pre-model manifests lack the `models` field; `#[serde(default)]`
  329. // keeps `sm121 report` able to read them.
  330. let json = r#"{"schema_version":1,"run":"r","created_utc":"2026-01-01T00:00:00Z",
  331. "recipe":{"name":"X","file":"f","sha256":"s"},
  332. "cluster":{"name":"c","nodes":[]},
  333. "launcher":{"bin":"b","git_sha":null},
  334. "bench":{"tool":"tool-eval-bench","version":"1","args":[],"final_score":null,"points":null},
  335. "images":{}}"#;
  336. let m: Manifest = serde_json::from_str(json).unwrap();
  337. assert!(m.models.is_empty());
  338. }
  339. #[test]
  340. fn run_package_writes_copy_and_manifest() {
  341. let dir = std::env::temp_dir().join(format!("sm121-artifacts-{}-x", std::process::id()));
  342. fs::create_dir_all(dir.join("reports")).unwrap();
  343. let recipe_file = dir.join("recipe.toml");
  344. fs::write(
  345. &recipe_file,
  346. "engine = \"vllm\"\nname = \"X\"\nmodel = \"m\"\n",
  347. )
  348. .unwrap();
  349. let config: Config = toml::from_str("ssh_user = \"u\"\n").unwrap();
  350. let cluster = Cluster {
  351. name: "c".into(),
  352. nodes: vec![], // no nodes → no SSH, no image capture
  353. master_ib_ip: None,
  354. extra_entities: vec![],
  355. };
  356. let recipe: Recipe = toml::from_str(
  357. "engine = \"vllm\"\nname = \"X\"\nmodel = \"m\"\ndocker_image = \"img:tag\"\n",
  358. )
  359. .unwrap();
  360. let bench_json: serde_json::Value =
  361. serde_json::from_str(r#"{"tool_eval_bench_version": "1.2.3", "final_score": 93, "scores": {"total_points": 163, "max_points": 176}}"#)
  362. .unwrap();
  363. let rt = tokio::runtime::Runtime::new().unwrap();
  364. rt.block_on(write_run_package(&RunPackage {
  365. config: &config,
  366. cluster: &cluster,
  367. recipe: &recipe,
  368. recipe_file: &recipe_file,
  369. stem: "2026-01-01_test",
  370. reports_dir: &dir.join("reports"),
  371. bench_args: &["--base-url".into(), "x".into()],
  372. bench_json: &bench_json,
  373. }))
  374. .unwrap();
  375. let copy = fs::read_to_string(dir.join("reports/2026-01-01_test.recipe.toml")).unwrap();
  376. assert_eq!(copy, "engine = \"vllm\"\nname = \"X\"\nmodel = \"m\"\n");
  377. let m: Manifest = serde_json::from_str(
  378. &fs::read_to_string(dir.join("reports/2026-01-01_test.manifest.json")).unwrap(),
  379. )
  380. .unwrap();
  381. assert_eq!(m.schema_version, 1);
  382. assert_eq!(m.run, "2026-01-01_test");
  383. assert_eq!(
  384. m.recipe.sha256,
  385. sha256_hex(b"engine = \"vllm\"\nname = \"X\"\nmodel = \"m\"\n")
  386. );
  387. assert_eq!(m.bench.version.as_deref(), Some("1.2.3"));
  388. assert_eq!(m.bench.points.as_deref(), Some("163/176"));
  389. assert_eq!(m.bench.final_score, Some(93));
  390. // no nodes → the image entry exists but has an empty node map
  391. let img = &m.images["img:tag"];
  392. assert_eq!(img["build_checksum"], "");
  393. assert!(img["nodes"].as_object().unwrap().is_empty());
  394. fs::remove_dir_all(&dir).ok();
  395. }
  396. }