Skip to content

qwen4exp: fix QSA correctness defects and harden metadata loading - #143

Closed
danielhanchen wants to merge 2 commits into
base/upstream-d7bd3bfcafrom
qwen4exp/qsa-correctness-fixes
Closed

qwen4exp: fix QSA correctness defects and harden metadata loading#143
danielhanchen wants to merge 2 commits into
base/upstream-d7bd3bfcafrom
qwen4exp/qsa-correctness-fixes

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Aug 29, 2026

Copy link
Copy Markdown
Member

In short

  • Sequence copies lost their indexer keys. The update context never built ctx_idx, so a copied sequence kept the destination stream's stale keys. Reachable with no flags through the OpenAI n parameter, and it silently produced wrong output.
  • Blocks were keyed on position alone. Correct only for a single sequence, so under --kv-unified a block could be pooled from another sequence's cells. Now keyed on (sequence set, index bucket).
  • Images collapsed into one block slot. Under M-RoPE every token of an image shares a position, so 299 of 300 image cells were scored by a pooled key they are not part of. Blocks are now cut on rank order.
  • Malformed metadata aborted the process. Eight GGML_ASSERT sites reachable from a hand edited GGUF are now throws, plus two cases that were being accepted in silence.
  • Fixes a pre-existing CUDA abort at long context, where the pooled block count reached the 65535 gridDim.y limit at n_kv 262144.

No GGUF format change. Every published artifact loads unmodified, and perplexity is identical before and after.

What this fixes

Four correctness defects in the qwen4exp implementation, plus one pre-existing crash that two of the investigations turned up independently. All four were found by auditing ggml-org#27879, a draft PR of machine-generated fixes whose author said plainly that they could not judge whether the fixes were correct. Two of its six claims were real, one was real but already documented, and three were wrong. This PR takes only what survived checking, written fresh rather than cherry-picked, because that PR's own version of the largest fix crashes on CUDA.

Everything here is runtime side. No GGUF changes, no new required metadata, and every published artifact keeps loading byte for byte unmodified.

1. The indexer cache is never copied when a sequence is copied

Of the four constructors of llama_memory_hybrid_idx_context, only the update one never builds ctx_idx. llama_kv_cache::update() is what applies a pending cross stream seq_cp, so with ctx_idx null the indexer key buffer is never copied while its cell metadata already claims the parent's positions.

Instrumented, n_cmpl=2: the attention cache got update() with n_stream_copies=1, the indexer cache got none, and ctx_idx was null in all 53 update contexts.

This is reachable with no flags at all, through the plain OpenAI n parameter (server-context.cpp:3748 to copy_state_to to seq_cp), and it produces silently wrong output. Two children of one prompt:

prompt before after
1464 tokens, below the QSA budget match, max delta logprob 0.0 match, 0.0
3476 tokens, above the budget differ, max delta logprob 0.93889 match, 0.0

The contrast is the point. Below the budget attention is dense and the indexer is unused, so the children must agree; above it they must not diverge either. A reproducer that shows only one half of that is not sensitive enough, and the first version of this one was not: with synthetic filler the children matched even on broken code, because the continuation was decided by the always visible tail.

The companion change setting the indexer cache to LLAMA_ROPE_TYPE_NONE is deliberate. Indexer keys are raw, rotation happens after pooling at read time, and llama-kv-cache.cpp:866 guards only the shift graph on rope_type, so stream copies in the same update() still run.

2. QSA blocks are keyed on position alone, which is unsafe under a unified cache

A QSA block is ratio cells one sequence holds at consecutive positions. The old code keyed it b = pos/ratio, slot = pos%ratio. That is only correct when the cells array holds one sequence. Under --kv-unified every sequence shares v_cells[0] and every sequence counts positions from zero, so the last writer wins and a block's pooled key is built from another sequence's cells.

Two sequences of 6387 tokens, both keyings from one binary via a toggle:

arm foreign cells cross sequence overfilled
before, unified 272,779 272,779 68,210
after, unified 0 0 0
before and after, non unified 0 0 0

Cells were actually placed into blocks in the fixed arms, so the zeros are not the result of nothing running.

A note on the magnitude, because it is easy to over-read. These counters are cumulative over set_input_qsa calls, so the "before" figure tracks the context size and the number of calls rather than being a property of the defect: the same test at a different -c reports 74,448 instead of 272,779. The portable claim is the shape, not the number, and it holds in every configuration measured: before is massively non zero under a unified cache, after is exactly zero, and the non unified path is unaffected either way.

