Skip to content

tools: repair int4-converted MTP heads in place; warn on --mtp --ebits <8#397

Merged
JustVugg merged 1 commit into
JustVugg:devfrom
NeuralNotwerk:fix/mtp-int8-repair
Jul 19, 2026
Merged

tools: repair int4-converted MTP heads in place; warn on --mtp --ebits <8#397
JustVugg merged 1 commit into
JustVugg:devfrom
NeuralNotwerk:fix/mtp-int8-repair

Conversation

@NeuralNotwerk

Copy link
Copy Markdown
Contributor

Summary

Adds tools/repair_mtp_int8.py β€” an in-place repair for colibri containers whose MTP head was quantized at per-row int4 β€” and a warning in convert_fp8_to_int4.py when --mtp is combined with --ebits <8 per-row scales.

The bug this repairs (found in a published container)

A pre-converted GLM-5.2 container on HF (jlnsrk/GLM-5.2-colibri-int4) shipped MTP shards made with an explicit --ebits 4, overriding --mtp's int8 default. Result: MTP acceptance 0% on that container, on every backend, while the main model works perfectly.

The mechanism is worse than ordinary quantization loss. model.layers.78.eh_proj.weight [6144, 12288] multiplies the MTP concat [embedding_norm ; hidden_norm], and its column halves differ in scale ~20-30Γ— per row (embedding-half absmax β‰ˆ 0.05, hidden-half β‰ˆ 1.4-1.6). Per-row int4 has one scale per row (absmax/7), so every embedding-half weight sits at 0.24-0.34 of a quantization step β€” np.rint rounds the entire embedding half of all 6144 rows to exact zeros (packed bytes 0x88). The draft head literally cannot see the input token; drafts are unrelated to context (true next token ranked ~#17,000 of 154,880 in draft logits).

Verified byte-for-byte: running this repo's quant_int4 on the official zai-org/GLM-5.2-FP8 BF16 rows reproduces the published bytes exactly, scales included. This is also the mechanism behind issue #8's measurement (int4 MTP: 0-4% acceptance; int8: 39-59%) β€” the asymmetry explains why int4 doesn't just degrade the head but kills it.

Full elimination trail before the data was implicated (engine exonerated): CPU and Metal builds identical garbage; unpatched upstream identical; IDOT=0 exact kernels, ABSORB=0, SPEC_PIN=0, expert pinning β€” all 0%; stage-by-stage NumPy replay of mtp_draft matched the C engine at every step (norms 1e-8, matmuls ≀0.9%). The engine computes broken data faithfully.

What the repair script does

python3 tools/repair_mtp_int8.py --snap /path/to/glm52_i4 --dry-run   # inspect
python3 tools/repair_mtp_int8.py --snap /path/to/glm52_i4             # repair
  • Finds the MTP layer's dense tensors stored as per-row int4 (eh_proj, q_a/q_b/kv_a/kv_b/o_proj, shared experts). Routed experts are left untouched.
  • Re-downloads only those tensors from the FP8 source repo (~355 MB of HTTP range reads β€” no full 756 GB re-download, no torch, no HF token).
  • Dequantizes (BF16 / FP8+128Γ—128 block scales) and requantizes at int8 with quant_int8's exact math.
  • Rewrites the affected shards atomically, keeping originals as *.bak-int4; all untouched tensors stay byte-identical.
  • The engine picks up int8 automatically (qt_from_disk infers format from blob size). Cost: ~+133 MB disk/resident.

Validation (GLM-5.2 744B int4 container, macOS ARM, CPU + Metal)

before repair after repair
MTP acceptance (greedy) 0% (0/22) 100% (10/10)
tokens/forward 1.11 3.20
decode tok/s (Metal, 4-bit KV) ~0.18 0.30

eh_proj embedding-half cosine vs official weights: 0.0000 β†’ 0.97.

A companion PR replacing the broken shards directly on the HF container: https://huggingface.co/jlnsrk/GLM-5.2-colibri-int4/discussions/3

Converter change

convert_fp8_to_int4.py now prints a warning when --mtp is used with --ebits <8 and per-row scales, pointing at the default (--ebits 8) or --group-size 128 (group scales give the embedding half its own scale, making int4 viable).

πŸ€– Generated with Claude Code

…rn on --mtp --ebits <8

Per-row int4 quantization of the MTP head's eh_proj [D,2D] zeroes its ENTIRE
embedding half: the tensor's two column halves differ in scale by ~20-30x per
row (embedding-half absmax ~0.05, hidden-half ~1.5 on GLM-5.2), so a single
per-row scale (absmax/7) puts every embedding-half weight below half a
quantization step and np.rint rounds it to exact zero (packed bytes 0x88).
The draft head then cannot see the input token and MTP acceptance collapses
to ~0% β€” the mechanism behind issue JustVugg#8's measurement (int4: 0-4%; int8:
39-59%), and the reason --mtp already defaults to --ebits 8.

This showed up in the wild: a published pre-converted container had its MTP
shards made with an explicit --ebits 4 and drafts were pure garbage on every
backend (deterministic, data-side; verified byte-for-byte by reproducing the
published bytes with quant_int4 on the official BF16 rows).

- tools/repair_mtp_int8.py: repairs such a container IN PLACE β€” finds the MTP
  layer's per-row-int4 dense tensors (eh_proj, q/kv/o projections, shared
  experts; routed experts untouched), re-downloads only those from the FP8
  source repo (~355 MB of HTTP range reads, no torch, no token), requantizes
  at int8 with quant_int8's exact math, and rewrites the shards atomically
  with *.bak-int4 backups. --dry-run to inspect. The engine picks up int8
  automatically (qt_from_disk detects format by blob size).
  Validated on GLM-5.2 744B: MTP acceptance 0% -> 100% (greedy), 1.11 -> 3.20
  tokens/forward, decode 0.18 -> 0.30 tok/s (Metal, 4-bit KV).

- convert_fp8_to_int4.py: print a warning when --mtp is combined with
  --ebits <8 and per-row scales (the exact footgun above); --group-size 128
  remains a valid int4 alternative since group scales give the embedding
  half its own scale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@JustVugg
JustVugg changed the base branch from main to dev July 19, 2026 08:36
@JustVugg
JustVugg merged commit 66b5e57 into JustVugg:dev Jul 19, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants