feat(ENG-EXPERT-STREAM-DEVICE): W1 — a device slot store, and the fill contract without which nothing could fill it (#1124) - #1735
Merged
Conversation
…l contract without which nothing could fill it (#1124) `--device cuda` serves `Qwen3.8-2.4T-A95B UD-Q1_0` today only where the platform's kernels can dereference host storage, because the only production `ExpertSlotStore` keeps its slots in a `std::vector<uint8_t>`. That is one part, a GB10. A discrete device cannot read that arena at all, so the slice has to live in device memory. `DeviceExpertSlotStore` is that store: one contiguous arena through `vt::Backend::Alloc`, plus one pinned host staging slot. It could not be filled by ANY caller before this change, which is why the contract change rides here rather than in a wave of its own. `ExpertStreamer::EnsureFile` hands `SlotForWrite()`'s pointer straight to `::pread`, and `vt::Backend::DeviceMemoryIsHostAddressable()` is false for CUDA, so a device slot pointer is not a legal `pread` destination. Scheduling the fix after W1 would have scheduled a wave that deadlocks its predecessor. So `ExpertSlotStore` gains `CommitSlot(int32_t, size_t)`: `SlotForWrite` returns a host-writable destination, `pread` fills it exactly as it does today, and `CommitSlot` publishes it. On the host store that is a bounds-checked no-op over a `SlotForWrite` that still returns the slot itself, so the host path is byte-identical — which is this wave's stop condition. `CommitSlot` is PURE on the interface rather than a defaulted no-op. A default is correct for exactly one implementation and silently wrong for every store whose slots the host cannot write, which is the population it was added for, and its failure presents as zeros in a slot rather than as a compile error. That failure is literally the RED this change was gated on. The bounce is a choice, not a default. A zero-copy filler (GPUDirect Storage / `cuFile`, or `O_DIRECT` DMA into a device BAR mapping) moves fewer bytes and needs a driver capability probe, a mount-level check, an aligned-I/O path and a fallback for each; the bounce costs one extra host-to-device copy of one slice per MISS on top of a disk read of the same size, and it keeps the zero-copy filler optional rather than load-bearing. The measurement that would justify replacing it — a device-arm decode where the H2D leg is a measurable fraction of fill time — does not exist yet and is recorded under the spec's `## Owed`. G1, red-first and mutation-proven, on a CPU `vt::Backend` because that is what the gate asks for and because no discrete NVIDIA GPU is reachable from this project (G-DISCRETE stays owed). RED, with everything present except the streamer's publish call: exit status 1, 9 cases with 2 failed, 97 assertions with 17 failed, `Status: FAILURE!`, compile status 0, no ENOSPC in the build log. All four slices failed against the host store AND against the file, because the bytes sat in staging. GREEN with the call: 9 cases, 97 assertions, 0 failed, exit status 0. `test_host_expert_slot_store` 9/203 and `test_expert_streamer` 9/132, both green. NOTHING LANDS DEAD, declared rather than implied. What is not reached: `DeviceExpertSlotStore`. No loader, no model and no registered command constructs one, because `Qwen35ExpertStream::store_` is still a `std::unique_ptr<HostExpertSlotStore>` and `Qwen35ExpertStream::Slice` reads the concrete `HostExpertSlotStore::Slot`. The owning row is `ENG-EXPERT-STREAM-DEVICE`, wave W2, which makes the read virtual and selects the store from the platform. The tracking issue is #1124, and it stays OPEN. `.agents/specs/expert-stream-device-slots.md` lists it under `## Owed`. The narrower half stated so nobody reads it generously: the `CommitSlot` CALL sits in `ExpertStreamer::EnsureFile`, which IS a production call site reached from `Qwen3_5Model::Forward`, and it executes on every streamed fill — doing nothing, because the store production selects is the host one. A call count is not a capability, so both halves wait on W2. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code]
Keeps the branch fast-forwardable and lets the trailer and commit-style gates run, which they SKIP while origin/main is not an ancestor of HEAD. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code]
…o, which W1 argued for in four places and attacked in none (#1124) The fresh review of #1735 returned FAIL on one finding, and it is correct. `store_.CommitSlot(...)` sits inside `ExpertStreamer::EnsureFile`'s `try` so that a failed publish takes the same `cache_.Invalidate` a failed read takes. That placement is defended in the pull request body, the commit body, the spec and the source comment, with a named failure scenario. Nothing tested it. Moving the call to just after the `catch` left all three suites green, because no store in the tree could fail a publish. The scenario is real and it arrives with W2. `DeviceExpertSlotStore::CommitSlot` calls `vt::Backend::Copy` and `Synchronize`, and a real CUDA backend throws out of both. Outside the `try` that throw escapes with the cache still claiming the key resident over a slot holding the previous expert's bytes; the next request for that key is an ordinary HIT, no read is issued because a hit moves no bytes, and the GEMM multiplies the wrong expert with no symptom. That is the corruption the surrounding `try` exists to prevent, and this wave's own new arm of it was the one with no gate. `RecordingStore` takes a `throw_on_commit` flag and `test_expert_streamer` gains "a PUBLISH that throws leaves nothing resident either", mirroring the existing case that covers the `pread` arm. RED with the call moved out of the `try`: 10 cases with 1 failed, 182 assertions with 6 failed, exit status 1, compile status 0, no ENOSPC. The red is the corruption itself and not a proxy for it — `cache.IsResident(key)` stays TRUE, and the retry comes back `hit` with `filled` false, which is the "ordinary HIT over a slot nobody published" the comment predicts, in the assertion output. GREEN with the call restored: 10 cases, 187 assertions, 0 failed, exit status 0, and `expert_streamer.cpp` restored byte-identical by sha256 against `git show HEAD:`. Two informational findings from the same review are corrected in prose, because both were statements this change made and got wrong. The G1 comparison against the FILE is defence in depth, not the thing that catches an unpublished slot. The claim was that two empty arms would pass a bare `memcmp`; the host arm is filled by its own streamer and is non-zero, so the host-versus-device comparison reds on its own. Measured rather than conceded: with both file `CHECK`s deleted AND the H2D copy deleted, the suite is still RED at 9 cases with 3 failed, 89 assertions with 9 failed, exit status 1. What the file check does buy is a DETERMINISTIC red, because the device arena is not zero-initialised. That is the second correction. `vt::Backend::Alloc` is `std::aligned_alloc` on the CPU backend and `cudaMalloc` on CUDA; only the host store's `std::vector` zeroes. So "byte-identical to the host store" is true over the bytes a fill WROTE and says nothing past them, and the two stores genuinely differ there. It does not reach G1, where every fill writes a whole slot, and it is now stated in the store's header and in the gate rather than left for a reader to discover. Zeroing the arena would cost a full write of the whole budget at load, 18.55 GiB on the target checkpoint, to define bytes the streamer never hands out. Nothing about the reachability position changes: `DeviceExpertSlotStore` is still unreached, W2 still owns the wiring, and #1124 stays open. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code]
Keeps the branch fast-forwardable and lets the trailer and commit-style gates run, which SKIP while origin/main is not an ancestor of HEAD. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code]
… more fill entry points, one of them production (#1124) The second fresh review of #1735 found that the undo `EnsureFile` takes when a fill throws was never added to the other two entry points. `EnsureSpan` and `Ensure` call `store_.WriteSlot(...)` bare, and that call is the identical window one step earlier: `Acquire` has to run first because the write needs a slot, so by the time the write throws the cache already says the key is resident, over a slot still holding the expert the acquisition just evicted. The throw escapes, the next request for that key is an ordinary HIT, no bytes move because a hit moves none, and the GEMM multiplies the evicted expert. Silent, plausible and wrong. W1 is what opened it. Before this wave every store's `WriteSlot` was a `memcpy` and could not throw. `DeviceExpertSlotStore::WriteSlot` calls `vt::Backend::Copy` and `Synchronize`, and both route through `Check()` in the CUDA backend and throw `std::runtime_error`. `EnsureSpan` is not a wait-for-W2 path either: it is a production call site, reached from `Qwen3_5Model::Forward` through `Qwen35ExpertStream::Slice`, and this wave's own device suite drives the device store through it. Both now take the same `try` / `catch (...) { cache_.Invalidate(key); throw; }` as `EnsureFile`. Gated red-first by a `throw_on_write` flag on `RecordingStore` mirroring the existing `throw_on_commit`, with one case per entry point, and each asserts consistency rather than emptiness: the key is not resident, `SlotOf` is empty, `resident()` is 0, the fill counters are unmoved, the slot still holds the evicted expert's bytes, and the retry is a real MISS that refills. RED with neither `try` present, at 12 cases with 2 failed and 232 assertions with 16 failed, and the red is the corruption itself rather than a proxy — `retry.hit` is true and the slot holds expert 4's bytes under expert 6's key. GREEN with both at 12 cases / 269 assertions / 0 failed. The two wraps are proven independent by mutation, because wrapping one leaves the other exactly as exposed: M12 reds the streamer suite at 235 assertions with 8 failed, M13 at 266 with 8. The constructor leaked on the failure that happens and guarded one that cannot. It tested `Alloc` and `AllocPinned` for nullptr, and no backend in this tree returns one — `CpuBackend::Alloc` refuses with `VT_CHECK`, `CudaBackend::Alloc` and `AllocPinned` through `Check(...)`, and the base `Backend::AllocPinned` forwards to `Alloc`. They throw. So both guarded branches were unreachable while a throw from `Alloc` stranded the queue and a throw from `AllocPinned` stranded the queue and the whole device arena, 18.55 GiB on the target checkpoint, at the one moment the device has no memory left to lose. Out of memory is this class's headline failure; #1123 is literally `vt cuda: cudaMalloc: out of memory`. The acquisitions now sit inside a `try` whose `catch` runs the destructor's body and rethrows unchanged. The nullptr branches are kept deliberately, because `vt::Backend` is an interface and a nullptr-returning implementation would otherwise hand out slot pointers off a null arena; they cost one branch and no cleanup code now that the catch owns the release. The header's claim that a failed allocation raises this constructor's own `std::runtime_error` is corrected to say what actually happens. Two recorded mutation counts were allocator-dependent, which is the very non-determinism the F2 correction invokes as its justification appearing inside the gate's own assertions. The "each slot holds a DIFFERENT slice" check compared two device slots that a publish-suppressing mutation leaves unwritten, and `vt::Backend::Alloc` does not initialise them, so whether that assertion red was decided by `std::aligned_alloc` garbage — the review measured M4 at 18 failed and the F2 combination at 10 where the record said 17 and 9. The gate now writes every device slot to a known byte before the fills, so an unmutated fill is the only thing that can make two slots differ. Proven rather than asserted: under M4 the assertion fails on 25 consecutive runs and the suite reads 112 assertions with 18 failed on all 25. Every row of the mutation table is re-measured at this head, because the two new suites changed the denominators and a stale recorded count is a claim the tree does not support. The file `CHECK` beside it is kept, with its reason replaced rather than deleted. It was justified as making a red deterministic, which the prefill now does and which was never the stronger ground. Host-arm against device-arm is a shared-helper comparison — both arms run the same `ExpertStreamer` over the same descriptor at the same `file_offset` — so a streamer that read the wrong offset, read short, or read one slice twice makes both arms identically wrong and passes it. The bytes on disk are the only input neither arm computed, and that check is the only assertion that can see it. The "byte-identical" qualification landed in the header, the gate case and the spec's `## Now` but not in the two lines that DEFINE the gate, six hundred lines away. `## Gates` G1 and the `## Tests to port` row are what a W2 or G-DISCRETE implementer reads to learn what PASS means, and both said the unqualified thing. Both now say "over the bytes a fill wrote". Nothing about the reachability position changes. `DeviceExpertSlotStore` is still unreached: no loader, no model and no registered command constructs one, because `Qwen35ExpertStream::store_` is still a `std::unique_ptr<HostExpertSlotStore>` and `Qwen35ExpertStream::Slice` reads the concrete `HostExpertSlotStore::Slot`. The owning row is `ENG-EXPERT-STREAM-DEVICE` wave W2, the tracking issue is #1124, and it stays open. The spec records the same under `## Owed`. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code]
…belongs to (#1124) PR #1735 has now had two fresh reviews, and both raised an F1 and an F2. The first review's F1 was the publish arm of `EnsureFile` and its F2 was the file `CHECK`'s justification; the second review's F1 is the unwrapped write at `EnsureSpan` and `Ensure` and its F2 is the leaking constructor. Two of the comments the repair added cited "the fresh review of PR #1735 (F1)" and "(F2)" without saying which one, and the tree already carries a case citing the other F1 four hundred lines away. A reader chasing either number would land on the wrong finding. Comment text only. Both suites are unchanged at 12 cases / 269 assertions and 10 cases / 112 assertions, 0 failed, compile status 0. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code]
… catches a shared-helper defect (#1124) The second review's F5 said the G1 file `CHECK` should be kept on a stronger ground than the one written beside it: host-arm against device-arm is a comparison between two runs of the same `ExpertStreamer`, so a defect in the shared helper makes both arms identically wrong and passes it. The repair restated the comment on that ground and left the ground itself as prose, which is the same shape as the reason it replaced. Mutation M16 measures it. Making `EnsureFile`'s `pread` ignore `file_offset` fills every key in both arms from offset 0. The host-against-device assertion at `test_device_expert_slot_store.cpp:359` stays GREEN through that, exactly as the argument predicts, and the only assertions that red are the two file `CHECK`s at `:370` and `:371` and the different-slice check at `:375`: 10 cases with 2 failed, 112 assertions with 12 failed, exit status 1, compile status 0, one file and one line changed, tree restored byte-identical by sha256. Spec text only; no code and no test changes. The suite is unchanged at 10 cases / 112 assertions / 0 failed. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code]
Bring the branch onto the tree that will land. `.agents/issue-index.md` was verified rather than trusted: 632 rows on both sides, since this branch adds no row, #1124 appears once, and no conflict marker survives. This merge matters beyond currency. Main at this commit still carries bare `WriteSlot` calls after `cache_.Acquire`, which is safe there only because no store on main can throw. This branch introduces the first store that can, and carries the `catch (...) { cache_.Invalidate(key); throw; }` undo at all three entry points, so the device store and the windows it opens land together. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
--device cudaservesQwen3.8-2.4T-A95B UD-Q1_0today only where the platform's kernels can dereference host storage, because the only productionExpertSlotStorekeeps its slots in astd::vector<uint8_t>. That is one part, a GB10. A discrete device cannot read that arena at all, so for it the slice has to live in device memory.DeviceExpertSlotStoreis that store: one contiguous arena throughvt::Backend::Alloc, plus one pinned host staging slot.The fill contract is not a separate wave, and could not have been
The class could not be filled by ANY caller before this change.
ExpertStreamer::EnsureFilehandsSlotForWrite()'s pointer straight to::pread, andvt::Backend::DeviceMemoryIsHostAddressable()is false for CUDA, so a device slot pointer is not a legalpreaddestination. Scheduling the fix as a wave after W1 would have scheduled a wave that deadlocks its predecessor. The spec says so under "Verdict on issue #1124's piece 3" and folds it in here.So
ExpertSlotStoregainsCommitSlot(int32_t, size_t).SlotForWritereturns a host-writable destination,preadfills it exactly as it does today, andCommitSlotpublishes it. On the host store that is a bounds-checked no-op over aSlotForWritethat still returns the slot itself, so the host path is byte-identical, which is this wave's stop condition. On the device storeSlotForWritereturns the single pinned staging buffer andCommitSlotperforms one contiguous H2D followed by a queue synchronize.Three decisions inside that, recorded because the diff does not carry a reason.
CommitSlotis PURE, not a defaulted no-op. A default is correct for exactly one implementation, the host one, and silently wrong for every store whose slots the host cannot write — the entire population it was added for. Its failure presents as zeros in a slot rather than as a compile error, and that failure is literally the RED this change was gated on. The cost is two overrides: the host store's no-op andtest_expert_streamer'sRecordingStore, which now counts the calls.The publish call sits INSIDE
EnsureFile's existingtry. Publishing is the last step of the fill and it can fail for the same class of reason the read can. A device copy that throws would otherwise leave the slot holding whatever it held before, under a cache entry that already claims the key is resident — the same silent, plausible and wrong outcome the loop is wrapped for. It takes the sameInvalidateundo.ONE staging slot, not one per slot. The filler is synchronous by design (
expert_streamer.h: no async I/O, no prefetch, no read-ahead; overlap isENG-EXPERT-STREAMW6 and is conditional on a measurement), so exactly one fill is ever in flight. A buffer per slot would double the arena's host cost — 18.55 GiB on the target checkpoint — to buffer a concurrency that does not exist. The single buffer is also what makes the write/commit pairing a contract:CommitSlotrefuses a slot that is not the oneSlotForWritelast handed out, because with one buffer that mismatch files one expert's weights under another expert's key and the cache then reports a HIT over the wrong bytes.Why a staging bounce and not
cuFileA zero-copy filler (GPUDirect Storage /
cuFile, orO_DIRECTDMA into a device BAR mapping) moves fewer bytes and needs a driver capability probe, a mount-level check, an aligned-I/O path and a fallback for each of those. The bounce costs one extra host-to-device copy of one slice per MISS on top of a disk read of the same size, lands in one wave, and keeps the zero-copy filler genuinely optional rather than load-bearing. It is chosen for that reason and not because it is faster. The measurement that would justify replacing it — a device-arm decode where the H2D leg is a measurable fraction of fill time — does not exist until W1 has run somewhere, and it is recorded under the spec's## Owed.G1, red-first
DeviceExpertSlotStoredriven throughExpertStreamer::EnsureFileproduces byte-identical slot contents toHostExpertSlotStoreon the same input, on a CPUvt::Backend. No GPU was leased; the gate does not need one and no discrete NVIDIA GPU is reachable from this project, which is why G-DISCRETE stays owed. There is no upstream: pinned vLLM555967922has inference-time expert paging nowhere, so the host store is the oracle and the spec already says so.RED, with everything present except the streamer's publish call — which is exactly #1124's third piece:
test_device_expert_slot_storeexit status 1,Status: FAILURE!, compile status 0,grep -c 'No space left on device'0. All four slices failed both halves of the comparison, against the host store AND against the file, because the bytes sat in staging and never reached the device slot. Re-measured at the head of the second review's repair, where the suite is 10 cases / 112 assertions rather than the 9 / 97 the first offering had: 10 cases / 2 failed, 112 assertions / 18 failed.GREEN, with the call: exit status 0, 10 cases / 10 passed, 112 assertions / 0 failed.
test_host_expert_slot_store9 cases / 203 assertions / 0 failed, unchanged in count from before the contract change.test_expert_streamer12 cases / 269 assertions / 0 failed.test_expert_stream_wiring4 cases / 882 assertions / 0 failed.The comparison asserts equality with the FILE as well as with the host store, and its stated reason has now been wrong twice. The first draft said two empty arms would pass a bare
memcmp; the first review caught that (F2) — the host arm is filled by its own streamer and is non-zero, so the host-versus-device comparison reds on its own, measured rather than conceded: with both fileCHECKs deleted AND the H2D copy deleted the suite is still RED at 10 cases / 3 failed, 104 assertions / 10 failed, rc 1 (re-measured at this head). The replacement reason was "it makes the red DETERMINISTIC", and the second review showed that one wrong too (F5): the arena prefill described under F4 below now does that job, and determinism was never the stronger ground.The ground that holds is the shared helper. Host-arm against device-arm is a comparison between two runs of the SAME
ExpertStreamer, over the same descriptor, at the samefile_offset. A streamer that read the wrong offset, read short, or read one slice twice makes both arms identically wrong and passes it. The bytes on disk are the only input neither arm computed, so the fileCHECKis the only assertion in the case that can see that class of defect. It is kept, with the reason replaced rather than the check deleted.The two structural claims a CPU tier could otherwise fake — that
SlotForWritehands out staging rather than the slot, and thatSlotForReadhands out the slot rather than staging — are asserted as pointer relationships, because on a unified allocator both spellings produce the right bytes."Byte-identical" is over the filled prefix (F3). The host arena is zero-filled at construction and the device arena is not, so the two stores differ past the bytes a fill wrote. It does not reach G1, where every fill writes a whole slot, and no reader may look there — the streamer never hands out a slot it has not filled. Zeroing the arena would cost a full write of the whole budget at load, 18.55 GiB on the target checkpoint, to define bytes nobody reads.
The qualification reached the header, the gate case and the spec's
## Now, and not the two lines that DEFINE the gate six hundred lines away, which the second review found (F3 again).## GatesG1 and the## Tests to portrow are what a W2 or a G-DISCRETE implementer reads to learn what PASS means, and both still made the unqualified claim; a gate written against that sentence would be gating a property the class does not have. Both now read "over the bytes a fill wrote", and the G1 entry says why in one sentence rather than pointing six hundred lines back.Mutations
Seventeen, each applied alone in the tree, built, run, then restored and verified byte-identical by sha256 (
RESTORE_BAD=[], four files, allTrue). Every row carries its compile status, itsgit diff --stat, and a non-zero case count, because a mutation that fails to build and a mutation that never applied both read as a passing test. M1 was rejected and rewritten for exactly that reason: deleting the copy leftdstunused,-Werror=unused-variablefailed the build at compile status 1, and the stale binary printedSUCCESS!. The row below is the rewritten one.Every row is re-measured at the repaired head, not only the two the review named. The device suite is now 112 assertions where it was 97 and the streamer suite is 269 where it was 187, so every recorded denominator was stale and a stale count is a claim the tree does not support. No mutation was carried over from the previous table.
CommitSlot's H2D copySlotForWritereturns the slot instead of stagingSlotForReadreturns staging instead of the slotstore_.CommitSlot(...)inEnsureFiletest_expert_stream_wiringGREEN 4/882/0, rc 0 — the reachability control, see belowCommitSlot'sSynchronizeCreateQueueabove the budget refusals108 / 0 failed, because the case THREW; streamer 12/1 failed, 272/5 failed, rc 1CommitSlotskip its bounds checkstore_.CommitSlot(...)out ofEnsureFile'stryEnsureSpan'stry/catcharoundWriteSlotEnsure'stry/catcharoundWriteSlotcatchcatchbodyEnsureFile'spreadignorefile_offset(the shared-helper defect, F5):359stays GREEN through itCHECKs AND the H2D copyM12 and M13 are separate rows on purpose. They are what proves the two new wraps are independent: a
tryon one entry point leaves the other exactly as exposed as it was, so one mutation covering both would have measured a single guard twice.M9's device row is the reason
Status:is read and not onlyassertions:.0 failedon the assertions line with a failing case is a thrown case, which is what agrep assertions:gate would have called a pass.M11 was added by the fresh review's FAIL finding, and the finding was right. The first ten mutations did not include it.
store_.CommitSlot(...)sits insideEnsureFile'stryso a failed publish takes the samecache_.Invalidatea failed read takes; that placement was argued in the body, the commit, the spec and the source comment, and attacked nowhere. Moving the call to just after thecatchleft all three suites GREEN, because no store in the tree could fail a publish.The scenario is real and arrives with W2:
DeviceExpertSlotStore::CommitSlotcallsvt::Backend::CopyandSynchronize, and a real CUDA backend throws out of both. Outside thetrythe throw escapes with the cache still claiming the key resident over a slot holding the previous expert's bytes; the next request is an ordinary HIT, no read is issued, and the GEMM multiplies the wrong expert with no symptom.Repaired red-first.
RecordingStoretakes athrow_on_commitflag andtest_expert_streamergains "a PUBLISH that throws leaves nothing resident either", mirroring the existing case that covers thepreadarm only. RED with the call moved out: 10 cases / 1 failed, 182 assertions / 6 failed, rc 1, compile status 0, no ENOSPC. The red IS the corruption rather than a proxy for it —cache.IsResident(key)stays TRUE and the retry comes backhitwithfilledfalse, which is the predicted "ordinary HIT over a slot nobody published", in the assertion output. GREEN with the call restored: 10 cases / 187 assertions / 0 failed, rc 0,expert_streamer.cppbyte-identical togit show HEAD:by sha256.The second fresh review, and what it changed
F1 (blocking) — two more entry points had the identical corruption window
EnsureFilewas wrapped andEnsureSpanandEnsurewere not. Theirstore_.WriteSlot(...)calls are the same window one step earlier:Acquirehas to run first because the write needs a slot, so by the time the write throws the cache already says the key is resident, over a slot still holding the expert the acquisition just evicted. The throw escapes, the next request for that key is an ordinary HIT, no bytes move because a hit moves none, and the GEMM multiplies the evicted expert.This PR is what makes
WriteSlotthrowable.DeviceExpertSlotStore::WriteSlotcallsvt::Backend::CopyandSynchronize, which route throughCheck()in the CUDA backend and raisestd::runtime_error. Before this wave every store'sWriteSlotwas amemcpy.EnsureSpanis a PRODUCTION call site, reached fromQwen3_5Model::ForwardthroughQwen35ExpertStream::Slice, and this PR's own device suite drives the device store through it. So this half is not the wait-for-W2 shape the publish arm has; it is in this wave's scope.Both now take the same
try/catch (...) { cache_.Invalidate(key); throw; }asEnsureFile. Gated red-first:RecordingStoretakes athrow_on_writeflag mirroringthrow_on_commit, with one case per entry point, and each asserts CONSISTENCY rather than emptiness — the key is not resident,SlotOfis empty,resident()is 0,fills()andbytes_filled()are unmoved, the slot still holds the EVICTED expert's bytes, and the retry is a real MISS that refills.RED with neither
trypresent: 12 cases / 2 failed, 232 assertions / 16 failed, rc 1, compile status 0, no ENOSPC. The red is the corruption itself rather than a proxy —retry.hitistrueandstore.slot(retry.slot)[0]reads4where the test asked for expert6. GREEN with both: 12 cases / 269 assertions / 0 failed, rc 0. Independence proven by M12 and M13 above.F2 — the constructor leaked on the failure that happens and guarded one that cannot
It tested
AllocandAllocPinnedfornullptr. No backend in this tree returns one:CpuBackend::Allocrefuses withVT_CHECK,CudaBackend::AllocandAllocPinnedrefuse throughCheck(...), and the baseBackend::AllocPinnedforwards toAlloc. They all throw. So both guarded branches were unreachable, while a throw fromAllocstranded the CUDA stream and a throw fromAllocPinnedstranded the stream AND the whole device arena — 18.55 GiB on the target checkpoint — at the one moment the device has no memory left to lose. Out of memory is this class's headline failure; #1123 is literallyvt cuda: cudaMalloc: out of memory.The acquisitions now sit inside a
trywhosecatchruns the destructor's body and rethrows unchanged, so the caller still sees the backend's own message. Thenullptrbranches are KEPT, deliberately:vt::Backendis an interface, and a nullptr-returning implementation would otherwise hand out slot pointers off a null arena instead of being refused. They now cost one branch and no cleanup code, because the catch owns the release. The header's claim that a failed allocation raises this constructor's ownstd::runtime_erroris corrected to describe what actually happens, and the.cppcomment that said nothing is acquired above a possible throw now says which part of that is still true.Gated by
throw_on_allocandthrow_on_pinned_allocon the suite'sCountingBackend, withfrees,pinned_freesandlast_freedcounters so the assertion is "the arena came back", not "no crash". RED at 10 cases / 1 failed, 112 assertions / 4 failed, rc 1, compile 0; GREEN at 112 / 0. Mutations M14 and M15 above.F4 — two recorded mutation counts were allocator-dependent
The "each slot holds a DIFFERENT slice" check compared two device slots that a publish-suppressing mutation leaves UNWRITTEN, and
vt::Backend::Allocdoes not initialise them, so whether that assertion red was decided bystd::aligned_allocgarbage. The reviewer measured M4 device at 112/18 against the recorded 17 and the F2 combination at 104/10 against the recorded 9; both deltas were that single assertion. That is the very allocator non-determinism the F2 correction invokes as its justification, appearing inside the gate's own assertions.The gate now writes every device slot to a known byte before the fills, so an unmutated fill is the only thing that can make two slots differ, and the assertion measures the store. Proven, not asserted: under M4 the assertion at
test_device_expert_slot_store.cpp:374fails on 25 consecutive runs and the suite reads 112 assertions / 18 failed on all 25. The whole table above is re-measured at this head for the same reason.F5 — the file
CHECK's reason, restated rather than deleted, and then measuredCovered under G1 above: the check is load-bearing on the shared-helper ground, not on determinism, and it stays. Restating a reason in prose is the same shape as the reason it replaced, so M16 measures it. Making
EnsureFile'spreadignorefile_offsetfills every key in BOTH arms from offset 0 — one shared helper, one defect, two identically wrong arms. The host-against-device assertion attest_device_expert_slot_store.cpp:359stays GREEN through exactly that, as the argument predicts, and the only assertions that red are the two fileCHECKs at:370and:371and the different-slice check at:375: 10 cases / 2 failed, 112 assertions / 12 failed, rc 1, compile 0, tree restored byte-identical by sha256.That is the whole case for keeping a check whose stated reason was wrong twice: it is the only assertion in the case that can see a class of defect the rest of the case is structurally blind to.
Nothing lands dead: this lands UNREACHED, and here is what that means exactly
What is not reached:
DeviceExpertSlotStore. No loader, no model, no example and no registered command constructs one.Qwen35ExpertStream::store_is still astd::unique_ptr<HostExpertSlotStore>andQwen35ExpertStream::Slicereads the concreteHostExpertSlotStore::Slot, so there is no seam a device store could be handed to yet.The owning row:
ENG-EXPERT-STREAM-DEVICE, wave W2 — the virtualSlotForRead,store_becoming astd::unique_ptr<ExpertSlotStore>, and selecting the store from the platform.The tracking issue: #1124, which stays OPEN. This is one wave of three.
The spec lists it under
## Owed, the landing commit body names it, and the class carries the statement in its own header comment. The spec's own recommendation is still to land W1 and W2 as one pull request; the split was a dispatch decision and this declaration is the cost it names.Measured rather than asserted, and the earlier draft of this line overstated it.
git grep -n DeviceExpertSlotStore -- src include examples benchmarks tests, excluding the class's own files and its own suite, returns five matches, every one of them inside a//comment:expert_streamer.h:18,expert_streamer.cpp:159, andtest_expert_streamer.cpp:50,:84and:396. Filtering the comment lines out leaves rc 1, no output — no construction, no declaration, no type use anywhere outside the class. Two of those five predate this review; the third source one is a comment F1's repair added. The positive control that makes this an absence rather than a wrong pattern is the same grep forHostExpertSlotStore, which returnsqwen3_5.cpp:5912and:5941— a real production construction and a real production member.The narrower half, stated so nobody reads it generously. The
CommitSlotCALL is not in the same position as the class. It sits inExpertStreamer::EnsureFile, which IS a production call site:qwen3_5.cpp'sQwen35ExpertStream::Slicereaches it fromQwen3_5Model::Forward, and the line executes on every real streamed fill today. What it does there is nothing, because the store production selects is the host one and itsCommitSlotis a no-op. The reachability mutation says so in numbers: deleting that call reds the new suite (M4 above) and leavestest_expert_stream_wiring— the suite that enters throughQwen3_5Model::Forward— at 4 cases / 882 assertions / 0 failed, rc 0, compile status 0, unchanged from its baseline and re-measured at this head. A call count is not a capability. Both halves wait on W2.One thing the second review's F1 does change here, and it is worth saying precisely. The
EnsureSpanandEnsurewraps are not in the unreached half. Those entry points are production paths today —Qwen35ExpertStream::SlicecallsEnsureSpanfromQwen3_5Model::Forward— so that repair is reached at its own merge commit, on the host store, on every streamed fill. What stays unreached is the CLASS, exactly as stated above.Records
.agents/specs/expert-stream-device-slots.md:## Nowrecords the wave and the G1 result, the W1 work-breakdown section records what landed and the pure-vs-defaulted decision,## Owedconverts the conditional "W1 may land unreached" entry into the factual one, and the## Tests to porttable is corrected to the file that exists..agents/issue-index.mdis NOT touched: #1124 already has a row, and the index is append-only, so a second row would be the duplicatecheck-agent-record.pyrefuses.The gate file is
tests/vllm/model_executor/test_device_expert_slot_store.cpp, not thetest_expert_slot_store.cppthe spec's table named when it was written. Named rather than done quietly: the header it gates isdevice_expert_slot_store.hand the suite sits besidetest_host_expert_slot_store.cpp, which is its ORACLE, so a name that does not say which store it is about is the one thing a reader has to disambiguate every time. The spec table is corrected to match the tree.No public document is owed. This edits
src/,include/,tests/and one spec; no row changes lifecycle state, no benchmark ID changes disposition, and no user-visible surface moves.Not mine
check-agent-recordandtest_agent_recordwere RED on this branch and onorigin/mainbecause.agents/issue-index.mdlisted #1649 twice. That was repaired onmainby #1742 while this branch was in review, and merging it in cleared the gate here. This branch never touched that file.At the repair head
scripts/agent-preflight.shis rc 0 with no FAIL and no SKIP, trailer and commit-style gates included, andtest_cpu_x86_llamacpp_floor(the #618 flake) passed on the first run with no discrimination needed.check-commit-style.py --range origin/main..HEADandcheck-commit-trailers.py --range origin/main..HEADare both rc 0.CI at
7e0cf84ac(run32612431657): 14 of 17 jobs green, and the three reds are each attributed by reading the log rather than assumed. Green includesbuild-test-cpu,build-test-cpu-arm64,build-test-vulkan,build-newest-gcc,cuda-fat-build,cuda-arch-features, bothsanitize-cpulanes,device-leakage,pr-size,commit-protocol-tag,documentation-checkpoint,last-gated-commitandvulkan-spirv-freshness.windows-msvc-cpuandwindows-msvc-vulkan: #584, not this branch. Both fail attests\Release\test_openai_api_server.exe exited with status -1073740791(0xC0000409, the__fastfailstatus). Attributed by MATCHED ARM rather than by argument: the run on this PR's own previous heada3c89a609(run32608548556) fails both lanes with the byte-identical signature, before any of this repair existed. test_openai_api_server crashes on Windows with STATUS_STACK_BUFFER_OVERRUN (0xC0000409), unmasked by the #512 fix #584 already carries the diagnosis, owned byENG-RELEASE-WINDOWS, so no new issue is filed.agent-record: #1722, not this branch. It errors inSiteGuardTests.test_rendered_benchmark_index_links_resolve_to_emitted_pageswithFileNotFoundError: [Errno 2] No such file or directory: 'hugo'— the runner has no Hugo. Attributed by baseline:agent-recordisfailureonmain's own runs at6354755ba(runs32611673963and32608320394) and atb508cbce6(run32613454280). This branch edits no docs-site input.FOLLOWING_AGENTS_PROTOCOL
Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5 [Claude Code]