staging.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. //! Declarative data staging — see specs/tech-architecture/staging-feature.md.
  2. //!
  3. //! A recipe declares `[[staging]]` blocks; `serve` executes them on every
  4. //! launch node before containers start, then (for hub-mirrors) serves from
  5. //! the local mirror instead of the shared HF cache. No manual per-node
  6. //! preparation is required — the operator runs plain `sm121 serve`.
  7. pub mod mirror;
  8. pub mod rows;
  9. pub mod view;
  10. use crate::config::{Config, Node, Recipe, StagingKind, expand_tilde};
  11. use anyhow::{Result, bail};
  12. use std::path::{Path, PathBuf};
  13. use tracing::{info, warn};
  14. /// How aggressively to stage (`--stage=auto|skip|force`).
  15. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  16. pub enum StageMode {
  17. /// Verify staged content against the ledger, copy what is new (default).
  18. Auto,
  19. /// Assume staging is already done; only print the plan (CI / known-good).
  20. Skip,
  21. /// Re-verify everything regardless of the ledger.
  22. Force,
  23. }
  24. impl StageMode {
  25. pub fn parse(s: &str) -> Result<Self> {
  26. match s.trim().to_ascii_lowercase().as_str() {
  27. "" | "auto" => Ok(StageMode::Auto),
  28. "skip" => Ok(StageMode::Skip),
  29. "force" => Ok(StageMode::Force),
  30. other => bail!("unknown --stage value '{other}' (auto|skip|force)"),
  31. }
  32. }
  33. }
  34. /// The first `local-view` block's mount: (host view dir, container mount,
  35. /// repo). `serve` appends the mount and rewrites `model` to
  36. /// `<container>/<repo>`, so the engine reads the staged files from local NVMe
  37. /// and everything else through the shared cache's own mount.
  38. pub fn local_view_mount(config: &Config, recipe: &Recipe) -> Option<(PathBuf, String, String)> {
  39. for (i, s) in recipe.staging.iter().enumerate() {
  40. if s.kind != StagingKind::LocalView {
  41. continue;
  42. }
  43. let id = s.ledger_id(i);
  44. let host = PathBuf::from(view::dst_for(s, &id, &config.ssh_user));
  45. let repo = view::repo_for(s, &recipe.model);
  46. return Some((host, view::CONTAINER_ROOT.to_string(), repo));
  47. }
  48. None
  49. }
  50. /// Free-space preflight for every staging block whose footprint is known.
  51. /// Runs BEFORE any copy and before any container starts: a node that cannot
  52. /// hold the staged files fails the serve with the node, the size and the
  53. /// target directory, instead of dying halfway through a 200 GB copy.
  54. pub async fn preflight(
  55. config: &Config,
  56. nodes: &[Node],
  57. recipe: &Recipe,
  58. shared_source: &Path,
  59. dry_run: bool,
  60. ) -> Result<()> {
  61. let mut needs: Vec<view::Need> = Vec::new();
  62. for (i, block) in recipe.staging.iter().enumerate() {
  63. if block.kind != StagingKind::LocalView || dry_run {
  64. continue;
  65. }
  66. needs.extend(view::measure(config, nodes, block, i, &recipe.model, shared_source).await?);
  67. }
  68. for n in &needs {
  69. info!("staging space: {}", n.line());
  70. }
  71. if let Some(msg) = view::shortfall(&needs) {
  72. bail!(msg);
  73. }
  74. Ok(())
  75. }
  76. /// True when the recipe stages at least one hub-mirror (the HF mount source
  77. /// gets swapped to the mirror's dst in that case).
  78. pub fn has_hub_mirror(recipe: &Recipe) -> bool {
  79. recipe
  80. .staging
  81. .iter()
  82. .any(|s| s.kind == StagingKind::HubMirror)
  83. }
  84. /// The hub-mirror dst (tilde-expanded) serve must mount instead of the
  85. /// shared HF cache. Multiple hub-mirror blocks must share one dst; blocks
  86. /// without an explicit `dst` use `~/.cache/hf-staging/<id>` — identical to
  87. /// the default `stage_node` picks.
  88. pub fn hub_mirror_dst(config: &Config, recipe: &Recipe) -> Option<PathBuf> {
  89. let mut out: Option<PathBuf> = None;
  90. for (i, s) in recipe.staging.iter().enumerate() {
  91. if s.kind != StagingKind::HubMirror {
  92. continue;
  93. }
  94. let d = s
  95. .dst
  96. .clone()
  97. .unwrap_or_else(|| format!("~/.cache/hf-staging/{}", s.ledger_id(i)));
  98. let p = PathBuf::from(expand_tilde(&d, &config.ssh_user));
  99. if let Some(prev) = &out
  100. && *prev != p
  101. {
  102. warn!(
  103. "multiple hub-mirror staging blocks with different dst — mounting {}",
  104. prev.display()
  105. );
  106. }
  107. if out.is_none() {
  108. out = Some(p);
  109. }
  110. }
  111. out
  112. }
  113. /// Serve-side staging pass: validate the CLI knobs, run the recipe's
  114. /// `[[staging]]` blocks on every launch node, and return the HF mount
  115. /// source for this serve: `(mirror, cache_dir)`. A hub-mirror block takes
  116. /// over the mount (`mirror=None`, `cache_dir=dst`); otherwise the standard
  117. /// `mirror::resolve` applies (`--mirror` / `--hf-cache`).
  118. pub async fn hf_mount(
  119. config: &Config,
  120. nodes: &[Node],
  121. recipe: &Recipe,
  122. mirror_cli: Option<&str>,
  123. hf_cache_cli: Option<&str>,
  124. stage_cli: Option<&str>,
  125. dry_run: bool,
  126. ) -> Result<(Option<PathBuf>, PathBuf)> {
  127. if mirror_cli.is_some() && hf_cache_cli.is_some() {
  128. anyhow::bail!(
  129. "--hf-cache and --mirror are mutually exclusive (both pick the HF mount source)"
  130. );
  131. }
  132. let staged = has_hub_mirror(recipe);
  133. if staged && mirror_cli.is_some() {
  134. anyhow::bail!(
  135. "--mirror and a recipe [[staging]] hub-mirror cannot be combined — declare the mirror in the recipe"
  136. );
  137. }
  138. if staged && hf_cache_cli.is_some() {
  139. anyhow::bail!(
  140. "--hf-cache is ignored for a hub-mirror staging recipe (dst wins); drop one of them"
  141. );
  142. }
  143. if !recipe.staging.is_empty() {
  144. let mode = stage_cli
  145. .map(StageMode::parse)
  146. .transpose()?
  147. .unwrap_or(StageMode::Auto);
  148. let shared_source = match hf_cache_cli {
  149. Some(p) => PathBuf::from(expand_tilde(p, &config.ssh_user)),
  150. None => config.resolved_hf_cache_dir(),
  151. };
  152. preflight(config, nodes, recipe, &shared_source, dry_run).await?;
  153. run(config, nodes, recipe, &shared_source, mode, dry_run).await?;
  154. }
  155. if staged {
  156. let dst = hub_mirror_dst(config, recipe)
  157. .ok_or_else(|| anyhow::anyhow!("hub-mirror staging block has no dst"))?;
  158. return Ok((None, dst));
  159. }
  160. let (mirror, cache_dir) = crate::docker::mirror::resolve(config, mirror_cli, hf_cache_cli);
  161. if let Some(ref m) = mirror {
  162. info!(
  163. "serving from local HF mirror {} (models copied per node on first use)",
  164. m.display()
  165. );
  166. }
  167. Ok((mirror, cache_dir))
  168. }
  169. /// Run every `[[staging]]` block on every launch node (blocks per node run
  170. /// in sequence, nodes in parallel). `shared_source` is the host-side HF cache
  171. /// a hub-mirror defaults to copy FROM (the `--hf-cache` / config dir).
  172. pub async fn run(
  173. config: &Config,
  174. nodes: &[Node],
  175. recipe: &Recipe,
  176. shared_source: &Path,
  177. mode: StageMode,
  178. dry_run: bool,
  179. ) -> Result<()> {
  180. if recipe.staging.is_empty() {
  181. return Ok(());
  182. }
  183. if mode == StageMode::Skip {
  184. info!("staging: --stage=skip — assuming staged");
  185. return Ok(());
  186. }
  187. // Validate ids before touching any node.
  188. let mut seen: Vec<String> = Vec::new();
  189. for (i, s) in recipe.staging.iter().enumerate() {
  190. let id = s.ledger_id(i);
  191. if seen.contains(&id) {
  192. bail!("duplicate [[staging]] id '{id}' in recipe");
  193. }
  194. seen.push(id);
  195. }
  196. let mut handles: Vec<tokio::task::JoinHandle<Result<()>>> = Vec::new();
  197. // sparse-rows splits the table across the engine's tensor-parallel
  198. // ranks: recipe TP wins, else one rank per launch node.
  199. let tp = if recipe.tensor_parallel_size > 0 {
  200. recipe.tensor_parallel_size
  201. } else {
  202. nodes.len() as u32
  203. };
  204. for node in nodes {
  205. let config = config.clone();
  206. let node = node.clone();
  207. let recipe = recipe.clone();
  208. let source = shared_source.to_path_buf();
  209. handles.push(crate::locale::spawn_with_locale(async move {
  210. let ctx = mirror::Ctx {
  211. config: &config,
  212. recipe: &recipe,
  213. shared_source: &source,
  214. mode,
  215. dry_run,
  216. };
  217. for (i, block) in recipe.staging.iter().enumerate() {
  218. match block.kind {
  219. StagingKind::HubMirror => mirror::stage_node(&ctx, &node, block, i).await?,
  220. StagingKind::LocalView => {
  221. let vctx = view::StageCtx {
  222. config: &config,
  223. recipe: &recipe,
  224. shared_source: &source,
  225. force: mode == StageMode::Force,
  226. dry_run,
  227. };
  228. view::stage_node(&vctx, &node, block, i).await?
  229. }
  230. StagingKind::SparseRows => {
  231. let rctx = rows::Ctx {
  232. config: &config,
  233. shared_source: &source,
  234. mode,
  235. dry_run,
  236. rank: node.rank,
  237. tp,
  238. };
  239. rows::stage_node(&rctx, &node, block, i).await?
  240. }
  241. }
  242. }
  243. Ok(())
  244. }));
  245. }
  246. for h in handles {
  247. h.await??;
  248. }
  249. Ok(())
  250. }
  251. #[cfg(test)]
  252. mod tests {
  253. use super::*;
  254. use crate::config::Node;
  255. use crate::config::{DeployTarget, DistributedBackend, Staging};
  256. use crate::pre_serve::{RecipeArgs, VenvSetup};
  257. use std::collections::HashMap;
  258. fn cfg() -> Config {
  259. Config {
  260. ssh_user: "u".into(),
  261. ssh_key_path: "/k".into(),
  262. clusters: vec![],
  263. nccl: HashMap::new(),
  264. hf_cache_dir: None,
  265. init_port: 25000,
  266. before_serve: HashMap::new(),
  267. exec_aliases: HashMap::new(),
  268. home_assistant: None,
  269. mikrotik: None,
  270. global_mods_dir: None,
  271. drop_caches: true,
  272. mount_aliases: HashMap::new(),
  273. recipe_sources: Vec::new(),
  274. recipe_repos: Vec::new(),
  275. docker_image_sources: Vec::new(),
  276. cluster_sources: Vec::new(),
  277. registry: None,
  278. }
  279. }
  280. fn trec() -> Recipe {
  281. Recipe {
  282. build_profile: None,
  283. parent_model: None,
  284. archived: false,
  285. engine: "vllm".into(),
  286. name: "T".into(),
  287. docker_image: "img".into(),
  288. model: "org/main".into(),
  289. port: 8000,
  290. required_nodes: Some(1),
  291. pipeline_parallel_size: Some(1),
  292. tensor_parallel_size: 1,
  293. args: RecipeArgs::List(vec![]),
  294. cold_args: RecipeArgs::default(),
  295. env: HashMap::new(),
  296. cluster: None,
  297. entrypoint: vec![],
  298. distributed_backend: Some(DistributedBackend::Mp),
  299. before_serve: vec![],
  300. after_stop: vec![],
  301. solo: None,
  302. deploy: DeployTarget::Docker,
  303. venv: None,
  304. venv_setup: VenvSetup::default(),
  305. venv_setup_script: None,
  306. mods: Default::default(),
  307. shm_size: String::new(),
  308. devices: vec![],
  309. memory_limit: None,
  310. cpus: None,
  311. sync_image: true,
  312. docker_image_dir: None,
  313. extra_mounts: vec![],
  314. extra_mounts_manifest: None,
  315. build: Default::default(),
  316. composed_model: None,
  317. staging: vec![],
  318. dgpp: None,
  319. }
  320. }
  321. #[test]
  322. fn stage_mode_parse() {
  323. assert_eq!(StageMode::parse("").unwrap(), StageMode::Auto);
  324. assert_eq!(StageMode::parse("auto").unwrap(), StageMode::Auto);
  325. assert_eq!(StageMode::parse("SKIP").unwrap(), StageMode::Skip);
  326. assert_eq!(StageMode::parse("force").unwrap(), StageMode::Force);
  327. assert!(StageMode::parse("nope").is_err());
  328. }
  329. #[tokio::test]
  330. async fn sparse_rows_dry_run_dispatches() {
  331. // dry-run touches no SSH: both nodes just print their plan (rank
  332. // 0 and 1 of a 2-rank split) and the pass succeeds — this exercises
  333. // the sparse-rows dispatch arm and the tp derivation.
  334. let mut r = trec();
  335. r.staging.push(Staging {
  336. kind: crate::config::StagingKind::SparseRows,
  337. src: Some("{{hf}}/hub/snap".into()),
  338. dst: Some("~/.cache/engram".into()),
  339. ..Default::default()
  340. });
  341. let nodes = vec![
  342. Node {
  343. name: "n0".into(),
  344. ssh_ip: "10.0.0.1".into(),
  345. ib_ip: None,
  346. rank: 0,
  347. host_key: None,
  348. entity: None,
  349. },
  350. Node {
  351. name: "n1".into(),
  352. ssh_ip: "10.0.0.2".into(),
  353. ib_ip: None,
  354. rank: 1,
  355. host_key: None,
  356. entity: None,
  357. },
  358. ];
  359. crate::staging::run(
  360. &cfg(),
  361. &nodes,
  362. &r,
  363. std::path::Path::new("/mnt/nas/hf"),
  364. StageMode::Auto,
  365. true,
  366. )
  367. .await
  368. .unwrap();
  369. }
  370. #[test]
  371. fn hub_mirror_dst_tilde_and_detection() {
  372. let mut r = trec();
  373. assert!(!has_hub_mirror(&r));
  374. assert!(hub_mirror_dst(&cfg(), &r).is_none());
  375. r.staging.push(Staging {
  376. kind: StagingKind::HubMirror,
  377. dst: Some("~/.cache/hf-local".into()),
  378. ..Default::default()
  379. });
  380. assert!(has_hub_mirror(&r));
  381. assert_eq!(
  382. hub_mirror_dst(&cfg(), &r),
  383. Some(PathBuf::from("/home/u/.cache/hf-local"))
  384. );
  385. }
  386. }