[Bugfix][KVOffload] P2P tier declares REQUEST_LEVEL on the producer leg (#52912)
Signed-off-by: Liran Schour <lirans@il.ibm.com>
liranschour committed
Aug 31, 2026 at 13:07 UTC
d8de4ae322b54e518b2de771fe28673c6e9664d9
5 files changed
+282
-9
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py
+235
-4
@@ -1293,12 +1293,21 @@ def test_two_groups_different_block_sizes(request_runner, async_scheduling: bool
1293
1294
def _make_scheduler_with_lookup(
1295
lookup_results: dict[int, LookupResult],
1296
+ default: LookupResult = LookupResult.MISS,
1297
) -> OffloadingConnectorScheduler:
1297
- """Create an OffloadingConnectorScheduler with a mocked manager.lookup."""
1298
+ """Create an OffloadingConnectorScheduler with a mocked manager.lookup.
1299
+
1300
+ Keys are addressed by their integer hash; `default` answers anything else.
1301
+ """
1302
manager = MagicMock(spec=OffloadingManager)
1299
- manager.lookup.side_effect = lambda key, req_context: lookup_results.get(
1300
- int(get_offload_block_hash(key).decode()), LookupResult.MISS
1301
- )
1303
+
1304
+ def lookup(key, req_context):
1305
+ block_hash = get_offload_block_hash(key)
1306
+ if not block_hash.isdigit():
1307
+ return default
1308
+ return lookup_results.get(int(block_hash.decode()), default)
1309
+
1310
+ manager.lookup.side_effect = lookup
1311
1312
scheduler = object.__new__(OffloadingConnectorScheduler)
1313
scheduler.manager = manager
@@ -1322,6 +1331,32 @@ def _maximal_lookup(sched, keys, start_chunk_idx: int = 0):
1331
)
1332
1333
1334
+# Lookups issued, and end index returned, by a window-1 scan over 3 keys all
1335
+# resolving to the same result. A result that keeps the streak alive ends the
1336
+# scan at the first key; one that resets it makes the scan walk every key,
1337
+# which is what widens the demanded set. A new member has to be added here.
1338
+_SCAN_BEHAVIOR = {
1339
+ LookupResult.HIT: (1, 3),
1340
+ LookupResult.HIT_PENDING: (1, None),
1341
+ LookupResult.RETRY: (3, None),
1342
+ LookupResult.MISS: (3, 0),
1343
+}
1344
+
1345
+
1346
+@pytest.mark.parametrize("result", list(LookupResult))
1347
+def test_scan_behavior_declared_for_every_lookup_result(result: LookupResult):
1348
+ """Whether a result keeps a sliding-window streak alive decides how wide
1349
+ the demanded chunk set gets, so every member needs deliberate behavior."""
1350
+ assert result in _SCAN_BEHAVIOR, f"{result} has no declared scan behavior"
1351
+ expected_lookups, expected_end = _SCAN_BEHAVIOR[result]
1352
+
1353
+ keys = to_keys([1, 2, 3])
1354
+ sched = _make_scheduler_with_lookup(dict.fromkeys([1, 2, 3], result))
1355
+
1356
+ assert sched._sliding_window_lookup(keys, 1, _EMPTY_REQ_CTX) == expected_end
1357
+ assert len(sched.manager.lookup.call_args_list) == expected_lookups
1358
+
1359
+
1360
class TestMaximalPrefixLookup:
1361
def test_all_hit(self):
1362
sched = _make_scheduler_with_lookup({1: LookupResult.HIT, 2: LookupResult.HIT})
@@ -1556,6 +1591,62 @@ class TestSlidingWindowLookup:
1591
)
1592
1593
1594
+# ---------------------------------------------------------------------------
1595
+# Tests for SWA store pruning vs. load demand
1596
+# ---------------------------------------------------------------------------
1597
+
1598
+
1599
+@pytest.mark.parametrize("alignment_chunk_count", [4, 8, 64])
1600
+@pytest.mark.parametrize("sliding_window_chunks", [1, 2, 3])
1601
+@pytest.mark.parametrize("is_eagle_group", [False, True])
1602
+@pytest.mark.parametrize("tail_chunks", [0, 1, 5])
1603
+def test_sliding_window_demand_is_store_reachable(
1604
+ alignment_chunk_count: int,
1605
+ sliding_window_chunks: int,
1606
+ is_eagle_group: bool,
1607
+ tail_chunks: int,
1608
+):
1609
+ """Every chunk the load path looks up must be one the store path keeps.
1610
+
1611
+ `is_store_reachable_swa_chunk` prunes chunks `_sliding_window_lookup` can
1612
+ never query, but the two mirror each other by hand: this pins the relation,
1613
+ where `test_is_store_reachable_swa_chunk` pins only the predicate's values.
1614
+ A looked-up key is a demanded key, since a secondary-tier hit queues a
1615
+ promotion immediately.
1616
+ """
1617
+ storable_chunk_count = alignment_chunk_count + tail_chunks
1618
+ # Both sides widen by one for an unverified EAGLE group.
1619
+ required_window = sliding_window_chunks + int(is_eagle_group)
1620
+
1621
+ sched = _make_scheduler_with_lookup(
1622
+ {i: LookupResult.HIT_PENDING for i in range(storable_chunk_count)}
1623
+ )
1624
+ sched._sliding_window_lookup(
1625
+ to_keys(range(storable_chunk_count)), required_window, _EMPTY_REQ_CTX
1626
+ )
1627
+
1628
+ demanded = {
1629
+ int(get_offload_block_hash(lookup_call.args[0]).decode())
1630
+ for lookup_call in sched.manager.lookup.call_args_list
1631
+ }
1632
+ assert demanded, "the scan must query something"
1633
+ unsuppliable = sorted(
1634
+ chunk_idx
1635
+ for chunk_idx in demanded
1636
+ if not is_store_reachable_swa_chunk(
1637
+ chunk_idx,
1638
+ storable_chunk_count,
1639
+ alignment_chunk_count,
1640
+ sliding_window_chunks,
1641
+ is_eagle_group,
1642
+ )
1643
+ )
1644
+ assert not unsuppliable, (
1645
+ f"chunks {unsuppliable} are demanded by the load path but pruned by the "
1646
+ f"store path, so a warm producer can never supply them"
1647
+ )
1648
+
1649
+
1650
@pytest.mark.parametrize("async_scheduling", [True, False])
1651
def test_request_level_policy_stores_all_blocks(request_runner, async_scheduling: bool):
1652
"""With REQUEST_LEVEL policy, all blocks are stored — including prefix hits."""
@@ -2308,6 +2399,146 @@ def test_swa_alignment_skip(request_runner, async_scheduling: bool):
2399
)
2400
2401
2402
+def _full_and_swa_groups(
2403
+ full_attn_block_size: int, swa_block_size: int, sliding_window: int
2404
+) -> list[KVCacheGroupSpec]:
2405
+ """Group 0 full attention (MLA-like), group 1 sliding window."""
2406
+ return [
2407
+ KVCacheGroupSpec(
2408
+ ["layer0"],
2409
+ FullAttentionSpec(
2410
+ block_size=full_attn_block_size,
2411
+ num_kv_heads=1,
2412
+ head_size=1,
2413
+ dtype=torch.float32,
2414
+ ),
2415
+ ),
2416
+ KVCacheGroupSpec(
2417
+ ["layer1"],
2418
+ SlidingWindowSpec(
2419
+ block_size=swa_block_size,
2420
+ num_kv_heads=1,
2421
+ head_size=1,
2422
+ dtype=torch.float32,
2423
+ sliding_window=sliding_window,
2424
+ ),
2425
+ ),
2426
+ ]
2427
+
2428
+
2429
+def _demanded_keys(runner, offload_keys_per_group: list[list], num_tokens: int) -> set:
2430
+ """Keys a cold consumer's lookup scan would demand for `num_tokens`.
2431
+
2432
+ Drives the real scan functions with every key resolving as a promoting
2433
+ secondary tier resolves it.
2434
+ """
2435
+ scan = _make_scheduler_with_lookup({}, default=LookupResult.HIT_PENDING)
2436
+ for group_config, offload_keys in zip(
2437
+ runner.connector_scheduler.config.kv_group_configs, offload_keys_per_group
2438
+ ):
2439
+ num_chunks = num_tokens // group_config.tokens_per_chunk
2440
+ keys = offload_keys[:num_chunks]
2441
+ window = group_config.sliding_window_size_in_chunks
2442
+ if window is None:
2443
+ _maximal_lookup(scan, keys)
2444
+ else:
2445
+ scan._sliding_window_lookup(keys, window, _EMPTY_REQ_CTX)
2446
+ return {lookup_call.args[0] for lookup_call in scan.manager.lookup.call_args_list}
2447
+
2448
+
2449
+@pytest.mark.parametrize("warmth", ["cold", "gpu_warm", "primary_warm"])
2450
+@pytest.mark.parametrize("with_swa_group", [False, True])
2451
+def test_request_level_supply_covers_consumer_demand(
2452
+ request_runner, warmth: str, with_swa_group: bool
2453
+):
2454
+ """A REQUEST_LEVEL producer must offer every key a consumer will demand.
2455
+
2456
+ Same relation as `test_sliding_window_demand_is_store_reachable`, but at
2457
+ the level where the key set is really built: `_build_store_jobs` prunes
2458
+ chunks with a null GPU block and unreachable SWA chunks, while the demand
2459
+ comes from an independent scan.
2460
+ """
2461
+ full_attn_block_size = 16
2462
+ swa_block_size = 4
2463
+ num_tokens = 32
2464
+
2465
+ kv_cache_groups = (
2466
+ _full_and_swa_groups(full_attn_block_size, swa_block_size, sliding_window=8)
2467
+ if with_swa_group
2468
+ else None
2469
+ )
2470
+ runner = request_runner(
2471
+ block_size=swa_block_size if with_swa_group else full_attn_block_size,
2472
+ num_gpu_blocks=200,
2473
+ async_scheduling=False,
2474
+ kv_cache_groups=kv_cache_groups,
2475
+ )
2476
+
2477
+ def store_everything_offered():
2478
+ runner.manager.prepare_store.side_effect = lambda keys, req_context: (
2479
+ generate_store_output(keys)
2480
+ )
2481
+
2482
+ # `_run` rather than `run`, which asserts on an expected GPU-block set this
2483
+ # test deliberately does not hard-code.
2484
+ if warmth != "cold":
2485
+ runner.new_request(token_ids=[0] * num_tokens)
2486
+ store_everything_offered()
2487
+ runner._run(decoded_tokens=[0], complete_transfers=True)
2488
+ runner._run(decoded_tokens=[EOS_TOKEN_ID], complete_transfers=True)
2489
+ if warmth == "primary_warm":
2490
+ # Warm only in the primary tier, so the producer must load first.
2491
+ runner.scheduler.reset_prefix_cache()
2492
+
2493
+ # The producer leg: same prompt, REQUEST_LEVEL, so prefix hits stay in the
2494
+ # store path instead of being skipped.
2495
+ runner.manager.reset_mock()
2496
+ store_everything_offered()
2497
+ runner.manager.on_new_request.return_value = RequestOffloadingContext(
2498
+ policy=OffloadPolicy.REQUEST_LEVEL
2499
+ )
2500
+ if warmth == "primary_warm":
2501
+ # HIT is final; HIT_PENDING would defer the request on every step.
2502
+ runner.manager.lookup.return_value = LookupResult.HIT
2503
+ runner.new_request(token_ids=[0] * num_tokens)
2504
+
2505
+ offload_keys_per_group: list[list] = []
2506
+
2507
+ def capture_offload_keys() -> None:
2508
+ for req_status in runner.connector_scheduler._req_status.values():
2509
+ offload_keys_per_group.clear()
2510
+ offload_keys_per_group.extend(
2511
+ list(group_state.offload_keys)
2512
+ for group_state in req_status.group_states
2513
+ )
2514
+
2515
+ runner._run(
2516
+ decoded_tokens=[0],
2517
+ complete_transfers=True,
2518
+ post_step_fn=capture_offload_keys,
2519
+ )
2520
+ runner._run(
2521
+ decoded_tokens=[EOS_TOKEN_ID],
2522
+ complete_transfers=True,
2523
+ post_step_fn=capture_offload_keys,
2524
+ )
2525
+
2526
+ assert offload_keys_per_group, "no request state was captured"
2527
+ supplied = {
2528
+ key
2529
+ for store_call in runner.manager.prepare_store.call_args_list
2530
+ for key in store_call.args[0]
2531
+ }
2532
+ demanded = _demanded_keys(runner, offload_keys_per_group, num_tokens)
2533
+
2534
+ assert demanded, "the scan must demand something"
2535
+ missing = demanded - supplied
2536
+ assert not missing, (
2537
+ f"{len(missing)} of {len(demanded)} demanded keys are never offered to "
2538
+ f"prepare_store, so a peer fetching them would wait out the load timeout"
2539
+ )
2540
+
2541
+
2542
@pytest.mark.parametrize("async_scheduling", [True, False])
2543
def test_stale_sliding_window_block_after_prepare_store_failure(
2544
request_runner, async_scheduling: bool
tests/v1/kv_connector/unit/offloading_connector/utils.py
+1
-1
@@ -65,7 +65,7 @@ def to_key(int_hash: int) -> OffloadKey:
65
return make_offload_key(str(int_hash).encode(), 0)
66
67
68
-def to_keys(int_hashes: list[int]) -> list[OffloadKey]:
68
+def to_keys(int_hashes: Iterable[int]) -> list[OffloadKey]:
69
return [to_key(i) for i in int_hashes]
70
71
tests/v1/kv_offload/tiering/p2p/test_manager.py
+28
-1
@@ -17,7 +17,12 @@ import pytest
17
18
from vllm.utils.hashing import sha256
19
from vllm.v1.core.kv_cache_utils import DEFAULT_NONE_HASH_SEED, init_none_hash
20
-from vllm.v1.kv_offload.base import LookupResult, ReqContext, ScheduleEndContext
20
+from vllm.v1.kv_offload.base import (
21
+ LookupResult,
22
+ OffloadPolicy,
23
+ ReqContext,
24
+ ScheduleEndContext,
25
+)
26
from vllm.v1.kv_offload.tiering.base import JobResult, TransferJob
27
from vllm.v1.kv_offload.tiering.p2p import manager as manager_module
28
from vllm.v1.kv_offload.tiering.p2p.manager import (
@@ -240,6 +245,28 @@ class TestLookup:
245
assert mgr.lookup(b"key", ctx) is LookupResult.MISS
246
247
248
+# ---------------------------------------------------------------------------
249
+# Tests for on_new_request offload policy
250
+# ---------------------------------------------------------------------------
251
+
252
+
253
+@pytest.mark.parametrize(
254
+ "kv_params,expected",
255
+ [
256
+ (_remote_decoder_kv_params(), OffloadPolicy.REQUEST_LEVEL),
257
+ (None, OffloadPolicy.BLOCK_LEVEL),
258
+ (_remote_prefiller_kv_params(), OffloadPolicy.BLOCK_LEVEL),
259
+ ({"remote_decoder": {}}, OffloadPolicy.BLOCK_LEVEL),
260
+ ],
261
+ ids=["producer", "plain", "consumer", "producer_no_id"],
262
+)
263
+def test_on_new_request_policy(monkeypatch, kv_params, expected):
264
+ """Only a producer leg carrying a kv_request_id widens to REQUEST_LEVEL."""
265
+ mgr = _make_manager()
266
+ monkeypatch.setattr(mgr, "_get_or_create_session", lambda peer_id: None)
267
+ assert mgr.on_new_request(_req_context(kv_params=kv_params)).policy is expected
268
+
269
+
270
# ---------------------------------------------------------------------------
271
# Tests for serve_external_requests
272
# ---------------------------------------------------------------------------
tests/v1/kv_offload/tiering/test_tiering_offloading.py
+10
-3
@@ -1063,10 +1063,17 @@ class TestTieringOffloadingManager:
1063
self.manager.on_request_finished(ctx)
1064
assert ctx.req_id not in self.manager._req_state
1065
1066
+ @pytest.mark.parametrize("new_ids", [(), (3, 4)], ids=["fully_warm", "mixed"])
1067
def test_prepare_store_cascades_existing_blocks_to_request_level_tiers(
1067
- self, manager_setup
1068
+ self, manager_setup, new_ids
1069
):
1069
- """prepare_store cascades hit blocks to request-level tiers only."""
1070
+ """prepare_store cascades hit blocks to request-level tiers only.
1071
+
1072
+ The fully-warm case is the producer shape from issue #52808: nothing is
1073
+ new, so primary returns an empty keys_to_store and no GPU->primary
1074
+ transfer runs. A request-level tier must still be handed the blocks,
1075
+ otherwise a peer waiting on them has nothing to fetch.
1076
+ """
1077
# Store some blocks to primary first
1078
existing_blocks = to_keys(range(3))
1079
self._start_request()
@@ -1093,7 +1100,7 @@ class TestTieringOffloadingManager:
1100
)
1101
1102
# Call prepare_store with existing + new blocks
1096
- new_blocks = to_keys(range(3, 5))
1103
+ new_blocks = to_keys(new_ids)
1104
all_blocks = existing_blocks + new_blocks
1105
result = self.manager.prepare_store(all_blocks, ctx)
1106
assert result is not None
vllm/v1/kv_offload/tiering/p2p/manager.py
+8
@@ -22,6 +22,7 @@ from vllm.v1.core.kv_cache_utils import get_none_hash_seed
22
from vllm.v1.kv_offload.base import (
23
LookupResult,
24
OffloadKey,
25
+ OffloadPolicy,
26
ReqContext,
27
RequestOffloadingContext,
28
ScheduleEndContext,
@@ -361,11 +362,18 @@ class P2PSecondaryTierManager(SecondaryTierManager):
362
prefiller side, sessions are created when the consumer's inbound
363
connection arrives in _accept_new_peers — submit_store no longer
364
pre-creates anything.
365
+
366
+ Producer-leg requests (``remote_decoder`` present) ask for
367
+ REQUEST_LEVEL: the peer needs every block of the request, not just
368
+ the ones this request computed.
369
"""
370
_annotate_req_context(req_context)
371
source = req_context.get_state(P2PSourceInfo)
372
if source is not None:
373
self._get_or_create_session(source.peer_id)
374
+ dest = req_context.get_state(P2PDestInfo)
375
+ if dest is not None and dest.kv_request_id:
376
+ return RequestOffloadingContext(policy=OffloadPolicy.REQUEST_LEVEL)
377
return RequestOffloadingContext()
378
379
@override