Skip to content

GLM-5.2 (glm_moe_dsa) inference: IndexShare, indexer RoPE/eps, int8 MLA-KV - #1419

Open
avlp12 wants to merge 7 commits into
ml-explore:mainfrom
avlp12:glm-5.2-dsa-inference
Open

GLM-5.2 (glm_moe_dsa) inference: IndexShare, indexer RoPE/eps, int8 MLA-KV#1419
avlp12 wants to merge 7 commits into
ml-explore:mainfrom
avlp12:glm-5.2-dsa-inference

Conversation

@avlp12

@avlp12 avlp12 commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Resolves #879 — adds inference support for GLM-5.2 (model_type: glm_moe_dsa), Zhipu/Z.ai's 744B (~40B active) MoE. It is architecturally DeepSeek-V3.2 (MLA + DSA "lightning indexer" sparse attention), so this extends the existing deepseek_v32.py rather than adding a new model.

What was missing

The stock glm_moe_dsa/deepseek_v32 port runs the DSA indexer on every layer and threads no indices between layers. GLM-5.2 computes top-k on a "full" layer and reuses it on the following "shared" layers (IndexShare). At ≤2048 ctx this is invisible (top-k selects all tokens → dense attention), but beyond that the port diverges from the reference and forfeits the sparse-attention savings.

Changes (inference only)

  • IndexSharederive_indexer_types() marks layers full/shared from index_topk_freq; "full" layers run the indexer, "shared" layers reuse the previous full layer's top-k (threaded through the decoder). index_topk_freq=1 (the default) makes every layer "full", i.e. stock DeepSeek-V3.2 behavior is unchanged.
  • Indexer RoPE / eps — GLM-5.2's indexer uses non-interleaved (half-split) RoPE + LayerNorm eps 1e-6, distinct from its interleaved main attention (verified against the HF reference to ~1e-7 on post-RoPE q). deepseek_v32 defaults are unchanged (traditional=True, eps 1e-5); glm_moe_dsa overrides them.
  • int8 MLA latent KV cacheMLACacheList (a CacheList subclass used only by these models) quantizes only the large compressed-latent cache and keeps the tiny DSA indexer cache full precision; the latent is dequantized on read. This lets a 1M-token context fit on a single machine. The generic CacheList is left untouched, so other hybrid-cache models are unaffected.
  • KVCache/ChunkedKVCache.state tolerate an unwritten sub-cache (the indexer KV on shared layers) so mx.eval(cache.state) during generation doesn't dereference keys=None.
  • glm_moe_dsa ModelArgs defaults the fields GLM-5.2's config.json omits.

Tests

  • test_all_models gains a glm_moe_dsa config with index_topk_freq=4 (exercises full + shared layers, fp32/fp16 prefill, decode, batch, deepcopy).
  • New test_glm_moe_dsa_quantized_kv covers the int8 latent-KV path: only the latent cache is quantized, the indexer cache stays fp16, and decode after quantization stays finite.

All existing deepseek_v32 tests still pass (defaults preserve V3.2 behavior).

Scope

Inference + KV-cache only. No training / AWQ / DWQ or distributed/pipeline changes.

🤖 Generated with Claude Code

Alis Volat Propriis and others added 2 commits June 21, 2026 23:12
…LA-KV

Adds inference support for GLM-5.2 (model_type glm_moe_dsa), a
DeepSeek-V3.2-style MLA + DSA sparse-attention MoE:

- IndexShare: the DSA indexer runs only on "full" layers; "shared"
  layers reuse the previous full layer's top-k (index_topk_freq).
  derive_indexer_types() defaults to all-"full" (index_topk_freq=1),
  preserving stock DeepSeek-V3.2 behavior.
- Indexer RoPE/eps: GLM-5.2 uses non-interleaved RoPE + LayerNorm
  eps 1e-6. deepseek_v32 defaults are unchanged (traditional RoPE,
  eps 1e-5); glm_moe_dsa overrides them.
