Skip to content

[Bugfix][KV Offloading] Offload last block at request finish and prevent reuse race#48596

Open
Alex-ai-future wants to merge 8 commits into
vllm-project:mainfrom
Alex-ai-future:fix/last_block
Open

[Bugfix][KV Offloading] Offload last block at request finish and prevent reuse race#48596
Alex-ai-future wants to merge 8 commits into
vllm-project:mainfrom
Alex-ai-future:fix/last_block

Conversation

@Alex-ai-future

@Alex-ai-future Alex-ai-future commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Purpose

Fix: last KV block that fills at request finish time is never offloaded, causing prefix cache misses for that block. Also fixes a data corruption race when the block is reused.

Bug principle: _build_store_jobs runs during schedule() — at that point the finishing token (EOS) hasn't been processed yet, so the last block is still partial and skipped. request_finished is called after EOS is appended (via update_block_hashes), but it had no store kickoff logic — the last block was silently dropped.

Consequence chain: last block not offloaded → next request with the same prefix misses that block → extra prefill compute + no KV reuse for the final block.

Fix: In request_finished, call update_offload_keys + _build_store_jobs_for_req to create store jobs for blocks that became storable only at finish time. Jobs are placed in _finished_req_store_jobs (a deferred queue), not submitted immediately — they are merged into store_jobs at the next build_connector_meta, where jobs_to_flush can fence the blocks if needed. This matches the approach described in #47107: build store jobs at request_finished, fence blocks, flush at next build_connector_meta.

Race condition fix: request_finished creates a store job but doesn't submit it. Before the next build_connector_meta runs, the scheduler may reallocate the same block to a new request. On the worker side, handle_preemptions calls wait() on jobs_to_flush — but the new store job hasn't been submit_store'd yet, so wait() is a no-op → block gets overwritten → silent data corruption. Fix: in handle_preemptions, submit store jobs that are in jobs_to_flush BEFORE calling wait(). This ensures wait() operates on submitted jobs and properly fences blocks until the store completes. Only jobs whose blocks are being reused are submitted early; normal stores remain deferred to preserve token generation performance.

Related issue: #41704 (same class of bug in SimpleCPUOffloadScheduler, fixed by #41777 — different component, not a duplicate).

Test Plan

Test Verifies
test_last_block_offload_at_request_finish[-1] EOS fills last block → request_finished creates store job → block is offloaded
test_last_block_offload_at_request_finish[-2] Block remains partial → no store job created → not offloaded
test_eos_block_reuse_race Block reallocated before store job submitted → job submitted before wait() → effective fence → no data corruption

Tests use wait_before_submit tracking to expose the race: if wait() fires before submit_store(), the test detects the no-op.

Test harness improvements (in utils.py):

  • wait_before_submit: tracks job_ids that wait() was called on before submit_store() — exposes the race where _finished_req_store_jobs jobs end up in jobs_to_flush but were never submitted, making wait() a no-op
  • pre_schedule_fn: callback invoked before each step's schedule() — allows injecting requests at precise timing points (e.g., after request_finished creates _finished_req_store_jobs but before build_connector_meta merges them)
  • drain_finished_req_stores: keeps the test loop running after token exhaustion so _finished_req_store_jobs can be delivered via the next build_connector_meta — without this, store jobs created at request finish would never be submitted
  • _discard_finished_req_store_jobs: cleans up deferred store jobs when drain_finished_req_stores=False — without this, stale jobs in _jobs would prevent the test loop from exiting
  • max_steps: safety limit on test loop iterations — prevents infinite loops from hanging CI, and the assertion message includes relevant scheduler state (_jobs, _finished_req_store_jobs) for fast diagnosis

