[1/N][KV Connector] Identify externally transferable KV cache groups (#53779)
Signed-off-by: Matthew Bonanni <mbonanni@redhat.com>
Matthew Bonanni committed
Aug 27, 2026 at 11:21 UTC
d85708f7a4362334eb58cff1fdf5265b49cac310
11 files changed
+137
-29
tests/v1/core/test_kv_cache_utils.py
+24
@@ -140,6 +140,30 @@ def new_kv_cache_spec(
140
)
141
142
143
+def test_kv_cache_config_selects_only_transferable_groups():
144
+ """Connectors must see a stable projection of transfer-eligible groups."""
145
+ groups = [
146
+ KVCacheGroupSpec(["layer.0"], new_kv_cache_spec()),
147
+ KVCacheGroupSpec(["layer.1"], new_kv_cache_spec(), enable_kv_transfer=False),
148
+ KVCacheGroupSpec(["layer.2"], new_kv_cache_spec()),
149
+ ]
150
+ config = KVCacheConfig(
151
+ num_blocks=1,
152
+ kv_cache_tensors=[],
153
+ kv_cache_groups=groups,
154
+ )
155
+
156
+ assert config.transfer_group_ids == (0, 2)
157
+ assert config.transfer_groups == (groups[0], groups[2])
158
+ assert config.transfer_group_index_by_layer == {"layer.0": 0, "layer.2": 1}
159
+ first_blocks = [1, 2]
160
+ third_blocks = [4]
161
+ assert config.select_transfer_block_ids((first_blocks, [3], third_blocks)) == (
162
+ first_blocks,
163
+ third_blocks,
164
+ )
165
+
166
+
167
def new_sliding_window_spec(
168
block_size=16,
169
num_kv_heads=2,
tests/v1/kv_connector/unit/test_mooncake_store_scheduler.py
+17
@@ -33,6 +33,9 @@ def _make_bare_scheduler(
33
scheduler._block_size = 16
34
scheduler._hash_block_size = hash_block_size
35
scheduler.enable_partial_hash_hits = enable_partial_hash_hits
36
+ scheduler.kv_cache_config = SimpleNamespace(
37
+ select_transfer_block_ids=lambda block_ids: tuple(block_ids)
38
+ )
39
scheduler.load_specs = {}
40
scheduler._unfinished_request_ids = {"req-0"}
41
scheduler._unfinished_requests = {}
@@ -179,6 +182,20 @@ def _add_unfinished_request(
182
)
183
184
185
+def test_update_state_excludes_nontransfer_groups():
186
+ """Store metadata must match the worker's registered cache groups."""
187
+ scheduler = _make_bare_scheduler()
188
+ scheduler.kv_cache_config = SimpleNamespace(
189
+ select_transfer_block_ids=lambda block_ids: (block_ids[0],)
190
+ )
191
+ request = SimpleNamespace(request_id="req-1")
192
+ blocks = SimpleNamespace(get_block_ids=lambda: ([1, 2], [9]))
193
+
194
+ scheduler.update_state_after_alloc(request, blocks, num_external_tokens=32)
195
+
196
+ assert scheduler._unfinished_requests["req-1"][1] == ([1, 2],)
197
+
198
+
199
def _setup_decode_request(
200
*,
201
kv_role: str = "kv_consumer",
tests/v1/kv_connector/unit/test_nixl_connector_hma.py
+14
-1
@@ -854,7 +854,10 @@ def test_post_process_zeroes_untransferred_tail():
854
worker.device_kv_caches = {"attn.0": attn_cache, "mamba.0": mamba_cache}
855
fa_group = MagicMock(layer_names=["attn.0"])
856
ssm_group = MagicMock(layer_names=["mamba.0"])
857
- worker.kv_cache_config = MagicMock(kv_cache_groups=[fa_group, ssm_group])
857
+ worker.kv_cache_config = MagicMock(
858
+ kv_cache_groups=[fa_group, ssm_group],
859
+ transfer_groups=[fa_group, ssm_group],
860
+ )
861
# The cached property filters mamba layers out of the permuted caches.
862
attn_caches = NixlConnectorWorker._attention_kv_caches.func(worker)
863
assert len(attn_caches) == 1 and attn_caches[0] is attn_cache
@@ -1477,6 +1480,16 @@ def test_exchange_clipped_blocks_ssm_positional_states():
1480
assert clipped == ([1, 2, 3], [0, 5, 6, 7])
1481
1482
1483
+@pytest.mark.cpu_test
1484
+def test_exchange_clipped_blocks_excludes_nontransfer_groups():
1485
+ """Scheduler block tables must match the worker's registered regions."""
1486
+ sched = make_nixl_scheduler()
1487
+ sched.kv_cache_config = make_kv_cache_config(block_size=16, swa_enabled=True)
1488
+ sched.kv_cache_config.kv_cache_groups[1].enable_kv_transfer = False
1489
+
1490
+ assert sched.get_exchange_clipped_blocks(([1, 2], [9])) == ([1, 2],)
1491
+
1492
+
1493
# ── Hybrid MLA+SSM (KimiLinear-shaped KDA+MLA) tests ─────────────────────
1494
1495
tests/v1/kv_connector/unit/utils.py
+8
@@ -524,6 +524,10 @@ def make_nixl_scheduler(
524
sched = object.__new__(NixlConnectorScheduler)
525
sched._has_mamba = has_mamba
526
sched._is_hma_required = is_hma_required
527
+ sched.kv_cache_config = make_kv_cache_config(
528
+ block_size=16,
529
+ mamba_enabled=has_mamba,
530
+ )
531
532
if heartbeat:
533
sched._heartbeat_by_engine = {}
@@ -582,6 +586,10 @@ def make_nixl_push_scheduler(
586
sched.side_channel_port = 5600
587
sched.is_bidirectional_kv_xfer_enabled = is_bidirectional_kv_xfer_enabled
588
sched._has_mamba = has_mamba
589
+ sched.kv_cache_config = make_kv_cache_config(
590
+ block_size=16,
591
+ mamba_enabled=has_mamba,
592
+ )
593
594
# vllm_config is consulted for parallel_config.tensor_parallel_size.
595
vllm_config = MagicMock()
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/connector.py
+2
-2
@@ -97,7 +97,7 @@ class MooncakeStoreConnector(KVConnectorBase_V1, SupportsHMA):
97
98
unsupported: list[str] = []
99
cache_block_size = vllm_config.cache_config.block_size
100
- for g_idx, g in enumerate(kv_cache_config.kv_cache_groups):
100
+ for g_idx, g in enumerate(kv_cache_config.transfer_groups):
101
spec = g.kv_cache_spec
102
if isinstance(spec, CrossAttentionSpec):
103
unsupported.append(f"group {g_idx}: CrossAttentionSpec")
@@ -110,7 +110,7 @@ class MooncakeStoreConnector(KVConnectorBase_V1, SupportsHMA):
110
)
111
pcp = vllm_config.parallel_config.prefill_context_parallel_size
112
dcp = vllm_config.parallel_config.decode_context_parallel_size
113
- if len(kv_cache_config.kv_cache_groups) > 1 and pcp * dcp > 1:
113
+ if len(kv_cache_config.transfer_groups) > 1 and pcp * dcp > 1:
114
unsupported.append(
115
f"PCP/DCP > 1 (pcp={pcp}, dcp={dcp}) with hybrid attention"
116
)
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/scheduler.py
+15
-11
@@ -69,6 +69,7 @@ class MooncakeStoreScheduler:
69
kv_event_config and kv_event_config.enable_kv_cache_events
70
)
71
self.client = LookupKeyClient(vllm_config)
72
+ self.kv_cache_config = kv_cache_config
73
74
# Align with the engine's own scheduler_block_size and hash_block_size.
75
self._block_size, self._hash_block_size = resolve_kv_cache_block_sizes(
@@ -157,7 +158,9 @@ class MooncakeStoreScheduler:
158
"""Update state after block allocation."""
159
local_block_ids: tuple[list[int], ...] = ()
160
if num_external_tokens > 0:
160
- local_block_ids = blocks.get_block_ids()
161
+ local_block_ids = self.kv_cache_config.select_transfer_block_ids(
162
+ blocks.get_block_ids()
163
+ )
164
165
self._unfinished_requests[request.request_id] = (request, local_block_ids)
166
self._unfinished_request_ids.add(request.request_id)
@@ -220,12 +223,12 @@ class MooncakeStoreScheduler:
223
request_tuple = self._unfinished_requests.get(request.req_id)
224
request_real = request_tuple[0] # type: ignore[index]
225
223
- if isinstance(request.block_ids, tuple):
224
- # Multi-group: preserve per-group structure.
225
- unfolded_block_ids = tuple(b.copy() for b in request.block_ids)
226
- else:
227
- # Single-group legacy: list[int] -> 1-tuple.
228
- unfolded_block_ids = (request.block_ids.copy(),)
226
+ unfolded_block_ids = tuple(
227
+ blocks.copy()
228
+ for blocks in self.kv_cache_config.select_transfer_block_ids(
229
+ request.block_ids
230
+ )
231
+ )
232
233
prefill_tokens = _new_req_prefill_tokens(request)
234
request_tracker = RequestTracker(
@@ -259,16 +262,17 @@ class MooncakeStoreScheduler:
262
if can_process_cached:
263
for i, req_id in enumerate(cached_reqs.req_ids):
264
new_block_ids = cached_reqs.new_block_ids[i]
265
+ if new_block_ids:
266
+ new_block_ids = self.kv_cache_config.select_transfer_block_ids(
267
+ new_block_ids
268
+ )
269
270
req_meta = None
271
if req_id in cached_reqs.resumed_req_ids:
272
# Resumed after preemption
273
if not new_block_ids:
274
continue
268
- if isinstance(new_block_ids, tuple):
269
- new_block_ids = tuple(b.copy() for b in new_block_ids)
270
- else:
271
- new_block_ids = (new_block_ids.copy(),)
275
+ new_block_ids = tuple(b.copy() for b in new_block_ids)
276
load_spec = self.load_specs.pop(req_id, None)
277
request_tuple = self._unfinished_requests.get(req_id)
278
request_real = request_tuple[0] # type: ignore[index]
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/worker.py
+1
-1
@@ -1497,7 +1497,7 @@ class MooncakeStoreWorker:
1497
# Single-group + PCP/DCP > 1: scale the lone group's spec.block_size to
1498
# self.block_size (= scheduler_block_size) so the coordinator's
1499
# ``block_size % hash_block_size == 0`` invariant holds.
1500
- groups = list(kv_cache_config.kv_cache_groups)
1500
+ groups = list(kv_cache_config.transfer_groups)
1501
if len(groups) == 1 and groups[0].kv_cache_spec.block_size != self.block_size:
1502
g = groups[0]
1503
groups = [
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_scheduler.py
+9
-6
@@ -90,12 +90,12 @@ class NixlBaseConnectorScheduler:
90
# Also handle unlikely SW-only model case instead of checking num_groups>1.
91
and any(
92
not isinstance(g.kv_cache_spec, FullAttentionSpec)
93
- for g in kv_cache_config.kv_cache_groups
93
+ for g in kv_cache_config.transfer_groups
94
)
95
)
96
self._has_mamba = any(
97
isinstance(g.kv_cache_spec, MambaSpec)
98
- for g in kv_cache_config.kv_cache_groups
98
+ for g in kv_cache_config.transfer_groups
99
)
100
101
logger.info("Initializing NIXL Scheduler %s", engine_id)
@@ -131,7 +131,7 @@ class NixlBaseConnectorScheduler:
131
(g.kv_cache_spec.sliding_window, g.kv_cache_spec.block_size)
132
if isinstance(g.kv_cache_spec, SlidingWindowSpec)
133
else (0, self.block_size)
134
- for g in kv_cache_config.kv_cache_groups
134
+ for g in kv_cache_config.transfer_groups
135
]
136
# cdiv(n_tokens, block_size) gives blocks/window; add 1 to conservatively
137
# account for boundary overlap eg window isn't fully aligned with blocks.
@@ -146,7 +146,7 @@ class NixlBaseConnectorScheduler:
146
g.kv_cache_spec.num_speculative_blocks
147
if isinstance(g.kv_cache_spec, MambaSpec)
148
else None
149
- for g in kv_cache_config.kv_cache_groups
149
+ for g in kv_cache_config.transfer_groups
150
]
151
# Only "all" mode keeps a state per block position; the other modes
152
# keep a single running state in the last non-speculative slot.
@@ -260,8 +260,11 @@ class NixlBaseConnectorScheduler:
260
for per-step partial lists (host-buffer save), where the SSM strip
261
does not apply.
262
"""
263
- if len(block_ids) == 0 or not self._is_hma_required:
264
- # No blocks to clip eg Full prefix cache hit or not a hybrid model.
263
+ if len(block_ids) == 0:
264
+ # No blocks to clip, e.g. a full prefix cache hit.
265
+ return block_ids
266
+ block_ids = self.kv_cache_config.select_transfer_block_ids(block_ids)
267
+ if not self._is_hma_required:
268
return block_ids
269
# NOTE (NickLucche) This logic is currently handled at the connector level
270
# because offloading connectors might want to receive the whole sequence even
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py
+6
-6
@@ -300,13 +300,13 @@ class NixlBaseConnectorWorker:
300
not vllm_config.scheduler_config.disable_hybrid_kv_cache_manager
301
and any(
302
not isinstance(g.kv_cache_spec, FullAttentionSpec)
303
- for g in kv_cache_config.kv_cache_groups
303
+ for g in kv_cache_config.transfer_groups
304
)
305
)
306
self.kv_cache_config = kv_cache_config
307
self._layer_specs = {
308
layer: group.kv_cache_spec
309
- for group in kv_cache_config.kv_cache_groups
309
+ for group in kv_cache_config.transfer_groups
310
for layer in group.layer_names
311
}
312
@@ -318,7 +318,7 @@ class NixlBaseConnectorWorker:
318
self._conv_decomp: MambaConvSplitInfo | None = None
319
self._has_mamba = any(
320
isinstance(g.kv_cache_spec, MambaSpec)
321
- for g in kv_cache_config.kv_cache_groups
321
+ for g in kv_cache_config.transfer_groups
322
)
323
if self._has_mamba:
324
assert self._is_hma_required
@@ -554,7 +554,7 @@ class NixlBaseConnectorWorker:
554
# Unwrap UniformTypeKVCacheSpecs to get the representative spec type
555
self._group_spec_types = tuple(
556
get_representative_spec_type(g.kv_cache_spec)
557
- for g in self.kv_cache_config.kv_cache_groups
557
+ for g in self.kv_cache_config.transfer_groups
558
)
559
560
# Per-region MLA flag, 1:1 with block_len_per_layer. True -> REPLICATE
@@ -1906,7 +1906,7 @@ class NixlBaseConnectorWorker:
1906
)
1907
mamba_layers = {
1908
name
1909
- for g, group in enumerate(self.kv_cache_config.kv_cache_groups)
1909
+ for g, group in enumerate(self.kv_cache_config.transfer_groups)
1910
if _is_ssm_spec(self._group_spec_types[g])
1911
for name in group.layer_names
1912
}
@@ -2361,7 +2361,7 @@ class NixlBaseConnectorWorker:
2361
return block_ids
2362
block_arange = np.arange(0, ratio).reshape(1, -1)
2363
# Mamba blocks have no logical<>physical discrepancy (block-size=1)
2364
- group_specs = self.kv_cache_config.kv_cache_groups
2364
+ group_specs = self.kv_cache_config.transfer_groups
2365
physical_block_ids = []
2366
for i, group in enumerate(block_ids):
2367
spec = group_specs[i].kv_cache_spec
vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py
+1
-1
@@ -290,7 +290,7 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
290
assert (
291
len(remote_block_ids)
292
== len(local_block_ids)
293
- == len(self.kv_cache_config.kv_cache_groups)
293
+ == len(self.kv_cache_config.transfer_groups)
294
)
295
local_block_ids, remote_block_ids = self._apply_prefix_caching(
296
decode_block_ids=local_block_ids,
vllm/v1/kv_cache_interface.py
+40
-1
@@ -5,10 +5,11 @@ from __future__ import annotations
5
6
import copy
7
from collections import Counter
8
-from collections.abc import Collection
8
+from collections.abc import Collection, Sequence
9
from dataclasses import dataclass, fields, replace
10
from enum import Enum, IntEnum
11
from fractions import Fraction
12
+from functools import cached_property
13
from math import prod
14
from typing import TYPE_CHECKING, TypeVar
15
@@ -1143,6 +1144,8 @@ class KVCacheGroupSpec:
1144
kv_cache_spec: KVCacheSpec
1145
# Whether this group contains EAGLE/MTP draft attention layers.
1146
is_eagle_group: bool = False
1147
+ # Whether this group is part of the externally transferable KV state.
1148
+ enable_kv_transfer: bool = True
1149
1150
1151
@dataclass
@@ -1168,6 +1171,42 @@ class KVCacheConfig:
1171
kv_cache_layout: str | None = None
1172
"""The KV cache layout resolved by the engine core, adopted by all workers."""
1173
1174
+ @cached_property
1175
+ def transfer_group_ids(self) -> tuple[int, ...]:
1176
+ """IDs of cache groups that participate in external KV transfer."""
1177
+ return tuple(
1178
+ group_id
1179
+ for group_id, group in enumerate(self.kv_cache_groups)
1180
+ if group.enable_kv_transfer
1181
+ )
1182
+
1183
+ @cached_property
1184
+ def transfer_groups(self) -> tuple[KVCacheGroupSpec, ...]:
1185
+ """Cache groups that participate in external KV transfer."""
1186
+ return tuple(
1187
+ self.kv_cache_groups[group_id] for group_id in self.transfer_group_ids
1188
+ )
1189
+
1190
+ @cached_property
1191
+ def transfer_group_index_by_layer(self) -> dict[str, int]:
1192
+ """Transfer-group tuple index for each participating layer."""
1193
+ return {
1194
+ layer_name: group_index
1195
+ for group_index, group in enumerate(self.transfer_groups)
1196
+ for layer_name in group.layer_names
1197
+ }
1198
+
1199
+ def select_transfer_block_ids(
1200
+ self, block_ids: Sequence[list[int]]
1201
+ ) -> tuple[list[int], ...]:
1202
+ """Select block IDs for externally transferable cache groups."""
1203
+ if len(block_ids) != len(self.kv_cache_groups):
1204
+ raise ValueError(
1205
+ f"Expected {len(self.kv_cache_groups)} KV cache groups, "
1206
+ f"got {len(block_ids)}."
1207
+ )
1208
+ return tuple(block_ids[group_id] for group_id in self.transfer_group_ids)
1209
+
1210
@property
1211
def has_mamba_layers(self) -> bool:
1212
return any(isinstance(g.kv_cache_spec, MambaSpec) for g in self.kv_cache_groups)