[Bugfix][Model Runner V2][Spec Decode] Decouple the draft's gumbel noise stream from the target's (#54282)

Signed-off-by: Giancarlo Delfin <gdelfin@inferact.ai>

Giancarlo Delfin committed Aug 29, 2026 at 09:58 UTC fe755c88995ad468882517b6c4bdd60138d46a3a
11 files changed +232 -30
tests/v1/spec_decode/test_rejection_sampler_utils.py
+96
@@ -6,6 +6,7 @@ import math
6 import pytest
7 import torch
8
9 +from vllm.v1.worker.gpu.sample.gumbel import gumbel_sample
10 from vllm.v1.worker.gpu.spec_decode.rejection_sampler_utils import (
11 rejection_sample,
12 )
@@ -195,6 +196,101 @@ def test_stochastic_rejection_sample(
196 )
197
198
199 +# The test above spreads its samples too thin to resolve a small distributional
200 +# bias: VOCAB_SIZE bins over 10 * VOCAB_SIZE trials is ~10 samples per bin.
201 +# Sixteen bins over 200K trials is ~12K per bin, which resolves a few percent.
202 +NARROW_VOCAB_SIZE = 16
203 +NARROW_NUM_TRIALS = 200_000
204 +
205 +
206 +def _gumbel_drafted_tokens(
207 + inputs: dict,
208 + draft_logits_1d: torch.Tensor,
209 + num_trials: int,
210 + num_speculative_steps: int,
211 +) -> torch.Tensor:
212 + """Proposals drawn with gumbel_sample, shaped like inputs["draft_sampled"].
213 +
214 + _build_rejection_sample_inputs draws them with torch.multinomial, which is
215 + independent of the resample noise by construction. Production drafts come
216 + from gumbel_sample keyed by pos[t * (K + 1) + i] for step i of trial t --
217 + the same entry _rejection_kernel and _resample_kernel read for that token --
218 + so the draft and the residual compete for one noise stream.
219 + """
220 + k = num_speculative_steps
221 + vocab_size = draft_logits_1d.shape[0]
222 + device = draft_logits_1d.device
223 + draft_tokens = gumbel_sample(
224 + draft_logits_1d.unsqueeze(0).expand(num_trials * k, vocab_size).float(),
225 + inputs["expanded_idx_mapping"]
226 + .view(num_trials, k + 1)[:, :k]
227 + .reshape(-1)
228 + .contiguous(),
229 + inputs["temperature"],
230 + inputs["seed"],
231 + inputs["pos"].view(num_trials, k + 1)[:, :k].reshape(-1).contiguous(),
232 + apply_temperature=True,
233 + is_drafting=True,
234 + )
235 + draft_sampled = torch.zeros(num_trials * (k + 1), dtype=torch.int64, device=device)
236 + draft_sampled.view(num_trials, k + 1)[:, 1:] = draft_tokens.view(num_trials, k)
237 + return draft_sampled
238 +
239 +
240 +@pytest.mark.parametrize("num_speculative_steps", [1, 3])
241 +def test_gumbel_drafted_rejection_sample_is_unbiased(num_speculative_steps: int):
242 + """The proposal and the residual resample must not share a noise vector.
243 +
244 + Draws proposals on the same (seed, pos) stream the sampler verifies and
245 + resamples with, then checks the output still follows the target. Conditioned
246 + on a proposal winning the argmax, every other token's Gumbel is truncated
247 + below that max -- most tightly for the tokens the draft ranked highest -- so
248 + a shared stream makes the residual under-weight exactly those tokens.
249 +
250 + Runs narrow because the wide-vocab test above cannot resolve this: dropping
251 + `is_drafting=True` in _gumbel_drafted_tokens takes position 0 from chi2 ~12 to
252 + ~1500 against a threshold of ~70 here, while leaving that test passing.
253 + """
254 + torch.manual_seed(42)
255 + device = "cuda"
256 +
257 + # A draft that ranks tokens in exactly the opposite order rejects ~73% of
258 + # proposals, so most trials reach the residual resample where the bias
259 + # lives. The disagreement has to be constructed rather than sampled: two
260 + # independent randn draws land close together often enough that the signal
261 + # swings between chi2 ~14 and ~1900 depending on the seed. At temperature
262 + # 1.0 the target needs no scaling before being passed in.
263 + target_logits_1d = torch.randn(
264 + NARROW_VOCAB_SIZE, device=device, dtype=torch.float32
265 + )
266 + draft_logits_1d = -target_logits_1d
267 +
268 + inputs = _build_rejection_sample_inputs(
269 + target_logits_1d,
270 + draft_logits_1d,
271 + num_speculative_steps,
272 + temperature=1.0,
273 + num_trials=NARROW_NUM_TRIALS,
274 + )
275 + inputs["draft_sampled"] = _gumbel_drafted_tokens(
276 + inputs, draft_logits_1d, NARROW_NUM_TRIALS, num_speculative_steps
277 + )
278 +
279 + sampled, num_sampled = rejection_sample(
280 + **inputs, num_speculative_steps=num_speculative_steps
281 + )
282 +
283 + # Position 0 carries the power: every trial reaches it, while later
284 + # positions are only reached on acceptance, which is rare by construction.
285 + assert (num_sampled >= 1).all()
286 + target_probs = torch.softmax(target_logits_1d, dim=0)
287 + for pos in range(num_speculative_steps + 1):
288 + accepted_mask = num_sampled >= pos + 1
289 + _assert_distribution_match(
290 + sampled[accepted_mask, pos], target_probs, device, label=f"position {pos}"
291 + )
292 +
293 +
294 @pytest.mark.parametrize("num_speculative_steps", [1, 3])
295 def test_greedy_rejection_sample(num_speculative_steps: int):
296 """
tests/v1/worker/test_gpu_gumbel_sample.py
+67 -3
@@ -54,6 +54,7 @@ def _sample(
54 *,
55 use_fp64: bool = False,
56 temperature: float = 1.0,
57 + is_drafting: bool = False,
58 ) -> torch.Tensor:
59 """Sample `num_samples` tokens from one logit vector.
60
@@ -74,6 +75,7 @@ def _sample(
75 seed,
76 pos,
77 apply_temperature=True,
78 + is_drafting=is_drafting,
79 use_fp64=use_fp64,
80 )
81
@@ -84,7 +86,11 @@ def _z_score(observed: int, expected: float, num_trials: int) -> float:
86
87
88 def _sample_histogram(
87 - logits_1d: torch.Tensor, num_samples: int, *, chunk: int = 1_000_000
89 + logits_1d: torch.Tensor,
90 + num_samples: int,
91 + *,
92 + chunk: int = 1_000_000,
93 + is_drafting: bool = False,
94 ) -> torch.Tensor:
95 """Histogram of `num_samples` draws, accumulated in chunks.
96
@@ -101,7 +107,13 @@ def _sample_histogram(
107 seed = torch.tensor([0xABCD], dtype=torch.int64, device=DEVICE)
108 pos = torch.arange(start, start + size, dtype=torch.int64, device=DEVICE)
109 out = gumbel_sample(
104 - logits, idx_mapping, temp, seed, pos, apply_temperature=True
110 + logits,
111 + idx_mapping,
112 + temp,
113 + seed,
114 + pos,
115 + apply_temperature=True,
116 + is_drafting=is_drafting,
117 )
118 hist += torch.bincount(out, minlength=vocab_size).double()
119 return hist
@@ -164,6 +176,55 @@ def test_full_vocab_distribution_fidelity():
176 assert chi2 < df + 10 * math.sqrt(2 * df), f"chi2={chi2:.0f}, df={df}"
177
178
179 +# --------------------------- Noise streams ---------------------------------
180 +
181 +
182 +def test_drafting_uses_a_separate_noise_stream():
183 + """is_drafting salts the Philox offset: same inputs, different draws.
184 +
185 + The draft proposal and the residual resample after a rejection must be
186 + independent. They key noise by the same (seed, pos), so only the salt keeps
187 + them apart -- without it the resample inherits the very noise vector that
188 + picked the rejected proposal. See test_stochastic_rejection_sample in
189 + tests/v1/spec_decode/test_rejection_sampler_utils.py for the distributional
190 + consequence.
191 +
192 + Relocating the offset must not distort the draw either, so both streams are
193 + checked against the target's far-tail mass, which sits HEAD_LOG_GAP logits
194 + below the head -- the regime where fp32 Gumbel precision matters.
195 + """
196 + counts = _make_heavy_tailed_counts()
197 + total = counts.sum().item()
198 + logits = _counts_to_logits(counts)
199 + tail_prob = (total - counts[0].item()) / total
200 +
201 + target = _sample(logits, NUM_SAMPLES, is_drafting=False)
202 + draft = _sample(logits, NUM_SAMPLES, is_drafting=True)
203 +
204 + # The head dominates, so the streams agree on most draws by construction.
205 + # Compare instead which draws leave the head: shared noise makes that
206 + # identical, independent noise makes them differ on ~2p(1-p) of draws.
207 + target_tail = target != 0
208 + draft_tail = draft != 0
209 + disagree = (target_tail != draft_tail).double().mean().item()
210 + assert disagree > tail_prob, (
211 + f"streams leave the head on the same draws ({disagree:.3e} disagreement "
212 + f"vs tail mass {tail_prob:.3e}); the draft salt is not taking effect"
213 + )
214 +
215 + # Both streams must still reproduce the target's tail mass.
216 + for name, tail in (("target", target_tail), ("draft", draft_tail)):
217 + tail_count = tail.sum().item()
218 + z = _z_score(tail_count, NUM_SAMPLES * tail_prob, NUM_SAMPLES)
219 + assert abs(z) < Z_TOLERANCE, (
220 + f"{name} tail mass {tail_count / NUM_SAMPLES:.3e} != "
221 + f"{tail_prob:.3e} (z={z:.2f})"
222 + )
223 +
224 + # The draft stream is reproducible.
225 + assert torch.equal(draft, _sample(logits, NUM_SAMPLES, is_drafting=True))
226 +
227 +
228 # ----------------------------- Edge cases ----------------------------------
229
230
@@ -178,7 +239,7 @@ def test_greedy_temperature_zero_returns_argmax():
239 pos = torch.arange(num_reqs, dtype=torch.int64, device=DEVICE)
240
241 sampled = gumbel_sample(
181 - logits, idx_mapping, temp, seed, pos, apply_temperature=True
242 + logits, idx_mapping, temp, seed, pos, apply_temperature=True, is_drafting=False
243 )
244 assert torch.equal(sampled, logits.argmax(dim=-1))
245
@@ -278,6 +339,7 @@ def test_logits_cache_stores_input_logits_bitwise(
339 seed,
340 pos,
341 apply_temperature=True,
342 + is_drafting=True,
343 logits_cache=cache,
344 logits_cache_col=cols,
345 )
@@ -326,6 +388,7 @@ def test_logits_cache_columns_stay_separate_across_steps(extra_cache_cols: int):
388 seed,
389 pos,
390 apply_temperature=True,
391 + is_drafting=True,
392 logits_cache=cache,
393 logits_cache_col=cols[step],
394 )
@@ -357,6 +420,7 @@ def test_logits_cache_narrower_than_logits_is_rejected():
420 seed,
421 pos,
422 apply_temperature=True,
423 + is_drafting=True,
424 logits_cache=cache,
425 logits_cache_col=torch.tensor(0, dtype=torch.int32, device=DEVICE),
426 )
vllm/v1/worker/gpu/sample/gumbel.py
+15
@@ -13,6 +13,12 @@ from vllm.triton_utils import HAS_TRITON, tl, tldevice, triton
13 # attribute is `None`, and `tl.constexpr(...)` would crash at import time.
14 _TL_RAND_MIN = tl.constexpr(4.6566127342e-10) if HAS_TRITON else 4.6566127342e-10
15
16 +# Offset salt keeping the draft's Gumbel noise disjoint from the target's.
17 +# Verification is a probability-ratio test, not a Gumbel coupling, so a proposal
18 +# and the residual it is resampled from must not share a noise vector.
19 +# Positions are int64 and never approach 2**30, so the streams cannot collide.
20 +_DRAFT_NOISE_SALT = tl.constexpr(1 << 30) if HAS_TRITON else (1 << 30)
21 +
22
23 @triton.jit
24 def _temperature_kernel(
@@ -89,6 +95,7 @@ def gumbel_noised_argmax(
95 seed,
96 pos,
97 temp,
98 + IS_DRAFTING: tl.constexpr,
99 USE_FP64: tl.constexpr,
100 APPLY_TEMPERATURE: tl.constexpr = True,
101 ):
@@ -108,6 +115,8 @@ def gumbel_noised_argmax(
115 if USE_FP64:
116 logits = logits.to(tl.float64)
117 if temp != 0.0:
118 + if IS_DRAFTING:
119 + pos = pos + _DRAFT_NOISE_SALT
120 gumbel_seed = tl.randint(seed, pos)
121 if USE_FP64:
122 u = tl_rand64(gumbel_seed, keys, includes_zero=False)
@@ -137,6 +146,7 @@ def gumbel_block_argmax(
146 logits_cache_stride_1,
147 logits_cache_col_ptr,
148 vocab_size,
149 + IS_DRAFTING: tl.constexpr,
150 APPLY_TEMPERATURE: tl.constexpr,
151 USE_FP64: tl.constexpr,
152 PER_TOKEN_COL: tl.constexpr = False,
@@ -173,6 +183,7 @@ def gumbel_block_argmax(
183 seed,
184 pos,
185 temp,
186 + IS_DRAFTING=IS_DRAFTING,
187 USE_FP64=USE_FP64,
188 APPLY_TEMPERATURE=APPLY_TEMPERATURE,
189 )
@@ -197,6 +208,7 @@ def _gumbel_sample_kernel(
208 temp_ptr,
209 vocab_size,
210 BLOCK_SIZE: tl.constexpr,
211 + IS_DRAFTING: tl.constexpr,
212 APPLY_TEMPERATURE: tl.constexpr,
213 USE_FP64: tl.constexpr,
214 PER_TOKEN_COL: tl.constexpr,
@@ -226,6 +238,7 @@ def _gumbel_sample_kernel(
238 logits_cache_stride_1,
239 logits_cache_col_ptr,
240 vocab_size,
241 + IS_DRAFTING=IS_DRAFTING,
242 APPLY_TEMPERATURE=APPLY_TEMPERATURE,
243 USE_FP64=USE_FP64,
244 PER_TOKEN_COL=PER_TOKEN_COL,
@@ -242,6 +255,7 @@ def gumbel_sample(
255 seed: torch.Tensor, # [max_num_reqs]
256 pos: torch.Tensor, # [num_tokens]
257 apply_temperature: bool,
258 + is_drafting: bool,
259 logits_cache: torch.Tensor | None = None, # [max_num_reqs, num_cols, vocab_size]
260 logits_cache_col: torch.Tensor | None = None, # scalar or [num_tokens]
261 use_fp64: bool = False,
@@ -281,6 +295,7 @@ def gumbel_sample(
295 temperature,
296 vocab_size,
297 BLOCK_SIZE=BLOCK_SIZE,
298 + IS_DRAFTING=is_drafting,
299 APPLY_TEMPERATURE=apply_temperature,
300 USE_FP64=use_fp64,
301 PER_TOKEN_COL=per_token_col,
vllm/v1/worker/gpu/sample/sampler.py
+1
@@ -317,6 +317,7 @@ class Sampler:
317 self.sampling_states.seeds.gpu,
318 pos,
319 apply_temperature=False,
320 + is_drafting=False,
321 use_fp64=self.use_fp64_gumbel,
322 )
323 return sampled, processed_logits
vllm/v1/worker/gpu/spec_decode/autoregressive/speculator.py
+30 -12
@@ -37,6 +37,9 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
37 self.last_token_indices = torch.zeros(
38 self.max_num_reqs, dtype=torch.int64, device=device
39 )
40 + self.sample_src_positions = torch.zeros(
41 + self.max_num_reqs, dtype=torch.int64, device=device
42 + )
43
44 self.inputs_embeds: torch.Tensor | None = None
45
@@ -327,6 +330,7 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
330 input_batch.seq_lens,
331 num_rejected,
332 self.input_buffers,
333 + self.sample_src_positions,
334 self.max_model_len,
335 self.max_num_reqs,
336 advance_draft_positions=self.advance_draft_positions,
@@ -439,6 +443,10 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
443 ) -> None:
444 last_token_indices = self.last_token_indices[:num_reqs]
445 positions = self.input_buffers.positions[last_token_indices]
446 + # The output hidden state at position P (= positions) and the token id
447 + # at P+1 are used to draft the token at P+2. Sampling keys a draw by the
448 + # position before the sampled token, so the net adjustment is +1.
449 + sample_src_positions = positions + 1
450 idx_mapping = self.idx_mapping[:num_reqs]
451
452 last_hidden_states, hidden_states = self._run_model(
@@ -449,11 +457,11 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
457 cudagraph_runtime_mode=cudagraph_runtime_mode,
458 mm_inputs=mm_inputs,
459 )
452 - sample_hidden_states = last_hidden_states[last_token_indices]
460
461 + sample_hidden_states = last_hidden_states[last_token_indices]
462 self.draft_tokens[:num_reqs, 0] = self.sample_draft(
463 sample_hidden_states,
456 - positions,
464 + sample_src_positions,
465 idx_mapping,
466 self.temperature,
467 self.seeds,
@@ -465,6 +473,7 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
473 else:
474 self.hidden_states[:num_reqs] = hidden_states[last_token_indices]
475 self.input_buffers.positions[:num_reqs] = positions
476 + self.sample_src_positions[:num_reqs] = sample_src_positions
477
478 def _multi_step_decode(
479 self,
@@ -617,7 +626,6 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
626 self._prepare_eplb_forward(num_reqs)
627
628 idx_mapping = self.idx_mapping[:num_reqs]
620 - positions = self.input_buffers.positions[:num_reqs]
629 # Run the draft model forward pass.
630 last_hidden_states, hidden_states = self._run_model(
631 num_tokens_padded,
@@ -626,18 +634,13 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
634 num_tokens_across_dp,
635 cudagraph_runtime_mode,
636 )
629 - last_hidden_states = last_hidden_states[:num_reqs]
630 -
631 - sample_positions = positions
632 - if not self.advance_draft_positions:
633 - # The forward pass holds positions fixed (Q-only, shared target KV),
634 - # but Gumbel sampling still needs the absolute draft position.
635 - sample_positions = positions + self.current_draft_step
637
638 # Sample the draft tokens.
639 + sample_hidden_states = last_hidden_states[:num_reqs]
640 + sample_src_positions = self.sample_src_positions[:num_reqs]
641 draft_tokens = self.sample_draft(
639 - last_hidden_states,
640 - sample_positions,
642 + sample_hidden_states,
643 + sample_src_positions,
644 idx_mapping,
645 self.temperature,
646 self.seeds,
@@ -653,6 +656,7 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
656 self.draft_tokens,
657 self.hidden_states,
658 self.input_buffers,
659 + self.sample_src_positions,
660 num_reqs,
661 self.max_model_len,
662 self.num_speculative_steps,
@@ -790,6 +794,7 @@ def _prepare_decode_inputs_kernel(
794 num_rejected_ptr,
795 input_ids_ptr,
796 positions_ptr,
797 + sample_src_positions_ptr,
798 query_start_loc_ptr,
799 seq_lens_ptr,
800 max_model_len,
@@ -818,6 +823,10 @@ def _prepare_decode_inputs_kernel(
823 draft_token = tl.load(draft_tokens_ptr + req_idx * draft_tokens_stride)
824 tl.store(input_ids_ptr + req_idx, draft_token)
825
826 + # Advance the draft sampling key.
827 + sample_position = tl.load(sample_src_positions_ptr + req_idx)
828 + tl.store(sample_src_positions_ptr + req_idx, sample_position + 1)
829 +
830 target_seq_len = tl.load(target_seq_lens_ptr + req_idx)
831 num_rejected = tl.load(num_rejected_ptr + req_idx)
832 seq_len = target_seq_len - num_rejected
@@ -837,6 +846,7 @@ def prepare_decode_inputs(
846 target_seq_lens: torch.Tensor,
847 num_rejected: torch.Tensor,
848 input_buffers: InputBuffers,
849 + sample_src_positions: torch.Tensor,
850 max_model_len: int,
851 max_num_reqs: int,
852 advance_draft_positions: bool = True,
@@ -849,6 +859,7 @@ def prepare_decode_inputs(
859 num_rejected,
860 input_buffers.input_ids,
861 input_buffers.positions,
862 + sample_src_positions,
863 input_buffers.query_start_loc,
864 input_buffers.seq_lens,
865 max_model_len,
@@ -866,6 +877,7 @@ def _update_draft_inputs_kernel(
877 next_input_hidden_states_stride,
878 input_ids_ptr,
879 positions_ptr,
880 + sample_src_positions_ptr,
881 seq_lens_ptr,
882 draft_tokens_ptr,
883 current_draft_step_ptr,
@@ -891,6 +903,10 @@ def _update_draft_inputs_kernel(
903 # This is the final step. Skip updating draft forward inputs.
904 return
905
906 + # Advance the draft sampling key.
907 + sample_position = tl.load(sample_src_positions_ptr + req_idx)
908 + tl.store(sample_src_positions_ptr + req_idx, sample_position + 1)
909 +
910 # Write the sampled draft token into the input ids tensor for the next
911 # forward pass.
912 tl.store(input_ids_ptr + req_idx, draft_token)
@@ -932,6 +948,7 @@ def update_draft_inputs(
948 output_draft_tokens: torch.Tensor,
949 next_input_hidden_states: torch.Tensor,
950 input_buffers: InputBuffers,
951 + sample_src_positions: torch.Tensor,
952 num_reqs: int,
953 max_model_len: int,
954 num_speculative_steps: int,
@@ -945,6 +962,7 @@ def update_draft_inputs(
962 next_input_hidden_states.stride(0),
963 input_buffers.input_ids,
964 input_buffers.positions,
965 + sample_src_positions,
966 input_buffers.seq_lens,
967 draft_tokens,
968 current_draft_step,
vllm/v1/worker/gpu/spec_decode/dflash/speculator.py
+3 -3
@@ -261,11 +261,11 @@ class DFlashSpeculator(DraftModelSpeculator):
261 )
262 num_sample = num_reqs * self.num_speculative_steps
263 sample_hidden_states = last_hidden_states[self.sample_indices[:num_sample]]
264 - # sample_pos is the predicted token's position Q; verification keys
265 - # Gumbel by the predecessor (Q-1). sample_draft adds +1, so pass Q-2.
264 + # sample_pos is the predicted token's position P. Sampling keys a draw
265 + # by the position before the sampled token, P-1.
266 draft_tokens = self.sample_draft(
267 sample_hidden_states,
268 - self.sample_pos[:num_sample] - 2,
268 + self.sample_pos[:num_sample] - 1,
269 self.sample_idx_mapping[:num_sample],
270 self.temperature,
271 self.seeds,
vllm/v1/worker/gpu/spec_decode/dflash2/speculator.py
+5 -3
@@ -51,15 +51,17 @@ def _selector_walk_kernel(
51 other=0,
52 )
53
54 - # Candidate ids key the noise, matching the target's own sampling.
55 - position = tl.load(sample_pos_ptr + flat) - 1
54 + # sample_pos is the predicted token's position P. Sampling keys a draw
55 + # by the position before the sampled token, P-1.
56 + sample_pos = tl.load(sample_pos_ptr + flat) - 1
57 _, index = gumbel_noised_argmax(
58 scores,
59 candidates,
60 mask & valid,
61 seed,
61 - position,
62 + sample_pos,
63 temperature if SAMPLE_PROBABILISTIC else 0.0,
64 + IS_DRAFTING=True,
65 USE_FP64=USE_FP64,
66 )
67
vllm/v1/worker/gpu/spec_decode/dspark/speculator.py
+3 -2
@@ -135,8 +135,8 @@ class DSparkSpeculator(DFlashSpeculator):
135 buf.index_copy_(1, self._d2t_scatter_index, logits.to(buf.dtype))
136 logits = buf
137
138 - # sample_pos is the predicted token's position Q; the target verifies
139 - # it with the predecessor's Gumbel key (Q-1). Pass Q-1.
138 + # sample_pos is the predicted token's position P. Sampling keys a draw
139 + # by the position before the sampled token, P-1.
140 return gumbel_sample(
141 logits,
142 idx_map,
@@ -144,6 +144,7 @@ class DSparkSpeculator(DFlashSpeculator):
144 self.seeds,
145 sample_pos - 1,
146 apply_temperature=True,
147 + is_drafting=True,
148 logits_cache=self.draft_logits,
149 logits_cache_col=self._step_cols[step],
150 use_fp64=self.use_fp64_gumbel,
vllm/v1/worker/gpu/spec_decode/multi_module_mtp/speculator.py
+8 -3
@@ -379,7 +379,11 @@ class MultiModuleMTPSpeculator(DraftModelSpeculator):
379 cudagraph_runtime_mode: CUDAGraphMode = CUDAGraphMode.NONE,
380 ) -> None:
381 last_token_indices = self.last_token_indices[:num_reqs]
382 - sample_positions = self.input_buffers.positions[last_token_indices]
382 + positions = self.input_buffers.positions[last_token_indices]
383 + # The output hidden state at position P (= positions) and the token id
384 + # at P+1 are used to draft the token at P+2. Sampling keys a draw by the
385 + # position before the sampled token, so the net adjustment is +1.
386 + sample_src_positions = positions + 1
387 idx_mapping = self.idx_mapping[:num_reqs]
388
389 # Cache the trailing token's ids, hidden states (and embeddings for
@@ -417,7 +421,7 @@ class MultiModuleMTPSpeculator(DraftModelSpeculator):
421 sample_hidden_states = last_hidden_states[last_token_indices]
422 draft_tokens = self.sample_draft(
423 sample_hidden_states,
420 - sample_positions,
424 + sample_src_positions,
425 idx_mapping,
426 self.temperature,
427 self.seeds,
@@ -448,7 +452,8 @@ class MultiModuleMTPSpeculator(DraftModelSpeculator):
452 idx_mapping,
453 num_reqs,
454 )
451 - sample_positions += 1
455 + # Advance the draft sampling key.
456 + sample_src_positions += 1
457
458
459 @triton.jit
vllm/v1/worker/gpu/spec_decode/rejection_sampler_utils.py
+1
@@ -837,6 +837,7 @@ def _resample_kernel(
837 0, # logits_cache_stride_1
838 None, # logits_cache_col_ptr
839 vocab_size,
840 + IS_DRAFTING=False,
841 APPLY_TEMPERATURE=False,
842 USE_FP64=USE_FP64,
843 )
vllm/v1/worker/gpu/spec_decode/speculator.py
+3 -4
@@ -364,7 +364,7 @@ class DraftModelSpeculator(BaseSpeculator):
364 def sample_draft(
365 self,
366 hidden_states: torch.Tensor,
367 - positions: torch.Tensor,
367 + sample_src_positions: torch.Tensor,
368 idx_mapping: torch.Tensor,
369 temperature: torch.Tensor,
370 seeds: torch.Tensor,
@@ -373,15 +373,14 @@ class DraftModelSpeculator(BaseSpeculator):
373 ) -> torch.Tensor:
374 if draft_logits is not None:
375 logits = self.model.compute_logits(hidden_states)
376 - # NOTE(woosuk): We must add 1 to the positions to match the Gumbel noise
377 - # used for draft and target sampling.
376 return gumbel_sample(
377 logits,
378 idx_mapping,
379 temperature,
380 seeds,
383 - positions + 1,
381 + sample_src_positions,
382 apply_temperature=True,
383 + is_drafting=True,
384 logits_cache=draft_logits,
385 logits_cache_col=draft_step,
386 use_fp64=self.use_fp64_gumbel,