[PCP][DCP] Support DCP target model with non-DCP Dspark (#56723)

Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Codex <noreply@openai.com>

Lucas Wilkinson committed Sep 26, 2026 at 18:12 UTC 927c87b3472a39da79a95a8df12d0a2b21e2d32e
32 files changed +606 -201
tests/v1/attention/test_attention_backends.py
+1 -2
@@ -942,8 +942,6 @@ def test_flashinfer_xqa_single_token_decode_preserves_cudagraph_padding(monkeypa
942 impl.o_sf_scale = None
943 impl.window_left = -1
944 impl.sinks = None
945 - impl.cache_config = unittest.mock.Mock()
946 - impl.cache_config.get_resolved_kv_cache_layout.return_value = KVCacheLayout.LBHNC
945
946 layer = unittest.mock.Mock(
947 _q_scale=torch.tensor(1.0),
@@ -959,6 +957,7 @@ def test_flashinfer_xqa_single_token_decode_preserves_cudagraph_padding(monkeypa
957 q_len_per_req=1,
958 )
959 attn_metadata = flashinfer_backend.FlashInferMetadata(
960 + kv_cache_layout=KVCacheLayout.LBHNC,
961 num_actual_tokens=2,
962 slot_mapping=torch.empty(0, dtype=torch.int64),
963 q_data_type_prefill=torch.bfloat16,
tests/v1/attention/test_group_head_counts.py
+1
@@ -101,6 +101,7 @@ def test_flash_attention_geometry_comes_from_the_group():
101 num_kv_heads=2,
102 head_size=64,
103 dtype=torch.bfloat16,
104 + dcp_sharded=True,
105 )
106
107 with (
tests/v1/core/test_kv_cache_utils.py
+143 -12
@@ -1599,19 +1599,142 @@ def test_project_kv_cache_groups_to_worker():
1599 assert set(proj_spec.kv_cache_specs.keys()) == {"layer1", "layer3"}
1600
1601
1602 -def test_dcp_world_size_for_kv_cache_spec_shards_full_attention_only():
1603 - dcp = 8
1604 - full = FullAttentionSpec(
1605 - block_size=16, num_kv_heads=1, head_size=1, dtype=torch.float32
1602 +@pytest.mark.parametrize("sliding_window", [None, 256])
1603 +@pytest.mark.parametrize("disable_hybrid", [False, True])
1604 +@pytest.mark.parametrize("pcp_size", [1, 4])
1605 +def test_dcp_target_allocates_replicated_draft_independently(
1606 + monkeypatch, sliding_window, disable_hybrid, pcp_size
1607 +):
1608 + """A draft must retain all positions even when the target shards them."""
1609 + from vllm.v1.attention.backends.utils import resolve_kv_cache_layout
1610 +
1611 + monkeypatch.delenv("VLLM_KV_CACHE_LAYOUT", raising=False)
1612 + config = VllmConfig(model_config=ModelConfig(max_model_len=1024))
1613 + config.parallel_config.decode_context_parallel_size = 4
1614 + config.parallel_config.prefill_context_parallel_size = pcp_size
1615 + config.scheduler_config.disable_hybrid_kv_cache_manager = disable_hybrid
1616 + config.cache_config.block_size = 16
1617 + config.cache_config.kv_cache_layout = None
1618 + draft_args = dict(
1619 + block_size=16,
1620 + num_kv_heads=8,
1621 + head_size=64,
1622 + dtype=torch.bfloat16,
1623 + dcp_sharded=False,
1624 + )
1625 + draft = (
1626 + FullAttentionSpec(**draft_args)
1627 + if sliding_window is None
1628 + else SlidingWindowSpec(**draft_args, sliding_window=sliding_window)
1629 )
1607 - mla = new_mla_spec()
1608 - mamba = new_mamba_spec()
1609 - uniform_mla = UniformTypeKVCacheSpecs(block_size=16, kv_cache_specs={"layer": mla})
1610 - assert kv_cache_utils.dcp_world_size_for_kv_cache_spec(full, dcp) == dcp
1611 - assert kv_cache_utils.dcp_world_size_for_kv_cache_spec(mla, dcp) == dcp
1612 - assert kv_cache_utils.dcp_world_size_for_kv_cache_spec(uniform_mla, dcp) == dcp
1613 - assert kv_cache_utils.dcp_world_size_for_kv_cache_spec(mamba, dcp) == 1
1614 - assert kv_cache_utils.dcp_world_size_for_kv_cache_spec(full, 1) == 1
1630 + specs = {"target": new_mla_spec(), "draft": draft}
1631 + layout = resolve_kv_cache_layout(config, [["LBHNC", "BLHNC"]], specs.values())
1632 + assert layout == KVCacheLayout.BLHNC
1633 + groups = get_kv_cache_groups(config, specs)
1634 + assert [group.layer_names for group in groups] == [["target"], ["draft"]]
1635 + widths = [g.kv_cache_spec.max_num_blocks_per_req(config, 1024) for g in groups]
1636 + assert widths == [16, 64]
1637 +
1638 + cache_config = kv_cache_utils.get_kv_cache_config_from_groups(
1639 + config, groups, available_memory=16 * 1024 * 1024
1640 + )
1641 + scheduler_config = generate_scheduler_kv_cache_config([cache_config])
1642 + assert get_max_concurrency_for_kv_cache_config(config, cache_config) > 0
1643 + assert get_max_concurrency_for_kv_cache_config(config, cache_config) == (
1644 + get_max_concurrency_for_kv_cache_config(config, scheduler_config)
1645 + )
1646 + manager = KVCacheManager(
1647 + scheduler_config,
1648 + max_model_len=1024,
1649 + hash_block_size=16,
1650 + scheduler_block_size=64,
1651 + dcp_world_size=4,
1652 + pcp_world_size=pcp_size,
1653 + enable_caching=True,
1654 + )
1655 + assert manager.coordinator.group_block_sizes == (64, 16)
1656 + request = make_request("replicated-draft", [1] * 65, block_size=16, hash_fn=sha256)
1657 + blocks = manager.allocate_slots(request, 65)
1658 + assert blocks is not None
1659 + assert [len(group) for group in blocks.blocks] == [2, 5]
1660 + manager.cache_blocks(request, 64)
1661 + cached_request = make_request(
1662 + "cached-draft", [1] * 65, block_size=16, hash_fn=sha256
1663 + )
1664 + cached_blocks, num_cached, _ = manager.get_computed_blocks(cached_request)
1665 + assert num_cached == 64
1666 + assert [len(group) for group in cached_blocks.blocks] == [1, 4]
1667 +
1668 +
1669 +@pytest.mark.parametrize("use_mla", [False, True])
1670 +def test_full_attention_merge_preserves_replicated_cache_geometry(use_mla):
1671 + config = VllmConfig(model_config=ModelConfig(max_model_len=1024))
1672 + config.parallel_config.decode_context_parallel_size = 4
1673 + draft = replace(
1674 + new_mla_spec() if use_mla else new_kv_cache_spec(),
1675 + block_size=16,
1676 + dcp_sharded=False,
1677 + )
1678 + merged = type(draft).merge([draft, draft])
1679 + wrapped = UniformTypeKVCacheSpecs.from_specs({"draft": merged})
1680 + assert wrapped is not None and not wrapped.dcp_sharded
1681 + assert merged.max_num_blocks_per_req(config, 1024) == 64
1682 + assert merged.max_memory_usage_bytes(config) == 64 * draft.page_size_bytes
1683 + assert kv_cache_utils.resolve_dcp_kv_block_size(merged, 4) == 16
1684 + with pytest.raises(AssertionError):
1685 + type(draft).merge([draft, replace(draft, dcp_sharded=True)])
1686 +
1687 +
1688 +@pytest.mark.parametrize("with_draft", [False, True])
1689 +@pytest.mark.parametrize("indexer_alignment", [None, 512])
1690 +def test_sparse_mla_preserves_physical_row_addressing(with_draft, indexer_alignment):
1691 + from vllm.v1.attention.backends.mla.sparse_utils import flat_kv_row_view
1692 + from vllm.v1.worker.utils import allocate_kv_cache
1693 +
1694 + config = VllmConfig(model_config=ModelConfig(max_model_len=1024))
1695 + config.cache_config.kv_cache_layout = "BLHNC"
1696 + config.parallel_config.decode_context_parallel_size = 4
1697 + common = dict(block_size=64, num_kv_heads=1, dtype=torch.uint8)
1698 + specs = {
1699 + "target": MLAAttentionSpec(
1700 + **common,
1701 + head_size=576,
1702 + state_content_bytes=656,
1703 + block_stride_alignment=656,
1704 + cache_dtype_str="fp8_ds_mla",
1705 + is_index_group_leader=True,
1706 + ),
1707 + "indexer": MLAAttentionSpec(
1708 + **common,
1709 + head_size=132,
1710 + cache_role=SparseCacheRole.INDEXER,
1711 + block_stride_alignment=indexer_alignment,
1712 + ),
1713 + "draft": SlidingWindowSpec(
1714 + block_size=64,
1715 + num_kv_heads=64,
1716 + head_size=64,
1717 + dtype=torch.bfloat16,
1718 + sliding_window=2048,
1719 + dcp_sharded=False,
1720 + ),
1721 + }
1722 + if not with_draft:
1723 + del specs["draft"]
1724 + groups = get_kv_cache_groups(config, specs)
1725 + cache_config = kv_cache_utils.get_kv_cache_config_from_groups(
1726 + config, groups, available_memory=8 * 1024 * 1024
1727 + )
1728 + caches = allocate_kv_cache(
1729 + cache_config, torch.device("cpu"), KVCacheLayout.BLHNC, [64] * len(groups)
1730 + )
1731 + cache = caches["target"].squeeze(1)
1732 + if indexer_alignment is not None:
1733 + assert caches["indexer"].stride(0) % indexer_alignment == 0
1734 + rows, stride_rows = flat_kv_row_view(cache, 64)
1735 + cache[1, 0].fill_(7)
1736 + torch.testing.assert_close(rows[stride_rows], cache[1, 0])
1737 + assert torch.all(rows[stride_rows] == 7)
1738
1739
1740 @pytest.mark.parametrize(
@@ -3815,6 +3938,8 @@ def test_unify_hybrid_kv_cache_specs():
3938 "layer_1": before_spec_1,
3939 "layer_2": before_spec_2,
3940 }
3941 + kv_cache_spec["draft_layer_1"] = replace(before_spec_1, dcp_sharded=False)
3942 + kv_cache_spec["draft_layer_2"] = replace(before_spec_2, dcp_sharded=False)
3943 kv_cache_utils.unify_hybrid_kv_cache_specs(kv_cache_spec)
3944 expected_spec_1 = new_kv_cache_spec(block_size=64)
3945 expected_spec_2 = new_kv_cache_spec(
@@ -3823,6 +3948,7 @@ def test_unify_hybrid_kv_cache_specs():
3948 assert kv_cache_spec["layer_1"] == expected_spec_1
3949 assert kv_cache_spec["layer_2"] == expected_spec_2
3950 assert kv_cache_spec["layer_2"].page_size_bytes == 64 * 1024
3951 + assert kv_cache_spec["draft_layer_2"] == replace(expected_spec_2, dcp_sharded=False)
3952
3953 # 2. has_full_attention and has_chunked_local_attention
3954 before_spec_1 = new_kv_cache_spec()
@@ -3874,6 +4000,11 @@ def test_unify_hybrid_kv_cache_specs():
4000 with pytest.raises(ValueError):
4001 kv_cache_utils.unify_hybrid_kv_cache_specs(kv_cache_spec)
4002
4003 + # Replicated Mamba state still requires the hybrid cache manager.
4004 + kv_cache_spec = {"attention": new_kv_cache_spec(), "mamba": new_mamba_spec()}
4005 + with pytest.raises(ValueError):
4006 + kv_cache_utils.unify_hybrid_kv_cache_specs(kv_cache_spec)
4007 +
4008
4009 def test_unify_kv_cache_spec_page_size_mamba():
4010 """Regression test for https://github.com/vllm-project/vllm/issues/43626.
tests/v1/core/test_prefix_caching.py
+12 -7
@@ -1336,12 +1336,12 @@ def make_kv_cache_config_three_types(
1336 )
1337
1338
1339 -def test_prefix_cache_hit_uses_per_group_dcp_geometry():
1339 +@pytest.mark.parametrize("draft_sharded", [True, False])
1340 +def test_prefix_cache_hit_uses_per_group_dcp_geometry(draft_sharded):
1341 """Prefix lookup must use each group's DCP size, not the process-wide one.
1342
1342 - Target and draft MLA are both sharded (DCP=8); Mamba stays replicated
1343 - (DCP=1). Hits then align to the sharded full-attention block, not the
1344 - unsharded page size.
1343 + Target MLA is sharded; draft MLA can opt out and Mamba stays replicated.
1344 + Hits must align to the target block in either case.
1345 """
1346 block_size = 16
1347 dcp = 8
@@ -1362,6 +1362,7 @@ def test_prefix_cache_hit_uses_per_group_dcp_geometry():
1362 KVCacheGroupSpec(
1363 ["draft_mla"],
1364 MLAAttentionSpec(
1365 + dcp_sharded=draft_sharded,
1366 block_size=block_size,
1367 num_kv_heads=1,
1368 head_size=1,
@@ -1388,10 +1389,10 @@ def test_prefix_cache_hit_uses_per_group_dcp_geometry():
1389 )
1390 target_mgr, draft_mgr, mamba_mgr = manager.coordinator.single_type_managers
1391 assert target_mgr.dcp_world_size == dcp
1391 - assert draft_mgr.dcp_world_size == dcp
1392 + assert draft_mgr.dcp_world_size == (dcp if draft_sharded else 1)
1393 assert mamba_mgr.dcp_world_size == 1
1394 assert target_mgr.block_size == sharded_block
1394 - assert draft_mgr.block_size == sharded_block
1395 + assert draft_mgr.block_size == (sharded_block if draft_sharded else block_size)
1396 assert mamba_mgr.block_size == block_size
1397
1398 hash_fn = sha256
@@ -1407,7 +1408,11 @@ def test_prefix_cache_hit_uses_per_group_dcp_geometry():
1408 req1 = make_request("1", common_token_ids + [100] * 5, block_size, hash_fn)
1409 computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
1410 assert num_computed_tokens == 2 * sharded_block
1410 - assert [len(group) for group in computed_blocks.blocks] == [2, 2, 16]
1411 + assert [len(group) for group in computed_blocks.blocks] == [
1412 + 2,
1413 + 2 if draft_sharded else 16,
1414 + 16,
1415 + ]
1416
1417 manager.free(req0)
1418 manager.free(req1)
tests/v1/e2e/spec_decode/draft_model/test_draft_model.py
+33
@@ -11,6 +11,7 @@ from vllm.config import CompilationConfig, VllmConfig, replace
11 from vllm.config.kernel import MoEBackend
12 from vllm.engine.arg_utils import EngineArgs
13 from vllm.platforms import current_platform
14 +from vllm.v1.spec_decode.draft_model import DraftModelProposer
15
16 from ..utils import (
17 Messages,
@@ -196,6 +197,38 @@ def test_draft_model_engine_args_tensor_parallelism():
197 assert draft_config.quant_config is None
198
199
200 +@pytest.mark.cpu_test
201 +@pytest.mark.parametrize("dcp_size", [1, 2])
202 +def test_draft_model_inherits_target_dcp_config(monkeypatch, dcp_size):
203 + monkeypatch.setattr(current_platform, "device_count", lambda: 16)
204 + target_config = EngineArgs(
205 + model="Qwen/Qwen3-1.7B",
206 + tensor_parallel_size=16,
207 + decode_context_parallel_size=dcp_size,
208 + cp_kv_cache_interleave_size=16,
209 + dcp_comm_backend="ag_rs",
210 + distributed_executor_backend="mp",
211 + speculative_config={
212 + "model": "Qwen/Qwen3-0.6B",
213 + "method": "draft_model",
214 + "num_speculative_tokens": 3,
215 + },
216 + ).create_engine_config()
217 + proposer = object.__new__(DraftModelProposer)
218 + proposer.vllm_config = target_config
219 + proposer.speculative_config = target_config.speculative_config
220 + draft_config = proposer._create_draft_vllm_config()
221 + draft_parallel = draft_config.parallel_config
222 + assert draft_parallel.decode_context_parallel_size == dcp_size
223 + assert draft_parallel.cp_kv_cache_interleave_size == 16
224 + assert draft_parallel.dcp_comm_backend == "ag_rs"
225 + assert (
226 + draft_parallel.dcp_q_replicate == target_config.parallel_config.dcp_q_replicate
227 + )
228 + assert draft_parallel is not target_config.parallel_config
229 + assert target_config.parallel_config.decode_context_parallel_size == dcp_size
230 +
231 +
232 def _apply_draft_moe_backend(vllm_config: VllmConfig) -> VllmConfig:
233 """Replicate SpecDecodeBaseProposer._create_draft_vllm_config logic
234 so we can test it without instantiating a full proposer."""
tests/v1/kv_connector/unit/test_decode_bench_connector.py
+2 -1
@@ -500,7 +500,7 @@ def test_decode_bench_connector_skips_null_padding():
500
501
502 def test_decode_bench_connector_uses_per_group_dcp_block_sizes():
503 - """DCP scales full-attention blocks but not sliding-window blocks."""
503 + """DCP scales target blocks but not the replicated draft's window."""
504 block_size = 16
505 dcp_world_size = 2
506 vllm_config = create_vllm_config(
@@ -515,6 +515,7 @@ def test_decode_bench_connector_uses_per_group_dcp_block_sizes():
515 dtype=torch.float32,
516 )
517 sliding_window = SlidingWindowSpec(
518 + dcp_sharded=False,
519 block_size=block_size,
520 num_kv_heads=1,
521 head_size=1,
tests/v1/simple_kv_offload/test_scheduler.py
+2 -2
@@ -2316,8 +2316,8 @@ def _make_hybrid_attention_mamba_scheduler(
2316 def test_hybrid_store_uses_resolved_group_block_sizes() -> None:
2317 """Replicated groups must not be scaled by the DCP world size.
2318
2319 - ``dcp_world_size_for_kv_cache_spec`` gives full attention the process DCP
2320 - size and every other spec 1. Reconstructing a group's geometry as
2319 + The spec's ``dcp_sharded`` flag controls its effective block size.
2320 + Reconstructing a group's geometry as
2321 ``spec.block_size * cp_world_size`` over-scales the replicated groups, so
2322 the eager store scan believes far fewer of their blocks are ready and
2323 silently offloads only a fraction of them.
tests/v1/spec_decode/test_dflash_prepare_inputs.py
+68 -30
@@ -2,6 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 from types import SimpleNamespace
5 +from unittest.mock import patch
6
7 import numpy as np
8 import pytest
@@ -9,7 +10,7 @@ import torch
10
11 from vllm.v1.attention.backends.utils import PAD_SLOT_ID
12 from vllm.v1.worker.gpu.spec_decode.dflash.speculator import (
12 - prepare_dflash_inputs,
13 + DFlashSpeculator,
14 )
15
16 pytestmark = pytest.mark.skipif(
@@ -24,6 +25,7 @@ def _run_prepare(
25 cp_rank: int = 0,
26 cp_size: int = 1,
27 cp_interleave: int = 1,
28 + draft_dcp_size: int | None = None,
29 ):
30 device = torch.device("cuda")
31 max_num_reqs = 4
@@ -40,7 +42,9 @@ def _run_prepare(
42 )
43 input_batch = SimpleNamespace(
44 num_reqs=1,
45 + num_tokens=4,
46 num_scheduled_tokens=np.array([4], dtype=np.int32),
47 + seq_lens_cpu_upper_bound=torch.tensor([target_positions[-1] + 1]),
48 positions=torch.tensor(target_positions, dtype=torch.int64, device=device),
49 query_start_loc=torch.tensor([0, 4], dtype=torch.int32, device=device),
50 idx_mapping=torch.tensor([2], dtype=torch.int32, device=device),
@@ -74,36 +78,65 @@ def _run_prepare(
78 next_prefill_tokens = torch.zeros_like(last_sampled)
79 block_table = torch.tensor([block_table_values], dtype=torch.int32, device=device)
80
77 - prepare_dflash_inputs(
78 - input_buffers,
79 - query_slot_mapping,
80 - context_positions,
81 - context_slot_mapping,
82 - sample_indices,
83 - sample_pos,
84 - sample_idx_mapping,
85 - temperature,
86 - seeds,
87 - input_batch,
88 - torch.tensor([1], dtype=torch.int32, device=device),
89 - torch.tensor([2], dtype=torch.int32, device=device),
90 - last_sampled,
91 - next_prefill_tokens,
92 - input_temperature,
93 - input_seeds,
94 - block_table,
95 - 4,
96 - cp_rank,
97 - cp_size,
98 - cp_interleave,
99 - 123,
100 - num_speculative_steps,
101 - num_speculative_steps,
102 - max_num_reqs,
103 - max_num_tokens,
104 - 128,
81 + class InputsPrepared(Exception):
82 + pass
83 +
84 + draft = SimpleNamespace(
85 + input_buffers=input_buffers,
86 + context_positions=context_positions,
87 + _context_slot_mappings=context_slot_mapping.unsqueeze(0),
88 + sample_indices=sample_indices,
89 + sample_pos=sample_pos,
90 + sample_idx_mapping=sample_idx_mapping,
91 + temperature=temperature,
92 + seeds=seeds,
93 + hidden_states=torch.zeros(4, 1, device=device),
94 + prepare_context_anchor=lambda *args: None,
95 + query_cudagraph_manager=None,
96 + dp_size=1,
97 + dp_rank=0,
98 + pcp_manager=None,
99 + draft_kv_cache_group_id=0,
100 + draft_kv_cache_group_ids=[0],
101 + _layer_group_idx=None,
102 + block_tables=SimpleNamespace(
103 + slot_mappings=query_slot_mapping.unsqueeze(0),
104 + input_block_tables=[block_table],
105 + kernel_block_sizes=[4],
106 + cp_rank=cp_rank,
107 + cp_size=cp_size,
108 + cp_interleave=cp_interleave,
109 + ),
110 + dcp_size=cp_size if draft_dcp_size is None else draft_dcp_size,
111 + parallel_drafting_token_id=123,
112 + num_query_per_req=num_speculative_steps,
113 + num_speculative_steps=num_speculative_steps,
114 + max_num_reqs=max_num_reqs,
115 + max_num_tokens=max_num_tokens,
116 + max_model_len=128,
117 sample_from_anchor=True,
118 )
119 + with (
120 + patch(
121 + "vllm.v1.worker.gpu.spec_decode.dflash.speculator.dispatch_cg_and_sync_dp",
122 + side_effect=InputsPrepared,
123 + ),
124 + pytest.raises(InputsPrepared),
125 + ):
126 + DFlashSpeculator.propose(
127 + draft,
128 + input_batch,
129 + {},
130 + {},
131 + draft.hidden_states,
132 + None,
133 + torch.tensor([1], dtype=torch.int32, device=device),
134 + torch.tensor([2], dtype=torch.int32, device=device),
135 + last_sampled,
136 + next_prefill_tokens,
137 + input_temperature,
138 + input_seeds,
139 + )
140 torch.accelerator.synchronize()
141 return SimpleNamespace(
142 input_buffers=input_buffers,
@@ -118,12 +151,17 @@ def _run_prepare(
151 )
152
153
121 -def test_prepare_dflash_inputs_excludes_rejected_context_suffix():
154 +@pytest.mark.parametrize("cp_rank,cp_size", [(0, 1), (0, 2), (1, 2)])
155 +def test_prepare_dflash_inputs_excludes_rejected_context_suffix(cp_rank, cp_size):
156 # Positions 10/11 use physical block 7. Rejected positions 12/13 would use
157 # block 8, but must be PAD context rather than contaminating draft KV.
158 out = _run_prepare(
159 target_positions=[10, 11, 12, 13],
160 block_table_values=[0, 0, 7, 8, 9, 10, 11, 12],
161 + cp_rank=cp_rank,
162 + cp_size=cp_size,
163 + cp_interleave=2,
164 + draft_dcp_size=1,
165 )
166
167 assert out.context_positions[:4].tolist() == [10, 11, 0, 0]
tests/v1/spec_decode/test_eagle_draft_attn_metadata.py
+9 -1
@@ -17,6 +17,7 @@ from types import MethodType, SimpleNamespace
17 from unittest.mock import patch
18
19 import numpy as np
20 +import pytest
21 import torch
22
23 from vllm.config.compilation import CUDAGraphMode
@@ -53,6 +54,7 @@ def _make_fake_speculator(
54 arange_np=np.arange(max_num_reqs + 1, dtype=np.int32),
55 draft_is_prefilling=torch.zeros(max_num_reqs, dtype=torch.bool),
56 block_tables=fake_block_tables,
57 + dcp_size=1,
58 input_buffers=fake_input_buffers,
59 attn_groups=[],
60 kv_cache_config=SimpleNamespace(kv_cache_groups=[]),
@@ -152,14 +154,17 @@ def test_build_draft_attn_metadata_clamps_to_max_model_len():
154 assert torch.equal(bound, torch.tensor([1024, 503], dtype=torch.int32))
155
156
155 -def test_build_draft_attn_metadata_recomputes_dcp_local_seq_lens():
157 +@pytest.mark.parametrize("draft_dcp_size", [1, 2])
158 +def test_build_draft_attn_metadata_recomputes_dcp_local_seq_lens(draft_dcp_size):
159 fake = _make_fake_speculator()
160 + fake.dcp_size = draft_dcp_size
161 fake.block_tables.cp_size = 2
162 fake.block_tables.cp_rank = 1
163 fake.block_tables.cp_interleave = 4
164 fake.input_buffers.seq_lens[:3] = torch.tensor([5, 9, 16])
165
166 def fake_prepare(out, seq_lens, num_reqs, dcp_size, dcp_rank, cp_interleave):
167 + assert draft_dcp_size > 1
168 assert seq_lens is fake.input_buffers.seq_lens
169 assert (num_reqs, dcp_size, dcp_rank, cp_interleave) == (3, 2, 1, 4)
170 out[:num_reqs].copy_(torch.tensor([1, 4, 8], dtype=torch.int32))
@@ -176,6 +181,9 @@ def test_build_draft_attn_metadata_recomputes_dcp_local_seq_lens():
181 )
182
183 local = captured["dcp_local_seq_lens"]
184 + if draft_dcp_size == 1:
185 + assert local is None
186 + return
187 assert isinstance(local, torch.Tensor)
188 assert local.data_ptr() == fake.input_buffers.dcp_local_seq_lens.data_ptr()
189 assert local.tolist() == [1, 4, 8, 0]
tests/v1/worker/test_cp_utils.py
+34
@@ -1,5 +1,7 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from types import SimpleNamespace
4 +
5 import pytest
6 import torch
7
@@ -7,6 +9,38 @@ from vllm.v1.attention.backends.utils import get_dcp_local_seq_lens
9 from vllm.v1.worker.cp_utils import should_skip_dcp_context_attention
10
11
12 +def test_cp_checks_allow_a_replicated_draft_backend_without_dcp(monkeypatch):
13 + from vllm.v1.worker import cp_utils
14 +
15 + config = SimpleNamespace(
16 + parallel_config=SimpleNamespace(
17 + prefill_context_parallel_size=1,
18 + decode_context_parallel_size=4,
19 + cp_kv_cache_interleave_size=16,
20 + ),
21 + speculative_config=object(),
22 + )
23 + target = SimpleNamespace(
24 + impl=SimpleNamespace(
25 + dcp_world_size=4,
26 + supports_mtp_with_cp_non_trivial_interleave_size=True,
27 + need_to_return_lse_for_decode=True,
28 + )
29 + )
30 + draft = SimpleNamespace(
31 + impl=SimpleNamespace(
32 + dcp_world_size=1,
33 + supports_mtp_with_cp_non_trivial_interleave_size=False,
34 + need_to_return_lse_for_decode=False,
35 + )
36 + )
37 + layers = {"target": target, "draft": draft}
38 + monkeypatch.setattr(cp_utils, "get_layers_from_vllm_config", lambda *args: layers)
39 + cp_utils.check_attention_cp_compatibility(config, target_layer_names={"target"})
40 + with pytest.raises(AssertionError, match="MTP with cp_kv_cache_interleave_size"):
41 + cp_utils.check_attention_cp_compatibility(config)
42 +
43 +
44 def test_skip_gate_only_for_zero_context():
45 assert should_skip_dcp_context_attention(torch.zeros(3, dtype=torch.int32))
46 assert not should_skip_dcp_context_attention(
tests/v1/worker/test_dspark_utils.py
+92
@@ -2,8 +2,17 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 from dataclasses import dataclass, field
5 +from types import SimpleNamespace
6
7 +import pytest
8 +import torch
9 +
10 +from vllm.config import ParallelConfig
11 +from vllm.v1.kv_cache_interface import FullAttentionSpec
12 +from vllm.v1.worker.gpu import model_runner
13 +from vllm.v1.worker.gpu.spec_decode.dflash.speculator import DFlashSpeculator
14 from vllm.v1.worker.gpu.spec_decode.dspark.utils import _get_dspark_parallel_config
15 +from vllm.v1.worker.gpu.spec_decode.speculator import DraftModelSpeculator
16
17
18 @dataclass
@@ -49,3 +58,86 @@ def test_dspark_parallel_config_disables_eplb_atomically():
58 assert draft_config.eplb_config.num_redundant_experts == 0
59 assert not draft_config.enable_elastic_ep
60 assert draft_config.eplb_config is not target_config.eplb_config
61 +
62 +
63 +@pytest.mark.parametrize("pcp_size", [1, 4])
64 +@pytest.mark.parametrize("dcp_size", [1, 4])
65 +@pytest.mark.parametrize("use_mla", [False, True])
66 +def test_draft_context_parallelism_without_changing_target(
67 + monkeypatch, pcp_size, dcp_size, use_mla
68 +):
69 + target_parallel = ParallelConfig(
70 + tensor_parallel_size=4,
71 + prefill_context_parallel_size=pcp_size,
72 + decode_context_parallel_size=dcp_size,
73 + cp_kv_cache_interleave_size=16,
74 + distributed_executor_backend="mp",
75 + )
76 + target_config = SimpleNamespace(
77 + parallel_config=target_parallel,
78 + speculative_config=SimpleNamespace(
79 + draft_model_config=SimpleNamespace(use_mla=use_mla)
80 + ),
81 + )
82 +
83 + class CapturedConfig(Exception):
84 + pass
85 +
86 + def capture_init(self, config, device):
87 + raise CapturedConfig(config)
88 +
89 + monkeypatch.setattr(DraftModelSpeculator, "__init__", capture_init)
90 + with pytest.raises(CapturedConfig) as captured:
91 + DFlashSpeculator(target_config, device=None)
92 + draft_parallel = captured.value.args[0].parallel_config
93 + assert draft_parallel.tensor_parallel_size == 4
94 + assert draft_parallel.prefill_context_parallel_size == 1
95 + assert draft_parallel.decode_context_parallel_size == (dcp_size if use_mla else 1)
96 + assert draft_parallel.cp_kv_cache_interleave_size == 16
97 + assert target_parallel.prefill_context_parallel_size == pcp_size
98 + assert target_parallel.decode_context_parallel_size == dcp_size
99 + assert target_parallel.cp_kv_cache_interleave_size == 16
100 +
101 +
102 +@pytest.mark.parametrize("configured_dcp", [1, 4])
103 +def test_attention_uses_draft_dcp_setting_inside_target_process_group(
104 + monkeypatch, configured_dcp
105 +):
106 + import vllm.config as config_module
107 + from vllm.distributed import parallel_state
108 + from vllm.v1.attention.backend import AttentionImplBase
109 +
110 + config = SimpleNamespace(
111 + parallel_config=SimpleNamespace(decode_context_parallel_size=configured_dcp)
112 + )
113 + monkeypatch.setattr(
114 + config_module, "get_current_vllm_config_or_none", lambda: config
115 + )
116 +
117 + monkeypatch.setattr(
118 + parallel_state, "_DCP", SimpleNamespace(world_size=4, rank_in_group=2)
119 + )
120 + impl = AttentionImplBase()
121 + assert impl.dcp_world_size == configured_dcp
122 + assert impl.dcp_rank == (0 if configured_dcp == 1 else 2)
123 +
124 +
125 +@pytest.mark.parametrize("draft_dcp_size", [1, 4])
126 +def test_runner_marks_only_replicated_draft_caches(monkeypatch, draft_dcp_size):
127 + spec = FullAttentionSpec(
128 + block_size=16, num_kv_heads=1, head_size=64, dtype=torch.bfloat16
129 + )
130 + monkeypatch.setattr(
131 + model_runner, "get_kv_cache_spec", lambda _: {"target": spec, "draft": spec}
132 + )
133 + runner = object.__new__(model_runner.GPUModelRunner)
134 + runner.vllm_config = None
135 + runner.dcp_size = 4
136 + runner.speculator = object.__new__(DFlashSpeculator)
137 + runner.speculator.dcp_size = draft_dcp_size
138 + runner.speculator.draft_attn_layer_names = {"draft"}
139 +
140 + specs = runner.get_kv_cache_spec()
141 +
142 + assert specs["target"].dcp_sharded
143 + assert specs["draft"].dcp_sharded == (draft_dcp_size == 4)
tests/v1/worker/test_gpu_autoregressive_speculator.py
+2
@@ -88,6 +88,7 @@ def test_pcp_draft_metadata_keeps_graph_padding_in_decode(cg_mode):
88 query_start_loc=torch.tensor([0, 1, 2, 2, 2], dtype=torch.int32),
89 seq_lens=torch.tensor([11, 21, 0, 0], dtype=torch.int32),
90 )
91 + speculator.dcp_size = 1
92 speculator.block_tables = SimpleNamespace(
93 cp_size=1,
94 input_block_tables=[torch.zeros(4, 1, dtype=torch.int32)],
@@ -183,6 +184,7 @@ def test_speculator_uses_draft_model_hidden_size(monkeypatch, hc_mult, expected)
184 parallel_config=SimpleNamespace(
185 data_parallel_size=1,
186 data_parallel_rank=0,
187 + decode_context_parallel_size=1,
188 ),
189 )
190
tests/v1/worker/test_gpu_block_table.py
+9 -7
@@ -171,22 +171,23 @@ def test_block_tables_skip_custom_slot_mapping_groups():
171
172 @pytest.mark.parametrize("cp_rank", range(4))
173 def test_dcp_slot_mapping_with_smaller_kernel_blocks(cp_rank: int):
174 - """DCP interleave is expressed in logical-block token coordinates."""
174 + """Only sharded groups use DCP interleave in logical-block coordinates."""
175 device = torch.device("cuda")
176 block_tables = BlockTables(
177 - block_sizes=[128],
177 + block_sizes=[128, 128],
178 max_num_reqs=1,
179 max_num_batched_tokens=1024,
180 - max_num_blocks_per_group=[2],
180 + max_num_blocks_per_group=[2, 8],
181 device=device,
182 - kernel_block_sizes=[64],
182 + kernel_block_sizes=[64, 64],
183 + dcp_sharded=[True, False],
184 cp_size=4,
185 cp_rank=cp_rank,
186 cp_interleave=128,
187 )
188 block_tables.append_block_ids(
189 req_index=0,
189 - new_block_ids=([5, 9],),
190 + new_block_ids=([5, 9], list(range(10, 18))),
191 overwrite=True,
192 )
193 block_tables.apply_staged_writes()
@@ -199,7 +200,7 @@ def test_dcp_slot_mapping_with_smaller_kernel_blocks(cp_rank: int):
200 query_start_loc,
201 positions,
202 num_tokens_padded=1024,
202 - )[0]
203 + )
204
205 expected = torch.full((1024,), -1, dtype=torch.int64, device=device)
206 first_start = cp_rank * 128
@@ -210,7 +211,8 @@ def test_dcp_slot_mapping_with_smaller_kernel_blocks(cp_rank: int):
211 expected[second_start : second_start + 128] = torch.arange(
212 9 * 128, 10 * 128, dtype=torch.int64, device=device
213 )
213 - assert torch.equal(actual, expected)
214 + assert torch.equal(actual[0], expected)
215 + assert torch.equal(actual[1], positions + 10 * 128)
216
217
218 def test_v1_block_table_move_row_clears_vacated_row():
tests/v1/worker/test_gpu_pcp_manager.py
+18
@@ -75,6 +75,24 @@ def test_validate_config_rejects_full_graph_for_prefills():
75 )
76
77
78 +@pytest.mark.parametrize("draft_uses_mla", [False, True])
79 +@pytest.mark.parametrize("dcp_size", [1, 2, 4])
80 +def test_validate_config_dcp_for_dspark(draft_uses_mla, dcp_size):
81 + config = _make_config(CUDAGraphMode.NONE)
82 + config.parallel_config.prefill_context_parallel_size = 4
83 + config.parallel_config.decode_context_parallel_size = dcp_size
84 + config.model_config.hf_text_config.index_topk = 1
85 + config.speculative_config = SimpleNamespace(
86 + use_dspark=lambda: True,
87 + draft_model_config=SimpleNamespace(use_mla=draft_uses_mla),
88 + )
89 + if draft_uses_mla and dcp_size == 2:
90 + with pytest.raises(NotImplementedError, match="requires DCP=1 or DCP=PCP"):
91 + PCPManager.validate_config(config, supports_mm_inputs=False)
92 + return
93 + PCPManager.validate_config(config, supports_mm_inputs=False)
94 +
95 +
96 def test_replicated_decode_piecewise_graph_padding(monkeypatch):
97 manager = PCPManager(
98 pcp_world_size=2,
vllm/distributed/kv_transfer/kv_connector/v1/decode_bench_connector.py
+1 -8
@@ -59,7 +59,6 @@ from vllm.platforms import current_platform
59 from vllm.utils.math_utils import cdiv
60 from vllm.v1.attention.backend import AttentionMetadata
61 from vllm.v1.core.kv_cache_utils import (
62 - dcp_world_size_for_kv_cache_spec,
62 resolve_dcp_kv_block_size,
63 )
64 from vllm.v1.kv_cache_interface import (
@@ -259,13 +258,7 @@ class DecodeBenchConnectorScheduler:
258 dcp_world_size = vllm_config.parallel_config.decode_context_parallel_size
259 self.kv_cache_groups = kv_cache_config.kv_cache_groups
260 self.group_block_sizes = tuple(
262 - resolve_dcp_kv_block_size(
263 - group.kv_cache_spec,
264 - dcp_world_size_for_kv_cache_spec(
265 - group.kv_cache_spec,
266 - dcp_world_size,
267 - ),
268 - )
261 + resolve_dcp_kv_block_size(group.kv_cache_spec, dcp_world_size)
262 for group in self.kv_cache_groups
263 )
264
vllm/distributed/parallel_state.py
+8
@@ -1583,6 +1583,14 @@ def get_dcp_group() -> GroupCoordinator:
1583 return _DCP
1584
1585
1586 +def get_dcp_world_size_and_rank(enabled: bool = True) -> tuple[int, int]:
1587 + """Return ``(world_size, rank)`` in the DCP group, or ``(1, 0)`` when disabled
1588 + (e.g. a replicated draft cache) or the group is uninitialized (unit tests)."""
1589 + if not enabled or _DCP is None:
1590 + return 1, 0
1591 + return _DCP.world_size, _DCP.rank_in_group
1592 +
1593 +
1594 _PP: GroupCoordinator | None = None
1595
1596
vllm/v1/attention/backend.py
+7 -8
@@ -870,15 +870,14 @@ class AttentionImplBase(ABC, Generic[T]):
870 def __new__(cls, *args, **kwargs):
871 # use __new__ so that all subclasses will call this
872 self = super().__new__(cls)
873 - try:
874 - from vllm.distributed.parallel_state import get_dcp_group
873 + from vllm.config import get_current_vllm_config_or_none
874 + from vllm.distributed.parallel_state import get_dcp_world_size_and_rank
875
876 - self.dcp_world_size = get_dcp_group().world_size
877 - self.dcp_rank = get_dcp_group().rank_in_group
878 - except AssertionError:
879 - # DCP might not be initialized in testing
880 - self.dcp_world_size = 1
881 - self.dcp_rank = 0
876 + # Replicated drafts run at DCP=1 inside a DCP target's process group.
877 + config = get_current_vllm_config_or_none()
878 + self.dcp_world_size, self.dcp_rank = get_dcp_world_size_and_rank(
879 + config is None or config.parallel_config.decode_context_parallel_size > 1
880 + )
881 try:
882 from vllm.distributed.parallel_state import get_pcp_group
883
vllm/v1/attention/backends/flash_attn.py
+7 -10
@@ -60,7 +60,10 @@ from vllm.config import (
60 get_layers_from_vllm_config,
61 )
62 from vllm.config.cache import CacheDType
63 -from vllm.distributed.parallel_state import get_dcp_group
63 +from vllm.distributed.parallel_state import (
64 + get_dcp_group,
65 + get_dcp_world_size_and_rank,
66 +)
67 from vllm.logger import init_logger
68 from vllm.platforms.interface import DeviceCapability
69 from vllm.utils.math_utils import cdiv, round_up
@@ -726,15 +729,9 @@ class FlashAttentionMetadataBuilder(AttentionMetadataBuilder[FlashAttentionMetad
729 num_qo_heads=self.num_heads_q,
730 )
731
729 - try:
730 - from vllm.distributed.parallel_state import get_dcp_group
731 -
732 - self.dcp_world_size = get_dcp_group().world_size
733 - self.dcp_rank = get_dcp_group().rank_in_group
734 - except AssertionError:
735 - # DCP might not be initialized in testing
736 - self.dcp_world_size = 1
737 - self.dcp_rank = 0
732 + self.dcp_world_size, self.dcp_rank = get_dcp_world_size_and_rank(
733 + kv_cache_spec.dcp_sharded
734 + )
735
736 # Fused draft decode reuses the captured metadata object across draft
737 # steps. For DCP, build-time host-side decisions such as
vllm/v1/attention/backends/flashinfer.py
+19 -25
@@ -30,7 +30,10 @@ from vllm.config import (
30 get_current_vllm_config_or_none,
31 )
32 from vllm.config.cache import CacheDType
33 -from vllm.distributed.parallel_state import get_dcp_group
33 +from vllm.distributed.parallel_state import (
34 + get_dcp_group,
35 + get_dcp_world_size_and_rank,
36 +)
37 from vllm.logger import init_logger
38 from vllm.model_executor.layers.quantization.utils.quant_utils import (
39 QuantKey,
@@ -641,6 +644,7 @@ class FlashInferTrtllmAPIDecode:
644
645 @dataclass
646 class FlashInferMetadata:
647 + kv_cache_layout: KVCacheLayout
648 num_actual_tokens: int
649 """Total number of tokens in the batch (excluding padding)."""
650
@@ -747,17 +751,12 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
751 self._decode_cudagraph_max_bs,
752 self.compilation_config.max_cudagraph_capture_size,
753 )
750 - try:
751 - self.dcp_world_size = get_dcp_group().world_size
752 - self.dcp_rank = get_dcp_group().rank_in_group
753 - self.dcp_kv_cache_interleave_size = (
754 - vllm_config.parallel_config.cp_kv_cache_interleave_size
755 - )
756 - except AssertionError:
757 - # DCP might not be initialized in testing
758 - self.dcp_world_size = 1
759 - self.dcp_rank = 0
760 - self.dcp_kv_cache_interleave_size = 1
754 + self.dcp_world_size, self.dcp_rank = get_dcp_world_size_and_rank(
755 + kv_cache_spec.dcp_sharded
756 + )
757 + self.dcp_kv_cache_interleave_size = (
758 + vllm_config.parallel_config.cp_kv_cache_interleave_size
759 + )
760 self.use_dcp = self.dcp_world_size > 1
761 self.dcp_a2a = (
762 self.use_dcp and vllm_config.parallel_config.dcp_comm_backend == "a2a"
@@ -1424,6 +1423,7 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1423 # Leave prefill/decode/cascade_wrapper empty, to be populated
1424 # case by case depending on the batch contents and backend selection.
1425 attn_metadata = FlashInferMetadata(
1426 + kv_cache_layout=self.kv_cache_layout,
1427 num_actual_tokens=num_actual_tokens,
1428 slot_mapping=common_attn_metadata.slot_mapping,
1429 q_data_type_prefill=self.q_data_type_prefill,
@@ -1904,8 +1904,6 @@ class FlashInferImpl(AttentionImpl):
1904 num_heads, num_kv_heads, is_prefill=False
1905 )
1906 vllm_config = get_current_vllm_config_or_none()
1907 - # The layout is resolved after model construction, so read it lazily.
1908 - self.cache_config = vllm_config.cache_config if vllm_config else None
1907 # Query pre-quantization needs a single dtype for the whole query tensor.
1908 # SM90 XQA needs BF16/FP16-Q for decode and FP8 for prefill,
1909 # so only enable this for SM100 trtllm-gen where both use FP8-Q.
@@ -1941,11 +1939,6 @@ class FlashInferImpl(AttentionImpl):
1939 else:
1940 self.dcp_combine = partial(cp_lse_ag_out_rs, is_lse_base_on_e=False)
1941
1944 - @property
1945 - def kv_cache_layout(self) -> KVCacheLayout:
1946 - assert self.cache_config is not None
1947 - return self.cache_config.get_resolved_kv_cache_layout()
1948 -
1942 def fused_output_quant_supported(self, quant_key: QuantKey):
1943 if quant_key == kNvfp4Dynamic and self.is_kvcache_nvfp4:
1944 logger.warning_once(
@@ -2111,6 +2104,7 @@ class FlashInferImpl(AttentionImpl):
2104 # performance to make sure it does not introduce any overhead.
2105
2106 num_actual_tokens = attn_metadata.num_actual_tokens
2107 + kv_cache_layout = attn_metadata.kv_cache_layout
2108
2109 # FlashInfer treats uint8 KV cache as NVFP4. vLLM stores FP8 KV cache
2110 # as uint8 bytes, so pass FP8 caches with their logical dtype.
@@ -2134,7 +2128,7 @@ class FlashInferImpl(AttentionImpl):
2128 if attn_metadata.use_cascade:
2129 # Cascade attention (rare case).
2130 assert attn_metadata.cascade_wrapper is not None
2137 - stride_order = self.kv_cache_layout.layer_view_order
2131 + stride_order = kv_cache_layout.layer_view_order
2132 if self.is_kvcache_nvfp4:
2133 kv_cache_views = tuple(
2134 cache.permute(*stride_order)
@@ -2154,7 +2148,7 @@ class FlashInferImpl(AttentionImpl):
2148 num_decode_tokens = attn_metadata.num_decode_tokens
2149 num_prefill_tokens = attn_metadata.num_prefill_tokens
2150
2157 - stride_order = self.kv_cache_layout.layer_view_order
2151 + stride_order = kv_cache_layout.layer_view_order
2152 kv_cache_permute = kv_cache.permute(*stride_order) # HND and contiguous
2153 # Fix degenerate strides on any size-1 dimension (e.g. num_kv_heads=1
2154 # with TP=8). PyTorch permits non-canonical strides on size-1 dims;
@@ -2311,7 +2305,7 @@ class FlashInferImpl(AttentionImpl):
2305 seq_lens_prefill = attn_metadata.prefill.seq_lens
2306
2307 # This path needs to be enabled with VLLM_KV_CACHE_LAYOUT = HND
2314 - assert get_flashinfer_layout_string(self.kv_cache_layout) == "HND"
2308 + assert get_flashinfer_layout_string(kv_cache_layout) == "HND"
2309 assert is_strictly_contiguous(prefill_query)
2310 assert is_strictly_contiguous(workspace_buffer)
2311 assert is_strictly_contiguous(block_tables_prefill)
@@ -2501,7 +2495,7 @@ class FlashInferImpl(AttentionImpl):
2495 # trtllm-gen needs HND layout on SM100. XQA is selected
2496 # separately on SM90 and does not use this SM100 layout gate.
2497 if decode_with_trtllm_gen:
2504 - assert get_flashinfer_layout_string(self.kv_cache_layout) == "HND"
2498 + assert get_flashinfer_layout_string(kv_cache_layout) == "HND"
2499 else:
2500 assert decode_with_xqa
2501 assert is_strictly_contiguous(decode_query)
@@ -2547,7 +2541,7 @@ class FlashInferImpl(AttentionImpl):
2541 window_left=self.window_left,
2542 out=output_padded[:decode_query_tokens],
2543 sinks=self.sinks,
2550 - kv_layout=get_flashinfer_layout_string(self.kv_cache_layout),
2544 + kv_layout=get_flashinfer_layout_string(kv_cache_layout),
2545 q_len_per_req=q_len_per_req,
2546 mask=attn_metadata.decode.mask,
2547 q_cu_seq_lens=attn_metadata.decode.q_cu_seq_lens,
@@ -2614,7 +2608,7 @@ class FlashInferImpl(AttentionImpl):
2608 sinks=self.sinks,
2609 o_sf_scale=self.o_sf_scale,
2610 out=out,
2617 - kv_layout=get_flashinfer_layout_string(self.kv_cache_layout),
2611 + kv_layout=get_flashinfer_layout_string(kv_cache_layout),
2612 backend=attn_metadata.decode.kernel.value,
2613 q_len_per_req=q_len_per_req,
2614 max_q_len=max_q_len,
vllm/v1/attention/backends/utils.py
+22 -2
@@ -21,7 +21,12 @@ from vllm.config.cache import _layout_from_name
21 from vllm.utils.gpu_sync_debug import gpu_sync_allowed
22 from vllm.utils.math_utils import cdiv
23 from vllm.utils.torch_utils import PIN_MEMORY, async_tensor_h2d, np_to_pinned_tensor
24 -from vllm.v1.kv_cache_interface import KVCacheLayout, KVCacheSpec, MambaSpec
24 +from vllm.v1.kv_cache_interface import (
25 + AttentionSpec,
26 + KVCacheLayout,
27 + KVCacheSpec,
28 + MambaSpec,
29 +)
30
31 if TYPE_CHECKING:
32 from vllm.v1.core.sched.output import SchedulerOutput
@@ -286,9 +291,10 @@ def resolve_kv_cache_layout(
291 # specs can re-interpret HNC with different sizes as long as the total number of
292 # bytes is the same. If not block-compact, each spec must agree on HNC to alias
293 # the same page (this aliasing is done by the Hybrid Memory Allocator, HMA).
294 + kv_cache_specs = tuple(kv_cache_specs or ())
295 hnc_shapes = {
296 (spec.num_heads, spec.num_states, spec.page_size_bytes)
291 - for spec in kv_cache_specs or ()
297 + for spec in kv_cache_specs
298 }
299 if len(hnc_shapes) > 1:
300 candidates = [m for m in candidates if m.is_block_compact]
@@ -298,6 +304,20 @@ def resolve_kv_cache_layout(
304 f"none is in every supported set: {supported_layouts}."
305 )
306
307 + dcp_sharding = {
308 + spec.dcp_sharded for spec in kv_cache_specs if isinstance(spec, AttentionSpec)
309 + }
310 + page_sizes = {spec.page_size_bytes for spec in kv_cache_specs}
311 + if len(dcp_sharding) > 1 and len(page_sizes) > 1:
312 + # Sharded target and replicated draft caches need independent groups.
313 + # Block-outer layouts can pack those groups without equalizing pages.
314 + candidates = [layout for layout in candidates if layout.is_block_outermost]
315 + if not candidates:
316 + raise ValueError(
317 + "DCP with a replicated draft and mixed KV page sizes requires "
318 + f"a block-outer KV cache layout; supported sets: {supported_layouts}."
319 + )
320 +
321 if (requested := envs.VLLM_KV_CACHE_LAYOUT) is not None:
322 layout = _layout_from_name(requested)
323 if layout not in candidates:
vllm/v1/core/kv_cache_coordinator.py
+13 -13
@@ -11,7 +11,6 @@ from vllm.v1.core.kv_cache_metrics import KVCacheMetricsCollector
11 from vllm.v1.core.kv_cache_utils import (
12 BlockHash,
13 KVCacheBlock,
14 - dcp_world_size_for_kv_cache_spec,
14 )
15 from vllm.v1.core.single_type_kv_cache_manager import (
16 CrossAttentionManager,
@@ -20,6 +19,7 @@ from vllm.v1.core.single_type_kv_cache_manager import (
19 get_manager_for_kv_cache_spec,
20 )
21 from vllm.v1.kv_cache_interface import (
22 + AttentionSpec,
23 FullAttentionSpec,
24 KVCacheConfig,
25 KVCacheSpec,
@@ -140,9 +140,7 @@ class KVCacheCoordinator(ABC):
140 role=kv_cache_group.role,
141 enable_caching=enable_caching,
142 kv_cache_group_id=i,
143 - dcp_world_size=dcp_world_size_for_kv_cache_spec(
144 - kv_cache_group.kv_cache_spec, dcp_world_size
145 - ),
143 + dcp_world_size=dcp_world_size,
144 pcp_world_size=pcp_world_size,
145 scheduler_block_size=self.scheduler_block_size,
146 needs_kv_cache_zeroing=self.kv_cache_config.needs_kv_cache_zeroing,
@@ -664,16 +662,18 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
662 f"hash_block_size. block_sizes={cacheable_block_sizes}, "
663 f"hash_block_size={hash_block_size}"
664 )
667 - assert pcp_world_size == 1, "PCP not support hybrid attn now."
668 - if dcp_world_size > 1:
669 - # DCP shards full-attention KV across ranks and replicates Mamba
670 - # state; other spec types (e.g. sliding window) have no DCP-aware
671 - # handling yet, so reject them explicitly.
672 - for g in kv_cache_config.kv_cache_groups:
673 - assert isinstance(g.kv_cache_spec, (FullAttentionSpec, MambaSpec)), (
665 + for g in kv_cache_config.kv_cache_groups:
666 + spec = g.kv_cache_spec
667 + replicated = not spec.dcp_sharded
668 + if pcp_world_size > 1:
669 + assert isinstance(spec, FullAttentionSpec) or (
670 + isinstance(spec, AttentionSpec) and replicated
671 + ), "PCP only supports full attention and replicated draft groups."
672 + if dcp_world_size > 1:
673 + assert isinstance(spec, FullAttentionSpec) or replicated, (
674 "DCP with hybrid KV cache layouts only supports "
675 - "full-attention and Mamba groups, got: "
676 - f"{type(g.kv_cache_spec).__name__}."
675 + "full-attention, Mamba, and replicated draft groups, got: "
676 + f"{type(spec).__name__}."
677 )
678 # Fine-grained hash hits require Mamba "align" and compatible cache
679 # managers in every group. TP needs hashing finer than the Mamba block;
vllm/v1/core/kv_cache_utils.py
+19 -41
@@ -682,12 +682,7 @@ def hash_block_tokens(
682
683 def resolve_dcp_kv_block_size(spec: KVCacheSpec, dcp_world_size: int) -> int:
684 """Return the token span of a cache block under DCP."""
685 - layer_specs = iter_layer_specs(spec)
686 - if len(layer_specs) > 0 and all(
687 - isinstance(layer_spec, AttentionSpec) for layer_spec in layer_specs
688 - ):
689 - return spec.block_size * dcp_world_size
690 - return spec.block_size
685 + return spec.block_size * (dcp_world_size if spec.dcp_sharded else 1)
686
687
688 def resolve_dcp_kv_cache_spec(spec: KVCacheSpec, dcp_world_size: int) -> KVCacheSpec:
@@ -707,28 +702,6 @@ def resolve_dcp_kv_cache_spec(spec: KVCacheSpec, dcp_world_size: int) -> KVCache
702 return replace(spec, block_size=block_size)
703
704
710 -def dcp_world_size_for_kv_cache_spec(spec: KVCacheSpec, dcp_world_size: int) -> int:
711 - """Return the DCP size that owns this group's block geometry.
712 -
713 - Full-attention KV (including MLA) is sharded across DCP ranks, so prefix
714 - hashing and manager ``block_size`` use the process DCP size. Other specs
715 - keep replicated per-rank state (Mamba, sliding window, chunked-local) and
716 - must keep ``dcp_world_size=1`` even when the process runs with DCP > 1.
717 -
718 - Draft MLA groups on the sharded DSpark path are ``FullAttentionSpec`` /
719 - ``MLAAttentionSpec`` and therefore keep the process DCP size. A replicated
720 - draft group would need a different spec, not this helper.
721 - """
722 - if dcp_world_size <= 1:
723 - return 1
724 - inner = spec
725 - if isinstance(spec, UniformTypeKVCacheSpecs):
726 - inner = next(iter(spec.kv_cache_specs.values()))
727 - if isinstance(inner, FullAttentionSpec):
728 - return dcp_world_size
729 - return 1
730 -
731 -
705 def resolve_kv_cache_block_sizes(
706 kv_cache_config: KVCacheConfig,
707 vllm_config: VllmConfig,
@@ -752,6 +725,8 @@ def resolve_kv_cache_block_sizes(
725 groups = kv_cache_config.kv_cache_groups
726
727 if len(groups) <= 1:
728 + if groups and not groups[0].kv_cache_spec.dcp_sharded:
729 + dcp = 1
730 bs = cache_config.block_size * dcp
731 return bs, bs
732
@@ -1935,18 +1910,21 @@ def unify_hybrid_kv_cache_specs(kv_cache_spec: dict[str, KVCacheSpec]):
1910 kv_cache_spec: The kv cache spec of each attention layer in the model
1911
1912 """
1938 - if is_kv_cache_spec_uniform(
1939 - kv_cache_spec
1940 - ) or UniformTypeKVCacheSpecs.is_uniform_type(kv_cache_spec):
1941 - return
1942 -
1943 - logger.warning(
1944 - "Hybrid KV cache manager is disabled for this hybrid model, "
1945 - "This means we do not enable any optimizations for saving KV cache "
1946 - "memory (e.g., dropping the KV cache outside the sliding window). "
1947 - "The compute of layers like sliding window is still saved."
1948 - )
1949 - kv_cache_spec.update(_promote_local_kv_cache_specs(kv_cache_spec))
1913 + groups: defaultdict[bool, dict[str, KVCacheSpec]] = defaultdict(dict)
1914 + for name, spec in kv_cache_spec.items():
1915 + replicated = isinstance(spec, AttentionSpec) and not spec.dcp_sharded
1916 + groups[replicated][name] = spec
1917 + for specs in groups.values():
1918 + promoted_specs = _promote_local_kv_cache_specs(specs)
1919 + if promoted_specs == specs:
1920 + continue
1921 + logger.warning(
1922 + "Hybrid KV cache manager is disabled for this hybrid model, "
1923 + "This means we do not enable any optimizations for saving KV cache "
1924 + "memory (e.g., dropping the KV cache outside the sliding window). "
1925 + "The compute of layers like sliding window is still saved."
1926 + )
1927 + kv_cache_spec.update(promoted_specs)
1928
1929
1930 def _approximate_gcd(values: Sequence[int], *, lower_bound: int | None = None) -> int:
@@ -2276,7 +2254,7 @@ def _ensure_min_page_size(
2254 scaled: list[KVCacheGroupSpec] = []
2255 for g in groups:
2256 s = g.kv_cache_spec
2279 - kw: dict[str, int] = {"block_size": s.block_size * scale}
2257 + kw: dict[str, Any] = {"block_size": s.block_size * scale}
2258 if isinstance(s, (AttentionSpec, MambaSpec)) and s.page_size_padded is not None:
2259 kw["page_size_padded"] = s.page_size_padded * scale
2260 scaled.append(KVCacheGroupSpec(g.layer_names, replace(s, **kw)))
vllm/v1/core/single_type_kv_cache_manager.py
+3 -9
@@ -106,11 +106,9 @@ class SingleTypeKVCacheManager(ABC):
106 # managers have been validated by the coordinator.
107 self.cache_hit_alignment_tokens = scheduler_block_size
108 # The block size for this manager; used for actual block allocation.
109 - self.block_size = kv_cache_spec.block_size
110 - self.dcp_world_size = dcp_world_size
111 - self.pcp_world_size = pcp_world_size
112 - if dcp_world_size > 1:
113 - self.block_size *= dcp_world_size
109 + self.dcp_world_size = dcp_world_size if kv_cache_spec.dcp_sharded else 1
110 + self.pcp_world_size = pcp_world_size if kv_cache_spec.dcp_sharded else 1
111 + self.block_size = kv_cache_spec.block_size * self.dcp_world_size
112 self.kv_cache_spec = kv_cache_spec
113 self.block_pool = block_pool
114 self.enable_caching = enable_caching
@@ -1455,10 +1453,6 @@ class MambaManager(SingleTypeKVCacheManager):
1453 self, kv_cache_spec: MambaSpec, block_pool: BlockPool, **kwargs
1454 ) -> None:
1455 super().__init__(kv_cache_spec, block_pool, **kwargs)
1458 - # Mamba layers use TP instead of DCP, so each rank holds the full
1459 - # recurrent state. Undo the DCP/PCP block_size scaling that the base
1460 - # class applies for attention groups whose KV cache is partitioned.
1461 - self.block_size = kv_cache_spec.block_size
1456 self.mamba_cache_mode = kv_cache_spec.mamba_cache_mode
1457 self.num_speculative_blocks: int = kv_cache_spec.num_speculative_blocks
1458 self.has_prefill_checkpoint_blocks = (
vllm/v1/kv_cache_interface.py
+24 -5
@@ -158,6 +158,8 @@ class KVCacheSpec:
158
159 # number of tokens in a block
160 block_size: int
161 + dcp_sharded: bool = field(default=False, kw_only=True)
162 + """Whether DCP shards this cache's token positions across ranks."""
163
164 block_stride_alignment: int | None = field(default=None, kw_only=True)
165 """Required byte alignment between physical blocks, including packed layers."""
@@ -481,6 +483,7 @@ class HiSparseResidentSpec(KVCacheSpec):
483
484 @dataclass(frozen=True, kw_only=True)
485 class AttentionSpec(KVCacheSpec):
486 + dcp_sharded: bool = True
487 num_kv_heads: int
488 head_size: int
489 dtype: torch.dtype
@@ -536,7 +539,9 @@ class AttentionSpec(KVCacheSpec):
539
540 def max_num_blocks_per_req(self, vllm_config: VllmConfig, max_len: int) -> int:
541 parallel_config = vllm_config.parallel_config
539 - kv_shard_count = parallel_config.decode_context_parallel_size
542 + kv_shard_count = (
543 + parallel_config.decode_context_parallel_size if self.dcp_sharded else 1
544 + )
545 return cdiv(max_len, self.block_size * kv_shard_count)
546
547
@@ -568,7 +573,7 @@ class FullAttentionSpec(AttentionSpec):
573 def max_memory_usage_bytes(self, vllm_config: VllmConfig) -> int:
574 max_model_len = vllm_config.model_config.max_model_len
575 dcp_world_size = vllm_config.parallel_config.decode_context_parallel_size
571 - if dcp_world_size > 1:
576 + if self.dcp_sharded and dcp_world_size > 1:
577 max_model_len = cdiv(max_model_len, dcp_world_size)
578 return cdiv(max_model_len, self.block_size) * self.page_size_bytes
579
@@ -612,6 +617,7 @@ class FullAttentionSpec(AttentionSpec):
617 head_size_v=specs[0].head_size_v,
618 dtype=specs[0].dtype,
619 kv_quant_mode=specs[0].kv_quant_mode,
620 + dcp_sharded=specs[0].dcp_sharded,
621 page_size_padded=specs[0].page_size_padded,
622 num_head_slots=specs[0].num_head_slots,
623 state_content_bytes=specs[0].state_content_bytes,
@@ -698,6 +704,7 @@ class MLAAttentionSpec(FullAttentionSpec):
704 head_size=specs[0].head_size,
705 dtype=specs[0].dtype,
706 kv_quant_mode=specs[0].kv_quant_mode,
707 + dcp_sharded=specs[0].dcp_sharded,
708 page_size_padded=specs[0].page_size_padded,
709 num_head_slots=specs[0].num_head_slots,
710 state_content_bytes=specs[0].state_content_bytes,
@@ -760,6 +767,7 @@ class RSWASpec(FullAttentionSpec):
767 head_size_v=base.head_size_v,
768 dtype=base.dtype,
769 kv_quant_mode=base.kv_quant_mode,
770 + dcp_sharded=base.dcp_sharded,
771 page_size_padded=base.page_size_padded,
772 num_head_slots=base.num_head_slots,
773 state_content_bytes=base.state_content_bytes,
@@ -850,9 +858,10 @@ class SlidingWindowSpec(AttentionSpec):
858 return cdiv(num_tokens, self.block_size) + 1
859
860 def max_memory_usage_bytes(self, vllm_config: VllmConfig) -> int:
853 - assert vllm_config.parallel_config.decode_context_parallel_size == 1, (
854 - "DCP not support sliding window."
855 - )
861 + assert (
862 + not self.dcp_sharded
863 + or vllm_config.parallel_config.decode_context_parallel_size == 1
864 + ), "DCP not support sliding window."
865 max_blocks = self.max_admission_blocks_per_request(
866 max_in_flight_tokens=vllm_config.max_in_flight_tokens,
867 max_model_len=vllm_config.model_config.max_model_len,
@@ -950,6 +959,7 @@ class SlidingWindowMLASpec(SlidingWindowSpec):
959 extra_retained_set = set(spec.extra_retained_tokens for spec in specs)
960 bounded_replay_set = set(spec.bounded_replay for spec in specs)
961 block_stride_alignment_set = {spec.block_stride_alignment for spec in specs}
962 + assert len({spec.dcp_sharded for spec in specs}) == 1
963 assert (
964 len(cache_dtype_str_set) == 1
965 and len(tokens_per_state_set) == 1
@@ -969,6 +979,7 @@ class SlidingWindowMLASpec(SlidingWindowSpec):
979 num_kv_heads=specs[0].num_kv_heads,
980 head_size=specs[0].head_size,
981 dtype=specs[0].dtype,
982 + dcp_sharded=specs[0].dcp_sharded,
983 page_size_padded=specs[0].page_size_padded,
984 num_head_slots=specs[0].num_head_slots,
985 state_content_bytes=specs[0].state_content_bytes,
@@ -1185,6 +1196,7 @@ class SinkFullAttentionSpec(FullAttentionSpec):
1196 block_stride_alignment=specs[0].block_stride_alignment,
1197 dtype=specs[0].dtype,
1198 kv_quant_mode=specs[0].kv_quant_mode,
1199 + dcp_sharded=specs[0].dcp_sharded,
1200 page_size_padded=specs[0].page_size_padded,
1201 num_head_slots=specs[0].num_head_slots,
1202 state_content_bytes=specs[0].state_content_bytes,
@@ -1217,6 +1229,11 @@ class UniformTypeKVCacheSpecs(KVCacheSpec):
1229
1230 kv_cache_specs: dict[str, KVCacheSpec]
1231
1232 + def __post_init__(self):
1233 + super().__post_init__()
1234 + if self.kv_cache_specs:
1235 + object.__setattr__(self, "dcp_sharded", self.first_spec.dcp_sharded)
1236 +
1237 @property
1238 def prefix_cacheable(self) -> bool:
1239 return all(spec.prefix_cacheable for spec in self.kv_cache_specs.values())
@@ -1270,6 +1287,8 @@ class UniformTypeKVCacheSpecs(KVCacheSpec):
1287 if len(block_sizes) > 1:
1288 # Different block sizes, not uniform.
1289 return False
1290 + if len({spec.dcp_sharded for spec in kv_cache_specs.values()}) > 1:
1291 + return False
1292 first_spec = next(iter(kv_cache_specs.values()))
1293 return first_spec.is_uniform_with_collection(kv_cache_specs)
1294
vllm/v1/simple_kv_offload/manager.py
+2 -6
@@ -27,13 +27,13 @@ from vllm.v1.core.kv_cache_coordinator import (
27 from vllm.v1.core.kv_cache_utils import (
28 BlockHashWithGroupId,
29 ExternalBlockHash,
30 - dcp_world_size_for_kv_cache_spec,
30 generate_block_hash_extra_keys,
31 get_block_hash,
32 get_group_id,
33 make_block_hash_with_group_id,
34 maybe_convert_block_hash,
35 resolve_block_hashes,
36 + resolve_dcp_kv_block_size,
37 )
38 from vllm.v1.core.sched.output import SchedulerOutput
39 from vllm.v1.kv_cache_interface import (
@@ -302,11 +302,7 @@ class SimpleCPUOffloadScheduler:
302 target = 0
303 for g in kv_cache_config.prefix_cacheable_groups:
304 spec = g.kv_cache_spec
305 - # Only full attention is sharded across DCP ranks; replicated specs
306 - # (mamba, sliding window, chunked-local) keep their own block size.
307 - block_size = spec.block_size * dcp_world_size_for_kv_cache_spec(
308 - spec, cp_world_size
309 - )
305 + block_size = resolve_dcp_kv_block_size(spec, cp_world_size)
306 if isinstance(spec, MambaSpec):
307 target += 2
308 elif isinstance(spec, SlidingWindowSpec):
vllm/v1/spec_decode/draft_model.py
+8 -1
@@ -81,13 +81,20 @@ class DraftModelProposer(SpecDecodeBaseProposer):
81 def _create_draft_vllm_config(self) -> VllmConfig:
82 base = super()._create_draft_vllm_config()
83 spec = self.speculative_config
84 + target_parallel = base.parallel_config
85
86 return replace(
87 base,
88 quant_config=None,
89 parallel_config=replace(
90 spec.draft_parallel_config,
90 - rank=self.vllm_config.parallel_config.rank,
91 + rank=target_parallel.rank,
92 + decode_context_parallel_size=(
93 + target_parallel.decode_context_parallel_size
94 + ),
95 + cp_kv_cache_interleave_size=target_parallel.cp_kv_cache_interleave_size,
96 + dcp_comm_backend=target_parallel.dcp_comm_backend,
97 + dcp_q_replicate=target_parallel.dcp_q_replicate,
98 ),
99 model_config=spec.draft_model_config,
100 )
vllm/v1/worker/cp_utils.py
+2
@@ -40,6 +40,8 @@ def check_attention_cp_compatibility(
40 layer_impl = getattr(layer, "impl", None)
41 if layer_impl is None:
42 continue
43 + if not check_pcp and layer_impl.dcp_world_size == 1:
44 + continue
45 if vllm_config.speculative_config is not None and interleave_size > 1:
46 assert layer_impl.supports_mtp_with_cp_non_trivial_interleave_size, (
47 "MTP with cp_kv_cache_interleave_size > 1 is not "
vllm/v1/worker/gpu/block_table.py
+11
@@ -27,6 +27,7 @@ class BlockTables:
27 cp_rank: int = 0,
28 cp_interleave: int = 1,
29 slot_mapping_enabled: list[bool] | None = None,
30 + dcp_sharded: list[bool] | None = None,
31 ):
32 self.block_sizes = block_sizes
33 self.kernel_block_sizes = kernel_block_sizes
@@ -44,6 +45,10 @@ class BlockTables:
45 slot_mapping_enabled = [True] * self.num_kv_cache_groups
46 assert len(slot_mapping_enabled) == self.num_kv_cache_groups
47 self._slot_mapping_enabled = slot_mapping_enabled
48 + if dcp_sharded is None:
49 + dcp_sharded = [True] * self.num_kv_cache_groups
50 + assert len(dcp_sharded) == self.num_kv_cache_groups
51 + self.dcp_sharded = torch.tensor(dcp_sharded, dtype=torch.bool, device=device)
52
53 self.blocks_per_kv_block = [
54 bs // kbs for bs, kbs in zip(block_sizes, kernel_block_sizes)
@@ -210,6 +215,7 @@ class BlockTables:
215 self.block_sizes_tensor,
216 self.kernel_block_sizes_tensor,
217 self.slot_mapping_enabled,
218 + self.dcp_sharded,
219 slot_mappings,
220 slot_mappings.stride(0),
221 self.cp_rank,
@@ -283,6 +289,7 @@ def _compute_slot_mappings_kernel(
289 block_sizes, # [num_kv_cache_groups]
290 kernel_block_sizes, # [num_kv_cache_groups]
291 slot_mapping_enabled, # [num_kv_cache_groups]
292 + dcp_sharded, # [num_kv_cache_groups]
293 slot_mappings_ptr, # [num_kv_cache_groups, max_num_tokens]
294 slot_mappings_stride,
295 cp_rank,
@@ -312,6 +319,8 @@ def _compute_slot_mappings_kernel(
319 kv_block_size = tl.load(block_sizes + group_id)
320 kernel_block_size = tl.load(kernel_block_sizes + group_id)
321 mapping_enabled = tl.load(slot_mapping_enabled + group_id)
322 + if CP_SIZE != 1:
323 + sharded = tl.load(dcp_sharded + group_id)
324
325 req_state_idx = tl.load(idx_mapping + batch_idx)
326 # idx_mapping == -1 marks a dummy (or CUDA-graph padding) request that owns
@@ -337,6 +346,8 @@ def _compute_slot_mappings_kernel(
346 remainder = virtual_block_offsets % CP_INTERLEAVE
347 local_offsets = rounds * CP_INTERLEAVE + remainder
348 local_positions = virtual_block_indices * kv_block_size + local_offsets
349 + local_positions = tl.where(sharded, local_positions, positions)
350 + is_local = ~sharded | is_local
351
352 block_indices = tl.where(
353 mapping_enabled, local_positions // kernel_block_size, 0
vllm/v1/worker/gpu/model_runner.py
+14 -1
@@ -21,6 +21,7 @@ import gc
21 import time
22 from contextlib import AbstractContextManager
23 from copy import deepcopy
24 +from dataclasses import replace
25 from typing import Any, NamedTuple
26
27 import numpy as np
@@ -561,7 +562,16 @@ class GPUModelRunner(LoRAModelRunnerMixin):
562 return encoder_runner.get_encoder_timing_stats()
563
564 def get_kv_cache_spec(self):
564 - return get_kv_cache_spec(self.vllm_config)
565 + kv_cache_spec = get_kv_cache_spec(self.vllm_config)
566 + # A draft running at DCP=1 under a DCP target keeps a replicated cache.
567 + if (
568 + isinstance(self.speculator, DraftModelSpeculator)
569 + and self.speculator.dcp_size == 1
570 + and self.dcp_size > 1
571 + ):
572 + for name in self.speculator.draft_attn_layer_names & kv_cache_spec.keys():
573 + kv_cache_spec[name] = replace(kv_cache_spec[name], dcp_sharded=False)
574 + return kv_cache_spec
575
576 def initialize_kv_cache(
577 self,
@@ -587,6 +597,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
597 block_sizes = []
598 max_num_blocks_per_group = []
599 slot_mapping_enabled = []
600 + dcp_sharded = []
601 for kv_cache_group in kv_cache_config.kv_cache_groups:
602 spec = kv_cache_group.kv_cache_spec
603 block_sizes.append(spec.block_size)
@@ -594,6 +605,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
605 spec.first_spec if isinstance(spec, UniformTypeKVCacheSpecs) else spec
606 )
607 slot_mapping_enabled.append(layer_spec.uses_slot_mapping)
608 + dcp_sharded.append(spec.dcp_sharded)
609 # Let each cache type account for CP. Attention KV is DCP-sharded,
610 # while Mamba/GDN recurrent state is replicated across DCP ranks.
611 max_num_blocks = spec.max_num_blocks_per_req(
@@ -657,6 +669,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
669 device=self.device,
670 kernel_block_sizes=self.kernel_block_sizes,
671 slot_mapping_enabled=slot_mapping_enabled,
672 + dcp_sharded=dcp_sharded,
673 cp_size=self.dcp_size,
674 cp_rank=self.dcp_rank,
675 cp_interleave=self.cp_interleave,
vllm/v1/worker/gpu/pcp_manager.py
+4 -1
@@ -145,7 +145,10 @@ class PCPManager:
145 if speculative_config is not None:
146 if speculative_config.use_dspark():
147 dcp_size = parallel_config.decode_context_parallel_size
148 - if dcp_size not in (1, pcp_size):
148 + if (
149 + dcp_size not in (1, pcp_size)
150 + and speculative_config.draft_model_config.use_mla
151 + ):
152 raise NotImplementedError(
153 "MRV2 PCP DSpark requires DCP=1 or DCP=PCP; got "
154 f"DCP={dcp_size}, PCP={pcp_size}."
vllm/v1/worker/gpu/spec_decode/dflash/speculator.py
+13 -7
@@ -37,12 +37,18 @@ class DFlashSpeculator(DraftModelSpeculator):
37
38 def __init__(self, vllm_config: VllmConfig, device: torch.device):
39 parallel_config = vllm_config.parallel_config
40 - if parallel_config.prefill_context_parallel_size > 1:
41 - vllm_config = copy.copy(vllm_config)
42 - vllm_config.parallel_config = replace(
43 - parallel_config,
44 - prefill_context_parallel_size=1,
45 - )
40 + speculative_config = vllm_config.speculative_config
41 + assert speculative_config is not None
42 + vllm_config = copy.copy(vllm_config)
43 + vllm_config.parallel_config = replace(
44 + parallel_config,
45 + prefill_context_parallel_size=1,
46 + decode_context_parallel_size=(
47 + parallel_config.decode_context_parallel_size
48 + if speculative_config.draft_model_config.use_mla
49 + else 1
50 + ),
51 + )
52 super().__init__(vllm_config, device)
53
54 self.hidden_states = torch.zeros(
@@ -427,7 +433,7 @@ class DFlashSpeculator(DraftModelSpeculator):
433 self.block_tables.input_block_tables[gid],
434 self.block_tables.kernel_block_sizes[gid],
435 self.block_tables.cp_rank,
430 - self.block_tables.cp_size,
436 + self.dcp_size,
437 self.block_tables.cp_interleave,
438 self.parallel_drafting_token_id,
439 self.num_query_per_req,
vllm/v1/worker/gpu/spec_decode/speculator.py
+3 -2
@@ -138,6 +138,7 @@ class DraftModelSpeculator(BaseSpeculator):
138 # DP configuration
139 self.dp_size = vllm_config.parallel_config.data_parallel_size
140 self.dp_rank = vllm_config.parallel_config.data_parallel_rank
141 + self.dcp_size = vllm_config.parallel_config.decode_context_parallel_size
142
143 self.eplb_state: EplbState | None = None
144
@@ -332,14 +333,14 @@ class DraftModelSpeculator(BaseSpeculator):
333 out=draft_seq_lens_cpu_upper_bound[:num_reqs],
334 )
335 draft_seq_lens_cpu_upper_bound[:num_reqs].clamp_(max=self.max_model_len)
335 - if dcp_local_seq_lens is None and self.block_tables.cp_size > 1:
336 + if dcp_local_seq_lens is None and self.dcp_size > 1:
337 # Draft steps advance and rewind their own global sequence lengths,
338 # so the target model's DCP-local lengths may already be stale.
339 dcp_local_seq_lens = prepare_dcp_local_seq_lens(
340 self.input_buffers.dcp_local_seq_lens,
341 self.input_buffers.seq_lens,
342 num_reqs,
342 - self.block_tables.cp_size,
343 + self.dcp_size,
344 self.block_tables.cp_rank,
345 self.block_tables.cp_interleave,
346 )