fix(ENG-CUDAGRAPH-BREAK): the capture pre-grow is gated on a flag the default server never sets, so a non-speculative capture cudaMallocs (#2029) - #2047
Conversation
…e default server never sets (#2029) #2029 reports that with DFlash2 speculation off the engine dies at concurrency 8 inside CUDA graph capture, and that the same binary with `--speculative-config` serves cleanly, so the failing allocation is on a path taken only when speculation is off. That path is the ABSENCE of the #1380 capture pre-grow. Both Qwen3.5 decode-graph drivers compute `dbuf = impl_->dbuf || spec_step` and put `Pool(b).PreGrowForCapture(b, s.demand)` inside `if (dbuf)`. `impl_->dbuf` needs `VT_ASYNC_EXECUTOR=1` and `spec_step` needs a speculative step, so on the default server neither holds and the driver opens `vt::GraphCaptureScope` over a `DevicePool` nobody prepared. `s.demand` and `MarkStepBoundary` are recorded unconditionally; only the consumer is gated. The spec lands before the implementation, per AGENTS.md. It records the mechanism, the one statement that moves in each driver, the gate — zero driver allocations between BeginCapture and EndCaptureGraph on a non-speculative step — and the stop conditions that apply if the drained-pool case does not go red. Three defects found in the same reading are filed and named under `## Owed` rather than fixed here: #2035 (seven drivers with no pre-grow at all), #2036 (two Marlin-path caches that allocate, and one that synchronizes, with no capture guard) and #2037 (the fatal handler promises a stack trace it never prints). FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code]
…ure, not only under the async ring or a speculative step (#2029) With DFlash2 speculation off the engine died at concurrency 8 with `vt cuda: cudaMalloc: operation not permitted when stream is capturing`, and every later request came back `[request submitted to a stopped AsyncLLM]`. The same binary with `--speculative-config` served. That is the default server, so this is not a benchmark artefact. The asymmetry is not in the speculative code. Both Qwen3.5 decode-graph drivers compute `dbuf = impl_->dbuf || spec_step` and put the #1380 capture pre-grow inside `if (dbuf)`. `impl_->dbuf` needs `VT_ASYNC_EXECUTOR=1`, `spec_step` needs a speculative step, and a user who omits `--speculative-config` has neither, so `Pool(b).PreGrowForCapture(b, s.demand)` never ran and the driver opened `vt::GraphCaptureScope` over a pool nobody had prepared. `s.demand` and `MarkStepBoundary` were already recorded unconditionally; only the consumer was gated, and the guard is an accident: that block exists for the parity ring's drain and its persistent step inputs, and #1393 replaced the pre-grow in place without revisiting what it sat under. The `!dbuf` arm needs it at least as much. It passes `persistent_sdi == nullptr`, so `BuildStepDevInputs` and `MaybeBuildAttnCosSin` run from the main pool INSIDE the captured region. That is also what makes `s.demand` exact for it: the cold step takes the identical branch, so its per-class peak IS the capture's demand. One statement moves in each driver. The pre-grow now precedes the `if (dbuf)` rather than following the `b.Synchronize` inside it; a pre-grow is `Backend::Alloc` and nothing else, and the drain still happens before `BeginCapture`. The gate asserts the guarantee, not the call: zero `Backend::Alloc` between `BeginCapture` and `EndCaptureGraph` on a step where `num_spec_decodes == 0` and `VT_ASYNC_EXECUTOR` is unset. `CaptureCapableCpuBackend` gains the capture-window split for it. The pool is drained between the cold step and the capture step, which makes the case deterministic instead of dependent on whether two of the tiny model's tensors collide in a size class, and which also settles the premise #1393 recorded as ungated: an empty free list can only be served by the pre-grow, so green says the cold step's profile COVERS the capture. RED before: 45 driver allocations inside the dense capture, 42 inside the MoE one. GREEN after: 0 and 0, with 49 allocations in the step, all made by the pre-grow outside the region. NOT DEVICE-VERIFIED. No GPU was available; the change edits no `.cu`, and #2029 stays open until a device run confirms the c=8 speculation-off rung serves. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code]
…ighbours and three mutations The spec's `## Evidence` said "recorded in the pull request body"; the readings now live beside the design they support. RED is 45 driver allocations inside the dense capture and 42 inside the MoE one; GREEN is 0 and 0. M3 is the reading worth keeping. Removing the case's `Drain` on the UNFIXED driver makes the capture-window assertion pass while the step allocates nothing at all, so the drain is what gives the case its discrimination and the `allocs() > 0` guard is what stops a pass that measured nothing. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code]
…s an em dash
`check-agent-record.py` refused `.agents/issue-index.md` with
malformed issue row '| [#2037](...) | ' ; expected ... | `ROW-ID` or —
#2037 has no owning row — it names its spec's `## Owed` instead, which is the
other form AGENTS.md allows — and the row spelled that as `-`. The schema wants
`—`.
This edits a row rather than appending one, which the file's own rule forbids.
The narrow reason it is correct here: the row has never left this branch, so no
other branch can hold a copy to merge against, and the net diff against
`origin/main` is still a pure append. An edit to a row that exists on `main` is
the case the rule is about and this is not it.
FOLLOWING_AGENTS_PROTOCOL
Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5 [Claude Code]
… finding predicts it The operator measured the identical fault at concurrency 1 with `--enable-prefix-caching --scheduling-policy lpm` and speculation off, on the same `3d895a202` binary. That is a cheaper reproducer and it widens the issue, so the spec now says what it does and does not change here. It changes nothing about the finding. The pre-grow is skipped on EVERY non-speculative step at EVERY padded size, so nothing in this wave was ever scoped to batch 8 — and this wave's own gate is a single-request `S = 1` case that goes red. The rival reading, that some buffer is sized from a per-step quantity, is checked against the code and recorded as refuted for this driver: `block_table_num_cols` is fixed by `BlockTable`'s constructor, the FA-2 decode scratch key is key-determined and carries its own capture refusal with a different message, and the forward has no knowledge of prefix caching at all. What varies is the pool's free-list state. Which size class is short is NOT determined here and cannot be from a CPU box. `## Owed` now carries three ordered device experiments, the first of which needs no rebuild — `VT_ASYNC_EXECUTOR=1` turns the pre-grow on by itself — together with the reason its negative result would be inconclusive rather than a refutation. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code]
origin/main moved to ba53af8 while this row was in flight. Merged rather than rebased, and the tests were rebuilt and rerun on the merged tree, because merge-tree clean is not merge-tree builds. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code]
origin/main moved again while this row was in flight. Merged rather than rebased, and the focused gates were rebuilt and rerun on the merged tree, because merge-tree clean is not merge-tree builds. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code]
Device proof: your root cause is CONFIRMED on hardware, no rebuild requiredRan experiment 1 from your report on dgx:gpu0 (GB10) under Configuration, identical in both arms except one environment variable:
Fault counters on the passing run:
By your own stated asymmetry, this is the informative direction: serving proves the missing pre-grow is the device cause, whereas dying would have been inconclusive because the same flag also opens the parity ring. It served. The throughput figure is not a performance datapoint — speculation is off and the async executor is on — it is only evidence that requests completed. Experiments 2 (a binary built from |
…at disables the pool, and say why `sanitize-cpu (thread)` failed on `REQUIRE( freed > 0 )` in both new cases. The cause is not ThreadSanitizer and not its allocator: `.github/workflows/ci.yml:1598` sets `VT_POOL_BYPASS: "1"` for that job, on BOTH the `address,undefined` and the `thread` lane. The identical `REQUIRE( 0 > 0 )` reproduces on an ordinary non-sanitized Release build with only that variable set. Under bypass every `Get` is a raw `Backend::Alloc` and every `Put` a real `Free`, so there is no free list, `Drain` reports 0, and `PreGrowForCapture` returns before it grows anything. The guarantee these cases assert is false BY DESIGN there, and false identically for the fixed and the unfixed driver. So the cases now carry `doctest::skip(PoolBypassLane())`, and the reason rides in the case NAME so a reader who sees the skip count can recover it. The predicate mirrors `DevicePool::Bypass()` exactly rather than approximating it. The `REQUIRE(freed > 0)` guard is UNCHANGED: it is still what stops the pooled lane from asserting nothing, which mutation M3 measured. The first reading of this failure was backwards and the comment now records the correction. Without the guard these cases do NOT pass vacuously — they reach the capture assertion and FAIL it, at 107 and 195 driver allocations inside the capture. The guard converts an inevitable failure that names the symptom into one that names the precondition, which is a smaller claim than the one made for it. Measured on a real TSan build (`-DVLLM_CPP_SANITIZE=thread`, `setarch -R`): with `VT_POOL_BYPASS=1` as CI sets it, exit 0, 8 passed / 2 skipped. WITHOUT it, exit 0, 10 passed / 0 skipped, `0` driver allocations inside both captures and zero ThreadSanitizer warnings — so the instrumented runtime exercises this guarantee perfectly well and only the job's environment variable removes it. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code]
origin/main gained #1994 (b758127), #2000 (61ba99f, the KV group layer-count fix) and #1997 (2c27f0d, the sampling defaults plus the Gumbel kernel) while this row was in flight. Merged rather than rebased. The .agents/issue-index.md union is verified by row-ID SET DIFFERENCE rather than by a clean automerge, because GitHub ignores this repository`s union driver and two relocations can automerge into a duplicate. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code]
… and a fourth mutation `sanitize-cpu (thread)` refused the two new cases on `REQUIRE( freed > 0 )`. The spec now records what that was and what it was not. It was not ThreadSanitizer: ci.yml:1598 sets `VT_POOL_BYPASS: "1"` for both lanes, and the identical failure reproduces on a non-sanitized Release build with only that variable set. Under bypass the guarantee is false by design, so the cases skip there with the reason in the name. The first reading of the failure is corrected rather than quietly dropped. The precondition guard did not prevent a vacuous pass — without it the cases fail the capture assertion at 107 and 195 allocations. It converts a symptom failure into a precondition failure, which is a smaller claim. Measured on a real TSan build: with the pool enabled, 10/10, 156 assertions, zero TSan warnings. So the instrument is fine and the job's environment is what removes the coverage. Filed as #2059, with the index row appended. M4 is added and all four mutations were re-run after the fixture change, because a fixture change can disarm a mutation proof. M4 reproduces the CI reading — 146 assertions, 2 failed — on a non-sanitized build. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code]
With DFlash2 speculation off the engine could not warm up at concurrency 8. It died inside CUDA graph capture, and every request after that came back 500
[request submitted to a stopped AsyncLLM].That is the configuration a user gets by omitting
--speculative-config, so this is the default server and not a benchmark artefact.Closes #2029.
The asymmetry is not in the speculative code
#2029 records that the same binary WITH
--speculative-configwarms and serves c=1/2/4 cleanly, and concludes that "the failing allocation is on a path taken only when speculation is off". That is exactly true, and the path is the absence of the #1380 capture pre-grow.Both Qwen3.5 decode-graph drivers compute
with
impl_->dbuf = enabled && DecodeGraphDoubleBufferEnabled()(:10529,:11082),DecodeGraphDoubleBufferEnabled()false unlessVT_ASYNC_EXECUTOR=1(:10302-10308), andspec_step = gdn_meta.num_spec_decodes > 0(:10715,:11267). AndPool(b).PreGrowForCapture(b, s.demand)sat insideif (dbuf)—:10885/:10907in the 35B MoE driver,:11439/:11461in the 27B dense one, which is the driver the #1574 subject runs on.So on the default server neither disjunct holds, the pre-grow never executed, and the driver opened
vt::GraphCaptureScopeover aDevicePoolnobody had prepared. AGetmiss inside that region isDevicePool::Get'sb.Alloc(key)(device_pool.h:212), which on CUDA is thecudaMallocin the message. #1380 named the same message from the same driver.s.demandandPool(b).MarkStepBoundary()were already recorded unconditionally (:11607/:11251,:11046/:10697). Only the consumer was gated, and the guard is an accident of history rather than a decision: that block exists for theVT_ASYNC_EXECUTORparity ring's drain and its persistent step inputs, and #1393 replaced the single-block pre-grow in place without revisiting what it sat under.The
!dbufarm needs it at least as much as thedbufarm. It passespersistent_sdi == nullptr(:11531,:10974), soBuildStepDevInputsandMaybeBuildAttnCosSinrun from the main pool INSIDE the captured region. That is also what makess.demandexact for it: the cold step takes the identicalnullptrbranch, so its per-class peak IS the capture's demand. On thedbufarm the captured region's main-pool demand is a strict subset of the cold step's, which is the containment #1393 recorded.The change
One statement moves in each driver, out of
if (dbuf)and in front of it. Everything else stays:b.Synchronizedrains an in-flight replay only the parity ring can leave behind, and theStepDevInputs/s.pinconstruction is the persistent-input path the ring and the spec capture need.The pre-grow now precedes that
b.Synchronizerather than following it. A pre-grow isBackend::Allocand nothing else — it enqueues no work and reads no in-flight buffer — and the drain still happens beforeBeginCapture, which is the property it was added for.Prefix caching drops the same fault to c=1, and this finding predicts it
Measured by the operator on
dgx:gpu0while this was in flight: same3d895a202binary, speculation off, plus--enable-prefix-caching --scheduling-policy lpm, concurrency 1 — the identical message,illegal memory access0,position discontinuity0,ok=0 failed=8.Nothing in this change was ever scoped to batch 8. The pre-grow is skipped on EVERY non-speculative step at EVERY padded size, and this pull request's own gate is a single-request
S = 1case. What the two configurations differ in is only whether the free list happens to be short when the capture opens.The rival reading — that some buffer is sized from a per-step quantity — was checked against the code and does not hold on this driver:
block_table_num_cols/max_blocksBlockTable's constructor frommax_model_len(block_table.cpp:49), read unchanged bygather_block_table(runner.cpp:1185-1187). It cannot move between two steps, and prefix caching does not touch itDecodeShapeKeyis{batch, hq, heads, groups, head_dim, max_blocks, page_size, num_splits}(cuda_flash_attn_fa2.cu:1013), all key-determined or constant — and it carries its OWN capture refusal at:1021-1029with a different message, so it cannot produce this oneenable_prefix_cachingappears nowhere in the runner's forward pathWhich size class is short in either run is NOT determined here, and cannot be from a CPU box. The fix does not depend on the answer — an unprepared pool is unprepared whatever empties it — but the issue stays open until a device says so. See What is not verified below.
Not the same fault as #2042 (prefix caching + DFlash2 at c=1, the draft's position invariant). With speculation ON, prefix caching fails through #2042; with speculation OFF, through this.
The gate
Two cases in
tests/vllm/models/test_qwen3_5_decode_graph_seam.cpp, one per driver, on the existing CPU seam indecode_graph_seam_harness.h.The assertion is the guarantee, not the call: zero
Backend::AllocbetweenBeginCaptureandEndCaptureGraph, on a step wherenum_spec_decodes == 0andVT_ASYNC_EXECUTORis unset.CHECK(the pre-grow was called)would be a transcription — it stays green when the demand profile is wrong, which is the half #1393's own body recorded as ungated: "the fix rests on the captured forward demanding no more blocks of any size class than the eager forward at that shape did ... no test asserts it."CaptureCapableCpuBackendgains the capture-window split for it: one counter, incremented under a flag the existingBeginCapture/EndCaptureGraphoverrides set.The pool is drained between the cold step and the capture step, and that is the load-bearing part of the construction. In production the free list is SHORT rather than empty — every captured
SizeSlotretains its[S, vocab]logits and[S, H]hidden forever,DevicePoolis keyed by SIZE CLASS, and a ramping server captures more shapes. Reproducing that by arithmetic would make the case depend on whether two of the tiny model's tensors happen to collide in a class, i.e. on a coincidence rather than on the rule.Drainis the same condition taken to its limit, it is a production API called at phase changes, and it is what also settles the premise above: an empty free list can only be served by the pre-grow, so green says the cold step's profile COVERS the capture.CHECK(allocs() > 0)is the non-vacuity guard. The pre-grow is itself a driver allocation made outside the region, so a fixed driver must allocate in this step and must allocate none of it under capture.The
sanitize-cpu (thread)red, and a correctionThat job refused the two new cases on the PRECONDITION, not on the guarantee:
It is not ThreadSanitizer and not its allocator.
.github/workflows/ci.yml:1598setsVT_POOL_BYPASS: "1"for that job, on both theaddress,undefinedand thethreadlane. The identicalREQUIRE( 0 > 0 )reproduces at the same two lines on an ordinary non-sanitized Release build with that one variable set and nothing else changed. (Theaddress,undefinedlane readpending, not green — it had not finished, so it never contradicted this.)Under bypass every
Getis a rawBackend::Allocand everyPuta realFree(device_pool.h:113-127,:246-252), so there is no free list,Drainreports 0, andPreGrowForCapturereturns before it grows anything (:429). The guarantee these cases assert is false by design there, and false identically for the fixed and the unfixed driver.So both cases now carry
doctest::skip(PoolBypassLane()), with the reason in the case NAME so a reader who sees the skip count can recover it, and the predicate mirroringDevicePool::Bypass()byte for byte rather than approximating it.REQUIRE(freed > 0)is unchanged — M3 measured that it is what stops the pooled lane from asserting nothing.A correction, because the first reading of this failure was backwards. The guard did not save a vacuous pass. Removed, the cases reach the capture assertion and FAIL it, at 107 and 195 driver allocations inside the capture, because under bypass every
Getis a driver call. What the guard buys is an inevitable failure that names the PRECONDITION instead of the SYMPTOM. Worth having, and a smaller claim than the one first made for it.The guarantee is therefore not exercised by
sanitize-cpu— and that is the job's configuration, not a limit of the instrument. Measured on a real TSan build,-DVLLM_CPP_SANITIZE=thread,setarch -R:test_qwen3_5_decode_graph_seamVT_POOL_BYPASS=1, as CI sets it0driver allocations inside both captures, 0 TSan warningsThreadSanitizer runs this guarantee green when the pool is on. Nothing in the test file can opt one case back in —
Bypass()is read once into a process-wide function-local static (device_pool.h:480-486), so no scope, no locally constructedDevicePooland noActivePoolScopereaches it — and unsetting the variable for these cases would be worse than the gap, because the pool would then retain blocks that the job's ownASAN_OPTIONS=detect_leaks=1reports as leaks. Filed as #2059.Evidence
mudler-ubuntu-box, x86-64, CPU-only.Release,-DVLLM_CPP_CUDA=OFF -DVLLM_CPP_TRITON=OFF -DVLLM_CPP_SERVER=OFF, Ninja,-j 4.45driver allocations inside the dense capture,42inside the MoE one0and0inside the capture,49in the step — all of them the pre-grow, outside the regiontest_qwen3_5_decode_graph_seam, whole file, three runstest_qwen3_decode_graph_seamtest_qwen3_moe_decode_graph_seamtest_deepseek_v2_decode_graph_seamtest_voxtral_decode_graph_seamtest_qwen3_dflash_decode_graph_seamtest_qwen3_dflash2_drafttest_moe_async_device_idstest_device_pooltest_breakable_graphtest_persistent_step_inputtest_qwen35_paged_forwardtest_qwen3_5_gdn_spec_routingtest_qwen3_5_fa2_classscripts/agent-preflight.shVT_POOL_BYPASS=1, non-sanitizedEvery row above was re-measured on the merged tree after
origin/mainmoved to2c27f0d57(#1994, #2000, #1997), because merge-tree clean is not merge-tree builds and that merge touchedqwen3_5.cppfrom both sides.The four
test_qwen3_5_decode_graph_seamruns also read on #1390, which reports that file exiting 139 with a non-reproducible assertion line: four runs here, exit 0 every time, 10 cases and 156 assertions every time. That is one build on one box and it does not close #1390; it is recorded because a flaky neighbour is otherwise something a reviewer has to re-derive.Mutations
Each compiled clean, each restored by
sha256sum -cagainst the baseline before the next.PreGrowForCaptureback insideif (dbuf)PreGrowForCapturereturns 0 before it grows anythingtest_qwen3_5_decode_graph_seam8/10 andtest_device_pool10/11Drain, on the UNFIXED driver0allocations inside the capture and0in the whole step — and onlyallocs() > 0fires| M4, the skip predicate:
PoolBypassLane()returns false, run underVT_POOL_BYPASS=1| RED — 10 cases / 8 passed / 2 failed, 146 assertions: a byte-for-byte reproduction of thesanitize-cpu (thread)reading, on a non-sanitized build. The skip is load-bearing, not a decorative no-op |All four were re-run after the fixture changed, because a fixture change can disarm a mutation proof. M1, M2 and M3 read identically before and after.
M3 is the one worth reading. It says the drain is load-bearing rather than decorative: without it this case cannot detect the defect at all, and without the non-vacuity guard beside it the case would have reported a pass while measuring nothing. M1 is also the reachability mutation "Nothing lands dead" asks for: deleting the production call site reds the focused gate.
What is not verified
No GPU was available to this implementer, and nothing here is reported as device-verified. The change edits no
.cu, so the CUDA arm is acuda-fat-buildcompile verdict and never a run verdict either way.#2029 therefore stays open. This removes a proven, non-speculative-only gap that produces exactly its message, at every padded size including the c=1 one prefix caching exposes. It does not prove that gap was the only one.
Three experiments settle it, in this order, each on the operator's own invocation with
--speculative-configremoved and--enable-prefix-caching --scheduling-policy lpmadded, at c=1:VT_ASYNC_EXECUTOR=1on the UNCHANGED3d895a202binary. That flipsimpl_->dbuftrue and turns the pre-grow on with no rebuild and no patch. A run that SERVES proves the missing pre-grow is the cause, on the device, against the exact binary c=8 with speculation OFF dies in CUDA graph capture: cudaMalloc while the stream is capturing #2029 was measured on. A run that still dies is inconclusive rather than a refutation, and the asymmetry has to be stated when the result is read: the same flag opens the parity ring, which retains a second[S, vocab]logits and[S, H]hidden per size and can empty the free list by a route of its own.VLLM_CPP_QWEN3_DENSE_DECODE_GRAPH=0, as the control. Serving confirms the fault is inside this driver's capture; dying says the capture that allocates is elsewhere and 1 and 2 answered the wrong question.If 2 still dies, the next instrument is a backtrace at
CudaBackend::Alloc— how #1380 was located, and exactly what #2037 exists to make unnecessary.VT_POOL_STATScannot substitute: it prints per-pool totals at destruction, after the engine is already dead, and names no class.Found and NOT fixed, each with an issue
Seven decode-graph drivers open a capture with no pool pre-grow and no demand profile at all #2035 — seven decode-graph drivers (
qwen3.cpp,qwen3_moe.cpp,deepseek_v2.cpp,deepseek_v4.cpp,voxtral.cpp,laguna.cpp,qwen3_dflash.cpp) open a capture with no pre-grow and no demand profile at all. A grep for the A speculative decode-graph capture does a cudaMalloc inside the captured region and throws on sm_110 #1380 machinery outsidedevice_pool.hreturnsqwen3_5.cppand nothing else. Three of them argue in comments that their cold step makes the capture allocation-free, which is the reasoning A speculative decode-graph capture does a cudaMalloc inside the captured region and throws on sm_110 #1380 measured and refuted. Each needs its ownMarkStepBoundary/StepDemandProfilewiring and its own gate, so it is a different change with a different blast radius.Two Marlin-path caches allocate — and one SYNCHRONIZES — with no CUDA-graph capture guard #2036 —
DenseAlignFor(qwen3_5.cpp:2825-2849) makes five rawd.b.Alloccalls and callsd.b.Synchronize(d.q)at:2846on anMmiss, andEnsureCtmp(cuda_marlin_dense.cu:74-89) grows withcudaMallocAsyncat:85— both with nocudaStreamIsCapturingrefusal, unlike the six sibling shape-keyed caches that have one. Neither is c=8 with speculation OFF dies in CUDA graph capture: cudaMalloc while the stream is capturing #2029's cause: both are keyed by a quantity the cold step already visits at the same shape.engine-fatalprints onlywhat(), so the promised "See stack trace (above)" never exists #2037 — the fatal handler printse.what()and no backtrace whilecore_client.h:63promises "See stack trace (above)". A speculative decode-graph capture does a cudaMalloc inside the captured region and throws on sm_110 #1380 closed only because somebody instrumentedCudaBackend::Allocby hand; CUDA illegal memory access under sustained c=8 load at seqs=16/ctx=8192, after c=1/2/4 serve cleanly (not #1983) #2028 and c=8 with speculation OFF dies in CUDA graph capture: cudaMalloc while the stream is capturing #2029 both record the gap; this change was located by reading the tree rather than by reading the failure.sanitize-cpusets VT_POOL_BYPASS=1 on BOTH lanes, so neither sanitizer ever exercises the DevicePool — and the thread lane gains nothing from it #2059 —sanitize-cpusetsVT_POOL_BYPASS=1on both lanes, so theDevicePoolfree list, size-class ladder, best-fit borrow (Memory grows monotonically per request at 12288 context: three requests exhaust the box #1922) and capture pre-grow (A speculative decode-graph capture does a cudaMalloc inside the captured region and throws on sm_110 #1380) are unexecuted under ASan and TSan. The stated justification is ASan'sdetect_leaks; ThreadSanitizer has no leak detector and gains nothing, while losing the one allocator whosestd::mutexand shared maps that lane exists to inspect. Measured above: with the pool enabled thethreadlane is 10/10 and silent.Out of scope and deliberately untouched: #2028 (the illegal memory access with speculation ON), #2007 (two pools), and the
P == 1capture gate atqwen3_dflash.cpp:1577.Records
.agents/specs/cudagraph-pregrow-nonspec.mdis committed before the implementation, and.agents/issue-index.mdgains four appended rows (#2029, #2035, #2036, #2037).origin/mainmoved three times while this was in flight; the last merge brought #1994 (b758127ec), #2000 (61ba99ffd) and #1997 (2c27f0d57). The.agents/issue-index.mdunion is verified by row-ID SET DIFFERENCE rather than by a clean automerge, because GitHub ignores this repository's union driver and two relocations can automerge into a duplicate: 750 rows before, 755 after, +5 added and 0 removed, every keyorigin/mainholds still present, the only keys not onorigin/mainbeing this branch's own five, and 755 unique numeric keys for 755 rows — no duplicate.One of those rows is then EDITED, which the file's own rule forbids, and the commit that does it argues for the exception rather than hiding it:
check-agent-record.pyrefused a hyphen where the schema wants an em dash, the row had never left this branch so no other branch can hold a copy to merge against, and the net diff againstorigin/mainis still a pure append. An edit to a row that exists onmainis the case the rule is about, and this is not it.FOLLOWING_AGENTS_PROTOCOL
Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5 [Claude Code]