Skip to content

measure(arcspec): per-sequence KV advance does not generate at batch — root cause, and the instruments that found it - #114

Merged
heydryft merged 4 commits into
masterfrom
agent/arcspec-perseq-measure
Aug 19, 2026
Merged

heydryft merged 4 commits into
masterfrom
agent/arcspec-perseq-measure

Conversation

@heydryft

@heydryft heydryft commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Measured on hardware first, then written up. H200 SXM5, V4 + qtip2b UQFF, ref be29f397, provenance asserted (the running server's baked git revision: matched the ref this run built, checked per arm).

The headline: per-sequence KV advance does not generate at batch

ARC_V4_XS_PER_SEQ=1 ARC_MTP_PER_SEQ_KV=1 at B=8 returns exactly one token per request, finish_reason: None, on two independent runs. Its own B=1 reference returns full length, and the control at B=8 generates normally.

             token lengths (8 prompts)                  finish_reason
OFF b=1      [48,48,48,48,48,48,48,48]                  all 'length'
ON  b=1      [48,48,48,48,48,48,48,48]                  all 'length'   <- identical to OFF b=1
OFF b=8      [48,48,48,48,18,48,48,48]                  'length' x7, 'stop'
ON  b=8      [ 1, 1, 1, 1, 1, 1, 1, 1]                  all None       <- fails to generate

Root cause — kv_cache/xs_rolling.rs:427, set_row_lens:

xs rolling cache: row 3 holds 9 tokens, fewer than the 11-wide retained window it would have to end at

tail_width() is self.tail.dim(1) — the raw window is a dense [B, W, …] tensor with one W, sized for the longest row. The compressed rows are genuinely per-row (tokens/base); the raw end-anchored window is not. The moment rows diverge, the shortest has t < w, clone_in_cache's set_row_lens refuses, the completion step errors, and the sequence dies after its first commit.

This explains every observation with no further assumption: B=1 works (one row, t == w); B=8 with the flags off works (cohort rollback keeps rows uniform); B=8 with the flags on dies immediately when prompts differ in length; and it is deterministic because it is an invariant check, not a race.

The trigger is prompt-length diversity, not generation length. An earlier sweep drove one identical 40-word prompt to all 8 workers, so rows started uniform and drifted slowly — 2,288 chunks before a row fell behind. The identity harness sent 8 prompts of different lengths and died at step 1.

The trigger is prompt-length diversity, which means this fails harder in real serving than in any benchmark we own, and every uniform-prompt measurement we hold understates it.

Fix direction (not in this PR). The invariant is correct; the representation cannot express what per-sequence advance produces. Ranked:

  1. Per-row extents on the tail — the interim. Front-pad each row into the [B, W] tail and carry per-row valid widths, with the reader masking. This mirrors what front_pad_kv_cache/drop_dead_prefix already do for Normal slots, so the shape of the solution is already in the tree. It is a narrower first step and it is not the general answer.
  2. Segment tables (feat(paged): segment tables — a KV read primitive that is a list of runs #90) — the general answer. A KV read primitive that is a list of runs needs no padding at all, because nothing has to be rectangular. This makes feat(paged): segment tables — a KV read primitive that is a list of runs #90 a prerequisite for per-sequence advance paying off, not an independent workstream sitting behind a flag.

Do not relax the check. Serving a short row's window from columns that are not its own is a wrong answer nothing downstream catches — the FP8-KV failure class this project has already paid for once.

Retracted: the performance numbers from this chain are void

Stated plainly rather than quietly dropped. All of the following were measured against a treatment arm that was erroring its sequences, and none should be carried forward: tok_per_step +13.2% at B=128; −20.3% aggregate at B=32; +156 ms/forward; and the diagnosis that unconditional per-step cache re-assembly (issues_cache_in's || granted) was the cost. The lead_pad decoupling that followed from it is moot until the chain generates.

Three further retractions from the same session, kept because the reasoning is the useful part:

  1. I killed my own headline. The +13.2% at B=128 rested on batch_steps=2 — two engine forwards in a 60 s window — and on a slice where MTP accounted for 15% of the tokens actually emitted (driver 50.0 tok/s vs engine 7.33). The drift control agreeing to −0.3% proves the control is reproducible, not that the row means anything.

  2. I killed the MTP-skip hypothesis I was about to build the story on. The arms log different skip reasons and the ON arm's is per-sequence-specific, which looked like the mechanism. drafted_steps/steps is 100% in every arm at K=1/8/32, so drafting was never skipped; the warning is once-per-process, so its count measures nothing.

  3. I proposed a fix that would have silently disabled the feature. Gating cache re-assembly on rows_uniform() looked like a pure optimization. per_seq_advance requires assembled_here, set only on the In arm, so skipping assembly on a uniform batch means the cohort rule runs, rows never diverge, and the gate never opens. Cost would drop to zero at every batch size, the chart would read as a clean win, and the init line would still say ON.

That third one is why this PR adds per_seq_steps.

What this PR contains

  • ARC_ATTN_BACKEND — names the attention backend once per process. Settles an open question about expert parallelism on hardware: V4 loads attn_sink, so Sdpa::run_attention diverts to sinks_attn on its first line and never reaches the use_nccl() => naive_sdpa gate; flash was unreachable at head_dim 512 anyway. Measured: sinks_attn head_dim=512 flash_sinks_ok=false => unfused matmul + softmax_with_sinks, with the run_attention_noflash REACHED marker absent. An EP=2-vs-EP=1 comparison on V4 is therefore not a comparison of two attention kernels, and does not need a 2xH100 rental to establish that.
  • per_seq_steps — counts decode steps on which per-sequence advance actually ran, versus the once-at-init line that only says it was granted. Granted-but-inert becomes a visible zero rather than an absence.
  • Two harnesses (arc-tools/arcspec_perseq_ab.sh, arc-tools/arcspec_token_identity_b8.sh) that produced these numbers. Both refuse rather than report when they cannot measure honestly: provenance assertion against the server's baked revision, counter-monotonicity, a crash watchdog that marks a cell VOID, owner-matched lock release with evidence-based stale reclaim, and completion graded independently of text.

The token-identity harness is the acceptance gate for the fix: it reproduced this twice, deterministically, in ~8 minutes per run, and passes only when the ON arm generates.

Also found, and independent of this chain

The engine is not batch-invariant with the flags off. Control, greedy, temperature 0, identical prompts: OFF b=8 diverges from OFF b=1 on 4/8 and 5/8 prompts across two runs — full-length generations both sides, different text. Some of this class is ordinary GPU batch-invariance, but it means greedy output is not reproducible across batch composition on this build, and any A/B comparing text at batch is unsound until it is characterised. Needs its own owner; it never touches the per-sequence path (zero occurrences in the control log).

The long-prompt death does not reproduce. 40/128/512/1100-word prompts all streamed clean (0.8/1.2/3.2/6.6 s), past the ~1,055-word point recorded in FACTS. Cost 12 seconds of box time.

Why the CPU tests did not catch any of this

batched_ragged_accept_is_token_identical_to_the_b1_reference and per_sequence_advance_is_token_identical_to_the_b1_reference both pass, unchanged, alongside all 68 mtp tests. They are SimSeq/sim_step arithmetic: fabricated tokens through a scheduling simulation that never builds a KV cache, never calls attention, and never runs the model. They are structurally incapable of observing a ragged-KV defect, so their passing is not evidence. That gap is itself a finding.

@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                     73        25129        18043         4319         2767
 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                  145        15139        12482          811         1846
 Shell                    37         8630         5732         2323          575
 Plain Text                4         3801            0         2479         1322
 TOML                     33         1498         1294           54          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                203        44777            0        34735        10042
 |- BASH                  72         1654         1202          331          121
 |- C                      3           17           17            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                  66         2051         1716           77          258
 |- TOML                   6          207          164            0           43
 |- YAML                   5           41           36            5            0
 (Total)                            50548         4687        35277        10584
─────────────────────────────────────────────────────────────────────────────────
 Rust                    671       324690       280167        16004        28519
 |- Markdown             489        27687          471        23888         3328
 (Total)                           352377       280638        39892        31847
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Total                  1317       484809       346283        87048        51478
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

@heydryft

Copy link
Copy Markdown
Contributor Author

ArcGate: HELD — parked above #104, and the reason is now specific rather than open-ended.

Full analysis on #104. Short form: #104 and #122 (landed as master 006657e05) are two mutually exclusive designs for ragged decode, and they contend on the clone_in_cache cadence —

#122 (on master) #104
dead prefix masked via RAGGED_LEAD_PAD stripped via drop_dead_prefix
clone_in membership change only every step

#104's issues_cache_in forces clone_in_cache every step; #122's deferred strip lives inside clone_in_cache and exists precisely to avoid that (O(B × layers × 2) device copies per token — ~2,200/token at B=47, commit 3adb69477). Land both and those copies come back silently: nothing errors, no test fails, throughput just returns to the pre-#122 number.

Two things that are not true, both checked rather than assumed:

This is scope, not a verdict. Nothing here is ranked down or closeable; the blocker is now one A/B measurement — does #104's design buy anything #122's does not, priced against its per-step rebuild.

Also note: this PR currently shows 1 check, the comment bot — zero CI lanes have ever run on it, because it predates the stacked-PR trigger fix that is now on master. Any push starts the full 16 lanes.

Nothing deleted; branch untouched.

heydryft and others added 4 commits August 19, 2026 11:43
…TN_BACKEND)

Expert parallelism was priced on the belief that building with NCCL silently
disables flash attention for V4 — `attention/mod.rs`'s `use_nccl() =>
naive_sdpa` gate — which would make an EP=2-vs-EP=1 comparison a comparison of
two different attention kernels rather than of expert parallelism.

Reading the dispatch says otherwise for V4, in three steps: V4 loads
`attn_sink`, so `sdpa_params.sinks` is `Some`; `Sdpa::run_attention` therefore
diverts to `sinks_attn` on its first line, before `can_use_flash` and long
before the `use_nccl()` gate, which lives in `run_attention_noflash`; and flash
was never reachable at head_dim 512 regardless (`FA2_MAX_HEAD_DIM` is 256, FA3
takes {64,128,256}, `flash_sinks_ok` takes {64..256}).

That is a code read, and a $9.22/hr 2xH100 pair is too expensive to book on
one. These two `OnceLock`-gated lines make it an observation: one names the
backend `sinks_attn` chose, the other fires only if a model actually reaches
the `use_nccl()` gate. For V4 the second must never appear — presence and
absence are the assertion, rather than a comment asserting it.

No numerics change: two log lines, each emitted once, off the hot path.

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

`kv_advance()` logs "per-sequence KV advance is ON" once, at init, when the
mode is GRANTED. That line has been this project's proof of a live treatment
arm all evening. It does not prove the mode did anything: `per_seq_advance`
(`mtp_pipeline.rs`) is recomputed every step from `assembled_here && post_op ==
Out && kv_advance() == PerSequence`, and can be false on every step while the
grant stands.

The gap is not hypothetical. A proposed optimisation — gate the batched-cache
re-assembly on `rows_uniform()` — would have made `assembled_here` false on
every steady-state step, so the mode would have been permanently inert. Cost
would have dropped to zero at every batch size, the throughput chart would have
read as a clean win, the init line would still have said ON, and every
engagement check in the harness would have passed. It was withdrawn only
because the semantics were read before it was written.

So the counter is the assertion: `per_seq_steps` on the `MTP[...]` marker says
how many decode steps actually applied per-sequence advance. Granted-but-inert
is now a visibly zero field rather than an absence, which is the same shape of
fix as naming the attention backend.

Appended at the end of the marker so parsers that look up named fields are
unaffected; 68 mtp tests pass unchanged, including both CPU token-identity
tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`arcspec_perseq_ab.sh` — per-sequence KV advance ON vs OFF, `tok_per_step`
differenced out of the engine's cumulative `MTP[agg]` counters across a
per-cell wall-clock fence. Two arms in one binary, plus a drift-control arm
that re-runs the control after the treatment so the comparison is bounded for
time drift.

`arcspec_token_identity_b8.sh` — greedy token identity at B=8 against a
strictly sequential B=1 reference, in both arms. The OFF arm is the control and
is not optional: if batching alone changes output, no ON divergence is
attributable.

Both refuse rather than report when they cannot measure honestly, because every
guard here was earned by something that went wrong on hardware tonight:

* provenance — the running server's baked `git revision:` must equal the ref
  the script built. A sibling harness built `-p arc-cli` (binary `arc`) and
  launched `target/release/mistralrs`, a stale binary from an unrelated build,
  guarded only by `[ -x "$BIN" ]`. For an A/B that is the worst failure
  available: both arms run the same stale code, the ratio is ~1.0, and it reads
  as an honest refutation.
* counter monotonicity — a negative delta means the parse crossed a restart, so
  it reports NA rather than the number it would otherwise produce.
* crash watchdog — a cell that crosses a CUDA fault is `VOID`, never a number.
  Note the shape of `crash_count`: `grep -c` prints `0` AND exits 1, so the
  obvious `|| echo 0` emits "0\n0", the integer test errors, and the guard is
  silently dead while looking installed. Found by testing the guard, not
  reading it.
* owner-matched lock release — a lock is only removed if it carries this
  process's own pid. Tonight I deleted another chain's live GPU lock because I
  saw the file, assumed it was my leak, and did not `cat` it first. The owner
  tag was already there; nobody was checking it.
* completion graded independently of text — a diverging control invalidates
  comparing *what* was generated, not *whether* anything was. The first version
  returned "uncontrolled" for everything and so misclassified a one-token
  failure to generate as an unattributable text difference.

Driver counts streamed SSE deltas, not completed requests: at B>=32 V4 finishes
no request inside a 60s window, and a completion-counting driver reports 0.00
tok/s with zero errors, which reads as a crash and is not one.

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

Two defects found by running these harnesses on a shared box, both of the same
shape: a guard that is safe in isolation and wrong in company.

**The lock could deadlock the box permanently.** Owner-matched release — only
remove a lock carrying your own pid — stops one chain deleting another's lock,
which I did tonight by seeing a file, assuming it was my leak, and not reading
it first. But it cannot self-heal: a lock whose owner died, or one created
empty with no owner tag at all, is unremovable by every well-behaved
participant. That is not hypothetical either — a size-0 lock appeared, its
presumed owner was dead, the H200 sat at 0% for six minutes, and the run
waiting on it would have aborted after 40 minutes having measured nothing.

`lock_is_stale` reclaims, but only on evidence and never on one signal alone:
older than a grace period (so a lock caught mid-write is never stolen), AND
empty or its named pid is dead, AND the GPU is idle by both memory and compute
processes. Tested across all five cases, including the two that would do harm:
a lock owned by a live pid is never taken, and a dead owner with a busy GPU is
never taken.

**The comparator misclassified the failure it exists to catch.** It graded only
text identity, so a diverging control returned "uncontrolled" for everything —
including an arm that returned one token with no `finish_reason` while its own
B=1 reference returned full-length. A diverging control invalidates comparing
WHAT was generated; it does not invalidate comparing WHETHER anything was.
Completion is now graded on its own axis and reported first.

Also worth recording, because `bash -n` did not catch it: an earlier attempt to
lift the lock block between scripts by regex deleted `acquire_lock`'s
definition while leaving its call site. The script parsed clean — `bash -n`
checks syntax, not name resolution — and would have failed at runtime on the
box. Verified here by running the script far enough to acquire and release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@heydryft
heydryft force-pushed the agent/arcspec-perseq-measure branch from 612b3a1 to c2e2b16 Compare August 19, 2026 11:09
@heydryft
heydryft changed the base branch from feat/batch-admission-ragged to master August 19, 2026 11:09
@heydryft

Copy link
Copy Markdown
Contributor Author

Rebased onto master (--onto origin/master ab42c4508, i.e. this PR's own 4 commits only — #104's ab42c4508 was dropped). Base retargeted to master. New tip c2e2b167b.

Verified independent of #104: neither arcspec_perseq_ab.sh nor arcspec_token_identity_b8.sh touches the ragged-admission surface, and the two instrumentation commits compile and pass against master unchanged.

CARGO_TARGET_DIR=/tmp/arc-shared-target \
  cargo test -p mistralrs-core -p mistralrs-quant -p mistralrs-vision --lib --tests
→ 903 passed, 0 failed (577+11+7+14+285+5+2+2), 8 ignored

(The two --doc failures seen on a first run were a shared-target-dir artifact — a concurrent build had removed libmistralrs_core-*.rlib — not a defect in this branch.)

Two conflicts resolved — both because master moved the code this instrumented

  1. attention/mod.rs — run_attention_noflash's if mask.rank()==2 || use_nccl() { return naive_sdpa(...) } early-return no longer exists on master; it was folded into ArcFlash's cublaslt_ok predicate. Resolved to master's side (the removed block was not re-introduced) and the ARC_ATTN_BACKEND: run_attention_noflash REACHED log moved to the top of the function, which is what "REACHED" was always meant to mean — at the old insertion point an ArcFlash/Tile dispatch returns before reaching it. Message text updated to name the ArcFlash/cuBLASLt dispatch instead of the deleted naive_sdpa gate.

  2. attention/backends/sinks.rs — master split flash_sinks_ok into flash_sinks_ok_cuda / flash_sinks_ok_metal (Metal's kernel stops at 256; CUDA admits 512 under flash_512_enabled()). Git auto-merged the log block into a scope where flash_sinks_ok no longer exists — error[E0425]: cannot find value 'flash_sinks_ok'. The log now reports both flags and resolves the verdict per device. The commit's claim that "flash was never reachable at head_dim 512" is no longer true on master and was corrected rather than carried forward.

  3. pipeline/mtp_pipeline.rs — additive conflict on the MTP[...] marker format string against master's p_pos/accepted_pos/reached_pos/pos_over_cap fields. Both kept; per_seq_steps stays last, as the commit intended.

@heydryft heydryft closed this Aug 19, 2026
@heydryft heydryft reopened this Aug 19, 2026
@heydryft

Copy link
Copy Markdown
Contributor Author

This PR was still in the false-green state after being rebased. Now fixed — and it exposes a limit of the new guard.

Rebased onto master and retargeted, but its checks were:

comment = success

That was the only one. No Continuous integration run existed for head c2e2b167 at all — not a failing one, not a queued one. None.

Why

Two events had to line up:

  1. The rebase force-push happened while the PR's merge ref was still built from the old base, whose ci.yml carried the narrow branches: - master filter. Base was another PR's branch, so the trigger matched nothing and no run was created.
  2. Retargeting the base to master afterwards fires pull_request: edited — which is not in the default type set (opened, synchronize, reopened). So nothing re-triggered.

analysis.yaml still ran because it is pull_request_target, which is why exactly one green check appeared. That is the same shape as the original defect.

Fixed by close/reopen, which fires reopened. CI is now running against the current merge ref: run 32254466145.

The limit this exposes, worth stating plainly

The base-branch lane added in #164 cannot catch this case, by construction: it lives inside ci.yml, so if ci.yml never triggers, the guard never runs either. A guard inside the workflow can only police runs that happen.

The thing that actually protects the merge is branch protection requiring CI complete — a PR with no ci-complete check is BLOCKED and cannot be merged regardless of how green it looks. That held here, and it is why this was never mergeable despite reading clean.

So the two layers are complementary and neither is redundant:

layer catches
base-branch lane the workflow ran, but against a base that will never land
required CI complete the workflow did not run at all

The residual gap is visual, not procedural: a reviewer scanning the checks list still sees one green check and no red. Worth a follow-up if that matters — but it cannot be closed from inside ci.yml.

@heydryft
heydryft merged commit 1b55a10 into master Aug 19, 2026
18 checks passed
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