[KV Connector] Support NIXL heterogeneous P/D block sizes for hybrid models (#49612)
Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Nick Hill committed
Jul 28, 2026 at 06:45 UTC
601fa9a74e8cd2ae0a4fa127d68b91bbf363a24e
7 files changed
+1014
-111
tests/v1/kv_connector/unit/test_nixl_connector.py
+6
-4
@@ -479,8 +479,9 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
479
super().__init__(*args, kv_cache_config=kv_cache_config, **kwargs)
480
self._hand_shake_latency = hand_shake_latency
481
self.kv_cache_layout = kv_cache_layout
482
- # Mock register_kv_caches attribute needed for tests that do not call it.
482
+ # Mock register_kv_caches attributes needed for tests that do not call it.
483
self.src_xfer_handles_by_block_size = {self.block_size: 1}
484
+ self.src_blocks_data = np.empty((0, 3), dtype=np.uint64)
485
test_shape = self.attn_backends[0].get_kv_cache_shape(
486
num_blocks=1, block_size=16, num_kv_heads=1, head_size=1
487
)
@@ -765,8 +766,9 @@ class TestNixlHandshake:
766
assert remote_info.remote_tp_size == remote_tp_size
767
assert -tp_ratio == worker.transfer_topo.tp_ratio(remote_tp_size)
768
# ensure src_xfer_handles_by_tp_ratio is populated with tpratio chunks
768
- assert -tp_ratio in worker.src_xfer_handles_by_tp_ratio
769
- assert len(worker.src_xfer_handles_by_tp_ratio[-tp_ratio]) == tp_ratio
769
+ split_key = (-tp_ratio, worker.block_size)
770
+ assert split_key in worker.src_xfer_handles_by_tp_ratio
771
+ assert len(worker.src_xfer_handles_by_tp_ratio[split_key]) == tp_ratio
772
assert remote_engine_id in worker.dst_xfer_side_handles
773
assert set(worker.dst_xfer_side_handles[remote_engine_id].keys()) == set(
774
range(tp_ratio)
@@ -2091,7 +2093,7 @@ def test_shutdown_cleans_up_resources(default_vllm_config, dist_init):
2093
# Mock register_kv_cache which registers local handle
2094
worker.src_xfer_handles_by_block_size = {worker.block_size: 455}
2095
# P TP = 2 * D TP case, we should register 2 local handles
2094
- worker.src_xfer_handles_by_tp_ratio = {-2: [456, 457]}
2096
+ worker.src_xfer_handles_by_tp_ratio = {(-2, 16): [456, 457]}
2097
worker.dst_xfer_side_handles = {"engine1": {0: 789}}
2098
worker._remote_agents = {"engine1": {(0, 0): "agent1"}}
2099
# _cleanup_remote_engine (called by shutdown) also clears these:
tests/v1/kv_connector/unit/test_nixl_connector_hma.py
+114
@@ -708,6 +708,120 @@ def test_get_block_descs_ids_kernel_block_mismatch():
708
assert list(result) == expected, f"Expected {expected}, got {list(result)}"
709
710
711
+@pytest.mark.cpu_test
712
+def test_get_block_descs_ids_hetero_block_size_hybrid():
713
+ """With a block-size ratio, FA desc ids are ratio-expanded while SSM
714
+ desc ids keep the unexpanded logical stride (state blocks are never
715
+ sub-split)."""
716
+ from vllm.v1.kv_cache_interface import FullAttentionSpec, MambaSpec
717
+
718
+ worker = _make_mock_worker_for_desc_ids(
719
+ num_regions=2,
720
+ has_mamba=True,
721
+ group_spec_types=(FullAttentionSpec, MambaSpec),
722
+ block_len_per_layer=[100],
723
+ )
724
+
725
+ ratio = 4
726
+ # FA ids are already remote-granularity (expanded) sub-block ids.
727
+ fa_sub_blocks = [3, 5]
728
+ ssm_blocks = [1]
729
+ result = worker._compute_desc_ids(
730
+ block_ids=(fa_sub_blocks, ssm_blocks),
731
+ dst_num_blocks=100,
732
+ block_size_ratio=ratio,
733
+ physical_blocks_per_logical=1,
734
+ )
735
+
736
+ # FA regions have 100*4 entries each; SSM regions (4 per layer) start at
737
+ # 2*400 and stride by the unexpanded 100 logical blocks.
738
+ expected = [3, 5, 403, 405, 801, 901, 1001, 1101]
739
+ assert list(result) == expected, f"Expected {expected}, got {list(result)}"
740
+
741
+
742
+def _bind_worker_method(worker, name):
743
+ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
744
+ NixlConnectorWorker,
745
+ )
746
+
747
+ method = getattr(NixlConnectorWorker, name)
748
+ setattr(worker, name, method.__get__(worker, NixlConnectorWorker))
749
+
750
+
751
+@pytest.mark.cpu_test
752
+def test_map_block_ids_for_block_size_ratio_hybrid():
753
+ """Attention groups expand to remote granularity and clip to the remote
754
+ coverage; mamba state blocks pass through 1:1."""
755
+ from unittest.mock import MagicMock
756
+
757
+ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
758
+ NixlConnectorWorker,
759
+ )
760
+ from vllm.v1.kv_cache_interface import FullAttentionSpec, MambaSpec
761
+
762
+ worker = MagicMock(spec=NixlConnectorWorker)
763
+ worker._group_spec_types = (FullAttentionSpec, MambaSpec)
764
+ _bind_worker_method(worker, "get_mapped_blocks")
765
+ _bind_worker_method(worker, "_map_block_ids_for_block_size_ratio")
766
+
767
+ local, remote = worker._map_block_ids_for_block_size_ratio(
768
+ [[1, 2, 3], [7]],
769
+ [list(range(30, 40)), [42]],
770
+ 4,
771
+ )
772
+ # [1, 2, 3] expand to sub-blocks [4..15], clipped to the 10 remote blocks.
773
+ assert local == [list(range(4, 14)), [7]]
774
+ assert remote == [list(range(30, 40)), [42]]
775
+
776
+ # Attention-only full prefix hit: empty local list is preserved.
777
+ worker._group_spec_types = (FullAttentionSpec,)
778
+ local, remote = worker._map_block_ids_for_block_size_ratio([[]], [[30, 31]], 4)
779
+ assert local == []
780
+
781
+
782
+@pytest.mark.cpu_test
783
+def test_post_process_zeroes_untransferred_tail():
784
+ """The untransferred sub-blocks of the last local block are zeroed on
785
+ receive; mamba state caches are untouched by the attention permute."""
786
+ from unittest.mock import MagicMock
787
+
788
+ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
789
+ NixlConnectorWorker,
790
+ )
791
+ from vllm.v1.kv_cache_interface import FullAttentionSpec, MambaSpec
792
+
793
+ ratio = 4
794
+ block_tokens = 8 # 2 tokens per remote sub-block
795
+
796
+ worker = MagicMock(spec=NixlConnectorWorker)
797
+ worker._group_spec_types = (FullAttentionSpec, MambaSpec)
798
+ worker.transfer_topo = MagicMock()
799
+ worker.device_type = "cpu"
800
+ worker.enable_permute_local_kv = False
801
+ attn_cache = torch.ones(6, block_tokens, 2, 4)
802
+ mamba_cache = torch.ones(6, 16)
803
+ worker.device_kv_caches = {"attn.0": attn_cache, "mamba.0": mamba_cache}
804
+ fa_group = MagicMock(layer_names=["attn.0"])
805
+ ssm_group = MagicMock(layer_names=["mamba.0"])
806
+ worker.kv_cache_config = MagicMock(kv_cache_groups=[fa_group, ssm_group])
807
+ # The cached property filters mamba layers out of the permuted caches.
808
+ attn_caches = NixlConnectorWorker._attention_kv_caches.func(worker)
809
+ assert len(attn_caches) == 1 and attn_caches[0] is attn_cache
810
+ worker._attention_kv_caches = attn_caches
811
+ _bind_worker_method(worker, "post_process_device_kv_on_receive")
812
+
813
+ # Request occupies blocks [2, 3]; only 6 of 8 sub-blocks were received.
814
+ worker.post_process_device_kv_on_receive(ratio, [([2, 3], 6)])
815
+
816
+ # Block 2 fully covered; block 3 covered for 2 sub-blocks (4 tokens).
817
+ assert torch.all(attn_cache[2] == 1)
818
+ assert torch.all(attn_cache[3, :4] == 1)
819
+ assert torch.all(attn_cache[3, 4:] == 0)
820
+ # Untouched blocks and the mamba cache keep their content.
821
+ assert torch.all(attn_cache[4] == 1)
822
+ assert torch.all(mamba_cache == 1)
823
+
824
+
825
@pytest.mark.cpu_test
826
def test_nixl_metadata_hybrid_ssm_block_ids():
827
"""Test NixlConnectorMetadata correctly stores block IDs for FA + SSM
tests/v1/kv_connector/unit/test_nixl_desc_geometry.py
new
+619
@@ -0,0 +1,619 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""End-to-end NIXL descriptor geometry invariants for hybrid MLA+SSM models
4
+under heterogeneous P/D block geometry (TP-sharded KDA-style state, so
5
+the mamba-aligned logical block size differs between P and D while the
6
+kernel-granularity pages stay equal).
7
+
8
+The invariant under test: every LOCAL byte range a request's READ transfers
9
+into must lie within that request's own blocks. A violation means an
10
+incoming transfer can overwrite a co-resident request's KV or mamba state
11
+mid-decode (silent corruption of an unrelated request).
12
+"""
13
+
14
+from unittest.mock import patch
15
+
16
+import numpy as np
17
+import pytest
18
+import torch
19
+
20
+from .utils import create_vllm_config
21
+
22
+
23
+class _RecordingNixl:
24
+ """Minimal NIXL wrapper stand-in that records descriptor lists and
25
+ prepared transfers so tests can resolve desc ids to byte ranges."""
26
+
27
+ def __init__(self, *args, **kwargs):
28
+ self.dlists: dict[int, np.ndarray] = {}
29
+ self.xfers: list[tuple] = []
30
+ self._next_handle = 1
31
+
32
+ def get_reg_descs(self, caches_data, mem_type):
33
+ return caches_data
34
+
35
+ def register_memory(self, descs, backends=None):
36
+ pass
37
+
38
+ def deregister_memory(self, descs):
39
+ pass
40
+
41
+ def get_agent_metadata(self):
42
+ return b"agent-meta"
43
+
44
+ def get_xfer_descs(self, blocks_data, mem_type):
45
+ return blocks_data
46
+
47
+ def prep_xfer_dlist(self, agent, descs):
48
+ handle = self._next_handle
49
+ self._next_handle += 1
50
+ self.dlists[handle] = np.asarray(descs, dtype=np.uint64).reshape(-1, 3)
51
+ return handle
52
+
53
+ def add_remote_agent(self, metadata):
54
+ return "remote-agent"
55
+
56
+ def make_prepped_xfer(
57
+ self, op, local_handle, local_ids, remote_handle, remote_ids, notif_msg=None
58
+ ):
59
+ handle = self._next_handle
60
+ self._next_handle += 1
61
+ self.xfers.append(
62
+ (
63
+ op,
64
+ local_handle,
65
+ np.asarray(local_ids),
66
+ remote_handle,
67
+ np.asarray(remote_ids),
68
+ )
69
+ )
70
+ return handle
71
+
72
+ def transfer(self, handle):
73
+ pass
74
+
75
+ def check_xfer_state(self, handle):
76
+ return "DONE"
77
+
78
+ def get_xfer_telemetry(self, handle):
79
+ from types import SimpleNamespace
80
+
81
+ return SimpleNamespace(
82
+ xferDuration=1.0, postDuration=1.0, totalBytes=1, descCount=1
83
+ )
84
+
85
+ def release_xfer_handle(self, handle):
86
+ pass
87
+
88
+ def release_dlist_handle(self, handle):
89
+ pass
90
+
91
+ def send_notif(self, agent, notif_msg=None):
92
+ pass
93
+
94
+ def get_new_notifs(self):
95
+ return {}
96
+
97
+ def remove_remote_agent(self, agent):
98
+ pass
99
+
100
+
101
+def _make_mla_hybrid_worker(local_block_size, kernel_block_size, num_logical_blocks):
102
+ """Build a real pull worker with a hybrid MLA + 2xKDA HMA layout."""
103
+ from vllm.distributed.kv_transfer.kv_connector.v1.nixl import (
104
+ base_worker as bw,
105
+ )
106
+ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
107
+ NixlConnectorWorker,
108
+ )
109
+ from vllm.v1.attention.backends.registry import MambaAttentionBackendEnum
110
+ from vllm.v1.kv_cache_interface import (
111
+ KVCacheConfig,
112
+ KVCacheGroupSpec,
113
+ KVCacheTensor,
114
+ MambaSpec,
115
+ MLAAttentionSpec,
116
+ )
117
+
118
+ mla_spec = MLAAttentionSpec(
119
+ block_size=local_block_size,
120
+ num_kv_heads=1,
121
+ head_size=6,
122
+ dtype=torch.float16,
123
+ )
124
+ unified_page = mla_spec.page_size_bytes
125
+ kda_spec = MambaSpec(
126
+ block_size=local_block_size,
127
+ shapes=((8, 3), (1, 4, 4)),
128
+ dtypes=(torch.float16, torch.float32),
129
+ page_size_padded=unified_page,
130
+ mamba_type=MambaAttentionBackendEnum.GDN_ATTN,
131
+ )
132
+ kv_cache_config = KVCacheConfig(
133
+ num_blocks=num_logical_blocks,
134
+ kv_cache_tensors=[
135
+ KVCacheTensor(
136
+ size=num_logical_blocks * unified_page,
137
+ shared_by=[f"mla.{i}", f"kda_a.{i}", f"kda_b.{i}"],
138
+ )
139
+ for i in range(2)
140
+ ],
141
+ kv_cache_groups=[
142
+ KVCacheGroupSpec(["mla.0", "mla.1"], mla_spec),
143
+ KVCacheGroupSpec(["kda_a.0", "kda_a.1"], kda_spec),
144
+ KVCacheGroupSpec(["kda_b.0", "kda_b.1"], kda_spec),
145
+ ],
146
+ )
147
+
148
+ vllm_config = create_vllm_config(block_size=local_block_size)
149
+ vllm_config.cache_config.enable_prefix_caching = False
150
+ # kv_buffer_device defaults to the *real* platform's device type, which on
151
+ # a CPU-only test host would make this a host-buffer worker: host xfer
152
+ # buffers are per-layer, so the HMA shared-tensor regions this test builds
153
+ # would not be deduplicated. Pin it to the faked device type.
154
+ vllm_config.kv_transfer_config.kv_buffer_device = "cuda"
155
+
156
+ from unittest.mock import MagicMock
157
+
158
+ fake_backend = MagicMock()
159
+ fake_backend.get_supported_kernel_block_sizes.return_value = [kernel_block_size]
160
+ fake_backend.get_name.return_value = "FLASHMLA"
161
+ fake_backend.full_cls_name.return_value = "fake.FLASHMLA"
162
+ fake_platform = MagicMock()
163
+ fake_platform.device_type = "cuda"
164
+ fake_platform.get_nixl_memory_type.return_value = "VRAM"
165
+
166
+ from vllm.config import set_current_vllm_config
167
+
168
+ with (
169
+ patch.object(bw, "NixlWrapper", _RecordingNixl),
170
+ patch.object(bw, "get_tensor_model_parallel_rank", return_value=0),
171
+ patch.object(bw, "get_tensor_model_parallel_world_size", return_value=1),
172
+ patch.object(bw, "get_current_attn_backends", return_value=[fake_backend]),
173
+ patch.object(bw, "current_platform", fake_platform),
174
+ patch(
175
+ "vllm.model_executor.layers.mamba.mamba_utils.get_conv_state_layout",
176
+ return_value="DS",
177
+ ),
178
+ set_current_vllm_config(vllm_config),
179
+ ):
180
+ worker = NixlConnectorWorker(vllm_config, "local-engine", kv_cache_config)
181
+ worker.use_mla = True
182
+
183
+ # Attention caches are kernel-block granular on dim 0, as the
184
+ # receive post-process assumes.
185
+ ppl = local_block_size // kernel_block_size
186
+ tensors = [
187
+ torch.zeros(
188
+ num_logical_blocks * ppl, unified_page // ppl, dtype=torch.uint8
189
+ )
190
+ for _ in range(2)
191
+ ]
192
+ worker.register_kv_caches(
193
+ {
194
+ "kda_a.0": tensors[0],
195
+ "mla.0": tensors[0],
196
+ "kda_b.0": tensors[0],
197
+ "kda_a.1": tensors[1],
198
+ "mla.1": tensors[1],
199
+ "kda_b.1": tensors[1],
200
+ }
201
+ )
202
+ # Keep tensors alive alongside the worker; flat views for byte checks.
203
+ worker._test_tensors = [t.view(-1) for t in tensors]
204
+ worker._test_tensors_2d = tensors
205
+ worker._test_unified_page = unified_page
206
+ return worker
207
+
208
+
209
+def _make_remote_meta(
210
+ worker,
211
+ remote_block_size,
212
+ remote_kernel_block_size,
213
+ remote_num_logical,
214
+ remote_ssm_sizes,
215
+):
216
+ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
217
+ NixlAgentMetadata,
218
+ )
219
+
220
+ remote_ppl = remote_block_size // remote_kernel_block_size
221
+ # Kernel-granularity pages are TP-independent for MLA hybrids and must
222
+ # match the local ones for the handshake to pass, scaled down by the
223
+ # block-size ratio when the remote's kernel block is smaller.
224
+ block_size_ratio = worker.block_size // remote_kernel_block_size
225
+ kernel_page = worker.block_len_per_layer[0] // block_size_ratio
226
+ return NixlAgentMetadata(
227
+ engine_id="remote-engine",
228
+ agent_metadata=b"remote-agent-meta",
229
+ device_id=0,
230
+ kv_caches_base_addr=[0x10_000_000, 0x20_000_000],
231
+ num_blocks=remote_num_logical * remote_ppl,
232
+ block_lens=[kernel_page, kernel_page],
233
+ kv_cache_layout=worker.kv_cache_layout,
234
+ block_size=remote_kernel_block_size,
235
+ ssm_sizes=remote_ssm_sizes,
236
+ attn_backend_name=worker.backend_name,
237
+ physical_blocks_per_logical_kv_block=remote_ppl,
238
+ )
239
+
240
+
241
+def _owned_byte_ranges(worker, group_logical_ids):
242
+ """Byte ranges owned by a request: for each HMA region tensor, every
243
+ logical block id of every group maps to one unified page."""
244
+ unified_page = worker._test_unified_page
245
+ bases = [t.data_ptr() for t in worker._test_tensors]
246
+ owned = []
247
+ for base in bases:
248
+ for ids in group_logical_ids:
249
+ for b in ids:
250
+ owned.append((base + b * unified_page, base + (b + 1) * unified_page))
251
+ return owned
252
+
253
+
254
+def _assert_local_writes_within(worker, owned_ranges):
255
+ nixl = worker.nixl_wrapper
256
+ assert nixl.xfers, "no transfers were posted"
257
+ violations = []
258
+ total_descs = 0
259
+ for op, local_handle, local_ids, _, remote_ids in nixl.xfers:
260
+ assert len(local_ids) == len(remote_ids)
261
+ desc_arr = nixl.dlists[local_handle]
262
+ for i in local_ids:
263
+ addr, length, _dev = desc_arr[int(i)]
264
+ addr, length = int(addr), int(length)
265
+ total_descs += 1
266
+ if not any(lo <= addr and addr + length <= hi for lo, hi in owned_ranges):
267
+ violations.append((int(i), hex(addr), length))
268
+ assert not violations, (
269
+ f"{len(violations)}/{total_descs} local descriptors write outside "
270
+ f"the request's own blocks: {violations[:10]}"
271
+ )
272
+ return total_descs
273
+
274
+
275
+@pytest.mark.cpu_test
276
+def test_hetero_ppl_multi_read_writes_stay_within_request_blocks():
277
+ """MLA-hybrid hetero geometry: local (D, TP1) logical blocks of 12 tokens
278
+ (kernel 4, ppl=3) vs remote (P, TP2) logical blocks of 8 tokens (ppl=2),
279
+ equal kernel pages, tp_ratio=-2 multi-read with replicated MLA and
280
+ TP-sharded KDA state. Every local descriptor of the request's reads must
281
+ stay within its own blocks."""
282
+ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
283
+ NixlConnectorMetadata,
284
+ )
285
+
286
+ worker = _make_mla_hybrid_worker(
287
+ local_block_size=12, kernel_block_size=4, num_logical_blocks=8
288
+ )
289
+ assert worker._physical_blocks_per_logical_kv_block == 3
290
+
291
+ meta_r = _make_remote_meta(
292
+ worker,
293
+ remote_block_size=8,
294
+ remote_kernel_block_size=4,
295
+ remote_num_logical=12,
296
+ remote_ssm_sizes=(24, 32),
297
+ )
298
+ for rank in (0, 1):
299
+ worker.add_remote_agent(meta_r, remote_tp_rank=rank, remote_tp_size=2)
300
+
301
+ # Request B: 17 matched tokens. Local: 2 logical blocks (24 tok
302
+ # capacity); remote: 16 prefilled tokens -> 2 remote logical blocks.
303
+ # Sparse, non-contiguous ids so neighbor blocks exist on all sides.
304
+ local_ids = ([2, 5], [1], [7])
305
+ remote_ids = [[1, 4], [5], [2]]
306
+
307
+ metadata = NixlConnectorMetadata()
308
+ metadata.add_new_req_to_recv(
309
+ request_id="req-b",
310
+ local_block_ids=local_ids,
311
+ kv_transfer_params={
312
+ "remote_block_ids": remote_ids,
313
+ "remote_engine_id": "remote-engine",
314
+ "remote_request_id": "prefill-req-b",
315
+ "remote_host": "localhost",
316
+ "remote_port": 1234,
317
+ "tp_size": 2,
318
+ },
319
+ )
320
+ meta = metadata.reqs_to_recv["req-b"]
321
+ meta.local_physical_block_ids = worker._logical_to_kernel_block_ids(
322
+ meta.local_block_ids, worker._physical_blocks_per_logical_kv_block
323
+ )
324
+ worker._recving_metadata["req-b"] = meta
325
+
326
+ worker._read_blocks_for_req("req-b", meta)
327
+
328
+ owned = _owned_byte_ranges(worker, local_ids)
329
+ total = _assert_local_writes_within(worker, owned)
330
+ # Multi-read: rank 0 carries the replicated MLA + its SSM shard,
331
+ # rank 1 carries only its SSM shard.
332
+ assert len(worker.nixl_wrapper.xfers) == 2
333
+ assert total > 0
334
+
335
+
336
+def _resolve(
337
+ desc_arr,
338
+ idx,
339
+ bases,
340
+ region_size,
341
+ unified_page,
342
+ desc_page,
343
+ logical_ids_attn,
344
+ block_tokens,
345
+):
346
+ """Resolve a desc id to (region, kind, token_start) where kind is 'attn'
347
+ (desc-page sized, sub-block-aligned, in the request's attention blocks)
348
+ or 'mamba'. token_start is the request-relative token offset, so local
349
+ and remote are comparable even when their kernel blocks differ in size."""
350
+ addr, length, _ = (int(x) for x in desc_arr[int(idx)])
351
+ for region, base in enumerate(bases):
352
+ off = addr - base
353
+ if 0 <= off < region_size:
354
+ b = off // unified_page
355
+ rem = off % unified_page
356
+ if length == desc_page and rem % desc_page == 0 and b in logical_ids_attn:
357
+ pos = logical_ids_attn.index(b)
358
+ tokens_per_desc = block_tokens * desc_page // unified_page
359
+ sub = rem // desc_page
360
+ return (region, "attn", pos * block_tokens + sub * tokens_per_desc)
361
+ return (region, "mamba", None)
362
+ raise AssertionError(f"desc {idx} addr {addr:#x} not in any region")
363
+
364
+
365
+def _run_hetero_case(
366
+ local_block, kernel, remote_block, num_tokens, tp_size=2, remote_kernel=None
367
+):
368
+ """Full pull-path run for one geometry; returns pairing records.
369
+
370
+ ``remote_kernel`` defaults to the local kernel block size; a smaller
371
+ value additionally exercises block_size_ratio > 1.
372
+ """
373
+ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
374
+ NixlConnectorMetadata,
375
+ )
376
+
377
+ remote_kernel = remote_kernel or kernel
378
+ block_size_ratio = kernel // remote_kernel
379
+ remote_ppl = remote_block // remote_kernel
380
+ matched = num_tokens - 1 # mamba N-1 rule
381
+ n_local = -(-num_tokens // local_block)
382
+ n_remote = -(-matched // remote_block)
383
+
384
+ worker = _make_mla_hybrid_worker(
385
+ local_block_size=local_block,
386
+ kernel_block_size=kernel,
387
+ num_logical_blocks=max(2 * n_local + 4, 8),
388
+ )
389
+ # Local KDA state pages are (48, 64) bytes; the remote holds 1/tp_size
390
+ # shards of each.
391
+ meta_r = _make_remote_meta(
392
+ worker,
393
+ remote_block_size=remote_block,
394
+ remote_kernel_block_size=remote_kernel,
395
+ remote_num_logical=max(2 * n_remote + 4, 8),
396
+ remote_ssm_sizes=(48 // tp_size, 64 // tp_size),
397
+ )
398
+ for rank in range(tp_size):
399
+ worker.add_remote_agent(meta_r, remote_tp_rank=rank, remote_tp_size=tp_size)
400
+
401
+ # Sparse ids so neighbors exist between the request's blocks.
402
+ local_attn = [2 * i + 1 for i in range(n_local)]
403
+ remote_attn = [2 * i + 2 for i in range(n_remote)]
404
+ local_ids = (local_attn, [0], [2 * n_local + 2])
405
+ remote_ids = [remote_attn, [1], [0]]
406
+
407
+ metadata = NixlConnectorMetadata()
408
+ metadata.add_new_req_to_recv(
409
+ request_id="req-b",
410
+ local_block_ids=local_ids,
411
+ kv_transfer_params={
412
+ "remote_block_ids": remote_ids,
413
+ "remote_engine_id": "remote-engine",
414
+ "remote_request_id": "prefill-req-b",
415
+ "remote_host": "localhost",
416
+ "remote_port": 1234,
417
+ "tp_size": tp_size,
418
+ },
419
+ )
420
+ meta = metadata.reqs_to_recv["req-b"]
421
+ meta.local_physical_block_ids = worker._logical_to_kernel_block_ids(
422
+ meta.local_block_ids, worker._physical_blocks_per_logical_kv_block
423
+ )
424
+ worker._recving_metadata["req-b"] = meta
425
+
426
+ # Sentinel-fill the local KV so untouched bytes are detectable.
427
+ for t in worker._test_tensors:
428
+ t.fill_(0xAA)
429
+
430
+ worker._read_blocks_for_req("req-b", meta)
431
+
432
+ # Invariant 1: all local writes within the request's own blocks.
433
+ owned = _owned_byte_ranges(worker, local_ids)
434
+ _assert_local_writes_within(worker, owned)
435
+
436
+ # Invariant 2: local<->remote attention pairs are token-aligned.
437
+ nixl = worker.nixl_wrapper
438
+ local_bases = [t.data_ptr() for t in worker._test_tensors]
439
+ remote_bases = [0x10_000_000, 0x20_000_000]
440
+ local_unified = worker._test_unified_page
441
+ remote_unified = (local_unified // local_block) * remote_block
442
+ # With block_size_ratio > 1 the local page is split into ratio sub-descs,
443
+ # each the size of a whole remote kernel page.
444
+ desc_page = worker.block_len_per_layer[0] // block_size_ratio
445
+ meta_r_num_blocks_bytes = (meta_r.num_blocks // remote_ppl) * remote_unified
446
+ covered_tokens = set()
447
+ for op, lh, lids, rh, rids in nixl.xfers:
448
+ larr, rarr = nixl.dlists[lh], nixl.dlists[rh]
449
+ for li, ri in zip(lids, rids):
450
+ lreg, lkind, ltok = _resolve(
451
+ larr,
452
+ li,
453
+ local_bases,
454
+ len(worker._test_tensors[0]),
455
+ local_unified,
456
+ desc_page,
457
+ local_attn,
458
+ local_block,
459
+ )
460
+ rreg, rkind, rtok = _resolve(
461
+ rarr,
462
+ ri,
463
+ remote_bases,
464
+ meta_r_num_blocks_bytes,
465
+ remote_unified,
466
+ desc_page,
467
+ remote_attn,
468
+ remote_block,
469
+ )
470
+ assert lkind == rkind, (
471
+ f"pair kind mismatch: local {lkind} vs remote {rkind} "
472
+ f"(local desc {li}, remote desc {ri})"
473
+ )
474
+ assert lreg == rreg, (
475
+ f"region mismatch: local {lreg} vs remote {rreg} for "
476
+ f"tokens {ltok} vs {rtok}"
477
+ )
478
+ if lkind == "attn":
479
+ assert ltok == rtok, (
480
+ f"TOKEN MISALIGNMENT: local sub-block holds tokens "
481
+ f"[{ltok}..) but receives remote tokens [{rtok}..) "
482
+ f"(geometry local_block={local_block}, "
483
+ f"remote_block={remote_block}, N={num_tokens})"
484
+ )
485
+ covered_tokens.add(ltok)
486
+
487
+ # Invariant 3: full coverage of the matched tokens, at the finest
488
+ # transfer granularity (the remote kernel block).
489
+ needed = {t for t in range(0, matched - matched % remote_kernel, remote_kernel)}
490
+ missing = needed - covered_tokens
491
+ assert not missing, (
492
+ f"tokens never transferred: {sorted(missing)[:8]} "
493
+ f"(geometry local_block={local_block}, remote_block={remote_block}, "
494
+ f"N={num_tokens}, matched={matched})"
495
+ )
496
+
497
+ # Invariant 4: no stale bytes after receive completion. The scheduler
498
+ # excludes the blocks covering the matched tokens from alloc-time KV
499
+ # zeroing (the zeroing would race the RDMA write), so every byte of
500
+ # those blocks must be either written by the transfer or zeroed by the
501
+ # receive post-process. Stale bytes surface as mid-response garbage
502
+ # once decode grows into the untransferred tail.
503
+ for op, lh, lids, rh, rids in nixl.xfers:
504
+ larr = nixl.dlists[lh]
505
+ for li in lids:
506
+ addr, length, _ = (int(x) for x in larr[int(li)])
507
+ for t in worker._test_tensors:
508
+ off = addr - t.data_ptr()
509
+ if 0 <= off < t.numel():
510
+ t[off : off + length] = 0 # simulate the RDMA write
511
+ break
512
+ done_sending, done_recving = worker.get_finished()
513
+ assert "req-b" in done_recving
514
+ n_excluded = -(-matched // local_block)
515
+ stale = []
516
+ for b in local_attn[:n_excluded]:
517
+ for region, t in enumerate(worker._test_tensors):
518
+ page = t[b * local_unified : (b + 1) * local_unified]
519
+ n_stale = int((page == 0xAA).sum())
520
+ if n_stale:
521
+ stale.append((region, b, n_stale))
522
+ assert not stale, (
523
+ f"stale (unzeroed, untransferred) bytes in matched-range attention "
524
+ f"blocks (region, block, bytes): {stale} "
525
+ f"(geometry local_block={local_block}, remote_block={remote_block}, "
526
+ f"N={num_tokens}, matched={matched})"
527
+ )
528
+
529
+
530
+@pytest.mark.cpu_test
531
+@pytest.mark.parametrize(
532
+ "local_block,remote_block",
533
+ [
534
+ (12, 8), # ppl 3 vs 2
535
+ (36, 8), # ppl 9 vs 2 (large ppl asymmetry, scaled)
536
+ (24, 4), # ppl 6 vs 1
537
+ (16, 24), # remote larger than local (D_TP > P_TP direction)
538
+ ],
539
+)
540
+@pytest.mark.parametrize("num_tokens", list(range(2, 40)))
541
+def test_hetero_ppl_token_alignment_sweep(local_block, remote_block, num_tokens):
542
+ """Sweep prompt lengths across block-boundary residues for several
543
+ hetero-ppl geometries; assert neighbor-safety, token alignment, and
544
+ coverage of every transferred kernel block."""
545
+ _run_hetero_case(
546
+ local_block, kernel=4, remote_block=remote_block, num_tokens=num_tokens
547
+ )
548
+
549
+
550
+@pytest.mark.cpu_test
551
+@pytest.mark.parametrize(
552
+ "num_tokens",
553
+ # Residues around the remote kernel block (4), the local kernel block
554
+ # (8), the remote logical block (8) and the local logical block (24).
555
+ [2, 5, 8, 9, 13, 16, 17, 21, 24, 25, 29, 32, 33, 41, 48, 49],
556
+)
557
+def test_hetero_ppl_with_block_size_ratio(num_tokens):
558
+ """Both hetero regimes at once: kernel blocks differ (local 8 / remote
559
+ 4, block_size_ratio=2) *and* physical_blocks_per_logical differs (3 vs
560
+ 2). The transfer is clipped at remote sub-block granularity by the
561
+ pairing and front-trimmed by _apply_prefix_caching, so the
562
+ untransferred tail can span both a partial block and whole blocks —
563
+ the case each of the two former zeroing paths handled only half of."""
564
+ _run_hetero_case(
565
+ local_block=24,
566
+ kernel=8,
567
+ remote_block=8,
568
+ remote_kernel=4,
569
+ num_tokens=num_tokens,
570
+ )
571
+
572
+
573
+@pytest.mark.cpu_test
574
+@pytest.mark.parametrize(
575
+ "num_tokens",
576
+ # Residues around every geometric boundary: kernel block (64), remote
577
+ # logical block (768), local logical block (5760), plus odd offsets.
578
+ [
579
+ 2,
580
+ 63,
581
+ 64,
582
+ 65,
583
+ 127,
584
+ 128,
585
+ 300,
586
+ 640,
587
+ 767,
588
+ 768,
589
+ 769,
590
+ 831,
591
+ 832,
592
+ 1000,
593
+ 1535,
594
+ 1536,
595
+ 1537,
596
+ 2303,
597
+ 2304,
598
+ 2305,
599
+ 3001,
600
+ 5759,
601
+ 5760,
602
+ 5761,
603
+ 5824,
604
+ 6528,
605
+ 6529,
606
+ ],
607
+)
608
+def test_mla_hybrid_large_ppl_geometry(num_tokens):
609
+ """KimiLinear-scale MLA-hybrid geometry (TP8 prefill -> TP1 decode):
610
+ decode (local) logical block 5760 / kernel 64 (ppl=90), prefill
611
+ (remote) logical block 768 (ppl=12), tp_ratio=-8 multi-read with
612
+ replicated MLA and 8-way TP-sharded KDA state."""
613
+ _run_hetero_case(
614
+ local_block=5760,
615
+ kernel=64,
616
+ remote_block=768,
617
+ num_tokens=num_tokens,
618
+ tp_size=8,
619
+ )
tests/v1/kv_connector/unit/test_tp_mapping.py
+54
@@ -161,3 +161,57 @@ class TestMambaPlanSplitHandles:
161
# FA: chunk=200//1=200, slot=0 (skip_fa) → (1000, 200, 0), (2000, 200, 0)
162
# SSM: chunk=400//2=200, idx=1 → (3200, 200, 0)
163
assert splits[1] == [(1000, 200, 0), (2000, 200, 0), (3200, 200, 0)]
164
+
165
+ def test_hetero_block_size_splits(self):
166
+ """With a block-size ratio, single-source FA sub-block descs pass
167
+ through whole; SSM descs are unexpanded and split per source."""
168
+ plan = TPMapping(
169
+ source_ranks_per_group=((0,), (0, 1)),
170
+ all_source_ranks=(0, 1),
171
+ rank_to_attention_slot={0: 0, 1: 0},
172
+ rank_offset_factor=0,
173
+ )
174
+
175
+ worker = _make_mock_worker_for_splits((FullAttentionSpec, MambaSpec))
176
+ # 2 FA blocks x ratio 2 sub-blocks + 1 SSM desc (never expanded).
177
+ src_blocks_data = np.array(
178
+ [
179
+ (1000, 100, 0),
180
+ (1100, 100, 0),
181
+ (2000, 100, 0),
182
+ (2100, 100, 0),
183
+ (3000, 400, 0),
184
+ ],
185
+ dtype=np.uint64,
186
+ )
187
+
188
+ splits = list(worker._build_local_splits_from_plan(plan, src_blocks_data, 4, 2))
189
+
190
+ assert len(splits) == 2
191
+ fa_passthrough = [
192
+ (1000, 100, 0),
193
+ (1100, 100, 0),
194
+ (2000, 100, 0),
195
+ (2100, 100, 0),
196
+ ]
197
+ assert splits[0] == fa_passthrough + [(3000, 200, 0)]
198
+ assert splits[1] == fa_passthrough + [(3200, 200, 0)]
199
+
200
+ def test_hetero_block_size_head_sharded_asserts(self):
201
+ """Head-sharded FA reads (multiple FA sources) are incompatible with
202
+ a block-size mismatch and must fail loudly."""
203
+ plan = TPMapping(
204
+ source_ranks_per_group=((0, 1), (0, 1)),
205
+ all_source_ranks=(0, 1),
206
+ rank_to_attention_slot={0: 0, 1: 1},
207
+ rank_offset_factor=0,
208
+ )
209
+
210
+ worker = _make_mock_worker_for_splits((FullAttentionSpec, MambaSpec))
211
+ src_blocks_data = np.array(
212
+ [(1000, 100, 0), (1100, 100, 0), (3000, 400, 0)],
213
+ dtype=np.uint64,
214
+ )
215
+
216
+ with pytest.raises(AssertionError, match="Head-sharded"):
217
+ list(worker._build_local_splits_from_plan(plan, src_blocks_data, 2, 2))
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py
+207
-64
@@ -12,6 +12,7 @@ import uuid
12
from collections import defaultdict
13
from collections.abc import Iterator
14
from concurrent.futures import Future, ThreadPoolExecutor
15
+from functools import cached_property
16
from typing import TYPE_CHECKING, Any, cast
17
18
import msgspec
@@ -67,6 +68,7 @@ from vllm.distributed.parallel_state import (
68
from vllm.logger import init_logger
69
from vllm.platforms import current_platform
70
from vllm.utils.network_utils import make_zmq_path
71
+from vllm.utils.torch_utils import async_tensor_h2d
72
from vllm.v1.attention.backends.utils import get_kv_cache_layout
73
from vllm.v1.kv_cache_interface import (
74
FullAttentionSpec,
@@ -122,9 +124,11 @@ class NixlBaseConnectorWorker:
124
return (region_ids * num_blocks + block_arr).flatten()
125
126
# Compute desc ids per group using the right stride: FA descs have
125
- # num_blocks entries per region (kernel granularity), SSM descs have
126
- # logical_blocks entries per region (no kernel splitting).
127
- logical_blocks = num_blocks // physical_blocks_per_logical
127
+ # num_blocks entries per region (kernel granularity, expanded by
128
+ # block_size_ratio for heterogeneous block sizes), SSM descs have
129
+ # logical_blocks entries per region (no kernel splitting, and never
130
+ # ratio-expanded since state blocks are indivisible).
131
+ logical_blocks = dst_num_blocks // physical_blocks_per_logical
132
all_descs: list[np.ndarray] = []
133
for i, group in enumerate(block_ids):
134
group_arr = np.asarray(group)
@@ -161,6 +165,7 @@ class NixlBaseConnectorWorker:
165
plan: TPMapping,
166
src_blocks_data: np.ndarray,
167
num_fa_descs: int,
168
+ block_size_ratio: int = 1,
169
) -> Iterator[list[tuple[int, int, int]]]:
170
"""Build split handle data for P_TP > D_TP scenario.
171
@@ -187,6 +192,11 @@ class NixlBaseConnectorWorker:
192
193
# Per-FA-descriptor replicate flag, in _build_fa_local emission order.
194
fa_desc_replicated = self._fa_desc_replicated(num_fa_descs)
195
+
196
+ assert block_size_ratio == 1 or fa_num_splits == 1 or all(fa_desc_replicated), (
197
+ "Head-sharded attention reads with P_TP > D_TP and heterogeneous "
198
+ "block sizes are not supported"
199
+ )
200
src_blocks_list = src_blocks_data.tolist()
201
202
for p_idx, p_rank in enumerate(plan.all_source_ranks):
@@ -442,9 +452,12 @@ class NixlBaseConnectorWorker:
452
453
# nixl_prepped_dlist_handle.
454
self.src_xfer_handles_by_block_size: dict[int, int] = {}
455
+ # Local descriptor arrays per remote block size (block_size_ratio>1),
456
+ # kept for building per-tp-ratio splits at the same granularity.
457
+ self.src_blocks_data_by_block_size: dict[int, np.ndarray] = {}
458
# Populated dynamically during handshake based on remote configuration.
446
- # Keep track of regions at different tp_ratio values. tp_ratio->handles
447
- self.src_xfer_handles_by_tp_ratio: dict[int, list[int]] = {}
459
+ # Per-source split handles, keyed by (tp_ratio, remote_block_size).
460
+ self.src_xfer_handles_by_tp_ratio: dict[tuple[int, int], list[int]] = {}
461
# Map of engine_id -> {tp_rank: nixl_prepped_dlist_handle (int)}.
462
self.dst_xfer_side_handles = defaultdict[EngineId, dict[int, int]](dict)
463
@@ -1258,11 +1271,7 @@ class NixlBaseConnectorWorker:
1271
agent_metadata_bytes=encoder.encode(agent_metadata),
1272
)
1273
1261
- def _build_mamba_local(
1262
- self,
1263
- base_addresses: list[int],
1264
- block_size_ratio: int,
1265
- ) -> np.ndarray:
1274
+ def _build_mamba_local(self, base_addresses: list[int]) -> np.ndarray:
1275
"""Build desc regions (conv sub-projections + ssm) per layer for
1276
local mamba blocks with DS conv layout, as an Nx3 uint64 array.
1277
@@ -1289,16 +1298,17 @@ class NixlBaseConnectorWorker:
1298
| Key N-1 | Val N-1 | |Conv N-1| SSM N-1 |
1299
+-------------------+ +--------------------+
1300
|1st_split-2nd_split| |1st_split-2nd_split |
1301
+
1302
+ Mamba state blocks are indivisible (not token-extent data), so the
1303
+ descriptors always use the local page geometry regardless of any
1304
+ attention block-size ratio; their desc ids are likewise never
1305
+ ratio-expanded (see _compute_desc_ids).
1306
"""
1293
- assert block_size_ratio == 1, (
1294
- "Mamba 3-read transfer with block_size_ratio != 1 is not tested. "
1295
- f"Got block_size_ratio={block_size_ratio}."
1296
- )
1307
assert base_addresses, "Local KV cache base addresses must not be empty."
1308
assert self._conv_decomp is not None
1309
conv_offsets = self._conv_decomp.local_conv_offsets
1310
conv_size, ssm_size = self._mamba_ssm_size
1301
- num_blocks = self._logical_num_blocks * block_size_ratio
1311
+ num_blocks = self._logical_num_blocks
1312
physical_per_logical = self._physical_blocks_per_logical_kv_block
1313
device_id = self.device_id
1314
block_arange = np.arange(num_blocks, dtype=np.uint64)
@@ -1307,9 +1317,7 @@ class NixlBaseConnectorWorker:
1317
for i, base_addr in enumerate(base_addresses):
1318
# Jump one page_size, but ssm page_size may be bigger when kernel
1319
# locks block size to a specific value (physical_per_logical scale).
1310
- page_stride = (
1311
- self.block_len_per_layer[i] // block_size_ratio * physical_per_logical
1312
- )
1320
+ page_stride = self.block_len_per_layer[i] * physical_per_logical
1321
blk_addrs = base_addr + block_arange * page_stride
1322
for off, sz in conv_offsets:
1323
parts.append(self._stack_descs(blk_addrs + off, sz, device_id))
@@ -1459,7 +1467,7 @@ class NixlBaseConnectorWorker:
1467
self.device_id,
1468
)
1469
if self._has_mamba:
1462
- assert self.num_descs == len(blocks_data)
1470
+ assert self.num_descs * block_size_ratio == len(blocks_data)
1471
# TODO (ZhanqiuHu): For homogeneous TP (tp_ratio == 1), the 3-descs split
1472
# is unnecessary — a single conv desc per block suffices. Consider
1473
# adding a fast path that falls back to the standard 2-region
@@ -1467,7 +1475,7 @@ class NixlBaseConnectorWorker:
1475
# remote has been seen. Currently we always register 4 regions
1476
# because local descs are created before knowing the remote TP.
1477
logger.debug("Registering local Mamba descriptors (4 regions/layer)")
1470
- mamba = self._build_mamba_local(local_base_addresses, block_size_ratio)
1478
+ mamba = self._build_mamba_local(local_base_addresses)
1479
blocks_data = np.concatenate([blocks_data, mamba])
1480
1481
descs = self.nixl_wrapper.get_xfer_descs(blocks_data, self.nixl_memory_type)
@@ -1607,29 +1615,44 @@ class NixlBaseConnectorWorker:
1615
1616
plan = self.tp_mappings[engine_id]
1617
1618
+ ### (Optional) Register a local handler at the remote engine's block
1619
+ ### granularity (remote/prefill blocks smaller than local).
1620
+ remote_block_size = nixl_agent_meta.block_size
1621
+ src_blocks_data = self.src_blocks_data
1622
+ if block_size_ratio > 1:
1623
+ if remote_block_size not in self.src_xfer_handles_by_block_size:
1624
+ handle, blocks_data = self.register_local_xfer_handler(
1625
+ remote_block_size
1626
+ )
1627
+ self.src_xfer_handles_by_block_size[remote_block_size] = handle
1628
+ self.src_blocks_data_by_block_size[remote_block_size] = blocks_data
1629
+ src_blocks_data = self.src_blocks_data_by_block_size[remote_block_size]
1630
+
1631
### (Optional) Register local agent memory regions. MLA is not split.
1632
+ split_key = (tp_ratio, remote_block_size)
1633
if (
1634
tp_ratio < 0
1635
and (not self.use_mla or len(plan.all_source_ranks) > 1)
1614
- and tp_ratio not in self.src_xfer_handles_by_tp_ratio
1636
+ and split_key not in self.src_xfer_handles_by_tp_ratio
1637
):
1638
# Remote tp_size > local tp_size: read from multiple remote ranks.
1639
# Logically "split" own regions into per-source chunks. Hybrid
1640
# MLA+SSM also needs this path: MLA is replicated and read once,
1641
# while the SSM state is sharded across every remote TP rank.
1620
- # We only do this once per remote tp_size (replica-friendly).
1621
- self.src_xfer_handles_by_tp_ratio[tp_ratio] = []
1642
+ # We only do this once per remote (tp_size, block_size).
1643
+ self.src_xfer_handles_by_tp_ratio[split_key] = []
1644
1645
for handle_data in self._build_local_splits_from_plan(
1646
plan,
1625
- self.src_blocks_data,
1626
- self.num_descs,
1647
+ src_blocks_data,
1648
+ self.num_descs * block_size_ratio,
1649
+ block_size_ratio,
1650
):
1651
descs = self.nixl_wrapper.get_xfer_descs(
1652
handle_data, self.nixl_memory_type
1653
)
1654
handle = self.nixl_wrapper.prep_xfer_dlist("NIXL_INIT_AGENT", descs)
1632
- self.src_xfer_handles_by_tp_ratio[tp_ratio].append(handle)
1655
+ self.src_xfer_handles_by_tp_ratio[split_key].append(handle)
1656
1657
### Register remote agent memory regions
1658
# With homogeneous TP, D pulls the whole kv cache from corresponding rank. With
@@ -1665,13 +1688,6 @@ class NixlBaseConnectorWorker:
1688
self.nixl_wrapper.prep_xfer_dlist(remote_agent_name, descs)
1689
)
1690
1668
- if block_size_ratio > 1:
1669
- # when prefill with smaller block_size, we need to init a
1670
- # new handler with same block_len to match
1671
- self.src_xfer_handles_by_block_size[nixl_agent_meta.block_size] = (
1672
- self.register_local_xfer_handler(nixl_agent_meta.block_size)[0]
1673
- )
1674
-
1691
return remote_agent_name
1692
1693
def _validate_remote_agent_handshake(
@@ -1716,9 +1732,13 @@ class NixlBaseConnectorWorker:
1732
"Disable prefix caching with --no-enable-prefix-caching."
1733
)
1734
1719
- if self._is_hma_required:
1720
- assert block_size_ratio == 1, (
1721
- "HMA does not support different remote block size yet"
1735
+ if block_size_ratio != 1:
1736
+ # Heterogeneous block sizes transfer at remote-block granularity;
1737
+ # the untransferred tail of the last local attention block is
1738
+ # zeroed in the receive post-process, and mamba state pages
1739
+ # transfer 1:1 (never sub-split).
1740
+ assert not self.use_host_buffer, (
1741
+ "Heterogeneous block sizes are not supported with host buffer"
1742
)
1743
kv_cache_layout = (
1744
self.kv_cache_layout
@@ -1875,27 +1895,57 @@ class NixlBaseConnectorWorker:
1895
"d2h",
1896
)
1897
1898
+ @cached_property
1899
+ def _attention_kv_caches(self) -> list[torch.Tensor]:
1900
+ """Device KV caches of attention layers (mamba states excluded),
1901
+ as consumed by the receive post-process."""
1902
+ assert self.device_kv_caches, (
1903
+ "_attention_kv_caches accessed before register_kv_caches"
1904
+ )
1905
+ mamba_layers = {
1906
+ name
1907
+ for g, group in enumerate(self.kv_cache_config.kv_cache_groups)
1908
+ if _is_ssm_spec(self._group_spec_types[g])
1909
+ for name in group.layer_names
1910
+ }
1911
+ kv_caches = self.device_kv_caches
1912
+ return [cache for name, cache in kv_caches.items() if name not in mamba_layers]
1913
+
1914
def post_process_device_kv_on_receive(
1915
self,
1916
block_size_ratio: int,
1881
- block_ids_list: list[list[int]],
1917
+ block_ids_list: list[tuple[list[int], int]],
1918
+ convert: bool = True,
1919
):
1920
"""
1921
Post process device kv cache after receiving from remote.
1922
1886
- 3 types of post processing supported:
1923
+ 3 types of conversion supported (``convert``):
1924
* kv_cache_postprocess_layout => convert from HND to NHD
1925
* kv_cache_postprocess_blksize => convert from small block size
1926
to large block size
1927
* kv_cache_postprocess_blksize_and_layout => convert from small
1928
block size to large block size and convert from HND to NHD
1929
1930
+ The transfer only covers ``covered_sub_blocks`` remote-sized
1931
+ sub-blocks of each request's local attention blocks; the rest was
1932
+ clipped, either by remote-block pairing (block-size ratio) or by the
1933
+ hetero-ppl front trim in ``_apply_prefix_caching``. Those blocks were
1934
+ excluded from the scheduler's alloc-time KV zeroing (which would race
1935
+ the RDMA write), so everything past the covered range is zeroed here.
1936
+ Stale bytes would otherwise surface as garbage or NaNs once decode
1937
+ grows into the untransferred tail.
1938
"""
1939
if len(self.device_kv_caches) == 0:
1940
return
1941
assert block_size_ratio >= 1, "Only nP < nD supported currently."
1942
assert self.transfer_topo is not None
1898
- if self.enable_permute_local_kv and block_size_ratio > 1:
1943
+ if not convert:
1944
+ logger.debug(
1945
+ "Post-processing device kv cache on receive by zeroing "
1946
+ "untransferred blocks."
1947
+ )
1948
+ elif self.enable_permute_local_kv and block_size_ratio > 1:
1949
logger.debug(
1950
"Post-processing device kv cache on receive by converting "
1951
"block_size with %sx bigger and permuting layout from HND"
@@ -1914,18 +1964,45 @@ class NixlBaseConnectorWorker:
1964
block_size_ratio,
1965
)
1966
1917
- for block_ids in block_ids_list:
1918
- indices = torch.tensor(block_ids, device=self.device_type, dtype=torch.long)
1967
+ attn_caches = self._attention_kv_caches
1968
+ device = attn_caches[0].device
1969
+ for block_ids, covered_sub_blocks in block_ids_list:
1970
+ # Blocks the transfer didn't write: the token tail of the last
1971
+ # partially covered block, then everything beyond it.
1972
+ covered_blocks, sub_blocks_in_last = divmod(
1973
+ covered_sub_blocks, block_size_ratio
1974
+ )
1975
+ first_stale = covered_blocks + (1 if sub_blocks_in_last else 0)
1976
+ has_stale = first_stale < len(block_ids)
1977
+ indices = None
1978
+ if convert or has_stale:
1979
+ indices = async_tensor_h2d(block_ids, device, torch.long)
1980
+
1981
+ if convert:
1982
+ for cache in attn_caches:
1983
+ if self.enable_permute_local_kv and block_size_ratio > 1:
1984
+ kv_postprocess_blksize_and_layout_on_receive(
1985
+ cache, indices, block_size_ratio
1986
+ )
1987
+ elif self.enable_permute_local_kv:
1988
+ kv_postprocess_layout_on_receive(cache, indices)
1989
+ else:
1990
+ kv_postprocess_blksize_on_receive(
1991
+ cache, indices, block_size_ratio
1992
+ )
1993
1920
- for cache in self.device_kv_caches.values():
1921
- if self.enable_permute_local_kv and block_size_ratio > 1:
1922
- kv_postprocess_blksize_and_layout_on_receive(
1923
- cache, indices, block_size_ratio
1924
- )
1925
- elif self.enable_permute_local_kv:
1926
- kv_postprocess_layout_on_receive(cache, indices)
1927
- else:
1928
- kv_postprocess_blksize_on_receive(cache, indices, block_size_ratio)
1994
+ if sub_blocks_in_last:
1995
+ last_block_id = block_ids[covered_blocks]
1996
+ for cache in attn_caches:
1997
+ # Both post-processed layouts leave tokens on dim 1.
1998
+ sub_block_tokens = cache.shape[1] // block_size_ratio
1999
+ zero_from = sub_blocks_in_last * sub_block_tokens
2000
+ cache[last_block_id, zero_from:].zero_()
2001
+ if has_stale:
2002
+ assert indices is not None
2003
+ stale_ids = indices[first_stale:]
2004
+ for cache in attn_caches:
2005
+ cache.index_fill_(0, stale_ids, 0)
2006
2007
def post_process_device_kv_on_receive_heterogeneous_attn(
2008
self, block_ids: list[int]
@@ -1995,18 +2072,33 @@ class NixlBaseConnectorWorker:
2072
if self.use_host_buffer:
2073
self.sync_recved_kv_to_device(req_id, meta)
2074
1998
- # post processing for heteroblocksize
2075
+ # Post processing for heteroblocksize/layout, and for blocks the
2076
+ # transfer clipped. The latter happens either at remote-block
2077
+ # granularity (block_size_ratio > 1) or at kernel-block
2078
+ # granularity, when equal kernel pages meet differing logical
2079
+ # block sizes and _apply_prefix_caching front-trims to the
2080
+ # minimum count (hybrid heterogeneous TP).
2081
remote_info = self.transfer_topo.get_engine_info(meta.remote.engine_id)
2082
block_size_ratio = self.transfer_topo.block_size_ratio(
2083
remote_info.remote_block_size
2084
)
2003
- if not self.use_mla and (
2004
- block_size_ratio > 1 or self.enable_permute_local_kv
2005
- ):
2006
- assert not self._is_hma_required
2007
- block_ids_for_blocksize_post_process[block_size_ratio].append(
2008
- meta.local_physical_block_ids[0]
2009
- )
2085
+ hetero_ppl = (
2086
+ remote_info.remote_physical_blocks_per_logical
2087
+ != self._physical_blocks_per_logical_kv_block
2088
+ )
2089
+ if block_size_ratio > 1 or self.enable_permute_local_kv or hetero_ppl:
2090
+ for g, local_group in enumerate(meta.local_physical_block_ids):
2091
+ if not local_group or _is_ssm_spec(self._group_spec_types[g]):
2092
+ continue
2093
+ # Number of remote-sized sub-blocks the transfer covered;
2094
+ # everything past this was clipped and must be zeroed.
2095
+ covered_sub_blocks = min(
2096
+ len(local_group) * block_size_ratio,
2097
+ len(meta.remote.block_ids[g]),
2098
+ )
2099
+ block_ids_for_blocksize_post_process[block_size_ratio].append(
2100
+ (local_group, covered_sub_blocks)
2101
+ )
2102
# post processing for heterogeneous attention
2103
if self.enable_heterogeneous_attn_post_process:
2104
block_ids_for_heterogeneous_attn_post_process.append(
@@ -2016,7 +2108,14 @@ class NixlBaseConnectorWorker:
2108
block_size_ratio,
2109
block_ids_list,
2110
) in block_ids_for_blocksize_post_process.items():
2019
- self.post_process_device_kv_on_receive(block_size_ratio, block_ids_list)
2111
+ # MLA never needs the block-size/layout conversion, but its
2112
+ # clipped blocks still need zeroing.
2113
+ convert = not self.use_mla and (
2114
+ block_size_ratio > 1 or self.enable_permute_local_kv
2115
+ )
2116
+ self.post_process_device_kv_on_receive(
2117
+ block_size_ratio, block_ids_list, convert
2118
+ )
2119
2120
for block_ids in block_ids_for_heterogeneous_attn_post_process:
2121
self.post_process_device_kv_on_receive_heterogeneous_attn(block_ids)
@@ -2206,6 +2305,45 @@ class NixlBaseConnectorWorker:
2305
2306
return mapped_2d.flatten().astype(np.int64)
2307
2308
+ def _map_block_ids_for_block_size_ratio(
2309
+ self,
2310
+ local_block_ids: BlockIds,
2311
+ remote_block_ids: BlockIds,
2312
+ block_size_ratio: int,
2313
+ ) -> tuple[BlockIds, BlockIds]:
2314
+ """Map attention-group block ids to remote-block granularity.
2315
+
2316
+ Each local attention block is split into ``block_size_ratio``
2317
+ sub-blocks paired 1:1 with remote blocks. Sub-blocks beyond the
2318
+ remote list — the untransferred tail of the last local block — are
2319
+ clipped here and zeroed in the receive post-process. Mamba state
2320
+ blocks are indivisible and transfer 1:1, unexpanded.
2321
+
2322
+ ex: remote (prefill) block ids with block_size 4:
2323
+ [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
2324
+ Local (decode) block ids with block_size 16: [1, 2, 3] expand to
2325
+ [4, 5, ..., 15], then clip to the first 10 to pair 1:1 with remote.
2326
+ """
2327
+ mapped_local: list[list[int]] = []
2328
+ mapped_remote: list[list[int]] = []
2329
+ for i, remote_group in enumerate(remote_block_ids):
2330
+ local_group = local_block_ids[i] if local_block_ids else []
2331
+ if _is_ssm_spec(self._group_spec_types[i]):
2332
+ mapped_local.append(list(local_group))
2333
+ mapped_remote.append(list(remote_group))
2334
+ continue
2335
+ mapped = self.get_mapped_blocks(
2336
+ np.asarray(local_group), block_size_ratio
2337
+ ).tolist()
2338
+ if len(mapped) > len(remote_group):
2339
+ mapped = mapped[: len(remote_group)]
2340
+ mapped_local.append(mapped)
2341
+ mapped_remote.append(list(remote_group))
2342
+ if not any(mapped_local):
2343
+ # Full prefix cache hit is indicated with an empty list.
2344
+ return [], mapped_remote
2345
+ return mapped_local, mapped_remote
2346
+
2347
def _logical_to_kernel_block_ids(self, block_ids: BlockIds, ratio: int) -> BlockIds:
2348
"""
2349
Convert block ids to kernel physical block ids.
@@ -2300,13 +2438,18 @@ class NixlBaseConnectorWorker:
2438
remote_block_ids[i] = remote_group[-num_local_blocks:]
2439
else:
2440
# TODO Handle prefix caching with different block_sizes
2303
- max_padding = max(
2304
- self._physical_blocks_per_logical_kv_block,
2305
- remote_physical_per_logical,
2441
+ # Allocation rounding legitimately leaves up to
2442
+ # ppl - 1 trailing dead kernel blocks per side (plus one
2443
+ # extra local block for the recomputed final token), so
2444
+ # the counts may differ by up to the sum of the two
2445
+ # ratios; anything larger indicates mismatched lists.
2446
+ max_padding = (
2447
+ self._physical_blocks_per_logical_kv_block
2448
+ + remote_physical_per_logical
2449
)
2307
- assert abs(num_local_blocks - num_remote_blocks) < max_padding, (
2450
+ assert abs(num_local_blocks - num_remote_blocks) <= max_padding, (
2451
f"Group {i}: |{num_local_blocks} - "
2309
- f"{num_remote_blocks}| >= {max_padding}"
2452
+ f"{num_remote_blocks}| > {max_padding}"
2453
)
2454
num_blocks = min(num_local_blocks, num_remote_blocks)
2455
local_block_ids[i] = local_block_ids[i][:num_blocks]
vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py
+7
-28
@@ -5,8 +5,6 @@
5
import time
6
from typing import TYPE_CHECKING
7
8
-import numpy as np
9
-
8
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker import (
9
NixlBaseConnectorWorker,
10
)
@@ -178,10 +176,10 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
176
)
177
# Get side handles.
178
if tp_ratio < 0 and (not self.use_mla or len(read_specs) > 1):
181
- assert remote_block_size == self.block_size
179
# Remote tp_size > local tp_size: we must perform multiple
180
# reads. Get the memory chunk onto which we will write to.
184
- local_xfer_side_handle = self.src_xfer_handles_by_tp_ratio[tp_ratio][i]
181
+ split_key = (tp_ratio, remote_block_size)
182
+ local_xfer_side_handle = self.src_xfer_handles_by_tp_ratio[split_key][i]
183
else:
184
# Single read from remote, we write to the whole memory region.
185
# Also handle remote block size different from local block size.
@@ -235,30 +233,11 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
233
remote_info.remote_block_size
234
)
235
if block_size_ratio > 1:
238
- # TODO (NickLucche) assume HMA is off. Change to handle multiple KV groups.
239
- assert not self._is_hma_required
240
- local_block_ids0 = local_block_ids[0] if local_block_ids else []
241
- remote_block_ids0 = remote_block_ids[0]
242
- local_block_ids_mapped = self.get_mapped_blocks(
243
- np.asarray(local_block_ids0), block_size_ratio
244
- ).tolist()
245
- if len(local_block_ids_mapped) > len(remote_block_ids0):
246
- # NOTE:
247
- # get_mapped_blocks will always expand block_ids for n times.
248
- # ex:
249
- # prefill block_ids with block_size as 4:
250
- # [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
251
- # Local decode block_ids with block_size as 16: [1, 2, 3]
252
- # expanded decode block_ids with get_mapped_blocks from [1, 2, 3] to
253
- # [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
254
- # Then we clip local to align with prefill
255
- # [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] to
256
- # [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
257
- local_block_ids_mapped = local_block_ids_mapped[
258
- : len(remote_block_ids0)
259
- ]
260
- local_block_ids = [local_block_ids_mapped] if local_block_ids_mapped else []
261
- remote_block_ids = [remote_block_ids0]
236
+ local_block_ids, remote_block_ids = (
237
+ self._map_block_ids_for_block_size_ratio(
238
+ local_block_ids, remote_block_ids, block_size_ratio
239
+ )
240
+ )
241
# NOTE(rob): having the staging blocks be on the READER side is
242
# not going to work well (since we will have to call rearrange tensors).
243
# after we detect the txn is complete (which means we cannot make the
vllm/distributed/kv_transfer/kv_connector/v1/nixl/push_worker.py
+7
-15
@@ -39,7 +39,6 @@ from concurrent.futures import Future
39
from typing import TYPE_CHECKING, Any
40
41
import msgspec
42
-import numpy as np
42
43
from vllm.distributed.kv_transfer.kv_connector.utils import BlockIds
44
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker import (
@@ -553,8 +552,8 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
552
req_id,
553
)
554
if tp_ratio < 0 and not self.use_mla:
556
- assert remote_block_size == self.block_size
557
- local_xfer_side_handle = self.src_xfer_handles_by_tp_ratio[tp_ratio][i]
555
+ split_key = (tp_ratio, remote_block_size)
556
+ local_xfer_side_handle = self.src_xfer_handles_by_tp_ratio[split_key][i]
557
else:
558
local_xfer_side_handle = self.src_xfer_handles_by_block_size[
559
remote_block_size
@@ -606,18 +605,11 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
605
remote_info.remote_block_size
606
)
607
if block_size_ratio > 1:
609
- assert not self._is_hma_required
610
- local_block_ids0 = local_block_ids[0] if local_block_ids else []
611
- remote_block_ids0 = remote_block_ids[0]
612
- local_block_ids_mapped = self.get_mapped_blocks(
613
- np.asarray(local_block_ids0), block_size_ratio
614
- ).tolist()
615
- if len(local_block_ids_mapped) > len(remote_block_ids0):
616
- local_block_ids_mapped = local_block_ids_mapped[
617
- : len(remote_block_ids0)
618
- ]
619
- local_block_ids = [local_block_ids_mapped] if local_block_ids_mapped else []
620
- remote_block_ids = [remote_block_ids0]
608
+ local_block_ids, remote_block_ids = (
609
+ self._map_block_ids_for_block_size_ratio(
610
+ local_block_ids, remote_block_ids, block_size_ratio
611
+ )
612
+ )
613
614
notif_id = f"{remote_request_id}:{self.world_size}".encode()
615