@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 6fbfdd1831
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

[NIXL] Per-region KV transfer classification for mixed full-attn + MLA groups (#44583)

Dao007forever committed Jun 11, 2026 at 21:42 UTC 6fbfdd183145443274df49c09c46cd13ea27af5f
3 files changed +234 -68
tests/v1/kv_connector/unit/test_nixl_connector.py
+81
@@ -1063,6 +1063,87 @@ class TestNixlHandshake:
1063 # whole block is moved.
1064 worker.add_remote_agent(meta, remote_tp_size=1)
1065
1066 + @patch(
1067 + "vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker.NixlWrapper",
1068 + FakeNixlWrapper,
1069 + )
1070 + def test_handshake_mixed_fa_mla_hetero_tp(self, default_vllm_config, dist_init):
1071 + """Mixed full-attn (SPLIT) + MLA (REPLICATE) single KV group under
1072 + heterogeneous TP must NOT raise (previously a NotImplementedError),
1073 + and the per-region gate must still reject a wrong block_len.
1074 + """
1075 + vllm_config = create_vllm_config()
1076 + with patch(
1077 + "vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker.get_tensor_model_parallel_world_size", # noqa: E501
1078 + return_value=2,
1079 + ):
1080 + connector = NixlConnector(
1081 + vllm_config, KVConnectorRole.WORKER, make_kv_cache_config(block_size=16)
1082 + )
1083 + connector.connector_worker = FakeNixlConnectorWorker(
1084 + vllm_config, connector.engine_id, hand_shake_latency=0
1085 + )
1086 + worker = connector.connector_worker
1087 +
1088 + # Region 0: full-attn (SPLIT). Region 1: MLA (REPLICATE).
1089 + fa_len = 4096 * worker.block_size
1090 + idx_len = 512 * worker.block_size
1091 + worker.slot_size_per_layer = [4096, 512]
1092 + worker.block_len_per_layer = [fa_len, idx_len]
1093 + worker._region_is_mla = [False, True]
1094 + worker.num_blocks = 1
1095 + worker.dst_num_blocks[worker.engine_id] = worker.num_blocks
1096 + worker.src_blocks_data = [
1097 + (0, fa_len, worker.tp_rank),
1098 + (0, idx_len, worker.tp_rank),
1099 + ]
1100 + worker.num_descs = len(worker.src_blocks_data)
1101 +
1102 + # D_TP=2, P_TP=1 -> tp_ratio=2. SPLIT region scales by tp_ratio;
1103 + # REPLICATE region is unchanged.
1104 + tp_ratio = 2
1105 + meta = NixlAgentMetadata(
1106 + engine_id=FakeNixlConnectorWorker.REMOTE_ENGINE_ID,
1107 + agent_metadata=FakeNixlWrapper.AGENT_METADATA,
1108 + kv_caches_base_addr=[0, 0],
1109 + device_id=0,
1110 + num_blocks=1,
1111 + block_lens=[fa_len * tp_ratio, idx_len],
1112 + kv_cache_layout=worker.kv_cache_layout,
1113 + block_size=worker.block_size,
1114 + ssm_sizes=(0, 0),
1115 + attn_backend_name=worker.backend_name,
1116 + physical_blocks_per_logical_kv_block=1,
1117 + )
1118 + worker.add_remote_agent(meta, remote_tp_size=1)
1119 + assert (
1120 + FakeNixlConnectorWorker.REMOTE_ENGINE_ID in worker.dst_xfer_side_handles
1121 + )
1122 + # Gate rejects an MLA region wrongly scaled by tp_ratio.
1123 + worker2 = FakeNixlConnectorWorker(
1124 + vllm_config, connector.engine_id, hand_shake_latency=0
1125 + )
1126 + worker2.block_len_per_layer = [fa_len, idx_len]
1127 + worker2._region_is_mla = [False, True]
1128 + worker2.num_blocks = 1
1129 + worker2.dst_num_blocks[worker2.engine_id] = worker2.num_blocks
1130 + bad_meta = NixlAgentMetadata(
1131 + engine_id=FakeNixlConnectorWorker.REMOTE_ENGINE_ID,
1132 + agent_metadata=FakeNixlWrapper.AGENT_METADATA,
1133 + kv_caches_base_addr=[0, 0],
1134 + device_id=0,
1135 + num_blocks=1,
1136 + # WRONG: MLA region scaled by tp_ratio (it should be replicated).
1137 + block_lens=[fa_len * tp_ratio, idx_len * tp_ratio],
1138 + kv_cache_layout=worker2.kv_cache_layout,
1139 + block_size=worker2.block_size,
1140 + ssm_sizes=(0, 0),
1141 + attn_backend_name=worker2.backend_name,
1142 + physical_blocks_per_logical_kv_block=1,
1143 + )
1144 + with pytest.raises(AssertionError):
1145 + worker2.add_remote_agent(bad_meta, remote_tp_size=1)
1146 +
1147
1148 # NOTE: resource cleanup in mp backend is a bit finicky, so the order in which
1149 # we put here is important. First run ray, it will clean up the resources, then
tests/v1/kv_connector/unit/test_tp_mapping.py
+11 -1
@@ -73,9 +73,19 @@ class TestTPMappingStructure:
73
74
75 def _make_mock_worker_for_splits(group_spec_types):
76 - """Build a mock NixlConnectorWorker with _group_spec_types for split tests."""
76 + """Build a mock NixlConnectorWorker with _group_spec_types for split tests.
77 +
78 + No per-region replicate flags are configured (``block_len_per_layer`` empty
79 + and ``num_regions == 0``), so ``_fa_desc_replicated`` takes its early-return
80 + path and treats every FA descriptor as SPLIT, matching the legacy behavior
81 + these tests assert.
82 + """
83 worker = object.__new__(NixlConnectorWorker)
84 worker._group_spec_types = group_spec_types
85 + worker.transfer_topo = SimpleNamespace(virtually_split_kv_in_blocks=False)
86 + worker.block_len_per_layer = []
87 + worker.num_regions = 0
88 + worker._region_is_mla = []
89 return worker
90
91
vllm/distributed/kv_transfer/kv_connector/v1/nixl/worker.py
+142 -67
@@ -71,6 +71,7 @@ from vllm.v1.attention.backends.utils import get_kv_cache_layout
71 from vllm.v1.kv_cache_interface import (
72 FullAttentionSpec,
73 MambaSpec,
74 + MLAAttentionSpec,
75 UniformTypeKVCacheSpecs,
76 )
77 from vllm.v1.worker.block_table import BlockTable
@@ -178,19 +179,63 @@ class NixlConnectorWorker:
179 else 0
180 )
181
182 + # Per-FA-descriptor replicate flag, in _build_fa_local emission order.
183 + fa_desc_replicated = self._fa_desc_replicated(num_fa_descs)
184 +
185 for p_idx, p_rank in enumerate(plan.all_source_ranks):
186 fa_slot = plan.rank_to_attention_slot.get(p_rank, 0)
187
188 handle: list[tuple[int, int, int]] = []
189 for j, (addr, local_len, dev) in enumerate(src_blocks_data):
190 if j < num_fa_descs:
187 - chunk = local_len // fa_num_splits
188 - handle.append((addr + fa_slot * chunk, chunk, dev))
191 + if fa_desc_replicated[j]:
192 + # REPLICATE (MLA): whole block written on every rank.
193 + handle.append((addr, local_len, dev))
194 + else:
195 + # SPLIT (full-attn): this rank's head slice.
196 + chunk = local_len // fa_num_splits
197 + handle.append((addr + fa_slot * chunk, chunk, dev))
198 else:
199 chunk = local_len // ssm_num_splits
200 handle.append((addr + p_idx * chunk, chunk, dev))
201 yield handle
202
203 + def _fa_desc_replicated(self, num_fa_descs: int) -> list[bool]:
204 + """Per-FA-descriptor replicate flag, in _build_fa_local emission order
205 + (region-major; K then optional V per region). Length ``num_fa_descs``.
206 + """
207 + assert self.transfer_topo is not None
208 + n_regions = len(self.block_len_per_layer)
209 + # Unset only when the worker is built directly in unit tests; a real
210 + # model always registers regions (no-KV-cache crashes long before here).
211 + # Fall back to all-SPLIT to preserve the pre-per-region behavior.
212 + if n_regions == 0 or self.num_regions == 0:
213 + return [False] * num_fa_descs
214 + # Descriptors (blocks) per stream; all streams share the same count.
215 + nblk = num_fa_descs // self.num_regions
216 + virtually_split = self.transfer_topo.virtually_split_kv_in_blocks
217 + flags: list[bool] = []
218 + for i in range(n_regions):
219 + replicated = self._is_region_replicated(i)
220 + # REPLICATE (MLA) is key-only -> 1 stream; SPLIT emits K and V
221 + # (2 streams) under the virtually-split layout.
222 + num_streams = 1 if replicated or not virtually_split else 2
223 + flags.extend([replicated] * (num_streams * nblk))
224 + assert len(flags) == num_fa_descs, (
225 + f"FA desc flags {len(flags)} != num_fa_descs {num_fa_descs}"
226 + )
227 + return flags
228 +
229 + def _is_region_replicated(self, region_idx: int) -> bool:
230 + """Whether region ``region_idx`` is transferred REPLICATE vs SPLIT.
231 +
232 + REPLICATE (MLA): identical on every rank, whole block read from one
233 + rank at offset 0, key-only. SPLIT (full-attn): head-sharded across TP.
234 + Defaults to SPLIT when the per-region map is unset (e.g. tests that set
235 + block_len_per_layer without register_kv_caches).
236 + """
237 + return region_idx < len(self._region_is_mla) and self._region_is_mla[region_idx]
238 +
239 def __init__(
240 self,
241 vllm_config: "VllmConfig",
@@ -450,6 +495,15 @@ class NixlConnectorWorker:
495 for g in self.kv_cache_config.kv_cache_groups
496 )
497
498 + # Per-region MLA flag, 1:1 with block_len_per_layer. True -> REPLICATE
499 + # (MLA), False -> SPLIT (head-sharded full-attn). Mixed only for models
500 + # combining both (e.g. GQA main + MLA Eagle-3 draft).
501 + self._region_is_mla = list[bool]()
502 +
503 + # Enable different block lengths for different layers *only* when MLA is used.
504 + # This is not used for SSM layers, which use the counterpart `mamba_ssm_size`.
505 + self.block_len_per_layer = list[int]()
506 +
507 # Per-engine TP mappings. Generated during handshake.
508 self.tp_mappings: dict[EngineId, TPMapping] = {}
509
@@ -849,9 +903,6 @@ class NixlConnectorWorker:
903 # to better exploit the memory layout (ie num_blocks is the first dim).
904 tensor_size_bytes = None
905
852 - # Enable different block lengths for different layers *only* when MLA is used.
853 - # This is not used for SSM layers, which use the counterpart `mamba_ssm_size`.
854 - self.block_len_per_layer = list[int]()
906 for layer_name, cache_or_caches in xfer_buffers.items():
907 # NOTE (NickLucche) Hybrid SSM models assume a layout that is similar to
908 # that of FI, with block laid out as in `get_backend_aware_kv_block_len`.
@@ -895,8 +946,6 @@ class NixlConnectorWorker:
946 # `page_size` accounts for physical blocks, st KVCache is always
947 # [`num_blocks` * `page_size`]
948 curr_tensor_size_bytes = num_blocks * physical_page_size
898 - if tensor_size_bytes is None:
899 - tensor_size_bytes = curr_tensor_size_bytes
949
950 # TODO (NickLucche) we could eventually unify how we handle FA/FI regions,
951 # registering a single tensor for both K/V and splitting logically like FI.
@@ -920,6 +969,20 @@ class NixlConnectorWorker:
969 )
970 else:
971 self.block_len_per_layer.append(physical_page_size)
972 + is_mla_region = isinstance(layer_spec, MLAAttentionSpec)
973 + self._region_is_mla.append(is_mla_region)
974 +
975 + # HeteroTP cannot transfer differently-sized regions, so every
976 + # non-MLA region in a group must share one tensor size (this also
977 + # holds for Mamba-like models). The sole exception is the DeepSeek
978 + # MLA indexer, which sits in a UniformTypeKVCacheSpecs group at a
979 + # different size; MLA regions are therefore exempt.
980 + if not is_mla_region:
981 + if tensor_size_bytes is None:
982 + tensor_size_bytes = curr_tensor_size_bytes
983 + assert tensor_size_bytes == curr_tensor_size_bytes, (
984 + "All non-MLA kv cache tensors must have the same size"
985 + )
986
987 if cache.shape[0] != num_blocks:
988 raise AssertionError(
@@ -937,12 +1000,6 @@ class NixlConnectorWorker:
1000 f"{self.transfer_topo.is_kv_layout_blocks_first}"
1001 )
1002
940 - if not self.use_mla:
941 - # Different kv cache shape is not supported by HeteroTP.
942 - # This must also hold true for Mamba-like models.
943 - assert tensor_size_bytes == curr_tensor_size_bytes, (
944 - "All kv cache tensors must have the same size"
945 - )
1003 # Need to make sure the device ID is non-negative for NIXL,
1004 # Torch uses -1 to indicate CPU tensors.
1005 self.device_id = max(cache.get_device(), 0)
@@ -953,7 +1010,11 @@ class NixlConnectorWorker:
1010 logger.debug(
1011 "Different block lengths collected: %s", set(self.block_len_per_layer)
1012 )
956 - assert len(self.block_len_per_layer) == len(seen_base_addresses)
1013 + assert (
1014 + len(self.block_len_per_layer)
1015 + == len(seen_base_addresses)
1016 + == len(self._region_is_mla)
1017 + )
1018
1019 self.kv_caches_base_addr[self.engine_id][self.tp_rank] = seen_base_addresses
1020 self.num_regions = len(caches_data)
@@ -967,7 +1028,12 @@ class NixlConnectorWorker:
1028 # of 'virtual' regions here and halve `block_len` below.
1029 # Similarly for Mamba layers, we register SSM+Conv as a single region and
1030 # then duplicate it logically to be able to index SSM/Conv separately.
970 - self.num_regions *= 2
1031 + # Exception: key-only REPLICATE regions (MLA) have no V half, so
1032 + # they contribute a single desc stream and are not doubled.
1033 + self.num_regions = sum(
1034 + 1 if self._is_region_replicated(i) else 2
1035 + for i in range(len(self._region_is_mla))
1036 + )
1037
1038 # Total local FA descriptors (boundary between FA and mamba descs).
1039 self.num_descs = self.num_regions * self.num_blocks
@@ -1133,10 +1199,13 @@ class NixlConnectorWorker:
1199 addr = base_addr + block_offset
1200 result.append((addr, kv_block_len, self.device_id))
1201
1136 - if self.transfer_topo.virtually_split_kv_in_blocks:
1202 + if (
1203 + self.transfer_topo.virtually_split_kv_in_blocks
1204 + and not self._is_region_replicated(i)
1205 + ):
1206 # Separate and interleave K/V regions to maintain the same
1207 # descs ordering. This is needed for selecting contiguous heads
1139 - # when split across TP ranks.
1208 + # when split across TP ranks. (Skipped for key-only REPLICATE.)
1209 second_split = self.get_backend_aware_kv_block_len(
1210 layer_idx=i, first_split=False, mamba_view=False
1211 )
@@ -1158,10 +1227,13 @@ class NixlConnectorWorker:
1227 fa_group_idx = next(
1228 i for i, t in enumerate(self._group_spec_types) if _is_attention_spec(t)
1229 )
1161 - num_attn_reads = len(plan.source_ranks_per_group[fa_group_idx])
1230 + # SPLIT regions read their head slice from this many remote ranks at a
1231 + # per-rank offset; REPLICATE regions read the whole block once.
1232 + split_reads = len(plan.source_ranks_per_group[fa_group_idx])
1233 num_blocks = nixl_agent_meta.num_blocks
1234 result: list[tuple[int, int, int]] = []
1235 for i, base_addr in enumerate(nixl_agent_meta.kv_caches_base_addr):
1236 + replicated = self._is_region_replicated(i)
1237 # Read our whole local region size from remote..
1238 local_block_len = self.get_backend_aware_kv_block_len(
1239 layer_idx=i, first_split=True, mamba_view=False
@@ -1171,8 +1243,13 @@ class NixlConnectorWorker:
1243 # ..using remote kv_block_len as transfer unit
1244 local_block_len = remote_kv_block_len
1245
1174 - local_block_len = local_block_len // num_attn_reads
1175 - rank_offset = plan.rank_offset_factor * remote_kv_block_len
1246 + # REPLICATE reads the whole block once at offset 0; SPLIT gathers
1247 + # its head slice from `split_reads` remote ranks at a per-rank offset.
1248 + num_reads = 1 if replicated else split_reads
1249 + rank_offset = (
1250 + 0 if replicated else plan.rank_offset_factor * remote_kv_block_len
1251 + )
1252 + local_block_len = local_block_len // num_reads
1253
1254 page_size = nixl_agent_meta.block_lens[i]
1255 for block_id in range(num_blocks):
@@ -1182,12 +1259,13 @@ class NixlConnectorWorker:
1259 addr = base_addr + block_offset + rank_offset
1260 result.append((addr, local_block_len, nixl_agent_meta.device_id))
1261
1185 - if self.transfer_topo.virtually_split_kv_in_blocks:
1262 + emits_v = self.transfer_topo.virtually_split_kv_in_blocks and not replicated
1263 + if emits_v:
1264 # With FlashInfer index V separately to allow head splitting.
1265 second_split = self.get_backend_aware_kv_block_len(
1266 layer_idx=i, first_split=False, mamba_view=False
1267 )
1190 - second_split = second_split // num_attn_reads
1268 + second_split = second_split // num_reads
1269 for block_id in range(num_blocks):
1270 block_offset = block_id * page_size
1271 addr = base_addr + block_offset + rank_offset
@@ -1527,49 +1605,43 @@ class NixlConnectorWorker:
1605 "Use HND layout on the prefill side."
1606 )
1607
1530 - # Block len can only vary across layers when using MLA.
1531 - remote_block_len = nixl_agent_meta.block_lens[0]
1532 - if self.use_mla or self.transfer_topo.is_kv_replicated(remote_engine_id):
1533 - # With replicated KV cache, only the number of blocks can differ.
1534 - # TODO (ZhanqiuHu): For mamba models, validate FA and mamba
1535 - # block_lens separately.
1536 - if not self._has_mamba:
1537 - for i in range(len(self.block_len_per_layer)):
1538 - assert (
1539 - self.block_len_per_layer[i] // block_size_ratio
1540 - == nixl_agent_meta.block_lens[i]
1541 - ), "KV cache sizes must match between P and D when replicated"
1542 - else:
1543 - # When MLA is not used, this is a list of the same block length
1544 - for block_len in nixl_agent_meta.block_lens:
1545 - assert block_len == remote_block_len, (
1546 - "All remote layers must have the same block size"
1547 - )
1548 -
1549 - # HMA hybrid models (mamba+attention) pad block_len to
1550 - # max(attn_page, mamba_page), so the linear tp_ratio scaling
1551 - # assumption only holds for pure-attention models.
1552 - if not self._has_mamba:
1553 - if tp_ratio > 0:
1554 - assert (
1555 - remote_block_len
1556 - == (self.block_len_per_layer[0] * tp_ratio) // block_size_ratio
1557 - ), (
1558 - "Remote P worker KV layer cache must be of shape [2, N,"
1559 - " local_kv_heads*tp_ratio, page_size, head_dim] and "
1560 - "same dtype."
1608 + # Per-region block_len validation enforcing the P/D invariant.
1609 + # REPLICATE regions (MLA, or a whole-model MLA / replicated-KV transfer)
1610 + # only allow the number of blocks to differ; SPLIT regions scale with
1611 + # tp_ratio. Mamba uses the ssm_sizes counterpart, so skip block_len here.
1612 + if not self._has_mamba:
1613 + assert len(self.block_len_per_layer) == len(nixl_agent_meta.block_lens), (
1614 + "Number of KV layers must match between prefill and decode"
1615 + )
1616 + model_replicated = self.use_mla or self.transfer_topo.is_kv_replicated(
1617 + remote_engine_id
1618 + )
1619 + for i, local_len in enumerate(self.block_len_per_layer):
1620 + replicated = model_replicated or self._is_region_replicated(i)
1621 + remote_len = nixl_agent_meta.block_lens[i]
1622 + if replicated:
1623 + # Whole block copied; only the number of blocks may differ.
1624 + assert local_len // block_size_ratio == remote_len, (
1625 + "KV cache sizes must match between P and D when "
1626 + f"replicated (region {i}: local={local_len}, "
1627 + f"remote={remote_len}, bsr={block_size_ratio})."
1628 + )
1629 + elif tp_ratio > 0:
1630 + # D_TP >= P_TP: remote holds tp_ratio x local heads.
1631 + assert remote_len == (local_len * tp_ratio) // block_size_ratio, (
1632 + f"SPLIT region {i}: remote P KV block_len {remote_len} "
1633 + f"must equal local {local_len} * tp_ratio {tp_ratio} "
1634 + f"// block_size_ratio {block_size_ratio}."
1635 )
1636 else:
1637 + # P_TP > D_TP: local holds |tp_ratio| x remote heads.
1638 assert block_size_ratio == 1, (
1564 - "Different local/remote block sizes are not supported"
1565 - " when P TP > D TP."
1639 + "Different local/remote block sizes are not supported "
1640 + "when P TP > D TP."
1641 )
1567 - assert remote_block_len == self.block_len_per_layer[0] // (
1568 - -tp_ratio
1569 - ), (
1570 - "Remote P worker KV layer cache must be of shape [2, N,"
1571 - " local_kv_heads/tp_ratio, page_size, head_dim] and "
1572 - "same dtype."
1642 + assert remote_len == local_len // (-tp_ratio), (
1643 + f"SPLIT region {i}: remote P KV block_len {remote_len} "
1644 + f"must equal local {local_len} // |tp_ratio| {-tp_ratio}."
1645 )
1646
1647 # TP workers that handhshake with same remote have same #blocks.
@@ -2450,13 +2522,16 @@ class NixlConnectorWorker:
2522 |1st_split-2nd_split| |1st_split-2nd_split |
2523 """
2524 assert self.transfer_topo is not None
2453 - if self.transfer_topo.virtually_split_kv_in_blocks:
2454 - if mamba_view:
2455 - block_len = self._mamba_ssm_size[not first_split]
2456 - else:
2457 - block_len = self.block_len_per_layer[layer_idx] // 2
2525 + virtually_split = self.transfer_topo.virtually_split_kv_in_blocks
2526 + if virtually_split and mamba_view:
2527 + block_len = self._mamba_ssm_size[not first_split]
2528 else:
2459 - block_len = self.block_len_per_layer[layer_idx]
2529 + # Per-descriptor block length: a SPLIT region (full-attn under the
2530 + # virtually-split layout) emits separate K and V and uses
2531 + # block_len//2; REPLICATE (MLA, key-only) and non-split layouts use
2532 + # the whole block.
2533 + half_block = virtually_split and not self._is_region_replicated(layer_idx)
2534 + block_len = self.block_len_per_layer[layer_idx] // (2 if half_block else 1)
2535 return block_len
2536
2537 def get_kv_connector_stats(self) -> KVConnectorStats | None: