feat(MODEL-MM-QWEN4-EXP): W4 — Qwen Sparse Attention with a GATHER consumer, counted at the key-row read - #2030
Merged
Merged
Conversation
…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]
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.
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_maxscansbackwards and stops at the first tile that is not all
-inf— so a mask that keeps anylate 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
MLAAttentionSpecbeing an MLAclaim; 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 == 4literallythe same number. Two things are deliberately NOT inherited: DSv4's
weights_proj, forwhich QSA has no tensor (its weight is the constant
1/sqrt(128)), and itsGPT-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_visitedwas assignedsel.size(), and the test compared it toSelectedCount(sel)— the same quantity, from the same input, by the same rule. Theassertion observed the index buffer and never the walk. The proof is a mutation that
computes a dot product against every one of the
kv_lencached rows, i.e. exactlyQsaMaskedAttention's cost, while still reporting the sparse figure: it passed 12cases / 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;wantnow comesfrom 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_layernormis RMS-scale-invariant and/4is exact — a hand-derived case now probeswhere 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 - 1cached tokens every candidate is selected, so thegather must be bit-identical to dense attention — asserted with
==on floats, nota 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_embintercepted rather than reimplemented, so the pooled-key RoPEposition 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
Qwen4ExpTextModelyet; the commit body,the spec
## Owedand the index row each name that, the owning row and #1978. No tokennumber, no speed number; the speed axis opens at G4, after the GGUF arm.
Tracked by #1991, under #1978.
Gates:
test_qwen4_exp_qsa14 cases / 7263 assertions;agent-preflight.shexit 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]