[2/N][KV Connector][NIXL] Support per-region transfer geometry (#53780)

Signed-off-by: Matthew Bonanni <mbonanni@redhat.com> Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: OpenAI Codex <noreply@openai.com>

Matthew Bonanni committed Sep 8, 2026 at 16:01 UTC e0aaef85f3de7a2376aee3708a3cba96286b41b1
9 files changed +892 -110
tests/v1/kv_connector/unit/test_nixl_connector.py
+125 -4
@@ -518,8 +518,15 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
518 slot_size_bytes = 4096
519 self.slot_size_per_layer = [slot_size_bytes]
520 self.block_len_per_layer = [slot_size_bytes * self.block_size]
521 - self.num_blocks = 1
521 + self.num_regions = 1
522 + self.block_stride_per_layer = list(self.block_len_per_layer)
523 + self.region_num_blocks = [self.num_blocks]
524 + self.region_group_ids = [0]
525 + self.region_mem_types = [self.nixl_memory_type]
526 self.dst_num_blocks[self.engine_id] = self.num_blocks
527 + self.dst_region_num_blocks[self.engine_id] = self.region_num_blocks
528 + self.dst_region_group_ids[self.engine_id] = self.region_group_ids
529 + self.dst_region_mem_types[self.engine_id] = self.region_mem_types
530
531 assert expected_engine_id == self.REMOTE_ENGINE_ID
532
@@ -548,7 +555,7 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
555 agent_metadata=FakeNixlWrapper.AGENT_METADATA,
556 kv_caches_base_addr=[0],
557 device_id=remote_tp_rank,
551 - num_blocks=1,
558 + num_blocks=self.num_blocks,
559 block_lens=remote_block_lens,
560 block_strides=remote_block_lens,
561 kv_cache_layout="LBHNC",
@@ -556,6 +563,9 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
563 ssm_sizes=(0, 0),
564 attn_backend_name=self.backend_name,
565 physical_blocks_per_logical_kv_block=1,
566 + region_num_blocks=self.region_num_blocks,
567 + region_group_ids=self.region_group_ids,
568 + region_mem_types=self.region_mem_types,
569 ),
570 remote_tp_rank=remote_tp_rank,
571 remote_tp_size=remote_tp_size,
@@ -640,7 +650,7 @@ class TestNixlHandshake:
650 request_id = "req_id"
651
652 # Test worker role in decode server.
643 - kv_cache_config = make_kv_cache_config(block_size=16, num_blocks=2)
653 + kv_cache_config = make_kv_cache_config(block_size=16, num_blocks=10)
654 connector = NixlConnector(vllm_config, KVConnectorRole.WORKER, kv_cache_config)
655 connector.connector_worker = FakeNixlConnectorWorker(
656 vllm_config,
@@ -1167,12 +1177,19 @@ class TestNixlHandshake:
1177 source_ranks_per_group=((0,), (0,)),
1178 rank_offset_factor=1,
1179 )
1170 - meta = MagicMock(
1180 + meta = NixlAgentMetadata(
1181 + engine_id=FakeNixlConnectorWorker.REMOTE_ENGINE_ID,
1182 + agent_metadata=FakeNixlWrapper.AGENT_METADATA,
1183 kv_caches_base_addr=[0x1000],
1184 device_id=0,
1185 num_blocks=1,
1186 block_lens=[remote_block_len],
1187 block_strides=[remote_block_len],
1188 + kv_cache_layout="HND",
1189 + block_size=worker.block_size,
1190 + ssm_sizes=(0, 0),
1191 + attn_backend_name=worker.backend_name,
1192 + physical_blocks_per_logical_kv_block=1,
1193 )
1194
1195 assert worker._build_fa_remote(plan, meta, block_size_ratio=1).tolist() == [
@@ -1846,6 +1863,110 @@ def _run_abort_timeout_test(llm: LLM, timeout: int):
1863 llm.llm_engine.engine_core.shutdown()
1864
1865
1866 +def test_mixed_memory_local_descriptors_split_by_memory_type():
1867 + worker = object.__new__(NixlConnectorWorker)
1868 + worker.transfer_topo = MagicMock()
1869 + worker.block_size = 16
1870 + worker.engine_id = "local"
1871 + worker.tp_rank = 0
1872 + worker.device_id = 3
1873 + worker.kv_caches_base_addr = {"local": {0: [100, 200]}}
1874 + worker._has_mamba = False
1875 + worker._mixed_mem_types = True
1876 + worker.region_mem_types = ["DRAM", "VRAM"]
1877 + worker.region_num_blocks = [2, 2]
1878 + worker._desc_is_dram_by_block_size = {}
1879 + worker._desc_pos_by_block_size = {}
1880 + worker._dram_src_handles_by_block_size = {}
1881 + worker.nixl_memory_type = "VRAM"
1882 + worker._build_fa_local = MagicMock( # type: ignore[method-assign]
1883 + return_value=np.array(
1884 + [
1885 + [100, 10, 3],
1886 + [110, 10, 3],
1887 + [200, 10, 3],
1888 + [210, 10, 3],
1889 + ]
1890 + )
1891 + )
1892 + worker.nixl_wrapper = MagicMock()
1893 + worker.nixl_wrapper.get_xfer_descs.side_effect = lambda blocks, memory_type: (
1894 + memory_type,
1895 + blocks,
1896 + )
1897 + worker.nixl_wrapper.prep_xfer_dlist.side_effect = [11, 22]
1898 +
1899 + handle, blocks = worker.register_local_xfer_handler(worker.block_size)
1900 +
1901 + assert handle == 22
1902 + assert worker._dram_src_handles_by_block_size[worker.block_size] == 11
1903 + assert [block[2] for block in blocks] == [0, 0, 3, 3]
1904 + assert [
1905 + call.args[1] for call in worker.nixl_wrapper.get_xfer_descs.call_args_list
1906 + ] == ["DRAM", "VRAM"]
1907 +
1908 +
1909 +def test_mixed_memory_read_notifies_after_both_transfers_finish():
1910 + worker = object.__new__(NixlConnectorWorker)
1911 + worker._desc_is_dram_by_block_size = {16: np.array([True, True, False, False])}
1912 + worker._desc_pos_by_block_size = {16: np.array([0, 1, 0, 1])}
1913 + worker._dram_src_handles_by_block_size = {16: 10}
1914 + worker._recving_metadata = {"request": MagicMock()}
1915 + worker._recving_transfers = defaultdict(list)
1916 + worker._pending_recv_notifs = {}
1917 + worker.xfer_stats = MagicMock()
1918 + worker.nixl_wrapper = MagicMock()
1919 + worker.nixl_wrapper.make_prepped_xfer.side_effect = [101, 102]
1920 + worker.nixl_wrapper.check_xfer_state.return_value = "DONE"
1921 +
1922 + worker._read_blocks_mixed(
1923 + request_id="request",
1924 + local_block_size_key=16,
1925 + local_device_handle=20,
1926 + local_dram_handle=10,
1927 + remote_xfer_side_handle=30,
1928 + local_block_descs_ids=np.array([0, 2]),
1929 + remote_block_descs_ids=np.array([5, 7]),
1930 + notif_agent="prefill",
1931 + notif_id=b"request:1",
1932 + )
1933 +
1934 + dram_read, device_read = worker.nixl_wrapper.make_prepped_xfer.call_args_list
1935 + assert dram_read.args[:2] == ("READ", 10)
1936 + assert dram_read.args[3] == 30
1937 + np.testing.assert_array_equal(dram_read.args[2], [0])
1938 + np.testing.assert_array_equal(dram_read.args[4], [5])
1939 + assert device_read.args[:2] == ("READ", 20)
1940 + assert device_read.args[3] == 30
1941 + np.testing.assert_array_equal(device_read.args[2], [0])
1942 + np.testing.assert_array_equal(device_read.args[4], [7])
1943 + worker.nixl_wrapper.send_notif.assert_not_called()
1944 +
1945 + assert worker._pop_done_transfers(worker._recving_transfers) == {"request"}
1946 + worker.nixl_wrapper.send_notif.assert_called_once_with(
1947 + "prefill", notif_msg=b"request:1"
1948 + )
1949 +
1950 +
1951 +def test_mixed_memory_read_failure_does_not_notify_producer():
1952 + """A failed half of a split READ must suppress its success notification."""
1953 + worker = object.__new__(NixlConnectorWorker)
1954 + worker._recving_metadata = {"request": MagicMock()}
1955 + worker._recving_transfers = {"request": [101, 102]}
1956 + worker._pending_recv_notifs = {"request": [("prefill", b"request:1")]}
1957 + worker._is_hma_required = True
1958 + worker._failed_recv_reqs = MagicMock()
1959 + worker._log_failure = MagicMock() # type: ignore[method-assign]
1960 + worker.xfer_stats = MagicMock()
1961 + worker.nixl_wrapper = MagicMock()
1962 + worker.nixl_wrapper.check_xfer_state.side_effect = ["ERR", "DONE"]
1963 +
1964 + worker._pop_done_transfers(worker._recving_transfers)
1965 +
1966 + assert "request" not in worker._pending_recv_notifs
1967 + worker.nixl_wrapper.send_notif.assert_not_called()
1968 +
1969 +
1970 def element_byte_addrs(view: torch.Tensor) -> list[int]:
1971 """Absolute byte addresses of every element of a (possibly strided) view."""
1972 offsets = torch.zeros(view.shape, dtype=torch.int64)
tests/v1/kv_connector/unit/test_nixl_connector_hma.py
+59
@@ -861,6 +861,7 @@ def _make_mock_worker_for_desc_ids(
861
862 worker = MagicMock(spec=NixlConnectorWorker)
863 worker.num_regions = num_regions
864 + worker._uses_region_group_mapping = False
865 worker._has_mamba = has_mamba
866 worker._group_spec_types = group_spec_types
867 worker.block_len_per_layer = block_len_per_layer or [100]
@@ -913,6 +914,64 @@ def test_get_block_descs_ids_hybrid_ssm():
914 assert list(result) == expected, f"Expected {expected}, got {list(result)}"
915
916
917 +@pytest.mark.cpu_test
918 +def test_get_block_descs_ids_selects_attention_regions_by_group():
919 + """Each attention group's blocks address only that group's regions."""
920 + from vllm.v1.kv_cache_interface import FullAttentionSpec
921 +
922 + worker = _make_mock_worker_for_desc_ids(
923 + num_regions=3,
924 + has_mamba=False,
925 + group_spec_types=(FullAttentionSpec, FullAttentionSpec),
926 + block_len_per_layer=[100, 100, 100],
927 + )
928 + worker.region_group_ids = [0, 0, 1]
929 + worker._uses_region_group_mapping = True
930 +
931 + result = worker._compute_desc_ids(
932 + block_ids=([1, 2], [7]),
933 + dst_num_blocks=10,
934 + block_size_ratio=None,
935 + physical_blocks_per_logical=1,
936 + )
937 +
938 + assert result.tolist() == [1, 2, 11, 12, 27]
939 +
940 + remapped = worker._compute_desc_ids(
941 + block_ids=([1, 2], [7]),
942 + dst_num_blocks=10,
943 + block_size_ratio=None,
944 + physical_blocks_per_logical=1,
945 + region_group_ids=[1, 1, 0],
946 + )
947 + assert remapped.tolist() == [21, 22, 7, 17]
948 +
949 +
950 +@pytest.mark.cpu_test
951 +def test_get_block_descs_ids_uses_per_region_pool_capacity():
952 + """Independent host and device pools use cumulative descriptor offsets."""
953 + from vllm.v1.kv_cache_interface import FullAttentionSpec
954 +
955 + worker = _make_mock_worker_for_desc_ids(
956 + num_regions=2,
957 + has_mamba=False,
958 + group_spec_types=(FullAttentionSpec, FullAttentionSpec),
959 + block_len_per_layer=[100, 100],
960 + )
961 + worker.region_group_ids = [0, 1]
962 + worker._uses_region_group_mapping = True
963 +
964 + result = worker._compute_desc_ids(
965 + block_ids=([4], [8]),
966 + dst_num_blocks=10,
967 + block_size_ratio=None,
968 + physical_blocks_per_logical=1,
969 + region_num_blocks=[5, 10],
970 + )
971 +
972 + assert result.tolist() == [4, 13]
973 +
974 +
975 @pytest.mark.cpu_test
976 def test_get_block_descs_ids_kernel_block_mismatch():
977 """Test _compute_desc_ids uses different strides for FA
tests/v1/kv_connector/unit/test_nixl_desc_geometry.py
+148 -2
@@ -11,7 +11,8 @@ 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 unittest.mock import patch
14 +from collections import defaultdict
15 +from unittest.mock import MagicMock, patch
16
17 import numpy as np
18 import pytest
@@ -27,13 +28,14 @@ class _RecordingNixl:
28 def __init__(self, *args, **kwargs):
29 self.dlists: dict[int, np.ndarray] = {}
30 self.xfers: list[tuple] = []
31 + self.registered: list[tuple[list[tuple], object]] = []
32 self._next_handle = 1
33
34 def get_reg_descs(self, caches_data, mem_type):
35 return caches_data
36
37 def register_memory(self, descs, backends=None):
36 - pass
38 + self.registered.append((descs, backends))
39
40 def deregister_memory(self, descs):
41 pass
@@ -98,6 +100,150 @@ class _RecordingNixl:
100 pass
101
102
103 +@pytest.mark.cpu_test
104 +def test_local_descriptors_follow_each_region_pool_capacity():
105 + from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
106 + NixlConnectorWorker,
107 + )
108 +
109 + worker = object.__new__(NixlConnectorWorker)
110 + worker.transfer_topo = MagicMock()
111 + worker.device_id = 0
112 + worker.block_len_per_layer = [16, 16]
113 + worker.block_stride_per_layer = [16, 16]
114 + worker.region_num_blocks = [2, 3]
115 +
116 + descriptors = worker._build_fa_local([100, 1000], block_size_ratio=1)
117 +
118 + assert descriptors[:, 0].tolist() == [100, 116, 1000, 1016, 1032]
119 +
120 +
121 +@pytest.mark.cpu_test
122 +def test_overlaid_transfer_groups_share_region_geometry():
123 + """Groups overlaid on one allocation share its transfer region."""
124 + import msgspec
125 +
126 + from vllm.distributed.kv_transfer.kv_connector.v1.nixl import base_worker as bw
127 + from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
128 + NixlAgentMetadata,
129 + )
130 + from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
131 + NixlConnectorWorker,
132 + )
133 + from vllm.v1.kv_cache_interface import (
134 + KVCacheConfig,
135 + KVCacheGroupSpec,
136 + KVCacheTensor,
137 + MLAAttentionSpec,
138 + )
139 +
140 + num_blocks = 4
141 + spec = MLAAttentionSpec(
142 + block_size=4,
143 + num_kv_heads=1,
144 + head_size=8,
145 + dtype=torch.uint8,
146 + )
147 + page_size = spec.page_size_bytes
148 + block_stride = 2 * page_size
149 + backing = torch.zeros(num_blocks, block_stride, dtype=torch.uint8)
150 + caches = {
151 + "layer.0": backing[:, :page_size],
152 + "layer.1": backing[:, :page_size],
153 + }
154 + groups = [KVCacheGroupSpec([layer_name], spec) for layer_name in caches]
155 +
156 + worker = object.__new__(NixlConnectorWorker)
157 + worker.tp_rank = 0
158 + worker.world_size = 1
159 + worker.block_size = 4
160 + worker.engine_id = "local-engine"
161 + worker.use_mla = True
162 + worker.model_config = MagicMock()
163 + worker.model_config.get_total_num_kv_heads.return_value = 1
164 + worker.attn_backends = []
165 + worker._has_mamba = False
166 + worker._is_csa_linear = False
167 + worker.vllm_config = MagicMock()
168 + worker.backend_name = "FLASHMLA"
169 + worker.num_blocks = num_blocks
170 + worker.nixl_memory_type = "VRAM"
171 + worker.nixl_backends = None
172 + worker.nixl_wrapper = _RecordingNixl()
173 + worker._registered_descs = []
174 + worker.dst_num_blocks = {}
175 + worker.dst_region_num_blocks = {}
176 + worker.dst_region_group_ids = {}
177 + worker.dst_uses_region_group_mapping = {}
178 + worker.dst_region_mem_types = {}
179 + worker._desc_is_dram_by_block_size = {}
180 + worker._desc_pos_by_block_size = {}
181 + worker._dram_src_handles_by_block_size = {}
182 + worker.src_xfer_handles_by_block_size = {}
183 + worker.kv_caches_base_addr = defaultdict(dict)
184 + worker._mamba_ssm_size = (0, 0)
185 + worker.kv_cache_layout = "NHD"
186 + worker.host_buffer_kv_cache_layout = "NHD"
187 + worker._physical_blocks_per_logical_kv_block = 1
188 + worker._logical_num_blocks = num_blocks
189 + worker.region_group_ids = []
190 + worker.region_mem_types = []
191 + worker._mixed_mem_types = False
192 + worker.region_names = []
193 + worker.region_num_blocks = []
194 + worker._region_is_mla = []
195 + worker.block_len_per_layer = []
196 + worker.block_stride_per_layer = []
197 + worker.device_id = 0
198 + worker.use_host_buffer = False
199 + worker.host_xfer_buffers = {}
200 + worker.device_kv_caches = {}
201 + worker.pp_size = 1
202 + worker.dcp_size = 1
203 + worker.pcp_size = 1
204 + worker.kv_buffer_device = "cuda"
205 + worker._layer_specs = {name: spec for name in caches}
206 + worker.kv_cache_config = KVCacheConfig(
207 + num_blocks=num_blocks,
208 + kv_cache_tensors=[
209 + KVCacheTensor(
210 + size=backing.nbytes,
211 + layers=[name],
212 + layer_stride=page_size,
213 + block_stride=block_stride,
214 + )
215 + for name in caches
216 + ],
217 + kv_cache_groups=groups,
218 + )
219 +
220 + transfer_topology = MagicMock()
221 +
222 + with (
223 + patch.object(bw, "TransferTopology", return_value=transfer_topology),
224 + patch.object(bw, "compute_nixl_compatibility_hash", return_value="hash"),
225 + ):
226 + worker.register_kv_caches(caches)
227 +
228 + assert worker.region_group_ids == [-1]
229 + assert worker.block_stride_per_layer == [block_stride]
230 + assert worker.nixl_wrapper.registered[0][0] == [
231 + (backing.data_ptr(), backing.nbytes, 0, "")
232 + ]
233 + expected_addrs = [
234 + backing.data_ptr() + block * block_stride for block in range(num_blocks)
235 + ]
236 + assert worker.src_blocks_data[:, 0].tolist() == expected_addrs
237 +
238 + metadata = msgspec.msgpack.decode(
239 + worker.xfer_handshake_metadata.agent_metadata_bytes,
240 + type=NixlAgentMetadata,
241 + )
242 + assert metadata.region_group_ids == [-1]
243 + assert metadata.region_num_blocks == [num_blocks]
244 + assert worker._block_ids_by_region(([0], [2]), worker.region_group_ids) == [[0, 2]]
245 +
246 +
247 def _make_mla_hybrid_worker(local_block_size, kernel_block_size, num_logical_blocks):
248 """Build a real pull worker with a hybrid MLA + 2xKDA HMA layout."""
249 from vllm.distributed.kv_transfer.kv_connector.v1.nixl import (
tests/v1/kv_connector/unit/test_nixl_push_connector.py
+15
@@ -357,6 +357,12 @@ class _StubWriterWorker(NixlPushConnectorWorker):
357 w.engine_id = "test-decode-engine"
358 w._remote_agents = {}
359 w._physical_blocks_per_logical_kv_block = 1
360 + w._uses_region_group_mapping = False
361 + w.region_group_ids = [0]
362 + w.dst_region_num_blocks = {}
363 + w.dst_region_group_ids = {}
364 + w.dst_uses_region_group_mapping = {}
365 + w.dst_region_mem_types = {}
366 # Single non-hybrid attention group, matching the stub block id lists.
367 w._has_mamba = False
368 w._is_csa_linear = False
@@ -1314,6 +1320,15 @@ class TestPushPrefixCaching:
1320 )
1321 }
1322 w.dst_num_blocks = {engine_id: 10_000, w.engine_id: 10_000}
1323 + w.dst_region_num_blocks = {
1324 + engine_id: [10_000],
1325 + w.engine_id: [10_000],
1326 + }
1327 + w.dst_region_group_ids = {engine_id: [0], w.engine_id: [0]}
1328 + w.dst_uses_region_group_mapping = {
1329 + engine_id: False,
1330 + w.engine_id: False,
1331 + }
1332 w.dst_xfer_side_handles = {engine_id: {0: 5000}}
1333 w.src_xfer_handles_by_block_size = {16: 2000}
1334
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py
+321 -36
@@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Any, cast
17
18 import msgspec
19 import numpy as np
20 +import regex as re
21 import torch
22 import zmq
23
@@ -92,6 +93,16 @@ if TYPE_CHECKING:
93
94 logger = init_logger(__name__)
95
96 +_SHARED_REGION_GROUP_ID = -1
97 +
98 +
99 +def _region_sort_key(layer_name: str) -> tuple[tuple[int, int | str], ...]:
100 + """Sort transfer regions in model-layer order, then by cache name."""
101 + return tuple(
102 + (0, int(part)) if part.isdigit() else (1, part)
103 + for part in re.split(r"(\d+)", layer_name)
104 + )
105 +
106
107 def _share_storage_and_block_stride(caches: list[torch.Tensor]) -> bool:
108 """Return whether all views share storage and a block stride."""
@@ -154,6 +165,9 @@ class NixlBaseConnectorWorker:
165 dst_num_blocks: int,
166 block_size_ratio: float | None,
167 physical_blocks_per_logical: int,
168 + region_num_blocks: list[int] | None = None,
169 + region_group_ids: list[int] | None = None,
170 + uses_region_group_mapping: bool | None = None,
171 ) -> np.ndarray:
172 """Compute NIXL descriptor IDs for given block IDs."""
173 num_ssm_regions = 0
@@ -172,21 +186,51 @@ class NixlBaseConnectorWorker:
186 num_blocks = dst_num_blocks
187 if block_size_ratio is not None:
188 num_blocks = int(num_blocks * block_size_ratio)
175 - num_fa_descs = self.num_regions * num_blocks
189 + if region_num_blocks is None:
190 + region_num_blocks = [num_blocks] * self.num_regions
191 + elif block_size_ratio is not None:
192 + region_num_blocks = [
193 + int(count * block_size_ratio) for count in region_num_blocks
194 + ]
195 + assert len(region_num_blocks) == self.num_regions
196 + region_offsets = np.cumsum([0, *region_num_blocks[:-1]])
197 + num_fa_descs = sum(region_num_blocks)
198
199 # All-attention fast path: single vectorized broadcast.
200 if num_ssm_regions == 0:
201 + if region_group_ids is None:
202 + region_group_ids = self.region_group_ids
203 + if uses_region_group_mapping is None:
204 + uses_region_group_mapping = self._uses_region_group_mapping
205 + assert len(region_group_ids) == self.num_regions
206 + if uses_region_group_mapping is None:
207 + uses_region_group_mapping = len(set(region_group_ids)) > 1
208 + region_group_ids_array = np.asarray(region_group_ids, dtype=np.int32)
209 # NOTE (NickLucche) With HMA, every kv group has the same number of layers
210 # and layers from different groups share the same kv tensor.
211 # eg block_ids=[[1, 2], [3]]->blocks [1, 2] need to be
212 # read across all regions, same for [3], but group0-group1 blocks will
213 # always differ (different areas). Therefore we can just flatten the
214 # block_ids and compute the descs ids for all groups at once.
185 - block_arr = np.concatenate(
186 - [np.asarray(g, dtype=np.int32) for g in block_ids]
187 - )[None, :]
188 - region_ids = np.arange(self.num_regions, dtype=np.int32)[:, None]
189 - return (region_ids * num_blocks + block_arr).ravel()
215 + if not uses_region_group_mapping:
216 + block_arr = np.concatenate(
217 + [np.asarray(group, dtype=np.int32) for group in block_ids]
218 + )[None, :]
219 + return (region_offsets[:, None] + block_arr).ravel()
220 + desc_ids = []
221 + for group_id, group in enumerate(block_ids):
222 + if not group:
223 + continue
224 + region_ids = np.flatnonzero(
225 + (region_group_ids_array == group_id)
226 + | (region_group_ids_array == _SHARED_REGION_GROUP_ID)
227 + )[:, None]
228 + assert region_ids.size > 0
229 + group_blocks = np.asarray(group, dtype=np.int32)[None, :]
230 + desc_ids.append((region_offsets[region_ids] + group_blocks).ravel())
231 + if not desc_ids:
232 + return np.array([], dtype=np.int64)
233 + return np.concatenate(desc_ids)
234
235 # Compute desc ids per group using the right stride: FA descs have
236 # num_blocks entries per region (kernel granularity, expanded by
@@ -556,6 +600,12 @@ class NixlBaseConnectorWorker:
600 # Number of NIXL regions. Currently one region per cache
601 # (so 1 per layer for MLA, otherwise 2 per layer)
602 self.num_regions = 0
603 + self.region_group_ids: list[int] = []
604 + self._uses_region_group_mapping = False
605 + self.region_names: list[str] = []
606 + self.region_num_blocks: list[int] = []
607 + self.region_mem_types: list[str] = []
608 + self._mixed_mem_types = False
609
610 # PP>1 (push mode): this worker holds a contiguous layer slice and
611 # transfers into the matching sub-range of a PP=1 remote's regions.
@@ -584,12 +634,20 @@ class NixlBaseConnectorWorker:
634 # Populated dynamically during handshake based on remote configuration.
635 # Per-source split handles, keyed by (tp_ratio, remote_block_size).
636 self.src_xfer_handles_by_tp_ratio: dict[tuple[int, int], list[int]] = {}
637 + self._dram_src_handles_by_tp_ratio: dict[tuple[int, int], list[int]] = {}
638 # Map of engine_id -> {tp_rank: nixl_prepped_dlist_handle (int)}.
639 self.dst_xfer_side_handles = defaultdict[EngineId, dict[int, int]](dict)
640
641 # Map of engine_id -> num_blocks. All ranks in the same deployment will
642 # have the same number of blocks.
643 self.dst_num_blocks: dict[EngineId, int] = {}
644 + self.dst_region_num_blocks: dict[EngineId, list[int]] = {}
645 + self.dst_region_group_ids: dict[EngineId, list[int]] = {}
646 + self.dst_uses_region_group_mapping: dict[EngineId, bool] = {}
647 + self.dst_region_mem_types: dict[EngineId, list[str]] = {}
648 + self._desc_is_dram_by_block_size: dict[int, np.ndarray] = {}
649 + self._desc_pos_by_block_size: dict[int, np.ndarray] = {}
650 + self._dram_src_handles_by_block_size: dict[int, int] = {}
651 self._registered_descs: list[Any] = []
652
653 # In progress transfers.
@@ -607,6 +665,7 @@ class NixlBaseConnectorWorker:
665 # Uses Queue for thread-safe cross-thread coordination with the
666 # background handshake thread, matching the _ready_requests pattern.
667 self._failed_recv_reqs: queue.Queue[ReqId] = queue.Queue()
668 + self._pending_recv_notifs: dict[ReqId, list[tuple[str, bytes]]] = {}
669
670 # Handshake metadata of this worker for NIXL transfers.
671 self.xfer_handshake_metadata: NixlHandshakePayload | None = None
@@ -1179,8 +1238,8 @@ class NixlBaseConnectorWorker:
1238 self.use_host_buffer,
1239 )
1240
1182 - caches_data = []
1183 - seen_storage_addresses: set[int] = set()
1241 + registration_ranges: dict[tuple[int, str], tuple[int, int, int, str]] = {}
1242 + region_mem_types: list[str] = []
1243 seen_base_addresses: list[int] = []
1244 self._ssm_region_indices = []
1245 self._scratch_region_indices = []
@@ -1219,7 +1278,13 @@ class NixlBaseConnectorWorker:
1278 # K and V are packed into the content dim, so each attention layer is a
1279 # single NIXL region whose block transfers as one unit. Mamba layers instead
1280 # register separate conv/ssm sub-regions (see `_build_mamba_local`).
1222 - for layer_name, cache in xfer_buffers.items():
1281 + layer_names = (
1282 + xfer_buffers
1283 + if self._is_csa_linear
1284 + else sorted(xfer_buffers, key=_region_sort_key)
1285 + )
1286 + for layer_name in layer_names:
1287 + cache = xfer_buffers[layer_name]
1288 # NOTE (NickLucche) Hybrid SSM mamba/FA physical page_size may differ when
1289 # kernel requires a specific block size. This leads to SSM and FA layers
1290 # having different num_blocks.
@@ -1240,6 +1305,7 @@ class NixlBaseConnectorWorker:
1305 else layer_spec.page_size_bytes
1306 // self._physical_blocks_per_logical_kv_block
1307 )
1308 + group_id = self.kv_cache_config.transfer_group_index_by_layer[layer_name]
1309 num_blocks = (
1310 self._logical_num_blocks
1311 if isinstance(layer_spec, MambaSpec)
@@ -1277,13 +1343,17 @@ class NixlBaseConnectorWorker:
1343 )
1344 continue
1345
1280 - # Memory registration follows allocations, while transfer regions follow
1281 - # logical layers (or contiguous head segments). This keeps strided
1282 - # cross-layer views inside their registered allocation.
1283 - if storage_addr not in seen_storage_addresses:
1284 - seen_storage_addresses.add(storage_addr)
1285 - self.device_id = max(cache.get_device(), 0)
1286 - caches_data.append((storage_addr, storage.nbytes(), self.device_id, ""))
1346 + if cache.device.type == "cpu":
1347 + mem_type = "DRAM"
1348 + region_device_id = 0
1349 + else:
1350 + mem_type = self.nixl_memory_type
1351 + region_device_id = max(cache.get_device(), 0)
1352 + self.device_id = region_device_id
1353 + registration_ranges.setdefault(
1354 + (storage_addr, mem_type),
1355 + (storage_addr, storage.nbytes(), region_device_id, ""),
1356 + )
1357
1358 is_mla_region = isinstance(
1359 layer_spec, (MLAAttentionSpec, SlidingWindowMLASpec)
@@ -1366,12 +1436,19 @@ class NixlBaseConnectorWorker:
1436 for base_addr, block_len, block_stride in region_specs:
1437 if base_addr in seen_base_addresses:
1438 region_index = seen_base_addresses.index(base_addr)
1439 + assert region_mem_types[region_index] == mem_type
1440 self._region_is_mla[region_index] |= is_mla_region
1441 + if self.region_group_ids[region_index] != group_id:
1442 + self.region_group_ids[region_index] = _SHARED_REGION_GROUP_ID
1443 else:
1444 region_index = len(seen_base_addresses)
1445 seen_base_addresses.append(base_addr)
1446 self.block_len_per_layer.append(block_len)
1447 self.block_stride_per_layer.append(block_stride)
1448 + self.region_group_ids.append(group_id)
1449 + self.region_names.append(layer_name)
1450 + self.region_num_blocks.append(num_blocks)
1451 + region_mem_types.append(mem_type)
1452 self._region_is_mla.append(is_mla_region)
1453
1454 if isinstance(layer_spec, MambaSpec):
@@ -1391,10 +1468,10 @@ class NixlBaseConnectorWorker:
1468 # caches are either [NB, PS] or [NB*r, PS/r] where r is bs/kbs.
1469 if (
1470 self._physical_blocks_per_logical_kv_block == 1
1394 - and cache.shape[0] != num_blocks
1471 + and cache.shape[0] < num_blocks
1472 ):
1473 raise AssertionError(
1397 - "All kv cache tensors must have the same number of "
1474 + "KV cache tensor has too few "
1475 f"blocks; layer={layer_name}, "
1476 f"expected_num_blocks={num_blocks}, "
1477 f"cache_shape={tuple(cache.shape)}, "
@@ -1414,12 +1491,21 @@ class NixlBaseConnectorWorker:
1491 == len(seen_base_addresses)
1492 == len(self._region_is_mla)
1493 == len(self.block_stride_per_layer)
1494 + == len(self.region_group_ids)
1495 + == len(self.region_names)
1496 + == len(self.region_num_blocks)
1497 + == len(region_mem_types)
1498 )
1499 # Descriptor ids must be region-ordered, matching the remote side.
1500 self._scratch_region_indices.sort()
1501
1502 self.kv_caches_base_addr[self.engine_id][self.tp_rank] = seen_base_addresses
1503 self.num_regions = len(seen_base_addresses)
1504 + self.region_mem_types = region_mem_types
1505 + self._uses_region_group_mapping = len(set(self.region_group_ids)) > 1
1506 + self._mixed_mem_types = len(set(region_mem_types)) > 1
1507 + if self._has_mamba:
1508 + self.region_num_blocks = [self.num_blocks] * self.num_regions
1509
1510 if self.pp_size > 1:
1511 start_layer, end_layer = self.model_config.get_layers_start_end_indices(
@@ -1431,16 +1517,29 @@ class NixlBaseConnectorWorker:
1517 self._remote_region_offset = regions_per_layer * start_layer
1518
1519 # Total local FA descriptors (boundary between FA and mamba descs).
1434 - self.num_descs = self.num_regions * self.num_blocks
1435 -
1436 - descs = self.nixl_wrapper.get_reg_descs(caches_data, self.nixl_memory_type)
1437 - logger.debug("Registering descs: %s", caches_data)
1438 - self.nixl_wrapper.register_memory(descs, backends=self.nixl_backends)
1439 - logger.debug("Done registering descs")
1440 - self._registered_descs.append(descs)
1520 + self.num_descs = sum(self.region_num_blocks)
1521 +
1522 + for mem_type in sorted(set(region_mem_types)):
1523 + ranges = [
1524 + registration
1525 + for (
1526 + _,
1527 + registration_mem_type,
1528 + ), registration in registration_ranges.items()
1529 + if registration_mem_type == mem_type
1530 + ]
1531 + descs = self.nixl_wrapper.get_reg_descs(ranges, mem_type)
1532 + self.nixl_wrapper.register_memory(descs, backends=self.nixl_backends)
1533 + self._registered_descs.append(descs)
1534
1535 self.device_kv_caches = kv_caches
1536 self.dst_num_blocks[self.engine_id] = self.num_blocks
1537 + self.dst_region_num_blocks[self.engine_id] = self.region_num_blocks
1538 + self.dst_region_group_ids[self.engine_id] = self.region_group_ids
1539 + self.dst_uses_region_group_mapping[self.engine_id] = (
1540 + self._uses_region_group_mapping
1541 + )
1542 + self.dst_region_mem_types[self.engine_id] = self.region_mem_types
1543
1544 if self._has_mamba:
1545 logger.info(
@@ -1479,6 +1578,10 @@ class NixlBaseConnectorWorker:
1578 physical_blocks_per_logical_kv_block=(
1579 self._physical_blocks_per_logical_kv_block
1580 ),
1581 + region_num_blocks=self.region_num_blocks,
1582 + region_group_ids=self.region_group_ids,
1583 + region_names=self.region_names,
1584 + region_mem_types=self.region_mem_types,
1585 dcp_size=self.dcp_size,
1586 pcp_size=self.pcp_size,
1587 )
@@ -1642,14 +1745,23 @@ class NixlBaseConnectorWorker:
1745 """Build local FA descriptors for all layers as an Nx3 uint64 array."""
1746 assert self.transfer_topo is not None
1747 assert base_addresses, "Local KV cache base addresses must not be empty."
1645 - num_blocks = self.num_blocks * block_size_ratio
1748 device_id = self.device_id
1647 - block_arange = np.arange(num_blocks, dtype=np.uint64)
1749 parts: list[np.ndarray] = []
1750 for i, base_addr in enumerate(base_addresses):
1751 block_len = self.block_len_per_layer[i] // block_size_ratio
1651 - block_stride = self.block_stride_per_layer[i] // block_size_ratio
1652 - addrs = base_addr + block_arange * block_stride
1752 + logical_blocks = np.repeat(
1753 + np.arange(self.region_num_blocks[i], dtype=np.uint64),
1754 + block_size_ratio,
1755 + )
1756 + split_offsets = np.tile(
1757 + np.arange(block_size_ratio, dtype=np.uint64),
1758 + self.region_num_blocks[i],
1759 + )
1760 + addrs = (
1761 + base_addr
1762 + + logical_blocks * self.block_stride_per_layer[i]
1763 + + split_offsets * block_len
1764 + )
1765 parts.append(self._stack_descs(addrs, block_len, device_id))
1766 return np.concatenate(parts)
1767
@@ -1670,9 +1782,11 @@ class NixlBaseConnectorWorker:
1782 # SPLIT regions read their head slice from this many remote ranks at a
1783 # per-rank offset; REPLICATE regions read the whole block once.
1784 split_reads = len(plan.source_ranks_per_group[fa_group_idx])
1673 - num_blocks = nixl_agent_meta.num_blocks
1785 + region_num_blocks = nixl_agent_meta.region_num_blocks or [
1786 + nixl_agent_meta.num_blocks
1787 + ] * len(nixl_agent_meta.kv_caches_base_addr)
1788 device_id = nixl_agent_meta.device_id
1675 - block_arange = np.arange(num_blocks, dtype=np.uint64)
1789 + block_strides = nixl_agent_meta.block_strides
1790 parts: list[np.ndarray] = []
1791 for i, base_addr in enumerate(nixl_agent_meta.kv_caches_base_addr):
1792 replicated = self._is_region_replicated(i)
@@ -1691,8 +1805,8 @@ class NixlBaseConnectorWorker:
1805 )
1806 local_block_len = local_block_len // num_reads
1807
1694 - page_size = nixl_agent_meta.block_strides[i]
1695 - addrs = base_addr + rank_offset + block_arange * page_size
1808 + block_arange = np.arange(region_num_blocks[i], dtype=np.uint64)
1809 + addrs = base_addr + rank_offset + block_arange * block_strides[i]
1810 parts.append(self._stack_descs(addrs, local_block_len, device_id))
1811 return np.concatenate(parts)
1812
@@ -1735,7 +1849,42 @@ class NixlBaseConnectorWorker:
1849 mamba = self._build_mamba_local(local_base_addresses)
1850 blocks_data = np.concatenate([blocks_data, mamba])
1851
1738 - descs = self.nixl_wrapper.get_xfer_descs(blocks_data, self.nixl_memory_type)
1852 + if self._mixed_mem_types:
1853 + desc_is_dram = np.concatenate(
1854 + [
1855 + np.full(count * block_size_ratio, mem_type == "DRAM", dtype=bool)
1856 + for mem_type, count in zip(
1857 + self.region_mem_types,
1858 + self.region_num_blocks,
1859 + strict=True,
1860 + )
1861 + ]
1862 + )
1863 + assert len(desc_is_dram) == len(blocks_data)
1864 + desc_pos = np.empty(len(desc_is_dram), dtype=np.int64)
1865 + dram_indices = np.flatnonzero(desc_is_dram)
1866 + device_indices = np.flatnonzero(~desc_is_dram)
1867 + desc_pos[dram_indices] = np.arange(len(dram_indices), dtype=np.int64)
1868 + desc_pos[device_indices] = np.arange(len(device_indices), dtype=np.int64)
1869 + self._desc_is_dram_by_block_size[block_size] = desc_is_dram
1870 + self._desc_pos_by_block_size[block_size] = desc_pos
1871 + blocks_data[desc_is_dram, 2] = 0
1872 + dram_descs = self.nixl_wrapper.get_xfer_descs(
1873 + blocks_data[desc_is_dram], "DRAM"
1874 + )
1875 + self._dram_src_handles_by_block_size[block_size] = (
1876 + self.nixl_wrapper.prep_xfer_dlist("NIXL_INIT_AGENT", dram_descs)
1877 + )
1878 + device_descs = self.nixl_wrapper.get_xfer_descs(
1879 + blocks_data[~desc_is_dram], self.nixl_memory_type
1880 + )
1881 + return (
1882 + self.nixl_wrapper.prep_xfer_dlist("NIXL_INIT_AGENT", device_descs),
1883 + blocks_data,
1884 + )
1885 +
1886 + mem_type = self.region_mem_types[0]
1887 + descs = self.nixl_wrapper.get_xfer_descs(blocks_data, mem_type)
1888 # NIXL_INIT_AGENT to be used for preparations of local descs.
1889 return self.nixl_wrapper.prep_xfer_dlist("NIXL_INIT_AGENT", descs), blocks_data
1890
@@ -1817,6 +1966,20 @@ class NixlBaseConnectorWorker:
1966 ]
1967 nixl_agent_meta.block_lens = nixl_agent_meta.block_lens[start:end]
1968 nixl_agent_meta.block_strides = nixl_agent_meta.block_strides[start:end]
1969 + if nixl_agent_meta.region_num_blocks is not None:
1970 + nixl_agent_meta.region_num_blocks = nixl_agent_meta.region_num_blocks[
1971 + start:end
1972 + ]
1973 + if nixl_agent_meta.region_group_ids is not None:
1974 + nixl_agent_meta.region_group_ids = nixl_agent_meta.region_group_ids[
1975 + start:end
1976 + ]
1977 + if nixl_agent_meta.region_names is not None:
1978 + nixl_agent_meta.region_names = nixl_agent_meta.region_names[start:end]
1979 + if nixl_agent_meta.region_mem_types is not None:
1980 + nixl_agent_meta.region_mem_types = nixl_agent_meta.region_mem_types[
1981 + start:end
1982 + ]
1983
1984 ### Register remote engine in TransferTopology (idempotent).
1985 assert self.transfer_topo is not None
@@ -1855,7 +2018,24 @@ class NixlBaseConnectorWorker:
2018 block_size_ratio = transfer_topo.block_size_ratio(nixl_agent_meta.block_size)
2019
2020 if engine_id not in self.dst_num_blocks:
2021 + num_remote_regions = len(nixl_agent_meta.kv_caches_base_addr)
2022 self.dst_num_blocks[engine_id] = nixl_agent_meta.num_blocks
2023 + self.dst_region_num_blocks[engine_id] = (
2024 + nixl_agent_meta.region_num_blocks
2025 + or [nixl_agent_meta.num_blocks] * num_remote_regions
2026 + )
2027 + self.dst_region_group_ids[engine_id] = nixl_agent_meta.region_group_ids or (
2028 + self.region_group_ids
2029 + if len(self.region_group_ids) == num_remote_regions
2030 + else [0] * num_remote_regions
2031 + )
2032 + self.dst_uses_region_group_mapping[engine_id] = (
2033 + len(set(self.dst_region_group_ids[engine_id])) > 1
2034 + )
2035 + self.dst_region_mem_types[engine_id] = (
2036 + nixl_agent_meta.region_mem_types
2037 + or [self.nixl_memory_type] * num_remote_regions
2038 + )
2039
2040 # Keep track of remote agent kv caches base addresses.
2041 self.kv_caches_base_addr[engine_id][remote_tp_rank] = (
@@ -1902,6 +2082,8 @@ class NixlBaseConnectorWorker:
2082 # while the SSM state is sharded across every remote TP rank.
2083 # We only do this once per remote (tp_size, block_size).
2084 self.src_xfer_handles_by_tp_ratio[split_key] = []
2085 + if self._mixed_mem_types:
2086 + self._dram_src_handles_by_tp_ratio[split_key] = []
2087
2088 for handle_data in self._build_local_splits_from_plan(
2089 plan,
@@ -1909,6 +2091,17 @@ class NixlBaseConnectorWorker:
2091 self.num_descs * block_size_ratio,
2092 block_size_ratio,
2093 ):
2094 + if self._mixed_mem_types:
2095 + handle_data = np.asarray(handle_data, dtype=np.uint64)
2096 + desc_is_dram = self._desc_is_dram_by_block_size[remote_block_size]
2097 + dram_descs = self.nixl_wrapper.get_xfer_descs(
2098 + handle_data[desc_is_dram], "DRAM"
2099 + )
2100 + dram_handle = self.nixl_wrapper.prep_xfer_dlist(
2101 + "NIXL_INIT_AGENT", dram_descs
2102 + )
2103 + self._dram_src_handles_by_tp_ratio[split_key].append(dram_handle)
2104 + handle_data = handle_data[~desc_is_dram]
2105 descs = self.nixl_wrapper.get_xfer_descs(
2106 handle_data, self.nixl_memory_type
2107 )
@@ -1943,8 +2136,14 @@ class NixlBaseConnectorWorker:
2136 mamba = self._build_mamba_remote(nixl_agent_meta, tp_ratio, transfer_info)
2137 blocks_data = np.concatenate([blocks_data, mamba])
2138
1946 - # Register with NIXL.
1947 - descs = self.nixl_wrapper.get_xfer_descs(blocks_data, self.nixl_memory_type)
2139 + # A mixed local destination is split into multiple READs below. The
2140 + # producer remains a single descriptor list in this prerequisite.
2141 + remote_mem_types = set(self.dst_region_mem_types[engine_id])
2142 + if len(remote_mem_types) != 1:
2143 + raise NotImplementedError(
2144 + "NIXL pull does not yet support a mixed-memory producer"
2145 + )
2146 + descs = self.nixl_wrapper.get_xfer_descs(blocks_data, remote_mem_types.pop())
2147 self.dst_xfer_side_handles[engine_id][remote_tp_rank] = (
2148 self.nixl_wrapper.prep_xfer_dlist(remote_agent_name, descs)
2149 )
@@ -2133,6 +2332,27 @@ class NixlBaseConnectorWorker:
2332 assert self.dst_num_blocks[remote_engine_id] == nixl_agent_meta.num_blocks
2333 # Same number of regions/~layers.
2334 assert len(nixl_agent_meta.kv_caches_base_addr) == len(self.block_len_per_layer)
2335 + num_remote_regions = len(nixl_agent_meta.kv_caches_base_addr)
2336 + if nixl_agent_meta.region_num_blocks is not None:
2337 + assert len(nixl_agent_meta.region_num_blocks) == num_remote_regions
2338 + assert len(nixl_agent_meta.block_strides) == num_remote_regions
2339 + assert all(
2340 + stride >= block_len
2341 + for stride, block_len in zip(
2342 + nixl_agent_meta.block_strides,
2343 + nixl_agent_meta.block_lens,
2344 + strict=True,
2345 + )
2346 + )
2347 + if nixl_agent_meta.region_group_ids is not None:
2348 + assert len(nixl_agent_meta.region_group_ids) == num_remote_regions
2349 + if nixl_agent_meta.region_names is not None:
2350 + assert nixl_agent_meta.region_names == self.region_names, (
2351 + "NIXL transfer regions must name the same model caches in the "
2352 + "same order"
2353 + )
2354 + if nixl_agent_meta.region_mem_types is not None:
2355 + assert len(nixl_agent_meta.region_mem_types) == num_remote_regions
2356
2357 def sync_recved_kv_to_device(self, req_id: str, meta: ReqMeta):
2358 """copy recved kv from host buffer to device."""
@@ -2526,6 +2746,7 @@ class NixlBaseConnectorWorker:
2746 # don't report it again, just drop the remaining handles.
2747 if req_id in self._recving_metadata:
2748 done_req_ids.add(req_id)
2749 + self._send_pending_recv_notifs(req_id)
2750 del transfers[req_id]
2751 else:
2752 transfers[req_id] = in_progress
@@ -2545,6 +2766,8 @@ class NixlBaseConnectorWorker:
2766 # reported failed on the first one, which pops its metadata in
2767 # get_finished(); on the later failures only the handle cleanup is left.
2768 # TODO (NickLucche) handle failed transfer for HMA.
2769 + # A split READ's notification is sent only after every handle succeeds.
2770 + self._pending_recv_notifs.pop(req_id, None)
2771 if (meta := self._recving_metadata.get(req_id)) is not None:
2772 if not self._is_hma_required:
2773 self._invalid_block_ids.put(set(meta.local_block_ids[0]))
@@ -2553,6 +2776,21 @@ class NixlBaseConnectorWorker:
2776 self.nixl_wrapper.release_xfer_handle(handle)
2777 self.xfer_stats.record_failed_transfer()
2778
2779 + def _send_pending_recv_notifs(self, req_id: str) -> None:
2780 + for agent_name, notif_id in self._pending_recv_notifs.pop(req_id, []):
2781 + try:
2782 + self.nixl_wrapper.send_notif(agent_name, notif_msg=notif_id)
2783 + except Exception as e:
2784 + self._log_failure(
2785 + failure_type="notification_failed",
2786 + msg="P worker blocks will be freed after timeout. "
2787 + "This may indicate network issues.",
2788 + req_id=req_id,
2789 + error=e,
2790 + remote_agent_name=agent_name,
2791 + )
2792 + self.xfer_stats.record_failed_notification()
2793 +
2794 def _send_heartbeats(self, metadata: NixlConnectorMetadata) -> None:
2795 """
2796 Send heartbeat notifications to remote engines, extending lease on KV blocks.
@@ -2726,6 +2964,42 @@ class NixlBaseConnectorWorker:
2964 matched_blocks = min(len(local_slice), len(remote_slice))
2965 return local_slice[:matched_blocks], remote_slice[:matched_blocks]
2966
2967 + @staticmethod
2968 + def _block_ids_by_region(
2969 + block_ids: BlockIds, region_group_ids: list[int]
2970 + ) -> BlockIds:
2971 + """Expand group block IDs into the corresponding region order."""
2972 + shared_block_ids = list(itertools.chain.from_iterable(block_ids))
2973 + block_ids_by_region = []
2974 + for group_id in region_group_ids:
2975 + if group_id == _SHARED_REGION_GROUP_ID:
2976 + block_ids_by_region.append(shared_block_ids.copy())
2977 + continue
2978 + block_ids_by_region.append(list(block_ids[group_id]))
2979 + return block_ids_by_region
2980 +
2981 + @staticmethod
2982 + def _apply_prefix_caching_by_region(
2983 + decode_block_ids: BlockIds, prefill_block_ids: BlockIds
2984 + ) -> tuple[BlockIds, BlockIds]:
2985 + """Pair an uncached decode suffix with the same prefill regions."""
2986 + assert len(decode_block_ids) == len(prefill_block_ids)
2987 + if not any(decode_block_ids):
2988 + return [], prefill_block_ids
2989 +
2990 + trimmed_prefill: list[list[int]] = []
2991 + for decode_region, prefill_region in zip(
2992 + decode_block_ids, prefill_block_ids, strict=True
2993 + ):
2994 + if len(decode_region) > len(prefill_region):
2995 + raise ValueError(
2996 + "Decode allocated more KV pages than the prefill worker supplied"
2997 + )
2998 + trimmed_prefill.append(
2999 + list(prefill_region[-len(decode_region) :]) if decode_region else []
3000 + )
3001 + return decode_block_ids, trimmed_prefill
3002 +
3003 def _apply_prefix_caching(
3004 self,
3005 decode_block_ids: BlockIds,
@@ -2893,6 +3167,10 @@ class NixlBaseConnectorWorker:
3167
3168 self.kv_caches_base_addr.pop(engine_id, None)
3169 self.dst_num_blocks.pop(engine_id, None)
3170 + self.dst_region_num_blocks.pop(engine_id, None)
3171 + self.dst_region_group_ids.pop(engine_id, None)
3172 + self.dst_uses_region_group_mapping.pop(engine_id, None)
3173 + self.dst_region_mem_types.pop(engine_id, None)
3174 self.tp_mappings.pop(engine_id, None)
3175 if self.transfer_topo is not None:
3176 self.transfer_topo.unregister_remote_engine(engine_id)
@@ -2929,6 +3207,13 @@ class NixlBaseConnectorWorker:
3207 for handle in handles:
3208 self.nixl_wrapper.release_dlist_handle(handle)
3209 self.src_xfer_handles_by_tp_ratio.clear()
3210 + for handles in self._dram_src_handles_by_tp_ratio.values():
3211 + for handle in handles:
3212 + self.nixl_wrapper.release_dlist_handle(handle)
3213 + self._dram_src_handles_by_tp_ratio.clear()
3214 + for handle in self._dram_src_handles_by_block_size.values():
3215 + self.nixl_wrapper.release_dlist_handle(handle)
3216 + self._dram_src_handles_by_block_size.clear()
3217 for engine_id in list(self._remote_agents):
3218 self._cleanup_remote_engine(engine_id, log_eviction=False)
3219 for desc in self._registered_descs:
vllm/distributed/kv_transfer/kv_connector/v1/nixl/metadata.py
+6 -1
@@ -46,8 +46,9 @@ PUSH_REG_NOTIF_PREFIX = b"PUSH_REG:"
46 # 8: Add dcp_size and pcp_size to NixlAgentMetadata
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 #
50 -NIXL_CONNECTOR_VERSION: int = 10
51 +NIXL_CONNECTOR_VERSION: int = 11
52
53
54 @dataclass
@@ -64,6 +65,10 @@ class NixlAgentMetadata:
65 ssm_sizes: tuple[int, int]
66 attn_backend_name: str
67 physical_blocks_per_logical_kv_block: int
68 + region_num_blocks: list[int] | None = None
69 + region_group_ids: list[int] | None = None
70 + region_names: list[str] | None = None
71 + region_mem_types: list[str] | None = None
72 dcp_size: int = 1
73 pcp_size: int = 1
74
vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py
+207 -67
@@ -5,6 +5,8 @@
5 import time
6 from typing import TYPE_CHECKING
7
8 +import numpy as np
9 +
10 from vllm.distributed.kv_transfer.kv_connector.utils import BlockIds
11 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker import (
12 NixlBaseConnectorWorker,
@@ -156,60 +158,86 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
158 remote_info = self.transfer_topo.get_engine_info(engine_id)
159 tp_ratio = self.transfer_topo.tp_ratio(remote_info.remote_tp_size)
160
159 - remote_logical_block_ids = meta.remote.block_ids
160 - meta.remote.block_ids = self._logical_to_kernel_block_ids(
161 - remote_logical_block_ids,
162 - remote_info.remote_physical_blocks_per_logical,
163 - )
164 - num_groups = len(meta.local_block_ids)
161 dcp_active = self.dcp_size > 1 or remote_info.remote_dcp_size > 1
166 -
167 - def group_ids(block_ids: BlockIds, rank: int) -> list[list[int]]:
168 - return [
169 - list(block_ids[g]) if rank in plan.source_ranks_per_group[g] else []
170 - for g in range(num_groups)
162 + local_block_ids = meta.local_physical_block_ids
163 + local_region_groups = getattr(self, "region_group_ids", [])
164 + remote_region_groups = getattr(self, "dst_region_group_ids", {}).get(
165 + engine_id, local_region_groups
166 + )
167 + if local_region_groups != remote_region_groups:
168 + if not self.use_mla or self._has_mamba:
169 + raise NotImplementedError(
170 + "Different NIXL cache-group layouts are only supported for "
171 + "pure MLA models"
172 + )
173 + assert len(plan.all_source_ranks) == 1
174 + remote_physical_block_ids = self._logical_to_kernel_block_ids(
175 + meta.remote.block_ids,
176 + remote_info.remote_physical_blocks_per_logical,
177 + )
178 + remote_by_region = self._block_ids_by_region(
179 + remote_physical_block_ids, remote_region_groups
180 + )
181 + read_specs = [
182 + ReadSpec(
183 + remote_rank=plan.all_source_ranks[0],
184 + local_block_ids=self._block_ids_by_region(
185 + local_block_ids, local_region_groups
186 + ),
187 + remote_block_ids=remote_by_region,
188 + block_ids_by_region=True,
189 + )
190 ]
191 + else:
192 + remote_logical_block_ids = meta.remote.block_ids
193 + meta.remote.block_ids = self._logical_to_kernel_block_ids(
194 + remote_logical_block_ids,
195 + remote_info.remote_physical_blocks_per_logical,
196 + )
197 + num_groups = len(meta.local_block_ids)
198
173 - read_specs = []
174 - for rank in plan.all_source_ranks:
175 - if dcp_active:
176 - # DCP interleaves at block granularity, so slicing happens
177 - # here on logical blocks, before kernel-block expansion.
178 - local_ids = group_ids(meta.local_block_ids, rank)
179 - remote_ids = group_ids(remote_logical_block_ids, rank)
180 - for g in range(num_groups):
181 - if not local_ids[g]:
182 - continue
183 - # Prefix cache hit may lead to skip some of the remote reads
184 - # TODO (NickLucche) consider unifying prefix cache handling on
185 - # logical blocks here for both dcp and non-dcp
186 - local_ids[g], remote_ids[g] = self._apply_dcp_prefix_caching(
187 - local_ids[g],
188 - remote_ids[g],
199 + def group_ids(block_ids: BlockIds, rank: int) -> list[list[int]]:
200 + return [
201 + list(block_ids[g]) if rank in plan.source_ranks_per_group[g] else []
202 + for g in range(num_groups)
203 + ]
204 +
205 + read_specs = []
206 + for rank in plan.all_source_ranks:
207 + if dcp_active:
208 + local_ids = group_ids(meta.local_block_ids, rank)
209 + remote_ids = group_ids(remote_logical_block_ids, rank)
210 + for g in range(num_groups):
211 + if not local_ids[g]:
212 + continue
213 + local_ids[g], remote_ids[g] = self._apply_dcp_prefix_caching(
214 + local_ids[g],
215 + remote_ids[g],
216 + remote_rank=rank,
217 + local_dcp_size=self.dcp_size,
218 + local_dcp_rank=self.dcp_rank,
219 + remote_dcp_size=remote_info.remote_dcp_size,
220 + local_num_computed_blocks=(
221 + meta.local_num_computed_blocks[g]
222 + ),
223 + )
224 + local_physical_ids = self._logical_to_kernel_block_ids(
225 + local_ids, self._physical_blocks_per_logical_kv_block
226 + )
227 + remote_physical_ids = self._logical_to_kernel_block_ids(
228 + remote_ids,
229 + remote_info.remote_physical_blocks_per_logical,
230 + )
231 + else:
232 + local_physical_ids = group_ids(meta.local_physical_block_ids, rank)
233 + remote_physical_ids = group_ids(meta.remote.block_ids, rank)
234 + read_specs.append(
235 + ReadSpec(
236 remote_rank=rank,
190 - local_dcp_size=self.dcp_size,
191 - local_dcp_rank=self.dcp_rank,
192 - remote_dcp_size=remote_info.remote_dcp_size,
193 - local_num_computed_blocks=meta.local_num_computed_blocks[g],
237 + local_block_ids=local_physical_ids,
238 + remote_block_ids=remote_physical_ids,
239 )
195 - local_physical_ids = self._logical_to_kernel_block_ids(
196 - local_ids, self._physical_blocks_per_logical_kv_block
197 - )
198 - remote_physical_ids = self._logical_to_kernel_block_ids(
199 - remote_ids, remote_info.remote_physical_blocks_per_logical
240 )
201 - else:
202 - # No DCP realignment needed: reuse the already-expanded full
203 - # physical lists instead of re-deriving them from logical ids.
204 - local_physical_ids = group_ids(meta.local_physical_block_ids, rank)
205 - remote_physical_ids = group_ids(meta.remote.block_ids, rank)
206 - read_specs.append(
207 - ReadSpec(
208 - remote_rank=rank,
209 - local_block_ids=local_physical_ids,
210 - remote_block_ids=remote_physical_ids,
211 - )
212 - )
241
242 # D may have to perform multiple reads from different remote ranks.
243 # Pure MLA reads once because its cache is replicated. Hybrid
@@ -234,27 +262,39 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
262 # reads. Get the memory chunk onto which we will write to.
263 split_key = (tp_ratio, remote_block_size)
264 local_xfer_side_handle = self.src_xfer_handles_by_tp_ratio[split_key][i]
265 + local_dram_handle = (
266 + self._dram_src_handles_by_tp_ratio[split_key][i]
267 + if self._mixed_mem_types
268 + else None
269 + )
270 else:
271 # Single read from remote, we write to the whole memory region.
272 # Also handle remote block size different from local block size.
273 local_xfer_side_handle = self.src_xfer_handles_by_block_size[
274 remote_block_size
275 ]
276 + local_dram_handle = (
277 + self._dram_src_handles_by_block_size[remote_block_size]
278 + if self._mixed_mem_types
279 + else None
280 + )
281
282 # Destination handle: remote_engine_id -> remote_rank -> handle.
283 remote_xfer_side_handle = self.dst_xfer_side_handles[meta.remote.engine_id][
284 spec.remote_rank
285 ]
286
249 - self._read_blocks(
287 + if not self._read_blocks(
288 read_spec=spec,
289 request_id=req_id,
290 dst_engine_id=meta.remote.engine_id,
291 remote_request_id=meta.remote.request_id,
292 local_xfer_side_handle=local_xfer_side_handle,
293 + local_dram_handle=local_dram_handle,
294 remote_xfer_side_handle=remote_xfer_side_handle,
295 expected_consumers=plan.local_consumers,
257 - )
296 + ):
297 + return
298
299 if self.use_mla and tp_ratio < 0 and len(read_specs) == 1:
300 # ..but we still need to notify the other remote ranks that we
@@ -274,9 +314,10 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
314 request_id: str,
315 remote_request_id: str,
316 local_xfer_side_handle: int,
317 + local_dram_handle: int | None,
318 remote_xfer_side_handle: int,
319 expected_consumers: int,
279 - ):
320 + ) -> bool:
321 """
322 Post a READ point-to-point xfer request from a single local worker to
323 a single remote worker.
@@ -291,6 +332,10 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
332 remote_info.remote_block_size
333 )
334 if block_size_ratio > 1:
335 + if read_spec.block_ids_by_region:
336 + raise NotImplementedError(
337 + "Region-mapped NIXL transfers require matching physical block sizes"
338 + )
339 local_block_ids, remote_block_ids = (
340 self._map_block_ids_for_block_size_ratio(
341 local_block_ids, remote_block_ids, block_size_ratio
@@ -328,26 +373,30 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
373 remote_agent_name=agent_name,
374 )
375 self.xfer_stats.record_failed_notification()
331 - return
376 + return True
377
333 - assert (
334 - len(remote_block_ids)
335 - == len(local_block_ids)
336 - == len(self.kv_cache_config.transfer_groups)
337 - )
338 - if not (self.dcp_size > 1 or remote_info.remote_dcp_size > 1):
339 - # DCP-active reads were already trimmed (and DCP-realigned, when
340 - # sizes mismatch) in _read_blocks_for_req, at logical granularity.
341 - local_block_ids, remote_block_ids = self._apply_prefix_caching(
378 + if read_spec.block_ids_by_region:
379 + local_block_ids, remote_block_ids = self._apply_prefix_caching_by_region(
380 decode_block_ids=local_block_ids,
381 prefill_block_ids=remote_block_ids,
344 - decode_physical_per_logical=(
345 - self._physical_blocks_per_logical_kv_block
346 - ),
347 - prefill_physical_per_logical=(
348 - remote_info.remote_physical_blocks_per_logical
349 - ),
382 )
383 + else:
384 + assert (
385 + len(remote_block_ids)
386 + == len(local_block_ids)
387 + == len(self.kv_cache_config.transfer_groups)
388 + )
389 + if not (self.dcp_size > 1 or remote_info.remote_dcp_size > 1):
390 + local_block_ids, remote_block_ids = self._apply_prefix_caching(
391 + decode_block_ids=local_block_ids,
392 + prefill_block_ids=remote_block_ids,
393 + decode_physical_per_logical=(
394 + self._physical_blocks_per_logical_kv_block
395 + ),
396 + prefill_physical_per_logical=(
397 + remote_info.remote_physical_blocks_per_logical
398 + ),
399 + )
400
401 # NOTE (nicolo) With homogeneous TP, each TP worker loads KV from
402 # corresponding rank. With heterogeneous TP, fixing D>P, the D tp
@@ -359,12 +408,34 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
408 dst_num_blocks=self.dst_num_blocks[dst_engine_id],
409 block_size_ratio=None,
410 physical_blocks_per_logical=remote_info.remote_physical_blocks_per_logical,
411 + region_num_blocks=self.dst_region_num_blocks[dst_engine_id],
412 + region_group_ids=(
413 + list(range(self.num_regions))
414 + if read_spec.block_ids_by_region
415 + else self.dst_region_group_ids[dst_engine_id]
416 + ),
417 + uses_region_group_mapping=(
418 + self.num_regions > 1
419 + if read_spec.block_ids_by_region
420 + else self.dst_uses_region_group_mapping[dst_engine_id]
421 + ),
422 )
423 local_block_descs_ids = self._compute_desc_ids(
424 block_ids=local_block_ids,
425 dst_num_blocks=self.dst_num_blocks[self.engine_id],
426 block_size_ratio=block_size_ratio,
427 physical_blocks_per_logical=self._physical_blocks_per_logical_kv_block,
428 + region_num_blocks=self.dst_region_num_blocks[self.engine_id],
429 + region_group_ids=(
430 + list(range(self.num_regions))
431 + if read_spec.block_ids_by_region
432 + else self.region_group_ids
433 + ),
434 + uses_region_group_mapping=(
435 + self.num_regions > 1
436 + if read_spec.block_ids_by_region
437 + else self._uses_region_group_mapping
438 + ),
439 )
440
441 assert len(local_block_descs_ids) == len(remote_block_descs_ids)
@@ -372,6 +443,19 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
443 # Prepare transfer with Nixl.
444 handle = None
445 try:
446 + if self._mixed_mem_types:
447 + self._read_blocks_mixed(
448 + request_id=request_id,
449 + local_block_size_key=remote_info.remote_block_size,
450 + local_device_handle=local_xfer_side_handle,
451 + local_dram_handle=local_dram_handle,
452 + remote_xfer_side_handle=remote_xfer_side_handle,
453 + local_block_descs_ids=local_block_descs_ids,
454 + remote_block_descs_ids=remote_block_descs_ids,
455 + notif_agent=self._remote_agents[dst_engine_id][(0, remote_rank)],
456 + notif_id=notif_id,
457 + )
458 + return True
459 handle = self.nixl_wrapper.make_prepped_xfer(
460 "READ",
461 local_xfer_side_handle,
@@ -386,6 +470,7 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
470
471 # Use handle to check completion in future step().
472 self._recving_transfers[request_id].append(handle)
473 + return True
474 except Exception as e:
475 # mark all (logical) blocks for this request as invalid
476 self._log_failure(
@@ -397,6 +482,61 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
482 remote_rank=remote_rank,
483 )
484 self._handle_failed_transfer(request_id, handle)
485 + return False
486 +
487 + def _read_blocks_mixed(
488 + self,
489 + request_id: str,
490 + local_block_size_key: int,
491 + local_device_handle: int,
492 + local_dram_handle: int | None,
493 + remote_xfer_side_handle: int,
494 + local_block_descs_ids: np.ndarray,
495 + remote_block_descs_ids: np.ndarray,
496 + notif_agent: str,
497 + notif_id: bytes,
498 + ) -> None:
499 + """Split a READ across the local DRAM and device descriptor lists."""
500 + desc_is_dram = self._desc_is_dram_by_block_size[local_block_size_key]
501 + desc_pos = self._desc_pos_by_block_size[local_block_size_key]
502 + local_ids = np.asarray(local_block_descs_ids)
503 + remote_ids = np.asarray(remote_block_descs_ids)
504 + is_dram = desc_is_dram[local_ids]
505 +
506 + assert local_dram_handle is not None
507 + reads = (
508 + (is_dram, local_dram_handle),
509 + (~is_dram, local_device_handle),
510 + )
511 + handles: list[int] = []
512 + try:
513 + for mask, local_handle in reads:
514 + if mask.any():
515 + handles.append(
516 + self.nixl_wrapper.make_prepped_xfer(
517 + "READ",
518 + local_handle,
519 + desc_pos[local_ids[mask]],
520 + remote_xfer_side_handle,
521 + remote_ids[mask],
522 + )
523 + )
524 + except Exception:
525 + for handle in handles:
526 + self.nixl_wrapper.release_xfer_handle(handle)
527 + raise
528 +
529 + self._pending_recv_notifs.setdefault(request_id, []).append(
530 + (notif_agent, notif_id)
531 + )
532 + for index, handle in enumerate(handles):
533 + try:
534 + self.nixl_wrapper.transfer(handle)
535 + except Exception:
536 + for unstarted in handles[index:]:
537 + self.nixl_wrapper.release_xfer_handle(unstarted)
538 + raise
539 + self._recving_transfers[request_id].append(handle)
540
541 def _get_new_notifs(self) -> set[str]:
542 """
vllm/distributed/kv_transfer/kv_connector/v1/nixl/push_worker.py
+10
@@ -132,6 +132,10 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
132
133 def register_kv_caches(self, kv_caches: dict[str, "torch.Tensor"]):
134 super().register_kv_caches(kv_caches)
135 + if self._mixed_mem_types:
136 + raise NotImplementedError(
137 + "NixlPushConnector does not support mixed-memory KV caches"
138 + )
139 if self._push_writer_thread is None:
140 self._push_writer_thread = threading.Thread(
141 target=self._push_writer_loop,
@@ -661,12 +665,18 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
665 dst_num_blocks=self.dst_num_blocks[dst_engine_id],
666 block_size_ratio=None,
667 physical_blocks_per_logical=remote_info.remote_physical_blocks_per_logical,
668 + region_num_blocks=self.dst_region_num_blocks[dst_engine_id],
669 + region_group_ids=self.dst_region_group_ids[dst_engine_id],
670 + uses_region_group_mapping=self.dst_uses_region_group_mapping[dst_engine_id],
671 )
672 local_block_descs_ids = self._compute_desc_ids(
673 block_ids=local_block_ids,
674 dst_num_blocks=self.dst_num_blocks[self.engine_id],
675 block_size_ratio=block_size_ratio,
676 physical_blocks_per_logical=self._physical_blocks_per_logical_kv_block,
677 + region_num_blocks=self.dst_region_num_blocks[self.engine_id],
678 + region_group_ids=self.region_group_ids,
679 + uses_region_group_mapping=self._uses_region_group_mapping,
680 )
681
682 assert len(local_block_descs_ids) == len(remote_block_descs_ids)
vllm/distributed/kv_transfer/kv_connector/v1/nixl/tp_mapping.py
+1
@@ -26,6 +26,7 @@ class ReadSpec:
26 remote_rank: int
27 local_block_ids: BlockIds
28 remote_block_ids: BlockIds
29 + block_ids_by_region: bool = False
30
31
32 def _is_attention_spec(spec_type: type[KVCacheSpec]) -> bool: