Fix KV offload request-finished lifecycle contract (#46284)
Signed-off-by: test test <2260891073@qq.com> Signed-off-by: Rui Yin <2260891073@qq.com> Co-authored-by: Or Ozeri <oro@il.ibm.com>
Rui Yin committed
Jun 24, 2026 at 20:42 UTC
cf9fd6457eb7bc18942bf242365a3aa7d6f49ad8
5 files changed
+336
-114
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py
+28
-26
@@ -216,17 +216,14 @@ def test_request_preemption(request_runner, async_scheduling: bool):
216
217
218
@pytest.mark.parametrize("async_scheduling", [True, False])
219
-def test_no_offload_call_after_on_request_finished(
219
+def test_on_request_finished_is_not_deferred_until_store_completion(
220
request_runner, async_scheduling: bool
221
):
222
- """on_request_finished is not issued before a per-request offload
223
- call.
224
-
225
- A request can finish while its GPU->primary store is still in flight; the
226
- later worker completion then drives complete_store. The scheduler defers
227
- on_request_finished until the request is finished AND has no in-flight
228
- transfer jobs, so complete_store is observed BEFORE on_request_finished,
229
- and it is called exactly once.
222
+ """on_request_finished fires when no more stores will be submitted.
223
+
224
+ A request can finish while its GPU->primary store is still in flight. The
225
+ manager-level hook should not wait for that completion; complete_store may
226
+ still arrive afterward for already-submitted transfer jobs.
227
"""
228
block_size = 4
229
block_size_factor = 3
@@ -263,27 +260,34 @@ def test_no_offload_call_after_on_request_finished(
260
complete_transfers=False,
261
)
262
266
- # Finish the request, completing its pending stores. on_request_finished is
267
- # deferred until the stores drain, so it lands after the last complete_store.
268
- # 4 offloaded blocks are stored (2 prompt + 2 decode) -> 4 * block_size_factor
269
- # GPU blocks.
263
+ # Finish the request while its stores are still in flight. The hook should
264
+ # fire immediately even though no complete_store has arrived yet.
265
runner.run(
266
decoded_tokens=[EOS_TOKEN_ID],
272
- expected_stored=tuple(range(4 * block_size_factor)),
267
+ complete_transfers=False,
268
)
269
270
req_id = str(runner.req_id)
271
272
+ assert calls == [("on_request_finished", req_id)], calls
273
+
274
+ # Drain the stores afterward. The already-submitted complete_store calls
275
+ # are allowed to arrive after on_request_finished.
276
+ runner.run(
277
+ decoded_tokens=[],
278
+ complete_transfers=True,
279
+ expected_stored=tuple(range(4 * block_size_factor)),
280
+ )
281
+
282
# on_request_finished is issued exactly once.
283
assert calls.count(("on_request_finished", req_id)) == 1, calls
284
285
finished_idx = calls.index(("on_request_finished", req_id))
286
store_indices = [i for i, c in enumerate(calls) if c == ("complete_store", req_id)]
287
283
- # All of the request's complete_store calls must precede its single
284
- # on_request_finished.
288
+ # The request-level hook no longer waits for already-submitted transfers.
289
assert store_indices, calls
286
- assert max(store_indices) < finished_idx, calls
290
+ assert finished_idx < min(store_indices), calls
291
292
293
@pytest.mark.parametrize("async_scheduling", [True, False])
@@ -1232,11 +1236,8 @@ def test_reset_cache(request_runner, async_scheduling: bool):
1236
def test_reset_cache_finalizes_finished_request_with_pending_store(
1237
request_runner, async_scheduling: bool
1238
):
1235
- """reset_cache must finalize a finished request whose in-flight stores it
1236
- discards: call on_request_finished and drop its _req_status entry.
1237
-
1238
- Otherwise the deferred hook (which waits for the now-discarded jobs to
1239
- complete) never fires and the entry leaks.
1239
+ """reset_cache drops a finished request whose in-flight stores it discards
1240
+ without calling on_request_finished twice.
1241
"""
1242
block_size = 4
1243
block_size_factor = 3
@@ -1273,14 +1274,15 @@ def test_reset_cache_finalizes_finished_request_with_pending_store(
1274
assert any(job.is_store for job in cs._jobs.values())
1275
1276
# Finish the request while its store is still in flight. request_finished
1276
- # takes the defer branch (pending jobs), so on_request_finished is NOT
1277
- # called yet and the entry stays tracked.
1277
+ # fires the hook eagerly, but the entry stays tracked so later completions
1278
+ # can still call complete_store().
1279
req_status.req.status = RequestStatus.FINISHED_STOPPED
1280
cs.request_finished(req_status.req)
1280
- assert finalized == []
1281
+ assert finalized == [req_id]
1282
assert req_id in cs._req_status
1283
1283
- # reset_cache discards the in-flight store; it must finalize the request.
1284
+ # reset_cache discards the in-flight store and drops the state without a
1285
+ # duplicate on_request_finished call.
1286
cs.reset_cache()
1287
assert finalized == [req_id]
1288
assert req_id not in cs._req_status
tests/v1/kv_offload/tiering/test_tiering_offloading.py
+203
-19
@@ -235,11 +235,16 @@ class TestTieringOffloadingManager:
235
self.manager.on_schedule_end()
236
list(self.manager.take_events())
237
238
+ def _start_request(self, req_context: ReqContext = _CTX):
239
+ if req_context.req_id not in self.manager._req_state:
240
+ self.manager.on_new_request(req_context)
241
+
242
def test_basic_store_to_primary(self, manager_setup):
243
"""Test basic store operation to primary tier."""
244
blocks = to_keys(range(3))
245
246
# Prepare store
247
+ self._start_request()
248
result = self.manager.prepare_store(blocks, _CTX)
249
assert result is not None
250
assert len(result.keys_to_store) == 3
@@ -262,6 +267,7 @@ class TestTieringOffloadingManager:
267
)
268
269
# Store to primary
270
+ self._start_request()
271
result = self.manager.prepare_store(blocks, _CTX)
272
assert result is not None
273
@@ -285,6 +291,7 @@ class TestTieringOffloadingManager:
291
blocks = to_keys(range(3))
292
293
# Store to primary
294
+ self._start_request()
295
result = self.manager.prepare_store(blocks, _CTX)
296
assert result is not None
297
self.manager.complete_store(blocks, _CTX, success=True)
@@ -330,6 +337,7 @@ class TestTieringOffloadingManager:
337
blocks = to_keys(range(3))
338
339
# Store blocks
340
+ self._start_request()
341
self.manager.prepare_store(blocks, _CTX)
342
self.manager.complete_store(blocks, _CTX, success=True)
343
@@ -366,6 +374,7 @@ class TestTieringOffloadingManager:
374
blocks = to_keys(range(5))
375
376
# Store first 3 blocks to primary
377
+ self._start_request()
378
self.manager.prepare_store(blocks[:3], _CTX)
379
self.manager.complete_store(blocks[:3], _CTX, success=True)
380
@@ -377,6 +386,7 @@ class TestTieringOffloadingManager:
386
# Primary tier has capacity of 5 blocks
387
# First, fill the primary tier
388
blocks = to_keys(range(5))
389
+ self._start_request()
390
result = self.manager.prepare_store(blocks, _CTX)
391
assert result is not None
392
assert len(result.keys_to_store) == 5
@@ -399,6 +409,7 @@ class TestTieringOffloadingManager:
409
blocks = to_keys(range(3))
410
411
# Store blocks
412
+ self._start_request()
413
self.manager.prepare_store(blocks, _CTX)
414
self.manager.complete_store(blocks, _CTX, success=True)
415
self._simulate_on_schedule_end()
@@ -431,6 +442,7 @@ class TestTieringOffloadingManager:
442
)
443
444
# Prepare store
445
+ self._start_request()
446
result = self.manager.prepare_store(blocks, _CTX)
447
assert result is not None
448
@@ -521,6 +533,7 @@ class TestTieringOffloadingManager:
533
534
ctx = ReqContext(req_id="req_ctx", kv_transfer_params={"key": "value"})
535
536
+ self._start_request(ctx)
537
self.manager.prepare_store(blocks, ctx)
538
self.manager.complete_store(blocks, ctx, success=True)
539
@@ -528,6 +541,172 @@ class TestTieringOffloadingManager:
541
job_metadata = self.secondary_tier1.submit_store.call_args.args[0]
542
assert job_metadata.req_context is ctx
543
544
+ def test_on_request_finished_delays_secondary_until_store_submitted(
545
+ self, manager_setup
546
+ ):
547
+ """Manager hook is eager; secondary hooks wait for cascade submission."""
548
+ blocks = to_keys(range(2))
549
+ ctx = ReqContext(req_id="req_delayed_secondary")
550
+ calls: list[tuple[str, str]] = []
551
+
552
+ self.primary_tier.on_request_finished = MagicMock(
553
+ side_effect=lambda req_context: calls.append(
554
+ ("primary_finish", req_context.req_id)
555
+ )
556
+ )
557
+
558
+ original_submit_store1 = self.secondary_tier1.submit_store
559
+ original_submit_store2 = self.secondary_tier2.submit_store
560
+
561
+ def submit_store1(job_metadata):
562
+ calls.append(("submit_store_1", job_metadata.req_context.req_id))
563
+ return original_submit_store1(job_metadata)
564
+
565
+ def submit_store2(job_metadata):
566
+ calls.append(("submit_store_2", job_metadata.req_context.req_id))
567
+ return original_submit_store2(job_metadata)
568
+
569
+ self.secondary_tier1.submit_store = MagicMock(side_effect=submit_store1)
570
+ self.secondary_tier2.submit_store = MagicMock(side_effect=submit_store2)
571
+ self.secondary_tier1.on_request_finished = MagicMock(
572
+ side_effect=lambda req_context: calls.append(
573
+ ("secondary_finish_1", req_context.req_id)
574
+ )
575
+ )
576
+ self.secondary_tier2.on_request_finished = MagicMock(
577
+ side_effect=lambda req_context: calls.append(
578
+ ("secondary_finish_2", req_context.req_id)
579
+ )
580
+ )
581
+
582
+ self._start_request(ctx)
583
+ self.manager.prepare_store(blocks, ctx)
584
+ self.manager.on_request_finished(ctx)
585
+
586
+ assert calls == [("primary_finish", ctx.req_id)]
587
+ self.secondary_tier1.on_request_finished.assert_not_called()
588
+ self.secondary_tier2.on_request_finished.assert_not_called()
589
+
590
+ self.manager.complete_store(blocks, ctx, success=True)
591
+
592
+ assert calls == [
593
+ ("primary_finish", ctx.req_id),
594
+ ("submit_store_1", ctx.req_id),
595
+ ("submit_store_2", ctx.req_id),
596
+ ("secondary_finish_1", ctx.req_id),
597
+ ("secondary_finish_2", ctx.req_id),
598
+ ]
599
+
600
+ def test_failed_store_finalizes_finished_request(self, manager_setup):
601
+ """Failed primary stores still unblock secondary finalization."""
602
+ blocks = to_keys(range(2))
603
+ ctx = ReqContext(req_id="req_failed_store_finalize")
604
+
605
+ self.secondary_tier1.submit_store = MagicMock(
606
+ wraps=self.secondary_tier1.submit_store
607
+ )
608
+ self.secondary_tier2.submit_store = MagicMock(
609
+ wraps=self.secondary_tier2.submit_store
610
+ )
611
+ self.secondary_tier1.on_request_finished = MagicMock(
612
+ wraps=self.secondary_tier1.on_request_finished
613
+ )
614
+ self.secondary_tier2.on_request_finished = MagicMock(
615
+ wraps=self.secondary_tier2.on_request_finished
616
+ )
617
+
618
+ self._start_request(ctx)
619
+ self.manager.prepare_store(blocks, ctx)
620
+ self.manager.on_request_finished(ctx)
621
+
622
+ self.secondary_tier1.on_request_finished.assert_not_called()
623
+ self.secondary_tier2.on_request_finished.assert_not_called()
624
+
625
+ self.manager.complete_store(blocks, ctx, success=False)
626
+
627
+ self.secondary_tier1.submit_store.assert_not_called()
628
+ self.secondary_tier2.submit_store.assert_not_called()
629
+ self.secondary_tier1.on_request_finished.assert_called_once_with(ctx)
630
+ self.secondary_tier2.on_request_finished.assert_called_once_with(ctx)
631
+ assert ctx.req_id not in self.manager._req_state
632
+
633
+ def test_zero_store_request_finalizes_immediately(self, manager_setup):
634
+ """Requests with no pending stores finalize secondary tiers immediately."""
635
+ ctx = ReqContext(req_id="req_zero_store_finalize")
636
+
637
+ self.secondary_tier1.on_request_finished = MagicMock(
638
+ wraps=self.secondary_tier1.on_request_finished
639
+ )
640
+ self.secondary_tier2.on_request_finished = MagicMock(
641
+ wraps=self.secondary_tier2.on_request_finished
642
+ )
643
+
644
+ self._start_request(ctx)
645
+ self.manager.on_request_finished(ctx)
646
+
647
+ self.secondary_tier1.on_request_finished.assert_called_once_with(ctx)
648
+ self.secondary_tier2.on_request_finished.assert_called_once_with(ctx)
649
+ assert ctx.req_id not in self.manager._req_state
650
+
651
+ def test_reset_cache_finalizes_delayed_secondary_request(self, manager_setup):
652
+ """reset_cache abandons pending primary stores and finalizes secondaries."""
653
+ blocks = to_keys(range(2))
654
+ ctx = ReqContext(req_id="req_reset_finalize_secondary")
655
+
656
+ self.secondary_tier1.on_request_finished = MagicMock(
657
+ wraps=self.secondary_tier1.on_request_finished
658
+ )
659
+ self.secondary_tier2.on_request_finished = MagicMock(
660
+ wraps=self.secondary_tier2.on_request_finished
661
+ )
662
+
663
+ self._start_request(ctx)
664
+ self.manager.prepare_store(blocks, ctx)
665
+ self.manager.on_request_finished(ctx)
666
+
667
+ self.secondary_tier1.on_request_finished.assert_not_called()
668
+ self.secondary_tier2.on_request_finished.assert_not_called()
669
+
670
+ self.manager.reset_cache()
671
+
672
+ self.secondary_tier1.on_request_finished.assert_called_once_with(ctx)
673
+ self.secondary_tier2.on_request_finished.assert_called_once_with(ctx)
674
+ assert self.manager._req_state == {}
675
+
676
+ def test_reset_cache_clears_pending_primary_stores_for_active_request(
677
+ self, manager_setup
678
+ ):
679
+ """reset_cache drops active pending stores so resumed requests finalize."""
680
+ initial_blocks = to_keys(range(2))
681
+ resumed_blocks = to_keys(range(2, 4))
682
+ ctx = ReqContext(req_id="req_reset_resume")
683
+
684
+ self.secondary_tier1.on_request_finished = MagicMock(
685
+ wraps=self.secondary_tier1.on_request_finished
686
+ )
687
+ self.secondary_tier2.on_request_finished = MagicMock(
688
+ wraps=self.secondary_tier2.on_request_finished
689
+ )
690
+
691
+ self._start_request(ctx)
692
+ self.manager.prepare_store(initial_blocks, ctx)
693
+ assert self.manager._req_state[ctx.req_id].pending_primary_stores == 1
694
+
695
+ self.manager.reset_cache()
696
+
697
+ assert ctx.req_id in self.manager._req_state
698
+ assert self.manager._req_state[ctx.req_id].pending_primary_stores == 0
699
+ self.secondary_tier1.on_request_finished.assert_not_called()
700
+ self.secondary_tier2.on_request_finished.assert_not_called()
701
+
702
+ self.manager.prepare_store(resumed_blocks, ctx)
703
+ self.manager.complete_store(resumed_blocks, ctx, success=True)
704
+ self.manager.on_request_finished(ctx)
705
+
706
+ self.secondary_tier1.on_request_finished.assert_called_once_with(ctx)
707
+ self.secondary_tier2.on_request_finished.assert_called_once_with(ctx)
708
+ assert ctx.req_id not in self.manager._req_state
709
+
710
def test_on_new_request_lifecycle(self, manager_setup):
711
"""Policy defaults to BLOCK_LEVEL, escalates when a tier requests it,
712
and is cleaned up on on_request_finished."""
@@ -535,23 +714,25 @@ class TestTieringOffloadingManager:
714
ctx = ReqContext(req_id="req_policy_lifecycle")
715
result = self.manager.on_new_request(ctx)
716
assert result.policy == OffloadPolicy.BLOCK_LEVEL
717
+ assert self.manager._req_state[ctx.req_id].request_level_tiers is None
718
self.manager.on_request_finished(ctx)
719
+ assert ctx.req_id not in self.manager._req_state
720
721
# Escalate: tier1 requests REQUEST_LEVEL
541
- self.secondary_tier1.on_new_request = (
542
- lambda req_context: RequestOffloadingContext(
543
- policy=OffloadPolicy.REQUEST_LEVEL
544
- )
722
+ self.secondary_tier1.on_new_request = lambda req_context: (
723
+ RequestOffloadingContext(policy=OffloadPolicy.REQUEST_LEVEL)
724
)
725
726
ctx = ReqContext(req_id="req_policy_lifecycle_2")
727
result = self.manager.on_new_request(ctx)
728
assert result.policy == OffloadPolicy.REQUEST_LEVEL
550
- assert ctx.req_id in self.manager._request_level_tiers
729
+ assert self.manager._req_state[ctx.req_id].request_level_tiers == {
730
+ self.secondary_tier1
731
+ }
732
733
# Cleanup
734
self.manager.on_request_finished(ctx)
554
- assert ctx.req_id not in self.manager._request_level_tiers
735
+ assert ctx.req_id not in self.manager._req_state
736
737
def test_prepare_store_cascades_existing_blocks_to_request_level_tiers(
738
self, manager_setup
@@ -559,6 +740,7 @@ class TestTieringOffloadingManager:
740
"""prepare_store cascades hit blocks to request-level tiers only."""
741
# Store some blocks to primary first
742
existing_blocks = to_keys(range(3))
743
+ self._start_request()
744
result = self.manager.prepare_store(existing_blocks, _CTX)
745
assert result is not None
746
self.manager.complete_store(existing_blocks, _CTX, success=True)
@@ -566,10 +748,8 @@ class TestTieringOffloadingManager:
748
self._simulate_on_schedule_end()
749
750
# Make tier1 request-level, tier2 stays block-level
569
- self.secondary_tier1.on_new_request = (
570
- lambda req_context: RequestOffloadingContext(
571
- policy=OffloadPolicy.REQUEST_LEVEL
572
- )
751
+ self.secondary_tier1.on_new_request = lambda req_context: (
752
+ RequestOffloadingContext(policy=OffloadPolicy.REQUEST_LEVEL)
753
)
754
755
ctx = ReqContext(req_id="req_cascade")
@@ -599,14 +779,15 @@ class TestTieringOffloadingManager:
779
# tier2 (block-level) does not get existing blocks here.
780
self.secondary_tier2.submit_store.assert_not_called()
781
602
- def test_reset_cache_clears_all_state(self, manager_setup):
782
+ def test_reset_cache_clears_orchestrator_state(self, manager_setup):
783
"""reset_cache wipes every kind of orchestrator state and resets
784
primary tier; pending submissions are dropped without being sent
605
- to the secondary tier."""
785
+ to the secondary tier. Active request state is retained."""
786
# Cascade — populates primary blocks and leaves cascade jobs
787
# in _transfer_jobs (the synchronous example tier has already
788
# queued completions); reset_cache's drain loop will pick them up.
789
blocks = to_keys(range(3))
790
+ self._start_request()
791
self.manager.prepare_store(blocks, _CTX)
792
self.manager.complete_store(blocks, _CTX, success=True)
793
assert self.manager._transfer_jobs
@@ -619,13 +800,14 @@ class TestTieringOffloadingManager:
800
assert self.manager._pending_load_submissions
801
802
# Request-level tier registration.
622
- self.secondary_tier1.on_new_request = (
623
- lambda req_context: RequestOffloadingContext(
624
- policy=OffloadPolicy.REQUEST_LEVEL
625
- )
803
+ self.secondary_tier1.on_new_request = lambda req_context: (
804
+ RequestOffloadingContext(policy=OffloadPolicy.REQUEST_LEVEL)
805
)
627
- self.manager.on_new_request(ReqContext(req_id="rl"))
628
- assert self.manager._request_level_tiers
806
+ rl_ctx = ReqContext(req_id="rl")
807
+ self.manager.on_new_request(rl_ctx)
808
+ assert self.manager._req_state[rl_ctx.req_id].request_level_tiers == {
809
+ self.secondary_tier1
810
+ }
811
812
# Mark this step as already polled (reset_cache must clear it).
813
self.manager._processed_jobs_this_step = True
@@ -640,7 +822,7 @@ class TestTieringOffloadingManager:
822
# Orchestrator state cleared.
823
assert self.manager._transfer_jobs == {}
824
assert self.manager._pending_load_submissions == {}
643
- assert self.manager._request_level_tiers == {}
825
+ assert set(self.manager._req_state) == {_CTX.req_id, rl_ctx.req_id}
826
assert self.manager._processed_jobs_this_step is False
827
828
# Primary tier reset to a fresh state.
@@ -668,6 +850,7 @@ class TestTieringOffloadingManager:
850
851
# Drive a cascade so a job lands in _transfer_jobs.
852
blocks = to_keys(range(3))
853
+ self._start_request()
854
self.manager.prepare_store(blocks, _CTX)
855
self.manager.complete_store(blocks, _CTX, success=True)
856
assert self.manager._transfer_jobs
@@ -696,6 +879,7 @@ class TestTieringOffloadingWithoutSecondaryTiers:
879
blocks = to_keys(range(3))
880
881
# Should work like a regular OffloadingManager
882
+ manager.on_new_request(_CTX)
883
result = manager.prepare_store(blocks, _CTX)
884
assert result is not None
885
manager.complete_store(blocks, _CTX, success=True)
vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py
+11
-19
@@ -1140,11 +1140,6 @@ class OffloadingConnectorScheduler:
1140
del self._jobs[job_id]
1141
req_status.transfer_jobs.remove(job_id)
1142
if not req_status.transfer_jobs and req_status.req.is_finished():
1143
- # Deferred from request_finished: the request's last in-flight
1144
- # job is now done, so fire the finalize hook here, after the
1145
- # final complete_store/complete_load above (and any submit_store
1146
- # the complete_store cascade issued).
1147
- self.manager.on_request_finished(req_status.req_context)
1143
del self._req_status[job_status.req_id]
1144
1145
def get_stats(self) -> OffloadingConnectorStats | None:
@@ -1181,20 +1176,23 @@ class OffloadingConnectorScheduler:
1176
if req_status is None:
1177
# Untracked request (offloading never started): no in-flight jobs,
1178
# nothing was deferred, so finalize immediately.
1184
- self.manager.on_request_finished(_create_req_context(request))
1179
+ req_context = _create_req_context(request)
1180
+ self.manager.on_new_request(req_context)
1181
+ self.manager.on_request_finished(req_context)
1182
return False, None
1183
1184
+ self.manager.on_request_finished(req_status.req_context)
1185
+
1186
if not req_status.transfer_jobs:
1188
- # No in-flight jobs: all per-request calls are done, finalize now.
1189
- self.manager.on_request_finished(req_status.req_context)
1187
+ # No in-flight jobs: no later complete_store()/complete_load() calls
1188
+ # need this request's state.
1189
del self._req_status[request.request_id]
1190
return False, None
1191
1193
- # In-flight jobs remain, so defer on_request_finished to
1194
- # update_connector_output, which fires it once the last job completes
1195
- # (after the final complete_store and any cascade submit_store it
1196
- # issues). These pending stores outlive the request's block ownership;
1197
- # register them so future reuse of those blocks triggers a flush.
1192
+ # In-flight jobs remain after the request stopped. Their completion may
1193
+ # still call manager.complete_store()/complete_load(), so keep req_status.
1194
+ # Pending stores outlive the request's block ownership; register them so
1195
+ # future reuse of those blocks triggers a flush.
1196
for job_id in req_status.transfer_jobs:
1197
job_status = self._jobs[job_id]
1198
for bid in job_status.non_sliding_window_block_ids or ():
@@ -1225,14 +1223,8 @@ class OffloadingConnectorScheduler:
1223
# Flush all in-flight jobs
1224
self._current_batch_jobs_to_flush.update(self._jobs.keys())
1225
1228
- # A finished request may still be tracked here with in-flight jobs that
1229
- # this reset discards, so its deferred on_request_finished() would never
1230
- # fire (completions are skipped as stale) and its _req_status entry would
1231
- # leak. Finalize such requests now, before resetting the manager.
1232
- # list() snapshots because we delete while iterating.
1226
for req_id, status in list(self._req_status.items()):
1227
if status.req.is_finished():
1235
- self.manager.on_request_finished(status.req_context)
1228
del self._req_status[req_id]
1229
1230
# Reset offloading manager cache
vllm/v1/kv_offload/base.py
+10
-10
@@ -276,16 +276,16 @@ class OffloadingManager(ABC):
276
"""
277
Called when a request has finished.
278
279
- By the time this is called, all per-request offload calls for this
280
- request (prepare_store/complete_store, prepare_load/complete_load,
281
- touch, lookup) have already been issued, and none will follow. The
282
- scheduler defers this call until the request is finished and has no
283
- in-flight transfer jobs.
284
-
285
- Note this signals only that no further calls will be made; it does NOT
286
- imply the data has been persisted. Asynchronous transfers already
287
- submitted for this request (e.g. CPU->secondary cascades) may still be
288
- in flight. This is the right place to release per-request bookkeeping.
279
+ By the time this is called, the scheduler will issue no more
280
+ submit-side calls for this request, such as prepare_store() and
281
+ prepare_load(). Completion callbacks for already-submitted transfers
282
+ (complete_store() and complete_load()) may still arrive afterward.
283
+
284
+ This hook does NOT imply the data has been persisted. Asynchronous
285
+ transfers already submitted for this request may still be in flight.
286
+ Managers that cascade to lower tiers should delay those tiers'
287
+ on_request_finished() calls until no more lower-tier submit calls can
288
+ be issued for this request.
289
290
Args:
291
req_context: per-request context.
vllm/v1/kv_offload/tiering/manager.py
+84
-40
@@ -20,7 +20,6 @@ Key Design Principles:
20
protecting blocks from eviction until complete_read() is called
21
"""
22
23
-from collections import defaultdict
23
from collections.abc import Collection, Iterable, Sequence
24
from dataclasses import dataclass, field
25
@@ -62,6 +61,14 @@ class PendingPromotion:
61
block_ids: list[int] = field(default_factory=list)
62
63
64
+@dataclass(slots=True)
65
+class RequestState:
66
+ req_context: ReqContext
67
+ pending_primary_stores: int = 0
68
+ is_finished: bool = False
69
+ request_level_tiers: set[SecondaryTierManager] | None = None
70
+
71
+
72
class CPUPrimaryTierOffloadingManager(CPUOffloadingManager):
73
"""CPUOffloadingManager with a primary/secondary transfer interface.
74
@@ -166,12 +173,10 @@ class TieringOffloadingManager(OffloadingManager):
173
# Reset at the end of each step in on_schedule_end().
174
self._processed_jobs_this_step: bool = False
175
169
- # Per-request set of secondary tiers that requested REQUEST_LEVEL
170
- # policy. Populated in on_new_request(),
171
- # cleaned up in on_request_finished().
172
- self._request_level_tiers: defaultdict[str, set[SecondaryTierManager]] = (
173
- defaultdict(set)
174
- )
176
+ # Per-request state for prepared GPU->primary stores and finalization.
177
+ # Secondary tiers are finalized only after pending primary stores reach
178
+ # complete_store(), since complete_store() can still submit cascades.
179
+ self._req_state: dict[str, RequestState] = {}
180
181
def _next_job_id(self) -> JobId:
182
"""Generate a unique job ID for async transfer tracking."""
@@ -432,9 +437,13 @@ class TieringOffloadingManager(OffloadingManager):
437
if primary_result is None:
438
return None
439
440
+ if primary_result.keys_to_store:
441
+ state = self._req_state[req_context.req_id]
442
+ state.pending_primary_stores += 1
443
+
444
# Step 3: For request-level tiers, cascade blocks already in primary
436
- request_level_tiers = self._request_level_tiers.get(req_context.req_id)
437
- if request_level_tiers is not None:
445
+ request_level_tiers = self._req_state[req_context.req_id].request_level_tiers
446
+ if request_level_tiers:
447
keys_to_store_set = set(primary_result.keys_to_store)
448
keys_already_in_primary = tuple(
449
k for k in keys if k not in keys_to_store_set
@@ -508,36 +517,38 @@ class TieringOffloadingManager(OffloadingManager):
517
# Step 1: Complete store in primary tier (makes blocks loadable)
518
self.primary_tier.complete_store(keys, req_context, success)
519
511
- if not success:
512
- # If GPU→Primary transfer failed, don't cascade to secondary tiers
513
- return
514
-
515
- # Step 2: Cascade to ALL secondary tiers
516
- # For each secondary tier, call primary.prepare_read() to get the
517
- # LoadStoreSpec AND to increment ref_cnt (protecting blocks from
518
- # eviction during the async transfer). One prepare_read() call per
519
- # secondary tier.
520
- for tier in self.secondary_tiers:
521
- primary_blocks_spec = self.primary_tier.prepare_read(keys, req_context)
520
+ if success:
521
+ # Step 2: Cascade to ALL secondary tiers
522
+ # For each secondary tier, call primary.prepare_read() to get the
523
+ # LoadStoreSpec AND to increment ref_cnt (protecting blocks from
524
+ # eviction during the async transfer). One prepare_read() call per
525
+ # secondary tier.
526
+ for tier in self.secondary_tiers:
527
+ primary_blocks_spec = self.primary_tier.prepare_read(keys, req_context)
528
523
- # Submit async store job: primary→secondary
524
- job_id = self._next_job_id()
529
+ # Submit async store job: primary→secondary
530
+ job_id = self._next_job_id()
531
526
- # Track this store job
527
- assert isinstance(primary_blocks_spec, CPULoadStoreSpec)
528
- job_metadata = JobMetadata(
529
- job_id=job_id,
530
- keys=keys,
531
- block_ids=primary_blocks_spec.block_ids,
532
- is_promotion=False,
533
- req_context=req_context,
534
- )
535
- self._transfer_jobs[job_id] = job_metadata
532
+ # Track this store job
533
+ assert isinstance(primary_blocks_spec, CPULoadStoreSpec)
534
+ job_metadata = JobMetadata(
535
+ job_id=job_id,
536
+ keys=keys,
537
+ block_ids=primary_blocks_spec.block_ids,
538
+ is_promotion=False,
539
+ req_context=req_context,
540
+ )
541
+ self._transfer_jobs[job_id] = job_metadata
542
537
- tier.submit_store(job_metadata)
543
+ tier.submit_store(job_metadata)
544
545
# Note: The async transfers are now in flight. Their completion is
546
# tracked via get_finished_jobs() / _maybe_process_finished_jobs().
547
+ req_id = req_context.req_id
548
+ state = self._req_state[req_id]
549
+ assert state.pending_primary_stores > 0
550
+ state.pending_primary_stores -= 1
551
+ self._maybe_finalize_request(req_id)
552
553
@override
554
def on_new_request(self, req_context: ReqContext) -> RequestOffloadingContext:
@@ -547,14 +558,18 @@ class TieringOffloadingManager(OffloadingManager):
558
Returns REQUEST_LEVEL if ANY secondary tier wants request-level.
559
Only stores REQUEST_LEVEL tier decisions for use in prepare_store.
560
"""
561
+ state = RequestState(req_context=req_context)
562
for tier in self.secondary_tiers:
563
tier_ctx = tier.on_new_request(req_context)
564
if tier_ctx.policy == OffloadPolicy.REQUEST_LEVEL:
553
- self._request_level_tiers[req_context.req_id].add(tier)
565
+ if state.request_level_tiers is None:
566
+ state.request_level_tiers = set()
567
+ state.request_level_tiers.add(tier)
568
+ self._req_state[req_context.req_id] = state
569
570
policy = (
571
OffloadPolicy.REQUEST_LEVEL
557
- if req_context.req_id in self._request_level_tiers
572
+ if state.request_level_tiers
573
else OffloadPolicy.BLOCK_LEVEL
574
)
575
return RequestOffloadingContext(policy=policy)
@@ -562,9 +577,26 @@ class TieringOffloadingManager(OffloadingManager):
577
@override
578
def on_request_finished(self, req_context: ReqContext) -> None:
579
self.primary_tier.on_request_finished(req_context)
580
+ state = self._req_state[req_context.req_id]
581
+ state.is_finished = True
582
+ self._maybe_finalize_request(req_context.req_id)
583
+
584
+ def _maybe_finalize_request(self, req_id: str) -> None:
585
+ """Finalize secondary tiers once no more store cascades can be submitted.
586
+
587
+ Finalization means forwarding on_request_finished() to secondary tiers.
588
+ It is delayed until pending GPU->primary stores finish, since their
589
+ complete_store() callbacks may still submit primary->secondary stores.
590
+ """
591
+ state = self._req_state[req_id]
592
+ if not state.is_finished:
593
+ return
594
+ if state.pending_primary_stores != 0:
595
+ return
596
+
597
for tier in self.secondary_tiers:
566
- tier.on_request_finished(req_context)
567
- self._request_level_tiers.pop(req_context.req_id, None)
598
+ tier.on_request_finished(state.req_context)
599
+ del self._req_state[req_id]
600
601
@override
602
def on_schedule_end(self) -> None:
@@ -604,7 +636,7 @@ class TieringOffloadingManager(OffloadingManager):
636
637
@override
638
def reset_cache(self) -> None:
607
- """Drop all tracked state in the orchestrator and primary tier.
639
+ """Reset transfer bookkeeping and primary-tier cache.
640
641
Called during sleep, weight update, or resume. Each secondary tier
642
drains its in-flight transfers via drain_jobs() so no tier I/O is
@@ -613,7 +645,9 @@ class TieringOffloadingManager(OffloadingManager):
645
from reusing primary slots while a transfer is mid-copy.
646
647
Secondary tiers are intentionally not reset: persistent stores
616
- (FS, network) keep their data across resets.
648
+ (FS, network) keep their data across resets. Active request state is
649
+ retained so those requests can continue after the reset; finished
650
+ requests are finalized and removed.
651
"""
652
for tier in self.secondary_tiers:
653
tier.drain_jobs()
@@ -626,9 +660,19 @@ class TieringOffloadingManager(OffloadingManager):
660
# called so no tier I/O is touching that memory.
661
self._pending_load_submissions.clear()
662
663
+ finished_req_ids = []
664
+ for req_id, state in self._req_state.items():
665
+ state.pending_primary_stores = 0
666
+ if not state.is_finished:
667
+ continue
668
+ for tier in self.secondary_tiers:
669
+ tier.on_request_finished(state.req_context)
670
+ finished_req_ids.append(req_id)
671
+
672
self.primary_tier.reset_cache()
673
631
- self._request_level_tiers.clear()
674
+ for req_id in finished_req_ids:
675
+ del self._req_state[req_id]
676
self._processed_jobs_this_step = False
677
678
@override