This is a selection quality defect and not a content leak. build_attn_mask_top_k returns ggml_add(kq_mask_top_k, kq_mask) (llama-graph.cpp:3016), so the ordinary causal mask is re-applied over the top-k result and a foreign cell stays at negative infinity however wrong the blocks are. What breaks is which of a sequence's own cells it selects. The description in ggml-org#27879 overstates this, and so did an earlier draft of these notes.

Blocks are now keyed on the pair of sequence set and position bucket. Cells carrying the same sequence set are visible to exactly the same sequences, so that set is the coarsest key that can never mix two of them. A bucket whose cells disagree splits into short groups, and a short group is dropped exactly as a partial tail block already was, which matches all three reference implementations: partial blocks are excluded, not pooled over fewer elements.

No tensor changes shape, so graph input bytes are identical before and after: 24.04 GiB at n_kv 262144, ratio 4, n_ubatch 2048 unified, in both cases.

Known cost. Generation throughput at unified batch 16 drops 0.44 percent, with a standard deviation of 0.22 and a 95 percent confidence interval of 0.26 to 0.62 percent. Six repetitions per arm, arm order rotated every repetition, page cache pre-warmed. It is the per cell sequence set comparison, in exactly the path that was previously incorrect. Prompt throughput is unchanged. A first version also cost 1.49 percent on the non unified path; a single sequence fast path returns that to within noise, since a stream holding one sequence needs no set test at all.

An earlier draft of this description reported 2.33 percent, and that figure is withdrawn. It did not reproduce. Re-measured on identical hardware with a third arm carrying the block keying change alone, so the merge could be ruled in or out:

arm generation t/s at B=16 unified versus base
base 389.94 +/- 2.45
block keying alone 387.65 +/- 2.19 -0.59 percent +/- 0.65
this branch, both fixes 388.22 +/- 2.80 -0.44 percent +/- 0.22

The merged branch differs from the keying change alone by +0.15 percent +/- 0.77, which is indistinguishable from zero, so merging the two fixes neither introduced nor removed a cost. Since 2.33 percent fails to reproduce for the unmerged change either, the discrepancy is in the original measurement configuration rather than in the code, and no claim is made that anything here improved it.

The pitfall that produced the original number is worth naming, because it is easy to repeat: a cold page cache. Run first and unwarmed, the base arm scored 139.21 t/s against a warm 391, so a single-sample comparison in the wrong order reports a fictitious result of well over 100 percent in either direction. Every figure above is warmed and order rotated.

3. Under M-RoPE many cells share a position, and they all collapse into one block slot

mtmd.cpp:2393-2399 gives every token of an image the same pos.t, varying only x and y, and llama-kv-cache.cpp:1127 stores section 0 as the cell position. So an image contributes hundreds of cells at one position. All of them compute the same b and the same slot, they overwrite each other, the remaining slots keep a fill value that is a real cell index, and the block is nevertheless declared complete because the fill count reached ratio.

Measured with llama-mtmd-cli on a real image above the budget, reading back the arrays that had just been emitted:

before  n_kv=2816 n_blocks=704 ratio=4 cells=2806 pos_dup=300 dupmem=1 unbacked=299
after   n_kv=2816 n_blocks=704 ratio=4 cells=2732 pos_dup=300 dupmem=0 unbacked=0

299 of the 300 image cells were being scored by a pooled key they are not part of.

An honest limit on the impact. This does not visibly break image understanding. The same image is described correctly before and after, at 2816 and at 15360 cells. Three quarters of the corrupted pooled key is cell 0, which behaves like an attention sink and scores high, so the image blocks tend to get selected anyway. The corruption is real and it does change the selection, but no prompt was found where it produces a wrong answer.

Blocks are now cut on each cell's rank in sequence order, ordering by position and then by the two spatial components, which is the same total order the M-RoPE causal mask already uses. The pooled key is roped with the full four section position of the block's first cell rather than a scalar. The path is gated on actually observing a duplicate position in a 2D batch, so with unique positions nothing changes and the text path is unaffected.

All three references agree on both points: block membership is by token index and never by position, and the group start indexes the full interleaved cosine and sine row.

4. Malformed metadata aborts instead of returning an error

