[Bugfix][KV Offload] Defer request-level cascade of in-flight primary keys (#53329)
Signed-off-by: almogtavor <almogtavor@gmail.com>
Almog Tavor committed
Aug 26, 2026 at 01:20 UTC
bc39ded3c4e7b0453af76c617883e5cd88092222
2 files changed
+181
-8
tests/v1/kv_offload/tiering/test_tiering_offloading.py
+134
@@ -247,6 +247,35 @@ class TestExampleSecondaryTierManager:
247
assert tier.lookup(blocks[2], _CTX) is LookupResult.MISS
248
249
250
+# What a request-level cascade does with a key already present in the primary
251
+# tier, keyed by what primary lookup says about it. SUPPLY reaches the peer now,
252
+# DEFER is retried next step, DROP is never offered again, permanently so since
253
+# prepare_store counts the key as stored and the scheduler advances past its
254
+# chunk. A new LookupResult has to declare which it is, and the test named
255
+# beside it is where that behavior is actually exercised.
256
+_CASCADE_SUPPLY_BEHAVIOR = {
257
+ # test_cascade_defers_keys_whose_primary_write_is_in_flight, after the
258
+ # writer's complete_store lands.
259
+ LookupResult.HIT: "supply",
260
+ # The same test, before it lands.
261
+ LookupResult.HIT_PENDING: "defer",
262
+ # test_cascade_drops_deferred_keys_whose_primary_write_failed, where the
263
+ # failed write frees the block.
264
+ LookupResult.MISS: "drop",
265
+ # test_cascade_rejects_retry_from_primary. The primary tier resolves every
266
+ # key it holds, so RETRY cannot reach the cascade, and parking on it would
267
+ # have no guarantee of draining, which is what makes parking HIT_PENDING
268
+ # safe. It is the one case no real primary tier can produce.
269
+ LookupResult.RETRY: "unreachable",
270
+}
271
+
272
+
273
+def test_every_lookup_result_has_a_declared_cascade_behavior():
274
+ """A new LookupResult must state what the cascade does with it, rather
275
+ than falling into whichever branch happens to catch it."""
276
+ assert set(_CASCADE_SUPPLY_BEHAVIOR) == set(LookupResult)
277
+
278
+
279
class TestTieringOffloadingManager:
280
"""Tests for TieringOffloadingManager."""
281
@@ -1079,6 +1108,111 @@ class TestTieringOffloadingManager:
1108
# tier2 (block-level) does not get existing blocks here.
1109
self.secondary_tier2.submit_store.assert_not_called()
1110
1111
+ def _make_request_level_request(self, req_id: str) -> ReqContext:
1112
+ """Start a request for which tier1 asks for request-level offloading,
1113
+ with tier1's submit_store wrapped so the cascade can be observed."""
1114
+ self.secondary_tier1.on_new_request = lambda req_context: (
1115
+ RequestOffloadingContext(policy=OffloadPolicy.REQUEST_LEVEL)
1116
+ )
1117
+ ctx = ReqContext(req_id=req_id)
1118
+ self.manager.on_new_request(ctx)
1119
+ self.secondary_tier1.submit_store = MagicMock(
1120
+ wraps=self.secondary_tier1.submit_store
1121
+ )
1122
+ return ctx
1123
+
1124
+ def _cascaded_keys_for(self, req_id: str) -> list[set[OffloadKey]]:
1125
+ """Key sets tier1 was asked to store on behalf of `req_id`."""
1126
+ return [
1127
+ set(call.args[0].keys)
1128
+ for call in self.secondary_tier1.submit_store.call_args_list
1129
+ if call.args[0].req_context.req_id == req_id
1130
+ ]
1131
+
1132
+ def test_cascade_rejects_retry_from_primary(self, manager_setup):
1133
+ """RETRY would be parked with no guarantee of ever draining, so the
1134
+ cascade refuses it. No real primary tier returns it, so this is the one
1135
+ disposition that has to be forced."""
1136
+ keys = to_keys(range(3))
1137
+ self._start_request()
1138
+ assert self.manager.prepare_store(keys, _CTX) is not None
1139
+ self.manager.complete_store(keys, _CTX, success=True)
1140
+ self._simulate_on_schedule_end()
1141
+
1142
+ ctx = self._make_request_level_request("req_cascade")
1143
+ self.manager.primary_tier.lookup = lambda key, req_context: (LookupResult.RETRY)
1144
+
1145
+ with pytest.raises(AssertionError):
1146
+ self.manager._cascade_existing_blocks_to_request_level_tiers(keys, ctx, {0})
1147
+
1148
+ def _start_in_flight_primary_write(self, keys: list[OffloadKey]) -> ReqContext:
1149
+ """Leave a GPU->primary write for `keys` open, so they look present to
1150
+ prepare_store but are not yet readable."""
1151
+ writer_ctx = ReqContext(req_id="req_writer")
1152
+ self._start_request(writer_ctx)
1153
+ assert self.manager.prepare_store(keys, writer_ctx) is not None
1154
+ return writer_ctx
1155
+
1156
+ def test_cascade_defers_keys_whose_primary_write_is_in_flight(self, manager_setup):
1157
+ """A key another request is still writing must reach the peer once the
1158
+ write lands, not be dropped: prepare_store already counts it as stored
1159
+ and the scheduler advances past its chunk, so nothing re-offers it."""
1160
+ keys = to_keys(range(3))
1161
+ writer_ctx = self._start_in_flight_primary_write(keys)
1162
+
1163
+ ctx = self._make_request_level_request("req_cascade")
1164
+ result = self.manager.prepare_store(keys, ctx)
1165
+ assert result is not None
1166
+ assert not result.keys_to_store
1167
+ assert not self._cascaded_keys_for(ctx.req_id)
1168
+
1169
+ self.manager.complete_store(keys, writer_ctx, success=True)
1170
+ self._simulate_on_schedule_end()
1171
+
1172
+ assert self._cascaded_keys_for(ctx.req_id) == [set(keys)]
1173
+
1174
+ def test_deferred_cascade_holds_request_from_finalization(self, manager_setup):
1175
+ """A request that finishes with keys still deferred must stay alive, or
1176
+ its tiers are torn down before the peer is ever served."""
1177
+ keys = to_keys(range(3))
1178
+ writer_ctx = self._start_in_flight_primary_write(keys)
1179
+
1180
+ ctx = self._make_request_level_request("req_cascade")
1181
+ result = self.manager.prepare_store(keys, ctx)
1182
+ assert result is not None
1183
+ assert not result.keys_to_store
1184
+ self.manager.on_request_finished(ctx)
1185
+
1186
+ assert ctx.req_id in self.manager._req_state
1187
+ assert self.manager.has_pending_work()
1188
+
1189
+ self.manager.complete_store(keys, writer_ctx, success=True)
1190
+ self._simulate_on_schedule_end()
1191
+
1192
+ assert self._cascaded_keys_for(ctx.req_id) == [set(keys)]
1193
+ assert ctx.req_id not in self.manager._req_state
1194
+
1195
+ def test_cascade_drops_deferred_keys_whose_primary_write_failed(
1196
+ self, manager_setup
1197
+ ):
1198
+ """A failed write frees the block, so the deferred key resolves to MISS
1199
+ and the request finalizes instead of parking forever."""
1200
+ keys = to_keys(range(3))
1201
+ writer_ctx = self._start_in_flight_primary_write(keys)
1202
+
1203
+ ctx = self._make_request_level_request("req_cascade")
1204
+ result = self.manager.prepare_store(keys, ctx)
1205
+ assert result is not None
1206
+ assert not result.keys_to_store
1207
+ self.manager.on_request_finished(ctx)
1208
+
1209
+ self.manager.complete_store(keys, writer_ctx, success=False)
1210
+ self._simulate_on_schedule_end()
1211
+
1212
+ assert not self._cascaded_keys_for(ctx.req_id)
1213
+ assert ctx.req_id not in self.manager._req_state
1214
+ assert not self.manager.has_pending_work()
1215
+
1216
def test_reset_cache_clears_orchestrator_state(self, manager_setup):
1217
"""reset_cache wipes every kind of orchestrator state and resets
1218
primary tier; pending submissions are dropped without being sent
vllm/v1/kv_offload/tiering/manager.py
+47
-8
@@ -74,6 +74,7 @@ class RequestState:
74
pending_primary_stores: int = 0
75
is_finished: bool = False
76
request_level_tiers: set[int] | None = None
77
+ pending_cascade_keys: list[OffloadKey] = field(default_factory=list)
78
79
80
class JobMetadata(NamedTuple):
@@ -610,13 +611,29 @@ class TieringOffloadingManager(OffloadingManager):
611
"""
612
For tiers that requested request-level policy, submit_store() for
613
blocks that are already present in the primary tier.
614
+
615
+ A key whose primary write is still in flight (HIT_PENDING) cannot be
616
+ dropped: prepare_store already excluded it as present, and the
617
+ scheduler advances past its chunk, so no path offers it again. Park it
618
+ instead. MISS keys are dropped, since nothing is there to read.
619
+
620
+ The primary tier resolves every key it holds, so RETRY cannot reach
621
+ here. Parking on it would have no guarantee of ever draining, which is
622
+ what makes parking HIT_PENDING safe, so it is rejected rather than
623
+ guessed at.
624
"""
614
- # Filter out keys that are not ready in primary (e.g. in-flight)
615
- ready_keys = tuple(
616
- k
617
- for k in keys
618
- if self.primary_tier.lookup(k, req_context) is LookupResult.HIT
619
- )
625
+ state = self._req_state[req_context.req_id]
626
+ ready_keys = []
627
+ for key in keys:
628
+ result = self.primary_tier.lookup(key, req_context)
629
+ if result is LookupResult.HIT:
630
+ ready_keys.append(key)
631
+ elif result is LookupResult.HIT_PENDING:
632
+ state.pending_cascade_keys.append(key)
633
+ else:
634
+ assert result is LookupResult.MISS, (
635
+ f"primary tier returned {result} for a cascade key"
636
+ )
637
if not ready_keys:
638
return
639
@@ -625,6 +642,22 @@ class TieringOffloadingManager(OffloadingManager):
642
tier = self.secondary_tiers[tier_idx]
643
tier.submit_store(job_metadata)
644
645
+ def _flush_pending_cascades(self) -> None:
646
+ """Retry request-level cascades parked on an in-flight primary write.
647
+
648
+ A parked key always resolves, to HIT or to MISS, so the set drains and
649
+ a request cannot be held from finalization forever.
650
+ """
651
+ for req_id, state in list(self._req_state.items()):
652
+ if not state.pending_cascade_keys:
653
+ continue
654
+ assert state.request_level_tiers
655
+ keys, state.pending_cascade_keys = state.pending_cascade_keys, []
656
+ self._cascade_existing_blocks_to_request_level_tiers(
657
+ keys, state.req_context, state.request_level_tiers
658
+ )
659
+ self._maybe_finalize_request(req_id)
660
+
661
@override
662
def complete_store(
663
self,
@@ -759,6 +792,8 @@ class TieringOffloadingManager(OffloadingManager):
792
return
793
if state.pending_primary_stores != 0:
794
return
795
+ if state.pending_cascade_keys:
796
+ return
797
798
for tier_idx, tier in enumerate(self.secondary_tiers):
799
if tier_idx == exclude_tier_idx:
@@ -788,6 +823,7 @@ class TieringOffloadingManager(OffloadingManager):
823
self._processed_jobs_this_step = False
824
825
self._flush_pending_promotions()
826
+ self._flush_pending_cascades()
827
for tier in self.secondary_tiers:
828
tier.on_schedule_end(context)
829
@@ -802,8 +838,10 @@ class TieringOffloadingManager(OffloadingManager):
838
# In-flight primary<->secondary transfers (pending promotions are
839
# translated to transfer jobs in on_schedule_end), plus any work the
840
# secondary tiers themselves still have outstanding.
805
- return bool(self._jobs) or any(
806
- tier.has_pending_work() for tier in self.secondary_tiers
841
+ return (
842
+ bool(self._jobs)
843
+ or any(state.pending_cascade_keys for state in self._req_state.values())
844
+ or any(tier.has_pending_work() for tier in self.secondary_tiers)
845
)
846
847
@override
@@ -848,6 +886,7 @@ class TieringOffloadingManager(OffloadingManager):
886
finished_req_ids = []
887
for req_id, state in self._req_state.items():
888
state.pending_primary_stores = 0
889
+ state.pending_cascade_keys.clear()
890
if not state.is_finished:
891
continue
892
for tier in self.secondary_tiers: