Skip to content

Commit ecf6bc5

Browse files
Address KV offload finalization review
Centralize finished-request signaling after final store construction and make the scheduler test runner drain finished request steps and transfer completions. Co-authored-by: Or Ozeri <oro@il.ibm.com> Signed-off-by: Rui Yin <2260891073@qq.com>
1 parent 8a4e1ed commit ecf6bc5

3 files changed

Lines changed: 108 additions & 143 deletions

File tree

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

Lines changed: 70 additions & 101 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-
"""Prepare the final store before notifying the manager of request finish.
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
@@ -94,85 +96,20 @@ def test_last_block_offloaded_at_request_finish(
9496
)
9597
# prompt = block_size + prompt_offset tokens
9698
runner.new_request(token_ids=[0] * (block_size + prompt_offset))
97-
manager_events: list[str] = []
98-
99-
def prepare_store(keys, req_context):
100-
manager_events.append("prepare_store")
101-
return generate_store_output(list(keys))
102-
103-
runner.manager.prepare_store.side_effect = prepare_store
104-
runner.manager.on_request_finished.side_effect = lambda req_context: (
105-
manager_events.append("on_request_finished")
106-
)
107-
108-
# Run with one step (EOS)
109-
runner.run(
110-
decoded_tokens=[EOS_TOKEN_ID],
111-
)
112-
113-
cs = runner.connector_scheduler
114-
# Verify req_status is kept alive for _build_store_jobs to process
115-
# regardless of whether there are storable blocks
116-
assert "0" in cs._req_status, (
117-
"req_status was deleted but should be kept alive "
118-
"for _build_store_jobs to process finished_req_ids."
119-
)
120-
assert manager_events == []
121-
122-
scheduler_output = runner.scheduler.schedule()
123-
metadata = scheduler_output.kv_connector_metadata
124-
assert isinstance(metadata, OffloadingConnectorMetadata)
125-
126-
if prompt_offset == -1:
127-
assert metadata.store_jobs
128-
assert manager_events == ["prepare_store", "on_request_finished"]
129-
assert "0" in cs._req_status
130-
else:
131-
assert not metadata.store_jobs
132-
assert manager_events == ["on_request_finished"]
133-
assert "0" not in cs._req_status
134-
135-
136-
@pytest.mark.parametrize("async_scheduling", [True, False])
137-
def test_store_completion_at_finish_keeps_pending_final_store(
138-
request_runner, async_scheduling: bool
139-
):
140-
"""A completed older store must not discard the pending final store."""
141-
block_size = 4
142-
blocks_per_chunk = 3
143-
tokens_per_chunk = block_size * blocks_per_chunk
144-
runner = request_runner(
145-
block_size=block_size,
146-
num_gpu_blocks=10,
147-
async_scheduling=async_scheduling,
148-
blocks_per_chunk=blocks_per_chunk,
149-
)
150-
runner.new_request(token_ids=[0] * (2 * tokens_per_chunk - 1))
15199
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
152100
generate_store_output(list(keys))
153101
)
154102

155-
# The first chunk's store completes in the same output that finishes the
156-
# request. The EOS-filled second chunk still needs the next schedule step.
157-
runner.run(
158-
decoded_tokens=[EOS_TOKEN_ID],
159-
complete_transfers=True,
160-
expected_stored=tuple(range(blocks_per_chunk)),
161-
)
103+
if prompt_offset == -1:
104+
# EOS fills the block, so 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, so no store job is created.
108+
runner.run(decoded_tokens=[EOS_TOKEN_ID])
162109

163110
cs = runner.connector_scheduler
164-
req_id = str(runner.req_id)
165-
assert req_id in cs._req_status
166-
assert not cs._req_status[req_id].transfer_jobs
167-
runner.manager.on_request_finished.assert_not_called()
168-
169-
scheduler_output = runner.scheduler.schedule()
170-
metadata = scheduler_output.kv_connector_metadata
171-
assert isinstance(metadata, OffloadingConnectorMetadata)
172-
assert metadata.store_jobs
173-
runner.manager.on_request_finished.assert_called_once_with(
174-
cs._req_status[req_id].req_context
175-
)
111+
# After the full run completes, req_status is cleaned up.
112+
assert "0" not in cs._req_status
176113

177114

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

633570

634571
@pytest.mark.parametrize("async_scheduling", [True, False])
635-
def test_on_request_finished_is_not_deferred_until_store_completion(
572+
def test_on_request_finished_not_deferred_until_store_completion(
636573
request_runner, async_scheduling: bool
637574
):
638-
"""on_request_finished fires when no more stores will be submitted.
575+
"""on_request_finished fires after the last prepare_store is submitted.
639576
640-
A request can finish while its GPU->primary store is still in flight. The
641-
manager-level hook should not wait for that completion; complete_store may
642-
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.
643580
"""
644581
block_size = 4
645582
blocks_per_chunk = 3
@@ -676,9 +613,9 @@ def test_on_request_finished_is_not_deferred_until_store_completion(
676613
complete_transfers=False,
677614
)
678615

