DCP supports hybrid attention (#40996)
Signed-off-by: YanXu <yancey.yx@alibaba-inc.com> Signed-off-by: Jingyi Yang <girasoleyang@gmail.com> Co-authored-by: Jingyi Yang <girasoleyang@gmail.com>
Yan Xu committed
Jul 10, 2026 at 12:34 UTC
95ed0feaa5cd7fb16d72c53ce04950aaf07c4698
26 files changed
+785
-127
tests/distributed/test_context_parallel.py
+8
@@ -33,6 +33,7 @@ CP_TEST_MODELS = [
33
# [LANGUAGE GENERATION]
34
"deepseek-ai/DeepSeek-V2-Lite-Chat",
35
"Qwen/Qwen2.5-1.5B-Instruct",
36
+ "Qwen/Qwen3.5-0.8B", # hybrid attention model
37
]
38
39
# GSM8K eval configuration
@@ -46,6 +47,7 @@ MIN_ACCURACY = {
47
"deepseek-ai/DeepSeek-V2-Lite-Chat": 0.64,
48
# .buildkite/lm-eval-harness/configs/Qwen2.5-1.5B-Instruct.yaml
49
"Qwen/Qwen2.5-1.5B-Instruct": 0.52,
50
+ "Qwen/Qwen3.5-0.8B": 0.33,
51
}
52
53
@@ -151,6 +153,12 @@ else:
153
cp_kv_cache_interleave_size=16, attn_backend="FLASHINFER"
154
),
155
],
156
+ "Qwen/Qwen3.5-0.8B": [
157
+ CPTestSettings.detailed(
158
+ cp_kv_cache_interleave_size=16,
159
+ attn_backend="FLASH_ATTN",
160
+ ),
161
+ ],
162
}
163
164
tests/distributed/test_pynccl.py
+47
@@ -16,6 +16,7 @@ from vllm.distributed.device_communicators.pynccl import PyNcclCommunicator
16
from vllm.distributed.device_communicators.pynccl_wrapper import NCCLLibrary
17
from vllm.distributed.parallel_state import (
18
ensure_model_parallel_initialized,
19
+ get_tp_group,
20
get_world_group,
21
graph_capture,
22
init_distributed_environment,
@@ -199,6 +200,52 @@ def test_pynccl_all_gather():
200
distributed_run(all_gather_worker_fn, 2)
201
202
203
+@worker_fn_wrapper
204
+def cuda_communicator_all_gather_dim_worker_fn():
205
+ with ensure_current_vllm_config():
206
+ ensure_model_parallel_initialized(2, 1)
207
+
208
+ tp_group = get_tp_group()
209
+ comm = tp_group.device_communicator
210
+ assert comm is not None
211
+
212
+ rank = tp_group.rank_in_group
213
+ world_size = tp_group.world_size
214
+ device = tp_group.device
215
+
216
+ shape = (2, 3, 4)
217
+ num_elems = 1
218
+ for size in shape:
219
+ num_elems *= size
220
+
221
+ for dim in (1, -1):
222
+ tensor = (
223
+ torch.arange(num_elems, dtype=torch.float32, device=device).reshape(shape)
224
+ + rank * num_elems
225
+ )
226
+ expected = torch.cat(
227
+ [
228
+ torch.arange(num_elems, dtype=torch.float32, device=device).reshape(
229
+ shape
230
+ )
231
+ + r * num_elems
232
+ for r in range(world_size)
233
+ ],
234
+ dim=dim,
235
+ )
236
+
237
+ result = comm.all_gather(tensor, dim=dim)
238
+ torch.accelerator.synchronize()
239
+ torch.testing.assert_close(result, expected, rtol=1e-5, atol=1e-8)
240
+
241
+
242
+@pytest.mark.skipif(
243
+ torch.accelerator.device_count() < 2, reason="Need at least 2 GPUs to run the test."
244
+)
245
+def test_cuda_communicator_all_gather_dim_not_zero():
246
+ distributed_run(cuda_communicator_all_gather_dim_worker_fn, 2)
247
+
248
+
249
@worker_fn_wrapper
250
def all_gatherv_worker_fn():
251
pynccl_comm = PyNcclCommunicator(
tests/models/language/generation/test_hybrid.py
+13
-1
@@ -43,6 +43,11 @@ HYBRID_MODELS = [
43
"tiny-random/qwen3-next-moe",
44
]
45
46
+HYBRID_MODELS_REQUIRING_CHUNKED_PREFILL = {
47
+ "LiquidAI/LFM2-1.2B",
48
+ "tiny-random/qwen3-next-moe",
49
+}
50
+
51
FULL_CUDA_GRAPH_MODELS = [
52
"ai21labs/Jamba-tiny-dev",
53
"pfnet/plamo-2-1b",
@@ -92,8 +97,15 @@ def test_models(
97
example_prompts, max_tokens, num_logprobs
98
)
99
100
+ extra_kwargs = {}
101
+ if model in HYBRID_MODELS_REQUIRING_CHUNKED_PREFILL:
102
+ extra_kwargs["enable_chunked_prefill"] = True
103
+
104
with vllm_runner(
96
- model, max_num_seqs=MAX_NUM_SEQS, attention_backend=ATTN_BACKEND
105
+ model,
106
+ max_num_seqs=MAX_NUM_SEQS,
107
+ attention_backend=ATTN_BACKEND,
108
+ **extra_kwargs,
109
) as vllm_model:
110
vllm_outputs = vllm_model.generate_greedy_logprobs(
111
example_prompts, max_tokens, num_logprobs
tests/models/multimodal/generation/test_vit_cudagraph.py
+1
@@ -171,6 +171,7 @@ MODEL_CONFIGS: dict[str, VitCudagraphTestConfig] = {
171
"Describe this video in one sentence."
172
),
173
needs_video_metadata=True,
174
+ vllm_runner_kwargs={"enable_chunked_prefill": True},
175
marks=[pytest.mark.core_model],
176
),
177
"internvl": VitCudagraphTestConfig(
tests/test_config.py
+4
-4
@@ -1121,14 +1121,14 @@ def test_is_chunked_prefill_supported(
1121
(
1122
"Qwen/Qwen3-Next-80B-A3B-Instruct",
1123
"hybrid",
1124
- False,
1125
- "Hybrid models do not support prefix caching since the feature is still experimental.", # noqa: E501
1124
+ True,
1125
+ "Generative hybrid models support prefix caching.", # noqa: E501
1126
),
1127
(
1128
"ibm-granite/granite-4.0-h-small",
1129
"hybrid",
1130
- False,
1131
- "Hybrid models do not support prefix caching since the feature is still experimental.", # noqa: E501
1130
+ True,
1131
+ "Generative hybrid models support prefix caching.", # noqa: E501
1132
),
1133
(
1134
"state-spaces/mamba-130m-hf",
tests/v1/streaming_input/test_gpu_model_runner_streaming.py
+1
@@ -38,6 +38,7 @@ def mock_model_runner_with_input_batch():
38
vocab_size=32000,
39
block_sizes=[16],
40
kernel_block_sizes=[16],
41
+ max_num_blocks_per_req=[64],
42
logitsprocs=None,
43
is_pooling_model=False,
44
)
tests/v1/worker/test_cp_utils.py
new
+45
@@ -0,0 +1,45 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+import pytest
4
+import torch
5
+
6
+from vllm.v1.attention.backends.utils import get_dcp_local_seq_lens
7
+from vllm.v1.worker.cp_utils import should_skip_dcp_context_attention
8
+
9
+
10
+def test_skip_gate_only_for_zero_context():
11
+ assert should_skip_dcp_context_attention(torch.zeros(3, dtype=torch.int32))
12
+ assert not should_skip_dcp_context_attention(
13
+ torch.tensor([0, 5, 0], dtype=torch.int32)
14
+ )
15
+
16
+
17
+@pytest.mark.parametrize(
18
+ "dcp_world_size,interleave_size,context_len",
19
+ [(2, 16, 10), (4, 16, 10), (8, 16, 10), (4, 1, 2)],
20
+)
21
+def test_skip_gate_rank_invariant_with_divergent_local_context(
22
+ dcp_world_size: int, interleave_size: int, context_len: int
23
+):
24
+ """Contexts shorter than a full interleave round land entirely on a
25
+ subset of DCP ranks, so the per-rank local context lengths diverge:
26
+ some ranks hold zero local context while others hold all of it. Ranks
27
+ with zero local context must still take the collective (non-skip) path,
28
+ otherwise the query all-gather in _forward_with_dcp deadlocks across
29
+ ranks. The skip gate must therefore depend only on the rank-invariant
30
+ global context lengths, never on get_dcp_local_seq_lens output.
31
+ """
32
+ context_kv_lens = torch.tensor([context_len], dtype=torch.int32)
33
+ local_maxes = [
34
+ int(
35
+ get_dcp_local_seq_lens(
36
+ context_kv_lens, dcp_world_size, rank, interleave_size
37
+ ).max()
38
+ )
39
+ for rank in range(dcp_world_size)
40
+ ]
41
+ # Precondition: the local view diverges across ranks.
42
+ assert 0 in local_maxes
43
+ assert max(local_maxes) > 0
44
+ # The batch still has context globally, so no rank may skip.
45
+ assert not should_skip_dcp_context_attention(context_kv_lens)
tests/v1/worker/test_gpu_input_batch.py
+6
@@ -238,6 +238,7 @@ def test_sampling_metadata_in_input_batch(device: str, batch_size: int):
238
vocab_size=1024,
239
block_sizes=[1],
240
kernel_block_sizes=[1],
241
+ max_num_blocks_per_req=[1024],
242
)
243
reqs: list[CachedRequestState] = []
244
req_id_reqs = {}
@@ -332,6 +333,7 @@ def test_swap_states_in_input_batch(device: str, batch_size: int, swap_list: lis
333
vocab_size=1024,
334
block_sizes=[1],
335
kernel_block_sizes=[1],
336
+ max_num_blocks_per_req=[1024],
337
)
338
ref_input_batch: InputBatch = InputBatch(
339
max_num_reqs=batch_size,
@@ -341,6 +343,7 @@ def test_swap_states_in_input_batch(device: str, batch_size: int, swap_list: lis
343
vocab_size=1024,
344
block_sizes=[1],
345
kernel_block_sizes=[1],
346
+ max_num_blocks_per_req=[1024],
347
)
348
349
reqs: list[CachedRequestState] = []
@@ -409,6 +412,7 @@ def test_pooling_prompt_lens_not_aliased(device: str):
412
vocab_size=VOCAB_SIZE,
413
block_sizes=[16],
414
kernel_block_sizes=[16],
415
+ max_num_blocks_per_req=[64],
416
is_pooling_model=True,
417
)
418
@@ -444,6 +448,7 @@ def test_placeholder_spec_token_ids_written_verbatim():
448
vocab_size=VOCAB_SIZE,
449
block_sizes=[16],
450
kernel_block_sizes=[16],
451
+ max_num_blocks_per_req=[1],
452
)
453
req = CachedRequestState(
454
req_id="req",
@@ -491,6 +496,7 @@ def test_pooling_metadata_token_id_buffers(
496
vocab_size=VOCAB_SIZE,
497
block_sizes=[16],
498
kernel_block_sizes=[16],
499
+ max_num_blocks_per_req=[64],
500
is_pooling_model=True,
501
)
502
req = _construct_pooling_request(0, PoolingParams(**pooling_params))
tests/v1/worker/test_gpu_model_runner.py
+3
@@ -89,6 +89,7 @@ def initialize_kv_cache(runner: GPUModelRunner):
89
kernel_block_sizes=[
90
kv_cache_config.kv_cache_groups[0].kv_cache_spec.block_size
91
],
92
+ max_num_blocks_per_req=[NUM_BLOCKS],
93
)
94
runner.initialize_attn_backend(kv_cache_config)
95
@@ -1397,6 +1398,7 @@ def test_input_batch_with_kernel_block_sizes():
1398
vocab_size=vocab_size,
1399
block_sizes=block_sizes,
1400
kernel_block_sizes=kernel_block_sizes,
1401
+ max_num_blocks_per_req=[16, 8],
1402
)
1403
1404
# Verify that block tables were created with kernel block sizes
@@ -1457,6 +1459,7 @@ def test_hybrid_cache_integration(default_vllm_config, dist_init):
1459
vocab_size=runner.model_config.get_vocab_size(),
1460
block_sizes=[kv_cache_config.kv_cache_groups[0].kv_cache_spec.block_size],
1461
kernel_block_sizes=[16],
1462
+ max_num_blocks_per_req=[NUM_BLOCKS],
1463
) # Use kernel block size
1464
1465
runner.initialize_attn_backend(kv_cache_config)
vllm/config/model.py
+2
-5
@@ -1881,11 +1881,8 @@ class ModelConfig:
1881
else:
1882
# for generative models
1883
if attn_type == "hybrid":
1884
- logger.debug(
1885
- "Hybrid models do not support prefix caching since the feature "
1886
- "is still experimental."
1887
- )
1888
- return False
1884
+ logger.debug("Generative hybrid models support prefix caching.")
1885
+ return True
1886
elif attn_type == "attention_free":
1887
logger.debug(
1888
"Attention free models do not support prefix caching since the "
vllm/distributed/device_communicators/cuda_communicator.py
+20
-3
@@ -341,13 +341,30 @@ class CudaCommunicator(DeviceCommunicatorBase):
341
def all_gather(self, input_: torch.Tensor, dim: int = -1) -> torch.Tensor:
342
# Route uniform dim-0 all-gathers through NVLS symmetric memory when
343
# enabled (mirrors reduce_scatter); otherwise fall back to the
344
- # base-class ring all-gather. Sequence parallelism's gather-before-GEMM
345
- # uses dim=0 with tp-aligned (uniform) shards.
344
+ # PyNccl/base-class all-gather. Sequence parallelism's
345
+ # gather-before-GEMM uses dim=0 with tp-aligned (uniform) shards.
346
if dim < 0:
347
dim += input_.dim()
348
if dim == 0 and should_nccl_symm_mem_ag_rs():
349
return self._all_gather_symm_mem(input_.contiguous())
350
- return super().all_gather(input_, dim)
350
+
351
+ pynccl_comm = self.pynccl_comm
352
+ if pynccl_comm is None or pynccl_comm.disabled:
353
+ return super().all_gather(input_, dim)
354
+
355
+ input_size = input_.size()
356
+ output_size = (input_size[0] * self.world_size,) + input_size[1:]
357
+ output_tensor = torch.empty(
358
+ output_size, dtype=input_.dtype, device=input_.device
359
+ )
360
+ pynccl_comm.all_gather(output_tensor, input_.contiguous())
361
+ output_tensor = output_tensor.reshape((self.world_size,) + input_size)
362
+ output_tensor = output_tensor.movedim(0, dim)
363
+ return output_tensor.reshape(
364
+ input_size[:dim]
365
+ + (self.world_size * input_size[dim],)
366
+ + input_size[dim + 1 :]
367
+ )
368
369
def reduce_scatter(self, input_: torch.Tensor, dim: int = -1):
370
world_size = self.world_size
vllm/engine/arg_utils.py
+5
-1
@@ -2493,7 +2493,11 @@ class EngineArgs:
2493
self, model_config: ModelConfig
2494
) -> None:
2495
default_chunked_prefill = model_config.is_chunked_prefill_supported
2496
- default_prefix_caching = model_config.is_prefix_caching_supported
2496
+ # Hybrid models support prefix caching but keep it opt-in for now
2497
+ # while the feature matures.
2498
+ default_prefix_caching = (
2499
+ model_config.is_prefix_caching_supported and not model_config.is_hybrid
2500
+ )
2501
2502
if self.enable_chunked_prefill is None:
2503
self.enable_chunked_prefill = default_chunked_prefill
vllm/model_executor/layers/attention_layer_base.py
+1
@@ -19,6 +19,7 @@ class AttentionLayerBase(ABC):
19
"""
20
21
impl: "AttentionImpl"
22
+ supports_dcp: bool = True
23
24
@abstractmethod
25
def get_attn_backend(self) -> type[AttentionBackend]:
vllm/model_executor/layers/mamba/abstract.py
+1
@@ -22,6 +22,7 @@ class MambaBase(AttentionLayerBase):
22
# Contains the KV cache (mamba state) for the layer
23
# in the shape specified by `self.get_state_shape`.
24
kv_cache: tuple[torch.Tensor, ...]
25
+ supports_dcp: bool = False
26
27
@abstractmethod
28
def get_state_shape(self) -> Iterable[tuple[int, ...]]:
vllm/utils/cpu_triton_utils.py
+9
-5
@@ -29,13 +29,17 @@ def _compute_slot_mapping_kernel_impl(
29
block_table_stride: int, # max_num_blocks_per_req
30
block_size: int,
31
slot_mapping: torch.Tensor, # [max_num_tokens], int64
32
- TOTAL_CP_WORLD_SIZE: int,
33
- TOTAL_CP_RANK: int,
34
- CP_KV_CACHE_INTERLEAVE_SIZE: int,
35
- PAD_ID: int,
36
- BLOCK_SIZE: int,
32
+ KV_CACHE_BLOCK_SIZE: int | None = None,
33
+ BLOCKS_PER_KV_BLOCK: int = 1,
34
+ TOTAL_CP_WORLD_SIZE: int = 1,
35
+ TOTAL_CP_RANK: int = 0,
36
+ CP_KV_CACHE_INTERLEAVE_SIZE: int = 1,
37
+ PAD_ID: int = -1,
38
+ BLOCK_SIZE: int = 1024,
39
) -> None:
40
assert TOTAL_CP_WORLD_SIZE == 1, "Context Parallelism is not supported on CPU."
41
+ if BLOCKS_PER_KV_BLOCK != 1:
42
+ assert block_size * BLOCKS_PER_KV_BLOCK == KV_CACHE_BLOCK_SIZE
43
torch.ops._C.compute_slot_mapping_kernel_impl(
44
query_start_loc,
45
positions,
vllm/v1/attention/backend.py
+2
@@ -796,6 +796,8 @@ class AttentionImplBase(ABC, Generic[T]):
796
797
# Whether the attention impl supports Prefill Context Parallelism.
798
supports_pcp: bool = False
799
+ # Whether the attention impl supports Decode Context Parallelism.
800
+ supports_dcp: bool = True
801
# Whether the attention impl(or ops) supports MTP
802
# when cp_kv_cache_interleave_size > 1
803
supports_mtp_with_cp_non_trivial_interleave_size: bool = False
vllm/v1/attention/backends/flash_attn.py
+176
-45
@@ -56,10 +56,14 @@ from vllm.v1.attention.backend import (
56
AttentionMetadataBuilder,
57
CommonAttentionMetadata,
58
)
59
-from vllm.v1.attention.backends.utils import (
60
- get_kv_cache_layout,
61
-)
59
+from vllm.v1.attention.backends.utils import get_kv_cache_layout
60
from vllm.v1.kv_cache_interface import AttentionSpec
61
+from vllm.v1.worker.cp_utils import (
62
+ run_split_fa2_dcp_context_attention,
63
+ should_skip_dcp_context_attention,
64
+ should_split_fa2_dcp_context_attention,
65
+ split_dcp_context_queries,
66
+)
67
68
logger = init_logger(__name__)
69
@@ -245,6 +249,13 @@ class FlashAttentionMetadata:
249
max_dcp_context_kv_len: int | None = None
250
dcp_context_kv_lens: torch.Tensor | None = None
251
252
+ # Split counts for FA2 DCP context attention. num_prefill_* tracks
253
+ # context-bearing extend rows; pure prefills do not attend to DCP context.
254
+ num_decode_reqs: int = 0
255
+ num_prefill_reqs: int = 0
256
+ num_decode_tokens: int = 0
257
+ num_prefill_tokens: int = 0
258
+
259
# Optional aot scheduling
260
scheduler_metadata: torch.Tensor | None = None
261
prefix_scheduler_metadata: torch.Tensor | None = None
@@ -513,6 +524,10 @@ class FlashAttentionMetadataBuilder(AttentionMetadataBuilder[FlashAttentionMetad
524
use_cascade = common_prefix_len > 0
525
max_dcp_context_kv_len = 0
526
dcp_context_kv_lens = None
527
+ num_decode_reqs = 0
528
+ num_prefill_reqs = 0
529
+ num_decode_tokens = 0
530
+ num_prefill_tokens = 0
531
532
cu_prefix_query_lens = None
533
prefix_kv_lens = None
@@ -532,23 +547,54 @@ class FlashAttentionMetadataBuilder(AttentionMetadataBuilder[FlashAttentionMetad
547
self._dcp_context_kv_lens[num_reqs:] = 0
548
dcp_context_kv_lens = self._dcp_context_kv_lens[:num_reqs]
549
550
+ skip_dcp_context_attention = False
551
+ if common_attn_metadata.seq_lens_cpu_upper_bound is not None:
552
+ query_lens_cpu = (
553
+ common_attn_metadata.query_start_loc_cpu[1 : num_reqs + 1]
554
+ - common_attn_metadata.query_start_loc_cpu[:num_reqs]
555
+ )
556
+ context_kv_lens_cpu = (
557
+ common_attn_metadata.seq_lens_cpu_upper_bound[:num_reqs]
558
+ - query_lens_cpu
559
+ )
560
+ skip_dcp_context_attention = should_skip_dcp_context_attention(
561
+ context_kv_lens_cpu
562
+ )
563
+
564
+ if max_query_len > 1:
565
+ (
566
+ num_decode_reqs,
567
+ num_prefill_reqs,
568
+ num_decode_tokens,
569
+ num_prefill_tokens,
570
+ ) = split_dcp_context_queries(
571
+ common_attn_metadata.query_start_loc_cpu,
572
+ common_attn_metadata.seq_lens_cpu_upper_bound,
573
+ max_query_len,
574
+ num_actual_tokens,
575
+ )
576
+
577
# After DCP distribution, the maximum number of tokens for any rank is
578
# ceil(L / (N * I)) * I, where L is max_seq_len, N is dcp_world_size,
579
# and I is cp_kv_cache_interleave_size.
580
# This eliminates GPU->CPU sync while minimizing workspace over-allocation.
539
- num_partitions = self.dcp_world_size * self.cp_kv_cache_interleave_size
540
- max_dcp_context_kv_len = (
541
- (max_seq_len + num_partitions - 1) // num_partitions
542
- ) * self.cp_kv_cache_interleave_size
543
-
544
- scheduler_metadata = schedule(
545
- batch_size=num_reqs,
546
- cu_query_lens=query_start_loc,
547
- max_query_len=max_query_len,
548
- seqlens=dcp_context_kv_lens,
549
- max_seq_len=max_dcp_context_kv_len,
550
- causal=False,
551
- )
581
+ if skip_dcp_context_attention:
582
+ max_dcp_context_kv_len = 0
583
+ scheduler_metadata = None
584
+ else:
585
+ num_partitions = self.dcp_world_size * self.cp_kv_cache_interleave_size
586
+ max_dcp_context_kv_len = (
587
+ (max_seq_len + num_partitions - 1) // num_partitions
588
+ ) * self.cp_kv_cache_interleave_size
589
+
590
+ scheduler_metadata = schedule(
591
+ batch_size=num_reqs,
592
+ cu_query_lens=query_start_loc,
593
+ max_query_len=max_query_len,
594
+ seqlens=dcp_context_kv_lens,
595
+ max_seq_len=max_dcp_context_kv_len,
596
+ causal=False,
597
+ )
598
elif use_cascade:
599
cu_prefix_query_lens = torch.tensor(
600
[0, num_actual_tokens], dtype=torch.int32, device=self.device
@@ -614,6 +660,10 @@ class FlashAttentionMetadataBuilder(AttentionMetadataBuilder[FlashAttentionMetad
660
slot_mapping=slot_mapping,
661
max_dcp_context_kv_len=max_dcp_context_kv_len,
662
dcp_context_kv_lens=dcp_context_kv_lens,
663
+ num_decode_reqs=num_decode_reqs,
664
+ num_prefill_reqs=num_prefill_reqs,
665
+ num_decode_tokens=num_decode_tokens,
666
+ num_prefill_tokens=num_prefill_tokens,
667
use_cascade=use_cascade,
668
common_prefix_len=common_prefix_len,
669
scheduler_metadata=scheduler_metadata,
@@ -743,8 +793,12 @@ class FlashAttentionImpl(AttentionImpl):
793
self.dcp_combine = dcp_a2a_lse_reduce if dcp_a2a else cp_lse_ag_out_rs
794
795
self._dcp_dtype: torch.dtype | None = None
796
+ self._dcp_max_num_tokens: int = 0
797
if vllm_config is not None and self.dcp_world_size > 1:
798
self._dcp_dtype = vllm_config.model_config.dtype
799
+ self._dcp_max_num_tokens = (
800
+ vllm_config.scheduler_config.max_num_batched_tokens
801
+ )
802
803
def forward(
804
self,
@@ -1063,40 +1117,120 @@ class FlashAttentionImpl(AttentionImpl):
1117
block_table = attn_metadata.block_table
1118
1119
query = query.contiguous()
1120
+ if attn_metadata.max_dcp_context_kv_len == 0:
1121
+ flash_attn_varlen_func(
1122
+ q=query,
1123
+ k=key,
1124
+ v=value,
1125
+ out=output,
1126
+ cu_seqlens_q=cu_seqlens_q,
1127
+ max_seqlen_q=max_seqlen_q,
1128
+ cu_seqlens_k=cu_seqlens_q,
1129
+ max_seqlen_k=max_seqlen_q,
1130
+ softmax_scale=self.scale,
1131
+ causal=attn_metadata.causal,
1132
+ alibi_slopes=self.alibi_slopes,
1133
+ window_size=list(self.sliding_window)
1134
+ if self.sliding_window is not None
1135
+ else None,
1136
+ softcap=self.logits_soft_cap,
1137
+ return_softmax_lse=True,
1138
+ fa_version=self.vllm_flash_attn_version,
1139
+ q_descale=q_descale,
1140
+ k_descale=k_descale,
1141
+ v_descale=v_descale,
1142
+ num_splits=attn_metadata.max_num_splits,
1143
+ )
1144
+ return output
1145
+
1146
query_across_dcp = get_dcp_group().all_gather(query, dim=1)
1147
sliding_window_size = (
1148
list(self.sliding_window) if self.sliding_window is not None else None
1149
)
1150
n = query_across_dcp.shape[0]
1071
- (dcp_context_out,) = current_workspace_manager().get_simultaneous(
1151
+ num_reqs = cu_seqlens_q.shape[0] - 1
1152
+ num_decodes = attn_metadata.num_decode_reqs
1153
+ num_context_prefills = attn_metadata.num_prefill_reqs
1154
+ num_decode_tokens = attn_metadata.num_decode_tokens
1155
+ num_context_prefill_tokens = attn_metadata.num_prefill_tokens
1156
+ split_dcp_context = should_split_fa2_dcp_context_attention(
1157
+ self.vllm_flash_attn_version,
1158
+ max_seqlen_q,
1159
+ num_reqs,
1160
+ num_decodes,
1161
+ num_context_prefills,
1162
+ )
1163
+ dcp_context_out_tokens = max(n, self._dcp_max_num_tokens)
1164
+ dcp_context_out_spec = (
1165
(
1073
- (n, self.num_heads * self.dcp_world_size, self.head_size),
1074
- self._dcp_dtype,
1166
+ dcp_context_out_tokens,
1167
+ self.num_heads * self.dcp_world_size,
1168
+ self.head_size,
1169
),
1170
+ self._dcp_dtype,
1171
)
1077
- context_attn_out, context_lse = flash_attn_varlen_func(
1078
- q=query_across_dcp,
1079
- k=key_cache,
1080
- v=value_cache,
1081
- out=dcp_context_out,
1082
- cu_seqlens_q=cu_seqlens_q,
1083
- max_seqlen_q=max_seqlen_q,
1084
- seqused_k=attn_metadata.dcp_context_kv_lens,
1085
- max_seqlen_k=attn_metadata.max_dcp_context_kv_len,
1086
- softmax_scale=self.scale,
1087
- causal=False,
1088
- alibi_slopes=self.alibi_slopes,
1089
- window_size=sliding_window_size,
1090
- block_table=block_table,
1091
- softcap=self.logits_soft_cap,
1092
- return_softmax_lse=True,
1093
- scheduler_metadata=attn_metadata.scheduler_metadata,
1094
- fa_version=self.vllm_flash_attn_version,
1095
- q_descale=q_descale,
1096
- k_descale=k_descale,
1097
- v_descale=v_descale,
1098
- num_splits=attn_metadata.max_num_splits,
1172
+ (dcp_context_out_workspace,) = current_workspace_manager().get_simultaneous(
1173
+ dcp_context_out_spec,
1174
)
1175
+ dcp_context_out = dcp_context_out_workspace[:n]
1176
+
1177
+ if split_dcp_context:
1178
+ # TODO: Remove this DCP + FA2 mixed decode/prefill workaround once
1179
+ # FA4 supports this Qwen3.5 shape.
1180
+ assert attn_metadata.dcp_context_kv_lens is not None
1181
+ assert attn_metadata.max_dcp_context_kv_len is not None
1182
+ assert self.vllm_flash_attn_version is not None
1183
+ context_attn_out, context_lse = run_split_fa2_dcp_context_attention(
1184
+ flash_attn_varlen_func,
1185
+ query_across_dcp,
1186
+ key_cache,
1187
+ value_cache,
1188
+ dcp_context_out,
1189
+ cu_seqlens_q,
1190
+ max_seqlen_q,
1191
+ attn_metadata.dcp_context_kv_lens,
1192
+ attn_metadata.max_dcp_context_kv_len,
1193
+ self.scale,
1194
+ self.alibi_slopes,
1195
+ sliding_window_size,
1196
+ block_table,
1197
+ self.logits_soft_cap,
1198
+ self.vllm_flash_attn_version,
1199
+ q_descale,
1200
+ k_descale,
1201
+ v_descale,
1202
+ attn_metadata.max_num_splits,
1203
+ self.num_heads,
1204
+ self.dcp_world_size,
1205
+ num_decodes,
1206
+ num_context_prefills,
1207
+ num_decode_tokens,
1208
+ num_context_prefill_tokens,
1209
+ )
1210
+ else:
1211
+ context_attn_out, context_lse = flash_attn_varlen_func(
1212
+ q=query_across_dcp,
1213
+ k=key_cache,
1214
+ v=value_cache,
1215
+ out=dcp_context_out,
1216
+ cu_seqlens_q=cu_seqlens_q,
1217
+ max_seqlen_q=max_seqlen_q,
1218
+ seqused_k=attn_metadata.dcp_context_kv_lens,
1219
+ max_seqlen_k=attn_metadata.max_dcp_context_kv_len,
1220
+ softmax_scale=self.scale,
1221
+ causal=False,
1222
+ alibi_slopes=self.alibi_slopes,
1223
+ window_size=sliding_window_size,
1224
+ block_table=block_table,
1225
+ softcap=self.logits_soft_cap,
1226
+ return_softmax_lse=True,
1227
+ scheduler_metadata=attn_metadata.scheduler_metadata,
1228
+ fa_version=self.vllm_flash_attn_version,
1229
+ q_descale=q_descale,
1230
+ k_descale=k_descale,
1231
+ v_descale=v_descale,
1232
+ num_splits=attn_metadata.max_num_splits,
1233
+ )
1234
# FA returns LSE in shape [ H, B ] but DCP combine wants [ B, H ]
1235
context_attn_out_cor, context_lse_cor = self.dcp_combine(
1236
context_attn_out,
@@ -1106,14 +1240,11 @@ class FlashAttentionImpl(AttentionImpl):
1240
)
1241
context_lse_cor = context_lse_cor.transpose(0, 1).contiguous()
1242
1109
- (dcp_query_out,) = current_workspace_manager().get_simultaneous(
1110
- ((query.shape[0], self.num_heads, self.head_size), self._dcp_dtype),
1111
- )
1243
query_attn_out, query_lse = flash_attn_varlen_func(
1244
q=query,
1245
k=key,
1246
v=value,
1116
- out=dcp_query_out,
1247
+ out=output,
1248
cu_seqlens_q=cu_seqlens_q,
1249
max_seqlen_q=max_seqlen_q,
1250
cu_seqlens_k=cu_seqlens_q,
vllm/v1/core/kv_cache_coordinator.py
+37
-12
@@ -550,12 +550,28 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
550
# different KV cache groups have different block sizes, the actual block size
551
# can be a multiple of hash_block_size.
552
self.hash_block_size = hash_block_size
553
+ self.dcp_world_size = dcp_world_size
554
+ group_block_sizes = [
555
+ manager.block_size for manager in self.single_type_managers
556
+ ]
557
assert all(
554
- g.kv_cache_spec.block_size % hash_block_size == 0
555
- for g in kv_cache_config.kv_cache_groups
556
- ), "block_size must be divisible by hash_block_size"
557
- assert dcp_world_size == 1, "DCP not support hybrid attn now."
558
+ block_size % hash_block_size == 0 for block_size in group_block_sizes
559
+ ), (
560
+ "Each KV cache group's real block_size must be divisible by "
561
+ f"hash_block_size. block_sizes={group_block_sizes}, "
562
+ f"hash_block_size={hash_block_size}"
563
+ )
564
assert pcp_world_size == 1, "PCP not support hybrid attn now."
565
+ if dcp_world_size > 1:
566
+ # DCP shards full-attention KV across ranks and replicates Mamba
567
+ # state; other spec types (e.g. sliding window) have no DCP-aware
568
+ # handling yet, so reject them explicitly.
569
+ for g in kv_cache_config.kv_cache_groups:
570
+ assert isinstance(g.kv_cache_spec, (FullAttentionSpec, MambaSpec)), (
571
+ "DCP with hybrid KV cache layouts only supports "
572
+ "full-attention and Mamba groups, got: "
573
+ f"{type(g.kv_cache_spec).__name__}."
574
+ )
575
self.verify_and_split_kv_cache_groups()
576
577
def verify_and_split_kv_cache_groups(self) -> None:
@@ -651,11 +667,11 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
667
- The number of tokens of the longest cache hit.
668
"""
669
654
- def _get_block_hashes(kv_cache_spec: KVCacheSpec) -> BlockHashList:
655
- if kv_cache_spec.block_size == self.hash_block_size:
670
+ def _get_block_hashes(block_size: int) -> BlockHashList:
671
+ if block_size == self.hash_block_size:
672
return block_hashes
673
return BlockHashListWithBlockSize(
658
- block_hashes, self.hash_block_size, kv_cache_spec.block_size
674
+ block_hashes, self.hash_block_size, block_size
675
)
676
677
num_groups = len(self.kv_cache_config.kv_cache_groups)
@@ -680,13 +696,14 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
696
for idx, (spec, group_ids, manager_cls, use_eagle) in enumerate(
697
self.attention_groups
698
):
699
+ group_block_size = self.single_type_managers[group_ids[0]].block_size
700
cached_blocks = hit_blocks_by_group[group_ids[0]]
701
if isinstance(spec, FullAttentionSpec) and cached_blocks is not None:
702
# Full attention is downward-closed: we only need to look
703
# up cached blocks once; on subsequent iterations just trim
704
# to the (reduced) current hit length.
705
curr_hit_length = (
689
- curr_hit_length // spec.block_size * spec.block_size
706
+ curr_hit_length // group_block_size * group_block_size
707
)
708
continue
709
@@ -696,18 +713,23 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
713
if drop_eagle_block:
714
# Eagle needs to match one more block and then pop the last.
715
_max_length = min(
699
- curr_hit_length + spec.block_size, max_cache_hit_length
716
+ curr_hit_length + group_block_size, max_cache_hit_length
717
)
718
hit_blocks = manager_cls.find_longest_cache_hit(
702
- block_hashes=_get_block_hashes(spec),
719
+ block_hashes=_get_block_hashes(group_block_size),
720
max_length=_max_length,
721
kv_cache_group_ids=group_ids,
722
block_pool=self.block_pool,
723
kv_cache_spec=spec,
724
drop_eagle_block=drop_eagle_block,
725
alignment_tokens=self.scheduler_block_size,
726
+ dcp_world_size=(
727
+ self.dcp_world_size
728
+ if isinstance(spec, FullAttentionSpec)
729
+ else 1
730
+ ),
731
)
710
- _new_hit_length = len(hit_blocks[0]) * spec.block_size
732
+ _new_hit_length = len(hit_blocks[0]) * group_block_size
733
if drop_eagle_block:
734
eagle_verified.add(idx)
735
elif _new_hit_length < curr_hit_length:
@@ -728,7 +750,10 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
750
# Truncate full attention blocks to final hit_length (if present)
751
first_group = self.attention_groups[0]
752
if isinstance(first_group.spec, FullAttentionSpec):
731
- num_blocks = hit_length // first_group.spec.block_size
753
+ group_block_size = self.single_type_managers[
754
+ first_group.group_ids[0]
755
+ ].block_size
756
+ num_blocks = hit_length // group_block_size
757
for group_id in first_group.group_ids:
758
if (blks := hit_blocks_by_group[group_id]) is not None:
759
del blks[num_blocks:]
vllm/v1/core/kv_cache_utils.py
+8
-8
@@ -627,7 +627,8 @@ def resolve_kv_cache_block_sizes(
627
- ``scheduler_block_size`` is the token-alignment invariant used by the
628
scheduler (e.g. for ``num_computed_tokens`` rounding). Single group:
629
``cache_config.block_size * dcp * pcp``. Multiple groups: LCM of every
630
- group's block size — context parallelism is not supported here.
630
+ group's effective block size. Attention groups are scaled by DCP/PCP;
631
+ Mamba groups keep their full per-rank state and are not scaled.
632
- ``hash_block_size`` is the granularity at which ``Request.block_hashes``
633
is computed. Single group: equals scheduler block size. Multiple groups:
634
``cache_config.hash_block_size`` override if set, else the GCD of group
@@ -645,13 +646,12 @@ def resolve_kv_cache_block_sizes(
646
bs = cache_config.block_size * dcp * pcp
647
return bs, bs
648
648
- if dcp != 1 or pcp != 1:
649
- raise ValueError(
650
- "Hybrid KV cache groups with multiple block sizes do not "
651
- "support context parallelism (dcp_world_size/pcp_world_size > 1)."
652
- )
653
-
654
- group_block_sizes = [g.kv_cache_spec.block_size for g in groups]
649
+ group_block_sizes = [
650
+ g.kv_cache_spec.block_size * dcp * pcp
651
+ if isinstance(g.kv_cache_spec, AttentionSpec)
652
+ else g.kv_cache_spec.block_size
653
+ for g in groups
654
+ ]
655
scheduler_block_size = math.lcm(*group_block_sizes)
656
657
# Block hashes are only consumed by prefix caching and KV connectors
vllm/v1/core/single_type_kv_cache_manager.py
+4
@@ -1031,6 +1031,10 @@ class MambaManager(SingleTypeKVCacheManager):
1031
self, kv_cache_spec: MambaSpec, block_pool: BlockPool, **kwargs
1032
) -> None:
1033
super().__init__(kv_cache_spec, block_pool, **kwargs)
1034
+ # Mamba layers use TP instead of DCP, so each rank holds the full
1035
+ # recurrent state. Undo the DCP/PCP block_size scaling that the base
1036
+ # class applies for attention groups whose KV cache is partitioned.
1037
+ self.block_size = kv_cache_spec.block_size
1038
self.cached_blocks_this_step: set[BlockHashWithGroupId] = set()
1039
self.mamba_cache_mode = kv_cache_spec.mamba_cache_mode
1040
self.num_speculative_blocks: int = kv_cache_spec.num_speculative_blocks
vllm/v1/kv_cache_interface.py
+34
@@ -128,6 +128,18 @@ class KVCacheSpec:
128
"""
129
raise NotImplementedError
130
131
+ def max_num_blocks_per_req(self, vllm_config: VllmConfig, max_len: int) -> int:
132
+ """
133
+ The number of block table entries needed per request, i.e. the row
134
+ length of the worker-side block table for this cache group.
135
+
136
+ Args:
137
+ vllm_config: The vllm config.
138
+ max_len: The maximum sequence length to size for, including the
139
+ encoder length for encoder-decoder models.
140
+ """
141
+ return cdiv(max_len, self.block_size)
142
+
143
def copy_with_new_block_size(self, block_size: int) -> Self:
144
"""
145
Create a new KVCacheSpec from self but replacing the block size.
@@ -201,6 +213,16 @@ class AttentionSpec(KVCacheSpec):
213
* get_dtype_size(self.dtype)
214
)
215
216
+ def max_num_blocks_per_req(self, vllm_config: VllmConfig, max_len: int) -> int:
217
+ # Attention KV is token-interleaved across DCP/PCP ranks, so each rank
218
+ # only stores max_len // (dcp * pcp) tokens per request.
219
+ parallel_config = vllm_config.parallel_config
220
+ total_cp_size = (
221
+ parallel_config.decode_context_parallel_size
222
+ * parallel_config.prefill_context_parallel_size
223
+ )
224
+ return cdiv(max_len, self.block_size * total_cp_size)
225
+
226
227
@dataclass(frozen=True, kw_only=True)
228
class FullAttentionSpec(AttentionSpec):
@@ -699,6 +721,18 @@ class MambaSpec(KVCacheSpec):
721
else:
722
return self.page_size_bytes * (1 + self.num_speculative_blocks)
723
724
+ def max_num_blocks_per_req(self, vllm_config: VllmConfig, max_len: int) -> int:
725
+ # Mamba state is replicated across DCP/PCP ranks, never sharded, so
726
+ # no CP scaling applies.
727
+ if vllm_config.cache_config.mamba_cache_mode == "align":
728
+ # Block table rows are position-indexed over the full sequence
729
+ # even though only 2 + num_speculative_blocks state blocks are
730
+ # resident at a time (earlier states are nulled out by
731
+ # remove_skipped_blocks), so the row length must cover max_len
732
+ # rather than max_memory_usage_bytes.
733
+ return cdiv(max_len, self.block_size) + self.num_speculative_blocks
734
+ return cdiv(self.max_memory_usage_bytes(vllm_config), self.page_size_bytes)
735
+
736
def is_uniform_with_collection(
737
self, kv_cache_specs: dict[str, KVCacheSpec]
738
) -> bool:
vllm/v1/worker/block_table.py
+54
-23
@@ -1,6 +1,8 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+from enum import Enum
5
+
6
import numpy as np
7
import torch
8
@@ -10,11 +12,15 @@ from vllm.triton_utils import tl, triton
12
from vllm.utils.math_utils import cdiv
13
from vllm.v1.attention.backends.utils import PAD_SLOT_ID
14
from vllm.v1.utils import CpuGpuBuffer
13
-from vllm.v1.worker.cp_utils import get_total_cp_world_size
15
16
logger = init_logger(__name__)
17
18
19
+class SlotMappingMode(Enum):
20
+ TOKEN_TO_KV_SLOT = "token_to_kv_slot"
21
+ NONE = "none"
22
+
23
+
24
class BlockTable:
25
def __init__(
26
self,
@@ -26,6 +32,7 @@ class BlockTable:
32
device: torch.device,
33
kernel_block_size: int,
34
cp_kv_cache_interleave_size: int,
35
+ slot_mapping_mode: SlotMappingMode = SlotMappingMode.TOKEN_TO_KV_SLOT,
36
):
37
"""
38
Args:
@@ -38,11 +45,15 @@ class BlockTable:
45
kernel_block_size: The block_size of underlying attention kernel.
46
Will be the same as `block_size` if `block_size` is supported
47
by the attention kernel.
48
+ slot_mapping_mode: How this cache group maps scheduled tokens to
49
+ cache slots. Mamba-like state caches do not use token slot
50
+ mappings and should use SlotMappingMode.NONE.
51
"""
52
self.max_num_reqs = max_num_reqs
53
self.max_num_batched_tokens = max_num_batched_tokens
54
self.pin_memory = pin_memory
55
self.device = device
56
+ self.kv_cache_block_size = block_size
57
58
if kernel_block_size == block_size:
59
# Standard case: allocation and computation use same block size
@@ -98,6 +109,7 @@ class BlockTable:
109
self.dcp_world_size = 1
110
self.dcp_rank = 0
111
self.cp_kv_cache_interleave_size = cp_kv_cache_interleave_size
112
+ self.slot_mapping_mode = slot_mapping_mode
113
114
def append_row(
115
self,
@@ -145,6 +157,12 @@ class BlockTable:
157
positions: torch.Tensor,
158
) -> None:
159
num_tokens = positions.shape[0]
160
+ if self.slot_mapping_mode == SlotMappingMode.NONE:
161
+ # Mamba/GDN groups consume the block table as recurrent state
162
+ # indices and do not use per-token slot mappings.
163
+ return
164
+ assert self.slot_mapping_mode == SlotMappingMode.TOKEN_TO_KV_SLOT
165
+
166
total_cp_world_size = self.pcp_world_size * self.dcp_world_size
167
total_cp_rank = self.pcp_rank * self.dcp_world_size + self.dcp_rank
168
_compute_slot_mapping_kernel[(num_reqs + 1,)](
@@ -156,6 +174,8 @@ class BlockTable:
174
self.block_table.gpu.stride(0),
175
self.block_size,
176
self.slot_mapping.gpu,
177
+ KV_CACHE_BLOCK_SIZE=self.kv_cache_block_size,
178
+ BLOCKS_PER_KV_BLOCK=self.blocks_per_kv_block,
179
TOTAL_CP_WORLD_SIZE=total_cp_world_size,
180
TOTAL_CP_RANK=total_cp_rank,
181
CP_KV_CACHE_INTERLEAVE_SIZE=self.cp_kv_cache_interleave_size,
@@ -226,30 +246,27 @@ class MultiGroupBlockTable:
246
def __init__(
247
self,
248
max_num_reqs: int,
229
- max_model_len: int,
249
max_num_batched_tokens: int,
250
pin_memory: bool,
251
device: torch.device,
252
block_sizes: list[int],
253
kernel_block_sizes: list[int],
235
- max_num_blocks: list[int] | None = None,
254
+ max_num_blocks: list[int],
255
cp_kv_cache_interleave_size: int = 1,
256
+ slot_mapping_modes: list[SlotMappingMode] | None = None,
257
) -> None:
258
if len(kernel_block_sizes) != len(block_sizes):
259
raise ValueError(
260
f"kernel_block_sizes length ({len(kernel_block_sizes)}) "
261
f"must match block_sizes length ({len(block_sizes)})"
262
)
243
- if max_num_blocks is None:
244
- # Note(hc): each dcp rank only store
245
- # (max_model_len//dcp_world_size) tokens in kvcache,
246
- # so the block_size which used for calc max_num_blocks_per_req
247
- # must be multiplied by dcp_world_size.
248
- total_cp_world_size = get_total_cp_world_size()
249
- max_num_blocks = [
250
- cdiv(max_model_len, block_size * total_cp_world_size)
251
- for block_size in block_sizes
252
- ]
263
+ if slot_mapping_modes is None:
264
+ slot_mapping_modes = [SlotMappingMode.TOKEN_TO_KV_SLOT] * len(block_sizes)
265
+ if len(slot_mapping_modes) != len(block_sizes):
266
+ raise ValueError(
267
+ f"slot_mapping_modes length ({len(slot_mapping_modes)}) "
268
+ f"must match block_sizes length ({len(block_sizes)})"
269
+ )
270
271
if len(max_num_blocks) != len(block_sizes):
272
raise ValueError(
@@ -274,9 +291,15 @@ class MultiGroupBlockTable:
291
device,
292
kernel_block_size,
293
cp_kv_cache_interleave_size,
294
+ slot_mapping_mode=slot_mapping_mode,
295
)
278
- for block_size, kernel_block_size, max_num_blocks_per_req in zip(
279
- block_sizes, kernel_block_sizes, max_num_blocks
296
+ for (
297
+ block_size,
298
+ kernel_block_size,
299
+ max_num_blocks_per_req,
300
+ slot_mapping_mode,
301
+ ) in zip(
302
+ block_sizes, kernel_block_sizes, max_num_blocks, slot_mapping_modes
303
)
304
]
305
@@ -332,6 +355,8 @@ def _compute_slot_mapping_kernel(
355
block_table_stride, # max_num_blocks_per_req
356
block_size,
357
slot_mapping_ptr, # [max_num_tokens], int64
358
+ KV_CACHE_BLOCK_SIZE: tl.constexpr,
359
+ BLOCKS_PER_KV_BLOCK: tl.constexpr,
360
TOTAL_CP_WORLD_SIZE: tl.constexpr,
361
TOTAL_CP_RANK: tl.constexpr,
362
CP_KV_CACHE_INTERLEAVE_SIZE: tl.constexpr,
@@ -354,18 +379,14 @@ def _compute_slot_mapping_kernel(
379
start_idx = tl.load(query_start_loc_ptr + req_idx).to(tl.int64)
380
end_idx = tl.load(query_start_loc_ptr + req_idx + 1).to(tl.int64)
381
357
- virtual_block_size = block_size * TOTAL_CP_WORLD_SIZE
382
+ virtual_block_size = KV_CACHE_BLOCK_SIZE * TOTAL_CP_WORLD_SIZE
383
row_offset = req_idx * block_table_stride
384
for i in range(start_idx, end_idx, BLOCK_SIZE):
385
offsets = i + tl.arange(0, BLOCK_SIZE)
386
mask = offsets < end_idx
387
pos = tl.load(positions_ptr + offsets, mask=mask, other=0)
363
- block_indices = pos // virtual_block_size
364
- block_numbers = tl.load(block_table_ptr + row_offset + block_indices).to(
365
- tl.int64
366
- )
367
-
368
- virtual_block_offsets = pos - block_indices * virtual_block_size
388
+ virtual_block_indices = pos // virtual_block_size
389
+ virtual_block_offsets = pos - virtual_block_indices * virtual_block_size
390
is_local = (
391
virtual_block_offsets // CP_KV_CACHE_INTERLEAVE_SIZE
392
) % TOTAL_CP_WORLD_SIZE == TOTAL_CP_RANK
@@ -375,6 +396,16 @@ def _compute_slot_mapping_kernel(
396
virtual_block_offsets % CP_KV_CACHE_INTERLEAVE_SIZE
397
)
398
378
- slot_ids = block_numbers * block_size + local_block_offsets
399
+ block_indices = (
400
+ virtual_block_indices * BLOCKS_PER_KV_BLOCK
401
+ + local_block_offsets // block_size
402
+ )
403
+ block_numbers = tl.load(
404
+ block_table_ptr + row_offset + block_indices,
405
+ mask=mask & is_local,
406
+ other=0,
407
+ ).to(tl.int64)
408
+ slot_offsets = local_block_offsets % block_size
409
+ slot_ids = block_numbers * block_size + slot_offsets
410
slot_ids = tl.where(is_local, slot_ids, PAD_ID)
411
tl.store(slot_mapping_ptr + offsets, slot_ids, mask=mask)
vllm/v1/worker/cp_utils.py
+237
@@ -1,15 +1,23 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+from types import SimpleNamespace
4
from typing import TYPE_CHECKING, Any, cast
5
6
+import torch
7
+
8
from vllm.config import VllmConfig, get_layers_from_vllm_config
9
from vllm.distributed import get_dcp_group, get_pcp_group
10
+from vllm.logger import init_logger
11
+from vllm.v1.attention.backend import CommonAttentionMetadata
12
+from vllm.v1.attention.backends.utils import split_decodes_prefills_and_extends
13
14
if TYPE_CHECKING:
15
from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
16
else:
17
AttentionLayerBase = object
18
19
+logger = init_logger(__name__)
20
+
21
22
def check_attention_cp_compatibility(vllm_config: VllmConfig) -> None:
23
pcp_size = vllm_config.parallel_config.prefill_context_parallel_size
@@ -56,3 +64,232 @@ def get_total_cp_world_size():
64
# DCP might not be initialized in testing
65
dcp_world_size = 1
66
return dcp_world_size * pcp_world_size
67
+
68
+
69
+def get_dcp_dummy_context_len(
70
+ dcp_world_size: int,
71
+ cp_kv_cache_interleave_size: int,
72
+ has_kv_cache_config: bool,
73
+ create_mixed_batch: bool,
74
+ is_graph_capturing: bool,
75
+ uniform_decode: bool,
76
+) -> int:
77
+ if (
78
+ dcp_world_size <= 1
79
+ or not has_kv_cache_config
80
+ or not (create_mixed_batch or (is_graph_capturing and uniform_decode))
81
+ ):
82
+ return 0
83
+ return dcp_world_size * cp_kv_cache_interleave_size
84
+
85
+
86
+def prepare_dcp_dummy_context_metadata(
87
+ *,
88
+ input_batch: Any,
89
+ kv_cache_config: Any,
90
+ query_pos: Any,
91
+ positions: torch.Tensor,
92
+ query_start_loc: Any,
93
+ num_reqs: int,
94
+ num_tokens_unpadded: int,
95
+ dcp_dummy_context_len: int,
96
+) -> None:
97
+ """Populate valid fake KV metadata for DCP CUDA graph warmup/capture."""
98
+ if dcp_dummy_context_len == 0:
99
+ return
100
+
101
+ # DCP graph warmup may exercise context attention, so block-table entries
102
+ # must point at allocated KV blocks.
103
+ assert kv_cache_config is not None
104
+ max_valid_block_id = kv_cache_config.num_blocks - 1
105
+ assert max_valid_block_id > 0
106
+ for blk_table in input_batch.block_table.block_tables:
107
+ max_row_blocks = (
108
+ blk_table.max_num_blocks_per_req // blk_table.blocks_per_kv_block
109
+ )
110
+ block_ids = [
111
+ (block_idx % max_valid_block_id) + 1 for block_idx in range(max_row_blocks)
112
+ ]
113
+ for req_idx in range(num_reqs):
114
+ blk_table.add_row(block_ids, req_idx)
115
+ blk_table.commit_block_table(num_reqs)
116
+
117
+ query_pos.copy_to_gpu(num_tokens_unpadded)
118
+ positions[:num_tokens_unpadded] = (
119
+ query_pos.gpu[:num_tokens_unpadded] + dcp_dummy_context_len
120
+ )
121
+ input_batch.block_table.compute_slot_mapping(
122
+ num_reqs,
123
+ query_start_loc.gpu[: num_reqs + 1],
124
+ positions[:num_tokens_unpadded],
125
+ )
126
+
127
+
128
+def should_skip_dcp_context_attention(context_kv_lens_cpu: torch.Tensor) -> bool:
129
+ """Whether DCP context attention can be skipped for this batch.
130
+
131
+ Must be computed from rank-invariant inputs only (the global context
132
+ lengths, NOT this rank's local share from get_dcp_local_seq_lens): the
133
+ non-skip path in _forward_with_dcp issues DCP collectives (query
134
+ all-gather + LSE combine), so every DCP rank must take the same branch.
135
+ A rank can hold zero local context tokens while other ranks still hold
136
+ context for the same batch.
137
+ """
138
+ return int(context_kv_lens_cpu.max().item()) == 0
139
+
140
+
141
+def split_dcp_context_queries(
142
+ query_start_loc: torch.Tensor,
143
+ seq_lens_cpu_upper_bound: torch.Tensor | None,
144
+ max_query_len: int,
145
+ num_actual_tokens: int,
146
+) -> tuple[int, int, int, int]:
147
+ """Split reordered DCP context queries into decode and extend regions."""
148
+ num_reqs = query_start_loc.shape[0] - 1
149
+ if max_query_len <= 1:
150
+ return num_reqs, 0, num_actual_tokens, 0
151
+ if seq_lens_cpu_upper_bound is None:
152
+ return 0, num_reqs, 0, num_actual_tokens
153
+
154
+ common_attn_metadata = cast(
155
+ CommonAttentionMetadata,
156
+ SimpleNamespace(
157
+ max_query_len=max_query_len,
158
+ num_reqs=num_reqs,
159
+ num_actual_tokens=num_actual_tokens,
160
+ query_start_loc_cpu=query_start_loc,
161
+ seq_lens_cpu_upper_bound=seq_lens_cpu_upper_bound,
162
+ is_prefilling=None,
163
+ ),
164
+ )
165
+ (
166
+ num_decodes,
167
+ num_extends,
168
+ _num_prefills,
169
+ num_decode_tokens,
170
+ num_extend_tokens,
171
+ _num_prefill_tokens,
172
+ ) = split_decodes_prefills_and_extends(common_attn_metadata)
173
+ return num_decodes, num_extends, num_decode_tokens, num_extend_tokens
174
+
175
+
176
+def should_split_fa2_dcp_context_attention(
177
+ fa_version: int | None,
178
+ max_query_len: int,
179
+ num_reqs: int,
180
+ num_decode_reqs: int,
181
+ num_context_prefill_reqs: int,
182
+) -> bool:
183
+ num_prefills = num_reqs - num_decode_reqs
184
+ # TODO: Remove this FA2-only DCP compatibility path once FA4 supports
185
+ # the Qwen3.5 head_size=256 shape on Blackwell and can be used here.
186
+ # FA2 paged-varlen context attention can fail for DCP mixed batches when
187
+ # decode rows, context-bearing extend rows, and zero-context pure prefill
188
+ # rows are submitted together.
189
+ return (
190
+ fa_version == 2
191
+ and max_query_len > 1
192
+ and num_prefills > 0
193
+ and (num_decode_reqs > 0 or num_context_prefill_reqs < num_prefills)
194
+ )
195
+
196
+
197
+def run_split_fa2_dcp_context_attention(
198
+ flash_attn_varlen_func: Any,
199
+ query_across_dcp: torch.Tensor,
200
+ key_cache: torch.Tensor,
201
+ value_cache: torch.Tensor,
202
+ dcp_context_out: torch.Tensor,
203
+ cu_seqlens_q: torch.Tensor,
204
+ max_seqlen_q: int,
205
+ dcp_context_kv_lens: torch.Tensor,
206
+ max_dcp_context_kv_len: int,
207
+ softmax_scale: float,
208
+ alibi_slopes: torch.Tensor | None,
209
+ sliding_window_size: list[int] | None,
210
+ block_table: torch.Tensor,
211
+ softcap: float,
212
+ fa_version: int,
213
+ q_descale: torch.Tensor | None,
214
+ k_descale: torch.Tensor | None,
215
+ v_descale: torch.Tensor | None,
216
+ max_num_splits: int,
217
+ num_heads: int,
218
+ dcp_world_size: int,
219
+ num_decode_reqs: int,
220
+ num_context_prefill_reqs: int,
221
+ num_decode_tokens: int,
222
+ num_context_prefill_tokens: int,
223
+) -> tuple[torch.Tensor, torch.Tensor]:
224
+ dcp_context_out.zero_()
225
+ context_lse = torch.full(
226
+ (num_heads * dcp_world_size, query_across_dcp.shape[0]),
227
+ -torch.inf,
228
+ dtype=torch.float32,
229
+ device=query_across_dcp.device,
230
+ )
231
+
232
+ if num_decode_tokens > 0:
233
+ _, decode_context_lse = flash_attn_varlen_func(
234
+ q=query_across_dcp[:num_decode_tokens],
235
+ k=key_cache,
236
+ v=value_cache,
237
+ out=dcp_context_out[:num_decode_tokens],
238
+ cu_seqlens_q=cu_seqlens_q[: num_decode_reqs + 1],
239
+ max_seqlen_q=1,
240
+ seqused_k=dcp_context_kv_lens[:num_decode_reqs],
241
+ max_seqlen_k=max_dcp_context_kv_len,
242
+ softmax_scale=softmax_scale,
243
+ causal=False,
244
+ alibi_slopes=alibi_slopes,
245
+ window_size=sliding_window_size,
246
+ block_table=block_table[:num_decode_reqs],
247
+ softcap=softcap,
248
+ return_softmax_lse=True,
249
+ scheduler_metadata=None,
250
+ fa_version=fa_version,
251
+ q_descale=q_descale[:num_decode_reqs] if q_descale is not None else None,
252
+ k_descale=k_descale[:num_decode_reqs] if k_descale is not None else None,
253
+ v_descale=v_descale[:num_decode_reqs] if v_descale is not None else None,
254
+ num_splits=max_num_splits,
255
+ )
256
+ context_lse[:, :num_decode_tokens] = decode_context_lse
257
+
258
+ if num_context_prefill_tokens > 0:
259
+ prefill_start = num_decode_tokens
260
+ prefill_end = prefill_start + num_context_prefill_tokens
261
+ prefill_query_start_loc = (
262
+ cu_seqlens_q[
263
+ num_decode_reqs : num_decode_reqs + num_context_prefill_reqs + 1
264
+ ]
265
+ - num_decode_tokens
266
+ )
267
+ prefill_req_slice = slice(
268
+ num_decode_reqs, num_decode_reqs + num_context_prefill_reqs
269
+ )
270
+ _, prefill_context_lse = flash_attn_varlen_func(
271
+ q=query_across_dcp[prefill_start:prefill_end],
272
+ k=key_cache,
273
+ v=value_cache,
274
+ out=dcp_context_out[prefill_start:prefill_end],
275
+ cu_seqlens_q=prefill_query_start_loc,
276
+ max_seqlen_q=max_seqlen_q,
277
+ seqused_k=dcp_context_kv_lens[prefill_req_slice],
278
+ max_seqlen_k=max_dcp_context_kv_len,
279
+ softmax_scale=softmax_scale,
280
+ causal=False,
281
+ alibi_slopes=alibi_slopes,
282
+ window_size=sliding_window_size,
283
+ block_table=block_table[prefill_req_slice],
284
+ softcap=softcap,
285
+ return_softmax_lse=True,
286
+ scheduler_metadata=None,
287
+ fa_version=fa_version,
288
+ q_descale=q_descale[prefill_req_slice] if q_descale is not None else None,
289
+ k_descale=k_descale[prefill_req_slice] if k_descale is not None else None,
290
+ v_descale=v_descale[prefill_req_slice] if v_descale is not None else None,
291
+ num_splits=max_num_splits,
292
+ )
293
+ context_lse[:, prefill_start:prefill_end] = prefill_context_lse
294
+
295
+ return dcp_context_out, context_lse
vllm/v1/worker/gpu_input_batch.py
+4
-3
@@ -28,7 +28,7 @@ from vllm.v1.sample.thinking_budget_state import (
28
maybe_create_thinking_budget_state_holder,
29
)
30
from vllm.v1.utils import copy_slice
31
-from vllm.v1.worker.block_table import MultiGroupBlockTable
31
+from vllm.v1.worker.block_table import MultiGroupBlockTable, SlotMappingMode
32
33
34
@dataclass
@@ -99,13 +99,14 @@ class InputBatch:
99
vocab_size: int,
100
block_sizes: list[int], # The block_size of each kv cache group
101
kernel_block_sizes: list[int],
102
- max_num_blocks_per_req: list[int] | None = None,
102
+ max_num_blocks_per_req: list[int],
103
logitsprocs: LogitsProcessors | None = None,
104
logitsprocs_need_output_token_ids: bool = False,
105
num_spec_tokens: int = 0,
106
is_pooling_model: bool = False,
107
cp_kv_cache_interleave_size: int = 1,
108
reasoning_config: ReasoningConfig | None = None,
109
+ slot_mapping_modes: list[SlotMappingMode] | None = None,
110
):
111
self.thinking_budget_state_holder = maybe_create_thinking_budget_state_holder(
112
reasoning_config,
@@ -171,7 +172,6 @@ class InputBatch:
172
# Block table.
173
self.block_table = MultiGroupBlockTable(
174
max_num_reqs=max_num_reqs,
174
- max_model_len=max_model_len,
175
max_num_batched_tokens=max_num_batched_tokens,
176
pin_memory=PIN_MEMORY,
177
device=device,
@@ -179,6 +179,7 @@ class InputBatch:
179
kernel_block_sizes=kernel_block_sizes,
180
max_num_blocks=max_num_blocks_per_req,
181
cp_kv_cache_interleave_size=cp_kv_cache_interleave_size,
182
+ slot_mapping_modes=slot_mapping_modes,
183
)
184
185
# Sampling-related.
vllm/v1/worker/gpu_model_runner.py
+61
-16
@@ -153,10 +153,12 @@ from vllm.v1.kv_cache_interface import (
153
KVCacheConfig,
154
KVCacheGroupSpec,
155
KVCacheSpec,
156
+ KVCacheSpecKind,
157
KVQuantMode,
158
MambaSpec,
159
SlidingWindowSpec,
160
UniformTypeKVCacheSpecs,
161
+ get_kv_cache_spec_kind,
162
)
163
from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry
164
from vllm.v1.outputs import (
@@ -201,9 +203,11 @@ from vllm.v1.spec_decode.utils import update_num_computed_tokens_for_batch_chang
203
from vllm.v1.structured_output.utils import apply_grammar_bitmask
204
from vllm.v1.utils import CpuGpuBuffer, record_function_or_nullcontext
205
from vllm.v1.worker import mamba_utils
206
+from vllm.v1.worker.block_table import SlotMappingMode
207
from vllm.v1.worker.cp_utils import (
208
check_attention_cp_compatibility,
206
- get_total_cp_world_size,
209
+ get_dcp_dummy_context_len,
210
+ prepare_dcp_dummy_context_metadata,
211
)
212
from vllm.v1.worker.dp_utils import coordinate_batch_across_dp
213
from vllm.v1.worker.ec_connector_model_runner_mixin import ECConnectorModelRunnerMixin
@@ -681,8 +685,13 @@ class GPUModelRunner(
685
placeholder_block_size = (
686
self.cache_config.block_size or CacheConfig.DEFAULT_BLOCK_SIZE
687
)
688
+ placeholder_max_num_blocks = cdiv(
689
+ max(self.max_model_len, self.max_encoder_len), placeholder_block_size
690
+ )
691
self._init_block_sizes = [placeholder_block_size]
692
self._init_kernel_block_sizes = [placeholder_block_size]
693
+ self._init_max_num_blocks = [placeholder_max_num_blocks]
694
+ self._init_slot_mapping_modes = [SlotMappingMode.TOKEN_TO_KV_SLOT]
695
self.input_batch = InputBatch(
696
max_num_reqs=self.max_num_reqs,
697
# We need to use the encoder length for encoder-decoder
@@ -693,6 +702,7 @@ class GPUModelRunner(
702
vocab_size=self.model_config.get_vocab_size(),
703
block_sizes=[placeholder_block_size],
704
kernel_block_sizes=[placeholder_block_size],
705
+ max_num_blocks_per_req=[placeholder_max_num_blocks],
706
num_spec_tokens=self.num_spec_tokens,
707
logitsprocs=build_logitsprocs(
708
self.vllm_config,
@@ -5858,6 +5868,14 @@ class GPUModelRunner(
5868
num_reqs_padded = (
5869
batch_desc.num_reqs if batch_desc.num_reqs is not None else num_reqs
5870
)
5871
+ dcp_dummy_context_len = get_dcp_dummy_context_len(
5872
+ self.dcp_world_size,
5873
+ self.parallel_config.cp_kv_cache_interleave_size,
5874
+ hasattr(self, "kv_cache_config"),
5875
+ create_mixed_batch,
5876
+ is_graph_capturing,
5877
+ uniform_decode,
5878
+ )
5879
ubatch_slices, ubatch_slices_padded = maybe_create_ubatch_slices(
5880
should_ubatch,
5881
num_scheduled_tokens,
@@ -5900,10 +5918,19 @@ class GPUModelRunner(
5918
# In the mixed batch mode (used for FI warmup), we use
5919
# shorter sequence lengths to run faster.
5920
# TODO(luka) better system for describing dummy batches
5903
- seq_lens = torch.tensor( # type: ignore[assignment]
5904
- [1] * num_decode_tokens + [num_prefill_tokens + 1],
5905
- dtype=torch.int,
5906
- )
5921
+ if dcp_dummy_context_len > 0:
5922
+ seq_lens = torch.tensor( # type: ignore[assignment]
5923
+ [1 + dcp_dummy_context_len] * num_decode_tokens
5924
+ + [num_prefill_tokens + dcp_dummy_context_len],
5925
+ dtype=torch.int,
5926
+ )
5927
+ else:
5928
+ seq_lens = torch.tensor( # type: ignore[assignment]
5929
+ [1] * num_decode_tokens + [num_prefill_tokens + 1],
5930
+ dtype=torch.int,
5931
+ )
5932
+ elif dcp_dummy_context_len > 0:
5933
+ seq_lens = max_query_len + dcp_dummy_context_len # type: ignore[assignment]
5934
else:
5935
seq_lens = max_query_len # type: ignore[assignment]
5936
self.optimistic_seq_lens_cpu[:num_reqs] = seq_lens
@@ -5919,6 +5946,17 @@ class GPUModelRunner(
5946
)
5947
self.query_start_loc.copy_to_gpu()
5948
5949
+ prepare_dcp_dummy_context_metadata(
5950
+ input_batch=self.input_batch,
5951
+ kv_cache_config=getattr(self, "kv_cache_config", None),
5952
+ query_pos=self.query_pos,
5953
+ positions=self.positions,
5954
+ query_start_loc=self.query_start_loc,
5955
+ num_reqs=num_reqs,
5956
+ num_tokens_unpadded=num_tokens_unpadded,
5957
+ dcp_dummy_context_len=dcp_dummy_context_len,
5958
+ )
5959
+
5960
# Sync block table CPU->GPU so cleared rows from
5961
# remove_request() are visible to the attention metadata
5962
# builder. Without this, stale block IDs from finished
@@ -7029,29 +7067,34 @@ class GPUModelRunner(
7067
"""
7068
block_sizes = []
7069
max_num_blocks = []
7070
+ slot_mapping_modes = []
7071
max_model_len = max(self.max_model_len, self.max_encoder_len)
7072
for kv_cache_group in kv_cache_config.kv_cache_groups:
7034
- if isinstance(kv_cache_group.kv_cache_spec, EncoderOnlyAttentionSpec):
7073
+ kv_cache_spec = kv_cache_group.kv_cache_spec
7074
+ kv_cache_spec_kind = get_kv_cache_spec_kind(kv_cache_spec)
7075
+ if kv_cache_spec_kind == KVCacheSpecKind.ENCODER_ONLY_ATTENTION:
7076
continue
7036
- block_size = kv_cache_group.kv_cache_spec.block_size
7077
+ block_size = kv_cache_spec.block_size
7078
block_sizes.append(block_size)
7038
- max_num_blocks_per_req = cdiv(
7039
- max_model_len, block_size * get_total_cp_world_size()
7040
- )
7041
- if isinstance(kv_cache_group.kv_cache_spec, MambaSpec):
7042
- max_num_blocks_per_req = (
7043
- max_num_blocks_per_req
7044
- if self.cache_config.enable_prefix_caching
7045
- else 1
7046
- ) + kv_cache_group.kv_cache_spec.num_speculative_blocks
7079
+ if kv_cache_spec_kind == KVCacheSpecKind.MAMBA:
7080
+ slot_mapping_modes.append(SlotMappingMode.NONE)
7081
+ else:
7082
+ slot_mapping_modes.append(SlotMappingMode.TOKEN_TO_KV_SLOT)
7083
+ max_num_blocks_per_req = kv_cache_spec.max_num_blocks_per_req(
7084
+ self.vllm_config, max_model_len
7085
+ )
7086
max_num_blocks.append(max_num_blocks_per_req)
7087
7088
if (
7089
block_sizes != self._init_block_sizes
7090
or kernel_block_sizes != self._init_kernel_block_sizes
7091
+ or max_num_blocks != self._init_max_num_blocks
7092
+ or slot_mapping_modes != self._init_slot_mapping_modes
7093
):
7094
self._init_block_sizes = block_sizes
7095
self._init_kernel_block_sizes = kernel_block_sizes
7096
+ self._init_max_num_blocks = max_num_blocks
7097
+ self._init_slot_mapping_modes = slot_mapping_modes
7098
self.input_batch = InputBatch(
7099
max_num_reqs=self.max_num_reqs,
7100
max_model_len=max_model_len,
@@ -7065,7 +7108,9 @@ class GPUModelRunner(
7108
logitsprocs=self.input_batch.logitsprocs,
7109
logitsprocs_need_output_token_ids=self.input_batch.logitsprocs_need_output_token_ids,
7110
is_pooling_model=self.is_pooling_model,
7111
+ cp_kv_cache_interleave_size=self.parallel_config.cp_kv_cache_interleave_size,
7112
reasoning_config=self.vllm_config.reasoning_config,
7113
+ slot_mapping_modes=slot_mapping_modes,
7114
)
7115
7116
assert self._init_block_sizes == block_sizes, (
vllm/v1/worker/tpu_input_batch.py
+2
-1
@@ -29,6 +29,7 @@ class InputBatch:
29
vocab_size: int,
30
block_sizes: list[int], # The block_size of each kv cache group
31
kernel_block_sizes: list[int],
32
+ max_num_blocks_per_req: list[int],
33
):
34
self.max_num_reqs = max_num_reqs
35
self.max_model_len = max_model_len
@@ -64,12 +65,12 @@ class InputBatch:
65
# Block table.
66
self.block_table = MultiGroupBlockTable(
67
max_num_reqs=max_num_reqs,
67
- max_model_len=max_model_len,
68
max_num_batched_tokens=max_num_batched_tokens,
69
pin_memory=pin_memory,
70
device=device,
71
block_sizes=block_sizes,
72
kernel_block_sizes=kernel_block_sizes,
73
+ max_num_blocks=max_num_blocks_per_req,
74
)
75
76
# Sampling-related.