staging.rs 11 KB

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