Skip to content

Commit fa07027

Browse files
committed
Fix on_request_finished
Signed-off-by: Or Ozeri <oro@il.ibm.com>
1 parent 428f3d9 commit fa07027

3 files changed

Lines changed: 117 additions & 58 deletions

File tree

tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py

Lines changed: 80 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,17 @@ def test_scheduler_reports_allocation_failure(request_runner):
6868
runner.run(decoded_tokens=[EOS_TOKEN_ID])
6969

7070
reduced = _reduce_kv_connector_stats(runner)
71-
assert reduced[_ConnectorMetricName.ALLOCATION_FAILURE] == 1
71+
# Two attempts: once while running (block becomes full during prefill),
72+
# once from finished_req_ids on the next step.
73+
assert reduced[_ConnectorMetricName.ALLOCATION_FAILURE] == 2
7274

7375

7476
@pytest.mark.parametrize("async_scheduling", [True, False])
7577
@pytest.mark.parametrize("prompt_offset", [-1, -2])
7678
def test_last_block_offloaded_at_request_finish(
7779
request_runner, async_scheduling: bool, prompt_offset: int
7880
):
79-
"""EOS fills the last block at request finish — verify req_status is kept alive.
81+
"""EOS fills the last block at request finish — verify the final block is stored.
8082
8183
prompt = block_size + prompt_offset tokens → not a full block at schedule time,
8284
so _build_store_jobs creates no store job. After EOS, request_finished
@@ -98,18 +100,16 @@ def test_last_block_offloaded_at_request_finish(
98100
generate_store_output(list(keys))
99101
)
100102

101-
# Run with one step (EOS)
102-
runner.run(
103-
decoded_tokens=[EOS_TOKEN_ID],
104-
)
103+
if prompt_offset == -1:
104+
# EOS fills the block → a store job is created for block 0.
105+
runner.run(decoded_tokens=[EOS_TOKEN_ID], expected_stored=(0,))
106+
else:
107+
# Block remains partial → no store job.
108+
runner.run(decoded_tokens=[EOS_TOKEN_ID])
105109

106110
cs = runner.connector_scheduler
107-
# Verify req_status is kept alive for _build_store_jobs to process
108-
# regardless of whether there are storable blocks
109-
assert "0" in cs._req_status, (
110-
"req_status was deleted but should be kept alive "
111-
"for _build_store_jobs to process finished_req_ids."
112-
)
111+
# After the full run completes, req_status is cleaned up.
112+
assert "0" not in cs._req_status
113113

114114

115115
@pytest.mark.parametrize("async_scheduling", [True, False])
@@ -569,14 +569,14 @@ def test_request_preemption(request_runner, async_scheduling: bool):
569569

570570

