[Bugfix][KV Offload] Stop offloading the final sampled token's KV slot (#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>

Almog Tavor committed Sep 6, 2026 at 14:22 UTC dc02934a07672eb778a8d819f4e35d0f93ee427b
2 files changed +40 -31
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py
+36 -30
@@ -371,18 +371,20 @@ def test_scheduler_reports_allocation_failure(request_runner):
371
372 @pytest.mark.parametrize("async_scheduling", [True, False])
373 @pytest.mark.parametrize("prompt_offset", [-1, -2])
374 -def test_last_block_offloaded_at_request_finish(
374 +def test_final_sampled_token_does_not_complete_an_offloaded_block(
375 request_runner, async_scheduling: bool, prompt_offset: int
376 ):
377 - """EOS fills the last block at request finish - verify the final block is stored.
377 + """A block whose last slot holds the final sampled token is not stored.
378
379 - prompt = block_size + prompt_offset tokens → not a full block at schedule time,
380 - so _build_store_jobs creates no store job. After EOS, request_finished
381 - keeps req_status alive so _build_store_jobs can process it on the next step.
379 + That token came out of the previous position's forward pass; its own KV
380 + slot is never written, and under spec decode it holds the first rejected
381 + draft's KV. Storing the block would publish it under a content hash that
382 + the GPU prefix cache itself declines to commit, so a later request with
383 + the same tokens would load the unwritten slot.
384
383 - prompt_offset=-1: EOS fills the block → store job created on next step.
384 - prompt_offset=-2: block remains partial → no store job, cleanup in
385 - _build_store_jobs deletes req_status.
385 + prompt = 2 * block_size + prompt_offset tokens. Block 0 is filled by
386 + prefill and stored either way. At prompt_offset=-1 the EOS fills block 1,
387 + which the finishing step must still decline; at -2 block 1 stays partial.
388 """
389 block_size = 4
390 runner = request_runner(
@@ -390,18 +392,11 @@ def test_last_block_offloaded_at_request_finish(
392 num_gpu_blocks=10,
393 async_scheduling=async_scheduling,
394 )
393 - # prompt = block_size + prompt_offset tokens
394 - runner.new_request(token_ids=[0] * (block_size + prompt_offset))
395 + runner.new_request(token_ids=[0] * (2 * block_size + prompt_offset))
396 runner.manager.prepare_store.side_effect = lambda keys, req_context: (
397 generate_store_output(list(keys))
398 )
398 -
399 - if prompt_offset == -1:
400 - # EOS fills the block, so a store job is created for block 0.
401 - runner.run(decoded_tokens=[EOS_TOKEN_ID], expected_stored=(0,))
402 - else:
403 - # Block remains partial, so no store job is created.
404 - runner.run(decoded_tokens=[EOS_TOKEN_ID])
399 + runner.run(decoded_tokens=[EOS_TOKEN_ID], expected_stored=(0,))
400
401 cs = runner.connector_scheduler
402 # After the full run completes, req_status is cleaned up.
@@ -933,10 +928,13 @@ def test_on_request_finished_not_deferred_until_store_completion(
928 def test_on_request_finished_fires_after_final_block_store(
929 request_runner, async_scheduling: bool
930 ):
936 - """on_request_finished fires after the final-block prepare_store at EOS.
931 + """on_request_finished fires after the final prepare_store of the request.
932
938 - When EOS fills a partial block, request_finished() keeps req_status alive
939 - so _build_store_jobs can create a store job for it on the next step.
933 + request_finished() keeps req_status alive so _build_store_jobs can retry a
934 + block on the next step; the retry must be issued before the manager is told
935 + the request is over. Every attempt made before the request finishes is
936 + declined here, so the store that succeeds is the one from the finishing
937 + step and the ordering has something to order.
938 """
939 block_size = 4
940 runner = request_runner(
@@ -950,22 +948,31 @@ def test_on_request_finished_fires_after_final_block_store(
948 ("on_request_finished", req_context.req_id)
949 )
950
951 + runner.new_request(token_ids=[0] * block_size)
952 + request = runner.scheduler.requests[str(runner.req_id)]
953 +
954 def prepare_store(keys, req_context):
954 - calls.append(("prepare_store", req_context.req_id))
955 - return generate_store_output(keys)
955 + finished = request.is_finished()
956 + calls.append(
957 + ("stored_after_finish" if finished else "prepare_store", req_context.req_id)
958 + )
959 + return generate_store_output(keys) if finished else None
960
961 runner.manager.prepare_store.side_effect = prepare_store
962
959 - runner.new_request(token_ids=[0] * (block_size - 1))
963 runner.run(decoded_tokens=[EOS_TOKEN_ID], expected_stored=(0,))
964
965 req_id = str(runner.req_id)
966 assert calls.count(("on_request_finished", req_id)) == 1, calls
967 + assert calls.count(("stored_after_finish", req_id)) == 1, calls
968
969 finished_idx = calls.index(("on_request_finished", req_id))
966 - prepare_indices = [i for i, c in enumerate(calls) if c == ("prepare_store", req_id)]
967 - assert prepare_indices, calls
968 - assert finished_idx > max(prepare_indices), calls
970 + store_indices = [
971 + i
972 + for i, c in enumerate(calls)
973 + if c in (("prepare_store", req_id), ("stored_after_finish", req_id))
974 + ]
975 + assert finished_idx > max(store_indices), calls
976
977
978 @pytest.mark.parametrize("async_scheduling", [True, False])
@@ -1156,10 +1163,9 @@ def test_two_groups_full_and_sliding_window(request_runner, async_scheduling: bo
1163 touch_calls = runner.manager.touch.call_args_list
1164 assert len(touch_calls) == 6
1165
1159 - # EOS fills the 7th block (offset 6). The extra schedule step processes
1160 - # finished_req_ids and stores block 6 for both groups before the request's
1161 - # GPU blocks are freed.
1162 - runner.run(decoded_tokens=[EOS_TOKEN_ID], expected_stored=(6,))
1166 + # EOS lands in the last slot of the 7th block (offset 6). No forward pass
1167 + # writes that slot, so the finishing step declines the block.
1168 + runner.run(decoded_tokens=[EOS_TOKEN_ID])
1169
1170 runner.scheduler.reset_prefix_cache()
1171
vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py
+4 -1
@@ -1384,7 +1384,10 @@ class OffloadingConnectorScheduler:
1384 if req.status is RequestStatus.FINISHED_ABORTED:
1385 num_tokens_after_batch = req.num_computed_tokens
1386 elif req.is_finished():
1387 - num_tokens_after_batch = req.num_tokens
1387 + # Clamp to the GPU prefix cache's commit point. The final sampled
1388 + # token's slot is never committed (under spec decode it holds a
1389 + # rejected draft's KV), so a block ending there must not be stored.
1390 + num_tokens_after_batch = max(req.num_prompt_tokens, req.num_tokens - 1)
1391 else:
1392 num_scheduled_tokens = scheduler_output.num_scheduled_tokens[req_id]
1393 num_tokens_after_batch = req.num_computed_tokens + num_scheduled_tokens