[Attention][MLA][DCP] Query replication for MLA decode (DeepSeek-V2/R1 + Kimi-K2.5) (#45964)

Signed-off-by: Sungsoo Ha <sungsooh@nvidia.com> Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com> Signed-off-by: Matthew Bonanni <mbonanni@redhat.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Lucas Wilkinson <lwilkins@redhat.com> Co-authored-by: Matthew Bonanni <mbonanni@redhat.com>

sungsoo ha committed Jul 20, 2026 at 16:51 UTC 2396a611085d7bc78c0f7c41ab45eccbbc7ec0a7
6 files changed +157 -15
tests/v1/attention/test_mla_backends.py
+1
@@ -119,6 +119,7 @@ def test_mla_post_load_preserves_runtime_weight_addresses(monkeypatch):
119 layer.kv_b_proj.quant_method = None
120 layer.is_aiter_triton_fp4_bmm_enabled = False
121 layer.is_aiter_triton_fp8_bmm_enabled = False
122 + layer.dcp_q_replicate = False
123 layer.quant_config = None
124 layer.layer_name = "test"
125
vllm/envs.py
+3
@@ -186,6 +186,7 @@ if TYPE_CHECKING:
186 VLLM_MOE_USE_DEEP_GEMM: bool = True
187 VLLM_USE_DEEP_GEMM_E8M0: bool = True
188 VLLM_USE_DEEP_GEMM_TMA_ALIGNED_SCALES: bool = True
189 + VLLM_DCP_Q_REPLICATE: bool = False
190 VLLM_DEEP_GEMM_WARMUP: Literal[
191 "skip",
192 "full",
@@ -1490,6 +1491,8 @@ environment_variables: dict[str, Callable[[], Any]] = {
1491 "VLLM_USE_DEEP_GEMM_TMA_ALIGNED_SCALES": lambda: bool(
1492 int(os.getenv("VLLM_USE_DEEP_GEMM_TMA_ALIGNED_SCALES", "1"))
1493 ),
1494 + # Opt-in MLA DCP query replication: skip the decode query all-gather.
1495 + "VLLM_DCP_Q_REPLICATE": lambda: bool(int(os.getenv("VLLM_DCP_Q_REPLICATE", "0"))),
1496 # DeepGemm JITs the kernels on-demand. The warmup attempts to make DeepGemm
1497 # JIT all the required kernels before model execution so there is no
1498 # JIT'ing in the hot-path. However, this warmup increases the engine
vllm/model_executor/layers/attention/mla_attention.py
+46 -5
@@ -366,6 +366,7 @@ class MLAAttention(nn.Module, AttentionLayerBase):
366 q_lora_rank: int | None,
367 kv_lora_rank: int,
368 kv_b_proj: ColumnParallelLinear,
369 + dcp_q_replicate: bool = False,
370 cache_config: CacheConfig | None = None,
371 quant_config: QuantizationConfig | None = None,
372 prefix: str = "",
@@ -384,10 +385,11 @@ class MLAAttention(nn.Module, AttentionLayerBase):
385 self.q_lora_rank = q_lora_rank
386 self.kv_lora_rank = kv_lora_rank
387 self.kv_b_proj = kv_b_proj
388 + self.dcp_q_replicate = dcp_q_replicate
389 + self.W_UK_T_dcp_qrep: torch.Tensor | None = None
390 self.head_size = kv_lora_rank + qk_rope_head_dim
391 self.layer_name = prefix
392 self.indexer = indexer
390 -
393 self.num_kv_heads = 1
394 self.qk_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim
395
@@ -591,6 +593,7 @@ class MLAAttention(nn.Module, AttentionLayerBase):
593 kv_c_normed: torch.Tensor,
594 k_pe: torch.Tensor,
595 output_shape: torch.Size | None = None,
596 + q_dcp_replicated: torch.Tensor | None = None,
597 ) -> torch.Tensor:
598 if self.calculate_kv_scales:
599 torch.ops.vllm.maybe_calc_kv_scales(
@@ -646,6 +649,7 @@ class MLAAttention(nn.Module, AttentionLayerBase):
649 self_kv_cache,
650 attn_metadata,
651 output=output,
652 + q_dcp_replicated=q_dcp_replicated,
653 )
654 return output
655 else:
@@ -665,6 +669,7 @@ class MLAAttention(nn.Module, AttentionLayerBase):
669 output,
670 encoded,
671 kv_cache_dummy_dep=kv_cache_dummy_dep,
672 + q_dcp_replicated=q_dcp_replicated,
673 )
674 return output
675
@@ -682,6 +687,7 @@ class MLAAttention(nn.Module, AttentionLayerBase):
687 quant_scale_ue8m0: bool | None = None,
688 quant_col_major: bool | None = None,
689 quant_tma_aligned: bool | None = None,
690 + q_dcp_replicated: torch.Tensor | None = None,
691 ) -> torch.Tensor:
692 assert output is not None, "Output tensor must be provided."
693
@@ -738,6 +744,8 @@ class MLAAttention(nn.Module, AttentionLayerBase):
744 output_padded = output
745 output = output[:num_actual_toks, ...]
746 q = q[:num_actual_toks, ...]
747 + if q_dcp_replicated is not None:
748 + q_dcp_replicated = q_dcp_replicated[:num_actual_toks, ...]
749 k_c_normed = k_c_normed[:num_actual_toks, ...]
750 k_pe = k_pe[:num_actual_toks, ...]
751
@@ -793,7 +801,12 @@ class MLAAttention(nn.Module, AttentionLayerBase):
801 )
802
803 if num_mqa_tokens > 0:
796 - mqa_q = q[:num_mqa_tokens]
804 + if q_dcp_replicated is not None:
805 + mqa_q = q_dcp_replicated[:num_mqa_tokens]
806 + qrep_decode = True
807 + else:
808 + mqa_q = q[:num_mqa_tokens]
809 + qrep_decode = False
810 mqa_output_slice = output[:num_mqa_tokens]
811
812 mqa_q_nope, mqa_q_pe = mqa_q.split(
@@ -833,7 +846,9 @@ class MLAAttention(nn.Module, AttentionLayerBase):
846 else:
847 # Pads the head_dim if necessary (for the underlying kernel)
848 N, B, P = mqa_q_nope.shape
836 - _, _, L = self.W_UK_T.shape
849 + W_UK_T = self.W_UK_T_dcp_qrep if qrep_decode else self.W_UK_T
850 + assert W_UK_T is not None
851 + _, _, L = W_UK_T.shape
852
853 if self.q_pad_num_heads is not None:
854 mqa_ql_nope = mqa_q_nope.new_empty((self.q_pad_num_heads, B, L))
@@ -842,7 +857,7 @@ class MLAAttention(nn.Module, AttentionLayerBase):
857 mqa_ql_nope = mqa_q_nope.new_empty((N, B, L))
858
859 # Multiply (N, B, P) x (N, P, L) -> (N, B, L)
845 - torch.bmm(mqa_q_nope, self.W_UK_T, out=mqa_ql_nope)
860 + torch.bmm(mqa_q_nope, W_UK_T, out=mqa_ql_nope)
861
862 # Convert from (N, B, L) to (B, N, L)
863 mqa_ql_nope = mqa_ql_nope.transpose(0, 1)
@@ -855,6 +870,7 @@ class MLAAttention(nn.Module, AttentionLayerBase):
870 )
871 else:
872 mqa_q = (mqa_ql_nope, mqa_q_pe)
873 + # concatenate nope + pe -> (B, N, L + P) (fp8 op above may have fused)
874 if self.impl.dcp_world_size > 1:
875 if self.use_pcp:
876 if self.impl.dcp_world_size > self.impl.pcp_world_size:
@@ -863,8 +879,11 @@ class MLAAttention(nn.Module, AttentionLayerBase):
879 mqa_q = get_tp_group().all_gather(mqa_q, dim=1)
880 else:
881 if isinstance(mqa_q, tuple):
882 + # concatenate mqa_ql_nope and mqa_q_pe -> (B, N, L + P)
883 mqa_q = torch.cat(mqa_q, dim=-1)
867 - mqa_q = get_dcp_group().all_gather(mqa_q, dim=1)
884 + if not qrep_decode:
885 + # mqa_q do allgather in head dim.
886 + mqa_q = get_dcp_group().all_gather(mqa_q, dim=1)
887
888 # call decode attn
889 if not self.impl.is_sparse:
@@ -952,6 +971,21 @@ class MLAAttention(nn.Module, AttentionLayerBase):
971 self.kv_b_proj, out_dtype=act_dtype
972 ).T
973
974 + if self.dcp_q_replicate:
975 + # qrep wired here: validate unsupported decode backends once.
976 + assert self.q_pad_num_heads in (None, self.num_heads), (
977 + "DCP query replication is unsupported on head-padding MLA "
978 + "backends (q_pad_num_heads)."
979 + )
980 + if (
981 + self.is_aiter_triton_fp4_bmm_enabled
982 + or self.is_aiter_triton_fp8_bmm_enabled
983 + ):
984 + raise NotImplementedError(
985 + "DCP query replication is not implemented for the aiter "
986 + "FP4/FP8 MLA BMM paths."
987 + )
988 +
989 assert kv_b_proj_weight.shape == (
990 self.kv_lora_rank,
991 self.num_heads * (self.qk_nope_head_dim + self.v_head_dim),
@@ -1032,6 +1066,10 @@ class MLAAttention(nn.Module, AttentionLayerBase):
1066 replace_parameter(self, "W_UV", W_UV.transpose(0, 1), prefer_copy=True)
1067 # Convert from (L, N, P) to (N, P, L)
1068 replace_parameter(self, "W_UK_T", W_UK.permute(1, 2, 0), prefer_copy=True)
1069 + if self.dcp_q_replicate:
1070 + self.W_UK_T_dcp_qrep = get_dcp_group().all_gather(
1071 + self.W_UK_T.contiguous(), dim=0
1072 + )
1073
1074 # If we should not load quant weights, we initialize the scales to 1.0
1075 # as the default value. See [Note: Register q/k/v/prob scales in state dict]
@@ -1176,6 +1214,7 @@ def unified_mla_attention_with_output(
1214 quant_scale_ue8m0: bool | None = None,
1215 quant_col_major: bool | None = None,
1216 quant_tma_aligned: bool | None = None,
1217 + q_dcp_replicated: torch.Tensor | None = None,
1218 ) -> None:
1219 # kv_cache_dummy_dep is not used but accepting it creates a data dependency
1220 # that ensures torch.compile preserves ordering between KV cache update and
@@ -1196,6 +1235,7 @@ def unified_mla_attention_with_output(
1235 quant_scale_ue8m0=quant_scale_ue8m0,
1236 quant_col_major=quant_col_major,
1237 quant_tma_aligned=quant_tma_aligned,
1238 + q_dcp_replicated=q_dcp_replicated,
1239 )
1240
1241
@@ -1212,6 +1252,7 @@ def unified_mla_attention_with_output_fake(
1252 quant_scale_ue8m0: bool | None = None,
1253 quant_col_major: bool | None = None,
1254 quant_tma_aligned: bool | None = None,
1255 + q_dcp_replicated: torch.Tensor | None = None,
1256 ) -> None:
1257 return
1258
vllm/model_executor/layers/linear.py
+75 -4
@@ -11,6 +11,7 @@ from torch.nn.parameter import Parameter
11 from typing_extensions import TypeIs
12
13 import vllm.envs as envs
14 +from vllm.config import get_current_vllm_config
15 from vllm.distributed import (
16 divide,
17 get_tensor_model_parallel_rank,
@@ -254,6 +255,8 @@ class LinearBase(PluggableLayer):
255 *,
256 return_bias: bool = True,
257 disable_tp: bool = False,
258 + tp_rank: int | None = None,
259 + tp_size: int | None = None,
260 ):
261 super().__init__()
262
@@ -277,8 +280,17 @@ class LinearBase(PluggableLayer):
280 raise ValueError("All linear layers should support quant method.")
281 self.return_bias = return_bias
282 self.disable_tp = disable_tp
280 - self.tp_rank = get_tensor_model_parallel_rank() if not disable_tp else 0
281 - self.tp_size = get_tensor_model_parallel_world_size() if not disable_tp else 1
283 + if disable_tp:
284 + self.tp_rank, self.tp_size = 0, 1
285 + else:
286 + self.tp_rank = (
287 + tp_rank if tp_rank is not None else get_tensor_model_parallel_rank()
288 + )
289 + self.tp_size = (
290 + tp_size
291 + if tp_size is not None
292 + else get_tensor_model_parallel_world_size()
293 + )
294
295 def update_param_tp_status(self):
296 # Single source of truth for a parameter's TP state. BasevLLMParameter
@@ -425,6 +437,11 @@ class ColumnParallelLinear(LinearBase):
437 (e.g. model.layers.0.qkv_proj)
438 return_bias: If true, return bias together with outputs in forward pass.
439 disable_tp: If true, weights matrix won't be sharded through tp rank.
440 + tp_rank: Override the tensor-parallel rank used for sharding. Defaults to
441 + the global TP rank. Used to shard at a coarser granularity than one
442 + shard per rank (see ``DCPGroupColumnParallelLinear``).
443 + tp_size: Override the tensor-parallel world size used for sharding.
444 + Defaults to the global TP world size.
445 """
446
447 # --8<-- [end:column_parallel_linear]
@@ -442,10 +459,21 @@ class ColumnParallelLinear(LinearBase):
459 *,
460 return_bias: bool = True,
461 disable_tp: bool = False,
462 + tp_rank: int | None = None,
463 + tp_size: int | None = None,
464 ):
465 # Divide the weight matrix along the last dimension.
447 - self.tp_rank = get_tensor_model_parallel_rank() if not disable_tp else 0
448 - self.tp_size = get_tensor_model_parallel_world_size() if not disable_tp else 1
466 + if disable_tp:
467 + self.tp_rank, self.tp_size = 0, 1
468 + else:
469 + self.tp_rank = (
470 + tp_rank if tp_rank is not None else get_tensor_model_parallel_rank()
471 + )
472 + self.tp_size = (
473 + tp_size
474 + if tp_size is not None
475 + else get_tensor_model_parallel_world_size()
476 + )
477 self.input_size_per_partition = input_size
478 self.output_size_per_partition = divide(output_size, self.tp_size)
479 self.output_partition_sizes = [self.output_size_per_partition]
@@ -465,6 +493,8 @@ class ColumnParallelLinear(LinearBase):
493 prefix,
494 return_bias=return_bias,
495 disable_tp=disable_tp,
496 + tp_rank=self.tp_rank,
497 + tp_size=self.tp_size,
498 )
499
500 self._maybe_allow_fp8_block_shape_mismatch()
@@ -586,6 +616,47 @@ class ColumnParallelLinear(LinearBase):
616 return s
617
618
619 +class DCPGroupColumnParallelLinear(ColumnParallelLinear):
620 + """Column-parallel linear whose weight is sharded across DCP groups.
621 +
622 + With Decode Context Parallelism (DCP) the KV cache is sharded across a DCP
623 + group, so MLA decode must attend the group's full head set. This layer shards
624 + its output across DCP *groups* (effective tp size ``tp_size //
625 + dcp_world_size``) rather than across every rank, so each rank in a group
626 + holds the whole group's heads, letting decode skip the query all-gather.
627 +
628 + :meth:`forward` returns the group's full head set. :meth:`_local_view`
629 + extracts this rank's TP shard for prefill.
630 + """
631 +
632 + def __init__(self, *args, **kwargs):
633 + dcp_world_size = (
634 + get_current_vllm_config().parallel_config.decode_context_parallel_size
635 + )
636 + rank = get_tensor_model_parallel_rank()
637 + world_size = get_tensor_model_parallel_world_size()
638 + self.group_size = max(dcp_world_size, 1)
639 + self.qrep_active = self.group_size > 1
640 + self.rank_in_group = rank % self.group_size
641 + super().__init__(
642 + *args,
643 + **kwargs,
644 + tp_rank=rank // self.group_size,
645 + tp_size=world_size // self.group_size,
646 + )
647 +
648 + def _local_view(self, out: torch.Tensor) -> torch.Tensor:
649 + """Slice this rank's tp head shard from a group-heads output.
650 +
651 + ``out`` is head-shaped, i.e. ``(..., group_heads, head_dim)``.
652 + """
653 + if self.group_size == 1:
654 + return out
655 + n = out.shape[-2] // self.group_size
656 + start = self.rank_in_group * n
657 + return out[..., start : start + n, :].contiguous()
658 +
659 +
660 class MergedColumnParallelLinear(ColumnParallelLinear):
661 """Packed linear layers with column parallelism.
662
vllm/model_executor/layers/mla.py
+20 -4
@@ -93,6 +93,10 @@ class MultiHeadLatentAttentionWrapper(PluggableLayer):
93 # the topk_tokens buffer written by a previous layer in the same pass.
94 # Refer: https://arxiv.org/abs/2603.12201 for more details.
95 self.skip_topk = skip_topk
96 + # qrep is active when the query projection is a DCP-group-sharded layer
97 + # that materializes the full group head set locally.
98 + q_proj_layer = self.q_b_proj if self.q_lora_rank is not None else self.q_proj
99 + self.dcp_q_replicate = getattr(q_proj_layer, "qrep_active", False)
100 if self.indexer is not None:
101 assert hasattr(self.indexer, "topk_tokens")
102 self.topk_tokens = self.indexer.topk_tokens
@@ -110,6 +114,7 @@ class MultiHeadLatentAttentionWrapper(PluggableLayer):
114 quant_config=quant_config,
115 prefix=f"{prefix}.attn",
116 kv_b_proj=self.kv_b_proj,
117 + dcp_q_replicate=self.dcp_q_replicate,
118 use_sparse=self.is_sparse,
119 indexer=self.indexer,
120 topk_indices_buffer=mla_modules.topk_indices_buffer,
@@ -143,7 +148,8 @@ class MultiHeadLatentAttentionWrapper(PluggableLayer):
148 dim=-1,
149 )
150 q_c = self.q_a_layernorm(q_c)
146 - q = self.q_b_proj(q_c)[0]
151 + q_proj_layer = self.q_b_proj
152 + q_proj_input = q_c
153 else:
154 assert self.kv_a_proj_with_mqa is not None, (
155 "kv_a_proj_with_mqa is required when q_lora_rank is None"
@@ -152,15 +158,20 @@ class MultiHeadLatentAttentionWrapper(PluggableLayer):
158 "q_proj is required when q_lora_rank is None"
159 )
160 kv_lora = self.kv_a_proj_with_mqa(hidden_states)[0]
155 - q = self.q_proj(hidden_states)[0]
161 + q_proj_layer = self.q_proj
162 + q_proj_input = hidden_states
163
164 kv_c, k_pe = kv_lora.split([self.kv_lora_rank, self.qk_rope_head_dim], dim=-1)
165 kv_c_normed = self.kv_a_layernorm(kv_c)
159 -
160 - q = q.view(-1, self.num_heads, self.qk_head_dim)
166 # Add head dim of 1 to k_pe
167 k_pe = k_pe.unsqueeze(1)
168
169 + q = q_proj_layer(q_proj_input)[0]
170 + heads = self.num_heads
171 + if self.dcp_q_replicate:
172 + heads *= q_proj_layer.group_size
173 + q = q.view(-1, heads, self.qk_head_dim)
174 +
175 if self.rotary_emb is not None:
176 q[..., self.qk_nope_head_dim :], k_pe = self.rotary_emb(
177 positions, q[..., self.qk_nope_head_dim :], k_pe
@@ -172,11 +183,16 @@ class MultiHeadLatentAttentionWrapper(PluggableLayer):
183 if llama_4_scaling is not None:
184 q *= llama_4_scaling
185
186 + q_dcp_replicated = None
187 + if self.dcp_q_replicate:
188 + q_dcp_replicated, q = q, q_proj_layer._local_view(q)
189 +
190 attn_out = self.mla_attn(
191 q,
192 kv_c_normed,
193 k_pe,
194 output_shape=(hidden_states.shape[0], self.num_heads * self.v_head_dim),
195 + q_dcp_replicated=q_dcp_replicated,
196 )
197
198 return self.o_proj(attn_out)[0]
vllm/model_executor/models/deepseek_v2.py
+12 -2
@@ -33,6 +33,7 @@ from torch import nn
33 from transformers import DeepseekV2Config, DeepseekV3Config
34
35 import vllm._custom_ops as ops
36 +import vllm.envs as envs
37 from vllm._aiter_ops import rocm_aiter_ops
38 from vllm.compilation.decorators import support_torch_compile
39 from vllm.config import CacheConfig, ParallelConfig, VllmConfig, get_current_vllm_config
@@ -56,6 +57,7 @@ from vllm.model_executor.layers.fused_moe import (
57 from vllm.model_executor.layers.layernorm import LayerNorm, RMSNorm
58 from vllm.model_executor.layers.linear import (
59 ColumnParallelLinear,
60 + DCPGroupColumnParallelLinear,
61 MergedColumnParallelLinear,
62 QKVParallelLinear,
63 ReplicatedLinear,
@@ -1015,9 +1017,17 @@ class DeepseekV2MLAAttention(nn.Module):
1017 prefix=f"{prefix}.kv_a_proj_with_mqa",
1018 )
1019
1020 + qrep_enabled = (
1021 + envs.VLLM_DCP_Q_REPLICATE
1022 + and vllm_config.parallel_config.decode_context_parallel_size > 1
1023 + and vllm_config.parallel_config.prefill_context_parallel_size <= 1
1024 + )
1025 + q_proj_cls = (
1026 + DCPGroupColumnParallelLinear if qrep_enabled else ColumnParallelLinear
1027 + )
1028 if self.q_lora_rank is not None:
1029 self.q_a_layernorm = RMSNorm(self.q_lora_rank, eps=config.rms_norm_eps)
1020 - self.q_b_proj = ColumnParallelLinear(
1030 + self.q_b_proj = q_proj_cls(
1031 self.q_lora_rank,
1032 self.num_heads * self.qk_head_dim,
1033 bias=False,
@@ -1025,7 +1035,7 @@ class DeepseekV2MLAAttention(nn.Module):
1035 prefix=f"{prefix}.q_b_proj",
1036 )
1037 else:
1028 - self.q_proj = ColumnParallelLinear(
1038 + self.q_proj = q_proj_cls(
1039 proj_input_size,
1040 self.num_heads * self.qk_head_dim,
1041 bias=False,