[Spec Decode] Support SWA + DFlash for MiMo (#46104)
Benjamin Chislett committed
Jun 30, 2026 at 23:34 UTC
9969466a597810db6e06b4942dd6cc2086885ee2
4 files changed
+243
-25
tests/models/registry.py
+1
-1
@@ -1408,7 +1408,7 @@ _SPECULATIVE_DECODING_EXAMPLE_MODELS = {
1408
# [DFlash]
1409
"DFlashDraftModel": _HfExamplesInfo(
1410
"Qwen/Qwen3.5-4B",
1411
- speculative_model="z-lab/Qwen3.5-4B-DFlash",
1411
+ speculative_model="z-lab/Qwen3-4B-DFlash-b16",
1412
use_original_num_layers=True, # Need all layers since DFlash has >1 layer,
1413
max_model_len=8192, # Reduce max len to ensure test runs in low-VRAM CI env
1414
max_num_seqs=32,
vllm/model_executor/models/mimo_v2.py
+16
-3
@@ -53,7 +53,12 @@ from vllm.sequence import IntermediateTensors
53
from vllm.v1.attention.backend import AttentionType
54
from vllm.v1.attention.backends.registry import AttentionBackendEnum
55
56
-from .interfaces import MixtureOfExperts, SupportsPP
56
+from .interfaces import (
57
+ EagleModelMixin,
58
+ MixtureOfExperts,
59
+ SupportsEagle3,
60
+ SupportsPP,
61
+)
62
from .utils import (
63
AutoWeightsLoader,
64
PPMissingLayer,
@@ -539,7 +544,7 @@ def _shard_fp8_qkv_proj(
544
545
546
@support_torch_compile
542
-class MiMoV2Model(nn.Module):
547
+class MiMoV2Model(nn.Module, EagleModelMixin):
548
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
549
super().__init__()
550
@@ -602,10 +607,16 @@ class MiMoV2Model(nn.Module):
607
hidden_states = intermediate_tensors["hidden_states"]
608
residual = intermediate_tensors["residual"]
609
610
+ aux_hidden_states = self._maybe_add_hidden_state(
611
+ [], self.start_layer, hidden_states, residual
612
+ )
613
for idx, layer in enumerate(
614
islice(self.layers, self.start_layer, self.end_layer)
615
):
616
hidden_states, residual = layer(positions, hidden_states, residual)
617
+ self._maybe_add_hidden_state(
618
+ aux_hidden_states, idx + 1, hidden_states, residual
619
+ )
620
621
if not get_pp_group().is_last_rank:
622
return IntermediateTensors(
@@ -614,6 +625,8 @@ class MiMoV2Model(nn.Module):
625
626
hidden_states, _ = self.norm(hidden_states, residual)
627
628
+ if len(aux_hidden_states) > 0:
629
+ return hidden_states, aux_hidden_states
630
return hidden_states
631
632
def get_expert_mapping(self) -> list[tuple[str, str, int, str]]:
@@ -822,7 +835,7 @@ class MiMoV2Model(nn.Module):
835
return True
836
837
825
-class MiMoV2FlashForCausalLM(nn.Module, SupportsPP, MixtureOfExperts):
838
+class MiMoV2FlashForCausalLM(nn.Module, SupportsPP, MixtureOfExperts, SupportsEagle3):
839
packed_modules_mapping = {
840
"qkv_proj": ["q_proj", "k_proj", "v_proj"],
841
"gate_up_proj": ["gate_proj", "up_proj"],
vllm/model_executor/models/qwen3_dflash.py
+193
-4
@@ -1,6 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+import io
5
from collections.abc import Iterable
6
7
import torch
@@ -11,7 +12,10 @@ from transformers import Qwen3Config
12
from vllm import _custom_ops as ops
13
from vllm.compilation.decorators import support_torch_compile
14
from vllm.config import CacheConfig, VllmConfig, get_current_vllm_config
14
-from vllm.distributed import get_tensor_model_parallel_world_size
15
+from vllm.distributed import (
16
+ get_tensor_model_parallel_rank,
17
+ get_tensor_model_parallel_world_size,
18
+)
19
from vllm.logger import init_logger
20
from vllm.model_executor.layers.attention import Attention
21
from vllm.model_executor.layers.layernorm import RMSNorm
@@ -33,6 +37,7 @@ from vllm.model_executor.model_loader.weight_utils import (
37
)
38
from vllm.multimodal.inputs import NestedTensors
39
from vllm.transformers_utils.config import set_default_rope_theta
40
+from vllm.transformers_utils.repo_utils import get_hf_file_bytes
41
from vllm.v1.attention.backend import AttentionType
42
43
from .qwen2 import Qwen2MLP as Qwen3MLP
@@ -47,6 +52,76 @@ from .utils import (
52
logger = init_logger(__name__)
53
54
55
+def _resolve_layer_attention(
56
+ config: Qwen3Config, layer_idx: int
57
+) -> tuple[int | None, bool]:
58
+ """Resolve ``(sliding_window, causal)`` for one DFlash draft layer.
59
+
60
+ +----------------------+-------------------------+--------------------------------+
61
+ | Config | ``layer_type`` | *``causal`` |
62
+ +======================+=========================+================================+
63
+ | ``layer_types`` | SWA if ``use_swa`` | True if ``layer_types[i]=SWA`` |
64
+ | | else ``layer_types[i]`` | else False |
65
+ +----------------------+-------------------------+--------------------------------+
66
+ | ``layer_types=None`` | SWA | False |
67
+ | + ``use_swa=True`` | | |
68
+ +----------------------+-------------------------+--------------------------------+
69
+ | ``layer_types=None`` | Full | False |
70
+ | + ``use_swa=False`` | | |
71
+ +----------------------+-------------------------+--------------------------------+
72
+ * If ``dflash_config.causal`` is set, its value overrides ``causal`` for all layers.
73
+
74
+ This is to support a varied ecosystem of checkpoints, including:
75
+ - XiaomiMiMo/MiMo-V2.5-Pro-FP4-DFlash (sets "use_swa", assumes non-causal)
76
+ - z-lab/gemma-4-31B-it-DFlash (has mixed layer types, assumes causal only for SWA)
77
+ - z-lab/Qwen3.5-9B-DFlash ("standard" DFlash, all full attn, assumes non-causal)
78
+ """
79
+ dflash_config = getattr(config, "dflash_config", None) or {}
80
+ layer_types = getattr(config, "layer_types", None)
81
+ use_swa = dflash_config.get("use_swa", False)
82
+ config_causal = dflash_config.get("causal", None)
83
+
84
+ SLIDING_ATTENTION = "sliding_attention"
85
+ any_sliding = False
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.
93
+ 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."
97
+ )
98
+
99
+ default_causal = False
100
+ if layer_types is None or (use_swa and not any_sliding):
101
+ # An absent ``layer_types`` (or the all-"full_attention" one that may
102
+ # be synthesized when the checkpoint omits it) must not override
103
+ # ``dflash_config.use_swa``, which forces SWA on every layer.
104
+ is_sliding = use_swa
105
+ else:
106
+ is_sliding = layer_types[layer_idx] == SLIDING_ATTENTION
107
+ # Full-attention layers default non-causal; SWA layers default causal.
108
+ default_causal = is_sliding
109
+
110
+ sliding_window = None
111
+ if is_sliding:
112
+ sliding_window = dflash_config.get(
113
+ "swa_window_size", getattr(config, "sliding_window", None)
114
+ )
115
+ if sliding_window is None:
116
+ raise ValueError(
117
+ "DFlash sliding attention requires a window size configured in "
118
+ "dflash_config.swa_window_size or the top-level sliding_window."
119
+ )
120
+
121
+ causal = config_causal if config_causal is not None else default_causal
122
+ return sliding_window, causal
123
+
124
+
125
class DFlashQwen3Attention(nn.Module):
126
"""Attention for DFlash speculative decoding.
127
@@ -64,6 +139,9 @@ class DFlashQwen3Attention(nn.Module):
139
head_dim: int | None = None,
140
rms_norm_eps: float = 1e-06,
141
attention_bias: bool = False,
142
+ add_swa_attention_sink_bias: bool = False,
143
+ sliding_window: int | None = None,
144
+ causal: bool = False,
145
cache_config: CacheConfig | None = None,
146
quant_config: QuantizationConfig | None = None,
147
prefix: str = "",
@@ -109,6 +187,14 @@ class DFlashQwen3Attention(nn.Module):
187
max_position=max_position,
188
rope_parameters=rope_parameters,
189
)
190
+
191
+ self.attention_sink_bias = (
192
+ torch.nn.Parameter(torch.empty(self.num_heads), requires_grad=False)
193
+ if add_swa_attention_sink_bias
194
+ else None
195
+ )
196
+
197
+ self.sliding_window = sliding_window
198
self.attn = Attention(
199
self.num_heads,
200
self.head_dim,
@@ -116,9 +202,13 @@ class DFlashQwen3Attention(nn.Module):
202
num_kv_heads=self.num_kv_heads,
203
cache_config=cache_config,
204
quant_config=quant_config,
205
+ per_layer_sliding_window=sliding_window,
206
prefix=f"{prefix}.attn",
207
attn_type=attn_type,
208
+ sinks=self.attention_sink_bias,
209
)
210
+ # NOTE: `causal` is currently unused here, but will be needed in the future
211
+ # to support models with different causality per-layer.
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
@@ -156,6 +246,7 @@ class DFlashQwen3DecoderLayer(nn.Module):
246
vllm_config: VllmConfig,
247
*,
248
config: Qwen3Config,
249
+ layer_idx: int,
250
cache_config: CacheConfig | None = None,
251
quant_config: QuantizationConfig | None = None,
252
prefix: str = "",
@@ -165,6 +256,18 @@ class DFlashQwen3DecoderLayer(nn.Module):
256
set_default_rope_theta(config, default_theta=1000000)
257
attn_type = AttentionType.DECODER
258
259
+ # DFlash drafts store the sink-bias flag inside dflash_config; fall back
260
+ # to the top-level attribute used by other (e.g. MiMo) configs.
261
+ dflash_config = getattr(config, "dflash_config", None) or {}
262
+ add_swa_attention_sink_bias = dflash_config.get(
263
+ "attention_sink_bias",
264
+ getattr(config, "add_swa_attention_sink_bias", False),
265
+ )
266
+
267
+ # Resolve this layer's attention mode (full vs sliding window, causal vs
268
+ # non-causal) from the draft config.
269
+ sliding_window, causal = _resolve_layer_attention(config, layer_idx)
270
+
271
self.self_attn = DFlashQwen3Attention(
272
hidden_size=self.hidden_size,
273
num_heads=config.num_attention_heads,
@@ -172,6 +275,9 @@ class DFlashQwen3DecoderLayer(nn.Module):
275
num_kv_heads=config.num_key_value_heads,
276
rms_norm_eps=config.rms_norm_eps,
277
attention_bias=getattr(config, "attention_bias", False),
278
+ add_swa_attention_sink_bias=add_swa_attention_sink_bias,
279
+ sliding_window=sliding_window,
280
+ causal=causal,
281
head_dim=getattr(config, "head_dim", None),
282
cache_config=cache_config,
283
quant_config=quant_config,
@@ -243,11 +349,24 @@ class DFlashQwen3Model(nn.Module):
349
prefix=maybe_prefix(prefix, "embed_tokens"),
350
)
351
352
+ # Masked query slots are fed to the draft as `mask_token_id`. Most DFlash
353
+ # checkpoints will have the mask embedding in the vocabulary embedding table
354
+ # at that slot id. Some checkpoints (XiaomiMiMo/MiMo-V2.5-Pro-FP4-DFlash) ship
355
+ # with a separate mask embedding tensor to use instead. When present, we load it
356
+ # and substitute it for embed_tokens[mask_token_id] when computing embeddings.
357
+ self.mask_token_id = drafter_config.get("mask_token_id")
358
+ self.mask_embedding = nn.Parameter(
359
+ torch.zeros(self.config.hidden_size, dtype=vllm_config.model_config.dtype),
360
+ requires_grad=False,
361
+ )
362
+ self.has_separate_mask_embedding = False
363
+
364
self.layers = nn.ModuleList(
365
[
366
DFlashQwen3DecoderLayer(
367
current_vllm_config,
368
config=self.config,
369
+ layer_idx=layer_idx,
370
cache_config=current_vllm_config.cache_config,
371
quant_config=self.quant_config,
372
prefix=maybe_prefix(prefix, f"layers.{layer_idx + start_layer_id}"),
@@ -284,7 +403,12 @@ class DFlashQwen3Model(nn.Module):
403
)
404
405
def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
287
- return self.embed_tokens(input_ids)
406
+ embeds = self.embed_tokens(input_ids)
407
+ if self.has_separate_mask_embedding and self.mask_token_id is not None:
408
+ # Replace masked slots with the dedicated mask embedding.
409
+ is_mask = (input_ids == self.mask_token_id).unsqueeze(-1)
410
+ embeds = torch.where(is_mask, self.mask_embedding.to(embeds.dtype), embeds)
411
+ return embeds
412
413
def _build_fused_kv_buffers(self) -> None:
414
"""Build fused weight buffers for precompute_and_store_context_kv.
@@ -469,6 +593,8 @@ class DFlashQwen3Model(nn.Module):
593
]
594
params_dict = dict(self.named_parameters())
595
loaded_params: set[str] = set()
596
+ tp_rank = get_tensor_model_parallel_rank()
597
+ tp_size = get_tensor_model_parallel_world_size()
598
for name, loaded_weight in weights:
599
if "midlayer." in name:
600
name = name.replace("midlayer.", "layers.0.")
@@ -476,6 +602,18 @@ class DFlashQwen3Model(nn.Module):
602
name = maybe_remap_kv_scale_name(name, params_dict)
603
if name is None:
604
continue
605
+ if "attention_sink_bias" in name:
606
+ if name not in params_dict:
607
+ continue
608
+ # Sink bias is per-head; shard it across TP ranks like the
609
+ # attention heads themselves.
610
+ param = params_dict[name]
611
+ heads_per_rank = loaded_weight.shape[0] // tp_size
612
+ head_start = tp_rank * heads_per_rank
613
+ narrow_weight = loaded_weight.narrow(0, head_start, heads_per_rank)
614
+ param.data.copy_(narrow_weight)
615
+ loaded_params.add(name)
616
+ continue
617
for param_name, weight_name, shard_id in stacked_params_mapping:
618
if weight_name not in name:
619
continue
@@ -495,7 +633,8 @@ class DFlashQwen3Model(nn.Module):
633
class DFlashQwen3ForCausalLM(Qwen3ForCausalLM):
634
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
635
nn.Module.__init__(self)
498
- self.config = vllm_config.speculative_config.draft_model_config.hf_config
636
+ self.draft_model_config = vllm_config.speculative_config.draft_model_config
637
+ self.config = self.draft_model_config.hf_config
638
if getattr(self.config, "draft_vocab_size", None) is None:
639
self.config.draft_vocab_size = getattr(self.config, "vocab_size", None)
640
target_layer_num = vllm_config.model_config.get_num_layers(
@@ -589,7 +728,9 @@ class DFlashQwen3ForCausalLM(Qwen3ForCausalLM):
728
includes_embed_tokens = False
729
for name, loaded_weight in weights:
730
assert "mask_hidden" not in name, (
592
- "DFlash should use mask_token_id to embed the padding hidden state"
731
+ "DFlash embeds masked slots via mask_token_id (optionally "
732
+ "overridden by a mask_embedding.pt file); it should not ship a "
733
+ "mask_hidden weight."
734
)
735
if "t2d" in name:
736
continue
@@ -603,6 +744,13 @@ class DFlashQwen3ForCausalLM(Qwen3ForCausalLM):
744
model_weights[name] = loaded_weight
745
process_eagle_weight(self, name)
746
747
+ # Route the separately-trained mask embedding (if shipped) through the
748
+ # standard weight loader alongside the rest of the draft weights.
749
+ mask_embedding = self._read_mask_embedding()
750
+ if mask_embedding is not None:
751
+ model_weights["model.mask_embedding"] = mask_embedding
752
+ self.model.has_separate_mask_embedding = True
753
+
754
skip_substrs = []
755
if not includes_draft_id_mapping:
756
skip_substrs.append("draft_id_to_target_id")
@@ -610,6 +758,8 @@ class DFlashQwen3ForCausalLM(Qwen3ForCausalLM):
758
skip_substrs.append("embed_tokens")
759
if not self.model.use_aux_hidden_state:
760
skip_substrs.append("fc.")
761
+ if not self.model.has_separate_mask_embedding:
762
+ skip_substrs.append("mask_embedding")
763
loader = AutoWeightsLoader(
764
self,
765
skip_prefixes=None,
@@ -617,3 +767,42 @@ class DFlashQwen3ForCausalLM(Qwen3ForCausalLM):
767
)
768
loader.load_weights(model_weights.items())
769
self.model._build_fused_kv_buffers()
770
+
771
+ def _read_mask_embedding(self) -> torch.Tensor | None:
772
+ """Checks for an override mask embedding in `mask_embedding.pt` and returns it.
773
+
774
+ Some checkpoints ship a separately-trained mask embedding for the mask token,
775
+ which we use to overwrite the embedding for `mask_token_id`. This helper
776
+ checks for the file, loads the pytorch tensor, and returns the embedding to use.
777
+
778
+ Returns None if the override file is not present.
779
+ """
780
+ mask_token_id = self.model.mask_token_id
781
+ if mask_token_id is None:
782
+ return None
783
+
784
+ MASK_EMBEDDING_FILENAME = "mask_embedding.pt"
785
+ data = get_hf_file_bytes(
786
+ MASK_EMBEDDING_FILENAME,
787
+ self.draft_model_config.model,
788
+ self.draft_model_config.revision,
789
+ )
790
+ if data is None:
791
+ return None
792
+
793
+ state = torch.load(io.BytesIO(data), weights_only=True)
794
+ if isinstance(state, dict):
795
+ if state.get("mask_token_id", mask_token_id) != mask_token_id:
796
+ raise ValueError(
797
+ f"{MASK_EMBEDDING_FILENAME} mask_token_id does not match "
798
+ f"dflash_config.mask_token_id ({mask_token_id}). "
799
+ f"Got {state.get('mask_token_id')}."
800
+ )
801
+ state = state["embedding"]
802
+
803
+ logger.info(
804
+ "Loaded DFlash mask embedding for mask_token_id %s from %s",
805
+ mask_token_id,
806
+ MASK_EMBEDDING_FILENAME,
807
+ )
808
+ return state.reshape(-1)
vllm/v1/attention/backends/flash_attn.py
+33
-17
@@ -252,6 +252,8 @@ class FlashAttentionMetadata:
252
253
causal: bool | torch.Tensor = True
254
255
+ sliding_window: tuple[int, int] | None = None
256
+
257
# PrefixLM bidirectional ranges for multimodal tokens.
258
# Shape: (num_seqs, max_ranges, 2) int32, [start, end] per range.
259
mm_prefix_range_tensor: torch.Tensor | None = None
@@ -284,6 +286,20 @@ def _get_sliding_window_configs(
286
return sliding_window_configs
287
288
289
+def _maybe_symmetrize_window(
290
+ window: tuple[int, int] | None,
291
+ causal: bool | torch.Tensor,
292
+) -> tuple[int, int] | None:
293
+ """Make a causal sliding window ``(w, 0)`` symmetric ``(w, w)`` when attention
294
+ is non-causal, so bidirectional queries attend in both directions. Leaves
295
+ full-attention ``(-1, -1)`` and already-symmetric windows untouched.
296
+ """
297
+ non_causal = isinstance(causal, torch.Tensor) or causal is False
298
+ if window is not None and window[0] >= 0 and window[1] == 0 and non_causal:
299
+ return (window[0], window[0])
300
+ return window
301
+
302
+
303
class FlashAttentionMetadataBuilder(AttentionMetadataBuilder[FlashAttentionMetadata]):
304
# FA3:
305
# Supports full cudagraphs for all cases.
@@ -487,7 +503,9 @@ class FlashAttentionMetadataBuilder(AttentionMetadataBuilder[FlashAttentionMetad
503
cu_seqlens_q=cu_query_lens,
504
page_size=self.block_size,
505
causal=causal,
490
- window_size=self.aot_sliding_window,
506
+ window_size=_maybe_symmetrize_window(
507
+ self.aot_sliding_window, causal
508
+ ),
509
num_splits=max_num_splits,
510
)
511
return None
@@ -579,6 +597,13 @@ class FlashAttentionMetadataBuilder(AttentionMetadataBuilder[FlashAttentionMetad
597
if isinstance(causal, torch.Tensor) and causal.dtype != torch.int32:
598
causal = causal.to(torch.int32)
599
600
+ # Symmetrize the spec's sliding_window for non-causal attention
601
+ group_sliding_window = getattr(self.kv_cache_spec, "sliding_window", None)
602
+ base_window = (
603
+ (-1, -1) if group_sliding_window is None else (group_sliding_window - 1, 0)
604
+ )
605
+ effective_sliding_window = _maybe_symmetrize_window(base_window, causal)
606
+
607
attn_metadata = FlashAttentionMetadata(
608
num_actual_tokens=num_actual_tokens,
609
max_query_len=max_query_len,
@@ -598,6 +623,7 @@ class FlashAttentionMetadataBuilder(AttentionMetadataBuilder[FlashAttentionMetad
623
prefix_scheduler_metadata=prefix_scheduler_metadata,
624
max_num_splits=max_num_splits,
625
causal=causal,
626
+ sliding_window=effective_sliding_window,
627
)
628
629
# Compute mm_prefix range tensor if the batch contains
@@ -844,28 +870,18 @@ class FlashAttentionImpl(AttentionImpl):
870
)
871
return output
872
else:
873
+ window = (
874
+ attn_metadata.sliding_window
875
+ if attn_metadata.sliding_window is not None
876
+ else self.sliding_window
877
+ )
878
sliding_window_size: list[int] | None = (
848
- list(self.sliding_window)
849
- if self.sliding_window is not None
850
- else None
879
+ list(window) if window is not None else None
880
)
881
882
causal = attn_metadata.causal
883
is_dynamic_causal = isinstance(causal, torch.Tensor)
884
856
- # For non-causal (bidirectional) attention, make the
857
- # sliding window symmetric so queries attend in both
858
- # directions.
859
- if (
860
- sliding_window_size is not None
861
- and sliding_window_size[1] == 0
862
- and (is_dynamic_causal or causal is False)
863
- ):
864
- sliding_window_size = [
865
- sliding_window_size[0],
866
- sliding_window_size[0],
867
- ]
868
-
885
mm_prefix_ranges = attn_metadata.mm_prefix_range_tensor
886
mm_mask_mod = None
887
mm_aux = None