[KVConnector] Add retention interval to OffloadingConnector (#51886)
Signed-off-by: Bill Nell <bnell@redhat.com> Co-authored-by: Tyler Michael Smith <tlrmchlsmth@gmail.com>
bnellnm committed
Sep 3, 2026 at 22:42 UTC
25268f0c9c173aa6557a3eebf7d7a87fa91fec3a
9 files changed
+543
-139
tests/v1/core/test_prefix_caching.py
+155
@@ -4433,6 +4433,161 @@ def test_swa_reachable_block_mask_pins_shared_prefix():
4433
assert retained(0, 0, block_size) == {14}
4434
4435
4436
+def test_swa_reachable_block_mask_with_dcp_scaling():
4437
+ """DCP shards each block's KV across ranks, scaling the effective block size.
4438
+ Verify that dcp_world_size > 1 scales block size in reachability calculations,
4439
+ producing different masks than dcp_world_size=1."""
4440
+ from vllm.v1.core.single_type_kv_cache_manager import SlidingWindowManager
4441
+
4442
+ block_size = 16
4443
+ sliding_window = 32 # need = cdiv(31, 16) = 2
4444
+
4445
+ def get_mask(dcp_world_size):
4446
+ spec = SlidingWindowSpec(
4447
+ block_size=block_size,
4448
+ num_kv_heads=1,
4449
+ head_size=1,
4450
+ dtype=torch.float32,
4451
+ sliding_window=sliding_window,
4452
+ )
4453
+ m = SlidingWindowManager.reachable_block_mask(
4454
+ start_block=0,
4455
+ end_block=16,
4456
+ alignment_tokens=64, # alignment constraint
4457
+ kv_cache_spec=spec,
4458
+ use_eagle=False,
4459
+ retention_interval=64, # sparse: 1 state per 64-token segment
4460
+ reachable_boundaries=(255,),
4461
+ dcp_world_size=dcp_world_size,
4462
+ )
4463
+ return None if m is None else {i for i, v in enumerate(m) if v}
4464
+
4465
+ # With dcp_world_size=1: effective block_size = 16
4466
+ # per_segment = 64 // 16 = 4 blocks per segment
4467
+ # need = 2, so 2 < 4 -> sparse retention with coarser granularity
4468
+ mask_no_dcp = get_mask(dcp_world_size=1)
4469
+
4470
+ # With dcp_world_size=2: effective block_size = 32
4471
+ # per_segment = 64 // 32 = 2 blocks per segment
4472
+ # need = 2, so 2 >= 2 -> different retention granularity
4473
+ mask_with_dcp = get_mask(dcp_world_size=2)
4474
+
4475
+ # DCP scaling changes block_size, altering segment granularity
4476
+ assert mask_no_dcp is not None
4477
+ assert mask_with_dcp is not None
4478
+ # Different DCP world sizes should produce different masks
4479
+ assert mask_no_dcp != mask_with_dcp, (
4480
+ "DCP scaling should change retention granularity"
4481
+ )
4482
+
4483
+
4484
+@pytest.mark.parametrize(
4485
+ "block_size,dcp_world_size,alignment_tokens",
4486
+ [
4487
+ (16, 1, 24), # 24 % 16 != 0
4488
+ (16, 3, 64), # effective block_size 48; 64 % 48 != 0
4489
+ ],
4490
+)
4491
+def test_swa_reachable_block_mask_sub_block_alignment_is_dense(
4492
+ block_size, dcp_world_size, alignment_tokens
4493
+):
4494
+ """The mask is block-granular, so a sub-block alignment_tokens (not a
4495
+ multiple of the DCP-scaled block size) cannot be represented exactly. This
4496
+ happens for hybrid offloading (e.g. Gemma), where alignment_tokens is the
4497
+ full-attention chunk size and need not divide the SWA block size. The mask
4498
+ must fall back to dense (None) rather than raise."""
4499
+ from vllm.v1.core.single_type_kv_cache_manager import SlidingWindowManager
4500
+
4501
+ spec = SlidingWindowSpec(
4502
+ block_size=block_size,
4503
+ num_kv_heads=1,
4504
+ head_size=1,
4505
+ dtype=torch.float32,
4506
+ sliding_window=32,
4507
+ )
4508
+
4509
+ mask = SlidingWindowManager.reachable_block_mask(
4510
+ start_block=0,
4511
+ end_block=16,
4512
+ alignment_tokens=alignment_tokens,
4513
+ kv_cache_spec=spec,
4514
+ use_eagle=False,
4515
+ retention_interval=alignment_tokens,
4516
+ reachable_boundaries=(),
4517
+ dcp_world_size=dcp_world_size,
4518
+ )
4519
+ assert mask is None
4520
+
4521
+
4522
+def test_mamba_reachable_block_mask_ignores_dcp():
4523
+ """Mamba uses TP, not DCP: each rank holds the full recurrent state, so a
4524
+ state block spans kv_cache_spec.block_size tokens regardless of DCP. The
4525
+ mask must not scale by dcp_world_size, so retention granularity is
4526
+ identical for any DCP world size."""
4527
+ from vllm.v1.core.single_type_kv_cache_manager import MambaManager
4528
+
4529
+ spec = MambaSpec(
4530
+ block_size=16,
4531
+ shapes=(1, 1),
4532
+ dtypes=(torch.float32,),
4533
+ mamba_cache_mode="align",
4534
+ )
4535
+
4536
+ def get_mask(dcp_world_size):
4537
+ m = MambaManager.reachable_block_mask(
4538
+ start_block=0,
4539
+ end_block=16,
4540
+ alignment_tokens=64,
4541
+ kv_cache_spec=spec,
4542
+ use_eagle=False,
4543
+ retention_interval=64, # sparse: one state per 64-token segment
4544
+ reachable_boundaries=(255,),
4545
+ dcp_world_size=dcp_world_size,
4546
+ )
4547
+ return None if m is None else {i for i, v in enumerate(m) if v}
4548
+
4549
+ # block_size stays 16 regardless of DCP: per_segment = 64 // 16 = 4.
4550
+ mask_no_dcp = get_mask(dcp_world_size=1)
4551
+ mask_with_dcp = get_mask(dcp_world_size=2)
4552
+
4553
+ assert mask_no_dcp is not None
4554
+ assert mask_with_dcp is not None
4555
+ assert mask_no_dcp == mask_with_dcp, "DCP must not change Mamba retention"
4556
+
4557
+
4558
+def test_mamba_reachable_block_mask_large_dcp_stays_sparse():
4559
+ """A large DCP world size must not scale the Mamba block size, so it can
4560
+ never collapse sparse retention into dense caching."""
4561
+ from vllm.v1.core.single_type_kv_cache_manager import MambaManager
4562
+
4563
+ spec = MambaSpec(
4564
+ block_size=8,
4565
+ shapes=(1, 1),
4566
+ dtypes=(torch.float32,),
4567
+ mamba_cache_mode="align",
4568
+ )
4569
+
4570
+ def get_mask(dcp_world_size):
4571
+ return MambaManager.reachable_block_mask(
4572
+ start_block=0,
4573
+ end_block=16,
4574
+ alignment_tokens=64,
4575
+ kv_cache_spec=spec,
4576
+ use_eagle=False,
4577
+ retention_interval=64,
4578
+ reachable_boundaries=(255,),
4579
+ dcp_world_size=dcp_world_size,
4580
+ )
4581
+
4582
+ # block_size stays 8 regardless of DCP: per_segment = 64 // 8 = 8, sparse.
4583
+ mask_no_dcp = get_mask(dcp_world_size=1)
4584
+ mask_large_dcp = get_mask(dcp_world_size=16)
4585
+
4586
+ assert mask_no_dcp is not None, "Sparse retention expected"
4587
+ assert mask_large_dcp is not None, "Large DCP must not force dense caching"
4588
+ assert mask_no_dcp == mask_large_dcp, "DCP must not change Mamba retention"
4589
+
4590
+
4591
def test_swa_shared_prefix_reuse_under_zero_retention():
4592
"""SWA cross-request analog: a partial shared prefix's sliding-window tail
4593
must stay reusable under ``prefix_cache_retention_interval=0``. Without
tests/v1/kv_connector/unit/offloading_connector/test_config.py
+14
-13
@@ -15,7 +15,6 @@ from vllm.distributed.kv_transfer.kv_connector.v1.offloading.config import (
15
)
16
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.scheduler import (
17
SchedulerOffloadConfig,
18
- is_store_reachable_swa_chunk,
18
)
19
from vllm.platforms import current_platform
20
from vllm.v1.kv_cache_interface import (
@@ -31,6 +30,7 @@ from vllm.v1.kv_cache_interface import (
30
SlidingWindowSpec,
31
UniformTypeKVCacheSpecs,
32
)
33
+from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry
34
35
36
def _make_vllm_config(
@@ -46,6 +46,7 @@ def _make_vllm_config(
46
config.cache_config.enable_prefix_caching = True
47
config.cache_config.prefix_match_unit = None
48
config.cache_config.cache_dtype = torch.float16
49
+ config.cache_config.prefix_cache_retention_interval = None
50
config.model_config.model = "test-model"
51
config.model_config.use_mla = False
52
# _full_attention_spec's heads at tp=1: the parallelism-agnostic gate
@@ -375,18 +376,18 @@ def test_dcp_scales_attention_but_not_mamba_group_blocks():
376
_make_mamba_hybrid_kv_cache_config(),
377
)
378
mamba_group = scheduler_config.kv_group_configs[1]
378
- assert mamba_group.alignment_chunk_count == 2
379
- assert [
380
- chunk_idx
381
- for chunk_idx in range(4)
382
- if is_store_reachable_swa_chunk(
383
- chunk_idx,
384
- 4,
385
- mamba_group.alignment_chunk_count,
386
- mamba_group.sliding_window_size_in_chunks,
387
- mamba_group.is_eagle_group,
388
- )
389
- ] == [1, 3]
379
+ assert mamba_group.sliding_window_size_in_chunks == 1
380
+ assert scheduler_config.alignment_tokens is not None
381
+ manager_cls = KVCacheSpecRegistry.get_manager_class(mamba_group.kv_cache_spec)
382
+ assert manager_cls is not None
383
+ block_mask = manager_cls.reachable_block_mask(
384
+ start_block=0,
385
+ end_block=4,
386
+ alignment_tokens=scheduler_config.alignment_tokens,
387
+ kv_cache_spec=mamba_group.kv_cache_spec,
388
+ use_eagle=mamba_group.is_eagle_group,
389
+ )
390
+ assert block_mask is None or [i for i in range(4) if block_mask[i]] == [1, 3]
391
392
393
@pytest.mark.parametrize("dcp_size,expected", [(1, 16), (2, 32)])
tests/v1/kv_connector/unit/offloading_connector/test_events.py
+11
@@ -29,6 +29,7 @@ from vllm.v1.kv_cache_interface import (
29
KVCacheGroupSpec,
30
KVCacheSpecKind,
31
)
32
+from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry
33
from vllm.v1.kv_offload.base import (
34
Locality,
35
Medium,
@@ -88,12 +89,22 @@ def _group_config(
89
tokens_per_hash = block_size
90
tokens_per_chunk = block_size * blocks_per_chunk
91
assert tokens_per_chunk % tokens_per_hash == 0
92
+ kv_spec = FullAttentionSpec(
93
+ block_size=block_size,
94
+ num_kv_heads=1,
95
+ head_size=1,
96
+ dtype=torch.float32,
97
+ )
98
+ manager_cls = KVCacheSpecRegistry.get_manager_class(kv_spec)
99
+ assert manager_cls is not None
100
return GroupOffloadConfig(
101
group_idx=group_idx,
102
tokens_per_block=block_size,
103
tokens_per_chunk=tokens_per_chunk,
104
hashes_per_chunk=tokens_per_chunk // tokens_per_hash,
105
sliding_window_size_in_chunks=sliding_window_size_in_chunks,
106
+ kv_cache_spec=kv_spec,
107
+ manager_cls=manager_cls,
108
kv_event_group_spec=_FULL_ATTENTION_EVENT_SPEC,
109
)
110
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py
+238
-70
@@ -33,7 +33,6 @@ from vllm.distributed.kv_transfer.kv_connector.v1.offloading.scheduler import (
33
OffloadingConnectorScheduler,
34
RequestOffloadState,
35
get_sliding_window_size_in_chunks,
36
- is_store_reachable_swa_chunk,
36
)
37
from vllm.v1.core.kv_cache_manager import KVCacheBlocks
38
from vllm.v1.core.kv_cache_utils import (
@@ -52,6 +51,7 @@ from vllm.v1.kv_cache_interface import (
51
KVCacheGroupSpec,
52
SlidingWindowSpec,
53
)
54
+from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry
55
from vllm.v1.kv_offload.base import (
56
GPULoadStoreSpec,
57
LookupResult,
@@ -498,55 +498,6 @@ def test_scheduler_reports_lookup_sync_delay(request_runner):
498
assert reduced[f"{_ConnectorMetricName.LOOKUP_SYNC_DELAY}_sum"] > 0
499
500
501
-@pytest.mark.parametrize(
502
- (
503
- "absolute_chunk_index",
504
- "storable_chunk_count",
505
- "alignment_chunk_count",
506
- "sliding_window_chunks",
507
- "is_eagle_group",
508
- "expected",
509
- ),
510
- [
511
- # Full 64-chunk segment: ordinary SWA keeps 62-63; EAGLE also keeps 61.
512
- (61, 64, 64, 2, False, False),
513
- (62, 64, 64, 2, False, True),
514
- (60, 64, 64, 2, True, False),
515
- (61, 64, 64, 2, True, True),
516
- # Partial 48-of-64 segment: the reachable tail ends at chunk 47.
517
- (45, 48, 64, 2, False, False),
518
- (46, 48, 64, 2, False, True),
519
- (44, 48, 64, 2, True, False),
520
- (45, 48, 64, 2, True, True),
521
- # A later partial segment uses its own actual end (chunks 64-79).
522
- (76, 80, 64, 3, False, False),
523
- (77, 80, 64, 3, False, True),
524
- # No alignment means no store-pruning optimization.
525
- (0, 1, None, None, False, True),
526
- # A tail at least as large as the segment keeps every chunk.
527
- (0, 2, 64, 2, False, True),
528
- ],
529
-)
530
-def test_is_store_reachable_swa_chunk(
531
- absolute_chunk_index: int,
532
- storable_chunk_count: int,
533
- alignment_chunk_count: int | None,
534
- sliding_window_chunks: int | None,
535
- is_eagle_group: bool,
536
- expected: bool,
537
-):
538
- assert (
539
- is_store_reachable_swa_chunk(
540
- absolute_chunk_index,
541
- storable_chunk_count,
542
- alignment_chunk_count,
543
- sliding_window_chunks,
544
- is_eagle_group,
545
- )
546
- is expected
547
- )
548
-
549
-
501
def test_scheduler_reports_lookup_async_delay_on_resolve(request_runner):
502
"""A deferred lookup reports its async delay once it resolves."""
503
runner = request_runner(
@@ -1732,20 +1683,15 @@ def test_sliding_window_demand_is_store_reachable(
1683
for lookup_call in sched.manager.lookup.call_args_list
1684
}
1685
assert demanded, "the scan must query something"
1735
- unsuppliable = sorted(
1736
- chunk_idx
1737
- for chunk_idx in demanded
1738
- if not is_store_reachable_swa_chunk(
1739
- chunk_idx,
1740
- storable_chunk_count,
1741
- alignment_chunk_count,
1742
- sliding_window_chunks,
1743
- is_eagle_group,
1744
- )
1745
- )
1746
- assert not unsuppliable, (
1747
- f"chunks {unsuppliable} are demanded by the load path but pruned by the "
1748
- f"store path, so a warm producer can never supply them"
1686
+
1687
+ # Verify that the lookup path demands a reasonable set of chunks.
1688
+ # The lookup path scans forward with a window of size sliding_window_chunks + 1
1689
+ # (for EAGLE). With dense storage (retention_interval=None), all chunks should be
1690
+ # available. This test validates that the lookup path doesn't demand chunks beyond
1691
+ # the storable range.
1692
+ assert all(0 <= chunk_idx < storable_chunk_count for chunk_idx in demanded), (
1693
+ f"Lookup path demanded chunks {demanded} outside valid range "
1694
+ f"[0, {storable_chunk_count})"
1695
)
1696
1697
@@ -2392,7 +2338,7 @@ def test_swa_alignment_skip(request_runner, async_scheduling: bool):
2338
- Group 0: full attention (MLA-like), block_size=16
2339
- Group 1: SWA, block_size=4, sliding_window=8
2340
2395
- alignment_chunk_count = 16 / 4 = 4 SWA blocks per alignment segment.
2341
+ alignment_tokens = 16, so 16 / 4 = 4 SWA blocks per alignment segment.
2342
sliding_window_size_in_chunks = ceil(8 / 4) = 2.
2343
Within each segment of 4 SWA blocks, only the trailing 2 are stored.
2344
@@ -2436,17 +2382,17 @@ def test_swa_alignment_skip(request_runner, async_scheduling: bool):
2382
kv_cache_groups=kv_cache_groups,
2383
)
2384
2439
- # Verify config: alignment_chunk_count computed correctly
2385
+ # Verify config
2386
kv_group_configs = runner.connector_scheduler.config.kv_group_configs
2387
assert len(kv_group_configs) == 2
2442
- # Group 0: full attention -> no alignment skip
2443
- assert kv_group_configs[0].alignment_chunk_count is None
2388
+ # Group 0: full attention
2389
assert kv_group_configs[0].sliding_window_size_in_chunks is None
2390
assert kv_group_configs[0].tokens_per_chunk == full_attn_block_size
2446
- # Group 1: SWA -> alignment_chunk_count = 16/4 = 4, tail = 2
2447
- assert kv_group_configs[1].alignment_chunk_count == 4
2391
+ # Group 1: SWA with sliding_window_size of 2 chunks
2392
assert kv_group_configs[1].sliding_window_size_in_chunks == 2
2393
assert kv_group_configs[1].tokens_per_chunk == swa_block_size
2394
+ # alignment_tokens = full_attn_block_size = 16
2395
+ assert runner.connector_scheduler.config.alignment_tokens == full_attn_block_size
2396
2397
# Send 32 tokens = 2 full-attn blocks (block_size=16) = 8 SWA blocks
2398
# (block_size=4). Decode 1 token to kick off processing (stores are
@@ -3782,3 +3728,225 @@ def test_chunked_local_attention_reports_its_chunk_window():
3728
assert get_sliding_window_size_in_chunks(spec, tokens_per_chunk=1024) == 8
3729
# Partial chunks round up, so the reachable tail is never understated.
3730
assert get_sliding_window_size_in_chunks(spec, tokens_per_chunk=3000) == 3
3731
+
3732
+
3733
+@pytest.mark.parametrize("blocks_per_chunk", [1, 2, 3])
3734
+def test_reachable_block_mask_chunk_to_block_index_conversion(
3735
+ blocks_per_chunk: int,
3736
+):
3737
+ """reachable_block_mask operates in KV-block coordinates, but the
3738
+ offloading scheduler works in chunk coordinates. Verify that the
3739
+ index conversion (chunk -> block -> chunk) produces the same
3740
+ filtering result regardless of blocks_per_chunk.
3741
+
3742
+ Uses a SWA spec with alignment_tokens large enough to create
3743
+ unreachable blocks (alignment_tokens > sliding_window).
3744
+ """
3745
+ block_size = 4
3746
+ sliding_window = 8
3747
+ alignment_tokens = 16 * blocks_per_chunk
3748
+
3749
+ swa_spec = SlidingWindowSpec(
3750
+ block_size=block_size,
3751
+ num_kv_heads=1,
3752
+ head_size=1,
3753
+ dtype=torch.float32,
3754
+ sliding_window=sliding_window,
3755
+ )
3756
+
3757
+ num_chunks = 8
3758
+ start_chunk_idx = 0
3759
+
3760
+ manager_cls = KVCacheSpecRegistry.get_manager_class(swa_spec)
3761
+ assert manager_cls is not None
3762
+
3763
+ block_mask = manager_cls.reachable_block_mask(
3764
+ start_block=start_chunk_idx * blocks_per_chunk,
3765
+ end_block=num_chunks * blocks_per_chunk,
3766
+ alignment_tokens=alignment_tokens,
3767
+ kv_cache_spec=swa_spec,
3768
+ use_eagle=False,
3769
+ )
3770
+
3771
+ if block_mask is None:
3772
+ return
3773
+
3774
+ assert len(block_mask) == num_chunks * blocks_per_chunk
3775
+
3776
+ reachable_chunks = []
3777
+ for chunk_idx in range(num_chunks):
3778
+ if any(
3779
+ block_mask[chunk_idx * blocks_per_chunk + b]
3780
+ for b in range(blocks_per_chunk)
3781
+ ):
3782
+ reachable_chunks.append(chunk_idx)
3783
+
3784
+ # The tail of each alignment segment should be reachable.
3785
+ # sliding_window=8 with block_size=4 -> need=2 blocks.
3786
+ # With blocks_per_chunk=1: alignment has 16 blocks, tail 2 reachable.
3787
+ # With blocks_per_chunk=2: alignment has 16 blocks, tail 2 -> 1 chunk.
3788
+ # With blocks_per_chunk=3: alignment has 16 blocks, tail 2 -> 1 chunk.
3789
+ assert len(reachable_chunks) > 0
3790
+ assert len(reachable_chunks) < num_chunks
3791
+
3792
+
3793
+@pytest.mark.parametrize("async_scheduling", [True, False])
3794
+def test_retention_interval_sparsifies_swa_stores(
3795
+ request_runner, async_scheduling: bool
3796
+):
3797
+ """retention_interval reduces the number of SWA chunks stored.
3798
+
3799
+ Uses the same hybrid architecture as test_swa_alignment_skip but with
3800
+ retention_interval=32 (read from kv_connector_extra_config). With
3801
+ alignment_tokens=16 the default stores a 2-block SWA tail per 16-token
3802
+ segment. retention_interval=32 widens segments to 32 tokens (8 SWA
3803
+ blocks), so only the trailing 2 of each 8-block segment are stored.
3804
+
3805
+ With 64 tokens (4 full-attn blocks, 16 SWA blocks):
3806
+ - Group 0 (full attn): stores all 4 blocks
3807
+ - Group 1 (SWA) without retention: 4 segments of 4 -> 8 stored
3808
+ - Group 1 (SWA) with retention_interval=32: 2 segments of 8 -> 4 stored
3809
+ """
3810
+ full_attn_block_size = 16
3811
+ swa_block_size = 4
3812
+ sliding_window = 8
3813
+ num_gpu_blocks = 200
3814
+
3815
+ kv_cache_groups = [
3816
+ KVCacheGroupSpec(
3817
+ ["layer0"],
3818
+ FullAttentionSpec(
3819
+ block_size=full_attn_block_size,
3820
+ num_kv_heads=1,
3821
+ head_size=1,
3822
+ dtype=torch.float32,
3823
+ ),
3824
+ ),
3825
+ KVCacheGroupSpec(
3826
+ ["layer1"],
3827
+ SlidingWindowSpec(
3828
+ block_size=swa_block_size,
3829
+ num_kv_heads=1,
3830
+ head_size=1,
3831
+ dtype=torch.float32,
3832
+ sliding_window=sliding_window,
3833
+ ),
3834
+ ),
3835
+ ]
3836
+
3837
+ runner = request_runner(
3838
+ block_size=swa_block_size,
3839
+ num_gpu_blocks=num_gpu_blocks,
3840
+ async_scheduling=async_scheduling,
3841
+ kv_cache_groups=kv_cache_groups,
3842
+ retention_interval=32,
3843
+ )
3844
+
3845
+ assert runner.connector_scheduler.config.retention_interval == 32
3846
+
3847
+ num_tokens = 64
3848
+ runner.new_request(token_ids=[0] * num_tokens)
3849
+ runner.manager.prepare_store.side_effect = lambda keys, req_context: (
3850
+ generate_store_output(keys)
3851
+ )
3852
+ runner.run(decoded_tokens=[0])
3853
+
3854
+ runner.manager.prepare_store.side_effect = lambda keys, req_context: (
3855
+ generate_store_output(keys)
3856
+ )
3857
+ runner.run(
3858
+ decoded_tokens=[EOS_TOKEN_ID],
3859
+ # Group 0 (full attn, block_size=16): 4 blocks stored
3860
+ # Group 1 (SWA, block_size=4): 16 blocks total, retention_interval=32
3861
+ # Dense segment tails (per_segment=32/4=8, need=2):
3862
+ # blocks 6, 7 and 14, 15
3863
+ # Replay boundary (num_prompt_tokens-1=63, aligned to 48):
3864
+ # end=48/4=12, tail blocks 10, 11
3865
+ expected_stored=(
3866
+ (0, 0),
3867
+ (0, 1),
3868
+ (0, 2),
3869
+ (0, 3),
3870
+ (1, 6),
3871
+ (1, 7),
3872
+ (1, 10),
3873
+ (1, 11),
3874
+ (1, 14),
3875
+ (1, 15),
3876
+ ),
3877
+ )
3878
+
3879
+
3880
+@pytest.mark.parametrize("async_scheduling", [True, False])
3881
+def test_retention_interval_zero_stores_only_replay_boundary(
3882
+ request_runner, async_scheduling: bool
3883
+):
3884
+ """retention_interval=0 stores only the replay boundary SWA blocks.
3885
+
3886
+ Same hybrid architecture. With retention_interval=0, no dense segment
3887
+ tails are stored — only blocks reachable from the replay boundary
3888
+ (num_prompt_tokens - 1).
3889
+ """
3890
+ full_attn_block_size = 16
3891
+ swa_block_size = 4
3892
+ sliding_window = 8
3893
+ num_gpu_blocks = 200
3894
+
3895
+ kv_cache_groups = [
3896
+ KVCacheGroupSpec(
3897
+ ["layer0"],
3898
+ FullAttentionSpec(
3899
+ block_size=full_attn_block_size,
3900
+ num_kv_heads=1,
3901
+ head_size=1,
3902
+ dtype=torch.float32,
3903
+ ),
3904
+ ),
3905
+ KVCacheGroupSpec(
3906
+ ["layer1"],
3907
+ SlidingWindowSpec(
3908
+ block_size=swa_block_size,
3909
+ num_kv_heads=1,
3910
+ head_size=1,
3911
+ dtype=torch.float32,
3912
+ sliding_window=sliding_window,
3913
+ ),
3914
+ ),
3915
+ ]
3916
+
3917
+ runner = request_runner(
3918
+ block_size=swa_block_size,
3919
+ num_gpu_blocks=num_gpu_blocks,
3920
+ async_scheduling=async_scheduling,
3921
+ kv_cache_groups=kv_cache_groups,
3922
+ retention_interval=0,
3923
+ )
3924
+
3925
+ assert runner.connector_scheduler.config.retention_interval == 0
3926
+
3927
+ num_tokens = 64
3928
+ runner.new_request(token_ids=[0] * num_tokens)
3929
+ runner.manager.prepare_store.side_effect = lambda keys, req_context: (
3930
+ generate_store_output(keys)
3931
+ )
3932
+ runner.run(decoded_tokens=[0])
3933
+
3934
+ runner.manager.prepare_store.side_effect = lambda keys, req_context: (
3935
+ generate_store_output(keys)
3936
+ )
3937
+ runner.run(
3938
+ decoded_tokens=[EOS_TOKEN_ID],
3939
+ # Group 0 (full attn): all 4 blocks stored
3940
+ # Group 1 (SWA): retention_interval=0 -> only replay boundary
3941
+ # replay boundary = num_prompt_tokens - 1 = 63
3942
+ # aligned to alignment_tokens=16 -> 48, end=48/4=12
3943
+ # tail = ceil(8/4) = 2 blocks -> blocks 10, 11
3944
+ expected_stored=(
3945
+ (0, 0),
3946
+ (0, 1),
3947
+ (0, 2),
3948
+ (0, 3),
3949
+ (1, 10),
3950
+ (1, 11),
3951
+ ),
3952
+ )
tests/v1/kv_connector/unit/offloading_connector/utils.py
+4
@@ -180,6 +180,7 @@ class RequestRunner:
180
kv_cache_groups: list[KVCacheGroupSpec] | None = None,
181
extra_config_overrides: dict[str, Any] | None = None,
182
worker_count: int = 1,
183
+ retention_interval: int | None = None,
184
):
185
assert blocks_per_chunk == 1 or kv_cache_groups is None, (
186
"blocks_per_chunk > 1 requires all groups to have the same "
@@ -200,6 +201,7 @@ class RequestRunner:
201
)
202
vllm_config.scheduler_config.async_scheduling = async_scheduling
203
vllm_config.parallel_config.world_size = worker_count
204
+ vllm_config.cache_config.prefix_cache_retention_interval = retention_interval
205
206
extra_config: dict[str, Any] = {
207
"spec_name": "MockOffloadingSpec",
@@ -676,6 +678,7 @@ def request_runner():
678
kv_cache_groups=None,
679
extra_config_overrides=None,
680
worker_count=1,
681
+ retention_interval=None,
682
):
683
runner = RequestRunner(
684
block_size=block_size,
@@ -685,6 +688,7 @@ def request_runner():
688
kv_cache_groups=kv_cache_groups,
689
extra_config_overrides=extra_config_overrides,
690
worker_count=worker_count,
691
+ retention_interval=retention_interval,
692
)
693
runners.append(runner)
694
return runner
tests/v1/kv_connector/unit/test_mooncake_store_coordinator.py
+32
@@ -287,6 +287,38 @@ def test_store_mask_swa_only_window_around_each_lcm_boundary():
287
assert masks[1] == [False, False, False, True, False, False, False, True]
288
289
290
+def test_store_mask_swa_does_not_double_scale_under_dcp():
291
+ """The coordinator receives DCP-resolved specs (worker.py applies
292
+ resolve_dcp_kv_cache_spec) and indexes chunks in that already-scaled block
293
+ size. So dcp_world_size must NOT further scale reachable_block_mask; the
294
+ mask must be identical for any dcp_world_size given the same specs.
295
+
296
+ Regression: previously the coordinator forwarded its dcp_world_size, which
297
+ double-scaled the SWA block size and produced a wrong mask when dcp > 1.
298
+ """
299
+ full = _full(32)
300
+ swa = _swa(block_size=8, sliding_window=8)
301
+ groups = [KVCacheGroupSpec(["L0"], full), KVCacheGroupSpec(["L1"], swa)]
302
+ scheduler_block_size = lcm(32, 8)
303
+
304
+ def make(dcp_world_size):
305
+ return MooncakeStoreCoordinator(
306
+ groups,
307
+ scheduler_block_size=scheduler_block_size,
308
+ hash_block_size=8,
309
+ dcp_world_size=dcp_world_size,
310
+ )
311
+
312
+ mask_dcp1 = make(1).store_mask(64)
313
+ mask_dcp2 = make(2).store_mask(64)
314
+
315
+ # dcp must not change the mask; both match the correct single-block-size
316
+ # result (chunks ending at 32 and 64 -> blocks 3 and 7).
317
+ expected = [False, False, False, True, False, False, False, True]
318
+ assert mask_dcp1[1] == expected
319
+ assert mask_dcp2[1] == expected
320
+
321
+
322
def test_store_mask_swa_wider_window_covers_more_blocks_per_lcm():
323
"""Same hybrid layout but sliding_window=16 (= 2 SWA blocks). Each lcm
324
boundary should now span two SWA tail blocks."""
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/coordinator.py
+4
@@ -298,6 +298,10 @@ class MooncakeStoreCoordinator:
298
use_eagle=use_eagle,
299
retention_interval=retention_interval,
300
reachable_boundaries=reachable_boundaries,
301
+ # ``spec`` is already DCP-resolved (worker.py applies
302
+ # resolve_dcp_kv_cache_spec) and ``end_chunk`` is indexed in
303
+ # that scaled block size, so the mask must not scale again.
304
+ dcp_world_size=1,
305
)
306
if mask is not None:
307
assert len(mask) == end_chunk - start_chunk
vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py
+72
-54
@@ -30,6 +30,7 @@ from vllm.logger import init_logger
30
from vllm.utils.math_utils import cdiv, round_down
31
from vllm.v1.core.kv_cache_manager import KVCacheBlocks
32
from vllm.v1.core.sched.output import SchedulerOutput
33
+from vllm.v1.core.single_type_kv_cache_manager import SingleTypeKVCacheManager
34
from vllm.v1.kv_cache_interface import (
35
ChunkedLocalAttentionSpec,
36
FullAttentionSpec,
@@ -38,6 +39,7 @@ from vllm.v1.kv_cache_interface import (
39
MambaSpec,
40
SlidingWindowSpec,
41
)
42
+from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry
43
from vllm.v1.kv_offload.base import (
44
GPULoadStoreSpec,
45
Locality,
@@ -91,15 +93,12 @@ class GroupOffloadConfig(NamedTuple):
93
kv_event_group_spec: OffloadingEventGroupSpec
94
# None below means full attention
95
sliding_window_size_in_chunks: int | None
96
+ kv_cache_spec: KVCacheSpec
97
+ # Cached manager class for this group's KV cache spec, resolved at init time
98
+ manager_cls: type[SingleTypeKVCacheManager]
99
# Partial-tail data for this group comes from the scheduler's CoW hand-off
100
# rather than the request block table.
101
requires_cow_source: bool = False
97
- # Number of this group's offloaded chunks per full-attention alignment
98
- # segment. Used to skip storing SWA chunks that can never serve a load
99
- # hit (e.g. DeepSeek V4 where SWA groups have much smaller block sizes
100
- # than the MLA full-attention group).
101
- # None for full-attention groups or when the optimization doesn't apply.
102
- alignment_chunk_count: int | None = None
102
# True for EAGLE/MTP draft-model attention groups. The trailing chunk
103
# of these groups is volatile and lacks a stable hash, so it must
104
# be excluded from store and load scheduling.
@@ -126,26 +125,6 @@ def get_sliding_window_size_in_chunks(
125
return None
126
127
129
-def is_store_reachable_swa_chunk(
130
- absolute_chunk_index: int,
131
- storable_chunk_count: int,
132
- alignment_chunk_count: int | None,
133
- sliding_window_chunks: int | None,
134
- is_eagle_group: bool,
135
-) -> bool:
136
- """Return whether an SWA chunk can participate in an external-cache hit."""
137
- if alignment_chunk_count is None:
138
- return True
139
- assert sliding_window_chunks is not None
140
- position_in_segment = absolute_chunk_index % alignment_chunk_count
141
- segment_start = absolute_chunk_index - position_in_segment
142
- actual_segment_length = min(
143
- alignment_chunk_count, storable_chunk_count - segment_start
144
- )
145
- reachable_tail = sliding_window_chunks + int(is_eagle_group)
146
- return position_in_segment >= actual_segment_length - reachable_tail
147
-
148
-
128
def resolve_mamba_align_size(
129
spec: "OffloadingSpec", kv_cache_config: KVCacheConfig
130
) -> int | None:
@@ -176,6 +155,9 @@ class SchedulerOffloadConfig(NamedTuple):
155
num_workers: int
156
offload_prompt_only: bool
157
supports_partial_tail: bool
158
+ alignment_tokens: int | None = None
159
+ retention_interval: int | None = None
160
+ dcp_world_size: int = 1
161
162
@classmethod
163
def from_spec(
@@ -199,23 +181,21 @@ class SchedulerOffloadConfig(NamedTuple):
181
full_attn_tokens_per_chunk.add(tokens_per_block * spec.blocks_per_chunk)
182
183
# Only apply the optimization if there's a single consistent
202
- # full-attention alignment size.
184
+ # full-attention alignment size. For pure SWA/Mamba models (no
185
+ # full-attention groups), fall back to the largest SWA/Mamba
186
+ # tokens_per_chunk so retention_interval has a boundary
187
+ # granularity to work with.
188
alignment_tokens: int | None = None
189
if len(full_attn_tokens_per_chunk) == 1:
190
alignment_tokens = full_attn_tokens_per_chunk.pop()
191
+ elif not full_attn_tokens_per_chunk:
192
+ all_tokens_per_chunk = [
193
+ tpb * spec.blocks_per_chunk for tpb in spec.tokens_per_block
194
+ ]
195
+ if all_tokens_per_chunk:
196
+ alignment_tokens = max(all_tokens_per_chunk)
197
207
- def _alignment_chunk_count(
208
- tokens_per_chunk: int,
209
- sliding_window_size_in_chunks: int | None,
210
- ) -> int | None:
211
- if alignment_tokens is None or sliding_window_size_in_chunks is None:
212
- return None
213
- if alignment_tokens <= tokens_per_chunk:
214
- return None
215
- per_segment = alignment_tokens // tokens_per_chunk
216
- if sliding_window_size_in_chunks >= per_segment:
217
- return None
218
- return per_segment
198
+ retention_interval = vllm_config.cache_config.prefix_cache_retention_interval
199
200
eagle_groups = {
201
idx
@@ -245,6 +225,10 @@ class SchedulerOffloadConfig(NamedTuple):
225
sw = get_sliding_window_size_in_chunks(
226
kv_spec, tokens_per_block * spec.blocks_per_chunk
227
)
228
+ manager_cls = KVCacheSpecRegistry.get_manager_class(kv_spec)
229
+ assert manager_cls is not None, (
230
+ f"No manager found for KV cache spec {type(kv_spec).__name__}"
231
+ )
232
kv_group_configs_list.append(
233
GroupOffloadConfig(
234
group_idx=idx,
@@ -255,9 +239,8 @@ class SchedulerOffloadConfig(NamedTuple):
239
// spec.tokens_per_hash
240
),
241
sliding_window_size_in_chunks=sw,
258
- alignment_chunk_count=_alignment_chunk_count(
259
- tokens_per_block * spec.blocks_per_chunk, sw
260
- ),
242
+ kv_cache_spec=kv_spec,
243
+ manager_cls=manager_cls,
244
kv_event_group_spec=get_offloading_event_group_spec(kv_cache_group),
245
is_eagle_group=idx in eagle_groups,
246
requires_cow_source=(
@@ -289,6 +272,23 @@ class SchedulerOffloadConfig(NamedTuple):
272
and vllm_config.parallel_config.decode_context_parallel_size == 1
273
)
274
275
+ if retention_interval is not None:
276
+ if retention_interval < 0:
277
+ raise ValueError(
278
+ f"VLLM_PREFIX_CACHE_RETENTION_INTERVAL "
279
+ f"({retention_interval}) must be non-negative."
280
+ )
281
+ for config in kv_group_configs:
282
+ if (
283
+ config.sliding_window_size_in_chunks is not None
284
+ and retention_interval % config.tokens_per_chunk != 0
285
+ ):
286
+ raise ValueError(
287
+ f"VLLM_PREFIX_CACHE_RETENTION_INTERVAL "
288
+ f"({retention_interval}) must be a multiple of "
289
+ f"tokens_per_chunk ({config.tokens_per_chunk})."
290
+ )
291
+
292
return cls(
293
num_workers=vllm_config.parallel_config.world_size,
294
kv_group_configs=kv_group_configs,
@@ -296,6 +296,9 @@ class SchedulerOffloadConfig(NamedTuple):
296
tokens_per_hash=spec.tokens_per_hash,
297
offload_prompt_only=spec.offload_prompt_only,
298
supports_partial_tail=supports_partial_tail,
299
+ alignment_tokens=alignment_tokens,
300
+ retention_interval=retention_interval,
301
+ dcp_world_size=vllm_config.parallel_config.decode_context_parallel_size,
302
)
303
304
@@ -1353,6 +1356,13 @@ class OffloadingConnectorScheduler:
1356
# Filter out chunks skipped due to sliding window attention / SSM
1357
# or unreachable by the load path's alignment constraints.
1358
new_offload_keys: list[OffloadKey] = []
1359
+
1360
+ reachable_boundaries: tuple[int, ...] = ()
1361
+ if self.config.retention_interval is not None:
1362
+ reachable_boundaries = (req.num_prompt_tokens - 1,)
1363
+ if req.shared_prefix_boundary:
1364
+ reachable_boundaries += (req.shared_prefix_boundary,)
1365
+
1366
for group_config, group_state in zip(
1367
self.config.kv_group_configs, req_status.group_states
1368
):
@@ -1379,23 +1389,31 @@ class OffloadingConnectorScheduler:
1389
]
1390
assert len(offload_keys) == len(offload_block_ids)
1391
1392
+ # Use reachable_block_mask to filter unreachable chunks
1393
+ # (SWA/Mamba sparsity + retention interval).
1394
+ # reachable_block_mask operates in KV-block coordinates,
1395
+ # so convert chunk indices to block indices.
1396
+ block_mask = group_config.manager_cls.reachable_block_mask(
1397
+ start_block=start_chunk_idx * blocks_per_chunk,
1398
+ end_block=num_chunks * blocks_per_chunk,
1399
+ alignment_tokens=self.config.alignment_tokens,
1400
+ kv_cache_spec=group_config.kv_cache_spec,
1401
+ use_eagle=group_config.is_eagle_group,
1402
+ retention_interval=self.config.retention_interval,
1403
+ reachable_boundaries=reachable_boundaries,
1404
+ dcp_world_size=self.config.dcp_world_size,
1405
+ )
1406
+
1407
for key_idx, (offload_key, block_id) in enumerate(
1408
zip(offload_keys, offload_block_ids)
1409
):
1410
if block_id == 0:
1411
continue
1387
- # Skip SWA chunks that can never serve a load hit:
1388
- # within each full-attention alignment segment, only the
1389
- # trailing chunks queried by _sliding_window_lookup are
1390
- # reachable. EAGLE/MTP requires one additional chunk that
1391
- # lookup later drops as its volatile draft tail.
1392
- abs_chunk_idx = start_chunk_idx + key_idx
1393
- if not is_store_reachable_swa_chunk(
1394
- abs_chunk_idx,
1395
- num_chunks,
1396
- group_config.alignment_chunk_count,
1397
- group_config.sliding_window_size_in_chunks,
1398
- group_config.is_eagle_group,
1412
+ # A chunk is reachable if any of its constituent
1413
+ # blocks is reachable.
1414
+ if block_mask is not None and not any(
1415
+ block_mask[key_idx * blocks_per_chunk + b]
1416
+ for b in range(blocks_per_chunk)
1417
):
1418
continue
1419
new_offload_keys.append(offload_key)
vllm/v1/core/single_type_kv_cache_manager.py
+13
-2
@@ -481,6 +481,7 @@ class SingleTypeKVCacheManager(ABC):
481
use_eagle=self.use_eagle,
482
retention_interval=retention_interval,
483
reachable_boundaries=reachable_boundaries,
484
+ dcp_world_size=self.dcp_world_size,
485
)
486
self.block_pool.cache_full_blocks(
487
request=request,
@@ -504,6 +505,7 @@ class SingleTypeKVCacheManager(ABC):
505
use_eagle: bool,
506
retention_interval: int | None = None,
507
reachable_boundaries: Sequence[int] = (),
508
+ dcp_world_size: int = 1,
509
) -> list[bool] | None:
510
"""Per-block mask for ``cache_full_blocks``. ``None`` means cache
511
every (non-null) block — the default for full attention.
@@ -1028,14 +1030,22 @@ class SlidingWindowManager(SingleTypeKVCacheManager):
1030
use_eagle: bool,
1031
retention_interval: int | None = None,
1032
reachable_boundaries: Sequence[int] = (),
1033
+ dcp_world_size: int = 1,
1034
) -> list[bool] | None:
1035
assert isinstance(kv_cache_spec, SlidingWindowSpec)
1036
if alignment_tokens is None:
1037
# Fast path: when the coordinator imposes no alignment constraint.
1038
return None
1036
- assert alignment_tokens % kv_cache_spec.block_size == 0
1039
+ block_size = kv_cache_spec.block_size * dcp_world_size
1040
+ if alignment_tokens % block_size != 0:
1041
+ # The mask is block-granular, so a sub-block alignment cannot be
1042
+ # represented exactly. This happens for hybrid offloading, where
1043
+ # ``alignment_tokens`` is the full-attention chunk size and need not
1044
+ # be a multiple of this SWA group's (DCP-scaled) block size (e.g.
1045
+ # Gemma). Fall back to dense: every block is reachable, which never
1046
+ # drops a block that could serve a hit.
1047
+ return None
1048
1038
- block_size = kv_cache_spec.block_size
1049
# Contiguous blocks a hit needs at a boundary (incl. the EAGLE peek).
1050
need = cls._contiguous_blocks_for_hit(
1051
window_size=kv_cache_spec.sliding_window,
@@ -1494,6 +1504,7 @@ class MambaManager(SingleTypeKVCacheManager):
1504
use_eagle: bool,
1505
retention_interval: int | None = None,
1506
reachable_boundaries: Sequence[int] = (),
1507
+ dcp_world_size: int = 1,
1508
) -> list[bool] | None:
1509
"""Sparse Mamba state-snapshot retention.
1510