571571
@pytest.mark.parametrize("async_scheduling", [True, False])
572-
def test_on_request_finished_is_not_deferred_until_store_completion(
572+
def test_on_request_finished_not_deferred_until_store_completion(
573573
request_runner, async_scheduling: bool
574574
):
575-
"""on_request_finished fires when no more stores will be submitted.
575+
"""on_request_finished fires after the last prepare_store is submitted.
576576
577-
A request can finish while its GPU->primary store is still in flight. The
578-
manager-level hook should not wait for that completion; complete_store may
579-
still arrive afterward for already-submitted transfer jobs.
577+
The manager contract guarantees no more submit-side calls (prepare_store)
578+
after on_request_finished. However, complete_store callbacks for
579+
already-submitted transfers may still arrive afterward.
580580
"""
581581
block_size = 4
582582
blocks_per_chunk = 3
@@ -613,8 +613,9 @@ def test_on_request_finished_is_not_deferred_until_store_completion(
613613
complete_transfers=False,
614614
)
615615

616-
# Finish the request while its stores are still in flight. The hook should
617-
# fire immediately even though no complete_store has arrived yet.
616+
# Finish the request while its stores are still in flight. The hook fires
617+
# once the last prepare_store is issued (on the next schedule step), even
618+
# though complete_store has not yet been called.
618619
runner.run(
619620
decoded_tokens=[EOS_TOKEN_ID],
620621
complete_transfers=False,
@@ -624,8 +625,7 @@ def test_on_request_finished_is_not_deferred_until_store_completion(
624625

625626
assert calls == [("on_request_finished", req_id)], calls
626627

627-
# Drain the stores afterward. The already-submitted complete_store calls
628-
# are allowed to arrive after on_request_finished.
628+
# Drain the stores afterward. complete_store is allowed after the hook.
629629
runner.run(
630630
decoded_tokens=[],
631631
complete_transfers=True,
@@ -638,11 +638,56 @@ def test_on_request_finished_is_not_deferred_until_store_completion(
638638
finished_idx = calls.index(("on_request_finished", req_id))
639639
store_indices = [i for i, c in enumerate(calls) if c == ("complete_store", req_id)]
640640

641-
# The request-level hook no longer waits for already-submitted transfers.
641+
# complete_store arrives after on_request_finished — allowed by contract.
642642
assert store_indices, calls
643643
assert finished_idx < min(store_indices), calls
644644

645645

646+
@pytest.mark.parametrize("async_scheduling", [True, False])
647+
def test_on_request_finished_fires_after_final_block_store(
648+
request_runner, async_scheduling: bool
649+
):
650+
"""on_request_finished fires after the final-block prepare_store at EOS.
651+
652+
When a request finishes with a partial block that becomes full at EOS,
653+
request_finished() keeps req_status alive so _build_store_jobs can create
654+
a store job for the last block on the next step. on_request_finished must
655+
fire after that prepare_store — not before it.
656+
"""
657+
block_size = 4
658+
runner = request_runner(
659+
block_size=block_size,
660+
num_gpu_blocks=10,
661+
async_scheduling=async_scheduling,
662+
)
663+
664+
calls: list[tuple[str, str]] = []
665+
runner.manager.on_request_finished.side_effect = lambda req_context: calls.append(
666+
("on_request_finished", req_context.req_id)
667+
)
668+
669+
def _prepare_store_side_effect(keys, req_context):
670+
calls.append(("prepare_store", req_context.req_id))
671+
return generate_store_output(keys)
672+
673+
runner.manager.prepare_store.side_effect = _prepare_store_side_effect
674+
675+
# Prompt is one token short of a full block — no store until EOS fills it.
676+
runner.new_request(token_ids=[0] * (block_size - 1))
677+
runner.run(decoded_tokens=[EOS_TOKEN_ID], expected_stored=(0,))
678+
679+
req_id = str(runner.req_id)
680+
681+
# on_request_finished fires exactly once, after the final prepare_store.
682+
assert calls.count(("on_request_finished", req_id)) == 1, calls
683+
684+
finished_idx = calls.index(("on_request_finished", req_id))
685+
prepare_indices = [i for i, c in enumerate(calls) if c == ("prepare_store", req_id)]
686+
687+
assert prepare_indices, calls
688+
assert finished_idx > max(prepare_indices), calls
689+
690+
646691
@pytest.mark.parametrize("async_scheduling", [True, False])
647692
def test_concurrent_lookups_of_the_same_prefix(request_runner, async_scheduling: bool):
648693
block_size = 4
@@ -831,7 +876,10 @@ def test_two_groups_full_and_sliding_window(request_runner, async_scheduling: bo
831876
touch_calls = runner.manager.touch.call_args_list
832877
assert len(touch_calls) == 6
833878

834-
runner.run(decoded_tokens=[EOS_TOKEN_ID])
879+
# EOS fills the 7th block (offset 6). The extra schedule step processes
880+
# finished_req_ids and stores block 6 for both groups before the request's
881+
# GPU blocks are freed.
882+
runner.run(decoded_tokens=[EOS_TOKEN_ID], expected_stored=(6,))
835883

836884
runner.scheduler.reset_prefix_cache()
837885

@@ -844,19 +892,11 @@ def test_two_groups_full_and_sliding_window(request_runner, async_scheduling: bo
844892
# Group 1 (sliding window, window=2): only the last 2 blocks
845893
# are within the window → loads blocks 1,2
846894
expected_loaded=((0, 0), (0, 1), (0, 2), (1, 1), (1, 2)),
847-
# The deferred store from the previous request's last block
848-
# completes during this step, and its blocks are flushed because
849-
# they were reallocated to the new request.
850-
# Only block 1 (sliding window group) is stored — block 0's
851-
# deferred store is flushed because it was reallocated.
852-
expected_stored=((0, 1),),
853-
expected_flushed=((0, 1),),
854895
)
855896

856-
# 4 touch calls: 2 from get_num_new_matched_tokens (2 groups)
857-
# + 2 from _get_reqs_to_store (2 groups)
897+
# 2 touch calls from get_num_new_matched_tokens (2 groups)
858898
touch_calls = runner.manager.touch.call_args_list
859-
assert len(touch_calls) == 4
899+
assert len(touch_calls) == 2
860900
# full attention group touched all 3 blocks
861901
assert len(touch_calls[0].args[0]) == 3
862902
# sliding window group touched just the last 2 blocks
@@ -1733,8 +1773,8 @@ def test_reset_cache(request_runner, async_scheduling: bool):
17331773
def test_reset_cache_finalizes_finished_request_with_pending_store(
17341774
request_runner, async_scheduling: bool
17351775
):
1736-
"""reset_cache drops a finished request whose in-flight stores it discards
1737-
without calling on_request_finished twice.
1776+
"""reset_cache fires on_request_finished for a finished request whose
1777+
in-flight stores it discards, exactly once.
17381778
"""
17391779
block_size = 4
17401780
blocks_per_chunk = 3
@@ -1770,16 +1810,15 @@ def test_reset_cache_finalizes_finished_request_with_pending_store(
17701810
assert req_status.transfer_jobs, "expected an in-flight store before finish"
17711811
assert any(job.is_store for job in cs._jobs.values())
17721812

1773-
# Finish the request while its store is still in flight. request_finished
1774-
# fires the hook eagerly, but the entry stays tracked so later completions
1775-
# can still call complete_store().
1813+
# Finish the request while its store is still in flight. on_request_finished
1814+
# is deferred — it does NOT fire here because transfer_jobs are pending.
17761815
req_status.req.status = RequestStatus.FINISHED_STOPPED
17771816
cs.request_finished(req_status.req)
1778-
assert finalized == [req_id]
1817+
assert finalized == []
17791818
assert req_id in cs._req_status
17801819

1781-
# reset_cache discards the in-flight store and drops the state without a
1782-
# duplicate on_request_finished call.
1820+
# reset_cache discards the in-flight stores and fires on_request_finished
1821+
# for the finished request before resetting the manager.
17831822
cs.reset_cache()
17841823
assert finalized == [req_id]
17851824
assert req_id not in cs._req_status

tests/v1/kv_connector/unit/offloading_connector/utils.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -482,8 +482,13 @@ def _run(
482482
# Strict-always-False frees the request immediately on EOS, but
483483
# the worker may still have a deferred store queued. In production
484484
# the next request's step drains it; in single-request tests we
485-
# must keep stepping until the scheduler sees no in-flight jobs.
486-
if not self.scheduler.requests and not self.connector_scheduler._jobs:
485+
# must keep stepping until the scheduler sees no in-flight jobs
486+
# and no pending finished_req_ids awaiting build_connector_meta.
487+
if (
488+
not self.scheduler.requests
489+
and not self.connector_scheduler._jobs
490+
and not self.scheduler.finished_req_ids
491+
):
487492
break
488493

489494
scheduler_output = self.scheduler.schedule()
@@ -544,19 +549,30 @@ def _run(
544549
if (
545550
prev_token_id == EOS_TOKEN_ID
546551
and prev_token_id != token_id
547-
and (self.scheduler.requests or self.connector_scheduler._jobs)
552+
and (
553+
self.scheduler.requests
554+
or self.connector_scheduler._jobs
555+
or self.scheduler.finished_req_ids
556+
)
548557
):
549558
# continue for one more step to allow offloading to kick off
550559
continue
551560

552561
if token_id is None:
553562
if self.async_scheduling:
554-
# sample last token
563+
# Flush the previous step's output.
555564
engine_outputs = self.scheduler.update_from_output(
556565
prev_scheduler_output, prev_model_runner_output
557566
)
558567
self._record_kv_connector_stats(engine_outputs)
559-
break
568+
prev_model_runner_output = None
569+
if self.scheduler.requests:
570+
# Request still running, just exhausted decoded_tokens.
571+
break
572+
if not self.scheduler.finished_req_ids and (
573+
not complete_transfers or not self.connector_scheduler._jobs
574+
):
575+
break
560576

561577
self._parse_transfers()
562578

vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,8 @@ class RequestOffloadState:
278278
# time.monotonic() of this request's first deferred offload lookup;
279279
# None once consumed (observed) or while no lookup is pending.
280280
deferred_lookup_start_time: float | None = None
281+
# True once on_request_finished has been signaled to the manager.
282+
finished_signaled: bool = False
281283

282284
def __post_init__(self) -> None:
283285
self.group_states = tuple(
@@ -481,13 +483,6 @@ def _calc_num_offloadable_tokens(
481483
num = min(num, req_status.req.num_prompt_tokens)
482484
return num
483485

484-
def _maybe_cleanup_finished_req(
485-
self, req_id: str, req_status: RequestOffloadState
486-
) -> None:
487-
"""Clean up req_status if finished and no in-flight jobs."""
488-
if req_status.req.is_finished() and not req_status.transfer_jobs:
489-
del self._req_status[req_id]
490-
491486
def _maximal_prefix_lookup(
492487
self,
493488
keys: Iterable[OffloadKey],
@@ -1041,7 +1036,6 @@ def _build_store_jobs(
10411036

10421037
if not new_offload_keys:
10431038
req_status.advance_stored_idx(num_offloadable_tokens)
1044-
self._maybe_cleanup_finished_req(req_id, req_status)
10451039
continue
10461040

10471041
store_output = self.manager.prepare_store(
@@ -1052,12 +1046,10 @@ def _build_store_jobs(
10521046
_ConnectorMetricName.ALLOCATION_FAILURE
10531047
)
10541048
logger.warning("Request %s: cannot store chunks", req_id)
1055-
self._maybe_cleanup_finished_req(req_id, req_status)
10561049
continue
10571050

10581051
if not store_output.keys_to_store:
10591052
req_status.advance_stored_idx(num_offloadable_tokens)
1060-
self._maybe_cleanup_finished_req(req_id, req_status)
10611053
continue
10621054

10631055
self._touch(req_status)
@@ -1201,6 +1193,17 @@ def build_connector_meta(
12011193
store_jobs=self._build_store_jobs(scheduler_output),
12021194
jobs_to_flush=self._current_batch_jobs_to_flush,
12031195
)
1196+
1197+
# All prepare_store calls for finished requests have been issued.
1198+
# Signal on_request_finished and clean up state where possible.
1199+
for req_id in scheduler_output.finished_req_ids or ():
1200+
req_status = self._req_status.get(req_id)
1201+
if req_status is None:
1202+
continue
1203+
req_status.finished_signaled = True
1204+
self.manager.on_request_finished(req_status.req_context)
1205+
if not req_status.transfer_jobs:
1206+
del self._req_status[req_id]
12041207
self._current_batch_load_jobs = {}
12051208
self._current_batch_jobs_to_flush = set()
12061209
self._current_batch_allocated_block_ids = set()
@@ -1291,7 +1294,7 @@ def update_connector_output(self, connector_output: KVConnectorOutput):
12911294

12921295
del self._jobs[job_id]
12931296
req_status.transfer_jobs.remove(job_id)
1294-
if not req_status.transfer_jobs and req_status.req.is_finished():
1297+
if req_status.finished_signaled and not req_status.transfer_jobs:
12951298
del self._req_status[job_status.req_id]
12961299

12971300
def get_stats(self) -> OffloadingConnectorStats | None:
@@ -1333,7 +1336,6 @@ def request_finished(
13331336
self.manager.on_request_finished(req_context)
13341337
return False, None
13351338

1336-
self.manager.on_request_finished(req_status.req_context)
13371339
self._maybe_observe_lookup_async_delay(req_status)
13381340

13391341
# Update offload keys with final block hash so _build_store_jobs can
@@ -1377,6 +1379,8 @@ def reset_cache(self) -> None:
13771379

13781380
for req_id, status in list(self._req_status.items()):
13791381
if status.req.is_finished():
1382+
if not status.finished_signaled:
1383+
self.manager.on_request_finished(status.req_context)
13801384
del self._req_status[req_id]
13811385

13821386
# Reset offloading manager cache

0 commit comments

Comments
 (0)