Skip to content

Arc → OpenRouter-ready: the single integration PR (everything else merges into this branch) - #194

Draft
heydryft wants to merge 21 commits into
masterfrom
release/openrouter-ready
Draft

heydryft wants to merge 21 commits into
masterfrom
release/openrouter-ready

Conversation

@heydryft

@heydryft heydryft commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

Arc → OpenRouter-ready: the single integration PR

"I need only 1 PR open — the openrouter one, which gets everything done. I don't want 21 PRs. Multiple agents, working in their own branches, their work landing in this one openrouter PR."

This is that PR. release/openrouter-ready is the integration branch, and this is the only pull request open against master. Every other open PR targets the integration branch and merges into it; when the branch is ready, this one merge decision ships all of it.

How to work in this model

Master stays protected

Widening that lane did not weaken master.

release/openrouter-ready reaches master through exactly one pull request — this one — and this PR's base is master, so it takes the master arm of the same check every other PR does. CI complete is still the single required status check in branch protection and still aggregates base-branch in its needs. Nothing enters master without a full run whose base is master.

This branch was re-cut, because merging the old one would have caused a regression

The previous release/openrouter-ready had diverged from master by ~40 commits in master's favour. Merging it as it stood would have reverted TCFRAG (#203) and re-broken the flag-polarity work (#212).

It has been force-updated to current master plus only the work it uniquely carried, so the diff below is genuinely additive.

What this branch carries today

Everything on master, plus five commits:

commit what
docs: the single OpenRouter-readiness gate creates docs/engineering/OPENROUTER_READY.md — 16 items across blocking / product / trust / commercial, each with measured provenance
docs: the gate is 14,000 tok/s aggregate, and the scope is Arc complete rewrites it: 1,400 aggregate is break-even and therefore pointless; 14,000 is $50/GPU-hr against a $4.92 card
test(ArcLab): batch sweep arc-tools/batch_sweep.py — fires B concurrent streaming requests and reports per-request vs aggregate with achieved concurrency, so the two anchors can be read together, plus memory-controller busy % sampled during decode
fix(ArcQuant): the MoE gate never selected the grouped GEMM in serving the keystone — see below
fix(ArcQuant): same 683-token gate on the LUT rung the same retraction on qtip2, which is the rung the measurements actually exercised

The keystone: the grouped GEMM was unreachable in real serving

Both QTIP rungs gated the trellis grouped GEMM on tile occupancy — switch only once each woken expert draws GROUPED_TILE_M (=16) pairs. At V4's top-6-of-256 routing that needs 6n/256 >= 16, i.e. n ≥ 683 tokens, which decode never reaches. The clause pinned every decode step, and every batch up to 682, on the per-pair gather-GEMV arm.

Tile fill was the wrong quantity. At n=16 the tile is 7.5% full and the grouped kernel still wins by 16%. The real mechanism is that the grouped kernel stages a woken expert's bytes once per m-tile instead of once per (token, expert) pair — the GEMV arm's reads are exactly linear in n_tokens, 12.00× redundant at n=512, while the grouped arm holds flat at one pass over the expert set — and that it runs on tensor cores.

DECODE_REGIME_MAX_TOKENS survives as a floor (b=1 measures 0.47×, and it is the RUN-161 capture-safety rule). ARC_QTIP_ONDEVICE_MOE_MAX_TOKENS still pins the GEMV arm for A/B, and ARC_NO_QTIP_GROUPED_MOE remains the kill switch.

The decision moved into gather_policy::prefer_gather_gemv specifically so it can be unit-tested — the dispatch site is inside #[cfg(feature = "cuda")], where a non-CUDA cargo test would otherwise report green over code it never compiled. The tests pin every measured batch size to the grouped arm and carry a control asserting the retired clause would have excluded all of them.

Carried forward without regression

