main
py 1,653 lines 57.8 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """End-to-end NIXL descriptor geometry invariants for hybrid MLA+SSM models
4 under heterogeneous P/D block geometry (TP-sharded KDA-style state, so
5 the mamba-aligned logical block size differs between P and D while the
6 kernel-granularity pages stay equal).
7
8 The invariant under test: every LOCAL byte range a request's READ transfers
9 into must lie within that request's own blocks. A violation means an
10 incoming transfer can overwrite a co-resident request's KV or mamba state
11 mid-decode (silent corruption of an unrelated request).
12 """
13
14 from collections import defaultdict
15 from threading import Event, Lock
16 from types import SimpleNamespace
17 from unittest.mock import MagicMock, patch
18
19 import msgspec
20 import numpy as np
21 import pytest
22 import torch
23
24 from vllm.distributed.kv_transfer.kv_connector.v1.nixl import base_worker as bw
25 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
26 NixlAgentMetadata,
27 )
28 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.push_worker import (
29 NixlPushConnectorWorker,
30 )
31 from vllm.v1.kv_cache_interface import (
32 KVCacheConfig,
33 KVCacheGroupSpec,
34 KVCacheLayout,
35 KVCacheTensor,
36 MLAAttentionSpec,
37 create_kv_cache_views,
38 )
39
40 from .utils import create_vllm_config
41
42
43 class _RecordingNixl:
44 """Minimal NIXL wrapper stand-in that records descriptor lists and
45 prepared transfers so tests can resolve desc ids to byte ranges."""
46
47 def __init__(self, *args, **kwargs):
48 self.dlists: dict[int, np.ndarray] = {}
49 self.xfers: list[tuple] = []
50 self.registered: list[tuple[list[tuple], object]] = []
51 self._next_handle = 1
52
53 def get_reg_descs(self, caches_data, mem_type):
54 return caches_data
55
56 def register_memory(self, descs, backends=None):
57 self.registered.append((descs, backends))
58
59 def deregister_memory(self, descs):
60 pass
61
62 def get_agent_metadata(self):
63 return b"agent-meta"
64
65 def get_xfer_descs(self, blocks_data, mem_type):
66 return blocks_data
67
68 def prep_xfer_dlist(self, agent, descs):
69 handle = self._next_handle
70 self._next_handle += 1
71 self.dlists[handle] = np.asarray(descs, dtype=np.uint64).reshape(-1, 3)
72 return handle
73
74 def add_remote_agent(self, metadata):
75 return "remote-agent"
76
77 def make_prepped_xfer(
78 self, op, local_handle, local_ids, remote_handle, remote_ids, notif_msg=None
79 ):
80 handle = self._next_handle
81 self._next_handle += 1
82 self.xfers.append(
83 (
84 op,
85 local_handle,
86 np.asarray(local_ids),
87 remote_handle,
88 np.asarray(remote_ids),
89 )
90 )
91 return handle
92
93 def transfer(self, handle):
94 pass
95
96 def check_xfer_state(self, handle):
97 return "DONE"
98
99 def get_xfer_telemetry(self, handle):
100 from types import SimpleNamespace
101
102 return SimpleNamespace(
103 xferDuration=1.0, postDuration=1.0, totalBytes=1, descCount=1
104 )
105
106 def release_xfer_handle(self, handle):
107 pass
108
109 def release_dlist_handle(self, handle):
110 pass
111
112 def send_notif(self, agent, notif_msg=None):
113 pass
114
115 def get_new_notifs(self):
116 return {}
117
118 def remove_remote_agent(self, agent):
119 pass
120
121
122 def _make_packed_mla_view_worker(
123 layouts,
124 block_stride,
125 *,
126 num_blocks=4,
127 pp_size=1,
128 backend_names=("FLASHMLA",),
129 ):
130 """Register real strided views; only NIXL and distributed runtime are fake."""
131 block_size = 16
132 raw = torch.zeros(num_blocks * block_stride, dtype=torch.int8)
133 tensors, groups, caches = [], [], {}
134 for name, (offset, page_size) in layouts.items():
135 spec = MLAAttentionSpec(
136 block_size=block_size,
137 num_kv_heads=1,
138 head_size=page_size // block_size,
139 dtype=torch.uint8,
140 )
141 tensor = KVCacheTensor(
142 size=raw.nbytes,
143 layers=[name],
144 layer_stride=page_size,
145 block_stride=block_stride,
146 offset=offset,
147 )
148 tensors.append(tensor)
149 groups.append(KVCacheGroupSpec([name], spec))
150 (caches[name],) = create_kv_cache_views(
151 raw, spec, num_blocks, KVCacheLayout.BLHNC, tensor
152 )
153
154 config = create_vllm_config(block_size=block_size)
155 config.parallel_config.pipeline_parallel_size = pp_size
156 config.kv_transfer_config.kv_role = "kv_producer"
157 config.cache_config.kv_cache_layout = "BLHNC"
158 backends = []
159 for name in backend_names:
160 backend = MagicMock()
161 backend.get_supported_kernel_block_sizes.return_value = [block_size]
162 backend.get_name.return_value = name
163 backend.full_cls_name.return_value = f"fake.{name}"
164 backends.append(backend)
165 with (
166 patch.object(bw, "NixlWrapper", _RecordingNixl),
167 patch.object(bw, "get_tensor_model_parallel_rank", return_value=0),
168 patch.object(bw, "get_tensor_model_parallel_world_size", return_value=1),
169 patch.object(bw, "get_current_attn_backends", return_value=backends),
170 patch("threading.Thread"),
171 ):
172 worker = NixlPushConnectorWorker(
173 config,
174 "producer" if pp_size > 1 else "consumer",
175 KVCacheConfig(num_blocks, tensors, groups),
176 )
177 worker.use_mla = True
178 worker.register_kv_caches(caches)
179 return worker, raw
180
181
182 @pytest.mark.cpu_test
183 @pytest.mark.parametrize("num_layers", [1, 2])
184 def test_packed_mla_pp1_push_peer_transfers_whole_rows(num_layers):
185 layouts = {f"L{i}": (i * 128, 128) for i in range(num_layers)}
186 stride = num_layers * 128
187 worker, raw = _make_packed_mla_view_worker(layouts, stride)
188 assert worker._registered_descs == [[(raw.data_ptr(), raw.nbytes, 0, "")]]
189 assert worker.src_blocks_data.tolist() == [
190 [raw.data_ptr() + block * stride, stride, 0] for block in range(4)
191 ]
192 assert worker._transfer_layer_group_ids == ()
193 metadata = msgspec.msgpack.decode(
194 worker.xfer_handshake_metadata.agent_metadata_bytes, type=NixlAgentMetadata
195 )
196 assert metadata.packed_member_layouts == layouts
197
198
199 @pytest.mark.cpu_test
200 @pytest.mark.parametrize(
201 "remote_backends, compatible",
202 [
203 (("INDEXER", "FLASHMLA"), True),
204 (("FLASHMLA", "OTHER"), False),
205 (("FLASHMLA",), False),
206 ],
207 )
208 def test_packed_push_compatibility_hash_uses_all_backends_in_stable_order(
209 remote_backends, compatible
210 ):
211 producer, _ = _make_packed_mla_view_worker(
212 {"L0": (0, 128)},
213 128,
214 pp_size=2,
215 backend_names=("FLASHMLA", "INDEXER"),
216 )
217 consumer, _ = _make_packed_mla_view_worker(
218 {"L0": (0, 128)}, 128, backend_names=remote_backends
219 )
220 assert (producer.compat_hash == consumer.compat_hash) is compatible
221
222
223 @pytest.mark.cpu_test
224 @pytest.mark.parametrize("p_num_blocks, d_num_blocks", [(4, 4), (4, 6), (6, 4)])
225 def test_packed_mla_pp_pairs_asymmetric_strides_and_overlapping_layers(
226 p_num_blocks, d_num_blocks
227 ):
228 # Different cache groups overlay pages of different sizes at the same address.
229 # PP also changes both the placement and the stride of the matching D pages.
230 producer, p_raw = _make_packed_mla_view_worker(
231 {"L2": (0, 128), "L2.swa": (0, 64), "L3": (128, 64)},
232 192,
233 num_blocks=p_num_blocks,
234 pp_size=2,
235 )
236 consumer, d_raw = _make_packed_mla_view_worker(
237 {"L0": (0, 128), "L2": (128, 128), "L2.swa": (0, 64), "L3": (64, 64)},
238 256,
239 num_blocks=d_num_blocks,
240 )
241 assert producer.block_len_per_layer == [128, 64, 64]
242 assert producer._transfer_layer_group_ids == (0, 1, 2)
243 assert producer.num_descs == 3 * p_num_blocks
244 local_ids = producer._compute_desc_ids(
245 [[1], [2], [3]],
246 producer.num_blocks,
247 None,
248 1,
249 region_num_blocks=producer.dst_region_num_blocks[producer.engine_id],
250 )
251 assert producer.src_blocks_data[local_ids].tolist() == [
252 [p_raw.data_ptr() + 192, 128, 0],
253 [p_raw.data_ptr() + 2 * 192, 64, 0],
254 [p_raw.data_ptr() + 128 + 3 * 192, 64, 0],
255 ]
256 # Each decoder TP rank receives the complete MLA page, without head slicing.
257 for rank in (0, 1):
258 metadata = msgspec.msgpack.decode(
259 consumer.xfer_handshake_metadata.agent_metadata_bytes,
260 type=NixlAgentMetadata,
261 )
262 producer.add_remote_agent(metadata, remote_tp_rank=rank, remote_tp_size=2)
263 assert metadata.region_num_blocks == [d_num_blocks] * 3
264 assert metadata.region_names == ["L2", "L2.swa", "L3"]
265 remote_ids = producer._compute_desc_ids(
266 [[3], [1], [2]],
267 metadata.num_blocks,
268 None,
269 1,
270 region_num_blocks=producer.dst_region_num_blocks[consumer.engine_id],
271 )
272 handle = producer.dst_xfer_side_handles[consumer.engine_id][rank]
273 remote_descs = producer.nixl_wrapper.dlists[handle]
274 assert len(remote_descs) == 3 * d_num_blocks
275 assert remote_descs[remote_ids].tolist() == [
276 [d_raw.data_ptr() + 128 + 3 * 256, 128, 0],
277 [d_raw.data_ptr() + 256, 64, 0],
278 [d_raw.data_ptr() + 64 + 2 * 256, 64, 0],
279 ]
280 aligned = msgspec.msgpack.encode(metadata)
281 producer._align_remote_regions_by_layer(metadata)
282 assert msgspec.msgpack.encode(metadata) == aligned
283
284
285 @pytest.mark.cpu_test
286 @pytest.mark.parametrize("remote_block_size", [8, 32])
287 def test_packed_mla_rejects_unequal_block_sizes_before_peer_registration(
288 remote_block_size,
289 ):
290 producer, _ = _make_packed_mla_view_worker({"L0": (0, 128)}, 128, pp_size=2)
291 metadata = msgspec.msgpack.decode(
292 producer.xfer_handshake_metadata.agent_metadata_bytes, type=NixlAgentMetadata
293 )
294 metadata.engine_id = "remote"
295 metadata.block_size = remote_block_size
296 with pytest.raises(NotImplementedError, match="identical P/D block sizes"):
297 producer.add_remote_agent(metadata)
298 assert len(producer.nixl_wrapper.dlists) == 1 # Only the local list exists.
299 assert producer.tp_mappings == {}
300 assert "remote" not in producer.dst_num_blocks
301 with pytest.raises(KeyError):
302 producer.transfer_topo.get_engine_info("remote")
303
304
305 @pytest.mark.cpu_test
306 def test_local_descriptors_follow_each_region_pool_capacity():
307 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
308 NixlConnectorWorker,
309 )
310
311 worker = object.__new__(NixlConnectorWorker)
312 worker.transfer_topo = MagicMock()
313 worker.device_id = 0
314 worker.block_len_per_layer = [16, 16]
315 worker.block_stride_per_layer = [16, 16]
316 worker.region_num_blocks = [2, 3]
317 worker._transfer_layer_region_indices = ()
318
319 descriptors = worker._build_fa_local([100, 1000], block_size_ratio=1)
320
321 assert descriptors[:, 0].tolist() == [100, 116, 1000, 1016, 1032]
322
323
324 def _register_overlaid_mla_worker(
325 *, push_pp: bool = False, tail_bytes: int = 0, page_covers_view: bool = True
326 ):
327 """Register two MLA layers overlaid on one allocation.
328
329 ``tail_bytes`` leaves spare bytes past the last block, the way an allocation
330 rounded up to a page boundary does. Clearing ``page_covers_view`` narrows
331 each layer's view below its page, leaving the block interior non-contiguous.
332 """
333 from vllm.distributed.kv_transfer.kv_connector.v1.nixl import base_worker as bw
334 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.push_worker import (
335 NixlPushConnectorWorker,
336 )
337 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
338 NixlConnectorWorker,
339 )
340 from vllm.v1.kv_cache_interface import (
341 KVCacheConfig,
342 KVCacheGroupSpec,
343 KVCacheTensor,
344 MLAAttentionSpec,
345 )
346
347 num_blocks = 4
348 spec = MLAAttentionSpec(
349 block_size=4,
350 num_kv_heads=1,
351 head_size=8,
352 dtype=torch.uint8,
353 )
354 page_size = spec.page_size_bytes
355 block_stride = 2 * page_size
356 view_size = page_size if page_covers_view else page_size - 8
357 allocation = torch.zeros(num_blocks * block_stride + tail_bytes, dtype=torch.uint8)
358 backing = allocation[: num_blocks * block_stride].view(num_blocks, block_stride)
359 caches = {
360 "layer.0": backing[:, :view_size],
361 "layer.1": backing[:, :view_size],
362 }
363 groups = [KVCacheGroupSpec([layer_name], spec) for layer_name in caches]
364
365 worker_cls = NixlPushConnectorWorker if push_pp else NixlConnectorWorker
366 worker = object.__new__(worker_cls)
367 if push_pp:
368 worker._push_writer_stop = Event()
369 worker._push_writer_wake = Event()
370 worker._push_writer_thread = MagicMock()
371 worker._sending_transfers_lock = Lock()
372 worker._sending_transfers = defaultdict(list)
373 worker.tp_rank = 0
374 worker.world_size = 1
375 worker.transfer_tp_rank = 0
376 worker.transfer_tp_size = 1
377 worker.block_size = 4
378 worker.engine_id = "local-engine"
379 worker.use_mla = True
380 worker.model_config = MagicMock()
381 worker.model_config.get_total_num_kv_heads.return_value = 1
382 worker.attn_backends = []
383 worker._has_mamba = False
384 worker._is_csa_linear = False
385 worker.vllm_config = MagicMock()
386 worker.backend_name = "FLASHMLA"
387 worker.num_blocks = num_blocks
388 worker.nixl_memory_type = "VRAM"
389 worker.nixl_backends = None
390 worker.nixl_wrapper = _RecordingNixl()
391 worker._registered_descs = []
392 worker.dst_num_blocks = {}
393 worker.dst_region_num_blocks = {}
394 worker.dst_region_group_ids = {}
395 worker.dst_uses_region_group_mapping = {}
396 worker.dst_region_mem_types = {}
397 worker._desc_is_dram_by_block_size = {}
398 worker._desc_pos_by_block_size = {}
399 worker._dram_src_handles_by_block_size = {}
400 worker.src_xfer_handles_by_block_size = {}
401 worker.kv_caches_base_addr = defaultdict(dict)
402 worker._mamba_ssm_size = (0, 0)
403 worker.kv_cache_layout = "LBNHC"
404 worker.host_buffer_kv_cache_layout = "LBNHC"
405 worker._physical_blocks_per_logical_kv_block = 1
406 worker._logical_num_blocks = num_blocks
407 worker.region_mem_types = []
408 worker.region_group_ids = []
409 worker._mixed_mem_types = False
410 worker.region_names = []
411 worker.region_num_blocks = []
412 worker._transfer_layer_names = ()
413 worker._transfer_layer_region_indices = ()
414 worker._transfer_layer_group_ids = ()
415 worker._region_is_mla = []
416 worker.block_len_per_layer = []
417 worker.block_stride_per_layer = []
418 worker.device_id = 0
419 worker.use_host_buffer = False
420 worker.host_xfer_buffers = {}
421 worker.device_kv_caches = {}
422 worker.pp_size = 2 if push_pp else 1
423 worker._is_hma_required = True
424 worker._has_packed_cache = False
425 worker.dcp_size = 1
426 worker.pcp_size = 1
427 worker.kv_buffer_device = "cuda"
428 worker._layer_specs = {name: spec for name in caches}
429 worker.kv_cache_config = KVCacheConfig(
430 num_blocks=num_blocks,
431 kv_cache_tensors=[
432 KVCacheTensor(
433 size=allocation.nbytes,
434 layers=[name],
435 layer_stride=page_size,
436 block_stride=block_stride,
437 )
438 for name in caches
439 ],
440 kv_cache_groups=groups,
441 )
442
443 with (
444 patch.object(bw, "TransferTopology", return_value=MagicMock()),
445 patch.object(bw, "compute_nixl_compatibility_hash", return_value="hash"),
446 ):
447 worker.register_kv_caches(caches)
448
449 return SimpleNamespace(
450 worker=worker,
451 allocation=allocation,
452 num_blocks=num_blocks,
453 block_stride=block_stride,
454 )
455
456
457 @pytest.mark.cpu_test
458 @pytest.mark.parametrize("push_pp", [False, True])
459 def test_overlaid_transfer_groups_share_region_geometry(push_pp):
460 """Groups overlaid on one allocation share its transfer region."""
461 import msgspec
462
463 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
464 NixlAgentMetadata,
465 )
466
467 registered = _register_overlaid_mla_worker(push_pp=push_pp)
468 worker = registered.worker
469 allocation = registered.allocation
470 num_blocks = registered.num_blocks
471 block_stride = registered.block_stride
472
473 assert worker.region_group_ids == [-1]
474 assert worker.block_stride_per_layer == [block_stride]
475 assert worker.nixl_wrapper.registered[0][0] == [
476 (allocation.data_ptr(), allocation.nbytes, 0, "")
477 ]
478 expected_addrs = [
479 allocation.data_ptr() + block * block_stride for block in range(num_blocks)
480 ]
481 num_desc_regions = 2 if push_pp else 1
482 assert worker.src_blocks_data[:, 0].tolist() == expected_addrs * num_desc_regions
483 assert worker.num_descs == num_blocks * num_desc_regions
484 assert (
485 worker.dst_region_num_blocks[worker.engine_id]
486 == [num_blocks] * num_desc_regions
487 )
488
489 metadata = msgspec.msgpack.decode(
490 worker.xfer_handshake_metadata.agent_metadata_bytes,
491 type=NixlAgentMetadata,
492 )
493 assert metadata.region_group_ids == [-1]
494 assert metadata.region_num_blocks == [num_blocks]
495 assert metadata.region_members == ([["layer.0", "layer.1"]] if push_pp else [])
496 assert worker._block_ids_by_region(([0], [2]), worker.region_group_ids) == [[0, 2]]
497
498
499 def _descriptor_geometry(registered) -> dict:
500 """The geometry handed to NIXL, addressed relative to the allocation."""
501 worker = registered.worker
502 base = registered.allocation.data_ptr()
503 return {
504 "block_len_per_layer": list(worker.block_len_per_layer),
505 "block_stride_per_layer": list(worker.block_stride_per_layer),
506 "desc_offsets": [addr - base for addr in worker.src_blocks_data[:, 0].tolist()],
507 "desc_lens": worker.src_blocks_data[:, 1].tolist(),
508 }
509
510
511 @pytest.mark.cpu_test
512 @pytest.mark.parametrize(
513 "page_covers_view", [True, False], ids=["dense-page", "narrow-view"]
514 )
515 def test_registration_ignores_a_sub_block_padding_tail(page_covers_view):
516 """A tail shorter than one block must not move a single descriptor.
517
518 An allocation rounded up to a page boundary keeps a few spare bytes past the
519 last block, which cannot hold another block. The narrow view is the branch
520 DeepSeek-V4-Flash's sliding-window cache lands in.
521 """
522 unpadded = _register_overlaid_mla_worker(page_covers_view=page_covers_view)
523 # More spare bytes than blocks, so dividing the padded length by the block
524 # count overshoots the stride and would shift every descriptor.
525 padded = _register_overlaid_mla_worker(
526 tail_bytes=2 * unpadded.num_blocks, page_covers_view=page_covers_view
527 )
528
529 assert _descriptor_geometry(padded) == _descriptor_geometry(unpadded)
530
531
532 def _make_mla_hybrid_worker(local_block_size, kernel_block_size, num_logical_blocks):
533 """Build a real pull worker with a hybrid MLA + 2xKDA HMA layout."""
534 from vllm.distributed.kv_transfer.kv_connector.v1.nixl import (
535 base_worker as bw,
536 )
537 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
538 NixlConnectorWorker,
539 )
540 from vllm.v1.attention.backends.registry import MambaAttentionBackendEnum
541 from vllm.v1.kv_cache_interface import (
542 KVCacheConfig,
543 KVCacheGroupSpec,
544 KVCacheTensor,
545 MambaSpec,
546 MLAAttentionSpec,
547 )
548
549 mla_spec = MLAAttentionSpec(
550 block_size=local_block_size,
551 num_kv_heads=1,
552 head_size=6,
553 dtype=torch.float16,
554 )
555 unified_page = mla_spec.page_size_bytes
556 kda_spec = MambaSpec(
557 block_size=local_block_size,
558 shapes=((8, 3), (1, 4, 4)),
559 dtypes=(torch.float16, torch.float32),
560 page_size_padded=unified_page,
561 mamba_type=MambaAttentionBackendEnum.GDN_ATTN,
562 )
563 # The three groups overlay each other, so layer i of every group aliases the same
564 # region: mla.i, kda_a.i and kda_b.i all live at i * layer_stride.
565 layer_stride = num_logical_blocks * unified_page
566 kv_cache_config = KVCacheConfig(
567 num_blocks=num_logical_blocks,
568 kv_cache_tensors=[
569 KVCacheTensor(
570 size=2 * layer_stride,
571 layers=[f"{prefix}.0", f"{prefix}.1"],
572 layer_stride=layer_stride,
573 block_stride=unified_page,
574 )
575 for prefix in ("mla", "kda_a", "kda_b")
576 ],
577 kv_cache_groups=[
578 KVCacheGroupSpec(["mla.0", "mla.1"], mla_spec),
579 KVCacheGroupSpec(["kda_a.0", "kda_a.1"], kda_spec),
580 KVCacheGroupSpec(["kda_b.0", "kda_b.1"], kda_spec),
581 ],
582 )
583
584 vllm_config = create_vllm_config(block_size=local_block_size)
585 vllm_config.cache_config.enable_prefix_caching = False
586 # kv_buffer_device defaults to the *real* platform's device type, which on
587 # a CPU-only test host would make this a host-buffer worker: host xfer
588 # buffers are per-layer, so the HMA shared-tensor regions this test builds
589 # would not be deduplicated. Pin it to the faked device type.
590 vllm_config.kv_transfer_config.kv_buffer_device = "cuda"
591
592 fake_backend = MagicMock()
593 fake_backend.get_supported_kernel_block_sizes.return_value = [kernel_block_size]
594 fake_backend.get_name.return_value = "FLASHMLA"
595 fake_backend.full_cls_name.return_value = "fake.FLASHMLA"
596 fake_platform = MagicMock()
597 fake_platform.device_type = "cuda"
598 fake_platform.get_nixl_memory_type.return_value = "VRAM"
599
600 from vllm.config import set_current_vllm_config
601
602 with (
603 patch.object(bw, "NixlWrapper", _RecordingNixl),
604 patch.object(bw, "get_tensor_model_parallel_rank", return_value=0),
605 patch.object(bw, "get_tensor_model_parallel_world_size", return_value=1),
606 patch.object(bw, "get_current_attn_backends", return_value=[fake_backend]),
607 patch.object(bw, "current_platform", fake_platform),
608 patch(
609 "vllm.model_executor.layers.mamba.mamba_utils.get_conv_state_layout",
610 return_value="DS",
611 ),
612 set_current_vllm_config(vllm_config),
613 ):
614 worker = NixlConnectorWorker(vllm_config, "local-engine", kv_cache_config)
615 worker.use_mla = True
616
617 # Attention caches are kernel-block granular on dim 0, as the
618 # receive post-process assumes.
619 ppl = local_block_size // kernel_block_size
620 tensors = [
621 torch.zeros(
622 num_logical_blocks * ppl, unified_page // ppl, dtype=torch.uint8
623 )
624 for _ in range(2)
625 ]
626 worker.register_kv_caches(
627 {
628 "kda_a.0": tensors[0],
629 "mla.0": tensors[0],
630 "kda_b.0": tensors[0],
631 "kda_a.1": tensors[1],
632 "mla.1": tensors[1],
633 "kda_b.1": tensors[1],
634 }
635 )
636 # Keep tensors alive alongside the worker; flat views for byte checks.
637 worker._test_tensors = [t.view(-1) for t in tensors]
638 worker._test_tensors_2d = tensors
639 worker._test_unified_page = unified_page
640 return worker
641
642
643 @pytest.mark.cpu_test
644 @pytest.mark.parametrize("logical_block_size", [1152, 640])
645 @pytest.mark.parametrize("tail_first", [False, True])
646 def test_register_compressed_indexer_uses_virtual_transfer_pages(
647 logical_block_size, tail_first
648 ):
649 """Compressed indexer rows must split into contiguous NIXL transfer pages."""
650 from unittest.mock import MagicMock
651
652 from vllm.config import set_current_vllm_config
653 from vllm.distributed.kv_transfer.kv_connector.v1.nixl import (
654 base_worker as bw,
655 )
656 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
657 NixlConnectorWorker,
658 )
659 from vllm.v1.kv_cache_interface import (
660 KpoolTailSpec,
661 KVCacheConfig,
662 KVCacheGroupSpec,
663 KVCacheLayout,
664 KVCacheTensor,
665 MLAAttentionSpec,
666 UniformTypeKVCacheSpecs,
667 create_kv_cache_views,
668 )
669
670 num_logical_blocks = 3
671 transfer_block_size = 64
672 kernel_block_size = 128
673 tokens_per_state = 4
674 state_content_bytes = 132
675
676 indexer_spec = MLAAttentionSpec(
677 block_size=logical_block_size,
678 num_kv_heads=1,
679 head_size=128,
680 head_size_v=0,
681 dtype=torch.uint8,
682 state_content_bytes=state_content_bytes,
683 tokens_per_state=tokens_per_state,
684 )
685 indexer_page_size = indexer_spec.page_size_bytes
686 tail_spec = KpoolTailSpec(
687 block_size=tokens_per_state,
688 num_kv_heads=2,
689 head_size=128,
690 head_size_v=0,
691 dtype=torch.bfloat16,
692 page_size_padded=indexer_page_size,
693 sliding_window=tokens_per_state,
694 )
695
696 allocation_size = num_logical_blocks * indexer_page_size
697 indexer_tensor = KVCacheTensor(
698 size=allocation_size,
699 layers=["indexer"],
700 layer_stride=allocation_size,
701 block_stride=indexer_page_size,
702 )
703 tail_tensor = KVCacheTensor(
704 size=allocation_size,
705 layers=["tail"],
706 layer_stride=allocation_size,
707 block_stride=indexer_page_size,
708 )
709 kv_cache_config = KVCacheConfig(
710 num_blocks=num_logical_blocks,
711 kv_cache_tensors=[indexer_tensor, tail_tensor],
712 kv_cache_groups=[
713 KVCacheGroupSpec(
714 ["indexer"],
715 UniformTypeKVCacheSpecs(
716 block_size=logical_block_size,
717 kv_cache_specs={"indexer": indexer_spec},
718 ),
719 ),
720 KVCacheGroupSpec(
721 ["tail"],
722 UniformTypeKVCacheSpecs(
723 block_size=tokens_per_state,
724 kv_cache_specs={"tail": tail_spec},
725 ),
726 ),
727 ],
728 )
729
730 raw = torch.zeros(allocation_size, dtype=torch.int8)
731 (indexer_cache,) = create_kv_cache_views(
732 raw,
733 indexer_spec,
734 num_logical_blocks,
735 KVCacheLayout.LBHNC,
736 indexer_tensor,
737 kernel_block_size=kernel_block_size,
738 )
739 (tail_cache,) = create_kv_cache_views(
740 raw,
741 tail_spec,
742 num_logical_blocks,
743 KVCacheLayout.LBHNC,
744 tail_tensor,
745 )
746 assert indexer_cache.data_ptr() == tail_cache.data_ptr() == raw.data_ptr()
747
748 vllm_config = create_vllm_config(block_size=logical_block_size)
749 vllm_config.cache_config.kv_cache_layout = "LBHNC"
750 vllm_config.kv_transfer_config.kv_buffer_device = "cuda"
751 fake_backend = MagicMock()
752 fake_backend.get_supported_kernel_block_sizes.return_value = [transfer_block_size]
753 fake_backend.get_name.return_value = "DEEPSEEK_V32_INDEXER"
754 fake_backend.full_cls_name.return_value = "fake.DEEPSEEK_V32_INDEXER"
755 fake_platform = MagicMock()
756 fake_platform.device_type = "cuda"
757 fake_platform.get_nixl_memory_type.return_value = "VRAM"
758
759 caches = [("indexer", indexer_cache), ("tail", tail_cache)]
760 if tail_first:
761 caches.reverse()
762
763 with (
764 patch.object(bw, "NixlWrapper", _RecordingNixl),
765 patch.object(bw, "get_tensor_model_parallel_rank", return_value=0),
766 patch.object(bw, "get_tensor_model_parallel_world_size", return_value=1),
767 patch.object(bw, "get_current_attn_backends", return_value=[fake_backend]),
768 patch.object(bw, "current_platform", fake_platform),
769 set_current_vllm_config(vllm_config),
770 ):
771 worker = NixlConnectorWorker(vllm_config, "local-engine", kv_cache_config)
772 worker.use_mla = True
773 worker.register_kv_caches(dict(caches))
774
775 transfer_page_size = transfer_block_size // tokens_per_state * state_content_bytes
776 num_transfer_blocks = num_logical_blocks * (
777 logical_block_size // transfer_block_size
778 )
779 expected_descs = np.asarray(
780 [
781 [
782 raw.data_ptr() + block_idx * transfer_page_size,
783 transfer_page_size,
784 0,
785 ]
786 for block_idx in range(num_transfer_blocks)
787 ],
788 dtype=np.uint64,
789 )
790
791 assert worker.block_size == transfer_block_size
792 assert worker.num_regions == 1
793 assert worker.block_len_per_layer == [transfer_page_size]
794 assert worker.block_stride_per_layer == [transfer_page_size]
795 assert worker._region_is_mla == [True]
796 assert worker.kv_caches_base_addr[worker.engine_id][0] == [raw.data_ptr()]
797 assert worker._registered_descs[0] == [(raw.data_ptr(), raw.nbytes, 0, "")]
798 np.testing.assert_array_equal(worker.src_blocks_data, expected_descs)
799 assert expected_descs[-1, 0] + expected_descs[-1, 1] == (
800 raw.data_ptr() + raw.nbytes
801 )
802
803
804 def _make_remote_meta(
805 worker,
806 remote_block_size,
807 remote_kernel_block_size,
808 remote_num_logical,
809 remote_ssm_sizes,
810 ):
811 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
812 NixlAgentMetadata,
813 )
814
815 remote_ppl = remote_block_size // remote_kernel_block_size
816 # Kernel-granularity pages are TP-independent for MLA hybrids and must
817 # match the local ones for the handshake to pass, scaled down by the
818 # block-size ratio when the remote's kernel block is smaller.
819 block_size_ratio = worker.block_size // remote_kernel_block_size
820 kernel_page = worker.block_len_per_layer[0] // block_size_ratio
821 return NixlAgentMetadata(
822 engine_id="remote-engine",
823 agent_metadata=b"remote-agent-meta",
824 device_id=0,
825 kv_caches_base_addr=[0x10_000_000, 0x20_000_000],
826 num_blocks=remote_num_logical * remote_ppl,
827 block_lens=[kernel_page, kernel_page],
828 # Non-interleaved remote: consecutive blocks abut, so stride == page length.
829 block_strides=[kernel_page, kernel_page],
830 kv_cache_layout=worker.kv_cache_layout,
831 block_size=remote_kernel_block_size,
832 ssm_sizes=remote_ssm_sizes,
833 attn_backend_name=worker.backend_name,
834 physical_blocks_per_logical_kv_block=remote_ppl,
835 )
836
837
838 def _register_remote_agents(worker, metadata, tp_size):
839 """Mirror the async handshake callback that publishes prepared agents."""
840 worker._remote_agents[metadata.engine_id] = {
841 (0, rank): worker.add_remote_agent(
842 metadata, remote_tp_rank=rank, remote_tp_size=tp_size
843 )
844 for rank in range(tp_size)
845 }
846
847
848 def _owned_byte_ranges(worker, group_logical_ids):
849 """Byte ranges owned by a request: for each HMA region tensor, every
850 logical block id of every group maps to one unified page."""
851 unified_page = worker._test_unified_page
852 bases = [t.data_ptr() for t in worker._test_tensors]
853 owned = []
854 for base in bases:
855 for ids in group_logical_ids:
856 for b in ids:
857 owned.append((base + b * unified_page, base + (b + 1) * unified_page))
858 return owned
859
860
861 def _assert_local_writes_within(worker, owned_ranges):
862 nixl = worker.nixl_wrapper
863 assert nixl.xfers, "no transfers were posted"
864 violations = []
865 total_descs = 0
866 for op, local_handle, local_ids, _, remote_ids in nixl.xfers:
867 assert len(local_ids) == len(remote_ids)
868 desc_arr = nixl.dlists[local_handle]
869 for i in local_ids:
870 addr, length, _dev = desc_arr[int(i)]
871 addr, length = int(addr), int(length)
872 total_descs += 1
873 if not any(lo <= addr and addr + length <= hi for lo, hi in owned_ranges):
874 violations.append((int(i), hex(addr), length))
875 assert not violations, (
876 f"{len(violations)}/{total_descs} local descriptors write outside "
877 f"the request's own blocks: {violations[:10]}"
878 )
879 return total_descs
880
881
882 @pytest.mark.cpu_test
883 def test_hetero_ppl_multi_read_writes_stay_within_request_blocks():
884 """MLA-hybrid hetero geometry: local (D, TP1) logical blocks of 12 tokens
885 (kernel 4, ppl=3) vs remote (P, TP2) logical blocks of 8 tokens (ppl=2),
886 equal kernel pages, tp_ratio=-2 multi-read with replicated MLA and
887 TP-sharded KDA state. Every local descriptor of the request's reads must
888 stay within its own blocks."""
889 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
890 NixlConnectorMetadata,
891 )
892
893 worker = _make_mla_hybrid_worker(
894 local_block_size=12, kernel_block_size=4, num_logical_blocks=8
895 )
896 assert worker._physical_blocks_per_logical_kv_block == 3
897
898 meta_r = _make_remote_meta(
899 worker,
900 remote_block_size=8,
901 remote_kernel_block_size=4,
902 remote_num_logical=12,
903 remote_ssm_sizes=(24, 32),
904 )
905 _register_remote_agents(worker, meta_r, 2)
906
907 # Request B: 17 matched tokens. Local: 2 logical blocks (24 tok
908 # capacity); remote: 16 prefilled tokens -> 2 remote logical blocks.
909 # Sparse, non-contiguous ids so neighbor blocks exist on all sides.
910 local_ids = ([2, 5], [1], [7])
911 remote_ids = [[1, 4], [5], [2]]
912
913 metadata = NixlConnectorMetadata()
914 metadata.add_new_req_to_recv(
915 request_id="req-b",
916 local_block_ids=local_ids,
917 kv_transfer_params={
918 "remote_block_ids": remote_ids,
919 "remote_engine_id": "remote-engine",
920 "remote_request_id": "prefill-req-b",
921 "remote_host": "remote-host",
922 "remote_port": 1234,
923 "tp_size": 2,
924 },
925 )
926 meta = metadata.reqs_to_recv["req-b"]
927 meta.local_physical_block_ids = worker._logical_to_kernel_block_ids(
928 meta.local_block_ids, worker._physical_blocks_per_logical_kv_block
929 )
930 worker._recving_metadata["req-b"] = meta
931
932 worker._read_blocks_for_req("req-b", meta)
933
934 owned = _owned_byte_ranges(worker, local_ids)
935 total = _assert_local_writes_within(worker, owned)
936 # Multi-read: rank 0 carries the replicated MLA + its SSM shard,
937 # rank 1 carries only its SSM shard.
938 assert len(worker.nixl_wrapper.xfers) == 2
939 assert total > 0
940
941
942 def _resolve(
943 desc_arr,
944 idx,
945 bases,
946 region_size,
947 unified_page,
948 desc_page,
949 logical_ids_attn,
950 block_tokens,
951 ):
952 """Resolve a desc id to (region, kind, token_start) where kind is 'attn'
953 (desc-page sized, sub-block-aligned, in the request's attention blocks)
954 or 'mamba'. token_start is the request-relative token offset, so local
955 and remote are comparable even when their kernel blocks differ in size."""
956 addr, length, _ = (int(x) for x in desc_arr[int(idx)])
957 for region, base in enumerate(bases):
958 off = addr - base
959 if 0 <= off < region_size:
960 b = off // unified_page
961 rem = off % unified_page
962 if length == desc_page and rem % desc_page == 0 and b in logical_ids_attn:
963 pos = logical_ids_attn.index(b)
964 tokens_per_desc = block_tokens * desc_page // unified_page
965 sub = rem // desc_page
966 return (region, "attn", pos * block_tokens + sub * tokens_per_desc)
967 return (region, "mamba", None)
968 raise AssertionError(f"desc {idx} addr {addr:#x} not in any region")
969
970
971 def _run_hetero_case(
972 local_block, kernel, remote_block, num_tokens, tp_size=2, remote_kernel=None
973 ):
974 """Full pull-path run for one geometry; returns pairing records.
975
976 ``remote_kernel`` defaults to the local kernel block size; a smaller
977 value additionally exercises block_size_ratio > 1.
978 """
979 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
980 NixlConnectorMetadata,
981 )
982
983 remote_kernel = remote_kernel or kernel
984 block_size_ratio = kernel // remote_kernel
985 remote_ppl = remote_block // remote_kernel
986 matched = num_tokens - 1 # mamba N-1 rule
987 n_local = -(-num_tokens // local_block)
988 n_remote = -(-matched // remote_block)
989
990 worker = _make_mla_hybrid_worker(
991 local_block_size=local_block,
992 kernel_block_size=kernel,
993 num_logical_blocks=max(2 * n_local + 4, 8),
994 )
995 # Local KDA state pages are (48, 64) bytes; the remote holds 1/tp_size
996 # shards of each.
997 meta_r = _make_remote_meta(
998 worker,
999 remote_block_size=remote_block,
1000 remote_kernel_block_size=remote_kernel,
1001 remote_num_logical=max(2 * n_remote + 4, 8),
1002 remote_ssm_sizes=(48 // tp_size, 64 // tp_size),
1003 )
1004 _register_remote_agents(worker, meta_r, tp_size)
1005
1006 # Sparse ids so neighbors exist between the request's blocks.
1007 local_attn = [2 * i + 1 for i in range(n_local)]
1008 remote_attn = [2 * i + 2 for i in range(n_remote)]
1009 local_ids = (local_attn, [0], [2 * n_local + 2])
1010 remote_ids = [remote_attn, [1], [0]]
1011
1012 metadata = NixlConnectorMetadata()
1013 metadata.add_new_req_to_recv(
1014 request_id="req-b",
1015 local_block_ids=local_ids,
1016 kv_transfer_params={
1017 "remote_block_ids": remote_ids,
1018 "remote_engine_id": "remote-engine",
1019 "remote_request_id": "prefill-req-b",
1020 "remote_host": "remote-host",
1021 "remote_port": 1234,
1022 "tp_size": tp_size,
1023 },
1024 )
1025 meta = metadata.reqs_to_recv["req-b"]
1026 meta.local_physical_block_ids = worker._logical_to_kernel_block_ids(
1027 meta.local_block_ids, worker._physical_blocks_per_logical_kv_block
1028 )
1029 worker._recving_metadata["req-b"] = meta
1030
1031 # Sentinel-fill the local KV so untouched bytes are detectable.
1032 for t in worker._test_tensors:
1033 t.fill_(0xAA)
1034
1035 worker._read_blocks_for_req("req-b", meta)
1036
1037 # Invariant 1: all local writes within the request's own blocks.
1038 owned = _owned_byte_ranges(worker, local_ids)
1039 _assert_local_writes_within(worker, owned)
1040
1041 # Invariant 2: local<->remote attention pairs are token-aligned.
1042 nixl = worker.nixl_wrapper
1043 local_bases = [t.data_ptr() for t in worker._test_tensors]
1044 remote_bases = [0x10_000_000, 0x20_000_000]
1045 local_unified = worker._test_unified_page
1046 remote_unified = (local_unified // local_block) * remote_block
1047 # With block_size_ratio > 1 the local page is split into ratio sub-descs,
1048 # each the size of a whole remote kernel page.
1049 desc_page = worker.block_len_per_layer[0] // block_size_ratio
1050 meta_r_num_blocks_bytes = (meta_r.num_blocks // remote_ppl) * remote_unified
1051 covered_tokens = set()
1052 for op, lh, lids, rh, rids in nixl.xfers:
1053 larr, rarr = nixl.dlists[lh], nixl.dlists[rh]
1054 for li, ri in zip(lids, rids):
1055 lreg, lkind, ltok = _resolve(
1056 larr,
1057 li,
1058 local_bases,
1059 len(worker._test_tensors[0]),
1060 local_unified,
1061 desc_page,
1062 local_attn,
1063 local_block,
1064 )
1065 rreg, rkind, rtok = _resolve(
1066 rarr,
1067 ri,
1068 remote_bases,
1069 meta_r_num_blocks_bytes,
1070 remote_unified,
1071 desc_page,
1072 remote_attn,
1073 remote_block,
1074 )
1075 assert lkind == rkind, (
1076 f"pair kind mismatch: local {lkind} vs remote {rkind} "
1077 f"(local desc {li}, remote desc {ri})"
1078 )
1079 assert lreg == rreg, (
1080 f"region mismatch: local {lreg} vs remote {rreg} for "
1081 f"tokens {ltok} vs {rtok}"
1082 )
1083 if lkind == "attn":
1084 assert ltok == rtok, (
1085 f"TOKEN MISALIGNMENT: local sub-block holds tokens "
1086 f"[{ltok}..) but receives remote tokens [{rtok}..) "
1087 f"(geometry local_block={local_block}, "
1088 f"remote_block={remote_block}, N={num_tokens})"
1089 )
1090 covered_tokens.add(ltok)
1091
1092 # Invariant 3: full coverage of the matched tokens, at the finest
1093 # transfer granularity (the remote kernel block).
1094 needed = {t for t in range(0, matched - matched % remote_kernel, remote_kernel)}
1095 missing = needed - covered_tokens
1096 assert not missing, (
1097 f"tokens never transferred: {sorted(missing)[:8]} "
1098 f"(geometry local_block={local_block}, remote_block={remote_block}, "
1099 f"N={num_tokens}, matched={matched})"
1100 )
1101
1102 # Invariant 4: no stale bytes after receive completion. The scheduler
1103 # excludes the blocks covering the matched tokens from alloc-time KV
1104 # zeroing (the zeroing would race the RDMA write), so every byte of
1105 # those blocks must be either written by the transfer or zeroed by the
1106 # receive post-process. Stale bytes surface as mid-response garbage
1107 # once decode grows into the untransferred tail.
1108 for op, lh, lids, rh, rids in nixl.xfers:
1109 larr = nixl.dlists[lh]
1110 for li in lids:
1111 addr, length, _ = (int(x) for x in larr[int(li)])
1112 for t in worker._test_tensors:
1113 off = addr - t.data_ptr()
1114 if 0 <= off < t.numel():
1115 t[off : off + length] = 0 # simulate the RDMA write
1116 break
1117 done_sending, done_recving = worker.get_finished()
1118 assert "req-b" in done_recving
1119 n_excluded = -(-matched // local_block)
1120 stale = []
1121 for b in local_attn[:n_excluded]:
1122 for region, t in enumerate(worker._test_tensors):
1123 page = t[b * local_unified : (b + 1) * local_unified]
1124 n_stale = int((page == 0xAA).sum())
1125 if n_stale:
1126 stale.append((region, b, n_stale))
1127 assert not stale, (
1128 f"stale (unzeroed, untransferred) bytes in matched-range attention "
1129 f"blocks (region, block, bytes): {stale} "
1130 f"(geometry local_block={local_block}, remote_block={remote_block}, "
1131 f"N={num_tokens}, matched={matched})"
1132 )
1133
1134
1135 @pytest.mark.cpu_test
1136 @pytest.mark.parametrize(
1137 "local_block,remote_block",
1138 [
1139 (12, 8), # ppl 3 vs 2
1140 (36, 8), # ppl 9 vs 2 (large ppl asymmetry, scaled)
1141 (24, 4), # ppl 6 vs 1
1142 (16, 24), # remote larger than local (D_TP > P_TP direction)
1143 ],
1144 )
1145 @pytest.mark.parametrize("num_tokens", list(range(2, 40)))
1146 def test_hetero_ppl_token_alignment_sweep(local_block, remote_block, num_tokens):
1147 """Sweep prompt lengths across block-boundary residues for several
1148 hetero-ppl geometries; assert neighbor-safety, token alignment, and
1149 coverage of every transferred kernel block."""
1150 _run_hetero_case(
1151 local_block, kernel=4, remote_block=remote_block, num_tokens=num_tokens
1152 )
1153
1154
1155 @pytest.mark.cpu_test
1156 @pytest.mark.parametrize(
1157 "num_tokens",
1158 # Residues around the remote kernel block (4), the local kernel block
1159 # (8), the remote logical block (8) and the local logical block (24).
1160 [2, 5, 8, 9, 13, 16, 17, 21, 24, 25, 29, 32, 33, 41, 48, 49],
1161 )
1162 def test_hetero_ppl_with_block_size_ratio(num_tokens):
1163 """Both hetero regimes at once: kernel blocks differ (local 8 / remote
1164 4, block_size_ratio=2) *and* physical_blocks_per_logical differs (3 vs
1165 2). The transfer is clipped at remote sub-block granularity by the
1166 pairing and front-trimmed by _apply_prefix_caching, so the
1167 untransferred tail can span both a partial block and whole blocks —
1168 the case each of the two former zeroing paths handled only half of."""
1169 _run_hetero_case(
1170 local_block=24,
1171 kernel=8,
1172 remote_block=8,
1173 remote_kernel=4,
1174 num_tokens=num_tokens,
1175 )
1176
1177
1178 @pytest.mark.cpu_test
1179 @pytest.mark.parametrize(
1180 "num_tokens",
1181 # Residues around every geometric boundary: kernel block (64), remote
1182 # logical block (768), local logical block (5760), plus odd offsets.
1183 [
1184 2,
1185 63,
1186 64,
1187 65,
1188 127,
1189 128,
1190 300,
1191 640,
1192 767,
1193 768,
1194 769,
1195 831,
1196 832,
1197 1000,
1198 1535,
1199 1536,
1200 1537,
1201 2303,
1202 2304,
1203 2305,
1204 3001,
1205 5759,
1206 5760,
1207 5761,
1208 5824,
1209 6528,
1210 6529,
1211 ],
1212 )
1213 def test_mla_hybrid_large_ppl_geometry(num_tokens):
1214 """KimiLinear-scale MLA-hybrid geometry (TP8 prefill -> TP1 decode):
1215 decode (local) logical block 5760 / kernel 64 (ppl=90), prefill
1216 (remote) logical block 768 (ppl=12), tp_ratio=-8 multi-read with
1217 replicated MLA and 8-way TP-sharded KDA state."""
1218 _run_hetero_case(
1219 local_block=5760,
1220 kernel=64,
1221 remote_block=768,
1222 num_tokens=num_tokens,
1223 tp_size=8,
1224 )
1225
1226
1227 @pytest.mark.cpu_test
1228 def test_mismatched_mla_kernel_page_rejected_for_mla_hybrid():
1229 """The MLA per-token page is TP-independent, so kernel block lengths
1230 differing by anything other than the block-size ratio must fail the
1231 handshake loudly rather than transfer at mismatched geometry."""
1232 worker = _make_mla_hybrid_worker(
1233 local_block_size=12, kernel_block_size=4, num_logical_blocks=8
1234 )
1235 meta_r = _make_remote_meta(
1236 worker,
1237 remote_block_size=8,
1238 remote_kernel_block_size=4,
1239 remote_num_logical=12,
1240 remote_ssm_sizes=(24, 32),
1241 )
1242 # Equal kernel block sizes (ratio 1), but a half-sized per-token page.
1243 meta_r.block_lens = [x // 2 for x in worker.block_len_per_layer]
1244 with pytest.raises((AssertionError, RuntimeError)):
1245 worker.add_remote_agent(meta_r, remote_tp_rank=0, remote_tp_size=2)
1246
1247
1248 def _make_csa_linear_ple_worker(scratch_aliases: str = "compressed"):
1249 """``scratch_aliases`` selects which pages the compressor ring overlays."""
1250 from unittest.mock import MagicMock
1251
1252 from vllm.config import set_current_vllm_config
1253 from vllm.distributed.kv_transfer.kv_connector.v1.nixl import base_worker as bw
1254 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
1255 NixlConnectorWorker,
1256 )
1257 from vllm.v1.attention.backends.registry import MambaAttentionBackendEnum
1258 from vllm.v1.kv_cache_interface import (
1259 CircularBufferSpec,
1260 FullAttentionSpec,
1261 KVCacheConfig,
1262 KVCacheGroupSpec,
1263 KVCacheTensor,
1264 MambaSpec,
1265 MLAAttentionSpec,
1266 UniformTypeKVCacheSpecs,
1267 )
1268
1269 main_kv_specs = {
1270 f"main_kv.{index}": FullAttentionSpec(
1271 block_size=4,
1272 num_kv_heads=1,
1273 head_size=16,
1274 dtype=torch.float16,
1275 )
1276 for index in range(2)
1277 }
1278 compressed_specs = {
1279 f"compressed.{index}": MLAAttentionSpec(
1280 block_size=4,
1281 num_kv_heads=1,
1282 head_size=64,
1283 dtype=torch.float16,
1284 tokens_per_state=2,
1285 )
1286 for index in range(2)
1287 }
1288 compressor_state_specs = {
1289 f"compressor_state.{index}": CircularBufferSpec(
1290 block_size=2,
1291 num_kv_heads=1,
1292 head_size=32,
1293 dtype=torch.float16,
1294 )
1295 for index in range(2)
1296 }
1297 sharded_mamba_spec = MambaSpec(
1298 block_size=1,
1299 shapes=((8, 3), (1, 4, 4)),
1300 dtypes=(torch.float16, torch.float32),
1301 page_size_padded=256,
1302 mamba_type=MambaAttentionBackendEnum.GDN_ATTN,
1303 )
1304 ple_spec = MambaSpec(
1305 block_size=1,
1306 shapes=((6, 3),),
1307 dtypes=(torch.float16,),
1308 page_size_padded=256,
1309 mamba_type=MambaAttentionBackendEnum.SHORT_CONV,
1310 tp_replicated=True,
1311 )
1312 compressed_sparse_specs = {
1313 name: spec
1314 for index in range(2)
1315 for name, spec in (
1316 (f"main_kv.{index}", main_kv_specs[f"main_kv.{index}"]),
1317 (f"compressed.{index}", compressed_specs[f"compressed.{index}"]),
1318 )
1319 }
1320
1321 def group(specs):
1322 uniform = UniformTypeKVCacheSpecs.from_specs(specs)
1323 assert uniform is not None
1324 return KVCacheGroupSpec(list(specs), uniform)
1325
1326 tensor_regions: tuple[tuple[str, ...], ...]
1327 if scratch_aliases == "compressed":
1328 tensor_regions = (
1329 ("main_kv.0", "mamba.sharded", "mamba.ple"),
1330 ("main_kv.1",),
1331 ("compressed.0", "compressor_state.0"),
1332 ("compressed.1", "compressor_state.1"),
1333 )
1334 else:
1335 assert scratch_aliases == "main_kv"
1336 tensor_regions = (
1337 ("main_kv.0", "mamba.sharded", "mamba.ple", "compressor_state.0"),
1338 ("main_kv.1", "compressor_state.1"),
1339 ("compressed.0",),
1340 ("compressed.1",),
1341 )
1342 region_size = 512
1343 page_size = 256
1344 kv_cache_config = KVCacheConfig(
1345 num_blocks=2,
1346 kv_cache_tensors=[
1347 KVCacheTensor(
1348 size=len(tensor_regions) * region_size,
1349 layers=[layer_name],
1350 layer_stride=region_size,
1351 block_stride=page_size,
1352 offset=region_index * region_size,
1353 )
1354 for region_index, layer_names in enumerate(tensor_regions)
1355 for layer_name in layer_names
1356 ],
1357 kv_cache_groups=[
1358 group(compressed_sparse_specs),
1359 group(compressor_state_specs),
1360 KVCacheGroupSpec(["mamba.sharded"], sharded_mamba_spec),
1361 KVCacheGroupSpec(["mamba.ple"], ple_spec),
1362 ],
1363 )
1364
1365 vllm_config = create_vllm_config(block_size=4)
1366 vllm_config.cache_config.enable_prefix_caching = False
1367 vllm_config.kv_transfer_config.kv_buffer_device = "cuda"
1368 fake_backend = MagicMock()
1369 fake_backend.get_supported_kernel_block_sizes.return_value = [4]
1370 fake_backend.get_name.return_value = "TEST_ATTN"
1371 fake_backend.full_cls_name.return_value = "test.AttentionBackend"
1372 fake_platform = MagicMock()
1373 fake_platform.device_type = "cuda"
1374 fake_platform.get_nixl_memory_type.return_value = "VRAM"
1375
1376 with (
1377 patch.object(bw, "NixlWrapper", _RecordingNixl),
1378 patch.object(bw, "get_tensor_model_parallel_rank", return_value=0),
1379 patch.object(bw, "get_tensor_model_parallel_world_size", return_value=1),
1380 patch.object(bw, "get_current_attn_backends", return_value=[fake_backend]),
1381 patch.object(bw, "current_platform", fake_platform),
1382 patch(
1383 "vllm.model_executor.layers.mamba.mamba_utils.get_conv_state_layout",
1384 return_value="DS",
1385 ),
1386 set_current_vllm_config(vllm_config),
1387 ):
1388 worker = NixlConnectorWorker(vllm_config, "local-engine", kv_cache_config)
1389 tensors = [torch.zeros((2, 256), dtype=torch.uint8) for _ in range(4)]
1390 worker.register_kv_caches(
1391 {
1392 layer_name: tensors[region_index]
1393 for region_index, layer_names in enumerate(tensor_regions)
1394 for layer_name in layer_names
1395 }
1396 )
1397 return worker
1398
1399
1400 @pytest.mark.cpu_test
1401 def test_csa_linear_registration_discovers_shared_regions():
1402 worker = _make_csa_linear_ple_worker()
1403
1404 assert worker._ssm_region_indices == [0]
1405 assert worker._ple_group_index == 3
1406 assert worker._ple_region_index == 0
1407 assert worker._region_is_mla == [False, False, True, True]
1408 assert worker._scratch_region_indices == [2, 3]
1409
1410
1411 @pytest.mark.cpu_test
1412 def test_csa_linear_scratch_descs_follow_the_pages_they_overlay():
1413 """The scratch ring is addressed by the regions it actually registered in,
1414 not by inferring that it must sit on the MLA pages: overlaying the main KV
1415 pages instead moves its descriptors with it."""
1416 worker = _make_csa_linear_ple_worker(scratch_aliases="main_kv")
1417
1418 # Same MLA regions as before, but the ring now lives in the non-MLA ones.
1419 assert worker._region_is_mla == [False, False, True, True]
1420 assert worker._scratch_region_indices == [0, 1]
1421
1422 desc_ids = worker._compute_desc_ids(
1423 block_ids=([1], [0], [1], [1]),
1424 dst_num_blocks=2,
1425 block_size_ratio=None,
1426 physical_blocks_per_logical=1,
1427 )
1428 # Scratch group (block 0) now maps to regions 0 and 1 -> descs 0 and 2,
1429 # where the compressed-page placement gave 4 and 6.
1430 assert desc_ids.tolist()[4:6] == [0, 2]
1431
1432
1433 @pytest.mark.cpu_test
1434 def test_csa_linear_selects_compressed_sparse_compressor_state_and_mamba_regions():
1435 worker = _make_csa_linear_ple_worker()
1436
1437 desc_ids = worker._compute_desc_ids(
1438 block_ids=([1], [0], [1], [1]),
1439 dst_num_blocks=2,
1440 block_size_ratio=None,
1441 physical_blocks_per_logical=1,
1442 )
1443 assert desc_ids.tolist() == [
1444 1,
1445 3,
1446 5,
1447 7,
1448 4,
1449 6,
1450 9,
1451 11,
1452 13,
1453 15,
1454 17,
1455 ]
1456
1457 mamba_only = worker._compute_desc_ids(
1458 block_ids=([], [], [1], [1]),
1459 dst_num_blocks=2,
1460 block_size_ratio=None,
1461 physical_blocks_per_logical=1,
1462 )
1463 assert mamba_only.tolist() == [9, 11, 13, 15, 17]
1464
1465
1466 @pytest.mark.cpu_test
1467 def test_csa_linear_ple_descriptor_is_not_split():
1468 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.tp_mapping import (
1469 TPMapping,
1470 )
1471
1472 worker = _make_csa_linear_ple_worker()
1473 bases = [0x10000, 0x20000, 0x30000, 0x40000]
1474 attention = worker._build_fa_local(bases, block_size_ratio=1)
1475 mamba = worker._build_mamba_local(bases)
1476 assert attention.shape == (8, 3)
1477 assert mamba.shape == (10, 3)
1478 assert mamba[-2:, 0].tolist() == [0x10000, 0x10100]
1479 assert mamba[-2:, 1].tolist() == [256, 256]
1480
1481 mapping = TPMapping(
1482 source_ranks_per_group=((0,), (0,), (0, 1), (0, 1)),
1483 all_source_ranks=(0, 1),
1484 rank_to_attention_slot={0: 0, 1: 0},
1485 rank_offset_factor=0,
1486 )
1487 splits = list(
1488 worker._build_local_splits_from_plan(
1489 mapping,
1490 np.concatenate([attention, mamba]),
1491 num_fa_descs=len(attention),
1492 )
1493 )
1494 assert len(splits) == 2
1495 assert [row[1] for row in splits[0][-2:]] == [256, 256]
1496 assert [row[1] for row in splits[1][-2:]] == [256, 256]
1497 assert splits[0][-3][1] * 2 == mamba[-3, 1]
1498 assert splits[1][-3][1] * 2 == mamba[-3, 1]
1499
1500
1501 @pytest.mark.cpu_test
1502 def test_csa_linear_remote_ple_is_copied_whole():
1503 from types import SimpleNamespace
1504
1505 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
1506 NixlAgentMetadata,
1507 )
1508
1509 worker = _make_csa_linear_ple_worker()
1510 metadata = NixlAgentMetadata(
1511 engine_id="remote",
1512 agent_metadata=b"agent",
1513 kv_caches_base_addr=[
1514 0x10000,
1515 0x20000,
1516 0x30000,
1517 0x40000,
1518 ],
1519 device_id=0,
1520 num_blocks=2,
1521 block_lens=[256] * 4,
1522 block_strides=[256] * 4,
1523 kv_cache_layout="HND",
1524 block_size=4,
1525 ssm_sizes=(24, 32),
1526 attn_backend_name="test",
1527 physical_blocks_per_logical_kv_block=1,
1528 )
1529 descriptors = worker._build_mamba_remote(
1530 metadata,
1531 tp_ratio=-2,
1532 transfer_info=SimpleNamespace(remote_physical_blocks_per_logical=1),
1533 )
1534 assert descriptors.shape == (10, 3)
1535 assert descriptors[-2:, 0].tolist() == [0x10000, 0x10100]
1536 assert descriptors[-2:, 1].tolist() == [256, 256]
1537
1538 metadata.block_lens[0] = 128
1539 with pytest.raises(ValueError, match="PLE pages require identical"):
1540 worker._build_mamba_remote(
1541 metadata,
1542 tp_ratio=-2,
1543 transfer_info=SimpleNamespace(remote_physical_blocks_per_logical=1),
1544 )
1545
1546
1547 def _make_ring_worker():
1548 """Paged MLA group plus a per-layer ring group, no Mamba: the shape of a
1549 model whose sliding-window KV lives in per-request rings."""
1550 from unittest.mock import MagicMock
1551
1552 from vllm.config import set_current_vllm_config
1553 from vllm.distributed.kv_transfer.kv_connector.v1.nixl import base_worker as bw
1554 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
1555 NixlConnectorWorker,
1556 )
1557 from vllm.v1.kv_cache_interface import (
1558 CircularBufferSpec,
1559 KVCacheConfig,
1560 KVCacheGroupSpec,
1561 KVCacheTensor,
1562 MLAAttentionSpec,
1563 UniformTypeKVCacheSpecs,
1564 )
1565
1566 paged_specs = {
1567 f"paged.{index}": MLAAttentionSpec(
1568 block_size=4, num_kv_heads=1, head_size=64, dtype=torch.float16
1569 )
1570 for index in range(2)
1571 }
1572 ring_specs = {
1573 f"ring.{index}": CircularBufferSpec(
1574 block_size=6,
1575 num_kv_heads=1,
1576 head_size=32,
1577 head_size_v=0,
1578 dtype=torch.float16,
1579 )
1580 for index in range(2)
1581 }
1582
1583 def group(specs):
1584 uniform = UniformTypeKVCacheSpecs.from_specs(specs)
1585 assert uniform is not None
1586 return KVCacheGroupSpec(list(specs), uniform)
1587
1588 # Both rings overlay the second paged region, so the scratch group must
1589 # address only that region while the paged group spans both.
1590 tensor_regions = (("paged.0",), ("paged.1", "ring.0", "ring.1"))
1591 region_size, page_size = 512, 256
1592 kv_cache_config = KVCacheConfig(
1593 num_blocks=2,
1594 kv_cache_tensors=[
1595 KVCacheTensor(
1596 size=len(tensor_regions) * region_size,
1597 layers=[layer_name],
1598 layer_stride=region_size,
1599 block_stride=page_size,
1600 offset=region_index * region_size,
1601 )
1602 for region_index, layer_names in enumerate(tensor_regions)
1603 for layer_name in layer_names
1604 ],
1605 kv_cache_groups=[group(paged_specs), group(ring_specs)],
1606 )
1607
1608 vllm_config = create_vllm_config(block_size=4)
1609 vllm_config.cache_config.enable_prefix_caching = False
1610 vllm_config.kv_transfer_config.kv_buffer_device = "cuda"
1611 fake_backend = MagicMock()
1612 fake_backend.get_supported_kernel_block_sizes.return_value = [4]
1613 fake_backend.get_name.return_value = "TEST_ATTN"
1614 fake_backend.full_cls_name.return_value = "test.AttentionBackend"
1615 fake_platform = MagicMock()
1616 fake_platform.device_type = "cuda"
1617 fake_platform.get_nixl_memory_type.return_value = "VRAM"
1618
1619 with (
1620 patch.object(bw, "NixlWrapper", _RecordingNixl),
1621 patch.object(bw, "get_tensor_model_parallel_rank", return_value=0),
1622 patch.object(bw, "get_tensor_model_parallel_world_size", return_value=1),
1623 patch.object(bw, "get_current_attn_backends", return_value=[fake_backend]),
1624 patch.object(bw, "current_platform", fake_platform),
1625 set_current_vllm_config(vllm_config),
1626 ):
1627 worker = NixlConnectorWorker(vllm_config, "local-engine", kv_cache_config)
1628 tensors = [torch.zeros((2, 256), dtype=torch.uint8) for _ in range(2)]
1629 worker.register_kv_caches(
1630 {
1631 layer_name: tensors[region_index]
1632 for region_index, layer_names in enumerate(tensor_regions)
1633 for layer_name in layer_names
1634 }
1635 )
1636 return worker
1637
1638
1639 @pytest.mark.cpu_test
1640 def test_ring_scratch_without_mamba_registers_and_addresses_its_own_regions():
1641 worker = _make_ring_worker()
1642
1643 assert worker._ple_group_index is None
1644 assert worker._scratch_region_indices == [1]
1645
1646 desc_ids = worker._compute_desc_ids(
1647 block_ids=([1], [0]),
1648 dst_num_blocks=2,
1649 block_size_ratio=None,
1650 physical_blocks_per_logical=1,
1651 )
1652 # Paged block 1 in both regions; ring block 0 only in its scratch region.
1653 assert sorted(desc_ids.tolist()) == sorted([0 * 2 + 1, 1 * 2 + 1, 1 * 2 + 0])