Skip to content

feat(v4): per-row xs compressor state — the keystone three workstreams named - #95

Merged
heydryft merged 2 commits into
masterfrom
feat/xs-per-sequence
Aug 17, 2026
Merged

heydryft merged 2 commits into
masterfrom
feat/xs-per-sequence

Conversation

@heydryft

Copy link
Copy Markdown
Contributor

Stacked on #92 (feat/mtp-per-seq-kv), because it reuses front_pad_kv_cache's left-alignment and removes #92's first named blocker. Review #92 first.

The blocker, in one sentence

V4's compressor history xs carried a single tokens/base pair for the whole batch, so no sequence could advance its compressed KV independently of the others.

PR #90 §6 ("the compressor's xs history has no block table"), PR #92 §5.1 ("third wave to land on it; it is the keystone") and wave29-BC §4b each arrived at it from a different direction and each ruled it out of its own scope. This is that project.

🔑 The two time bases need OPPOSITE anchors

I expected one mechanism. It needs two, and the reason is geometric.

anchor needs
comp — completed blocks start: column j is absolute block j for every row a per-row append column
tail — retained raw window end: a suffix window that slides one shared append offset ⇒ left-alignment

tail is exactly #92's case and takes #92's answer. comp is not, and forcing it into that shape would have been the wrong call — left-aligning it costs a per-row data shift every step and a two-sided mask. Left alone it needs neither, and it gets its mask for free:

A row's live blocks are [0, tokens_i / ratio), and the compressed branch's existing threshold is b < floor((q_abs + 1) / ratio) (dsv4_attention.rs:419-434). Since q_abs + 1 <= tokens_i, that already excludes exactly the columns a shorter row has not reached. No extra mask term — only a per-row query position.

🔑 Window geometry is a function of the RESIDUE alone

The naive ragged path is one compressor call per row — 128 per layer per step, which would cost more than the raggedness buys. For row i at tokens_i = q_i·ratio + r_i, both the slice offset and its width depend on r_i only; two rows at the same residue need byte-for-byte the same window and differ only in the destination block q_i. So rows group by residue: ≤ 4 calls under MTP (the verify window bounds the spread), not B. The scatter is bounded to the column band the plan says every write falls in, making it O(B·spread) instead of O(B·capacity) — ~200K elements per layer-step instead of ~33M at B=128.

Why "flag off is byte-identical" is structural, not a test result

advance dispatches on rows_uniform(), and the uniform branch is the pre-existing scalar code verbatim. The uniform layout is the special case W == tokens − base, under which end-anchored and start-anchored coincide exactly — so the reinterpretation of tail is a no-op there. Every B=1 sequence and every uniform batch runs the same characters as before.

Per-row state lives on the cache. No HashMap<seq_id, …> — that is the divergence wave59-CJ documents and #92 removed, and it is not back.

Also fixes a pre-existing bug

front_align_batch kept whatever lead the last slot returned. On V4 the 41 compressor slots come last, so it reported "no dead prefix" for every sequence — #92's consumer would have built no mask for a batch that needs one.

📊 Measured — CPU unit tests on the real V4Compressor, not hardware (D14)

cargo test -p mistralrs-core --lib → 398 passed, 0 failed (was 377) · --test synthetic_load_smoke → 13 passed · workspace cargo check --tests green · scoped clippy lane green · zero rustfmt drift (checked like-for-like: xs_rolling 0→0, kv_cache/mod 0→0, deepseek4 30→30, mtp_pipeline 0→0).

Identity tests run the real compressor, give every row its own input stream (one shared stream would make every row's block j cover identical tokens and hide any row mix-up), and compare each row against the same sequence advanced alone: 4 residues on CSA, ratio-128 on HCA, one-call-two-destinations, 24 re-batched steps, a rolled-back row, the split, and a uniform control.

Mutation runs — and the three that survived

scatter: drop the per-row destination ......... 2 FAILED
keep_from without the `.max(base)` floor ...... 1 FAILED
split_row: take the share from the FRONT ...... 1 FAILED
advance: always take the uniform path ......... 5 FAILED
front_align: keep the lead from EVERY slot .... 1 FAILED
uniformity check: never exempt xs ............. 1 FAILED
batch assembly: front-pad at the BACK ......... 3 FAILED

⚠️ Three of eight initially survived, and the fixtures were wrong, not the mutations: every row happened to land on the same comp column, so the per-row destination was never exercised. two_rows_one_compressor_call_two_destinations and the rollback/split tests exist because of that. Recording it because a mutation table nobody failed is a table nobody ran.

An existing integration test (v4_xs_history_two_seq_batch_matches_single_sequence) also caught a real regression — a shrinking batch — which is fixed properly (uniform reshape stays exact in both directions; only a ragged reshape is refused, because which rows survived is the caller's knowledge).

🔴 The honest gap — this does NOT move the B=128 number

#92 named two blockers. This removes the first. The second is untouched and is now the only one: no model threads the ragged mask (target_masks_ragged_batches returns Err unconditionally). For V4 that wire needs a per-row q0, because dsv4_attention derives one scalar absolute query position and uses it for both branches; under left-alignment the raw window stays correct (q and k shift together) but the compressed threshold becomes too permissive. A rank-4 caller mask can remove those columns — compose_caller_mask already accepts rank 4 — so the consuming end is done; the missing piece is the same inputs_processor wire, extended to the compressed columns. Deliberately not started here: it is a model change, not a cache change.

So the observable effect of ARC_V4_XS_PER_SEQ=1 today is that the refusal MOVES. cache_supports_per_sequence_advance stops naming XsRolling; kv_advance still returns Cohort, now citing the mask. That is the only safe outcome — a zero-filled dead prefix is not a masked prefix, and serving from it is the FP8-KV failure again.

🔴 The GPU ask

Nothing changes a default, so it is a no-regression check on the box already serving V4:

mistralrs bench -m <v4> -b 1 -b 128 2>&1 | tee /tmp/a.txt
ARC_V4_XS_PER_SEQ=1 ARC_MTP_PER_SEQ_KV=1 mistralrs bench -m <v4> -b 1 -b 128 2>&1 | tee /tmp/b.txt

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.00 at both. And grep "cannot honour it" /tmp/b.txt must now cite the ragged mask and must not contain XsRolling — if it still does, the flag is not reaching the cache.

Full write-up: memory/mission/wave63-CO-xs-per-sequence.md.

🤖 Generated with Claude Code

…s named

V4's compressor history carried one `tokens`/`base` pair for the whole batch,
so no sequence could advance its compressed KV independently. PR #90 §6,
PR #92 §5.1 and wave29-BC §4b each hit it and each ruled it out of scope.

The two time bases need OPPOSITE anchors:

* `comp` stays START-anchored — physical column j is absolute block j for
  every row — and takes a per-row append column (a `slot_mapping`-shaped
  scatter). It needs no extra mask: the compressed branch's own causality
  threshold `b < floor((q_abs+1)/ratio)` already excludes exactly the
  columns a shorter row has not reached.
* `tail` becomes END-anchored, i.e. PR #92's left-alignment, so the one
  shared append offset serves every row.

Window geometry is a function of `tokens mod ratio` alone, so rows group by
residue: one compressor call per group, <= 4 under MTP rather than B.
The scatter is bounded to the column band the plan says every write falls
in, so it is O(B * spread), not O(B * capacity).

`advance` dispatches on `rows_uniform()` and the uniform branch is the
pre-existing scalar code verbatim, so B=1 and every uniform batch are
byte-identical by construction. Behind `ARC_V4_XS_PER_SEQ`, default OFF.

Also fixes a pre-existing bug: `front_align_batch` kept the `lead` from the
LAST slot, which on V4 is a compressor slot, so it reported "no dead prefix"
for every sequence.

Does NOT move the B=128 number yet. `KvAdvance::PerSequence` stays refused —
the refusal now cites the ragged-mask wire instead of `XsRolling`, which is
the point: it moves, it does not disappear.

398 lib tests pass (was 377), 13 synthetic_load_smoke, zero rustfmt drift.
Hardware used: none; every number here is CPU arithmetic or a CPU unit test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 17, 2026 •

Copy link
Copy Markdown
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                189        37703            0        28931         8772
 |- BASH                  70         1618         1189          313          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)                            43427         4663        29455         9309
─────────────────────────────────────────────────────────────────────────────────
 Rust                    662       307804       266560        13893        27351
 |- Markdown             477        22853          471        19669         2713
 (Total)                           330657       267031        33562        30064
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Total                  1277       451971       330166        73659        48146
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

…ort it

PR #93 landed `XsRollingCache::trim_tail_to(new_base: usize)` and
`reconcile_xs_bases` on master while this branch was making `base`/`tokens`
per-row `Vec<usize>`. The 7 resulting errors are semantic, not textual:
`base` is the same identifier naming two different quantities.

  - On master `tail` is `tokens - base` wide, so `base` IS the physical left
    edge of the buffer. Divergent `base` at equal `tokens` gives physical
    widths 4 vs 132 and the `slice_set` mismatch #93 exists to fix, reachable
    on plain decode through the prefix cacher.
  - Here `tail` is `[B, W, hidden]` and end-anchored, with
    `base[i] >= tokens[i] - W`. `base` is a logical resume point decoupled
    from the buffer; `BatchSrc::of` sets `v_slack_dim: Some(1)` with
    `v_slack_at_front: true`, so the widths already agree.

So the reconciliation is required on one path and a regression on the other.
Trimming every row to `base_max` raises the shortest-reach row's rollback
floor to the batch's -- a per-row quantity replaced by a batch-wide scalar,
the same shape as the cohort min-rollback #92 removed and the padding traps
#102/#103/#104 each caught. This is the last layer still holding one.

  - `reconcile_xs_bases` takes `xs_per_seq` and returns early when set. Flag
    off it runs verbatim; #93's two mutation-proven tests pass unchanged
    (they run flag-off), with only a private-field read swapped for the
    `resumable_from()` accessor, which is `base[0]` for a single-row cache.
  - `trim_tail_to` REFUSES a multi-row cache rather than reading `base[0]`.
    A scalar `new_base` cannot describe a trim of rows at different resume
    points, and proceeding returns a right-shaped tensor -- exactly what the
    caller checks -- so it names the reason instead (D18).

`per_row_xs_bases_survive_a_batch_round_trip_and_are_not_flattened` is the
test that discriminates: flag on, two rows at divergent `base` and equal
`tokens` must batch AND each keep its own `base` through `split_row`. Both
mutations were run rather than assumed -- trimming the tensors is caught by
the width assertion, but reconciling only the logical `base` passes every
shape check and fails solely on the per-row read.

8/8 mutations caught. One survivor on the first pass (`trim_tail_to`
narrowing from the wrong end) was real: no test read tail CONTENT, because
every fixture fed zeros. `trimming_the_retained_window_drops_the_oldest_rows_not_the_newest`
feeds a ramp and closes it.

430 lib tests pass, 14 synthetic, workspace check clean, scoped clippy exits
0, zero new clippy diagnostics in the touched files, and rustfmt drift is 6
before and 6 after -- no upstream reformat churn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@heydryft

Copy link
Copy Markdown
Contributor Author

Merged master; the trim_tail_to collision is resolved by gating, not porting

Master's trim_tail_to / reconcile_xs_bases (#93) collided with this branch's per-row base/tokens. The 7 errors were semantic — base is one identifier naming two different quantities:

master (#93) this branch (#95)
tail tokens - base wide [B, W, hidden], end-anchored
base the physical left edge a logical resume point, base[i] >= tokens[i] - W
BatchSrc::of v_slack_dim: None → widths must match exactly Some(1), v_slack_at_front: true → narrow rows front-pad

So divergent base at equal tokens is the 4-vs-132 slice_set failure only with the flag off. With it on the widths already agree.

Porting it would have been a category error; deleting it would have re-broken #93's plain-path bug. And it is not merely redundant under the flag — it is harmful: trimming every row to base_max raises the shortest-reach row's rollback floor to the batch's, turning a per-row quantity into a batch-wide scalar. That is the same shape as the cohort min-rollback #92 removed and the dead-prefix traps #102/#103/#104 each caught. This is the last layer still holding one.

Resolution

  • reconcile_xs_bases takes xs_per_seq and returns early when set. Flag-off runs verbatim; fix(kv-cache): reconcile XsRolling base — a plain-path batching bug split out of #86 #93's two mutation-proven tests pass unchanged (they run flag-off) — the only edit is a private-field read swapped for resumable_from(), which is base[0] for a single-row cache.
  • trim_tail_to refuses a multi-row cache rather than reading base[0]. A scalar new_base cannot describe a trim of rows at different resume points, and proceeding returns a right-shaped tensor — exactly what the caller checks — so it names the reason instead (D18).

The discriminating test: per_row_xs_bases_survive_a_batch_round_trip_and_are_not_flattened — flag on, two rows at divergent base and equal tokens must batch and each keep its own base through split_row. Both failure modes were run, not assumed:

  • trimming the tensors → batched tail is [2, 4, …] not [2, 132, …] → the width assertion catches it;
  • reconciling only the logical base → every shape exactly right, [256, 256] instead of [256, 128] → only the per-row read catches it.

8/8 mutations caught. One survived the first pass and was real: trim_tail_to narrowing from the wrong end kept the right number of columns holding the wrong tokens, and no test read tail content because every fixture fed zeros. trimming_the_retained_window_drops_the_oldest_rows_not_the_newest feeds a ramp and closes it — the fifth time on this chain a fixture couldn't reach the condition the code was written for.

Gates: 430 lib tests, 14 synthetic, cargo check --workspace --tests clean, scoped clippy exits 0, zero new clippy diagnostics in the touched files, rustfmt drift 6 before / 6 after (no upstream reformat churn).

@heydryft
heydryft merged commit 120f348 into master Aug 17, 2026
16 checks passed
@heydryft
heydryft deleted the feat/xs-per-sequence branch August 17, 2026 21:42
@heydryft

Copy link
Copy Markdown
Contributor Author

Admin-merged without review (this fork has no second reviewer); all 15 substantive checks green at merge time — 0 pending, 0 failures on head 29a08217.

Merged as the base of the chain, without --delete-branch; #100 was retargeted to master before feat/xs-per-sequence was deleted. That ordering is now D20, and it exists because doing it the other way on #92 auto-closed this PR: GitHub closes a PR whose base ref disappears rather than retargeting it, and then refuses to reopen it while the ref is missing. Recovery took recreating the ref, reopening, retargeting, and deleting again.

Two things from the resolution worth keeping, because they are the kind of detail that evaporates once a PR closes.

The discriminating test actually discriminates, and that was demonstrated rather than asserted. Both mutations were run. Trimming the tensors — the naive "just port it" resolution — is caught by the width assertion. Reconciling only the logical base produces every shape exactly right and is caught only by the per-row read. The doc had claimed the width check would catch both; it was corrected to "only the second" instead of leaving the overclaim standing. A test that distinguishes two failure modes is worth much more than one that catches a single obvious one, but only if you know which of the two it actually distinguishes.

The mutation that survived the first pass had a cause worth naming. trim_tail_to narrowing from the wrong end keeps the right number of columns holding the wrong tokens — and no fixture could see it, because every fixture feeds zero-filled xs. Zeros make "correct tokens" and "wrong tokens" byte-identical. The fix feeds a ramp and reads token identities back.

That is the fifth fixture-reach failure on this chain, and it is a new sub-shape: not "the fixture could not reach the condition" but "the fixture's data made two different states indistinguishable." A fixture can have full coverage of the code path and still be blind, if its values collapse the distinction the assertion depends on. Filed alongside the D18 verification-code sub-pattern.

The semantic conflict with #93 was gated rather than ported or deleted: reconcile_xs_bases takes xs_per_seq and returns early when set, so the flag-off path runs verbatim and #93's two mutation-proven tests pass unchanged — the only edit being .base → .resumable_from(), forced by the field becoming private, an accessor swap and not a behaviour change. trim_tail_to now refuses rows() > 1 with a named reason instead of silently reading base[0].

@heydryft

Copy link
Copy Markdown
Contributor Author

Bypass record — this merge went in over a MISSING required status check. Recording it so it is a decision and not a silent gap.

At merge time the required context on master had just been switched to CI complete, and this PR's head 29a08217 could not produce it: the branch predates #106, so its ci.yml has no ci-complete job. The context was absent, not failing — which is precisely the state #106 exists to make impossible, so it should not pass without a note.

What I read instead, on head 29a08217 — 15 substantive checks, every one success, 0 pending:

Check (ubuntu-latest, stable) · Check (macOS-latest, stable) · Check (windows-latest, stable) · Check (metal) · Test Suite (ubuntu-latest, stable) · Test Suite (macOS-latest, stable) · Test Suite (windows-latest, stable) · Clippy · Rustfmt · Typos · Docs · MSRV Check (1.90) · cargo check (cuda, workspace) · nvcc compile (sm_80) · nvcc compile (sm_90)

Those are the same eight lanes ci-complete aggregates, plus the CUDA lanes, read individually. Equivalent evidence, manually gathered.

Scope of this bypass: the pre-#106 transition only. It is not a precedent. From here the rule is absorb, don't bypass — a child PR merges master in first, which produces a real CI complete, and that is what gets read. #100 onward are going in that way; this PR was the last of the chain merged before the rule was tightened.

The compensating control here was a human reading lanes, which does not survive this session. The absorb rule replaces it with a machine check.

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.

1 participant