- int8 MLA latent KV cache via MLACacheList (a CacheList subclass used
  only by these models): quantizes only the compressed-latent cache,
  keeps the tiny DSA indexer cache full precision; the latent is
  dequantized on read. Lets a 1M-token context fit on one machine.
- KVCache/ChunkedKVCache.state tolerate an unwritten cache (the indexer
  KV on shared layers) so mx.eval(cache.state) doesn't deref keys=None.
- glm_moe_dsa ModelArgs defaults the fields GLM-5.2's config omits.

Adds a glm_moe_dsa entry to test_all_models and a test for the int8
latent-KV path. Resolves ml-explore#879.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GLM IndexShare leaves the DSA indexer KV empty (keys=None) on "shared"
layers. batch_generate (used by mlx_lm.evaluate and batched inference)
converts each cache to BatchKVCache and assumes every cache is written,
which crashes for glm_moe_dsa / deepseek_v32 with index_topk_freq > 1:

- BatchKVCache.state getter/setter: tolerate keys=None (empty state).
- BatchKVCache.extract: return an empty per-sequence cache when unwritten.
- dynamic_roll: no-op on a None array (left/right padding path).
- generate.py BatchStats: guard prompt/generation tps against zero time.

Adds test_batch_kv_cache_unwritten covering the four guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@avlp12

avlp12 commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

Added a follow-up commit extending support to batch_generate (used by mlx_lm.evaluate and batched inference). GLM IndexShare leaves the DSA indexer KV empty (keys=None) on "shared" layers; batch_generate converts each cache to BatchKVCache and assumed every cache is written, crashing for glm_moe_dsa/deepseek_v32 with index_topk_freq > 1. The commit adds None-guards to BatchKVCache.state/extract, dynamic_roll, and the BatchStats tps divisions, plus a unit test. Same bug class as the single-stream KVCache/ChunkedKVCache guards already in this PR.

@avlp12

avlp12 commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Real-world confirmation that this PR is required to load IndexShare-quantized GLM-5.2 checkpoints with stock mlx-lm:

omlx 0.4.5.dev1 bundles stock mlx-lm@2ed2231 (pre-IndexShare). Loading the published avlp12/GLM-5.2-Alis-MLX-Dynamic-3.5bpw (78 layers; DSA indexer stored only on the 21 "full" layers [0,1,2,6,10,14,…,74], the other 57 reuse via IndexShare) fails at weight load:

ValueError: Missing 285 parameters:
model.layers.11.self_attn.indexer.k_norm.bias,
model.layers.11.self_attn.indexer.k_norm.weight,
model.layers.11.self_attn.indexer.weights_proj.weight,
model.layers.11.self_attn.indexer.wk.weight,
model.layers.11.self_attn.indexer.wq_b.weight,
model.layers.12.self_attn.indexer. ...

That's exactly 57 shared layers × 5 indexer params = 285 — stock mlx-lm builds the DSA indexer on every layer, so it expects indexer weights that IndexShare deliberately omits on shared layers. The IndexShare change in this PR is what makes these checkpoints loadable on stock mlx-lm.

Happy to rebase onto current main if that helps move this along.

Alis Volat Propriis and others added 5 commits July 6, 2026 12:04
Restore GLM-5.2's built-in MTP layer (model.layers.78) instead of stripping
it, and use it for lossless self-speculative decoding:

- deepseek_v32: sanitize() keeps + remaps layer-num_hidden_layers into an
  MtpModule when num_nextn_predict_layers > 0 and the checkpoint retains the
  weights (otherwise strips as before). Model.mtp_forward() (causal-masked
  for multi-token calls), has_mtp, make_mtp_cache; backbone stashes the
  pre-final-norm hidden (h_prenorm) that the MTP head consumes.
- generate: mtp_speculative_generate_step — chunked prefill of both caches,
  k chained drafts feeding back the shared_head-NORMED hidden (chaining the
  raw hidden halves chained acceptance: 0.27 -> 0.75 conditional measured),
  sampler-equality acceptance (distribution-lossless at any temperature),
  optional conservative prompt-lookup drafting (--mtp-hybrid), and a
  single-sync pipelined loop (next drafts+verify enqueued before yielding so
  detokenizer/wrapper Python overlaps GPU). CLI: --mtp,
  --mtp-num-draft-tokens, --mtp-hybrid.
