Repository navigation
feat(v4): per-row q0 in dsv4_attention — the ragged-mask blocker #92 named - #100
Conversation
Stacked on #95. Removes PR #92's second named blocker: dsv4_attention now takes a per-row absolute query position, so a left-aligned ragged cohort gets its dead prefix masked on the raw branch AND its start-anchored compressed threshold rebuilt from each row's own position. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Extract resolve_ragged_rows and per_sequence_refusal so the dispatch and the capability composition are testable directly — a mutation that routed uniform batches down the ragged path was numerically invisible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Code Metrics Report━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Language Files Lines Code Comments Blanks ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ C Header 5 305 210 52 43 CSS 2 1181 1036 34 111 CUDA 72 24328 17592 4018 2718 Dockerfile 1 39 22 8 9 JavaScript 16 3546 2676 482 388 Jinja2 7 694 656 5 33 JSON 74 4600 4597 0 3 Makefile 1 6 5 0 1 Metal Shading Lan| 33 12224 9431 1142 1651 PowerShell 1 300 227 30 43 Python 143 14830 12217 797 1816 Shell 23 5756 3964 1412 380 Plain Text 4 3801 0 2479 1322 TOML 33 1485 1292 43 150 YAML 3 25 23 2 0 ───────────────────────────────────────────────────────────────────────────────── HTML 4 2687 2604 43 40 |- CSS 2 543 479 37 27 |- JavaScript 1 1233 1215 12 6 (Total) 4463 4298 92 73 ───────────────────────────────────────────────────────────────────────────────── Jupyter Notebooks 4 122 83 23 16 |- Markdown 1 60 30 22 8 |- Python 1 122 113 1 8 (Total) 304 226 46 32 ───────────────────────────────────────────────────────────────────────────────── Markdown 190 37932 0 29111 8821 |- BASH 71 1623 1192 315 116 |- C 2 12 12 0 0 |- CUDA 2 84 56 16 12 |- JSON 18 708 708 0 0 |- PowerShell 1 1 1 0 0 |- Python 23 1008 787 113 108 |- Rust 65 2048 1713 77 258 |- TOML 6 207 164 0 43 |- YAML 4 38 33 5 0 (Total) 43661 4666 29637 9358 ───────────────────────────────────────────────────────────────────────────────── Rust 662 309221 267775 14013 27433 |- Markdown 477 23236 471 20016 2749 (Total) 332457 268246 34029 30182 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Total 1278 454005 331384 74308 48313 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ |
Absorbing master brought in a Dsv4AttentionConfig initializer that predates this PR's row_q0 field, so the lib-test build failed with E0063 while the lib build alone was clean — cargo check -p mistralrs-core does not build tests, which is why this only surfaced in CI. The fixture builds a uniform batch, and every other non-ragged fixture in this file uses row_q0: None, so None is the identity: it preserves exactly what the test asserted before the field existed. Caught by ci-complete, not by the check-run count — the run showed 0 pending while the test lane had failed and its two sibling matrix legs were cancelled by fail-fast.
|
Admin-merged without review (no second reviewer on this fork). The bypass covered the review requirement only — the required Merged without Worth recording: Two things that missed it, both worth knowing:
Fix was |
feat(mtp): per-row `cache_len` in the fused step — the pipeline blocker #100 named
Master moved 10+ commits since 120f348, including #100 (v4 ragged mask wire) and #102 (per-row cache_len in the fused MTP step) — adjacent territory. Merged now rather than letting the divergence be discovered later. Clean auto-merge; the only overlapping file is `kv_cache/mod.rs`. 466/466 green on the merged tree (441 mine + master's new tests). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stacked on #95 (
feat/xs-per-sequence), which is stacked on #92. Review those first.The blocker, in one sentence
PR #92 named two blockers. #95 removed the first and the refusal moved to this one. This removes it.
Dsv4AttentionConfignow carriesrow_q0: Option<&[usize]>, andcache_supports_per_sequence_advance+target_masks_ragged_batchesboth pass for a V4 target.🔑 Left-alignment breaks exactly ONE of the two time bases — and adds a third problem
leadpreserves their difference, and both the causal and the sliding-window tests are differences. In cache-column space the batch-wideq0is right for every row.leadzero columns appear ahead of each row. A zero K row scores logit 0 and takes real softmax weight.bis absolute blockbfor every row, sob < (q_abs + 1) / rationeeds that row's ownq_abs. Fed the batch's, a row that advanced less attends blocks covering tokens it never generated.The third row is why #92's
[B,1,t_q,k]K/V mask is not sufficient, and #95 said so. What it could not have known is that a caller mask cannot express it either:ratioand the compressed widtht_care per-layer (0/4/128; ratio-0 layers have no compressed columns at all) whileCausalMaskerbuilds one mask for all 43 layers. So the correction has to live whereratiois known — insidedsv4_attention.compose_caller_mask's rank-4 support stays exactly as useful as it was; it just is not the vehicle for this.🔑 The wire was already threaded — it was being collapsed, not missing
seqlen_offsets[i]is already rowi's own past length: the engine threads it to every attention call so RoPE places that row's queries at their true absolute positions. It is exactly theq0the compressed branch needs and, vialead_i = q0 - row_q0[i], exactly the dead prefix the raw branch needs.So no value is plumbed anywhere.
Attention::forwardalready receivesseqlen_offsets; what changed is thatdsv4_attentionstops collapsing the vector to one scalar. No signature churn throughDecoderLayer, no new field onModelInputs, noHashMap<seq_id, …>.Only the dense arm opts in. PagedAttention keeps per-request block tables and the graph-decode arm reads a fixed window whose columns are device slots, not positions — neither is a left-aligned cohort, so both keep
row_q0: Noneand are untouched.Why "flag off is byte-identical" is structural
resolve_ragged_rowsreturnsNoneunless some row differs fromq0, andNoneruns the pre-change scalar code verbatim. Every B=1 request, every prefill and every batch built with the flag off takes that branch. No third flag: the gate isARC_MTP_PER_SEQ_KV, becausefront_align_batchis the only producer of a ragged dense batch and it runs only underKvAdvance::PerSequence.[b,1,t_q,n_keys]mask plus two host→device copies on every layer of every B=1 step).a_uniform_batch_never_reaches_the_ragged_pathpins the dispatch directly; that is whatresolve_ragged_rowsexists as a separate function for.📊 Measured — CPU unit tests on the real
dsv4_attention, not hardware (D14)cargo test -p mistralrs-core --lib→ 407 passed, 0 failed (was 405 on #95's tip, 398 at #95) ·--test synthetic_load_smoke→ 13 passed ·cargo check --workspace --testsgreen · scoped clippy lane green · zero rustfmt drift (checked like-for-like: dsv4_attention 8→8, deepseek4 341→341, mtp_pipeline 0→0;kv_cache/mod.rsadds 4 lines and reformats nothing).The core test runs each row of a ragged cohort against that row run alone with its dead prefix absent, with its own input stream per row (a shared stream would make block
jcover identical tokens on every row and hide any mix-up). Its three degeneracy assertions run first and fail loudly if a later edit closes one: rows must land on different compressed thresholds, at least one threshold must be belowt_c, and at least one dead prefix must reach inside the retained raw span. A negative-control test shows the same fixture disagrees when fed the batch-wide position.Mutation runs — 12 mutations, all 12 caught
The last four exist because of #95's warning. The uniform-dispatch and opt-in-flag mutations both survived their first drafting and the tests were rewritten around them, not the other way round.
🔴 The honest gap —
PerSequenceis still refused, and I found out whyThis does not move the B=128 number, and the reason is not the mask. With both previously-named blockers cleared, a third one is now visible that neither #92 nor #95 could see from where they stood.
MtpSpeculativePipeline::step_fullkeys the entire fused step on ONE scalarcache_len = current_normal_cache_len(self)— the batched buffer's width, which left-alignment makesmax_j L_j, not any row's length. Four uses break, in different ways:uncached[i] = toks_i.len() - cache_len(:2546) saturates to 0 for every row but the longest ⇒window_okfails ⇒ every ragged step defers. Per-sequence advance would be reachable but inert.toks[cache_len..](:2719) is a panic site oncecache_len > toks_i.len(), unreachable today only becausewindow_okbails first.run_target_forward_batch(.., Some((w, cache_len)))(:2725) ⇒seqlen_offsets[i] = cache_lenfor every row (inputs_processor.rs:244—last_n_context_len.1is a shared scalar). So the mask this PR built is correct but would be handed uniform values.set_len(cache_len + committed_i)(:2925) counts the dead prefix inside the recorded length (front_pad_singlesetscurrent_seq_len = target_len;clone_out_cachecopies the batched length verbatim into every row). The nextfront_align_batchtherefore pads relative to already-padded lengths and the dead prefix accumulates linearly — contradictingfront_pad_single's doc claim of asqrt(steps)random walk. Nothing inSequenceorSingleCacherecords a row's true live length; the only honest source isseq.get_toks().len().t_q == 1(layers_masker.rs:285) — so the dead prefix would be attended as real keys by the very fallback that was supposed to be safe. That is whykv_advancegains a third named check rather than being switched on, and whya_fully_capable_v4_cache_is_still_refused_and_now_names_the_stepasserts the refusal text namescache_lenand no longer namesXsRolling.So the observable effect of
ARC_MTP_PER_SEQ_KV=1today is, again, that the refusal moves. It is stated as a capability function (step_carries_per_sequence_lengths) so the next change flips exactly one thing. That change is pipeline arithmetic plus a per-row route throughinputs_processor— not a model change, which is why it is not here.🔴 The GPU ask
Nothing changes a default, so this is a no-regression check on the box already serving V4:
The one number: decode tok/s in B as a fraction of A, at B=1 and B=128; the claim is
B/A == 1.00at both. Andgrep "cannot honour it" /tmp/b.txtmust now cite the fused step's sharedcache_lenand must contain neitherXsRollingnor the ragged mask — if it names either, this wave did not land.Noticed, not shipped
ragged_union_maskbuilds twob-element host tensors per layer per step (~43 tiny H2D copies per forward, flag-on path only). The file already does this forkp/qp; hoisting all of them to one per-forward build is a separate change.make_causal_mask_matrixalready has theRaggedKvLenshook, andPastKvLenCache for Vec<KvCache>returnsNoneforper_seq_kv_lens. Giving the cache a record of its own padding would make the raw-side dead-prefix mask work for every model, not just V4 — but only V4 needs the compressed half, so it was out of scope here.