[KV Offload] Add `ParentManager` ABC for secondary tier callbacks (#47274)
Signed-off-by: Ronen Schaffer <ronen.schaffer@ibm.com> Co-authored-by: Or Ozeri <oro@il.ibm.com>
Ronen Schaffer committed
Jul 7, 2026 at 13:51 UTC
48fcfc926c0a6e62a8f46557a59163702b7403b9
2 files changed
+169
-44
vllm/v1/kv_offload/tiering/base.py
+48
-2
@@ -49,6 +49,43 @@ class JobResult:
49
success: bool
50
51
52
+class ParentManager(ABC):
53
+ """Interface for secondary tiers to call back into the tiering manager.
54
+
55
+ Passed to secondary tiers via serve_external_requests() each step.
56
+ The _SecondaryTierFacingParent wrapper implements this, automatically
57
+ excluding the calling tier from fan-out operations.
58
+
59
+ Required call sequence for each remote request:
60
+ 1. on_new_request(req_context) — set up per-request state
61
+ 2. lookup(key, req_context) — check block availability
62
+ (repeat per block)
63
+ 3. create_store_job(keys, req_context) — pin blocks and get a
64
+ job handle
65
+ 4. on_request_finished(req_context) — clean up per-request state
66
+
67
+ Steps 2-3 may be interleaved. Step 4 must be called even if no
68
+ blocks were found, to avoid leaking async lookup state (e.g. in
69
+ the fs tier's AsyncLookupManager).
70
+ """
71
+
72
+ @abstractmethod
73
+ def on_new_request(self, req_context: ReqContext) -> RequestOffloadingContext: ...
74
+
75
+ @abstractmethod
76
+ def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult: ...
77
+
78
+ @abstractmethod
79
+ def create_store_job(
80
+ self,
81
+ keys: Collection[OffloadKey],
82
+ req_context: ReqContext,
83
+ ) -> JobMetadata: ...
84
+
85
+ @abstractmethod
86
+ def on_request_finished(self, req_context: ReqContext) -> None: ...
87
+
88
+
89
class SecondaryTierManager(ABC):
90
"""
91
Abstract interface for managing a single non-primary offloading tier.
@@ -210,11 +247,20 @@ class SecondaryTierManager(ABC):
247
"""
248
return
249
250
+ def serve_external_requests(self, parent: ParentManager) -> None:
251
+ """Process remotely-originated requests using the parent manager.
252
+
253
+ Called once per scheduler step, BEFORE _flush_pending_promotions().
254
+ The parent handle is valid only for the duration of this call.
255
+ Tiers that don't serve external requests leave this as a no-op.
256
+ """
257
+ return
258
+
259
def on_schedule_end(self, context: ScheduleEndContext) -> None:
260
"""Called once at the end of each scheduler step.
261
216
- Secondary tiers may override this for per-step cleanup or
217
- deferred work submission.
262
+ Args:
263
+ context: Per-step context from the scheduler.
264
"""
265
return
266
vllm/v1/kv_offload/tiering/manager.py
+121
-42
@@ -48,6 +48,7 @@ from vllm.v1.kv_offload.cpu.shared_offload_region import SharedOffloadRegion
48
from vllm.v1.kv_offload.tiering.base import (
49
JobId,
50
JobMetadata,
51
+ ParentManager,
52
SecondaryTierManager,
53
)
54
@@ -120,6 +121,35 @@ class CPUPrimaryTierOffloadingManager(CPUOffloadingManager):
121
self._mmap_region.cleanup()
122
123
124
+class _SecondaryTierFacingParent(ParentManager):
125
+ """Wrapper that implements ParentManager by delegating to the
126
+ TieringOffloadingManager with exclude_tier set to the origin tier."""
127
+
128
+ __slots__ = ("_m", "_origin")
129
+
130
+ def __init__(
131
+ self,
132
+ manager: "TieringOffloadingManager",
133
+ tier: SecondaryTierManager,
134
+ ):
135
+ self._m = manager
136
+ self._origin = tier
137
+
138
+ def on_new_request(self, req_context: ReqContext) -> RequestOffloadingContext:
139
+ return self._m.on_new_request(req_context, exclude_tier=self._origin)
140
+
141
+ def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult:
142
+ return self._m.lookup(key, req_context, exclude_tier=self._origin)
143
+
144
+ def create_store_job(
145
+ self, keys: Collection[OffloadKey], req_context: ReqContext
146
+ ) -> JobMetadata:
147
+ return self._m.create_store_job(keys, req_context)
148
+
149
+ def on_request_finished(self, req_context: ReqContext) -> None:
150
+ return self._m.on_request_finished(req_context, exclude_tier=self._origin)
151
+
152
+
153
class TieringOffloadingManager(OffloadingManager):
154
"""
155
Orchestrates multi-tier KV cache offloading.
@@ -180,6 +210,12 @@ class TieringOffloadingManager(OffloadingManager):
210
# complete_store(), since complete_store() can still submit cascades.
211
self._req_state: dict[str, RequestState] = {}
212
213
+ # Cached ParentManager wrappers for each secondary tier.
214
+ self._tier_parents: dict[SecondaryTierManager, _SecondaryTierFacingParent] = {
215
+ tier: _SecondaryTierFacingParent(self, tier)
216
+ for tier in self.secondary_tiers
217
+ }
218
+
219
def _next_job_id(self) -> JobId:
220
"""Generate a unique job ID for async transfer tracking."""
221
job_id = self._job_id_counter
@@ -235,7 +271,13 @@ class TieringOffloadingManager(OffloadingManager):
271
)
272
273
@override
238
- def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult:
274
+ def lookup(
275
+ self,
276
+ key: OffloadKey,
277
+ req_context: ReqContext,
278
+ *,
279
+ exclude_tier: SecondaryTierManager | None = None,
280
+ ) -> LookupResult:
281
"""
282
Check whether a single block is offloaded and ready.
283
@@ -257,6 +299,10 @@ class TieringOffloadingManager(OffloadingManager):
299
MISS — block not found in any tier, or primary is full
300
and cannot accept a promotion.
301
"""
302
+ # Poll first so a promotion that finished since the last call is
303
+ # already reflected as HIT (not stale HIT_PENDING/MISS) below, and
304
+ # so blocks freed by cascade or promotion completions are evictable
305
+ # in time for a promotion this lookup may initiate.
306
self._maybe_process_finished_jobs()
307
308
primary_hit = self.primary_tier.lookup(key, req_context)
@@ -267,6 +313,8 @@ class TieringOffloadingManager(OffloadingManager):
313
314
any_retry = False
315
for tier in self.secondary_tiers:
316
+ if tier is exclude_tier:
317
+ continue
318
result = tier.lookup(key, req_context)
319
if result is LookupResult.HIT:
320
if not self._initiate_promotion(tier, key, req_context):
@@ -359,8 +407,8 @@ class TieringOffloadingManager(OffloadingManager):
407
"""
408
Prepare blocks to be loaded from primary tier to GPU.
409
362
- CRITICAL: This method calls _maybe_process_finished_jobs() FIRST to ensure
363
- that any completed promotions have been finalized and blocks are ready.
410
+ Callers only pass keys already confirmed HIT by lookup() earlier this
411
+ step.
412
413
This increments ref_cnt on the blocks in the primary tier, protecting
414
them from eviction during the transfer.
@@ -372,9 +420,6 @@ class TieringOffloadingManager(OffloadingManager):
420
Returns:
421
LoadStoreSpec for reading from primary tier.
422
"""
375
- # Process completed promotions to ensure blocks are ready
376
- self._maybe_process_finished_jobs()
377
-
423
return self.primary_tier.prepare_load(keys, req_context)
424
425
@override
@@ -427,8 +472,15 @@ class TieringOffloadingManager(OffloadingManager):
472
evicted, or None if store cannot proceed.
473
"""
474
# Step 1: Poll for completed async jobs FIRST
430
- # This decrements ref_cnt on primary blocks that have been
431
- # successfully transferred to secondary tiers.
475
+ # _process_finished_jobs() handles two kinds of completions here:
476
+ # - Cascade completions (store to a secondary tier, either a local
477
+ # cascade or a store job created for a remote requester via
478
+ # create_store_job()): decrements ref_cnt on the primary blocks
479
+ # that were read, making them evictable again once ref_cnt hits 0.
480
+ # - Promotion completions (secondary->primary loads): sets a
481
+ # not-yet-ready block's ref_cnt from -1 to 0 via complete_write(),
482
+ # making it evictable for the first time.
483
+ # Both must be accounted for before the eviction decision below.
484
self._maybe_process_finished_jobs()
485
486
# Step 2: Store to primary tier (new blocks only).
@@ -478,20 +530,7 @@ class TieringOffloadingManager(OffloadingManager):
530
return
531
532
for tier in request_level_tiers:
481
- primary_blocks_spec = self.primary_tier.prepare_read(
482
- ready_keys, req_context
483
- )
484
-
485
- job_id = self._next_job_id()
486
- assert isinstance(primary_blocks_spec, CPULoadStoreSpec)
487
- job_metadata = JobMetadata(
488
- job_id=job_id,
489
- keys=ready_keys,
490
- block_ids=primary_blocks_spec.block_ids,
491
- is_promotion=False,
492
- req_context=req_context,
493
- )
494
- self._transfer_jobs[job_id] = job_metadata
533
+ job_metadata = self.create_store_job(ready_keys, req_context)
534
tier.submit_store(job_metadata)
535
536
@override
@@ -529,22 +568,7 @@ class TieringOffloadingManager(OffloadingManager):
568
# eviction during the async transfer). One prepare_read() call per
569
# secondary tier.
570
for tier in self.secondary_tiers:
532
- primary_blocks_spec = self.primary_tier.prepare_read(keys, req_context)
533
-
534
- # Submit async store job: primary→secondary
535
- job_id = self._next_job_id()
536
-
537
- # Track this store job
538
- assert isinstance(primary_blocks_spec, CPULoadStoreSpec)
539
- job_metadata = JobMetadata(
540
- job_id=job_id,
541
- keys=keys,
542
- block_ids=primary_blocks_spec.block_ids,
543
- is_promotion=False,
544
- req_context=req_context,
545
- )
546
- self._transfer_jobs[job_id] = job_metadata
547
-
571
+ job_metadata = self.create_store_job(keys, req_context)
572
tier.submit_store(job_metadata)
573
574
# Note: The async transfers are now in flight. Their completion is
@@ -555,8 +579,40 @@ class TieringOffloadingManager(OffloadingManager):
579
state.pending_primary_stores -= 1
580
self._maybe_finalize_request(req_id)
581
582
+ def create_store_job(
583
+ self,
584
+ keys: Collection[OffloadKey],
585
+ req_context: ReqContext,
586
+ ) -> JobMetadata:
587
+ """Pin blocks in the primary tier and create a tracked store job.
588
+
589
+ Calls prepare_read() to increment ref_cnt (protecting blocks
590
+ from eviction during the async transfer), allocates a job ID,
591
+ and registers the job in _transfer_jobs.
592
+
593
+ The caller is responsible for the actual data transfer and
594
+ reporting completion via get_finished_jobs().
595
+ """
596
+ primary_blocks_spec = self.primary_tier.prepare_read(keys, req_context)
597
+ assert isinstance(primary_blocks_spec, CPULoadStoreSpec)
598
+ job_id = self._next_job_id()
599
+ job_metadata = JobMetadata(
600
+ job_id=job_id,
601
+ keys=keys,
602
+ block_ids=primary_blocks_spec.block_ids,
603
+ is_promotion=False,
604
+ req_context=req_context,
605
+ )
606
+ self._transfer_jobs[job_id] = job_metadata
607
+ return job_metadata
608
+
609
@override
559
- def on_new_request(self, req_context: ReqContext) -> RequestOffloadingContext:
610
+ def on_new_request(
611
+ self,
612
+ req_context: ReqContext,
613
+ *,
614
+ exclude_tier: SecondaryTierManager | None = None,
615
+ ) -> RequestOffloadingContext:
616
"""
617
Query each secondary tier for its offload policy preference.
618
@@ -565,6 +621,8 @@ class TieringOffloadingManager(OffloadingManager):
621
"""
622
state = RequestState(req_context=req_context)
623
for tier in self.secondary_tiers:
624
+ if tier is exclude_tier:
625
+ continue
626
tier_ctx = tier.on_new_request(req_context)
627
if tier_ctx.policy == OffloadPolicy.REQUEST_LEVEL:
628
if state.request_level_tiers is None:
@@ -580,13 +638,22 @@ class TieringOffloadingManager(OffloadingManager):
638
return RequestOffloadingContext(policy=policy)
639
640
@override
583
- def on_request_finished(self, req_context: ReqContext) -> None:
641
+ def on_request_finished(
642
+ self,
643
+ req_context: ReqContext,
644
+ *,
645
+ exclude_tier: SecondaryTierManager | None = None,
646
+ ) -> None:
647
self.primary_tier.on_request_finished(req_context)
648
state = self._req_state[req_context.req_id]
649
state.is_finished = True
587
- self._maybe_finalize_request(req_context.req_id)
650
+ self._maybe_finalize_request(req_context.req_id, exclude_tier)
651
589
- def _maybe_finalize_request(self, req_id: str) -> None:
652
+ def _maybe_finalize_request(
653
+ self,
654
+ req_id: str,
655
+ exclude_tier: SecondaryTierManager | None = None,
656
+ ) -> None:
657
"""Finalize secondary tiers once no more store cascades can be submitted.
658
659
Finalization means forwarding on_request_finished() to secondary tiers.
@@ -600,6 +667,8 @@ class TieringOffloadingManager(OffloadingManager):
667
return
668
669
for tier in self.secondary_tiers:
670
+ if tier is exclude_tier:
671
+ continue
672
tier.on_request_finished(state.req_context)
673
del self._req_state[req_id]
674
@@ -611,8 +680,18 @@ class TieringOffloadingManager(OffloadingManager):
680
Called once per scheduler step from
681
OffloadingConnectorScheduler.build_connector_meta().
682
"""
683
+ # Catch-all poll: guarantees jobs are processed even on steps where
684
+ # lookup()/prepare_store() were never called (e.g. no requests
685
+ # scheduled but a tier still has_pending_work()).
686
self._maybe_process_finished_jobs()
687
+
688
+ for tier in self.secondary_tiers:
689
+ tier.serve_external_requests(self._tier_parents[tier])
690
+
691
+ # Reset the per-step gate AFTER serve_external_requests so that
692
+ # lookup() calls within it skip redundant _process_finished_jobs().
693
self._processed_jobs_this_step = False
694
+
695
self._flush_pending_promotions()
696
for tier in self.secondary_tiers:
697
tier.on_schedule_end(context)