[KV Connector] Support NIXL P/D for hybrid MLA+SSM models (#49762)

Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Jared Wen <w13431838023@gmail.com>

Nick Hill committed Jul 28, 2026 at 20:50 UTC f37f03db4af69e4969242767734db3d9175055f7
5 files changed +281 -38
tests/v1/kv_connector/unit/test_nixl_connector_hma.py
+196
@@ -1380,3 +1380,199 @@ def test_logical_to_kernel_block_ids_with_remote_ratio(
1380 assert list(result) == expected_kernel_block_ids, (
1381 f"Expected {expected_kernel_block_ids}, got {result}"
1382 )
1383 +
1384 +
1385 +# ── Hybrid MLA+SSM (KimiLinear-shaped KDA+MLA) tests ─────────────────────
1386 +
1387 +
1388 +def _make_hybrid_mla_kv_cache_config(num_blocks: int = 4):
1389 + """KimiLinear-shaped config: one MLA group and two KDA (GDN-typed
1390 + MambaSpec) groups whose layers share the same HMA tensors, with a
1391 + mamba-aligned unified page and an MLA kernel block smaller than the
1392 + logical block."""
1393 + from vllm.v1.attention.backends.registry import MambaAttentionBackendEnum
1394 + from vllm.v1.kv_cache_interface import (
1395 + KVCacheConfig,
1396 + KVCacheGroupSpec,
1397 + KVCacheTensor,
1398 + MambaSpec,
1399 + MLAAttentionSpec,
1400 + )
1401 +
1402 + # 12-token logical blocks over a 4-token MLA kernel block.
1403 + mla_spec = MLAAttentionSpec(
1404 + block_size=12, num_kv_heads=1, head_size=6, dtype=torch.float16
1405 + )
1406 + unified_page = mla_spec.page_size_bytes
1407 + kda_spec = MambaSpec(
1408 + block_size=12,
1409 + # GDN-decomposable conv (Q|K|V = 2|2|4 cols x 3 rows) + fp32 temporal.
1410 + shapes=((8, 3), (1, 4, 4)),
1411 + dtypes=(torch.float16, torch.float32),
1412 + page_size_padded=unified_page,
1413 + mamba_type=MambaAttentionBackendEnum.GDN_ATTN,
1414 + )
1415 + assert kda_spec.page_size_bytes == unified_page
1416 + return KVCacheConfig(
1417 + num_blocks=num_blocks,
1418 + kv_cache_tensors=[
1419 + KVCacheTensor(
1420 + size=num_blocks * unified_page,
1421 + shared_by=[f"mla.{i}", f"kda_a.{i}", f"kda_b.{i}"],
1422 + )
1423 + for i in range(2)
1424 + ],
1425 + kv_cache_groups=[
1426 + KVCacheGroupSpec(["mla.0", "mla.1"], mla_spec),
1427 + KVCacheGroupSpec(["kda_a.0", "kda_a.1"], kda_spec),
1428 + KVCacheGroupSpec(["kda_b.0", "kda_b.1"], kda_spec),
1429 + ],
1430 + )
1431 +
1432 +
1433 +@pytest.mark.cpu_test
1434 +def test_register_kv_caches_hybrid_mla_dual_purpose_regions():
1435 + """Hybrid MLA+KDA registration: HMA tensors shared by both layer types
1436 + must be flagged as MLA regions even when a KDA layer registers them
1437 + first, expose TP-independent kernel-granularity block lens, and build
1438 + FA + mamba descriptors for every region."""
1439 + from unittest.mock import MagicMock
1440 +
1441 + from vllm.config import set_current_vllm_config
1442 + from vllm.distributed.kv_transfer.kv_connector.v1.nixl import base_worker as bw
1443 + from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
1444 + NixlConnectorWorker,
1445 + )
1446 +
1447 + kv_cache_config = _make_hybrid_mla_kv_cache_config()
1448 + unified_page = kv_cache_config.kv_cache_groups[0].kv_cache_spec.page_size_bytes
1449 + vllm_config = create_vllm_config(block_size=12)
1450 + # kv_buffer_device defaults to the *real* platform's device type, which on
1451 + # a CPU-only test host would make this a host-buffer worker: host xfer
1452 + # buffers are per-layer, so the HMA shared tensors would not be
1453 + # deduplicated. Pin it to the faked device type.
1454 + vllm_config.kv_transfer_config.kv_buffer_device = "cuda"
1455 +
1456 + fake_backend = MagicMock()
1457 + fake_backend.get_supported_kernel_block_sizes.return_value = [4]
1458 + fake_backend.get_name.return_value = "FLASHMLA"
1459 + fake_backend.full_cls_name.return_value = "fake.FLASHMLA"
1460 + fake_platform = MagicMock()
1461 + fake_platform.device_type = "cuda"
1462 + fake_platform.get_nixl_memory_type.return_value = "VRAM"
1463 +
1464 + with (
1465 + patch.object(bw, "NixlWrapper"),
1466 + patch.object(bw, "get_tensor_model_parallel_rank", return_value=0),
1467 + patch.object(bw, "get_tensor_model_parallel_world_size", return_value=1),
1468 + patch.object(bw, "get_current_attn_backends", return_value=[fake_backend]),
1469 + patch.object(bw, "current_platform", fake_platform),
1470 + patch(
1471 + "vllm.model_executor.layers.mamba.mamba_utils.get_conv_state_layout",
1472 + return_value="DS",
1473 + ),
1474 + set_current_vllm_config(vllm_config),
1475 + ):
1476 + worker = NixlConnectorWorker(vllm_config, "test-engine", kv_cache_config)
1477 + worker.use_mla = True # opt-125m test config is not MLA; force the flag
1478 + worker.nixl_wrapper.get_agent_metadata.return_value = b"fake-agent-metadata"
1479 +
1480 + tensors = [torch.zeros(4 * unified_page, dtype=torch.uint8) for _ in range(2)]
1481 + # KDA layer first per tensor: exercises the dual-purpose flag merge.
1482 + worker.register_kv_caches(
1483 + {
1484 + "kda_a.0": tensors[0],
1485 + "mla.0": tensors[0],
1486 + "kda_b.0": tensors[0],
1487 + "kda_a.1": tensors[1],
1488 + "mla.1": tensors[1],
1489 + "kda_b.1": tensors[1],
1490 + }
1491 + )
1492 +
1493 + # 12-token logical blocks over the 4-token MLA kernel block.
1494 + assert worker._physical_blocks_per_logical_kv_block == 3
1495 + assert worker.block_size == 4 and worker.num_blocks == 12
1496 + # Both shared tensors are dual-purpose: their FA view is MLA even though
1497 + # a KDA layer registered them first.
1498 + assert worker._region_is_mla == [True, True]
1499 + assert worker.num_regions == 2 and worker.num_descs == 24
1500 + # Kernel-granularity block lens; TP-independent for MLA hybrids.
1501 + assert worker.block_len_per_layer == [unified_page // 3] * 2
1502 + # Split handles must replicate every FA descriptor (MLA isn't head-sharded).
1503 + assert worker._fa_desc_replicated(worker.num_descs) == [True] * 24
1504 + # FA descs: 2 regions x 12 kernel blocks, page stride = kernel page.
1505 + # Mamba descs: 2 regions x (3 conv sub-projections + 1 ssm) x 4 blocks.
1506 + assert worker.src_blocks_data.shape == (24 + 32, 3)
1507 + fa_descs = worker.src_blocks_data[:24]
1508 + assert fa_descs[1][0] - fa_descs[0][0] == unified_page // 3
1509 + assert all(size == unified_page // 3 for size in fa_descs[:, 1])
1510 +
1511 +
1512 +@pytest.mark.cpu_test
1513 +def test_push_write_hybrid_mla_replicates_attention():
1514 + """Hybrid MLA+SSM push with P_TP < D_TP: attention blocks must be
1515 + written to every covered D rank (replicated MLA latent) while SSM state
1516 + is written per-rank through the split handles."""
1517 + import threading
1518 + from collections import defaultdict
1519 + from unittest.mock import MagicMock
1520 +
1521 + from vllm.distributed.kv_transfer.kv_connector.v1.nixl.push_worker import (
1522 + NixlPushConnectorWorker,
1523 + )
1524 + from vllm.distributed.kv_transfer.kv_connector.v1.nixl.tp_mapping import (
1525 + TPMapping,
1526 + )
1527 + from vllm.v1.kv_cache_interface import MambaSpec, MLAAttentionSpec
1528 +
1529 + worker = object.__new__(NixlPushConnectorWorker)
1530 + worker.shutdown = lambda: None # skeleton worker: silence __del__
1531 + worker.use_mla = True
1532 + worker._has_mamba = True
1533 + worker._group_spec_types = (MLAAttentionSpec, MambaSpec)
1534 + worker.transfer_topo = MagicMock()
1535 + worker.transfer_topo.tp_ratio.return_value = -2
1536 + remote_info = MagicMock()
1537 + remote_info.remote_physical_blocks_per_logical = 1
1538 + remote_info.remote_block_size = 4
1539 + worker.transfer_topo.get_engine_info.return_value = remote_info
1540 +
1541 + engine_id = "remote-engine"
1542 + # Read-oriented mapping collapses the replicated attention group to one
1543 + # source rank; the SSM state is sharded across both covered D ranks.
1544 + worker.tp_mappings = {
1545 + engine_id: TPMapping(
1546 + source_ranks_per_group=((0,), (0, 1)),
1547 + all_source_ranks=(0, 1),
1548 + rank_to_attention_slot={0: 0, 1: 0},
1549 + rank_offset_factor=0,
1550 + )
1551 + }
1552 + worker.dst_xfer_side_handles = {engine_id: {0: 100, 1: 101}}
1553 + worker.src_xfer_handles_by_tp_ratio = {(-2, 4): [200, 201]}
1554 + worker.src_xfer_handles_by_block_size = {4: 300}
1555 + worker._sending_transfers = defaultdict(list)
1556 + worker._sending_transfers_lock = threading.Lock()
1557 + worker.kv_cache_config = _make_hybrid_mla_kv_cache_config()
1558 + worker._xfer_blocks = MagicMock(return_value=1)
1559 +
1560 + meta = MagicMock()
1561 + meta.remote.engine_id = engine_id
1562 + meta.remote.block_ids = [[7, 8], [3]]
1563 + meta.local_physical_block_ids = [[1, 2], [5]]
1564 +
1565 + worker._xfer_blocks_for_req("req-1", meta)
1566 +
1567 + calls = worker._xfer_blocks.call_args_list
1568 + assert len(calls) == 2
1569 + for call, rank, local_handle, remote_handle in zip(
1570 + calls, (0, 1), (200, 201), (100, 101)
1571 + ):
1572 + spec = call.kwargs["read_spec"]
1573 + assert spec.remote_rank == rank
1574 + # Attention group replicated to every rank, SSM by membership.
1575 + assert spec.local_block_ids == [[1, 2], [5]]
1576 + assert spec.remote_block_ids == [[7, 8], [3]]
1577 + assert call.kwargs["local_xfer_side_handle"] == local_handle
1578 + assert call.kwargs["remote_xfer_side_handle"] == remote_handle
tests/v1/kv_connector/unit/test_nixl_desc_geometry.py
+21
@@ -617,3 +617,24 @@ def test_mla_hybrid_large_ppl_geometry(num_tokens):
617 num_tokens=num_tokens,
618 tp_size=8,
619 )
620 +
621 +
622 +@pytest.mark.cpu_test
623 +def test_mismatched_mla_kernel_page_rejected_for_mla_hybrid():
624 + """The MLA per-token page is TP-independent, so kernel block lengths
625 + differing by anything other than the block-size ratio must fail the
626 + handshake loudly rather than transfer at mismatched geometry."""
627 + worker = _make_mla_hybrid_worker(
628 + local_block_size=12, kernel_block_size=4, num_logical_blocks=8
629 + )
630 + meta_r = _make_remote_meta(
631 + worker,
632 + remote_block_size=8,
633 + remote_kernel_block_size=4,
634 + remote_num_logical=12,
635 + remote_ssm_sizes=(24, 32),
636 + )
637 + # Equal kernel block sizes (ratio 1), but a half-sized per-token page.
638 + meta_r.block_lens = [x // 2 for x in worker.block_len_per_layer]
639 + with pytest.raises((AssertionError, RuntimeError)):
640 + worker.add_remote_agent(meta_r, remote_tp_rank=0, remote_tp_size=2)
tests/v1/kv_connector/unit/test_nixl_push_connector.py
+4
@@ -43,6 +43,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.push_worker import (
43 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.utils import (
44 get_base_request_id,
45 )
46 +from vllm.v1.kv_cache_interface import FullAttentionSpec
47 from vllm.v1.outputs import KVConnectorOutput
48
49 from .utils import make_nixl_push_scheduler
@@ -337,6 +338,9 @@ class _StubWriterWorker(NixlPushConnectorWorker):
338 w.engine_id = "test-decode-engine"
339 w._remote_agents = {}
340 w._physical_blocks_per_logical_kv_block = 1
341 + # Single non-hybrid attention group, matching the stub block id lists.
342 + w._has_mamba = False
343 + w._group_spec_types = (FullAttentionSpec,)
344
345 # Track _do_start_push_kv invocations.
346 calls: list[tuple[str, Any, dict[str, Any]]] = []
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py
+25 -5
@@ -1093,7 +1093,7 @@ class NixlBaseConnectorWorker:
1093
1094 caches_data = []
1095 # With hybrid allocator, layers can share a kv cache tensor
1096 - seen_base_addresses = []
1096 + seen_base_addresses: list[int] = []
1097
1098 # K and V are packed into the content dim, so each attention layer is a
1099 # single NIXL region whose block transfers as one unit. Mamba layers instead
@@ -1139,11 +1139,19 @@ class NixlBaseConnectorWorker:
1139 curr_tensor_size_bytes = num_blocks * physical_page_size
1140
1141 base_addr = cache.data_ptr()
1142 + is_mla_region = isinstance(
1143 + layer_spec, (MLAAttentionSpec, SlidingWindowMLASpec)
1144 + )
1145 if base_addr in seen_base_addresses:
1146 # NOTE (NickLucche) HMA employs memory pooling to share tensors
1147 # across groups. This results in skipping all tensors but the ones
1148 # pointed to by group0. Also, generally we will have more blocks
1149 # per tensor but fewer regions.
1150 + # A shared tensor may back both SSM and attention layers (e.g.
1151 + # KDA+MLA in KimiLinear); the region's FA view is MLA whichever
1152 + # layer registered it first.
1153 + idx = seen_base_addresses.index(base_addr)
1154 + self._region_is_mla[idx] |= is_mla_region
1155 logger.debug("Skipping %s because it's already seen", layer_name)
1156 continue
1157 logger.debug(
@@ -1157,9 +1165,6 @@ class NixlBaseConnectorWorker:
1165 )
1166 else:
1167 self.block_len_per_layer.append(physical_page_size)
1160 - is_mla_region = isinstance(
1161 - layer_spec, (MLAAttentionSpec, SlidingWindowMLASpec)
1162 - )
1168 self._region_is_mla.append(is_mla_region)
1169
1170 if not is_mla_region:
@@ -1801,7 +1806,22 @@ class NixlBaseConnectorWorker:
1806 # the per-rank KV head ratio rather than the raw tp_ratio, because GQA
1807 # replication caps per-rank heads at 1 when tp > total_kv_heads
1808 # (issue #45330). Mamba uses the ssm_sizes counterpart, so skip here.
1804 - if not self._has_mamba:
1809 + if self._has_mamba and self.use_mla:
1810 + # Hybrid MLA+SSM (e.g. KimiLinear's KDA+MLA): regions are
1811 + # kernel-granularity views of the mamba-unified page. The MLA
1812 + # per-token page is TP-independent, so the block lengths must
1813 + # match up to the kernel block size ratio even under
1814 + # heterogeneous TP (remote kernel blocks may be smaller).
1815 + # SSM geometry is validated via ssm_sizes/conv offsets instead.
1816 + assert self.block_len_per_layer == [
1817 + block_len * block_size_ratio for block_len in nixl_agent_meta.block_lens
1818 + ], (
1819 + "Hybrid MLA kernel-granularity block lengths must match "
1820 + f"between P and D (block_size_ratio={block_size_ratio}): "
1821 + f"local={self.block_len_per_layer}, "
1822 + f"remote={nixl_agent_meta.block_lens}."
1823 + )
1824 + elif not self._has_mamba:
1825 assert len(self.block_len_per_layer) == len(nixl_agent_meta.block_lens), (
1826 "Number of KV layers must match between prefill and decode"
1827 )
vllm/distributed/kv_transfer/kv_connector/v1/nixl/push_worker.py
+35 -33
@@ -52,7 +52,10 @@ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
52 ReqMeta,
53 TransferHandle,
54 )
55 -from vllm.distributed.kv_transfer.kv_connector.v1.nixl.tp_mapping import ReadSpec
55 +from vllm.distributed.kv_transfer.kv_connector.v1.nixl.tp_mapping import (
56 + ReadSpec,
57 + _is_attention_spec,
58 +)
59 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.utils import get_base_request_id
60 from vllm.logger import init_logger
61
@@ -505,41 +508,37 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
508 local_block_ids = meta.local_physical_block_ids
509 num_groups = len(local_block_ids)
510
508 - if self.use_mla and tp_ratio < 0:
509 - # MLA latent is replicated across D's TP ranks: the tp-mapping
510 - # collapses to one rank (fine for reads), but push must WRITE every
511 - # D rank or the rest decode stale KV; only the dst differs per rank.
511 + # MLA latent is replicated across D's TP ranks: the tp-mapping
512 + # collapses it to one rank (fine for reads), but push must WRITE every
513 + # D rank or the rest decode stale KV. For hybrid MLA+SSM the sharded
514 + # SSM state already targets every covered D rank, so only the
515 + # attention groups need widening; pure MLA writes to all handshaked
516 + # ranks (only the dst differs per rank).
517 + replicate_attn = self.use_mla and tp_ratio < 0
518 + if replicate_attn and not self._has_mamba:
519 assert len(plan.all_source_ranks) == 1
513 - mla_local_ids = [list(ids) for ids in local_block_ids]
514 - mla_remote_ids = [list(ids) for ids in remote_block_ids]
515 - read_specs = [
516 - ReadSpec(
517 - remote_rank=rank,
518 - local_block_ids=mla_local_ids,
519 - remote_block_ids=mla_remote_ids,
520 - )
521 - for rank in self.dst_xfer_side_handles[engine_id]
522 - ]
520 + write_ranks = sorted(self.dst_xfer_side_handles[engine_id])
521 else:
524 - read_specs = [
525 - ReadSpec(
526 - remote_rank=rank,
527 - local_block_ids=[
528 - list(local_block_ids[g])
529 - if rank in plan.source_ranks_per_group[g]
530 - else []
531 - for g in range(num_groups)
532 - ],
533 - remote_block_ids=[
534 - list(remote_block_ids[g])
535 - if rank in plan.source_ranks_per_group[g]
536 - else []
537 - for g in range(num_groups)
538 - ],
539 - )
540 - for rank in plan.all_source_ranks
522 + write_ranks = list(plan.all_source_ranks)
523 +
524 + def group_ids(block_ids: BlockIds, rank: int) -> BlockIds:
525 + return [
526 + list(block_ids[g])
527 + if (replicate_attn and _is_attention_spec(self._group_spec_types[g]))
528 + or rank in plan.source_ranks_per_group[g]
529 + else []
530 + for g in range(num_groups)
531 ]
532
533 + read_specs = [
534 + ReadSpec(
535 + remote_rank=rank,
536 + local_block_ids=group_ids(local_block_ids, rank),
537 + remote_block_ids=group_ids(remote_block_ids, rank),
538 + )
539 + for rank in write_ranks
540 + ]
541 +
542 handles: list[int] = []
543 for i, spec in enumerate(read_specs):
544 remote_block_size = remote_info.remote_block_size
@@ -551,7 +550,10 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
550 remote_block_size,
551 req_id,
552 )
554 - if tp_ratio < 0 and not self.use_mla:
553 + if tp_ratio < 0 and (not self.use_mla or len(plan.all_source_ranks) > 1):
554 + # Multiple targets: write each rank its chunk of local memory.
555 + # Hybrid MLA+SSM also lands here: its split handles replicate
556 + # the attention descriptors and chunk only the SSM state.
557 split_key = (tp_ratio, remote_block_size)
558 local_xfer_side_handle = self.src_xfer_handles_by_tp_ratio[split_key][i]
559 else: