Skip to content

feat(MODEL-MM-QWEN4-EXP): W4 — Qwen Sparse Attention with a GATHER consumer, counted at the key-row read - #2030

Merged
localai-bot merged 6 commits into
mainfrom
row/MODEL-MM-QWEN4-EXP-W4
Aug 27, 2026
Merged

feat(MODEL-MM-QWEN4-EXP): W4 — Qwen Sparse Attention with a GATHER consumer, counted at the key-row read#2030
localai-bot merged 6 commits into
mainfrom
row/MODEL-MM-QWEN4-EXP-W4

Conversation

@localai-bot

Copy link
Copy Markdown
Collaborator

Qwen Sparse Attention: the indexer, its side cache, and a gather consumer.

The consumer shape is the point of this wave. llama.cpp #27739 records, with the
mechanism named, that a sparse mask over a dense cache costs the same as dense
attention under CUDA flash attention, because flash_attn_mask_to_KV_max scans
backwards and stops at the first tile that is not all -inf — so a mask that keeps any
late key pays the whole dense prefix. #27742 is mask-only and therefore buys
correctness without decode speed. A mask-shaped port here would pass a token gate and
silently forfeit the long-context lever the row exists to preserve.

QSA maps onto DeepSeek-V4's C4 indexer lane, not MiniMax-M3, and the first reading
of this row said the opposite. That reasoning rested on MLAAttentionSpec being an MLA
claim; it is not — M3's own indexer cache uses it while M3 is plain GQA, with the
comment "Key-only: MLAAttentionSpec budgets one vector/token (not 2x for K+V)". Remove
that prop and nine structural matches with DSv4 remain, compress_ratio == 4 literally
the same number. Two things are deliberately NOT inherited: DSv4's weights_proj, for
which QSA has no tensor (its weight is the constant 1/sqrt(128)), and its
GPT-J-style trailing-span RoPE, where QSA uses interleaved mRoPE over the leading
64 dims — the halves are swapped end for end.

The first review FAILED this change, and the finding is the one worth recording.
keys_visited was assigned sel.size(), and the test compared it to
SelectedCount(sel) — the same quantity, from the same input, by the same rule. The
assertion observed the index buffer and never the walk. The proof is a mutation that
computes a dot product against every one of the kv_len cached rows, i.e. exactly
QsaMaskedAttention's cost, while still reporting the sparse figure: it passed 12
cases / 7251 assertions
, including the case named "the GATHER touches only the
selected rows". The code was a real gather; nothing in the tree kept it one.

The counter is now incremented at the point of the key-row read, in both consumers.
The mask was made counted too — leaving it assign-not-count would have put the two
counters in different units and made the comparison between them meaningless. That
mutation is now RED, as is one that merely reads each selected row twice, which the
old instrument also could not see because it was not a read count even for the correct
implementation.

The instrument's limits are declared rather than implied, and the re-review
measured both. A walk-and-skip consumer that iterates all positions without reading the
unselected key rows stays green: the cost #27739 measures is the QK dot and the K/V
tile traffic, and a per-row predicate is about one byte against 256 bytes of key
traffic, so a counter firing on it would be measuring a loop index — the defect the old
counter had. And no in-function instrument can refuse a fabricated number: a dense
body with a hand-written sparse assignment is green by construction. Both are stated in
the header.

Four more gate defects the review found, all closed. The sub-budget "bit-identical to
dense attention" case compared the gather to itself — both sides called
QsaGatherAttention — so scaling its output by two left it green; want now comes
from an independent masked walk over the full causal prefix. Two config refusals masked
each other, because the probe value was odd and either guard alone still threw, leaving
the upstream one untested. The mean pool's normalisation was unobservable, since
k_layernorm is RMS-scale-invariant and /4 is exact — a hand-derived case now probes
where the norm is eps-dominated and linear, with rope neutralised, so every expected
value is exact. And the selection-budget refusal cannot be reached in isolation, so its
case asserts which refusal fires, the only available discriminator.