The gate retraction was authored (#197) before #212 landed, and its original diff would have reverted ARC_NO_QTIP_ONDEVICE_MOE from #212's by-value parser back to a presence check. That was resolved in #212's favour: the cherry-pick keeps crate::env_flag_is_set(...), so ARC_NO_QTIP_ONDEVICE_MOE=0 still means off.

Verified on the re-cut branch:

  • TCFRAG remains opt-in — tcfrag2b.rs still selects on value == Some("1").
  • fix(ArcGate): read boolean ARC_* flags by value, so =0 means off #212's env_flag_is_set conversions are intact — the count is unchanged from master in every touched file, and the only env-flag line in the whole diff is an explanatory comment.
  • mistralrs-quant/src/qtip/tcfrag2b.rs and mistralrs-quant/src/env_flag.rs are not touched at all by this branch.
  • cargo check -p mistralrs-quant clean; all 12 gather_policy tests pass, including the_grouped_gemm_is_reachable_at_decode_batch_sizes and the the_retired_tile_fill_clause_would_have_excluded_all_of_them control.

Branch facts

base commit (re-cut from) 8ae209008 — current master, which already carries #212, #203/TCFRAG, #205, #213, #214, #215 and the CI rule change #216
branch tip d665956ab
commits ahead of master 5 (previously the branch was ~40 commits behind master)
files touched vs master 5 — arc-tools/batch_sweep.py, docs/engineering/OPENROUTER_READY.md, qtip/bitshift.rs, qtip/gather_policy.rs, qtip/mod.rs

Regression proofs on the re-cut branch (git merge-base --is-ancestor <c> HEAD → 0 for each):

mistralrs-quant/src/qtip/tcfrag2b.rs and mistralrs-quant/src/env_flag.rs are not in the touched-file list at all, so neither can have regressed.

The rest of the queue

Every other open PR now targets this branch. Four of them were stacks whose bottom PR was their base; they are now siblings, so merge order is load-bearing and no longer enforced by the base ref — each carries a note saying whether it is the bottom or the top:

Two are red for real defects rather than for addressing, and are labelled needs-work: #184 (genuine test failures) and #108 (a real fat-binary failure). Do not read a green base check on those as readiness.

@github-actions

github-actions Bot commented Aug 20, 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                     81        29475        20161         6275         3039
 Dockerfile                1           39           22            8            9
 JavaScript               16         3546         2676          482          388
 Jinja2                    7          694          656            5           33
 JSON                     75         4896         4893            0            3
 Makefile                  1            6            5            0            1
 Metal Shading Lan|       33        12224         9431         1142         1651
 PowerShell                1          300          227           30           43
 Python                  147        15371        12677          824         1870
 Shell                    42        10304         6858         2769          677
 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                213        48932            0        38081        10851
 |- BASH                  72         1655         1203          331          121
 |- C                      4           19           19            0            0
 |- CUDA                   2           84           56           16           12
 |- JSON                  19          779          779            0            0
 |- PowerShell             1            1            1            0            0
 |- Python                23         1008          787          113          108
 |- Rust                  68         2063         1727           78          258
 |- TOML                   6          207          164            0           43
 |- YAML                   5           41           36            5            0
 (Total)                            54789         4772        38624        11393
─────────────────────────────────────────────────────────────────────────────────
 Rust                    681       340988       293252        18096        29640
 |- Markdown             504        32562          471        28063         4028
 (Total)                           373550       293723        46159        33668
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Total                  1353       516771       363188        99077        54506
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

@heydryft

Copy link
Copy Markdown
Contributor Author

Batch sweep on landed master d7742670a — H200, V4-Flash qtip2

One server (--max-seqs 512), one binary, one config. Per-request is a median of
inter-token intervals (prefill excluded). agg measured is cohort tokens over
cohort span; conc is achieved concurrency integrated over that span.

B per-req tok/s ms/tok agg derived agg measured spread MEM-CTRL % (med/max) conc VRAM free
1 36.68 27.3 36.68 36.39 0.8% 10 / 10 1 51,748
8 6.45 155.0 51.62 43.70 15.3% 3 / 4 7 51,748
32 2.43 411.6 77.76 62.71 19.3% 3 / 12 28 51,748
64 1.61 622.0 102.89 68.63 33.3% 3 / 21 53 51,140
128 0.82 1213.1 105.52 72.63 31.2% 3 / 38 106 44,804
256 0.36 2802.3 91.35 79.86 12.6% 3 / 70 216 38,116
512 0.18 5471.2 93.58 87.32 6.7% 10 / 100 478 15,876

There is no knee

Aggregate rises 36.39 → 87.32 tok/s (×2.40) for 512× the users. Per-request
retention falls to 0.5%. The curve is flat-then-flatter; the amortisation regime
never starts anywhere up to B=512.

The memory controller is the finding

Median memory-controller utilisation is 3% at B=8 through B=256, and 10% at
B=512 — while GPU utilisation reads 99–100%.
It does not climb with batch. At
b=1 it is 10%, and adding users makes it lower, not higher.

Per the stated criterion — "if it stays near 4% at B=256, batching is structurally
broken" — it stays at 3%. A GPU at 100% utilisation with a 3%-busy memory
controller is saturated with small kernels, not with weight traffic. 256 or 512
concurrent users are not sharing the 74 GB read; the engine is re-doing per-token
work that batching should have amortised.

What it is not

  • Not an OOM or a capacity cap. 15.9 GB still free at B=512; no row failed.
  • Not scheduler admission. Achieved concurrency tracks B (478/512).
  • Not the kernels being individually slow. They are individually tiny.

derived vs measured diverges most at B=64/128 (33%, 31%) precisely where
achieved concurrency lags B (53/64, 106/128) — which is why the concurrency
column has to be reported next to any aggregate number.

Against the gate

The target is 14,000 tok/s aggregate. Measured 87.32 at B=512 — 0.6% of the
gate, ~160× short
, and ~0.3% of the ~33,000 tok/s roofline at that batch. On
this evidence 14K is not reachable by kernel tuning; it needs the expert path to
actually batch.

Harness: arc-tools/batch_sweep.py (this PR). Zero-token rows fail loudly and are
never averaged in — no number here came from the requested-token formula.

@heydryft

Copy link
Copy Markdown
Contributor Author

The missing knee has a named cause: the #167 gate never selects the grouped GEMM in decode

Landed master d7742670a, H200, V4-Flash qtip2b. One binary, one env toggle
(ARC_NO_QTIP_ONDEVICE_MOE=1 disables the gather arm and forces the grouped GEMM).

1. The gate's arithmetic makes the grouped path unreachable in decode

grouped_gemm_tiles_amortize = expected_pairs_per_distinct_expert(n, top_k, E) >= GROUPED_TILE_M,
with GROUPED_TILE_M = 16. At V4's top-6-of-256 routing, pairs-per-woken-expert
is 6n/256 once the experts saturate, so the predicate needs

6n / 256 >= 16 → n >= 683

Decode never has 683 tokens in a step. DECODE_REGIME_MAX_TOKENS = 8 covers the bottom,
and the !amortize clause covers everything from 9 to 682 — so every batch I swept
(32/128/512) takes the gather-GEMV arm.

2. Proven from the mangled symbol, not from re-reading the condition

nsys --cuda-graph-trace=node on the default path at B=128:

kernel time instances
qtip_gather_gemv_warp_kernel<__nv_bfloat16> 57.6% 3,225
qtip_lut_grouped_gemm_kernel<__nv_bfloat16> 11.4% 129

The gather kernel is 57.6% of all GPU time and 25× more launches. The grouped kernel
exists and runs (the machinery is present, as expected for qtip2b) — it is simply not the
one doing the MoE work.

3. Forcing the grouped path is worth up to 1.66×, free

B default (gather-GEMV) forced grouped gain MEM-CTRL def → forced
1 36.39 17.10 0.47× 10% → —
32 62.71 57.47 0.92× 3% → 2%
128 72.63 120.36 1.66× 3% → 2%
512 87.32 131.49 1.51× 10% → 1%

Per-request improves too: B=512 goes 0.18 → 0.34 tok/s (1.9×). The b=1 and B=32 rows show
the gate is right at small n — so this is a crossover that is set ~10× too high, not a
kernel to replace. The true crossover sits between 32 and 128, not at 683.

That is a gate condition, and it is the cheapest 1.5× on the board.

4. But the memory controller still does not climb — so this is not the whole answer

Median memory-controller utilisation is 1–2% on the forced-grouped arm, versus 3% on
the default, while GPU utilisation is 100% in both. Forcing the amortising kernel raised
throughput 1.66× and moved the memory controller not at all.

Per the stated decision rule: the grouped path is not grouping either. Fixing the gate
buys a real, immediate 1.5–1.66× at B≥128, but 131 tok/s is still ~107× short of the
14,000 gate and the card is still nowhere near its weight-read bound. The remaining work is
a proper token→expert sort + genuinely batched GEMM — the SGLang fused-MoE shape — not a
tuning constant.

Two named causes now, in priority order:

  1. grouped_gemm_tiles_amortize demands n≥683 — unreachable in decode. Free 1.5–1.66×.
  2. Neither MoE kernel is memory-bound at any batch size — the structural item.

Harness: arc-tools/batch_sweep.py. Zero-token rows fail loudly; no number here came from
the requested-token formula.

heydryft added a commit that referenced this pull request Aug 21, 2026
…hould permit (#216)

Seven open PRs are red for their ADDRESSING, not their quality. The
`base-branch` lane hard-fails any PR whose base is not literally `master`,
and the PR queue is being restructured so that agents branch off a single
integration branch, `release/openrouter-ready`, and PR back into it.

The lane's intent is kept intact. It was never really asking "is the base
literally master" — it was asking "is the base a stable branch that will
itself land on master through a PR this lane has gated". `master` satisfies
that. So does exactly one other branch, by construction:
`release/openrouter-ready` reaches master through exactly one pull request
(#194), whose own base IS `master`, so it takes the `master` arm of this
same check and must be green on a tree whose parent is proven master.

Master's protection is therefore unchanged: nothing enters master without a
full run whose base is master, and `CI complete` still aggregates this lane.

The original incident was PRs based on another PR's TRANSIENT branch — a base
that can be rebased, force-pushed or abandoned underneath them, so their green
described a tree that might never exist. An integration branch is the opposite:
long-lived, the tree that will exist, and re-proven against master by #194
before any of it ships.

Implementation notes:
- Exact-match allowlist via `case`, not a `release/*` glob. A prefix match
  would let any newly pushed `release/anything` declare itself a landing
  branch, which is the same unproven-base hole under a new name.
- `BASE_REF` still arrives via `env:`, never interpolated into the script.
- The job name (`Base branch`) is unchanged, so branch protection and the
  `ci-complete` needs list keep matching.

Verified: the allowlist accepts `master` and `release/openrouter-ready` and
refuses `main`, `release/something-else`, `release/openrouter-ready-evil`,
`xrelease/openrouter-ready`, `MASTER`, `master*`, `master; echo pwned`, the
empty base, and every current PR base branch in the queue.


Claude-Session: https://claude.ai/code/session_01UMmjFy8TvsgypxVWNVhhC7

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nirupam Bhowmick and others added 5 commits August 21, 2026 16:35
One branch, one PR, one merge decision. Merging it means Arc can serve
and sell tokens on OpenRouter. 16 items across blocking / product /
trust / commercial, each with its measured provenance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jish, direct: 1,400 aggregate is break-even and therefore pointless.
14,000 is $50/GPU-hr against a $4.92 card - 10x margin, a business.

And 'OpenRouter-releasable' means Arc as we envisioned it, not a defect
list. Rewritten accordingly: batching that amortises, the engine near
the silicon, prefill as a product, the memory system, the multipliers
we built and never switched on, and instruments we can trust.

Key arithmetic: 14K is 84% of roofline at B=256 but only 42% at B=512,
because memory traffic per step is flat as batch grows. 14K is a
batching problem, not a kernel-speed problem - and batching is broken
precisely because we are instruction-bound at 4% memory utilisation.

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

Measures what a capacity claim actually rests on: fire B concurrent streaming
requests and time the inter-token intervals of each.

Two aggregate anchors that must be read together, because they disagree exactly
when the batch never formed:
  * derived  = per_request * B   -- assumes all B ran at the median rate
  * measured = cohort tokens / cohort span  -- what the box actually delivered
Achieved concurrency is reported beside them, integrated over the span. At
B=64 the scheduler only ever held ~53 streams, so `derived` overstates by 33%;
without the concurrency column that gap is invisible and the higher number is
the one that gets quoted.

Samples `utilization.memory` DURING the decode, not after. That is the
memory-controller busy percentage, and it is the diagnostic that separates
"batching is amortising the weight reads" from "the GPU looks busy running tiny
kernels". A sampler started after the run writes an empty file and looks green,
so this one starts before the requests and stamps every sample.

A row where any request returns zero tokens is printed as FAILED and never
averaged into the table.

Measured on landed master d774267, H200, V4-Flash qtip2 (see PR discussion):
aggregate rises only 36.4 -> 87.3 tok/s from B=1 to B=512 (x2.40 for 512x the
users) while the memory controller sits at 3% median through B=256.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Qtip2bLayer::gather_forward` gated the trellis grouped GEMM on tile occupancy:
switch only once each woken expert draws `GROUPED_TILE_M` (=16) pairs. At V4's
top-6-of-256 routing that needs `6n/256 >= 16`, i.e. **n >= 683** — a token
count decode never reaches. The clause therefore pinned every decode step, and
every batch up to 682, on the per-pair gather-GEMV arm. A profile of the
shipped default shows the consequence directly: `qtip_gather_gemv_warp_kernel`
at 57.6% of GPU time with 25x the launches of the grouped kernel.

Two independent measurements retire it. Decode on an H200, clean rows only
(>=97% achieved concurrency, <7% derived-vs-measured spread): grouped wins at
B=48 1.10x, 64 1.14x, 80 1.22x, 96 1.29x, 128 1.66x, 512 1.51x. Prefill over
prompt length: +16% at 16 tokens, +27% at 32, +50% at 64, +78% at 128, +181%
at 512. The gate's own justifying A/B -- "1.00x at N=128 and N=512" -- does
not reproduce.

**Tile fill was the wrong quantity.** At n=16 the tile is 7.5% full and the
grouped kernel still wins by 16%, so a predicate demanding a full tile cannot
be the gate. The win is that the grouped kernel stages a woken expert's bytes
once per m-tile instead of once per (token, expert) pair -- the GEMV arm's
reads are exactly linear in n_tokens, 12.00x redundant at n=512, while the
grouped arm holds flat at one pass over the expert set -- and that it runs on
tensor cores. Occupancy was never the mechanism being traded against.

`DECODE_REGIME_MAX_TOKENS` survives as a floor: below it there is nothing to
group (b=1 measures 0.47x) and it is the RUN-161 capture-safety rule. 9..=15
is unmeasured, bounded by that floor and by the +16% at 16, and rides with the
grouped arm. Fast path by default; `ARC_NO_QTIP_GROUPED_MOE` remains the kill
switch and `ARC_QTIP_ONDEVICE_MOE_MAX_TOKENS` still pins the GEMV arm for A/B.

The decision moves into `prefer_gather_gemv` so it can be tested: the dispatch
site is inside `#[cfg(feature = "cuda")]`, and a non-CUDA `cargo test` reports
a full green over code it never compiled -- which it did to me once during this
change. The tests pin every measured batch size to the grouped arm and carry a
control asserting the retired clause would have excluded all of them.

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

The previous commit removed the tile-fill conjunct from the bitshift rung
(`Qtip2bLayer`, --isq qtip2b). The LUT rung (`QtipLayer`, --isq qtip2) carries
the identical clause at mod.rs:3652, and the published V4-Flash artifact this
was measured against is **qtip2**, not qtip2b: the profile's kernels are
`qtip_lut_grouped_gemm_kernel` and `qtip_gather_gemv_warp_kernel`.

So every decode number in the sweep — GEMV 78.46 vs grouped 89.37 at B=64,
72.63 vs 120.36 at B=128 — came from this rung, and fixing only the bitshift
rung changed nothing on the box: the default still read 81.17 at B=128 after
that build. Fixing the rung the measurement actually exercised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@heydryft
heydryft force-pushed the release/openrouter-ready branch from 17597e6 to d665956 Compare August 21, 2026 15:36
@heydryft heydryft changed the title Arc → OpenRouter-ready: the single gate for selling tokens Arc → OpenRouter-ready: the single integration PR (everything else merges into this branch) Aug 21, 2026
heydryft and others added 16 commits August 21, 2026 17:46
…#170)

* feat(qtip): UQFF geometry discriminator — K8/V4/L12 artifacts declare themselves

Stage 2 of the K=8/V=4/L=12 rung: an artifact can now say which trellis
geometry it was baked at, the loader reads it, and a build that cannot decode
it refuses instead of guessing.

WHY THIS NEEDS A DISCRIMINATOR AT ALL
Both geometries are 2 bits per weight (`bpw = K/V`), so a K=8/V=4 row and a
K=4/V=2 row of the same `in_features` occupy the SAME number of packed bytes.
A mislabelled artifact therefore indexes in bounds at every symbol and returns
plausible garbage rather than faulting. The table is the only tensor that
differs — `[4096, 4]` BF16 vs `[65536, 2]` F32 — which is why `validate_shapes`
checks the table's dtype AND its length, and why both checks are load-bearing
rather than belt-and-braces.

WIRE FORMAT
A trailing section `[tag=3, K, L, V]`, written ONLY for a non-default geometry
and written BEFORE the codebook section. Both of those are deliberate:

  * Default writes nothing, so every artifact Arc has already produced stays
    byte-identical and no checksum moves. Pinned as an exact suffix, not as a
    vague "unchanged": serializing the same layer with the tag flipped appends
    exactly [3, 8, 12, 4] and nothing else.
  * BEFORE the codebook, because a build that predates this field parses the
    trailing region by handing its first byte to `QtipCodebook::from_wire`,
    which refuses every tag it does not know. Tag 3 first means an old Arc
    fails closed. Tag 3 last would let it consume the codebook section, stop,
    and decode K=8/V=4 symbols as K=4/V=2 without faulting.

The trailing region is now a section loop; a repeated tag is refused rather
than letting the last one win.

`QtipGeometry` has no per-site default: adding the field broke all 11
construction sites at compile time and each one now states its geometry, the
same discipline `search` and `search_detail` already follow. `from_stacked_parts`
takes it explicitly and validates it. `stack_experts` and the 3-D quantize path
refuse mixed-geometry stacks — only one table survives a stack, so at most one
expert could decode.

The computed `sum2` codebook is refused at V=4 on both the write and the read
side: it produces a PAIR of values per state and has no V=4 form.

Guards were mutation-tested (F1-F9). Three passed on broken code and are now
covered: `from_stacked_parts` validated nothing, the table-dtype half of the
discriminator was dead (the element-count check masked it), and
`stack_experts`' mixed-geometry refusal was untested. `assert_tensor_bits_eq`
grew a BF16 arm — it panicked on the new table rather than comparing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpVNMpb13HkUXqSqbN1o9H

* fix(qtip): refuse an unsupported geometry at every decode entry point

Stage 2 made a K=8/V=4/L=12 artifact loadable. On its own that is a
half-applied change: every decoder behind `forward`, `gather_forward`,
`dequantize_weights`, `dequantize_expert` and `qtip_packed` unpacks nibbles and
indexes a [2^16, 2] table, and handed a K=8 row none of them would fault — at
2 bits per weight the packed byte count is identical and every index lands in
bounds. They would serve garbage.

So each entry point now states the geometries it implements. `qtip_packed`
returns None rather than a `QtipPackedView`, which carries no geometry field
and would therefore be read as K=4/V=2 nibbles by any consumer.

The refusal names the entry point, and the test asserts that name. An entry
point that merely inherits a callee's guard is not guarded: mutation G1 removed
`forward`'s own check and every test stayed green, because the error still
arrived from `dequantize_weights` further down. All six mutations (G1-G6) are
red now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpVNMpb13HkUXqSqbN1o9H

* fix(qtip): UQFF validates the PADDED row stride, not the data length

Follows the padding decision one branch down. `QtipGeometry::packed_len` now
means the allocated stride (what a tensor is shaped at and what this format
validates) and `data_bytes` is the bit-rate-governed part, both delegating to
`trellis_v4l12::Rung` so there is still one implementation of each.

Three tests changed premise rather than value and were rewritten, not patched:
the bit-rate ratio claim moved to `data_bytes` where it is exact, and the
stride got its own bounds (never smaller than the data, never more than 4 bytes
larger). A K=9 row of `in_features=36` is 11 data bytes in a 12-byte stride.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpVNMpb13HkUXqSqbN1o9H

* fix(qtip): the CUDA bake fast path lost the geometry wire tag

`quantize_with_options_cuda` builds a QtipLayer directly and was the one
initializer the geometry discriminator missed. It is behind
`#[cfg(feature = "cuda")]`, so the default Check jobs compiled fine and
only the CUDA lane went red (E0063 at qtip/mod.rs:2318).

Set the same K4V2L16 tag its CPU sibling sets — the CUDA bake kernels run
the identical K=4/V=2/L=16 trellis, so a GPU-baked artifact must carry the
identical tag or it would deserialize as a different geometry.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lackwell (#94)

* feat(turboquant): instantiate the CUDA kernels at head_dim 64/128/256/512

TurboQuant's paged kernels were hardcoded to head_dim 128 — `constexpr int
HS = 128`, a single `SGN[128]` sign table, `rotate128`, and six `extern "C"`
launchers each opening with `if (hs != 128) return;`. That early return is a
silent no-op: the output buffer is left uninitialized, so a mismatched head
dim produces garbage *fast* rather than failing.

Kernels
- `tq_attn`, `tq_attn_clocked`, `tq_cache_k`, `tq_cache_v` and the two clocked
  cache variants now take HS as a template parameter. NT stays 128 for every
  HS, so the warp-reduction topology is unchanged; wider heads give each
  thread DPT = HS/NT output dims instead of adding threads. At HS=128 the
  emitted work is the same as before.
- The per-lane packed-K gather is now one vector load (`tq_load_klane`)
  instead of BPL scalar byte loads. The x=16 cache swizzle keeps each lane's
  run contiguous and naturally aligned, which `packed_k_bytes_are_swizzle_aligned`
  asserts.
- Every `if (hs != 128) return;` is replaced by a dispatch over the
  instantiated widths.

D16 dual-arch
- `TQ_PREFETCH` software-pipelines the K gather on SM90/SM100/SM103 and runs
  flat below Hopper — same arithmetic, scheduled for the part it runs on.
- Launches now opt in to the arch's real dynamic shared-memory budget. The
  logits array scales with context length and CUDA caps dynamic shared memory
  at 48 KB without an explicit opt-in, so contexts past ~12k tokens could not
  launch at all on any arch.
- `ARC_CUDA_ARCHS=90,100,103` emits cubins for Hopper *and* Blackwell rather
  than only the build box's GPU.

Tables
- Codebooks and sign diagonals are dimension-dependent, so 512 needs its own.
  All 20 tables are emitted by `emit_turboquant_cuda_tables`, which calls the
  same `generate_signs`/`get_codebook` the Rust compressor uses — correct by
  construction, not by transcription. The generator reproduces the shipped
  d=128 table byte-for-byte.
- `turboquant::cuda_tables` re-derives every table at test time and diffs the
  checked-in header, and pins the Rust head-dim list against both the kernel's
  dispatch switch and the FFI wrapper's list. Nothing previously asserted that
  the tree's four copies of the sign table agreed with the generator or with
  each other.

Gates
- `TURBOQUANT_HEAD_DIM = 128` exact-match becomes membership in
  `TURBOQUANT_CUDA_HEAD_DIMS`; K and V are compressed independently and no
  longer have to be equal widths.

Not measured on a GPU: the kernels are written and gated but this box has no
nvcc, so nothing here is a claim about hardware behaviour.

* test(gpu): wave63 gate — dual-arch compile proof for the head_dim 512 kernels

Proves the four head-dim instantiations compile for sm_90a AND sm_100a/sm_103a
(D16), not just the build box's GPU, and that the shipping decode path did not
regress. Deliberately contains no quality A/B: TurboQuant quality is settled by
prior measurement and re-proving it would spend GPU budget on a known number.

* fix(build): watch turbo_paged_attention.cu for changes

Every other .cu in this crate had a rerun-if-changed line; this one did not.
Emitting any rerun-if-changed disables cargo's watch-the-whole-package default,
so edits to the largest CUDA file in the crate were relying entirely on
cudaforge's object cache to trigger a rebuild.

* fix(turboquant): 64-bit cache offsets, per-device smem opt-in, clocked-kernel parity

Three problems from review of the head_dim generalization.

1. 32-bit overflow on cache byte offsets. `pb*kbs`, `pb*vbs`, `bi*cbs` and
   `bi*vbs` were all `int`. At head_dim 512 with nkvh=8 and block_size=32,
   `kbs` is 65536 B, so `pb*kbs` wraps once the K cache passes 2 GB — an
   entirely reachable allocation on an H200, and 4x sooner than at 128. Now
   computed in `long long` at all eight sites.

2. The shared-memory opt-in cached a process-wide `granted` flag per kernel
   instantiation. The requestable maximum differs by architecture, so one flag
   cannot be right across a mixed fleet, and a failed request left `granted`
   at 0 and fell through to a launch that then failed with no diagnostic. The
   cache is gone; the call is cheap and now always attempted.

3. `tq_attn_clocked` kept the scalar byte gather while `tq_attn` moved to a
   vector load plus the arch-gated pipeline. Identical results, but the phase-2
   stamps would have described a kernel nobody launches — at head_dim 512 that
   is 8 scalar loads measured against the 1 vector load actually executed. The
   loop now mirrors `tq_attn`.

Verified without nvcc by type-checking the kernel bodies against a CUDA shim
(clang++ -Wall -Wextra -Wshadow, both __CUDA_ARCH__ branches): clean. Still not
run on a GPU.

* fix(gate): D18 exit codes, branch-independent preflight, source-landmine guard

The gate exited 1 when gpu_box_preflight.sh was absent. That read as a code
failure for a couple of minutes when it was really 'this machine cannot
answer'. Per D18 rule 2: 0 pass, 1 genuine failure (the only signal to act
on), 2 environment could not answer.

Nine conditions now exit 2 — missing/failing/incomplete preflight, no repo, no
nvcc, unreachable remote, un-checkout-able branch, no archive to inspect, no
cuobjdump. Four exit 1, all genuine: nvcc compile failure, a native build
failure, a test failure, and the archive missing an arch it claims to carry.

The preflight is now looked up at ARC_PREFLIGHT, then /root/arc-tools, then
/usr/local/lib/arc, and only last inside the repo. It lives on
wave61/box-preflight-shared-prefix, so a repo-relative path vanishes the moment
a runner checks out any other branch — which is exactly how this gate came to
refuse to run.

Guarded the sourced-preflight landmine directly. gpu_box_preflight.sh ends on

    [ "$_arc_pf_sourced" = "1" ] || exit 1

so when SOURCED on a *failing* box that test succeeds, short-circuits the
'|| exit 1', and becomes the last command — 'source pf || handler' returns 0
and the handler never fires. The gate ignores the return value entirely and
reads _ARC_PF_FAILED, treating an unset flag as 'did not reach a verdict'
rather than as a pass.

Also reordered so the sm_90a+sm_100a+sm_103a compile is STEP 1 and needs no
full workspace build, and it now asserts on cuobjdump output that both cubins
are actually in the archive — a build that merely succeeds is not the claim.

Exit paths verified locally against a stub reproducing the real preflight's
sourced-exit structure: all four environment cases return 2.

* fix(paged): keep the TurboQuant DEFAULT at head_dim 128; widen only what is asked for

Widening the acceptance gate from head_dim == 128 to every instantiated
width was correct for what a user may REQUEST, but PagedCacheType::
TurboQuant carries #[default] (cache_engine.rs:22), so it also silently
moved standard-layout models at head_dim 64/256/512 onto kernels that have
never executed — and silently dropped prefix caching with them, since
supports_prefix_cache() is false for every TurboQuant variant. Two
regressions, neither visible at the call site, for users who never asked
for TurboQuant at all.

This repo has paid for that exact shape once: FP8 KV shipped default-on and
unmeasured (wave43-BU) and every V4 request died. #98 cites that incident
by name as its own reason not to default itself on; this takes the same
choice.

resolve_for_model already distinguished explicit from ambient-default in
order to pick error-vs-fallback, so the fix is to let that same 'forced'
flag also pick WHICH head-dim set applies:

  compiled  -> widens what you may ask for   (TURBOQUANT_CUDA_HEAD_DIMS)
  measured  -> widens what you get unasked   (TURBOQUANT_DEFAULT_HEAD_DIMS)

TURBOQUANT_DEFAULT_HEAD_DIMS is [128] and moves when a hardware gate
passes, not when a kernel compiles. Nothing about the kernels changes: the
six 'if (hs != 128) return;' no-ops (D18 instance 3) and the 48 KB dynamic
smem cap fix are untouched, and explicit --pa-cache-type turboquant still
reaches every instantiated width including V4's 512.

The unsupported-geometry message now separates 'no kernel exists' from 'a
kernel exists but is unmeasured, so the default will not choose it for
you' — the second tells the user to opt in rather than to give up.

Tests: the old fixture asserted the regression (its expected-to-fall-back
list moved [64,96,192,256] -> [96,192,320,1024] precisely because 64 and
256 stopped falling back). Replaced with an explicit-path test over every
instantiated width, plus two pins on the default path. Both pins were
mutation-checked by reverting the const to the full set: both fail, and the
first revision of one passed vacuously because widening the const emptied
its loop, so it now asserts the loop is non-empty before iterating.

* docs: correct the public record for the widened TurboQuant kernels

This PR branched before #101 ("correct the public record") landed, so it
had never seen that text. Merging master in makes four README claims
false — three falsified by this PR's own kernels, one that was already
wrong when it was written.

Falsified by this PR:

  - L31  "the paged kernel exists at head_dim 128 only"
  - L31  "there is no kernel at head_dim 512, so DeepSeek V4 cannot use it"
         DeepSeek V4 reports KvCacheLayout::Standard at head_dim 512
         (normal_loaders.rs), so the 512 instantiation genuinely reaches it.
  - L141 an explicit `--pa-cache-type turboquant` off-128 "is a hard error"
         It is now accepted at any instantiated width; the hard error moved
         out to the uninstantiated set.

Already wrong before this PR:

  - L141 "TurboQuant is not the default anywhere"

    `defaults::PAGED_CACHE_TYPE` is `PagedCacheType::TurboQuant` and the
    CLI's `--pa-cache-type` has no clap default, so leaving it unset on
    CUDA gives a standard-layout head_dim-128 model TurboQuant KV with no
    flag — and silently drops prefix caching, which no TurboQuant variant
    supports. That was true on master; the narrowing in a3a5faa bounds it
    to 128 but does not remove it. Stating the opposite understated the
    risk to anyone reading the README to decide whether to opt out, so it
    is corrected in place rather than quietly reworded, and the Rust
    example's comment now says `Auto` opts *out* rather than implying
    TurboQuant is off until asked for.

Also refreshes the `--pa-cache-type` help text, which still described the
explicit path as 128-or-error.

No behaviour change: documentation and one clap doc comment.

* fix(build): stop emitting a duplicate -gencode for the build box's own arch

`mistralrs-paged-attn/build.rs` appended one `-gencode` per entry in
`ARC_CUDA_ARCHS` on top of the one cudaforge derives itself, and applied
the same `a` suffix for cap >= 90 that cudaforge's `GpuArch::auto_suffix`
does. Verified against cudaforge 0.1.5 rather than assumed:

  compute_cap.rs  auto_suffix(90).to_gencode_arg()
                    -> "-gencode=arch=compute_90a,code=sm_90a"
  builder.rs:401  let gencode_arg = gpu_arch.to_gencode_arg();
  builder.rs:405  command.arg(&gencode_arg)...
  builder.rs:412  for arg in &self.extra_args { command.arg(arg); }

cudaforge emits its own gencode first and then appends `extra_args`
verbatim, so on an H200 building `ARC_CUDA_ARCHS=90,100,103` nvcc received
`-gencode=arch=compute_90a,code=sm_90a` twice, byte-identical.

Fixed by hoisting the existing `get_compute_cap()` binding above the loop
(it is what decides which requested arches are genuinely extra) and
skipping the one cudaforge already covers. The later duplicate binding is
deleted; there is now one.

This is the dedupe half of #108's `arc_target::build::split_primary()`,
done locally because `arc-target` does not exist on this branch. The other
half — `verify_and_export()`, the `cuobjdump` gate that fails a build whose
archive is missing a requested arch — genuinely needs that crate and is
left to #108, which should replace this block with the two calls. Until it
lands, `arc-tools/wave64_v4_turboquant_kv_gate.sh` STEP 2 asserts the arch
list externally with `cuobjdump --list-elf`, so a missing arch is caught by
the gate even though it is not yet caught by the build.

Not verified here: whether nvcc rejects or tolerates the duplicate flag.
macOS cannot run the cuda build path. Emitting it once is correct either
way, which is why this is worth doing without waiting on that answer.
* feat(qtip): Stage 3 — dispatch and selection for K=9, default OFF

A K=9/V=4/L=12 layer is now SERVED rather than refused, and there is a way to
ask for the rung. K=8 stays exactly as it was: the compiled control, nothing
spent on serving it.

WIRED
  * CPU decode for the whole V=4/L=12 family, routed through
    `trellis_v4l12::Rung` — which already owns the extraction arithmetic and is
    bit-exactly gated against the GPU kernel. Not restated here: a format with
    two implementations has none.
  * `dequantize_weights` / `dequantize_w` / `forward` (2-D) for the family.
  * `forward_v4l12_gemv_cuda`: the family's single GPU path, the single-token
    fused decode+gemv the kernel exists for. Row-scale hoist OFF — it is the
    one lever that costs bit-exactness against the CPU reference, and there is
    no device measurement to trade that for.

STILL REFUSED, LOUDLY
Every path with no V=4 kernel: 3-D `gather_forward`, `dequantize_expert`,
3-D `dequantize_weights`, and `qtip_packed` (whose `QtipPackedView` carries no
geometry field, so a consumer would read K=9 bytes as K=4 nibbles). A
half-wired dispatch that falls through is worse than one that refuses — at
2 bpw a K=4 misread does not fault, it just serves wrong weights.

SELECTION
`ARC_QTIP_GEOMETRY` = `k4v2l16` | `k<K>v4l12`, mirroring `ARC_QTIP_CODEBOOK`.
Unknown values are refused, never resolved.

DEFAULT OFF, and this is the load-bearing line: `QtipGeometry::DEFAULT` stays
`K4V2L16`. Nothing in this family has touched a GPU — the kernel has never been
compiled at any K — and the precedent is specific: the last KV-storage change
that shipped default-on with only CPU validation killed every request on the
first V4 forward that met a real device. Its arithmetic was proven and its
device-time cost was not. Ship the path, leave the default alone.

Guards shown red (S1-S7). THREE HOLES, all mine:
  * S2/S5 sit behind `#[cfg(feature = "cuda")]`, which no test lane compiles,
    so both mutations passed. Now pinned over the SOURCE — a guard that only
    exists on hardware nobody has is not a guard.
  * S7: the env test reimplemented the parse rule inline and asserted the
    replica, so the real reader could silently resolve an unsupported K to the
    default and stay green. `from_env` now delegates to a pure `from_spec` and
    the test calls that.

I also deleted my own five new tests mid-edit — a second replacement span
swallowed what the first had just inserted, leaving two dangling doc citations
and a suite that looked green. Restored and verified present by name.

And the source guard fired on itself: `include_str!("mod.rs")` includes the
test module, so a negative assertion naming the string it forbids matched its
own text. Scoped to the serving half. That is the second time this class bit
me here; the note is in the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpVNMpb13HkUXqSqbN1o9H

* fix(qtip): Stage 3 CPU decode reads the padded row stride

The K=9 CPU decode sized its rows at the data length. Rows now carry tail
padding so the kernel's compile-time-width extraction needs no clamp, and that
padding is part of what the artifact stores — so a decoder that assumes the
data length disagrees with the loader about where row N starts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpVNMpb13HkUXqSqbN1o9H

* fix(ci): the CUDA dispatch source-guard died on Windows CRLF

`include_str!("mod.rs")` yields CRLF on a Windows checkout, so
`split_once("#[cfg(test)]\nmod tests {")` never matched and the guard hit
its `expect` instead of asserting anything — the guard was not failing,
it was not running. Normalise line endings before the split; every
assertion after it already goes through the whitespace-squeezer.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d refuse to instantiate on a miss (#181)

Capture recorded and died on the FIRST cuGraphLaunch with 700
(CUDA_ERROR_ILLEGAL_ADDRESS), one line after:

  [alloc-cache] WARNING: MISS during capture, size 393216 bytes (not pre-warmed)

That miss is the cause, not a bystander. An allocation that misses the warm pool
while a capture is open is served by cuMemAllocAsync from the graph's PRIVATE
pool and recorded as a graph memory node whose address the first launch has to
back; it does not get backed, the launch faults, and the poisoned context
surfaces later as glibc heap corruption. The allocator's 1 GiB cap is not
involved — bounded and unbounded legs are byte-identical during capture
(alloc/step 44.1, free/step 78.7, hit-rate 0.9954, held 315.4 MiB) and die on the
same line.

Why the warm pass missed it: `tail` in the rolling compressor is rebuilt every
step at width `tokens - base`, and `base` jumps a whole `ratio` at a group
boundary while `tokens` climbs by one, so the size cycles through `ratio`
consecutive values (measured `4096 × {18,19,20,21}`). One warm pass warms one
phase of that cycle. The captured step lands on another phase and asks for a size
the pool has never held.

Three changes, none of them a hardcoded size:

1. Warm until the profile stops growing. `ARC_GRAPH_DEFERRED_PASSES` defaults to
   4 (was 1), and a pass that teaches the allocator a new size buys another,
   from a bounded budget (`ARC_GRAPH_DEFERRED_MAX`, 24). A cycle of any period is
   covered without anyone tuning a constant.

2. Top the pool up from the measured profile before capture opens —
   `prewarm_alloc_cache(slack)`, `ARC_GRAPH_PREWARM_SLACK` default 4 buffers per
   size. This covers the residual case the warm passes cannot: the captured step
   allocating MORE of a size than any warm step did.

3. Gate instantiation on zero capture-time misses. The allocator now keeps a
   counted, capture-scoped miss ledger (the old `missed` set is a log dedup — it
   silences a size's SECOND miss, which is exactly the one that matters). If any
   miss occurred while recording, the capture is cancelled and the run falls back
   to eager instead of instantiating a graph already known to fault.

(3) is the part that holds even if (1) and (2) are incomplete: it converts a
poisoned context and a process death into a named list of sizes. If those sizes
turn out to grow monotonically rather than cycle, no amount of warmup is the
answer and the next step is a shape-constant buffer or allocator size-class
bucketing — the refusal message says so.

Needs the candle fork's capture-miss ledger + profile-driven pre-warm
(`prewarm_alloc_cache`, `reset_capture_misses`, `capture_misses`). This commit
originally pinned Cargo.toml straight at 1fa534db, which was correct when it was
written and is WRONG now: since then the integration branch moved to 89ab14ef,
and the two revs have DIVERGED. 1fa534db is missing three commits 89ab14ef has —
"perf(cuda): bound the caching allocator", its cap-contract test, and
"fix(cuda): eviction must not run inside the capture window" — the first of which
is what #213 (bound the alloc cache against real headroom) rests on.

Pinning at 1fa534db would therefore have silently reverted the bounded allocator
and a capture-correctness fix. Pinned instead at 9586979db
(`arcgraph/bounded-alloc-plus-prewarm`), the merge of the two, which is ahead of
89ab14ef by 6 and of 1fa534db by 8 and behind neither — verified with
`gh api repos/aeonmindai/candle/compare/...` and by reading all four required
`pub fn`s out of `candle-core/src/cuda_backend/device.rs` at that rev.

`cargo check --workspace` is green on the new pin; mistralrs-core's warning
count is unchanged at 23.

The re-indent of the same-step-probe block in normal.rs is the counterpart of
the one 6fcb87f did on the integration branch: that commit de-indented it by
4 precisely because this commit's `if !misses.is_empty()` nesting level was not
yet present. It is now, so the block goes back 4 deeper.

Co-authored-by: Nirupam Bhowmick <support@runcrate.ai>
…t prices per-sequence advance — 4.46x at spread B=8 [MEASURED] (#121)

* measure(sched+arcspec): the throughput ladder #116 unlocks, and the confound it has to be read against

is worth anything: MTP was measured at 1.93 tok/step at one user collapsing to
1.06 at 128, and that collapse is what this stack targets.

Two pieces, because the number is unreadable without the second.

1. `scheduler::bucket_telemetry` — a `SCHED[agg]` marker carrying
   buckets_per_step, running_bucket_size and offered_per_step, emitted on the
   SAME log fence as `MTP[agg]`.

   Both schedulers bucket the running set by exact cache length and run one
   bucket per step, preempting the rest. So a cell labelled B=128 can be a
   3-wide step in the engine, and an aggregate number measured inside that is a
   measurement of the scheduler rather than of KV advance. Ragged admission
   exists precisely to collapse those buckets, so the two effects are
   confounded by construction — without these counters "aggregate did not move"
   is unattributable. running_bucket_size is the width that actually ran;
   offered_per_step is the width the harness thinks it asked for.

2. `arc-tools/arcspec_perseq_ladder.sh` — B = 1, 8, 32, 128, both arms, one
   server per arm, counters differenced across each cell's own wall-clock
   fence.

   It reports aggregate tok/s AND tok_per_step AND the decomposition
   (tok_per_batch_step x batch_steps/s, plus ms/step), because tok/step rising
   while aggregate falls has already happened on this chain and either number
   alone is unreadable.

   Prompt lengths are RAGGED by construction — 24..320 words cycled across
   workers — which is the deliberate difference from `arcspec_perseq_ab.sh`'s
   fixed 40. Uniform prompts hide the failure mode this stack addresses: the
   `xs` window defect could not even be reached with equal-length prompts. 320
   keeps a >3x margin to the ~1,055-word serving cliff this branch does not
   carry the fix for.

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

* measure(arcspec): add the uniform arm — the only regime where the batch is whole

The scheduler A/B came back while this was being built and it changes what the
ladder can conclude. The bucketing law is now measured, not hypothesised:

    running bucket size = B / (distinct cache lengths)

holding at 8/8=1 and 32/8=4, with `1 running, 7 waiting` sustained, and B=8 on
spread lengths measuring 7.91 tok/s against B=1's 15.36 — batching is NEGATIVE
on realistic traffic.

So a spread-only ladder measures per-sequence KV advance inside a scheduler
that is running one sequence at a time, and a flat aggregate there is
unreadable: it cannot distinguish "the fix does not pay" from "the batch never
existed". Both regimes are now run, per arm, mean-matched:

  spread  144 24 320 64 260 40 200 96 words (8 distinct lengths)
  uniform 144 words — the spread's mean AND its first element, so B=1 is
          byte-identical between regimes and the only thing that differs at
          B>1 is the spread itself

Uniform is where a whole batch actually forms, so it is the only place this
stack's effect on aggregate throughput is visible without the serialisation
swamping it. Spread is still the regime the stack exists for.

The report now prints the bucketing law's prediction beside the measured
`running_bucket_size` per cell, and refuses to let a spread cell be read as a
batch result when its running bucket is ~1.

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

* perf(v4): stop reallocating the xs window every decode step — restore the bound the type already documents

`tail` is described at the top of this file as "the raw rows behind `tokens`,
bounded by `span_groups * ratio + margin` and independent of context length".
That is an invariant the type has always claimed. The allocation did not honour
it: `advance` rebuilt the buffer with `cat` + `narrow` every step at width
exactly `tokens - base`, which is not stable — `tokens` climbs by one per token
while `base` jumps a whole `ratio` at a group boundary — so the buffer cycled
through `ratio` consecutive sizes and was reallocated on every decode step, on
every one of V4's 41 compressed layers.

This is not "add a pin". It is restoring a documented invariant that one code
path broke.

THE BOUND, WHICH IS THE REVIEWABLE PART

  keep_from = ((tokens - margin) / ratio + 1 - span_groups) * ratio
  base      = max(keep_from, previous base), capped at tokens

Write m = tokens - margin and q = floor(m / ratio). Then q * ratio > m - ratio,
so keep_from = (q + 1 - span_groups) * ratio > m - span_groups * ratio, giving

  W = tokens - base <= tokens - keep_from < margin + span_groups * ratio

so W <= span_groups * ratio + margin - 1, ALWAYS. Pinning to
span_groups * ratio + margin is provably sufficient and provably never
truncates. It is a function of the layer's geometry, not of context length: 24
for CSA (ratio 4, span 2, margin 16), 144 for HCA. That is the difference
between this and a capacity that turns out to be a million-token context.

Derived twice independently and agreeing, and checked a third way: the steady
band is measured at [capacity - ratio, capacity - 1] = {20..23}.

THE {18..21} vs {20..23} DISCREPANCY, SETTLED RATHER THAN ASSERTED

ArcGraph measured 4096 x {18,19,20,21} from outside the engine; the retention
rule predicts {20,21,22,23}. Both ratio-consecutive, both containing 21, offset
by 2. The pre-committed reconciliation was that theirs is the pre-saturation
ramp (base still at 0 while tokens climbs) and mine the steady state.
`the_window_ramps_then_settles_to_ratio_consecutive_sizes` checks exactly that:
the early band sits below the steady one, the steady one is `ratio` consecutive
sizes at [cap-ratio, cap-1], and no width over 128 steps reaches `cap`. A bound
that is right for the wrong reason is a trap for the next context length.

WHY DEFAULT ON WITHOUT A THROUGHPUT NUMBER FIRST

The measure-then-default-on rule exists because FP8 KV shipped on and changed
VALUES. This changes only the size of an allocation: the compressor is handed a
slice covering the same absolute tokens either way, because every offset is
derived from the row's own token count rather than from the buffer's width.
`pinning_the_window_is_numerically_inert` runs both settings against one stream
for 40 steps and requires exact equality of the compressed rows and both time
bases — and asserts the unpinned widths actually varied, so the equality is not
vacuous. `ARC_V4_XS_PIN_WINDOW=0` restores the resizing buffer, which is also
the A/B arm.

split_row keeps the pinned buffer rather than re-narrowing it: `clone_out_cache`
calls it once per layer per sequence on EVERY engine step, so narrowing there
would undo the pin exactly on the hot path. The resume point does not move.

Two fixtures now run explicitly unpinned, because pinning removes their
discriminator rather than their subject: `ragged_xs_tail_is_refused_by_name_not_panicked`
needs the widths 18/22 that only a resizing buffer produces, and
`splitting_a_batched_row_restores_the_per_sequence_window` needs a row narrower
than the shared window. Both keep testing what they were written for, and both
gained a pinned-side counterpart.

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

* measure(arcspec): price the window pin in the same session — a third arm

ON vs ON_UNPINNED differ in exactly one thing: whether the compressor's raw
window is reallocated every decode step on all 41 compressed layers, or held at
the bound the type documents. Same per-sequence flags in both, so the pin is
isolated rather than confounded with the KV mechanism, and it is priced in the
session that was already going to queue for the box.

On the width discrepancy the pin rests on: this harness does not need to settle
it. `max_tokens` is 4096 and each cell drives a 45 s steady window, so every
request is thousands of tokens past saturation and the ramp is invisible here
anyway. The settling is measured directly instead, on CPU, over 128 steps, by
`the_window_ramps_then_settles_to_ratio_consecutive_sizes` — the early band sits
below the steady one, the steady one is `ratio` consecutive sizes at
[cap-ratio, cap-1], and no width ever reaches `cap`. That is a stronger
instrument than a throughput leg and it costs no card time.

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

* instr(v4): name the window mode once per process, and assert the pin A/B is real

D18, applied to the arm I am about to run. `ARC_V4_XS_PIN_WINDOW=0` is the only
thing separating ON from ON_UNPINNED, and if that name were wrong the two arms
would be the same build, produce a 1.000x ratio, and read as "the pin costs
nothing" — the granted-but-inert failure with a throughput number attached.

So the flag names itself once per process on first read ("xs rolling window is
PINNED / RESIZING"), following the same convention as "per-sequence KV advance
is ON", and the ladder summary now requires PINNED in the ON log and RESIZING
in the ON_UNPINNED log before either arm's ratio may be read. If it cannot find
both it prints PIN A/B IS VOID and says the comparison is of one thing with
itself, rather than printing a ratio.

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

* measure(arcspec): make the pin control provably the control, not the default

The window pin defaults ON, so the TREATMENT is the default and the CONTROL is
the arm carrying `ARC_V4_XS_PIN_WINDOW=0`. That inverts the usual failure: a
typo in the flag name does not disable the feature, it produces a control that
silently ran the treatment and a clean-looking 1.000x ratio.

Three things, all of which had to be right and only one of which was obvious:

* The assertion reads the runtime LOG, never the binary. Both mode strings are
  compiled in unconditionally, so `strings <binary> | grep RESIZING` succeeds in
  every arm and proves nothing. Only the line a process emits says which branch
  it took.
* `RUST_LOG=info` is set once inside `run_arm`, shared by every arm, so a log
  filter cannot suppress the line in one arm while leaving it in another. An
  assertion that configuration can mute is a guard with an undocumented off
  switch.
* The guard now also fails when the control's log contains PINNED, not just
  when it lacks RESIZING — the leak is the thing being tested for, so it is
  checked directly rather than inferred from an absence.

On VOID it refuses to let the ratio be read at all rather than printing it with
a caveat.

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

* fix(ArcKV): one xs window pin, two triggers; scope the unpinned tests

Integration fix for #121 landing after #181's `pin_tail_width`. Both arrived at
the same idea — hold the retained raw xs window at a constant width instead of
letting it breathe with `tokens % ratio` — for two different reasons, and both
callers are real:

  * #181: CUDA-graph capture, where a moving allocation size is not slow but
    INVALID (a capture-time miss becomes an unstable graph memory node).
    Per-cache trigger, `pin_tail_width()`.
  * #121: serving, where the per-step reallocation is simply waste.
    Process-wide trigger, `ARC_V4_XS_PIN_WINDOW`.

They agreed on the width and disagreed on everything else, so this keeps one
width policy and both triggers:

1. `graph_tail_width()` and `window_capacity()` were the same expression
   (`span_groups * ratio + margin`) written twice. `graph_tail_width` now
   delegates. Two pins that computed "the pinned width" independently could
   drift, and capture and serving would then disagree about a number whose only
   value is that it does not move.

2. `retained_width` is gated on `pin_is_on()` = `self.pin_tail ||
   xs_pin_window_enabled()`, so capture still gets its constant width when the
   env switch is off — which, for a capture-only run, it is.

3. #181's `pinned_base` form is dropped in favour of #121's. Both produce the
   same constant width; they differed in where the constancy came from. #181
   moved `base` earlier so the logical span went constant; #121 leaves `base`
   at the retention point the type documents and widens the physical buffer.
   #121's is the one that composes, because it also moved `win_start` to the
   buffer's PHYSICAL start (`tokens - w_phys`) rather than `base`, which makes
   "buffer wider than the row promised" a representable state. `plan_xs_advance`
   still refuses to read below `base`, so the pin cannot change an answer.

4. Three `clone_in_cache_invariant_tests` assert the geometry of the UNPINNED
   window — exact tail widths `(4, 132)`, and that column 0 holds token `base`.
   Neither survives a pinned buffer, and #121 makes the pin the DEFAULT. They
   now run under `unpinned(..)`, the same `pin_test_override` shape #121 uses
   for its own affected tests. They are not testing a dead path:
   `ARC_V4_XS_PIN_WINDOW=0` is a supported mode and is the control arm of
   #121's own A/B.

NOTE FOR REVIEW, not a defect: #121 flips the serving default — the window is
PINNED unless `ARC_V4_XS_PIN_WINDOW` is set to 0. Its "+23.2% at uniform B=32"
was measured on #121's own branch and is NOT re-measured here; this rebase
changes which code that number describes.

`cargo test -p mistralrs-core --lib`: 701 passed, 0 failed. Warning count back
to master's 23 (the `xs_rolling` import is test-only and now lives in the test
module).

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

* fix(ArcKV): the xs window pin is OPT-IN, with the experiment that flips it named

#121 shipped `ARC_V4_XS_PIN_WINDOW` default-ON, with `=0` as the control arm.
Retracting the default and keeping the change.

Why: the pin changes what the serving path RETAINS — at V4's HCA geometry the
window is held at 144 columns where the resizing policy keeps as few as 4 at
some residues — and the +23.2% at uniform B=32 that motivated defaulting it on
was measured on #121's own pre-rebase branch, against a tree that no longer
exists. That is an unmeasured default. TCFRAG was an unmeasured default too: it
carried "UNVERIFIED ON HARDWARE — NEVER RUN" in its own header, held 63 GB and
permanently broke a layer through a poisoned `OnceLock` (#209). So: unverified
means default-off, and "unverified" means unmeasured, not new.

The correctness argument #121 made for the pin is SOUND and is kept in place —
it proves the pin changes no answer, which is necessary and not sufficient. The
40-step bit-identity A/B in `deepseek4` still guards it; its doc comment no
longer claims to justify a default.

## Off is a temporary state with an owner, not the finish line

Arc's larger problem is not unbuilt work — it is finished, correct, tested work
left switched off. So this does NOT ship as a parked flag. The flip condition is
written three places, one of them enforced:

* `xs_pin_window_enabled_from`'s doc comment carries it in full, under a
  "FLIP CONDITION" heading, at the gate itself.
* `capability_reachability.rs` — the file titled "the switched-off guard", which
  is where someone goes looking for exactly this — carries it as a registry
  entry, so the gate cannot go dark without CI going red.
* `arcspec_perseq_ladder.sh`'s `ON_PINNED` arm IS the experiment.

The experiment: one binary, uniform B=32, same prompt and seed, `ON` (flag
unset) against `ON_PINNED` (`ARC_V4_XS_PIN_WINDOW=1`). Pass = ON_PINNED faster
on aggregate tok/s with identical generated tokens. On pass the default flips to
ON in the same change that records the number.

## The harness inverted with the default, and that is easy to get silently wrong

The pin used to be the default, so the A/B's TREATMENT was the default arm and
its CONTROL carried the flag. Now it is the other way round. Three things had to
move together, and any one left behind would have produced a clean-looking and
meaningless number:

1. The arm carries `=1` and is renamed `ON_UNPINNED` -> `ON_PINNED`.
2. The report's ratio pairing is swapped, so it stays treatment/control.
   Renaming the arm without this would have inverted every printed ratio.
3. The engagement guard's expectations are swapped: `ON` must log RESIZING,
   `ON_PINNED` must log PINNED, and the leak check now looks for a treatment
   that silently ran the control.

Polarity is `== Some("1")`, split into the pure `xs_pin_window_enabled_from` and
pinned by tests: unset is OFF, and `0`/`false`/`off`/`true`/`on`/`2`/`" 1"` are
all OFF. #212 converted 23 `ARC_*` flags for this reason — `var_os(..).is_some()`
made `ARC_FOO=0` mean ON, which turns an A/B control into a second treatment.

A third test pins the thing most likely to be broken by this retraction:
CUDA-graph capture must still get a constant width with the env flag off. It
does — capture asks per-cache via `pin_tail_width()` and `pin_is_on` honours
that trigger independently. Without that, capture would silently return to a
per-step-varying allocation size, which is not a slow graph but an invalid one.

`cargo test -p mistralrs-core -p mistralrs-quant -p mistralrs-vision`: green.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…to 0, opt-in (#217)

* perf(ArcInfer/ArcGraph): device decode loop — N steps, N cuGraphLaunch, zero host syncs

CUDA-graph capture already cut launch APIs per token 3,961 -> 3.8 and bought
only ~8%. The reason is not that op count stopped mattering: replay() launches
the graph and then blocks on cudaStreamSynchronize (graph.rs:362), the caller
samples on the host, and the host-side argmax D2H (sampler.rs:1479)
re-synchronizes anyway. Capture removed the cost of *issuing* the work and left
the serialized host round trip between steps completely intact.

Removing the sync alone buys nothing, so this removes the sync and the host
sample together, behind an opt-in.

Per step, all on one stream: cuGraphLaunch -> CudaSampler (on device, token to
an i32 device buffer, never copied down) -> arc_graph_step_commit (scatters the
token into the PINNED U32 buffer the captured graph reads, advances the device
position, publishes into a pinned+mapped host ring). Nothing blocks.

Fault detection replaces the sync rather than dropping it, with three signals
that need no blocking call: non-blocking cudaStreamQuery (which NVIDIA
documents as also returning errors from previous asynchronous launches), an
idle-but-short ring (every step retired yet fewer tokens landed — a fault with
no timeout heuristic in it), and a sticky device fault word the commit kernel
latches before it writes anything. A spin backstop covers a kernel that hangs
without ever erroring.

The driver is expressed against a DeviceOps trait that deliberately has no
synchronize method, so it is structurally incapable of blocking the stream, and
it compiles without CUDA — 20 unit tests run against a scripted fake GPU on any
host. Two mutations were checked: dropping the device-fault read and skipping
one launch per burst each fail exactly the tests that name them.

Aliasing: the logits tensor aliases storage the next launch overwrites. Safe
here only because every access is stream-ordered and none is a host read; the
ring the host drains is a separate pinned allocation no launch touches. The
invariant that lets the aliasing tensor be returned at all is enforced at
runtime, not just documented.

Default OFF (ARC_GRAPH_DEVICE_LOOP). Greedy-only: the device sampler runs
Splitmix64 against the host's Isaac64, so any stochastic mode would draw a
different, equally valid token and silently break seeded reproducibility.
Every refusal falls back to the existing path; one failure latches it off for
the process.

Kept as a pure insertion, away from replay(), to merge cleanly with PR #205.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMmjFy8TvsgypxVWNVhhC7

* fix(ArcGraph): device loop stands aside for ARC_GRAPH_VERIFY_ALWAYS

The device-loop branch sits earlier in the chain than the runner.replay(bs)
arm that PR #205 extended with ARC_GRAPH_VERIFY_ALWAYS. Without this it would
take every step and that switch would silently verify nothing — a clean
diagnostic that never ran. Also cross-references #205's BOOBY TRAP note from
replay_handles, since the same aliasing applies to the handles it returns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMmjFy8TvsgypxVWNVhhC7

* docs(ArcGraph): record the burst's scheduler-admission limitation

A burst admits no new request for its whole length (default 4 decode steps).
Harmless at batch 1 — the only shape admit() accepts — and not harmless above
it, which is why admit() refuses batch_size != 1 outright rather than treating
it as a tuning question. Stated in the module docs so it survives past the
return it was first noted in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMmjFy8TvsgypxVWNVhhC7

* fix(ArcGraph): the device-loop sampling hook takes &mut Sequence

The CUDA lane's whole purpose, on its first run against this branch: three
jobs, one error, in the one block a macOS `cargo check` structurally cannot
reach.

  error[E0596]: cannot borrow `*seq` as mutable, as it is behind a `&` reference
     --> mistralrs-core/src/pipeline/sampling.rs:557:12
  557 |         && seq.sampler().is_greedy_trivial()

`Sequence::sampler()` takes `&mut self` (sequence.rs:885) even though it only
clones an Arc, so the `&Sequence` parameter could never call it. `sample_sequence`
already holds `&mut Sequence` and was reborrowing it down to `&`, which is why
the call site compiled and the body did not.

This is rustc's own suggested fix. Nothing here mutates the sequence, and the
`&&` chain still borrow-checks: `sampler()` returns an owned `Arc<Sampler>`, so
the mutable borrow ends before `seq.recognizer` is read. Verified by compiling
the cuda body on a host with no CUDA — cfg removed, the three `arc_cuda_graph`
calls stubbed — which puts the real `Sequence` through borrowck and passes.

The `#[cfg(not(feature = "cuda"))]` twin moves with it so the two signatures
cannot drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMmjFy8TvsgypxVWNVhhC7

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…els it proves were switched off (#196)

* fix(ArcLab/ArcInfer): prefill measured 0.000 tok/s and aborted at teardown

Three defects, all on the path that makes prefill measurable at all.

1. `pp N` reported `0.000±0.000` for every prompt benchmark.

   A prefill-only request (`max_len = 1`) finishes *during* the prompt step:
   `pipeline/sampling.rs` calls `seq.update_time_info()`, builds
   `group.get_usage()` and dispatches `Response::Done` from inside `step()`.
   But the default (non-paged) scheduler arm stamped `prompt_timestamp` and
   `total_prompt_time` only AFTER `step()` returned. So at the moment the usage
   was built `prompt_timestamp` was still `None`, `update_time_info` skipped
   `group.total_prompt_time`, and `get_usage` took its `== 0` branch and
   returned `avg_prompt_tok_per_sec: 0.0`.

   The PagedAttention arm already stamps before `step()`, with a comment saying
   exactly why. The fix was simply never applied to the arm that serves --
   PagedAttention is banned here (it shadows the graph arm and measures zero
   tokens). Stamp on the default arm too.

2. The harness printed that zero into a results table and exited 0.

   A zero is not a measurement. Every row now has to carry evidence the engine
   processed the tokens the row claims to be about -- non-zero prompt tokens,
   the count actually asked for, a non-zero timed prefill, and a finite positive
   rate. A row that cannot exits 2 (environment failure, never 1) and prints no
   table. Adds a second, engine-independent instrument: wall-clock seconds and
   wall-clock prompt tok/s, so the engine's own numbers are only believed when
   an outside clock agrees.

   Also stops reusing one request id for every concurrent copy and repetition:
   `next_request_id()` was called once and the request cloned, so
   `concurrency * repetitions` sequences were in flight sharing one engine
   handle.

3. The binary aborted at teardown AFTER printing valid results.

   `Request::Terminate` only asks; nothing waited. `engine_handler` had no
   `.join()` anywhere in the workspace, so `main` returned while the engine
   thread was still releasing device memory and CUDA objects, and libc `exit()`
   ran CUDA's atexit handler concurrently with it -- a textbook
   `corrupted double-linked list` / SIGSEGV. `Drop for MistralRs` now joins each
   engine thread with a bounded 30s timeout, and says so loudly if it has to
   detach instead.

   Second half of the same race: `DedicatedDecodePath::new` binds the CUDA
   primary context and warns that skipping it gives "a SEGV in libcuda MOVAPS
   ... no error code, just a fault". Its `Drop` then made the same class of raw
   calls (`cuGraphExecDestroy`, many `cudaFree`) with no bind at all, made legal
   on a foreign thread by `unsafe impl Send`/`Sync`. It now binds exactly as
   `new()` does.

   This matters beyond tidiness: the abort libelled good runs. One chain already
   discarded an expensive valid measurement because its harness gated on rc==0.

Parent system: ArcLab (harness) / ArcInfer (engine timing, teardown).

* test(ArcLab): print the grouped-GEMM launch counters beside the results

`grouped.rs` already states the rule: "a harness that reports a per-variant
timing MUST show this counter advancing on the variant it claims to have
measured, and NOT advancing on the others, or the number describes some other
kernel." The counters existed and were public. Nothing in the workspace ever
read them, so every grouped-vs-GEMV and variant-vs-variant comparison this
harness could produce was unproven by construction.

The bench now prints `grouped_launch_counts()` and the selected variant next to
every results table, and says so explicitly when the total is zero -- which is
the common case, because the grouped GEMM declines to run below its tile-fill
boundary (~683 tokens at V4's top-6-of-256 routing) and the measurement is then
the per-pair gather GEMV, not the kernel the reader will assume.

Parent system: ArcLab.

* perf(ArcQuant): ship the tuned grouped-GEMM kernel as the default (+19.5% prefill)

The trellis grouped GEMM has three variants compiled into every binary. The
tuned ones were measured long ago at +39.4% (v1) and +41.6% (v2) per m-tile,
bit-identical in output, and then left unreachable: `grouped_variant()` fell
back to `QTIP_GROUPED_VARIANT_BASELINE` whenever `ARC_QTIP_GROUPED_VARIANT` was
unset, and the only callers of `set_grouped_variant` in the entire workspace are
inside `examples/qtip_grouped_curve.rs`. So every production prefill since the
variants landed has run variant 0, and the faster kernels existed only for a
benchmark nobody ran in serving.

✅ MEASURED END-TO-END, H200, DeepSeek-V4-Flash qtip2b, pp2048 b=1,
--no-paged-attn, on `arc-prefill`:

    variant 0 (was default): 561.7 prompt tok/s | 1.780 ms/tok | TTFT 3.675 s
    variant 2 (now default): 671.3 prompt tok/s | 1.490 ms/tok | TTFT 3.080 s
    => +19.5% prefill throughput, -16.2% TTFT

The arm is proved from the runtime launch counters, not from the build:
[129,0,0] on the control against [0,0,129] on the variant -- 43 layers x 3
expert matrices, and zero launches on the arms not under test. An
engine-independent wall clock agrees (557.2 -> 664.9 tok/s, +19.3%).

The end-to-end gain is smaller than the kernel gain because the expert gather is
~56% of a 2048-token prefill step; backing +41.6% on that share out of a measured
+19.5% overall is consistent.

Also: an unrecognised env value used to fall through to the baseline. With the
baseline now the slow arm, a typo would silently cost ~20% of prefill, so
unknown values warn and keep the default, and `baseline`/`0` is now an explicit
arm so the A/B knob still selects the control.

Parent system: ArcQuant / QTIP.

---------

Co-authored-by: Nirupam Bhowmick <support@runcrate.ai>
… +69% (114.76 -> 193.46 tok/s) (#195)

* perf(ArcInfer/ArcMoE): warp-butterfly Sinkhorn on top of the ladder — 1.36x

Rebased onto release/openrouter-ready. The earlier version of this change was
measured against the PRE-LADDER kernel and read 4.19x; that baseline is gone.
Against the templated kernel the ladder actually shipped, the honest number is
1.36x. Recording both so the retracted figure is not quoted again.

  MEASURED, H200 / nvcc 12.4 / sm_90, n=1 hc=4 iters=20, 86 calls/token:
    pre-ladder (runtime hc, 192 B stack frame)   30.578 us/call  2.6297 ms/tok
    templated + shared/barriers (master today)    9.771 us/call  0.8403 ms/tok
    warp butterfly (this commit)                  7.201 us/call  0.6193 ms/tok
  => 1.357x incremental, -0.221 ms/token. Bit-identical to the templated arm.

The templated kernel got the row/tree buffers into registers but still stages
the matrix through shared memory and pays TWO __syncthreads() per iteration --
40 barriers and ~80 shared round trips per call -- purely to move each column
to the thread that divides by it. hc <= 16 means every thread is a lane of ONE
warp, so the column reduction can be a butterfly and the matrix never has to
leave registers.

Bit-identity by construction: candle's level s is
`buf[t] = __fadd_rn(buf[t], buf[t + s])` for t < s. The butterfly gives lane t
`__fadd_rn(buf[t], buf[t ^ s])`, which IS that expression for t < s and its
commuted form for t >= s; IEEE-754 addition is commutative (only associativity
fails), so lane 0 ends holding precisely candle's result. Lanes with row >= HC
feed 0.0f, exactly candle's `shr[tid] = 0` identity padding.

ARC_NO_SINKHORN_WARP=1 restores the templated kernel. The arms are separable in
a profile by kernel name (`arc_sinkhorn::warp_kernel<N>` vs the
anonymous-namespace `sinkhorn_normalize_f32_kernel<N>`), so which arm ran is
provable from nsys alone and a stale object file cannot fake it.

No Rust changes; the C ABI is unchanged.

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

* instrument(ArcQuant/QTIP): count expert-weight bytes read per step

Answers whether the MoE path amortises expert weights across a batch. Flat
bytes/step with batch => amortisation works and the aggregate deficit is
elsewhere; linear => every token drags its own copy of the expert weights.

METHOD: kernel-issued bytes, computed exactly from each launch's own geometry.
Not a profiler sample and not DRAM traffic. `ncu` cannot attach on the rented
H200 ("Failed to prepare kernel for profiling / Unknown Error on device 0",
verified on this box, not inherited), there is no DCGM, and `nvidia-smi dmon`
reports a utilisation percentage rather than bytes -- so no hardware DRAM
counter is available and the accounting is done from the dispatch instead.

The gather GEMV (qtip_gather_gemv.cu) is indexed `pair = blockIdx.y` with
`row-block = blockIdx.x`, and each block loads its own expert's packed rows.
Summed over blockIdx.x one pair reads one WHOLE expert, and nothing is shared
between pairs, so a launch reads n_pairs * n_rows * packed_per_row bytes. The
grouped GEMM stages a woken expert once per GROUPED_TILE_M pairs, so it is
accounted as ceil(pairs_e / TILE_M) copies. Both arms report in the same units
so they are directly comparable.

Because these are loads ISSUED they upper-bound DRAM traffic: repeated reads of
one expert inside a short window can be served by L2. That is the point --
99-100% SM occupancy against a 3% memory controller is what re-reading a
redundant working set looks like, and the redundancy factor is the quantity
that must reach 1.0 for batching to amortise, whichever cache serves it.

ARC_MOE_BYTE_PROBE=1 to enable; off by default. One relaxed atomic add per
expert-GEMM launch when on -- no device sync and no D2H read, so it is
capture-safe and does not perturb the dispatch it measures.

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

* test(ArcQuant/QTIP): pin the measured expert-byte curve

MEASURED on H200, V4-Flash qtip2b, ARC_MOE_BYTE_PROBE=1. One expert is 2 MiB;
a step is 129 gather invocations (43 layers x 3). Expert bytes per step:

    n_tokens   GEMV arm   grouped arm   redundancy
           1     1.5 GiB       1.5 GiB        1.00x
           8    12.1 GiB      12.1 GiB        1.00x
          64    96.8 GiB      64.5 GiB        1.50x
         512   774.0 GiB      64.5 GiB       12.00x

The GEMV arm is exactly linear in the batch -- 512x the users reads 512x the
expert bytes, so nothing is shared. The grouped arm is FLAT at the whole expert
working set once every expert is woken.

Two independent anchors agree:
  - a real 8-user decode through the server reads 12.09 GiB/step, identical to
    the prefill-derived n_tokens=8 figure, so the sweep measured the same
    quantity that batched serving pays;
  - the amortised floor of 64.5 GiB/step is 95.1% of the 67.82 GiB of qtip2b
    shards on disk, i.e. it is the whole expert working set read once.

This is a characterization test, not an endorsement: it encodes the defect so
that fixing the dispatch fails here first and the numbers get updated.

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

* test(ArcQuant/QTIP): pin the corrected dispatch, with the verified numbers

The shipped default (no env override) now selects the flat-bytes arm above the
capture-safety floor. Verified end-to-end on H200:

  B      old tile-fill gate    shipped default    delta
    8            57.18               60.28        +5.4%
   64           106.26              156.61       +47.4%
  256           114.76              193.46       +68.6%

Kernel identity is from the profile, not the gate condition: at n_tokens=256
the default runs qtip2b_grouped_gemm_kernel n=129 -- exactly one step's worth
of gathers (43 layers x 3) -- while n_tokens=1 decode steps stay on
qtip2b_gemv_tuned_kernel below the floor, which is the intended split.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… reachable (#130)

The headline is an ABI mismatch that explains a failure already on our record.

`ffi.rs` declared `cudaGraphAddNode` with FIVE arguments and the first two
transposed. CUDA 13.1 `cuda.h:21829` declares SIX, and the first parameter is
an OUT pointer:

    CUresult cuGraphAddNode(CUgraphNode *phGraphNode, CUgraph hGraph,
                            const CUgraphNode *dependencies,
                            const CUgraphEdgeData *dependencyData,
                            size_t numDependencies,
                            CUgraphNodeParams *nodeParams);

So the driver received a `CUgraph` handle where it expected `CUgraphNode *`
and wrote the new node handle THROUGH it, while `numDependencies` received a
pointer and `nodeParams` an uninitialised register. That is memory corruption
from a declaration, and it matches the previously unexplained "host heap
corruption" CUDA-graph capture failure we had recorded with no cause.

Four more defects, each of which alone stops the autonomous loop:

* Conditional handles were created with `flags = 0`. `cuda.h:21919` applies
  `defaultLaunchValue` only when `CU_GRAPH_COND_ASSIGN_DEFAULT` is set, so the
  condition read 0 at launch and a WHILE body executed ZERO times while every
  CUDA call returned success. Measured both ways on an H200.
* The WHILE body was captured into a throwaway graph and then DESTROYED,
  leaving the conditional body empty. Bodies are now populated with
  `cuStreamBeginCaptureToGraph` (`cuda.h:1976` names it as the supported way),
  and a node-count assert refuses a body that recorded nothing.
* `CUDA_CONDITIONAL_NODE_PARAMS` was missing its 5th field, `ctx`.
* `DecodeState` allocated I64 while every kernel signature is `int32_t*`, so
  host and device disagreed about where element `i` lived; and `reset()`
  reallocated the buffers, silently invalidating the pointers a captured graph
  had baked in.

`tensor_device_ptr` had no I32 arm, so `CudaSampler::sample` returned
`unsupported dtype I32` on EVERY call -- the correct top-k/top-p sampler had
never once executed on a GPU. Its test suite is a CPU simulator, which by
construction cannot observe that.

Sampling now runs on device and survives replay. The sampler previously wired
into the graph body took `rng_offset` as a BY-VALUE kernel argument, which a
captured graph bakes: measured 1 distinct token over 64 replays, versus 64
distinct for a device-resident `rng_state`. It also was not nucleus sampling --
it walked the vocabulary in TOKEN-ID order.

The replacement is a hybrid, because measurement showed neither component wins
everywhere (H200, vocab 129280, host+GPU verified exclusive before and after;
support width -> legacy / hybrid us):

    1 -> 134.9/166.6   8 -> 503.0/624.2      64 -> 3127.8/4178.5
    512 -> 24320.7/4185.0   4096 -> 205125.2/4187.3   12928 -> 664200.0/4188.5

Exact enumeration while the nucleus is small, threshold bisection past a fixed
budget, chosen block-uniformly ON DEVICE because a captured graph cannot ask
the host which branch to take. Costs +24% on the peaked distributions real
models produce -- 32 us, 0.05% of a 66.68 ms V4 decode step -- and removes a
664 ms cliff that was ~10x an entire decode step for a single token.

No cap and no narrowing: exceeding the budget changes which ALGORITHM selects
the nucleus, never which tokens it contains. Verified against the enumerating
sampler with branch counters proving which path each case exercised:

    peaked          TV=0.0000  A=20000 B=0
    scrambled order TV=0.0000  A=20000 B=0   (15.6% per-draw agreement, so the
                                              orders genuinely diverged)
    diffuse         TV=0.0000  A=0     B=3000
    tie boundary    TV=0.0000  A=20000 B=0
    narrowed (ctrl) TV=0.5027  -> correctly DISAGREE

Known and deliberately left: on TIED diffuse supports the fallback keeps 116
tokens where enumeration keeps 111 (TV=0.1103) -- it keeps every token sharing
a boundary bit pattern, erring WIDER, never narrower. And the budget is
derived from bisection's pass count (32) when it should follow the FALLBACK's
(~70), which costs a 0.75x window at support ~64; correcting it needs a more
diffuse fixture so the fallback stays covered.

Not claimed: no V4 end-to-end number. Autonomous decode is unreachable on V4
by construction -- no PagedAttention means `cache_config` is None and the
runner is never built (`normal.rs:1907`, pinned by `normal_loaders.rs:5687`) --
and `cuda.h:1971` bars alloc/free nodes inside a conditional body against
11,436 allocations per token.

Co-authored-by: Claude (ArcGraph chain) <noreply@anthropic.com>
…es/step -> 301 (#198)

* perf(ArcAttention): batch V4 RoPE over the cohort — 99,072 launches/step -> 301

`DeepSeekV2RotaryEmbedding::{forward,forward_inverse_tail}` gated the batched
path on `seqlen_offsets.len() == 1` — the LENGTH of the offset vector, where
the thing that matters is the DISTINCTNESS of its values. `len()` is the batch
size, so the batched path was unreachable at every batch size above one.

The offsets are uniform anyway. `scheduler::default_scheduler`'s
`select_running_bucket` admits a forward pass only when cache lengths are
exactly equal, and the one producer of a ragged dense batch is gated behind
`ARC_MTP_PER_SEQ_KV`, which defaults off (`deepseek4::ragged_row_q0`). So the
loop was performing B bit-identical recomputations and concatenating them back.

Measured, B=256 pure decode, steady state (nsys, 204.80 tok/s):

  kernel          share   launches/step   per (layer, seq)
  ucopy_bf16      10.2%          22,794   2   (2*43*256 = 22,016)
  copy2d_bf16      5.0%          33,610   3   (3*43*256 = 33,024)
  rope_i_bf16      3.9%          33,054   3   (3*43*256 = 33,024)
  uneg_bf16        0.8%          11,008   1   (1*43*256 = 11,008, exact)

19.9% of step time, and the only cost in the profile that gets WORSE as batch
grows. `copy2d` is the 256-argument `Tensor::cat` that exists solely to undo
the split the same function just made; it goes to zero. `uneg` is 11,008
launches to negate a [1,32] slice of a CONSTANT table, so `neg_sin` is now
materialised once at construction — not a kernel to fuse, a kernel to delete.

Cohort form is 43 layers * 7 = 301 launches (129 of them rope_i), which lands
RoPE at the same order as fp8_matmul_tiled (301) and qtip2b_grouped_gemm (129)
instead of dwarfing them. PREDICTION, not a result: this is a launch-count fix,
the bytes moved are unchanged, and how much of the 19.9% actually returns must
be measured on hardware.

Ragged offsets still take the loop verbatim, so enabling ARC_MTP_PER_SEQ_KV
behaves exactly as today. No ragged index_select path is added — nothing ships
dark.

Bit-identical by construction: with 2-D [T, D/2] cos/sin the kernel's stride_b
is 0 (candle-nn/src/rotary_emb.rs), so every batch row reads the same cos/sin
row and each output element is an independent two-multiply-one-add. Seven tests
hold the pre-fix loop as a verbatim oracle and assert raw IEEE-754 bit equality
(not a tolerance) for uniform AND ragged offsets, at seq_len 1 and 2, with a
one-ULP negative control proving the comparator can fail.

Engagement is counted (`rope_cohort_stats::counts`): a fast path that silently
declined would pass every equality assertion trivially. The counter already
earned itself — it caught a missing lock in one of these tests.

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

* perf(ArcAttention): un-deaden the fused qk_norm_rope kernel above batch 1

`fused_qk_gate` declined with "per-sequence position offsets" whenever
`seqlen_offsets.len() != 1` — the same length-vs-values confusion as the RoPE
dispatch in the parent commit, and with the same consequence: the fused
one-launch kernel was dead at EVERY batch size above one, i.e. in exactly the
serving regime it was written for. Its own doc comment advertises "replacing 16
candle launches per layer with 1"; at B=256 it replaced nothing.

The kernel is already batch-correct and needed no change. `qk_norm_rope.cu`
flattens (b, t) into blockIdx.y, recovers `b = bt / seq_len`, and reads the
table at `pos_offset + t` — a position independent of `b`, which is exactly
right when the rows share an offset. Its grid is (n_heads + 1, batch * seq_len)
and the wrapper sizes buffers as `batch * ...`. Nothing assumed batch == 1.

Gate now takes the uniform offset via the shared
`DeepSeekV2RotaryEmbedding::uniform_offset` helper, so both call sites cannot
drift apart. Genuinely ragged rows still decline and still take the eager chain.

Split from the parent commit so it can be reverted independently: this one
swaps in a CUDA kernel that has never run above batch 1, and unlike the parent
it is not bit-identical-by-construction — it is bit-identical by the kernel's
own design, which `ARC_QK_VERIFY=1` checks against the eager chain at every
layer. Verify before trusting; `ARC_QK_FUSED=0` restores the eager path from
the same binary.

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

* fix(ArcAttention): one cohort predicate, one counter pair, per-thread test counts

Integration fix for #198 landing after #204. Both PRs made the same correction
-- `seqlen_offsets.len() == 1` tests the length of the vector where the property
needed is the distinctness of its values -- and each shipped its own spelling of
the predicate and its own engagement counters. `uniform_seqlen_offset`'s doc
comment on the integration branch asked for exactly this collapse ("there must
not be two spellings of the same predicate"); this is that collapse.

1. `pub mod rope_cohort_stats` was declared TWICE in one file (layers.rs:400
   from #204, :1568 from #198). That is a hard name collision -- the file did
   not compile. #204's is the superset (it has `record_cohort` /
   `record_per_sequence` as well as `counts`), so #198's is the one that goes.

2. `DeepSeekV2RotaryEmbedding::uniform_offset` had a byte-identical body to
   `uniform_seqlen_offset`. Removed; `forward`, `forward_inverse_tail` and
   `deepseek4::fused_qk_gate` now call the free function, so the two cannot
   drift apart. The reasoning #198's doc comment carried and #204's did not --
   that uniformity is *enforced* by `select_running_bucket` rather than merely
   observed, and the measured B=256 cost -- moves onto the survivor.

3. Collapsing the counters made them shared, and shared process-global counters
   cannot be delta-asserted from a parallel test binary. `deepseek_rope_cohort_
   tests` held a private mutex, `rope_cohort_tests` held none (until now it was
   the only writer), and three tests duly failed with `(2, 1)` where they wanted
   `(1, 0)`.

   A shared mutex was the first fix and it is also wrong: it obliges every
   present and future test that touches ANY rotary to know about a lock in
   another module. Two such tests already exist and did not --
   `deepseek4::tests::{standard_layer_rope_is_unscaled_and_compressed_is_not,
   mtp_block_takes_the_standard_unscaled_rope_table}` both drive
   `DeepSeekV2RotaryEmbedding::forward` -- and they race the cohort tests. That
   was caught by merging #198 and #121 together and running the full suite:
   `--test-threads=1` passes 707, the parallel run fails one.

   So the counters now also keep a `#[cfg(test)]` per-thread mirror, and the
   tests assert on `local_counts()`. Each test's delta is its own by
   construction, no cooperation from other modules is required, and the serving
   instrument -- the global `counts()` -- is byte-for-byte unchanged.

Also drops #198's copy of `uniform_offset_tests_values_not_length`: with one
predicate it asserted nothing the surviving copy does not, against the same six
cases.

`cargo test -p mistralrs-core --lib`: 699 passed, 0 failed; 707 passed on the
merged #198+#181+#130+#121 tree. The 99,072 -> 301 launch figure remains #198's
own branch measurement and is NOT re-measured here.

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

* fix(ArcAttention): the batched fused qk_norm_rope path is OPT-IN

The parent commit un-deadens the fused Q/K kernel above batch 1 by fixing a
length-vs-values gate. That is the right fix to the gate and the wrong default:
it makes a CUDA kernel LIVE on a path it has never run on.

This repo has paid for that twice in one week.

* TCFRAG shipped default-ON carrying "UNVERIFIED ON HARDWARE — NEVER RUN" in
  its own header. It held 63 GB and permanently broke a layer through a
  poisoned `OnceLock`. #209 retired it to opt-in.
* The fused-512 attention path ran for four days silently dropping the
  attention mask, at 12% agreement with the reference, because nobody ran the
  path it had quietly become live on.

So the doctrine, and this commit: **unverified means default-off, and
"unverified" means unmeasured, not new.**

`ARC_QK_FUSED_COHORT` gates the batched path, default OFF. With it unset,
`batch > 1` declines and takes the eager chain exactly as master does today —
so the parent commit ships its gate fix with no behaviour change, and the
cohort path is available to be measured rather than discovered in production.
`batch == 1` is untouched: that path is already live and already exercised.

Read by VALUE, `== Some("1")`, split into the pure `fused_cohort_enabled_from`
so the polarity is testable without mutating the environment (racy across
`cargo test`'s threads, `unsafe` since the 2024 edition, and meaningless
against a `OnceLock` that latches the first read). Two tests pin it, and they
run with no features and no GPU on every PR: unset is OFF, and `0`/`false`/
`off`/`true`/`on`/`2`/`"1 "` are all OFF. That second one is not pedantry —
#212 converted 23 `ARC_*` flags this week because `var_os(..).is_some()` made
`ARC_FOO=0` mean ON, which silently cancels any A/B whose control sets zero.

To turn it on, once `ARC_QK_VERIFY=1` has bit-compared it against the eager
chain on hardware and it is measured faster: `ARC_QK_FUSED_COHORT=1`. When that
measurement exists, this default flips and the doc comment goes with it.

`cargo test -p mistralrs-core --lib`: 701 passed, 0 failed.

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

* perf(ArcAttention): gather the RAGGED cohort's RoPE — one rank-3 rope_i, opt-in ARC_ROPE_COHORT

The uniform-offset fix (#198, and the shared predicate it now rides on) left
one arm untouched: rows at genuinely DIFFERENT positions still take the
per-sequence loop — ~9 ops x 43 layers x B per step on the measured B=256
profile (~99,000 launches, 19.9% of step time), of which the copy2d half only
undoes a split the same function just made.

Nothing structural required the loop: candle's rope_i accepts a rank-3
[B, T, D/2] cos/sin whenever B matches the input batch (rope_check_cs,
candle-nn/src/rotary_emb.rs, pinned rev 89ab14e; the CUDA wrapper sets
stride_b for rank-3, the CPU path indexes i + b_i*t*d/2). So the ragged arm
becomes: gather the per-row table rows once — [B*T] u32 indices, host-built
because the offsets ARE host data, memoised per instance so the H2D and the
three index_selects run once per step per device (the model shares one rotary
per device per table kind), CLAUDE.md pitfall #5 — then ONE rope_i per
projection. ~5 launches per layer where the loop paid ~9 x B.

DEFAULT OFF behind ARC_ROPE_COHORT, read by VALUE via env_flag_is_set
(=0 means OFF; #212), latched once. Unset keeps the loop byte-for-byte.
Registered in capability_reachability.rs beside the fused-cohort entry, same
doctrine: unverified means default-off, and unverified means unmeasured.

Both ragged arms are named methods driven directly by
deepseek_ragged_cohort_tests — bit-pattern parity (not tolerance) for mixed
offsets at B in {1,3}, T in {1,2}, ascending and descending, forward and
inverse tail, plus a stale-memo test (the cache must rebuild when the cohort
moves) and a pinned unset-default. engaged_count in cuda/qk_norm_rope.rs also
gains its first reader: a test that proves the counters advance, closing the
silent-success hole where an A/B could read a dead path as live.

Parent system: ArcInfer / ArcAttention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

* fix(ArcAttention): bounds-guard the cohort gather — both ragged arms must fail LOUD

The loop arm's narrow(0, offset, seq_len) hard-errors past the table end; the
gathered arm fed the same out-of-range index to index_select, which is not
reliably bounds-checked on the CUDA backend and can read garbage SILENTLY. A
failure-mode asymmetry on exactly the arm the hardware A/B will exercise is
the D18 house fault, so the offsets — host data, the check is free — are now
guarded host-side in cohort_tables before any index reaches the device, with
the offending offset, seq_len and table size named in the error.

out_of_bounds_offset_fails_loud_on_both_arms pins: the loop arm errors, the
cohort arm errors on forward AND inverse tail, the message is the GUARD's (a
backend error cannot pass the test by coincidence), and offset 31 + T=1 on a
32-row table still works, so the bound is not off by one.

Parent system: ArcInfer / ArcAttention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

---------

Co-authored-by: Nirupam Bhowmick <support@runcrate.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…gate (#219)

* feat(ArcSched/ArcKV): lift the three scheduler caps below the B>=214 gate — serve default 256, device-side ragged mask, prefill floor default 4

Parent system: ArcInfer / ArcSched + ArcKV.

CAP 1 — admission default. mistralrs-cli's max_seqs default goes 32 -> 256
(arg default, struct default, default_max_seqs(), and the tune config
template). At B=32 the physics ceiling is ~3,740 tok/s, so the 14,000 tok/s
aggregate gate (B >= 214) was unreachable on the default configuration.
Memory implications documented on the flag; PagedAttention admission is
block-budgeted, DefaultScheduler admission is count-only (VRAM-aware check
for the dense path is named follow-up, not built).

CAP 2 — the mask that would make ragged decode read as a regression.
CausalMasker::make_left_padded_causal_mask was a scalar triple loop over
B * t_q * (past + t_q) host f32s plus a full-mask H2D copy, per decode step
while a cohort is ragged — 4.2 MB/token at B=256 ctx-4096 (CLAUDE.md
pitfall #5 on the hot path). It is now built on the device from broadcast
comparisons; the only per-step H2D is the [B] lead vector, and the index
rows come from a per-device cached arange grown geometrically. The host
loop is kept verbatim as make_left_padded_causal_mask_host, the test
oracle, and the two are pinned element-for-element across mixed lengths,
sliding windows, multi-row queries and the degenerate past=0.

CAP 3 — the prompt-starvation floor defaults ON at 4. Behind a 47-sequence
decode cohort a fresh prompt wins 0 of 24 steps with no floor and 1 in 5
with floor=4 (this file's own fixture). ARC_PREFILL_FLOOR_STEPS=0 is the
kill-switch restoring the pre-floor selection key for key; the env mapping
is factored into floor_from() and pinned by test on both sides.

The V4 ragged-pair default is left OFF deliberately: on this branch the
pair is --v4-ragged-decode / ARC_V4_XS_PER_SEQ (the xs cache) plus
ARC_MTP_PER_SEQ_KV (the per-row query positions in dsv4_attention via
ragged_row_q0). The second gate lives outside this change's file fence,
and opening only the first would let shorter V4 rows attend compressed
blocks they have not reached — silently. The box run flips the two
together on a measured pass.

New/updated capability registry entries: the device-built mask, the
scheduler's ragged_decode_supported read, the request_xs_per_sequence
CLI/config latch, and the floor entry renamed to carry its new default.

New tests: a_formed_cohort_admits_a_newcomer_when_ragged_decode_is_on
(cohorts could previously only shrink),
the_device_mask_matches_the_host_oracle_element_for_element,
the_device_mask_and_the_oracle_refuse_the_same_inputs,
the_floor_defaults_on_and_zero_is_the_kill_switch. All mutation-checked;
values recorded in the test docs and the PR body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

* fix(ArcSched): a 1-token prompt keyed into the pinned ragged decode bucket — prompt-ness joins the BucketKey; typos lane green

The collision the PR body flagged as UNRESOLVED is real, and is now fixed
and pinned by test. With ragged decode published, decode sequences key the
sentinel length 0 — and a 1-token prompt's cache_bucket_len() is
len() - 1 == 0, the same number. The probe test showed the prompt riding
into the decode cohort through the merged bucket's `<= 1` fast path:
scheduled in the same pass (prompt = [4] alongside completion =
[0, 1, 2, 3]), bypassing select_running_bucket, the floor's bookkeeping
and the one-bucket-per-step invariant.

Fix: `is_prompt` becomes a fourth BucketKey component, so a bucket is
homogeneous by construction. The coalescing filter requires matching
prompt-ness (a decode bucket must never idle to catch up to a prompt
bucket), and the floor's is_prompt_bucket now reads the key instead of
whichever sequence sits first in the bucket — removing an order-dependence
at the same time.

Test: a_one_token_prompt_is_not_folded_into_the_pinned_decode_bucket.
Green: completion = [0, 1, 2, 3], prompt = [], waiting = 1. Mutated
(fourth component pinned false): red with prompt = [4]. All 14 scheduler
tests and the full 702-test core lib suite pass.

Typos lane: "unparseable" -> "unparsable" (floor doc), and word-level
allowlist entries for arange/ARANGE in .typos.toml — candle's
Tensor::arange naming appearing as a segment inside cached_arange_f32 /
ARANGE_CACHE, which the existing exact-identifier entries do not cover.
`typos --config .typos.toml .` is clean repo-wide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… the V4 union mask across layers (#221)

* fix(ArcAttention): the Metal sinks arm still dropped the caller's mask

CORRECTNESS, the other backend. #199 gated the CUDA fused arm on
mask.is_none(), but the Metal arm kept engaging on head_dim alone and calling
flash_attn_sinks_metal — which takes no mask parameter at all, so unlike the
CUDA kernel it cannot even refuse one at the interface. GPT-OSS reaches this
arm on every macOS build: head_dim 64 is in Metal's fused set and the model
passes a mask it asserts is Some (gpt_oss.rs).

MEASURED on a real Metal device (M-series, head_dim 128, F32):

    max|fused - reference(causal only)| = 0.000001
    max|fused - reference(masked)|      = 0.860029
    max|ref(causal) - ref(masked)|      = 0.860029   <- the mask's whole effect

The maskless kernel tracks the causal-only reference to 1e-6 and misses the
masked one by the mask's entire effect: dropped, not approximated.

The fix is the same one line as CUDA's: an explicit mask pins the call to the
unfused mask-honoring path, with a once-per-process warn naming the deliberate
perf loss. New tests:

  * metal_sinks_mask_tests (feature = "metal", real device): the baseline
    above, plus the gate — masked dispatch vs masked reference, diff 0.0.
    Mutation-checked on hardware: with the guard reverted the gate test fails
    at max abs diff 0.8600293; restored, 0.00000000.
  * padding_mask_is_honored_and_dropping_it_changes_the_output (CPU, always
    runs): a padding-column mask — the class no fused kernel can re-derive —
    survives the dispatch end to end. Mutation-checked: dropping the mask at
    the fall-through arm fails it at max abs diff 0.9353 vs 0.0 required.

Rider (perf, contained): sinks_attn_cpu_varlen rebuilt its per-sequence mask
with Tensor::from_vec — a host-to-device upload per sequence per layer per
step on the GPU fall-through (CLAUDE.md pitfall #5). It is a pure function of
(q_len, kv_len, window, dtype, device), now memoized in a single slot;
uniform batches build it once per shape. varlen_mask_memo_hits_and_never_
serves_stale pins both memo failure modes (mutation-checked: with the key
comparison removed it fails on the (5, 8, 3) entry).

Also corrects the file docs that called the unfused fall-through "CPU": it is
device-agnostic candle math and is exactly what V4's head_dim-512 layers run
on CUDA. The to_vec1 D2H syncs on cu_seqlens remain — hoisting the host copy
into FlashParams is shared plumbing, reported not fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

* perf(ArcInfer/ArcAttention): memoize the V4 union mask across layers

The union mask is a pure function of nine integers — (q0, t_q, raw_base, t_k,
t_c, ratio, window, dtype, device) — yet dsv4_attention rebuilt it once per
LAYER per step. On the prefill path that is three Tensor::arange
host-to-device uploads plus their casts, compares, the U8 cat and the 0/-inf
conversion, every layer (CLAUDE.md pitfall #5: each arange with a GPU device
is a CPU-to-GPU sync). a7b0890/64fe1d678 already killed the DECODE-path
aranges; this removes the per-layer rebuild wholesale on both paths.

Mechanics: the validity build is now lazy (build_valid closure,
character-for-character the old code on a miss) and the additive
[1, 1, t_q, n_keys] mask is memoized thread-local on the integer key —
4 FIFO slots, since one step touches at most three keys (the Standard / CSA /
HCA layer classes). Per step, each distinct mask is built once instead of
~once per layer. Excluded on purpose:

  * graph_positions (fixed-capacity graph decode): the key would be constant
    across steps, and the ops must stay inside the capture region rather than
    be hoisted into a warmup-owned tensor whose later eviction would leave a
    recorded graph reading freed memory. That build is already arange-free.
  * ragged cohorts (row_q0): the per-row leads are content but not key.

Tests (CPU, validating code not perf — D14):

  * memo_hits_without_building_and_misses_on_a_changed_key: a hit returns the
    cached handle WITHOUT running the builder (handle identity is the only
    observable — a memo that always misses returns correct values while doing
    nothing); an advanced q0 must miss.
  * memoized_masks_are_bit_identical_to_fresh_builds: decode at ctx 35 then 36
    (ratio 4, window 8) — two masks with the SAME shape and different content
    (threshold crosses 8 -> 9). Sequential same-thread runs must be
    bit-identical to fresh-thread (empty-memo) references.

Mutation-checked: with the memo's key comparison replaced by
"serve the newest entry", both tests fail — the identity test at
TensorId(1) != TensorId(6), the end-to-end test with ctx=36's output bits
diverging from the fresh reference (first lane 1051680768 vs 1051066368).

No behavioral change: a miss runs the exact pre-change build, and the
existing 38 dsv4_attention tests (bit-identity, ragged, graph-geometry,
absorbed-decode) all pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

* docs(ArcAttention): reword 'aranges' for the Typos lane (config untouched; #219 owns the allowlist)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ICE ends the 66 MB/step host copy that walled off the GPU sampler; 3 fast-path aggravators (#222)

* perf(ArcInfer/ArcSample): port the STEP_us TOTAL/fwd/sample/other split onto the non-paged step arm V4 actually runs

The only TOTAL/fwd/sample/other instrument lived on the PagedAttention arm,
which V4 can never reach (DeepSeekV4Loader::supports_paged_attention is
false). The DefaultInstructions arm — the path V4 runs — had never once
reported how a step divides between GPU and host. Log format byte-identical
to the paged arm's so tooling parses both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

* fix(ArcInfer/ArcSample): three measured fast-path aggravators + the device-eligibility predicate

(a) has_penalties(): a DRY config at its disabled default (multiplier 0.0,
    a proven no-op in apply_dry_penalty) no longer disqualifies the GPU
    fast path or is_raw_argmax.
(b) sample_top_kp_min_p: the server defaults (top_k=-1, top_p=1.0,
    min_p=0.0) ran a full-vocab sort_unstable_by (129,280 elements on V4,
    per sequence per token) whose result nothing consumed — skip it.
    Seeded draw provably identical (thread-local sort counter + pinned
    token 506 in tests).
(c) sample_multinomial: the global rng MutexGuard was held (temporary
    lifetime extension) across get_top_logprobs and tokenizer.decode —
    narrowed to the draw.

Plus Sampler::device_sampling_terminates, the single-source mirror of
sample()'s device dispatch that ARC_SAMPLE_ON_DEVICE partitioning consults,
and the ignored $0 host-cost probe at V4 vocab (D14: host evidence only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

* test(ArcInfer/ArcSample): partition split tests — copy discipline + seeded token parity for ARC_SAMPLE_ON_DEVICE

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

* chore(ArcInfer/ArcSample): u32::try_from for the gather row index (clippy-by-hand for mistralrs-core)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ispatch engagement log, three-arm sweep harness, SASS probe (#218)

* fix(ArcKernels): the cuBLASLt FP8 branch never flattened rank-3 to 2-D

The native branch flattens `[B, T, hidden]` to `[B*T, hidden]` before its GEMM.
The dequantize + cuBLASLt branch did not — it passed `x` through untouched.

V4's activation is rank-3, and bias is `None` on every V4 linear, so
`UnquantLinear::forward` took `w.broadcast_left(B)` into
`cublaslt.batch_matmul` with `stride_b = 0`. At decode T=1, making it a **B-way
batched GEMM with m=1**: a batched GEMV wearing a GEMM's name, and the one
shape cuBLASLt has no advantage in. The documented 27x was measured at prefill,
where `[1, 512, hidden]` collapses to batch=1 and the call is a real GEMM.

This invalidated the threshold A/B rather than merely slowing it: lowering
`ARC_FP8_CUBLAS_MIN_M` selected the batched-GEVM shape, so the sweep was
comparing the tiled kernel against a degenerate call and would have supported
"cuBLASLt loses at decode" — a conclusion about a shape the change was never
meant to select. Flattening makes the two branches comparable, which is the
premise the A/B rests on.

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

* perf(ArcKernels): pow2 shift fast path for the tiled FP8 GEMM's per-element scale divisions

get_scale in fp8_matmul_tiled performs TWO runtime signed integer divisions
per weight element staged into shared memory (n / block_size_y, k /
block_size_x). Both divisors are kernel arguments, so nvcc expands each into
a ~15-20 instruction reciprocal-and-fixup sequence — paid once per element of
every weight tile, ~33.9G times per B=256 decode step by the source-derived
count. The shipped weight_block_size is [128, 128]: both powers of two.

This adds a POW2_SCALE template arm: the host launcher tests pow2-ness once
per launch, computes shift = log2(block_size) on the host, and the kernel
indexes the scale grid with two SHFs instead. Non-power-of-two geometries
keep the division path, unchanged.

Bit-identical by construction: for the non-negative indices used here,
n >> log2(d) == n / d for every input — same quotient, same scale word
fetched, same arithmetic after it. Dispatching on POW2_SCALE can never change
what an A/B leg measures, only how fast the scalar arm runs.

The C ABI (launch_fp8_matmul_{f16,bf16}) is unchanged; no Rust edits needed.
Compile-unverified locally (macOS has no nvcc); covered by the nvcc CI lane
(.github/workflows/cuda_compile_check.yaml), which builds this TU for
sm_80/sm_90 on every PR touching it.

The bank-conflict half of this lane's brief (+4 -> +1 tile padding) was
already landed by PR #215 and is an ancestor of this branch; the kernel's
stride-33 comment documents the bank arithmetic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

* feat(ArcLab): ARC_LOG_FP8_DISPATCH — engagement lines proving WHICH FP8 kernel served a forward

Every threshold sweep on this lane needs an engagement assertion: a server-log
line proving which kernel actually ran, per leg. None existed — a leg could
silently measure the wrong arm (the ARC_NO_DEDICATED_DECODE incident produced
two void A/Bs exactly this way, both arms running the same code).

ARC_LOG_FP8_DISPATCH=1 (read by VALUE via env_flag_is_set, latched once per
process) makes each dispatch path print ONE line per process:

  [arc-fp8-dispatch] path=<gemv_wide|gemv_warp|wmma|tiled|dequant_cublaslt> first_shape=mM_nN_kK

Sites: the native dispatch chain in fp8_blockwise_matmul_impl (both dtype
arms, mirroring the if/else exactly) and the ARC_FP8_CUBLAS_MIN_M divert to
dequantize+cuBLASLt in BlockwiseFP8Linear::forward. Cost when unset: one
latched bool read. When set: one relaxed atomic swap per call — no locks, so
enabling it does not perturb the rates a sweep records.

Registered in mistralrs-core/tests/capability_reachability.rs (Status::Live);
the registry run passes (11/11). The Rust is cfg(cuda) — compile-unverified
locally on macOS; covered by the cuda-typecheck job of the nvcc CI lane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

* feat(ArcLab): three-arm FP8 threshold sweep harness — the measurement allowed to move ARC_FP8_CUBLAS_MIN_M

arc_fp8_cublas_min_m's doc and the WMMA kernel's doc both forbid merging a
threshold from a two-arm result: the only sweep ever taken predates both the
WMMA GEMM (#200) and the rank-3 flatten, so it compared cuBLASLt-as-batched-
GEMV against the scalar kernel only. This script is the three-arm re-run they
demand, on ONE binary, env toggles only, fresh server per leg (the gates are
OnceLock-latched, so re-exporting into a live server would silently measure
the previous leg).

Design: the threshold candidates {5, 8, 64, 256, 512} are swept as the
OFFERED DECODE BATCH M (a grid that varies only the env at one fixed batch
routes every MIN_M <= batch leg to the same kernel and cannot name a
crossover in M), and each ladder point runs all three arms:
  (a) cublaslt: ARC_FP8_CUBLAS_MIN_M=5
  (b) wmma:     ARC_FP8_CUBLAS_MIN_M=1000000, ARC_NO_FP8_WMMA=0
  (c) tiled:    ARC_FP8_CUBLAS_MIN_M=1000000, ARC_NO_FP8_WMMA=1

A leg counts only if ALL of:
  1. ENGAGEMENT — its expected [arc-fp8-dispatch] path= line present in the
     server log and both rivals ABSENT (a WMMA leg whose log shows tiled is
     eligibility silently failing: VOID, loudly);
  2. FLOOR — summed usage.completion_tokens >= MIN_TOKENS_FLOOR (default
     1000); the rate divides by tokens the server SAYS it generated, never by
     requested max_tokens;
  3. CANARY — greedy fixed-prompt stream vs the baseline leg (tiled @ M=5);
     cross-arm divergence is expected numerics and is REPORTED with its first
     index; a degenerate stream (<5 tokens / no finish_reason) voids the leg.

The summary table names, per M, cublaslt vs min(wmma, tiled) and the FIRST
clean M where cuBLASLt wins — the value ARC_FP8_CUBLAS_MIN_M may move to. No
clean crossover => the default stays 512.

Script only — NOT run here (no GPU; D14). House lock/preflight/provenance
discipline copied from arcspec_token_identity_b8.sh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

* ci(ArcGate): free SASS probe for the FP8 dense-GEMM lane — LDS width census + WMMA HMMA presence

Two questions this settles with nvcc + cuobjdump alone, no GPU:

1. Whether the 4-way bank-conflict cost model for fp8_matmul_tiled describes
   the compiled artifact: the model is a claim about SCALAR 32-bit LDS in the
   inner product, and if ptxas had vectorized those shared loads to LDS.128
   its arithmetic would be about instructions that do not exist. The probe
   prints a per-kernel LDS/LDS.64/LDS.128 census. (Note: the +1 pad that
   kills the conflict also forecloses LDS.128 on tile rows — scalar LDS is
   the EXPECTED reading on current source.)

2. Whether the never-executed tensor-core GEMM (blockwise_fp8_gemm_wmma.cu)
   actually contains tensor-core instructions: HARD FAIL if the TU has zero
   HMMA/HGMMA — the three-arm sweep would otherwise compare cuBLASLt against
   two scalar kernels while calling one of them WMMA.

Teeth: exactly 4 fp8_matmul_tiled kernels asserted (2 dtypes x 2 POW2_SCALE
instantiations — a dropped instantiation makes the census about a kernel not
in the build), and every tiled kernel must show >=1 LDS or the extractor lost
the function body.

Wired into the nvcc CI lane as a step beside the QTIP spill gate, so it runs
for sm_80 and sm_90 on every Rust/kernel PR; also runnable standalone:
  arc-tools/fp8_gemm_sass_check.sh sm_90

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

* fix(ArcGate): SASS probe — replace GNU-awk-unsupported \< \> ERE escapes with cuobjdump -fun extraction

The probe's first CI run went red on itself, honestly: GNU awk treats \< \>
(and warns on \.) as plain characters in ERE, so the awk body-splitter's
counting regexes matched NOTHING, the per-function census returned zero LDS
for all 4 fp8_matmul_tiled instantiations, and the fail-on-zero guard refused
to trust its own zero — which is exactly what it exists for.

This revision removes regex body-splitting entirely: the mangled kernel names
come from the Function listing (awk '{print $NF}', no regex), and
`cuobjdump -sass -fun <mangled>` extracts each function's SASS directly. All
counting is grep -cE with POSIX classes and bracket expressions only — no
\< \> anywhere. Also, per review:

* The two failure modes are now DISTINGUISHED: FAIL[extractor] (no
  instruction lines came back — the census is unverified) vs FAIL[zero-LDS]
  (body extracted, genuinely no shared loads — the conflict model has no
  subject). Body presence is judged on /*<hex>*/ instruction lines, so the
  two cannot be confused.
* The VERDICT[tiled] line prints ONLY when the per-function census is
  complete and non-empty; otherwise it prints NOT ESTABLISHED. A TU-level
  cross-check line (whole cubin, includes the GEMV/MoE kernels) is always
  printed, labeled informational.
* HMMA counting moved off the \< \> GNUism onto the same POSIX pattern.

Verified locally against a stub cuobjdump with a realistic SASS fixture:
happy path (4 kernels, LDSM excluded, LDS/LDS.64/LDS.128 counted per
function, scalar arithmetic correct, exit 0) plus three negative controls
(broken -fun -> FAIL[extractor] + verdict NOT ESTABLISHED; LDS-free body ->
FAIL[zero-LDS]; HMMA-stripped WMMA TU -> hard fail), all exit 1. The real
cuobjdump run is CI's — if -fun ever returns nothing there, the probe fails
loudly rather than lying.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s no longer outlive their sequence (#220)

* fix(ArcInfer/ArcGraph): parked burst tokens no longer outlive their sequence — one funnel, one leak test

A device-loop burst runs with eos_token_id = -1, so it always parks its
full burst; a sequence stopping mid-burst (stop string, max_tokens,
cancel, error, panic, eviction) left its undrained tokens parked, and the
NEXT sequence's first sample_sequence — including the one after its
prefill — consumed them as its own. One user's tokens in another user's
response. clear_pending_tokens documented the contract but its only
production call sites were fallback paths.

The fix is one call at the one funnel every completion path passes
through: Sequence::set_state stands the device loop down on every
transition out of the running set (Done, Error, FinishedAborted,
FinishedIgnored, Waiting, Swapped). Clearing on preemption is deliberate
fail-closed: the sequence re-prefills on re-admission, so its parked
tokens are stale.

To make the funnel testable on machines without CUDA — the cfg(cuda)
darkness is exactly what let this ship — arc-cuda-graph is now a
non-optional dependency of mistralrs-core (its host surface is plain
Rust; CUDA plumbing stays behind its own cuda feature, still enabled by
mistralrs-core/cuda) and device_loop_pre_sampled_token loses its cfg
split. The new leak test drives the real sample_sequence path both ways:
wired, B's first token is its own argmax and the queue is empty; with the
funnel removed, the queue holds 3 of A's tokens and B's first sampled
token is 22 — A's parked id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

* fix(ArcInfer/ArcGraph): owner-tag the pending queue — the leak becomes structurally impossible, not funnel-dependent

The completion funnel cannot see a sequence the DefaultScheduler moves
aside WITHOUT a state change (the bucketing waitlist says so in its own
doc), and batch growth has the same shape. So the queue itself now
refuses: tokens are parked tagged with the owning sequence's id (published
by the sampling pre-hook with eligibility), take requires a matching id,
and a foreign queue is dropped whole with the grep-able instrument line
"ArcGraph: dropped N foreign parked tokens".

The forward's pending>0 short-circuit gains the two guards its aliasing
contract always needed: seq_len == 1 (a parked queue at a prefill step
must not replace the prefill) and pending_owned_by_current(). If the
short-circuit fired and the sample still cannot take (scheduler swapped
sequences in between), the step fails loudly via the aliased-logits
marker instead of host-sampling another sequence's stale graph output.

DeviceDecodeLoop finally resets: every stand_down (including the funnel's)
bumps a device-state generation; run() compares and zeroes ring, cursors
and fault word before a newer-generation burst. Reset at engagement, not
completion, because the funnel cannot reach the pipeline-owned loop and
between bursts is the only point with no in-flight ring writes.

ARC_GRAPH_DEVICE_LOOP is now registered in capability_reachability.rs
(Symbol: device_loop_enabled), closing the refuted premise from the
previous commit.

Interleave test (A waitlisted, no set_state): wired, B samples its own
argmax 9 and the foreign tail is dropped; with the owner comparison
removed, B's first sampled token is 22 — A's parked id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…lips — GEMV_WIDE ON, QK_FUSED_COHORT ON, CUBLAS_MIN_M 512→64 (stacked: B=256 575→758 tok/s, b=1 40.8→43.8) (#223)

* perf(ArcKernels): ARC_FP8_GEMV_WIDE default ON — measured +7.5% b=1 (43.80 vs 40.76 tok/s)

Session-10 hardware A/B, box arc-s10-ledger (H200), candidate 4d03b9e,
leg 3: 3x512 produced tokens, seeded t=0.7 p=0.95, engagement proven by
[arc-fp8-dispatch] path=gemv_wide; canary1 byte-identical, canary2 coherent
and arithmetically correct (numerics within the stated f32 re-association
bound). The ledger's ~1.6x prediction did NOT materialise end-to-end; the
default stands on the measured +7.5%.

Polarity flips from 'only "1" enables' to 'only "0" disables' — still
value-read, never presence-read (#212). Test renamed accordingly
(only_literal_zero_disables) with the assertion values swapped. Registry
entry added (capability_reachability.rs, ArcKernels).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

* perf(ArcAttention): ARC_QK_FUSED_COHORT default ON — engaged + bit-identical at B=256 (599.35 vs 575.12 tok/s)

Session-10 hardware A/B, box arc-s10-ledger (H200), candidate 4d03b9e,
leg 4: fused qk_norm_rope ENGAGED at B=256 (the default arm DECLINED and
took the eager chain), seeded canaries byte-identical, 0/512 request
errors, aggregate 599.35 vs 575.12 tok/s. The +4.2% is inside the ~±5%
inter-leg variance measured the same session (leg 4b no-op delta), so the
flip stands on engaged + bit-identical + not-slower, exactly the bar the
capability entry set. Doc comment and registry entry updated as the old
comment promised; decline message now names the kill switch
(ARC_QK_FUSED_COHORT=0). Polarity stays value-read (#212).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

* perf(ArcKernels/ArcQuant): ARC_FP8_CUBLAS_MIN_M 512 -> 64 — the three-arm sweep this constant's own doc demanded

Session-10, box arc-s10-ledger (H200), candidate 4d03b9e,
arc-tools/fp8_threshold_sweep.sh, LADDER {8,64,256}, one binary, flatten
fix present, >=1000-token floor. Clean rows (tok/s):

  M=64:  cublaslt 552.4 | wmma 328.2 | tiled 223.1  -> cuBLASLt wins
  M=256: cublaslt 715.9 | wmma 620.4 | tiled 285.8  -> cuBLASLt wins

First clean M where dequant+cuBLASLt beats min(WMMA, tiled): 64. M=8 rows
were VOID on the token floor, so 5..63 stays with the native arms — a void
row makes no claim. fp8_gemv_warp still owns M<=4 (the M=1 floor row,
cuBLASLt 0.72x, still stands). Rows quoted in the doc per its own rule:
a threshold is a claim about every value it excludes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

* perf(ArcKV): ARC_V4_FP8_KV default ON — the U8 CUDA leg finally measured: engaged, byte-identical, not slower

Session-10, box arc-s10-ledger (H200), candidate 4d03b9e. Leg 10: seeded
canaries byte-identical to the BF16 arm, b=1 41.49 vs 40.76 tok/s. The lane
has no engagement log line (silent-success class, noted for follow-up), so
engagement was proven by nsys kernel capture instead:
arc_kv_fp8_quantize_kernel x16,297 + arc_kv_fp8_dequantize_kernel x32,594
in one b=1 run. 4-gate stacked verification (this + the three earlier
flips, one binary): B=256 744.7 tok/s (vs 757.5 three-gate, inside the
±5% band) with canaries IDENTICAL through the stack; b=1 44.68 tok/s —
the session's best b=1.

Unlike wave43-BU (same default, shipped unrun, broke every forward), this
default stands on a measurement. ARC_V4_FP8_KV=0 restores BF16; the
ARC_V4_CAPTURE_PROBE veto is unconditional and now tested for the default
arm too (write_kv_inplace has no U8 variant).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WK8VgocBUrr5djjE1ZfCNf

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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