ソースを参照

fix(composed_model): add exclude field to skip overlay files

The Lloyd NF4 recipe excludes layers 5, 8, 9, 32, 46 from VLLM_NF4_LAYERS
so they decode as E2M1. But the Lloyd source's glob "L*.safetensors"
still copied those 5 files over the MXFP4 base's all-E2M1 versions —
the Lloyd versions have 255 Lloyd-coded experts + 1 E2M1 expert per layer.
Decoding as E2M1 garbage-decodes the 255 Lloyd experts (the card warns:
"A decoder that assumes the E2M1 value table will produce garbage").

Fix: add an `exclude` field to [[composed_model.sources]] — a list of
path patterns (matched via shell `case` against the file's relative
path) that are skipped during symlinking. The Lloyd source now excludes
L5/L8/L9/L32/L46.safetensors, so the MXFP4 base's all-E2M1 versions
survive for those layers, matching the E2M1 decode path.

The exclude clause is injected into the existing shell loop body between
rel computation and the ln -snf, with the same character-set validation
as glob (alphanumeric + *?._-/). 130 tests pass (+1 deserialization test).
Alexander Korolev 2 ヶ月 前
親
コミット
dbbdc3efbf

+ 5 - 1
AGENTS.md

@@ -209,6 +209,7 @@ repo = "aidendle94/GLM-5.2-MXFP4-Experts-GPTQ"      # base: config, weights, tok
 [[composed_model.sources]]
 repo = "aidendle94/GLM-5.2-NF4-Lloyd-Experts-GPTQ"  # overlay: Lloyd NF4 experts
 glob = "L*.safetensors"                             # only expert weight files (glob, may include / for subdirs)
+exclude = ["L5.safetensors"]                       # optional: skip these paths (keep earlier source's version)
 
 [[composed_model.files]]            # generated real files (not symlinks)
 name = "nf4_layers.json"
@@ -220,7 +221,10 @@ With **no glob**, the entire snapshot hierarchy is symlinked recursively
 so a later source can overlay individual files at any path. With a `glob`,
 only matching entries are symlinked (one level; `/` allowed for a specific
 subdir, e.g. `grids/*.json`) and the directory structure is preserved.
-Allowed glob characters: alphanumeric, `* ? . _ - /`.
+`exclude` (optional list of path patterns) skips matched files so an earlier
+source's version survives — e.g. `exclude = ["L5.safetensors"]` keeps the
+base checkpoint's `L5.safetensors` instead of the overlay's.
+Allowed characters (glob + exclude): alphanumeric, `* ? . _ - /`.
 
 The composed dir is cleaned up automatically on `sm121 stop`.
 

+ 19 - 5
recipes/glm52-nf4-lloyd-experts-gptq/recipe.toml

@@ -100,15 +100,29 @@ repo = "aidendle94/GLM-5.2-MXFP4-Experts-GPTQ"
 [[composed_model.sources]]
 repo = "aidendle94/GLM-5.2-NF4-Lloyd-Experts-GPTQ"
 glob = "L*.safetensors"
+# Exclude the 5 layers with mis-packed experts (per model card known-defect
+# workaround). These layers' Lloyd repo L{n}.safetensors contain 255 Lloyd-
+# coded experts + 1 E2M1 expert — decoding as E2M1 (which nf4_layers.json
+# does) would garbage-decode the 255 Lloyd experts. Instead, skip the Lloyd
+# overlay for these files so the MXFP4 base's all-E2M1 L{n}.safetensors
+# survives, and the excluded-from-NF4 E2M1 decode path matches their bytes.
+exclude = [
+    "L5.safetensors",
+    "L8.safetensors",
+    "L9.safetensors",
+    "L32.safetensors",
+    "L46.safetensors",
+]
 
 # NF4 layer list — all 75 MoE layers (3–77) EXCEPT the 5 layers with a
 # known packing defect (per model card 2026-07-27). Layers 5, 8, 9, 32, 46
 # each contain 1 expert whose bytes were packed with E2M1/MXFP4 nibbles
-# (quantizer arm won per-expert, but decoding is per-layer). Routing those
-# layers through the Lloyd NF4 decode path introduces ~10.5% relative weight
-# error on the mis-packed expert. Excluding them forces E2M1 decode, matching
-# their actual bytes. Layer 10 had the same defect but was already caught +
-# fixed upstream by the packing guard, so it stays in the NF4 set.
+# (quantizer arm won per-expert, but decoding is per-layer). These layers
+# are ALSO excluded from the Lloyd source above (exclude = [...]) so the
+# MXFP4 base's all-E2M1 L{n}.safetensors survives — decoding as E2M1
+# (which this list does) matches their bytes. Layer 10 had the same defect
+# but was already caught + fixed upstream by the packing guard, so it
+# stays in the NF4 set.
 # Ref: https://huggingface.co/aidendle94/GLM-5.2-NF4-Lloyd-Experts-GPTQ
 # ("Known defect: 5 experts are packed on the wrong grid")
 [[composed_model.files]]

+ 32 - 0
src/config/tests.rs

@@ -82,6 +82,38 @@ fn test_recipe_deserialize_no_required_nodes() {
     assert_eq!(r.pipeline_parallel_size, None);
 }
 
