[transformers-to-mlx skill] glm_moe_dsa: DSA cross-layer indexer sharing (GLM-5.2)#1410
[transformers-to-mlx skill] glm_moe_dsa: DSA cross-layer indexer sharing (GLM-5.2)#1410pcuenca wants to merge 2 commits into
Conversation
GLM-5.2 drives the DSA indexer with a per-layer schedule (config.indexer_types): full layers run their own indexer; shared layers reuse the previous full layer's top-k selection and carry no indexer weights. Implement the schedule and thread prev_topk_indices through the decoder layers; shared layers also skip the indexer KV cache (an unused one crashes on .state during generation). Add a model test covering the full/shared schedule, the sparse path, and cache serialization. Defaults (no schedule) reduce to plain deepseek_v32. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GLM-5.2 (
|
| Variant | Repo | Format | Notes |
|---|---|---|---|
| GLM-5.2 | zai-org/GLM-5.2 |
bf16 | ~750B, full precision |
| GLM-5.2-FP8 | zai-org/GLM-5.2-FP8 |
block-FP8 (e4m3, 128×128, dynamic) | conversion source |
| GLM-5.2 MLX | mlx-community/GLM-5.2-mxfp4 |
mxfp4, 4.252 bpw, 368 GB, 76 shards | tested |
Expected runtime dtype: bfloat16 (source: config.json dtype: "bfloat16").
Architecture
- 78 layers; MLA:
kv_lora_rank=512,q_lora_rank=2048,qk_nope_head_dim=192,
qk_rope_head_dim=64,v_head_dim=256;num_attention_heads=64. - MoE: 256 routed experts (8 active) + 1 shared;
n_group=1,topk_method=noaux_tc,
scoring_func=sigmoid,routed_scaling_factor=2.5; first 3 layers dense
(first_k_dense_replace=3). - DSA indexer:
index_head_dim=128,index_n_heads=32,index_topk=2048. - RoPE:
rope_theta=8e6,max_position_embeddings=1048576. Both main attention
(rope_interleave: true) and indexer (indexer_rope_interleave: true) use
interleaved RoPE. - 1 MTP layer (
num_nextn_predict_layers=1) — dropped on load.
Novelty: cross-layer DSA indexer sharing
config.indexer_types is a per-layer list of "full"/"shared" (78 entries for
GLM-5.2: full at layers 0,1,2 then every 4th — index_topk_freq=4,
index_skip_topk_offset=3):
fulllayers run their own DSA indexer (the lightweightwq_b/wk/
weights_proj/k_normprojections) and produce top-k token indices.sharedlayers run no indexer (no indexer weights in the checkpoint) and
reuse the previous full layer's top-k, threaded down asprev_topk_indices.
This is the GlmMoeDsa divergence from deepseek_v3.2 in transformers, where the
attention/decoder/model thread prev_topk_indices and shared layers set
self.indexer = None.
Reference: modeling_glm_moe_dsa.py
(GlmMoeDsaAttention, GlmMoeDsaDecoderLayer, GlmMoeDsaModel).
Changes to mlx-lm
All in mlx_lm/models/glm_moe_dsa.py (previously a bare deepseek_v32 subclass):
ModelArgs: addindexer_typesplusindex_topk_pattern/index_topk_freq
/index_skip_topk_offset, deriving the schedule in__post_init__(mirrors the
transformers config). Default (freq=1) → all"full", i.e. identical to plain
deepseek_v3.2.GlmMoeDsaAttention: shared layers setself.indexer = None, consume
prev_topk_indices, and return their top-k; the indexer-cachemx.depends
bookkeeping is gated to full layers.GlmMoeDsaDecoderLayer/GlmMoeDsaModel: threadprev_topk_indices
layer→layer.make_cache: shared layers get only the mainKVCache(no indexer
KVCache). Without this, the unused indexer cache stays empty and crashes in
KVCache.stateduring generation.
No changes to deepseek_v32.py. Its indexer already uses interleaved RoPE
(traditional=True), which is correct for GLM-5.2 (see RoPE note).
Testing
What we could not do
Full-model numerical / per-layer comparison vs transformers.
This is deferred to a separate test harness.
Module-level indexer parity
glm_moe_dsa.py's indexer is deepseek_v32's. We copied real weights from the
transformers GlmMoeDsaIndexer into the mlx Indexer (tiny synthetic config),
compared top-k selections, and checked the sharing schedule against the real
config.json:
[rope] transformers indexer matched by: traditional=True -> False, traditional=False -> True
[share] using real indexer_types from config (78 layers)
[share] indexer present per layer matches schedule: True
[share] shared layers carry no indexer weights: True
[share] strict load_weights OK
RoPE note (important): GLM-5.2's config.json sets indexer_rope_interleave: true
(= mlx traditional=True). mlx-lm's indexer is already traditional=True and is
correct. The parity test shows current upstream transformers matches
traditional=False (half-split / apply_rotary_pos_emb) for the indexer — i.e.
upstream transformers appears to have a regression for the GLM-5.2 indexer RoPE,
to be verified.
Test 2 — dtype (on the converted mxfp4 model)
Tensor dtype tally across the 76 converted shards:
U8 (mxfp4 scales): 992 | U32 (mxfp4 weights): 992 | BF16: 430 | F32: 75
The 75 F32 tensors are all model.layers.N.mlp.gate.e_score_correction_bias
(one per sparse MoE layer) — intentionally kept fp32 by deepseek_v32's
cast_predicate. No stray float32 contamination.
Test 1 — short generation (distributed, tensor parallel)
2× M3 Ultra (512 GB) over a Thunderbolt ring, TP:
$ mlx.launch --backend ring --hostfile hosts.json --env MLX_METAL_FAST_SYNCH=1 \
sharded_generate.py --model GLM-5.2-mlx-mxfp4 \
--prompt "In one sentence, what is tensor parallelism?" -m 128
...coherent reasoning trace about splitting tensors across GPUs...
Prompt: 22 tokens, 22.941 tokens-per-sec
Generation: 128 tokens, 18.242 tokens-per-sec
Peak memory: 206.562 GB # ~half the 368 GB model per node — TP shards correctly
Test 5 — long-context generation (single node) — the key test
Single node (model fits under the 480 GB wired limit). This is the decisive test:
the DSA indexer only sparsifies past index_topk=2048, so a wrong indexer RoPE or
sharing schedule shows up as gibberish only at long context.
$ MLX_METAL_FAST_SYNCH=1 mlx_lm.generate --model GLM-5.2-mlx-mxfp4 \
--prompt "Write a complete, polished single-file HTML and JavaScript implementation of Space Invaders, with comments explaining each section." \
--max-tokens 16384
Prompt: 34 tokens, 5.599 tokens-per-sec
Generation: 16384 tokens, 18.081 tokens-per-sec
Peak memory: 396.755 GB
Result: fully coherent across all 16,384 tokens. The output is a complete,
well-formed HTML/CSS/JS Space Invaders game (closing </html>, 49 structural
tags, sprite bitmaps defined as 2D arrays deep in the tail at ~16K tokens), with no
gibberish and no degenerate repetition (most-repeated lines are ordinary }/CSS).
This validates the interleaved indexer RoPE and the cross-layer sharing end-to-end
on real weights, well past the 2048 sparsification boundary.
Unit test
tests/test_models.py::test_glm_moe_dsa — builds a model with an alternating
FSFSFS schedule and asserts: the derived indexer_types, that only full layers
hold an indexer, the standard forward/cache flow (model_test_runner), the sparse
path (prompt > index_topk), and cache serialization ([c.state for c in cache],
which is what crashed before the make_cache fix).
Conversion details
Converted from the FP8 checkpoint.
$ mlx_lm.convert --hf-path GLM-5.2-FP8 --mlx-path GLM-5.2-mlx-mxfp4 --q-mode mxfp4 -q
[INFO] Quantized model with 4.252 bits per weight.
(Note: converting from FP8 quantizes already-fp8-rounded weights — marginally lossier
than converting from the bf16 source, but negligible at 4-bit and the generation
quality is clearly intact.)
Errors encountered & resolved
KVCache.statecrash during generation: shared layers never populate the
second (indexer)KVCache, so it stayed empty and crashed when
generate_stepserialized cache state. Fixed viamake_cache(shared layers get
no indexer cache). The unit test now covers this path.
Notes / learnings
- Indexer RoPE convention is per-model and must be read from
config
(indexer_rope_interleave); the short-sequence test blind spot (≤index_topk)
means RoPE bugs in the indexer only surface in long-context generation. - For a model this large that fits one node when quantized, single-node generation
is more reliable than a 2-node ring for long runs.
Environment
mlx 0.31.2; 2× Apple M3 Ultra (512 GB), macOS; conversion target mxfp4.
|
Setting as draft. Initial tests pass, but we want to run additional tests and review the code in more detail. |
|
cc @Blaizzy |
|
Tested this on a dual mac studio setup running GLM-5.2-8bit and seems to be working well! Initially, I did something similar and extended the deepseek_v32.py implementation to support the indexer sharing, which results in overall fewer changes, but I think this approach is better, since the extension is glm_moe_dsa specific. |
|
Also tested this, but on a single mac studio. Passed all my tests for coding and translating accuracy. |
Based on ml-explore/mlx-lm#1410 by @pcuenca (Pedro Cuenca): [transformers-to-mlx skill] glm_moe_dsa: DSA cross-layer indexer sharing (GLM-5.2) Upstream PR: ml-explore/mlx-lm#1410 Upstream head: 3cb18b51892a7800678923116f5b4920a7666208 Port the GLM-5.2 model patch into oMLX as a pre-load monkey patch so the pinned mlx-lm runtime can load glm_moe_dsa checkpoints with native full/shared DSA indexer sharing. Keep post-load IndexCache limited to DeepSeek DSA and cover CacheList hot/cold round trips.
Implement streaming sensitivity proxy generation for GLM FP8 sources so large checkpoints can be quantized without mlx_lm.convert. Support GLM MoE DSA sanitize replay, partial FP8 block scales, and previous top-k state during sensitivity calibration. Based on ml-explore/mlx-lm#1410 by @pcuenca (Pedro Cuenca), upstream head 3cb18b51892a7800678923116f5b4920a7666208.
|
I will test this tomorrow! Thanks @pcuenca for conversion and converter! What an amazing job there! 🙏 |
|
Thank you @ivanfioravanti! For additional context, I'm trying to compare hidden states against the transformers implementation, but the process is giving me some trouble (infra issues). Will update when I can! |
|
I used this PR to quantize for a single M3 Ultra yesterday, and it's been driving my Claude Code ever since. Very stable and extremely capable model, if a bit slow. Thanks @pcuenca! |
|
There seem to be some issues: In https://huggingface.co/mlx-community/GLM-5.2-DQ4plus-q8/discussions/3 people mention that as the prompt gets longer, the MLX quants based on this PR become incoherent. @pcuenca make a mxfp4 quant from GLM-5.2-FP8, which works. When I tried to make a mxfp4 quant from the BF16 version, it created it, but |
|
Fixes everything for me! (EXO with 2 Mac Studios against Thank you so much @pcuenca! |
|
Same here! Thanks @pcuenca 🚀 |
Seems solved. Great work as always @pcuenca |
GLM-5.2 mxfp4 produced gibberish past ~2K tokens because exo's pinned mlx-lm (rltakashige leo/deepseek-v4) ships a stub glm_moe_dsa.py that runs GLM-5.2 as plain DeepSeek-V3.2, so the sparse-attention indexer is wrong at long context. Fix: pcuenca's open PR ml-explore/mlx-lm#1410 (DSA cross-layer indexer sharing) ported onto the exo base in krystophny/mlx-lm @ glm-5.2-dsa-indexer. exo_repoint_mlx_lm.sh repins + installs it per node; recreating the instance loads it (no exo restart). Verified coherent at 11K context. Docs + CI updated.
|
Has the work from this PR landed in the main branch through another PR or is this issue still ongoing ? |
|
Still ongoing. Antirez reported some lower quality responses compared to GGUF and super @pcuenca was investigating. |
|
Update: Prompted by this thread, I replicated @antirez's quality testing protocol from DS4:
I don't have access to @antirez's GLM code, so I checked my mxfp4 MLX quant against a Q3-K-L quant I quantized myself from the same original GLM 5.2 bf16 checkpoint, using llama.cpp. These are the results I got ( I'm reading this as "results between Q3-K-L (llama.cpp) and mxfp4 (llama.cpp) are quite similar". At least it doesn't appear obvious to me that there are implementation errors in the MLX code (or they are subtle enough that we don't notice them in this test). Do you have any suggestions for other quants to compare, @ivanfioravanti @antirez? (The motivation for choosing the quants above is to be able to run them on a single M3 Ultra 512 GB computer. 4-bit llama.cpp quants OOM for me, and so does the |
|
@pcuenca My mixed 4-bit MLX quant should be quite close to the Unsloth GGUF mentioned in the linked X thread. In my quantization sweeps, over-doing some parts of the model absolutely can lead to double-digit % divergences, so I wouldn't rule that out as an explanation. |
|
We've been maintaining #1463 in parallel (independent implementation of the same
On the quality investigation: we're porting the same DS4 100-case protocol to our C/Metal engine, which loads the original mxfp4 weights (no re-quantization). If it scores inside the ds4 reference band, that isolates the remaining mlx-lm gap to quantization choice rather than schedule/attention implementation — and if it doesn't, that's a strong signal the gap is architectural. Happy to share results either way. If the tests get folded in here, we'll close #1463. |
GLM-5.2 (
glm_moe_dsa) adds a per-layer DSA indexer schedule with cross-layer top-k sharing that the current baredeepseek_v32subclass doesn't implement. This PR adds the schedule +prev_topk_indicesthreading, gives shared layers no indexer cache (fixes aKVCache.statecrash), and adds a model test. Defaults reduce to plaindeepseek_v32. Validated by FP8→mxfp4 conversion + short (TP) and 16K-token (single-node) generation. Full report in the comment below.Disclosure: produced with the transformers-to-mlx skill (AI-assisted), human-driven and reviewed.
Test model: mlx-community/GLM-5.2-mxfp4