[Spec Decode] DSpark (#46995)
Signed-off-by: Benjamin Chislett <bchislett@nvidia.com> Signed-off-by: Giancarlo Delfin <gdelfin@inferact.ai> Signed-off-by: mgoin <mgoin64@gmail.com> Co-authored-by: Giancarlo Delfin <gdelfin@inferact.ai> Co-authored-by: mgoin <mgoin64@gmail.com>
Benjamin Chislett committed
Jul 1, 2026 at 11:30 UTC
f5a8d73377d0f0a4e00cba172f9fbd0d50471b07
24 files changed
+1821
-94
tests/models/registry.py
+13
@@ -1407,6 +1407,19 @@ _SPECULATIVE_DECODING_EXAMPLE_MODELS = {
1407
max_num_seqs=32,
1408
min_transformers_version="4.56.3", # Required for Qwen3Next
1409
),
1410
+ # [DSpark]
1411
+ "DSparkDraftModel": _HfExamplesInfo(
1412
+ "deepseek-ai/DeepSeek-V4-Pro-DSpark",
1413
+ speculative_model="deepseek-ai/DeepSeek-V4-Pro-DSpark", # draft in mtp.*
1414
+ is_available_online=False,
1415
+ use_original_num_layers=True, # DSpark has >1 draft block
1416
+ ),
1417
+ "Qwen3DSparkModel": _HfExamplesInfo(
1418
+ "Qwen/Qwen3-8B",
1419
+ speculative_model="deepseek-ai/dspark_qwen3_8b_block7",
1420
+ is_available_online=False,
1421
+ use_original_num_layers=True, # DSpark backbone requires all layers
1422
+ ),
1423
# [Eagle]
1424
"EagleCohereForCausalLM": _HfExamplesInfo(
1425
"/host/engines/cohere-moe",
tests/models/test_registry.py
+4
@@ -48,6 +48,10 @@ def test_registry_imports(model_arch):
48
"(see #41376)"
49
)
50
51
+ # DSpark draft model is NVIDIA-only; class is stubbed to None on ROCm/XPU.
52
+ if model_arch == "DSparkDraftModel" and not current_platform.is_cuda():
53
+ pytest.skip("DSparkDraftModel is only supported on CUDA")
54
+
55
# Ensure all model classes can be imported successfully
56
model_cls = ModelRegistry._try_load_model_cls(model_arch)
57
assert model_cls is not None
tests/v1/attention/test_dspark_noncausal_sparse_mla.py
new
+529
@@ -0,0 +1,529 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Correctness tests for DSpark non-causal sliding-window MLA via sparse indices.
4
+
5
+DSpark drafts a block of N tokens whose attention is NON-CAUSAL within the block:
6
+every block token attends to the sliding window of context AND to all block
7
+tokens (including ones at later positions than itself).
8
+
9
+We can implement this using the existing sparse-MLA pathway by expanding the window size
10
+to include the rest of the block tokens: instead of setting topk indices to the 127
11
+previous tokens, we expand it to the next power of 2 (256) and include up to
12
+swa_size + block_size - 1 topk indices, so that each query attends to the rest. The
13
+remaining slots are filled with padding.
14
+
15
+The sparse-MLA decode kernels (FlashMLA on SM90/SM100, FlashInfer TRTLLM on
16
+SM100/SM120) are index-driven: each query attends over exactly the slots in its
17
+index list, with no causal mask (see ``flash_mla_with_kvcache(..., indices=...)``
18
+and ``_forward_decode``'s "attend only by generated indices"). The existing
19
+``test_sparse_mla_backends`` suite already validates arbitrary index lists, but
20
+only ones whose entries are <= the query's own position. This test suite specifically
21
+ensures correctness of the non-causal attention case.
22
+
23
+This reuses the harness/helpers of ``test_sparse_mla_backends.py`` (same model
24
+shapes, fp8_ds_mla round-trip, mock indexer, MockSparseMLAAttentionLayer); only
25
+the index construction differs.
26
+"""
27
+
28
+import math
29
+from types import MethodType, SimpleNamespace
30
+
31
+import pytest
32
+import torch
33
+
34
+from tests.v1.attention.test_mla_backends import (
35
+ BatchSpec,
36
+ MockSparseMLAAttentionLayer,
37
+ create_and_prepopulate_kv_cache,
38
+)
39
+from tests.v1.attention.test_sparse_mla_backends import (
40
+ _quantize_dequantize_fp8_ds_mla,
41
+)
42
+from tests.v1.attention.utils import (
43
+ create_common_attn_metadata,
44
+ create_standard_kv_cache_spec,
45
+ create_vllm_config,
46
+)
47
+from vllm.config import set_current_vllm_config
48
+from vllm.model_executor.layers.linear import ColumnParallelLinear
49
+from vllm.platforms import current_platform
50
+
51
+if not current_platform.is_cuda():
52
+ pytest.skip(
53
+ "DSpark non-causal sparse MLA tests currently only support CUDA.",
54
+ allow_module_level=True,
55
+ )
56
+
57
+from vllm.utils.math_utils import cdiv
58
+from vllm.v1.attention.backends.mla.flashinfer_mla_sparse import (
59
+ FlashInferMLASparseTRTLLMBackend,
60
+)
61
+from vllm.v1.attention.backends.mla.flashmla_sparse import FlashMLASparseBackend
62
+from vllm.v1.attention.ops import flashmla
63
+
64
+DEVICE_TYPE = current_platform.device_type
65
+
66
+# (window, block_size, topk_width). topk_width must be a multiple of the kernel's
67
+# B_TOPK (= padded query-head count, 64 or 128); we use 128-multiples to cover
68
+# both. The "wide" case needs window + block > 128 -> width must grow past 128.
69
+_DSPARK_CONFIGS = {
70
+ "small_block": (8, 4, 128),
71
+ "full_window_block": (128, 5, 256),
72
+}
73
+
74
+
75
+def _build_dspark_noncausal_indices(
76
+ seq_lens: list[int],
77
+ query_lens: list[int],
78
+ window: int,
79
+ topk_width: int,
80
+ device: torch.device,
81
+) -> torch.Tensor:
82
+ """Per-token sparse indices for the DSpark non-causal block.
83
+
84
+ For a request with context length ``ctx`` and a query block of ``q_len``
85
+ tokens (block positions ``ctx .. ctx+q_len-1``), EVERY block query attends to
86
+ the same set: the trailing ``window`` context positions plus all block
87
+ positions, i.e. the contiguous range ``[max(ctx-window,0) .. ctx+q_len-1]``.
88
+ This is non-causal: an early block query's list contains later block tokens
89
+ (future-pointing). The list is padded to ``topk_width`` with ``-1``.
90
+ """
91
+ total_query_tokens = sum(query_lens)
92
+ sparse_indices = torch.full(
93
+ (total_query_tokens, topk_width), -1, dtype=torch.int32, device=device
94
+ )
95
+ gt = 0
96
+ for s_len, q_len in zip(seq_lens, query_lens):
97
+ ctx_len = s_len - q_len
98
+ lo = max(ctx_len - window, 0)
99
+ hi = ctx_len + q_len # exclusive: window context + the full block
100
+ idx_list = torch.arange(lo, hi, dtype=torch.int32, device=device)
101
+ n = idx_list.numel()
102
+ assert n <= topk_width, (
103
+ f"index list ({n}) exceeds aligned topk width ({topk_width})"
104
+ )
105
+ for _ in range(q_len):
106
+ sparse_indices[gt, :n] = idx_list
107
+ gt += 1
108
+ return sparse_indices
109
+
110
+
111
+def _run_sparse_backend_vs_sdpa(
112
+ backend_cls,
113
+ seq_lens: list[int],
114
+ query_lens: list[int],
115
+ sparse_indices: torch.Tensor,
116
+ kv_cache_dtype: str,
117
+ block_size: int,
118
+ num_heads: int,
119
+ device: torch.device,
120
+ force_future_dominance: bool = False,
121
+) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
122
+ """Run a sparse-MLA backend with the given per-token indices and compute a
123
+ dense per-token SDPA reference over the SAME indices.
124
+
125
+ Mirrors ``test_sparse_mla_backends.test_sparse_backend_decode_correctness``
126
+ but with externally-supplied (non-causal) ``sparse_indices``.
127
+
128
+ ``num_heads`` selects the kernel's B_TOPK (= padded q-head count): 128 -> 128,
129
+ 64 -> 64. The aligned widths (128/256) are multiples of both, so num_heads=64
130
+ exercises the head64 decode path that the SM100 alignment assert guards.
131
+
132
+ ``force_future_dominance`` scales the LAST block token's latent KV so it
133
+ dominates the softmax for every query that attends to it. With random data the
134
+ few future block tokens carry negligible attention mass (especially with a wide
135
+ window), so causal and non-causal outputs coincide; this knob makes the
136
+ future-token contribution provably large for the differentiation test. It is
137
+ OFF for the correctness test (which needs sensitivity to all tokens).
138
+
139
+ Returns (backend_output, noncausal_reference, causal_reference). The causal
140
+ reference restricts each query to indices <= its own absolute position.
141
+ """
142
+ batch_spec = BatchSpec(seq_lens=seq_lens, query_lens=query_lens)
143
+ topk_tokens = sparse_indices.shape[1]
144
+ dtype = torch.bfloat16
145
+ use_fp8_ds_mla_quantization = kv_cache_dtype == "fp8_ds_mla"
146
+
147
+ kv_lora_rank = 512
148
+ qk_nope_head_dim = 128
149
+ qk_rope_head_dim = 64
150
+ v_head_dim = 128
151
+ head_size = kv_lora_rank + qk_rope_head_dim
152
+
153
+ max_seqlen = max(seq_lens)
154
+ total_cache_tokens = sum(seq_lens)
155
+
156
+ vllm_config = create_vllm_config(
157
+ model_name="deepseek-ai/DeepSeek-V2-Lite-Chat",
158
+ tensor_parallel_size=1,
159
+ max_model_len=max_seqlen,
160
+ num_gpu_blocks=max(2048, cdiv(total_cache_tokens, block_size) + 1),
161
+ block_size=block_size,
162
+ hf_config_override={
163
+ "index_topk": topk_tokens,
164
+ "attn_module_list_cfg": [{"topk_tokens": topk_tokens}],
165
+ },
166
+ )
167
+ model_config = vllm_config.model_config
168
+ model_config.hf_text_config = SimpleNamespace(
169
+ q_lora_rank=None,
170
+ kv_lora_rank=kv_lora_rank,
171
+ qk_nope_head_dim=qk_nope_head_dim,
172
+ qk_rope_head_dim=qk_rope_head_dim,
173
+ v_head_dim=v_head_dim,
174
+ model_type="deepseek_v2",
175
+ )
176
+ model_config.dtype = dtype
177
+ model_config.get_num_attention_heads = MethodType(
178
+ lambda self, parallel_config: num_heads, model_config
179
+ )
180
+ model_config.get_num_kv_heads = MethodType(
181
+ lambda self, parallel_config: 1, model_config
182
+ )
183
+ model_config.get_head_size = MethodType(lambda self: head_size, model_config)
184
+ model_config.get_sliding_window = MethodType(lambda self: None, model_config)
185
+
186
+ kv_cache_spec = create_standard_kv_cache_spec(vllm_config)
187
+
188
+ torch.manual_seed(0)
189
+ scale = 1.0 / math.sqrt(head_size)
190
+
191
+ # Shared MLA projection weights, used by both reference and backend.
192
+ W_UK = torch.rand(
193
+ kv_lora_rank, num_heads, qk_nope_head_dim, dtype=dtype, device=device
194
+ )
195
+ W_UV = torch.rand(kv_lora_rank, num_heads, v_head_dim, dtype=dtype, device=device)
196
+
197
+ all_q_vllm, all_kv_c_vllm, all_k_pe_vllm = [], [], []
198
+ kv_c_contexts, k_pe_contexts = [], []
199
+ reference_outputs = []
200
+ # Causal counterpart of the reference: same index lists, but each query is
201
+ # restricted to indices <= its own absolute position (drops future-pointing
202
+ # block tokens). Used to prove the non-causal result is genuinely different.
203
+ causal_reference_outputs = []
204
+
205
+ kv_cache_scale = torch.tensor(1.0, dtype=torch.float32, device=device)
206
+ global_token_idx = 0
207
+
208
+ for s_len, q_len in zip(seq_lens, query_lens):
209
+ ctx_len = s_len - q_len
210
+
211
+ q_c = torch.rand(
212
+ q_len,
213
+ num_heads,
214
+ qk_nope_head_dim + qk_rope_head_dim,
215
+ dtype=dtype,
216
+ device=device,
217
+ )
218
+ kv_c_full = torch.rand(s_len, kv_lora_rank, dtype=dtype, device=device)
219
+ k_pe_full = torch.rand(s_len, 1, qk_rope_head_dim, dtype=dtype, device=device)
220
+
221
+ if force_future_dominance:
222
+ # Scale the last block token's latent KV so its key/value dominate the
223
+ # softmax for any query attending to it. 4x in the latent dot makes its
224
+ # pre-softmax score exceed the others by a wide margin, so non-causal
225
+ # queries (which include it) diverge sharply from causal ones (which,
226
+ # for all but the last query, exclude it). Applied before quantization
227
+ # so cache and reference stay consistent.
228
+ kv_c_full[s_len - 1] = kv_c_full[s_len - 1] * 4.0 + 2.0
229
+
230
+ if use_fp8_ds_mla_quantization:
231
+ is_sm100 = torch.cuda.get_device_capability()[0] >= 10
232
+ kv_c_full, k_pe_squeezed = _quantize_dequantize_fp8_ds_mla(
233
+ kv_c_full,
234
+ k_pe_full.squeeze(1),
235
+ block_size=block_size,
236
+ scale=kv_cache_scale,
237
+ simulate_sm100_e8m0_scales=is_sm100,
238
+ )
239
+ k_pe_full = k_pe_squeezed.unsqueeze(1)
240
+
241
+ q_nope, q_pe = q_c.split([qk_nope_head_dim, qk_rope_head_dim], dim=-1)
242
+ ql_nope = torch.einsum("qnh,lnh->qnl", q_nope, W_UK)
243
+ q_mqa = torch.cat([ql_nope, q_pe], dim=-1)
244
+
245
+ k_mqa = torch.cat([kv_c_full, k_pe_full.squeeze(1)], dim=-1)
246
+ v_mqa = kv_c_full
247
+
248
+ # Per-token sparse SDPA reference over the supplied (non-causal) indices.
249
+ def _sparse_sdpa(idx_tensor, q_tok, k_mqa=k_mqa, v_mqa=v_mqa):
250
+ k_sparse = k_mqa[idx_tensor].unsqueeze(1).expand(-1, num_heads, -1)
251
+ v_sparse = v_mqa[idx_tensor].unsqueeze(1).expand(-1, num_heads, -1)
252
+ out = torch.nn.functional.scaled_dot_product_attention(
253
+ q_tok.unsqueeze(0).transpose(1, 2),
254
+ k_sparse.unsqueeze(0).transpose(1, 2),
255
+ v_sparse.unsqueeze(0).transpose(1, 2),
256
+ scale=scale,
257
+ )
258
+ out = out.transpose(1, 2).squeeze(0)
259
+ out = torch.einsum("qnl,lnv->qnv", out, W_UV)
260
+ return out.flatten(start_dim=-2)
261
+
262
+ for q_idx in range(q_len):
263
+ tok_sparse_idx = sparse_indices[global_token_idx]
264
+ valid_indices = tok_sparse_idx[tok_sparse_idx >= 0].long()
265
+
266
+ q_tok = q_mqa[q_idx : q_idx + 1]
267
+ reference_outputs.append(_sparse_sdpa(valid_indices, q_tok))
268
+
269
+ # Causal: drop indices pointing past this query's own position.
270
+ abs_pos = ctx_len + q_idx
271
+ causal_indices = valid_indices[valid_indices <= abs_pos]
272
+ causal_reference_outputs.append(_sparse_sdpa(causal_indices, q_tok))
273
+ global_token_idx += 1
274
+
275
+ all_q_vllm.append(q_c)
276
+ all_kv_c_vllm.append(kv_c_full[ctx_len:])
277
+ all_k_pe_vllm.append(k_pe_full[ctx_len:])
278
+ kv_c_contexts.append(kv_c_full[: ctx_len + 1])
279
+ k_pe_contexts.append(k_pe_full[: ctx_len + 1])
280
+
281
+ query_vllm = torch.cat(all_q_vllm, dim=0)
282
+ kv_c_vllm = torch.cat(all_kv_c_vllm, dim=0)
283
+ k_pe_vllm = torch.cat(all_k_pe_vllm, dim=0)
284
+ sdpa_reference = torch.cat(reference_outputs, dim=0)
285
+ causal_reference = torch.cat(causal_reference_outputs, dim=0)
286
+
287
+ vllm_config.cache_config.cache_dtype = kv_cache_dtype
288
+ vllm_config.model_config.hf_config.index_topk = topk_tokens
289
+
290
+ common_attn_metadata = create_common_attn_metadata(
291
+ batch_spec, block_size, device, arange_block_indices=True
292
+ )
293
+ kv_cache = create_and_prepopulate_kv_cache(
294
+ kv_c_contexts=kv_c_contexts,
295
+ k_pe_contexts=k_pe_contexts,
296
+ block_size=block_size,
297
+ head_size=head_size,
298
+ dtype=dtype,
299
+ device=device,
300
+ num_blocks=vllm_config.cache_config.num_gpu_blocks,
301
+ common_attn_metadata=common_attn_metadata,
302
+ randomize_blocks=False,
303
+ kv_cache_dtype=kv_cache_dtype,
304
+ scale=kv_cache_scale,
305
+ )
306
+
307
+ builder = backend_cls.get_builder_cls()(
308
+ kv_cache_spec, ["placeholder"], vllm_config, device
309
+ )
310
+ metadata = builder.build(
311
+ common_prefix_len=0, common_attn_metadata=common_attn_metadata
312
+ )
313
+
314
+ mock_indexer = SimpleNamespace(topk_indices_buffer=sparse_indices)
315
+
316
+ kv_b_proj_weight = torch.cat([W_UK, W_UV], dim=-1).view(
317
+ kv_lora_rank, num_heads * (qk_nope_head_dim + v_head_dim)
318
+ )
319
+ mock_kv_b_proj = ColumnParallelLinear(
320
+ input_size=kv_lora_rank,
321
+ output_size=num_heads * (qk_nope_head_dim + v_head_dim),
322
+ bias=False,
323
+ ).to(device=device, dtype=dtype)
324
+ mock_kv_b_proj.weight = torch.nn.Parameter(kv_b_proj_weight.T.contiguous())
325
+
326
+ with set_current_vllm_config(vllm_config):
327
+ impl = backend_cls.get_impl_cls()(
328
+ num_heads=num_heads,
329
+ head_size=head_size,
330
+ scale=scale,
331
+ num_kv_heads=1,
332
+ alibi_slopes=None,
333
+ sliding_window=None,
334
+ kv_cache_dtype=vllm_config.cache_config.cache_dtype,
335
+ logits_soft_cap=None,
336
+ attn_type="decoder",
337
+ kv_sharing_target_layer_name=None,
338
+ q_lora_rank=None,
339
+ kv_lora_rank=kv_lora_rank,
340
+ qk_nope_head_dim=qk_nope_head_dim,
341
+ qk_rope_head_dim=qk_rope_head_dim,
342
+ qk_head_dim=qk_nope_head_dim + qk_rope_head_dim,
343
+ v_head_dim=v_head_dim,
344
+ kv_b_proj=mock_kv_b_proj,
345
+ indexer=mock_indexer,
346
+ )
347
+ impl.process_weights_after_loading(dtype)
348
+ mock_layer = MockSparseMLAAttentionLayer(
349
+ impl=impl,
350
+ num_heads=num_heads,
351
+ qk_nope_head_dim=qk_nope_head_dim,
352
+ qk_rope_head_dim=qk_rope_head_dim,
353
+ v_head_dim=v_head_dim,
354
+ kv_lora_rank=kv_lora_rank,
355
+ device=device,
356
+ W_UK=W_UK,
357
+ W_UV=W_UV,
358
+ q_scale=1.0,
359
+ k_scale=1.0,
360
+ )
361
+
362
+ out_buffer = torch.empty(
363
+ metadata.num_actual_tokens, num_heads * v_head_dim, dtype=dtype, device=device
364
+ )
365
+ with torch.inference_mode():
366
+ backend_output = mock_layer.forward_impl(
367
+ query_vllm, kv_c_vllm, k_pe_vllm, kv_cache, metadata, out_buffer
368
+ )
369
+ return backend_output, sdpa_reference, causal_reference
370
+
371
+
372
+def _skip_if_backend_unavailable(backend_cls, kv_cache_dtype: str, block_size: int):
373
+ if kv_cache_dtype not in backend_cls.supported_kv_cache_dtypes:
374
+ pytest.skip(f"{backend_cls.get_name()} does not support {kv_cache_dtype}")
375
+ if (
376
+ backend_cls is FlashMLASparseBackend
377
+ and kv_cache_dtype.startswith("fp8")
378
+ and kv_cache_dtype != "fp8_ds_mla"
379
+ ):
380
+ pytest.skip("FlashMLA Sparse fp8 only supports fp8_ds_mla kv-cache dtype")
381
+ if block_size not in backend_cls.get_supported_kernel_block_sizes():
382
+ pytest.skip(
383
+ f"{backend_cls.get_name()} does not support block_size={block_size}"
384
+ )
385
+ if backend_cls is FlashMLASparseBackend:
386
+ ok, reason = flashmla.is_flashmla_sparse_supported()
387
+ if not ok:
388
+ pytest.skip(reason)
389
+ elif backend_cls is FlashInferMLASparseTRTLLMBackend:
390
+ cap = current_platform.get_device_capability()
391
+ if cap is None or not backend_cls.supports_compute_capability(cap):
392
+ pytest.skip("FlashInferMLASparseTRTLLMBackend requires SM 10.x capability")
393
+
394
+
395
+@pytest.mark.parametrize(
396
+ "backend_cls",
397
+ [FlashMLASparseBackend, FlashInferMLASparseTRTLLMBackend],
398
+ ids=["FlashMLA", "FlashInferTRTLLM"],
399
+)
400
+@pytest.mark.parametrize("config_name", list(_DSPARK_CONFIGS.keys()))
401
+# Per backend, the skip logic routes fp8 to the supported flavor: FlashMLA tests
402
+# auto + fp8_ds_mla (and skips per-tensor "fp8", which it aliases to ds_mla);
403
+# FlashInfer TRTLLM tests auto + per-tensor "fp8" (and skips fp8_ds_mla, which it
404
+# does not implement). So both backends get a bf16 case and an fp8 case.
405
+@pytest.mark.parametrize("kv_cache_dtype", ["auto", "fp8_ds_mla", "fp8"])
406
+@pytest.mark.parametrize("block_size", [64])
407
+# h_q=128 -> B_TOPK=128; h_q=64 -> B_TOPK=64 (covers the head64 decode path the
408
+# SM100 alignment assert specifically guards). Aligned widths (128/256) satisfy both.
409
+@pytest.mark.parametrize("num_heads", [128, 64], ids=["h128", "h64"])
410
+def test_dspark_noncausal_sparse_mla_matches_sdpa(
411
+ default_vllm_config,
412
+ dist_init,
413
+ workspace_init,
414
+ backend_cls,
415
+ config_name,
416
+ kv_cache_dtype,
417
+ block_size,
418
+ num_heads,
419
+):
420
+ """Non-causal (window ∪ block, future-pointing) per-token indices must match
421
+ a dense SDPA reference over the same indices, for both sparse-MLA backends."""
422
+ _skip_if_backend_unavailable(backend_cls, kv_cache_dtype, block_size)
423
+
424
+ window, block, topk_width = _DSPARK_CONFIGS[config_name]
425
+ device = torch.device(DEVICE_TYPE)
426
+
427
+ # Decode-style batch: each request has `block` query tokens and enough
428
+ # context for a full sliding window.
429
+ seq_lens = [window + block + 123, window + block + 50]
430
+ query_lens = [block, block]
431
+
432
+ sparse_indices = _build_dspark_noncausal_indices(
433
+ seq_lens, query_lens, window, topk_width, device
434
+ )
435
+
436
+ # Sanity: the construction must actually be non-causal (an early block query
437
+ # must reference a later block position than itself).
438
+ ctx0 = seq_lens[0] - query_lens[0]
439
+ first_query_valid = sparse_indices[0][sparse_indices[0] >= 0]
440
+ assert int(first_query_valid.max()) >= ctx0 + query_lens[0] - 1, (
441
+ "expected the first block query to attend to a future block token"
442
+ )
443
+
444
+ backend_output, sdpa_reference, _ = _run_sparse_backend_vs_sdpa(
445
+ backend_cls,
446
+ seq_lens,
447
+ query_lens,
448
+ sparse_indices,
449
+ kv_cache_dtype,
450
+ block_size,
451
+ num_heads,
452
+ device,
453
+ )
454
+
455
+ assert backend_output.shape == sdpa_reference.shape
456
+ assert backend_output.dtype == sdpa_reference.dtype
457
+ assert torch.isfinite(backend_output).all()
458
+ if kv_cache_dtype.startswith("fp8"):
459
+ rtol, atol = 0.065, 0.05
460
+ else:
461
+ rtol, atol = 0.01, 0.01
462
+ torch.testing.assert_close(backend_output, sdpa_reference, rtol=rtol, atol=atol)
463
+
464
+
465
+@pytest.mark.parametrize(
466
+ "backend_cls",
467
+ [FlashMLASparseBackend, FlashInferMLASparseTRTLLMBackend],
468
+ ids=["FlashMLA", "FlashInferTRTLLM"],
469
+)
470
+@pytest.mark.parametrize("config_name", list(_DSPARK_CONFIGS.keys()))
471
+@pytest.mark.parametrize("block_size", [64])
472
+def test_dspark_noncausal_differs_from_causal(
473
+ default_vllm_config,
474
+ dist_init,
475
+ workspace_init,
476
+ backend_cls,
477
+ config_name,
478
+ block_size,
479
+):
480
+ """Differentiation guard: prove the backend genuinely attends to the
481
+ future-pointing indices (not silently applying a causal mask, and not merely
482
+ coinciding with a causal result because future tokens carry little weight).
483
+
484
+ With random data the few future block tokens are a negligible fraction of the
485
+ attended set (especially with a wide window), so causal and non-causal outputs
486
+ are numerically indistinguishable -- that is correct physics, not a backend
487
+ bug. To make the check meaningful we use ``force_future_dominance`` so the last
488
+ block token dominates the softmax: the backend must then match the non-causal
489
+ reference and diverge sharply from the causal one. bf16 (``auto``) suffices;
490
+ the property is dtype-independent and fp8 correctness is covered above.
491
+ """
492
+ _skip_if_backend_unavailable(backend_cls, "auto", block_size)
493
+
494
+ window, block, topk_width = _DSPARK_CONFIGS[config_name]
495
+ device = torch.device(DEVICE_TYPE)
496
+ seq_lens = [window + block + 123, window + block + 50]
497
+ query_lens = [block, block]
498
+
499
+ sparse_indices = _build_dspark_noncausal_indices(
500
+ seq_lens, query_lens, window, topk_width, device
501
+ )
502
+
503
+ backend_output, sdpa_reference, causal_reference = _run_sparse_backend_vs_sdpa(
504
+ backend_cls,
505
+ seq_lens,
506
+ query_lens,
507
+ sparse_indices,
508
+ "auto",
509
+ block_size,
510
+ 128,
511
+ device,
512
+ force_future_dominance=True,
513
+ )
514
+
515
+ # The two references must be clearly distinguishable for the check to mean
516
+ # anything (dominance guarantees this).
517
+ ref_gap = (sdpa_reference - causal_reference).abs().max().item()
518
+ assert ref_gap > 0.1, (
519
+ f"non-causal and causal references are too close (gap={ref_gap}); "
520
+ "force_future_dominance did not create a separable scenario"
521
+ )
522
+
523
+ # Backend must track the NON-causal reference, not the causal one.
524
+ torch.testing.assert_close(backend_output, sdpa_reference, rtol=0.01, atol=0.01)
525
+ causal_err = (backend_output - causal_reference).abs().max().item()
526
+ assert causal_err > 0.1, (
527
+ f"non-causal backend output matches the causal reference "
528
+ f"(max abs diff={causal_err}); future-pointing indices are not attended to"
529
+ )
tests/v1/e2e/spec_decode/test_spec_decode.py
+61
@@ -1412,6 +1412,67 @@ def test_dflash_acceptance_rates(
1412
cleanup_dist_env_and_memory()
1413
1414
1415
+@pytest.fixture
1416
+def dspark_config():
1417
+ target_model = "Qwen/Qwen3-4B-FP8"
1418
+ draft_model = "deepseek-ai/dspark_qwen3_4b_block7"
1419
+
1420
+ return dict(
1421
+ model=target_model,
1422
+ trust_remote_code=True,
1423
+ speculative_config={
1424
+ "method": "dspark",
1425
+ "model": draft_model,
1426
+ "num_speculative_tokens": 7,
1427
+ "attention_backend": "FLASH_ATTN",
1428
+ "draft_sample_method": "probabilistic",
1429
+ },
1430
+ max_model_len=4096,
1431
+ disable_log_stats=False,
1432
+ )
1433
+
1434
+
1435
+@single_gpu_only
1436
+@large_gpu_mark(min_gb=24)
1437
+def test_dspark_correctness_and_acceptance_rate(dspark_config):
1438
+ """
1439
+ E2E test for DSpark speculative decoding: acceptance rate/length
1440
+ regression coverage plus GSM8K correctness, at temperature=1.0 to
1441
+ exercise the probabilistic draft-sampling/rejection-sampling path
1442
+ (not just greedy).
1443
+
1444
+ Uses Qwen/Qwen3-4B-FP8 as target with the dspark_qwen3_4b_block7 draft
1445
+ model. Reference: measured over 12 runs of the full GSM8K set at
1446
+ temperature=1.0 (prefix caching disabled to avoid cross-run reuse):
1447
+ accuracy: min=0.782 max=0.814 mean=0.801
1448
+ acceptance_rate: min=0.418 max=0.434 mean=0.428
1449
+ acceptance_len: min=3.928 max=4.037 mean=3.994
1450
+ Thresholds set conservatively to 10% to avoid flaking due to unlucky sampling
1451
+ """
1452
+ spec_llm = LLM(**dspark_config)
1453
+
1454
+ results = evaluate_gsm8k_offline(spec_llm, temperature=1.0)
1455
+ gsm8k_accuracy = results["accuracy"]
1456
+
1457
+ metrics = spec_llm.get_metrics()
1458
+ acceptance_rate = compute_acceptance_rate(metrics)
1459
+ acceptance_len = compute_acceptance_len(metrics)
1460
+
1461
+ print(
1462
+ f"DSpark acceptance_rate={acceptance_rate:.2f}, "
1463
+ f"acceptance_len={acceptance_len:.2f}, "
1464
+ f"gsm8k_accuracy={gsm8k_accuracy:.3f}"
1465
+ )
1466
+
1467
+ assert acceptance_rate >= 0.428 * 0.9
1468
+ assert acceptance_len >= 3.994 * 0.9
1469
+ assert gsm8k_accuracy >= 0.801 * 0.9
1470
+
1471
+ del spec_llm
1472
+ torch.accelerator.empty_cache()
1473
+ cleanup_dist_env_and_memory()
1474
+
1475
+
1476
@single_gpu_only
1477
def test_synthetic_acceptance_rate():
1478
"""Verify that synthetic rejection sampling produces an acceptance
vllm/benchmarks/datasets/datasets.py
+1
@@ -2452,6 +2452,7 @@ def get_samples(args, tokenizer: TokenizerLike) -> list[SampleRequest]:
2452
num_requests=args.num_prompts,
2453
tokenizer=tokenizer,
2454
output_len=args.speed_bench_output_len,
2455
+ skip_chat_template=args.skip_chat_template,
2456
chat_template_kwargs=getattr(args, "chat_template_kwargs", None),
2457
enable_multimodal_chat=args.enable_multimodal_chat,
2458
request_id_prefix=args.request_id_prefix,
vllm/config/speculative.py
+36
-3
@@ -54,6 +54,7 @@ MTPModelTypes = Literal[
54
]
55
NgramGPUTypes = Literal["ngram_gpu"]
56
DFlashModelTypes = Literal["dflash"]
57
+DSparkModelTypes = Literal["dspark"]
58
EagleModelTypes = Literal[
59
"eagle", "eagle3", "extract_hidden_states", MTPModelTypes, DFlashModelTypes
60
]
@@ -66,6 +67,7 @@ SpeculativeMethod = Literal[
67
"custom_class",
68
EagleModelTypes,
69
NgramGPUTypes,
70
+ DSparkModelTypes,
71
]
72
RejectionSampleMethod = Literal["standard", "synthetic", "block"]
73
DraftSampleMethod = Literal["greedy", "probabilistic"]
@@ -291,6 +293,7 @@ class SpeculativeConfig:
293
"eagle3",
294
"extract_hidden_states",
295
"dflash",
296
+ "dspark",
297
)
298
factors.append(uses_aux_hidden_states)
299
@@ -608,6 +611,13 @@ class SpeculativeConfig:
611
# --quantization fp8 with a bf16 checkpoint.
612
if not self.quantization:
613
self.quantization = self.target_model_config.quantization
614
+ elif self.method == "dspark":
615
+ # DeepSeek DSpark can ship the weights inside the target checkpoint
616
+ if self.target_model_config is None:
617
+ raise ValueError("target_model_config must be present for dspark")
618
+ self.model = self.target_model_config.model
619
+ if not self.quantization:
620
+ self.quantization = self.target_model_config.quantization
621
elif self.method in ("ngram", "[ngram]"):
622
self.model = "ngram"
623
elif self.method == "ngram_gpu":
@@ -755,18 +765,24 @@ class SpeculativeConfig:
765
draft_hf.truncated_vocab_size = target_vocab
766
767
# Automatically detect the method
758
- if self.method in ("eagle", "eagle3", "dflash"):
768
+ if self.method in ("eagle", "eagle3", "dflash", "dspark"):
769
pass
770
# examples:
771
# yuhuili/EAGLE-LLaMA3-Instruct-8B
772
# yuhuili/EAGLE3-LLaMA3.1-Instruct-8B
773
# AngelSlim/Qwen3-8B_eagle3
774
+ # deepseek-ai/dspark_qwen3_8b_block7
775
elif "eagle-" in self.draft_model_config.model.lower():
776
self.method = "eagle"
777
elif "eagle3" in self.draft_model_config.model.lower():
778
self.method = "eagle3"
779
elif "dflash" in self.draft_model_config.model.lower():
780
self.method = "dflash"
781
+ elif (
782
+ "dspark" in self.draft_model_config.model.lower()
783
+ or "Qwen3DSparkModel" in self.draft_model_config.architectures
784
+ ):
785
+ self.method = "dspark"
786
elif self.draft_model_config.hf_config.model_type == "medusa":
787
self.method = "medusa"
788
elif self.draft_model_config.hf_config.model_type == "mlp_speculator":
@@ -813,7 +829,18 @@ class SpeculativeConfig:
829
self.draft_model_config.hf_config = eagle_config
830
self.update_arch_()
831
816
- if self.method == "dflash":
832
+ if self.method == "dspark" and (
833
+ "Qwen3DSparkModel" not in self.draft_model_config.architectures
834
+ ):
835
+ # DeepSeek-V4 DSpark reuses the full DeepSeek-V4 config
836
+ # and its weights ship in the target checkpoint.
837
+ self.draft_model_config.hf_config.model_type = "deepseek_v4"
838
+ self.draft_model_config.hf_config.architectures = [
839
+ "DSparkDraftModel"
840
+ ]
841
+ self.update_arch_()
842
+
843
+ if self.method in ("dflash", "dspark"):
844
self.parallel_drafting = True
845
846
if self.num_speculative_tokens is not None and hasattr(
@@ -1129,11 +1156,17 @@ class SpeculativeConfig:
1156
)
1157
1158
def use_eagle(self) -> bool:
1132
- return self.method in ("eagle", "eagle3", "mtp", "dflash")
1159
+ # NOTE: This method is usually a stand-in for "speculative decoding using
1160
+ # target model hidden states"
1161
+ # TODO(ben): Refactor this so the naming is clearer
1162
+ return self.method in ("eagle", "eagle3", "mtp", "dflash", "dspark")
1163
1164
def use_dflash(self) -> bool:
1165
return self.method == "dflash"
1166
1167
+ def use_dspark(self) -> bool:
1168
+ return self.method == "dspark"
1169
+
1170
def uses_dynamic_speculative_decoding(self) -> bool:
1171
return self.num_speculative_tokens_per_batch_size is not None
1172
vllm/config/vllm.py
+24
-5
@@ -524,6 +524,16 @@ class VllmConfig:
524
if use_v2_model_runner is not None:
525
return use_v2_model_runner
526
527
+ # DSpark is implemented only by the V2 GPU model runner, and DeepSeek-V4
528
+ # is not otherwise a default-V2 architecture, so force V2 for it. If V2
529
+ # is unsupported for the rest of the config, _validate_v2_model_runner
530
+ # raises rather than silently falling back to V1 (which can't run dspark).
531
+ if (
532
+ self.speculative_config is not None
533
+ and self.speculative_config.method == "dspark"
534
+ ):
535
+ return True
536
+
537
if self.model_config is not None and self.model_config.is_diffusion:
538
return True
539
@@ -958,10 +968,11 @@ class VllmConfig:
968
self.speculative_config.method not in get_args(EagleModelTypes)
969
and self.speculative_config.method not in get_args(NgramGPUTypes)
970
and self.speculative_config.method != "draft_model"
971
+ and self.speculative_config.method != "dspark"
972
):
973
raise ValueError(
974
"Currently, async scheduling is only supported "
964
- "with EAGLE/MTP/Draft Model/NGram GPU kind of "
975
+ "with EAGLE/MTP/Draft Model/NGram GPU/DSpark kind of "
976
"speculative decoding"
977
)
978
if self.speculative_config.disable_padded_drafter_batch:
@@ -989,6 +1000,7 @@ class VllmConfig:
1000
self.speculative_config is not None
1001
and self.speculative_config.method not in get_args(EagleModelTypes)
1002
and self.speculative_config.method not in get_args(NgramGPUTypes)
1003
+ and self.speculative_config.method != "dspark"
1004
):
1005
logger.warning_once(
1006
"Async scheduling not supported with %s-based "
@@ -2038,17 +2050,24 @@ class VllmConfig:
2050
# TODO: ngram / ngram_gpu are not supported by the v2 model runner yet
2051
if speculative_config.method in ("ngram", "ngram_gpu"):
2052
unsupported.append("ngram/ngram_gpu speculative decoding")
2041
- elif speculative_config.method not in ("eagle", "eagle3", "mtp", "dflash"):
2053
+ elif speculative_config.method not in (
2054
+ "eagle",
2055
+ "eagle3",
2056
+ "mtp",
2057
+ "dflash",
2058
+ "dspark",
2059
+ ):
2060
unsupported.append(f"speculative method '{speculative_config.method}'")
2061
2062
if speculative_config.uses_dynamic_speculative_decoding():
2063
unsupported.append("dynamic speculative decoding")
2064
2047
- # V2 EagleSpeculator does not support parallel_drafting (for P-Eagle)
2048
- # DFlash uses parallel drafting natively in V2 via DFlashSpeculator.
2065
+ # V2 EagleSpeculator does not support parallel_drafting (for P-Eagle).
2066
+ # DFlash and DSpark use parallel drafting natively in V2 via their
2067
+ # own speculators.
2068
if (
2069
speculative_config.parallel_drafting
2051
- and speculative_config.method != "dflash"
2070
+ and speculative_config.method not in ("dflash", "dspark")
2071
):
2072
unsupported.append("parallel drafting for EAGLE speculative decoding")
2073
vllm/model_executor/models/qwen3_dflash.py
+9
-3
@@ -472,7 +472,7 @@ class DFlashQwen3Model(nn.Module):
472
self,
473
context_states: torch.Tensor,
474
context_positions: torch.Tensor,
475
- context_slot_mapping: torch.Tensor | None = None,
475
+ context_slot_mapping: torch.Tensor | list[torch.Tensor | None] | None = None,
476
) -> None:
477
"""Precompute K/V for context states write them into each layer's KV cache.
478
@@ -551,7 +551,13 @@ class DFlashQwen3Model(nn.Module):
551
552
# --- Per-layer cache insert ---
553
all_k_final = all_k_flat.view(L, num_ctx, nkv, hd)
554
+ per_layer = isinstance(context_slot_mapping, (list, tuple))
555
for i in range(L):
556
+ slot_mapping = (
557
+ context_slot_mapping[i] if per_layer else context_slot_mapping
558
+ )
559
+ if slot_mapping is None:
560
+ continue # dummy run: skip cache ops
561
attn = self._attn_layers[i]
562
kv_cache = attn.kv_cache
563
attn.impl.do_kv_cache_update(
@@ -559,7 +565,7 @@ class DFlashQwen3Model(nn.Module):
565
all_k_final[i],
566
all_v[i],
567
kv_cache,
562
- context_slot_mapping,
568
+ slot_mapping,
569
)
570
571
def forward(
@@ -701,7 +707,7 @@ class DFlashQwen3ForCausalLM(Qwen3ForCausalLM):
707
self,
708
context_states: torch.Tensor,
709
context_positions: torch.Tensor,
704
- context_slot_mapping: torch.Tensor | None = None,
710
+ context_slot_mapping: torch.Tensor | list[torch.Tensor | None] | None = None,
711
) -> None:
712
"""Precompute projected + RoPE'd K/V and write to cache."""
713
self.model.precompute_and_store_context_kv(
vllm/model_executor/models/qwen3_dspark.py
new
+153
@@ -0,0 +1,153 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Qwen3 DSpark draft model for semi-autoregressive drafting.
4
+
5
+DSpark drafts a whole block in one parallel pass (DFlash-style: context-KV
6
+precompute + a non-causal query-block forward) and then injects intra-block
7
+dependency with a lightweight sequential Markov head.
8
+
9
+The parallel backbone is a standard Qwen3 decoder stack reused from the
10
+DFlash Qwen3 draft (see qwen3_dflash.py). DSpark adds:
11
+ * ``markov_head``: low-rank V x r / r x V transition bias added to the base
12
+ logits, sampled left-to-right by the speculator (the sequential stage).
13
+
14
+DSparkMarkovHead is shared with the DSV4-style DSpark model.
15
+"""
16
+
17
+from collections.abc import Iterable
18
+
19
+import torch
20
+import torch.nn as nn
21
+
22
+from vllm.config import VllmConfig
23
+from vllm.logger import init_logger
24
+from vllm.model_executor.layers.logits_processor import LogitsProcessor
25
+from vllm.model_executor.layers.vocab_parallel_embedding import (
26
+ ParallelLMHead,
27
+ VocabParallelEmbedding,
28
+)
29
+
30
+from .qwen3_dflash import DFlashQwen3ForCausalLM, DFlashQwen3Model
31
+from .utils import AutoWeightsLoader, maybe_prefix, process_eagle_weight
32
+
33
+logger = init_logger(__name__)
34
+
35
+
36
+class DSparkMarkovHead(nn.Module):
37
+ """Sequential transition-bias head (low-rank V x r, r x V).
38
+
39
+ ``markov_w1[token]`` is an r-dim embedding of the previously sampled token;
40
+ ``markov_w2`` projects it back to a vocab-size bias added to the base logits.
41
+ """
42
+
43
+ def __init__(self, vocab_size: int, markov_rank: int, prefix: str) -> None:
44
+ super().__init__()
45
+ # TODO(ben): profile for which (if any) it makes sense to replicate or TP-shard
46
+ self.markov_w1 = VocabParallelEmbedding(
47
+ vocab_size, markov_rank, prefix=maybe_prefix(prefix, "markov_w1")
48
+ )
49
+ self.markov_w2 = ParallelLMHead(
50
+ vocab_size, markov_rank, prefix=maybe_prefix(prefix, "markov_w2")
51
+ )
52
+
53
+ def embed(self, token_ids: torch.Tensor) -> torch.Tensor:
54
+ """r-dim Markov embedding of ``token_ids`` ([B] -> [B, r])."""
55
+ return self.markov_w1(token_ids)
56
+
57
+ def bias(self, markov_embed: torch.Tensor, logits_processor) -> torch.Tensor:
58
+ """Vocab-size transition bias from a Markov embedding ([B, r] -> [B, V])."""
59
+ return logits_processor(self.markov_w2, markov_embed)
60
+
61
+
62
+class Qwen3DSparkModel(DFlashQwen3Model):
63
+ """DFlash Qwen3 backbone + DSpark Markov head."""
64
+
65
+ def __init__(
66
+ self,
67
+ *,
68
+ vllm_config: VllmConfig,
69
+ start_layer_id: int = 0,
70
+ prefix: str = "",
71
+ ) -> None:
72
+ super().__init__(
73
+ vllm_config=vllm_config, start_layer_id=start_layer_id, prefix=prefix
74
+ )
75
+ config = self.config
76
+ self.markov_head = DSparkMarkovHead(
77
+ config.vocab_size,
78
+ config.markov_rank,
79
+ prefix=maybe_prefix(prefix, "markov_head"),
80
+ )
81
+
82
+
83
+class Qwen3DSparkForCausalLM(DFlashQwen3ForCausalLM):
84
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
85
+ nn.Module.__init__(self)
86
+ self.draft_model_config = vllm_config.speculative_config.draft_model_config
87
+ self.config = self.draft_model_config.hf_config
88
+ if getattr(self.config, "draft_vocab_size", None) is None:
89
+ self.config.draft_vocab_size = getattr(self.config, "vocab_size", None)
90
+ target_layer_num = vllm_config.model_config.get_num_layers(
91
+ vllm_config.parallel_config
92
+ )
93
+ self.model = Qwen3DSparkModel(
94
+ vllm_config=vllm_config,
95
+ prefix=maybe_prefix(prefix, "model"),
96
+ start_layer_id=target_layer_num,
97
+ )
98
+
99
+ logit_scale = getattr(self.config, "logit_scale", 1.0)
100
+ self.lm_head = ParallelLMHead(
101
+ self.config.draft_vocab_size,
102
+ self.config.hidden_size,
103
+ prefix=maybe_prefix(prefix, "lm_head"),
104
+ )
105
+ self.logits_processor = LogitsProcessor(
106
+ self.config.draft_vocab_size, scale=logit_scale
107
+ )
108
+ target_vocab_size = vllm_config.model_config.get_vocab_size()
109
+ if self.config.draft_vocab_size != target_vocab_size:
110
+ self.draft_id_to_target_id = nn.Parameter(
111
+ torch.zeros(self.config.draft_vocab_size, dtype=torch.long),
112
+ requires_grad=False,
113
+ )
114
+ else:
115
+ self.draft_id_to_target_id = None
116
+
117
+ def get_draft_kv_cache_layer_names(self) -> list[str]:
118
+ return [layer.self_attn.attn.layer_name for layer in self.model.layers]
119
+
120
+ def markov_embed(self, token_ids: torch.Tensor) -> torch.Tensor:
121
+ return self.model.markov_head.embed(token_ids)
122
+
123
+ def markov_bias(self, markov_embed: torch.Tensor) -> torch.Tensor:
124
+ return self.model.markov_head.bias(markov_embed, self.logits_processor)
125
+
126
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]):
127
+ model_weights = {}
128
+ includes_embed_tokens = False
129
+ includes_lm_head = False
130
+ for name, loaded_weight in weights:
131
+ if "lm_head" not in name:
132
+ name = "model." + name
133
+ if "embed_tokens" in name:
134
+ includes_embed_tokens = True
135
+ if "lm_head" in name:
136
+ includes_lm_head = True
137
+ model_weights[name] = loaded_weight
138
+ # Sets has_own_embed_tokens / has_own_lm_head so load_dspark_model
139
+ # knows whether to keep these or alias the target's.
140
+ process_eagle_weight(self, name)
141
+
142
+ # mask_embedding is an unused placeholder param; DSpark masks via the vocab row.
143
+ # confidence_head is not wired into inference yet; skip its weights.
144
+ # embed_tokens / lm_head are optional; when omitted they are shared from
145
+ # the target by load_dspark_model, so skip the unloaded params here.
146
+ skip_substrs = ["mask_embedding", "confidence_head"]
147
+ if not includes_embed_tokens:
148
+ skip_substrs.append("embed_tokens")
149
+ if not includes_lm_head:
150
+ skip_substrs.append("lm_head")
151
+ loader = AutoWeightsLoader(self, skip_substrs=skip_substrs)
152
+ loader.load_weights(model_weights.items())
153
+ self.model._build_fused_kv_buffers()
vllm/model_executor/models/registry.py
+2
@@ -585,6 +585,8 @@ _SPECULATIVE_DECODING_MODELS = {
585
"EagleLlama4ForCausalLM": ("llama4_eagle", "EagleLlama4ForCausalLM"),
586
"EagleMiniCPMForCausalLM": ("minicpm_eagle", "EagleMiniCPMForCausalLM"),
587
"DFlashDraftModel": ("qwen3_dflash", "DFlashQwen3ForCausalLM"),
588
+ "DSparkDraftModel": ("vllm.models.deepseek_v4", "DSparkDeepseekV4ForCausalLM"),
589
+ "Qwen3DSparkModel": ("qwen3_dspark", "Qwen3DSparkForCausalLM"),
590
"PEagleDraftModel": ("llama_eagle3", "Eagle3LlamaForCausalLM"),
591
"PeagleLlamaForCausalLM": ("llama_eagle3", "Eagle3LlamaForCausalLM"),
592
"Eagle3LlamaForCausalLM": ("llama_eagle3", "Eagle3LlamaForCausalLM"),
vllm/models/deepseek_v4/__init__.py
+9
@@ -17,14 +17,23 @@ from .quant_config import DeepseekV4FP8Config
17
if current_platform.is_rocm():
18
from .amd.model import DeepseekV4ForCausalLM
19
from .amd.mtp import DeepSeekV4MTP
20
+
21
+ # DSpark is NVIDIA-only for now.
22
+ DSparkDeepseekV4ForCausalLM = None # type: ignore[assignment]
23
elif current_platform.is_xpu():
24
from .xpu.model import DeepseekV4ForCausalLM # type: ignore[assignment]
25
from .xpu.mtp import DeepSeekV4MTP # type: ignore[assignment]
26
+
27
+ DSparkDeepseekV4ForCausalLM = None # type: ignore[assignment]
28
else:
29
+ from .nvidia.dspark import ( # type: ignore[assignment]
30
+ DSparkDeepseekV4ForCausalLM,
31
+ )
32
from .nvidia.model import DeepseekV4ForCausalLM # type: ignore[assignment]
33
from .nvidia.mtp import DeepSeekV4MTP # type: ignore[assignment]
34
35
__all__ = [
36
+ "DSparkDeepseekV4ForCausalLM",
37
"DeepSeekV4MTP",
38
"DeepseekV4FP8Config",
39
"DeepseekV4ForCausalLM",
vllm/models/deepseek_v4/nvidia/dspark.py
new
+477
@@ -0,0 +1,477 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""DSpark draft model for DeepSeek-V4 (semi-autoregressive speculative decoding).
4
+
5
+See: qwen3_dspark.py for base architecture. This one is specialized to the DSV4 DSpark,
6
+which reuses the target model's architecture similarly to MTP.
7
+
8
+To implement non-causal attention, we leverage the sparse attention implementation to
9
+include the future query tokens in the top-k indices for each query token.
10
+"""
11
+
12
+from collections.abc import Iterable
13
+
14
+import regex as re
15
+import torch
16
+import torch.nn as nn
17
+
18
+from vllm.config import VllmConfig, get_current_vllm_config
19
+from vllm.distributed import (
20
+ get_tensor_model_parallel_rank,
21
+ get_tensor_model_parallel_world_size,
22
+)
23
+from vllm.logger import init_logger
24
+from vllm.model_executor.kernels.mhc.tilelang import (
25
+ hc_head_fused_kernel_tilelang,
26
+ mhc_post_tilelang,
27
+)
28
+from vllm.model_executor.layers.fused_moe import (
29
+ fused_moe_make_expert_params_mapping,
30
+)
31
+from vllm.model_executor.layers.layernorm import RMSNorm
32
+from vllm.model_executor.layers.linear import ReplicatedLinear
33
+from vllm.model_executor.layers.logits_processor import LogitsProcessor
34
+from vllm.model_executor.layers.vocab_parallel_embedding import (
35
+ ParallelLMHead,
36
+ VocabParallelEmbedding,
37
+)
38
+from vllm.model_executor.model_loader.weight_utils import default_weight_loader
39
+from vllm.model_executor.models.qwen3_dspark import (
40
+ DSparkMarkovHead,
41
+)
42
+from vllm.model_executor.models.utils import maybe_prefix
43
+
44
+from .model import (
45
+ DeepseekV4DecoderLayer,
46
+ make_deepseek_v4_expert_params_mapping,
47
+)
48
+
49
+logger = init_logger(__name__)
50
+
51
+# MoE expert scale suffix differs by expert dtype (mirrors deepseek_v4 loaders):
52
+# fp4 experts register ``.weight_scale``; block-fp8 experts ``.weight_scale_inv``.
53
+_EXPERT_SCALE_RE = re.compile(r"\.experts\.\d+\.w[123]\.scale$")
54
+
55
+
56
+class DSparkDeepseekV4Model(nn.Module):
57
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
58
+ super().__init__()
59
+ assert vllm_config.speculative_config is not None
60
+ config = vllm_config.speculative_config.draft_model_config.hf_config
61
+ self.config = config
62
+ self.hidden_size = config.hidden_size
63
+ self.hc_mult = config.hc_mult
64
+ self.hc_eps = config.hc_eps
65
+ self.rms_norm_eps = config.rms_norm_eps
66
+ self.num_hidden_layers = config.num_hidden_layers
67
+ self.target_layer_ids = tuple(config.dspark_target_layer_ids)
68
+
69
+ self.num_dspark_layers = getattr(config, "n_mtp_layers", None) or 3
70
+
71
+ # Shared with the target (aliased by the speculator's loading utility).
72
+ self.embed_tokens = VocabParallelEmbedding(
73
+ config.vocab_size,
74
+ config.hidden_size,
75
+ prefix=maybe_prefix(prefix, "embed_tokens"),
76
+ )
77
+
78
+ self.main_proj = ReplicatedLinear(
79
+ config.hidden_size * len(self.target_layer_ids),
80
+ config.hidden_size,
81
+ bias=False,
82
+ return_bias=False,
83
+ quant_config=vllm_config.quant_config,
84
+ prefix=maybe_prefix(prefix, "main_proj"),
85
+ )
86
+ self.main_norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
87
+
88
+ current_vllm_config = get_current_vllm_config()
89
+ self.layers = nn.ModuleList(
90
+ [
91
+ DeepseekV4DecoderLayer(
92
+ current_vllm_config,
93
+ prefix=maybe_prefix(prefix, f"layers.{self.num_hidden_layers + i}"),
94
+ )
95
+ for i in range(self.num_dspark_layers)
96
+ ]
97
+ )
98
+
99
+ # Heads: final norm + hc_head, and the Markov head
100
+ # Loaded from the "final" MTP layer weights (mtp.*) in the target checkpoint
101
+ self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
102
+ hc_dim = self.hc_mult * config.hidden_size
103
+ self.hc_head_fn = nn.Parameter(
104
+ torch.empty(self.hc_mult, hc_dim, dtype=torch.float32),
105
+ requires_grad=False,
106
+ )
107
+ self.hc_head_base = nn.Parameter(
108
+ torch.empty(self.hc_mult, dtype=torch.float32), requires_grad=False
109
+ )
110
+ self.hc_head_scale = nn.Parameter(
111
+ torch.empty(1, dtype=torch.float32), requires_grad=False
112
+ )
113
+ self.markov_head = DSparkMarkovHead(
114
+ config.vocab_size,
115
+ config.dspark_markov_rank,
116
+ prefix=maybe_prefix(prefix, "markov_head"),
117
+ )
118
+
119
+ def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
120
+ return self.embed_tokens(input_ids)
121
+
122
+ def combine_hidden_states(self, aux_hidden_states: torch.Tensor) -> torch.Tensor:
123
+ """main_x = main_norm(main_proj(concat of target aux hidden states)).
124
+
125
+ ``aux_hidden_states`` is [T, hidden_size * len(target_layer_ids)].
126
+ """
127
+ return self.main_norm(self.main_proj(aux_hidden_states))
128
+
129
+ @torch.inference_mode()
130
+ def precompute_and_store_context_kv(
131
+ self,
132
+ main_x: torch.Tensor,
133
+ context_positions: torch.Tensor,
134
+ context_slot_mappings: list[torch.Tensor | None] | None = None,
135
+ ) -> None:
136
+ """Insert the sliding-window context KV for every draft layer.
137
+
138
+ Mirrors the reference DSparkAttention: each layer derives its context KV
139
+ from the SAME projected target hidden ``main_x``, via that layer's own
140
+ ``wkv`` + ``kv_norm`` + RoPE + quant, then writes it at the
141
+ layer's context slots.
142
+
143
+ ``context_slot_mappings`` is a per-layer list (each entry is the context
144
+ slot mapping for that layer's kv-cache group, since the hybrid manager may
145
+ place draft layers in different groups). ``None`` (or a ``None`` entry)
146
+ runs the projection to reserve workspace but writes nothing (profiling).
147
+ """
148
+ for i, layer in enumerate(self.layers):
149
+ slot_mapping = (
150
+ None if context_slot_mappings is None else context_slot_mappings[i]
151
+ )
152
+ attn = layer.attn
153
+ # Optimized DSV4 MLA path: wkv part of the fused wq_a|wkv projection
154
+ # (q_lora part discarded), then RoPE/quant/insert via the fused op.
155
+ qr_kv, _ = attn.fused_wqa_wkv(main_x)
156
+ kv = qr_kv[..., attn.q_lora_rank :]
157
+ kv = attn.kv_norm(kv)
158
+ if slot_mapping is None:
159
+ continue
160
+ _insert_context_kv(attn, kv, context_positions, slot_mapping)
161
+
162
+ def forward(
163
+ self,
164
+ input_ids: torch.Tensor,
165
+ positions: torch.Tensor,
166
+ inputs_embeds: torch.Tensor | None = None,
167
+ ) -> torch.Tensor:
168
+ if inputs_embeds is None:
169
+ inputs_embeds = self.embed_input_ids(input_ids)
170
+ # Expand to hc_mult copies for hyper-connections ([T, H] -> [T, hc, H]).
171
+ hidden_states = inputs_embeds.unsqueeze(-2).repeat(1, self.hc_mult, 1)
172
+
173
+ residual = post_mix = res_mix = None
174
+ for layer in self.layers:
175
+ hidden_states, residual, post_mix, res_mix = layer(
176
+ hidden_states,
177
+ positions,
178
+ input_ids,
179
+ post_mix,
180
+ res_mix,
181
+ residual,
182
+ )
183
+ hidden_states = mhc_post_tilelang(hidden_states, residual, post_mix, res_mix)
184
+ # hc_head reduces the hc copies; return the PRE-norm head hidden
185
+ hidden_states = hc_head_fused_kernel_tilelang(
186
+ hidden_states,
187
+ self.hc_head_fn,
188
+ self.hc_head_scale,
189
+ self.hc_head_base,
190
+ self.rms_norm_eps,
191
+ self.hc_eps,
192
+ )
193
+ return hidden_states
194
+
195
+
196
+def _insert_context_kv(
197
+ attn: nn.Module,
198
+ kv: torch.Tensor,
199
+ positions: torch.Tensor,
200
+ slot_mapping: torch.Tensor,
201
+) -> None:
202
+ """RoPE + quant + paged-cache insert of (already kv_norm'd) context KV.
203
+
204
+ Reuses the DSV4 fused insert ops (which also process a query; we pass a dummy
205
+ query and discard it, since context tokens have no query). Mirrors
206
+ ``DeepseekV4Attention._fused_qnorm_rope_kv_insert``.
207
+ """
208
+ swa_cache = attn.swa_cache_layer.kv_cache
209
+ block_size = attn.swa_cache_layer.block_size
210
+ cos_sin_cache = attn.rotary_emb.cos_sin_cache
211
+ cache_dtype = swa_cache.dtype
212
+ n_ctx = kv.shape[0]
213
+ dummy_q = torch.zeros(
214
+ (n_ctx, attn.n_local_heads, attn.head_dim),
215
+ dtype=kv.dtype,
216
+ device=kv.device,
217
+ )
218
+ if cache_dtype == torch.uint8:
219
+ # fp8_ds_mla UE8M0 paged layout
220
+ swa_2d = swa_cache.view(swa_cache.shape[0], -1)
221
+ torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert(
222
+ dummy_q,
223
+ kv,
224
+ swa_2d,
225
+ slot_mapping,
226
+ positions,
227
+ cos_sin_cache,
228
+ attn.padded_heads,
229
+ attn.eps,
230
+ block_size,
231
+ )
232
+ elif cache_dtype == torch.bfloat16:
233
+ swa_3d = swa_cache.view(-1, block_size, attn.head_dim)
234
+ torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_bf16_insert(
235
+ dummy_q,
236
+ kv,
237
+ swa_3d,
238
+ slot_mapping,
239
+ positions,
240
+ cos_sin_cache,
241
+ attn.eps,
242
+ block_size,
243
+ )
244
+ else: # per-tensor fp8 (torch.float8_e4m3fn)
245
+ # TODO(ben): double-check if this is being dispatched correctly for FI backend
246
+ swa_3d = swa_cache.view(-1, block_size, attn.head_dim)
247
+ dummy_q_fp8 = torch.zeros_like(dummy_q, dtype=torch.float8_e4m3fn)
248
+ torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_fp8_insert(
249
+ dummy_q,
250
+ kv,
251
+ dummy_q_fp8,
252
+ swa_3d,
253
+ slot_mapping,
254
+ positions,
255
+ cos_sin_cache,
256
+ attn._flashinfer_fp8_kv_scale,
257
+ attn._flashinfer_fp8_q_scale_inv,
258
+ attn.eps,
259
+ block_size,
260
+ )
261
+
262
+
263
+class DSparkDeepseekV4ForCausalLM(nn.Module):
264
+ # Draft weights ship in the target checkpoint (mtp.*) without embed/head, so
265
+ # load_dspark_model always aliases the target's.
266
+ has_own_embed_tokens = False
267
+ has_own_lm_head = False
268
+
269
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
270
+ super().__init__()
271
+ assert vllm_config.speculative_config is not None
272
+ self.draft_model_config = vllm_config.speculative_config.draft_model_config
273
+ self.config = self.draft_model_config.hf_config
274
+ self.model = DSparkDeepseekV4Model(
275
+ vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
276
+ )
277
+ # Shared with the target (aliased by the speculator's load utility).
278
+ self.lm_head = ParallelLMHead(
279
+ self.config.vocab_size,
280
+ self.config.hidden_size,
281
+ prefix=maybe_prefix(prefix, "lm_head"),
282
+ )
283
+ self.logits_processor = LogitsProcessor(self.config.vocab_size)
284
+
285
+ # --- Hooks used by the speculator -------------------------------------
286
+
287
+ def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
288
+ return self.model.embed_input_ids(input_ids)
289
+
290
+ def combine_hidden_states(self, aux_hidden_states: torch.Tensor) -> torch.Tensor:
291
+ return self.model.combine_hidden_states(aux_hidden_states)
292
+
293
+ def get_draft_kv_cache_layer_names(self) -> list[str]:
294
+ # DSV4 MLA path: each draft layer's sliding-window cache is a separate
295
+ # layer, named by its prefix.
296
+ return [layer.attn.swa_cache_layer.prefix for layer in self.model.layers]
297
+
298
+ def precompute_and_store_context_kv(
299
+ self,
300
+ context_states: torch.Tensor,
301
+ context_positions: torch.Tensor,
302
+ context_slot_mappings: list[torch.Tensor | None] | None = None,
303
+ ) -> None:
304
+ self.model.precompute_and_store_context_kv(
305
+ context_states, context_positions, context_slot_mappings
306
+ )
307
+
308
+ def forward(
309
+ self,
310
+ input_ids: torch.Tensor,
311
+ positions: torch.Tensor,
312
+ inputs_embeds: torch.Tensor | None = None,
313
+ ) -> torch.Tensor:
314
+ # Returns the pre-norm hc_head hidden ([T, hidden_size]).
315
+ return self.model(input_ids, positions, inputs_embeds)
316
+
317
+ def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor:
318
+ """Base logits U_k = lm_head(norm(head_hidden))."""
319
+ return self.logits_processor(self.lm_head, self.model.norm(hidden_states))
320
+
321
+ def markov_embed(self, token_ids: torch.Tensor) -> torch.Tensor:
322
+ return self.model.markov_head.embed(token_ids)
323
+
324
+ def markov_bias(self, markov_embed: torch.Tensor) -> torch.Tensor:
325
+ return self.model.markov_head.bias(markov_embed, self.logits_processor)
326
+
327
+ # --- Weight loading ----------------------------------------------------
328
+
329
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
330
+ """Load the ``mtp.{0,1,2}.*`` draft weights from the target checkpoint.
331
+
332
+ Non-mtp weights (embed/head/main layers) belong to the target model and
333
+ are skipped here. ``embed_tokens``/``lm_head`` are aliased from the target.
334
+ """
335
+ first_layer = self.model.layers[0]
336
+ use_mega_moe = first_layer.ffn.use_mega_moe
337
+ if use_mega_moe:
338
+ expert_mapping = make_deepseek_v4_expert_params_mapping(
339
+ self.config.n_routed_experts
340
+ )
341
+ else:
342
+ expert_mapping = fused_moe_make_expert_params_mapping(
343
+ self,
344
+ ckpt_gate_proj_name="w1",
345
+ ckpt_down_proj_name="w2",
346
+ ckpt_up_proj_name="w3",
347
+ num_experts=self.config.n_routed_experts,
348
+ )
349
+ expert_scale_suffix = (
350
+ ".weight_scale"
351
+ if getattr(self.config, "expert_dtype", "fp4") == "fp4"
352
+ else ".weight_scale_inv"
353
+ )
354
+
355
+ # (param_name, ckpt_shard_name, shard_id) for non-expert stacked params.
356
+ stacked_params_mapping = [
357
+ ("gate_up_proj", "w1", 0),
358
+ ("gate_up_proj", "w3", 1),
359
+ ("attn.fused_wqa_wkv", "attn.wq_a", 0),
360
+ ("attn.fused_wqa_wkv", "attn.wkv", 1),
361
+ ]
362
+
363
+ params_dict = dict(self.named_parameters())
364
+ loaded_params: set[str] = set()
365
+
366
+ tp_size = get_tensor_model_parallel_world_size()
367
+ tp_rank = get_tensor_model_parallel_rank()
368
+ n_local_head = self.config.num_attention_heads // tp_size
369
+ head_start = n_local_head * tp_rank
370
+ head_end = n_local_head * (tp_rank + 1)
371
+
372
+ for name, loaded_weight in weights:
373
+ mapped = self._remap_dspark_name(name)
374
+ if mapped is None:
375
+ continue
376
+ name = mapped
377
+
378
+ # ``.scale`` -> per-method scale suffix.
379
+ if name.endswith(".scale"):
380
+ suffix = (
381
+ expert_scale_suffix
382
+ if _EXPERT_SCALE_RE.search(name)
383
+ else ".weight_scale_inv"
384
+ )
385
+ name = name.removesuffix(".scale") + suffix
386
+
387
+ # E8M0 expert scales: keep raw exponent bytes.
388
+ if ".experts." in name:
389
+ if (
390
+ "weight_scale" in name
391
+ and loaded_weight.dtype == torch.float8_e8m0fnu
392
+ ):
393
+ loaded_weight = loaded_weight.view(torch.uint8)
394
+ for param_name, weight_name, expert_id, shard_id in expert_mapping:
395
+ if weight_name not in name:
396
+ continue
397
+ name_mapped = name.replace(weight_name, param_name)
398
+ param = params_dict[name_mapped]
399
+ success = param.weight_loader(
400
+ param,
401
+ loaded_weight,
402
+ name_mapped,
403
+ shard_id=shard_id,
404
+ expert_id=expert_id,
405
+ return_success=True,
406
+ )
407
+ if success:
408
+ loaded_params.add(name_mapped)
409
+ break
410
+ continue
411
+
412
+ # Stacked rules only apply to decoder-layer weights. Head-stack params
413
+ # (main_proj/norm/hc_head/markov_head) load directly — otherwise e.g.
414
+ # "markov_w1" would collide with the "w1" shard rule.
415
+ is_layer_param = name.startswith("model.layers.")
416
+ for param_name, weight_name, stacked_shard_id in stacked_params_mapping:
417
+ if not is_layer_param or weight_name not in name:
418
+ continue
419
+ name = name.replace(weight_name, param_name)
420
+ param = params_dict[name]
421
+ param.weight_loader(param, loaded_weight, stacked_shard_id)
422
+ loaded_params.add(name)
423
+ break
424
+ else:
425
+ if "attn_sink" in name:
426
+ narrow = loaded_weight[head_start:head_end]
427
+ params_dict[name][: narrow.shape[0]].copy_(narrow)
428
+ loaded_params.add(name)
429
+ continue
430
+ if ".shared_experts.w2" in name:
431
+ name = name.replace(
432
+ ".shared_experts.w2", ".shared_experts.down_proj"
433
+ )
434
+ if name.endswith(".ffn.gate.bias"):
435
+ name = name.replace(
436
+ ".ffn.gate.bias", ".ffn.gate.e_score_correction_bias"
437
+ )
438
+ param = params_dict[name]
439
+ weight_loader = getattr(param, "weight_loader", default_weight_loader)
440
+ weight_loader(param, loaded_weight)
441
+ loaded_params.add(name)
442
+
443
+ self._finalize_moe()
444
+ logger.info_once("DSpark draft model loaded: %d params", len(loaded_params))
445
+ return loaded_params
446
+
447
+ def _finalize_moe(self) -> None:
448
+ for layer in self.model.layers:
449
+ layer.ffn.finalize_mega_moe_weights()
450
+
451
+ def _remap_dspark_name(self, name: str) -> str | None:
452
+ """Map a checkpoint ``mtp.{i}.*`` name to this model's parameter path.
453
+
454
+ Returns None for non-mtp weights (owned by the target model).
455
+ """
456
+ m = re.match(r"mtp\.(\d+)\.(.*)", name)
457
+ if m is None:
458
+ return None
459
+ stage = int(m.group(1))
460
+ rest = m.group(2)
461
+ # The confidence head is not wired into inference yet; drop its weights.
462
+ if rest.startswith("confidence_head."):
463
+ return None
464
+ # Head-stack params live at model level (mtp.last), context combiner at
465
+ # model level (mtp.0); everything else is a per-layer decoder block.
466
+ head_prefixes = (
467
+ "norm.",
468
+ "hc_head_fn",
469
+ "hc_head_base",
470
+ "hc_head_scale",
471
+ "markov_head.",
472
+ )
473
+ if rest.startswith(("main_proj.", "main_norm.")) or rest.startswith(
474
+ head_prefixes
475
+ ):
476
+ return f"model.{rest}"
477
+ return f"model.layers.{stage}.{rest}"
vllm/models/deepseek_v4/nvidia/model.py
+32
-7
@@ -48,7 +48,12 @@ from vllm.model_executor.layers.vocab_parallel_embedding import (
48
VocabParallelEmbedding,
49
)
50
from vllm.model_executor.model_loader.weight_utils import default_weight_loader
51
-from vllm.model_executor.models.interfaces import MixtureOfExperts, SupportsPP
51
+from vllm.model_executor.models.interfaces import (
52
+ EagleModelMixin,
53
+ MixtureOfExperts,
54
+ SupportsEagle3,
55
+ SupportsPP,
56
+)
57
from vllm.model_executor.models.utils import (
58
AutoWeightsLoader,
59
PPMissingLayer,
@@ -933,7 +938,7 @@ class DeepseekV4DecoderLayer(nn.Module):
938
return x, residual, post_mix, res_mix
939
940
936
-class DeepseekV4Model(nn.Module):
941
+class DeepseekV4Model(nn.Module, EagleModelMixin):
942
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
943
super().__init__()
944
@@ -1074,7 +1079,12 @@ class DeepseekV4Model(nn.Module):
1079
input_ids = input_ids.to(torch.int64)
1080
1081
residual, post_mix, res_mix = None, None, None
1077
- for layer in islice(self.layers, self.start_layer, self.end_layer):
1082
+ aux_hidden_states: list[torch.Tensor] = []
1083
+ final_aux_recon: torch.Tensor | None = None # avoid duplicate mhc_post call
1084
+ for idx, layer in enumerate(
1085
+ islice(self.layers, self.start_layer, self.end_layer),
1086
+ start=self.start_layer,
1087
+ ):
1088
hidden_states, residual, post_mix, res_mix = layer(
1089
hidden_states,
1090
positions,
@@ -1083,10 +1093,21 @@ class DeepseekV4Model(nn.Module):
1093
res_mix,
1094
residual,
1095
)
1096
+ if idx + 1 in self.aux_hidden_state_layers:
1097
+ # Reconstruct the aux hidden state for draft models
1098
+ aux_recon = mhc_post_tilelang(
1099
+ hidden_states, residual, post_mix, res_mix
1100
+ )
1101
+ aux_hidden_states.append(aux_recon.mean(dim=1))
1102
+ final_aux_recon = aux_recon
1103
if layer is not None:
1087
- hidden_states = mhc_post_tilelang(
1088
- hidden_states, residual, post_mix, res_mix
1089
- )
1104
+ # Reuse if the last layer was captured as an aux hidden state
1105
+ if self.end_layer in self.aux_hidden_state_layers:
1106
+ hidden_states = final_aux_recon
1107
+ else:
1108
+ hidden_states = mhc_post_tilelang(
1109
+ hidden_states, residual, post_mix, res_mix
1110
+ )
1111
1112
if not get_pp_group().is_last_rank:
1113
return IntermediateTensors({"hidden_states": hidden_states})
@@ -1104,6 +1125,8 @@ class DeepseekV4Model(nn.Module):
1125
self.hc_eps,
1126
)
1127
hidden_states = self.norm(hidden_states)
1128
+ if len(aux_hidden_states) > 0:
1129
+ return hidden_states, aux_hidden_states
1130
return hidden_states
1131
1132
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
@@ -1330,7 +1353,9 @@ class DeepseekV4MixtureOfExperts(MixtureOfExperts):
1353
moe.experts.update_expert_map()
1354
1355
1333
-class DeepseekV4ForCausalLM(nn.Module, SupportsPP, DeepseekV4MixtureOfExperts):
1356
+class DeepseekV4ForCausalLM(
1357
+ nn.Module, SupportsPP, SupportsEagle3, DeepseekV4MixtureOfExperts
1358
+):
1359
model_cls = DeepseekV4Model
1360
1361
# Default mapper assumes the original FP4-expert checkpoint layout.
vllm/v1/attention/backends/mla/sparse_swa.py
+139
-23
@@ -306,16 +306,19 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
306
)
307
308
# Handle MTP: adjust decode_threshold like the indexer does
309
+ spec_config = self.vllm_config.speculative_config
310
self.num_speculative_tokens = (
310
- self.vllm_config.speculative_config.num_speculative_tokens
311
- if self.vllm_config.speculative_config
312
- else 0
311
+ spec_config.num_speculative_tokens if spec_config else 0
312
+ )
313
+ # Decode can have query_len up to
314
+ # 1 + (2 if parallel drafting else 1) * num_speculative_tokens.
315
+ # This MUST match the flashmla_sparse / indexer threshold so that
316
+ # all backends agree on the decode/prefill split.
317
+ spec_mult = (
318
+ 2 if (spec_config is not None and spec_config.parallel_drafting) else 1
319
)
314
- # With MTP, decode can have query_len up to 1 + num_speculative_tokens.
315
- # Must match the threshold used by the indexer and flashmla_sparse so
316
- # that all backends agree on the decode/prefill split.
320
self.decode_threshold = (
318
- self.reorder_batch_threshold + self.num_speculative_tokens
321
+ self.reorder_batch_threshold + spec_mult * self.num_speculative_tokens
322
)
323
324
hf_config = self.vllm_config.model_config.hf_config
@@ -368,6 +371,20 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
371
device=self.device,
372
)
373
374
+ # DSpark draft: the block is non-causal (every query attends to the
375
+ # trailing window of context PLUS all query tokens, including future ones),
376
+ # so its per-token index list is wider than `window_size`. The kernel pads
377
+ # the q-head count to B_TOPK (64/128), which requires the index width to be
378
+ # a multiple of 128.
379
+ self.is_dspark = spec_config is not None and spec_config.use_dspark()
380
+ self.noncausal_index_width = (
381
+ cdiv(self.window_size + self.num_speculative_tokens, 128) * 128
382
+ if self.is_dspark
383
+ else 0
384
+ )
385
+ self.decode_swa_indices_noncausal: torch.Tensor | None = None
386
+ self._max_tokens = max_tokens
387
+
388
def build(
389
self,
390
common_prefix_len: int,
@@ -407,23 +424,56 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
424
is_valid_token = self.is_valid_token[: slot_mapping.shape[0]]
425
is_valid_token.copy_(slot_mapping >= 0)
426
427
+ non_causal = not common_attn_metadata.causal
428
+ decode_swa_indices = self.decode_swa_indices
429
if num_decode_tokens > 0:
430
self.decode_swa_lens[num_decode_tokens:] = 0
412
- _compute_swa_indices_and_lens_kernel[(num_decode_tokens,)](
413
- self.decode_swa_indices,
414
- self.decode_swa_indices.stride(0),
415
- self.decode_swa_lens,
416
- self.window_size,
417
- query_start_loc,
418
- seq_lens,
419
- token_to_req_indices,
420
- is_valid_token,
421
- block_table,
422
- block_table.stride(0),
423
- self.block_size,
424
- token_offset=0,
425
- TRITON_BLOCK_SIZE=1024,
426
- )
431
+ if non_causal:
432
+ assert self.is_dspark, (
433
+ "Non-causal DeepseekV4 SWA is only supported for the DSpark "
434
+ "speculation mode, but causal=False was set without DSpark."
435
+ )
436
+ if self.decode_swa_indices_noncausal is None:
437
+ self.decode_swa_indices_noncausal = torch.zeros(
438
+ self._max_tokens,
439
+ 1,
440
+ self.noncausal_index_width,
441
+ dtype=torch.int32,
442
+ device=self.device,
443
+ )
444
+ decode_swa_indices = self.decode_swa_indices_noncausal
445
+ _compute_dspark_noncausal_swa_indices_kernel[(num_decode_tokens,)](
446
+ decode_swa_indices,
447
+ decode_swa_indices.stride(0),
448
+ self.decode_swa_lens,
449
+ self.window_size,
450
+ self.noncausal_index_width,
451
+ query_start_loc,
452
+ seq_lens,
453
+ token_to_req_indices,
454
+ is_valid_token,
455
+ block_table,
456
+ block_table.stride(0),
457
+ self.block_size,
458
+ token_offset=0,
459
+ TRITON_BLOCK_SIZE=1024,
460
+ )
461
+ else:
462
+ _compute_swa_indices_and_lens_kernel[(num_decode_tokens,)](
463
+ decode_swa_indices,
464
+ decode_swa_indices.stride(0),
465
+ self.decode_swa_lens,
466
+ self.window_size,
467
+ query_start_loc,
468
+ seq_lens,
469
+ token_to_req_indices,
470
+ is_valid_token,
471
+ block_table,
472
+ block_table.stride(0),
473
+ self.block_size,
474
+ token_offset=0,
475
+ TRITON_BLOCK_SIZE=1024,
476
+ )
477
478
# Prefill SWA indices live in paged coordinates. `token_offset` lets
479
# the kernel read is_valid_token / token_to_req_indices at absolute
@@ -471,7 +521,7 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
521
slot_mapping=slot_mapping,
522
is_valid_token=is_valid_token,
523
token_to_req_indices=token_to_req_indices,
474
- decode_swa_indices=self.decode_swa_indices[:num_decode_tokens],
524
+ decode_swa_indices=decode_swa_indices[:num_decode_tokens],
525
decode_swa_lens=self.decode_swa_lens[:num_decode_tokens],
526
prefill_swa_indices=(
527
self.prefill_swa_indices[:num_prefill_tokens]
@@ -665,3 +715,69 @@ def _compute_swa_indices_and_lens_kernel(
715
slot_ids,
716
mask=offset < window_size,
717
)
718
+
719
+
720
+# TODO(ben): unify this kernel to reduce duplication
721
+@triton.jit(do_not_specialize=["token_offset"])
722
+def _compute_dspark_noncausal_swa_indices_kernel(
723
+ swa_indices_ptr,
724
+ swa_indices_stride,
725
+ swa_lens_ptr,
726
+ window_size,
727
+ index_width,
728
+ query_start_loc_ptr,
729
+ seq_lens_ptr,
730
+ token_to_req_indices_ptr,
731
+ is_valid_token_ptr,
732
+ block_table_ptr,
733
+ block_table_stride,
734
+ block_size,
735
+ token_offset,
736
+ TRITON_BLOCK_SIZE: tl.constexpr,
737
+):
738
+ """Non-causal per-token indices for the DSpark draft block.
739
+
740
+ Here, we populate the topk indices with the trailing window of context tokens,
741
+ plus all query tokens (including future ones).
742
+ """
743
+ pid = tl.program_id(0)
744
+ token_idx = pid + token_offset
745
+ is_valid = tl.load(is_valid_token_ptr + token_idx)
746
+ if not is_valid:
747
+ tl.store(swa_lens_ptr + pid, 0)
748
+ return
749
+
750
+ req_idx = tl.load(token_to_req_indices_ptr + token_idx)
751
+
752
+ query_start = tl.load(query_start_loc_ptr + req_idx)
753
+ query_end = tl.load(query_start_loc_ptr + req_idx + 1)
754
+ query_len = query_end - query_start
755
+
756
+ seq_len = tl.load(seq_lens_ptr + req_idx)
757
+ prefix_len = seq_len - query_len
758
+
759
+ # Block-anchored window (shared by every token in the block) + full block.
760
+ start_pos = tl.maximum(prefix_len - window_size, 0)
761
+ end_pos = seq_len
762
+
763
+ swa_len = end_pos - start_pos
764
+ tl.store(swa_lens_ptr + pid, swa_len)
765
+
766
+ for i in range(0, index_width, TRITON_BLOCK_SIZE):
767
+ offset = i + tl.arange(0, TRITON_BLOCK_SIZE)
768
+
769
+ pos_offset = start_pos + offset
770
+ block_indices = pos_offset // block_size
771
+ block_numbers = tl.load(
772
+ block_table_ptr + req_idx * block_table_stride + block_indices,
773
+ mask=pos_offset < end_pos,
774
+ )
775
+ block_offsets = pos_offset % block_size
776
+ slot_ids = block_numbers * block_size + block_offsets
777
+
778
+ slot_ids = tl.where(offset < swa_len, slot_ids, -1)
779
+ tl.store(
780
+ swa_indices_ptr + pid * swa_indices_stride + offset,
781
+ slot_ids,
782
+ mask=offset < index_width,
783
+ )
vllm/v1/core/sched/scheduler.py
+5
@@ -247,6 +247,11 @@ class Scheduler(SchedulerInterface):
247
# decoding instead of standard next-token sampling, so it has a query
248
# for the last sampled token plus queries for each draft token.
249
self.num_lookahead_tokens = self.num_spec_tokens + 1
250
+ if speculative_config.use_dspark():
251
+ # DSpark drafts a block of num_spec_tokens query tokens in which the
252
+ # anchor itself is the first prediction position (no separate bonus
253
+ # query), so it needs exactly num_spec_tokens lookahead slots.
254
+ self.num_lookahead_tokens = self.num_spec_tokens
255
256
# Create the KV cache manager.
257
if hash_block_size is None:
vllm/v1/worker/gpu/model_runner.py
+1
-1
@@ -193,7 +193,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
193
if self.is_last_pp_rank:
194
self.speculator = init_speculator(self.vllm_config, self.device)
195
196
- if self.speculative_config.method in ("eagle3", "dflash"):
196
+ if self.speculative_config.method in ("eagle3", "dflash", "dspark"):
197
# Drafting may require auxiliary hidden states from target model outputs
198
self.use_aux_hidden_state_outputs = True
199
if self.use_pp:
vllm/v1/worker/gpu/sample/gumbel.py
+5
@@ -223,6 +223,11 @@ def gumbel_sample(
223
output_processed_logits_col: torch.Tensor | None = None,
224
use_fp64: bool = False,
225
) -> torch.Tensor:
226
+ # Enforce contiguity on non-strided input tensors
227
+ expanded_idx_mapping = expanded_idx_mapping.contiguous()
228
+ pos = pos.contiguous()
229
+ if output_processed_logits_col is not None:
230
+ output_processed_logits_col = output_processed_logits_col.contiguous()
231
num_tokens, vocab_size = logits.shape
232
BLOCK_SIZE = 1024
233
num_blocks = triton.cdiv(vocab_size, BLOCK_SIZE)
vllm/v1/worker/gpu/spec_decode/__init__.py
+6
@@ -14,6 +14,12 @@ def init_speculator(vllm_config: VllmConfig, device: torch.device):
14
)
15
16
return DFlashSpeculator(vllm_config, device)
17
+ elif speculative_config.method == "dspark":
18
+ from vllm.v1.worker.gpu.spec_decode.dspark.speculator import (
19
+ DSparkSpeculator,
20
+ )
21
+
22
+ return DSparkSpeculator(vllm_config, device)
23
elif speculative_config.use_gemma4_mtp():
24
from vllm.v1.worker.gpu.spec_decode.gemma4.speculator import (
25
Gemma4Speculator,
vllm/v1/worker/gpu/spec_decode/dflash/speculator.py
+98
-49
@@ -29,6 +29,8 @@ logger = init_logger(__name__)
29
30
31
class DFlashSpeculator(DraftModelSpeculator):
32
+ _speculator_name = "DFlash" # For logging, so we can share methods with subclasses
33
+
34
def __init__(self, vllm_config: VllmConfig, device: torch.device):
35
super().__init__(vllm_config, device)
36
@@ -48,15 +50,17 @@ class DFlashSpeculator(DraftModelSpeculator):
50
51
self.dflash_causal = get_dflash_causal(self.draft_model_config)
52
51
- # Buffers for context K/V precomputation. Populated by prepare_dflash_inputs,
52
- # and processed by the model's precompute_and_store_context_kv method.
53
- # NOT captured by CUDA graphs.
53
+ # Whether the anchor query position is itself a prediction. DFlash default uses
54
+ # the anchor as the bonus token (only mask tokens predict); DSpark samples from
55
+ # the anchor and the N-1 mask token positions. See _prepare_dflash_inputs_kernel
56
+ self.sample_from_anchor = False
57
+
58
+ # Context positions for the K/V precompute. Populated by
59
+ # prepare_dflash_inputs, and processed by the model's
60
+ # precompute_and_store_context_kv method. NOT captured by CUDA graphs.
61
self.context_positions = torch.zeros(
62
self.max_num_tokens, dtype=torch.int64, device=device
63
)
57
- self.context_slot_mapping = torch.zeros(
58
- self.max_num_tokens, dtype=torch.int64, device=device
59
- )
64
65
# Per-mask-token sampling buffers. Flattened from (num_reqs, num_spec_tokens).
66
max_num_sampled_tokens = self.max_num_reqs * self.num_speculative_steps
@@ -94,7 +98,7 @@ class DFlashSpeculator(DraftModelSpeculator):
98
)
99
100
def capture(self, attn_states: dict | None = None) -> None:
97
- logger.info("Capturing model for DFlash speculator...")
101
+ logger.info("Capturing model for %s speculator...", self._speculator_name)
102
# Reset sampling indices to zero to prevent stale values from prior
103
# dummy runs from being baked into the captured graph.
104
self.sample_indices.zero_()
@@ -108,7 +112,7 @@ class DFlashSpeculator(DraftModelSpeculator):
112
self.attn_groups,
113
self.kv_cache_config,
114
self.max_model_len,
111
- progress_bar_desc="Capturing dflash CUDA graphs",
115
+ progress_bar_desc=f"Capturing {self._speculator_name.lower()} CUDA graphs",
116
)
117
118
def load_draft_model(
@@ -126,18 +130,39 @@ class DFlashSpeculator(DraftModelSpeculator):
130
) -> None:
131
super().set_attn(model_state, kv_cache_config, block_tables)
132
129
- # DFlash precomputes context K/V with a single block_size; mixing
130
- # kv-cache groups would silently corrupt the cache for the non-matching group.
131
- draft_groups = [gid for gid, g in enumerate(self.attn_groups) if g]
132
- assert len(draft_groups) == 1, (
133
- "DFlash currently requires all draft attention layers to share "
134
- "a single kv-cache group."
135
- )
136
- self.draft_kv_cache_group_id = draft_groups[0]
133
+ self.draft_kv_cache_group_ids = [
134
+ gid for gid, g in enumerate(self.attn_groups) if g
135
+ ]
136
+ assert self.draft_kv_cache_group_ids, "No draft attention groups found."
137
+ self.draft_kv_cache_group_id = self.draft_kv_cache_group_ids[0]
138
self.draft_block_size = self.block_tables.block_sizes[
139
self.draft_kv_cache_group_id
140
]
141
142
+ # Per-group context slot buffers for the precompute (one row per group).
143
+ self._context_slot_mappings = torch.zeros(
144
+ len(self.draft_kv_cache_group_ids),
145
+ self.max_num_tokens,
146
+ dtype=torch.int64,
147
+ device=self.device,
148
+ )
149
+
150
+ # Map each draft decoder layer to the index (within draft_kv_cache_group_ids)
151
+ # of the kv-cache group its cache belongs to. Models that share a single group
152
+ # leave this as None and share one context slot mapping.
153
+ self._layer_group_idx: list[int] | None = None
154
+ if hasattr(self.model, "get_draft_kv_cache_layer_names"):
155
+ name_to_gid = {
156
+ ln: gid
157
+ for gid, group in enumerate(kv_cache_config.kv_cache_groups)
158
+ for ln in group.layer_names
159
+ }
160
+ gid_to_idx = {gid: i for i, gid in enumerate(self.draft_kv_cache_group_ids)}
161
+ self._layer_group_idx = [
162
+ gid_to_idx[name_to_gid[name]]
163
+ for name in self.model.get_draft_kv_cache_layer_names()
164
+ ]
165
+
166
@torch.inference_mode()
167
def _run_model(
168
self,
@@ -183,9 +208,11 @@ class DFlashSpeculator(DraftModelSpeculator):
208
209
num_sample = num_reqs * self.num_speculative_steps
210
sample_hidden_states = last_hidden_states[self.sample_indices[:num_sample]]
211
+ # sample_pos is the predicted token's position Q; verification keys
212
+ # Gumbel by the predecessor (Q-1). sample_draft adds +1, so pass Q-2.
213
draft_tokens = self.sample_draft(
214
sample_hidden_states,
188
- self.sample_pos[:num_sample],
215
+ self.sample_pos[:num_sample] - 2,
216
self.sample_idx_mapping[:num_sample],
217
self.temperature,
218
self.seeds,
@@ -294,40 +321,49 @@ class DFlashSpeculator(DraftModelSpeculator):
321
# The query slot mapping is written into the shared BlockTables slot_mappings.
322
# That buffer's address is what the captured CUDA graph reads from at replay.
323
assert self.draft_kv_cache_group_id >= 0
297
- query_slot_mapping = self.block_tables.slot_mappings[
298
- self.draft_kv_cache_group_id
299
- ]
300
- prepare_dflash_inputs(
301
- self.input_buffers,
302
- query_slot_mapping,
303
- self.context_positions,
304
- self.context_slot_mapping,
305
- self.sample_indices,
306
- self.sample_pos,
307
- self.sample_idx_mapping,
308
- input_batch,
309
- num_sampled,
310
- num_rejected,
311
- last_sampled,
312
- next_prefill_tokens,
313
- self.block_tables.input_block_tables[self.draft_kv_cache_group_id],
314
- self.draft_block_size,
315
- self.parallel_drafting_token_id,
316
- self.num_query_per_req,
317
- self.num_speculative_steps,
318
- self.max_num_reqs,
319
- self.max_num_tokens,
320
- )
324
+ # Support multiple draft KV cache groups by preparing inputs once for each
325
+ for i, gid in enumerate(self.draft_kv_cache_group_ids):
326
+ prepare_dflash_inputs(
327
+ self.input_buffers,
328
+ self.block_tables.slot_mappings[gid],
329
+ self.context_positions,
330
+ self._context_slot_mappings[i],
331
+ self.sample_indices,
332
+ self.sample_pos,
333
+ self.sample_idx_mapping,
334
+ input_batch,
335
+ num_sampled,
336
+ num_rejected,
337
+ last_sampled,
338
+ next_prefill_tokens,
339
+ self.block_tables.input_block_tables[gid],
340
+ self.block_tables.block_sizes[gid],
341
+ self.parallel_drafting_token_id,
342
+ self.num_query_per_req,
343
+ self.num_speculative_steps,
344
+ self.max_num_reqs,
345
+ self.max_num_tokens,
346
+ self.max_model_len,
347
+ self.sample_from_anchor,
348
+ )
349
350
# Pre-insert context K/V into the cache. Runs eagerly outside the captured graph
351
# because the context shape varies per step. During dummy runs the block tables
352
# are placeholders, so we skip the cache write to avoid clobbering real entries.
353
+ # Each layer uses the context slots of its own kv-cache group.
354
+ if dummy_run:
355
+ context_slots: torch.Tensor | list[torch.Tensor | None] | None = None
356
+ elif self._layer_group_idx is not None:
357
+ context_slots = [
358
+ self._context_slot_mappings[gidx][:num_target_tokens]
359
+ for gidx in self._layer_group_idx
360
+ ]
361
+ else:
362
+ context_slots = self._context_slot_mappings[0][:num_target_tokens]
363
self.model.precompute_and_store_context_kv(
364
self.hidden_states[:num_target_tokens],
365
self.context_positions[:num_target_tokens],
328
- context_slot_mapping=(
329
- None if dummy_run else self.context_slot_mapping[:num_target_tokens]
330
- ),
366
+ context_slots,
367
)
368
369
# Every DFlash step has exactly num_query_per_req tokens, so we can use FULL CGs
@@ -408,6 +444,8 @@ def _prepare_dflash_inputs_kernel(
444
num_speculative_steps,
445
max_num_reqs,
446
max_num_tokens,
447
+ max_model_len,
448
+ SAMPLE_FROM_ANCHOR: tl.constexpr,
449
PAD_SLOT_ID: tl.constexpr,
450
BLOCK_SIZE: tl.constexpr,
451
):
@@ -468,14 +506,21 @@ def _prepare_dflash_inputs_kernel(
506
q_slot = q_block_id * block_size + (query_pos % block_size)
507
508
tl.store(out_input_ids_ptr + query_idx, input_id, mask=is_query)
471
- tl.store(out_query_positions_ptr + query_idx, query_pos, mask=is_query)
509
+ clamped_query_pos = tl.minimum(query_pos, max_model_len - 1)
510
+ tl.store(out_query_positions_ptr + query_idx, clamped_query_pos, mask=is_query)
511
tl.store(out_query_slot_mapping_ptr + query_idx, q_slot, mask=is_query)
512
474
- # --- Sample indices / positions / idx_mapping (mask tokens only) ---
475
- is_sample = is_query & (query_off > 0)
476
- sample_idx = req_idx * num_speculative_steps + (query_off - 1)
513
+ # --- Sample indices / positions / idx_mapping ---
514
+ # When SAMPLE_FROM_ANCHOR (DSpark), so we sample at EVERY query position
515
+ # and each position k predicts the NEXT token (sampled position = query_pos + 1).
516
+ # Otherwise (DFlash default) the anchor is the bonus token and only the mask tokens
517
+ # at offsets > 0 are sampled from, each AT its own position.
518
+ sample_off = 0 if SAMPLE_FROM_ANCHOR else 1
519
+ is_sample = is_query & (query_off >= sample_off)
520
+ sample_idx = req_idx * num_speculative_steps + (query_off - sample_off)
521
+ sample_pos = query_pos + 1 if SAMPLE_FROM_ANCHOR else query_pos
522
tl.store(out_sample_indices_ptr + sample_idx, query_idx, mask=is_sample)
478
- tl.store(out_sample_pos_ptr + sample_idx, query_pos, mask=is_sample)
523
+ tl.store(out_sample_pos_ptr + sample_idx, sample_pos, mask=is_sample)
524
tl.store(out_sample_idx_mapping_ptr + sample_idx, req_state_idx, mask=is_sample)
525
526
if block_idx == 0:
@@ -542,6 +587,8 @@ def prepare_dflash_inputs(
587
num_speculative_steps: int,
588
max_num_reqs: int,
589
max_num_tokens: int,
590
+ max_model_len: int,
591
+ sample_from_anchor: bool = False,
592
) -> None:
593
num_reqs = input_batch.num_reqs
594
assert num_reqs > 0
@@ -577,6 +624,8 @@ def prepare_dflash_inputs(
624
num_speculative_steps,
625
max_num_reqs,
626
max_num_tokens,
627
+ max_model_len,
628
+ SAMPLE_FROM_ANCHOR=sample_from_anchor,
629
PAD_SLOT_ID=PAD_SLOT_ID,
630
BLOCK_SIZE=BLOCK_SIZE,
631
)
vllm/v1/worker/gpu/spec_decode/dspark/__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/dspark/speculator.py
new
+133
@@ -0,0 +1,133 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""DSpark speculator: semi-autoregressive parallel drafting.
4
+
5
+DSpark drafts a block of ``num_speculative_tokens`` tokens in one parallel pass
6
+(reusing the DFlash machinery: context-KV precompute + a query-block forward),
7
+then injects intra-block dependency with a lightweight sequential Markov head.
8
+
9
+Differences from DFlash:
10
+ * Anchor-as-first-prediction: each request emits exactly ``N =
11
+ num_speculative_tokens`` query tokens (anchor + N-1 noise), NOT ``1 + N``.
12
+ Every query position is a prediction (the anchor predicts the first draft
13
+ token), so we sample at all N positions and ``sample_pos = query_pos + 1``
14
+ (standard next-token), whereas DFlash's masks sit AT the predicted position.
15
+ This is the ``sample_from_anchor`` path in the shared prepare-inputs kernel.
16
+ * Sequential Markov sampling: instead of DFlash's single parallel sample, we
17
+ sample left-to-right, adding a prefix-dependent Markov bias derived from the
18
+ previously sampled token at each step.
19
+
20
+CUDA graphs (FULL, mirroring DFlash) cover the whole draft step: the parallel
21
+backbone forward AND the sequential Markov sampling.
22
+"""
23
+
24
+from typing import Any
25
+
26
+import torch
27
+
28
+from vllm.config import VllmConfig
29
+from vllm.config.compilation import CUDAGraphMode
30
+from vllm.v1.worker.gpu.sample.gumbel import gumbel_sample
31
+from vllm.v1.worker.gpu.spec_decode.dflash.speculator import DFlashSpeculator
32
+from vllm.v1.worker.gpu.spec_decode.dspark.utils import load_dspark_model
33
+
34
+
35
+class DSparkSpeculator(DFlashSpeculator):
36
+ _speculator_name = "DSpark"
37
+
38
+ def __init__(self, vllm_config: VllmConfig, device: torch.device):
39
+ super().__init__(vllm_config, device)
40
+
41
+ # Anchor-first: N query tokens per request (anchor + N-1 noise), not 1+N.
42
+ self.num_query_per_req = self.num_speculative_steps
43
+
44
+ # DSpark consumes mean-pooled target aux hidden states at the target
45
+ # layers, combined to hidden_size via main_proj. Store that combined
46
+ # main_x (hidden_size wide). DSpark does not use the same pre-allocated buffer
47
+ # that DeepSeek-V4's MTP uses.
48
+ draft_hidden = self.draft_model_config.get_hidden_size()
49
+ self.hidden_states = torch.zeros(
50
+ self.max_num_tokens, draft_hidden, dtype=self.dtype, device=device
51
+ )
52
+
53
+ self.dflash_causal = False
54
+
55
+ # The anchor query position is itself a prediction (see module docstring).
56
+ self.sample_from_anchor = True
57
+
58
+ self._step_cols = torch.arange(
59
+ self.num_speculative_steps, dtype=torch.int32, device=device
60
+ )
61
+
62
+ self._anchor_idx = (
63
+ torch.arange(self.max_num_reqs, dtype=torch.int64, device=device)
64
+ * self.num_query_per_req
65
+ )
66
+
67
+ def load_draft_model(
68
+ self,
69
+ target_model: torch.nn.Module,
70
+ target_attn_layer_names: set[str],
71
+ ) -> torch.nn.Module:
72
+ return load_dspark_model(target_model, self.vllm_config)
73
+
74
+ def _sample_sequential(self, num_reqs: int, head_hidden: torch.Tensor) -> None:
75
+ # Sequential Markov sampling over the backbone's output hidden states.
76
+ n_spec = self.num_speculative_steps
77
+ num_sample = num_reqs * n_spec
78
+ # Per-(req, position) head hidden, ordered (req, step).
79
+ sample_hidden = head_hidden[self.sample_indices[:num_sample]]
80
+ base_logits = self.model.compute_logits(sample_hidden)
81
+ vocab_size = base_logits.shape[-1]
82
+ base_logits = base_logits.view(num_reqs, n_spec, vocab_size)
83
+
84
+ idx_map = self.sample_idx_mapping[:num_sample].view(num_reqs, n_spec)
85
+ sample_pos = self.sample_pos[:num_sample].view(num_reqs, n_spec)
86
+
87
+ # Anchor (bonus) token per request = the input id at query offset 0,
88
+ # read via the precomputed persistent index (fixed buffer for capture).
89
+ prev = self.input_buffers.input_ids[self._anchor_idx[:num_reqs]]
90
+
91
+ for i in range(n_spec):
92
+ # Sequential stage: Markov bias from the previously sampled token.
93
+ markov_embed = self.model.markov_embed(prev)
94
+ bias = self.model.markov_bias(markov_embed)
95
+ logits_i = base_logits[:, i] + bias
96
+ if self.draft_logits is not None:
97
+ # sample_pos is the predicted token's position Q; the target
98
+ # verifies it with the predecessor's Gumbel key (Q-1). Pass Q-1.
99
+ draft_i = gumbel_sample(
100
+ logits_i,
101
+ idx_map[:, i],
102
+ self.temperature,
103
+ self.seeds,
104
+ sample_pos[:, i] - 1,
105
+ apply_temperature=True,
106
+ output_processed_logits=self.draft_logits,
107
+ output_processed_logits_col=self._step_cols[i],
108
+ use_fp64=self.use_fp64_gumbel,
109
+ )
110
+ else:
111
+ draft_i = logits_i.argmax(dim=-1)
112
+ self.draft_tokens[:num_reqs, i] = draft_i
113
+ prev = draft_i
114
+
115
+ def _generate_draft(
116
+ self,
117
+ num_reqs: int,
118
+ num_tokens_padded: int,
119
+ attn_metadata: dict[str, Any] | None,
120
+ slot_mappings: dict[str, torch.Tensor] | None,
121
+ num_tokens_across_dp: torch.Tensor | None,
122
+ cudagraph_runtime_mode: CUDAGraphMode = CUDAGraphMode.NONE,
123
+ ) -> None:
124
+ # Full draft step (captured under CUDA graph): parallel backbone forward
125
+ # then sequential Markov sampling over its hidden state outputs.
126
+ head_hidden = self._run_model(
127
+ num_tokens_padded,
128
+ attn_metadata,
129
+ slot_mappings,
130
+ num_tokens_across_dp,
131
+ cudagraph_runtime_mode,
132
+ )
133
+ self._sample_sequential(num_reqs, head_hidden)
vllm/v1/worker/gpu/spec_decode/dspark/utils.py
new
+64
@@ -0,0 +1,64 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import torch.nn as nn
5
+
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
10
+
11
+
12
+def load_dspark_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Module:
13
+ speculative_config = vllm_config.speculative_config
14
+ assert speculative_config is not None
15
+ draft_model_config = speculative_config.draft_model_config
16
+
17
+ from vllm.compilation.backends import set_model_tag
18
+
19
+ # DSpark uses non-causal attention.
20
+ causal = False
21
+ draft_vllm_config = replace(
22
+ vllm_config,
23
+ attention_config=replace(
24
+ vllm_config.attention_config,
25
+ use_non_causal=not causal,
26
+ backend=speculative_config.attention_backend,
27
+ ),
28
+ )
29
+
30
+ with set_model_tag("dspark_head"):
31
+ draft_model = get_model(
32
+ vllm_config=draft_vllm_config, model_config=draft_model_config
33
+ )
34
+
35
+ if get_pp_group().world_size != 1:
36
+ raise NotImplementedError("DSpark does not support pipeline parallelism.")
37
+
38
+ target_language_model = (
39
+ target_model.get_language_model()
40
+ if hasattr(target_model, "get_language_model")
41
+ else target_model
42
+ )
43
+ target_inner = target_language_model.model
44
+ draft_inner = draft_model.model
45
+
46
+ target_embed = getattr(target_inner, "embed_tokens", None)
47
+ draft_embed = getattr(draft_inner, "embed_tokens", None)
48
+ if target_embed is not None and _should_share(
49
+ draft_model, "has_own_embed_tokens", draft_embed, target_embed
50
+ ):
51
+ if draft_embed is not None:
52
+ del draft_inner.embed_tokens
53
+ draft_inner.embed_tokens = target_embed
54
+
55
+ target_lm_head = getattr(target_model, "lm_head", None)
56
+ draft_lm_head = getattr(draft_model, "lm_head", None)
57
+ if target_lm_head is not None and _should_share(
58
+ draft_model, "has_own_lm_head", draft_lm_head, target_lm_head
59
+ ):
60
+ if draft_lm_head is not None:
61
+ del draft_model.lm_head
62
+ draft_model.lm_head = target_lm_head
63
+
64
+ return draft_model
vllm/v1/worker/gpu/spec_decode/eagle/eagle3_utils.py
+9
@@ -44,6 +44,15 @@ def get_eagle3_aux_layers_from_config(
44
if dflash_config and isinstance(dflash_config, dict):
45
# Add 1 to convert DFlash's aux layer id semantics
46
layer_ids = [i + 1 for i in (dflash_config.get("target_layer_ids") or [])]
47
+ if not layer_ids:
48
+ dspark_layer_ids = getattr(hf_config, "dspark_target_layer_ids", None)
49
+ if dspark_layer_ids:
50
+ layer_ids = [i + 1 for i in dspark_layer_ids]
51
+ if not layer_ids:
52
+ # Dense DSpark (e.g. Qwen3) also uses different aux layer semantics.
53
+ target_layer_ids = getattr(hf_config, "target_layer_ids", None)
54
+ if target_layer_ids:
55
+ layer_ids = [i + 1 for i in target_layer_ids]
56
if layer_ids and isinstance(layer_ids, (list, tuple)):
57
return tuple(layer_ids)
58
return None
vllm/v1/worker/gpu/spec_decode/utils.py
+9
-3
@@ -54,17 +54,23 @@ class DraftTokensHandler:
54
def get_parallel_drafting_token_id(hf_config) -> int:
55
"""Resolve the mask token id used for parallel drafting slots.
56
57
- Checks (in order): `dflash_config.mask_token_id`, `pard_token`,
58
- `ptd_token_id`. Raises ValueError if none are present.
57
+ Checks (in order): `dflash_config.mask_token_id`, top-level `mask_token_id`,
58
+ `dspark_noise_token_id`, `pard_token`, `ptd_token_id`. Raises ValueError if
59
+ none are present.
60
"""
61
dflash_config = getattr(hf_config, "dflash_config", None) or {}
62
if "mask_token_id" in dflash_config:
63
return int(dflash_config["mask_token_id"])
64
+ if getattr(hf_config, "mask_token_id", None) is not None:
65
+ return int(hf_config.mask_token_id)
66
+ if hasattr(hf_config, "dspark_noise_token_id"):
67
+ return int(hf_config.dspark_noise_token_id)
68
if hasattr(hf_config, "pard_token"):
69
return int(hf_config.pard_token)
70
if hasattr(hf_config, "ptd_token_id"):
71
return int(hf_config.ptd_token_id)
72
raise ValueError(
73
"Model config must specify `dflash_config.mask_token_id`,"
69
- " `pard_token`, or `ptd_token_id` for parallel drafting."
74
+ " `mask_token_id`, `dspark_noise_token_id`, `pard_token`, or"
75
+ " `ptd_token_id` for parallel drafting."
76
)