Existing test fix: test_scheduler_reports_allocation_failure assertion updated from == 1 to == 2. request_finished creates a store job for the same block already attempted during schedule(), triggering a second allocation failure (the block's next_stored_block_idx is not advanced on failure, so request_finished retries it).

.venv/bin/python -m pytest tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py -v -k "last_block or eos_block_reuse"

Test Result

tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py::test_last_block_offload_at_request_finish[-1-False] PASSED
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py::test_last_block_offload_at_request_finish[-1-True] PASSED
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py::test_last_block_offload_at_request_finish[-2-False] PASSED
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py::test_last_block_offload_at_request_finish[-2-True] PASSED
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py::test_eos_block_reuse_race[False] PASSED
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py::test_eos_block_reuse_race[True] PASSED

93 passed

When EOS fills the final KV cache block, that block was never offloaded
because _build_store_jobs runs at schedule time (before the block is full)
and request_finished had no store kickoff logic.

Fix: extract _build_store_jobs_for_req from _build_store_jobs and call it
from request_finished with updated offload_keys. Delayed store jobs are
buffered in _finished_req_store_jobs and delivered via the next
build_connector_meta.

Test harness: add drain_finished_req_stores parameter to RequestRunner.run
for async-mode drain of finished-req store jobs.

Signed-off-by: Alex <alex.tech.lab@outlook.com>
… request finish

Add test_last_block_offloaded_on_request_finish to verify that when EOS
completes a block, request_finished creates a store job in
_finished_req_store_jobs. This validates the fix for the last block
offload scenario.

Also add wait_before_submit tracking in MockOffloadingWorker and max_steps
safety limit in test harness to prevent infinite loops.

Signed-off-by: Alex <alex.tech.lab@outlook.com>
Add test_eos_block_reuse_race to verify that when _finished_req_store_jobs
contains a pending store job and a new request reuses the same block,
wait() is called on an unsubmitted job (no-op), allowing block corruption.

Also add pre_schedule_fn hook in test harness to inject requests at
specific points in the scheduling cycle (after update_from_output but
before schedule).

Signed-off-by: Alex <alex.tech.lab@outlook.com>
…revent data corruption

When _finished_req_store_jobs contains a store job and the block is
reallocated to a new request, the store job would read corrupted data.

Fix: In build_connector_meta, after merging _finished_req_store_jobs into
store_jobs, check if any of those jobs' blocks are in
_current_batch_allocated_block_ids. If so, drop the job entirely (same
behavior as before the request_finished fix — the last block is simply
not offloaded when its block is reused).

This prevents the race where wait() is called on an unsubmitted job
(no-op), allowing block corruption before the store reads it.

Signed-off-by: Alex <alex.tech.lab@outlook.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@mergify mergify Bot added v1 bug Something isn't working kv-connector labels Jul 14, 2026
- Extract _calc_num_offloadable_tokens: eliminates 8-line clamp
  duplication between _build_store_jobs and request_finished
- Extract _drop_store_job: eliminates pop+cleanup+discard teardown
  duplication between build_connector_meta and test harness
- Upgrade _remove_pending_job to safe .get()+.discard() for all
  contexts
- Remove stale TODO(orozery) comment (now implemented)
- Remove dead code: unreachable 'if job_id not in store_jobs' guard
- Merge test_partial_block_not_offloaded into
  test_last_block_offload_at_request_finish via prompt_offset
  parametrization

Signed-off-by: Alex <alex.tech.lab@outlook.com>
…corruption race

Previously, _finished_req_store_jobs were merged into store_jobs in
build_connector_meta and added to jobs_to_flush when blocks were being
reallocated. However, handle_preemptions called wait(jobs_to_flush)
BEFORE prepare_store_kv submitted these stores, making wait() a no-op
on unsubmitted jobs. This allowed blocks to be overwritten before the
store read them → silent data corruption.

Fix: in handle_preemptions, submit store jobs that are in jobs_to_flush
BEFORE calling wait(). This ensures wait() operates on submitted jobs
and properly fences blocks until the store completes. Only jobs whose
blocks are being reused are submitted early; normal stores remain
deferred to preserve token generation performance.

Also removes the scheduler-side conflict-dropping workaround that was
dropping these jobs to avoid the race (which meant the block was never
offloaded). With proper submit-before-wait, blocks are correctly fenced.

Signed-off-by: Alex <alex.tech.lab@outlook.com>
- Simplify handle_preemptions comment (6 lines → 2 lines)
- Simplify request_finished comment (5 lines → 2 lines)
- Add isinstance assertions for GPULoadStoreSpec to fix mypy arg-type

Signed-off-by: Alex <alex.tech.lab@outlook.com>
… defensive changes

- Move _drop_store_job logic inline into test utils _discard_finished_req_store_jobs,
  removing a test-only method from production scheduler code.
- Revert _remove_pending_job defensive hardening (.get/.discard) back to direct
  access ([]/.remove) matching the original code pattern.
- Fix test_scheduler_reports_allocation_failure assertion: update >= 1 to == 2
  to reflect the actual allocation failure count after request_finished adds
  a redundant retry of blocks already attempted during schedule().

Signed-off-by: Alex <alex.tech.lab@outlook.com>
@Alex-ai-future

Copy link
Copy Markdown
Contributor Author

@orozery PTAL — this implements your request_finished TODO plus the handle_preemptions wait-before-submit fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working kv-connector v1

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant