Skip to content

Commit 65e82de

Browse files
[Refactor][KV Offloading] Consolidate duplicated cleanup and clamp logic
- 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>
1 parent 028b096 commit 65e82de

3 files changed

Lines changed: 63 additions & 112 deletions

File tree

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

Lines changed: 22 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2762,20 +2762,25 @@ def capture_fence():
27622762

27632763

27642764
@pytest.mark.parametrize("async_scheduling", [True, False])
2765-
def test_last_block_offloaded_on_request_finish(request_runner, async_scheduling: bool):
2765+
@pytest.mark.parametrize("prompt_offset", [-1, -2])
2766+
def test_last_block_offload_at_request_finish(
2767+
request_runner, async_scheduling: bool, prompt_offset: int
2768+
):
27662769
"""The last block that fills at request finish time is offloaded.
27672770
27682771
Regression test for orozery's TODO: when the finishing token (EOS)
27692772
completes a block, that block's KV must still be offloaded. Before
27702773
schedule time the block wasn't full, so _build_store_jobs skipped it;
27712774
only request_finished has the complete token count.
27722775
2773-
Scenario (block_size=4, factor=1):
2774-
- prompt = 3 tokens (not a full block)
2775-
- decode EOS -> 4 tokens = 1 full block
2776+
prompt_offset=-1: prompt has block_size-1 tokens, decode EOS fills
2777+
the block → store job created → offloaded.
2778+
prompt_offset=-2: prompt has block_size-2 tokens, decode EOS leaves
2779+
the block partial → no store job → not offloaded.
27762780
"""
27772781
block_size = 4
27782782
block_size_factor = 1
2783+
should_offload = prompt_offset == -1
27792784

27802785
runner = request_runner(
27812786
block_size=block_size,
@@ -2787,9 +2792,8 @@ def test_last_block_offloaded_on_request_finish(request_runner, async_scheduling
27872792
generate_store_output(keys)
27882793
)
27892794

2790-
runner.new_request(token_ids=[0] * (block_size - 1))
2795+
runner.new_request(token_ids=[0] * (block_size + prompt_offset))
27912796

2792-
# Track _finished_req_store_jobs state during the run
27932797
cs = runner.connector_scheduler
27942798
finished_req_store_jobs_seen = []
27952799

@@ -2804,49 +2808,20 @@ def patched_update(*args, **kwargs):
28042808

28052809
runner.run(
28062810
decoded_tokens=[EOS_TOKEN_ID],
2807-
expected_stored=(0,),
2808-
drain_finished_req_stores=True,
2809-
)
2810-
2811-
# Verify _finished_req_store_jobs was non-empty at some point
2812-
non_empty_states = [s for s in finished_req_store_jobs_seen if s]
2813-
assert non_empty_states, (
2814-
"_finished_req_store_jobs was never non-empty. "
2815-
"Expected request_finished to create a store job for the last block. "
2816-
f"All states seen: {finished_req_store_jobs_seen}"
2817-
)
2818-
2819-
assert runner.manager.complete_store.call_count == 1
2820-
2821-
2822-
def test_partial_block_not_offloaded(request_runner):
2823-
"""Tokens that do not form a complete block are not offloaded.
2824-
2825-
Scenario:
2826-
- prompt has block_size - 2 tokens
2827-
- decode 1 token
2828-
- total is block_size - 1 tokens, not enough for a full block
2829-
"""
2830-
block_size = 4
2831-
block_size_factor = 1
2832-
2833-
runner = request_runner(
2834-
block_size=block_size,
2835-
num_gpu_blocks=10,
2836-
async_scheduling=False,
2837-
block_size_factor=block_size_factor,
2838-
)
2839-
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
2840-
generate_store_output(keys)
2841-
)
2842-
2843-
runner.new_request(token_ids=[0] * (block_size - 2))
2844-
runner.run(
2845-
decoded_tokens=[EOS_TOKEN_ID],
2846-
expected_stored=(),
2811+
expected_stored=(0,) if should_offload else (),
2812+
drain_finished_req_stores=should_offload,
28472813
)
28482814

