| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347 |
- //! Declarative data staging — see specs/tech-architecture/staging-feature.md.
- //!
- //! A recipe declares `[[staging]]` blocks; `serve` executes them on every
- //! launch node before containers start, then (for hub-mirrors) serves from
- //! the local mirror instead of the shared HF cache. No manual per-node
- //! preparation is required — the operator runs plain `sm121 serve`.
- pub mod mirror;
- pub mod rows;
- use crate::config::{Config, Node, Recipe, StagingKind, expand_tilde};
- use anyhow::{Result, bail};
- use std::path::{Path, PathBuf};
- use tracing::{info, warn};
- /// How aggressively to stage (`--stage=auto|skip|force`).
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
- pub enum StageMode {
- /// Verify staged content against the ledger, copy what is new (default).
- Auto,
- /// Assume staging is already done; only print the plan (CI / known-good).
- Skip,
- /// Re-verify everything regardless of the ledger.
- Force,
- }
- impl StageMode {
- pub fn parse(s: &str) -> Result<Self> {
- match s.trim().to_ascii_lowercase().as_str() {
- "" | "auto" => Ok(StageMode::Auto),
- "skip" => Ok(StageMode::Skip),
- "force" => Ok(StageMode::Force),
- other => bail!("unknown --stage value '{other}' (auto|skip|force)"),
- }
- }
- }
- /// True when the recipe stages at least one hub-mirror (the HF mount source
- /// gets swapped to the mirror's dst in that case).
- pub fn has_hub_mirror(recipe: &Recipe) -> bool {
- recipe
- .staging
- .iter()
- .any(|s| s.kind == StagingKind::HubMirror)
- }
- /// The hub-mirror dst (tilde-expanded) serve must mount instead of the
- /// shared HF cache. Multiple hub-mirror blocks must share one dst; blocks
- /// without an explicit `dst` use `~/.cache/hf-staging/<id>` — identical to
- /// the default `stage_node` picks.
- pub fn hub_mirror_dst(config: &Config, recipe: &Recipe) -> Option<PathBuf> {
- let mut out: Option<PathBuf> = None;
- for (i, s) in recipe.staging.iter().enumerate() {
- if s.kind != StagingKind::HubMirror {
- continue;
- }
- let d = s
- .dst
- .clone()
- .unwrap_or_else(|| format!("~/.cache/hf-staging/{}", s.ledger_id(i)));
- let p = PathBuf::from(expand_tilde(&d, &config.ssh_user));
- if let Some(prev) = &out
- && *prev != p
- {
- warn!(
- "multiple hub-mirror staging blocks with different dst — mounting {}",
- prev.display()
- );
- }
- if out.is_none() {
- out = Some(p);
- }
- }
- out
- }
- /// Serve-side staging pass: validate the CLI knobs, run the recipe's
- /// `[[staging]]` blocks on every launch node, and return the HF mount
- /// source for this serve: `(mirror, cache_dir)`. A hub-mirror block takes
- /// over the mount (`mirror=None`, `cache_dir=dst`); otherwise the standard
- /// `mirror::resolve` applies (`--mirror` / `--hf-cache`).
- pub async fn hf_mount(
- config: &Config,
- nodes: &[Node],
- recipe: &Recipe,
- mirror_cli: Option<&str>,
- hf_cache_cli: Option<&str>,
- stage_cli: Option<&str>,
- dry_run: bool,
- ) -> Result<(Option<PathBuf>, PathBuf)> {
- if mirror_cli.is_some() && hf_cache_cli.is_some() {
- anyhow::bail!(
- "--hf-cache and --mirror are mutually exclusive (both pick the HF mount source)"
- );
- }
- let staged = has_hub_mirror(recipe);
- if staged && mirror_cli.is_some() {
- anyhow::bail!(
- "--mirror and a recipe [[staging]] hub-mirror cannot be combined — declare the mirror in the recipe"
- );
- }
- if staged && hf_cache_cli.is_some() {
- anyhow::bail!(
- "--hf-cache is ignored for a hub-mirror staging recipe (dst wins); drop one of them"
- );
- }
- if !recipe.staging.is_empty() {
- let mode = stage_cli
- .map(StageMode::parse)
- .transpose()?
- .unwrap_or(StageMode::Auto);
- let shared_source = match hf_cache_cli {
- Some(p) => PathBuf::from(expand_tilde(p, &config.ssh_user)),
- None => config.resolved_hf_cache_dir(),
- };
- run(config, nodes, recipe, &shared_source, mode, dry_run).await?;
- }
- if staged {
- let dst = hub_mirror_dst(config, recipe)
- .ok_or_else(|| anyhow::anyhow!("hub-mirror staging block has no dst"))?;
- return Ok((None, dst));
- }
- let (mirror, cache_dir) = crate::docker::mirror::resolve(config, mirror_cli, hf_cache_cli);
- if let Some(ref m) = mirror {
- info!(
- "serving from local HF mirror {} (models copied per node on first use)",
- m.display()
- );
- }
- Ok((mirror, cache_dir))
- }
- /// Run every `[[staging]]` block on every launch node (blocks per node run
- /// in sequence, nodes in parallel). `shared_source` is the host-side HF cache
- /// a hub-mirror defaults to copy FROM (the `--hf-cache` / config dir).
- pub async fn run(
- config: &Config,
- nodes: &[Node],
- recipe: &Recipe,
- shared_source: &Path,
- mode: StageMode,
- dry_run: bool,
- ) -> Result<()> {
- if recipe.staging.is_empty() {
- return Ok(());
- }
- if mode == StageMode::Skip {
- info!("staging: --stage=skip — assuming staged");
- return Ok(());
- }
- // Validate ids before touching any node.
- let mut seen: Vec<String> = Vec::new();
- for (i, s) in recipe.staging.iter().enumerate() {
- let id = s.ledger_id(i);
- if seen.contains(&id) {
- bail!("duplicate [[staging]] id '{id}' in recipe");
- }
- seen.push(id);
- }
- let mut handles: Vec<tokio::task::JoinHandle<Result<()>>> = Vec::new();
- // sparse-rows splits the table across the engine's tensor-parallel
- // ranks: recipe TP wins, else one rank per launch node.
- let tp = if recipe.tensor_parallel_size > 0 {
- recipe.tensor_parallel_size
- } else {
- nodes.len() as u32
- };
- for node in nodes {
- let config = config.clone();
- let node = node.clone();
- let recipe = recipe.clone();
- let source = shared_source.to_path_buf();
- handles.push(crate::locale::spawn_with_locale(async move {
- let ctx = mirror::Ctx {
- config: &config,
- recipe: &recipe,
- shared_source: &source,
- mode,
- dry_run,
- };
- for (i, block) in recipe.staging.iter().enumerate() {
- match block.kind {
- StagingKind::HubMirror => mirror::stage_node(&ctx, &node, block, i).await?,
- StagingKind::SparseRows => {
- let rctx = rows::Ctx {
- config: &config,
- shared_source: &source,
- mode,
- dry_run,
- rank: node.rank,
- tp,
- };
- rows::stage_node(&rctx, &node, block, i).await?
- }
- }
- }
- Ok(())
- }));
- }
- for h in handles {
- h.await??;
- }
- Ok(())
- }
- #[cfg(test)]
- mod tests {
- use super::*;
- use crate::config::Node;
- use crate::config::{DeployTarget, DistributedBackend, Staging};
- use crate::pre_serve::{RecipeArgs, VenvSetup};
- use std::collections::HashMap;
- fn cfg() -> Config {
- Config {
- ssh_user: "u".into(),
- ssh_key_path: "/k".into(),
- clusters: vec![],
- nccl: HashMap::new(),
- hf_cache_dir: None,
- init_port: 25000,
- before_serve: HashMap::new(),
- exec_aliases: HashMap::new(),
- home_assistant: None,
- mikrotik: None,
- global_mods_dir: None,
- drop_caches: true,
- mount_aliases: HashMap::new(),
- recipe_sources: Vec::new(),
- recipe_repos: Vec::new(),
- docker_image_sources: Vec::new(),
- cluster_sources: Vec::new(),
- registry: None,
- }
- }
- fn trec() -> Recipe {
- Recipe {
- build_profile: None,
- parent_model: None,
- archived: false,
- engine: "vllm".into(),
- name: "T".into(),
- docker_image: "img".into(),
- model: "org/main".into(),
- port: 8000,
- required_nodes: Some(1),
- pipeline_parallel_size: Some(1),
- tensor_parallel_size: 1,
- args: RecipeArgs::List(vec![]),
- cold_args: RecipeArgs::default(),
- env: HashMap::new(),
- cluster: None,
- entrypoint: vec![],
- distributed_backend: Some(DistributedBackend::Mp),
- before_serve: vec![],
- after_stop: vec![],
- solo: None,
- deploy: DeployTarget::Docker,
- venv: None,
- venv_setup: VenvSetup::default(),
- venv_setup_script: None,
- mods: Default::default(),
- shm_size: String::new(),
- devices: vec![],
- memory_limit: None,
- cpus: None,
- sync_image: true,
- docker_image_dir: None,
- extra_mounts: vec![],
- extra_mounts_manifest: None,
- build: Default::default(),
- composed_model: None,
- staging: vec![],
- dgpp: None,
- }
- }
- #[test]
- fn stage_mode_parse() {
- assert_eq!(StageMode::parse("").unwrap(), StageMode::Auto);
- assert_eq!(StageMode::parse("auto").unwrap(), StageMode::Auto);
- assert_eq!(StageMode::parse("SKIP").unwrap(), StageMode::Skip);
- assert_eq!(StageMode::parse("force").unwrap(), StageMode::Force);
- assert!(StageMode::parse("nope").is_err());
- }
- #[tokio::test]
- async fn sparse_rows_dry_run_dispatches() {
- // dry-run touches no SSH: both nodes just print their plan (rank
- // 0 and 1 of a 2-rank split) and the pass succeeds — this exercises
- // the sparse-rows dispatch arm and the tp derivation.
- let mut r = trec();
- r.staging.push(Staging {
- kind: crate::config::StagingKind::SparseRows,
- src: Some("{{hf}}/hub/snap".into()),
- dst: Some("~/.cache/engram".into()),
- ..Default::default()
- });
- let nodes = vec![
- Node {
- name: "n0".into(),
- ssh_ip: "10.0.0.1".into(),
- ib_ip: None,
- rank: 0,
- host_key: None,
- entity: None,
- },
- Node {
- name: "n1".into(),
- ssh_ip: "10.0.0.2".into(),
- ib_ip: None,
- rank: 1,
- host_key: None,
- entity: None,
- },
- ];
- crate::staging::run(
- &cfg(),
- &nodes,
- &r,
- std::path::Path::new("/mnt/nas/hf"),
- StageMode::Auto,
- true,
- )
- .await
- .unwrap();
- }
- #[test]
- fn hub_mirror_dst_tilde_and_detection() {
- let mut r = trec();
- assert!(!has_hub_mirror(&r));
- assert!(hub_mirror_dst(&cfg(), &r).is_none());
- r.staging.push(Staging {
- kind: StagingKind::HubMirror,
- dst: Some("~/.cache/hf-local".into()),
- ..Default::default()
- });
- assert!(has_hub_mirror(&r));
- assert_eq!(
- hub_mirror_dst(&cfg(), &r),
- Some(PathBuf::from("/home/u/.cache/hf-local"))
- );
- }
- }
|