Compare commits

...
Author SHA1 Message Date
Woosuk KwonandClaude Fable 5 9feb5a94be [Spec Decode] Keep draft_gumbel_pos at the top of spec_decode utils
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Woosuk Kwon <woosuk@inferact.ai>
2026-07-02 15:56:23 +00:00
Woosuk KwonandClaude Fable 5 e83808768c [Spec Decode] Move draft_gumbel_pos to spec_decode utils
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Woosuk Kwon <woosuk@inferact.ai>
2026-07-02 15:54:15 +00:00
Woosuk KwonandClaude Fable 5 7fd9f22fcb [Spec Decode] Salt DSpark's draft Gumbel stream too
DSparkSpeculator's sequential Markov sampling calls gumbel_sample
directly with key Q-1 (the verification key), bypassing sample_draft.
Route it through draft_gumbel_pos so its probabilistic drafts get the
same disjoint stream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Woosuk Kwon <woosuk@inferact.ai>
2026-07-02 15:49:18 +00:00
Woosuk Kwon d61cfb1452 Merge branch 'main' into woosuk/mrv2-draft-gumbel-decouple 2026-07-02 15:46:57 +00:00
Woosuk KwonandClaude Fable 5 214a2054a8 [Model Runner V2][Spec Decode] Decouple draft Gumbel stream from acceptance/recovery noise
With draft_sample_method="probabilistic", the draft token for position P
was sampled with Gumbel noise keyed by Philox offset P -- the same offset
that keys the acceptance uniform (u == float of the very Philox draw used
as the draft's Gumbel key) and the recovery Gumbel noise in the rejection
sampler. On rejection, the recovery draw therefore reused the exact noise
vector that selected the rejected draft token, violating the independence
assumption of rejection sampling and biasing the output marginal toward
draft-favored tokens (measured TV distance from the target 0.0125 vs a
0.0028 noise floor; 0.0014 after the fix). Acceptance rate is unchanged.

Salt the draft-side Philox offsets into a range disjoint from the
target-side streams (positions are bounded by max_model_len << 2**30).
The default greedy draft mode consumes no Gumbel noise and is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Woosuk Kwon <woosuk@inferact.ai>
2026-07-02 04:33:42 +00:00
5 changed files with 148 additions and 8 deletions
@@ -0,0 +1,116 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Output distribution of rejection sampling with probabilistic drafts.
Rejection sampling only preserves the target distribution if the draft
proposal, the acceptance uniform, and the recovery Gumbel noise are
independent. The rejection sampler keys the acceptance uniform and the
recovery noise for position P by Philox offset P, so the draft's Gumbel
stream (draft_gumbel_pos) must live in a disjoint offset range: keying the
draft by offset P as well makes the recovery draw reuse the exact noise
vector that selected the rejected draft token, inflating draft-favored
tokens (TV distance ~0.0125 vs a ~0.003 noise floor in this setup).
"""
import pytest
import torch
from vllm.platforms import current_platform
from vllm.v1.worker.gpu.sample.gumbel import gumbel_sample
from vllm.v1.worker.gpu.spec_decode.rejection_sampler_utils import rejection_sample
from vllm.v1.worker.gpu.spec_decode.utils import draft_gumbel_pos
VOCAB_SIZE = 32768
NUM_REQS = 4096
ITERS = 16
NUM_SPEC = 1
POS = 1000
@pytest.mark.skipif(not current_platform.is_cuda(), reason="Requires CUDA")
def test_probabilistic_draft_output_distribution():
device = torch.device("cuda:0")
# Target p and draft q with mass on the first 8 tokens, deliberately
# ranked in opposite order so draft-noise reuse would visibly skew the
# output marginal toward q's favorites.
p_probs = torch.tensor([0.30, 0.20, 0.15, 0.10, 0.10, 0.05, 0.05, 0.05])
q_probs = torch.tensor([0.05, 0.05, 0.05, 0.10, 0.10, 0.15, 0.20, 0.30])
p_logits = torch.full((VOCAB_SIZE,), -30.0)
p_logits[:8] = p_probs.log()
q_logits = torch.full((VOCAB_SIZE,), -30.0)
q_logits[:8] = q_probs.log()
true_p = torch.softmax(p_logits, dim=0).double()
num_logits = NUM_REQS * (NUM_SPEC + 1)
target_logits = torch.empty(
num_logits, VOCAB_SIZE, dtype=torch.float32, device=device
)
target_logits[0::2] = p_logits.to(device)
target_logits[1::2] = p_logits.to(device) # bonus row; not measured
cu_num_logits = torch.arange(NUM_REQS + 1, device=device, dtype=torch.int32) * 2
idx_mapping = torch.arange(NUM_REQS, device=device, dtype=torch.int32)
expanded_idx_mapping = idx_mapping.repeat_interleave(2)
expanded_local_pos = torch.tensor([0, 1], device=device, dtype=torch.int32).repeat(
NUM_REQS
)
pos = torch.tensor([POS, POS + 1], device=device, dtype=torch.int64).repeat(
NUM_REQS
)
temperature = torch.ones(NUM_REQS, dtype=torch.float32, device=device)
q_batch = q_logits.to(device).expand(NUM_REQS, VOCAB_SIZE).contiguous()
draft_step = torch.zeros((), dtype=torch.int64, device=device)
# Draft rows sit at the position preceding the proposed token, exactly
# as in DraftModelSpeculator.sample_draft.
draft_row_pos = torch.full((NUM_REQS,), POS - 1, dtype=torch.int64, device=device)
counts = torch.zeros(VOCAB_SIZE, dtype=torch.int64)
accepted = 0
gen = torch.Generator(device="cpu").manual_seed(999)
for _ in range(ITERS):
seeds = torch.randint(
-(2**62), 2**62, (NUM_REQS,), generator=gen, dtype=torch.int64
).to(device)
draft_logits = torch.zeros(
NUM_REQS, NUM_SPEC, VOCAB_SIZE, dtype=torch.float32, device=device
)
draft_tokens = gumbel_sample(
q_batch.clone(),
idx_mapping,
temperature,
seeds,
draft_gumbel_pos(draft_row_pos),
apply_temperature=True,
output_processed_logits=draft_logits.view(NUM_REQS, -1),
output_processed_logits_col=draft_step,
)
# Row layout per request: [prev sampled token, draft token].
draft_sampled = torch.stack(
[torch.zeros_like(draft_tokens), draft_tokens], dim=1
).flatten()
sampled, num_sampled = rejection_sample(
target_logits,
draft_logits,
draft_sampled,
cu_num_logits,
pos,
idx_mapping,
expanded_idx_mapping,
expanded_local_pos,
temperature,
seeds,
NUM_SPEC,
)
counts += torch.bincount(sampled[:, 0].cpu(), minlength=VOCAB_SIZE)
accepted += (num_sampled == 2).sum().item()
total = NUM_REQS * ITERS
empirical = counts.double() / total
tv = 0.5 * (empirical - true_p).abs().sum().item()
# Noise floor at this sample count is ~0.0055; the coupled-stream bug
# measures ~0.0125. Deterministic given the fixed seeds.
assert tv < 0.008, f"output marginal deviates from target: TV={tv:.5f}"
# Acceptance rate must stay ~sum(min(p, q)) = 0.5; decoupling the draft
# stream must not cost acceptance.
accept_rate = accepted / total
assert 0.47 < accept_rate < 0.53, f"unexpected acceptance rate {accept_rate:.4f}"
@@ -208,8 +208,9 @@ class DFlashSpeculator(DraftModelSpeculator):
num_sample = num_reqs * self.num_speculative_steps
sample_hidden_states = last_hidden_states[self.sample_indices[:num_sample]]
# sample_pos is the predicted token's position Q; verification keys
# Gumbel by the predecessor (Q-1). sample_draft adds +1, so pass Q-2.
# sample_pos is the predicted token's position Q; sample_draft keys
# the (salted) draft Gumbel stream by positions + 1, so pass Q-2 to
# get a key unique per predicted position.
draft_tokens = self.sample_draft(
sample_hidden_states,
self.sample_pos[:num_sample] - 2,
@@ -32,6 +32,7 @@ from vllm.config.compilation import CUDAGraphMode
from vllm.v1.worker.gpu.sample.gumbel import gumbel_sample
from vllm.v1.worker.gpu.spec_decode.dflash.speculator import DFlashSpeculator
from vllm.v1.worker.gpu.spec_decode.dspark.utils import load_dspark_model
from vllm.v1.worker.gpu.spec_decode.utils import draft_gumbel_pos
class DSparkSpeculator(DFlashSpeculator):
@@ -129,14 +130,17 @@ class DSparkSpeculator(DFlashSpeculator):
buf = self._draft_scatter_buf[:num_reqs]
buf.index_copy_(1, self._d2t_scatter_index, logits_i.to(buf.dtype))
logits_i = buf
# sample_pos is the predicted token's position Q; the target
# verifies it with the predecessor's Gumbel key (Q-1). Pass Q-1.
# sample_pos is the predicted token's position Q;
# draft_gumbel_pos keys the (salted) draft Gumbel stream by
# positions + 1, so pass Q-2 to get a key unique per
# predicted position and disjoint from the rejection
# sampler's acceptance/recovery keys.
draft_sampled_i = gumbel_sample(
logits_i,
idx_map[:, i],
self.temperature,
self.seeds,
sample_pos[:, i] - 1,
draft_gumbel_pos(sample_pos[:, i] - 2),
apply_temperature=True,
output_processed_logits=self.draft_logits,
output_processed_logits_col=self._step_cols[i],
+2 -3
View File
@@ -24,6 +24,7 @@ from vllm.v1.worker.gpu.cudagraph_utils import (
from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
from vllm.v1.worker.gpu.model_states.interface import ModelState
from vllm.v1.worker.gpu.sample.gumbel import gumbel_sample
from vllm.v1.worker.gpu.spec_decode.utils import draft_gumbel_pos
logger = init_logger(__name__)
@@ -270,14 +271,12 @@ class DraftModelSpeculator(BaseSpeculator):
) -> torch.Tensor:
if draft_logits is not None:
logits = self.model.compute_logits(hidden_states)
# NOTE(woosuk): We must add 1 to the positions to match the Gumbel noise
# used for draft and target sampling.
return gumbel_sample(
logits,
idx_mapping,
temperature,
seeds,
positions + 1,
draft_gumbel_pos(positions),
apply_temperature=True,
output_processed_logits=draft_logits,
output_processed_logits_col=draft_step,
+20
View File
@@ -7,6 +7,26 @@ from vllm.v1.outputs import DraftTokenIds
from vllm.v1.worker.gpu.async_utils import async_copy_to_np
from vllm.v1.worker.gpu.input_batch import InputBatch
# Salt added to the Philox offsets used for draft-token Gumbel noise.
# Positions are bounded by max_model_len, so this puts the draft stream in a
# range disjoint from the target-side offsets.
DRAFT_GUMBEL_POS_OFFSET = 1 << 30
def draft_gumbel_pos(positions: torch.Tensor) -> torch.Tensor:
"""Philox offsets for the draft Gumbel noise, given draft-row positions.
The rejection sampler keys both the acceptance uniform and the
recovery/bonus Gumbel noise for the token at position P by Philox offset
P (see _rejection_kernel and gumbel_block_argmax). If the draft's
proposal for position P used the same offset, the recovery draw would
reuse the exact noise vector that selected the rejected draft token,
biasing rejection sampling. Key the proposal for position P by
P + DRAFT_GUMBEL_POS_OFFSET instead, keeping the streams disjoint.
"""
# Parenthesized so the constant folds and this is a single tensor add.
return positions + (1 + DRAFT_GUMBEL_POS_OFFSET)
class DraftTokensHandler:
def __init__(self, device: torch.device | None = None):