Two free oracles carry the correctness argument, and neither needs weights. Below
indexer_budget + compress_ratio - 1 cached tokens every candidate is selected, so the
gather must be bit-identical to dense attention — asserted with == on floats, not
a tolerance, over all 2051 rows. Above it, selected token index sets must equal the
transformers reference, including the ragged tail, which is always attended. The
goldens come from running unmodified transformers 5.16.0 with
apply_rotary_pos_emb intercepted rather than reimplemented, so the pooled-key RoPE
position is captured from the oracle's own execution; perturbing the upstream module
moves the goldens, which is how that was proved rather than assumed.

A corollary this wave imposes on every later gate: any QSA token gate must exceed
2048 tokens of context.
Below the budget a correct implementation and one attending
pooled keys instead of real tokens are indistinguishable, and the wrong one still emits
plausible output.

This slice lands unreached. There is no Qwen4ExpTextModel yet; the commit body,
the spec ## Owed and the index row each name that, the owning row and #1978. No token
number, no speed number; the speed axis opens at G4, after the GGUF arm.

Tracked by #1991, under #1978.

Gates: test_qwen4_exp_qsa 14 cases / 7263 assertions; agent-preflight.sh exit 0;
commit style, trailers and index append-only all OK. A fresh review returned FAIL, a
different implementer repaired it, and a fresh scoped re-review returned PASS, with
every repair proved by re-running the mutation that used to survive and the two
declared limits measured rather than asserted.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Claude:claude-opus-5 [Claude Code]

