[Model] Add Qwen3-Omni DSpark support (#52560)
Signed-off-by: Zhou248 <630563665@qq.com>
Zhou248 committed
Aug 23, 2026 at 06:35 UTC
2f55ef254c70e110d637beeedf48238977ebb683
11 files changed
+731
-22
tests/model_executor/test_qwen3_omni.py
+171
-1
@@ -1,9 +1,13 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
-from unittest.mock import Mock
4
+from contextlib import nullcontext
5
+from types import SimpleNamespace
6
+from unittest.mock import Mock, patch
7
8
import pytest
9
+import torch
10
+import torch.nn as nn
11
from transformers import PretrainedConfig
12
13
from vllm.multimodal.processing import InputProcessingContext
@@ -218,5 +222,171 @@ def test_qwen3_omni_get_updates_use_audio_in_video(
222
)
223
224
225
+@pytest.mark.skip_global_cleanup
226
+def test_qwen3_omni_exposes_eagle3_to_its_text_backbone():
227
+ from vllm.model_executor.models.interfaces import EagleModelMixin, supports_eagle3
228
+ from vllm.model_executor.models.qwen3_omni_moe_thinker import (
229
+ Qwen3OmniMoeThinkerForConditionalGeneration,
230
+ )
231
+
232
+ class DummyBackbone(nn.Module, EagleModelMixin):
233
+ def __init__(self):
234
+ super().__init__()
235
+ self.layers = nn.ModuleList([nn.Identity(), nn.Identity()])
236
+
237
+ class DummyLanguageModel(nn.Module):
238
+ def __init__(self):
239
+ super().__init__()
240
+ self.model = DummyBackbone()
241
+
242
+ def embed_input_ids(self, input_ids):
243
+ return input_ids
244
+
245
+ model = Qwen3OmniMoeThinkerForConditionalGeneration.__new__(
246
+ Qwen3OmniMoeThinkerForConditionalGeneration
247
+ )
248
+ nn.Module.__init__(model)
249
+ model.language_model = DummyLanguageModel()
250
+
251
+ assert supports_eagle3(model)
252
+ model.set_aux_hidden_state_layers((1, 2))
253
+ assert model.language_model.model.aux_hidden_state_layers == (1, 2)
254
+
255
+
256
+@pytest.mark.skip_global_cleanup
257
+def test_qwen3_omni_text_model_collects_post_deepstack_aux_hidden_states():
258
+ from vllm.model_executor.models.qwen3_omni_moe_thinker import Qwen3MoeLLMModel
259
+
260
+ class DummyLayer(nn.Module):
261
+ def forward(self, positions, hidden_states, residual):
262
+ return hidden_states + 1, torch.full_like(hidden_states, 10)
263
+
264
+ class DummyNorm(nn.Module):
265
+ def forward(self, hidden_states, residual):
266
+ return hidden_states + residual, None
267
+
268
+ model = Qwen3MoeLLMModel.__new__(Qwen3MoeLLMModel)
269
+ nn.Module.__init__(model)
270
+ model.start_layer = 0
271
+ model.end_layer = 1
272
+ model.layers = nn.ModuleList([DummyLayer()])
273
+ model.norm = DummyNorm()
274
+ model.aux_hidden_state_layers = (1,)
275
+
276
+ pp_group = Mock(is_first_rank=True, is_last_rank=True)
277
+ inputs_embeds = torch.tensor([[1.0]])
278
+ deepstack_inputs = {"deepstack_input_embeds_0": torch.tensor([[3.0]])}
279
+ with patch(
280
+ "vllm.model_executor.models.qwen3_omni_moe_thinker.get_pp_group",
281
+ return_value=pp_group,
282
+ ):
283
+ output, aux_hidden_states = model.forward(
284
+ input_ids=None,
285
+ positions=torch.tensor([0]),
286
+ inputs_embeds=inputs_embeds,
287
+ deepstack_input_embeds=deepstack_inputs,
288
+ )
289
+
290
+ torch.testing.assert_close(output, torch.tensor([[15.0]]))
291
+ assert len(aux_hidden_states) == 1
292
+ torch.testing.assert_close(aux_hidden_states[0], torch.tensor([[15.0]]))
293
+
294
+
295
+@pytest.mark.skip_global_cleanup
296
+@pytest.mark.parametrize(
297
+ ("input_vocab_size", "draft_vocab_size", "weights", "error"),
298
+ [
299
+ (101, 100, [], "must include embed_tokens weights"),
300
+ (99, 99, [], "must include lm_head weights"),
301
+ (100, 40, [], "must include lm_head weights"),
302
+ (
303
+ 100,
304
+ 40,
305
+ [("lm_head.weight", torch.empty(40, 8))],
306
+ "must include a d2t mapping",
307
+ ),
308
+ ],
309
+)
310
+def test_qwen3_dspark_rejects_incomplete_vocab_weights(
311
+ input_vocab_size, draft_vocab_size, weights, error
312
+):
313
+ from vllm.model_executor.models.qwen3_dspark import Qwen3DSparkForCausalLM
314
+
315
+ model = Qwen3DSparkForCausalLM.__new__(Qwen3DSparkForCausalLM)
316
+ nn.Module.__init__(model)
317
+ object.__setattr__(
318
+ model,
319
+ "config",
320
+ SimpleNamespace(
321
+ vocab_size=input_vocab_size,
322
+ draft_vocab_size=draft_vocab_size,
323
+ ),
324
+ )
325
+ object.__setattr__(model, "target_vocab_size", 100)
326
+
327
+ with pytest.raises(ValueError, match=error):
328
+ model.load_weights(weights)
329
+
330
+
331
+@pytest.mark.skip_global_cleanup
332
+def test_dspark_shares_target_embedding_with_smaller_draft_vocabulary():
333
+ from vllm.v1.worker.gpu.spec_decode.dspark import utils as dspark_utils
334
+
335
+ target_embedding = nn.Embedding(100, 8)
336
+ draft_embedding = nn.Embedding(99, 8)
337
+ target_model = SimpleNamespace(model=SimpleNamespace(embed_tokens=target_embedding))
338
+ draft_model = SimpleNamespace(
339
+ model=SimpleNamespace(embed_tokens=draft_embedding),
340
+ has_own_embed_tokens=False,
341
+ )
342
+ draft_model_config = SimpleNamespace(
343
+ hf_config=SimpleNamespace(model_type="qwen3"),
344
+ get_vocab_size=Mock(return_value=99),
345
+ )
346
+ vllm_config = SimpleNamespace(
347
+ speculative_config=SimpleNamespace(
348
+ draft_model_config=draft_model_config,
349
+ attention_backend=None,
350
+ kv_cache_dtype=None,
351
+ ),
352
+ attention_config=SimpleNamespace(backend=None),
353
+ cache_config=SimpleNamespace(),
354
+ model_config=SimpleNamespace(get_vocab_size=Mock(return_value=100)),
355
+ )
356
+
357
+ def fake_replace(config, **changes):
358
+ values = vars(config).copy()
359
+ values.update(changes)
360
+ return SimpleNamespace(**values)
361
+
362
+ with (
363
+ patch.object(dspark_utils, "replace", side_effect=fake_replace),
364
+ patch.object(
365
+ dspark_utils,
366
+ "get_pp_group",
367
+ return_value=SimpleNamespace(world_size=1),
368
+ ),
369
+ patch(
370
+ "vllm.compilation.backends.set_model_tag",
371
+ return_value=nullcontext(),
372
+ ),
373
+ patch(
374
+ "vllm.model_executor.model_loader.get_model",
375
+ return_value=draft_model,
376
+ ),
377
+ patch(
378
+ "vllm.model_executor.models.qwen3_dflash.dflash_has_any_non_causal",
379
+ return_value=False,
380
+ ),
381
+ patch(
382
+ "vllm.model_executor.models.utils.get_draft_quant_config",
383
+ return_value=None,
384
+ ),
385
+ ):
386
+ loaded_model = dspark_utils.load_dspark_model(target_model, vllm_config)
387
+
388
+ assert loaded_model.model.embed_tokens is target_embedding
389
+
390
+
391
if __name__ == "__main__":
392
pytest.main([__file__, "-v"])
tests/models/registry.py
+7
@@ -1557,6 +1557,13 @@ _SPECULATIVE_DECODING_EXAMPLE_MODELS = {
1557
is_available_online=False,
1558
use_original_num_layers=True, # DSpark backbone requires all layers
1559
),
1560
+ "Qwen3OmniDSparkModel": _HfExamplesInfo(
1561
+ "Qwen/Qwen3-Omni-30B-A3B-Instruct",
1562
+ speculative_model="Qwen/Qwen3-Omni-30B-A3B-DSpark",
1563
+ is_available_online=False,
1564
+ use_original_num_layers=True,
1565
+ min_transformers_version="4.57",
1566
+ ),
1567
"Gemma4DSparkModel": _HfExamplesInfo(
1568
"google/gemma-4-12B-it",
1569
speculative_model="deepseek-ai/dspark_gemma4_12b_block7",
tests/test_config.py
+121
@@ -32,6 +32,7 @@ from vllm.config.compilation import CompilationMode, CUDAGraphMode
32
from vllm.config.kernel import IrOpPriorityConfig
33
from vllm.config.load import LoadConfig
34
from vllm.config.mamba import MambaBackendEnum
35
+from vllm.config.speculative import _validate_qwen3_omni_dspark
36
from vllm.config.utils import get_field
37
from vllm.config.vllm import OPTIMIZATION_LEVEL_TO_CONFIG, OptimizationLevel
38
from vllm.platforms import current_platform
@@ -2100,6 +2101,126 @@ def test_mtp_draft_uses_model_weights_not_local_cache(mock_model_config_cls):
2101
assert actual_model == s3_url
2102
2103
2104
+def _make_qwen3_omni_dspark_configs():
2105
+ text_config = SimpleNamespace(
2106
+ hidden_size=2048,
2107
+ num_hidden_layers=48,
2108
+ num_attention_heads=32,
2109
+ num_key_value_heads=4,
2110
+ head_dim=128,
2111
+ vocab_size=152064,
2112
+ )
2113
+ target_model_config = SimpleNamespace(
2114
+ hf_config=SimpleNamespace(
2115
+ model_type="qwen3_omni_moe",
2116
+ architectures=["Qwen3OmniMoeForConditionalGeneration"],
2117
+ thinker_config=SimpleNamespace(text_config=text_config),
2118
+ ),
2119
+ hf_text_config=text_config,
2120
+ architectures=["Qwen3OmniMoeForConditionalGeneration"],
2121
+ get_hidden_size=lambda: 2048,
2122
+ get_total_num_hidden_layers=lambda: 48,
2123
+ get_vocab_size=lambda: 152064,
2124
+ )
2125
+ draft_hf_config = SimpleNamespace(
2126
+ model_type="qwen3",
2127
+ architectures=["Qwen3OmniDSparkModel"],
2128
+ hidden_size=2048,
2129
+ num_attention_heads=32,
2130
+ num_key_value_heads=4,
2131
+ head_dim=128,
2132
+ block_size=7,
2133
+ target_hidden_size=2048,
2134
+ target_layer_ids=[1, 9, 17, 25, 33],
2135
+ use_aux_hidden_state=True,
2136
+ markov_rank=64,
2137
+ markov_head_type="vanilla",
2138
+ sample_from_anchor=True,
2139
+ dspark_bonus_anchor=False,
2140
+ vocab_size=152064,
2141
+ draft_vocab_size=32000,
2142
+ mask_token_id=151669,
2143
+ rope_parameters={"rope_type": "default", "rope_theta": 1000000.0},
2144
+ )
2145
+ draft_model_config = SimpleNamespace(
2146
+ hf_config=draft_hf_config,
2147
+ architectures=["Qwen3OmniDSparkModel"],
2148
+ )
2149
+ return target_model_config, draft_model_config
2150
+
2151
+
2152
+@pytest.mark.skip_global_cleanup
2153
+def test_qwen3_omni_dspark_checkpoint_contract_is_accepted():
2154
+ target_config, draft_config = _make_qwen3_omni_dspark_configs()
2155
+ _validate_qwen3_omni_dspark(target_config, draft_config, 7)
2156
+
2157
+
2158
+@pytest.mark.skip_global_cleanup
2159
+def test_qwen3_omni_dspark_rejects_generic_qwen3_architecture():
2160
+ target_config, draft_config = _make_qwen3_omni_dspark_configs()
2161
+ draft_config.hf_config.architectures = ["Qwen3DSparkModel"]
2162
+ draft_config.architectures = ["Qwen3DSparkModel"]
2163
+
2164
+ with pytest.raises(ValueError, match="must be converted first"):
2165
+ _validate_qwen3_omni_dspark(target_config, draft_config, 7)
2166
+
2167
+
2168
+@pytest.mark.parametrize(
2169
+ ("field", "value", "error"),
2170
+ [
2171
+ ("block_size", 5, "trained block_size"),
2172
+ ("target_hidden_size", 4096, "target_hidden_size"),
2173
+ ("hidden_size", 4096, "draft hidden_size"),
2174
+ ("num_attention_heads", 16, "num_attention_heads"),
2175
+ ("num_key_value_heads", 8, "num_key_value_heads"),
2176
+ ("head_dim", 64, "head_dim"),
2177
+ ("target_layer_ids", [7, 48], "zero-based text-layer"),
2178
+ ("target_layer_ids", [23, 7], "strictly increasing"),
2179
+ ("use_aux_hidden_state", False, "use_aux_hidden_state=true"),
2180
+ ("markov_rank", 0, "markov_rank"),
2181
+ ("markov_head_type", "gated", "markov_head_type='vanilla'"),
2182
+ ("sample_from_anchor", False, "sample_from_anchor=true"),
2183
+ ("dspark_bonus_anchor", True, "dspark_bonus_anchor=false"),
2184
+ ("vocab_size", 0, "input vocab_size must be a positive integer"),
2185
+ ],
2186
+)
2187
+@pytest.mark.skip_global_cleanup
2188
+def test_qwen3_omni_dspark_rejects_incompatible_checkpoint_fields(field, value, error):
2189
+ target_config, draft_config = _make_qwen3_omni_dspark_configs()
2190
+ setattr(draft_config.hf_config, field, value)
2191
+
2192
+ with pytest.raises(ValueError, match=error):
2193
+ _validate_qwen3_omni_dspark(target_config, draft_config, 7)
2194
+
2195
+
2196
+@pytest.mark.skip_global_cleanup
2197
+def test_qwen3_omni_dspark_rejects_mrope_draft_positions():
2198
+ target_config, draft_config = _make_qwen3_omni_dspark_configs()
2199
+ draft_config.hf_config.rope_parameters["mrope_section"] = [24, 20, 20]
2200
+
2201
+ with pytest.raises(ValueError, match="logical 1-D RoPE"):
2202
+ _validate_qwen3_omni_dspark(target_config, draft_config, 7)
2203
+
2204
+
2205
+@pytest.mark.skip_global_cleanup
2206
+def test_qwen3_omni_dspark_allows_draft_only_noise_token_row():
2207
+ target_config, draft_config = _make_qwen3_omni_dspark_configs()
2208
+ draft_config.hf_config.vocab_size = 152065
2209
+ draft_config.hf_config.draft_vocab_size = 152064
2210
+ draft_config.hf_config.mask_token_id = 152064
2211
+
2212
+ _validate_qwen3_omni_dspark(target_config, draft_config, 7)
2213
+
2214
+
2215
+@pytest.mark.skip_global_cleanup
2216
+def test_qwen3_omni_dspark_allows_smaller_input_vocabulary():
2217
+ target_config, draft_config = _make_qwen3_omni_dspark_configs()
2218
+ draft_config.hf_config.vocab_size = 151936
2219
+ draft_config.hf_config.mask_token_id = 151669
2220
+
2221
+ _validate_qwen3_omni_dspark(target_config, draft_config, 7)
2222
+
2223
+
2224
def test_ir_op_priority_default():
2225
"""Test that IR op priority defaults are set correctly."""
2226
from vllm.config.kernel import IrOpPriorityConfig
tests/transformers_utils/test_speculators_dspark_config.py
new
+58
@@ -0,0 +1,58 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import pytest
5
+
6
+from vllm.transformers_utils.configs.speculators.base import SpeculatorsConfig
7
+
8
+pytestmark = pytest.mark.skip_global_cleanup
9
+
10
+
11
+def _make_dspark_config(architecture: str) -> dict:
12
+ return {
13
+ "speculators_model_type": "dspark",
14
+ "architectures": [architecture],
15
+ "sample_from_anchor": True,
16
+ "use_aux_hidden_state": True,
17
+ "draft_vocab_size": 32,
18
+ "target_hidden_size": 16,
19
+ "mask_token_id": 31,
20
+ "markov_rank": 4,
21
+ "markov_head_type": "vanilla",
22
+ "block_size": 3,
23
+ "aux_hidden_state_layer_ids": [2, 5],
24
+ "transformer_layer_config": {
25
+ "model_type": "qwen3",
26
+ "hidden_size": 16,
27
+ },
28
+ }
29
+
30
+
31
+def test_dspark_updater_preserves_qwen3_omni_contract() -> None:
32
+ config = SpeculatorsConfig.extract_transformers_pre_trained_config(
33
+ _make_dspark_config("Qwen3OmniDSparkModel")
34
+ )
35
+
36
+ assert config["architectures"] == ["Qwen3OmniDSparkModel"]
37
+ assert config["sample_from_anchor"] is True
38
+ assert config["dspark_bonus_anchor"] is False
39
+ assert config["use_aux_hidden_state"] is True
40
+ assert config["target_layer_ids"] == [1, 4]
41
+
42
+
43
+def test_dspark_updater_keeps_legacy_qwen3_checkpoint_loadable() -> None:
44
+ config = SpeculatorsConfig.extract_transformers_pre_trained_config(
45
+ _make_dspark_config("DSparkSpeculator")
46
+ )
47
+
48
+ assert config["architectures"] == ["Qwen3DSparkModel"]
49
+
50
+
51
+def test_dspark_updater_maps_bonus_anchor_semantics() -> None:
52
+ outer_config = _make_dspark_config("Qwen3DSparkModel")
53
+ outer_config["sample_from_anchor"] = False
54
+
55
+ config = SpeculatorsConfig.extract_transformers_pre_trained_config(outer_config)
56
+
57
+ assert config["sample_from_anchor"] is False
58
+ assert config["dspark_bonus_anchor"] is True
vllm/config/speculative.py
+302
-2
@@ -3,7 +3,7 @@
3
4
import copy
5
import functools
6
-from collections.abc import Callable
6
+from collections.abc import Callable, Mapping
7
from typing import TYPE_CHECKING, Any, Literal, get_args
8
9
from pydantic import Field, SkipValidation, field_validator, model_validator
@@ -80,6 +80,293 @@ SpeculativeMethod = Literal[
80
RejectionSampleMethod = Literal["standard", "synthetic", "block"]
81
DraftSampleMethod = Literal["greedy", "probabilistic"]
82
83
+_QWEN3_OMNI_TARGET_ARCHITECTURES = frozenset(
84
+ {
85
+ "Qwen3OmniMoeForConditionalGeneration",
86
+ "Qwen3OmniMoeThinkerForConditionalGeneration",
87
+ }
88
+)
89
+_QWEN3_OMNI_DSPARK_ARCHITECTURE = "Qwen3OmniDSparkModel"
90
+
91
+
92
+def _is_qwen3_omni_target(model_config: ModelConfig) -> bool:
93
+ hf_config = model_config.hf_config
94
+ architectures = set(getattr(model_config, "architectures", ()) or ())
95
+ architectures.update(getattr(hf_config, "architectures", ()) or ())
96
+ return getattr(hf_config, "model_type", None) == "qwen3_omni_moe" or bool(
97
+ architectures & _QWEN3_OMNI_TARGET_ARCHITECTURES
98
+ )
99
+
100
+
101
+def _get_qwen3_omni_text_config(model_config: ModelConfig) -> Any:
102
+ thinker_config = getattr(model_config.hf_config, "thinker_config", None)
103
+ text_config = getattr(thinker_config, "text_config", None)
104
+ if text_config is None:
105
+ text_config = getattr(model_config, "hf_text_config", None)
106
+ return text_config
107
+
108
+
109
+def _get_attention_head_dim(config: Any) -> int | None:
110
+ head_dim = getattr(config, "head_dim", None)
111
+ if head_dim is not None:
112
+ return head_dim
113
+ hidden_size = getattr(config, "hidden_size", None)
114
+ num_attention_heads = getattr(config, "num_attention_heads", None)
115
+ if (
116
+ isinstance(hidden_size, int)
117
+ and isinstance(num_attention_heads, int)
118
+ and num_attention_heads > 0
119
+ and hidden_size % num_attention_heads == 0
120
+ ):
121
+ return hidden_size // num_attention_heads
122
+ return None
123
+
124
+
125
+def _get_nested_config_value(config: Any, section: str, name: str) -> Any:
126
+ nested_config = getattr(config, section, None)
127
+ if isinstance(nested_config, Mapping):
128
+ return nested_config.get(name)
129
+ return getattr(nested_config, name, None)
130
+
131
+
132
+def _get_qwen3_dspark_value(config: Any, name: str) -> Any:
133
+ # Match DFlashQwen3Model's precedence: dflash_config overrides
134
+ # eagle_config, and top-level values are the modern fallback.
135
+ value = _get_nested_config_value(config, "dflash_config", name)
136
+ if value is None:
137
+ value = _get_nested_config_value(config, "eagle_config", name)
138
+ if value is None:
139
+ value = getattr(config, name, None)
140
+ return value
141
+
142
+
143
+def _validate_qwen3_omni_dspark(
144
+ target_model_config: ModelConfig,
145
+ draft_model_config: ModelConfig,
146
+ num_speculative_tokens: int,
147
+) -> None:
148
+ """Validate the standalone Qwen3-Omni DSpark checkpoint contract."""
149
+ if not _is_qwen3_omni_target(target_model_config):
150
+ return
151
+
152
+ draft_hf_config = draft_model_config.hf_config
153
+ draft_architectures = set(getattr(draft_model_config, "architectures", ()) or ())
154
+ draft_architectures.update(getattr(draft_hf_config, "architectures", ()) or ())
155
+ if _QWEN3_OMNI_DSPARK_ARCHITECTURE not in draft_architectures:
156
+ raise ValueError(
157
+ "Qwen3-Omni DSpark requires a standalone draft checkpoint with "
158
+ f"architectures=['{_QWEN3_OMNI_DSPARK_ARCHITECTURE}']; generic "
159
+ "Qwen3DSparkModel checkpoints must be converted first."
160
+ )
161
+
162
+ block_size = _get_qwen3_dspark_value(draft_hf_config, "block_size")
163
+ if block_size is None:
164
+ block_size = _get_qwen3_dspark_value(draft_hf_config, "dspark_block_size")
165
+ if (
166
+ not isinstance(block_size, int)
167
+ or isinstance(block_size, bool)
168
+ or block_size <= 0
169
+ ):
170
+ raise ValueError(
171
+ "Qwen3-Omni DSpark requires a positive integer block_size in the "
172
+ "draft config."
173
+ )
174
+ if num_speculative_tokens != block_size:
175
+ raise ValueError(
176
+ "Qwen3-Omni DSpark requires num_speculative_tokens to match the "
177
+ f"trained block_size ({block_size}); got {num_speculative_tokens}."
178
+ )
179
+
180
+ target_text_config = _get_qwen3_omni_text_config(target_model_config)
181
+ if target_text_config is None:
182
+ raise ValueError(
183
+ "Qwen3-Omni DSpark could not resolve the target thinker text_config."
184
+ )
185
+ target_hidden_size = getattr(
186
+ target_text_config,
187
+ "hidden_size",
188
+ target_model_config.get_hidden_size(),
189
+ )
190
+ draft_target_hidden_size = getattr(draft_hf_config, "target_hidden_size", None)
191
+ if draft_target_hidden_size != target_hidden_size:
192
+ raise ValueError(
193
+ "Qwen3-Omni DSpark draft target_hidden_size must match the target "
194
+ f"text hidden size ({target_hidden_size}); got {draft_target_hidden_size}."
195
+ )
196
+ draft_hidden_size = getattr(draft_hf_config, "hidden_size", None)
197
+ if draft_hidden_size != target_hidden_size:
198
+ raise ValueError(
199
+ "Qwen3-Omni DSpark draft hidden_size must match the target thinker "
200
+ f"hidden size ({target_hidden_size}); got {draft_hidden_size}."
201
+ )
202
+
203
+ target_attention = {
204
+ "num_attention_heads": getattr(target_text_config, "num_attention_heads", None),
205
+ "num_key_value_heads": getattr(
206
+ target_text_config,
207
+ "num_key_value_heads",
208
+ getattr(target_text_config, "num_attention_heads", None),
209
+ ),
210
+ "head_dim": _get_attention_head_dim(target_text_config),
211
+ }
212
+ draft_attention = {
213
+ "num_attention_heads": getattr(draft_hf_config, "num_attention_heads", None),
214
+ "num_key_value_heads": getattr(
215
+ draft_hf_config,
216
+ "num_key_value_heads",
217
+ getattr(draft_hf_config, "num_attention_heads", None),
218
+ ),
219
+ "head_dim": _get_attention_head_dim(draft_hf_config),
220
+ }
221
+ for name, target_value in target_attention.items():
222
+ if (
223
+ not isinstance(target_value, int)
224
+ or isinstance(target_value, bool)
225
+ or target_value <= 0
226
+ ):
227
+ raise ValueError(
228
+ "Qwen3-Omni DSpark requires a positive integer "
229
+ f"{name} in the target thinker text_config; got {target_value}."
230
+ )
231
+ draft_value = draft_attention[name]
232
+ if draft_value != target_value:
233
+ raise ValueError(
234
+ f"Qwen3-Omni DSpark draft {name} must match the target thinker "
235
+ f"value ({target_value}); got {draft_value}."
236
+ )
237
+
238
+ target_layer_ids = _get_nested_config_value(
239
+ draft_hf_config, "dflash_config", "target_layer_ids"
240
+ )
241
+ if target_layer_ids is None:
242
+ target_layer_ids = getattr(draft_hf_config, "dspark_target_layer_ids", None)
243
+ if target_layer_ids is None:
244
+ target_layer_ids = getattr(draft_hf_config, "target_layer_ids", None)
245
+ if not isinstance(target_layer_ids, (list, tuple)) or not target_layer_ids:
246
+ raise ValueError(
247
+ "Qwen3-Omni DSpark requires a non-empty target_layer_ids list in "
248
+ "the draft config."
249
+ )
250
+ if any(
251
+ not isinstance(layer_id, int) or isinstance(layer_id, bool)
252
+ for layer_id in target_layer_ids
253
+ ):
254
+ raise ValueError(
255
+ "Qwen3-Omni DSpark target_layer_ids must contain only integers."
256
+ )
257
+ if list(target_layer_ids) != sorted(set(target_layer_ids)):
258
+ raise ValueError(
259
+ "Qwen3-Omni DSpark target_layer_ids must be unique and strictly increasing."
260
+ )
261
+ target_num_layers = getattr(
262
+ target_text_config,
263
+ "num_hidden_layers",
264
+ target_model_config.get_total_num_hidden_layers(),
265
+ )
266
+ if target_layer_ids[0] < 0 or target_layer_ids[-1] >= target_num_layers:
267
+ raise ValueError(
268
+ "Qwen3-Omni DSpark target_layer_ids must be zero-based text-layer "
269
+ f"indices in [0, {target_num_layers - 1}]; got {target_layer_ids}."
270
+ )
271
+
272
+ if _get_qwen3_dspark_value(draft_hf_config, "use_aux_hidden_state") is not True:
273
+ raise ValueError(
274
+ "Qwen3-Omni DSpark requires use_aux_hidden_state=true because Omni "
275
+ "conditioning is supplied through thinker hidden states."
276
+ )
277
+
278
+ markov_rank = getattr(draft_hf_config, "markov_rank", None)
279
+ if (
280
+ not isinstance(markov_rank, int)
281
+ or isinstance(markov_rank, bool)
282
+ or markov_rank <= 0
283
+ ):
284
+ raise ValueError(
285
+ "Qwen3-Omni DSpark requires a positive integer markov_rank in the "
286
+ "draft config."
287
+ )
288
+ markov_head_type = getattr(draft_hf_config, "markov_head_type", None)
289
+ if markov_head_type != "vanilla":
290
+ raise ValueError(
291
+ "Qwen3-Omni DSpark currently requires markov_head_type='vanilla'; "
292
+ f"got {markov_head_type!r}."
293
+ )
294
+
295
+ sample_from_anchor = getattr(draft_hf_config, "sample_from_anchor", True)
296
+ bonus_anchor = getattr(draft_hf_config, "dspark_bonus_anchor", False)
297
+ if sample_from_anchor is not True or bonus_anchor is not False:
298
+ raise ValueError(
299
+ "Qwen3-Omni DSpark requires sample_from_anchor=true and "
300
+ "dspark_bonus_anchor=false."
301
+ )
302
+
303
+ target_vocab_size = getattr(
304
+ target_text_config,
305
+ "vocab_size",
306
+ target_model_config.get_vocab_size(),
307
+ )
308
+ draft_input_vocab_size = getattr(draft_hf_config, "vocab_size", None)
309
+ if (
310
+ not isinstance(draft_input_vocab_size, int)
311
+ or isinstance(draft_input_vocab_size, bool)
312
+ or draft_input_vocab_size <= 0
313
+ ):
314
+ raise ValueError(
315
+ "Qwen3-Omni DSpark input vocab_size must be a positive integer; "
316
+ f"got {draft_input_vocab_size}."
317
+ )
318
+ draft_output_vocab_size = getattr(draft_hf_config, "draft_vocab_size", None)
319
+ if draft_output_vocab_size is None:
320
+ draft_output_vocab_size = draft_input_vocab_size
321
+ if (
322
+ not isinstance(draft_output_vocab_size, int)
323
+ or isinstance(draft_output_vocab_size, bool)
324
+ or not 0 < draft_output_vocab_size <= target_vocab_size
325
+ ):
326
+ raise ValueError(
327
+ "Qwen3-Omni DSpark draft_vocab_size must be a positive integer no "
328
+ f"larger than target vocab_size ({target_vocab_size}); got "
329
+ f"{draft_output_vocab_size}."
330
+ )
331
+
332
+ noise_token_id = _get_nested_config_value(
333
+ draft_hf_config, "dflash_config", "mask_token_id"
334
+ )
335
+ for name in (
336
+ "mask_token_id",
337
+ "dspark_noise_token_id",
338
+ "pard_token",
339
+ "ptd_token_id",
340
+ ):
341
+ if noise_token_id is None:
342
+ noise_token_id = getattr(draft_hf_config, name, None)
343
+ if noise_token_id is not None:
344
+ break
345
+ if (
346
+ not isinstance(noise_token_id, int)
347
+ or isinstance(noise_token_id, bool)
348
+ or not 0 <= noise_token_id < draft_input_vocab_size
349
+ ):
350
+ raise ValueError(
351
+ "Qwen3-Omni DSpark requires a valid mask/noise token id within the "
352
+ f"draft input vocabulary [0, {draft_input_vocab_size - 1}]."
353
+ )
354
+
355
+ rope_configs = (
356
+ getattr(draft_hf_config, "rope_parameters", None),
357
+ getattr(draft_hf_config, "rope_scaling", None),
358
+ )
359
+ has_mrope = getattr(draft_hf_config, "mrope_section", None) is not None
360
+ has_mrope = has_mrope or any(
361
+ isinstance(rope_config, Mapping) and "mrope_section" in rope_config
362
+ for rope_config in rope_configs
363
+ )
364
+ if has_mrope:
365
+ raise ValueError(
366
+ "Qwen3-Omni DSpark draft checkpoints must use logical 1-D RoPE and "
367
+ "must not define mrope_section."
368
+ )
369
+
370
371
@config
372
class SpeculativeConfig:
@@ -964,6 +1251,8 @@ class SpeculativeConfig:
1251
elif (
1252
"dspark" in self.draft_model_config.model.lower()
1253
or "Qwen3DSparkModel" in self.draft_model_config.architectures
1254
+ or _QWEN3_OMNI_DSPARK_ARCHITECTURE
1255
+ in self.draft_model_config.architectures
1256
or "Gemma4DSparkModel" in self.draft_model_config.architectures
1257
or (
1258
"DSparkDraftModel" in self.draft_model_config.architectures
@@ -1037,6 +1326,8 @@ class SpeculativeConfig:
1326
self.update_arch_()
1327
elif self.method == "dspark" and (
1328
"Qwen3DSparkModel" not in self.draft_model_config.architectures
1329
+ and _QWEN3_OMNI_DSPARK_ARCHITECTURE
1330
+ not in self.draft_model_config.architectures
1331
and "Gemma4DSparkModel" not in self.draft_model_config.architectures
1332
and "K3DSparkModel" not in self.draft_model_config.architectures
1333
):
@@ -1125,13 +1416,22 @@ class SpeculativeConfig:
1416
if (
1417
"Qwen3DSparkModel"
1418
not in self.draft_model_config.architectures
1419
+ and _QWEN3_OMNI_DSPARK_ARCHITECTURE
1420
+ not in self.draft_model_config.architectures
1421
):
1422
raise ValueError(
1423
"dspark_draft_topk is only supported by "
1131
- "Qwen3DSparkModel"
1424
+ "Qwen3DSparkModel and Qwen3OmniDSparkModel"
1425
)
1426
hf_config.dspark_draft_topk = dspark_draft_topk
1427
1428
+ assert self.target_model_config is not None
1429
+ _validate_qwen3_omni_dspark(
1430
+ self.target_model_config,
1431
+ self.draft_model_config,
1432
+ self.num_speculative_tokens,
1433
+ )
1434
+
1435
self.draft_tensor_parallel_size = (
1436
SpeculativeConfig._verify_and_get_draft_tp(
1437
self.target_parallel_config,
vllm/model_executor/models/qwen3_dflash.py
+8
-8
@@ -377,8 +377,6 @@ class DFlashQwen3DecoderLayer(nn.Module):
377
378
@support_torch_compile
379
class DFlashQwen3Model(nn.Module):
380
- decoder_layer_cls = DFlashQwen3DecoderLayer
381
-
380
hf_to_vllm_mapper = WeightsMapper(
381
orig_to_new_substr={
382
"midlayer.": "layers.0.",
@@ -412,10 +410,10 @@ class DFlashQwen3Model(nn.Module):
410
drafter_config = getattr(self.config, "eagle_config", {})
411
drafter_config.update(getattr(self.config, "dflash_config", {}))
412
415
- if drafter_config is not None and "use_aux_hidden_state" in drafter_config:
416
- self.use_aux_hidden_state = drafter_config["use_aux_hidden_state"]
417
- else:
418
- self.use_aux_hidden_state = True
413
+ self.use_aux_hidden_state = drafter_config.get(
414
+ "use_aux_hidden_state",
415
+ getattr(self.config, "use_aux_hidden_state", True),
416
+ )
417
418
current_vllm_config = get_current_vllm_config()
419
@@ -430,7 +428,9 @@ class DFlashQwen3Model(nn.Module):
428
# at that slot id. Some checkpoints (XiaomiMiMo/MiMo-V2.5-Pro-FP4-DFlash) ship
429
# with a separate mask embedding tensor to use instead. When present, we load it
430
# and substitute it for embed_tokens[mask_token_id] when computing embeddings.
433
- self.mask_token_id = drafter_config.get("mask_token_id")
431
+ self.mask_token_id = drafter_config.get(
432
+ "mask_token_id", getattr(self.config, "mask_token_id", None)
433
+ )
434
self.mask_embedding = nn.Parameter(
435
torch.zeros(self.config.hidden_size, dtype=vllm_config.model_config.dtype),
436
requires_grad=False,
@@ -439,7 +439,7 @@ class DFlashQwen3Model(nn.Module):
439
440
self.layers = nn.ModuleList(
441
[
442
- self.decoder_layer_cls(
442
+ DFlashQwen3DecoderLayer(
443
current_vllm_config,
444
config=self.config,
445
layer_idx=layer_idx,
vllm/model_executor/models/qwen3_dspark.py
+20
-2
@@ -200,8 +200,8 @@ class Qwen3DSparkForCausalLM(DFlashQwen3ForCausalLM):
200
self.logits_processor = LogitsProcessor(
201
self.config.draft_vocab_size, scale=logit_scale
202
)
203
- target_vocab_size = vllm_config.model_config.get_vocab_size()
204
- if self.config.draft_vocab_size != target_vocab_size:
203
+ self.target_vocab_size = vllm_config.model_config.get_vocab_size()
204
+ if self.config.draft_vocab_size != self.target_vocab_size:
205
self.draft_id_to_target_id = nn.Parameter(
206
torch.zeros(self.config.draft_vocab_size, dtype=torch.long),
207
requires_grad=False,
@@ -280,6 +280,24 @@ class Qwen3DSparkForCausalLM(DFlashQwen3ForCausalLM):
280
# mask_embedding is an unused placeholder param; DSpark masks via the vocab row.
281
# embed_tokens / lm_head are optional; when omitted they are shared from
282
# the target by load_dspark_model, so skip the unloaded params here.
283
+ uses_expanded_input_vocab = self.config.vocab_size > self.target_vocab_size
284
+ uses_reduced_vocab = self.config.draft_vocab_size != self.target_vocab_size
285
+ if uses_expanded_input_vocab and not includes_embed_tokens:
286
+ raise ValueError(
287
+ "Qwen3 DSpark checkpoints whose input vocab_size is larger than "
288
+ "the target vocabulary must include embed_tokens weights."
289
+ )
290
+ if uses_reduced_vocab and not includes_lm_head:
291
+ raise ValueError(
292
+ "Reduced-vocabulary Qwen3 DSpark checkpoints must include "
293
+ "lm_head weights; the full target lm_head cannot be shared."
294
+ )
295
+ if uses_reduced_vocab and not includes_draft_id_mapping:
296
+ raise ValueError(
297
+ "Reduced-vocabulary Qwen3 DSpark checkpoints must include a "
298
+ "d2t mapping so sampled draft ids can be converted to target ids."
299
+ )
300
+
301
orig_to_new_substr = {"mask_embedding": None}
302
if not includes_embed_tokens:
303
orig_to_new_substr["embed_tokens"] = None
vllm/model_executor/models/qwen3_omni_moe_thinker.py
+11
-1
@@ -83,6 +83,7 @@ from vllm.v1.attention.backends.registry import AttentionBackendEnum
83
84
from .interfaces import (
85
MultiModalEmbeddings,
86
+ SupportsEagle3,
87
SupportsMRoPE,
88
SupportsMultiModal,
89
SupportsPP,
@@ -1067,7 +1068,7 @@ class Qwen3MoeLLMModel(Qwen3MoeModel):
1068
intermediate_tensors: IntermediateTensors | None = None,
1069
inputs_embeds: torch.Tensor | None = None,
1070
deepstack_input_embeds: IntermediateTensors | None = None,
1070
- ) -> torch.Tensor | IntermediateTensors:
1071
+ ) -> torch.Tensor | IntermediateTensors | tuple[torch.Tensor, list[torch.Tensor]]:
1072
if get_pp_group().is_first_rank:
1073
if inputs_embeds is not None:
1074
hidden_states = inputs_embeds
@@ -1078,6 +1079,9 @@ class Qwen3MoeLLMModel(Qwen3MoeModel):
1079
assert intermediate_tensors is not None
1080
hidden_states = intermediate_tensors["hidden_states"]
1081
residual = intermediate_tensors["residual"]
1082
+ aux_hidden_states = self._maybe_add_hidden_state(
1083
+ [], self.start_layer, hidden_states, residual
1084
+ )
1085
for layer_idx, layer in enumerate(
1086
self.layers[self.start_layer : self.end_layer]
1087
):
@@ -1096,12 +1100,17 @@ class Qwen3MoeLLMModel(Qwen3MoeModel):
1100
hidden_states
1101
+ deepstack_input_embeds[f"deepstack_input_embeds_{layer_idx}"]
1102
)
1103
+ self._maybe_add_hidden_state(
1104
+ aux_hidden_states, layer_idx + 1, hidden_states, residual
1105
+ )
1106
1107
if not get_pp_group().is_last_rank:
1108
return IntermediateTensors(
1109
{"hidden_states": hidden_states, "residual": residual}
1110
)
1111
hidden_states, _ = self.norm(hidden_states, residual)
1112
+ if aux_hidden_states:
1113
+ return hidden_states, aux_hidden_states
1114
return hidden_states
1115
1116
@@ -1577,6 +1586,7 @@ class Qwen3OmniMoeThinkerForConditionalGeneration(
1586
SupportsMultiModal,
1587
SupportsPP,
1588
SupportsMRoPE,
1589
+ SupportsEagle3,
1590
Qwen3OmniMoeConditionalGenerationMixin,
1591
SupportsTranscription,
1592
):
vllm/model_executor/models/registry.py
+1
@@ -643,6 +643,7 @@ _SPECULATIVE_DECODING_MODELS = {
643
"DFlashMuseGlimmerAssistantModel": ("qwen3_dflash", "DFlashQwen3ForCausalLM"),
644
"DSparkDraftModel": ("vllm.models.deepseek_v4", "DSparkDeepseekV4ForCausalLM"),
645
"Qwen3DSparkModel": ("qwen3_dspark", "Qwen3DSparkForCausalLM"),
646
+ "Qwen3OmniDSparkModel": ("qwen3_dspark", "Qwen3DSparkForCausalLM"),
647
"K3DSparkModel": (
648
"vllm.models.kimi_k3.nvidia.dspark_mla",
649
"K3DSparkForCausalLM",
vllm/transformers_utils/configs/speculators/algos.py
+17
-4
@@ -154,10 +154,22 @@ def update_dspark(config_dict: dict, pre_trained_config: dict) -> None:
154
False (anchor is a bonus token, only mask tokens predict, yielding
155
block_size - 1 speculative tokens).
156
"""
157
- pre_trained_config["architectures"] = ["Qwen3DSparkModel"]
158
- pre_trained_config["sample_from_anchor"] = config_dict.get(
159
- "sample_from_anchor", False
160
- )
157
+ architectures = config_dict.get("architectures") or []
158
+ supported_architectures = {
159
+ "Qwen3DSparkModel",
160
+ "Qwen3OmniDSparkModel",
161
+ }
162
+ selected_architectures = [
163
+ architecture
164
+ for architecture in architectures
165
+ if architecture in supported_architectures
166
+ ]
167
+ # Legacy msModelSpec checkpoints use a training-only architecture name.
168
+ pre_trained_config["architectures"] = selected_architectures or ["Qwen3DSparkModel"]
169
+
170
+ sample_from_anchor = config_dict.get("sample_from_anchor", False)
171
+ pre_trained_config["sample_from_anchor"] = sample_from_anchor
172
+ pre_trained_config["dspark_bonus_anchor"] = not sample_from_anchor
173
174
aux_layer_ids = config_dict["aux_hidden_state_layer_ids"]
175
pre_trained_config["eagle_aux_hidden_state_layer_ids"] = aux_layer_ids
@@ -173,6 +185,7 @@ def update_dspark(config_dict: dict, pre_trained_config: dict) -> None:
185
"block_size",
186
"enable_confidence_head",
187
"confidence_head_with_markov",
188
+ "use_aux_hidden_state",
189
):
190
if config_dict.get(key) is not None:
191
pre_trained_config[key] = config_dict[key]
vllm/v1/worker/gpu/spec_decode/dspark/utils.py
+15
-4
@@ -86,11 +86,16 @@ def load_dspark_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Mo
86
)
87
target_inner = target_language_model.model
88
draft_inner = draft_model.model
89
+ target_vocab_size = vllm_config.model_config.get_vocab_size()
90
91
target_embed = getattr(target_inner, "embed_tokens", None)
92
draft_embed = getattr(draft_inner, "embed_tokens", None)
92
- if target_embed is not None and _should_share(
93
- draft_model, "has_own_embed_tokens", draft_embed, target_embed
93
+ if (
94
+ target_embed is not None
95
+ and draft_model_config.get_vocab_size() <= target_vocab_size
96
+ and _should_share(
97
+ draft_model, "has_own_embed_tokens", draft_embed, target_embed
98
+ )
99
):
100
if draft_embed is not None:
101
del draft_inner.embed_tokens
@@ -98,8 +103,14 @@ def load_dspark_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Mo
103
104
target_lm_head = get_target_lm_head(target_model, target_language_model)
105
draft_lm_head = getattr(draft_model, "lm_head", None)
101
- if target_lm_head is not None and _should_share(
102
- draft_model, "has_own_lm_head", draft_lm_head, target_lm_head
106
+ draft_output_vocab_size = (
107
+ getattr(draft_model_config.hf_config, "draft_vocab_size", None)
108
+ or draft_model_config.get_vocab_size()
109
+ )
110
+ if (
111
+ target_lm_head is not None
112
+ and draft_output_vocab_size == target_vocab_size
113
+ and _should_share(draft_model, "has_own_lm_head", draft_lm_head, target_lm_head)
114
):
115
if draft_lm_head is not None:
116
del draft_model.lm_head