load_arch_hparams had eight GGML_ASSERT sites that a hand edited or truncated GGUF can reach, which aborts the process rather than failing the load. These become throws naming the offending key and its value:

file before after
ssm.conv_kernel = 0 SIGABRT qwen4exp.ssm.conv_kernel must be greater than zero, got 0
hyper_connection.low_rank = 0 SIGABRT qwen4exp.hyper_connection.low_rank must be greater than zero, got 0
ple.layers = [1,5] SIGABRT qwen4exp.ple.layers lists 2 layers, but only one PLE layer is supported
short ple.layer_multipliers silently accepted has 1 entries, but at least 3 are required

The last row is a genuinely new class rather than a tidier message. get_arr() into a fixed array bounds checks the upper end, but a short array is copied as is and leaves the destination tail alone. Since hparams is value initialised that tail reads as zero, and the n-gram hash quietly drops those positions. A short offsets array made heads 4 through 15 all read from offset 0. There were two such silent cases, not one.

Checks were adopted only where the reference implementations agree the value is impossible, not merely unused today. hc_count == 1 is rejected because all three raise on it. An absent compress_ratios is accepted because all three treat it as optional, so ggml-org#27879's check on that is not taken. Multiple PLE layers are permitted by all three, so the single layer limit here is a llama.cpp representational limit rather than an architectural rule, and the message says so instead of claiming the architecture forbids it.

5. A pre-existing CUDA abort at long context

Found independently by two of the investigations above, and present on master with none of this PR's code.

build_qsa_top_k reshapes the pooled keys so that the block count lands on ne2, and rms_norm launches gridDim.y = ne2, which is capped at 65535. At n_kv 262144 with ratio 4 that is 65536 blocks, over the limit by exactly one:

master, ctx 262144  ->  Aborted, CUDA error: invalid argument, in ggml_cuda_op_rms_norm_fused
master, ctx 131072  ->  PPL = 4.8154 +/- 0.04186

Blocks are now counted along ne1 for the norm, which is bounded by gridDim.x at 2^31, and only reshaped for the rope, which flattens rows into gridDim.x anyway. Every other operation in the path stays inside its limits for n_kv up to 262144 and n_ubatch up to 2048.

This is the same kernel and the same limit that ggml-org#27879 hits, reached through n_kv rather than through n_ubatch. That PR did not introduce the failure class; it made an existing hazard easy to trigger.

Verification

Every fix was developed against its own build of the same base, and each was checked against pristine master rather than against a sibling fix.

check result
perplexity, 1 chunk and 16 chunks, ctx 2048 identical to every digit, before and after
perplexity, ctx 131072 identical, 4.8154 +/- 0.04186
sparse vs dense below the budget, 2040 tokens / 2048 cells max delta logprob 0.0
before vs after logits at 2048 and 2304 cells, sparse and dense max delta logprob 0.0
test-llama-archs -a qwen4exp OK on CPU 0.00e+00 and CUDA, roundtrip OK
malformed fixture matrix, 20 cases 2/20 before, 20/20 after
all 11 published GGUF artifacts load unchanged
image description, blk_bias on and off correct on both
multi slot contamination, unified and non unified 4/4

Note on the budget boundary: it is indexer_top_k + ratio - 1 = 2051 cells, and the cache pads to a multiple of 256. A 2051 token prompt therefore allocates 2304 cells and is already above the budget, so the bit-identical check belongs at 2040 tokens and 2048 cells.

Every new test was verified to fail on deliberately broken code. Two did not, and are reported rather than counted:

  • The multi slot contamination check cannot detect the unified block mixing defect. With the bug deliberately re-enabled it still reports 4 of 4, and structurally it always will, for the masking reason in section 2. That limitation is now recorded in the script so a future reader does not mistake a pass for coverage.
  • The image test alone cannot detect the M-RoPE keying defect. A deliberately broken variant still produced a correct description, because it degenerates into making every cell visible, which is correct but not sparse. An independent oracle that recomputes visibility from the mask rule was added; it reports 0 on correct code and 3 on the sabotaged version.

What is not covered

  • The unified batch 16 generation cost of 0.44 percent +/- 0.22 is a small but real regression against master, accepted as the price of correctness in the path that was wrong.
  • Non CUDA backends were not exercised beyond the CPU arch test.
  • Audio chunks and multiple images in one sequence were not run, though the ordering argument covers the latter.
  • No quantitative measure of how much the old block keying degraded selection quality, only that it did not leak content.