mudler added 6 commits August 26, 2026 17:17
…nsumer, so the long-context lever survives the port (#1991)

A mask over a dense cache would have been correct. llama.cpp #27739 records,
with the mechanism named, that it would also have been no faster: CUDA flash
attention's `flash_attn_mask_to_KV_max` only scans back to the first tile that
is not all `-inf`, so a sparse mask pays dense cost. llama.cpp #27742 is
mask-only for exactly that reason and buys correctness without decode speed.
Every value comparison in this suite passes under either implementation, which
is why the wave could have shipped the wrong one and nobody would have seen it.

So the consumer expands selected block `b` into tokens `[4b, 4b+4)`, appends the
ragged tail, and reduces over only those rows. That claim is instrumented rather
than asserted: `QsaGatherAttention` reports `keys_visited`, and mutating it to
delegate to the mask reference leaves 11 of 12 cases green and fails only
`qsa-consumer: the GATHER touches only the selected rows`. One case in this file
is therefore not about correctness at all, and deleting it would restore the
blindness this commit exists to remove.

Two free oracles carry the correctness half, and neither needs a checkpoint or a
GPU. Below `indexer_budget + compress_ratio - 1` cached tokens every candidate is
selected, so the gather must be bit-identical to dense attention; #27742 measures
a max logit delta of 0.0 over all 2051 such rows and the sub-budget case states
that in C++. Above it, the selected token index sets must equal the reference
including the ragged tail, which is always attended. The second oracle is the one
that matters, because at or below the budget a wrong implementation attending
pooled keys instead of real tokens is indistinguishable and still emits plausible
tokens — the failure the spec's G2 clause warns about.

The goldens come from RUNNING the unmodified `Qwen4ExpTextQSAIndexer.forward` at
transformers 5.16.0, this row's accepted algorithm lane pin, with the module's
`apply_rotary_pos_emb` intercepted rather than reimplemented so the pooled-key
RoPE position is captured from the oracle's own execution. That also settles a
record the oracle file said was owed: the lane pin's version string was
UNMEASURED, and `transformers.__version__` now reads 5.16.0 on a live import.
`gateable` stays `no` — running one module on random weights is not running the
model, and no published artifact fits any fleet device.

Ported onto DeepSeek-V4's C4 indexer lane per the spec's reversal, with the two
things it must not inherit reconciled in code rather than in a comment. DSv4
folds a learned per-head `weights_proj` and a `head_scale` of `n_heads ** -0.5`
into the logit; QSA has neither tensor and its weight is the constant
`1/sqrt(128)`, applied after the sum over heads. DSv4's indexer RoPE is GPT-J
style over a trailing span; QSA is NeoX `rotate_half` over the leading 64 dims
with the NoPE dims trailing, so the halves are swapped end for end and the
pairing convention differs too. A mutation for the first of those was UNMOVED
until a hand-derived score case existed, because top-k is invariant under a
positive rescale of every score and the selection goldens cannot see the
constant at all — that gap is now closed, and the constant still matters as the
logits' dynamic range for any future fp8 indexer kernel.

`SparseAttnCompressNormRopeStoreC4Kernel` supplies scaffolding only. Its
boundary predicate, block-start RoPE and paged store port directly; its
`tl.softmax(score, dim=0)` pool over an overlapping window is a learned pool
driven by a score channel this checkpoint does not have, and is replaced by an
unweighted mean over a non-overlapping window of 4.

One design decision has no upstream to mirror, because upstream never had to make
it. Transformers leaves the selected tokens in score-rank order, which is
unobservable once they are scattered into a mask. A gather's order IS the
softmax's reduction order, so the buffer is sorted ascending — which is both what
makes the sub-budget bit-identity hold and what vLLM's own all-select shortcut
emits in `sampler.cu`.

The side cache is `MLAAttentionSpec(num_kv_heads=1, head_size=128,
tokens_per_state=4)` = 64 B/token/layer at bf16, asserted rather than defaulted,
and `StatesForTokens` floors because a ragged block writes no state at all.

This slice LANDS UNREACHED. `Qwen4ExpTextModel` does not exist yet — its PLE
(#1987), hyper-connection stream (#1988) and GGUF reader (#1989) are sibling
waves and the registry entry plus runner wiring belong to W5. Row
MODEL-MM-QWEN4-EXP owns that wiring, #1978 tracks it, and the spec now lists it
under `## Owed` along with the device arm. No CUDA kernel and no speed number:
the gather's cost advantage over the mask is a counted quantity here, not a
measurement, and the speed axis opens at G4 after W6a.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Claude:claude-opus-5 [Claude Code]
…oop, not its index buffer

The fresh review of e1476d1 returned FAIL on one deciding finding: this wave
exists to keep the QSA consumer a GATHER rather than a mask over a dense cache,
and nothing in the tree kept it one. `keys_visited` was ASSIGNED `sel.size()`,
and the test compared it to `SelectedCount(sel)` — the same quantity, from the
same input, by the same rule. The assertion observed the index buffer and never
the walk. The reviewer replaced the gather's body with a dot product against
every one of the `kv_len` cached rows, masked with -inf — arithmetically
`QsaMaskedAttention`, the exact cost llama.cpp #27739 measures — and the suite
stayed 12/12 cases, 7251/7251 assertions GREEN, including the case named
`qsa-consumer: the GATHER touches only the selected rows`. I reproduced that
mutation (M22c) green on the committed tree before changing anything.

Both consumers now COUNT the read, at the read. `key_dot` increments beside the
`Dot` on the key row, so the observable is a function of the loop: an honest
gather reports `selected * num_q_heads * 2`, any walk over the whole cache
reports `kv_len * num_q_heads * 2`, and the two are in the same unit so their
ratio is measured rather than asserted. M22c is now RED, 24 assertions in the
case that names the property. So is M31, a redundant second pass over the
selected rows, which the old counter could not see either — it never was a read
count, since the honest gather already reads each selected row twice.

Five further findings, all repaired in the same change:

* The sub-budget "BIT-IDENTICAL to dense attention" case built `want` from
  `QsaGatherAttention` and compared it to `QsaGatherAttention`. Scaling the
  gather's output by 2 (M40) left it green; so did reversing its reduction order
  (M29). `want` now comes from `QsaMaskedAttention` over the full causal prefix,
  an independent walk with no position removed, and the `==` on floats stands.
  Both mutations are RED there now.
* The upstream rotary-fit refusal was probed with `index_head_dim + 1`, which is
  odd, so our evenness check covered for it and deleting either one alone left
  the suite green. The fit refusal is now probed with an EVEN out-of-range value
  and the evenness refusal with an odd in-range one, and each case asserts WHICH
  refusal fired, not merely that something threw.
* The mean pool's `/ compress_ratio` was unobservable: `k_layernorm` is
  RMS-scale-invariant at the published eps and `/4` is exact in binary floating
  point, so storing a SUM changed nothing downstream. A hand-derived case probes
  where the norm is linear instead — eps dominating the mean square — with the
  rope neutralised to the identity and the expected values exact rather than
  toleranced.
* The `<= block_topk()` refusal was untested and cannot be reached in isolation:
  `compress_ratio * (block_topk + 1)` is exactly one token past `index_width()`,
  so the width check throws too. The new case therefore asserts which refusal
  fires, which is the only available discriminator.
* Anchors. DeepSeek-V4's `head_scale` is `attention.py:930`, not `:843` (an FP8
  cache head_dim). The `sampler.cu` tie rule is `:515`, not `:517`. The
  `tl.softmax` pool belongs to the TRITON
  `_fused_kv_compress_norm_rope_insert_indexer_attn`, not to the CuteDSL
  `SparseAttnCompressNormRopeStoreC4Kernel`, which lives in its own file — the
  header contradicted itself about which kernel it meant. `compressor_utils.py`
  is `:50`/`:51`/`:61`, the positivity raise is `:219-220`, and
  `apply_rotary_pos_emb` runs to `:608`. Each was read at the pin, not copied.

Two claimed op-semantics are now labelled as what they are. Reversing the
all-select shortcut's order (M20) and flipping the tie rule (M19) both leave the
suite green: `std::sort` sets the gather's order, and the fixtures produce no
exact tie. They are inherited so a QSA top-k resolves the way the op it mirrors
resolves one, and the comments no longer claim the bit-identity oracle depends
on them.

The spec's `## Owed` gains two entries. W4 is this row's first product code, so
`.agents/model-matrix.md` still reading `READY` / "SPEC ONLY, NO PRODUCT CODE" is
false from this commit onwards; that cell is NOT edited here, because W1 through
W3 are live on the same file and a per-wave edit to one shared row is the lock
AGENTS.md "Records" forbids — W5 lands the transition with the wiring. And
nothing gates the interleaved-mRoPE section layout anywhere yet: the generator
passes a 2-D `position_ids` that upstream expands into three identical streams,
so `apply_interleaved_mrope` runs value-blind.

`./build/tests/test_qwen4_exp_qsa` is 14 cases / 7263 assertions green.
`scripts/agent-preflight.sh` exits 0.

Issue: #1991. Row MODEL-MM-QWEN4-EXP, spec .agents/specs/qwen4-exp-flash-next.md.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Claude:claude-opus-5 [Claude Code]
`scripts/agent-preflight.sh --staged` SKIPPED both trailer gates because this
branch was behind origin/main at 2a42cb3, and a skipped gate reports nothing
about the tree. Merging first is what makes them run. The incoming commit is
the GDN recurrent-state KV budget (#1983), which touches no QSA path;
`test_qwen4_exp_qsa` rebuilds and stays at 14 cases / 7263 assertions green
across the merge.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Claude:claude-opus-5 [Claude Code]
The branch was 19 commits behind origin/main, so its green CI verdict was
measured against a base that has since moved and the diff-scoped trailer gates
would SKIP while still exiting zero. Merging restores the ancestry those gates
need and puts the operator gate on the tree that will actually land.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5 [claude-code]
W2 landed on origin/main as 583b9a9, so this branch is behind its own base.
The trailer gates are diff-scoped against origin/main and SKIP while still
exiting zero when the base is not an ancestor of HEAD, and GitHub refuses the
squash outright on a stale head. Merging restores both.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5 [claude-code]
origin/main advanced while this branch waited on a saturated CI queue. The
trailer gates are diff-scoped against origin/main and SKIP while still exiting
zero when the base is not an ancestor of HEAD, and GitHub refuses the squash
outright on a stale head. Merging restores both.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5 [claude-code]
@localai-bot
localai-bot merged commit 3104a52 into main Aug 27, 2026
10 of 24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants