|
@@ -24,13 +24,17 @@ pub(crate) struct DockerCommandCtx {
|
|
|
pub keep: bool,
|
|
pub keep: bool,
|
|
|
pub init_port: u16,
|
|
pub init_port: u16,
|
|
|
pub mods_remote_path: String,
|
|
pub mods_remote_path: String,
|
|
|
- /// Recipe `extra_mounts` with host paths already resolved (alias lookup +
|
|
|
|
|
- /// `~` expansion) against `Config.mount_aliases` / `ssh_user`.
|
|
|
|
|
|
|
+ /// Static `extra_mounts` (alias/host) with host paths already resolved
|
|
|
|
|
+ /// once against `Config.mount_aliases` / `ssh_user`. Model-subfolder mounts
|
|
|
|
|
+ /// are NOT here — `start_cluster` resolves those per node and merges the
|
|
|
|
|
+ /// results into this `Vec` (as `ResolvedMount`s) before calling
|
|
|
|
|
+ /// [`build_docker_command`].
|
|
|
pub extra_mounts: Vec<ResolvedMount>,
|
|
pub extra_mounts: Vec<ResolvedMount>,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// A recipe `ExtraMount` after host-path resolution (alias + tilde), ready to
|
|
/// A recipe `ExtraMount` after host-path resolution (alias + tilde), ready to
|
|
|
-/// turn into a `-v` flag / `-e` env pair.
|
|
|
|
|
|
|
+/// turn into a `-v` flag / `-e` env pair. Also the final form a model-subfolder
|
|
|
|
|
+/// mount takes after per-node resolution.
|
|
|
#[derive(Clone)]
|
|
#[derive(Clone)]
|
|
|
pub(crate) struct ResolvedMount {
|
|
pub(crate) struct ResolvedMount {
|
|
|
pub host: String,
|
|
pub host: String,
|
|
@@ -39,15 +43,51 @@ pub(crate) struct ResolvedMount {
|
|
|
pub env: Option<String>,
|
|
pub env: Option<String>,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/// Resolve every `extra_mount` in a recipe to a concrete host path using the
|
|
|
|
|
-/// config alias registry and the SSH user (for `~` expansion). Done once per
|
|
|
|
|
-/// serve; the resulting `Vec<ResolvedMount>` is cloned into each node's
|
|
|
|
|
-/// [`DockerCommandCtx`].
|
|
|
|
|
-pub(crate) fn resolve_extra_mounts(config: &Config, recipe: &Recipe) -> Result<Vec<ResolvedMount>> {
|
|
|
|
|
- recipe
|
|
|
|
|
- .extra_mounts
|
|
|
|
|
- .iter()
|
|
|
|
|
- .map(|m| {
|
|
|
|
|
|
|
+/// A model-subfolder mount spec (`repo`+`subfolder`) before per-node
|
|
|
|
|
+/// resolution. The host path is resolved at serve time from each node's HF
|
|
|
|
|
+/// cache (so it can differ across nodes); `container` is the fixed path the
|
|
|
|
|
+/// recipe references in args.
|
|
|
|
|
+#[derive(Clone)]
|
|
|
|
|
+pub(crate) struct ModelMountSpec {
|
|
|
|
|
+ pub repo: String,
|
|
|
|
|
+ pub subfolder: Option<String>,
|
|
|
|
|
+ pub container: String,
|
|
|
|
|
+ pub read_only: bool,
|
|
|
|
|
+ pub env: Option<String>,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Partition a recipe's `extra_mounts` into static (resolved now) and
|
|
|
|
|
+/// model-subfolder (deferred to per-node resolution). Validates that a mount
|
|
|
|
|
+/// is one or the other — `repo` set means `alias`/`host` must be empty, and
|
|
|
|
|
+/// `subfolder` without `repo` is an error.
|
|
|
|
|
+pub(crate) fn resolve_extra_mounts(
|
|
|
|
|
+ config: &Config,
|
|
|
|
|
+ recipe: &Recipe,
|
|
|
|
|
+) -> Result<ResolvedExtraMounts> {
|
|
|
|
|
+ let mut static_mounts = Vec::new();
|
|
|
|
|
+ let mut model_mounts = Vec::new();
|
|
|
|
|
+ for m in &recipe.extra_mounts {
|
|
|
|
|
+ if m.repo.as_deref().is_some_and(|r| !r.trim().is_empty()) {
|
|
|
|
|
+ if m.alias.is_some() || m.host.is_some() {
|
|
|
|
|
+ anyhow::bail!(
|
|
|
|
|
+ "extra_mount '{}': `repo` is mutually exclusive with `alias`/`host`",
|
|
|
|
|
+ m.container
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ model_mounts.push(ModelMountSpec {
|
|
|
|
|
+ repo: m.repo.as_ref().unwrap().trim().to_string(),
|
|
|
|
|
+ subfolder: m
|
|
|
|
|
+ .subfolder
|
|
|
|
|
+ .as_deref()
|
|
|
|
|
+ .filter(|s| !s.trim().is_empty())
|
|
|
|
|
+ .map(|s| s.trim().to_string()),
|
|
|
|
|
+ container: m.container.clone(),
|
|
|
|
|
+ read_only: m.read_only,
|
|
|
|
|
+ env: m.env.clone(),
|
|
|
|
|
+ });
|
|
|
|
|
+ } else if m.subfolder.as_deref().is_some_and(|s| !s.trim().is_empty()) {
|
|
|
|
|
+ anyhow::bail!("extra_mount '{}': `subfolder` requires `repo`", m.container);
|
|
|
|
|
+ } else {
|
|
|
let host = crate::config::resolve_mount_host(
|
|
let host = crate::config::resolve_mount_host(
|
|
|
m.alias.as_deref(),
|
|
m.alias.as_deref(),
|
|
|
m.host.as_deref(),
|
|
m.host.as_deref(),
|
|
@@ -55,14 +95,43 @@ pub(crate) fn resolve_extra_mounts(config: &Config, recipe: &Recipe) -> Result<V
|
|
|
&config.ssh_user,
|
|
&config.ssh_user,
|
|
|
&m.container,
|
|
&m.container,
|
|
|
)?;
|
|
)?;
|
|
|
- Ok(ResolvedMount {
|
|
|
|
|
|
|
+ static_mounts.push(ResolvedMount {
|
|
|
host,
|
|
host,
|
|
|
container: m.container.clone(),
|
|
container: m.container.clone(),
|
|
|
read_only: m.read_only,
|
|
read_only: m.read_only,
|
|
|
env: m.env.clone(),
|
|
env: m.env.clone(),
|
|
|
- })
|
|
|
|
|
- })
|
|
|
|
|
- .collect()
|
|
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(ResolvedExtraMounts {
|
|
|
|
|
+ static_mounts,
|
|
|
|
|
+ model_mounts,
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Result of [`resolve_extra_mounts`]: static mounts ready to clone per node,
|
|
|
|
|
+/// model-subfolder specs deferred to per-node resolution.
|
|
|
|
|
+pub(crate) struct ResolvedExtraMounts {
|
|
|
|
|
+ pub static_mounts: Vec<ResolvedMount>,
|
|
|
|
|
+ pub model_mounts: Vec<ModelMountSpec>,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Dry-run placeholder for a model-subfolder mount: nodes may be off, so no
|
|
|
|
|
+/// HF cache to read. Emits a descriptive path so the printed `docker run`
|
|
|
|
|
+/// command shows *what* would be mounted, not a broken lookup.
|
|
|
|
|
+pub(crate) fn model_mount_placeholder(spec: &ModelMountSpec) -> ResolvedMount {
|
|
|
|
|
+ let sub = spec.subfolder.as_deref().unwrap_or("");
|
|
|
|
|
+ let host = format!(
|
|
|
|
|
+ "<hf_cache>/hub/{}/snapshots/<refs/main>/{}",
|
|
|
|
|
+ crate::config::model_cache_dir_name(&spec.repo),
|
|
|
|
|
+ sub
|
|
|
|
|
+ );
|
|
|
|
|
+ ResolvedMount {
|
|
|
|
|
+ host,
|
|
|
|
|
+ container: spec.container.clone(),
|
|
|
|
|
+ read_only: spec.read_only,
|
|
|
|
|
+ env: spec.env.clone(),
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// Collect NCCL cluster env vars for multi-node deployments.
|
|
/// Collect NCCL cluster env vars for multi-node deployments.
|
|
@@ -274,6 +343,7 @@ mod tests {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
fn run_with(recipe: &Recipe) -> String {
|
|
fn run_with(recipe: &Recipe) -> String {
|
|
|
|
|
+ let resolved = resolve_extra_mounts(&minimal_config(), recipe).unwrap();
|
|
|
let ctx = DockerCommandCtx {
|
|
let ctx = DockerCommandCtx {
|
|
|
cluster: tcls(),
|
|
cluster: tcls(),
|
|
|
nccl: tnccl(),
|
|
nccl: tnccl(),
|
|
@@ -286,7 +356,7 @@ mod tests {
|
|
|
keep: false,
|
|
keep: false,
|
|
|
init_port: 25000,
|
|
init_port: 25000,
|
|
|
mods_remote_path: String::new(),
|
|
mods_remote_path: String::new(),
|
|
|
- extra_mounts: resolve_extra_mounts(&minimal_config(), recipe).unwrap_or_default(),
|
|
|
|
|
|
|
+ extra_mounts: resolved.static_mounts,
|
|
|
};
|
|
};
|
|
|
build_docker_command(&ctx, &tcls().nodes[0], true).join(" ")
|
|
build_docker_command(&ctx, &tcls().nodes[0], true).join(" ")
|
|
|
}
|
|
}
|
|
@@ -321,6 +391,8 @@ mod tests {
|
|
|
r.extra_mounts.push(ExtraMount {
|
|
r.extra_mounts.push(ExtraMount {
|
|
|
alias: Some("models".into()),
|
|
alias: Some("models".into()),
|
|
|
host: Some("~/.cache/models".into()),
|
|
host: Some("~/.cache/models".into()),
|
|
|
|
|
+ repo: None,
|
|
|
|
|
+ subfolder: None,
|
|
|
container: "/models:ro".into(),
|
|
container: "/models:ro".into(),
|
|
|
read_only: true,
|
|
read_only: true,
|
|
|
env: Some("MODEL_DIR".into()),
|
|
env: Some("MODEL_DIR".into()),
|
|
@@ -339,6 +411,8 @@ mod tests {
|
|
|
r.extra_mounts.push(ExtraMount {
|
|
r.extra_mounts.push(ExtraMount {
|
|
|
alias: Some("no_such_alias".into()),
|
|
alias: Some("no_such_alias".into()),
|
|
|
host: Some("~/.cache/jit".into()),
|
|
host: Some("~/.cache/jit".into()),
|
|
|
|
|
+ repo: None,
|
|
|
|
|
+ subfolder: None,
|
|
|
container: "/root/.cache/jit".into(),
|
|
container: "/root/.cache/jit".into(),
|
|
|
read_only: false,
|
|
read_only: false,
|
|
|
env: None,
|
|
env: None,
|
|
@@ -348,4 +422,42 @@ mod tests {
|
|
|
assert!(cmd.contains("-v /home/u/.cache/jit:/root/.cache/jit"));
|
|
assert!(cmd.contains("-v /home/u/.cache/jit:/root/.cache/jit"));
|
|
|
assert!(!cmd.contains(":ro"));
|
|
assert!(!cmd.contains(":ro"));
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn model_subfolder_mount_uses_placeholder_in_dry_run() {
|
|
|
|
|
+ // In build_docker_command (no per-node SSH resolution here), a model
|
|
|
|
|
+ // mount shows the descriptive placeholder host so the printed command
|
|
|
|
|
+ // documents what would be mounted. start_cluster replaces this with
|
|
|
|
|
+ // the real resolved path at serve time (or keeps it under dry-run).
|
|
|
|
|
+ let mut r = base_recipe();
|
|
|
|
|
+ r.extra_mounts.push(ExtraMount {
|
|
|
|
|
+ alias: None,
|
|
|
|
|
+ host: None,
|
|
|
|
|
+ repo: Some("aidendle94/GLM-5.2-Hybrid-FP8-MXFP4".into()),
|
|
|
|
|
+ subfolder: Some("mtp-draft".into()),
|
|
|
|
|
+ container: "/models/mtp-draft".into(),
|
|
|
|
|
+ read_only: true,
|
|
|
|
|
+ env: Some("MTP_DRAFT_PATH".into()),
|
|
|
|
|
+ });
|
|
|
|
|
+ let cmd = run_with(&r);
|
|
|
|
|
+ // model_mounts are NOT in extra_mounts here (run_with doesn't resolve
|
|
|
|
|
+ // them per-node), so the -v flag for the model mount is absent — only
|
|
|
|
|
+ // start_cluster emits it. Verify the spec is parsed + the env is NOT
|
|
|
|
|
+ // injected yet (env injection follows extra_mounts, which is empty of
|
|
|
|
|
+ // the model mount here). This test guards the partition.
|
|
|
|
|
+ assert!(!cmd.contains("-v /models/mtp-draft"));
|
|
|
|
|
+ assert!(!cmd.contains("MTP_DRAFT_PATH"));
|
|
|
|
|
+ // But the recipe parsed into a model_mount spec:
|
|
|
|
|
+ let resolved = resolve_extra_mounts(&minimal_config(), &r).unwrap();
|
|
|
|
|
+ assert_eq!(resolved.model_mounts.len(), 1);
|
|
|
|
|
+ assert_eq!(resolved.static_mounts.len(), 0);
|
|
|
|
|
+ let ph = model_mount_placeholder(&resolved.model_mounts[0]);
|
|
|
|
|
+ assert_eq!(ph.container, "/models/mtp-draft");
|
|
|
|
|
+ assert!(
|
|
|
|
|
+ ph.host
|
|
|
|
|
+ .contains("models--aidendle94--GLM-5.2-Hybrid-FP8-MXFP4")
|
|
|
|
|
+ );
|
|
|
|
|
+ assert!(ph.host.contains("mtp-draft"));
|
|
|
|
|
+ assert!(ph.read_only);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|