File size: 7,907 Bytes
d833396
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
#!/usr/bin/env python3
"""Repair oMLX-quantized Qwen3.5-MoE checkpoints that store routed experts
in the legacy per-expert layout (mlp.experts.<E>.{gate,up,down}_proj.*).

mlx-lm's qwen3_5_moe sanitize() only stacks the fused `experts.gate_up_proj`
layout, so per-expert checkpoints fail to load with
"Received NNNNN parameters not in model". This script repairs the model
in place:

  1. Stacks every `<prefix>.mlp.experts.<E>.<proj>.<tensor>` group along a new
     leading axis into `<prefix>.mlp.switch_mlp.<proj>.<tensor>`.
  2. Rewrites per-path quantization overrides in config.json from raw HF key
     names (model.language_model.*) to post-sanitize module paths
     (language_model.model.*), which is how mlx-lm looks them up at load time.

New shards are written alongside the originals, verified bitwise against the
source tensors, and only then swapped in (originals deleted). A failure at any
point leaves the original model untouched. Needs free disk roughly equal to
the model size while running.

Usage:
    python3 repair_moe_experts.py <model_dir>

Requires mlx; no other dependencies.
"""

import json
import re
import struct
import sys
from pathlib import Path

import mlx.core as mx

EXPERT_RE = re.compile(
    r"^(?P<prefix>.+\.mlp)\.experts\.(?P<e>\d+)\.(?P<proj>\w+_proj)\.(?P<t>weight|scales|biases)$"
)
SHARD_BYTES = 5 * 1024**3
TMP_PREFIX = "tmp-shard-"


def quant_key_to_module_path(key):
    if key.startswith("model.language_model"):
        return key.replace("model.language_model", "language_model.model", 1)
    if key.startswith("language_model."):
        return key
    return "language_model." + key


def read_header(path):
    with open(path, "rb") as f:
        n = struct.unpack("<Q", f.read(8))[0]
        return json.loads(f.read(n))


def nbytes(a):
    return a.size * a.dtype.size


class ShardWriter:
    def __init__(self, out_dir, metadata):
        self.out_dir = out_dir
        self.metadata = metadata
        self.buffer = {}
        self.buffer_bytes = 0
        self.files = []  # [(tmp_path, [keys])]

    def add(self, key, array):
        self.buffer[key] = array
        self.buffer_bytes += nbytes(array)
        if self.buffer_bytes >= SHARD_BYTES:
            self.flush()

    def flush(self):
        if not self.buffer:
            return
        tmp = self.out_dir / f"{TMP_PREFIX}{len(self.files):05d}.safetensors"
        mx.save_safetensors(str(tmp), self.buffer, metadata=self.metadata)
        mx.clear_cache()
        self.files.append((tmp, list(self.buffer)))
        self.buffer = {}
        self.buffer_bytes = 0

    def tmp_weight_map(self):
        return {k: tmp for tmp, keys in self.files for k in keys}

    def commit(self, old_shards):
        """Delete the original shards and move tmp shards to final names."""
        for shard in old_shards:
            (self.out_dir / shard).unlink()
        n = len(self.files)
        weight_map, total = {}, 0
        for i, (tmp, keys) in enumerate(self.files, 1):
            name = f"model-{i:05d}-of-{n:05d}.safetensors"
            tmp.rename(self.out_dir / name)
            hdr = read_header(self.out_dir / name)
            for k, v in hdr.items():
                if k != "__metadata__":
                    total += v["data_offsets"][1] - v["data_offsets"][0]
            for k in keys:
                weight_map[k] = name
        index = {"metadata": {"total_size": total}, "weight_map": weight_map}
        with open(self.out_dir / "model.safetensors.index.json", "w") as f:
            json.dump(index, f, indent=2)
        return weight_map


def cleanup_tmp(model_dir):
    for p in model_dir.glob(f"{TMP_PREFIX}*.safetensors"):
        p.unlink()


