[Bugfix][KV Offloading] Offload last block at request finish and prevent reuse race#48596
Conversation
65e82de to
92bfd18
Compare
|
@orozery PTAL — this implements your request_finished TODO plus the handle_preemptions wait-before-submit fix. |
orozery
left a comment
There was a problem hiding this comment.
Thanks @Alex-ai-future ! This looks pretty good.
However, I think we can make it simpler and thinner change:
In request_finished, just add req_status.update_offload_keys() and keep req_status alive when there are storable blocks remaining.
In _build_store_jobs - chain finished_req_ids into the existing loop:
for req_id in itertools.chain(
scheduler_output.num_scheduled_tokens,
scheduler_output.finished_req_ids or (),
):
req_status = self._req_status.get(req_id)
if req_status is None:
continue
req = req_status.req
if req.is_finished():
num_tokens_after_batch = req.num_tokens
else:
num_scheduled_tokens = scheduler_output.num_scheduled_tokens[req_id]
num_tokens_after_batch = req.num_computed_tokens + num_scheduled_tokens
# ... existing loop body unchanged ...
# At end of loop body, after job creation:
if req.is_finished():
for bid in non_sliding_window_block_ids or ():
self._block_id_to_pending_jobs.setdefault(bid, set()).add(job_id)
if bid in self._current_batch_allocated_block_ids:
self._current_batch_jobs_to_flush.add(job_id)
if not req_status.transfer_jobs:
del self._req_status[req_id]
| entry = kv_connector_metadata.store_jobs.get(job_id) | ||
| if entry is not None: | ||
| assert isinstance(entry.src_spec, GPULoadStoreSpec) | ||
| success = self.worker.submit_store( | ||
| job_id, entry.src_spec, entry.dst_spec | ||
| ) | ||
| assert success | ||
| del kv_connector_metadata.store_jobs[job_id] |
There was a problem hiding this comment.
| entry = kv_connector_metadata.store_jobs.get(job_id) | |
| if entry is not None: | |
| assert isinstance(entry.src_spec, GPULoadStoreSpec) | |
| success = self.worker.submit_store( | |
| job_id, entry.src_spec, entry.dst_spec | |
| ) | |
| assert success | |
| del kv_connector_metadata.store_jobs[job_id] | |
| entry = kv_connector_metadata.store_jobs.pop(job_id, None) | |
| if entry is not None: | |
| self._unsubmitted_store_jobs.append( | |
| (job_id, entry.src_spec, entry.dst_spec) | |
| ) |
| @@ -84,6 +84,11 @@ def __init__(self): | |||
| self.waiting_jobs: set[int] = set() | |||
| self.completed_jobs: list[int] = [] | |||
| self.flushed_jobs: set[int] = set() | |||
| # Tracks job_ids that were wait()ed before submit_store() — exposes | |||
There was a problem hiding this comment.
The changes to the test utils seems overly complicated.
If we can't test with the existing utils, I suggest we just skip adding tests for this.
|
I will close #47107 in favour of this |
|
Thanks @orozery, the simplified approach is much cleaner! All updated per your suggestions — |
cefa727 to
41179d4
Compare
|
@Alex-ai-future can we revert to using just |
|
@orozery Updated per your feedback to minimize the diff:
One simplification I made: removed the has_storable check in request_finished. Now _build_store_jobs handles all cleanup on the next step. The cleanup delay is one step (negligible) but saves ~15 lines. Let me know if this looks OK or if I should restore the original check. |
| @@ -464,6 +464,7 @@ def _run( | |||
| decoded_tokens: list[int], | |||
| complete_transfers: bool, | |||
| post_step_fn: Callable[[], None] | None = None, | |||
| drain_finished_req_stores: bool = False, | |||
There was a problem hiding this comment.
Can we please avoid changes in this file?
|
This pull request has merge conflicts that must be resolved before it can be |
|
@orozery I removed the changes in test utils according to your request. But to sum up, I made two changes without permission.
|
64a4361 to
b85900b
Compare
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: process finished_req_ids in _build_store_jobs via itertools.chain, so the last block is offloaded on the next schedule step. request_finished calls update_offload_keys() and keeps req_status alive for deferred processing. Also fix handle_preemptions to submit jobs_to_flush before wait() to prevent block corruption race. Signed-off-by: Alex <alex.tech.lab@outlook.com>
b85900b to
3478651
Compare
| should_advance = True | ||
| if new_offload_keys: | ||
| store_output = self.manager.prepare_store( | ||
| new_offload_keys, req_status.req_context |
There was a problem hiding this comment.
@Alex-ai-future I liked the previous diff with _maybe_cleanup_finished_req helper better.
Now you change a lot of indentations.
…inimize diff Restore flat continue structure in _build_store_jobs instead of should_advance flag + nested if/elif. Extract cleanup logic into _maybe_cleanup_finished_req helper called at each exit point. Reduces scheduler.py diff from 262 to 82 changed lines. Signed-off-by: Alex <alex.tech.lab@outlook.com>
Revert unrelated changes: logger message (blocks→chunks), GPULoadStoreSpec formatting, and inline comment. Keep only essential changes for last block offload fix. Signed-off-by: Alex <alex.tech.lab@outlook.com>
|
Thank you @orozery, having worked on the offloading scheduler recently, I found its complexity quite challenging — particularly the interplay between Would there be interest in extracting job state management into a dedicated registry? This way the scheduler file would focus on scheduling semantics rather than also owning data-structure bookkeeping — which might make it easier to reason about and extend. |
| self._maybe_cleanup_finished_req(req_id, req_status) | ||
|
|
There was a problem hiding this comment.
This can be removed.
If we reached here, req_status.transfer_jobs is non-empty, and so self._maybe_cleanup_finished_req is a no-op.
I agree |
|
Hi @Alex-ai-future, the pre-commit checks have failed. Please run: uv pip install pre-commit>=4.5.1
pre-commit install
pre-commit run --all-filesThen, commit the changes and push to your branch. For future commits, |
OK, I'm willing to spend some time dealing with this recently. |
orozery
left a comment
There was a problem hiding this comment.
Thanks @Alex-ai-future !
…ent reuse race (vllm-project#48596) Signed-off-by: Alex <alex.tech.lab@outlook.com> Co-authored-by: Or Ozeri <oro@il.ibm.com>
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_jobsruns duringschedule()— at that point the finishing token (EOS) hasn't been processed yet, so the last block is still partial and skipped.request_finishedis called after EOS is appended, 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: Use orozery's simplified approach —
request_finishedcallsupdate_offload_keys()to refresh block hashes and always keepsreq_statusalive. The next step's_build_store_jobsprocessesfinished_req_ids(viaitertools.chain) and creates store jobs for the now-complete blocks through the normal code path. This matches the approach described in #47107: build store jobs atrequest_finished, fence blocks, flush at nextbuild_connector_meta.Worker-side fence fix: Since store jobs for finished requests are deferred (created in the next
schedule()but not submitted until the step after), when the block gets reused before submission,wait()inhandle_preemptionsfinds the job injobs_to_flushbut it hasn't beensubmit_store'd yet →wait()is a no-op → block gets overwritten. Fix: inhandle_preemptions, submitjobs_to_flushstores fromstore_jobsbefore callingwait(). This ensureswait()actually fences the blocks. Only block-reuse scenarios are affected; 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_last_block_offloaded_at_request_finishrequest_finishedkeeps req_status alive → next step's_build_store_jobscreates store job → block is offloadedtest_two_groups_full_and_sliding_window_build_store_jobsprocessing finished requestsTest Result