Skip to content

perf(ArcInfer/ArcKV): bound the alloc cache against real headroom — a 1 GiB cap cannot bind a card with 262 MiB free - #213

Merged
heydryft merged 1 commit into
masterfrom
fix/arckv-decode-vram-leak
Aug 21, 2026
Merged

heydryft merged 1 commit into
masterfrom
fix/arckv-decode-vram-leak

Conversation

@heydryft

@heydryft heydryft commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Rebased onto f709872ab. Source claims are read at that ref; measurements are from the H200 legs run on this lane, attributed inline.

⚠️ This is not the crash fix — #209 is

The V4 OOM was a headroom failure. ARC_QTIP_TCFRAG's repack retains ~63 GB against a ~79.5 GB model (ARC_QTIP_TCFRAG=0 frees 64 262 MiB vs 262 MiB), and #209 made it opt-in. This PR bounds the retention rate that rode on top of that. Both rescue the run independently, which is the honest way to state it:

leg change result
ARC_QTIP_TCFRAG=0, alloc cache still ON #209 256/256 tokens, finish=length, five runs
ARC_CANDLE_ALLOC_CACHE=0, TCFRAG still ON this PR's mechanism 1 000 tokens, finish=length, 0.19 MiB/token
both default (before either) — OOM at 19 tokens, ~12.8 MiB/step, held 236.9 MiB / cap 1024 MiB

The cache-off leg reported held 0.0 MiB / sizes 0 on every step, so it was genuinely out of circuit rather than merely quiet. That confirms retention is a mechanism, and that the owner is AllocCache.free on CudaDevice (candle-core/src/cuda_backend/device.rs), not a leaked Tensor — which is what the original bug report assumed. This change is defence in depth on the rate.

The defect: the bound is denominated above available VRAM

The retention bound already exists — #182's candle side (89ab14ef) has been on master since 65dc0fb4a, its mistralrs-core side since 7d91c1127. Both are ancestors of master; git merge-base --is-ancestor 7d91c1127 origin/master passes. #182 is not a missing fix and can be closed as landed-by-another-route rather than rebased.

What is wrong is the number it is set from. DEFAULT_CAPACITY_BYTES is a constant 1 GiB, picked to be "far larger than a decode step's working set and far smaller than any card this runs on". The second half is false for the configuration Arc ships: V4 leaves 262 MiB free at decode start on the measured box (844 MiB on an earlier leg), so the cap sits ~4× above everything the card has left. Crossing it is the only trigger for the LRU eviction behind it (AllocCache::put → 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. Same defect class as every other one-data-point constant in this tree.

The cross-request half of the original report is not a second leak: cuMemFreeAsync returns bytes to the CUDA async pool, and cuMemGetInfo/nvidia-smi count pool-held memory as used (utils/memory_usage.rs). trim_cuda_memory_pools() runs once, post-ISQ, never between requests.

The fix

  • plan_alloc_cache_capacity(raw, free_bytes) = min(1 GiB, min(free/2, max(64 MiB, free/8))), from MemoryUsage::get_memory_available. 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 wins outright, 0 (unbounded, for A/B) included; a typo still does not fall back to unbounded; no reading = previous behaviour exactly.
  • arm_alloc_cache_capacity on the enable edge and every 64 decode steps, because free VRAM shrinks as the KV cache grows. set_alloc_cache_capacity evicts to the low-water mark before returning, so it also hands back what is already held above the new bound.

On the leg that died (262 MiB free) that is a 64 MiB cap against the 236.9 MiB it actually held. On the 844 MiB leg it is 105 MiB. Above 8 GiB free the 1 GiB ceiling binds instead, so every card that is not tight keeps byte-identical allocator behaviour. Only the configuration that OOMs is re-tuned.

🔑 A bound on an unreusable-by-construction growth — the counters say why

sizes (distinct retained size classes) climbed ~10 per step, and free/step was 0.0 on every step after step 3. The reuse rate of the growing buffers is zero by construction: each decode step's KV-shaped tensors are one token longer than the last, so every step files a byte size that will never be requested again. A cache entry that can never be hit is not caching anything — it is a deferred free.

So this PR makes the deferred frees bounded and prompt. The fix is to stop filing a new size every step: preallocate the KV buffers to the sequence's maximum length once and write each step in place, removing the per-step allocation rather than recycling it. That is the named follow-up — larger than this change, since it touches SingleCache's growth policy and the graph path's pinned-capacity contract, and it wants a GPU leg.

Two proposed mechanisms for the growth were killed by arithmetic rather than by argument. Both are recorded in the source doc so they are not re-proposed:

  1. repeat_kv's 64× MQA expansion — unreachable. V4-Flash is 64 Q heads : 1 KV head (deepseek4.rs, pinned by v4_flash_real_config_parses), but dsv4_attention.rs gates on t_q == 1 && k_cat.dim(1)? == 1 && sdpa_params.sinks.is_some() && !absorbed_decode_disabled() and returns absorbed_mqa_decode, which folds the 64 heads into the GEMM M-dimension. Counterfactually it would allocate ~1.6 GiB per decode step at a 2 k context — it would fail on token 1, not token 19. The only other call sites (arcflash.rs) are inside #[cfg(test)] mod tests.
  2. KV-length-dependent size churn (wave65-CQ) — off by ~20×. I proposed this and it is wrong. Its rate is ∝ t_c ∝ prompt length: at a 2 k prompt the arithmetic gives ~7.7 MiB/token, which fits. But the failing run's prompt was one message under 100 tokens (TTFT 0.207 s corroborates independently), where the same formula gives ~0.55 MiB/token against ~12.8 measured. Dead.

The growing term is therefore per generated token, not per-context — which is exactly what sizes += ~10/step at a short prompt says directly.

The 6.04 MiB/token in our record, reconciled

normal.rs recorded "+6.04 MiB per decoded token" for the unbounded cache, from #182's V4 leg over 2 600 tokens. Tonight's leg measured ~12.8 MiB/step and died at 19 tokens. The obvious reading — that a run surviving 2 600 tokens and a run dying at 19 cannot be the same defect — is wrong, and the arithmetic says why.

6.04 MiB/token × 2 600 tokens is 15.3 GiB retained, so #182's leg had at least that much free at decode start. Tonight's box had 844 MiB. TCFRAG landed as #203 on 08-20, after #182's measurement, and retains ~63 GB. Same per-token retention in both runs, two orders of magnitude difference in headroom. That closes it without a second mechanism.

Residual caveat, stated rather than buried: 6.04 against ~12.8 is still a 2× gap in the rate, and that is not explained. It is no longer load-bearing — what needed explaining was the survival difference — but neither number should be quoted as "the V4 rate" without its configuration. The likeliest candidate is TCFRAG itself (tonight's leg had it on; #182's predates it), and that is directly testable: the base256 arm already runs ARC_QTIP_TCFRAG=0 with the cache on, so its held/step is the discriminator. Flagged on #182.

Frame 4 of the original OOM backtrace, resolved as far as source can take it

<Tensor>::cat_contiguous::<&Tensor> is a checkable constraint. Tensor::cat reaches cat_contiguous only when every argument is contiguous, and the &Tensor monomorphization only when the call site literally passes &[&Tensor, …]. Tensor::stack collects Vec<Tensor> and calls Self::cat(&args, dim), so stack can never produce cat_contiguous::<&Tensor> — which removes every packed/fused expert-weight path in one step.

Under that constraint, an exhaustive sweep finds no Tensor::cat of F8E4M3 operands anywhere on a V4 decode forward: blockwise_fp8/ has no cat/stack at all and dequantizes to bf16 first; every DType::F8E4M3 in distributed/layers.rs is inside PackedExperts::new or FusedExperts::new, load-time, and concatenates with stack; moe/experts.rs and deepseek4.rs's compressor fuse are load-time; mxfp4/ops.rs passes &Vec<Tensor>; nvfp4's is #[cfg(test)]; and on the FP8-KV path dsv4_kv_fp8.rs casts off FP8 before the cat.

So one of three things is true, and only the box can say which: the pasted frames were not contiguous (frame 3 was elided); the symbolizer mis-attributed the inlined arm (all ten dtype arms of alloc_uninit inline into one function); or the run carried a configuration not in this tree. It does not change this PR — the OOM was a card-full condition, and the victim says what to shrink next, not what leaked.

Interaction with #211 (merged) — sequencing

A cap that is reachable means LRU eviction actually runs, and eviction cannot express "this address is baked into an instantiated CUgraphExec" — the hazard #211 registers. That hazard is masked twice over today: the cap has never been crossed on a V4 serve, so evict_if_over_capacity has never executed; and the process died at token 19–30, so no graph stayed alive long enough for its baked buffers to age to the front of the LRU queue. Any probe of it before this PR returns a green the instrument never had a chance to fail.

Not reachable in the shipping configuration — capture is behind ARC_V4_CAPTURE_PROBE, unset by default, and arc-cuda-graph drains the cache before every cuMemPoolDestroy. #211's fix must land before capture is defaulted on.

Surfaced, not shipped

  • Polarity-class instance for the sweeping lane: ARC_V4_STANDARD_DENSE is read as std::env::var_os(..).is_some() in mistralrs-core/src/models/dsv4_attention.rs, so ARC_V4_STANDARD_DENSE=0 turns dense-causal attention ON for V4's two Standard layers — the spelling that reads as "off". Same class as ARC_NO_DEDICATED_DECODE. For contrast, ARC_V4_NO_ABSORBED_DECODE twenty lines earlier in the same file is correctly value-read (Ok("1") | Ok("true")), so both conventions sit side by side. Not fixed here — different subsystem, behaviour change, another lane owns the class.
  • Prefix-cache view pinning. prefix_cacher.rs stores seq.normal_cache().to_vec(), and after clone_out_cache those all_data tensors are chunk() views of the cohort's batched buffer (candle narrow shares storage). One entry pins the whole B-row buffer, up to n_on_device = 16 entries, each inserted twice per request. Degenerate (free) at B=1, so it is not the measured per-token growth. The honest fix is a force_contiguous in store() — one device copy per request, which wants a GPU leg before it lands on a tight card.

Tests

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

  • the_cap_must_be_reachable_before_the_card_is_exhausted against old_policy_constant_cap → cap 1073741824 B exceeds the 884998144 B this card has free. Unmutated: 110 624 768 B planned, 774 373 376 B left 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.
  • the_measured_oom_leg_is_bounded_below_what_it_actually_held pins 262 MiB free / 236.9 MiB held against the planned 64 MiB.

cargo check --workspace --all-targets green; cargo test -p mistralrs-core 689 passed, 0 failed, including doc_citations_do_not_rot after the rebase. No new rustfmt diffs (3 before, 3 after, the same 3 pre-existing).

What a GPU leg should falsify

ARC_ALLOC_CACHE_STATS=32 prints held against cap. The claim is that held plateaus at the planned cap, and free/step goes non-zero within the first few steps (it was 0.0 forever before). That is the whole claim — the crash is #209's to have fixed.

Note what this will not fix: sizes will keep climbing ~10/step, because the growth is unreusable by construction. Watching sizes go flat is the acceptance test for the preallocation follow-up, not for this PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UMmjFy8TvsgypxVWNVhhC7

@heydryft
heydryft force-pushed the fix/arckv-decode-vram-leak branch from adea2ea to c249f82 Compare August 21, 2026 13:22
@github-actions

github-actions Bot commented Aug 21, 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        29464        20161         6264         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        10274         6849         2750          675
 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                212        47873            0        37220        10653
 |- BASH                  72         1655         1203          331          121
 |- C                      3           17           17            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                  67         2058         1723           77          258
 |- TOML                   6          207          164            0           43
 |- YAML                   5           41           36            5            0
 (Total)                            53723         4766        37762        11195
─────────────────────────────────────────────────────────────────────────────────
 Rust                    680       339917       292580        17753        29584
 |- Markdown             503        31934          471        27528         3935
 (Total)                           371851       293051        45281        33519
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Total                  1351       513965       362501        97307        54157
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

@heydryft
heydryft force-pushed the fix/arckv-decode-vram-leak branch from c249f82 to 286e7c5 Compare August 21, 2026 13:31
@heydryft
heydryft force-pushed the fix/arckv-decode-vram-leak branch from 286e7c5 to 6d9427c Compare August 21, 2026 13:49
@heydryft heydryft changed the title fix(ArcInfer/ArcKV): the allocator's retention cap was unreachable on V4 — 1 GiB of budget on a card with 844 MiB free fix(ArcInfer/ArcKV): bound the allocator against real headroom — the 1 GiB cap could not bind a card with 262 MiB free 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
heydryft force-pushed the fix/arckv-decode-vram-leak branch from 6d9427c to f7265a5 Compare August 21, 2026 13:58
@heydryft heydryft changed the title fix(ArcInfer/ArcKV): bound the allocator against real headroom — the 1 GiB cap could not bind a card with 262 MiB free perf(ArcInfer/ArcKV): bound the alloc cache against real headroom — a 1 GiB cap cannot bind a card with 262 MiB free Aug 21, 2026
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
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
@heydryft
heydryft merged commit e976a77 into master Aug 21, 2026
18 checks passed
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 pushed a commit that referenced this pull request Aug 21, 2026
…d refuse to instantiate on a miss

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.
heydryft added a commit that referenced this pull request Aug 21, 2026
…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>
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