+#[test]
+fn test_composed_source_exclude_deserialize() {
+    let toml = r#"
+name = "C"
+model = "t/m"
+[composed_model]
+mount = "/root/model"
+[[composed_model.sources]]
+repo = "base"
+[[composed_model.sources]]
+repo = "overlay"
+glob = "L*.safetensors"
+exclude = ["L5.safetensors", "L8.safetensors"]
+[[composed_model.files]]
+name = "cfg.json"
+content = "{}"
+"#;
+    let r: Recipe = toml::from_str(toml).unwrap();
+    let composed = r.composed_model.unwrap();
+    assert_eq!(composed.sources.len(), 2);
+    let overlay = &composed.sources[1];
+    assert_eq!(overlay.repo, "overlay");
+    assert_eq!(overlay.glob.as_deref(), Some("L*.safetensors"));
+    let exclude = overlay
+        .exclude
+        .as_ref()
+        .expect("exclude should deserialize");
+    assert_eq!(exclude.len(), 2);
+    assert!(exclude.contains(&"L5.safetensors".to_string()));
+    assert!(exclude.contains(&"L8.safetensors".to_string()));
+}
+
 #[test]
 fn test_config_deserialize() {
     let c: Config = toml::from_str("ssh_user = \"a\"\nssh_key_path = \"/p\"\n[[clusters]]\nname = \"p\"\nmaster_ib_ip = \"1\"\nnodes = [{name=\"n1\",ssh_ip=\"10.0.0.10\",ib_ip=\"1\",rank=0},{name=\"n2\",ssh_ip=\"10.0.0.11\",ib_ip=\"2\",rank=1}]\n[nccl.\"p\"]\nib_hca = \"m\"\nsocket_ifname = \"e\"\nib_gid_index = 3\nib_disable = 0\n").unwrap();

+ 11 - 2
src/config/types.rs

@@ -219,16 +219,25 @@ pub struct ComposedModel {
 }
 
 /// A source repo for [`ComposedModel`]. The repo's HF cache snapshot is
-/// symlinked into the composed dir, optionally filtered by `glob`.
+/// symlinked into the composed dir, optionally filtered by `glob` and
+/// `exclude`.
 #[derive(Clone, Debug, Default, Deserialize)]
 pub struct ComposedSource {
     /// HF Hub repo id (e.g. `aidendle94/GLM-5.2-MXFP4-Experts-GPTQ`).
     pub repo: String,
     /// Optional glob to filter which snapshot files are symlinked (e.g.
     /// `L*.safetensors` — only expert weight files). Empty/omitted = all
-    /// files. Only glob-safe characters are allowed (alphanumeric + `*?._-`).
+    /// files. Only glob-safe characters are allowed (alphanumeric + `*?._-/`).
     #[serde(default)]
     pub glob: Option<String>,
+    /// Optional list of path patterns to skip (matched against the file's
+    /// path relative to the snapshot root, via shell `case`). Lets a later
+    /// source overlay skip specific files so an earlier source's version
+    /// survives — e.g. `exclude = ["L5.safetensors"]` keeps the base
+    /// checkpoint's `L5.safetensors` instead of the overlay's. Same allowed
+    /// characters as `glob` (alphanumeric + `*?._-/`).
+    #[serde(default)]
+    pub exclude: Option<Vec<String>>,
 }
 
 /// A generated file written into the composed dir (not a symlink).

+ 27 - 0
src/docker/compose.rs

@@ -123,6 +123,31 @@ async fn symlink_source(
         );
     }
 
+    // Build a shell `case` clause to skip excluded paths. Each entry is
+    // matched against the file's path relative to the snapshot root (e.g.
+    // `L5.safetensors`, or `subdir/file.bin`). Same character set as glob.
+    let exclude_clause = match &source.exclude {
+        None => String::new(),
+        Some(entries) if entries.is_empty() => String::new(),
+        Some(entries) => {
+            for e in entries {
+                if e.is_empty()
+                    || !e
+                        .chars()
+                        .all(|c| c.is_ascii_alphanumeric() || "*?._-/".contains(c))
+                {
+                    anyhow::bail!(
+                        "composed_model source '{}': exclude entry '{}' is invalid \
+                         (allowed: alphanumeric, * ? . _ - /)",
+                        source.repo,
+                        e
+                    );
+                }
+            }
+            format!("case \"$rel\" in {}) continue ;; esac; ", entries.join("|"))
+        }
+    };
+
     // Enumerate: no glob → recursive `find` (whole hierarchy, every file in
     // every subdir); glob → shell glob match (one level, `/` allowed for a
     // specific subdir). Both feed the same per-file body below.
@@ -158,6 +183,7 @@ async fn symlink_source(
          {enumerate} \
            [ -e \"$f\" ] || [ -L \"$f\" ] || continue; \
            rel=\"${{f#\"$snap\"/}}\"; \
+           {exclude_clause}\
            parent=\"$(dirname {host_q}/\"$rel\")\"; \
            mkdir -p \"$parent\" && \
            ln -snf \"$csnap/$rel\" {host_q}/\"$rel\"; \
@@ -167,6 +193,7 @@ async fn symlink_source(
         model_q = shq(&model_dir),
         host_q = shq(host_path),
         enumerate = enumerate,
+        exclude_clause = exclude_clause,
     );
 
     run_ssh(config, node, cmd, false).await.with_context(|| {