glm_moe_dsa: sparse prefill over the indexer's top-k keys

1 hour ago

This pull request makes long prompts much faster to prefill on GLM models with DeepSeek sparse attention. The indexer already picks the top-k keys for each query. Now prefill attention reads only those keys, gathered from the cache, instead of masking all the others. It's on by default once the cache holds about 16,000 tokens.

Ask about this presentation

Chapters

  1. 0:00Sparse prefill for GLM
  2. 0:22The dense mask
  3. 0:441 · How it works
  4. 0:46When it runs
  5. 1:09Sparse attention
  6. 1:38Blocked top-k
  7. 2:04What it touches
  8. 2:252 · Evidence
  9. 2:26Prefill speed
  10. 2:57Peak memory
  11. 3:28Accuracy and tests
  12. 3:57Limits and what to do
Show transcript

Sparse prefill for GLM

The changeHow it worksEvidence

GLM prefill now attends only to the keys its indexer picks

sparse_prefill · on by default from 16,384 cached tokens
MLX

This pull request makes long prompts much faster to prefill on GLM models with DeepSeek sparse attention. The indexer already picks the top-k keys for each query. Now prefill attention reads only those keys, gathered from the cache, instead of masking all the others. It's on by default once the cache holds about 16,000 tokens.

The dense mask

The changeHow it worksEvidence

Prefill picked the top-k keys, then attended to every key anyway

N keys in the cache
dense [L, N] boolean mask · black = picked, gray = attended anyway
Grows with context: the mask and the expanded K/V scale with N.

Before this change, prefill turned the indexer's selection into a dense boolean mask, one row per query and one column per key, and then ran masked attention over every key. In the author's words, the selection is computed, then the work is spent on all N keys anyway. The mask and the expanded keys and values also grow linearly with context.

1 · How it works

1

How the sparse path works

Here's how the sparse path works.

When it runs

The changeHow it worksEvidence

Two config fields decide when the sparse path runs

class ModelArgs(BaseModelArgs):
    sparse_prefill: bool = True
    sparse_prefill_min_context: int = 16384

# GlmMoeDsaAttention.__call__
sparse = (
    self.sparse_prefill
    and B == 1
    and L > 1
    and kv_latent.shape[2] >= self.sparse_prefill_min_context
)
One sequenceBatches keep the masked dense path.
Prefill onlyDecode is untouched.
16,384 cached tokensBelow that, dense is faster.

Two new fields on ModelArgs control it. Sparse prefill is on by default, with a minimum context of 16,384 tokens. It only runs for a single sequence, so batches keep the masked dense path. It only runs in prefill, so decode is untouched. And below the minimum context the dense path is faster, so the code keeps it there.

Sparse attention

The changeHow it worksEvidence

Attention now costs L × topk per chunk, down from L × N

Latent + rope cache N rows, 576 wide = 512 latent + 64 rope
Gathered [L, K, 576], shared by all heads
Attention absorbed latent space, as in decode
O(L · topk)
O(L · N)
Chunks longer than 1,024 queries run in query blocks of _SPARSE_QUERY_BLOCK = 1024.

The attention half is a new function, sparse prefill. It joins the latent and rope caches into one 576-wide key per position, then gathers just the top-k rows each query picked. All heads share that gathered block. Attention runs in the absorbed latent space, the same form decode already uses, so each chunk costs L times top-k instead of L times N. Chunks longer than 1,024 queries split into query blocks, which keeps the gathered keys small.

Blocked top-k

The changeHow it worksEvidence

The indexer scores 4,096 keys at a time and keeps a running top-k

keys 0–4,095
4,096–8,191
8,192–12,287
12,288–16,383
…
running top-kargpartition over topk + 4,096, not N
The full score tensor [B, 32, L, N] is no longer built.

The indexer gets the same treatment, in a function called blocked top-k. It scores the keys in blocks of 4,096 and folds each block into a running top-k list, so each argpartition sorts rows of top-k plus 4,096 instead of N. The full score tensor, batch by 32 heads by L by N, is no longer built. It selects the same keys as the stock indexer, up to the order of equal scores.

What it touches

The changeHow it worksEvidence

