[KVConnector][NIXL] Support attention-HMA layouts in pipeline-parallel push prefill (#50494)
Signed-off-by: zixi-qi <zixi@inferact.ai> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Yiliu Dong <91178480+qianlihuang@users.noreply.github.com> Co-authored-by: Codex <noreply@openai.com>
qizixi committed
Sep 15, 2026 at 15:43 UTC
0fefffc9346644a01dc132e544b881ad646ea81b
8 files changed
+713
-49
docs/design/nixl_kv_push_connector.md
+44
@@ -181,6 +181,50 @@ The completion notif sent from P to D after a WRITE is the existing
181
is D's own request id, taken from the registration), so the D-side
182
accounting code is unchanged.
183
184
+## Pipeline parallelism and hybrid KV caches
185
+
186
+The pull connector requires the local and remote workers to expose a
187
+congruent list of KV regions — region *i* here corresponds to region
188
+*i* there. That assumption breaks under pipeline parallelism (PP)
189
+combined with a hybrid (HMA) KV layout:
190
+
191
+* a PP-sharded prefiller (**P**) holds only a slice of the model's
192
+ layers while the `PP=1` decoder (**D**) holds them all, so region
193
+ counts differ;
194
+* with HMA, several layer names are pooled into one region and the layer
195
+ that represents a pooled region can differ between P and D.
196
+
197
+`NixlPushConnector` handles this for PP-sharded producers by routing
198
+**by layer-name (member) identity** instead of by region index. Each
199
+worker advertises which layer names back each of its NIXL regions in the
200
+handshake metadata (`NixlAgentMetadata.region_members`). A producer that
201
+needs member routing derives its member-major layout once, when it
202
+registers its KV caches, and every transfer it issues uses that order.
203
+`add_remote_agent` then selects exactly the remote regions this stage
204
+owns and reorders them to match, so both sides stay paired regardless of
205
+how each remote rank happens to order its metadata.
206
+
207
+Addresses, block lengths, strides, and per-region capacities follow the same
208
+member order. Descriptor offsets use each member's region capacity, so P and
209
+D need not allocate the same number of blocks. Physical allocations are still
210
+registered once, even when multiple members share them.
211
+
212
+Invariants enforced when the remote regions are aligned:
213
+
214
+* every locally owned member must be advertised exactly once by the
215
+ remote; a missing member fails the handshake rather than silently
216
+ leaving that layer's KV stale, and remote-only members (owned by other
217
+ PP stages) are ignored;
218
+* a remote that omits member metadata while the local layout requires
219
+ member routing fails the handshake instead of falling back to
220
+ region-index routing;
221
+* member order is a property of the local layout alone, so the same local
222
+ source descriptors serve every remote engine and TP rank; only the
223
+ remote descriptor list is rebuilt per rank.
224
+
225
+Decode-side PP is unsupported because completions are counted per
226
+consumer rank. Mamba/SSM hybrids are unsupported under PP.
227
+
228
## Scheduler-side responsibilities
229
230
`NixlPushConnectorScheduler` extends the base scheduler with:
docs/features/nixl_connector_compatibility.md
+18
@@ -104,6 +104,24 @@ By default, a **compatibility hash** is checked during handshake. P and D instan
104
- `LBNHC` (token-major, formerly `NHD`) layout is supported but does **not** allow heterogeneous TP head splitting.
105
- Experimental `LBHNC` ↔ `LBNHC` permute: enable via `--kv-transfer-config '{"enable_permute_local_kv": true}'`. Not supported with HMA.
106
107
+### Pipeline parallelism
108
+
109
+The default (pull) NixlConnector does **not** support
110
+`pipeline-parallel-size > 1` together with a hybrid KV cache (HMA)
111
+layout: region indices are not a stable identity across the
112
+prefill/decode layer split, so the connector raises at startup. Use the
113
+push connector (`NixlPushConnector`) for pipeline parallelism with
114
+hybrid KV caches — it routes transfers by layer-name (member) identity,
115
+so a PP-sharded prefiller can write into a `PP=1` decoder. See
116
+[NIXL push-mode KV transfer](../design/nixl_kv_push_connector.md).
117
+
118
+Current push PP + HMA limitations:
119
+
120
+- Only the prefiller (producer) may be PP-sharded; decode-side PP is not supported.
121
+- Hybrid SSM/Mamba layouts are not supported under PP.
122
+- HMA requires the same block size on P and D.
123
+- Attention-HMA member routing requires decode TP to be no greater than prefill TP.
124
+
125
### Quantized KV cache
126
127
[Quantized KV cache](quantization/quantized_kvcache.md) (e.g., FP8) requires both P and D instances to use the **same** `cache_dtype`. Mismatched cache dtypes will fail the compatibility hash check during handshake.
tests/v1/kv_connector/unit/test_nixl_connector_hma.py
+1
@@ -918,6 +918,7 @@ def _make_mock_worker_for_desc_ids(
918
worker = MagicMock(spec=NixlConnectorWorker)
919
worker.num_regions = num_regions
920
worker._uses_region_group_mapping = False
921
+ worker._transfer_layer_group_ids = ()
922
worker._has_mamba = has_mamba
923
worker._group_spec_types = group_spec_types
924
worker.block_len_per_layer = block_len_per_layer or [100]
tests/v1/kv_connector/unit/test_nixl_desc_geometry.py
+28
-4
@@ -12,6 +12,7 @@ mid-decode (silent corruption of an unrelated request).
12
"""
13
14
from collections import defaultdict
15
+from threading import Event, Lock
16
from unittest.mock import MagicMock, patch
17
18
import numpy as np
@@ -112,6 +113,7 @@ def test_local_descriptors_follow_each_region_pool_capacity():
113
worker.block_len_per_layer = [16, 16]
114
worker.block_stride_per_layer = [16, 16]
115
worker.region_num_blocks = [2, 3]
116
+ worker._transfer_layer_region_indices = ()
117
118
descriptors = worker._build_fa_local([100, 1000], block_size_ratio=1)
119
@@ -119,7 +121,8 @@ def test_local_descriptors_follow_each_region_pool_capacity():
121
122
123
@pytest.mark.cpu_test
122
-def test_overlaid_transfer_groups_share_region_geometry():
124
+@pytest.mark.parametrize("push_pp", [False, True])
125
+def test_overlaid_transfer_groups_share_region_geometry(push_pp):
126
"""Groups overlaid on one allocation share its transfer region."""
127
import msgspec
128
@@ -127,6 +130,9 @@ def test_overlaid_transfer_groups_share_region_geometry():
130
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
131
NixlAgentMetadata,
132
)
133
+ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.push_worker import (
134
+ NixlPushConnectorWorker,
135
+ )
136
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
137
NixlConnectorWorker,
138
)
@@ -153,7 +159,14 @@ def test_overlaid_transfer_groups_share_region_geometry():
159
}
160
groups = [KVCacheGroupSpec([layer_name], spec) for layer_name in caches]
161
156
- worker = object.__new__(NixlConnectorWorker)
162
+ worker_cls = NixlPushConnectorWorker if push_pp else NixlConnectorWorker
163
+ worker = object.__new__(worker_cls)
164
+ if push_pp:
165
+ worker._push_writer_stop = Event()
166
+ worker._push_writer_wake = Event()
167
+ worker._push_writer_thread = MagicMock()
168
+ worker._sending_transfers_lock = Lock()
169
+ worker._sending_transfers = defaultdict(list)
170
worker.tp_rank = 0
171
worker.world_size = 1
172
worker.transfer_tp_rank = 0
@@ -198,6 +211,9 @@ def test_overlaid_transfer_groups_share_region_geometry():
211
worker._desc_is_dram_by_block_size = {}
212
worker._desc_pos_by_block_size = {}
213
worker._dram_src_handles_by_block_size = {}
214
+ worker._transfer_layer_names = ()
215
+ worker._transfer_layer_region_indices = ()
216
+ worker._transfer_layer_group_ids = ()
217
worker._region_is_mla = []
218
worker.block_len_per_layer = []
219
worker.block_stride_per_layer = []
@@ -205,7 +221,8 @@ def test_overlaid_transfer_groups_share_region_geometry():
221
worker.use_host_buffer = False
222
worker.host_xfer_buffers = {}
223
worker.device_kv_caches = {}
208
- worker.pp_size = 1
224
+ worker.pp_size = 2 if push_pp else 1
225
+ worker._is_hma_required = True
226
worker.dcp_size = 1
227
worker.pcp_size = 1
228
worker.kv_buffer_device = "cuda"
@@ -240,7 +257,13 @@ def test_overlaid_transfer_groups_share_region_geometry():
257
expected_addrs = [
258
backing.data_ptr() + block * block_stride for block in range(num_blocks)
259
]
243
- assert worker.src_blocks_data[:, 0].tolist() == expected_addrs
260
+ num_desc_regions = 2 if push_pp else 1
261
+ assert worker.src_blocks_data[:, 0].tolist() == expected_addrs * num_desc_regions
262
+ assert worker.num_descs == num_blocks * num_desc_regions
263
+ assert (
264
+ worker.dst_region_num_blocks[worker.engine_id]
265
+ == [num_blocks] * num_desc_regions
266
+ )
267
268
metadata = msgspec.msgpack.decode(
269
worker.xfer_handshake_metadata.agent_metadata_bytes,
@@ -248,6 +271,7 @@ def test_overlaid_transfer_groups_share_region_geometry():
271
)
272
assert metadata.region_group_ids == [-1]
273
assert metadata.region_num_blocks == [num_blocks]
274
+ assert metadata.region_members == ([["layer.0", "layer.1"]] if push_pp else [])
275
assert worker._block_ids_by_region(([0], [2]), worker.region_group_ids) == [[0, 2]]
276
277
tests/v1/kv_connector/unit/test_nixl_push_connector.py
+398
-2
@@ -32,9 +32,13 @@ from unittest.mock import MagicMock, patch
32
import msgspec
33
import pytest
34
35
+from vllm.distributed.kv_transfer.kv_connector.utils import TransferTopology
36
from vllm.distributed.kv_transfer.kv_connector.v1.base import (
37
KVConnectorTransferResults,
38
)
39
+from vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker import (
40
+ NixlBaseConnectorWorker,
41
+)
42
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
43
PUSH_REG_NOTIF_PREFIX,
44
NixlAgentMetadata,
@@ -43,6 +47,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
47
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.push_worker import (
48
NixlPushConnectorWorker,
49
)
50
+from vllm.distributed.kv_transfer.kv_connector.v1.nixl.tp_mapping import TPMapping
51
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.utils import (
52
get_base_request_id,
53
)
@@ -354,6 +359,7 @@ class _StubWriterWorker(NixlPushConnectorWorker):
359
w._failed_recv_reqs = queue.Queue()
360
w._recv_failures = set()
361
w._recving_transfers = defaultdict(list)
362
+ w._is_hma_required = False
363
w._reqs_to_process = set()
364
w._reqs_to_send = {}
365
w.consumer_notification_counts_by_req = defaultdict(int)
@@ -361,12 +367,16 @@ class _StubWriterWorker(NixlPushConnectorWorker):
367
w.pcp_rank = 0
368
w.pcp_dcp_sharded = False
369
w.world_size = 1
370
+ w.pp_size = 1
371
w.engine_id = "test-decode-engine"
372
w._remote_agents = {}
373
w._handshake_lock = threading.RLock()
374
w._physical_blocks_per_logical_kv_block = 1
375
w._uses_region_group_mapping = False
376
w.region_group_ids = [0]
377
+ w._transfer_layer_names = ()
378
+ w._transfer_layer_region_indices = ()
379
+ w._transfer_layer_group_ids = ()
380
w.dst_region_num_blocks = {}
381
w.dst_region_group_ids = {}
382
w.dst_uses_region_group_mapping = {}
@@ -922,12 +932,20 @@ class TestPushWriterNotifs:
932
# Both handles were still cleaned up.
933
assert request_id not in w._sending_transfers
934
925
- def test_completed_push_send_waits_for_consumer_notif(self):
935
+ @pytest.mark.parametrize(("pp_size", "is_hma"), [(1, False), (2, True)])
936
+ def test_completed_push_send_waits_for_consumer_notif(self, pp_size, is_hma):
937
"""P-side sends are completed by consumer notifs / lease expiry,
938
not by local WRITE-handle completion."""
939
w = self._pollable_worker()
940
+ w.pp_size = pp_size
941
+ w._is_hma_required = is_hma
942
request_id = self._make_sending_req(w)
930
- w.nixl_wrapper.check_xfer_state.return_value = "DONE"
943
+ w.nixl_wrapper.check_xfer_state.side_effect = ["DONE", "PROC", "DONE"]
944
+
945
+ results = w.get_transfer_results()
946
+ assert request_id not in results.finished_sending
947
+ assert w._sending_transfers[request_id] == [102]
948
+ assert request_id in w._reqs_to_send
949
950
results = w.get_transfer_results()
951
@@ -940,6 +958,12 @@ class TestPushWriterNotifs:
958
results = w.get_transfer_results()
959
assert request_id in results.finished_sending
960
assert request_id not in w._reqs_to_send
961
+ assert request_id not in w._reqs_to_process
962
+ assert w._evict_finished_inbox.get_nowait() == request_id
963
+ assert w.get_transfer_results().finished_sending == set()
964
+ assert w._evict_finished_inbox.empty()
965
+ assert w.nixl_wrapper.release_xfer_handle.call_count == 2
966
+ w.xfer_stats.record_kv_expired_req.assert_not_called()
967
968
969
# ----------------------------------------------------------------- #
@@ -1576,3 +1600,375 @@ class TestPushPrefixCaching:
1600
local, remote = self._written_block_ids(w)
1601
assert local == [10, 11, 12]
1602
assert remote == [500, 501, 502]
1603
+
1604
+ @pytest.mark.parametrize("layer_name_routing", [False, True])
1605
+ def test_different_region_groups_require_layer_routing(self, layer_name_routing):
1606
+ """A layer mapped to a pooled remote region can still receive its blocks."""
1607
+ w, engine_id = self._worker_driving_xfer()
1608
+ w.dst_region_group_ids[engine_id] = [-1]
1609
+ if layer_name_routing:
1610
+ w._transfer_layer_group_ids = (0,)
1611
+ reg = _registration_data("req-groups", local_block_ids=([500, 501],))
1612
+
1613
+ if not layer_name_routing:
1614
+ with pytest.raises(NotImplementedError, match="cache-group layouts"):
1615
+ NixlPushConnectorWorker._do_start_push_kv(
1616
+ w, "req-groups", ([10, 11],), reg
1617
+ )
1618
+ w.nixl_wrapper.make_prepped_xfer.assert_not_called()
1619
+ return
1620
+
1621
+ NixlPushConnectorWorker._do_start_push_kv(w, "req-groups", ([10, 11],), reg)
1622
+
1623
+ local, remote = self._written_block_ids(w)
1624
+ assert local == [10, 11]
1625
+ assert remote == [500, 501]
1626
+
1627
+
1628
+def _agent_metadata(
1629
+ region_layers: list[list[str]],
1630
+ base_addresses: list[int],
1631
+ block_lens: list[int],
1632
+ block_strides: list[int] | None = None,
1633
+) -> NixlAgentMetadata:
1634
+ return NixlAgentMetadata(
1635
+ engine_id="remote-engine",
1636
+ agent_metadata=b"agent",
1637
+ kv_caches_base_addr=base_addresses,
1638
+ device_id=7,
1639
+ num_blocks=2,
1640
+ block_lens=block_lens,
1641
+ block_strides=list(block_lens if block_strides is None else block_strides),
1642
+ kv_cache_layout="LBHNC",
1643
+ block_size=16,
1644
+ ssm_sizes=(0, 0),
1645
+ attn_backend_name="FLASH_ATTN",
1646
+ physical_blocks_per_logical_kv_block=1,
1647
+ region_members=region_layers,
1648
+ )
1649
+
1650
+
1651
+def _layer_routing_worker(
1652
+ region_layers: list[list[str]],
1653
+ group_by_layer: dict[str, int],
1654
+ pp_size: int = 2,
1655
+ is_hma: bool = True,
1656
+) -> _StubWriterWorker:
1657
+ worker = _StubWriterWorker.fresh()
1658
+ worker.pp_size = pp_size
1659
+ worker.dcp_size = 1
1660
+ worker.block_size = 16
1661
+ worker._has_mamba = False
1662
+ worker._is_hma_required = is_hma
1663
+ worker.kv_cache_config = MagicMock(transfer_group_index_by_layer=group_by_layer)
1664
+ worker.transfer_topo = MagicMock()
1665
+ worker._set_region_layers(region_layers)
1666
+ return worker
1667
+
1668
+
1669
+@pytest.mark.parametrize(
1670
+ ("region_num_blocks", "expected"),
1671
+ [(None, [1, 2, 15, 21, 22]), ([4, 7, 5], [1, 2, 9, 12, 13])],
1672
+)
1673
+def test_layer_group_ids_route_descriptor_blocks(region_num_blocks, expected):
1674
+ worker = _layer_routing_worker(
1675
+ [["a", "a.swa"], ["b"]], {"a": 0, "a.swa": 1, "b": 0}
1676
+ )
1677
+ desc_ids = worker._compute_desc_ids(
1678
+ block_ids=[[1, 2], [5]],
1679
+ dst_num_blocks=10,
1680
+ block_size_ratio=None,
1681
+ physical_blocks_per_logical=1,
1682
+ region_num_blocks=region_num_blocks,
1683
+ )
1684
+ assert desc_ids.tolist() == expected
1685
+
1686
+
1687
+def test_layer_metadata_round_trip():
1688
+ metadata = _agent_metadata([["L0", "L1"]], [0x10000], [256])
1689
+ metadata.region_num_blocks = [4]
1690
+ metadata.region_group_ids = [-1]
1691
+ metadata.region_names = ["L0"]
1692
+ metadata.region_mem_types = ["VRAM"]
1693
+
1694
+ encoded = msgspec.msgpack.encode(metadata)
1695
+ assert msgspec.msgpack.Decoder(NixlAgentMetadata).decode(encoded) == metadata
1696
+
1697
+
1698
+def test_layer_identity_gate_preserves_the_non_hma_path():
1699
+ assert _layer_routing_worker([["a"]], {"a": 0})._transfer_layer_region_indices == (
1700
+ 0,
1701
+ )
1702
+
1703
+ # A base worker (pull) never routes by layer name.
1704
+ pull = object.__new__(NixlBaseConnectorWorker)
1705
+ pull._transfer_layer_names = ()
1706
+ pull._transfer_layer_region_indices = ()
1707
+ pull._transfer_layer_group_ids = ()
1708
+ pull._set_region_layers([["a"]])
1709
+ assert pull._transfer_layer_region_indices == ()
1710
+
1711
+ # A non-HMA local layout does not require layer-name routing.
1712
+ assert (
1713
+ _layer_routing_worker(
1714
+ [["a"]], {"a": 0}, is_hma=False
1715
+ )._transfer_layer_region_indices
1716
+ == ()
1717
+ )
1718
+
1719
+ # PP=1 has congruent local/remote regions and keeps the region route even
1720
+ # when its allocator is hybrid.
1721
+ assert (
1722
+ _layer_routing_worker(
1723
+ [["a"]], {"a": 0}, pp_size=1
1724
+ )._transfer_layer_region_indices
1725
+ == ()
1726
+ )
1727
+
1728
+
1729
+def test_layer_alignment_fails_loud_when_remote_omits_layers():
1730
+ # Falling back to region-index routing here would silently transfer stale
1731
+ # KV, so an unannounced peer must fail the handshake instead.
1732
+ worker = _layer_routing_worker([["a"]], {"a": 0})
1733
+ with pytest.raises(AssertionError, match="no region_members"):
1734
+ worker._align_remote_regions_by_layer(_agent_metadata([], [0xA000], [128]))
1735
+
1736
+
1737
+def test_layer_alignment_expands_pooled_regions():
1738
+ worker = _layer_routing_worker(
1739
+ [["a", "a.swa"], ["b"]], {"a": 0, "a.swa": 1, "b": 0}
1740
+ )
1741
+ assert worker._transfer_layer_names == ("a", "a.swa", "b")
1742
+ assert worker._transfer_layer_region_indices == (0, 0, 1)
1743
+ assert worker._transfer_layer_group_ids == (0, 1, 0)
1744
+
1745
+ metadata = _agent_metadata([["a", "a.swa"], ["b"]], [0xA000, 0xB000], [128, 128])
1746
+ metadata.region_num_blocks = [4, 6]
1747
+ metadata.region_group_ids = [-1, 0]
1748
+ metadata.region_names = ["a", "b"]
1749
+ metadata.region_mem_types = ["VRAM", "VRAM"]
1750
+ worker._align_remote_regions_by_layer(metadata)
1751
+
1752
+ assert metadata.kv_caches_base_addr == [0xA000, 0xA000, 0xB000]
1753
+ assert metadata.block_lens == [128, 128, 128]
1754
+ assert metadata.block_strides == [128, 128, 128]
1755
+ assert metadata.region_members == [["a"], ["a.swa"], ["b"]]
1756
+ assert metadata.region_num_blocks == [4, 4, 6]
1757
+ assert metadata.region_group_ids == [-1, -1, 0]
1758
+ assert metadata.region_names == ["a", "a.swa", "b"]
1759
+ assert metadata.region_mem_types == ["VRAM", "VRAM", "VRAM"]
1760
+
1761
+
1762
+def test_layer_alignment_filters_and_reorders_a_pp_stage():
1763
+ worker = _layer_routing_worker([["l2"], ["l3"]], {"l2": 0, "l3": 1})
1764
+ metadata = _agent_metadata(
1765
+ [["l2"], ["l0"], ["l3"], ["l1"]],
1766
+ [0xC000, 0xA000, 0xD000, 0xB000],
1767
+ [65536, 65536, 32768, 32768],
1768
+ [131072, 131072, 65536, 65536],
1769
+ )
1770
+ metadata.region_num_blocks = [4, 6, 8, 10]
1771
+ metadata.region_group_ids = [0, 0, 1, 1]
1772
+ metadata.region_names = ["l2", "l0", "l3", "l1"]
1773
+ metadata.region_mem_types = ["VRAM"] * 4
1774
+
1775
+ worker._align_remote_regions_by_layer(metadata)
1776
+
1777
+ assert worker._transfer_layer_region_indices == (0, 1)
1778
+ assert worker._transfer_layer_group_ids == (0, 1)
1779
+ assert metadata.kv_caches_base_addr == [0xC000, 0xD000]
1780
+ assert metadata.block_lens == [65536, 32768]
1781
+ assert metadata.block_strides == [131072, 65536]
1782
+ assert metadata.region_num_blocks == [4, 8]
1783
+ assert metadata.region_group_ids == [0, 1]
1784
+ assert metadata.region_names == ["l2", "l3"]
1785
+ assert metadata.region_mem_types == ["VRAM", "VRAM"]
1786
+
1787
+
1788
+@pytest.mark.parametrize(
1789
+ ("local_num_blocks", "remote_num_blocks"),
1790
+ [([4, 4], [4, 4, 4]), ([4, 6], [3, 5, 7])],
1791
+)
1792
+def test_layer_descriptors_pair_layers_across_asymmetric_pp_split(
1793
+ local_num_blocks, remote_num_blocks
1794
+):
1795
+ """A PP split can leave each stage a different mix of attention types.
1796
+
1797
+ Stage 1 owns L3 (full) pooled with L4 (sliding) in region 0, and L5 (full)
1798
+ alone in region 1. The consumer holds every layer and pools them
1799
+ differently, so region indices cannot be paired positionally.
1800
+ """
1801
+ worker = _layer_routing_worker([["L3", "L4"], ["L5"]], {"L3": 0, "L4": 1, "L5": 0})
1802
+ worker.block_len_per_layer = [128, 128]
1803
+ worker.block_stride_per_layer = [256, 512]
1804
+ worker._region_is_mla = [False, False]
1805
+ worker.num_blocks = 4
1806
+ worker.region_num_blocks = local_num_blocks
1807
+ worker.device_id = 0
1808
+
1809
+ consumer = _agent_metadata(
1810
+ [["L0", "L1"], ["L3", "L2"], ["L5", "L4"]],
1811
+ [0xA000, 0xB000, 0xC000],
1812
+ [128, 128, 128],
1813
+ [256, 512, 1024],
1814
+ )
1815
+ consumer.num_blocks = 4
1816
+ consumer.region_num_blocks = remote_num_blocks
1817
+ worker._align_remote_regions_by_layer(consumer)
1818
+
1819
+ # L3 -> remote region 1, L4 -> 2, L5 -> 2. Pairing by index would have sent
1820
+ # L5 to region 1 and L4 to region 2's sibling.
1821
+ assert consumer.kv_caches_base_addr == [0xB000, 0xC000, 0xC000]
1822
+
1823
+ plan = TPMapping(((0,), (0,)), (0,), {0: 0}, 0)
1824
+ local_descs = worker._build_fa_local([0x1000, 0x2000], block_size_ratio=1)
1825
+ remote_descs = worker._build_fa_remote(plan, consumer, block_size_ratio=1)
1826
+ local_counts = [local_num_blocks[i] for i in worker._transfer_layer_region_indices]
1827
+ local_ids = worker._compute_desc_ids(
1828
+ [[1, 2], [3]], 4, None, 1, region_num_blocks=local_counts
1829
+ )
1830
+ remote_ids = worker._compute_desc_ids(
1831
+ [[0, 1], [2]], 4, None, 1, region_num_blocks=consumer.region_num_blocks
1832
+ )
1833
+ assert len(local_descs) == sum(local_counts)
1834
+ assert len(remote_descs) == sum(consumer.region_num_blocks)
1835
+
1836
+ # Each pair addresses the same layer's blocks despite different pooling
1837
+ # and strides. L3 and L5 use group 0; L4 uses group 1.
1838
+ assert local_descs[local_ids].tolist() == [
1839
+ [0x1100, 128, 0],
1840
+ [0x1200, 128, 0],
1841
+ [0x1300, 128, 0],
1842
+ [0x2200, 128, 0],
1843
+ [0x2400, 128, 0],
1844
+ ]
1845
+ assert remote_descs[remote_ids].tolist() == [
1846
+ [0xB000, 128, 7],
1847
+ [0xB200, 128, 7],
1848
+ [0xC800, 128, 7],
1849
+ [0xC000, 128, 7],
1850
+ [0xC400, 128, 7],
1851
+ ]
1852
+
1853
+
1854
+def test_layer_alignment_is_canonical_across_remote_orderings():
1855
+ local, groups = [["x"], ["y"]], {"x": 0, "y": 1}
1856
+ rank0 = _agent_metadata([["x"], ["y"]], [0x1000, 0x2000], [64, 128], [256, 512])
1857
+ rank1 = _agent_metadata([["y"], ["x"]], [0x2000, 0x1000], [128, 64], [512, 256])
1858
+
1859
+ _layer_routing_worker(local, groups)._align_remote_regions_by_layer(rank0)
1860
+ _layer_routing_worker(local, groups)._align_remote_regions_by_layer(rank1)
1861
+
1862
+ assert rank0.kv_caches_base_addr == rank1.kv_caches_base_addr == [0x1000, 0x2000]
1863
+ assert rank0.block_lens == rank1.block_lens == [64, 128]
1864
+ assert rank0.block_strides == rank1.block_strides == [256, 512]
1865
+
1866
+
1867
+def test_layer_alignment_is_idempotent():
1868
+ worker = _layer_routing_worker(
1869
+ [["a", "a.swa"], ["b"]], {"a": 0, "a.swa": 1, "b": 0}
1870
+ )
1871
+ metadata = _agent_metadata([["a", "a.swa"], ["b"]], [0xA000, 0xB000], [128, 128])
1872
+ metadata.region_num_blocks = [4, 6]
1873
+ metadata.region_group_ids = [-1, 0]
1874
+ metadata.region_names = ["a", "b"]
1875
+ metadata.region_mem_types = ["VRAM", "VRAM"]
1876
+
1877
+ worker._align_remote_regions_by_layer(metadata)
1878
+ aligned = msgspec.msgpack.encode(metadata)
1879
+ worker._align_remote_regions_by_layer(metadata)
1880
+
1881
+ assert msgspec.msgpack.encode(metadata) == aligned
1882
+
1883
+
1884
+def test_layer_alignment_rejects_missing_local_layer():
1885
+ worker = _layer_routing_worker([["l0"], ["l1"]], {"l0": 0, "l1": 1})
1886
+ metadata = _agent_metadata([["l0"]], [0xA000], [128])
1887
+
1888
+ with pytest.raises(AssertionError, match="missing locally owned layers"):
1889
+ worker._align_remote_regions_by_layer(metadata)
1890
+
1891
+
1892
+def test_layer_alignment_rejects_duplicate_remote_layer():
1893
+ worker = _layer_routing_worker([["a"]], {"a": 0})
1894
+ metadata = _agent_metadata([["a"], ["a"]], [0xA000, 0xB000], [128, 128])
1895
+
1896
+ with pytest.raises(AssertionError, match="in multiple regions"):
1897
+ worker._align_remote_regions_by_layer(metadata)
1898
+
1899
+
1900
+def test_layer_alignment_rejects_inconsistent_remote_metadata():
1901
+ worker = _layer_routing_worker([["a"]], {"a": 0})
1902
+ metadata = _agent_metadata([["a"], ["b"]], [0xA000], [128])
1903
+
1904
+ with pytest.raises(AssertionError, match="lengths disagree"):
1905
+ worker._align_remote_regions_by_layer(metadata)
1906
+
1907
+
1908
+@pytest.mark.parametrize(
1909
+ "field",
1910
+ ["region_num_blocks", "region_group_ids", "region_names", "region_mem_types"],
1911
+)
1912
+def test_layer_alignment_rejects_inconsistent_region_geometry(field):
1913
+ worker = _layer_routing_worker([["a"]], {"a": 0})
1914
+ metadata = _agent_metadata([["a"], ["b"]], [0xA000, 0xB000], [128, 128])
1915
+ setattr(metadata, field, [])
1916
+ original = msgspec.msgpack.encode(metadata)
1917
+
1918
+ with pytest.raises(AssertionError, match="lengths disagree"):
1919
+ worker._align_remote_regions_by_layer(metadata)
1920
+ assert msgspec.msgpack.encode(metadata) == original
1921
+
1922
+
1923
+def test_set_region_layers_rejects_duplicate_local_layer():
1924
+ with pytest.raises(AssertionError, match="spans multiple NIXL regions"):
1925
+ _layer_routing_worker([["a"], ["a"]], {"a": 0})
1926
+
1927
+
1928
+def test_set_region_layers_rejects_layer_outside_any_kv_group():
1929
+ with pytest.raises(AssertionError, match="outside any local group"):
1930
+ _layer_routing_worker([["a"], ["b"]], {"a": 0})
1931
+
1932
+
1933
+@pytest.mark.parametrize(
1934
+ ("local_block_size", "remote_block_size", "remote_tp_size", "error"),
1935
+ [
1936
+ (32, 16, 1, "identical P/D block sizes"),
1937
+ (16, 32, 1, "identical P/D block sizes"),
1938
+ (16, 16, 2, "decode TP greater"),
1939
+ ],
1940
+)
1941
+def test_layer_handshake_rejects_unsupported_geometry(
1942
+ local_block_size: int, remote_block_size: int, remote_tp_size: int, error: str
1943
+):
1944
+ """Reject unsupported peers without registering agents or transfer state."""
1945
+ metadata = _agent_metadata([["a"]], [0xA000], [128])
1946
+ metadata.block_size = remote_block_size
1947
+ worker = _layer_routing_worker([["a"]], {"a": 0})
1948
+ worker.block_size = local_block_size
1949
+ worker.block_len_per_layer = [128]
1950
+ worker.use_mla = False
1951
+ worker.nixl_wrapper = MagicMock()
1952
+ worker.kv_caches_base_addr = defaultdict(dict)
1953
+ worker.dst_num_blocks = {}
1954
+ worker.tp_mappings = {}
1955
+ worker.transfer_topo = TransferTopology(
1956
+ tp_rank=0,
1957
+ tp_size=1,
1958
+ block_size=local_block_size,
1959
+ engine_id=worker.engine_id,
1960
+ is_mla=False,
1961
+ is_mamba=False,
1962
+ total_num_kv_heads=8,
1963
+ attn_backends=[],
1964
+ )
1965
+
1966
+ with pytest.raises(NotImplementedError, match=error):
1967
+ worker.add_remote_agent(metadata, remote_tp_size=remote_tp_size)
1968
+ worker.nixl_wrapper.add_remote_agent.assert_not_called()
1969
+ worker.nixl_wrapper.prep_xfer_dlist.assert_not_called()
1970
+ assert not worker.tp_mappings
1971
+ assert not worker.dst_num_blocks
1972
+ assert not worker.kv_caches_base_addr
1973
+ with pytest.raises(KeyError):
1974
+ worker.transfer_topo.get_engine_info(metadata.engine_id)
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py
+216
-40
@@ -164,6 +164,10 @@ class NixlBaseConnectorWorker:
164
# Overridden by NixlPushConnectorWorker.
165
_TRANSFER_MODE: str = "pull"
166
167
+ # Layer-name routing is supported only by NixlPushConnector for HMA KV
168
+ # caches under PP.
169
+ _supports_pp_hma = False
170
+
171
def _compute_desc_ids(
172
self,
173
block_ids: BlockIds,
@@ -191,28 +195,42 @@ class NixlBaseConnectorWorker:
195
num_blocks = dst_num_blocks
196
if block_size_ratio is not None:
197
num_blocks = int(num_blocks * block_size_ratio)
194
- num_regions = self.num_regions or len(self.block_len_per_layer)
198
+ use_layer_name_routing = bool(self._transfer_layer_group_ids)
199
+ num_desc_regions = (
200
+ len(self._transfer_layer_group_ids)
201
+ if use_layer_name_routing
202
+ else self.num_regions or len(self.block_len_per_layer)
203
+ )
204
if region_num_blocks is None:
196
- region_num_blocks = [num_blocks] * num_regions
205
+ region_num_blocks = [num_blocks] * num_desc_regions
206
elif block_size_ratio is not None:
207
region_num_blocks = [
208
int(count * block_size_ratio) for count in region_num_blocks
209
]
201
- if region_num_blocks:
202
- num_regions = len(region_num_blocks)
203
- assert len(region_num_blocks) == num_regions
210
+ if region_num_blocks and not use_layer_name_routing:
211
+ num_desc_regions = len(region_num_blocks)
212
+ assert len(region_num_blocks) == num_desc_regions
213
region_offsets = np.cumsum([0, *region_num_blocks[:-1]])
214
num_fa_descs = sum(region_num_blocks)
215
216
+ if use_layer_name_routing:
217
+ # Descriptors in layer order, using each layer's KV-cache group.
218
+ return np.concatenate(
219
+ [
220
+ np.asarray(block_ids[g], dtype=np.int32) + region_offsets[k]
221
+ for k, g in enumerate(self._transfer_layer_group_ids)
222
+ ]
223
+ )
224
+
225
# All-attention fast path.
226
if num_ssm_regions == 0:
227
if region_group_ids is None:
228
region_group_ids = self.region_group_ids
229
if uses_region_group_mapping is None:
230
uses_region_group_mapping = self._uses_region_group_mapping
213
- if not region_group_ids and num_regions == 1:
231
+ if not region_group_ids and num_desc_regions == 1:
232
region_group_ids = [0]
215
- assert len(region_group_ids) == num_regions
233
+ assert len(region_group_ids) == num_desc_regions
234
if uses_region_group_mapping is None:
235
uses_region_group_mapping = len(set(region_group_ids)) > 1
236
region_group_ids_array = np.asarray(region_group_ids, dtype=np.int32)
@@ -392,6 +410,103 @@ class NixlBaseConnectorWorker:
410
"""
411
return region_idx < len(self._region_is_mla) and self._region_is_mla[region_idx]
412
413
+ def _set_region_layers(self, region_layers: list[list[str]]) -> None:
414
+ layer_names = [layer_name for region in region_layers for layer_name in region]
415
+ assert len(layer_names) == len(set(layer_names)), (
416
+ "A KV cache layer spans multiple NIXL regions"
417
+ )
418
+ self.region_members: list[list[str]] = region_layers
419
+ if not self._requires_layer_name_routing():
420
+ return
421
+ group_by_layer = self.kv_cache_config.transfer_group_index_by_layer
422
+ ungrouped = [name for name in layer_names if name not in group_by_layer]
423
+ assert not ungrouped, f"KV cache layers outside any local group: {ungrouped}"
424
+ self._transfer_layer_names = tuple(layer_names)
425
+ self._transfer_layer_region_indices = tuple(
426
+ region_idx
427
+ for region_idx, region in enumerate(region_layers)
428
+ for _ in region
429
+ )
430
+ self._transfer_layer_group_ids = tuple(
431
+ group_by_layer[name] for name in layer_names
432
+ )
433
+
434
+ def _requires_layer_name_routing(self) -> bool:
435
+ """Whether PP push must match HMA layers by name, not region index."""
436
+ return (
437
+ self._supports_pp_hma
438
+ and self.pp_size > 1
439
+ and self._is_hma_required
440
+ and not self._has_mamba
441
+ )
442
+
443
+ def _align_remote_regions_by_layer(
444
+ self, nixl_agent_meta: NixlAgentMetadata
445
+ ) -> None:
446
+ """Select remote regions for this PP stage in local layer order."""
447
+ remote_region_layers = nixl_agent_meta.region_members
448
+ # Region-index routing here would silently transfer stale KV.
449
+ assert remote_region_layers, "Remote advertised no region_members"
450
+ assert (
451
+ len(nixl_agent_meta.kv_caches_base_addr)
452
+ == len(nixl_agent_meta.block_lens)
453
+ == len(nixl_agent_meta.block_strides)
454
+ == len(remote_region_layers)
455
+ ), "Remote region metadata lengths disagree"
456
+ assert all(
457
+ values is None or len(values) == len(remote_region_layers)
458
+ for values in (
459
+ nixl_agent_meta.region_num_blocks,
460
+ nixl_agent_meta.region_group_ids,
461
+ nixl_agent_meta.region_names,
462
+ nixl_agent_meta.region_mem_types,
463
+ )
464
+ ), "Remote region metadata lengths disagree"
465
+
466
+ remote_region_by_layer: dict[str, int] = {}
467
+ for region_idx, layer_names in enumerate(remote_region_layers):
468
+ for layer_name in layer_names:
469
+ assert layer_name not in remote_region_by_layer, (
470
+ f"Remote advertised layer {layer_name!r} in multiple regions"
471
+ )
472
+ remote_region_by_layer[layer_name] = region_idx
473
+
474
+ missing = [
475
+ name
476
+ for name in self._transfer_layer_names
477
+ if name not in remote_region_by_layer
478
+ ]
479
+ assert not missing, f"Remote is missing locally owned layers: {missing}"
480
+
481
+ remote_regions = [
482
+ remote_region_by_layer[name] for name in self._transfer_layer_names
483
+ ]
484
+ nixl_agent_meta.kv_caches_base_addr = [
485
+ nixl_agent_meta.kv_caches_base_addr[i] for i in remote_regions
486
+ ]
487
+ nixl_agent_meta.block_lens = [
488
+ nixl_agent_meta.block_lens[i] for i in remote_regions
489
+ ]
490
+ nixl_agent_meta.block_strides = [
491
+ nixl_agent_meta.block_strides[i] for i in remote_regions
492
+ ]
493
+ if nixl_agent_meta.region_num_blocks is not None:
494
+ nixl_agent_meta.region_num_blocks = [
495
+ nixl_agent_meta.region_num_blocks[i] for i in remote_regions
496
+ ]
497
+ if nixl_agent_meta.region_group_ids is not None:
498
+ nixl_agent_meta.region_group_ids = [
499
+ nixl_agent_meta.region_group_ids[i] for i in remote_regions
500
+ ]
501
+ if nixl_agent_meta.region_mem_types is not None:
502
+ nixl_agent_meta.region_mem_types = [
503
+ nixl_agent_meta.region_mem_types[i] for i in remote_regions
504
+ ]
505
+ if nixl_agent_meta.region_names is not None:
506
+ nixl_agent_meta.region_names = list(self._transfer_layer_names)
507
+ # One layer per region keeps a second alignment pass a no-op.
508
+ nixl_agent_meta.region_members = [[name] for name in self._transfer_layer_names]
509
+
510
def __init__(
511
self,
512
vllm_config: "VllmConfig",
@@ -638,18 +753,26 @@ class NixlBaseConnectorWorker:
753
# transfers into the matching sub-range of a PP=1 remote's regions.
754
self.pp_size = vllm_config.parallel_config.pipeline_parallel_size
755
self._remote_region_offset = 0
641
- # PP push slices regions per layer (uniform count); HMA breaks that.
642
- if self.pp_size > 1 and self._is_hma_required:
643
- raise NotImplementedError(
644
- "NixlPushConnector does not support pipeline_parallel_size > 1 "
645
- "with hybrid KV cache layouts (HMA) yet."
646
- )
647
- # Decode-side PP is unsupported (completions counted per consumer rank).
648
- if vllm_config.kv_transfer_config.kv_role == "kv_consumer" and self.pp_size > 1:
649
- raise NotImplementedError(
650
- "NixlPushConnector consumer (decode) does not support "
651
- "pipeline_parallel_size > 1."
652
- )
756
+ if self.pp_size > 1:
757
+ if self._is_hma_required and not self._supports_pp_hma:
758
+ raise NotImplementedError(
759
+ "NixlConnector (pull) does not support pipeline_parallel_size "
760
+ "> 1 with hybrid KV cache layouts (HMA); use NixlPushConnector "
761
+ "for PP + HMA."
762
+ )
763
+ # PP push routes HMA (hybrid) attention layouts by layer name;
764
+ # Mamba/SSM hybrids are not yet supported under PP.
765
+ if self._has_mamba:
766
+ raise NotImplementedError(
767
+ "NixlPushConnector does not support pipeline_parallel_size > 1 "
768
+ "with Mamba/SSM hybrid KV cache layouts yet."
769
+ )
770
+ # Decode-side PP is unsupported (completions counted per consumer rank).
771
+ if vllm_config.kv_transfer_config.kv_role == "kv_consumer":
772
+ raise NotImplementedError(
773
+ "NixlPushConnector consumer (decode) does not support "
774
+ "pipeline_parallel_size > 1."
775
+ )
776
# Keep heartbeat handshakes to a PP-sharded producer notif-only.
777
self._hb_handshake_notif_only = False
778
@@ -773,6 +896,13 @@ class NixlBaseConnectorWorker:
896
# within a block (BLHNC/BHLNC), where stride > block_len.
897
self.block_stride_per_layer = list[int]()
898
899
+ # Region layer membership is populated for HMA layouts.
900
+ self.region_members = []
901
+ # Local layer order, set by ``_set_region_layers`` only when
902
+ # routing by layer name. Empty means region-index routing.
903
+ self._transfer_layer_names = tuple[str, ...]()
904
+ self._transfer_layer_region_indices = tuple[int, ...]()
905
+ self._transfer_layer_group_ids = tuple[int, ...]()
906
# Per-engine TP mappings. Generated during handshake.
907
self.tp_mappings: dict[EngineId, TPMapping] = {}
908
@@ -1306,6 +1436,11 @@ class NixlBaseConnectorWorker:
1436
):
1437
compressed_region_owners.setdefault(cache.data_ptr(), cache)
1438
1439
+ track_region_layers = (
1440
+ self._supports_pp_hma and self._is_hma_required and not self._has_mamba
1441
+ )
1442
+ region_layers: list[list[str]] = []
1443
+
1444
# K and V are packed into the content dim, so each attention layer is a
1445
# single NIXL region whose block transfers as one unit. Mamba layers instead
1446
# register separate conv/ssm sub-regions (see `_build_mamba_local`).
@@ -1508,6 +1643,12 @@ class NixlBaseConnectorWorker:
1643
self._region_is_mla.append(is_mla_region)
1644
region_mem_types.append(mem_type)
1645
1646
+ if track_region_layers:
1647
+ if region_index == len(region_layers):
1648
+ region_layers.append([layer_name])
1649
+ else:
1650
+ region_layers[region_index].append(layer_name)
1651
+
1652
if isinstance(layer_spec, MambaSpec):
1653
if layer_spec.tp_replicated:
1654
assert self._is_csa_linear
@@ -1563,7 +1704,9 @@ class NixlBaseConnectorWorker:
1704
if self._has_mamba:
1705
self.region_num_blocks = [self.num_blocks] * self.num_regions
1706
1566
- if self.pp_size > 1:
1707
+ self._set_region_layers(region_layers)
1708
+
1709
+ if self.pp_size > 1 and not self._is_hma_required:
1710
start_layer, end_layer = self.model_config.get_layers_start_end_indices(
1711
self.vllm_config.parallel_config
1712
)
@@ -1573,7 +1716,12 @@ class NixlBaseConnectorWorker:
1716
self._remote_region_offset = regions_per_layer * start_layer
1717
1718
# Total local FA descriptors (boundary between FA and mamba descs).
1576
- self.num_descs = sum(self.region_num_blocks)
1719
+ xfer_region_num_blocks = (
1720
+ [self.region_num_blocks[i] for i in self._transfer_layer_region_indices]
1721
+ if self._transfer_layer_region_indices
1722
+ else self.region_num_blocks
1723
+ )
1724
+ self.num_descs = sum(xfer_region_num_blocks)
1725
1726
self._mixed_mem_types = len(set(region_mem_types)) > 1
1727
if self._mixed_mem_types:
@@ -1597,7 +1745,7 @@ class NixlBaseConnectorWorker:
1745
1746
self.device_kv_caches = kv_caches
1747
self.dst_num_blocks[self.engine_id] = self.num_blocks
1600
- self.dst_region_num_blocks[self.engine_id] = self.region_num_blocks
1748
+ self.dst_region_num_blocks[self.engine_id] = xfer_region_num_blocks
1749
self.dst_region_group_ids[self.engine_id] = self.region_group_ids
1750
self.dst_uses_region_group_mapping[self.engine_id] = (
1751
self._uses_region_group_mapping
@@ -1647,6 +1795,7 @@ class NixlBaseConnectorWorker:
1795
region_mem_types=self.region_mem_types,
1796
dcp_size=self.dcp_size,
1797
pcp_size=self.pcp_size,
1798
+ region_members=self.region_members,
1799
)
1800
# Wrap metadata in payload with hash for defensive decoding
1801
assert self.compat_hash is not None
@@ -1805,12 +1954,16 @@ class NixlBaseConnectorWorker:
1954
base_addresses: list[int],
1955
block_size_ratio: int,
1956
) -> np.ndarray:
1808
- """Build local FA descriptors for all layers as an Nx3 uint64 array."""
1957
+ """Build local FA descriptors as an Nx3 uint64 array."""
1958
assert self.transfer_topo is not None
1959
assert base_addresses, "Local KV cache base addresses must not be empty."
1960
device_id = self.device_id
1961
+ region_indices = self._transfer_layer_region_indices or range(
1962
+ len(base_addresses)
1963
+ )
1964
parts: list[np.ndarray] = []
1813
- for i, base_addr in enumerate(base_addresses):
1965
+ for i in region_indices:
1966
+ base_addr = base_addresses[i]
1967
block_len = self.block_len_per_layer[i] // block_size_ratio
1968
logical_blocks = np.repeat(
1969
np.arange(self.region_num_blocks[i], dtype=np.uint64),
@@ -1834,7 +1987,7 @@ class NixlBaseConnectorWorker:
1987
nixl_agent_meta: NixlAgentMetadata,
1988
block_size_ratio: int,
1989
) -> np.ndarray:
1837
- """Build remote FA descriptors for all layers as an Nx3 uint64 array."""
1990
+ """Build remote FA descriptors as an Nx3 uint64 array."""
1991
assert self.transfer_topo is not None
1992
assert nixl_agent_meta.kv_caches_base_addr, (
1993
"Remote KV cache base addresses must not be empty."
@@ -1851,10 +2004,14 @@ class NixlBaseConnectorWorker:
2004
device_id = nixl_agent_meta.device_id
2005
block_strides = nixl_agent_meta.block_strides
2006
parts: list[np.ndarray] = []
2007
+ use_layer_name_routing = bool(self._transfer_layer_region_indices)
2008
for i, base_addr in enumerate(nixl_agent_meta.kv_caches_base_addr):
1855
- replicated = self._is_region_replicated(i)
2009
+ local_region = (
2010
+ self._transfer_layer_region_indices[i] if use_layer_name_routing else i
2011
+ )
2012
+ replicated = self._is_region_replicated(local_region)
2013
# Read our whole local region size from remote..
1857
- local_block_len = self.block_len_per_layer[i]
2014
+ local_block_len = self.block_len_per_layer[local_region]
2015
remote_kv_block_len = local_block_len // block_size_ratio
2016
if block_size_ratio > 1:
2017
# ..using remote kv_block_len as transfer unit
@@ -2021,9 +2178,22 @@ class NixlBaseConnectorWorker:
2178
)
2179
return self._remote_agents[engine_id][(0, remote_tp_rank)]
2180
2181
+ assert self.transfer_topo is not None
2182
+ transfer_topo = self.transfer_topo
2183
# Number of physical regions registered locally (one per layer/tensor).
2184
num_local_regions = len(self.block_len_per_layer)
2026
- if (
2185
+ if self._transfer_layer_region_indices:
2186
+ if self.block_size != nixl_agent_meta.block_size:
2187
+ raise NotImplementedError(
2188
+ "Attention-HMA push requires identical P/D block sizes."
2189
+ )
2190
+ if remote_tp_size > transfer_topo.tp_size and not self.use_mla:
2191
+ raise NotImplementedError(
2192
+ "Attention-HMA push does not support decode TP greater than "
2193
+ "prefill TP yet"
2194
+ )
2195
+ self._align_remote_regions_by_layer(nixl_agent_meta)
2196
+ elif (
2197
self.pp_size > 1
2198
and len(nixl_agent_meta.kv_caches_base_addr) > num_local_regions
2199
):
@@ -2038,6 +2208,10 @@ class NixlBaseConnectorWorker:
2208
]
2209
nixl_agent_meta.block_lens = nixl_agent_meta.block_lens[start:end]
2210
nixl_agent_meta.block_strides = nixl_agent_meta.block_strides[start:end]
2211
+ if nixl_agent_meta.region_num_blocks is not None:
2212
+ nixl_agent_meta.region_num_blocks = nixl_agent_meta.region_num_blocks[
2213
+ start:end
2214
+ ]
2215
if nixl_agent_meta.region_group_ids is not None:
2216
nixl_agent_meta.region_group_ids = nixl_agent_meta.region_group_ids[
2217
start:end
@@ -2048,14 +2222,8 @@ class NixlBaseConnectorWorker:
2222
nixl_agent_meta.region_mem_types = nixl_agent_meta.region_mem_types[
2223
start:end
2224
]
2051
- if nixl_agent_meta.region_num_blocks is not None:
2052
- nixl_agent_meta.region_num_blocks = nixl_agent_meta.region_num_blocks[
2053
- start:end
2054
- ]
2225
2226
### Register remote engine in TransferTopology (idempotent).
2057
- assert self.transfer_topo is not None
2058
- transfer_topo = self.transfer_topo
2227
physical_blocks_per_logical = (
2228
nixl_agent_meta.physical_blocks_per_logical_kv_block
2229
)
@@ -2349,6 +2517,9 @@ class NixlBaseConnectorWorker:
2517
# the per-rank KV head ratio rather than the raw tp_ratio, because GQA
2518
# replication caps per-rank heads at 1 when tp > total_kv_heads
2519
# (issue #45330). Mamba uses the ssm_sizes counterpart, so skip here.
2520
+ local_regions = self._transfer_layer_region_indices or range(
2521
+ len(self.block_len_per_layer)
2522
+ )
2523
if self._has_mamba and self.use_mla:
2524
# Hybrid MLA+SSM (e.g. KimiLinear's KDA+MLA): regions are
2525
# kernel-granularity views of the mamba-unified page. The MLA
@@ -2365,7 +2536,7 @@ class NixlBaseConnectorWorker:
2536
f"remote={nixl_agent_meta.block_lens}."
2537
)
2538
elif not self._has_mamba:
2368
- assert len(self.block_len_per_layer) == len(nixl_agent_meta.block_lens), (
2539
+ assert len(local_regions) == len(nixl_agent_meta.block_lens), (
2540
"Number of KV layers must match between prefill and decode"
2541
)
2542
model_replicated = self.use_mla or self.transfer_topo.is_kv_replicated(
@@ -2374,8 +2545,9 @@ class NixlBaseConnectorWorker:
2545
total_kv_heads = self.transfer_topo.total_num_kv_heads
2546
local_heads = self.transfer_topo.local_physical_heads
2547
remote_heads = max(1, total_kv_heads // remote_tp_size)
2377
- for i, local_len in enumerate(self.block_len_per_layer):
2378
- replicated = model_replicated or self._is_region_replicated(i)
2548
+ for i, region_idx in enumerate(local_regions):
2549
+ local_len = self.block_len_per_layer[region_idx]
2550
+ replicated = model_replicated or self._is_region_replicated(region_idx)
2551
remote_len = nixl_agent_meta.block_lens[i]
2552
if replicated:
2553
assert local_len // block_size_ratio == remote_len, (
@@ -2406,8 +2578,7 @@ class NixlBaseConnectorWorker:
2578
2579
# TP workers that handhshake with same remote have same #blocks.
2580
assert self.dst_num_blocks[remote_engine_id] == nixl_agent_meta.num_blocks
2409
- # Same number of regions/~layers.
2410
- assert len(nixl_agent_meta.kv_caches_base_addr) == len(self.block_len_per_layer)
2581
+ assert len(nixl_agent_meta.kv_caches_base_addr) == len(local_regions)
2582
num_remote_regions = len(nixl_agent_meta.kv_caches_base_addr)
2583
if nixl_agent_meta.region_num_blocks is not None:
2584
assert len(nixl_agent_meta.region_num_blocks) == num_remote_regions
@@ -2423,7 +2594,12 @@ class NixlBaseConnectorWorker:
2594
if nixl_agent_meta.region_group_ids is not None:
2595
assert len(nixl_agent_meta.region_group_ids) == num_remote_regions
2596
if nixl_agent_meta.region_names is not None:
2426
- assert nixl_agent_meta.region_names == self.region_names, (
2597
+ expected_names = (
2598
+ list(self._transfer_layer_names)
2599
+ if self._transfer_layer_names
2600
+ else self.region_names
2601
+ )
2602
+ assert nixl_agent_meta.region_names == expected_names, (
2603
"NIXL transfer regions must name the same model caches in the "
2604
"same order"
2605
)
vllm/distributed/kv_transfer/kv_connector/v1/nixl/metadata.py
+5
-2
@@ -2,7 +2,7 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
"""Metadata dataclasses and helpers for the NIXL connector."""
4
5
-from dataclasses import dataclass
5
+from dataclasses import dataclass, field
6
from typing import Any
7
8
from vllm.config import VllmConfig
@@ -47,8 +47,9 @@ PUSH_REG_NOTIF_PREFIX = b"PUSH_REG:"
47
# 9: Add block_strides
48
# 10: Add dense virtual transfer pages for compressed MLA caches
49
# 11: Add per-region transfer geometry and memory types to NixlAgentMetadata
50
+# 12: Add per-region member names for PP push
51
#
51
-NIXL_CONNECTOR_VERSION: int = 11
52
+NIXL_CONNECTOR_VERSION: int = 12
53
54
55
@dataclass
@@ -71,6 +72,8 @@ class NixlAgentMetadata:
72
region_mem_types: list[str] | None = None
73
dcp_size: int = 1
74
pcp_size: int = 1
75
+ # Layer names sharing each advertised region, in region order.
76
+ region_members: list[list[str]] = field(default_factory=list)
77
78
79
@dataclass
vllm/distributed/kv_transfer/kv_connector/v1/nixl/push_worker.py
+3
-1
@@ -83,6 +83,8 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
83
# Distinguishes push from pull in the NIXL compatibility hash.
84
_TRANSFER_MODE: str = "push"
85
86
+ _supports_pp_hma = True
87
+
88
def __init__(
89
self,
90
vllm_config: "VllmConfig",
@@ -530,7 +532,7 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
532
local_region_groups = self.region_group_ids
533
remote_region_groups = self.dst_region_group_ids[engine_id]
534
groups_differ = local_region_groups != remote_region_groups
533
- if groups_differ:
535
+ if groups_differ and not self._transfer_layer_group_ids:
536
raise NotImplementedError(
537
"NixlPushConnector does not support different producer and "
538
"consumer cache-group layouts"