[Bugfix][KV Offload] Stop offloading the final sampled token's KV slot - #54288
Conversation
When a request finishes, _build_store_jobs sets the store watermark to req.num_tokens. That counts the final sampled token, but no forward pass ever writes its KV slot: the token was produced by the previous position's forward, and the request ends before another one runs. If the finish lands on a block boundary the last block is stored with an unwritten slot in it. Under spec decode that slot holds the KV of the first rejected draft token, so the stored block is plausible garbage rather than obviously wrong. The block is content-addressed over token ids, all of which are valid, so a later request whose prompt replays the same tokens (the multi-turn shape) hits it and loads the unwritten slot. The engine's own prefix cache already refuses to commit that far. KVCacheManager caps caching at min(computed + new, num_tokens), and the async scheduler commits num_computed_tokens - num_output_placeholders. Both stop one token short of where the offload path was storing. Measured at the finishing step with block_size 4 and a 3 token prompt plus EOS: sync gives num_tokens=4, num_computed=3, placeholders=0; async gives num_tokens=4, num_computed=4, placeholders=1. The frontier is 3 in both, while the code used 4. num_tokens - 1 is exact in both modes, which plain num_computed_tokens is not. Reproduced without a GPU: with a 7 token prompt finishing on a boundary, the GPU prefix cache commits 1 block while the offload path stored 2. A second request whose prompt is the first request's full token stream, EOS included, then gets content-hash hits and loads both blocks. Three existing tests asserted the old watermark; they are updated to the corrected contract. test_on_request_finished_fires_after_final_block_store now declines the first prepare_store so its ordering assertion still has a store to order against. Fixes vllm-project#54193 Signed-off-by: almogtavor <almogtavor@gmail.com>
|
Thanks for jumping on this so fast. The fix is essentially identical to what we've been running in our fork since we hit it — same branch, same One thing that might be worth a thought while it's in review: we wrote the watermark as Also, declining the first |
|
@tomylin890 good idea I'll add that |
A request with zero output tokens never reaches this branch today, but the clamp costs nothing and keeps the last prompt position, which prefill did write, offloadable if one ever does. Signed-off-by: almogtavor <almogtavor@gmail.com>
|
Added in 79290b6. I also ran it on an H100 against
|
…ler.py Co-authored-by: Or Ozeri <or@ozery.com> Signed-off-by: Almog Tavor <70065337+almogtavor@users.noreply.github.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (1)
Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review. 📝 SummarySummary by CodeRabbit
WalkthroughThe scheduler now excludes the final sampled token from finished-request KV storage while preserving the full prompt boundary. Tests cover block completion, store retries, request-finish ordering, and sliding-window behavior. ChangesFinished-request KV storage
Estimated code review effort: 2 (Simple) | ~10 minutes Merge Risk: ⚪ Minimal · up to Finished-request KV offloading no longer stores the unwritten final sampled-token slot, preventing invalid cached blocks from being reused. Boundary and retry behavior are covered by updated scheduler tests, with no remaining merge-blocking risk. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
…ount Signed-off-by: almogtavor <almogtavor@gmail.com>
|
/ci run |
|
✅ @almogtavor, CI is now available for this PR.
|
|
✅ Triggered Buildkite CI #87438 for commit |
vllm-project#54288) Signed-off-by: almogtavor <almogtavor@gmail.com> Signed-off-by: Almog Tavor <70065337+almogtavor@users.noreply.github.com> Co-authored-by: Or Ozeri <or@ozery.com> Signed-off-by: Jyotirmoy Roy <jyotirmoyroy649@gmail.com>
OffloadingConnector under MTP/EAGLE: serve stored hits instead of vetoing the whole request, and read the load boundary from the computed offset. Backports three merged upstream fixes onto the 0.28.0 tree (all land after the 0.28.0 wheel, so this image has neither): vllm-project#52771 (merged 2026-09-07) -- OffloadingConnector: stop zeroing offload hits under MTP/EAGLE spec decode. Three changes: the all-groups fallback no longer marks every KV group as a drafter group (it logs instead); the volatile-tail store exclusion is lifted once a request is finished (the final chunk is then stored); the lookup query is widened for every eagle group, not only sliding-window ones. vllm-project#52807 (merged 2026-09-03) -- do not let a recurrent group's unhashed block truncate the load boundary: scan from the computed boundary, not 0, so a sparse (Mamba / SWA) group's legitimate unhashed blocks below it are not mistaken for the load start. The confirmed symptom (upstream vllm-project#52735, reproduced there on 3090 Ti, H100 NVL on stock v0.27.1, and GB200): with a hybrid-GDN model under MTP the CPU offload tier stores but never serves. The full-attention group hits every chunk while the Mamba groups return 0 and veto the whole request, so 41 GB is written and 0 bytes ever read back. This fork's own offload-dflash-eagle-groups.patch narrows the fallback for the dflash path only; under MTP every group is still flagged, which is exactly the reporter's boot line in syv-ai#95. Applies on top of offload-dflash-eagle-groups.patch (the hunks were generated against the tree with that patch already applied). Third fix (upstream vllm-project#54288, merged 2026-09-06, also absent from 0.29.0): a finished request offloaded up to num_tokens, but the final sampled token has no KV of its own (no forward pass covers its slot; under spec decode it holds a rejected draft), so a block ending there was stored under a hash of legitimate token ids and a later request replaying those tokens loaded the unwritten slot. Clamp the finished watermark to num_tokens - 1 (never below the prompt). Reachable only with offload_prompt_only=false; the default clamps to the prompt first. Upstream wrote vllm-project#52771 against a tree that already had this fix, and its EAGLE trailing-block tests fail without it. Ported to 0.29.0 from the fork's PR#100 branch (syv-ai/qwen38-27b-rtx3090, patches/offload-mtp-serve.patch at 3284992); all seven hunks apply unchanged. Neither upstream fix is in the v0.29.0 tag. Signed-off-by: Michael Stufflebeam <cpuchip@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
OffloadingConnector under MTP/EAGLE: serve stored hits instead of vetoing the whole request, and read the load boundary from the computed offset. Backports three merged upstream fixes onto the 0.28.0 tree (all land after the 0.28.0 wheel, so this image has neither): vllm-project#52771 (merged 2026-09-07) -- OffloadingConnector: stop zeroing offload hits under MTP/EAGLE spec decode. Three changes: the all-groups fallback no longer marks every KV group as a drafter group (it logs instead); the volatile-tail store exclusion is lifted once a request is finished (the final chunk is then stored); the lookup query is widened for every eagle group, not only sliding-window ones. vllm-project#52807 (merged 2026-09-03) -- do not let a recurrent group's unhashed block truncate the load boundary: scan from the computed boundary, not 0, so a sparse (Mamba / SWA) group's legitimate unhashed blocks below it are not mistaken for the load start. The confirmed symptom (upstream vllm-project#52735, reproduced there on 3090 Ti, H100 NVL on stock v0.27.1, and GB200): with a hybrid-GDN model under MTP the CPU offload tier stores but never serves. The full-attention group hits every chunk while the Mamba groups return 0 and veto the whole request, so 41 GB is written and 0 bytes ever read back. This fork's own offload-dflash-eagle-groups.patch narrows the fallback for the dflash path only; under MTP every group is still flagged, which is exactly the reporter's boot line in syv-ai#95. Applies on top of offload-dflash-eagle-groups.patch (the hunks were generated against the tree with that patch already applied). Third fix (upstream vllm-project#54288, merged 2026-09-06, also absent from 0.29.0): a finished request offloaded up to num_tokens, but the final sampled token has no KV of its own (no forward pass covers its slot; under spec decode it holds a rejected draft), so a block ending there was stored under a hash of legitimate token ids and a later request replaying those tokens loaded the unwritten slot. Clamp the finished watermark to num_tokens - 1 (never below the prompt). Reachable only with offload_prompt_only=false; the default clamps to the prompt first. Upstream wrote vllm-project#52771 against a tree that already had this fix, and its EAGLE trailing-block tests fail without it. Ported to 0.29.0 from the fork's PR#100 branch (syv-ai/qwen38-27b-rtx3090, patches/offload-mtp-serve.patch at 3284992); all seven hunks apply unchanged. Neither upstream fix is in the v0.29.0 tag. Signed-off-by: Michael Stufflebeam <cpuchip@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
OffloadingConnector under MTP/EAGLE: serve stored hits instead of vetoing the whole request, and read the load boundary from the computed offset. Backports three merged upstream fixes onto the 0.28.0 tree (all land after the 0.28.0 wheel, so this image has neither): vllm-project#52771 (merged 2026-09-07) -- OffloadingConnector: stop zeroing offload hits under MTP/EAGLE spec decode. Three changes: the all-groups fallback no longer marks every KV group as a drafter group (it logs instead); the volatile-tail store exclusion is lifted once a request is finished (the final chunk is then stored); the lookup query is widened for every eagle group, not only sliding-window ones. vllm-project#52807 (merged 2026-09-03) -- do not let a recurrent group's unhashed block truncate the load boundary: scan from the computed boundary, not 0, so a sparse (Mamba / SWA) group's legitimate unhashed blocks below it are not mistaken for the load start. The confirmed symptom (upstream vllm-project#52735, reproduced there on 3090 Ti, H100 NVL on stock v0.27.1, and GB200): with a hybrid-GDN model under MTP the CPU offload tier stores but never serves. The full-attention group hits every chunk while the Mamba groups return 0 and veto the whole request, so 41 GB is written and 0 bytes ever read back. This fork's own offload-dflash-eagle-groups.patch narrows the fallback for the dflash path only; under MTP every group is still flagged, which is exactly the reporter's boot line in syv-ai#95. Applies on top of offload-dflash-eagle-groups.patch (the hunks were generated against the tree with that patch already applied). Third fix (upstream vllm-project#54288, merged 2026-09-06, also absent from 0.29.0): a finished request offloaded up to num_tokens, but the final sampled token has no KV of its own (no forward pass covers its slot; under spec decode it holds a rejected draft), so a block ending there was stored under a hash of legitimate token ids and a later request replaying those tokens loaded the unwritten slot. Clamp the finished watermark to num_tokens - 1 (never below the prompt). Reachable only with offload_prompt_only=false; the default clamps to the prompt first. Upstream wrote vllm-project#52771 against a tree that already had this fix, and its EAGLE trailing-block tests fail without it. Ported to 0.29.0 from the fork's PR#100 branch (syv-ai/qwen38-27b-rtx3090, patches/offload-mtp-serve.patch at 3284992); all seven hunks apply unchanged. Neither upstream fix is in the v0.29.0 tag. Signed-off-by: Michael Stufflebeam <cpuchip@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
OffloadingConnector under MTP/EAGLE: serve stored hits instead of vetoing the whole request, and read the load boundary from the computed offset. Backports three merged upstream fixes onto the 0.28.0 tree (all land after the 0.28.0 wheel, so this image has neither): vllm-project#52771 (merged 2026-09-07) -- OffloadingConnector: stop zeroing offload hits under MTP/EAGLE spec decode. Three changes: the all-groups fallback no longer marks every KV group as a drafter group (it logs instead); the volatile-tail store exclusion is lifted once a request is finished (the final chunk is then stored); the lookup query is widened for every eagle group, not only sliding-window ones. vllm-project#52807 (merged 2026-09-03) -- do not let a recurrent group's unhashed block truncate the load boundary: scan from the computed boundary, not 0, so a sparse (Mamba / SWA) group's legitimate unhashed blocks below it are not mistaken for the load start. The confirmed symptom (upstream vllm-project#52735, reproduced there on 3090 Ti, H100 NVL on stock v0.27.1, and GB200): with a hybrid-GDN model under MTP the CPU offload tier stores but never serves. The full-attention group hits every chunk while the Mamba groups return 0 and veto the whole request, so 41 GB is written and 0 bytes ever read back. This fork's own offload-dflash-eagle-groups.patch narrows the fallback for the dflash path only; under MTP every group is still flagged, which is exactly the reporter's boot line in syv-ai#95. Applies on top of offload-dflash-eagle-groups.patch (the hunks were generated against the tree with that patch already applied). Third fix (upstream vllm-project#54288, merged 2026-09-06, also absent from 0.29.0): a finished request offloaded up to num_tokens, but the final sampled token has no KV of its own (no forward pass covers its slot; under spec decode it holds a rejected draft), so a block ending there was stored under a hash of legitimate token ids and a later request replaying those tokens loaded the unwritten slot. Clamp the finished watermark to num_tokens - 1 (never below the prompt). Reachable only with offload_prompt_only=false; the default clamps to the prompt first. Upstream wrote vllm-project#52771 against a tree that already had this fix, and its EAGLE trailing-block tests fail without it. Ported to 0.29.0 from the fork's PR#100 branch (syv-ai/qwen38-27b-rtx3090, patches/offload-mtp-serve.patch at 3284992); all seven hunks apply unchanged. Neither upstream fix is in the v0.29.0 tag. Signed-off-by: Michael Stufflebeam <cpuchip@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
OffloadingConnector under MTP/EAGLE: serve stored hits instead of vetoing the whole request, and read the load boundary from the computed offset. Backports three merged upstream fixes onto the 0.28.0 tree (all land after the 0.28.0 wheel, so this image has neither): vllm-project#52771 (merged 2026-09-07) -- OffloadingConnector: stop zeroing offload hits under MTP/EAGLE spec decode. Three changes: the all-groups fallback no longer marks every KV group as a drafter group (it logs instead); the volatile-tail store exclusion is lifted once a request is finished (the final chunk is then stored); the lookup query is widened for every eagle group, not only sliding-window ones. vllm-project#52807 (merged 2026-09-03) -- do not let a recurrent group's unhashed block truncate the load boundary: scan from the computed boundary, not 0, so a sparse (Mamba / SWA) group's legitimate unhashed blocks below it are not mistaken for the load start. The confirmed symptom (upstream vllm-project#52735, reproduced there on 3090 Ti, H100 NVL on stock v0.27.1, and GB200): with a hybrid-GDN model under MTP the CPU offload tier stores but never serves. The full-attention group hits every chunk while the Mamba groups return 0 and veto the whole request, so 41 GB is written and 0 bytes ever read back. This fork's own offload-dflash-eagle-groups.patch narrows the fallback for the dflash path only; under MTP every group is still flagged, which is exactly the reporter's boot line in syv-ai#95. Applies on top of offload-dflash-eagle-groups.patch (the hunks were generated against the tree with that patch already applied). Third fix (upstream vllm-project#54288, merged 2026-09-06, also absent from 0.29.0): a finished request offloaded up to num_tokens, but the final sampled token has no KV of its own (no forward pass covers its slot; under spec decode it holds a rejected draft), so a block ending there was stored under a hash of legitimate token ids and a later request replaying those tokens loaded the unwritten slot. Clamp the finished watermark to num_tokens - 1 (never below the prompt). Reachable only with offload_prompt_only=false; the default clamps to the prompt first. Upstream wrote vllm-project#52771 against a tree that already had this fix, and its EAGLE trailing-block tests fail without it. Ported to 0.29.0 from the fork's PR#100 branch (syv-ai/qwen38-27b-rtx3090, patches/offload-mtp-serve.patch at 3284992); all seven hunks apply unchanged. Neither upstream fix is in the v0.29.0 tag. Signed-off-by: Michael Stufflebeam <cpuchip@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
OffloadingConnector under MTP/EAGLE: serve stored hits instead of vetoing the whole request, and read the load boundary from the computed offset. Backports three merged upstream fixes onto the 0.28.0 tree (all land after the 0.28.0 wheel, so this image has neither): vllm-project#52771 (merged 2026-09-07) -- OffloadingConnector: stop zeroing offload hits under MTP/EAGLE spec decode. Three changes: the all-groups fallback no longer marks every KV group as a drafter group (it logs instead); the volatile-tail store exclusion is lifted once a request is finished (the final chunk is then stored); the lookup query is widened for every eagle group, not only sliding-window ones. vllm-project#52807 (merged 2026-09-03) -- do not let a recurrent group's unhashed block truncate the load boundary: scan from the computed boundary, not 0, so a sparse (Mamba / SWA) group's legitimate unhashed blocks below it are not mistaken for the load start. The confirmed symptom (upstream vllm-project#52735, reproduced there on 3090 Ti, H100 NVL on stock v0.27.1, and GB200): with a hybrid-GDN model under MTP the CPU offload tier stores but never serves. The full-attention group hits every chunk while the Mamba groups return 0 and veto the whole request, so 41 GB is written and 0 bytes ever read back. This fork's own offload-dflash-eagle-groups.patch narrows the fallback for the dflash path only; under MTP every group is still flagged, which is exactly the reporter's boot line in syv-ai#95. Applies on top of offload-dflash-eagle-groups.patch (the hunks were generated against the tree with that patch already applied). Third fix (upstream vllm-project#54288, merged 2026-09-06, also absent from 0.29.0): a finished request offloaded up to num_tokens, but the final sampled token has no KV of its own (no forward pass covers its slot; under spec decode it holds a rejected draft), so a block ending there was stored under a hash of legitimate token ids and a later request replaying those tokens loaded the unwritten slot. Clamp the finished watermark to num_tokens - 1 (never below the prompt). Reachable only with offload_prompt_only=false; the default clamps to the prompt first. Upstream wrote vllm-project#52771 against a tree that already had this fix, and its EAGLE trailing-block tests fail without it. Ported to 0.29.0 from the fork's PR#100 branch (syv-ai/qwen38-27b-rtx3090, patches/offload-mtp-serve.patch at 3284992); all seven hunks apply unchanged. Neither upstream fix is in the v0.29.0 tag. Signed-off-by: Michael Stufflebeam <cpuchip@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
OffloadingConnector under MTP/EAGLE: serve stored hits instead of vetoing the whole request, and read the load boundary from the computed offset. Backports three merged upstream fixes onto the 0.28.0 tree (all land after the 0.28.0 wheel, so this image has neither): vllm-project#52771 (merged 2026-09-07) -- OffloadingConnector: stop zeroing offload hits under MTP/EAGLE spec decode. Three changes: the all-groups fallback no longer marks every KV group as a drafter group (it logs instead); the volatile-tail store exclusion is lifted once a request is finished (the final chunk is then stored); the lookup query is widened for every eagle group, not only sliding-window ones. vllm-project#52807 (merged 2026-09-03) -- do not let a recurrent group's unhashed block truncate the load boundary: scan from the computed boundary, not 0, so a sparse (Mamba / SWA) group's legitimate unhashed blocks below it are not mistaken for the load start. The confirmed symptom (upstream vllm-project#52735, reproduced there on 3090 Ti, H100 NVL on stock v0.27.1, and GB200): with a hybrid-GDN model under MTP the CPU offload tier stores but never serves. The full-attention group hits every chunk while the Mamba groups return 0 and veto the whole request, so 41 GB is written and 0 bytes ever read back. This fork's own offload-dflash-eagle-groups.patch narrows the fallback for the dflash path only; under MTP every group is still flagged, which is exactly the reporter's boot line in syv-ai#95. Applies on top of offload-dflash-eagle-groups.patch (the hunks were generated against the tree with that patch already applied). Third fix (upstream vllm-project#54288, merged 2026-09-06, also absent from 0.29.0): a finished request offloaded up to num_tokens, but the final sampled token has no KV of its own (no forward pass covers its slot; under spec decode it holds a rejected draft), so a block ending there was stored under a hash of legitimate token ids and a later request replaying those tokens loaded the unwritten slot. Clamp the finished watermark to num_tokens - 1 (never below the prompt). Reachable only with offload_prompt_only=false; the default clamps to the prompt first. Upstream wrote vllm-project#52771 against a tree that already had this fix, and its EAGLE trailing-block tests fail without it. Ported to 0.29.0 from the fork's PR#100 branch (syv-ai/qwen38-27b-rtx3090, patches/offload-mtp-serve.patch at 3284992); all seven hunks apply unchanged. Neither upstream fix is in the v0.29.0 tag. Signed-off-by: Michael Stufflebeam <cpuchip@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
Purpose
Fixes #54193.
_build_store_jobslets a finished request offload up toreq.num_tokens. The last of those tokens has no KV of its own because it was produced by the forward pass at the position before it and the request ends before any pass covers its own slot. The last position with KV that anything wrote is thereforenum_tokens - 1and a finish landing on a block boundary hands the offload tier a full block whose final slot sits past that point.Offloaded blocks are keyed by a hash of their token ids and every id involved is legitimate. A later request that replays the same tokens in normal multi-turn chat will therefore match the hash and load the bad block. In the reproduction below the GPU prefix cache commits one block where the offload path stored two.
Under speculative decoding the junk is dangerous since Spec decode proposes several draft tokens, runs them all through the model, then throws away the rejected ones. Slot 3 that was never written for that token (although we generated the [0,1,2,3] tokens), can hold the KV of the first rejected draft. It’s a KV vector for a token that vllm decided not to keep, but downstream it looks legitimate.
The cache key is a hash of [a, b, c, EOS] so later request that happens to start with those same four tokens gets a hit and loads the poisoned block, so the model attends to a token that wasnt accepted.
Fix
num_tokens_after_batch = max(req.num_prompt_tokens, req.num_tokens - 1)in the finished branch.I compared that against
num_computed_tokens - num_output_placeholders, the confirmed-token formula used by bothAsyncScheduler._update_request_with_outputandsimple_kv_offload. One H100 running Qwen3-30B-A3B-W4A16 with 11 finished requests observed per configuration:num_tokens - 1max()changed the answerThe confirmed-token formula runs ahead by up to
num_speculative_tokensunder synchronous speculative decoding becausenum_computed_tokenscounts the whole scheduled batch before rejected drafts are rolled back. Logged cases hadnum_computed_tokensat 28 and 60 and 98 wherenum_tokens - 1was 27 and 57 and 95. Themax()suggested in the issue applies only to a request that finishes without generating anything and nothing reaches this branch in that state because aborted requests take the branch above it and pooling requests finish in the same step they prefill so they take the running branch instead. A pooling probe of 12 requests saw none arrive here. Themax()therefore never changed an answer but it costs nothing and covers the case if a future caller does reach it.#48596 added this branch so that the block completed at finish would stop being skipped. Skipping it is the correct behaviour and the branch still runs for every other block so the worker side fence fix from that PR is untouched.
Reproduction
CPU only. A 7-token prompt finishing on a block boundary has the GPU prefix cache committing 1 block while the offload path stores 2. A second request whose prompt is the first request's full token stream including EOS then gets content-hash hits on both.
Measured on one H100
Qwen3-30B-A3B-W4A16 with
offload_prompt_only: falsebecause the default of true clamps the watermark down to the prompt and hides this branch completely.A request finishing at exactly 64 tokens was snapshotted at the step before it finished and again at the step it finished. Slot 63 of its last GPU block was bit identical across that final forward pass and held all zeros while slot 62 went from zero to a norm of 526. The store job emitted at that same step copied that GPU block to the offload tier.
A later request whose prompt starts with those 64 tokens then loaded the block back into fresh GPU blocks. Position 63 came back all zeros while its fifteen neighbours ranged from 506 to 538 so the model decoded while attending to an empty slot.
Changing the one line to
num_tokens - 1stops that store job. The same later request recomputes the block instead and position 63 comes back at 520.Test plan
241 passed, 2 skipped. Three tests encoded the old watermark and now assert the corrected contract, withtest_last_block_offloaded_at_request_finishrenamed totest_final_sampled_token_does_not_complete_an_offloaded_block. Running those tests against unfixed source gives4 failed, 237 passed, 2 skipped.ruff checkandruff format --checkat v0.14.0 pass on both changed files. No model eval because the measurement above reads the KV cache directly and is exact where sampled output on this mixture of experts model drifts between runs of identical code.Not a duplicate: no other open PR touches this branch.