[Spec Decode][DSpark] Add Gemma4-12B DSpark draft model (#47216)
Signed-off-by: DiegoCao <DiegoCao@users.noreply.github.com> Co-authored-by: DiegoCao <DiegoCao@users.noreply.github.com>
Hangrui Cao committed
Jul 16, 2026 at 14:51 UTC
4a394bfcda817562af9a9e070e7c50ae57143b99
6 files changed
+417
-4
tests/evals/gsm8k/gsm8k_eval.py
+12
-4
@@ -283,28 +283,36 @@ def evaluate_gsm8k_offline(
283
max_tokens: int = 256,
284
temperature: float = 0.0,
285
gen_prefix: str = "",
286
+ use_chat_completions: bool = False,
287
) -> dict[str, float | int]:
288
"""Evaluate GSM8K accuracy using an offline vllm.LLM object.
289
290
Same prompts and scoring as evaluate_gsm8k(), but runs generation
291
directly via llm.generate() instead of calling a server over HTTP.
292
+
293
+ When ``use_chat_completions=True``, prompts go through the chat template via
294
+ ``llm.chat()`` instead of raw completion (for instruction-tuned models).
295
"""
296
from vllm import SamplingParams
297
298
prompts, labels = _build_gsm8k_prompts(num_questions, num_shots, gen_prefix)
295
-
299
sampling_params = SamplingParams(
300
temperature=temperature,
301
max_tokens=max_tokens,
302
stop=["Question", "Assistant:", "<|separator|>"],
303
)
301
-
304
+ mode = "chat" if use_chat_completions else "completion"
305
print(
303
- f"Running offline GSM8K evaluation: {len(prompts)} questions, {num_shots}-shot"
306
+ f"Running offline GSM8K evaluation: {len(prompts)} questions, "
307
+ f"{num_shots}-shot, {mode}"
308
)
309
310
tic = time.perf_counter()
307
- outputs = llm.generate(prompts, sampling_params)
311
+ if use_chat_completions:
312
+ conversations = [[{"role": "user", "content": p}] for p in prompts]
313
+ outputs = llm.chat(conversations, sampling_params)
314
+ else:
315
+ outputs = llm.generate(prompts, sampling_params)
316
latency = time.perf_counter() - tic
317
318
states = [o.outputs[0].text for o in outputs]
tests/models/registry.py
+6
@@ -1477,6 +1477,12 @@ _SPECULATIVE_DECODING_EXAMPLE_MODELS = {
1477
is_available_online=False,
1478
use_original_num_layers=True, # DSpark backbone requires all layers
1479
),
1480
+ "Gemma4DSparkModel": _HfExamplesInfo(
1481
+ "google/gemma-4-12B-it",
1482
+ speculative_model="deepseek-ai/dspark_gemma4_12b_block7",
1483
+ is_available_online=False,
1484
+ use_original_num_layers=True,
1485
+ ),
1486
# [Eagle]
1487
"EagleCohereForCausalLM": _HfExamplesInfo(
1488
"/host/engines/cohere-moe",
tests/v1/e2e/spec_decode/test_spec_decode.py
+67
@@ -286,6 +286,73 @@ def test_suffix_decoding_acceptance(
286
cleanup_dist_env_and_memory()
287
288
289
+@pytest.mark.slow_test
290
+@large_gpu_mark(min_gb=80)
291
+def test_gemma4_dspark_correctness_and_acceptance_rate(
292
+ monkeypatch: pytest.MonkeyPatch,
293
+):
294
+ """
295
+ E2E test for Gemma4 DSpark speculative decoding: acceptance rate/length
296
+ regression coverage plus GSM8K correctness, at temperature=1.0 to exercise
297
+ the probabilistic draft-sampling/rejection-sampling path (not just greedy).
298
+
299
+ Uses google/gemma-4-12B-it as target with the dspark_gemma4_12b_block7 draft
300
+ model. Exercises the full Gemma4 DSpark path (draft build over the reused
301
+ base classes DFlashQwen3Model / Qwen3DSparkForCausalLM / Gemma4MTP* /
302
+ DSparkMarkovHead, the fused context-KV precompute, the Markov head, and
303
+ rejection sampling), so it fails if any base class drifts out of sync.
304
+
305
+ gemma-4-12B is instruct-tuned, so GSM8K is run through the chat template
306
+ (use_chat_completions=True; raw few-shot completion collapses to a few
307
+ percent). Reference: measured over 5 runs of 200 GSM8K questions at
308
+ temperature=1.0 (prefix caching disabled):
309
+ accuracy: min=0.900 max=0.955 mean=0.937
310
+ acceptance_rate: min=0.578 max=0.595 mean=0.588
311
+ acceptance_len: min=5.044 max=5.167 mean=5.116
312
+ Thresholds set conservatively to 10% to avoid flaking due to unlucky sampling
313
+ """
314
+ monkeypatch.setenv("VLLM_USE_FLASHINFER_SAMPLER", "0")
315
+
316
+ spec_llm = LLM(
317
+ model="google/gemma-4-12B-it",
318
+ trust_remote_code=True,
319
+ speculative_config={
320
+ "method": "dspark",
321
+ "model": "deepseek-ai/dspark_gemma4_12b_block7",
322
+ "num_speculative_tokens": 7,
323
+ "draft_sample_method": "probabilistic",
324
+ },
325
+ max_model_len=8192,
326
+ max_num_seqs=32,
327
+ gpu_memory_utilization=0.8,
328
+ enforce_eager=True,
329
+ enable_prefix_caching=False,
330
+ disable_log_stats=False,
331
+ )
332
+ try:
333
+ results = evaluate_gsm8k_offline(
334
+ spec_llm, num_questions=200, temperature=1.0, use_chat_completions=True
335
+ )
336
+ gsm8k_accuracy = results["accuracy"]
337
+
338
+ metrics = spec_llm.get_metrics()
339
+ acceptance_rate = compute_acceptance_rate(metrics)
340
+ acceptance_len = compute_acceptance_len(metrics)
341
+ print(
342
+ f"Gemma4 DSpark acceptance_rate={acceptance_rate:.2f}, "
343
+ f"acceptance_len={acceptance_len:.2f}, "
344
+ f"gsm8k_accuracy={gsm8k_accuracy:.3f}"
345
+ )
346
+
347
+ assert acceptance_rate >= 0.588 * 0.9
348
+ assert acceptance_len >= 5.116 * 0.9
349
+ assert gsm8k_accuracy >= 0.937 * 0.9
350
+ finally:
351
+ del spec_llm
352
+ torch.accelerator.empty_cache()
353
+ cleanup_dist_env_and_memory()
354
+
355
+
356
@pytest.mark.parametrize(
357
["model_path", "expected_accuracy_threshold"],
358
[
vllm/config/speculative.py
+19
@@ -882,6 +882,7 @@ class SpeculativeConfig:
882
elif (
883
"dspark" in self.draft_model_config.model.lower()
884
or "Qwen3DSparkModel" in self.draft_model_config.architectures
885
+ or "Gemma4DSparkModel" in self.draft_model_config.architectures
886
):
887
self.method = "dspark"
888
elif self.draft_model_config.hf_config.model_type == "medusa":
@@ -932,6 +933,7 @@ class SpeculativeConfig:
933
934
if self.method == "dspark" and (
935
"Qwen3DSparkModel" not in self.draft_model_config.architectures
936
+ and "Gemma4DSparkModel" not in self.draft_model_config.architectures
937
):
938
# DeepSeek-V4 DSpark reuses the full DeepSeek-V4 config
939
# and its weights ship in the target checkpoint.
@@ -940,6 +942,23 @@ class SpeculativeConfig:
942
"DSparkDraftModel"
943
]
944
self.update_arch_()
945
+ elif (
946
+ self.method == "dspark"
947
+ and "Gemma4DSparkModel" in self.draft_model_config.architectures
948
+ ):
949
+ # Normalize the self-contained Gemma4 draft's config keys to
950
+ # the DSpark conventions.
951
+ hf = self.draft_model_config.hf_config
952
+ if (
953
+ getattr(hf, "dspark_target_layer_ids", None) is None
954
+ and getattr(hf, "target_layer_ids", None) is not None
955
+ ):
956
+ hf.dspark_target_layer_ids = hf.target_layer_ids
957
+ if (
958
+ getattr(hf, "n_predict", None) is None
959
+ and getattr(hf, "block_size", None) is not None
960
+ ):
961
+ hf.n_predict = hf.block_size
962
963
if self.method in ("dflash", "dspark"):
964
self.parallel_drafting = True
vllm/model_executor/models/gemma4_dspark.py
new
+312
@@ -0,0 +1,312 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Gemma4 DSpark draft model for speculative decoding."""
4
+
5
+from collections.abc import Iterable
6
+
7
+import torch
8
+import torch.nn as nn
9
+import torch.nn.functional as F
10
+
11
+from vllm import _custom_ops as ops
12
+from vllm.compilation.decorators import support_torch_compile
13
+from vllm.config import CacheConfig, VllmConfig, get_current_vllm_config
14
+from vllm.model_executor.layers.layernorm import RMSNorm
15
+from vllm.model_executor.layers.linear import ColumnParallelLinear, ReplicatedLinear
16
+from vllm.model_executor.layers.logits_processor import LogitsProcessor
17
+from vllm.model_executor.layers.quantization import QuantizationConfig
18
+from vllm.model_executor.layers.vocab_parallel_embedding import (
19
+ ParallelLMHead,
20
+ VocabParallelEmbedding,
21
+)
22
+from vllm.model_executor.model_loader.weight_utils import default_weight_loader
23
+
24
+from .gemma4_mtp import Gemma4MTPAttention, Gemma4MTPDecoderLayer
25
+from .qwen3_dflash import DFlashQwen3Model
26
+from .qwen3_dspark import DSparkMarkovHead, Qwen3DSparkForCausalLM
27
+from .utils import extract_layer_index, maybe_prefix
28
+
29
+
30
+class Gemma4DSparkAttention(Gemma4MTPAttention):
31
+ """Gemma4 attention with its own KV cache and K/V projections."""
32
+
33
+ def __init__(
34
+ self,
35
+ config,
36
+ cache_config: CacheConfig | None,
37
+ quant_config: QuantizationConfig | None,
38
+ prefix: str,
39
+ ) -> None:
40
+ is_full = config.layer_types[extract_layer_index(prefix)] == "full_attention"
41
+ head_dim = (
42
+ getattr(config, "global_head_dim", config.head_dim)
43
+ if is_full
44
+ else config.head_dim
45
+ )
46
+ use_k_eq_v = is_full and getattr(config, "attention_k_eq_v", False)
47
+ num_kv_heads = (
48
+ getattr(config, "num_global_key_value_heads", config.num_key_value_heads)
49
+ if use_k_eq_v
50
+ else config.num_key_value_heads
51
+ )
52
+ super().__init__(
53
+ config=config,
54
+ hidden_size=config.hidden_size,
55
+ num_heads=config.num_attention_heads,
56
+ num_kv_heads=num_kv_heads,
57
+ head_dim=head_dim,
58
+ max_position_embeddings=config.max_position_embeddings,
59
+ cache_config=cache_config,
60
+ quant_config=quant_config,
61
+ attn_logits_soft_cap=getattr(config, "attn_logit_softcapping", None),
62
+ prefix=prefix,
63
+ )
64
+ self.is_kv_shared_layer = False
65
+ self.use_k_eq_v = use_k_eq_v
66
+ self.kv_size = self.num_kv_heads * self.head_dim
67
+ attn_bias = getattr(config, "attention_bias", False)
68
+ self.k_proj = ColumnParallelLinear(
69
+ config.hidden_size,
70
+ self.total_num_kv_heads * self.head_dim,
71
+ bias=attn_bias,
72
+ quant_config=quant_config,
73
+ prefix=f"{prefix}.k_proj",
74
+ )
75
+ self.v_proj = (
76
+ None
77
+ if use_k_eq_v
78
+ else ColumnParallelLinear(
79
+ config.hidden_size,
80
+ self.total_num_kv_heads * self.head_dim,
81
+ bias=attn_bias,
82
+ quant_config=quant_config,
83
+ prefix=f"{prefix}.v_proj",
84
+ )
85
+ )
86
+ self.k_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps)
87
+ self.v_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps, has_weight=False)
88
+
89
+ def _kv_proj(
90
+ self, hidden_states: torch.Tensor
91
+ ) -> tuple[torch.Tensor, torch.Tensor]:
92
+ k, _ = self.k_proj(hidden_states)
93
+ k_normed = self.k_norm(k.unflatten(-1, (self.num_kv_heads, self.head_dim)))
94
+ v_src = k if self.use_k_eq_v else self.v_proj(hidden_states)[0]
95
+ v_normed = self.v_norm(v_src.unflatten(-1, (self.num_kv_heads, self.head_dim)))
96
+ return k_normed.flatten(-2, -1), v_normed.flatten(-2, -1)
97
+
98
+ def forward(
99
+ self, positions: torch.Tensor, hidden_states: torch.Tensor, **kwargs
100
+ ) -> torch.Tensor:
101
+ q, _ = self.q_proj(hidden_states)
102
+ q = self.q_norm(q.unflatten(-1, (self.num_heads, self.head_dim))).flatten(
103
+ -2, -1
104
+ )
105
+ k, v = self._kv_proj(hidden_states)
106
+ q, k = self.rotary_emb(positions, q, k)
107
+ attn_output = self.attn(q, k, v)
108
+ output, _ = self.o_proj(attn_output)
109
+ return output
110
+
111
+
112
+class Gemma4DSparkDecoderLayer(Gemma4MTPDecoderLayer):
113
+ """Gemma4 MTP decoder layer using the KV-owning DSpark attention."""
114
+
115
+ def __init__(
116
+ self,
117
+ config,
118
+ cache_config: CacheConfig | None,
119
+ quant_config: QuantizationConfig | None,
120
+ prefix: str,
121
+ ) -> None:
122
+ super().__init__(
123
+ config, cache_config=cache_config, quant_config=quant_config, prefix=prefix
124
+ )
125
+ get_current_vllm_config().compilation_config.static_forward_context.pop(
126
+ f"{prefix}.self_attn.attn", None
127
+ )
128
+ self.self_attn = Gemma4DSparkAttention(
129
+ config, cache_config, quant_config, prefix=f"{prefix}.self_attn"
130
+ )
131
+
132
+
133
+@support_torch_compile
134
+class Gemma4DSparkModel(DFlashQwen3Model):
135
+ """Gemma4 DSpark draft backbone (Gemma4 layers + DSpark Markov head)."""
136
+
137
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
138
+ nn.Module.__init__(self)
139
+ assert vllm_config.speculative_config is not None
140
+ config = vllm_config.speculative_config.draft_model_config.hf_config
141
+ self.config = config
142
+ self.use_aux_hidden_state = True
143
+ self.target_layer_ids = tuple(
144
+ getattr(config, "dspark_target_layer_ids", None) or config.target_layer_ids
145
+ )
146
+ current_vllm_config = get_current_vllm_config()
147
+ cache_config = current_vllm_config.cache_config
148
+ quant_config = current_vllm_config.quant_config
149
+
150
+ self.embed_tokens = VocabParallelEmbedding(
151
+ config.vocab_size,
152
+ config.hidden_size,
153
+ prefix=maybe_prefix(prefix, "embed_tokens"),
154
+ )
155
+ self.register_buffer(
156
+ "normalizer",
157
+ torch.tensor(config.hidden_size**0.5, dtype=vllm_config.model_config.dtype),
158
+ persistent=False,
159
+ )
160
+ self.fc = ReplicatedLinear(
161
+ config.hidden_size * len(self.target_layer_ids),
162
+ config.hidden_size,
163
+ bias=False,
164
+ return_bias=False,
165
+ quant_config=quant_config,
166
+ prefix=maybe_prefix(prefix, "fc"),
167
+ )
168
+ self.hidden_norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
169
+ self.layers = nn.ModuleList(
170
+ Gemma4DSparkDecoderLayer(
171
+ config,
172
+ cache_config,
173
+ quant_config,
174
+ prefix=maybe_prefix(prefix, f"layers.{i}"),
175
+ )
176
+ for i in range(config.num_hidden_layers)
177
+ )
178
+ self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
179
+ draft_vocab_size = (
180
+ getattr(config, "draft_vocab_size", None) or config.vocab_size
181
+ )
182
+ self.markov_head = DSparkMarkovHead(
183
+ config.vocab_size,
184
+ draft_vocab_size,
185
+ config.markov_rank,
186
+ prefix=maybe_prefix(prefix, "markov_head"),
187
+ )
188
+
189
+ def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
190
+ return self.embed_tokens(input_ids) * self.normalizer
191
+
192
+ def _build_fused_kv_buffers(self) -> None:
193
+ layers_attn = [layer.self_attn for layer in self.layers]
194
+ attn0 = layers_attn[0]
195
+ assert all(a.use_k_eq_v for a in layers_attn), (
196
+ "Gemma4 DSpark fused precompute assumes uniform attention_k_eq_v layers"
197
+ )
198
+ self._build_context_kv_buffers(layers_attn, attn0.k_proj.bias is not None)
199
+ self._rope_head_size = attn0.rotary_emb.head_size
200
+ self._rope_cos_sin_cache = attn0.rotary_emb.cos_sin_cache
201
+ self._rope_is_neox = attn0.rotary_emb.is_neox_style
202
+ self._num_attn_layers = len(layers_attn)
203
+ self._kv_size = attn0.kv_size
204
+ self._head_dim = attn0.head_dim
205
+ self._num_kv_heads = attn0.num_kv_heads
206
+ self._rms_norm_eps = attn0.q_norm.variance_epsilon
207
+ self._attn_layers = [layer.self_attn.attn for layer in self.layers]
208
+
209
+ def _build_context_kv_buffers(
210
+ self, layers_attn: list[nn.Module], has_bias: bool
211
+ ) -> None:
212
+ self._hidden_norm_weight = self.hidden_norm.weight.data
213
+ self._fused_k_weight = torch.cat([a.k_proj.weight for a in layers_attn], dim=0)
214
+ self._fused_k_bias: torch.Tensor | None = (
215
+ torch.cat([a.k_proj.bias for a in layers_attn], dim=0) if has_bias else None
216
+ )
217
+ self._k_norm_weights = torch.stack(
218
+ [a.k_norm.weight.data for a in layers_attn], dim=0
219
+ ).contiguous()
220
+ # v_norm has no learnable scale; ones matching the K-norm call shape.
221
+ self._v_norm_weights = torch.ones(
222
+ len(layers_attn),
223
+ layers_attn[0].head_dim,
224
+ dtype=self._k_norm_weights.dtype,
225
+ device=self._k_norm_weights.device,
226
+ )
227
+
228
+ def _project_context_kv(
229
+ self,
230
+ context_states: torch.Tensor,
231
+ num_ctx: int,
232
+ num_layers: int,
233
+ num_kv_heads: int,
234
+ head_dim: int,
235
+ ) -> tuple[torch.Tensor, torch.Tensor]:
236
+ # Project once via k_proj for all layers. K is raw (the inherited path
237
+ # applies k_norm + RoPE); V = v_norm(same projection), no RoPE (k_eq_v).
238
+ normed = torch.empty_like(context_states)
239
+ ops.rms_norm(
240
+ normed, context_states, self._hidden_norm_weight, self._rms_norm_eps
241
+ )
242
+ all_k_flat = F.linear(normed, self._fused_k_weight, self._fused_k_bias)
243
+ all_k = (
244
+ all_k_flat.view(num_ctx, num_layers, num_kv_heads, head_dim)
245
+ .permute(1, 0, 2, 3)
246
+ .contiguous()
247
+ )
248
+ all_v = torch.empty_like(all_k)
249
+ ops.rms_norm(all_v, all_k, self._v_norm_weights, self._rms_norm_eps)
250
+ return all_k, all_v
251
+
252
+ def forward(
253
+ self,
254
+ input_ids: torch.Tensor,
255
+ positions: torch.Tensor,
256
+ input_embeds: torch.Tensor | None = None,
257
+ ) -> torch.Tensor:
258
+ hidden_states = (
259
+ self.embed_input_ids(input_ids) if input_embeds is None else input_embeds
260
+ )
261
+ for layer in self.layers:
262
+ hidden_states, _ = layer(positions, hidden_states, None)
263
+ return self.norm(hidden_states)
264
+
265
+
266
+class Gemma4DSparkForCausalLM(Qwen3DSparkForCausalLM):
267
+ """Gemma4 DSpark speculator over a self-contained draft checkpoint."""
268
+
269
+ dspark_shares_target_embeddings = False
270
+ packed_modules_mapping = {"gate_up_proj": ["gate_proj", "up_proj"]}
271
+
272
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
273
+ nn.Module.__init__(self)
274
+ assert vllm_config.speculative_config is not None
275
+ self.draft_model_config = vllm_config.speculative_config.draft_model_config
276
+ self.config = self.draft_model_config.hf_config
277
+ self.model = Gemma4DSparkModel(
278
+ vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
279
+ )
280
+ self.lm_head = ParallelLMHead(
281
+ self.config.vocab_size,
282
+ self.config.hidden_size,
283
+ prefix=maybe_prefix(prefix, "lm_head"),
284
+ )
285
+ self.logits_processor = LogitsProcessor(
286
+ self.config.vocab_size,
287
+ soft_cap=getattr(self.config, "final_logit_softcapping", None),
288
+ )
289
+ self.draft_id_to_target_id = None
290
+
291
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
292
+ stacked = [("gate_up_proj", "gate_proj", 0), ("gate_up_proj", "up_proj", 1)]
293
+ params = dict(self.named_parameters())
294
+ params.update(dict(self.named_buffers()))
295
+ loaded: set[str] = set()
296
+ for name, w in weights:
297
+ if "confidence_head" in name:
298
+ continue
299
+ if "lm_head" not in name:
300
+ name = "model." + name
301
+ for pn, wn, shard in stacked:
302
+ if wn in name and (mapped := name.replace(wn, pn)) in params:
303
+ params[mapped].weight_loader(params[mapped], w, shard)
304
+ loaded.add(mapped)
305
+ break
306
+ else:
307
+ if name in params:
308
+ p = params[name]
309
+ getattr(p, "weight_loader", default_weight_loader)(p, w)
310
+ loaded.add(name)
311
+ self.model._build_fused_kv_buffers()
312
+ return loaded
vllm/model_executor/models/registry.py
+1
@@ -609,6 +609,7 @@ _SPECULATIVE_DECODING_MODELS = {
609
"DSparkDraftModel": ("vllm.models.deepseek_v4", "DSparkDeepseekV4ForCausalLM"),
610
"Qwen3DSparkModel": ("qwen3_dspark", "Qwen3DSparkForCausalLM"),
611
"DFlashLagunaForCausalLM": ("laguna_dflash", "DFlashLagunaForCausalLM"),
612
+ "Gemma4DSparkModel": ("gemma4_dspark", "Gemma4DSparkForCausalLM"),
613
"PEagleDraftModel": ("llama_eagle3", "Eagle3LlamaForCausalLM"),
614
"PeagleLlamaForCausalLM": ("llama_eagle3", "Eagle3LlamaForCausalLM"),
615
"Eagle3LlamaForCausalLM": ("llama_eagle3", "Eagle3LlamaForCausalLM"),