KaedeTai commited on
Commit
dc03fdf
·
verified ·
1 Parent(s): 66619c6

MTP head graft for MLX Qwen3.8-27B quantizations that dropped theirs

Browse files
Files changed (3) hide show
  1. README.md +168 -0
  2. graft_mtp.py +150 -0
  3. qwen3.8-27b-mtp-4bit.safetensors +3 -0
README.md ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ library_name: mlx
4
+ tags:
5
+ - mlx
6
+ - speculative-decoding
7
+ - multi-token-prediction
8
+ - apple-silicon
9
+ - qwen3.8
10
+ base_model:
11
+ - Qwen/Qwen3.8-27B
12
+ ---
13
+
14
+ # MTP graft — restoring Multi-Token-Prediction heads to MLX quantizations that dropped them
15
+
16
+ Most MLX quantizations of Qwen3.5/3.6/3.8 keep `mtp_num_hidden_layers: 1` in
17
+ `config.json` **while silently dropping the MTP weights**. The checkpoint loads,
18
+ generates correctly, and gives you no reason to suspect anything — it just
19
+ cannot use Lightning MTP in oMLX, and nothing tells you why.
20
+
21
+ This repo contains the **MTP head only** (314 MB), plus a tool to graft it onto a
22
+ recipient checkpoint. No base weights are redistributed.
23
+
24
+ Measured on an Apple **M5 Max, 128 GB**, macOS 26.4.1, oMLX 0.6.3rc1.
25
+
26
+ ## Result
27
+
28
+ Recipient: `Qwen3.8-27B` abliterated with [Heretic](https://github.com/p-e-w/heretic)
29
+ (`trohrbaugh/Qwen3.8-27B-heretic-ara`, MLX affine 4-bit g64), which ships with
30
+ **0 MTP tensors**. Served through oMLX with `mtp_enabled`, greedy, thinking off
31
+ via `chat_template_kwargs`, A/B/A interleaved:
32
+
33
+ | prompt | before (no MTP) | after graft | speedup |
34
+ |---|---:|---:|---:|
35
+ | Python LRU cache | 29.0 / 29.9 tok/s | **61.1 / 67.1** | **2.18×** |
36
+ | Chinese prose (200 chars) | 29.4 tok/s | **41.6 / 40.3** | **1.39×** |
37
+
38
+ For scale, the same machine and settings on a checkpoint that *shipped* with its
39
+ MTP heads intact (`tozp/…-OBLITERATED-V2`) gives 63.3 / 39–41 tok/s — i.e. the
40
+ graft lands the recipient in the same place, and the MTP multiplier itself is
41
+ the same (2.15× vs 2.18×).
42
+
43
+ **MTP is strongly content-dependent.** Code roughly doubles; Chinese prose gains
44
+ ~40%. That matches what a separate speculative-decoding stack (DFlash2 block
45
+ diffusion) does on the same machine — two unrelated mechanisms hitting the same
46
+ wall on CJK, which suggests the cause is CJK token entropy rather than any one
47
+ draft head being undertrained.
48
+
49
+ ## Output fidelity — read this
50
+
51
+ Speculative decoding is verify-then-accept: the head only *proposes*, the base
52
+ model verifies. It cannot make the model say something the base model would
53
+ reject. But batched K-token verification and single-token decoding take
54
+ different floating-point paths, so **near-ties can flip at temperature 0**.
55
+
56
+ Byte-for-byte comparison, fixed seed, `temperature=0, top_p=1, top_k=0`:
57
+
58
+ | content | tokens (before / after) | identical | similarity |
59
+ |---|---|---|---:|
60
+ | Python code | 165 / 165 | **yes** | 1.000 |
61
+ | Arithmetic | 3 / 3 | **yes** | 1.000 |
62
+ | Chinese explanation | 70 / 70 | no — 1 token | 0.992 |
63
+ | Chinese creative prose | 192 / 217 | no — diverges at ~50% | 0.663 |
64
+
65
+ The single-token flip looks like this:
66
+
67
+ ```
68
+ before: …因為它假設近期被使用過的資料[[,未來被再次使用的機率較高。]]
69
+ after : …因為它假設近期被使用過的資料[[在未來被再次使用的機率較高。]]
70
+ ```
71
+
72
+ Both are fluent; neither is better. Low-entropy text has no near-ties, so it
73
+ reproduces exactly. Creative text is full of them, and once one token flips
74
+ everything after it differs. **If you need bit-reproducible output, do not
75
+ enable MTP.**
76
+
77
+ ## Known incompatibility: `mlx_lm` breaks on MTP tensors
78
+
79
+ A checkpoint containing MTP tensors produces **garbage** under `mlx_lm.load`,
80
+ silently — no error, no warning. Controlled test, same checkpoint, only the 46
81
+ MTP tensors differing:
82
+
83
+ ```
84
+ BigBang-v1-heretic-MLX-4bit (MTP = 0) -> normal Chinese output
85
+ BigBang-v1-heretic-MLX-4bit-mtp (MTP = 46) -> ";av L WL two one lya"**开始中j位ode理ments谷雨谷…"
86
+ ```
87
+
88
+ `mlx_vlm` and oMLX both handle the same checkpoints correctly. So:
89
+
90
+ * **oMLX** — works, and is the only stack that actually *uses* the head.
91
+ * **mlx_vlm** — works (ignores the head).
92
+ * **mlx_lm** — **broken**. Keep an ungrafted copy if you need this path.
93
+
94
+ This bit us: we first blamed vision tensors for the same garbage on a VL
95
+ checkpoint that happened to have both. It is the MTP tensors.
96
+
97
+ ## Usage
98
+
99
+ ```bash
100
+ python graft_mtp.py \
101
+ --recipient /path/to/model-without-mtp \
102
+ --donor /path/to/donor-or-standalone-head \
103
+ --out /path/to/model-with-mtp
104
+ ```
105
+
106
+ The recipient's weights are **hard-linked**, not copied — the graft costs only
107
+ the MTP shard on disk. The original directory is never modified.
108
+
109
+ The tool refuses to graft across mismatched `hidden_size`, head counts,
110
+ `vocab_size`, `num_experts`, `moe_intermediate_size`, or a different base
111
+ quantization scheme (`--force` overrides). Two donor layouts are handled: heads
112
+ already namespaced under `language_model.mtp.`, and repos that *are* just the
113
+ head (`fc`/`layers.0`/`norm`/`pre_fc_norm_*`), such as
114
+ [`mlx-community/Qwen3.6-35B-A3B-MTP-4bit`](https://huggingface.co/mlx-community/Qwen3.6-35B-A3B-MTP-4bit).
115
+
116
+ Then in oMLX: set `mtp_enabled` for the model **and unload/reload it**. oMLX
117
+ reads model settings at load time only — editing `model_settings.json` while the
118
+ model is resident has no effect whatsoever (verified twice).
119
+
120
+ ### Finding recipients
121
+
122
+ Check the weights, not the config — the config lies:
123
+
124
+ ```python
125
+ import json
126
+ wm = json.load(open(f"{d}/model.safetensors.index.json"))["weight_map"]
127
+ print(sum(1 for k in wm if "mtp" in k.lower() or "nextn" in k.lower()))
128
+ ```
129
+
130
+ Of eight Qwen3.5/3.6/3.8 MLX checkpoints on hand, **four** declared
131
+ `mtp_num_hidden_layers: 1` with zero MTP tensors present.
132
+
133
+ ## Provenance
134
+
135
+ The head in this repo is **Qwen's own**, not something trained here:
136
+
137
+ 1. `Qwen/Qwen3.8-27B` — original MTP heads
138
+ 2. `tozp/Qwen3.8-27B-OBLITERATED-V2-mlx-4bit` — MLX affine 4-bit g64; its
139
+ `abliteration_metadata.json` records *"Restore MTP + vision tensors from
140
+ stock"*, so the heads it carries are unmodified
141
+ 3. this repo — those 29 tensors, extracted unchanged
142
+
143
+ 29 tensors, 314.3 MB, `sha256[:16] = 505827ce17922d2d`.
144
+
145
+ Applies to **`qwen3_5` dense, hidden 5120** (Qwen3.8-27B and derivatives).
146
+ For `qwen3_5_moe` (hidden 2048, 256 experts), use
147
+ `mlx-community/Qwen3.6-35B-A3B-MTP-4bit` as donor instead — same tool, 46
148
+ tensors.
149
+
150
+ ## Limitations
151
+
152
+ * One machine (M5 Max), one serving stack (oMLX), the prompt set above. No
153
+ long-context, no batch >1, no acceptance-rate instrumentation — oMLX does not
154
+ expose one.
155
+ * Grafting the stock head onto an **abliterated** model is not obviously sound in
156
+ theory. The empirical argument: the recipient's KL to stock is 0.0535 (Heretic
157
+ reports it), while a checkpoint that ships this head natively sits at 0.9624 —
158
+ the recipient is ~18× closer to stock than a model where the head demonstrably
159
+ works. And the failure mode is bounded: a mismatched head lowers acceptance,
160
+ it cannot corrupt output.
161
+ * Not tested with vision inputs on VL recipients.
162
+
163
+ ## Credits
164
+
165
+ - [Qwen](https://huggingface.co/Qwen/Qwen3.8-27B) — the MTP heads
166
+ - [tozp](https://huggingface.co/tozp/Qwen3.8-27B-OBLITERATED-V2-mlx-4bit) — the 4-bit quantization they were extracted from
167
+ - [Heretic](https://github.com/p-e-w/heretic) / [trohrbaugh](https://huggingface.co/trohrbaugh/Qwen3.8-27B-heretic-ara) — the recipient
168
+ - [oMLX](https://omlx.ai) — Lightning MTP
graft_mtp.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Graft Multi-Token-Prediction heads onto an MLX checkpoint that lost them.
3
+
4
+ Many MLX quantizations of Qwen3.5/3.6/3.8 keep `mtp_num_hidden_layers: 1` in
5
+ config.json while silently dropping the MTP weights, because the standard
6
+ conversion path only walks the main decoder. The result loads fine and runs
7
+ fine -- it just cannot use Lightning MTP in oMLX, and nothing tells you why.
8
+
9
+ This copies the MTP subtree from a donor that still has it, into a recipient
10
+ that does not. The recipient's own weights are never modified: they are
11
+ hard-linked into a new directory and the MTP tensors are added as one extra
12
+ shard.
13
+
14
+ Why this is safe: speculative decoding is verify-then-accept. The MTP head
15
+ only *proposes* tokens; the base model verifies every one of them. A poorly
16
+ matched head lowers the acceptance rate -- it costs throughput, it cannot
17
+ corrupt output. The worst outcome is no speedup.
18
+
19
+ Two caveats that are real and measured, see README.
20
+ """
21
+ import argparse, json, os, shutil, sys
22
+ import mlx.core as mx
23
+
24
+ MTP_PREFIX = "language_model.mtp."
25
+
26
+
27
+ def _load_all(d):
28
+ idx = os.path.join(d, "model.safetensors.index.json")
29
+ if os.path.exists(idx):
30
+ wm = json.load(open(idx))["weight_map"]
31
+ return wm, sorted(set(wm.values()))
32
+ files = sorted(f for f in os.listdir(d) if f.endswith(".safetensors"))
33
+ wm = {}
34
+ for f in files:
35
+ for k in mx.load(os.path.join(d, f)).keys():
36
+ wm[k] = f
37
+ return wm, files
38
+
39
+
40
+ def collect_mtp(donor):
41
+ """Pull the MTP subtree out of `donor`, normalised to MTP_PREFIX.
42
+
43
+ Handles both layouts seen in the wild:
44
+ * in-model -- keys already start with `language_model.mtp.`
45
+ * standalone -- the repo IS just the head (fc/layers.0/norm/pre_fc_*),
46
+ e.g. mlx-community/Qwen3.6-35B-A3B-MTP-4bit
47
+ """
48
+ wm, files = _load_all(donor)
49
+ inline = {k: k for k in wm if k.startswith(MTP_PREFIX)}
50
+ if inline:
51
+ keys = inline
52
+ else:
53
+ roots = ("fc.", "layers.", "norm.", "pre_fc_norm_embedding.",
54
+ "pre_fc_norm_hidden.")
55
+ keys = {k: MTP_PREFIX + k for k in wm if k.startswith(roots)}
56
+ if not keys:
57
+ sys.exit(f"no MTP tensors found in {donor}")
58
+ out = {}
59
+ for f in files:
60
+ w = mx.load(os.path.join(donor, f))
61
+ for src, dst in keys.items():
62
+ if wm[src] == f and src in w:
63
+ out[dst] = w[src]
64
+ if len(out) != len(keys):
65
+ sys.exit(f"expected {len(keys)} MTP tensors, recovered {len(out)}")
66
+ return out
67
+
68
+
69
+ def check(recipient_cfg, donor_cfg):
70
+ """Refuse mismatched architectures. These are the fields the MTP head's
71
+ shapes actually depend on; a mismatch means the graft cannot work."""
72
+ r = recipient_cfg.get("text_config", recipient_cfg)
73
+ d = donor_cfg.get("text_config", donor_cfg)
74
+ bad = []
75
+ for k in ("hidden_size", "num_attention_heads", "num_key_value_heads",
76
+ "head_dim", "vocab_size", "num_experts", "moe_intermediate_size"):
77
+ if k in r and k in d and r[k] != d[k]:
78
+ bad.append(f"{k}: recipient={r[k]} donor={d[k]}")
79
+ rq = (recipient_cfg.get("quantization") or {})
80
+ dq = (donor_cfg.get("quantization") or {})
81
+ for k in ("bits", "group_size", "mode"):
82
+ # Per-tensor overrides are fine; only the base scheme has to agree.
83
+ if rq.get(k) != dq.get(k):
84
+ bad.append(f"quantization.{k}: recipient={rq.get(k)} donor={dq.get(k)}")
85
+ return bad
86
+
87
+
88
+ def main():
89
+ ap = argparse.ArgumentParser()
90
+ ap.add_argument("--recipient", required=True, help="model missing its MTP head")
91
+ ap.add_argument("--donor", required=True, help="model (or standalone head) that has one")
92
+ ap.add_argument("--out", required=True)
93
+ ap.add_argument("--force", action="store_true", help="graft despite arch mismatch")
94
+ a = ap.parse_args()
95
+
96
+ rc = json.load(open(os.path.join(a.recipient, "config.json")))
97
+ dc = json.load(open(os.path.join(a.donor, "config.json")))
98
+ bad = check(rc, dc)
99
+ if bad:
100
+ print("architecture mismatch:")
101
+ for b in bad:
102
+ print(" " + b)
103
+ if not a.force:
104
+ sys.exit("refusing to graft (use --force to override)")
105
+
106
+ if os.path.exists(a.out):
107
+ shutil.rmtree(a.out)
108
+ os.makedirs(a.out)
109
+ # Hard-link the weights: the graft costs only the MTP shard on disk, and
110
+ # the recipient's files are shared, not copied. Small files are copied so
111
+ # a later in-place config edit cannot corrupt the original -- a real bug
112
+ # we hit with a tool that hard-linked config.json then rewrote it.
113
+ for f in os.listdir(a.recipient):
114
+ s, d = os.path.join(a.recipient, f), os.path.join(a.out, f)
115
+ if not os.path.isfile(s):
116
+ continue
117
+ (os.link if f.endswith(".safetensors") else shutil.copy2)(s, d)
118
+
119
+ mtp = collect_mtp(a.donor)
120
+ shard = "model-mtp-00001-of-00001.safetensors"
121
+ mx.save_safetensors(os.path.join(a.out, shard), mtp, metadata={"format": "mlx"})
122
+ size = os.path.getsize(os.path.join(a.out, shard))
123
+
124
+ ridx = os.path.join(a.recipient, "model.safetensors.index.json")
125
+ if os.path.exists(ridx):
126
+ base = json.load(open(ridx))
127
+ else:
128
+ wm, _ = _load_all(a.recipient)
129
+ base = {"metadata": {"total_size": 0}, "weight_map": wm}
130
+ wm = dict(base["weight_map"])
131
+ for k in mtp:
132
+ wm[k] = shard
133
+ meta = dict(base.get("metadata", {}))
134
+ meta["total_size"] = int(meta.get("total_size", 0)) + size
135
+ json.dump({"metadata": meta, "weight_map": wm},
136
+ open(os.path.join(a.out, "model.safetensors.index.json"), "w"), indent=2)
137
+
138
+ cfg = json.load(open(os.path.join(a.out, "config.json")))
139
+ tgt = cfg.get("text_config", cfg)
140
+ if not (cfg.get("mtp_num_hidden_layers") or tgt.get("mtp_num_hidden_layers")):
141
+ cfg["mtp_num_hidden_layers"] = 1
142
+ json.dump(cfg, open(os.path.join(a.out, "config.json"), "w"), indent=1)
143
+ print(" set mtp_num_hidden_layers = 1")
144
+
145
+ print(f"grafted {len(mtp)} MTP tensors ({size/1e6:.1f} MB) -> {a.out}")
146
+ print(f" tensors: {len(base['weight_map'])} -> {len(wm)}")
147
+
148
+
149
+ if __name__ == "__main__":
150
+ main()
qwen3.8-27b-mtp-4bit.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:505827ce17922d2d0f274ca6be21d7a35404eab21600da7784612ba7cef27f9f
3
+ size 314300890