A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 2, 06:00 UTC
[KV Offload] Add `on_schedule_end()` hook to separate step lifecycle from event draining (#44206)
Signed-off-by: Ronen Schaffer <ronen.schaffer@ibm.com>
Ronen Schaffer committed
Jun 2, 2026 at 13:42 UTC
2a2b5ca7919014cb851bac690fc99d356520254b
5 files changed
+54
-39
tests/v1/kv_offload/test_tiering_offloading.py
+16
-12
@@ -123,6 +123,11 @@ class TestTieringOffloadingManager:
123
secondary_tiers=[self.secondary_tier1, self.secondary_tier2],
124
)
125
126
+ def _simulate_on_schedule_end(self):
127
+ """Simulate end of scheduler step: lifecycle flush + drain events."""
128
+ self.manager.on_schedule_end()
129
+ list(self.manager.take_events())
130
+
131
def test_basic_store_to_primary(self, manager_setup):
132
"""Test basic store operation to primary tier."""
133
blocks = to_keys(range(3))
@@ -185,10 +190,10 @@ class TestTieringOffloadingManager:
190
assert block.ref_cnt == 2
191
192
# End of step 1: _maybe_process_finished_jobs() was already called by
188
- # prepare_store() above (setting the per-step flag), so take_events()
193
+ # prepare_store() above (setting the per-step flag), so on_schedule_end()
194
# does NOT poll get_finished_jobs() again — cascade completions remain
195
# unprocessed until the next step.
191
- list(self.manager.take_events())
196
+ self._simulate_on_schedule_end()
197
198
# ref_cnt still held: cascade jobs finished (sync tier) but haven't
199
# been polled yet because the per-step guard skipped the second call.
@@ -202,7 +207,7 @@ class TestTieringOffloadingManager:
207
208
# End of step 2: flag was reset, so _maybe_process_finished_jobs()
209
# runs and processes the cascade completions (complete_read → ref_cnt--)
205
- list(self.manager.take_events())
210
+ self._simulate_on_schedule_end()
211
212
# After cascade completes, ref_cnt should be 0
213
for block_hash in blocks:
@@ -238,10 +243,10 @@ class TestTieringOffloadingManager:
243
assert result is None # Retry later (promotion initiated)
244
245
# End of step 1: flushes deferred submit_load() calls
241
- list(self.manager.take_events())
246
+ self._simulate_on_schedule_end()
247
248
# End of step 2: processes the completed promotion jobs
244
- list(self.manager.take_events())
249
+ self._simulate_on_schedule_end()
250
251
# Now blocks should be in primary tier
252
assert count_hits(self.primary_tier, blocks) == 3
@@ -271,7 +276,7 @@ class TestTieringOffloadingManager:
276
self.manager.complete_store(blocks, _CTX, success=True)
277
278
# End of step: release ref_cnt from cascade
274
- list(self.manager.take_events())
279
+ self._simulate_on_schedule_end()
280
281
# Now try to store 2 more blocks (should trigger eviction)
282
more_blocks = to_keys(range(5, 7))
@@ -289,7 +294,7 @@ class TestTieringOffloadingManager:
294
# Store blocks
295
self.manager.prepare_store(blocks, _CTX)
296
self.manager.complete_store(blocks, _CTX, success=True)
292
- list(self.manager.take_events())
297
+ self._simulate_on_schedule_end()
298
299
self.secondary_tier1.touch = MagicMock(wraps=self.secondary_tier1.touch)
300
self.secondary_tier2.touch = MagicMock(wraps=self.secondary_tier2.touch)
@@ -328,7 +333,7 @@ class TestTieringOffloadingManager:
333
self.secondary_tier2.submit_store.assert_not_called()
334
335
def test_lookup_batches_submit_load_per_request(self, manager_setup):
331
- """lookup() defers submit_load until take_events(), one call per request.
336
+ """lookup() defers submit_load until on_schedule_end(), one per request.
337
338
Blocks from different requests each get their own submit_load call, each
339
carrying the correct req_context.
@@ -354,7 +359,7 @@ class TestTieringOffloadingManager:
359
self.secondary_tier1.submit_load.assert_not_called()
360
361
# simulate end of step
357
- list(self.manager.take_events())
362
+ self._simulate_on_schedule_end()
363
364
assert self.secondary_tier1.submit_load.call_count == 2
365
calls = self.secondary_tier1.submit_load.call_args_list
@@ -389,7 +394,7 @@ class TestTieringOffloadingManager:
394
assert result_a is None
395
assert result_b is None
396
392
- list(self.manager.take_events())
397
+ self._simulate_on_schedule_end()
398
399
# Only one submit_load call despite two lookups
400
self.secondary_tier1.submit_load.assert_called_once()
@@ -449,8 +454,7 @@ class TestTieringOffloadingManager:
454
assert result is not None
455
self.manager.complete_store(existing_blocks, _CTX, success=True)
456
# Drain cascade completions
452
- list(self.manager.take_events())
453
- list(self.manager.take_events())
457
+ self._simulate_on_schedule_end()
458
459
# Make tier1 request-level, tier2 stays block-level
460
self.secondary_tier1.on_new_request = (
vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py
+1
@@ -897,6 +897,7 @@ class OffloadingConnectorScheduler:
897
self, scheduler_output: SchedulerOutput
898
) -> KVConnectorMetadata:
899
self._update_req_states(scheduler_output)
900
+ self.manager.on_schedule_end()
901
902
# Flush jobs for preempted requests.
903
for req_id in scheduler_output.preempted_req_ids or ():
vllm/v1/kv_offload/base.py
+8
@@ -257,6 +257,14 @@ class OffloadingManager(ABC):
257
"""
258
return ()
259
260
+ def on_schedule_end(self) -> None:
261
+ """Called once at the end of each scheduler step.
262
+
263
+ Managers may override this to flush deferred work accumulated
264
+ during the step (e.g., batched promotions).
265
+ """
266
+ return
267
+
268
def reset_cache(self) -> None:
269
"""Evict all tracked blocks and reset internal state."""
270
return
vllm/v1/kv_offload/tiering/base.py
+8
@@ -185,6 +185,14 @@ class SecondaryTierManager(ABC):
185
"""
186
return
187
188
+ def on_schedule_end(self) -> None:
189
+ """Called once at the end of each scheduler step.
190
+
191
+ Secondary tiers may override this for per-step cleanup or
192
+ deferred work submission.
193
+ """
194
+ return
195
+
196
def shutdown(self) -> None:
197
"""Release resources held by this tier (threads, connections, etc.)."""
198
return
vllm/v1/kv_offload/tiering/manager.py
+21
-27
@@ -152,7 +152,7 @@ class TieringOffloadingManager(OffloadingManager):
152
self._transfer_jobs: dict[JobId, JobMetadata] = {}
153
154
# Pending promotion requests accumulated during lookup() calls; flushed
155
- # as one batched submit_load() per (tier, request) in take_events().
155
+ # as one batched submit_load() per (tier, request) in on_schedule_end().
156
# Outer key: tier. Inner key: req_context.req_id — the same ReqContext
157
# object is reused for all block lookups of a given request per engine step.
158
self._pending_load_submissions: dict[
@@ -160,7 +160,7 @@ class TieringOffloadingManager(OffloadingManager):
160
] = {}
161
162
# Gate for once-per-step execution of _maybe_process_finished_jobs().
163
- # Reset at the end of each step in take_events().
163
+ # Reset at the end of each step in on_schedule_end().
164
self._processed_jobs_this_step: bool = False
165
166
# Per-request set of secondary tiers that requested REQUEST_LEVEL
@@ -182,7 +182,7 @@ class TieringOffloadingManager(OffloadingManager):
182
183
Guarded by _processed_jobs_this_step: the first call in an engine step
184
does the actual polling; subsequent calls are no-ops. The flag is reset
185
- in take_events() at the end of each step.
185
+ in on_schedule_end() at the end of each step.
186
"""
187
if self._processed_jobs_this_step:
188
return
@@ -304,8 +304,8 @@ class TieringOffloadingManager(OffloadingManager):
304
305
store_spec = primary_write_result.store_spec
306
assert isinstance(store_spec, CPULoadStoreSpec)
307
- # Defer submit_load to take_events(). Group by (tier, request) so each
308
- # request's blocks are submitted as one batched job per tier.
307
+ # Defer submit_load to on_schedule_end(). Group by (tier, request) so
308
+ # each request's blocks are submitted as one batched job per tier.
309
tier_pending = self._pending_load_submissions.setdefault(tier, {})
310
ctx_id = req_context.req_id
311
if ctx_id not in tier_pending:
@@ -320,8 +320,8 @@ class TieringOffloadingManager(OffloadingManager):
320
def _flush_pending_promotions(self) -> None:
321
"""Submit one batched submit_load() per (tier, request).
322
323
- Called from take_events() at the end of each engine step, flushing
324
- all promotion requests deferred during lookup().
323
+ Called from on_schedule_end() at the end of each scheduler step,
324
+ flushing all promotion requests deferred during lookup().
325
"""
326
if not self._pending_load_submissions:
327
return
@@ -564,32 +564,26 @@ class TieringOffloadingManager(OffloadingManager):
564
self._request_level_tiers.pop(req_context.req_id, None)
565
566
@override
567
- def take_events(self) -> Iterable[OffloadingEvent]:
568
- """
569
- End-of-step hook: flush deferred work, yield events, reset per-step state.
567
+ def on_schedule_end(self) -> None:
568
+ """End-of-schedule hook: process finished jobs, flush deferred
569
+ promotions, and reset the per-step gate.
570
571
- Called once per engine step from Scheduler.update_from_output() →
572
- connector.take_events(). Ensures _maybe_process_finished_jobs() has run
573
- at least once this step, flushes pending promotions, yields collected
574
- events, and resets the per-step flag.
575
-
576
- Yields:
577
- New OffloadingEvents collected since the last call.
571
+ Called once per scheduler step from
572
+ OffloadingConnectorScheduler.build_connector_meta().
573
"""
579
- # TODO: Move _flush_pending_promotions() to a dedicated end_of_batch()
580
- # hook once one exists. For now, take_events() serves as the flush
581
- # point under the assumption that it is called at the end of each
582
- # engine step (Scheduler.update_from_output() → connector.take_events()).
583
- # When the dedicated hook is added, update tests that rely on
584
- # take_events() to signal end of step.
585
-
574
self._maybe_process_finished_jobs()
587
-
575
+ self._processed_jobs_this_step = False
576
self._flush_pending_promotions()
577
+ for tier in self.secondary_tiers:
578
+ tier.on_schedule_end()
579
590
- # Reset the per-step gate so next step's first call does real work.
591
- self._processed_jobs_this_step = False
580
+ @override
581
+ def take_events(self) -> Iterable[OffloadingEvent]:
582
+ """Yield offloading events collected since the last call.
583
584
+ Yields:
585
+ New OffloadingEvents collected since the last call.
586
+ """
587
if self.events is not None:
588
yield from self.events
589
self.events.clear()