view.rs 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. //! `local-view` staging block executor.
  2. //!
  3. //! Some checkpoints stream a large side table at every token (the DSV4.1
  4. //! Engram tables read ~1 MB of rows per token). Serving those rows from the
  5. //! shared NAS costs an order of magnitude more than from local NVMe, while the
  6. //! bulk of the weights belongs on shared storage. A `local-view` block
  7. //! materializes, per launch node, a directory holding **real copies** of the
  8. //! files matching `local` and symlinks (into the shared cache's container
  9. //! path) for everything else; `serve` mounts it and points `model` at it.
  10. //!
  11. //! The node-side work is `scripts/local_view.py`; this module only builds the
  12. //! command lines, decides the container mount and computes the bytes a node
  13. //! must have free (used by the pre-start free-space check).
  14. use crate::config::{Config, Node, Staging, expand_tilde};
  15. use crate::ssh::{run_ssh_long_running, upload_script};
  16. use anyhow::{Result, bail};
  17. use std::path::Path;
  18. use tracing::info;
  19. pub const SCRIPT_BODY: &str = include_str!("scripts/local_view.py");
  20. /// Container-side path the view is mounted at (the model references it).
  21. pub const CONTAINER_ROOT: &str = "/root/.cache/hf-view";
  22. fn script_path(id: &str) -> String {
  23. format!("/tmp/sm121-local-view-{id}.py")
  24. }
  25. /// The repo a block views: explicit `repo`, else the recipe's model.
  26. pub fn repo_for(block: &Staging, model: &str) -> String {
  27. block.repo.clone().unwrap_or_else(|| model.to_string())
  28. }
  29. /// Node-side view directory (tilde-expanded).
  30. pub fn dst_for(block: &Staging, id: &str, ssh_user: &str) -> String {
  31. block
  32. .dst
  33. .as_deref()
  34. .map(|d| expand_tilde(d, ssh_user))
  35. .unwrap_or_else(|| format!("~/.cache/hf-view/{id}"))
  36. }
  37. /// `measure` (bytes a node needs) or `apply` (build the view).
  38. pub struct ViewSpec<'a> {
  39. pub repo: &'a str,
  40. pub local: &'a [String],
  41. pub verify: &'a str,
  42. pub min_free: Option<&'a str>,
  43. pub force: bool,
  44. }
  45. /// Command line for `python3 <script> apply --src … --repo … --dst … --local …`.
  46. pub fn command_for(id: &str, mode: &str, src: &str, dst: &str, spec: &ViewSpec<'_>) -> String {
  47. let mut cmd = format!(
  48. "python3 {} {} --src {} --repo {} --dst {} --container-root {} --verify {}",
  49. crate::docker::shq(&script_path(id)),
  50. mode,
  51. crate::docker::shq(src),
  52. crate::docker::shq(spec.repo),
  53. crate::docker::shq(dst),
  54. crate::docker::shq(CONTAINER_ROOT),
  55. spec.verify,
  56. );
  57. for g in spec.local {
  58. cmd.push_str(&format!(" --local {}", crate::docker::shq(g)));
  59. }
  60. if let Some(m) = spec.min_free {
  61. cmd.push_str(&format!(" --min-free {}", crate::docker::shq(m)));
  62. }
  63. if spec.force {
  64. cmd.push_str(" --force");
  65. }
  66. cmd
  67. }
  68. /// What one node needs free for a view, plus what it has (bytes).
  69. #[derive(Debug, Clone, PartialEq, Eq)]
  70. pub struct Need {
  71. pub node: String,
  72. pub repo: String,
  73. pub need: u64,
  74. pub free: u64,
  75. pub dst: String,
  76. }
  77. impl Need {
  78. pub fn short(&self) -> bool {
  79. self.free < self.need
  80. }
  81. pub fn line(&self) -> String {
  82. format!(
  83. "{}: {} → needs {:.1} GiB, has {:.1} GiB{}",
  84. self.node,
  85. self.repo,
  86. self.need as f64 / (1u64 << 30) as f64,
  87. self.free as f64 / (1u64 << 30) as f64,
  88. if self.short() { " ⚠ SHORT" } else { "" }
  89. )
  90. }
  91. }
  92. /// Parse the `measure` output (`{"need": …, "free": …}`) of the node script.
  93. pub fn parse_measure(node: &str, repo: &str, dst: &str, stdout: &str) -> Result<Need> {
  94. let v: serde_json::Value = serde_json::from_str(stdout.trim()).map_err(|e| {
  95. anyhow::anyhow!("local-view measure on {node}: unparsable output {stdout:?}: {e}")
  96. })?;
  97. let num = |k: &str| v.get(k).and_then(serde_json::Value::as_u64).unwrap_or(0);
  98. Ok(Need {
  99. node: node.to_string(),
  100. repo: repo.to_string(),
  101. need: num("need"),
  102. free: num("free"),
  103. dst: dst.to_string(),
  104. })
  105. }
  106. /// The aggregated, human-facing shortfall message (`None` when every node fits).
  107. pub fn shortfall(needs: &[Need]) -> Option<String> {
  108. let short: Vec<&Need> = needs.iter().filter(|n| n.short()).collect();
  109. if short.is_empty() {
  110. return None;
  111. }
  112. let mut msg =
  113. String::from("not enough free space on the stage target(s) — nothing was started:\n");
  114. for n in &short {
  115. msg.push_str(&format!(
  116. " {}: {} needs {:.1} GiB in {}, has {:.1} GiB\n",
  117. n.node,
  118. n.repo,
  119. n.need as f64 / (1u64 << 30) as f64,
  120. n.dst,
  121. n.free as f64 / (1u64 << 30) as f64
  122. ));
  123. }
  124. msg.push_str("free space (docker rmi / stale staging dirs) or point dst at a bigger volume.");
  125. Some(msg)
  126. }
  127. /// Ask every launch node how much space a block's `local` copies need.
  128. pub async fn measure(
  129. config: &Config,
  130. nodes: &[Node],
  131. block: &Staging,
  132. index: usize,
  133. model: &str,
  134. shared_source: &Path,
  135. ) -> Result<Vec<Need>> {
  136. let id = block.ledger_id(index);
  137. let repo = repo_for(block, model);
  138. let spec = ViewSpec {
  139. repo: &repo,
  140. local: &block.local,
  141. verify: block.verify.as_str(),
  142. min_free: block.min_free.as_deref(),
  143. force: false,
  144. };
  145. let mut out = Vec::new();
  146. for node in nodes {
  147. let dst = dst_for(block, &id, &config.ssh_user);
  148. upload_script(config, node, SCRIPT_BODY, &script_path(&id), false).await?;
  149. let cmd = command_for(
  150. &id,
  151. "measure",
  152. &shared_source.display().to_string(),
  153. &dst,
  154. &spec,
  155. );
  156. let stdout = crate::ssh::run_ssh(config, node, cmd, false).await?;
  157. out.push(parse_measure(&node.name, &repo, &dst, &stdout)?);
  158. }
  159. Ok(out)
  160. }
  161. /// Everything one node's `local-view` pass needs.
  162. pub struct StageCtx<'a> {
  163. pub config: &'a Config,
  164. pub recipe: &'a crate::config::Recipe,
  165. pub shared_source: &'a Path,
  166. pub force: bool,
  167. pub dry_run: bool,
  168. }
  169. /// Stage one `local-view` block on one node.
  170. pub async fn stage_node(
  171. ctx: &StageCtx<'_>,
  172. node: &Node,
  173. block: &Staging,
  174. index: usize,
  175. ) -> Result<()> {
  176. let (config, model, shared_source, force, dry_run) = (
  177. ctx.config,
  178. ctx.recipe.model.as_str(),
  179. ctx.shared_source,
  180. ctx.force,
  181. ctx.dry_run,
  182. );
  183. if block.local.is_empty() {
  184. bail!(
  185. "[[staging]] local-view block '{}' needs at least one `local` glob (the files that must be real node-local copies)",
  186. block.ledger_id(index)
  187. );
  188. }
  189. let id = block.ledger_id(index);
  190. let repo = repo_for(block, model);
  191. let dst = dst_for(block, &id, &config.ssh_user);
  192. let spec = ViewSpec {
  193. repo: &repo,
  194. local: &block.local,
  195. verify: block.verify.as_str(),
  196. min_free: block.min_free.as_deref(),
  197. force,
  198. };
  199. let src = shared_source.display().to_string();
  200. if dry_run {
  201. info!(
  202. "dry-run staging[{}] on {}: local-view {} → {} (local: {:?}, min_free {:?})",
  203. id, node.name, repo, dst, block.local, block.min_free
  204. );
  205. return Ok(());
  206. }
  207. upload_script(config, node, SCRIPT_BODY, &script_path(&id), false).await?;
  208. let cmd = command_for(&id, "apply", &src, &dst, &spec);
  209. run_ssh_long_running(config, node, cmd, false).await?;
  210. Ok(())
  211. }
  212. #[cfg(test)]
  213. mod tests {
  214. use super::*;
  215. fn need(node: &str, need: u64, free: u64) -> Need {
  216. Need {
  217. node: node.into(),
  218. repo: "org/model".into(),
  219. need,
  220. free,
  221. dst: "/v".into(),
  222. }
  223. }
  224. #[test]
  225. fn measure_output_parses() {
  226. let n = parse_measure(
  227. "dgx5",
  228. "org/m",
  229. "/v",
  230. r#"{"repo":"org/m","need":203,"free":400}"#,
  231. )
  232. .unwrap();
  233. assert_eq!(n.need, 203);
  234. assert_eq!(n.free, 400);
  235. assert!(!n.short());
  236. }
  237. #[test]
  238. fn shortfall_lists_only_the_short_nodes() {
  239. let needs = vec![need("a", 100, 200), need("b", 300, 250)];
  240. let msg = shortfall(&needs).expect("b is short");
  241. assert!(msg.contains("b:"), "{msg}");
  242. assert!(!msg.contains("a:"), "{msg}");
  243. assert!(msg.contains("nothing was started"), "{msg}");
  244. assert!(shortfall(&[need("a", 1, 2)]).is_none());
  245. }
  246. #[test]
  247. fn command_carries_globs_and_container_root() {
  248. let globs = vec!["model-00047-of-00048.safetensors".to_string()];
  249. let spec = ViewSpec {
  250. repo: "org/model",
  251. local: &globs,
  252. verify: "sample-hash",
  253. min_free: Some("10G"),
  254. force: true,
  255. };
  256. let cmd = command_for("s0", "apply", "/src", "/dst", &spec);
  257. // shq() shell-quotes arguments, so the glob may come back quoted
  258. assert!(cmd.contains("model-00047-of-00048.safetensors"), "{cmd}");
  259. assert!(cmd.contains("--local"), "{cmd}");
  260. assert!(cmd.contains(CONTAINER_ROOT), "{cmd}");
  261. assert!(cmd.contains("--min-free '10G'"), "{cmd}");
  262. assert!(cmd.ends_with("--force"), "{cmd}");
  263. }
  264. }