[Spec Decode] Support hybrid (SWA + full attention) DFlash drafters (#47914)
Signed-off-by: mgoin <mgoin64@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Michael Goin committed
Jul 8, 2026 at 11:12 UTC
0d12618e98ff2d21d36081e0e9b4eb23573b6d38
9 files changed
+79
-30
vllm/config/vllm.py
+17
@@ -542,6 +542,11 @@ class VllmConfig:
542
):
543
return True
544
545
+ # Mixed sliding/full DFlash drafts need multiple KV groups (V2 only);
546
+ # force V2 as for dspark, since a hybrid target otherwise defaults to V1.
547
+ if self._dflash_needs_multi_kv_group():
548
+ return True
549
+
550
if self.model_config is not None and self.model_config.is_diffusion:
551
return True
552
@@ -565,6 +570,18 @@ class VllmConfig:
570
571
return True
572
573
+ def _dflash_needs_multi_kv_group(self) -> bool:
574
+ """Whether a DFlash draft mixes sliding-window and full attention."""
575
+ spec = self.speculative_config
576
+ if spec is None or spec.method != "dflash":
577
+ return False
578
+ draft_config = getattr(spec, "draft_model_config", None)
579
+ if draft_config is None:
580
+ return False
581
+ layer_types = getattr(draft_config.hf_config, "layer_types", None) or []
582
+ num_sliding = sum(lt == "sliding_attention" for lt in layer_types)
583
+ return 0 < num_sliding < len(layer_types)
584
+
585
def _is_default_v2_model_runner_model(self) -> bool:
586
model_config = self.model_config
587
if model_config is None:
vllm/model_executor/models/qwen3_dflash.py
+17
-9
@@ -86,14 +86,15 @@ def _resolve_layer_attention(
86
if layer_types is not None:
87
num_sliding = sum(lt == SLIDING_ATTENTION for lt in layer_types)
88
any_sliding = num_sliding > 0
89
- all_sliding = num_sliding == len(layer_types)
90
- if any_sliding and not all_sliding:
91
- # Mixed sliding/full attention needs per-layer causal metadata and
92
- # multiple KV-cache groups, which DFlash does not yet support.
89
+ # Mixed sliding/full attention needs multiple KV groups (V2 runner only).
90
+ if (
91
+ 0 < num_sliding < len(layer_types)
92
+ and not get_current_vllm_config().use_v2_model_runner
93
+ ):
94
raise NotImplementedError(
94
- "DFlash does not yet support mixed sliding/full attention via "
95
- "layer_types; see "
96
- "https://github.com/vllm-project/vllm/issues/40898."
95
+ "DFlash drafters with mixed sliding/full attention require "
96
+ "the V2 model runner; relaunch with "
97
+ "VLLM_USE_V2_MODEL_RUNNER=1."
98
)
99
100
default_causal = False
@@ -207,8 +208,7 @@ class DFlashQwen3Attention(nn.Module):
208
attn_type=attn_type,
209
sinks=self.attention_sink_bias,
210
)
210
- # NOTE: `causal` is currently unused here, but will be needed in the future
211
- # to support models with different causality per-layer.
211
+ self.causal = causal
212
self.q_norm = RMSNorm(self.head_dim, eps=rms_norm_eps)
213
self.k_norm = RMSNorm(self.head_dim, eps=rms_norm_eps)
214
@@ -709,6 +709,14 @@ class DFlashQwen3ForCausalLM(Qwen3ForCausalLM):
709
) -> torch.Tensor:
710
return self.model(input_ids, positions, inputs_embeds)
711
712
+ def get_draft_kv_cache_layer_names(self) -> list[str]:
713
+ return [layer.self_attn.attn.layer_name for layer in self.model.layers]
714
+
715
+ def get_draft_attn_causal(self) -> list[bool]:
716
+ """Per-layer attention causality, aligned with
717
+ get_draft_kv_cache_layer_names."""
718
+ return [layer.self_attn.causal for layer in self.model.layers]
719
+
720
def compute_logits(
721
self,
722
hidden_states: torch.Tensor,
vllm/v1/worker/gpu/attn_utils.py
+5
-3
@@ -1,6 +1,6 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-from collections.abc import Iterable, Sequence
3
+from collections.abc import Iterable, Mapping, Sequence
4
from dataclasses import dataclass, replace
5
from math import prod
6
from typing import Any, cast
@@ -573,7 +573,7 @@ def build_attn_metadata(
573
mm_req_doc_ranges: dict[int, list[tuple[int, int]]] | None = None,
574
model_specific_attn_metadata: ModelSpecificAttnMetadata | None = None,
575
for_cudagraph_capture: bool = False,
576
- causal: bool = True,
576
+ causal: bool | Mapping[int, bool] = True,
577
rswa_prefix_lens: torch.Tensor | None = None,
578
) -> dict[str, Any]:
579
seq_lens = seq_lens[:num_reqs]
@@ -587,6 +587,8 @@ def build_attn_metadata(
587
for i in range(num_kv_cache_groups):
588
block_table = block_tables[i]
589
slot_mapping = slot_mappings[i]
590
+ # Per-group causal for hybrid drafters (mixed SWA/full attention).
591
+ group_causal = causal if isinstance(causal, bool) else causal.get(i, True)
592
593
common_attn_metadata_extra_kwargs = (
594
model_specific_attn_metadata.get_extra_common_attn_kwargs(i, num_reqs)
@@ -604,7 +606,7 @@ def build_attn_metadata(
606
max_query_len=max_query_len,
607
block_table_tensor=block_table,
608
slot_mapping=slot_mapping,
607
- causal=causal,
609
+ causal=group_causal,
610
dcp_local_seq_lens=dcp_local_seq_lens,
611
positions=positions,
612
mm_req_doc_ranges=mm_req_doc_ranges,
vllm/v1/worker/gpu/spec_decode/dflash/cudagraph.py
+4
-7
@@ -1,6 +1,6 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-from collections.abc import Callable
3
+from collections.abc import Callable, Mapping
4
5
import torch
6
@@ -29,7 +29,7 @@ def _prepare_dflash_inputs_to_capture(
29
kv_cache_config: KVCacheConfig,
30
max_model_len: int,
31
skip_attn: bool,
32
- causal: bool,
32
+ causal: bool | Mapping[int, bool],
33
) -> AttentionState:
34
input_batch = InputBatch.make_dummy(num_reqs, num_tokens, input_buffers)
35
input_block_tables = block_tables.get_dummy_block_tables(num_reqs)
@@ -63,10 +63,6 @@ class DFlashCudaGraphManager(CudaGraphManager):
63
"""DFlash CudaGraphManager for the parallel-drafting query forward,
64
building its own attention metadata from scratch."""
65
66
- def __init__(self, *args, causal: bool = False, **kwargs) -> None:
67
- super().__init__(*args, **kwargs)
68
- self.causal = causal
69
-
66
def capture(
67
self,
68
forward_fn: Callable,
@@ -75,6 +71,7 @@ class DFlashCudaGraphManager(CudaGraphManager):
71
attn_groups: list[list[AttentionGroup]],
72
kv_cache_config: KVCacheConfig,
73
max_model_len: int,
74
+ causal: bool | Mapping[int, bool],
75
progress_bar_desc: str = "Capturing CUDA graphs",
76
) -> None:
77
def create_forward_fn(
@@ -97,7 +94,7 @@ class DFlashCudaGraphManager(CudaGraphManager):
94
kv_cache_config,
95
max_model_len,
96
skip_attn=(desc.cg_mode == CUDAGraphMode.PIECEWISE),
100
- causal=self.causal,
97
+ causal=causal,
98
)
99
attn_metadata, slot_mappings = attn_state
100
vllm/v1/worker/gpu/spec_decode/dflash/speculator.py
+15
-5
@@ -1,5 +1,6 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+from collections.abc import Mapping
4
from typing import Any
5
6
import torch
@@ -94,7 +95,6 @@ class DFlashSpeculator(DraftModelSpeculator):
95
self.device,
96
cudagraph_mode,
97
decode_query_len=self.num_query_per_req,
97
- causal=self.dflash_causal,
98
)
99
100
def capture(self, attn_states: dict | None = None) -> None:
@@ -112,6 +112,7 @@ class DFlashSpeculator(DraftModelSpeculator):
112
self.attn_groups,
113
self.kv_cache_config,
114
self.max_model_len,
115
+ causal=self._group_causal,
116
progress_bar_desc=f"Capturing {self._speculator_name.lower()} CUDA graphs",
117
)
118
@@ -148,7 +149,10 @@ class DFlashSpeculator(DraftModelSpeculator):
149
# of the kv-cache group its cache belongs to. Models that share a single group
150
# leave this as None and share one context slot mapping.
151
self._layer_group_idx: list[int] | None = None
152
+ # Per-KV-group causal, falling back to the scalar dflash_causal.
153
+ self._group_causal: dict[int, bool] | bool = self.dflash_causal
154
if hasattr(self.model, "get_draft_kv_cache_layer_names"):
155
+ layer_names = self.model.get_draft_kv_cache_layer_names()
156
name_to_gid = {
157
ln: gid
158
for gid, group in enumerate(kv_cache_config.kv_cache_groups)
@@ -156,9 +160,15 @@ class DFlashSpeculator(DraftModelSpeculator):
160
}
161
gid_to_idx = {gid: i for i, gid in enumerate(self.draft_kv_cache_group_ids)}
162
self._layer_group_idx = [
159
- gid_to_idx[name_to_gid[name]]
160
- for name in self.model.get_draft_kv_cache_layer_names()
163
+ gid_to_idx[name_to_gid[name]] for name in layer_names
164
]
165
+ if hasattr(self.model, "get_draft_attn_causal"):
166
+ self._group_causal = {
167
+ name_to_gid[name]: layer_causal
168
+ for name, layer_causal in zip(
169
+ layer_names, self.model.get_draft_attn_causal()
170
+ )
171
+ }
172
173
@torch.inference_mode()
174
def _run_model(
@@ -226,7 +236,7 @@ class DFlashSpeculator(DraftModelSpeculator):
236
num_reqs_padded: int,
237
num_tokens_padded: int,
238
num_query_per_req: int | None = None,
229
- causal: bool = False,
239
+ causal: bool | Mapping[int, bool] = False,
240
) -> dict[str, Any] | None:
241
if not self.draft_attn_layer_names:
242
return None
@@ -383,7 +393,7 @@ class DFlashSpeculator(DraftModelSpeculator):
393
num_reqs=num_reqs,
394
num_reqs_padded=num_reqs_padded,
395
num_tokens_padded=num_tokens_padded,
386
- causal=self.dflash_causal,
396
+ causal=self._group_causal,
397
)
398
draft_slot_mappings_by_layer = build_slot_mappings_by_layer(
399
self.block_tables.slot_mappings[:, :num_tokens_padded],
vllm/v1/worker/gpu/spec_decode/dflash/utils.py
+5
-2
@@ -5,7 +5,10 @@ import torch.nn as nn
5
from vllm.config import ModelConfig, VllmConfig, replace
6
from vllm.distributed.parallel_state import get_pp_group
7
from vllm.model_executor.model_loader import get_model
8
-from vllm.v1.worker.gpu.spec_decode.eagle.utils import _should_share
8
+from vllm.v1.worker.gpu.spec_decode.eagle.utils import (
9
+ _should_share,
10
+ get_target_lm_head,
11
+)
12
13
14
def get_dflash_causal(draft_model_config: ModelConfig) -> bool:
@@ -57,7 +60,7 @@ def load_dflash_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Mo
60
del draft_inner.embed_tokens
61
draft_inner.embed_tokens = target_embed
62
60
- target_lm_head = getattr(target_model, "lm_head", None)
63
+ target_lm_head = get_target_lm_head(target_model, target_language_model)
64
draft_lm_head = getattr(dflash_model, "lm_head", None)
65
if target_lm_head is not None and _should_share(
66
dflash_model, "has_own_lm_head", draft_lm_head, target_lm_head
vllm/v1/worker/gpu/spec_decode/dspark/utils.py
+5
-2
@@ -6,7 +6,10 @@ import torch.nn as nn
6
from vllm.config import VllmConfig, replace
7
from vllm.distributed.parallel_state import get_pp_group
8
from vllm.model_executor.model_loader import get_model
9
-from vllm.v1.worker.gpu.spec_decode.eagle.utils import _should_share
9
+from vllm.v1.worker.gpu.spec_decode.eagle.utils import (
10
+ _should_share,
11
+ get_target_lm_head,
12
+)
13
14
15
def load_dspark_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Module:
@@ -52,7 +55,7 @@ def load_dspark_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Mo
55
del draft_inner.embed_tokens
56
draft_inner.embed_tokens = target_embed
57
55
- target_lm_head = getattr(target_model, "lm_head", None)
58
+ target_lm_head = get_target_lm_head(target_model, target_language_model)
59
draft_lm_head = getattr(draft_model, "lm_head", None)
60
if target_lm_head is not None and _should_share(
61
draft_model, "has_own_lm_head", draft_lm_head, target_lm_head
vllm/v1/worker/gpu/spec_decode/eagle/utils.py
+9
-1
@@ -25,6 +25,14 @@ def _should_share(eagle: nn.Module, flag: str, draft, target) -> bool:
25
return torch.equal(w, target.weight)
26
27
28
+def get_target_lm_head(target_model: nn.Module, target_language_model: nn.Module):
29
+ """The target's lm_head — from get_language_model() for
30
+ *ForConditionalGeneration targets, else the top-level module."""
31
+ return getattr(target_language_model, "lm_head", None) or getattr(
32
+ target_model, "lm_head", None
33
+ )
34
+
35
+
36
def load_eagle_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Module:
37
from vllm.compilation.backends import set_model_tag
38
@@ -64,7 +72,7 @@ def load_eagle_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Mod
72
del draft_inner.embed_tokens
73
draft_inner.embed_tokens = target_embed
74
67
- target_lm_head = getattr(target_model, "lm_head", None)
75
+ target_lm_head = get_target_lm_head(target_model, target_language_model)
76
draft_lm_head = getattr(eagle_model, "lm_head", None)
77
if target_lm_head is not None and _should_share(
78
eagle_model, "has_own_lm_head", draft_lm_head, target_lm_head
vllm/v1/worker/gpu/spec_decode/speculator.py
+2
-1
@@ -1,6 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
from abc import ABC, abstractmethod
4
+from collections.abc import Mapping
5
from typing import Any
6
7
import torch
@@ -202,7 +203,7 @@ class DraftModelSpeculator(BaseSpeculator):
203
num_reqs_padded: int,
204
num_tokens_padded: int,
205
num_query_per_req: int = 1,
205
- causal: bool = True,
206
+ causal: bool | Mapping[int, bool] = True,
207
) -> dict[str, Any] | None:
208
# Uniform query: query_start_loc[i] = min(i, num_reqs) * num_query_per_req.
209
# Clamp keeps the series non-decreasing past num_reqs, which some