Ver código fonte

add composed_model.local_files: hardlink node-local files into composed dir

Extends the standard composed-model feature: local_files hardlinks real
files from a per-node local host path (NVMe) into the composed dir, after
the repo symlink pass (local wins at the same name). Hardlinked, not
copied — sparse sources stay sparse, zero extra disk. Used by the r36
recipe to serve the two 101.5 GB Engram table shards from the pre-existing
per-rank sparse copies (48 GB real per node) instead of the shared NAS.

- config: ComposedLocalFile {host, name?} + ComposedModel.local_files
- compose.rs: link_local_file (tilde-expand, name validation, test+ln -f)
- locales: 'local file(s)' plural in en/ru/de/fr/es/it dry-run+built
- test: parse test; locale test gets the new param
- AGENTS.md: local_files documented
- recipe dsv41-flash-jovian-r36-4x: [composed_model] (moved to EOF —
  TOML section order bug had swallowed entrypoint/args into the table)
Alexander Korolev 1 mês atrás
pai
commit
eb63d88

+ 13 - 0
AGENTS.md

@@ -433,8 +433,21 @@ exclude = ["L5.safetensors"]                       # optional: skip these paths
 [[composed_model.files]]            # generated real files (not symlinks)
 name = "nf4_layers.json"
 content = '{"layers":[3,4,...,77]}'
+
+[[composed_model.local_files]]      # hardlinked from a per-node local host path
+host = "/var/tmp/engram-local/DeepSeek-V4.1-Flash/model-00047-of-00048.safetensors"
+name = "model-00047-of-00048.safetensors"  # optional; defaults to the host basename
 ```
 
+`local_files` hardlinks real files from node-local storage (NVMe) into the
+composed dir — run AFTER the repo symlink pass, so a local file replaces the
+repo's symlink at the same name (local wins). Use to serve a shard from local
+disk while the rest of the model stays on the shared cache (e.g. Engram
+table shards from a sparse per-rank NVMe copy instead of the NAS). Hardlinked,
+NOT copied — a sparse source stays sparse and costs zero extra disk; the host
+path must be on the same filesystem as `/tmp` and exist on every launch node
+(missing → serve aborts).
+
 With **no glob**, the entire snapshot hierarchy is symlinked recursively
 (every file in every subdir, as individual symlinks with real parent dirs) —
 so a later source can overlay individual files at any path. With a `glob`,

+ 6 - 0
locales/de/main.ftl

@@ -159,6 +159,9 @@ composed-dir-dry-run = [Testlauf] Zusammengestelltes Modellverzeichnis '{ $host
     }, { $files } { $files ->
         [one] Datei
        *[other] Dateien
+    }, { $local } { $local ->
+        [one] lokale Datei
+       *[other] lokale Dateien
     })
 composed-dir-built = Zusammengestelltes Modellverzeichnis '{ $host }' auf '{ $node }' erstellt ({ $sources } { $sources ->
         [one] Quelle
@@ -166,6 +169,9 @@ composed-dir-built = Zusammengestelltes Modellverzeichnis '{ $host }' auf '{ $no
     }, { $files } { $files ->
         [one] Datei
        *[other] Dateien
+    }, { $local } { $local ->
+        [one] lokale Datei
+       *[other] lokale Dateien
     })
 tmux-mods-unsupported = Recipe '{ $name }' verwendet [mods], was Docker-only ist. tmux-Bereitstellung unterstützt keine mods.
 tmux-deploy-ignored-opts = ⚠️ tmux-Bereitstellung: ignoriere Docker-only CLI-Optionen: { $opts }

+ 6 - 0
locales/en/main.ftl

@@ -178,6 +178,9 @@ composed-dir-dry-run = [dry-run] would build composed model dir '{ $host }' on '
     }, { $files } { $files ->
         [one] file
        *[other] files
+    }, { $local } { $local ->
+        [one] local file
+       *[other] local files
     })
 composed-dir-built = Built composed model dir '{ $host }' on '{ $node }' ({ $sources } { $sources ->
         [one] source
@@ -185,6 +188,9 @@ composed-dir-built = Built composed model dir '{ $host }' on '{ $node }' ({ $sou
     }, { $files } { $files ->
         [one] file
        *[other] files
+    }, { $local } { $local ->
+        [one] local file
+       *[other] local files
     })
 
 # health render module (plain/md/html)

+ 6 - 0
locales/es/main.ftl

@@ -159,6 +159,9 @@ composed-dir-dry-run = [simulación] se crearía el directorio de modelo compues
     }, { $files } { $files ->
         [one] archivo
        *[other] archivos
+    }, { $local } { $local ->
+        [one] archivo local
+       *[other] archivos locales
     })
 composed-dir-built = Creado directorio de modelo compuesto '{ $host }' en '{ $node }' ({ $sources } { $sources ->
         [one] fuente
@@ -166,6 +169,9 @@ composed-dir-built = Creado directorio de modelo compuesto '{ $host }' en '{ $no
     }, { $files } { $files ->
         [one] archivo
        *[other] archivos
+    }, { $local } { $local ->
+        [one] archivo local
+       *[other] archivos locales
     })
 tmux-mods-unsupported = Recipe '{ $name }' usa [mods], que es Docker-only. El despliegue tmux no admite mods.
 tmux-deploy-ignored-opts = ⚠️ tmux deploy: ignorando opciones CLI Docker-only: { $opts }

+ 6 - 0
locales/fr/main.ftl

@@ -159,6 +159,9 @@ composed-dir-dry-run = [simulation] création du répertoire de modèle composé
     }, { $files } { $files ->
         [one] fichier
        *[other] fichiers
+    }, { $local } { $local ->
+        [one] fichier local
+       *[other] fichiers locaux
     })
 composed-dir-built = Répertoire de modèle composé créé : '{ $host }' sur '{ $node }' ({ $sources } { $sources ->
         [one] source
@@ -166,6 +169,9 @@ composed-dir-built = Répertoire de modèle composé créé : '{ $host }' sur '{
     }, { $files } { $files ->
         [one] fichier
        *[other] fichiers
+    }, { $local } { $local ->
+        [one] fichier local
+       *[other] fichiers locaux
     })
 tmux-mods-unsupported = Recipe '{ $name }' utilise [mods], ce qui est Docker-only. Le déploiement tmux ne prend pas en charge les mods.
 tmux-deploy-ignored-opts = ⚠️ tmux deploy : ignore les options CLI Docker-only : { $opts }

+ 6 - 0
locales/it/main.ftl

@@ -159,6 +159,9 @@ composed-dir-dry-run = [prova] verrebbe creata la directory del modello composto
     }, { $files } { $files ->
         [one] file
        *[other] file
+    }, { $local } { $local ->
+        [one] file locale
+       *[other] file locali
     })
 composed-dir-built = Creata directory del modello composto '{ $host }' su '{ $node }' ({ $sources } { $sources ->
         [one] sorgente
@@ -166,6 +169,9 @@ composed-dir-built = Creata directory del modello composto '{ $host }' su '{ $no
     }, { $files } { $files ->
         [one] file
        *[other] file
+    }, { $local } { $local ->
+        [one] file locale
+       *[other] file locali
     })
 tmux-mods-unsupported = Recipe '{ $name }' usa [mods], che è Docker-only. Il deployment tmux non supporta i mods.
 tmux-deploy-ignored-opts = ⚠️ tmux deploy: ignoro le opzioni CLI Docker-only: { $opts }

+ 8 - 0
locales/ru/main.ftl

@@ -166,6 +166,10 @@ composed-dir-dry-run = [пробный запуск] будет создан к
         [one] файл
         [few] файла
        *[many] файлов
+    }, { $local } { $local ->
+        [one] локальный файл
+        [few] локальных файла
+       *[many] локальных файлов
     })
 composed-dir-built = Создан каталог составной модели '{ $host }' на '{ $node }' ({ $sources } { $sources ->
         [one] источник
@@ -175,6 +179,10 @@ composed-dir-built = Создан каталог составной модели
         [one] файл
         [few] файла
        *[many] файлов
+    }, { $local } { $local ->
+        [one] локальный файл
+        [few] локальных файла
+       *[many] локальных файлов
     })
 tmux-mods-unsupported = Recipe '{ $name }' использует [mods], который является Docker-only. Развертывание tmux не поддерживает mods.
 tmux-deploy-ignored-opts = ⚠️ tmux deploy: игнорируются Docker-only CLI опции: { $opts }

+ 23 - 0
recipes/dsv41-flash-jovian-r36-4x/recipe.toml

@@ -107,6 +107,7 @@ args = [
 ]
 
 [env]
+
 # Their serve-ds41-jovian.sh runtime env (multi-node-adapted; the launcher
 # injects NCCL_SOCKET_IFNAME/IB_HCA/GID + VLLM_HOST_IP per node).
 NCCL_NCHANNELS = "16"
@@ -131,3 +132,25 @@ HF_HUB_OFFLINE = "1"
 # Deliberately NOT set: VLLM_ENABLE_PCIE_ALLREDUCE (single-host PCIe in
 # their profile; cross-node on the switch fabric uses the launcher's NCCL),
 # VLLM_HOST_IP (launcher per-node), NCCL_SOCKET_IFNAME (launcher per cluster).
+
+# --- Engram from node-local NVMe, the rest from the shared NAS ---
+# [composed_model] (standard launcher feature) builds a per-node /tmp dir:
+# the whole DSV4.1 snapshot symlinked into the HF cache + the two 101.5 GB
+# Engram table shards (model-00047/00048) HARDLINKED from the pre-existing
+# per-rank sparse copies in /var/tmp/engram-local (48 GB real per node, built
+# for the eugr/aiden lanes — offset-preserving full-size files, only this
+# rank's rows materialized; hardlink = zero extra disk, same fs as /tmp).
+# The r36 io_uring PLE reader then reads this rank's table rows from local
+# NVMe (no NFS); weights stream from the NAS at load time (one-shot, same as
+# prod). Local files run after the repo symlinks and win at the same name.
+[composed_model]
+mount = "/model"
+
+[[composed_model.sources]]
+repo = "deepseek-ai/DeepSeek-V4.1-Flash"          # whole snapshot, symlinked into the HF cache
+
+[[composed_model.local_files]]                      # hardlinks win over the symlinks above
+host = "/var/tmp/engram-local/DeepSeek-V4.1-Flash/model-00047-of-00048.safetensors"
+
+[[composed_model.local_files]]
+host = "/var/tmp/engram-local/DeepSeek-V4.1-Flash/model-00048-of-00048.safetensors"

+ 27 - 0
src/config/tests.rs

@@ -85,6 +85,33 @@ fn test_recipe_deserialize_no_required_nodes() {
     assert_eq!(r.pipeline_parallel_size, None);
 }
 
+#[test]
+fn test_composed_local_files_deserialize() {
+    let toml = r#"
+name = "L"
+model = "t/m"
+[composed_model]
+mount = "/model"
+[[composed_model.sources]]
+repo = "t/m"
+[[composed_model.local_files]]
+host = "/var/tmp/engram-local/t/m/model-00047-of-00048.safetensors"
+[[composed_model.local_files]]
+host = "~/local/table.bin"
+name = "table.bin"
+"#;
+    let r: Recipe = toml::from_str(toml).unwrap();
+    let composed = r.composed_model.unwrap();
+    assert_eq!(composed.local_files.len(), 2);
+    assert_eq!(
+        composed.local_files[0].host,
+        "/var/tmp/engram-local/t/m/model-00047-of-00048.safetensors"
+    );
+    assert_eq!(composed.local_files[0].name, None);
+    assert_eq!(composed.local_files[1].host, "~/local/table.bin");
+    assert_eq!(composed.local_files[1].name.as_deref(), Some("table.bin"));
+}
+
 #[test]
 fn test_composed_source_exclude_deserialize() {
     let toml = r#"

+ 20 - 0
src/config/types.rs

@@ -151,6 +151,26 @@ pub struct ComposedModel {
     /// Real files (not symlinks) to write into the composed dir.
     #[serde(default)]
     pub files: Vec<ComposedFile>,
+    /// Real files HARDLINKED from a per-node local host path into the
+    /// composed dir (e.g. a local NVMe copy of a table shard instead of the
+    /// shared-NAS version the repo symlinks would point at). Later local
+    /// files win over earlier sources. Must be on the node's local disk
+    /// (same filesystem as /tmp) — hardlinked, not copied.
+    #[serde(default)]
+    pub local_files: Vec<ComposedLocalFile>,
+}
+
+/// A local file hardlinked into a [`ComposedModel`] dir (per node).
+/// Used to serve a shard from node-local storage (NVMe) while the rest of
+/// the model stays symlinked into the shared HF cache.
+#[derive(Clone, Debug, Default, Deserialize)]
+pub struct ComposedLocalFile {
+    /// Host path on each node (leading `~`/`~/` expanded to the SSH user's
+    /// home). Must exist on every launch node.
+    pub host: String,
+    /// Optional path inside the composed dir; defaults to the host basename.
+    #[serde(default)]
+    pub name: Option<String>,
 }
 
 /// A source repo for [`ComposedModel`]. The repo's HF cache snapshot is

+ 66 - 4
src/docker/compose.rs

@@ -1,7 +1,9 @@
 //! Composed model directories: build a /tmp dir per-node with symlinks into
-//! the HF cache (container-side absolute paths) + generated files, then
-//! bind-mount it into the container. Lets a recipe combine multiple HF repos
-//! (e.g. a base checkpoint + an expert overlay) without mutating the HF cache.
+//! the HF cache (container-side absolute paths) + generated files + hardlinks
+//! from node-local files, then bind-mount it into the container. Lets a
+//! recipe combine multiple HF repos (e.g. a base checkpoint + an expert
+//! overlay), and serve selected files from node-local storage (NVMe) instead
+//! of the shared NAS (local_files), without mutating the HF cache.
 //!
 //! Symlinks point to `/root/.cache/huggingface/...` (the container-side HF
 //! cache mount path), NOT the host-side path. They are dangling on the host
@@ -11,7 +13,7 @@
 //! relative-path gymnastics across different mount roots.
 
 use super::shq;
-use crate::config::{ComposedModel, ComposedSource};
+use crate::config::{ComposedLocalFile, ComposedModel, ComposedSource, expand_tilde};
 use crate::ssh::run_ssh;
 use anyhow::{Context, Result};
 use base64::Engine;
@@ -44,6 +46,7 @@ pub(crate) async fn build_composed_dir_on_node(
                 "mount" => container_path,
                 "sources" => composed.sources.len() as u32,
                 "files" => composed.files.len() as u32,
+                "local" => composed.local_files.len() as u32,
             })
         );
         return Ok((host_path, container_path));
@@ -59,6 +62,13 @@ pub(crate) async fn build_composed_dir_on_node(
         symlink_source(config, node, source, &host_path, &cache_str).await?;
     }
 
+    // Hardlink local files (later wins over the symlinks above). These come
+    // from node-local storage (e.g. an NVMe copy of a table shard) and must
+    // live on the same filesystem as the composed dir (/tmp) to hardlink.
+    for lf in &composed.local_files {
+        link_local_file(config, node, lf, &host_path).await?;
+    }
+
     // Write generated files (base64-encoded to avoid quoting issues).
     for file in &composed.files {
         let b64 = STANDARD.encode(file.content.as_bytes());
@@ -83,6 +93,7 @@ pub(crate) async fn build_composed_dir_on_node(
             "node" => node.name.clone(),
             "sources" => composed.sources.len() as u32,
             "files" => composed.files.len() as u32,
+            "local" => composed.local_files.len() as u32,
         })
     );
 
@@ -205,3 +216,54 @@ async fn symlink_source(
     })?;
     Ok(())
 }
+
+/// Hardlink a node-local file into the composed dir. Runs after the repo
+/// symlink pass, so a local file overwrites the repo's symlink at the same
+/// name (local wins — that is the point: e.g. an NVMe table shard instead of
+/// the shared-NAS version). Hardlinked, NOT copied — a sparse local copy
+/// stays sparse and costs zero extra disk — so the host path must be on the
+/// same filesystem as the composed dir (/tmp).
+async fn link_local_file(
+    config: &crate::config::Config,
+    node: &crate::config::Node,
+    lf: &ComposedLocalFile,
+    host_path: &str,
+) -> Result<()> {
+    let host = expand_tilde(&lf.host, &config.ssh_user);
+    let name = lf
+        .name
+        .as_deref()
+        .filter(|n| !n.is_empty())
+        .or_else(|| host.rsplit('/').next())
+        .filter(|s| !s.is_empty())
+        .ok_or_else(|| anyhow::anyhow!("composed_model local_file with empty host path"))?;
+    if name.starts_with('/')
+        || name.contains("..")
+        || !name
+            .chars()
+            .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
+    {
+        anyhow::bail!(
+            "composed_model local_file '{}': name '{}' is invalid \
+             (allowed: alphanumeric . _ - ; flat name, no directories)",
+            host,
+            name
+        );
+    }
+    // Existence check + hardlink in one round-trip; `ln -f` replaces an
+    // earlier symlink at the same path.
+    let cmd = format!(
+        "test -e {h} && ln -f {h} {d}/{n}",
+        h = shq(&host),
+        d = shq(host_path),
+        n = shq(name),
+    );
+    run_ssh(config, node, cmd, false).await.with_context(|| {
+        format!(
+            "composed_model local_file: '{}' not found on '{}' — a per-node \
+             local path is required (and it must share a filesystem with /tmp to hardlink)",
+            host, node.name
+        )
+    })?;
+    Ok(())
+}

+ 1 - 0
src/docker/mirror.rs

@@ -274,6 +274,7 @@ mod tests {
                 },
             ],
             files: vec![],
+            local_files: vec![],
         });
         assert_eq!(
             repos_for_recipe(&r),

+ 1 - 0
src/locale.rs

@@ -54,6 +54,7 @@ mod tests {
                     "node" => "n".to_string(),
                     "sources" => 3u32,
                     "files" => 1u32,
+                    "local" => 2u32,
                 }
             )
         })