[Spec decode] Support Kimi-K3 DCP with DSpark (#52188)

Signed-off-by: wzhao18 <wzhao18.sz@gmail.com>

Wei Zhao committed Aug 17, 2026 at 16:08 UTC d1e3eee6fb8ed3623241ef5c8e3ac533f775bff9
15 files changed +341 -51
tests/transformers_utils/test_dspark_mla_config.py
-23
@@ -140,26 +140,3 @@ def test_dspark_mla_speculative_config_preserves_architecture(tmp_path):
140 assert speculative_config.draft_model_config.architectures == ["K3DSparkModel"]
141 assert speculative_config.draft_model_config.hf_config.model_type == "k3_dspark"
142 assert speculative_config.draft_model_config.use_mla
143 -
144 -
145 -def test_dspark_mla_rejects_decode_context_parallelism(tmp_path):
146 - target_path = tmp_path / "target"
147 - draft_path = tmp_path / "draft"
148 - _write_target_config(target_path)
149 - _write_dspark_config(draft_path)
150 - target_config = ModelConfig(
151 - model=str(target_path), tokenizer_mode="skip", max_model_len=32768
152 - )
153 -
154 - with pytest.raises(ValueError, match="does not currently support decode context"):
155 - SpeculativeConfig(
156 - model=str(draft_path),
157 - method="dspark",
158 - num_speculative_tokens=8,
159 - target_model_config=target_config,
160 - target_parallel_config=ParallelConfig(
161 - tensor_parallel_size=2,
162 - decode_context_parallel_size=2,
163 - distributed_executor_backend="external_launcher",
164 - ),
165 - )
tests/v1/attention/test_flashinfer_mla_dcp.py
+1
@@ -45,6 +45,7 @@ def test_flashinfer_mla_forward_uses_gathered_head_count(monkeypatch):
45 impl.bmm1_scale = 1.0
46 impl.bmm2_scale = 1.0
47 impl.need_to_return_lse_for_decode = True
48 + impl.dcp_world_size = 2
49 impl.num_heads = 6
50 impl.qk_nope_head_dim = 128
51 impl.kv_lora_rank = 512
tests/v1/attention/test_mla_backends.py
+77
@@ -837,9 +837,86 @@ def test_mock_mla_dcp_fp8_decode_gathers_quantized_query(
837 def test_tokenspeed_mla_noncausal_capability():
838 builder = tokenspeed_mla_module.TokenspeedMLAMetadataBuilder
839 assert builder.supports_non_causal_multi_token_decode
840 + assert builder.supports_non_causal_multi_token_dcp
841 assert tokenspeed_mla_module.TokenspeedMLABackend.supports_non_causal()
842
843
844 +def test_flashinfer_mla_dcp_multi_token_decode_uses_per_query_bounds(monkeypatch):
845 + flashinfer_mla_module = pytest.importorskip(
846 + "vllm.v1.attention.backends.mla.flashinfer_mla"
847 + )
848 +
849 + decode_call = None
850 +
851 + def fake_decode(**kwargs):
852 + nonlocal decode_call
853 + decode_call = kwargs
854 + query = kwargs["query"]
855 + output = torch.empty(*query.shape[:-1], 512, dtype=torch.bfloat16)
856 + lse = torch.empty(query.shape[0], query.shape[-2], dtype=torch.float32)
857 + return output, lse
858 +
859 + monkeypatch.setattr(
860 + flashinfer_mla_module,
861 + "trtllm_batch_decode_with_kv_cache_mla",
862 + fake_decode,
863 + )
864 + monkeypatch.setattr(
865 + flashinfer_mla_module,
866 + "_get_workspace_buffer",
867 + lambda return_lse: torch.empty(1, dtype=torch.int8),
868 + )
869 +
870 + impl = object.__new__(flashinfer_mla_module.FlashInferMLAImpl)
871 + impl.dcp_world_size = 2
872 + impl.dcp_rank = 1
873 + impl.cp_kv_cache_interleave_size = 1
874 + impl.need_to_return_lse_for_decode = True
875 + impl.kv_lora_rank = 512
876 + impl.qk_nope_head_dim = 128
877 + impl.qk_rope_head_dim = 64
878 + impl.bmm1_scale = 1.0
879 + impl.bmm2_scale = 1.0
880 +
881 + block_table = torch.tensor([[1], [2]], dtype=torch.int32)
882 + metadata = SimpleNamespace(
883 + num_decodes=2,
884 + num_decode_tokens=6,
885 + max_seq_len=7,
886 + causal=True,
887 + decode=SimpleNamespace(
888 + block_table=block_table,
889 + seq_lens=torch.tensor([5, 6], dtype=torch.int32),
890 + dcp_tot_seq_lens=torch.tensor([10, 13], dtype=torch.int32),
891 + flattened_block_table=None,
892 + flattened_seq_lens=None,
893 + query_len=0,
894 + ),
895 + )
896 + query = torch.empty(6, 2, 576, dtype=torch.bfloat16)
897 + kv_cache = torch.empty(3, 16, 576, dtype=torch.bfloat16)
898 +
899 + output, lse = impl.forward_mqa(
900 + query,
901 + kv_cache,
902 + metadata,
903 + SimpleNamespace(),
904 + )
905 +
906 + assert output.shape == (6, 2, 512)
907 + assert lse is not None
908 + assert lse.shape == (6, 2)
909 + assert decode_call is not None
910 + assert decode_call["query"].shape == (6, 1, 2, 576)
911 + torch.testing.assert_close(
912 + decode_call["seq_lens"],
913 + torch.tensor([4, 4, 5, 5, 6, 6], dtype=torch.int32),
914 + )
915 + torch.testing.assert_close(
916 + decode_call["block_tables"], block_table.repeat_interleave(3, dim=0)
917 + )
918 +
919 +
920 @pytest.mark.parametrize(
921 ("causal", "tokens_per_decode", "dcp_world_size", "dcp_rank"),
922 [
tests/v1/spec_decode/test_dflash_prepare_inputs.py
+25 -1
@@ -17,7 +17,14 @@ pytestmark = pytest.mark.skipif(
17 )
18
19
20 -def _run_prepare(*, target_positions: list[int], block_table_values: list[int]):
20 +def _run_prepare(
21 + *,
22 + target_positions: list[int],
23 + block_table_values: list[int],
24 + cp_rank: int = 0,
25 + cp_size: int = 1,
26 + cp_interleave: int = 1,
27 +):
28 device = torch.device("cuda")
29 max_num_reqs = 4
30 max_num_tokens = 16
@@ -86,6 +93,9 @@ def _run_prepare(*, target_positions: list[int], block_table_values: list[int]):
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,
@@ -131,6 +141,20 @@ def test_prepare_dflash_inputs_excludes_rejected_context_suffix():
141 assert out.seeds[2].item() == 17
142
143
144 +def test_prepare_dflash_inputs_excludes_rejected_context_suffix_with_dcp():
145 + out = _run_prepare(
146 + target_positions=[10, 11, 12, 13],
147 + block_table_values=[0, 7, 8, 9],
148 + cp_rank=1,
149 + cp_size=2,
150 + cp_interleave=2,
151 + )
152 +
153 + assert out.context_positions[:4].tolist() == [10, 11, 0, 0]
154 + assert out.context_slot_mapping[:4].tolist() == [28, 29, PAD_SLOT_ID, PAD_SLOT_ID]
155 + assert out.query_slot_mapping[:3].tolist() == [PAD_SLOT_ID, PAD_SLOT_ID, 30]
156 +
157 +
158 def test_prepare_dflash_inputs_never_writes_the_null_block():
159 # The valid context uses logical block 0 and the replacement query uses
160 # logical block 1. Both map to the null block and must remain unwritable.
vllm/config/speculative.py
-10
@@ -1059,16 +1059,6 @@ class SpeculativeConfig:
1059 if self.method in ("dflash", "dspark"):
1060 self.parallel_drafting = True
1061
1062 - if (
1063 - self.method == "dspark"
1064 - and "K3DSparkModel" in self.draft_model_config.architectures
1065 - and self.target_parallel_config.decode_context_parallel_size > 1
1066 - ):
1067 - raise ValueError(
1068 - "MLA DSpark does not currently support decode context "
1069 - "parallelism; set decode_context_parallel_size=1."
1070 - )
1071 -
1062 if self.num_speculative_tokens is not None and hasattr(
1063 self.draft_model_config.hf_config, "num_lookahead_tokens"
1064 ):
vllm/model_executor/layers/attention/mla_attention.py
+29
@@ -1984,6 +1984,9 @@ class MLACommonMetadataBuilder(AttentionMetadataBuilder[M]):
1984 # Whether this builder can flatten a non-causal query block into decode rows.
1985 supports_non_causal_multi_token_decode: ClassVar[bool] = False
1986
1987 + # Whether can support non-causal multi-token decode with DCP KV cache.
1988 + supports_non_causal_multi_token_dcp: ClassVar[bool] = False
1989 +
1990 # The threshold for reordering the batch into decode and prefill requests.
1991 # If > 1, the batch will be reordered such that requests with
1992 # query length <= threshold are classified as decode requests.
@@ -1991,6 +1994,31 @@ class MLACommonMetadataBuilder(AttentionMetadataBuilder[M]):
1994 # when speculative decoding is enabled.
1995 reorder_batch_threshold: int = 1
1996
1997 + def _validate_dspark_dcp_support(self, supports_dcp_with_varlen: bool) -> None:
1998 + speculative_config = getattr(self.vllm_config, "speculative_config", None)
1999 + parallel_config = self.vllm_config.parallel_config
2000 + if (
2001 + speculative_config is None
2002 + or getattr(speculative_config, "method", None) != "dspark"
2003 + or parallel_config.decode_context_parallel_size <= 1
2004 + ):
2005 + return
2006 +
2007 + if self.non_causal_multi_token_decode:
2008 + supported = self.supports_non_causal_multi_token_dcp
2009 + query_mode = "non-causal draft"
2010 + else:
2011 + supported = supports_dcp_with_varlen
2012 + query_mode = "causal multi-token"
2013 +
2014 + if not supported:
2015 + raise ValueError(
2016 + f"{type(self).__name__} does not support {query_mode} MLA "
2017 + "attention for DSpark with decode context parallelism. Select "
2018 + "a backend with explicit DSpark DCP support or set "
2019 + "decode_context_parallel_size=1."
2020 + )
2021 +
2022 @staticmethod
2023 def determine_chunked_prefill_workspace_size(vllm_config: VllmConfig) -> int:
2024 scheduler_config = vllm_config.scheduler_config
@@ -2084,6 +2112,7 @@ class MLACommonMetadataBuilder(AttentionMetadataBuilder[M]):
2112 self.non_causal_multi_token_decode = getattr(
2113 kv_cache_spec, "non_causal_multi_token_decode", False
2114 )
2115 + self._validate_dspark_dcp_support(supports_dcp_with_varlen)
2116
2117 # A draft cache group can have a different head count from the target.
2118 self.num_heads = get_num_attention_heads_from_layers(
vllm/models/kimi_k3/nvidia/mla.py
-5
@@ -341,11 +341,6 @@ class MultiHeadLatentAttention(nn.Module, AttentionLayerBase):
341 "parallelism."
342 )
343 self.dcp_world_size = parallel_config.decode_context_parallel_size
344 - assert self.dcp_world_size <= 1 or self.rotary_emb is None, (
345 - "Kimi-K3 MultiHeadLatentAttention does not support RoPE with decode "
346 - "context parallelism because gathered queries require gathered "
347 - "positions."
348 - )
344 self.dcp_manager: MLADCPManager | None = None
345 if self.dcp_world_size > 1:
346 query_dtype = (
vllm/v1/attention/backend.py
+8
@@ -330,6 +330,11 @@ class AttentionBackend(ABC):
330 except NotImplementedError:
331 return False
332
333 + @classmethod
334 + def supports_non_causal_dcp(cls) -> bool:
335 + builder_cls = cls.get_builder_cls()
336 + return bool(getattr(builder_cls, "supports_non_causal_multi_token_dcp", False))
337 +
338 @classmethod
339 def supports_attn_type(cls, attn_type: str) -> bool:
340 """Check if backend supports a given attention type.
@@ -378,6 +383,7 @@ class AttentionBackend(ABC):
383 use_kv_connector: bool = False,
384 use_pcp: bool = False,
385 use_adaptive_verification: bool = False,
386 + use_dcp: bool = False,
387 ) -> list[str]:
388 invalid_reasons = []
389 if not cls.supports_head_size(head_size):
@@ -414,6 +420,8 @@ class AttentionBackend(ABC):
420 invalid_reasons.append("sliding window not supported")
421 if use_non_causal and not cls.supports_non_causal():
422 invalid_reasons.append("non-causal attention not supported")
423 + if use_mla and use_non_causal and use_dcp and not cls.supports_non_causal_dcp():
424 + invalid_reasons.append("non-causal MLA attention with DCP not supported")
425 if use_batch_invariant and not cls.supports_batch_invariance():
426 invalid_reasons.append("batch invariance not supported")
427 if use_kv_connector and not cls.supports_kv_connector():
vllm/v1/attention/backends/mla/flashinfer_mla.py
+115 -7
@@ -1,7 +1,8 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -from typing import ClassVar
4 +from dataclasses import dataclass
5 +from typing import TYPE_CHECKING, ClassVar
6
7 import torch
8 from flashinfer.decode import trtllm_batch_decode_with_kv_cache_mla
@@ -15,6 +16,7 @@ from vllm.config.cache import CacheDType
16 from vllm.logger import init_logger
17 from vllm.model_executor.layers.attention.mla_attention import (
18 MLACommonBackend,
19 + MLACommonDecodeMetadata,
20 MLACommonImpl,
21 MLACommonMetadata,
22 MLACommonMetadataBuilder,
@@ -30,6 +32,10 @@ from vllm.v1.attention.backend import (
32 )
33 from vllm.v1.attention.backends.utils import KVCacheLayoutType
34
35 +if TYPE_CHECKING:
36 + from vllm.config import VllmConfig
37 + from vllm.v1.kv_cache_interface import AttentionSpec
38 +
39 logger = init_logger(__name__)
40
41
@@ -112,12 +118,56 @@ def _get_multi_ctas_kv_counter_buffer(
118 return _fi_multi_ctas_kv_counter
119
120
115 -class FlashInferMLAMetadataBuilder(MLACommonMetadataBuilder[MLACommonMetadata]):
121 +@dataclass
122 +class FlashInferMLADecodeMetadata(MLACommonDecodeMetadata):
123 + flattened_block_table: torch.Tensor | None = None
124 + flattened_seq_lens: torch.Tensor | None = None
125 + query_len: int = 0
126 +
127 +
128 +@dataclass
129 +class FlashInferMLAMetadata(MLACommonMetadata[FlashInferMLADecodeMetadata]):
130 + pass
131 +
132 +
133 +class FlashInferMLAMetadataBuilder(MLACommonMetadataBuilder[FlashInferMLAMetadata]):
134 _cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.UNIFORM_BATCH
135 query_len_support: ClassVar[QueryLenSupport] = QueryLenSupport.UNIFORM
136 # Non-causal DSpark blocks are flattened to single-token rows in forward_mqa.
137 supports_non_causal_multi_token_decode: ClassVar[bool] = True
138
139 + def __init__(
140 + self,
141 + kv_cache_spec: "AttentionSpec",
142 + layer_names: list[str],
143 + vllm_config: "VllmConfig",
144 + device: torch.device,
145 + ) -> None:
146 + super().__init__(
147 + kv_cache_spec,
148 + layer_names,
149 + vllm_config,
150 + device,
151 + FlashInferMLAMetadata,
152 + supports_dcp_with_varlen=True,
153 + )
154 +
155 + def _build_decode(
156 + self,
157 + block_table_tensor: torch.Tensor,
158 + seq_lens_device: torch.Tensor,
159 + max_seq_len: int,
160 + query_start_loc_cpu: torch.Tensor,
161 + query_start_loc_device: torch.Tensor,
162 + num_decode_tokens: int,
163 + dcp_tot_seq_lens_device: torch.Tensor | None,
164 + ) -> FlashInferMLADecodeMetadata:
165 + return FlashInferMLADecodeMetadata(
166 + block_table=block_table_tensor,
167 + seq_lens=seq_lens_device,
168 + dcp_tot_seq_lens=dcp_tot_seq_lens_device,
169 + )
170 +
171
172 class FlashInferMLABackend(MLACommonBackend):
173 supported_dtypes: ClassVar[list[torch.dtype]] = [torch.float16, torch.bfloat16]
@@ -193,7 +243,7 @@ class FlashInferMLABackend(MLACommonBackend):
243 return "HND"
244
245
196 -class FlashInferMLAImpl(MLACommonImpl[MLACommonMetadata]):
246 +class FlashInferMLAImpl(MLACommonImpl[FlashInferMLAMetadata]):
247 can_return_lse_for_decode: bool = True
248 # trtllm-gen MLA decode emits LSE in log2 (per flashinfer's own
249 # reference at flashinfer/trace/templates/attention.py:81:
@@ -264,7 +314,7 @@ class FlashInferMLAImpl(MLACommonImpl[MLACommonMetadata]):
314 self,
315 q: torch.Tensor | tuple[torch.Tensor, torch.Tensor],
316 kv_c_and_k_pe_cache: torch.Tensor,
267 - attn_metadata: MLACommonMetadata,
317 + attn_metadata: FlashInferMLAMetadata,
318 layer: AttentionLayer,
319 ) -> tuple[torch.Tensor, torch.Tensor | None]:
320 assert kv_c_and_k_pe_cache.numel() > 0
@@ -276,16 +326,28 @@ class FlashInferMLAImpl(MLACommonImpl[MLACommonMetadata]):
326
327 block_table = attn_metadata.decode.block_table
328 seq_lens = attn_metadata.decode.seq_lens
329 + query_len = attn_metadata.num_decode_tokens // attn_metadata.num_decodes
330
331 if not attn_metadata.causal:
332 # Non-causal DSpark block: flatten to single-token decode rows with
333 # per-row context seq_lens (trtllm-gen has no causal flag and would
334 # otherwise mask the block causally).
284 - query_len = attn_metadata.num_decode_tokens // attn_metadata.num_decodes
335 q = q.unsqueeze(1)
336 if query_len > 1:
287 - block_table = block_table.repeat_interleave(query_len, dim=0)
288 - seq_lens = seq_lens.repeat_interleave(query_len)
337 + block_table, seq_lens = self._prepare_flattened_decode_metadata(
338 + attn_metadata,
339 + query_len,
340 + causal=False,
341 + )
342 + elif self.dcp_world_size > 1 and query_len > 1:
343 + # Causal DCP block: flatten to single-token decode rows with
344 + # per-row rank-local seq_lens for each query's visible prefix.
345 + block_table, seq_lens = self._prepare_flattened_decode_metadata(
346 + attn_metadata,
347 + query_len,
348 + causal=True,
349 + )
350 + q = q.unsqueeze(1)
351 # trtllm API requires extra dimension q_len_per_request for MTP
352 elif attn_metadata.num_decode_tokens % attn_metadata.num_decodes != 0:
353 logger.warning_once(
@@ -358,3 +420,49 @@ class FlashInferMLAImpl(MLACommonImpl[MLACommonMetadata]):
420 o = o.view(-1, o.shape[-2], o.shape[-1])
421
422 return o, lse
423 +
424 + def _prepare_flattened_decode_metadata(
425 + self,
426 + attn_metadata: FlashInferMLAMetadata,
427 + query_len: int,
428 + *,
429 + causal: bool,
430 + ) -> tuple[torch.Tensor, torch.Tensor]:
431 + """Prepare flattened decode metadata once for all layers in the group."""
432 + decode = attn_metadata.decode
433 + assert decode is not None
434 + if decode.query_len:
435 + assert decode.query_len == query_len
436 + assert decode.flattened_block_table is not None
437 + assert decode.flattened_seq_lens is not None
438 + return decode.flattened_block_table, decode.flattened_seq_lens
439 +
440 + block_table = decode.block_table.repeat_interleave(query_len, dim=0)
441 + if causal:
442 + global_seq_lens = decode.dcp_tot_seq_lens
443 + assert global_seq_lens is not None
444 + offsets = torch.arange(
445 + query_len - 1,
446 + -1,
447 + -1,
448 + device=global_seq_lens.device,
449 + dtype=global_seq_lens.dtype,
450 + )
451 + per_query_global_lens = torch.clamp(
452 + (global_seq_lens.unsqueeze(1) - offsets).reshape(-1), min=0
453 + )
454 + interleave = self.cp_kv_cache_interleave_size
455 + dcp_span = self.dcp_world_size * interleave
456 + remainder = torch.clamp(
457 + per_query_global_lens % dcp_span - self.dcp_rank * interleave,
458 + min=0,
459 + max=interleave,
460 + )
461 + seq_lens = per_query_global_lens // dcp_span * interleave + remainder
462 + else:
463 + seq_lens = decode.seq_lens.repeat_interleave(query_len)
464 +
465 + decode.flattened_block_table = block_table
466 + decode.flattened_seq_lens = seq_lens
467 + decode.query_len = query_len
468 + return block_table, seq_lens
vllm/v1/attention/backends/mla/tokenspeed_mla.py
+1
@@ -61,6 +61,7 @@ class TokenspeedMLAMetadataBuilder(MLACommonMetadataBuilder[MLACommonMetadata]):
61 # The kernel accepts an explicit causal mask, so a non-causal DSpark
62 # block can remain fused instead of being flattened to single tokens.
63 supports_non_causal_multi_token_decode: ClassVar[bool] = True
64 + supports_non_causal_multi_token_dcp: ClassVar[bool] = True
65
66 def __init__(
67 self,
vllm/v1/attention/selector.py
+4 -1
@@ -38,6 +38,7 @@ class AttentionSelectorConfig(NamedTuple):
38 use_kv_connector: bool = False
39 use_pcp: bool = False
40 use_adaptive_verification: bool = False
41 + use_dcp: bool = False
42
43 def __repr__(self):
44 return (
@@ -56,7 +57,8 @@ class AttentionSelectorConfig(NamedTuple):
57 f"use_batch_invariant={self.use_batch_invariant}, "
58 f"use_kv_connector={self.use_kv_connector}, "
59 f"use_adaptive_verification={self.use_adaptive_verification}, "
59 - f"use_pcp={self.use_pcp})"
60 + f"use_pcp={self.use_pcp}, "
61 + f"use_dcp={self.use_dcp})"
62 )
63
64
@@ -168,6 +170,7 @@ def get_attn_backend(
170 use_kv_connector=use_kv_connector,
171 use_pcp=vllm_config.parallel_config.prefill_context_parallel_size > 1,
172 use_adaptive_verification=use_adaptive_verification,
173 + use_dcp=vllm_config.parallel_config.decode_context_parallel_size > 1,
174 )
175
176 # A per-KV-group override (keyed by KVCacheSpecKind) takes precedence over
vllm/v1/worker/gpu/cp_utils.py
+21
@@ -59,3 +59,24 @@ def _dcp_local_seq_lens_kernel(
59 # For [num_reqs, max_num_reqs), pad with 0
60 local_seq_lens = tl.where(block < num_reqs, local_seq_lens, 0)
61 tl.store(out_ptr + block, local_seq_lens, mask=block < max_num_reqs)
62 +
63 +
64 +@triton.jit
65 +def cp_local_slot(
66 + positions,
67 + block_numbers,
68 + block_size,
69 + cp_rank,
70 + CP_SIZE: tl.constexpr,
71 + CP_INTERLEAVE: tl.constexpr,
72 + PAD_ID: tl.constexpr,
73 +):
74 + """Return rank-local KV slots, or PAD_ID for positions not owned by this rank."""
75 + block_offsets = positions % (block_size * CP_SIZE)
76 + if CP_SIZE == 1:
77 + return block_numbers * block_size + block_offsets
78 + is_local = block_offsets // CP_INTERLEAVE % CP_SIZE == cp_rank
79 + rounds = block_offsets // (CP_INTERLEAVE * CP_SIZE)
80 + remainder = block_offsets % CP_INTERLEAVE
81 + local_offsets = rounds * CP_INTERLEAVE + remainder
82 + return tl.where(is_local, block_numbers * block_size + local_offsets, PAD_ID)
vllm/v1/worker/gpu/spec_decode/dflash/cudagraph.py
+13
@@ -11,6 +11,7 @@ from vllm.v1.worker.gpu.attn_utils import (
11 build_slot_mappings_by_layer,
12 )
13 from vllm.v1.worker.gpu.block_table import BlockTables
14 +from vllm.v1.worker.gpu.cp_utils import prepare_dcp_local_seq_lens
15 from vllm.v1.worker.gpu.cudagraph_utils import (
16 AttentionState,
17 BatchExecutionDescriptor,
@@ -41,6 +42,17 @@ def _prepare_dflash_inputs_to_capture(
42 attn_metadata = None
43 if not skip_attn:
44 query_start_loc_cpu = torch.from_numpy(input_batch.query_start_loc_np)
45 + dcp_local_seq_lens = None
46 + if block_tables.cp_size > 1:
47 + prepare_dcp_local_seq_lens(
48 + input_buffers.dcp_local_seq_lens,
49 + input_buffers.seq_lens,
50 + num_reqs,
51 + block_tables.cp_size,
52 + block_tables.cp_rank,
53 + block_tables.cp_interleave,
54 + )
55 + dcp_local_seq_lens = input_buffers.dcp_local_seq_lens
56 attn_metadata = build_attn_metadata(
57 attn_groups=attn_groups,
58 num_reqs=num_reqs,
@@ -49,6 +61,7 @@ def _prepare_dflash_inputs_to_capture(
61 query_start_loc_cpu=query_start_loc_cpu,
62 max_query_len=num_tokens // num_reqs,
63 seq_lens=input_batch.seq_lens,
64 + dcp_local_seq_lens=dcp_local_seq_lens,
65 max_seq_len=max_model_len,
66 block_tables=input_block_tables,
67 slot_mappings=slot_mappings,
vllm/v1/worker/gpu/spec_decode/dflash/speculator.py
+41 -4
@@ -17,6 +17,7 @@ from vllm.v1.attention.backends.utils import PAD_SLOT_ID
17 from vllm.v1.kv_cache_interface import KVCacheConfig
18 from vllm.v1.worker.gpu.attn_utils import build_slot_mappings_by_layer
19 from vllm.v1.worker.gpu.block_table import BlockTables
20 +from vllm.v1.worker.gpu.cp_utils import cp_local_slot, prepare_dcp_local_seq_lens
21 from vllm.v1.worker.gpu.dp_utils import dispatch_cg_and_sync_dp
22 from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
23 from vllm.v1.worker.gpu.model_states.interface import ModelState
@@ -285,10 +286,21 @@ class DFlashSpeculator(DraftModelSpeculator):
286 num_query_per_req: int | None = None,
287 causal: bool | Mapping[int, bool] = False,
288 query_start_loc_np: np.ndarray | None = None,
289 + dcp_local_seq_lens: torch.Tensor | None = None,
290 ) -> dict[str, Any] | None:
291 if not self.draft_attn_layer_names:
292 return None
293 assert num_query_per_req is None # Omitted for DFlash, read from self instead
294 + if dcp_local_seq_lens is None and self.block_tables.cp_size > 1:
295 + prepare_dcp_local_seq_lens(
296 + self.input_buffers.dcp_local_seq_lens,
297 + self.input_buffers.seq_lens,
298 + num_reqs,
299 + self.block_tables.cp_size,
300 + self.block_tables.cp_rank,
301 + self.block_tables.cp_interleave,
302 + )
303 + dcp_local_seq_lens = self.input_buffers.dcp_local_seq_lens
304 return super()._build_draft_attn_metadata(
305 num_reqs,
306 num_reqs_padded,
@@ -298,6 +310,7 @@ class DFlashSpeculator(DraftModelSpeculator):
310 num_query_per_req=self.num_query_per_req,
311 causal=causal,
312 query_start_loc_np=query_start_loc_np,
313 + dcp_local_seq_lens=dcp_local_seq_lens,
314 )
315
316 @torch.inference_mode()
@@ -393,6 +406,9 @@ class DFlashSpeculator(DraftModelSpeculator):
406 seeds,
407 self.block_tables.input_block_tables[gid],
408 self.block_tables.kernel_block_sizes[gid],
409 + self.block_tables.cp_rank,
410 + self.block_tables.cp_size,
411 + self.block_tables.cp_interleave,
412 self.parallel_drafting_token_id,
413 self.num_query_per_req,
414 self.num_speculative_steps,
@@ -507,8 +523,11 @@ def _prepare_dflash_inputs_kernel(
523 max_num_reqs,
524 max_num_tokens,
525 max_model_len,
526 + cp_rank,
527 SAMPLE_FROM_ANCHOR: tl.constexpr,
528 PAD_SLOT_ID: tl.constexpr,
529 + CP_SIZE: tl.constexpr,
530 + CP_INTERLEAVE: tl.constexpr,
531 BLOCK_SIZE: tl.constexpr,
532 ):
533 req_idx = tl.program_id(0)
@@ -543,7 +562,7 @@ def _prepare_dflash_inputs_kernel(
562 # --- Context positions / slots ---
563 ctx_pos_idx = ctx_start + tl.where(is_ctx, j, 0)
564 ctx_pos = tl.load(target_positions_ptr + ctx_pos_idx, mask=is_valid_ctx, other=0)
546 - ctx_block_num = ctx_pos // block_size
565 + ctx_block_num = ctx_pos // (block_size * CP_SIZE)
566 ctx_block_num = tl.minimum(ctx_block_num, block_table_stride - 1)
567 ctx_block_id = tl.load(
568 block_table_ptr + req_idx * block_table_stride + ctx_block_num,
@@ -554,9 +573,12 @@ def _prepare_dflash_inputs_kernel(
573 # to it after eviction; rejected suffix rows are invalid context as well.
574 # Neither kind of row may write draft KV into physical block 0.
575 ctx_resident = is_valid_ctx & (ctx_block_id != 0)
576 + local_ctx_slot = cp_local_slot(
577 + ctx_pos, ctx_block_id, block_size, cp_rank, CP_SIZE, CP_INTERLEAVE, PAD_SLOT_ID
578 + )
579 ctx_slot = tl.where(
580 ctx_resident,
559 - ctx_block_id * block_size + (ctx_pos % block_size),
581 + local_ctx_slot,
582 PAD_SLOT_ID,
583 )
584 # Stored over the full [0, num_ctx) span while the loads above are masked to
@@ -573,7 +595,7 @@ def _prepare_dflash_inputs_kernel(
595 is_bonus = is_query & (query_off == 0)
596 input_id = tl.where(is_bonus, bonus_token, parallel_drafting_token_id)
597
576 - q_block_num = query_pos // block_size
598 + q_block_num = query_pos // (block_size * CP_SIZE)
599 q_block_num = tl.minimum(q_block_num, block_table_stride - 1)
600 q_block_id = tl.load(
601 block_table_ptr + req_idx * block_table_stride + q_block_num,
@@ -583,9 +605,18 @@ def _prepare_dflash_inputs_kernel(
605 # A null block is never a writable cache slot. This can occur when a
606 # sliding-window block table contains evicted/global padding entries.
607 q_resident = is_query & (q_block_id != 0)
608 + local_q_slot = cp_local_slot(
609 + query_pos,
610 + q_block_id,
611 + block_size,
612 + cp_rank,
613 + CP_SIZE,
614 + CP_INTERLEAVE,
615 + PAD_SLOT_ID,
616 + )
617 q_slot = tl.where(
618 q_resident,
588 - q_block_id * block_size + (query_pos % block_size),
619 + local_q_slot,
620 PAD_SLOT_ID,
621 )
622
@@ -681,6 +712,9 @@ def prepare_dflash_inputs(
712 # [max_num_reqs, max_num_blocks]
713 block_table: torch.Tensor,
714 block_size: int,
715 + cp_rank: int,
716 + cp_size: int,
717 + cp_interleave: int,
718 parallel_drafting_token_id: int,
719 num_query_per_req: int,
720 num_speculative_steps: int,
@@ -728,7 +762,10 @@ def prepare_dflash_inputs(
762 max_num_reqs,
763 max_num_tokens,
764 max_model_len,
765 + cp_rank,
766 SAMPLE_FROM_ANCHOR=sample_from_anchor,
767 PAD_SLOT_ID=PAD_SLOT_ID,
768 + CP_SIZE=cp_size,
769 + CP_INTERLEAVE=cp_interleave,
770 BLOCK_SIZE=BLOCK_SIZE,
771 )
vllm/v1/worker/gpu/spec_decode/speculator.py
+6
@@ -234,6 +234,7 @@ class DraftModelSpeculator(BaseSpeculator):
234 num_query_per_req: int = 1,
235 causal: bool | Mapping[int, bool] = True,
236 query_start_loc_np: np.ndarray | None = None,
237 + dcp_local_seq_lens: torch.Tensor | None = None,
238 ) -> dict[str, Any] | None:
239 if query_start_loc_np is not None:
240 # Non-uniform query layout (e.g. multi-module MTP's mixed
@@ -278,6 +279,11 @@ class DraftModelSpeculator(BaseSpeculator):
279 query_start_loc_cpu=query_start_loc_cpu,
280 max_query_len=max_query_len,
281 seq_lens=self.input_buffers.seq_lens[:num_reqs_padded],
282 + dcp_local_seq_lens=(
283 + None
284 + if dcp_local_seq_lens is None
285 + else dcp_local_seq_lens[:num_reqs_padded]
286 + ),
287 max_seq_len=self.draft_max_seq_len,
288 block_tables=block_tables,
289 slot_mappings=slot_mappings,