[Nixl][PD] DCP support for MLA models (#50611)
Signed-off-by: NickLucche <nicolo.lucchesi@mistral.ai>
Nicolò Lucchesi committed
Aug 29, 2026 at 18:03 UTC
7f4793eaa335a3667927a0191868d01f36b170af
23 files changed
+818
-104
tests/test_config.py
+30
@@ -17,8 +17,11 @@ import vllm.config.vllm as vllm_config_module
17
import vllm.envs as envs
18
from vllm.compilation.backends import VllmBackend
19
from vllm.config import (
20
+ CacheConfig,
21
CompilationConfig,
22
+ DeviceConfig,
23
KernelConfig,
24
+ KVTransferConfig,
25
ModelConfig,
26
ObservabilityConfig,
27
ParallelConfig,
@@ -100,6 +103,32 @@ def test_per_request_spec_decode_metrics_requires_spec_decode():
103
)
104
105
106
+def test_pd_dcp_interleave_size_is_adjusted_to_block_size(caplog):
107
+ config = VllmConfig(
108
+ cache_config=CacheConfig(block_size=16),
109
+ device_config=DeviceConfig(device="cpu"),
110
+ parallel_config=ParallelConfig(
111
+ tensor_parallel_size=2,
112
+ decode_context_parallel_size=2,
113
+ cp_kv_cache_interleave_size=3,
114
+ distributed_executor_backend="mp",
115
+ ),
116
+ kv_transfer_config=KVTransferConfig(
117
+ kv_connector="NixlConnector",
118
+ kv_role="kv_both",
119
+ ),
120
+ )
121
+
122
+ kv_cache_config = SimpleNamespace(
123
+ kv_cache_groups=[SimpleNamespace(kv_cache_spec=SimpleNamespace(block_size=16))]
124
+ )
125
+ with caplog.at_level(logging.INFO):
126
+ config.adjust_dcp_kv_cache_interleave_size(kv_cache_config)
127
+
128
+ assert config.parallel_config.cp_kv_cache_interleave_size == 16
129
+ assert "automatically adjusted from 3 to block_size 16" in caplog.text
130
+
131
+
132
def test_compile_config_repr_succeeds():
133
# setup: VllmBackend mutates the config object
134
config = VllmConfig()
@@ -1730,6 +1759,7 @@ def test_validate_mamba_align_subblock_prefill():
1759
long_prefill_token_threshold=4096,
1760
disable_chunked_mm_input=False,
1761
),
1762
+ kv_transfer_config=None,
1763
)
1764
1765
VllmConfig.validate_block_size(config)
tests/v1/kv_connector/unit/test_nixl_connector.py
+1
@@ -507,6 +507,7 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
507
port: int,
508
remote_tp_size: int,
509
expected_engine_id: str,
510
+ remote_dcp_size: int = 1,
511
remote_pp_size: int = 1,
512
notif_agents_only: bool = False,
513
) -> tuple[dict[tuple[int, int], str], float]:
tests/v1/kv_connector/unit/test_nixl_connector_hma.py
+181
-1
@@ -3,7 +3,7 @@
3
"""Unit tests for NixlConnectorScheduler with HMA and Mamba N-1 prefill."""
4
5
import gc
6
-from unittest.mock import patch
6
+from unittest.mock import MagicMock, patch
7
8
import pytest
9
import torch
@@ -63,6 +63,180 @@ def test_sw_sizes(mock_platform, swa_enabled, expected_sw_sizes):
63
)
64
65
66
+@pytest.mark.cpu_test
67
+@pytest.mark.parametrize(
68
+ "use_mla,source_ranks,tp_ratio,expected",
69
+ [
70
+ pytest.param(True, (0,), -2, False, id="pure_mla_with_remote_dcp"),
71
+ pytest.param(True, (0, 1), -2, True, id="hybrid_mla_ssm"),
72
+ pytest.param(False, (0,), -2, True, id="full_attention"),
73
+ pytest.param(False, (0,), 2, False, id="local_tp_greater"),
74
+ ],
75
+)
76
+def test_needs_split_local_xfer_handles(use_mla, source_ranks, tp_ratio, expected):
77
+ """Handle creation and selection must use the same TP-mapping predicate.
78
+
79
+ In pure MLA, DCP can target several physical remote workers even though
80
+ the TP mapping has one replicated attention source. Those workers own
81
+ disjoint blocks, so every read uses the whole local-region handle.
82
+ """
83
+ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
84
+ NixlConnectorWorker,
85
+ )
86
+
87
+ worker = object.__new__(NixlConnectorWorker)
88
+ worker.use_mla = use_mla
89
+ plan = MagicMock()
90
+ plan.all_source_ranks = source_ranks
91
+
92
+ assert worker._needs_split_local_xfer_handles(tp_ratio, plan) is expected
93
+
94
+
95
+@pytest.mark.cpu_test
96
+def test_update_state_after_alloc_tracks_cached_blocks_per_group():
97
+ """Hybrid SWA+FA requests can have different prefix-cache-hit counts per
98
+ KV cache group. Each group's count must be tracked independently rather
99
+ than collapsed to a single scalar, or DCP read-phase alignment
100
+ (local_num_computed_blocks) would misalign one of the groups."""
101
+ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.pull_scheduler import (
102
+ NixlPullConnectorScheduler,
103
+ )
104
+ from vllm.v1.core.kv_cache_manager import KVCacheBlocks
105
+ from vllm.v1.core.kv_cache_utils import KVCacheBlock
106
+
107
+ scheduler = object.__new__(NixlPullConnectorScheduler)
108
+ scheduler._reqs_in_batch = set()
109
+ scheduler._reqs_need_save = {}
110
+ scheduler._reqs_need_recv = {}
111
+ scheduler.use_host_buffer = False
112
+ scheduler.is_bidirectional_kv_xfer_enabled = False
113
+ scheduler._is_hma_required = False
114
+ scheduler.kv_cache_config = MagicMock(
115
+ select_transfer_block_ids=lambda block_ids: block_ids
116
+ )
117
+
118
+ def cached(block_id):
119
+ return KVCacheBlock(block_id=block_id, _block_hash=object())
120
+
121
+ def uncached(block_id):
122
+ return KVCacheBlock(block_id=block_id)
123
+
124
+ # Group 0 (full-attention): 2 of 3 blocks cached.
125
+ # Group 1 (sliding-window): 1 of 3 blocks cached.
126
+ blocks = KVCacheBlocks(
127
+ blocks=(
128
+ [cached(0), cached(1), uncached(2)],
129
+ [cached(10), uncached(11), uncached(12)],
130
+ )
131
+ )
132
+
133
+ request = create_request(do_remote_prefill=True)
134
+
135
+ scheduler.update_state_after_alloc(request, blocks, num_external_tokens=2)
136
+
137
+ _, _, local_num_computed_blocks = scheduler._reqs_need_recv[request.request_id]
138
+ assert local_num_computed_blocks == (2, 1)
139
+
140
+
141
+@pytest.mark.cpu_test
142
+@pytest.mark.parametrize(
143
+ "local_ids,remote_ids,remote_rank,local_dcp_size,local_dcp_rank,"
144
+ "remote_dcp_size,local_num_computed_blocks,expected_local,expected_remote",
145
+ [
146
+ # local more finely sharded than remote: remote's raw slice has an
147
+ # extra tail block beyond what local's phase-aligned slice covers.
148
+ pytest.param(
149
+ [100, 101, 102],
150
+ [200, 201],
151
+ 1,
152
+ 1,
153
+ 0,
154
+ 2,
155
+ 0,
156
+ [101],
157
+ [200],
158
+ id="remote_tail_padding",
159
+ ),
160
+ # remote more finely sharded than local: local's raw slice has a
161
+ # block beyond what remote's phase-aligned slice covers.
162
+ pytest.param(
163
+ [100],
164
+ [200],
165
+ 0,
166
+ 2,
167
+ 1,
168
+ 1,
169
+ 0,
170
+ [],
171
+ [],
172
+ id="local_tail_padding",
173
+ ),
174
+ # Asymmetric DCP with a partial prefix cache hit: the cached count
175
+ # shifts both the local phase and the remote start position.
176
+ pytest.param(
177
+ [100],
178
+ [200, 201],
179
+ 2,
180
+ 2,
181
+ 0,
182
+ 4,
183
+ 3,
184
+ [100],
185
+ [201],
186
+ id="prefix_phase",
187
+ ),
188
+ # Equal (but active) DCP sizes still need to skip the locally cached
189
+ # prefix from the front of remote's list.
190
+ pytest.param(
191
+ [100, 101],
192
+ [200, 201, 202, 203],
193
+ 1,
194
+ 2,
195
+ 1,
196
+ 2,
197
+ 2,
198
+ [100, 101],
199
+ [202, 203],
200
+ id="equal_dcp_with_prefix",
201
+ ),
202
+ ],
203
+)
204
+def test_apply_dcp_prefix_caching_matches_global_logical_positions(
205
+ local_ids,
206
+ remote_ids,
207
+ remote_rank,
208
+ local_dcp_size,
209
+ local_dcp_rank,
210
+ remote_dcp_size,
211
+ local_num_computed_blocks,
212
+ expected_local,
213
+ expected_remote,
214
+):
215
+ """_apply_dcp_prefix_caching must operate on logical block IDs (DCP
216
+ interleaves at logical-block granularity) and always return equal-length
217
+ local/remote slices, so a later, unconditional _apply_prefix_caching
218
+ trim is a guaranteed no-op rather than double-processing the read."""
219
+ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
220
+ NixlConnectorWorker,
221
+ )
222
+
223
+ worker = object.__new__(NixlConnectorWorker)
224
+
225
+ actual_local, actual_remote = worker._apply_dcp_prefix_caching(
226
+ local_ids,
227
+ remote_ids,
228
+ remote_rank=remote_rank,
229
+ local_dcp_size=local_dcp_size,
230
+ local_dcp_rank=local_dcp_rank,
231
+ remote_dcp_size=remote_dcp_size,
232
+ local_num_computed_blocks=local_num_computed_blocks,
233
+ )
234
+
235
+ assert actual_local == expected_local
236
+ assert actual_remote == expected_remote
237
+ assert len(actual_local) == len(actual_remote)
238
+
239
+
240
@pytest.mark.cpu_test
241
def test_logical_to_kernel_block_ids_with_hma():
242
"""Test _logical_to_kernel_block_ids expands blocks when HMA is enabled.
@@ -211,7 +385,12 @@ def test_read_blocks_for_req_expands_remote_ids(
385
worker = object.__new__(NixlConnectorWorker)
386
worker._physical_blocks_per_logical_kv_block = local_physical_per_logical
387
worker._engine_last_active = {}
388
+ worker._recving_transfers = {}
389
worker._bidirectional_kv_xfer_enabled = False
390
+ worker.dcp_size = 1
391
+ worker.dcp_rank = 0
392
+ worker._group_spec_types = resolved_types
393
+ worker._has_mamba = any(t is MambaSpec for t in resolved_types)
394
395
has_mamba = any(t is MambaSpec for t in resolved_types)
396
has_swa = any(t is SlidingWindowSpec for t in resolved_types)
@@ -227,6 +406,7 @@ def test_read_blocks_for_req_expands_remote_ids(
406
worker.transfer_topo.tp_ratio.return_value = tp_ratio
407
remote_info = MagicMock()
408
remote_info.remote_physical_blocks_per_logical = remote_physical_per_logical
409
+ remote_info.remote_dcp_size = 1
410
worker.transfer_topo.get_engine_info.return_value = remote_info
411
worker.use_mla = False
412
tests/v1/kv_connector/unit/test_tp_mapping.py
+73
-6
@@ -14,6 +14,7 @@ from types import SimpleNamespace
14
import numpy as np
15
import pytest
16
17
+from vllm.distributed.kv_transfer.kv_connector.utils import TransferTopology
18
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.tp_mapping import (
19
TPMapping,
20
compute_tp_mapping,
@@ -35,17 +36,20 @@ def _compute_mapping(
36
is_mla: bool = False,
37
num_kv_heads: int = 8,
38
group_spec_types: tuple[type, ...] = (FullAttentionSpec,),
39
+ dcp_size: int = 1,
40
+ remote_dcp_size: int = 1,
41
) -> TPMapping:
39
- transfer_topology = SimpleNamespace(
40
- tp_rank=tp_rank,
41
- tp_size=tp_size,
42
- is_mla=is_mla,
43
- total_num_kv_heads=num_kv_heads,
44
- )
42
+ transfer_topology = object.__new__(TransferTopology)
43
+ transfer_topology.tp_rank = tp_rank
44
+ transfer_topology.tp_size = tp_size
45
+ transfer_topology.is_mla = is_mla
46
+ transfer_topology.total_num_kv_heads = num_kv_heads
47
+ transfer_topology.dcp_size = dcp_size
48
return compute_tp_mapping(
49
transfer_topology=transfer_topology,
50
remote_tp_size=remote_tp_size,
51
group_spec_types=group_spec_types,
52
+ remote_dcp_size=remote_dcp_size,
53
)
54
55
@@ -68,6 +72,69 @@ class TestTPMappingStructure:
72
assert m.all_source_ranks == (0, 1)
73
74
75
+@pytest.mark.parametrize(
76
+ "tp_rank,tp_size,remote_tp_size,dcp_size,remote_dcp_size,expected_ranks",
77
+ [
78
+ (0, 4, 4, 1, 4, (0, 1, 2, 3)),
79
+ (2, 4, 4, 4, 4, (2,)),
80
+ (0, 2, 4, 2, 4, (0, 2)),
81
+ (3, 4, 2, 4, 2, (1,)),
82
+ ],
83
+)
84
+def test_mla_dcp_source_ranks(
85
+ tp_rank,
86
+ tp_size,
87
+ remote_tp_size,
88
+ dcp_size,
89
+ remote_dcp_size,
90
+ expected_ranks,
91
+):
92
+ mapping = _compute_mapping(
93
+ tp_rank=tp_rank,
94
+ tp_size=tp_size,
95
+ remote_tp_size=remote_tp_size,
96
+ is_mla=True,
97
+ num_kv_heads=1,
98
+ dcp_size=dcp_size,
99
+ remote_dcp_size=remote_dcp_size,
100
+ )
101
+
102
+ assert mapping.all_source_ranks == expected_ranks
103
+
104
+
105
+@pytest.mark.parametrize(
106
+ "tp_size,remote_tp_size,dcp_size,remote_dcp_size",
107
+ [
108
+ (4, 2, 4, 2),
109
+ (4, 4, 4, 4),
110
+ (4, 1, 4, 1),
111
+ (4, 4, 1, 4),
112
+ (2, 4, 2, 4),
113
+ ],
114
+)
115
+def test_dcp_consumer_count_matches_readers(
116
+ tp_size, remote_tp_size, dcp_size, remote_dcp_size
117
+):
118
+ """Each producer waits for exactly the local ranks that read from it."""
119
+ mappings = [
120
+ _compute_mapping(
121
+ tp_rank=tp_rank,
122
+ tp_size=tp_size,
123
+ remote_tp_size=remote_tp_size,
124
+ is_mla=True,
125
+ num_kv_heads=1,
126
+ dcp_size=dcp_size,
127
+ remote_dcp_size=remote_dcp_size,
128
+ )
129
+ for tp_rank in range(tp_size)
130
+ ]
131
+
132
+ for remote_rank in range(remote_tp_size):
133
+ readers = [m for m in mappings if remote_rank in m.all_source_ranks]
134
+ for mapping in readers:
135
+ assert mapping.local_consumers == len(readers)
136
+
137
+
138
# ======================================================================
139
# Split handle tests
140
# ======================================================================
tests/v1/worker/test_gpu_model_runner.py
+71
@@ -2,6 +2,7 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
import gc
5
+from contextlib import nullcontext
6
from types import SimpleNamespace
7
from unittest.mock import Mock
8
@@ -1380,6 +1381,76 @@ def test_hybrid_attention_mamba_tensor_shapes():
1381
assert torch.equal(actual_ssm, expected_ssm)
1382
1383
1384
+def test_input_batch_reinitialized_after_late_interleave_adjustment(monkeypatch):
1385
+ runner = object.__new__(GPUModelRunner)
1386
+ runner.vllm_config = SimpleNamespace(reasoning_config=None)
1387
+ runner.parallel_config = SimpleNamespace(cp_kv_cache_interleave_size=16)
1388
+ runner.cache_config = SimpleNamespace(use_replayssm=False)
1389
+ runner.model_config = SimpleNamespace(get_vocab_size=lambda: 32)
1390
+ runner.max_model_len = 64
1391
+ runner.max_encoder_len = 0
1392
+ runner.max_num_reqs = 1
1393
+ runner.max_num_tokens = 64
1394
+ runner.num_spec_tokens = 0
1395
+ runner.device = torch.device("cpu")
1396
+ runner.is_pooling_model = False
1397
+ runner._init_block_sizes = [16]
1398
+ runner._init_kernel_block_sizes = [16]
1399
+ runner._init_max_num_blocks = [4]
1400
+ runner._init_slot_mapping_modes = [
1401
+ gpu_model_runner_module.SlotMappingMode.TOKEN_TO_KV_SLOT
1402
+ ]
1403
+ runner.cp_kv_cache_interleave_size = 1
1404
+ runner.input_batch = SimpleNamespace(
1405
+ logitsprocs=None,
1406
+ logitsprocs_need_output_token_ids=False,
1407
+ )
1408
+ runner.jit_warmup_registry = Mock()
1409
+ runner.jit_warmup_registry.activate.return_value = nullcontext()
1410
+
1411
+ spec = SimpleNamespace(
1412
+ block_size=16,
1413
+ max_num_blocks_per_req=lambda *_: 4,
1414
+ )
1415
+ kv_cache_config = SimpleNamespace(
1416
+ kv_cache_groups=[SimpleNamespace(kv_cache_spec=spec)]
1417
+ )
1418
+ input_batch_cls = Mock(return_value=SimpleNamespace())
1419
+ monkeypatch.setattr(gpu_model_runner_module, "InputBatch", input_batch_cls)
1420
+ monkeypatch.setattr(
1421
+ gpu_model_runner_module,
1422
+ "get_kv_cache_spec_kind",
1423
+ lambda _: gpu_model_runner_module.KVCacheSpecKind.FULL_ATTENTION,
1424
+ )
1425
+
1426
+ runner.may_reinitialize_input_batch(kv_cache_config, [16])
1427
+
1428
+ assert input_batch_cls.call_count == 1
1429
+ assert input_batch_cls.call_args.kwargs["cp_kv_cache_interleave_size"] == 16
1430
+
1431
+
1432
+def test_v2_runner_snapshots_late_interleave_adjustment(monkeypatch):
1433
+ from vllm.v1.worker.gpu import model_runner as v2_model_runner_module
1434
+
1435
+ runner = object.__new__(v2_model_runner_module.GPUModelRunner)
1436
+ runner.parallel_config = SimpleNamespace(cp_kv_cache_interleave_size=16)
1437
+ runner.cp_interleave = 1
1438
+
1439
+ class StopInitialization(Exception):
1440
+ pass
1441
+
1442
+ monkeypatch.setattr(
1443
+ v2_model_runner_module,
1444
+ "deepcopy",
1445
+ Mock(side_effect=StopInitialization),
1446
+ )
1447
+
1448
+ with pytest.raises(StopInitialization):
1449
+ runner.initialize_kv_cache(SimpleNamespace())
1450
+
1451
+ assert runner.cp_interleave == 16
1452
+
1453
+
1454
def test_hybrid_block_table_initialization():
1455
"""Test hybrid block table with different kernel and kvcache_manager block
1456
sizes."""
tests/v1/worker/test_gpu_model_runner_v2.py
+6
-1
@@ -57,14 +57,19 @@ def test_initialize_kv_cache_does_not_dcp_shard_mamba_block_table(
57
KVCacheGroupSpec(["kda"], mamba_spec),
58
],
59
)
60
+ parallel_config = SimpleNamespace(
61
+ decode_context_parallel_size=dcp_size,
62
+ cp_kv_cache_interleave_size=1,
63
+ )
64
vllm_config = SimpleNamespace(
61
- parallel_config=SimpleNamespace(decode_context_parallel_size=dcp_size),
65
+ parallel_config=parallel_config,
66
cache_config=SimpleNamespace(mamba_cache_mode=mamba_cache_mode),
67
)
68
runner = SimpleNamespace(
69
max_model_len=max_model_len,
70
is_encoder_decoder=False,
71
vllm_config=vllm_config,
72
+ parallel_config=parallel_config,
73
)
74
75
class _CapturedWidths(Exception):
vllm/config/kv_transfer.py
+9
@@ -119,3 +119,12 @@ class KVTransferConfig:
119
120
def get_from_extra_config(self, key, default) -> Any:
121
return self.kv_connector_extra_config.get(key, default)
122
+
123
+ def has_connector(self, connector_name: str) -> bool:
124
+ """Whether ``connector_name`` is configured, directly or in MultiConnector."""
125
+ if self.kv_connector == connector_name:
126
+ return True
127
+ return self.kv_connector == "MultiConnector" and any(
128
+ child.get("kv_connector") == connector_name
129
+ for child in self.kv_connector_extra_config.get("connectors", [])
130
+ )
vllm/config/vllm.py
+78
-15
@@ -1141,6 +1141,30 @@ class VllmConfig:
1141
self._verify_sampling_replay_config()
1142
self._verify_trace_replay_config()
1143
1144
+ # A NIXL side is either fully replicated or fully DCP-sharded; MLA only.
1145
+ if (
1146
+ self.kv_transfer_config is not None
1147
+ and self.kv_transfer_config.has_connector("NixlConnector")
1148
+ ):
1149
+ assert self.parallel_config.prefill_context_parallel_size == 1, (
1150
+ "NIXL does not support prefill context parallelism."
1151
+ )
1152
+ dcp_size = self.parallel_config.decode_context_parallel_size
1153
+ tp_size = self.parallel_config.tensor_parallel_size
1154
+ assert dcp_size in (1, tp_size), (
1155
+ f"decode_context_parallel_size={dcp_size} must be 1 or equal "
1156
+ f"to tensor_parallel_size={tp_size} when using NixlConnector."
1157
+ )
1158
+ if self.model_config is not None:
1159
+ assert self.model_config.use_mla or dcp_size == 1, (
1160
+ "PD with decode_context_parallel_size > 1 is only "
1161
+ "supported for MLA models."
1162
+ )
1163
+ assert not (self.model_config.is_hybrid and dcp_size > 1), (
1164
+ "PD with decode_context_parallel_size > 1 is not "
1165
+ "supported for hybrid Mamba/SSM models."
1166
+ )
1167
+
1168
if self.lora_config is not None:
1169
self.lora_config.verify_with_model_config(self.model_config)
1170
@@ -2714,6 +2738,52 @@ class VllmConfig:
2738
f"Model Runner V1 does not support: {', '.join(unsupported)}"
2739
)
2740
2741
+ def adjust_dcp_kv_cache_interleave_size(
2742
+ self, kv_cache_config: "KVCacheConfig"
2743
+ ) -> None:
2744
+ """Normalize DCP interleave size against the resolved block_size for PD.
2745
+
2746
+ Called by each worker (via ensure_kv_transfer_initialized), once it knows its
2747
+ own final block_size via kv_cache_config.
2748
+ """
2749
+ dcp_size = self.parallel_config.decode_context_parallel_size
2750
+ if dcp_size <= 1:
2751
+ return
2752
+ # Get the kernel block_size, but don't use resolve_kv_cache_block_size to avoid
2753
+ # scaling by dcp_size (we need the local block_size here).
2754
+ local_block_size = min(
2755
+ g.kv_cache_spec.block_size for g in kv_cache_config.kv_cache_groups
2756
+ )
2757
+ if self.parallel_config.dcp_kv_cache_interleave_size > 1 and (
2758
+ self.parallel_config.cp_kv_cache_interleave_size
2759
+ != self.parallel_config.dcp_kv_cache_interleave_size
2760
+ ):
2761
+ self.parallel_config.cp_kv_cache_interleave_size = (
2762
+ self.parallel_config.dcp_kv_cache_interleave_size
2763
+ )
2764
+ logger.warning_once(
2765
+ "cp_kv_cache_interleave_size is overridden by dcp_kv_cache"
2766
+ "_interleave_size. And dcp-kv-cache-interleave-size will be "
2767
+ "deprecated when PCP is fully supported."
2768
+ )
2769
+
2770
+ if (
2771
+ self.kv_transfer_config is not None
2772
+ and self.kv_transfer_config.kv_connector is not None
2773
+ and self.parallel_config.cp_kv_cache_interleave_size != local_block_size
2774
+ ):
2775
+ interleave = self.parallel_config.cp_kv_cache_interleave_size
2776
+ self.parallel_config.cp_kv_cache_interleave_size = local_block_size
2777
+ logger.info_once(
2778
+ "When using PD disaggregation with DCP "
2779
+ "(decode_context_parallel_size=%d), "
2780
+ "cp_kv_cache_interleave_size is automatically adjusted "
2781
+ "from %d to block_size %d for block-level alignment.",
2782
+ dcp_size,
2783
+ interleave,
2784
+ local_block_size,
2785
+ )
2786
+
2787
def validate_block_size(self) -> None:
2788
"""Validate block_size against DCP and mamba constraints.
2789
@@ -2722,20 +2792,14 @@ class VllmConfig:
2792
"""
2793
block_size = self.cache_config.block_size
2794
2725
- # DCP interleave-size compatibility
2726
- if self.parallel_config.decode_context_parallel_size > 1:
2727
- if self.parallel_config.dcp_kv_cache_interleave_size > 1 and (
2728
- self.parallel_config.cp_kv_cache_interleave_size
2729
- != self.parallel_config.dcp_kv_cache_interleave_size
2730
- ):
2731
- self.parallel_config.cp_kv_cache_interleave_size = (
2732
- self.parallel_config.dcp_kv_cache_interleave_size
2733
- )
2734
- logger.warning_once(
2735
- "cp_kv_cache_interleave_size is overridden by dcp_kv_cache"
2736
- "_interleave_size. And dcp-kv-cache-interleave-size will be "
2737
- "deprecated when PCP is fully supported."
2738
- )
2795
+ # Skip DCP interleave-size compatibility when a KV connector is configured:
2796
+ # cp_kv_cache_interleave_size is pinned to block_size for PD by each worker
2797
+ pd_active = (
2798
+ self.kv_transfer_config is not None
2799
+ and self.kv_transfer_config.kv_connector is not None
2800
+ and self.kv_transfer_config.is_kv_transfer_instance
2801
+ )
2802
+ if self.parallel_config.decode_context_parallel_size > 1 and not pd_active:
2803
assert (
2804
self.parallel_config.cp_kv_cache_interleave_size <= block_size
2805
and block_size % self.parallel_config.cp_kv_cache_interleave_size == 0
@@ -2744,7 +2808,6 @@ class VllmConfig:
2808
"than or equal to and divisible by cp_kv_cache_interleave_size "
2809
f"({self.parallel_config.cp_kv_cache_interleave_size})."
2810
)
2747
-
2811
# Mamba cache align-mode constraints
2812
if self.cache_config.mamba_cache_mode == "align":
2813
assert not self.scheduler_config.disable_chunked_mm_input, (
vllm/distributed/kv_transfer/kv_connector/utils.py
+70
-3
@@ -404,6 +404,9 @@ class EngineTransferInfo:
404
end_layer: int = 0
405
"""Exclusive global index after the last layer owned by this PP rank."""
406
407
+ remote_dcp_size: int = 1
408
+ """Remote decode context parallel size."""
409
+
410
411
# ---- Transfer topology ----
412
@@ -420,6 +423,7 @@ class TransferTopology:
423
is_mamba: bool
424
total_num_kv_heads: int
425
attn_backends: list[type[AttentionBackend]]
426
+ dcp_size: int = 1
427
tensor_shape: torch.Size | None = None
428
429
def __post_init__(self):
@@ -461,6 +465,15 @@ class TransferTopology:
465
for key in [k for k in self._engines if k[0] == remote_engine_id]:
466
del self._engines[key]
467
468
+ @property
469
+ def dcp_rank(self) -> int:
470
+ """This rank's DCP coverage rank.
471
+
472
+ with ``dcp_size in (1, tp_size)`` enforced at the connector boundary, a
473
+ rank's DCP identity is always exactly ``tp_rank % dcp_size``.
474
+ """
475
+ return self.tp_rank % self.dcp_size
476
+
477
# ============================================================
478
# Common methods
479
# ============================================================
@@ -514,18 +527,70 @@ class TransferTopology:
527
"""Whether the local engine's KV cache is replicated."""
528
return self.is_mla or self.tp_size > self.total_num_kv_heads
529
517
- def handshake_target_ranks(self, remote_tp_size: int) -> list[int]:
530
+ def dcp_source_ranks(self, remote_tp_size: int, remote_dcp_size: int) -> list[int]:
531
+ """Remote ranks whose DCP slice overlaps mine (MLA, ``remote_dcp_size > 1``).
532
+
533
+ Shared by ``handshake_target_ranks`` (who to query metadata from) and
534
+ ``compute_tp_mapping`` (who to actually read from) — for MLA the two
535
+ questions have the identical answer, since DCP sharding is the only
536
+ thing keeping a remote rank from being interchangeable with any other.
537
+ """
538
+ local_dcp_size = self.dcp_size
539
+ local_dcp_rank = self.dcp_rank
540
+ if local_dcp_size <= remote_dcp_size:
541
+ # Keep every remote rank whose slice sits inside mine. When
542
+ # local_dcp_size == 1 (replicated locally), local_dcp_rank == 0 reduces to
543
+ # every remote rank, since no single one holds the whole sequence.
544
+ return [
545
+ r for r in range(remote_tp_size) if r % local_dcp_size == local_dcp_rank
546
+ ]
547
+ # Local finer-grained: exactly one remote rank covers my whole slice
548
+ return [local_dcp_rank % remote_dcp_size]
549
+
550
+ def handshake_target_ranks(
551
+ self, remote_tp_size: int, remote_dcp_size: int = 1
552
+ ) -> list[int]:
553
"""Pre-registration: compute which remote TP ranks to handshake with.
554
520
- Pure math based on local/remote TP sizes — does not require
521
- the remote engine to be registered yet.
555
+ Pure math based on local/remote TP (and DCP, when the remote shards
556
+ its KV cache) sizes — does not require the remote engine to be
557
+ registered yet.
558
+
559
+ DCP support is scoped to ``dcp_size in (1, tp_size)`` on each side
560
+ and DCP sizes that divide one another: neither side ever has a
561
+ partially-duplicated, partially-sharded KV cache. When the
562
+ remote is not sharded (``remote_dcp_size == 1``) this reduces
563
+ exactly to the DTP>=PTP case, since a sharded local side
564
+ already has ``tp_size == dcp_size``.
565
"""
566
+ if remote_dcp_size > 1:
567
+ return self.dcp_source_ranks(remote_tp_size, remote_dcp_size)
568
+
569
tp_ratio = self.tp_ratio(remote_tp_size)
570
if tp_ratio > 0:
571
return [self.tp_rank // tp_ratio]
572
abs_ratio = -tp_ratio
573
return [self.tp_rank * abs_ratio + i for i in range(abs_ratio)]
574
575
+ def dcp_consumer_count(self, remote_tp_size: int, remote_dcp_size: int) -> int:
576
+ """How many local ranks (in aggregate) read from a given remote rank.
577
+
578
+ Used by the producer side to know how many reader notifications to
579
+ wait for before freeing a request's blocks. Reuses ``tp_ratio``
580
+ whenever the remote isn't sharded — a sharded local side already
581
+ has ``tp_size == dcp_size``, so the existing TP-ratio formula is
582
+ already correct there unmodified.
583
+ """
584
+ if remote_dcp_size > 1:
585
+ if self.dcp_size == 1:
586
+ # Replicated locally: every local rank reads every shard.
587
+ return self.tp_size
588
+ # Both sharded, different degrees.
589
+ return max(1, self.dcp_size // remote_dcp_size)
590
+ # Remote replicated: `tp_ratio` local ranks share each remote rank
591
+ # when local_tp >= remote_tp, else each remote rank has one reader.
592
+ return max(1, self.tp_ratio(remote_tp_size))
593
+
594
def target_remote_ranks(
595
self, remote_engine_id: EngineId, remote_pp_rank: int = 0
596
) -> list[int]:
@@ -551,6 +616,8 @@ class TransferTopology:
616
f"local_tp={self.tp_size}, "
617
f"remote_tp={info.remote_tp_size}, "
618
f"remote_pp={remote_pp_rank}, "
619
+ f"local_dcp={self.dcp_size}, "
620
+ f"remote_dcp={info.remote_dcp_size}, "
621
f"local_rank={self.tp_rank}, "
622
f"remote_block_len={info.remote_block_len})"
623
)
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_scheduler.py
+7
-2
@@ -109,7 +109,9 @@ class NixlBaseConnectorScheduler:
109
# Requests that need to start recv/send.
110
# New requests are added by update_state_after_alloc in
111
# the scheduler. Used to make metadata passed to Worker.
112
- self._reqs_need_recv: dict[ReqId, tuple[Request, BlockIds]] = {}
112
+ self._reqs_need_recv: dict[
113
+ ReqId, tuple[Request, BlockIds, tuple[int, ...]]
114
+ ] = {}
115
self._reqs_need_save: dict[ReqId, Request] = {}
116
# Reqs to send and their expiration time
117
self._reqs_need_send: dict[ReqId, float] = {}
@@ -207,6 +209,7 @@ class NixlBaseConnectorScheduler:
209
host = params.get("remote_host")
210
port = params.get("remote_port")
211
tp_size = params.get("tp_size")
212
+ dcp_size = params.get("dcp_size", 1)
213
pp_size = params.get("pp_size", 1)
214
if (
215
remote_engine_id is None
@@ -222,6 +225,7 @@ class NixlBaseConnectorScheduler:
225
host=host,
226
port=port,
227
tp_size=tp_size,
228
+ dcp_size=dcp_size,
229
pp_size=pp_size,
230
)
231
self._heartbeat_by_engine[remote_engine_id].req_ids.add(remote_request_id)
@@ -452,12 +456,13 @@ class NixlBaseConnectorScheduler:
456
meta = NixlConnectorMetadata()
457
458
# Loop through scheduled reqs and convert to ReqMeta.
455
- for req_id, (req, block_ids) in self._reqs_need_recv.items():
459
+ for req_id, (req, block_ids, cached) in self._reqs_need_recv.items():
460
assert req.kv_transfer_params is not None
461
meta.add_new_req_to_recv(
462
request_id=req_id,
463
local_block_ids=block_ids,
464
kv_transfer_params=req.kv_transfer_params,
465
+ local_num_computed_blocks=cached,
466
)
467
468
if self.use_host_buffer:
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py
+111
-15
@@ -231,6 +231,17 @@ class NixlBaseConnectorWorker:
231
handle.append((addr + p_idx * chunk, chunk, dev))
232
yield handle
233
234
+ def _needs_split_local_xfer_handles(self, tp_ratio: int, plan: TPMapping) -> bool:
235
+ """Whether reads need per-source slices of the local KV region.
236
+
237
+ Pure MLA attention is replicated across TP ranks and writes the whole
238
+ local region. Multiple physical remote workers may still participate
239
+ because DCP assigns them disjoint blocks, but that does not require
240
+ splitting the local region. Hybrid MLA+SSM is different: its mapping
241
+ contains multiple source ranks for the sharded SSM state.
242
+ """
243
+ return tp_ratio < 0 and (not self.use_mla or len(plan.all_source_ranks) > 1)
244
+
245
def _fa_desc_replicated(self, num_fa_descs: int) -> list[bool]:
246
"""Per-FA-descriptor replicate flag, in _build_fa_local emission order
247
(region-major; one desc per block, with K/V packed). Length ``num_fa_descs``.
@@ -380,6 +391,13 @@ class NixlBaseConnectorWorker:
391
self.dcp_size = vllm_config.parallel_config.decode_context_parallel_size
392
self.pcp_rank = get_pcp_group().rank_in_group if self.pcp_size > 1 else 0
393
394
+ # DCP support is scoped to MLA, with dcp_size in (1, tp_size): either fully
395
+ # replicated or fully sharded. A DCP rank is always derivable this way.
396
+ self.dcp_rank = self.tp_rank % self.dcp_size
397
+ if self._has_mamba and self.dcp_size > 1:
398
+ # Prefix-cache-aware DCP slicing isn't implemented for the Mamba group.
399
+ raise ValueError("DCP is not supported for hybrid MLA+Mamba models.")
400
+
401
self.num_blocks = kv_cache_config.num_blocks
402
self.enable_permute_local_kv = False
403
self.enable_heterogeneous_attn_post_process = False
@@ -543,9 +561,10 @@ class NixlBaseConnectorWorker:
561
self.compat_hash: str | None = None
562
self.transfer_topo: TransferTopology | None = None
563
546
- # With heterogeneous TP, P must wait for all assigned D TP workers to
547
- # finish reading before safely freeing the blocks.
564
+ # With heterogeneous TP (or DCP), P must wait for all assigned D
565
+ # workers to finish reading before safely freeing the blocks.
566
self.consumer_notification_counts_by_req = defaultdict[ReqId, int](int)
567
+ self.expected_consumer_notifications_by_req: dict[ReqId, int] = {}
568
self.xfer_stats = NixlKVConnectorStats()
569
570
self._physical_blocks_per_logical_kv_block = 1
@@ -556,7 +575,6 @@ class NixlBaseConnectorWorker:
575
get_representative_spec_type(g.kv_cache_spec)
576
for g in self.kv_cache_config.transfer_groups
577
)
559
-
578
# Per-region MLA flag, 1:1 with block_len_per_layer. True -> REPLICATE
579
# (MLA), False -> SPLIT (head-sharded full-attn). Mixed only for models
580
# combining both (e.g. GQA main + MLA Eagle-3 draft).
@@ -620,6 +638,7 @@ class NixlBaseConnectorWorker:
638
port: int,
639
remote_tp_size: int,
640
expected_engine_id: str,
641
+ remote_dcp_size: int = 1,
642
remote_pp_size: int = 1,
643
notif_agents_only: bool = False,
644
) -> tuple[dict[tuple[int, int], str], float]:
@@ -643,7 +662,9 @@ class NixlBaseConnectorWorker:
662
# local rank will read from. Note that With homogeneous TP,
663
# this happens to be the same single rank_i.
664
assert self.transfer_topo is not None
646
- p_remote_ranks = self.transfer_topo.handshake_target_ranks(remote_tp_size)
665
+ p_remote_ranks = self.transfer_topo.handshake_target_ranks(
666
+ remote_tp_size, remote_dcp_size
667
+ )
668
remote_rank_to_agent_name: dict[tuple[int, int], str] = {}
669
path = make_zmq_path("tcp", host, port)
670
# Clock offset to the peer, estimated from the handshake round-trip.
@@ -747,11 +768,11 @@ class NixlBaseConnectorWorker:
768
# Register Remote agent.
769
if notif_agents_only:
770
remote_agent_name = self._add_notif_only_remote_agent(
750
- metadata, remote_tp_size
771
+ metadata, remote_tp_size, metadata.dcp_size
772
)
773
else:
774
remote_agent_name = self.add_remote_agent(
754
- metadata, remote_rank, remote_tp_size
775
+ metadata, remote_rank, remote_tp_size, metadata.dcp_size
776
)
777
setup_agent_time = time.perf_counter()
778
logger.debug(
@@ -766,7 +787,7 @@ class NixlBaseConnectorWorker:
787
return remote_rank_to_agent_name, best_offset
788
789
def _add_notif_only_remote_agent(
769
- self, metadata: NixlAgentMetadata, remote_tp_size: int
790
+ self, metadata: NixlAgentMetadata, remote_tp_size: int, remote_dcp_size: int = 1
791
) -> str:
792
"""Load a remote agent for notifs only on the push-mode decode side.
793
@@ -782,6 +803,7 @@ class NixlBaseConnectorWorker:
803
remote_physical_blocks_per_logical=(
804
metadata.physical_blocks_per_logical_kv_block
805
),
806
+ remote_dcp_size=remote_dcp_size,
807
),
808
)
809
return self.nixl_wrapper.add_remote_agent(metadata.agent_metadata)
@@ -898,6 +920,7 @@ class NixlBaseConnectorWorker:
920
host: str,
921
port: int,
922
tp_size: int,
923
+ dcp_size: int = 1,
924
pp_size: int = 1,
925
notif_agents_only: bool = False,
926
) -> Future[tuple[dict[tuple[int, int], str], float]] | None:
@@ -923,6 +946,7 @@ class NixlBaseConnectorWorker:
946
port,
947
tp_size,
948
engine_id,
949
+ dcp_size,
950
pp_size,
951
notif_agents_only,
952
)
@@ -960,6 +984,8 @@ class NixlBaseConnectorWorker:
984
meta.remote.host,
985
meta.remote.port,
986
meta.tp_size,
987
+ meta.dcp_size,
988
+ meta.pp_size,
989
)
990
if fut is None:
991
# Already handshaked — only happens if caller does not pre-check.
@@ -991,8 +1017,11 @@ class NixlBaseConnectorWorker:
1017
block_size=self.block_size,
1018
engine_id=self.engine_id,
1019
is_mla=self.use_mla,
994
- total_num_kv_heads=self.model_config.get_total_num_kv_heads(),
1020
+ total_num_kv_heads=1
1021
+ if self.use_mla
1022
+ else self.model_config.get_total_num_kv_heads(),
1023
attn_backends=self.attn_backends,
1024
+ dcp_size=self.dcp_size,
1025
# SSM States come in tuples (ssm, conv)
1026
tensor_shape=next(iter(kv_caches.values())).shape
1027
if not self._has_mamba
@@ -1469,6 +1498,7 @@ class NixlBaseConnectorWorker:
1498
nixl_agent_meta: NixlAgentMetadata,
1499
remote_tp_rank: int = 0,
1500
remote_tp_size: int = 1,
1501
+ remote_dcp_size: int = 1,
1502
) -> str:
1503
"""
1504
Add the remote NIXL agent and prepare the descriptors for reading cache
@@ -1553,6 +1583,7 @@ class NixlBaseConnectorWorker:
1583
remote_block_size=nixl_agent_meta.block_size,
1584
remote_block_len=nixl_agent_meta.block_lens[0],
1585
remote_physical_blocks_per_logical=physical_blocks_per_logical,
1586
+ remote_dcp_size=remote_dcp_size,
1587
)
1588
transfer_topo.register_remote_engine(engine_id, transfer_info)
1589
logger.info("Transfer plan: %s", transfer_topo.describe(engine_id))
@@ -1561,6 +1592,7 @@ class NixlBaseConnectorWorker:
1592
transfer_topology=transfer_topo,
1593
remote_tp_size=remote_tp_size,
1594
group_spec_types=self._group_spec_types,
1595
+ remote_dcp_size=remote_dcp_size,
1596
)
1597
1598
remote_agent_name = self.nixl_wrapper.add_remote_agent(
@@ -1583,7 +1615,9 @@ class NixlBaseConnectorWorker:
1615
self.kv_caches_base_addr[engine_id][remote_tp_rank] = (
1616
nixl_agent_meta.kv_caches_base_addr
1617
)
1586
- self._validate_remote_agent_handshake(nixl_agent_meta, remote_tp_size)
1618
+ self._validate_remote_agent_handshake(
1619
+ nixl_agent_meta, remote_tp_size, remote_dcp_size
1620
+ )
1621
1622
# This is 1 when P and D `--tensor-parallel-size` match. Otherwise,
1623
# this is the ratio between the two sizes.
@@ -1613,10 +1647,8 @@ class NixlBaseConnectorWorker:
1647
1648
### (Optional) Register local agent memory regions. MLA is not split.
1649
split_key = (tp_ratio, remote_block_size)
1616
- if (
1617
- tp_ratio < 0
1618
- and (not self.use_mla or len(plan.all_source_ranks) > 1)
1619
- and split_key not in self.src_xfer_handles_by_tp_ratio
1650
+ if self._needs_split_local_xfer_handles(tp_ratio, plan) and (
1651
+ split_key not in self.src_xfer_handles_by_tp_ratio
1652
):
1653
# Remote tp_size > local tp_size: read from multiple remote ranks.
1654
# Logically "split" own regions into per-source chunks. Hybrid
@@ -1674,7 +1706,10 @@ class NixlBaseConnectorWorker:
1706
return remote_agent_name
1707
1708
def _validate_remote_agent_handshake(
1677
- self, nixl_agent_meta: NixlAgentMetadata, remote_tp_size: int
1709
+ self,
1710
+ nixl_agent_meta: NixlAgentMetadata,
1711
+ remote_tp_size: int,
1712
+ remote_dcp_size: int = 1,
1713
):
1714
"""
1715
Validate the remote agent handshake metadata ensuring the
@@ -1685,6 +1720,15 @@ class NixlBaseConnectorWorker:
1720
assert self.transfer_topo is not None
1721
remote_info = self.transfer_topo.get_engine_info(remote_engine_id)
1722
assert remote_info.remote_tp_size == remote_tp_size
1723
+ assert remote_info.remote_dcp_size == remote_dcp_size
1724
+ # DCP sizes must divide one another; this is what keeps the
1725
+ # read-slicing math in pull_worker a closed form.
1726
+ assert (
1727
+ self.dcp_size % remote_dcp_size == 0 or remote_dcp_size % self.dcp_size == 0
1728
+ ), (
1729
+ f"DCP sizes must divide one another: local={self.dcp_size}, "
1730
+ f"remote={remote_dcp_size} (engine {remote_engine_id})."
1731
+ )
1732
1733
tp_ratio = self.transfer_topo.tp_ratio(remote_tp_size)
1734
block_size_ratio = self.transfer_topo.block_size_ratio(
@@ -2132,6 +2176,7 @@ class NixlBaseConnectorWorker:
2176
if now < expires:
2177
break
2178
count = self.consumer_notification_counts_by_req.pop(req_id, 0)
2179
+ self.expected_consumer_notifications_by_req.pop(req_id, None)
2180
self.xfer_stats.record_kv_expired_req()
2181
logger.warning(
2182
"Releasing expired KV blocks for request %s which were "
@@ -2267,6 +2312,7 @@ class NixlBaseConnectorWorker:
2312
hb_info.host,
2313
hb_info.port,
2314
hb_info.tp_size,
2315
+ hb_info.dcp_size,
2316
hb_info.pp_size,
2317
self._hb_handshake_notif_only and hb_info.pp_size > 1,
2318
)
@@ -2352,7 +2398,7 @@ class NixlBaseConnectorWorker:
2398
This is required when the logical block size (the one set by the user)
2399
does not match the one required by the attn backend.
2400
`ratio` is the number of physical blocks per logical block.
2355
- We always receive logical blocks from the engine, so we expand them here eg:
2401
+ We always receive logical blocks from the engine, so we expand them here eg:
2402
logical block ids: [(SW-clipped) [1], (FA) [2, 3]], ratio=2
2403
physical block ids: [(SW-clipped) [2, 3], (FA) [4, 5, 6, 7]]
2404
"""
@@ -2377,6 +2423,56 @@ class NixlBaseConnectorWorker:
2423
)
2424
return physical_block_ids
2425
2426
+ def _apply_dcp_prefix_caching(
2427
+ self,
2428
+ local_ids: list[int],
2429
+ remote_ids: list[int],
2430
+ remote_rank: int,
2431
+ local_dcp_size: int,
2432
+ local_dcp_rank: int,
2433
+ remote_dcp_size: int,
2434
+ local_num_computed_blocks: int,
2435
+ ) -> tuple[list[int], list[int]]:
2436
+ """Match local and remote block IDs by global DCP position.
2437
+
2438
+ ``local_ids`` excludes blocks already satisfied by the local prefix
2439
+ cache, while ``remote_ids`` contains the full transferable remote list.
2440
+
2441
+ Example:
2442
+ local DCP size 2, rank 0 owns *global positions*=[0, 2, 4, 6]
2443
+ cached_blocks=[0,2,4]
2444
+ to transfer: [6] (the rest is cached)
2445
+
2446
+ remote DCP size 4
2447
+ remote rank 0 owns [0, 4] -> no more match, skip the read
2448
+ remote rank 2 owns [2, 6] -> match position 6 and read
2449
+
2450
+ An empty result (rank 0 above) takes the notification-only path, allowing the
2451
+ remote to release its blocks without performing a transfer.
2452
+ """
2453
+ local_size, remote_size = local_dcp_size, remote_dcp_size
2454
+
2455
+ if local_size == remote_size:
2456
+ local_slice = local_ids
2457
+ remote_slice = remote_ids[local_num_computed_blocks:]
2458
+ elif local_size < remote_size:
2459
+ k = remote_size // local_size
2460
+ p = (remote_rank - local_dcp_rank) // local_size
2461
+ start_local = (p - local_num_computed_blocks) % k
2462
+ start_remote = (local_num_computed_blocks + start_local - p) // k
2463
+ local_slice = local_ids[start_local::k]
2464
+ remote_slice = remote_ids[start_remote:]
2465
+ else:
2466
+ k = local_size // remote_size
2467
+ remote_dcp_rank = remote_rank % remote_size
2468
+ c = (local_dcp_rank - remote_dcp_rank) // remote_size
2469
+ start_remote = c + local_num_computed_blocks * k
2470
+ local_slice = local_ids
2471
+ remote_slice = remote_ids[start_remote::k]
2472
+
2473
+ matched_blocks = min(len(local_slice), len(remote_slice))
2474
+ return local_slice[:matched_blocks], remote_slice[:matched_blocks]
2475
+
2476
def _apply_prefix_caching(
2477
self,
2478
decode_block_ids: BlockIds,
vllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py
+4
@@ -369,6 +369,10 @@ class NixlPushConnector(NixlBaseConnector):
369
kv_cache_config: "KVCacheConfig",
370
):
371
super().__init__(vllm_config, role, kv_cache_config)
372
+ if vllm_config.parallel_config.decode_context_parallel_size > 1:
373
+ raise ValueError(
374
+ "NixlPushConnector does not support decode_context_parallel_size > 1."
375
+ )
376
self.connector_scheduler: NixlPushConnectorScheduler | None = None
377
self.connector_worker: NixlPushConnectorWorker | None = None
378
if role == KVConnectorRole.SCHEDULER:
vllm/distributed/kv_transfer/kv_connector/v1/nixl/metadata.py
+15
-2
@@ -209,6 +209,7 @@ class HeartbeatInfo:
209
host: str
210
port: int
211
tp_size: int
212
+ dcp_size: int = 1
213
pp_size: int = 1
214
215
@@ -228,6 +229,12 @@ class ReqMeta:
229
# To be used when logical block size does not match the kernel block size
230
local_physical_block_ids: BlockIds
231
tp_size: int
232
+ dcp_size: int = 1
233
+ # Per-KV-cache-group logical blocks this rank already holds, i.e. its
234
+ # prefix-cache hit. Fixes where this rank's DCP slice starts relative to
235
+ # the remote's; kept per-group since hybrid models (e.g. SWA+FA) can have
236
+ # different cache-hit counts per group.
237
+ local_num_computed_blocks: tuple[int, ...] = ()
238
remote: RemoteMeta | None = None
239
# Remote block size, discovered during NIXL handshake (push mode).
240
remote_block_size: int | None = None
@@ -261,14 +268,17 @@ class NixlConnectorMetadata(KVConnectorMetadata):
268
self,
269
local_block_ids: BlockIds,
270
kv_transfer_params: dict[str, Any],
271
+ local_num_computed_blocks: tuple[int, ...] = (),
272
) -> ReqMeta:
273
return ReqMeta(
274
local_block_ids=local_block_ids,
275
local_physical_block_ids=local_block_ids,
268
- # P workers don't need to receive tp_size from proxy here.
276
+ # P workers don't need to receive these from proxy here.
277
tp_size=kv_transfer_params.get("tp_size", 1),
278
+ dcp_size=kv_transfer_params.get("dcp_size", 1),
279
remote_block_size=kv_transfer_params.get("remote_block_size"),
280
pp_size=kv_transfer_params.get("pp_size", 1),
281
+ local_num_computed_blocks=local_num_computed_blocks,
282
)
283
284
def add_new_req_to_save(
@@ -286,8 +296,11 @@ class NixlConnectorMetadata(KVConnectorMetadata):
296
request_id: ReqId,
297
local_block_ids: BlockIds,
298
kv_transfer_params: dict[str, Any],
299
+ local_num_computed_blocks: tuple[int, ...] = (),
300
):
290
- req = self._add_new_req(local_block_ids, kv_transfer_params)
301
+ req = self._add_new_req(
302
+ local_block_ids, kv_transfer_params, local_num_computed_blocks
303
+ )
304
req.remote = RemoteMeta(
305
block_ids=kv_transfer_params["remote_block_ids"],
306
engine_id=kv_transfer_params["remote_engine_id"],
vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_scheduler.py
+14
-1
@@ -155,12 +155,23 @@ class NixlPullConnectorScheduler(NixlBaseConnectorScheduler):
155
local_block_ids = self.get_exchange_clipped_blocks(
156
unhashed_local_block_ids
157
)
158
+ # Blocks covered by the local prefix cache, per KV cache group.
159
+ # Each count fixes where that group's DCP slice starts, which the
160
+ # worker needs to line up with the remote's slice.
161
+ local_num_computed_blocks = tuple(
162
+ sum(
163
+ block.block_hash is not None and not block.is_null
164
+ for block in group
165
+ )
166
+ for group in blocks.blocks
167
+ )
168
169
# Get unhashed blocks to pull from remote. Mind that a full prefix
170
# cache hit is indicated with an empty list.
171
self._reqs_need_recv[request.request_id] = (
172
request,
173
local_block_ids,
174
+ local_num_computed_blocks,
175
)
176
177
else:
@@ -213,7 +224,7 @@ class NixlPullConnectorScheduler(NixlBaseConnectorScheduler):
224
# To avoid stranding the prefill blocks in the prefill instance,
225
# we must add empty block_ids to _reqs_need_recv so that our
226
# worker side will notify and free blocks in the prefill instance.
216
- self._reqs_need_recv[request.request_id] = (request, [])
227
+ self._reqs_need_recv[request.request_id] = (request, [], ())
228
params["do_remote_prefill"] = False
229
return False, None
230
@@ -272,6 +283,8 @@ class NixlPullConnectorScheduler(NixlBaseConnectorScheduler):
283
remote_host=self.side_channel_host,
284
remote_port=self.side_channel_port,
285
tp_size=self.vllm_config.parallel_config.tensor_parallel_size,
286
+ dcp_size=self.vllm_config.parallel_config.decode_context_parallel_size,
287
+ pp_size=self.vllm_config.parallel_config.pipeline_parallel_size,
288
remote_num_tokens=remote_num_tokens,
289
remote_blocks_expiry_time=blocks_expiry_time,
290
transfer_mode=self._TRANSFER_MODE,
vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py
+94
-46
@@ -5,6 +5,7 @@
5
import time
6
from typing import TYPE_CHECKING
7
8
+from vllm.distributed.kv_transfer.kv_connector.utils import BlockIds
9
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker import (
10
NixlBaseConnectorWorker,
11
)
@@ -143,40 +144,78 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
144
self._handle_failed_transfer(req_id, None)
145
return
146
147
+ if any(len(group) > 0 for group in meta.local_block_ids):
148
+ # The scheduler waits for finished_recving from *every* worker.
149
+ # Under DCP a rank's slice can legitimately come out empty when its
150
+ # interleaved positions fall past the end of the sequence. _read_blocks
151
+ # then takes the notify-only path without registering a transfer.
152
+ # Seed the entry so this rank still reports completion.
153
+ self._recving_transfers.setdefault(req_id, [])
154
+
155
plan = self.tp_mappings[engine_id]
156
remote_info = self.transfer_topo.get_engine_info(engine_id)
157
tp_ratio = self.transfer_topo.tp_ratio(remote_info.remote_tp_size)
158
159
+ remote_logical_block_ids = meta.remote.block_ids
160
meta.remote.block_ids = self._logical_to_kernel_block_ids(
151
- meta.remote.block_ids,
161
+ remote_logical_block_ids,
162
remote_info.remote_physical_blocks_per_logical,
163
)
154
- remote_block_ids = meta.remote.block_ids
155
- local_block_ids = meta.local_physical_block_ids
156
- num_groups = len(local_block_ids)
157
- read_specs = [
158
- ReadSpec(
159
- remote_rank=rank,
160
- local_block_ids=[
161
- list(local_block_ids[g])
162
- if rank in plan.source_ranks_per_group[g]
163
- else []
164
- for g in range(num_groups)
165
- ],
166
- remote_block_ids=[
167
- list(remote_block_ids[g])
168
- if rank in plan.source_ranks_per_group[g]
169
- else []
170
- for g in range(num_groups)
171
- ],
164
+ num_groups = len(meta.local_block_ids)
165
+ 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)
171
+ ]
172
+
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],
189
+ 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],
194
+ )
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
200
+ )
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
)
173
- for rank in plan.all_source_ranks
174
- ]
213
214
# D may have to perform multiple reads from different remote ranks.
215
# Pure MLA reads once because its cache is replicated. Hybrid
178
- # MLA+SSM still needs one read per SSM source rank.
179
- if self.use_mla and tp_ratio < 0 and not self._has_mamba:
216
+ # MLA+SSM still needs one read per SSM source rank. With DCP, pure
217
+ # MLA may also read from multiple ranks (disjoint token slices).
218
+ if self.use_mla and tp_ratio < 0 and not self._has_mamba and not dcp_active:
219
assert len(read_specs) == 1
220
221
for i, spec in enumerate(read_specs):
@@ -214,12 +253,15 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
253
remote_request_id=meta.remote.request_id,
254
local_xfer_side_handle=local_xfer_side_handle,
255
remote_xfer_side_handle=remote_xfer_side_handle,
256
+ expected_consumers=plan.local_consumers,
257
)
258
259
if self.use_mla and tp_ratio < 0 and len(read_specs) == 1:
260
# ..but we still need to notify the other remote ranks that we
261
# have the blocks we need so they can update the request state.
222
- notif_id = f"{meta.remote.request_id}:{self.world_size}".encode()
262
+ # Same thing for DCP (tp_size == dcp_size), so the raw tp_ratio already
263
+ # reflects whether any remote replica is left unchosen.
264
+ notif_id = f"{meta.remote.request_id}:{plan.local_consumers}".encode()
265
remote_agents = self._remote_agents[meta.remote.engine_id]
266
for rank_to_notify, agent in remote_agents.items():
267
if rank_to_notify != (0, read_specs[0].remote_rank):
@@ -233,6 +275,7 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
275
remote_request_id: str,
276
local_xfer_side_handle: int,
277
remote_xfer_side_handle: int,
278
+ expected_consumers: int,
279
):
280
"""
281
Post a READ point-to-point xfer request from a single local worker to
@@ -262,13 +305,13 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
305
# NOTE(rob): according to nvidia the staging blocks are used to
306
# saturate IB with heterogeneous TP sizes.
307
265
- # Number of D TP workers that will read from dst P. Propagate info
266
- # on notification so that dst worker can wait before freeing blocks.
267
- notif_id = f"{remote_request_id}:{self.world_size}".encode()
308
+ # Number of local workers that will notify this producer worker.
309
+ # Propagate on notification so dst worker can wait before freeing.
310
+ notif_id = f"{remote_request_id}:{expected_consumers}".encode()
311
312
# Full prefix cache hit: do not need to read remote blocks,
313
# just notify P worker that we have the blocks we need.
271
- if len(local_block_ids) == 0:
314
+ if not any(len(group) > 0 for group in local_block_ids):
315
# A full prefix cache hit is indicated with an empty list.
316
agent_name = self._remote_agents[dst_engine_id][(0, remote_rank)]
317
try:
@@ -292,12 +335,19 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
335
== len(local_block_ids)
336
== len(self.kv_cache_config.transfer_groups)
337
)
295
- local_block_ids, remote_block_ids = self._apply_prefix_caching(
296
- decode_block_ids=local_block_ids,
297
- prefill_block_ids=remote_block_ids,
298
- decode_physical_per_logical=self._physical_blocks_per_logical_kv_block,
299
- prefill_physical_per_logical=remote_info.remote_physical_blocks_per_logical,
300
- )
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(
342
+ decode_block_ids=local_block_ids,
343
+ 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
+ ),
350
+ )
351
352
# NOTE (nicolo) With homogeneous TP, each TP worker loads KV from
353
# corresponding rank. With heterogeneous TP, fixing D>P, the D tp
@@ -351,8 +401,8 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
401
def _get_new_notifs(self) -> set[str]:
402
"""
403
Get req_ids which got a remote xfer message. When multiple consumers
354
- are reading from the same producer (heterogeneous TP scenario), wait
355
- for all consumers to be done pulling.
404
+ are reading from the same producer (heterogeneous TP or DCP
405
+ scenario), wait for all consumers to be done pulling.
406
407
Also handles heartbeat notifications ("HB:req1,req2,...") by
408
extending the lease on the referenced requests.
@@ -368,7 +418,7 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
418
self._handle_heartbeat(msg[3:])
419
continue
420
371
- req_id, tp_size = msg.rsplit(":", 1)
421
+ req_id, expected_consumers = msg.rsplit(":", 1)
422
if (
423
req_id not in self._reqs_to_send
424
and req_id not in self._reqs_to_process
@@ -381,24 +431,22 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
431
)
432
continue
433
384
- # NOTE: `tp_ratio` is the opposite when swapping local<>remote
385
- n_consumers = int(tp_size)
386
- tp_ratio = self.transfer_topo.tp_ratio(n_consumers)
387
-
388
- # Number of reads *per producer* to wait for.
389
- # When remote D TP > local P TP we expect `tp_ratio` reads.
390
- consumers_per_producer = (
391
- -tp_ratio if n_consumers > self.world_size else 1
434
+ # Every reader of this req_id reports the same count (it's
435
+ # derived from aggregate topology, not the specific rank),
436
+ # so repeated notifications never disagree on it.
437
+ self.expected_consumer_notifications_by_req[req_id] = int(
438
+ expected_consumers
439
)
440
441
self.consumer_notification_counts_by_req[req_id] += 1
442
# Wait all consumers (D) to be done reading before freeing.
443
if (
444
self.consumer_notification_counts_by_req[req_id]
398
- == consumers_per_producer
445
+ == self.expected_consumer_notifications_by_req[req_id]
446
):
447
notified_req_ids.add(req_id)
448
del self.consumer_notification_counts_by_req[req_id]
449
+ del self.expected_consumer_notifications_by_req[req_id]
450
self._reqs_to_process.remove(req_id)
451
self._reqs_to_send.pop(req_id, None)
452
return notified_req_ids
vllm/distributed/kv_transfer/kv_connector/v1/nixl/push_scheduler.py
+2
-2
@@ -198,7 +198,7 @@ class NixlPushConnectorScheduler(NixlBaseConnectorScheduler):
198
# ReqMeta without a KeyError — the actual remote block IDs are
199
# learned by P over the NIXL handshake at WRITE time.
200
params["remote_block_ids"] = ()
201
- self._reqs_need_recv[request.request_id] = (request, local_block_ids)
201
+ self._reqs_need_recv[request.request_id] = (request, local_block_ids, ())
202
203
# Mark as processed so a re-entry (e.g. preemption + reschedule)
204
# doesn't re-stage the registration.
@@ -240,7 +240,7 @@ class NixlPushConnectorScheduler(NixlBaseConnectorScheduler):
240
# recv so the worker emits a notif that lets P free them.
241
# Seed remote_block_ids so add_new_req_to_recv won't KeyError.
242
params["remote_block_ids"] = ()
243
- self._reqs_need_recv[request.request_id] = (request, [])
243
+ self._reqs_need_recv[request.request_id] = (request, [], ())
244
params["do_remote_prefill"] = False
245
return False, None
246
vllm/distributed/kv_transfer/kv_connector/v1/nixl/tp_mapping.py
+23
-5
@@ -56,6 +56,10 @@ class TPMapping:
56
# FA head offset factor for hetero-TP (D_TP > P_TP).
57
rank_offset_factor: int
58
59
+ # Local ranks (in aggregate) that read from a given source rank. The producer frees
60
+ # a request's blocks only once that many notifications have come in.
61
+ local_consumers: int = 1
62
+
63
64
# ======================================================================
65
# TP mapping computation
@@ -66,22 +70,31 @@ def compute_tp_mapping(
70
transfer_topology: TransferTopology,
71
remote_tp_size: int,
72
group_spec_types: tuple[type[KVCacheSpec], ...],
73
+ remote_dcp_size: int = 1,
74
) -> TPMapping:
75
"""Build the complete local-to-remote TP mapping.
76
77
Computes source ranks, head slot assignments, and the rank offset
78
factor in a single pass.
79
+
80
+ DCP support is scoped to MLA only, with a side is either fully replicated or fully
81
+ sharded. DCP-branch reuses the same rank set used at handshake selection.
82
"""
83
tp_rank = transfer_topology.tp_rank
84
tp_size = transfer_topology.tp_size
85
total_num_kv_heads = transfer_topology.total_num_kv_heads
86
# --- Attention source ranks ---
87
if transfer_topology.is_mla or tp_size >= remote_tp_size:
80
- # D (local TP) > P (remote TP): multiple local ranks read different chunks from
81
- # *one* remote rank, corresponding to different kv heads.
82
- # For MLA, we only need one remote since cache is duplicated. When P TP=k*TP k,
83
- # this will spread mla ranks to read from remote k*tp_rank.
84
- attn_ranks = [tp_rank * remote_tp_size // tp_size]
88
+ if transfer_topology.is_mla and remote_dcp_size > 1:
89
+ attn_ranks = transfer_topology.dcp_source_ranks(
90
+ remote_tp_size, remote_dcp_size
91
+ )
92
+ else:
93
+ # D (local TP) > P (remote TP): multiple local ranks read different chunks
94
+ # from *one* remote rank, corresponding to different kv heads.
95
+ # For MLA, we only need one remote since cache is duplicated. When
96
+ # P TP=k*TP k, this will spread mla ranks to read from remote k*tp_rank.
97
+ attn_ranks = [tp_rank * remote_tp_size // tp_size]
98
else:
99
# P (remote TP) > D (local TP): one local rank
100
# reads from multiple remote ranks.
@@ -134,9 +147,14 @@ def compute_tp_mapping(
147
# D TP > P TP: we index into remote to read different heads depending on rank.
148
rank_offset_factor = tp_rank % (tp_size // remote_tp_size)
149
150
+ local_consumers = transfer_topology.dcp_consumer_count(
151
+ remote_tp_size, remote_dcp_size
152
+ )
153
+
154
return TPMapping(
155
source_ranks_per_group=source_ranks_per_group,
156
all_source_ranks=tuple(all_ranks),
157
rank_to_attention_slot=rank_to_attention_slot,
158
rank_offset_factor=rank_offset_factor,
159
+ local_consumers=local_consumers,
160
)
vllm/distributed/kv_transfer/kv_transfer_state.py
+2
@@ -85,6 +85,8 @@ def ensure_kv_transfer_initialized(
85
vllm_config.kv_transfer_config.is_kv_transfer_instance
86
and _KV_CONNECTOR_AGENT is None
87
):
88
+ # PD only supports an interleave_size equal to block_size.
89
+ vllm_config.adjust_dcp_kv_cache_interleave_size(kv_cache_config)
90
_sync_engine_id_across_tp(vllm_config)
91
92
_KV_CONNECTOR_AGENT = KVConnectorFactory.create_connector(
vllm/model_executor/layers/attention/mla_attention.py
-3
@@ -3157,9 +3157,6 @@ class MLACommonImpl(MLACommonBaseImpl[M], Generic[M]):
3157
parallel_config = get_current_vllm_config().parallel_config
3158
# Avoid requiring an initialized DCP group in tests.
3159
self.dcp_world_size: int = parallel_config.decode_context_parallel_size
3160
- self.cp_kv_cache_interleave_size: int = (
3161
- parallel_config.cp_kv_cache_interleave_size
3162
- )
3160
3161
@abstractmethod
3162
def forward_mqa(
vllm/model_executor/layers/sparse_attn_indexer.py
+17
-1
@@ -771,14 +771,30 @@ class SparseAttnIndexer(CustomOp):
771
parallel_config = get_current_vllm_config().parallel_config
772
self.dcp_world_size = parallel_config.decode_context_parallel_size
773
self.dcp_rank = get_dcp_group().rank_in_group if self.dcp_world_size > 1 else 0
774
- self.cp_kv_cache_interleave_size = parallel_config.cp_kv_cache_interleave_size
774
self.use_pcp = parallel_config.prefill_context_parallel_size > 1
775
+ self._cp_kv_cache_interleave_size: int | None = None
776
if current_platform.is_cuda() and not has_deep_gemm():
777
raise RuntimeError(
778
"Sparse Attention Indexer CUDA op requires DeepGEMM support in "
779
"the current vLLM environment."
780
)
781
782
+ @property
783
+ def cp_kv_cache_interleave_size(self) -> int:
784
+ """With PD+DCP, the real value isn't known until block_size is finalized,
785
+ which happens after this layer is built. Safe to cache after the first access,
786
+ as long as the adjustment always runs before any forward pass
787
+ (it's set up in Worker.initialize_from_config, ahead of warmup/serving).
788
+ """
789
+ if self._cp_kv_cache_interleave_size is None:
790
+ value = (
791
+ get_current_vllm_config().parallel_config.cp_kv_cache_interleave_size
792
+ )
793
+ if isinstance(get_forward_context().attn_metadata, dict):
794
+ self._cp_kv_cache_interleave_size = value
795
+ return value
796
+ return self._cp_kv_cache_interleave_size
797
+
798
def forward_native(
799
self,
800
hidden_states: torch.Tensor,
vllm/v1/attention/backends/mla/flashinfer_mla.py
-1
@@ -324,7 +324,6 @@ class FlashInferMLAImpl(MLACommonImpl[MLACommonMetadata]):
324
extra_kwargs: dict[str, Any] = {}
325
decode_backend: str | None
326
if self.dcp_world_size > 1:
327
- assert self.cp_kv_cache_interleave_size == 1
327
causal_seqlens_kv_global = attn_metadata.decode.dcp_tot_seq_lens
328
assert causal_seqlens_kv_global is not None
329
if not attn_metadata.causal and query_len > 1:
vllm/v1/worker/gpu/model_runner.py
+2
@@ -531,6 +531,8 @@ class GPUModelRunner(LoRAModelRunnerMixin):
531
is_profiling: bool = False,
532
kv_cache_allocation_context: AbstractContextManager | None = None,
533
) -> None:
534
+ # GPUWorker finalizes the PD interleave before KV cache initialization.
535
+ self.cp_interleave = self.parallel_config.cp_kv_cache_interleave_size
536
kv_cache_config = deepcopy(kv_cache_config)
537
self.kv_cache_config = kv_cache_config
538
vllm/v1/worker/gpu_model_runner.py
+8
@@ -749,6 +749,9 @@ class GPUModelRunner(
749
self._init_kernel_block_sizes = [placeholder_block_size]
750
self._init_max_num_blocks = [placeholder_max_num_blocks]
751
self._init_slot_mapping_modes = [SlotMappingMode.TOKEN_TO_KV_SLOT]
752
+ self.cp_kv_cache_interleave_size = (
753
+ self.parallel_config.cp_kv_cache_interleave_size
754
+ )
755
# Capture warmup providers registered by the initial placeholder InputBatch
756
with self.jit_warmup_registry.activate():
757
self.input_batch = InputBatch(
@@ -7350,11 +7353,16 @@ class GPUModelRunner(
7353
or kernel_block_sizes != self._init_kernel_block_sizes
7354
or max_num_blocks != self._init_max_num_blocks
7355
or slot_mapping_modes != self._init_slot_mapping_modes
7356
+ or self.cp_kv_cache_interleave_size
7357
+ != self.parallel_config.cp_kv_cache_interleave_size
7358
):
7359
self._init_block_sizes = block_sizes
7360
self._init_kernel_block_sizes = kernel_block_sizes
7361
self._init_max_num_blocks = max_num_blocks
7362
self._init_slot_mapping_modes = slot_mapping_modes
7363
+ self.cp_kv_cache_interleave_size = (
7364
+ self.parallel_config.cp_kv_cache_interleave_size
7365
+ )
7366
# Capture warmup providers registered after final KV-cache geometry is known
7367
with self.jit_warmup_registry.activate():
7368
self.input_batch = InputBatch(