Repository navigation
feat(v4): per-row xs compressor state — the keystone three workstreams named - #95
Conversation
…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>
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>
Merged master; the
|
| 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_basestakesxs_per_seqand 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 forresumable_from(), which isbase[0]for a single-row cache.trim_tail_torefuses a multi-row cache rather than readingbase[0]. A scalarnew_basecannot 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).
|
Admin-merged without review (this fork has no second reviewer); all 15 substantive checks green at merge time — 0 pending, 0 failures on head Merged as the base of the chain, without 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. 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: |
|
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 What I read instead, on head
Those are the same eight lanes 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 The compensating control here was a human reading lanes, which does not survive this session. The absorb rule replaces it with a machine check. |
Stacked on #92 (
feat/mtp-per-seq-kv), because it reusesfront_pad_kv_cache's left-alignment and removes #92's first named blocker. Review #92 first.The blocker, in one sentence
PR #90 §6 ("the compressor's
xshistory 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.
comp— completed blocksjis absolute blockjfor every rowtail— retained raw windowtailis exactly #92's case and takes #92's answer.compis 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:🔑 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
iattokens_i = q_i·ratio + r_i, both the slice offset and its width depend onr_ionly; two rows at the same residue need byte-for-byte the same window and differ only in the destination blockq_i. So rows group by residue: ≤ 4 calls under MTP (the verify window bounds the spread), notB. The scatter is bounded to the column band the plan says every write falls in, making itO(B·spread)instead ofO(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
advancedispatches onrows_uniform(), and the uniform branch is the pre-existing scalar code verbatim. The uniform layout is the special caseW == tokens − base, under which end-anchored and start-anchored coincide exactly — so the reinterpretation oftailis 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_batchkept whateverleadthe 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 · workspacecargo check --testsgreen · 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
jcover 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
compcolumn, so the per-row destination was never exercised.two_rows_one_compressor_call_two_destinationsand 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_batchesreturnsErrunconditionally). For V4 that wire needs a per-rowq0, becausedsv4_attentionderives one scalar absolute query position and uses it for both branches; under left-alignment the raw window stays correct (qandkshift together) but the compressed threshold becomes too permissive. A rank-4 caller mask can remove those columns —compose_caller_maskalready accepts rank 4 — so the consuming end is done; the missing piece is the sameinputs_processorwire, 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=1today is that the refusal MOVES.cache_supports_per_sequence_advancestops namingXsRolling;kv_advancestill returnsCohort, 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:
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 ragged mask and must not containXsRolling— 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