Noctalin Claude Fable 5 commited on
Commit
d833396
·
1 Parent(s): 2b672f1

fix(weights): stack per-expert MoE tensors into switch_mlp layout for mlx-lm

Browse files

Ornith-1.0-35B ships experts in the legacy per-expert HF layout, which
mlx-lm's qwen3_5_moe sanitize() does not stack, so the quantized build
failed to load with 'Received 92160 parameters not in model'. Stack the
92,160 per-expert tensors into 360 switch_mlp tensors (bitwise-identical
values, no requantization) and rewrite the per-path quantization override
keys in config.json to post-sanitize module paths. Ship
repair_moe_experts.py (in-place repair with bitwise self-check) so broken
earlier downloads can be fixed locally, and document it in the README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

README.md CHANGED
@@ -99,6 +99,27 @@ To seamlessly route this model through agentic development workspaces like **Ope
99
 
100
  ---
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  ## 🌡️ Thermal Optimization Notice
103
 
104
  Sustained execution across massive context windows heavily taxes the Apple Silicon GPU/CPU complexes, causing rapid heat buildup. Because Apple's default fan profiles emphasize near-silent operation, they delay ramping up system fans until thermal throttling has already begun to affect generation tokens-per-second (TPS).
 
99
 
100
  ---
101
 
102
+ ## 🩹 Repair Script (`repair_moe_experts.py`)
103
+
104
+ The weights in this repository are already fixed and load correctly — **you do not need to run this for normal use.**
105
+
106
+ It's included only for anyone who cached an earlier broken download of this repo, or who runs into the same issue when quantizing another MoE model with a similar per-expert weight layout. Symptom: loading fails with an error like:
107
+
108
+ ```
109
+ Received 92160 parameters not in model:
110
+ language_model.model.layers.0.mlp.experts.0.down_proj.biases, ...
111
+ ```
112
+
113
+ Cause: Ornith-1.0-35B ships its MoE experts as separate per-expert tensors (`mlp.experts.<0-255>.{gate,up,down}_proj`), but mlx-lm's `qwen3_5_moe` loader only understands the fused `switch_mlp` layout — so an affected build never quantizes them into a loadable shape, and its `config.json` also carries stale pre-sanitize key names for the per-path quantization overrides.
114
+
115
+ Usage, if ever needed:
116
+
117
+ ```bash
118
+ python3 repair_moe_experts.py /path/to/Ornith-1.0-35B-oQ4-fp16
119
+ ```
120
+
121
+ Requires only `mlx` (any environment with `mlx-lm`/`omlx` installed has it). It streams the weights shard-by-shard, stacks the per-expert tensors into `switch_mlp` (bitwise-identical values — no requantization), and fixes the `config.json` key names. The repair happens **in place**: new shards are written alongside the originals, verified bitwise against the source tensors, and only then swapped in — a failure at any point leaves the original model untouched. It needs free disk roughly equal to the model size while running, and is safe to re-run (exits early on an already-repaired model).
122
+
123
  ## 🌡️ Thermal Optimization Notice
124
 
125
  Sustained execution across massive context windows heavily taxes the Apple Silicon GPU/CPU complexes, causing rapid heat buildup. Because Apple's default fan profiles emphasize near-silent operation, they delay ramping up system fans until thermal throttling has already begun to affect generation tokens-per-second (TPS).
config.json CHANGED
The diff for this file is too large to render. See raw diff
 
model-00001-of-00005.safetensors → model-00001-of-00004.safetensors RENAMED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:d772f04e18e4705dcaa317c4e054eaeb8af9bdb184e4b6a8fcfc98b55b98bc8a
3
- size 5005868403
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c9b6651178c7eafbf36afca441f6015fe237b20af0b8c7b66ff16ed79e253ae3
3
+ size 5445093729
model-00002-of-00005.safetensors → model-00002-of-00004.safetensors RENAMED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:132022bd8c275d6901bebc7156cffbe7b10fda558590f21f66a95a26565f281d
3
- size 5003882154
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b0051c2108c949a365edec5ecd6ab64feb17286ed74c09b5ca8144b9861ce27e
3
+ size 5372082282
model-00003-of-00005.safetensors → model-00003-of-00004.safetensors RENAMED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:bf933742439697a5a635b954692431e8fbe9efdf7256f3819876edbac30ecda0
3
- size 5003483080
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1225ab315b23a821dd68f724d92c1fecc77adc28f5b377ee1c6e225e5c5c3c89
3
+ size 5389445564
model-00004-of-00005.safetensors → model-00004-of-00004.safetensors RENAMED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:142bb0b29d53912fb71efdefb5bb24f6dc0d62634085bc2522c4453fa6ba80d2
3
- size 5003348234
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8cc12d547e07abb4e59b06768b66ee82b875a9f6cbc4c82c85618bb436d85e07
3
+ size 4162092468
model-00005-of-00005.safetensors DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:a22ba1857651fac25640dc98404f44f69389579acd565538cd81771708d3d721
3
- size 364659351
 
 
 
 
model.safetensors.index.json CHANGED
The diff for this file is too large to render. See raw diff
 
