Repository navigation
GLM-5.x: faster prefill and decode (sparse attention, fused indexer and MoE kernels) - #2539
Open
AlexCheema wants to merge 4 commits into
Open
AlexCheema wants to merge 4 commits into
AlexCheema wants to merge 4 commits into
Conversation
GLM-5.x attention (dsa_attention.GlmAttention) computes mlx-lm's GlmMoeDsaAttention restructured, with mlx-lm's code as the fallback where Metal kernels can't run: - Dense prefill scores each head's 192 no-rope and 64 rope dimensions in one 256-wide product with a boolean mask, instead of a separate rope-part product materialised as an additive float mask over every head, query and key. - Sparse (top-k) prefill starts at 8192 keys of context instead of 16384 (where it overtakes dense attention on a Mac Studio, M3 Ultra), gathers the selected keys with 16-byte vector copies (3x faster than mx.take here), concatenates the latent keys once per call and runs the value up-projection once for all queries. - Single-token decode multiplies all heads with the shared latent keys as one product, instead of one matrix-vector product per head (8% faster decode at batch 8). - The DSA indexer sums relu(q.k) * w over its heads in float32 in one pass (a Metal kernel), instead of 31 rounded bfloat16 additions of [L, N] tensors: 5x faster at 16k keys, and its top-2048 selection matches a float32 indexer. - The router weight is stored in float32 once (sanitize) instead of cast on every call. 8 real layers of mlx-community/GLM-5.3-4bit on a Mac Studio (M3 Ultra): prefill of a 16k-token prompt 11.8 s -> 8.4 s, 4k 1.83 s -> 1.75 s; decode unchanged at batch 1. Teacher-forced against transformers in float32 (same weights): mean KL 6.1e-5 -> 5.8e-5, top-1 0.9669 -> 0.9691 over 31k positions; the paired gate passes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GLM-5.x decode runs ~25 small dependent kernels per layer, and on a Mac Studio (M3 Ultra) each costs a few microseconds of GPU latency on top of the weight reads. Two chains of the MoE become single kernels: - kernels.router_select replaces mlx-lm's group_expert_select for one expert group (sigmoid, bias, top-k by sorting all experts, gather, normalise, scale) when the router has a multiple of 32 experts (GLM-5.x: 256, top 8). - kernels.moe_combine adds the experts' weighted outputs (float32) and the shared expert's output with mlx-lm's roundings (bit-identical in the unit test), where mlx-lm runs a multiply, a sum, a cast and an add. The MoE is layers.GlmMoE. The attention module moves from dsa_attention.py to layers.py with the MoE. 8 real layers of mlx-community/GLM-5.3-4bit on a Mac Studio (M3 Ultra), batch 1: decode 5.55 -> 5.38 ms/token at 512 tokens of context, 6.17 -> 5.99 ms at 4k (both commits). Teacher-forced against transformers in float32: mean KL 5.7e-5, top-1 0.9688 (before this branch 6.1e-5, 0.9669); the paired gate passes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…l kernel In a sequence's first prefill chunk, a query at a position below index_topk (2048) sees fewer keys than the indexer keeps, so its selection is every earlier key: plain causal attention. Those queries now go through mx.fast.scaled_dot_product_attention with a causal mask over the first 2048 keys (MLX's fused kernel, which skips the masked half), and only the later queries take the dense path with the selection mask. 8 real layers of mlx-community/GLM-5.3-4bit on a Mac Studio (M3 Ultra): prefill of a 4095-token prompt 1.72 s -> 1.53 s, 3000 tokens 1.18 s -> 1.06 s, 8191 tokens 3.99 s -> 3.79 s. Same selection; teacher-forced against transformers in float32 the paired gate passes (mean KL 5.7e-5, top-1 0.9691), and prompts shorter than 2048 tokens are bit-identical. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The sparse prefill path scales each [1024 queries, 64 heads, 2048 keys] block of scores in float32, masks it and takes the softmax: four passes over half a gigabyte. Compiled, the element-wise steps fuse around the softmax. Bit-identical outputs. 8 real layers of mlx-community/GLM-5.3-4bit on a Mac Studio (M3 Ultra): prefill of a 16k-token prompt 8.24 s -> 7.94 s, 32k 17.15 s -> 16.62 s. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AlexCheema
force-pushed
the
models/glm-5.3
branch
from
November 1, 2026 16:20
7e19c94 to
8a4ef14
Compare
AlexCheema
force-pushed
the
perf/glm-5.3
branch
from
November 1, 2026 18:26
774da2e to
f731ee7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Builds on #2512 (GLM-5.3), now merged into main.
Summary
GLM-5.2/5.3 (
glm_moe_dsa) is DeepSeek sparse attention (MLA with a DSA indexer shared across layers) and a 256-expert MoE. This PR speeds up its attention and MoE in exo without changing the weights, the quantization or what the model computes. Every change keeps the float32 paths the model PR needed, and two of them compute in float32 where mlx-lm rounded to bfloat16.The work is in the GLM family folder (
models/glm/):glm_moe_dsa.py, and two new modules,layers.pyandkernels.py, with type stubs and tests. Where the Metal kernels can't run (no Metal GPU as the default device), every call goes to mlx-lm's own code.Prefill
mx.takefor the [1024, 2048, 576] gather.Decode
Where the time goes
Measured on the first 8 layers of
mlx-community/GLM-5.3-4bit(real weights), Mac Studio (M3 Ultra), by zeroing the attention or the MLP. Times are for the 8 layers.Before and after on the same 8 layers. This is mlx-lm's generation loop, with exo's chunking (4096-token chunks) and exo's batch caches for decode. Median of 2 interleaved rounds, after a warm-up:
Batched decode at 4k tokens of context: 4 sequences 14.02 → 13.40 ms per step, 8 sequences 23.30 → 21.13 ms.
Performance
Full model,
mlx-community/GLM-5.3-4bit(418 GB), through exo's API, on M3 Ultra Mac Studios (512 GB), the same machines before and after. The harness perf runner generates 128 tokens per request, with a 3 s cooldown between requests. Builds were interleaved before, after, before, after: 2 rounds, each with a warm-up and then 2 requests per row (2 batches for the concurrent rows). The rounds are pooled. TTFT is the median [min–max].Single device
Two Mac Studios: 2x tensor parallel over RDMA, 2x pipeline parallel over the ring (the same protocol, both configurations in every round, exo's own placement):
Long context, 2x TP, 32,768-token prompt (2 interleaved rounds per build, 2 requests each after a warm-up): TTFT 140.53 s [140.52–140.61] → 124.96 s [124.87–125.05], prefill 236.1 → 266.0 tok/s (1.13×), decode 20.0 → 21.3 tok/s (1.06×), MLX peak 242.2 / 242.6 GB per node.
Correctness
Verdict: PASS. This PR stays as close to the float32 reference as before, and past 2048 tokens it gets slightly closer, because the indexer now sums its scores in float32.
Teacher-forced against transformers in float32 (paired gate)
Setup, the same as #2512's:
mlx-community/GLM-5.3-4bit(real weights; indexer layers 0, 1, 2 and 6, shared layers 3, 4, 5 and 7).By prompt, past 2048 tokens (top-1, mean KL):
exo after against exo before (same positions), mean KL 4.5e-5 to 7.4e-5 and top-1 0.957 to 0.967. That is the size of bf16 rounding noise; the noise floor above is the same size against float32. Where it comes from:
Distributed (8 layers, two ranks on one Mac Studio over the TCP ring)
Full model, one Mac Studio
mlx-community/GLM-5.3-4biton one M3 Ultra Mac Studio. Everything below except the teacher-forced check goes through exo's API.Teacher-forced, before against after (in-process on one Mac Studio, exo's 4096-token prefill chunks and the last 64 tokens through single-token decode, the same four prompts as the 8-layer gate). There is no float32 reference for the full 418 GB model, so this compares the two builds directly:
At 78 layers, any bfloat16-level change moves the full model this much: #2512 measured 2.8e-3 to 6.3e-3 mean KL below 2048 tokens between origin/main and #2512 on the full model, where only the float32 router, an epsilon and the MLX version differ. On the 8 layers, where a float32 reference exists, the same change moves the model toward the reference (above).
Temperature-1 samples: 17 chat prompts × 6 samples per build through the API, 192 tokens at most, temperature 1, top_p 1, no top-k or min-p. The prompts cover code, math, multilingual, multi-turn, JSON and thinking. Both sample sets are then scored by one fixed scorer, the before build in-process, on the exact prompts the API rendered:
Two-sample KS test on the per-token NLL: D = 0.011, p = 0.31. No difference between the two builds' sampling distributions is detectable.
GSM8K (first 100 test items, 2 seeds, the card's sampling, thinking off):
The difference is 0.000, 95% CI [−0.015, +0.015].
End to end on the after build:
stop.get_weatherwith valid JSON arguments, finished withtool_calls.Long-context needle on two Mac Studios (after build), with the card's sampling: 4.5k and 16k tokens, 2 needles each, 2 trials (the second trial reuses exo's prompt cache):
Test plan
uv run basedpyright(0 errors),uv run ruff check,nix fmt(no changes), andpytest src: 1929 passed, 3 skipped. The image-generation tests were left out because they need torch, and this PR doesn't touch them.models/glm/tests/test_glm_attention_paths.py:index_topk, sparse prefill, batched decode with left padding, and the fallback off Metal (bit-identical to mlx-lm).group_expert_select) and the expert combine (bit-identical).test_auto_parallel.py) pass with the new attention.🤖 Generated with Claude Code