2849-
assert runner.manager.complete_store.call_count == 0
2815+
if should_offload:
2816+
non_empty_states = [s for s in finished_req_store_jobs_seen if s]
2817+
assert non_empty_states, (
2818+
"_finished_req_store_jobs was never non-empty. "
2819+
"Expected request_finished to create a store job for the last block. "
2820+
f"All states seen: {finished_req_store_jobs_seen}"
2821+
)
2822+
assert runner.manager.complete_store.call_count == 1
2823+
else:
2824+
assert runner.manager.complete_store.call_count == 0
28502825

28512826

28522827
@pytest.mark.parametrize("async_scheduling", [True, False])

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

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -477,25 +477,15 @@ def _discard_finished_req_store_jobs(self):
477477
"""
478478
cs = self.connector_scheduler
479479
for job_id in list(cs._finished_req_store_jobs.keys()):
480-
job_status = cs._jobs.pop(job_id, None)
480+
job_status = cs._drop_store_job(job_id)
481481
if job_status is not None:
482482
req_status = cs._req_status.get(job_status.req_id)
483-
if req_status is not None:
484-
req_status.transfer_jobs.discard(job_id)
485-
if not req_status.transfer_jobs and req_status.req.is_finished():
486-
del cs._req_status[job_status.req_id]
487-
for bid in job_status.non_sliding_window_block_ids or ():
488-
pending = cs._block_id_to_pending_jobs.get(bid)
489-
if pending:
490-
pending.discard(job_id)
491-
if not pending:
492-
del cs._block_id_to_pending_jobs[bid]
493-
for bid in job_status.sliding_window_block_ids or ():
494-
pending = cs._block_id_to_pending_jobs.get(bid)
495-
if pending:
496-
pending.discard(job_id)
497-
if not pending:
498-
del cs._block_id_to_pending_jobs[bid]
483+
if (
484+
req_status is not None
485+
and not req_status.transfer_jobs
486+
and req_status.req.is_finished()
487+
):
488+
del cs._req_status[job_status.req_id]
499489
cs._finished_req_store_jobs.clear()
500490

501491
def _run(

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

Lines changed: 34 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -430,10 +430,33 @@ def _generate_job_id(self) -> int:
430430

431431
def _remove_pending_job(self, job_id: int, block_ids: list[int] | None) -> None:
432432
for bid in block_ids or ():
433-
pending = self._block_id_to_pending_jobs[bid]
434-
pending.remove(job_id)
435-
if not pending:
436-
del self._block_id_to_pending_jobs[bid]
433+
pending = self._block_id_to_pending_jobs.get(bid)
434+
if pending is not None:
435+
pending.discard(job_id)
436+
if not pending:
437+
del self._block_id_to_pending_jobs[bid]
438+
439+
def _calc_num_offloadable_tokens(
440+
self, req_status: RequestOffloadState, num_computed_tokens: int
441+
) -> int:
442+
num = min(num_computed_tokens, req_status.req.num_tokens)
443+
max_offload_tokens = req_status.max_offload_tokens
444+
if max_offload_tokens is not None:
445+
num = min(num, max_offload_tokens)
446+
if self.config.offload_prompt_only:
447+
num = min(num, req_status.req.num_prompt_tokens)
448+
return num
449+
450+
def _drop_store_job(self, job_id: int) -> TransferJobStatus | None:
451+
job = self._jobs.pop(job_id, None)
452+
if job is None:
453+
return None
454+
self._remove_pending_job(job_id, job.sliding_window_block_ids)
455+
self._remove_pending_job(job_id, job.non_sliding_window_block_ids)
456+
req_status = self._req_status.get(job.req_id)
457+
if req_status is not None:
458+
req_status.transfer_jobs.discard(job_id)
459+
return job
437460

438461
def _maximal_prefix_lookup(
439462
self, keys: Iterable[OffloadKey], req_context: ReqContext
@@ -1079,20 +1102,9 @@ def _build_store_jobs(
10791102

10801103
num_scheduled_tokens = scheduler_output.num_scheduled_tokens[req_id]
10811104
num_tokens_after_batch = req.num_computed_tokens + num_scheduled_tokens
1082-
# with async scheduling, some tokens may be missing
1083-
num_offloadable_tokens = min(num_tokens_after_batch, req.num_tokens)
1084-
max_offload_tokens = req_status.max_offload_tokens
1085-
if max_offload_tokens is not None:
1086-
num_offloadable_tokens = min(num_offloadable_tokens, max_offload_tokens)
1087-
1088-
# Skip decode-phase blocks: clamp to the prompt length so only
1089-
# prefill (prompt) blocks become eligible for store. next_stored_idx
1090-
# never advances past this boundary, so decode blocks are never
1091-
# queued in this or any later step.
1092-
if self.config.offload_prompt_only:
1093-
num_offloadable_tokens = min(
1094-
num_offloadable_tokens, req.num_prompt_tokens
1095-
)
1105+
num_offloadable_tokens = self._calc_num_offloadable_tokens(
1106+
req_status, num_tokens_after_batch
1107+
)
10961108

10971109
self._build_store_jobs_for_req(
10981110
req_status, num_offloadable_tokens, store_jobs
@@ -1151,8 +1163,6 @@ def build_connector_meta(
11511163
if self._current_batch_allocated_block_ids:
11521164
jobs_to_drop = set()
11531165
for job_id in finished_req_job_ids:
1154-
if job_id not in store_jobs:
1155-
continue # Already removed or not present
11561166
job = self._jobs.get(job_id)
11571167
if job is not None and job.is_store:
11581168
job_blocks = set(job.non_sliding_window_block_ids or [])
@@ -1162,24 +1172,7 @@ def build_connector_meta(
11621172
for job_id in jobs_to_drop:
11631173
del store_jobs[job_id]
11641174
self._current_batch_jobs_to_flush.discard(job_id)
1165-
job = self._jobs.pop(job_id, None)
1166-
if job is not None:
1167-
for bid in job.sliding_window_block_ids or []:
1168-
pjs = self._block_id_to_pending_jobs.get(bid)
1169-
if pjs is not None:
1170-
pjs.discard(job_id)
1171-
if not pjs:
1172-
del self._block_id_to_pending_jobs[bid]
1173-
for bid in job.non_sliding_window_block_ids or []:
1174-
pjs = self._block_id_to_pending_jobs.get(bid)
1175-
if pjs is not None:
1176-
pjs.discard(job_id)
1177-
if not pjs:
1178-
del self._block_id_to_pending_jobs[bid]
1179-
# Also remove from req_status.transfer_jobs
1180-
req_status = self._req_status.get(job.req_id)
1181-
if req_status is not None:
1182-
req_status.transfer_jobs.discard(job_id)
1175+
self._drop_store_job(job_id)
11831176

11841177
meta = OffloadingConnectorMetadata(
11851178
load_jobs=self._current_batch_load_jobs,
@@ -1308,8 +1301,6 @@ def request_finished(
13081301
Optional KVTransferParams to be included in the request outputs
13091302
returned by the engine.
13101303
"""
1311-
# TODO(orozery): possibly kickoff offload for last block
1312-
# which may have been deferred due to async scheduling
13131304
req_status = self._req_status.get(request.request_id)
13141305

13151306
if req_status is None:
@@ -1329,14 +1320,9 @@ def request_finished(
13291320
# append_output_token_ids → update_block_hashes, but offload_keys
13301321
# is only refreshed in _update_req_states / build_connector_meta.
13311322
req_status.update_offload_keys()
1332-
num_offloadable_tokens = request.num_tokens
1333-
if self.config.offload_prompt_only:
1334-
num_offloadable_tokens = min(
1335-
num_offloadable_tokens, request.num_prompt_tokens
1336-
)
1337-
max_offload_tokens = req_status.max_offload_tokens
1338-
if max_offload_tokens is not None:
1339-
num_offloadable_tokens = min(num_offloadable_tokens, max_offload_tokens)
1323+
num_offloadable_tokens = self._calc_num_offloadable_tokens(
1324+
req_status, request.num_tokens
1325+
)
13401326
self._build_store_jobs_for_req(
13411327
req_status, num_offloadable_tokens, self._finished_req_store_jobs
13421328
)

0 commit comments

Comments
 (0)