Four runtime side fixes, no GGUF format change:

- copy the indexer cache on a cross stream seq_cp. The update constructor
  never built ctx_idx, so a copied sequence kept the destination stream's
  old indexer keys while its cell metadata claimed the parent's positions.
  Reachable through the plain OpenAI n parameter with no flags.
- key QSA blocks on the pair of sequence set and position bucket. Keying
  on position alone is only correct when the cells array holds one
  sequence, so under a unified cache the last writer won and a block's
  pooled key was built from another sequence's cells.
- cut blocks on each cell's rank in sequence order under M-RoPE. Every
  token of an image shares one position, so all of them mapped to the
  same block slot and overwrote each other.
- throw instead of aborting on malformed metadata. Eight GGML_ASSERT
  sites in load_arch_hparams were reachable from a hand edited GGUF, and
  a short fixed size array was accepted in silence.

Also counts pooled blocks along ne1 for the norm, which avoids a
pre existing CUDA abort at n_kv 262144 where the block count reached the
65535 gridDim.y limit.

Perplexity is identical before and after over 1 and 16 chunks, and
test-llama-archs -a qwen4exp passes on CPU and CUDA with roundtrip.
@danielhanchen
danielhanchen requested a review from CISC as a code owner August 29, 2026 08:36
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-29T08:47:04.397590Z 70a88db New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: efaa1ed738

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/llama-memory-hybrid-idx.cpp Outdated
Comment on lines +377 to +381
// a block is r cells one sequence holds at consecutive indices, so it is keyed on
// (sequence set, index bucket). A unified cache shares one cells array between all
// sequences and every sequence counts positions from zero, so the bucket alone pools
// different sequences into one block. Cells with the same sequence set are visible to
// the same sequences, so that set is the coarsest key that never mixes two of them.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the QSA comments concise

This newly added comment is ten lines long and splits sentences across multiple // lines; the same pattern recurs throughout set_input_qsa. The repository explicitly requires comments to be usually 1-2 lines and forbids mid-sentence hard wrapping, so condense these explanations rather than landing the repeated long-form commentary.

AGENTS.md reference: AGENTS.md:L74-L81

Useful? React with 👍 / 👎.

// cell. Rank the cells in sequence order and key on the rank: that is the token
// index the reference groups on. A rank only orders cells inside one sequence, so
// this needs a cache holding one; otherwise the short groups are dropped as above.
if (dup && ubatch->is_pos_2d() && one_seq) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rank repeated positions after unified sequence copies

When a multimodal prompt is copied to another sequence in a unified cache, the shared prompt cells contain both sequence IDs, so n_seq_present is greater than one and one_seq is false even though all those cells have the same sequence set. This condition therefore skips ranking for the repeated M-RoPE image positions; group_cells() leaves the image cells competing for one slot, causing their block to be discarded as incomplete or pooled from only one image cell once QSA is active. The repeated-position ranking needs to handle copied/shared sequence sets, not only caches containing one sequence ID.

Useful? React with 👍 / 👎.

Comment on lines +387 to +388
GGML_ASSERT(r <= 64);
const uint64_t slots_full = r == 64 ? ~uint64_t(0) : ((uint64_t(1) << r) - 1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject oversized compression ratios during loading

For a Qwen4Exp GGUF whose nonzero attention.compress_ratios entry exceeds 64, metadata loading still accepts the value, but the first sparse-attention graph input terminates the process at this GGML_ASSERT. This also undermines the new catchable-error handling for malformed metadata. Validate the upper bound in load_arch_hparams and throw a load error, or represent block occupancy without the 64-bit limit.

Useful? React with 👍 / 👎.

Cut the added comment lines from 64 to 19. Keeps the invariants that the
code does not state, the reference citations, and the two traps, and
drops restatements, spacers and measured numbers that the pull request
description already carries.

Also corrects two comments in llama-memory-hybrid-idx.h that became
wrong when the update context started building ctx_idx.
@danielhanchen

Copy link
Copy Markdown
Member Author

Superseded by ggml-org#27941, which is the same work with nine more lines in llama-memory-hybrid-idx.cpp and a llama-kv-cache.cpp change this branch does not carry. #148 pins that commit into the nightly set, which is what actually reaches users; merging this branch to fork master would not have, since the nightly tree is the upstream tag plus scripts/unsloth/pr-set.json.

Closing so there is one copy to maintain.

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