- server: same flags; MTP requests use fresh caches and skip prompt-cache
  reuse/store (the spec loop can exit with uncommitted tail entries);
  batching disabled in MTP mode.
- Also syncs the serving stack these changes build on (previously local):
  int8 MLA-KV moved into cache.py CacheList, DeepseekV32 layer-signature
  cleanup, optional MLX_LM_EVAL_EVERY_LAYERS command-buffer splitting.

Measured (GLM-5.2 744B 3.5bpw, M3 Ultra 512GB, greedy, single request):
22.0 -> 24.6 tok/s (~1.11x), mean accepted length ~2.5, per-position
acceptance ~0.88/0.75/0.58; greedy output verified identical to
non-speculative decoding, tiny-model e2e (loader/prefill-equivalence/
lossless k=1..3/hybrid/legacy-strip) all pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MTP drafting is validated in the dense-attention regime only. Past
index_topk tokens the DSA sparse path engages and MTP acceptance
collapses (index_skip_topk_offset handling is not wired yet) — measured
3x slower than plain decoding at a 2190-token prompt. stream_generate
now falls back to generate_step with a stderr note instead of
degrading.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The earlier 3x slowdown that motivated the guard (49c5505) was
misdiagnosed: per-position acceptance in the DSA-sparse regime is fine
(measured 0.73-0.82 first-draft at 2750-token context, accept-len
2.1-2.3 end-to-end), and index_skip_topk_offset turned out to be a
config-schedule parameter (indexer_types derivation), not an MTP
inference knob — it is already honored via the explicit indexer_types.

The observed slowdown decomposed into (a) benchmark-host swap pressure
and measurement ordering, and (b) the real-but-modest cost of the L>1
sparse-mask verify path (~2.4x a plain step for a 3-token verify),
which roughly offsets the accepted length: long-context MTP is
approximately performance-neutral, not harmful. A future win is
extending the L==1 gather path to small-L verifies so verify cost
stops scaling with context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…text MTP)

At context > index_topk, L<=4 forwards (MTP verify, tiny continuations)
previously took the L>1 sparse-mask path: per-position argpartition +
put_along_axis boolean masks + attention over the FULL key set, on every
layer. Amortized over a 2048-token prefill chunk that is fine; paid per
speculative-decode iteration it dominated everything — measured 0.26x
plain decode at a 2.1K-token context (accept-len 1.8 held; the cost was
pure verify overhead, ~7 plain-steps per iteration).

L<=4 sparse forwards now gather each position's top-k keys (like the
existing L==1 branch) and run batched-matmul attention with k = v =
latent + unembed after; gathered scores are re-masked for the
threshold-boundary case where a position has fewer than K causally
valid keys. Equivalence vs the mask path verified at 1e-06 on a tiny
sparse model (L=2/3/4); tiny MTP e2e (lossless k=1..3, hybrid, chunked
prefill, legacy strip) all pass. MLXLM_NO_SMALL_L_GATHER=1 restores the
old path.

Measured (GLM-5.2 4-bit sibling, M3 Ultra 512GB, 2146-token context,
prefill-separated timing): mtp k=2 decode 5.35 -> 17.38 tok/s
(0.26x -> 0.85x of plain); k=1 0.98x; short-context k=2 unchanged at
1.15x. Remaining long-context gap is acceptance economics (accept-len
1.8 vs ~2.1 break-even), not path cost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
self.h_prenorm (added for MTP) was a public mx.array attribute, so it
joined Module.parameters() after the first forward — tree_flatten-based
save flows then wrote it into checkpoints, which fail strict loading
("Received 1 parameters not in model: model.h_prenorm"). Renamed to
_h_prenorm (underscored attributes stay private in MLX modules); caught
by a quantize->save->load round-trip test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add model support for GLM-5 (glm_moe_dsa architecture)

2 participants