Skip to content

Fix deepseek_v32 Indexer evicting attention sinks from sparse top-k - #1552

Open
robertlangdonn wants to merge 2 commits into
ml-explore:mainfrom
robertlangdonn:fix-deepseek-indexer-attention-sinks
Open

robertlangdonn wants to merge 2 commits into
ml-explore:mainfrom
robertlangdonn:fix-deepseek-indexer-attention-sinks

Conversation

@robertlangdonn

@robertlangdonn robertlangdonn commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Fix deepseek_v32 Indexer evicting attention sinks from sparse top-k

Summary

Problem

Indexer.__call__ ranks all key positions by a learned score and keeps only the top index_topk via argpartition. Nothing guarantees the first few positions (attention sinks, Xiao et al. 2023, StreamingLLM) survive that ranking — if the router's raw score for a sink is low relative to other keys, it drops out. Once even one sink is evicted across enough of the ~60+ layers in a real model, the softmax redistributes onto irrelevant keys and generation collapses.

Reproduced directly against Indexer (no checkpoint needed) with a synthetic sequence containing a "distractor" block that outscores the sink columns on raw dot-product alone — pre-fix, the last query's top-k drops 3 of 4 sink columns.

Fix

Force sink columns + a small recency window into the selection before argpartition, by setting their score to +inf:

n_sinks, local_window = 4, 128
...
force_keep = ((key_pos >= sink_start) & (key_pos < sink_start + n_sinks)) | (
    (key_pos <= query_pos) & (key_pos > query_pos - local_window)
)
scores = mx.where(force_keep[:, None], mx.array(float("inf"), scores.dtype), scores)

Non-causal picks for early prefill rows are harmless — the caller ANDs this selection with the real causal mask (sparse_mask & mask) before it reaches attention.

offset/left_padding are folded in with an explicit batch axis rather than assumed scalar, because under BatchKVCache (batched generate()) both are per-sequence arrays, and "sink" means the first real tokens of each sequence — not buffer column 0 once left-padding shifts them.

Total change: +31 in mlx_lm/models/deepseek_v32.py.

Test plan

  • New regression test test_deepseek_v32_indexer_keeps_attention_sinks — synthetic single-sequence case with a distractor block, asserts all 4 sinks survive top-k (fails pre-fix, passes post-fix).
  • New regression test test_deepseek_v32_indexer_keeps_padded_batch_sinks — batched BatchKVCache with per-sequence left-padding, asserts each sequence's real sinks (offset by its own padding) survive.
  • Full suite: python -m unittest discover tests/ — 188 tests, only pre-existing unrelated import errors (datasets/lm_eval/requests, optional deps not installed locally).
  • End-to-end sanity: full deepseek_v32.Model (tiny synthetic config), prefill past index_topk then 5 decode steps — runs cleanly, exercises the L==1 decode path and growing cache offset.
  • pre-commit run --files mlx_lm/models/deepseek_v32.py tests/test_models.py — clean.

Once a sequence exceeds index_topk, the DSA Indexer's learned top-k
selection does not reliably keep the first few key positions. Losing
these attention sinks (Xiao et al. 2023) causes the softmax to
redistribute onto irrelevant keys, and decode collapses into
repetition/garbage exactly at the index_topk boundary. Affects
deepseek_v32 and glm_moe_dsa (which reuses this Indexer).

Force sink columns and a small recency window into the selection
before argpartition. Non-causal picks for early prefill rows are
harmless since the caller ANDs this selection with the real causal
mask before use.
The previous commit assumed offset was a scalar. Under BatchKVCache
(batched generate()), offset and left_padding are per-sequence arrays,
and "sink" means the first real tokens of each sequence, not buffer
column 0 once left-padding shifts them. Fold both into the position
math with an explicit batch axis instead. Adds a regression test for
the batched + left-padded case alongside the single-sequence one.
@robertlangdonn
robertlangdonn force-pushed the fix-deepseek-indexer-attention-sinks branch from 4ee25f2 to a37627b Compare July 11, 2026 21:28
pierre427 pushed a commit to pierre427/mlx-lm that referenced this pull request Jul 13, 2026
snapkv_keep_indices already staged sinks + recent window into the keep set
before the score-ranked fill, but capped the sink floor at max(1, budget//8),
so a tight budget silently kept fewer than sink_tokens sinks. Dropping an
attention sink collapses decode into repetition -- the same failure mlx-lm
ml-explore#1552 fixed for DeepSeek's DSA indexer top-k selection. Add a default-on
guarantee_sinks guard that clamps the sink floor only by budget/seq_len, so
the first N sinks (and the recent window) can never be evicted no matter how
high a middle row scores. guarantee_sinks=False restores the old capped
behaviour. Existing callers use budget>=32 (cap>=4) so behaviour is unchanged
there; the guard only bites when budget//8 < sink_tokens.

Adds regression tests: N sinks + recency survive budget=16 with every middle
row scored 1e9, and the flag toggles the floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@stepnoy

stepnoy commented Aug 13, 2026

Copy link
Copy Markdown

Independent verification on glm_moe_dsa (GLM-5.2): fix confirmed

This PR states it affects glm_moe_dsa as well; here is quantitative confirmation on that second model, on hardware unrelated to the original report.

Environment: Mac Studio M3 Ultra 512 GB, macOS 26.6.1, mlx-lm 0.31.3 + this PR's deepseek_v32.py, mlx 0.32.0. Model: GLM-5.2 Q4 MLX community build (index_topk: 2048), sampling per vendor recommendation (temp 1.0, top_p 0.95).

Method: short prompt (~51 tokens), 3200-token generation, 4-gram repetition ratio per 250-token block — so the sequence crosses index_topk mid-generation.

total tokens stock 0.31.3 with this PR
≤ ~1000 0% 0%
~1176–1926 3–12% 0–1%
~2176 (crosses 2048) 13% 1%
~2426 26% 4%
~2676 22% 9%
~2926 67% 0%
~3176 78% 0%

Stock degenerates into full repetition right past the threshold (tail: the model arguing with itself in a two-phrase loop); with the patch the essay stays coherent to the end.

Dense-attention control: the same generation through llama.cpp (GGUF build of the same model family, dense attention, same sampling) is 0% in every block — which rules out the checkpoint and sampling, and matches the sink-eviction mechanism: the failure belongs to the sparse path exactly at its activation boundary.

One methodological note that may help reviewers: needle-in-haystack tests at 6–8K prompt tokens pass on stock — prefill is not where this bites, which is presumably why it survived so long. A repetition profile of a long generation across the threshold catches it in one run.

Would be great to see this merged — the same failure signature is currently being re-discovered model by model (cf. the DeepSeek-V4 thread in #1189).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

deepseek_v32 Indexer sparse top-k evicts attention sinks → decode collapses past index_topk

3 participants