Skip to content

Bound the caching allocator: +6.04 MiB/token leak -> flat, for 0.15 ms/token - #182

Closed
heydryft wants to merge 6 commits into
masterfrom
perf/alloc-cache-bounded
Closed

heydryft wants to merge 6 commits into
masterfrom
perf/alloc-cache-bounded

Conversation

@heydryft

Copy link
Copy Markdown
Contributor

Stacked on aeonmindai/candle#7, which is where the allocator lives. This branch pins that rev and adds the counters.

The defect

perf/alloc-cache-reachable-on-decode (#177) turned candle's caching allocator on for decode. It is worth 7.80 ms/token and that is real. But the allocator had no capacity and no eviction — the only ways memory went back to the driver were set_alloc_cache_enabled(false) and drain_alloc_cache_and_free(), and this pipeline only calls those on a prefill. One long generation has one prefill, so nothing ever drained.

Measured on an H200, 2 600 tokens, memory.used at 2 Hz

Same binary for all three legs, card verified empty before each. Legs that started on a non-empty card were refused rather than reported.

tok/s VRAM over 2 600 tok 2nd-half slope alloc/step free/step held hit rate
cache off 17.12 +212 MiB 1.52 MiB/s — — — —
unbounded (before) 19.76 +15 700 MiB 110.58 MiB/s 3.4 0.0 15 672 MiB 0.9996
capped 1 GiB (after) 19.70 +2 004 MiB 2.40 MiB/s 43.3 173.0 937 MiB 0.9961

Confirmation leg on the exact shipping tree (arc 576e599, binary dc35a565…): 19.77 tok/s, free/step 173.0, held 937.1 MiB / cap 1024 MiB, server survived.

VRAM by decile, MiB above the start — unbounded is a straight line, capped fills once and plateaus:

unbounded    0  2772  4148  5492  6964  8340  9780 11220 12724 14132 15700
capped 1GiB  0  1716  1780  1780  1812  1844  1844  1876  1940  1940  2004
cache off    0     0     0    32    32    32    32    32    96    96   160

Bounding costs 0.15 ms/token — 50.76 vs 50.61 ms/token — against the 7.80 ms/token the cache buys over having it off. It keeps 98% of the gain.

Two things the report asked about that did not reproduce

The server does not OOM on the first completion. It survives at --max-seq-len 4096 on an empty card, before and after, in every leg. One load-time CUDA_ERROR_OUT_OF_MEMORY was seen (1 of 4 loads) and it is not the cache: the cache is only enabled from inside a decode forward, long after load. Auto-device-map plans 140 GB of a 143 GB card, so load sits on a knife edge. Separate defect.

The unboundedness is not what blocks CUDA-graph capture. Capture aborts identically with the cap on and off, and the allocator counters are byte-identical between the two legs — alloc/step 44.1, free/step 78.7, hit-rate 0.9954, held 315.4 MiB. The cap never engages at 315 MiB. The blocker is one line earlier:

[alloc-cache] WARNING: MISS during capture, size 393216 bytes (not pre-warmed) -> graph will be unstable
ARC capture: instantiate/launch failed: First cuGraphLaunch failed: 700   (CUDA_ERROR_ILLEGAL_ADDRESS)

An allocation size warmup never covered becomes an unstable graph memory node, the first launch reads an unbacked address, and the glibc abort is downstream of the poisoned context. Warmup coverage for 393 216 bytes is the fix, not the cap.

What is here

ARC_ALLOC_CACHE_MAX_MB sets the cap (unset = candle's 1 GiB; 0 = old unbounded, for A/B). A typo deliberately does not fall back to unbounded.

ARC_ALLOC_CACHE_STATS=N prints allocations per step, frees per step, hit rate, and bytes held against the cap, every N decode steps. Those are the numbers this has to be judged on — a green log is not evidence. An earlier arena here reported "accounting OK" and bit-identical output over 52 steps while silently bypassing itself for every buffer under 128 bytes (KERNEL_RULES.md:977-984); what separates a working bounded cache from that is allocations staying low and frees being non-zero, and both are printed.

14 tests (10 in candle for the bound, 4 here for the cap's parse), all host-runnable.

heydryft and others added 5 commits August 19, 2026 20:20
`set_alloc_cache_enabled(true)` sat behind three stacked default-off gates:
`probe && seq_len == 1 && env("ARC_CANDLE_ALLOC_CACHE")`. The first conjunct
tied a general-purpose allocator to the V4 capture probe, so the only way to
recycle a decode step's frees was to also be capturing. The ~11k allocations
per token were a disabled feature, not a missing one.

Replaced with a pure policy, `alloc_cache_action(seq_len, enabled, killed)`:

* decode (`seq_len == 1`)  -> Enable
* prefill (`seq_len != 1`) -> DrainAndDisable
* already in that state    -> Leave

Prefill draining is not incidental. The cache is keyed on exact byte count
(`free: HashMap<usize, Vec<CUdeviceptr>>`, no bucketing, no smallest-fit) and
has no capacity bound and no eviction, so a prefill's large one-shot buffers
would be parked for the process lifetime under a key nothing asks for again.

`ARC_CANDLE_ALLOC_CACHE=0` is the kill switch. Any other value, and unset,
leave the policy in force, so the `=1` the ops scripts pass still means what
it always meant.

The capture probe keeps its own gate for the graph-mode positions; only the
allocator moved out from under it.

Six host-runnable tests for the policy. The allocator has no tests at all in
either repo — candle's `cuda_backend/{device,mod}.rs` carry no `#[cfg(test)]`
and every arc-side exercise is `#[cfg(feature = "cuda")]` — so the decision of
when it is on is now the part that CI can see.

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

Picks up candle b2a4dd80, which gives `AllocCache` a capacity and LRU eviction.
The allocator had neither, and arc only drains it on a prefill, so a single long
generation grew forever. Measured on an H200 over 2 600 tokens, `memory.used` at
2 Hz: **+6.04 MiB per decoded token with no plateau**, against +0.057 MiB/token
for the same run with the cache off — so the growth is the cache's and nothing
else's.

`ARC_ALLOC_CACHE_MAX_MB` sets the cap; unset leaves candle's 1 GiB default; `0`
restores the old unbounded behaviour for A/B. A typo deliberately does *not*
fall back to unbounded.

`ARC_ALLOC_CACHE_STATS=N` prints the allocator's counters every N decode steps:
allocations per step, **frees per step**, hit rate, bytes held against the cap.
Those are the numbers this has to be judged on. A green log is not evidence — an
earlier arena here reported "accounting OK" and bit-identical output over 52
steps while silently bypassing itself for every buffer under 128 bytes
(KERNEL_RULES.md:977-984). Allocations staying low *and* frees being non-zero is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpVNMpb13HkUXqSqbN1o9H
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpVNMpb13HkUXqSqbN1o9H
Test-only change on the candle side; `git diff b2a4dd80..859c49c8` touches
nothing outside `#[cfg(test)] mod alloc_cache_tests`. The H200 numbers in the
branch description were measured at b2a4dd80 and stand.

Correction to that commit message: the allocator has **ten** tests, not eleven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpVNMpb13HkUXqSqbN1o9H
Closes a free path this change introduced: leaving capture mode re-filed parked
buffers through the evicting put, which could hand a private-pool pointer back
to the driver at a moment arc-cuda-graph does not control. Decode is unaffected
— `set_capture_mode` is only called during a capture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpVNMpb13HkUXqSqbN1o9H
@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                     74        25734        18232         4703         2799
 Dockerfile                1           39           22            8            9
 JavaScript               16         3546         2676          482          388
 Jinja2                    7          694          656            5           33
 JSON                     74         4600         4597            0            3
 Makefile                  1            6            5            0            1
 Metal Shading Lan|       33        12224         9431         1142         1651
 PowerShell                1          300          227           30           43
 Python                  145        15139        12482          811         1846
 Shell                    40         9948         6637         2655          656
 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                204        45280            0        35143        10137
 |- BASH                  72         1655         1203          331          121
 |- C                      3           17           17            0            0
 |- CUDA                   2           84           56           16           12
 |- JSON                  18          708          708            0            0
 |- PowerShell             1            1            1            0            0
 |- Python                23         1008          787          113          108
 |- Rust                  66         2051         1716           77          258
 |- TOML                   6          207          164            0           43
 |- YAML                   5           41           36            5            0
 (Total)                            51052         4688        35685        10679
─────────────────────────────────────────────────────────────────────────────────
 Rust                    673       330618       284971        16725        28922
 |- Markdown             491        29234          471        25204         3559
 (Total)                           359852       285442        41929        32481
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Total                  1324       494711       352182        90209        52320
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Two sites in pipeline/normal.rs — a doc comment and a test name. No
behaviour change; the Typos job was the only red check on this PR.

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

Copy link
Copy Markdown
Contributor Author

CI fixed (3c4bb1d2d). Green and ready. Not merging solo — see the ordering note.

What I fixed. Only red check was Typos: unparseable -> unparsable at mistralrs-core/src/pipeline/normal.rs:200 (doc comment) and :2823 (test name). A real spelling fix, no behaviour change.

Ordering matters more than the fix. This PR bounds candle's caching allocator (+6.04 MiB/token leak -> flat). #177 turns that same allocator ON by default and its own docs concede the KV-length-tracking buffers grow O(context^2) and are 'not bounded by this policy'. So:

I have flagged #177 to invert its polarity to opt-in. If that happens, this PR becomes a straightforward improvement that can land on its own merits. If #177 lands as-is, this becomes urgent rather than nice-to-have.

Blast radius of this PR alone: bounds an allocator that is currently off by default, so it is close to inert today and becomes load-bearing the moment #177 lands. The 0.15 ms/token figure was measured on this branch's base — re-measure before quoting.

@heydryft

Copy link
Copy Markdown
Contributor Author

Superseded by #177, which now carries these commits.

The ladder was rebased onto current master and this PR's bounded-allocator work was folded into #177 (the branch it built on), so the stack ships reachable AND bounded as one change rather than as a fast path plus a follow-up nobody merges. #177 is now based on #175.

One defect was fixed while folding it in: Cargo.toml here pinned candle 89ab14ef but Cargo.lock still resolved 88d86a2. cargo metadata --locked failed on this branch. A --locked CI build resolves the LOCK, so this lane would have compiled the OLD, UNBOUNDED allocator while reporting green on the PR whose entire subject is bounding it. The lock is regenerated in #177.

Candle ancestry, verified: 88d86a2 -> b2a4dd80 -> 859c49c8 -> 89ab14ef is a clean linear chain, so the bump is safe for master's pin. arcgraph/prewarm-profile (candle 1fa534d) is a SIBLING off 88d86a2, not an ancestor, and it conflicts with 89ab14ef in four hunks of candle-core/src/cuda_backend/device.rs — the AllocCache struct body, set_alloc_cache_enabled, drain_alloc_cache_and_free and cache_take. The substantive part is that prewarm assumes free's value type is a Vec, which bounded changes to a SizeClass, so three of its sites will not compile even after the markers are resolved. Reconcile, do not clobber — whoever merges second owns that merge.

heydryft added a commit that referenced this pull request Aug 21, 2026
… record the #182 interaction

Three fixes to wave65-CR.

Typos: `arange_step` twice. `.typos.toml` allow-lists `arange` as a whole
identifier under `[type.md.extend-identifiers]`, but `arange_step` is a
different identifier, so typos splits it and flags the `arange` word. Fixed by
rewording, not by widening the config — every other md file in the repo
(CLAUDE.md, wave64-CP, BUDGET_V4_B1, 00_RESUME_HERE) already refers to the bare
`arange`, and the call site is now cited by line instead, which is more useful
than the symbol name anyway.

doc_citations: this would have hard-failed, and the coordinator's warning is
what caught it. candle is not vendored here, so `device.rs:N` resolves by
component-suffix match to arc-profiler/src/device.rs (268 lines) and every one
of the 11 candle citations was LINE-OUT-OF-RANGE — SYMBOL-ROT, which has no
waiver path. All candle references now name file and symbol with no `:N`, and a
note in the preamble says why so nobody adds them back. Verified by running the
gate standalone (it is std-only) against this tree: 12/12 green, and an
engagement check re-introducing `device.rs:1054` reproduces the exact failure
first, so the green is not vacuous.

Re-verified every arc citation against current master. normal.rs:248 -> 299
(moved by #208). deepseek4.rs:3009/3013/6031-6032 and graph.rs:56/214/278/517
unchanged. Added deepseek4.rs:3033 for the arange call site and made the
:6033-6041 range a real citation rather than decoration.

New section on the interaction the coordinator asked about. #182
(perf/alloc-cache-bounded) is not a neighbour of hazard 1 — it is the arc-side
landing of the same allocator line, and therefore its precondition: before the
bound there was no eviction at all, so hazard 1 had nothing to fire with. The
KV cat_contiguous growth sets hazard 1's rate, because retention only reaches
the cap when something churns distinct byte sizes per token. Both branches of
the open one-bug-or-two question are written out and leave hazard 1's mechanism
unchanged, so this does not pre-empt the agent settling it. Records that hazard
1 is currently masked: a process that OOMs at token 22-30 never keeps a graph
alive long enough to observe the eviction, so a green probe today would come
from an instrument that never had a chance to go red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
heydryft added a commit that referenced this pull request Aug 21, 2026
… V4 — 1 GiB of budget on a card with 844 MiB free

## The defect

V4 dies with `CUDA_ERROR_OUT_OF_MEMORY` after 22-30 generated tokens, on the
only KV path it has (eager; PagedAttention does not support MLA). Measured on
an H200: 142 927 -> 143 151 MiB over 20 tokens, ~11 MiB/token in ~32 MiB
granules, and the next request starts at the previous one's high-water mark.

The retention bound on candle's caching allocator already exists (#182's candle
side, `89ab14ef`, on master since `65dc0fb4a`). It is set from a constant —
1 GiB — chosen to be "far smaller than any card this runs on". That is false
for the configuration Arc ships: DeepSeek-V4 loads to 142 927 MiB of 143 771,
so decode starts with **~844 MiB free**. A 1 024 MiB cap cannot be crossed
before the card is exhausted, and crossing it is the *only* thing that triggers
the LRU eviction behind it. The bound was present and out of reach, so
retention was unbounded in practice — arithmetically identical to having no cap
at all.

## The fix

Plan the cap against the headroom the card actually reports, and re-arm it
during decode because free VRAM shrinks as the KV cache grows:

* `plan_alloc_cache_capacity(raw, free_bytes)` — `min(1 GiB, max(64 MiB,
  free/8))`. An explicit `ARC_ALLOC_CACHE_MAX_MB` still wins outright, `0`
  (unbounded, for A/B) included. `None` free reading = previous behaviour.
* `arm_alloc_cache_capacity` on the enable edge and every 64 decode steps.
  `set_alloc_cache_capacity` evicts to the new low-water mark before returning,
  so this also hands back whatever is already held above the new bound.

At V4's headroom the cap becomes 105 MiB, less than half the 224 MiB the failing
run consumed, leaving ~739 MiB to the forward. Above 8 GiB free the 1 GiB
ceiling binds instead, so **every card that is not tight keeps byte-identical
allocator behaviour** and only the configuration that OOMs is re-tuned.

## What this does NOT do, and what it un-masks

It bounds retention; it does not remove the per-token size churn that drives
retention up to the cap. That churn is the `t_c`-sized `Tensor::cat` in
`compressed_kv_from_rows` -> `forward_at_positions` (~22 MB/token, wave65-CQ),
plus every other KV-length-dependent transient. Removing it is a separate
change with a wrong-answer hazard of its own.

A reachable cap means eviction actually runs, and eviction cannot express "this
address is baked into an instantiated CUgraphExec" (PR #211). That hazard is
currently masked by this very defect — a process that dies at token 22 never
keeps a graph alive long enough for its baked buffers to age to the front of the
queue — so landing this makes it reachable for the first time. It is not
reachable in the shipping config (capture is behind `ARC_V4_CAPTURE_PROBE`,
unset by default; `arc-cuda-graph` drains before every `cuMemPoolDestroy`), but
#211 must land before capture is defaulted on.

## Tests

8 new host-runnable tests carrying the measured H200 numbers.
`the_cap_must_be_reachable_before_the_card_is_exhausted` is mutation-checked
against `old_policy_constant_cap`, an executable copy of the previous policy:
with it, `cap 1073741824 B exceeds the 884998144 B this card has free`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMmjFy8TvsgypxVWNVhhC7
heydryft added a commit that referenced this pull request Aug 21, 2026
… V4 — 1 GiB of budget on a card with 844 MiB free

## The defect

V4 dies with `CUDA_ERROR_OUT_OF_MEMORY` after 22-30 generated tokens, on the
only KV path it has (eager; PagedAttention does not support MLA). Measured on
an H200: 142 927 -> 143 151 MiB over 20 tokens, ~11 MiB/token in ~32 MiB
granules, and the next request starts at the previous one's high-water mark.

The retention bound on candle's caching allocator already exists (#182's candle
side, `89ab14ef`, on master since `65dc0fb4a`). It is set from a constant —
1 GiB — chosen to be "far smaller than any card this runs on". That is false
for the configuration Arc ships: DeepSeek-V4 loads to 142 927 MiB of 143 771,
so decode starts with **~844 MiB free**. A 1 024 MiB cap cannot be crossed
before the card is exhausted, and crossing it is the *only* thing that triggers
the LRU eviction behind it. The bound was present and out of reach, so
retention was unbounded in practice — arithmetically identical to having no cap
at all.

## The fix

Plan the cap against the headroom the card actually reports, and re-arm it
during decode because free VRAM shrinks as the KV cache grows:

* `plan_alloc_cache_capacity(raw, free_bytes)` — `min(1 GiB, max(64 MiB,
  free/8))`. An explicit `ARC_ALLOC_CACHE_MAX_MB` still wins outright, `0`
  (unbounded, for A/B) included. `None` free reading = previous behaviour.
* `arm_alloc_cache_capacity` on the enable edge and every 64 decode steps.
  `set_alloc_cache_capacity` evicts to the new low-water mark before returning,
  so this also hands back whatever is already held above the new bound.

At V4's headroom the cap becomes 105 MiB, less than half the 224 MiB the failing
run consumed, leaving ~739 MiB to the forward. Above 8 GiB free the 1 GiB
ceiling binds instead, so **every card that is not tight keeps byte-identical
allocator behaviour** and only the configuration that OOMs is re-tuned.

## What fills the cache is still open — and two candidates are dead

This bounds retention; it does not identify what drives retention to the bound.
Both mechanisms proposed tonight were killed by arithmetic, and both are
recorded in the source doc so they are not re-proposed:

* `repeat_kv`'s 64x MQA expansion is **unreachable** — V4 decode returns through
  `absorbed_mqa_decode`. Counterfactually it would allocate ~1.6 GiB per decode
  step at 2 k context: it would fail on token 1, not token 22.
* KV-length-dependent size churn (wave65-CQ) is **off by ~20x** — its rate is
  proportional to `t_c`, hence to prompt length, and the failing run's prompt
  was one message under 100 tokens (TTFT 0.207 s corroborates). The arithmetic
  gives ~0.55 MiB/token against 11 measured.

The survivor must be per-generated-token rather than per-context, and files a
new byte size each step (the ~32 MiB granule is size-class quantisation).
`ARC_CACHE_DEBUG=1` names it directly.

## What this un-masks

A reachable cap means eviction actually runs, and eviction cannot express "this
address is baked into an instantiated CUgraphExec" (PR #211). That hazard is
currently masked by this very defect — a process that dies at token 22 never
keeps a graph alive long enough for its baked buffers to age to the front of the
queue. Not reachable in the shipping config (capture is behind
`ARC_V4_CAPTURE_PROBE`, unset; `arc-cuda-graph` drains before every
`cuMemPoolDestroy`), but #211 must land before capture is defaulted on.

## Tests

8 new host-runnable tests carrying the measured H200 numbers.
`the_cap_must_be_reachable_before_the_card_is_exhausted` is mutation-checked
against `old_policy_constant_cap`, an executable copy of the previous policy:
with it, `cap 1073741824 B exceeds the 884998144 B this card has free`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMmjFy8TvsgypxVWNVhhC7
heydryft added a commit that referenced this pull request Aug 21, 2026
…1 GiB cap could not bind a card with 262 MiB free

## Retention is the mechanism (control leg, H200, 2026-08-21)

Same request, same box, same 262 MiB of headroom:

| leg | result | rate | cache |
|---|---|---|---|
| cache ON (default) | **OOM at 19 tokens** | ~12.8 MiB/step | `held 236.9 MiB / cap 1024 MiB` |
| `ARC_CANDLE_ALLOC_CACHE=0` | **ntok=1000, finish=length** | **0.19 MiB/token** | `held 0.0 MiB / sizes 0` |

~60x. The owner is `AllocCache.free` on `CudaDevice`
(`candle-core/src/cuda_backend/device.rs`), not a leaked `Tensor`.

## The defect: the bound is denominated above available VRAM

#182's bound is already on master (`7d91c1127`, candle `65dc0fb4a`) and is set
from a constant 1 GiB, chosen to be "far smaller than any card this runs on".
V4 leaves **262 MiB free** at decode start, so the cap sits ~4x above everything
the card has left. Crossing it is the only trigger for `evict_if_over_capacity`,
so eviction **never ran once**: `free/step` was 0.0 on every step after step 3.
A bound that cannot be reached is arithmetically identical to no bound.

Fix: `plan_alloc_cache_capacity(raw, free_bytes)` =
`min(1 GiB, min(free/2, max(64 MiB, free/8)))`, armed on the enable edge and
re-armed every 64 decode steps. The `free/2` term stops the 64 MiB floor from
becoming the loosest term on exactly the hardware the policy protects.
`ARC_ALLOC_CACHE_MAX_MB` still overrides outright. Above 8 GiB free the 1 GiB
ceiling binds and allocator behaviour is byte-identical to before.

## This is a BOUND, not a FIX — and the counters say why

`sizes` climbed ~10 classes **per step** and `free/step` stayed 0.0. The reuse
rate of the growing buffers is **zero by construction**: each decode step's
KV-shaped tensors are one token longer, so every step files a byte size nothing
will ever request again. A cache entry that can never be hit is a deferred free,
not a cache.

So this makes the deferred frees bounded and prompt. **The real fix is to stop
filing a new size every step** — preallocate the KV buffers to the sequence's
max length and write in place. That is the named follow-up and a larger change.

Two proposed growth mechanisms were killed by arithmetic and are recorded in the
source so they are not re-proposed: `repeat_kv`'s 64x MQA expansion is
unreachable (V4 decode returns through `absorbed_mqa_decode`; counterfactually
~1.6 GiB/step, would die on token 1), and wave65-CQ's `t_c`-sized churn is off
by ~20x at the measured sub-100-token prompt (TTFT 0.207 s corroborates).

## A number in our own record is disputed

`normal.rs` recorded "+6.04 MiB per decoded token" for the **unbounded** cache,
from #182's V4 leg. Tonight measured ~12.8 MiB/step at the default 1 GiB cap —
twice as bad *with* a cap as the figure recorded for no cap. A cap that never
binds is behaviourally identical to no cap, and context length cannot explain
the gap in that direction (the 6.04 leg had the longer context). The entry is
now annotated as disputed with both provenances; do not quote 6.04 until it is
re-measured. Flagged to #182.

## Tests

10 host-runnable tests carrying the measured numbers, two mutation-checked
against executable copies of the policy they replace:

* `the_cap_must_be_reachable_before_the_card_is_exhausted` vs
  `old_policy_constant_cap` -> `cap 1073741824 B exceeds the 884998144 B this
  card has free`.
* `the_floor_never_exceeds_half_of_what_is_free` with the `free/2` term dropped
  -> `planned 67108864 B against 0 B free — the floor became the loosest term
  on the tightest card`.

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

Copy link
Copy Markdown
Contributor Author

Two things from tonight's H200 legs that bear directly on this PR.

1. This PR's content is already on master — it is not waiting to land. 7d91c1127 (the normal.rs half) and 65dc0fb4a (the candle bump to 89ab14ef, this PR's target rev) are both ancestors of master; git merge-base --is-ancestor 7d91c1127 origin/master passes, and 37 of this PR's added symbols are in master's normal.rs. Worth closing as landed-by-another-route rather than rebasing the conflict.

2. ⚠️ The 6.04 MiB/token figure this PR's case rests on is contradicted, and in a direction context length cannot explain.

This PR records +6.04 MiB per decoded token for the unbounded cache (V4, 2 600 tokens, memory.used at 2 Hz), and that number is now quoted in mistralrs-core/src/pipeline/normal.rs. Tonight, V4 on an H200 at the default 1 GiB cap measured ~12.8 MiB/step — twice as bad with a cap as the figure recorded for no cap at all.

A cap that never binds is behaviourally identical to no cap, and this one never bound: the leg died at held 236.9 MiB / cap 1024 MiB with free/step 0.0 on every step after step 3. So the two numbers describe the same allocator behaviour. And context length cannot close the gap: the 6.04 leg had the longer context, which should have made its rate higher, not lower.

The control that is not in dispute, measured the same night on the same box: ARC_CANDLE_ALLOC_CACHE=0 ran to ntok=1000, finish=length at 0.19 MiB/token with held 0.0 MiB / sizes 0, against an OOM at 19 tokens with the cache on. Your central claim — that retention is the mechanism — is confirmed. It is the rate that does not reconcile.

I have annotated the source entry as disputed with both provenances rather than deleting it (#213). If you still have the 2 600-token leg's config, the discriminator is whatever differed between the two runs, because it is not the context length.

3. The bound itself is inert on the configuration Arc ships. 1 GiB is ~4x the 262 MiB V4 leaves free on that box, so evict_if_over_capacity has never executed once in a V4 serve. #213 derives the cap from measured free VRAM instead of a constant. That is a follow-on to this work, not a contradiction of it — the bound was the right idea denominated in the wrong units.

heydryft added a commit that referenced this pull request Aug 21, 2026
… 1 GiB cap cannot bind a card with 262 MiB free

**Not the crash fix — #209 is.** #209 made `ARC_QTIP_TCFRAG` opt-in and gave the
card back ~63 GB; the V4 OOM was a headroom failure. This bounds the retention
rate that rode on top of it. Both rescue the run independently, measured on an
H200 2026-08-21:

* `ARC_QTIP_TCFRAG=0`, alloc cache still ON — 256/256 tokens, `finish=length`,
  five runs.
* `ARC_CANDLE_ALLOC_CACHE=0`, TCFRAG still ON — 1 000 tokens, `finish=length`,
  **0.19 MiB/token** vs ~12.8 MiB/step and an OOM at 19 tokens with the cache
  on. `held 0.0 MiB / sizes 0` every step confirms the cache was out of circuit.

So retention is confirmed as *a* mechanism and the owner is `AllocCache.free` on
`CudaDevice`, not a leaked `Tensor`. This change is defence in depth on the rate.

## The defect: the bound is denominated above available VRAM

#182's bound is already on master (`7d91c1127`, candle `65dc0fb4a`) and is set
from a constant 1 GiB, chosen to be "far smaller than any card this runs on".
V4 leaves 262 MiB free at decode start on the measured box, so the cap sits ~4x
above everything the card has left. Crossing it is the only trigger for
`evict_if_over_capacity`, so eviction **never ran once**: `free/step` was 0.0 on
every step after step 3, and the run died holding 236.9 MiB against a 1 024 MiB
budget. A bound that cannot be reached is arithmetically identical to no bound.

Fix: `plan_alloc_cache_capacity(raw, free_bytes)` =
`min(1 GiB, min(free/2, max(64 MiB, free/8)))`, armed on the enable edge and
re-armed every 64 decode steps. The `free/2` term stops the 64 MiB floor
becoming the loosest term on exactly the hardware the policy protects — at
262 MiB free, `free/8` is 32.75 MiB, under the floor, so the floor alone would
have set the cap at 24% of everything left. `ARC_ALLOC_CACHE_MAX_MB` still
overrides outright. Above 8 GiB free the 1 GiB ceiling binds and allocator
behaviour is byte-identical to before.

## Bound, not fix — the counters say why

`sizes` climbed ~10 classes per step and `free/step` stayed 0.0. The reuse rate
of the growing buffers is **zero by construction**: each decode step's KV-shaped
tensors are one token longer, so every step files a byte size nothing will ever
request again. A cache entry that can never be hit is a deferred free.

The real fix is to stop filing a new size every step — preallocate the KV
buffers to max length and write in place. Named as the follow-up; `sizes` going
flat is its acceptance test, not this one's.

Two proposed growth mechanisms were killed by arithmetic and are recorded in the
source so they are not re-proposed: `repeat_kv`'s 64x MQA expansion is
unreachable (V4 decode returns through `absorbed_mqa_decode`; counterfactually
~1.6 GiB/step, would die on token 1), and wave65-CQ's `t_c`-sized churn is off
by ~20x at the measured sub-100-token prompt (TTFT 0.207 s corroborates).

## The 6.04 MiB/token in our record, reconciled

#182 recorded 6.04 MiB/token for the unbounded cache over 2 600 tokens. That is
15.3 GiB retained, so that leg had at least that much free; tonight's box had
844 MiB. TCFRAG landed as #203 on 08-20, *after* #182's measurement. Same
per-token retention, two orders of magnitude less headroom. The residual 2x rate
gap (6.04 vs ~12.8) is unexplained but no longer load-bearing; the likeliest
candidate is TCFRAG itself, and the `base256` arm already has the discriminator.

## Tests

10 host-runnable tests, two mutation-checked against executable copies of the
policy they replace:

* `the_cap_must_be_reachable_before_the_card_is_exhausted` vs
  `old_policy_constant_cap` -> `cap 1073741824 B exceeds the 884998144 B this
  card has free`.
* `the_floor_never_exceeds_half_of_what_is_free` with the `free/2` term dropped
  -> `planned 67108864 B against 0 B free — the floor became the loosest term
  on the tightest card`.

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

Copy link
Copy Markdown
Contributor Author

Follow-up to my previous comment — the 6.04 MiB/token figure reconciles, and I was wrong to call it disputed. Correcting it here so the retraction sits next to the claim.

6.04 MiB/token x 2 600 tokens is 15.3 GiB retained, so your leg had at least that much free at decode start. Tonight's box had 844 MiB (262 MiB on the TCFRAG leg). ARC_QTIP_TCFRAG landed as #203 on 08-20 — after your measurement — and its repack retains ~63 GB against a ~79.5 GB model (ARC_QTIP_TCFRAG=0 frees 64 262 MiB vs 262 MiB; #209 has since made it opt-in).

So it is the same per-token retention in both runs, against two orders of magnitude difference in headroom. That is why a run survived 2 600 tokens and another died at 19, and it needs no second mechanism. Your number stands and your central claim — that retention is the mechanism — is confirmed by tonight's control leg (ARC_CANDLE_ALLOC_CACHE=0: 1 000 tokens, finish=length, 0.19 MiB/token, held 0.0 MiB / sizes 0, against an OOM at 19 tokens with the cache on).

One residual, stated rather than buried: 6.04 against ~12.8 MiB/step is still a 2x gap in the rate, unexplained. It is not load-bearing — what needed explaining was the survival difference — but neither number should be quoted without its configuration. Likeliest candidate is TCFRAG itself, since tonight's leg had it on and yours predates it, and it is directly testable: the base256 arm already runs ARC_QTIP_TCFRAG=0 with the cache on, so its held/step is the discriminator. If you still have your leg's config, that would close it.

The source annotation in #213 now reads as reconciled-with-a-caveat rather than disputed.

My other two points stand: this PR's content is already on master (7d91c1127 + 65dc0fb4a, both ancestors), so it can be closed as landed rather than rebased; and the 1 GiB bound is inert on the shipping configuration, which #213 fixes by deriving the cap from measured free VRAM.

heydryft added a commit that referenced this pull request Aug 21, 2026
…e-user number is void (wave66-CS)

The durable record of session 9. Issues are disabled on this repo, so this is
the issue.

THE BLOCKER. At cc5487a on an H200, batch=1 decode dies with
CUDA_ERROR_OUT_OF_MEMORY after 22-30 generated tokens, 3/3 runs, two
independent arms. Growth is ~11 MiB/token and is never released across
requests: one run ended at 142,959 MiB used and the next started there. The
card is genuinely full at failure, 143,151 of 143,771 MiB.

Consequence, stated as strongly as the evidence supports: every single-user V4
throughput number this project has recorded was measured over <=30 tokens
before a crash. The three-way b=1 agreement (15.11 / 15.39 / 15.51 tok/s) has
"24 tokens" written in its own FACTS row. The best clean fragment tonight was
37.35 tok/s over 20 tokens - a fragment, not a rate.

Six more findings, each with its ref: TCFRAG was default-ON with "UNVERIFIED ON
HARDWARE - NEVER RUN" in its own header (fixed, #209); two host round-trips per
decode step, not one; the dedicated decode tier cannot accept V4
architecturally; the b=1 FP8 GEMV is instruction-bound; four instrument
failures; and #182 is a precondition whose hazard is masked by the OOM.

Retractions added to FACTS: the 5,300-cast / 1,571-MHC premise (stale, profiled
at 05af600), DecodeKvGeometry (never existed in Rust), the 27.2% TAIL (sole
source is a docs file not on master), the latency-bound b=1 story, and "op
count is retired as a lever".

Corrections made while verifying, all against 9c127a2 rather than against the
report: normal.rs:2515 -> :2619; has_cached_kv_info is 3 prose references and 0
definitions, not zero occurrences; ba026e9 is a root/import commit and
introduced nothing; the TCFRAG OnceLock does not panic, it caches None
permanently; tcfrag2b.rs:151-157 -> :151-158; the 2.97 TB/s issue ceiling used
the FP32 warp rate, and the INT32 pipe gives 1.49, so the defensible claim is a
1.5-3.0 TB/s range; ARC_NO_DEFERRED_FREE was overstated; and mark_unreachable
being dark was already on the record from session 8.

Also surfaced, unresolved: PR #182's H200 leg ran 2,600 tokens with no OOM,
which needs >=15.7 GB free at decode start against tonight's 844 MiB. Nobody
has explained the ~60 GB gap, and if it is the serve config then this is a
concurrency-budget defect as much as a per-token leak.

Gates: doc_citations 12/12 green, run standalone against this tree, and the
gate reproduced a real failure on an earlier revision of this file first, so
the green is not vacuous. typos clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMmjFy8TvsgypxVWNVhhC7
heydryft added a commit that referenced this pull request Aug 21, 2026
The box measured the missing ~60 GB after this file was drafted:
ARC_QTIP_TCFRAG=0 frees 64,262 MiB against 262 MiB with it on. tcfrag_words is
a per-weight OnceLock caching a repacked copy of every weight for the process
lifetime — ~63 GB against a ~79.5 GB model. #182's leg predates TCFRAG, so it
had ~15.7 GB of headroom and survived 2,600 tokens at the same per-token rate.
Same leak, two orders of magnitude less room.

The concurrency-budget hypothesis is WITHDRAWN. It was a fair reading of the
evidence available; the measurement removed the need for it.

Attribution corrected: two independent single-variable rescues each work alone
(ARC_QTIP_TCFRAG=0 -> 256/256 tokens x5; ARC_CANDLE_ALLOC_CACHE=0 -> 1,000
tokens), so #209 restored the headroom and #213 is defence in depth on the rate.

Also splits an over-claim of my own. "Every single-user number was measured
inside a crashing run" is two claims and only one is universal:
  A: every V4 single-user tok/s figure is a <=30-token short window. Stands.
  B: "inside a crashing run" holds only at or after cc5487a (#203, today).
     The b=1 trio is from 08-17, before TCFRAG existed - its 24 was a harness
     choice, not a truncation. Claiming otherwise would be a fabrication.
And the 33.4 -> 34.2 pair is a third case: its source CAPTURE_LANE.md exists
nowhere in this repo, on any branch. Unsourced, like the 27.2% and the 16.0%.

Drops the unsourced "declined 4 of 4 loads" claim, records that #210 merged
with a projection multiplied out of the unsourced 16.0% share, and notes #211
merged (registers the hazard; the pin is still unwritten) - and that #209
un-masks it, since 256-token runs now live long enough for the probe to mean
something.

cuda_ops.rs: fix the mis-quote #209 shipped. The header phrase "the whole of
the b=1 decode path" describes qtip2b_gemv_tuned_kernel, not TCFRAG. The
conclusion rests on the dispatch sites instead. Adds the 63 GB retention to the
gate's doc comment, where the next reader of #203 will look.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMmjFy8TvsgypxVWNVhhC7
heydryft added a commit that referenced this pull request Aug 21, 2026
Three corrections to my own text:

- The FACTS session-9 header still asserted "every single-user V4 tok/s figure
  was measured inside a crashing run", which the Claim A/B split immediately
  below it contradicts. Header now states the universal half (<=30-token
  fragment) and points at section 2 for what it does and does not void.
- Provenance SHA f709872 -> fdf0b6c (#205 landed after the rebase).
- TCFRAG (#203, cc5487a) landed 2026-08-21, not 08-20. git log -1
  --date=short is the check; the date is load-bearing because it is what
  establishes that #182's leg predates TCFRAG.

Also 11 -> ~11.8 MiB/token where the retained figure is contrasted with
wave65-CQ's ~22 MB/token moved, so the two are not confusable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMmjFy8TvsgypxVWNVhhC7
heydryft added a commit that referenced this pull request Aug 21, 2026
… 1 GiB cap cannot bind a card with 262 MiB free (#213)

**Not the crash fix — #209 is.** #209 made `ARC_QTIP_TCFRAG` opt-in and gave the
card back ~63 GB; the V4 OOM was a headroom failure. This bounds the retention
rate that rode on top of it. Both rescue the run independently, measured on an
H200 2026-08-21:

* `ARC_QTIP_TCFRAG=0`, alloc cache still ON — 256/256 tokens, `finish=length`,
  five runs.
* `ARC_CANDLE_ALLOC_CACHE=0`, TCFRAG still ON — 1 000 tokens, `finish=length`,
  **0.19 MiB/token** vs ~12.8 MiB/step and an OOM at 19 tokens with the cache
  on. `held 0.0 MiB / sizes 0` every step confirms the cache was out of circuit.

So retention is confirmed as *a* mechanism and the owner is `AllocCache.free` on
`CudaDevice`, not a leaked `Tensor`. This change is defence in depth on the rate.

## The defect: the bound is denominated above available VRAM

#182's bound is already on master (`7d91c1127`, candle `65dc0fb4a`) and is set
from a constant 1 GiB, chosen to be "far smaller than any card this runs on".
V4 leaves 262 MiB free at decode start on the measured box, so the cap sits ~4x
above everything the card has left. Crossing it is the only trigger for
`evict_if_over_capacity`, so eviction **never ran once**: `free/step` was 0.0 on
every step after step 3, and the run died holding 236.9 MiB against a 1 024 MiB
budget. A bound that cannot be reached is arithmetically identical to no bound.

Fix: `plan_alloc_cache_capacity(raw, free_bytes)` =
`min(1 GiB, min(free/2, max(64 MiB, free/8)))`, armed on the enable edge and
re-armed every 64 decode steps. The `free/2` term stops the 64 MiB floor
becoming the loosest term on exactly the hardware the policy protects — at
262 MiB free, `free/8` is 32.75 MiB, under the floor, so the floor alone would
have set the cap at 24% of everything left. `ARC_ALLOC_CACHE_MAX_MB` still
overrides outright. Above 8 GiB free the 1 GiB ceiling binds and allocator
behaviour is byte-identical to before.

## Bound, not fix — the counters say why

`sizes` climbed ~10 classes per step and `free/step` stayed 0.0. The reuse rate
of the growing buffers is **zero by construction**: each decode step's KV-shaped
tensors are one token longer, so every step files a byte size nothing will ever
request again. A cache entry that can never be hit is a deferred free.

The real fix is to stop filing a new size every step — preallocate the KV
buffers to max length and write in place. Named as the follow-up; `sizes` going
flat is its acceptance test, not this one's.

Two proposed growth mechanisms were killed by arithmetic and are recorded in the
source so they are not re-proposed: `repeat_kv`'s 64x MQA expansion is
unreachable (V4 decode returns through `absorbed_mqa_decode`; counterfactually
~1.6 GiB/step, would die on token 1), and wave65-CQ's `t_c`-sized churn is off
by ~20x at the measured sub-100-token prompt (TTFT 0.207 s corroborates).

## The 6.04 MiB/token in our record, reconciled

#182 recorded 6.04 MiB/token for the unbounded cache over 2 600 tokens. That is
15.3 GiB retained, so that leg had at least that much free; tonight's box had
844 MiB. TCFRAG landed as #203 on 08-20, *after* #182's measurement. Same
per-token retention, two orders of magnitude less headroom. The residual 2x rate
gap (6.04 vs ~12.8) is unexplained but no longer load-bearing; the likeliest
candidate is TCFRAG itself, and the `base256` arm already has the discriminator.

## Tests

10 host-runnable tests, two mutation-checked against executable copies of the
policy they replace:

* `the_cap_must_be_reachable_before_the_card_is_exhausted` vs
  `old_policy_constant_cap` -> `cap 1073741824 B exceeds the 884998144 B this
  card has free`.
* `the_floor_never_exceeds_half_of_what_is_free` with the `free/2` term dropped
  -> `planned 67108864 B against 0 B free — the floor became the loosest term
  on the tightest card`.


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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
heydryft added a commit that referenced this pull request Aug 21, 2026
…held 63 GB (wave66-CS) (#214)

* docs(ArcInfer/ArcKV): V4 cannot serve past ~22 tokens, so every single-user number is void (wave66-CS)

The durable record of session 9. Issues are disabled on this repo, so this is
the issue.

THE BLOCKER. At cc5487a on an H200, batch=1 decode dies with
CUDA_ERROR_OUT_OF_MEMORY after 22-30 generated tokens, 3/3 runs, two
independent arms. Growth is ~11 MiB/token and is never released across
requests: one run ended at 142,959 MiB used and the next started there. The
card is genuinely full at failure, 143,151 of 143,771 MiB.

Consequence, stated as strongly as the evidence supports: every single-user V4
throughput number this project has recorded was measured over <=30 tokens
before a crash. The three-way b=1 agreement (15.11 / 15.39 / 15.51 tok/s) has
"24 tokens" written in its own FACTS row. The best clean fragment tonight was
37.35 tok/s over 20 tokens - a fragment, not a rate.

Six more findings, each with its ref: TCFRAG was default-ON with "UNVERIFIED ON
HARDWARE - NEVER RUN" in its own header (fixed, #209); two host round-trips per
decode step, not one; the dedicated decode tier cannot accept V4
architecturally; the b=1 FP8 GEMV is instruction-bound; four instrument
failures; and #182 is a precondition whose hazard is masked by the OOM.

Retractions added to FACTS: the 5,300-cast / 1,571-MHC premise (stale, profiled
at 05af600), DecodeKvGeometry (never existed in Rust), the 27.2% TAIL (sole
source is a docs file not on master), the latency-bound b=1 story, and "op
count is retired as a lever".

Corrections made while verifying, all against 9c127a2 rather than against the
report: normal.rs:2515 -> :2619; has_cached_kv_info is 3 prose references and 0
definitions, not zero occurrences; ba026e9 is a root/import commit and
introduced nothing; the TCFRAG OnceLock does not panic, it caches None
permanently; tcfrag2b.rs:151-157 -> :151-158; the 2.97 TB/s issue ceiling used
the FP32 warp rate, and the INT32 pipe gives 1.49, so the defensible claim is a
1.5-3.0 TB/s range; ARC_NO_DEFERRED_FREE was overstated; and mark_unreachable
being dark was already on the record from session 8.

Also surfaced, unresolved: PR #182's H200 leg ran 2,600 tokens with no OOM,
which needs >=15.7 GB free at decode start against tonight's 844 MiB. Nobody
has explained the ~60 GB gap, and if it is the serve config then this is a
concurrency-budget defect as much as a per-token leak.

Gates: doc_citations 12/12 green, run standalone against this tree, and the
gate reproduced a real failure on an earlier revision of this file first, so
the green is not vacuous. typos clean.

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

* docs: put wave66-CS in the bounded read order, and point FACTS at §2026-08-21

The blocker block at the top is no use if the read order still sends people to
the session-8 FACTS section first. Adds wave66-CS as item 2 (before the census,
because a GPU-spend decision made on void single-user numbers is worse than one
made late) and renumbers.

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

* docs: make the op-count retraction exact — the captured arm kept both round-trips

The first wording said the 1,137x launch cut "left two host syncs in both arms",
which is wrong: the eager arm never replays a graph, so it never reaches
graph.rs:362. The correct and stronger statement is about the CAPTURED arm — it
still pays the replay sync and the sampler's 4-byte D2H, so the CPU never left
the loop and the ~8% cannot be read as the value of removing it.

Also adds the guard against over-claiming in the other direction: this does not
show the two syncs ARE the limiter, only that the experiment could not see them.
The number that would settle it has not been taken.

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

* docs: two wording fixes — "three corrections" not two, and the =0 polarity cuts both ways

The env-flag consequence was stated as "both arms ran with the feature on".
That holds for the 13 .is_some() flags; for the 5 .is_none() ones, =0 counts as
set and therefore SUPPRESSES, so the legs are inverted rather than identical.
Either way the control leg was not a control, but the two failure shapes are
different and a reader debugging an old A/B needs to know which one they have.

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

* docs: name where PR #210's 16.0% kernel share actually comes from

It was cross-referenced loosely as "the same provenance problem as the 27.2%".
Both numbers live on the same line of docs/engineering/OPENROUTER_READY.md,
which is not on master — it exists only on agent/tail-sinkhorn-warp-v2 and
agent/decode-share-probe. One of the two is already being multiplied into a
projected win, so the pointer should be explicit rather than a cross-reference.

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

* docs: flag the mis-quote #209 shipped into cuda_ops.rs

#209's merged doc comment argues TCFRAG "owns the whole of the b=1 decode path"
and cites the .cu header for it. The header says that about
qtip2b_gemv_tuned_kernel — the shipped kernel TCFRAG was written to replace —
at qtip2b_tcfrag.cu:19, not about TCFRAG.

The conclusion is still right, and the two dispatch sites in bitshift.rs
establish it without the quote, so this changes nothing about #209 being the
correct fix. But the quoted sentence does not say what it was cited as saying,
and it is now on master saying it. Recorded rather than left for someone to
re-derive.

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

* docs: TCFRAG's 63 GB closes the blocker — #209 is the fix, not #213

The box measured the missing ~60 GB after this file was drafted:
ARC_QTIP_TCFRAG=0 frees 64,262 MiB against 262 MiB with it on. tcfrag_words is
a per-weight OnceLock caching a repacked copy of every weight for the process
lifetime — ~63 GB against a ~79.5 GB model. #182's leg predates TCFRAG, so it
had ~15.7 GB of headroom and survived 2,600 tokens at the same per-token rate.
Same leak, two orders of magnitude less room.

The concurrency-budget hypothesis is WITHDRAWN. It was a fair reading of the
evidence available; the measurement removed the need for it.

Attribution corrected: two independent single-variable rescues each work alone
(ARC_QTIP_TCFRAG=0 -> 256/256 tokens x5; ARC_CANDLE_ALLOC_CACHE=0 -> 1,000
tokens), so #209 restored the headroom and #213 is defence in depth on the rate.

Also splits an over-claim of my own. "Every single-user number was measured
inside a crashing run" is two claims and only one is universal:
  A: every V4 single-user tok/s figure is a <=30-token short window. Stands.
  B: "inside a crashing run" holds only at or after cc5487a (#203, today).
     The b=1 trio is from 08-17, before TCFRAG existed - its 24 was a harness
     choice, not a truncation. Claiming otherwise would be a fabrication.
And the 33.4 -> 34.2 pair is a third case: its source CAPTURE_LANE.md exists
nowhere in this repo, on any branch. Unsourced, like the 27.2% and the 16.0%.

Drops the unsourced "declined 4 of 4 loads" claim, records that #210 merged
with a projection multiplied out of the unsourced 16.0% share, and notes #211
merged (registers the hazard; the pin is still unwritten) - and that #209
un-masks it, since 256-token runs now live long enough for the probe to mean
something.

cuda_ops.rs: fix the mis-quote #209 shipped. The header phrase "the whole of
the b=1 decode path" describes qtip2b_gemv_tuned_kernel, not TCFRAG. The
conclusion rests on the dispatch sites instead. Adds the 63 GB retention to the
gate's doc comment, where the next reader of #203 will look.

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

* docs: re-verify every citation against fdf0b6c — #205 moved fifteen of them

Master moved twice more while this was being written (#210 -> f709872, #205
-> fdf0b6c). #205 added +292 lines to normal.rs, +275 to layers.rs and +144
to deepseek4.rs, moving fifteen citations in this file including every
mark_unreachable site and the return Ok(None) that section 4 is built on.

doc_citations stayed GREEN through both. That is not a gate failure, it is the
gate's contract: it checks a line is in RANGE, not that it is the RIGHT line,
and a citation with no backticked symbol gets no symbol check at all. Recorded
in the provenance block, because it is the most useful thing on the page for
anyone maintaining these docs. Everything re-derived by grep, twice.

Corrections: normal.rs 1432->1465, 2379->2665, 2370->2656, 2575->2861,
2604->2890, 2619->2905, 2977->3263, and the six mark_unreachable sites
1556/1635/1921/2404/2474/2602 -> 1589/1668/1954/2690/2760/2888; deepseek4.rs
2968->3030, 3149->3190, 3165->3206, 3283->3324, 3334->3375, 2219->2281;
cuda_ops.rs 1601->1614, 1612->1627; graph.rs 214->212; blockwise_fp8/ops.rs
1139->1179, 1140->1180; and three self-citations into 00_RESUME_HERE.md that my
own edits had shifted.

Also notes that master's own code carries the same rot: the site marker inside
mark_unreachable("cuda_graph.autonomous_decode.capture") still reads
"normal.rs:2619". It is a string literal, so the gate cannot see it by design.

Title no longer says "taken inside a crashing run" — that was the half of the
claim the TCFRAG measurement showed is not universal.

Verified capture is still default-off after #205 (ARC_V4_CAPTURE_PROBE, unset,
normal.rs:1940), so section 7's sequencing statement stands.

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

* docs: reorder section 1 so the resolution precedes the remaining work

The "Owner: PR #213" block still sat above the TCFRAG resolution and read as if
#213 owned the blocker. Moved below it and retitled "the rate half", with the
part of #213's diagnosis that survives stated plainly: the 1 GiB constant really
was unreachable at 844 MiB free, and that stays true at any headroom small
enough to matter. It is worth landing; it is not why serving was broken.

Adds the sequencing point that follows: #213's falsification criterion wants a
256-token run, which only exists because #209 landed. Testing it against a
22-token crash would have measured nothing.

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

* docs: fix the FACTS header claim, the master SHA, and a date I got wrong

Three corrections to my own text:

- The FACTS session-9 header still asserted "every single-user V4 tok/s figure
  was measured inside a crashing run", which the Claim A/B split immediately
  below it contradicts. Header now states the universal half (<=30-token
  fragment) and points at section 2 for what it does and does not void.
- Provenance SHA f709872 -> fdf0b6c (#205 landed after the rebase).
- TCFRAG (#203, cc5487a) landed 2026-08-21, not 08-20. git log -1
  --date=short is the check; the date is load-bearing because it is what
  establishes that #182's leg predates TCFRAG.

Also 11 -> ~11.8 MiB/token where the retained figure is contrasted with
wave65-CQ's ~22 MB/token moved, so the two are not confusable.

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>
@heydryft

Copy link
Copy Markdown
Contributor Author

Closing as superseded by 538a955a7 (the merge of #177), verified rather than assumed.

Both of this PR's substantive commits are on master under that merge, as rebased equivalents with identical subjects:

this PR on master under 538a955a7?
7d8760f99 perf(arckv): make candle's caching allocator reachable and on for decode dd5583c4b git merge-base --is-ancestor dd5583c4b 538a955a7 → 0
b0cf25a73 perf(arckv): bound the caching allocator, and print the counters that prove it 7d91c1127 git merge-base --is-ancestor 7d91c1127 538a955a7 → 0

and git merge-base --is-ancestor 538a955a7 origin/master → 0.

The bounded-allocator surface this PR introduced is live on master today: alloc_cache_action, plan_alloc_cache_capacity, parse_alloc_cache_capacity, arm_alloc_cache_capacity, alloc_cache_capacity_bytes and report_alloc_cache_step all exist in mistralrs-core/src/pipeline/normal.rs.

The one thing left that was arguably unique here — the Cargo.toml candle bump to 89ab14ef1216331a782539f533b99f6816708604 ("no eviction inside the capture window") — is also already master's pinned rev, so nothing in this branch is unmerged.

Follow-on work has since built on top of it: #213 (e976a7753) replaced the fixed cap with one computed against real free VRAM, because a 1 GiB cap cannot bind a card with 262 MiB free.

No unmerged work is being discarded by this close.

@heydryft heydryft closed this Aug 21, 2026
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