Two files carry the change

mlx_lm/models/glm_moe_dsa.py+100 −1
tests/models/test_glm_sparse_prefill.py+175
pyproject.toml · adds pillow+1
.pre-commit-config.yaml · black 26.10.1+1 −1
tests/__init__.py, tests/models/__init__.py+1 each
4 more files under tests/models/+0 −0

Almost all of it lands in two files: the model file, with about 100 added lines, and a new test file of 175 lines. The rest rides along. Pillow joins the package dependencies, the black pre-commit hook gets a version bump, and two init files turn the test folders into packages.

2 · Evidence

2

The evidence

Now the evidence.

Prefill speed

The changeHow it worksEvidence

At 128K context, prefill runs about 8× faster

8.33×

Prefill throughput at 128K context, 512-token chunks: 11.5 tok/s baseline, 96.0 tok/s patch.

With mlx_lm.benchmark: 32K prompt 87.3 to 159.3 tok/s (1.82×); 64K prompt 51.6 to 142.2 tok/s (2.76×).

11.5 to 96.0 tok/s at 128K.
Decode: 1.00× at every depth.

M3 Ultra, 512 GB · MLX 0.32.3
mlx-community/GLM-5.3-4bit
mean of 3 runs after 1 warmup

This is an M3 Ultra running GLM 5.3 at 4-bit, with 512-token prefill chunks. Black is the patch, and the dashed gray line is the baseline. Up to 4,000 tokens of context the two match, because the gate keeps the dense path. From 16,000 on, the baseline falls off, and the patch slows far more gently. At 128,000 tokens, prefill goes from about 12 to 96 tokens per second, roughly eight times faster. Decode speed is unchanged.

Peak memory

The changeHow it worksEvidence

Peak memory at 128K falls from 482.5 GB to 437.5 GB

contextbaselinepatch
0420.0 GB420.0 GB
16K428.3 GB426.3 GB
32K437.3 GB427.9 GB
64K450.8 GB431.1 GB
128K482.5 GB437.5 GB
peak during the prefill chunk, 1,024-token chunks
2,048-token chunks at 128K
Baseline: skipped, predicted above the GPU working set
Patch: 440.5 GB

Memory follows the same curve. With 1,024-token chunks, the baseline's peak climbs fast with context, and the patch's climbs slowly. At 128,000 tokens it drops from about 480 to about 440 gigabytes. With 2,048-token chunks at 128,000 tokens, the baseline was skipped because its predicted peak was above the GPU working set. The patch ran it, peaking at about 440 gigabytes.

Accuracy and tests

The changeHow it worksEvidence

Sparse prefill keeps the stock top token at almost every position

98.68%

top-1 agreement with stock
mean KL divergence 0.0046

12,288-token prompt, on M3

8 new tests, 3 fail without the change
test_default_is_on
test_sparse_prefill_matches_dense
test_blocked_topk_matches_indexer
test_short_context_is_unchanged
test_batch_uses_dense_path
test_min_context_uses_dense_path
test_query_blocks_match
test_decode_after_sparse_prefill

Output stays close to stock. On a prompt of about 12,000 tokens, mean KL divergence is 0.0046, and the top token matches stock at almost 99 of every 100 positions. Eight new tests cover the path. Sparse output matches dense within a ten-thousandth, batches and short or gated contexts take the dense path unchanged, query blocks match, and decode works after a sparse prefill. Three of them fail without this change.

Limits and what to do

Long GLM prompts prefill faster, one sequence at a time

Batches still take the masked dense path.
DeepSeek models with DSA are a planned follow-up, with a smaller expected gain.

On by default. To turn it off, set "sparse_prefill": false in the model config.

github.com/ml-explore/mlx-lm/pull/1997

Two limits. Batches still take the dense path, and DeepSeek models with sparse attention aren't covered yet. They're planned for a follow-up, with a smaller expected gain, since their indexer runs on every layer. It's on by default, and setting sparse prefill to false in the model config turns it off. GLM prefill now attends only to the keys its indexer picks.

What happened in AI today, in your inbox

One short email each evening. Read it and you’re caught up.

Built in New York with ❤️