def main():
    if len(sys.argv) != 2:
        sys.exit(__doc__)
    model_dir = Path(sys.argv[1]).resolve()
    index_file = model_dir / "model.safetensors.index.json"
    if not index_file.exists():
        sys.exit(f"error: {index_file} not found")
    cleanup_tmp(model_dir)  # leftovers from an interrupted run

    weight_map = json.load(open(index_file))["weight_map"]
    expert_keys = [k for k in weight_map if EXPERT_RE.match(k)]
    if not expert_keys:
        print("no per-expert tensors found — model is already repaired")
        return

    # group per-expert keys by their stacked target
    groups = {}  # stacked_key -> {expert_idx: source_key}
    for k in expert_keys:
        m = EXPERT_RE.match(k)
        stacked = f"{m['prefix']}.switch_mlp.{m['proj']}.{m['t']}"
        groups.setdefault(stacked, {})[int(m["e"])] = k
    n_experts = {len(v) for v in groups.values()}
    if len(n_experts) != 1:
        sys.exit(f"error: inconsistent expert counts per group: {sorted(n_experts)}")
    n_experts = n_experts.pop()
    key_to_stacked = {sk: stacked for stacked, exps in groups.items() for sk in exps.values()}
    print(f"{len(expert_keys)} per-expert tensors -> {len(groups)} stacked tensors ({n_experts} experts)")

    mx.set_default_device(mx.cpu)
    old_shards = sorted({v for v in weight_map.values()})
    src_metadata = read_header(model_dir / old_shards[0]).get("__metadata__") or {"format": "mlx"}
    writer = ShardWriter(model_dir, src_metadata)
    pending = {}  # stacked_key -> {expert_idx: array}

    try:
        for i, shard in enumerate(old_shards, 1):
            print(f"[{i}/{len(old_shards)}] {shard}")
            tensors = mx.load(str(model_dir / shard))
            for key, array in tensors.items():
                stacked = key_to_stacked.get(key)
                if stacked is None:
                    writer.add(key, array)
                    continue
                e = int(EXPERT_RE.match(key)["e"])
                pending.setdefault(stacked, {})[e] = array
                if len(pending[stacked]) == n_experts:
                    parts = pending.pop(stacked)
                    assert sorted(parts) == list(range(n_experts)), f"non-contiguous experts for {stacked}"
                    writer.add(stacked, mx.stack([parts[j] for j in range(n_experts)]))
            del tensors
        if pending:
            raise RuntimeError(f"incomplete expert groups: {list(pending)[:3]}")
        writer.flush()

        # verify before touching the originals
        tmp_map = writer.tmp_weight_map()
        expected = len(weight_map) - len(expert_keys) + len(groups)
        if len(tmp_map) != expected:
            raise RuntimeError(f"tensor count mismatch: {len(tmp_map)} != {expected}")
        check = [(sk, e) for sk in list(groups)[::max(1, len(groups) // 4)] for e in (0, n_experts - 1)]
        by_tmp = {}
        for sk, e in check:
            by_tmp.setdefault(tmp_map[sk], []).append((sk, e))
        for tmp, items in by_tmp.items():
            out_tensors = mx.load(str(tmp))
            for sk, e in items:
                src_key = groups[sk][e]
                orig = mx.load(str(model_dir / weight_map[src_key]))[src_key]
                if not mx.array_equal(out_tensors[sk][e], orig).item():
                    raise RuntimeError(f"bitwise mismatch at {sk}[{e}]")
            del out_tensors
        print(f"spot-check passed ({len(check)} slices)")
    except Exception as e:
        cleanup_tmp(model_dir)
        sys.exit(f"error: {e} — original model left untouched")

    # point of no return: swap repaired shards in, rewrite index and config
    writer.commit(old_shards)
    config_file = model_dir / "config.json"
    config = json.load(open(config_file))
    for section in ("quantization", "quantization_config"):
        if isinstance(config.get(section), dict):
            config[section] = {
                (quant_key_to_module_path(k) if isinstance(v, dict) else k): v
                for k, v in config[section].items()
            }
    with open(config_file, "w") as f:
        json.dump(config, f, indent=2)
    print(f"done: {model_dir} repaired in place")


if __name__ == "__main__":
    main()