679-
# Finish the request while its stores are still in flight. The following
680-
# scheduler step prepares any final store and fires the hook before any
681-
# complete_store has arrived.
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.
682619
runner.run(
683620
decoded_tokens=[EOS_TOKEN_ID],
684621
complete_transfers=False,
@@ -688,8 +625,7 @@ def test_on_request_finished_is_not_deferred_until_store_completion(
688625

689626
assert calls == [("on_request_finished", req_id)], calls
690627

691-
# Drain the stores afterward. The already-submitted complete_store calls
692-
# are allowed to arrive after on_request_finished.
628+
# Drain the stores afterward. complete_store is allowed after the hook.
693629
runner.run(
694630
decoded_tokens=[],
695631
complete_transfers=True,
@@ -702,11 +638,50 @@ def test_on_request_finished_is_not_deferred_until_store_completion(
702638
finished_idx = calls.index(("on_request_finished", req_id))
703639
store_indices = [i for i, c in enumerate(calls) if c == ("complete_store", req_id)]
704640

705-
# The request-level hook no longer waits for already-submitted transfers.
641+
# complete_store arrives after on_request_finished, as allowed by the contract.
706642
assert store_indices, calls
707643
assert finished_idx < min(store_indices), calls
708644

709645

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 EOS fills a partial block, request_finished() keeps req_status alive
653+
so _build_store_jobs can create a store job for it on the next step.
654+
"""
655+
block_size = 4
656+
runner = request_runner(
657+
block_size=block_size,
658+
num_gpu_blocks=10,
659+
async_scheduling=async_scheduling,
660+
)
661+
662+
calls: list[tuple[str, str]] = []
663+
runner.manager.on_request_finished.side_effect = lambda req_context: calls.append(
664+
("on_request_finished", req_context.req_id)
665+
)
666+
667+
def prepare_store(keys, req_context):
668+
calls.append(("prepare_store", req_context.req_id))
669+
return generate_store_output(keys)
670+
671+
runner.manager.prepare_store.side_effect = prepare_store
672+
673+
runner.new_request(token_ids=[0] * (block_size - 1))
674+
runner.run(decoded_tokens=[EOS_TOKEN_ID], expected_stored=(0,))
675+
676+
req_id = str(runner.req_id)
677+
assert calls.count(("on_request_finished", req_id)) == 1, calls
678+
679+
finished_idx = calls.index(("on_request_finished", req_id))
680+
prepare_indices = [i for i, c in enumerate(calls) if c == ("prepare_store", req_id)]
681+
assert prepare_indices, calls
682+
assert finished_idx > max(prepare_indices), calls
683+
684+
710685
@pytest.mark.parametrize("async_scheduling", [True, False])
711686
def test_concurrent_lookups_of_the_same_prefix(request_runner, async_scheduling: bool):
712687
block_size = 4
@@ -895,7 +870,10 @@ def test_two_groups_full_and_sliding_window(request_runner, async_scheduling: bo
895870
touch_calls = runner.manager.touch.call_args_list
896871
assert len(touch_calls) == 6
897872

898-
runner.run(decoded_tokens=[EOS_TOKEN_ID])
873+
# EOS fills the 7th block (offset 6). The extra schedule step processes
874+
# finished_req_ids and stores block 6 for both groups before the request's
875+
# GPU blocks are freed.
876+
runner.run(decoded_tokens=[EOS_TOKEN_ID], expected_stored=(6,))
899877

900878
runner.scheduler.reset_prefix_cache()
901879

@@ -908,19 +886,11 @@ def test_two_groups_full_and_sliding_window(request_runner, async_scheduling: bo
908886
# Group 1 (sliding window, window=2): only the last 2 blocks
909887
# are within the window → loads blocks 1,2
910888
expected_loaded=((0, 0), (0, 1), (0, 2), (1, 1), (1, 2)),
911-
# The deferred store from the previous request's last block
912-
# completes during this step, and its blocks are flushed because
913-
# they were reallocated to the new request.
914-
# Only block 1 (sliding window group) is stored — block 0's
915-
# deferred store is flushed because it was reallocated.
916-
expected_stored=((0, 1),),
917-
expected_flushed=((0, 1),),
918889
)
919890

920-
# 4 touch calls: 2 from get_num_new_matched_tokens (2 groups)
921-
# + 2 from _get_reqs_to_store (2 groups)
891+
# 2 touch calls from get_num_new_matched_tokens (2 groups)
922892
touch_calls = runner.manager.touch.call_args_list
923-
assert len(touch_calls) == 4
893+
assert len(touch_calls) == 2
924894
# full attention group touched all 3 blocks
925895
assert len(touch_calls[0].args[0]) == 3
926896
# sliding window group touched just the last 2 blocks
@@ -1797,8 +1767,8 @@ def test_reset_cache(request_runner, async_scheduling: bool):
17971767
def test_reset_cache_finalizes_finished_request_with_pending_store(
17981768
request_runner, async_scheduling: bool
17991769
):
1800-
"""reset_cache drops a finished request whose in-flight stores it discards
1801-
without calling on_request_finished twice.
1770+
"""reset_cache fires on_request_finished for a finished request whose
1771+
in-flight stores it discards, exactly once.
18021772
"""
18031773
block_size = 4
18041774
blocks_per_chunk = 3
@@ -1835,8 +1805,7 @@ def test_reset_cache_finalizes_finished_request_with_pending_store(
18351805
assert any(job.is_store for job in cs._jobs.values())
18361806

18371807
# Finish the request while its store is still in flight. The manager hook
1838-
# is deferred until the final store decision, and the entry stays tracked
1839-
# so later completions can still call complete_store().
1808+
# is deferred because the final store decision has not happened yet.
18401809
req_status.req.status = RequestStatus.FINISHED_STOPPED
18411810
cs.request_finished(req_status.req)
18421811
assert finalized == []

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

0 commit comments

Comments
 (0)