repair_moe_experts.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Repair oMLX-quantized Qwen3.5-MoE checkpoints that store routed experts
3
+ in the legacy per-expert layout (mlp.experts.<E>.{gate,up,down}_proj.*).
4
+
5
+ mlx-lm's qwen3_5_moe sanitize() only stacks the fused `experts.gate_up_proj`
6
+ layout, so per-expert checkpoints fail to load with
7
+ "Received NNNNN parameters not in model". This script repairs the model
8
+ in place:
9
+
10
+ 1. Stacks every `<prefix>.mlp.experts.<E>.<proj>.<tensor>` group along a new
11
+ leading axis into `<prefix>.mlp.switch_mlp.<proj>.<tensor>`.
12
+ 2. Rewrites per-path quantization overrides in config.json from raw HF key
13
+ names (model.language_model.*) to post-sanitize module paths
14
+ (language_model.model.*), which is how mlx-lm looks them up at load time.
15
+
16
+ New shards are written alongside the originals, verified bitwise against the
17
+ source tensors, and only then swapped in (originals deleted). A failure at any
18
+ point leaves the original model untouched. Needs free disk roughly equal to
19
+ the model size while running.
20
+
21
+ Usage:
22
+ python3 repair_moe_experts.py <model_dir>
23
+
24
+ Requires mlx; no other dependencies.
25
+ """
26
+
27
+ import json
28
+ import re
29
+ import struct
30
+ import sys
31
+ from pathlib import Path
32
+
33
+ import mlx.core as mx
34
+
35
+ EXPERT_RE = re.compile(
36
+ r"^(?P<prefix>.+\.mlp)\.experts\.(?P<e>\d+)\.(?P<proj>\w+_proj)\.(?P<t>weight|scales|biases)$"
37
+ )
38
+ SHARD_BYTES = 5 * 1024**3
39
+ TMP_PREFIX = "tmp-shard-"
40
+
41
+
42
+ def quant_key_to_module_path(key):
43
+ if key.startswith("model.language_model"):
44
+ return key.replace("model.language_model", "language_model.model", 1)
45
+ if key.startswith("language_model."):
46
+ return key
47
+ return "language_model." + key
48
+
49
+
50
+ def read_header(path):
51
+ with open(path, "rb") as f:
52
+ n = struct.unpack("<Q", f.read(8))[0]
53
+ return json.loads(f.read(n))
54
+
55
+
56
+ def nbytes(a):
57
+ return a.size * a.dtype.size
58
+
59
+
60
+ class ShardWriter:
61
+ def __init__(self, out_dir, metadata):
62
+ self.out_dir = out_dir
63
+ self.metadata = metadata
64
+ self.buffer = {}
65
+ self.buffer_bytes = 0
66
+ self.files = [] # [(tmp_path, [keys])]
67
+
68
+ def add(self, key, array):
69
+ self.buffer[key] = array
70
+ self.buffer_bytes += nbytes(array)
71
+ if self.buffer_bytes >= SHARD_BYTES:
72
+ self.flush()
73
+
74
+ def flush(self):
75
+ if not self.buffer:
76
+ return
77
+ tmp = self.out_dir / f"{TMP_PREFIX}{len(self.files):05d}.safetensors"
78
+ mx.save_safetensors(str(tmp), self.buffer, metadata=self.metadata)
79
+ mx.clear_cache()
80
+ self.files.append((tmp, list(self.buffer)))
81
+ self.buffer = {}
82
+ self.buffer_bytes = 0
83
+
84
+ def tmp_weight_map(self):
85
+ return {k: tmp for tmp, keys in self.files for k in keys}
86
+
87
+ def commit(self, old_shards):
88
+ """Delete the original shards and move tmp shards to final names."""
89
+ for shard in old_shards:
90
+ (self.out_dir / shard).unlink()
91
+ n = len(self.files)
92
+ weight_map, total = {}, 0
93
+ for i, (tmp, keys) in enumerate(self.files, 1):
94
+ name = f"model-{i:05d}-of-{n:05d}.safetensors"
95
+ tmp.rename(self.out_dir / name)
96
+ hdr = read_header(self.out_dir / name)
97
+ for k, v in hdr.items():
98
+ if k != "__metadata__":
99
+ total += v["data_offsets"][1] - v["data_offsets"][0]
100
+ for k in keys:
101
+ weight_map[k] = name
102
+ index = {"metadata": {"total_size": total}, "weight_map": weight_map}
103
+ with open(self.out_dir / "model.safetensors.index.json", "w") as f:
104
+ json.dump(index, f, indent=2)
105
+ return weight_map
106
+
107
+
108
+ def cleanup_tmp(model_dir):
109
+ for p in model_dir.glob(f"{TMP_PREFIX}*.safetensors"):
110
+ p.unlink()
111
+
112
+
113
+ def main():
114
+ if len(sys.argv) != 2:
115
+ sys.exit(__doc__)
116
+ model_dir = Path(sys.argv[1]).resolve()
117
+ index_file = model_dir / "model.safetensors.index.json"
118
+ if not index_file.exists():
119
+ sys.exit(f"error: {index_file} not found")
120
+ cleanup_tmp(model_dir) # leftovers from an interrupted run
121
+
122
+ weight_map = json.load(open(index_file))["weight_map"]
123
+ expert_keys = [k for k in weight_map if EXPERT_RE.match(k)]
124
+ if not expert_keys:
125
+ print("no per-expert tensors found — model is already repaired")
126
+ return
127
+
128
+ # group per-expert keys by their stacked target
129
+ groups = {} # stacked_key -> {expert_idx: source_key}
130
+ for k in expert_keys:
131
+ m = EXPERT_RE.match(k)
132
+ stacked = f"{m['prefix']}.switch_mlp.{m['proj']}.{m['t']}"
133
+ groups.setdefault(stacked, {})[int(m["e"])] = k
134
+ n_experts = {len(v) for v in groups.values()}
135
+ if len(n_experts) != 1:
136
+ sys.exit(f"error: inconsistent expert counts per group: {sorted(n_experts)}")
137
+ n_experts = n_experts.pop()
138
+ key_to_stacked = {sk: stacked for stacked, exps in groups.items() for sk in exps.values()}
139
+ print(f"{len(expert_keys)} per-expert tensors -> {len(groups)} stacked tensors ({n_experts} experts)")
140
+
141
+ mx.set_default_device(mx.cpu)
142
+ old_shards = sorted({v for v in weight_map.values()})
143
+ src_metadata = read_header(model_dir / old_shards[0]).get("__metadata__") or {"format": "mlx"}
144
+ writer = ShardWriter(model_dir, src_metadata)
145
+ pending = {} # stacked_key -> {expert_idx: array}
146
+
147
+ try:
148
+ for i, shard in enumerate(old_shards, 1):
149
+ print(f"[{i}/{len(old_shards)}] {shard}")
150
+ tensors = mx.load(str(model_dir / shard))
151
+ for key, array in tensors.items():
152
+ stacked = key_to_stacked.get(key)
153
+ if stacked is None:
154
+ writer.add(key, array)
155
+ continue
156
+ e = int(EXPERT_RE.match(key)["e"])
157
+ pending.setdefault(stacked, {})[e] = array
158
+ if len(pending[stacked]) == n_experts:
159
+ parts = pending.pop(stacked)
160
+ assert sorted(parts) == list(range(n_experts)), f"non-contiguous experts for {stacked}"
161
+ writer.add(stacked, mx.stack([parts[j] for j in range(n_experts)]))
162
+ del tensors
163
+ if pending:
164
+ raise RuntimeError(f"incomplete expert groups: {list(pending)[:3]}")
165
+ writer.flush()
166
+
167
+ # verify before touching the originals
168
+ tmp_map = writer.tmp_weight_map()
169
+ expected = len(weight_map) - len(expert_keys) + len(groups)
170
+ if len(tmp_map) != expected:
171
+ raise RuntimeError(f"tensor count mismatch: {len(tmp_map)} != {expected}")
172
+ check = [(sk, e) for sk in list(groups)[::max(1, len(groups) // 4)] for e in (0, n_experts - 1)]
173
+ by_tmp = {}
174
+ for sk, e in check:
175
+ by_tmp.setdefault(tmp_map[sk], []).append((sk, e))
176
+ for tmp, items in by_tmp.items():
177
+ out_tensors = mx.load(str(tmp))
178
+ for sk, e in items:
179
+ src_key = groups[sk][e]
180
+ orig = mx.load(str(model_dir / weight_map[src_key]))[src_key]
181
+ if not mx.array_equal(out_tensors[sk][e], orig).item():
182
+ raise RuntimeError(f"bitwise mismatch at {sk}[{e}]")
183
+ del out_tensors
184
+ print(f"spot-check passed ({len(check)} slices)")
185
+ except Exception as e:
186
+ cleanup_tmp(model_dir)
187
+ sys.exit(f"error: {e} — original model left untouched")
188
+
189
+ # point of no return: swap repaired shards in, rewrite index and config
190
+ writer.commit(old_shards)
191
+ config_file = model_dir / "config.json"
192
+ config = json.load(open(config_file))
193
+ for section in ("quantization", "quantization_config"):
194
+ if isinstance(config.get(section), dict):
195
+ config[section] = {
196
+ (quant_key_to_module_path(k) if isinstance(v, dict) else k): v
197
+ for k, v in config[section].items()
198
+ }
199
+ with open(config_file, "w") as f:
200
+ json.dump(config, f, indent=2)
201
+ print(f"done: {model_dir} repaired in place")
202
+
203
+
204
+ if __name__ == "__main__":
205
+ main()