[Spec Decode] DFlash2: local convolution + candidate selector (#52816)

Zihan Zhang committed Aug 20, 2026 at 22:27 UTC b389ac29465b33f9e9c534df221ea3c129e9793f
14 files changed +866 -44
tests/models/registry.py
+8
@@ -1501,6 +1501,14 @@ _SPECULATIVE_DECODING_EXAMPLE_MODELS = {
1501 max_model_len=8192, # Reduce max len to ensure test runs in low-VRAM CI env
1502 max_num_seqs=32,
1503 ),
1504 + "DFlash2DraftModel": _HfExamplesInfo(
1505 + "Qwen/Qwen3.8-27B",
1506 + speculative_model="z-lab/Qwen3.8-27B-DFlash2",
1507 + is_available_online=False,
1508 + use_original_num_layers=True,
1509 + max_model_len=8192,
1510 + max_num_seqs=32,
1511 + ),
1512 "DFlashLagunaForCausalLM": _HfExamplesInfo(
1513 "poolside/Laguna-XS-2.1-NVFP4",
1514 speculative_model="poolside/Laguna-XS-2.1-DFlash-NVFP4",
tests/test_config.py
+24
@@ -199,6 +199,7 @@ def test_dsa_models_default_to_mrv2_and_breakable_cudagraph(
199 ),
200 )
201 config._dflash_needs_multi_kv_group = lambda: False
202 + config._is_dflash2_draft = lambda: False
203 config._is_default_v2_model_runner_model = lambda: (
204 VllmConfig._is_default_v2_model_runner_model(config)
205 )
@@ -292,6 +293,29 @@ def test_v2_model_runner_supports_extract_hidden_states():
293 assert config._get_v2_model_runner_unsupported_features() == []
294
295
296 +def test_dflash2_draft_forces_v2_model_runner():
297 + """A DFlash2 draft must reach the V2 speculator, the only one that runs its
298 + candidate selector; on V1 it would draft as DFlash1 without raising."""
299 +
300 + def config(method, architectures):
301 + return SimpleNamespace(
302 + speculative_config=SimpleNamespace(
303 + method=method,
304 + draft_model_config=SimpleNamespace(architectures=architectures),
305 + )
306 + )
307 +
308 + assert VllmConfig._is_dflash2_draft(config("dflash", ["DFlash2DraftModel"]))
309 + assert not VllmConfig._is_dflash2_draft(config("dflash", ["DFlashDraftModel"]))
310 + assert not VllmConfig._is_dflash2_draft(config("eagle", ["DFlash2DraftModel"]))
311 + assert not VllmConfig._is_dflash2_draft(SimpleNamespace(speculative_config=None))
312 + assert not VllmConfig._is_dflash2_draft(
313 + SimpleNamespace(
314 + speculative_config=SimpleNamespace(method="dflash", draft_model_config=None)
315 + )
316 + )
317 +
318 +
319 @pytest.mark.parametrize(
320 ("use_v2_model_runner", "expected_capture_sizes"),
321 [
tests/v1/spec_decode/test_dflash2.py new
+118
@@ -0,0 +1,118 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from types import SimpleNamespace
5 +
6 +import pytest
7 +import torch
8 +
9 +from vllm.model_executor.models.qwen3_dflash2 import _grouped_conv, _score_edges
10 +from vllm.v1.worker.gpu.spec_decode.dflash.speculator import DFlashSpeculator
11 +from vllm.v1.worker.gpu.spec_decode.dflash2.speculator import DFlash2Speculator
12 +
13 +
14 +@pytest.mark.parametrize("block_size", [5, 8])
15 +def test_grouped_conv_matches_reference(block_size: int):
16 + torch.manual_seed(0)
17 + batch, taps, num_groups, group_size = 3, 3, 4, 2
18 + hidden = torch.randn(batch * block_size, num_groups * group_size)
19 + delta = torch.randn(batch * block_size, taps, num_groups)
20 + base = torch.randn(taps, num_groups * group_size)
21 +
22 + actual = _grouped_conv(
23 + hidden, delta, base, block_size, num_groups, group_size, taps
24 + )
25 + hidden_blocks = hidden.view(batch, block_size, num_groups, group_size)
26 + expected = torch.zeros_like(hidden_blocks)
27 + base = base.view(taps, num_groups, group_size)
28 + delta = delta.view(batch, block_size, taps, num_groups)
29 + for position in range(block_size):
30 + for tap in range(min(taps, position + 1)):
31 + expected[:, position] += (
32 + base[tap] + delta[:, position, tap, :, None]
33 + ) * hidden_blocks[:, position - tap]
34 +
35 + torch.testing.assert_close(actual, expected.flatten(0, 1).flatten(-2))
36 +
37 +
38 +def test_selector_edges_match_sequential_reference():
39 + torch.manual_seed(1)
40 + batch, steps, top_k, rank = 2, 4, 3, 5
41 + vocab = 17
42 + predecessors = torch.randn(vocab, rank)
43 + successors = torch.randn(vocab, rank)
44 + candidate_ids = torch.randint(vocab, (batch, steps, top_k))
45 + unary = torch.randn(batch, steps, top_k)
46 + hidden = torch.randn(batch, steps, rank)
47 + anchors = torch.randint(vocab, (batch,))
48 +
49 + actual = _score_edges(
50 + predecessors,
51 + successors,
52 + candidate_ids,
53 + unary,
54 + hidden,
55 + anchors,
56 + top_k,
57 + )
58 + expected = torch.empty_like(actual)
59 + for step in range(steps):
60 + pred = (
61 + anchors[:, None].expand(-1, top_k)
62 + if step == 0
63 + else candidate_ids[:, step - 1]
64 + )
65 + expected[:, step] = unary[:, step, None] + torch.einsum(
66 + "bpr,bcr->bpc",
67 + predecessors[pred] * hidden[:, step, None],
68 + successors[candidate_ids[:, step]],
69 + )
70 +
71 + torch.testing.assert_close(actual, expected)
72 +
73 +
74 +def _stub_base(monkeypatch, draft_logits):
75 + """A DFlashSpeculator.__init__ that allocates only what the base class would.
76 +
77 + The real base class fills draft_logits from draft_logits_spec, so callers
78 + pass a tensor already in that state.
79 + """
80 +
81 + def init_base(self, _vllm_config, device):
82 + self.draft_model_config = SimpleNamespace(
83 + hf_config=SimpleNamespace(dflash_config={"selector_top_k": 3})
84 + )
85 + self.max_num_reqs = 2
86 + self.num_query_per_req = 5
87 + self.num_speculative_steps = 4
88 + self.vocab_size = 17
89 + self.draft_tokens = torch.empty((2, 4), dtype=torch.int64, device=device)
90 + self.draft_logits = draft_logits
91 +
92 + monkeypatch.setattr(DFlashSpeculator, "__init__", init_base)
93 +
94 +
95 +def test_selector_leaves_greedy_drafting_without_proposal_logits(monkeypatch):
96 + """Greedy is the default, and it caches no proposal distribution.
97 +
98 + The base class allocates draft_logits only for "probabilistic"; verification
99 + reads `draft_logits is None` to decide whether a distribution is on offer, so
100 + allocating one here would claim a proposal the walk never sampled from.
101 + """
102 + _stub_base(monkeypatch, None)
103 + speculator = DFlash2Speculator(None, torch.device("cpu"))
104 +
105 + assert speculator.draft_logits is None
106 +
107 +
108 +def test_selector_asks_for_fp32_proposal_logits():
109 + """The spec the base class allocates from: fp32, filled -inf.
110 +
111 + Not the head dtype -- rounding selector scores to bf16 moves the argmax of a
112 + candidate row often enough that the walk and the rejection sampler checking it
113 + would no longer read the same distribution.
114 + """
115 + dtype, fill = DFlash2Speculator.draft_logits_spec(None, None)
116 +
117 + assert dtype is torch.float32
118 + assert fill == float("-inf")
tests/v1/spec_decode/test_dflash_causality.py
+25 -1
@@ -21,12 +21,13 @@ from vllm.v1.worker.gpu.spec_decode.eagle.eagle3_utils import (
21 )
22
23
24 -def _config(num_hidden_layers, layer_types=None, causal_override=None):
24 +def _config(num_hidden_layers, layer_types=None, causal_override=None, is_causal=None):
25 dflash_config = None if causal_override is None else {"causal": causal_override}
26 return SimpleNamespace(
27 num_hidden_layers=num_hidden_layers,
28 layer_types=layer_types,
29 dflash_config=dflash_config,
30 + is_causal=is_causal,
31 )
32
33
@@ -40,6 +41,19 @@ def _config(num_hidden_layers, layer_types=None, causal_override=None):
41 _config(2, layer_types=["sliding_attention"] * 2, causal_override=False),
42 True,
43 ),
44 + # DFlash2 stores the explicit attention semantics at the top level.
45 + (
46 + _config(
47 + 2,
48 + layer_types=["sliding_attention"] * 2,
49 + is_causal=False,
50 + ),
51 + True,
52 + ),
53 + (
54 + _config(2, layer_types=["full_attention"] * 2, is_causal=True),
55 + False,
56 + ),
57 # SWA-derived: full-attention layers are non-causal.
58 (_config(2, layer_types=["sliding_attention", "full_attention"]), True),
59 # SWA-derived: all-sliding is fully causal.
@@ -59,6 +73,16 @@ def test_dflash_layer_causal_is_per_layer():
73 assert _dflash_layer_causal(config, 1) is False
74
75
76 +def test_dflash_layer_causal_honors_top_level_override():
77 + config = _config(
78 + 2,
79 + layer_types=["sliding_attention", "full_attention"],
80 + is_causal=False,
81 + )
82 + assert _dflash_layer_causal(config, 0) is False
83 + assert _dflash_layer_causal(config, 1) is False
84 +
85 +
86 def _vllm_config(**draft_config):
87 config = SimpleNamespace(**draft_config)
88 return SimpleNamespace(
vllm/config/vllm.py
+17
@@ -670,6 +670,12 @@ class VllmConfig:
670 if self._dflash_needs_multi_kv_group():
671 return True
672
673 + # The DFlash2 candidate selector exists only in the V2 speculator. On V1
674 + # the same checkpoint drafts through DFlashProposer, which never calls
675 + # it, so the draft degrades to DFlash1 silently. Force V2 as for dspark.
676 + if self._is_dflash2_draft():
677 + return True
678 +
679 if self.model_config is not None and self.model_config.is_diffusion:
680 return True
681
@@ -693,6 +699,17 @@ class VllmConfig:
699
700 return True
701
702 + def _is_dflash2_draft(self) -> bool:
703 + """Whether the DFlash draft is a DFlash2 one, by the architecture the
704 + speculator selects on (v1/worker/gpu/spec_decode/__init__.py)."""
705 + spec = self.speculative_config
706 + if spec is None or spec.method != "dflash":
707 + return False
708 + draft_config = getattr(spec, "draft_model_config", None)
709 + if draft_config is None:
710 + return False
711 + return "DFlash2DraftModel" in (draft_config.architectures or [])
712 +
713 def _dflash_needs_multi_kv_group(self) -> bool:
714 """Whether a DFlash draft mixes sliding-window and full attention."""
715 spec = self.speculative_config
vllm/model_executor/layers/logits_processor.py
+81
@@ -2,6 +2,9 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """A layer that compute logits from hidden_stats."""
4
5 +from collections.abc import Callable
6 +from functools import cache
7 +
8 import torch
9 import torch.nn.functional as F
10
@@ -10,12 +13,43 @@ from vllm.distributed import (
13 tensor_model_parallel_all_gather,
14 tensor_model_parallel_gather,
15 )
16 +from vllm.logger import init_logger
17 from vllm.model_executor.custom_op import PluggableLayer
18 from vllm.model_executor.layers.vocab_parallel_embedding import (
19 UnquantizedEmbeddingMethod,
20 VocabParallelEmbedding,
21 )
22 from vllm.platforms import current_platform
23 +from vllm.utils.flashinfer import has_flashinfer
24 +
25 +logger = init_logger(__name__)
26 +
27 +
28 +@cache
29 +def _flashinfer_topk() -> Callable[..., tuple[torch.Tensor, torch.Tensor]] | None:
30 + """FlashInfer's radix top-k, or None for torch.topk.
31 +
32 + The top-k spans the vocabulary, where the radix kernel is about twice
33 + torch.topk.
34 + """
35 + if not current_platform.is_cuda():
36 + return None
37 + if not has_flashinfer():
38 + logger.info_once(
39 + "flashinfer is unavailable; vocab-parallel top-k uses torch.topk, "
40 + "at roughly half the speed."
41 + )
42 + return None
43 + from flashinfer import top_k
44 +
45 + return top_k
46 +
47 +
48 +def _topk(scores: torch.Tensor, k: int) -> tuple[torch.Tensor, torch.Tensor]:
49 + impl = _flashinfer_topk()
50 + if impl is None or not scores.is_cuda:
51 + return torch.topk(scores, k, dim=-1)
52 + return impl(scores, k, sorted=True, deterministic=True)
53
54
55 # --8<-- [start:logits_processor]
@@ -204,6 +238,53 @@ class LogitsProcessor(PluggableLayer):
238 top_tokens = gathered[:, :, 1].gather(dim=-1, index=max_rank_idx)
239 return top_tokens.squeeze(-1).to(torch.int64)
240
241 + def get_top_k_tokens(
242 + self,
243 + lm_head: VocabParallelEmbedding,
244 + hidden_states: torch.Tensor,
245 + k: int,
246 + embedding_bias: torch.Tensor | None = None,
247 + ) -> tuple[torch.Tensor, torch.Tensor]:
248 + """Vocab-parallel top-k without all-gathering full logits.
249 +
250 + The `get_top_tokens` reduction widened from one token to k, returning
251 + the values as well as the global ids. Communication is
252 + O(batch * 2k * tp_size) rather than O(batch * vocab_size).
253 +
254 + Scale and soft cap are applied to the k selected values rather than
255 + the whole vocabulary; both are monotonic, so the selection is the same
256 + and only k entries are touched.
257 + """
258 + if self.scale <= 0.0 and self.scale != 1.0:
259 + raise ValueError(
260 + "The local top-k reduction optimization is not supported for "
261 + "non-positive logit scaling factors."
262 + )
263 +
264 + logits = self._apply_head(lm_head, hidden_states, embedding_bias)
265 +
266 + # Mask out padding entries beyond org_vocab_size on this shard.
267 + num_pad = lm_head.shard_indices.num_org_vocab_padding
268 + if num_pad > 0:
269 + logits[..., -num_pad:] = -float("inf")
270 +
271 + values, ids = _topk(logits, k)
272 + # Convert shard-local indices to global vocab indices.
273 + ids = ids.to(torch.int64) + lm_head.shard_indices.org_vocab_start_index
274 +
275 + if lm_head.tp_size > 1:
276 + values = tensor_model_parallel_all_gather(values, dim=-1)
277 + ids = tensor_model_parallel_all_gather(ids, dim=-1)
278 + values, selected = _topk(values, k)
279 + ids = ids.gather(-1, selected)
280 +
281 + values = values.float()
282 + if self.scale != 1.0:
283 + values = values * self.scale
284 + if self.soft_cap is not None:
285 + values = torch.tanh(values / self.soft_cap) * self.soft_cap
286 + return ids, values
287 +
288 def extra_repr(self) -> str:
289 s = f"vocab_size={self.vocab_size}"
290 s += f", org_vocab_size={self.org_vocab_size}"
vllm/model_executor/models/qwen3_dflash.py
+11 -4
@@ -56,10 +56,13 @@ _SLIDING_ATTENTION = "sliding_attention"
56
57
58 def _dflash_layer_causal(config: Qwen3Config, layer_idx: int) -> bool:
59 - """``dflash_config.causal`` overrides all layers; else only SWA layers causal."""
59 + """Resolve explicit causality before falling back to legacy layer defaults."""
60 + is_causal = getattr(config, "is_causal", None)
61 + if is_causal is not None:
62 + return bool(is_causal)
63 override = (getattr(config, "dflash_config", None) or {}).get("causal")
64 if override is not None:
62 - return override
65 + return bool(override)
66 layer_types = getattr(config, "layer_types", None)
67 return bool(layer_types) and layer_types[layer_idx] == _SLIDING_ATTENTION
68
@@ -374,6 +377,8 @@ class DFlashQwen3DecoderLayer(nn.Module):
377
378 @support_torch_compile
379 class DFlashQwen3Model(nn.Module):
380 + decoder_layer_cls = DFlashQwen3DecoderLayer
381 +
382 hf_to_vllm_mapper = WeightsMapper(
383 orig_to_new_substr={
384 "midlayer.": "layers.0.",
@@ -434,7 +439,7 @@ class DFlashQwen3Model(nn.Module):
439
440 self.layers = nn.ModuleList(
441 [
437 - DFlashQwen3DecoderLayer(
442 + self.decoder_layer_cls(
443 current_vllm_config,
444 config=self.config,
445 layer_idx=layer_idx,
@@ -699,6 +704,8 @@ class DFlashQwen3Model(nn.Module):
704
705
706 class DFlashQwen3ForCausalLM(Qwen3ForCausalLM):
707 + model_cls = DFlashQwen3Model
708 +
709 def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
710 nn.Module.__init__(self)
711 self.draft_model_config = vllm_config.speculative_config.draft_model_config
@@ -708,7 +715,7 @@ class DFlashQwen3ForCausalLM(Qwen3ForCausalLM):
715 target_layer_num = vllm_config.model_config.get_num_layers(
716 vllm_config.parallel_config
717 )
711 - self.model = DFlashQwen3Model(
718 + self.model = self.model_cls(
719 vllm_config=vllm_config,
720 prefix=maybe_prefix(prefix, "model"),
721 start_layer_id=target_layer_num,
vllm/model_executor/models/qwen3_dflash2.py new
+290
@@ -0,0 +1,290 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import torch
5 +import torch.nn.functional as F
6 +from torch import nn
7 +
8 +from vllm.compilation.backends import set_model_tag
9 +from vllm.compilation.decorators import support_torch_compile
10 +from vllm.config import CacheConfig, VllmConfig
11 +from vllm.model_executor.layers.linear import ReplicatedLinear
12 +from vllm.model_executor.layers.logits_processor import LogitsProcessor
13 +from vllm.model_executor.layers.quantization.base_config import QuantizationConfig
14 +
15 +from .qwen3_dflash import (
16 + DFlashQwen3DecoderLayer,
17 + DFlashQwen3ForCausalLM,
18 + DFlashQwen3Model,
19 +)
20 +from .utils import maybe_prefix
21 +
22 +
23 +def _grouped_conv(
24 + hidden_states: torch.Tensor,
25 + delta: torch.Tensor,
26 + base: torch.Tensor,
27 + block_size: int,
28 + num_groups: int,
29 + group_size: int,
30 + taps: int,
31 +) -> torch.Tensor:
32 + blocks = hidden_states.unflatten(-1, (num_groups, group_size))
33 + coefficients = base.view(1, taps, num_groups, group_size) + delta.unsqueeze(-1)
34 + output = coefficients[:, 0] * blocks
35 + position = torch.arange(hidden_states.shape[0], device=hidden_states.device)
36 + if block_size & (block_size - 1) == 0:
37 + position = position & (block_size - 1)
38 + else:
39 + position = position % block_size
40 + for tap in range(1, taps):
41 + shifted = F.pad(blocks[:-tap], (0, 0, 0, 0, tap, 0))
42 + output += coefficients[:, tap] * shifted * (position >= tap).view(-1, 1, 1)
43 + return output.flatten(-2)
44 +
45 +
46 +class DFlashGroupedConv(nn.Module):
47 + def __init__(
48 + self,
49 + hidden_size: int,
50 + taps: int,
51 + group_size: int,
52 + block_size: int,
53 + params_dtype: torch.dtype,
54 + prefix: str,
55 + ) -> None:
56 + super().__init__()
57 + if hidden_size % group_size:
58 + raise ValueError(
59 + f"conv_group_size={group_size} must divide hidden_size={hidden_size}."
60 + )
61 + self.block_size = block_size
62 + self.taps = taps
63 + self.group_size = group_size
64 + self.num_groups = hidden_size // group_size
65 + self.base_kernel = nn.Parameter(
66 + torch.empty(2, taps, hidden_size, dtype=params_dtype),
67 + requires_grad=False,
68 + )
69 + self.kernel_projection = ReplicatedLinear(
70 + hidden_size,
71 + 2 * taps * self.num_groups,
72 + bias=False,
73 + params_dtype=params_dtype,
74 + quant_config=None,
75 + prefix=maybe_prefix(prefix, "kernel_projection"),
76 + return_bias=False,
77 + )
78 +
79 + def _convolve(
80 + self, hidden_states: torch.Tensor, delta: torch.Tensor, side: int
81 + ) -> torch.Tensor:
82 + return _grouped_conv(
83 + hidden_states,
84 + delta,
85 + self.base_kernel[side],
86 + self.block_size,
87 + self.num_groups,
88 + self.group_size,
89 + self.taps,
90 + )
91 +
92 + def prepare(self, hidden_states: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
93 + coefficients = self.kernel_projection(hidden_states).reshape(
94 + hidden_states.shape[0], 2, self.taps, self.num_groups
95 + )
96 + return self._convolve(hidden_states, coefficients[:, 0], 0), coefficients[:, 1]
97 +
98 + def finish(
99 + self, hidden_states: torch.Tensor, coefficients: torch.Tensor
100 + ) -> torch.Tensor:
101 + return self._convolve(hidden_states, coefficients, 1)
102 +
103 +
104 +class DFlash2Qwen3DecoderLayer(DFlashQwen3DecoderLayer):
105 + def __init__(
106 + self,
107 + vllm_config: VllmConfig,
108 + *,
109 + config,
110 + layer_idx: int,
111 + cache_config: CacheConfig | None = None,
112 + quant_config: QuantizationConfig | None = None,
113 + prefix: str = "",
114 + ) -> None:
115 + super().__init__(
116 + vllm_config,
117 + config=config,
118 + layer_idx=layer_idx,
119 + cache_config=cache_config,
120 + quant_config=quant_config,
121 + prefix=prefix,
122 + )
123 + draft_config = config.dflash_config
124 + speculative_config = vllm_config.speculative_config
125 + assert speculative_config is not None
126 + conv_args = dict(
127 + hidden_size=config.hidden_size,
128 + taps=int(draft_config["conv_kernel_size"]),
129 + group_size=int(draft_config["conv_group_size"]),
130 + # Query tokens per request: the bonus token plus the mask tokens.
131 + block_size=1 + speculative_config.num_speculative_tokens,
132 + params_dtype=vllm_config.model_config.dtype,
133 + )
134 + self.attention_conv = DFlashGroupedConv(
135 + **conv_args, prefix=maybe_prefix(prefix, "attention_conv")
136 + )
137 + self.mlp_conv = DFlashGroupedConv(
138 + **conv_args, prefix=maybe_prefix(prefix, "mlp_conv")
139 + )
140 +
141 + def forward(
142 + self,
143 + positions: torch.Tensor,
144 + hidden_states: torch.Tensor,
145 + residual: torch.Tensor | None,
146 + ) -> tuple[torch.Tensor, torch.Tensor]:
147 + if residual is None:
148 + residual = hidden_states
149 + hidden_states = self.input_layernorm(hidden_states)
150 + else:
151 + hidden_states, residual = self.input_layernorm(hidden_states, residual)
152 +
153 + hidden_states, coefficients = self.attention_conv.prepare(hidden_states)
154 + hidden_states = self.self_attn(positions=positions, hidden_states=hidden_states)
155 + hidden_states = self.attention_conv.finish(hidden_states, coefficients)
156 +
157 + hidden_states, residual = self.post_attention_layernorm(hidden_states, residual)
158 + hidden_states, coefficients = self.mlp_conv.prepare(hidden_states)
159 + hidden_states = self.mlp(hidden_states)
160 + hidden_states = self.mlp_conv.finish(hidden_states, coefficients)
161 + return hidden_states, residual
162 +
163 +
164 +def _score_edges(
165 + predecessor_table: torch.Tensor,
166 + successor_table: torch.Tensor,
167 + candidate_ids: torch.Tensor,
168 + unary_logits: torch.Tensor,
169 + hidden: torch.Tensor,
170 + anchor_token_ids: torch.Tensor,
171 + top_k: int,
172 +) -> torch.Tensor:
173 + successors = successor_table[candidate_ids]
174 + predecessor_ids = torch.cat(
175 + (
176 + anchor_token_ids[:, None, None].expand(-1, 1, top_k),
177 + candidate_ids[:, :-1],
178 + ),
179 + dim=1,
180 + )
181 + predecessors = predecessor_table[predecessor_ids]
182 + return unary_logits[:, :, None] + torch.einsum(
183 + "blpr,blcr->blpc", predecessors * hidden[:, :, None], successors
184 + )
185 +
186 +
187 +@support_torch_compile
188 +class CandidateSelector(nn.Module):
189 + def __init__(
190 + self,
191 + hidden_size: int,
192 + vocab_size: int,
193 + rank: int,
194 + top_k: int,
195 + params_dtype: torch.dtype,
196 + prefix: str,
197 + ) -> None:
198 + super().__init__()
199 + self.top_k = top_k
200 + self.predecessor_codebook = nn.Parameter(
201 + torch.empty(vocab_size, rank, dtype=params_dtype), requires_grad=False
202 + )
203 + self.successor_codebook = nn.Parameter(
204 + torch.empty(vocab_size, rank, dtype=params_dtype), requires_grad=False
205 + )
206 + self.hidden_projection = ReplicatedLinear(
207 + hidden_size,
208 + rank,
209 + bias=False,
210 + params_dtype=params_dtype,
211 + quant_config=None,
212 + prefix=maybe_prefix(prefix, "hidden_projection"),
213 + return_bias=False,
214 + )
215 +
216 + def forward(
217 + self,
218 + candidate_ids: torch.Tensor,
219 + unary_logits: torch.Tensor,
220 + hidden_states: torch.Tensor,
221 + anchor_token_ids: torch.Tensor,
222 + ) -> torch.Tensor:
223 + hidden = self.hidden_projection(hidden_states)
224 + return _score_edges(
225 + self.predecessor_codebook,
226 + self.successor_codebook,
227 + candidate_ids,
228 + unary_logits,
229 + hidden,
230 + anchor_token_ids,
231 + self.top_k,
232 + )
233 +
234 +
235 +class DFlash2Qwen3Model(DFlashQwen3Model):
236 + decoder_layer_cls = DFlash2Qwen3DecoderLayer
237 +
238 + def __init__(
239 + self,
240 + *,
241 + vllm_config: VllmConfig,
242 + start_layer_id: int = 0,
243 + prefix: str = "",
244 + ) -> None:
245 + super().__init__(
246 + vllm_config=vllm_config,
247 + start_layer_id=start_layer_id,
248 + prefix=prefix,
249 + )
250 + draft_config = self.config.dflash_config
251 + self.input_embedding_scale = float(
252 + draft_config.get("input_embedding_scale", 1.0)
253 + )
254 + # Without its own tag the selector shares the draft head's compile cache.
255 + with set_model_tag("dflash2_candidate_selector"):
256 + self.candidate_selector = CandidateSelector(
257 + hidden_size=self.config.hidden_size,
258 + vocab_size=self.config.vocab_size,
259 + rank=int(draft_config["selector_rank"]),
260 + top_k=int(draft_config["selector_top_k"]),
261 + params_dtype=vllm_config.model_config.dtype,
262 + prefix=maybe_prefix(prefix, "candidate_selector"),
263 + )
264 +
265 + def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
266 + return super().embed_input_ids(input_ids) * self.input_embedding_scale
267 +
268 +
269 +class DFlash2Qwen3ForCausalLM(DFlashQwen3ForCausalLM):
270 + model_cls = DFlash2Qwen3Model
271 +
272 + def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
273 + super().__init__(vllm_config=vllm_config, prefix=prefix)
274 + draft_config = self.config.dflash_config
275 + softcap = float(draft_config.get("final_logit_softcapping") or 0.0)
276 + self.candidate_logits_processor = LogitsProcessor(
277 + vllm_config.model_config.get_vocab_size(),
278 + scale=float(draft_config.get("output_multiplier", 1.0)),
279 + soft_cap=softcap if softcap > 0 else None,
280 + )
281 +
282 + def compute_candidates(
283 + self, hidden_states: torch.Tensor
284 + ) -> tuple[torch.Tensor, torch.Tensor]:
285 + return self.candidate_logits_processor.get_top_k_tokens(
286 + self.lm_head, hidden_states, self.model.candidate_selector.top_k
287 + )
288 +
289 +
290 +EntryClass = DFlash2Qwen3ForCausalLM
vllm/model_executor/models/registry.py
+1
@@ -632,6 +632,7 @@ _SPECULATIVE_DECODING_MODELS = {
632 "EagleLlama4ForCausalLM": ("llama4_eagle", "EagleLlama4ForCausalLM"),
633 "EagleMiniCPMForCausalLM": ("minicpm_eagle", "EagleMiniCPMForCausalLM"),
634 "DFlashDraftModel": ("qwen3_dflash", "DFlashQwen3ForCausalLM"),
635 + "DFlash2DraftModel": ("qwen3_dflash2", "DFlash2Qwen3ForCausalLM"),
636 # Muse Glimmer's DFlash draft head, reusing the generic qwen3_dflash
637 # implementation. EAGLEConfig rewrites a dflash draft's architecture to
638 # DFlash{arch} unless it already starts or ends with "DFlash" (see
vllm/v1/worker/gpu/sample/gumbel.py
+52 -34
@@ -81,6 +81,46 @@ def tl_rand32(seed, offset, includes_zero: tl.constexpr):
81 return u
82
83
84 +@triton.jit
85 +def gumbel_noised_argmax(
86 + logits,
87 + keys,
88 + mask,
89 + seed,
90 + pos,
91 + temp,
92 + USE_FP64: tl.constexpr,
93 + APPLY_TEMPERATURE: tl.constexpr = True,
94 +):
95 + """Argmax of logits under Gumbel-max sampling, or plain argmax at temp 0.
96 +
97 + `keys` indexes the noise, so the same token draws the same noise wherever it
98 + appears; `pos` and `seed` place the draw in the request's stream, which is
99 + what lets a draft and its verification agree.
100 + """
101 + if temp != 0.0 and APPLY_TEMPERATURE:
102 + # Match the behavior of _temperature_kernel: if that kernel uses
103 + # tl.div_rn, this must too.
104 + logits = logits / temp
105 +
106 + # fp32 is the default reduction dtype; fp64 is ~1/32-1/64x the throughput
107 + # on H100/Ada/Blackwell and empirically indistinguishable for Gumbel-max.
108 + if USE_FP64:
109 + logits = logits.to(tl.float64)
110 + if temp != 0.0:
111 + gumbel_seed = tl.randint(seed, pos)
112 + if USE_FP64:
113 + u = tl_rand64(gumbel_seed, keys, includes_zero=False)
114 + gumbel_noise = -tl.log(-tl.log(u))
115 + else:
116 + u = tl_rand32(gumbel_seed, keys, includes_zero=False)
117 + # log1p keeps the winning tail at u -> 0, where fp32 resolves it.
118 + gumbel_noise = -tl.log(-tldevice.log1p(-u))
119 + logits = tl.where(mask, logits + gumbel_noise, float("-inf"))
120 +
121 + return tl.max(logits, axis=0, return_indices=True)
122 +
123 +
124 @triton.jit
125 def gumbel_block_argmax(
126 logits,
@@ -124,40 +164,18 @@ def gumbel_block_argmax(
164 mask=mask & is_valid_req,
165 )
166
127 - if temp != 0.0 and APPLY_TEMPERATURE:
128 - # Apply temperature.
129 - # NOTE(woosuk): Match the behavior of _temperature_kernel.
130 - # E.g., if the kernel uses tl.div_rn, we should use tl.div_rn here too.
131 - logits = logits / temp
132 -
133 - # fp32 is the default reduction dtype; fp64 is ~1/32–1/64x the throughput
134 - # on H100/Ada/Blackwell and empirically indistinguishable for Gumbel-max.
135 - if USE_FP64:
136 - logits = logits.to(tl.float64)
137 - if temp != 0.0:
138 - # Calculate the seed for gumbel noise.
139 - seed = tl.load(seeds_ptr + req_state_idx, mask=is_valid_req, other=0)
140 - pos = tl.load(pos_ptr + token_idx)
141 - gumbel_seed = tl.randint(seed, pos)
142 -
143 - if USE_FP64:
144 - u = tl_rand64(gumbel_seed, block, includes_zero=False)
145 - gumbel_noise = -tl.log(-tl.log(u))
146 - else:
147 - u = tl_rand32(gumbel_seed, block, includes_zero=False)
148 - # Draw the large-noise tail (which decides the argmax winner) from u -> 0,
149 - # where fp32 has fine resolution, instead of u -> 1, where fp32 spacing is
150 - # ~2**-24. The naive `-log(-log(u))` puts the winning tail at u -> 1,
151 - # hard-capping the noise at ~16.6 and coarsely quantizing it; using
152 - # `log1p(-u)` == `log(1 - u)` keeps the tail in the well-resolved region.
153 - # Note `1 - u` would lose precision for small u, so `log1p` is required.
154 - gumbel_noise = -tl.log(-tldevice.log1p(-u))
155 -
156 - # Apply gumbel noise.
157 - logits = tl.where(mask, logits + gumbel_noise, float("-inf"))
158 -
159 - value, idx = tl.max(logits, axis=0, return_indices=True)
160 - return value, idx
167 + seed = tl.load(seeds_ptr + req_state_idx, mask=is_valid_req, other=0)
168 + pos = tl.load(pos_ptr + token_idx)
169 + return gumbel_noised_argmax(
170 + logits,
171 + block,
172 + mask,
173 + seed,
174 + pos,
175 + temp,
176 + USE_FP64=USE_FP64,
177 + APPLY_TEMPERATURE=APPLY_TEMPERATURE,
178 + )
179
180
181 @triton.jit
vllm/v1/worker/gpu/spec_decode/__init__.py
+6
@@ -15,6 +15,12 @@ def init_speculator(vllm_config: VllmConfig, device: torch.device):
15
16 return ExtractHiddenStatesSpeculator(vllm_config, device)
17 elif speculative_config.method == "dflash":
18 + if "DFlash2DraftModel" in speculative_config.draft_model_config.architectures:
19 + from vllm.v1.worker.gpu.spec_decode.dflash2.speculator import (
20 + DFlash2Speculator,
21 + )
22 +
23 + return DFlash2Speculator(vllm_config, device)
24 from vllm.v1.worker.gpu.spec_decode.dflash.speculator import (
25 DFlashSpeculator,
26 )
vllm/v1/worker/gpu/spec_decode/dflash2/__init__.py new
+2
@@ -0,0 +1,2 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
vllm/v1/worker/gpu/spec_decode/dflash2/speculator.py new
+215
@@ -0,0 +1,215 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from typing import Any
5 +
6 +import torch
7 +
8 +from vllm.config import VllmConfig
9 +from vllm.config.compilation import CUDAGraphMode
10 +from vllm.triton_utils import tl, triton
11 +from vllm.v1.worker.gpu.sample.gumbel import gumbel_noised_argmax
12 +from vllm.v1.worker.gpu.spec_decode.dflash.speculator import DFlashSpeculator
13 +
14 +
15 +@triton.jit
16 +def _selector_walk_kernel(
17 + scores_ptr,
18 + candidate_ptr,
19 + sample_pos_ptr,
20 + req_state_ptr,
21 + temperature_ptr,
22 + seeds_ptr,
23 + tokens_ptr,
24 + realized_scores_ptr,
25 + num_steps: tl.constexpr,
26 + top_k: tl.constexpr,
27 + BLOCK_K: tl.constexpr,
28 + SAMPLE_PROBABILISTIC: tl.constexpr,
29 + USE_FP64: tl.constexpr,
30 +):
31 + row = tl.program_id(0)
32 + offsets = tl.arange(0, BLOCK_K)
33 + mask = offsets < top_k
34 + req_state = tl.load(req_state_ptr + row * num_steps)
35 + valid = req_state >= 0
36 + temperature = tl.load(temperature_ptr + req_state, mask=valid, other=0.0)
37 + seed = tl.load(seeds_ptr + req_state, mask=valid, other=0)
38 + previous = 0
39 + for step in range(num_steps):
40 + flat = row * num_steps + step
41 + score_base = (flat * top_k + previous) * top_k
42 + scores = tl.load(
43 + scores_ptr + score_base + offsets,
44 + mask=mask & valid,
45 + other=float("-inf"),
46 + ).to(tl.float64 if USE_FP64 else tl.float32)
47 + candidate_base = flat * top_k
48 + candidates = tl.load(
49 + candidate_ptr + candidate_base + offsets,
50 + mask=mask & valid,
51 + other=0,
52 + )
53 +
54 + # Candidate ids key the noise, matching the target's own sampling.
55 + position = tl.load(sample_pos_ptr + flat) - 1
56 + _, index = gumbel_noised_argmax(
57 + scores,
58 + candidates,
59 + mask & valid,
60 + seed,
61 + position,
62 + temperature if SAMPLE_PROBABILISTIC else 0.0,
63 + USE_FP64=USE_FP64,
64 + )
65 +
66 + tl.store(
67 + realized_scores_ptr + candidate_base + offsets,
68 + scores,
69 + mask=mask & valid,
70 + )
71 + token = tl.load(candidate_ptr + candidate_base + index, mask=valid, other=0)
72 + tl.store(tokens_ptr + flat, token, mask=valid)
73 + previous = index
74 +
75 +
76 +@triton.jit
77 +def _cache_draft_logits_kernel(
78 + draft_logits_ptr,
79 + cached_candidate_ptr,
80 + candidate_ptr,
81 + scores_ptr,
82 + req_state_ptr,
83 + draft_logits_stride_0,
84 + draft_logits_stride_1,
85 + num_steps: tl.constexpr,
86 + top_k: tl.constexpr,
87 + BLOCK_K: tl.constexpr,
88 +):
89 + flat = tl.program_id(0)
90 + req_state = tl.load(req_state_ptr + flat)
91 + step = flat % num_steps
92 + offsets = tl.arange(0, BLOCK_K)
93 + mask = (req_state >= 0) & (offsets < top_k)
94 + candidate_base = flat * top_k
95 + cache_base = (req_state * num_steps + step) * top_k
96 + old_token_ids = tl.load(cached_candidate_ptr + cache_base + offsets, mask=mask)
97 + logits_base = (
98 + draft_logits_ptr
99 + + req_state * draft_logits_stride_0
100 + + step * draft_logits_stride_1
101 + )
102 + tl.store(logits_base + old_token_ids, -float("inf"), mask=mask)
103 + token_ids = tl.load(candidate_ptr + candidate_base + offsets, mask=mask)
104 + scores = tl.load(scores_ptr + candidate_base + offsets, mask=mask)
105 + tl.store(logits_base + token_ids, scores, mask=mask)
106 + tl.store(cached_candidate_ptr + cache_base + offsets, token_ids, mask=mask)
107 +
108 +
109 +class DFlash2Speculator(DFlashSpeculator):
110 + _speculator_name = "DFlash2"
111 +
112 + def __init__(self, vllm_config: VllmConfig, device: torch.device):
113 + super().__init__(vllm_config, device)
114 + draft_config = self.draft_model_config.hf_config.dflash_config
115 + self.selector_top_k = int(draft_config["selector_top_k"])
116 + self._anchor_indices = (
117 + torch.arange(self.max_num_reqs, dtype=torch.int64, device=device)
118 + * self.num_query_per_req
119 + )
120 + self._selector_scores = torch.empty(
121 + self.max_num_reqs,
122 + self.num_speculative_steps,
123 + self.selector_top_k,
124 + dtype=torch.float32,
125 + device=device,
126 + )
127 + self._cached_candidate_ids = torch.zeros(
128 + self._selector_scores.shape, dtype=torch.int64, device=device
129 + )
130 +
131 + def draft_logits_spec(self, vllm_config: VllmConfig) -> tuple[torch.dtype, float]:
132 + # fp32 so the walk and the rejection that checks it read the same
133 + # distribution; -inf because the cache kernel writes only the K
134 + # candidates.
135 + return torch.float32, -float("inf")
136 +
137 + def _sample_path(
138 + self,
139 + candidate_ids: torch.Tensor,
140 + scores: torch.Tensor,
141 + num_reqs: int,
142 + ) -> None:
143 + block_k = triton.next_power_of_2(self.selector_top_k)
144 + _selector_walk_kernel[(num_reqs,)](
145 + scores.contiguous(),
146 + candidate_ids.contiguous(),
147 + self.sample_pos,
148 + self.sample_idx_mapping,
149 + self.temperature,
150 + self.seeds,
151 + self.draft_tokens,
152 + self._selector_scores,
153 + num_steps=self.num_speculative_steps,
154 + top_k=self.selector_top_k,
155 + BLOCK_K=block_k,
156 + SAMPLE_PROBABILISTIC=self.draft_logits is not None,
157 + USE_FP64=self.use_fp64_gumbel,
158 + num_warps=1,
159 + )
160 +
161 + def _cache_draft_logits(self, candidate_ids: torch.Tensor, num_sample: int) -> None:
162 + draft_logits = self.draft_logits
163 + assert draft_logits is not None
164 + block_k = triton.next_power_of_2(self.selector_top_k)
165 + _cache_draft_logits_kernel[(num_sample,)](
166 + draft_logits,
167 + self._cached_candidate_ids,
168 + candidate_ids,
169 + self._selector_scores,
170 + self.sample_idx_mapping,
171 + draft_logits.stride(0),
172 + draft_logits.stride(1),
173 + num_steps=self.num_speculative_steps,
174 + top_k=self.selector_top_k,
175 + BLOCK_K=block_k,
176 + num_warps=1,
177 + )
178 +
179 + def _generate_draft(
180 + self,
181 + num_reqs: int,
182 + num_tokens_padded: int,
183 + attn_metadata: dict[str, Any] | None,
184 + slot_mappings: dict[str, torch.Tensor] | None,
185 + num_tokens_across_dp: torch.Tensor | None,
186 + cudagraph_runtime_mode: CUDAGraphMode = CUDAGraphMode.NONE,
187 + ) -> None:
188 + last_hidden_states = self._run_model(
189 + num_tokens_padded,
190 + attn_metadata,
191 + slot_mappings,
192 + num_tokens_across_dp,
193 + cudagraph_runtime_mode,
194 + )
195 + num_sample = num_reqs * self.num_speculative_steps
196 + hidden_states = last_hidden_states[self.sample_indices[:num_sample]].view(
197 + num_reqs, self.num_speculative_steps, -1
198 + )
199 + candidate_ids, unary_logits = self.model.compute_candidates(
200 + hidden_states.flatten(0, 1)
201 + )
202 + candidate_ids = candidate_ids.view(
203 + num_reqs, self.num_speculative_steps, self.selector_top_k
204 + )
205 + unary_logits = unary_logits.view_as(candidate_ids)
206 + anchor_token_ids = self.input_buffers.input_ids[self._anchor_indices[:num_reqs]]
207 + scores = self.model.model.candidate_selector(
208 + candidate_ids,
209 + unary_logits,
210 + hidden_states,
211 + anchor_token_ids,
212 + )
213 + self._sample_path(candidate_ids, scores, num_reqs)
214 + if self.draft_logits is not None:
215 + self._cache_draft_logits(candidate_ids, num_sample)
vllm/v1/worker/gpu/spec_decode/speculator.py
+16 -5
@@ -132,11 +132,15 @@ class DraftModelSpeculator(BaseSpeculator):
132 self.draft_logits: torch.Tensor | None = None
133 if self.speculative_config.draft_sample_method == "probabilistic":
134 # Pre-temperature logits, cached from the previous decode step.
135 - self.draft_logits = torch.zeros(
136 - self.max_num_reqs,
137 - self.num_speculative_steps,
138 - self.vocab_size,
139 - dtype=vllm_config.model_config.head_dtype,
135 + dtype, fill = self.draft_logits_spec(vllm_config)
136 + self.draft_logits = torch.full(
137 + (
138 + self.max_num_reqs,
139 + self.num_speculative_steps,
140 + self.vocab_size,
141 + ),
142 + fill,
143 + dtype=dtype,
144 device=device,
145 )
146
@@ -293,6 +297,13 @@ class DraftModelSpeculator(BaseSpeculator):
297 )
298 return attn_metadata
299
300 + def draft_logits_spec(self, vllm_config: VllmConfig) -> tuple[torch.dtype, float]:
301 + """Dtype and fill for the cached proposal distribution.
302 +
303 + Speculators that write only a subset of columns each step override this.
304 + """
305 + return vllm_config.model_config.head_dtype, 0.0
306 +
307 def _validate_local_argmax_reduction(self) -> None:
308 if not self.use_local_argmax_reduction:
309 return