Files
vllm-project--vllm/tests/v1/spec_decode/test_rejection_sampler_utils.py
T
wehub-resource-sync 7ce4c8e27e
pre-commit / pre-run-check (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:55:37 +08:00

415 lines
13 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import math
import pytest
import torch
from vllm.v1.worker.gpu.spec_decode.rejection_sampler_utils import (
rejection_sample,
)
VOCAB_SIZE = 4096
# Skip if no CUDA - Triton kernel requires GPU
pytest.importorskip("triton")
if not torch.cuda.is_available():
pytest.skip("CUDA required for rejection sampler tests", allow_module_level=True)
def _build_rejection_sample_inputs(
target_logits_1d: torch.Tensor,
draft_logits_1d: torch.Tensor,
num_speculative_steps: int,
temperature: float,
num_trials: int,
) -> dict:
device = target_logits_1d.device
vocab_size = target_logits_1d.shape[0]
K = num_speculative_steps
num_logits = num_trials * (K + 1)
target_logits = target_logits_1d.unsqueeze(0).expand(num_logits, -1).contiguous()
draft_logits = (
draft_logits_1d.view(1, 1, vocab_size).expand(num_trials, K, -1).contiguous()
)
draft_probs = torch.softmax(draft_logits_1d, dim=0)
draft_tokens = torch.multinomial(
draft_probs.expand(num_trials, -1), K, replacement=True
)
draft_sampled_2d = torch.zeros(num_trials, K + 1, dtype=torch.int64, device=device)
draft_sampled_2d[:, 1:] = draft_tokens
draft_sampled = draft_sampled_2d.reshape(-1)
cu_num_logits = torch.arange(num_trials + 1, dtype=torch.int32, device=device) * (
K + 1
)
pos = torch.arange(num_logits, dtype=torch.int32, device=device)
idx_mapping = torch.arange(num_trials, dtype=torch.int32, device=device)
expanded_idx_mapping = torch.arange(
num_trials, dtype=torch.int32, device=device
).repeat_interleave(K + 1)
expanded_local_pos = torch.arange(K + 1, dtype=torch.int32, device=device).repeat(
num_trials
)
temp_tensor = torch.full(
(num_trials,), temperature, dtype=torch.float32, device=device
)
seed = torch.arange(num_trials, dtype=torch.int64, device=device)
return dict(
target_logits=target_logits,
draft_logits=draft_logits,
draft_sampled=draft_sampled,
cu_num_logits=cu_num_logits,
pos=pos,
idx_mapping=idx_mapping,
expanded_idx_mapping=expanded_idx_mapping,
expanded_local_pos=expanded_local_pos,
temperature=temp_tensor,
seed=seed,
)
def _assert_distribution_match(
sampled_tokens: torch.Tensor,
target_probs: torch.Tensor,
device: str,
label: str = "",
min_expected: float = 5.0,
):
"""
Assert sampled tokens match the target distribution via a
chi-squared goodness-of-fit test. This is done by computing
observed vs expected token counts (target_probs * num_samples),
then checking that the chi-squared statistic is below a conservative
threshold. The threshold is set at df + 10*sqrt(2*df), which
corresponds to ~10 sigma under the chi-squared distribution's
normal approximation, effectively disallowing false positives.
NOTE: Tokens with expected count < min_expected are merged into
a single "other" bin to minimize chi-squared noise.
"""
num_samples = sampled_tokens.shape[0]
vocab_size = target_probs.shape[0]
observed = torch.zeros(vocab_size, device=device, dtype=torch.float32)
observed.scatter_add_(0, sampled_tokens, torch.ones(num_samples, device=device))
expected = target_probs * num_samples
sufficient = expected >= min_expected
obs_main = observed[sufficient]
exp_main = expected[sufficient]
obs_other = observed[~sufficient].sum().unsqueeze(0)
exp_other = expected[~sufficient].sum().unsqueeze(0)
if exp_other.item() >= min_expected:
obs_all = torch.cat([obs_main, obs_other])
exp_all = torch.cat([exp_main, exp_other])
else:
obs_all = obs_main
exp_all = exp_main
chi2 = ((obs_all - exp_all) ** 2 / exp_all).sum().item()
df = obs_all.shape[0] - 1
if df < 1:
# All samples were merged into < 2 bins, which is too
# few to evaluate.
return
threshold = df + 10 * math.sqrt(2 * df)
prefix = f"[{label}] " if label else ""
assert chi2 < threshold, (
f"{prefix}Chi-squared test failed: chi2={chi2:.1f}, "
f"df={df}, threshold={threshold:.1f}. "
f"Output distribution does not match target distribution."
)
@pytest.mark.parametrize(
"num_speculative_steps,temperature",
[
(1, 0.6),
(3, 0.6),
(1, 1.0),
(3, 1.0),
],
)
def test_stochastic_rejection_sample(num_speculative_steps: int, temperature: float):
"""
Verify that rejection sampling produces the target distribution.
This is done by simulating many independent trials of speculative
decoding (from a fixed target and draft distribution). We then
run rejection sample on all of the trials (requests), and verify
that the sampled tokens at every position follow the target
distribution p(x).
"""
torch.manual_seed(42)
device = "cuda"
num_trials = 10 * VOCAB_SIZE
target_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32)
draft_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32)
if temperature > 0:
target_logits_1d /= temperature
draft_logits_1d /= temperature
inputs = _build_rejection_sample_inputs(
target_logits_1d,
draft_logits_1d,
num_speculative_steps,
temperature=temperature,
num_trials=num_trials,
)
sampled, num_sampled = rejection_sample(
**inputs, num_speculative_steps=num_speculative_steps
)
target_probs = torch.softmax(target_logits_1d, dim=0)
for pos in range(num_speculative_steps + 1):
accepted_mask = num_sampled >= pos + 1
_assert_distribution_match(
sampled[accepted_mask, pos], target_probs, device, label=f"position {pos}"
)
@pytest.mark.parametrize("num_speculative_steps", [1, 3])
def test_greedy_rejection_sample(num_speculative_steps: int):
"""
Verify that greedy (temperature=0) always outputs the target argmax
at every accepted position.
"""
torch.manual_seed(42)
device = "cuda"
num_trials = 10 * VOCAB_SIZE
target_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32)
draft_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32)
inputs = _build_rejection_sample_inputs(
target_logits_1d,
draft_logits_1d,
num_speculative_steps,
temperature=0.0,
num_trials=num_trials,
)
sampled, num_sampled = rejection_sample(
**inputs, num_speculative_steps=num_speculative_steps
)
target_argmax = target_logits_1d.argmax().item()
steps = torch.arange(num_speculative_steps + 1, device=device).unsqueeze(0)
accepted_mask = steps < num_sampled.unsqueeze(1)
assert (sampled[accepted_mask] == target_argmax).all(), (
"Greedy sampling produced tokens that are not the target argmax"
)
@pytest.mark.parametrize(
"num_speculative_steps,temperature,unconditional_rates",
[
(3, 1.0, [0.9, 0.5, 0.2]),
(3, 0.0, [0.9, 0.5, 0.2]),
(3, 1.0, [1.0, 1.0, 1.0]),
(3, 0.0, [1.0, 1.0, 1.0]),
(3, 1.0, [0.0, 0.0, 0.0]),
(3, 0.0, [0.0, 0.0, 0.0]),
(1, 1.0, [0.7]),
(1, 0.0, [0.7]),
],
)
def test_synthetic_rejection_sample(
num_speculative_steps: int,
temperature: float,
unconditional_rates: list[float],
):
"""
Verify that synthetic rejection sampling produces the expected
per-position acceptance rates. The unconditional rate at position i
is P(all draft steps 0..i accepted) = product(conditional_rates[0:i+1]).
This is approximately mean(num accepted >= i + 1) over many trials.
"""
from vllm.v1.spec_decode.utils import unconditional_to_conditional_rates
torch.manual_seed(42)
device = "cuda"
num_trials = 10 * VOCAB_SIZE
deviation_tol = 1e-2
target_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32)
draft_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32)
if temperature > 0:
target_logits_1d /= temperature
draft_logits_1d /= temperature
inputs = _build_rejection_sample_inputs(
target_logits_1d,
draft_logits_1d,
num_speculative_steps,
temperature=temperature,
num_trials=num_trials,
)
conditional_rates = unconditional_to_conditional_rates(unconditional_rates)
synthetic_conditional_rates = torch.tensor(
conditional_rates, dtype=torch.float32, device=device
)
_, num_sampled = rejection_sample(
**inputs,
num_speculative_steps=num_speculative_steps,
synthetic_conditional_rates=synthetic_conditional_rates,
)
# num_sampled includes the resampled/bonus token.
num_accepted = num_sampled - 1
for i, expected_rate in enumerate(unconditional_rates):
observed_rate = (num_accepted >= i + 1).float().mean().item()
assert abs(observed_rate - expected_rate) < deviation_tol, (
f"Step {i}: observed rate {observed_rate:.4f} deviates from "
f"expected rate {expected_rate:.4f} by more than {deviation_tol}."
)
def test_placeholder_draft_token_rejected():
"""A placeholder draft id (-1) must be rejected without reading the logit
tensors out of bounds, for any sampling method.
"""
torch.manual_seed(0)
device = "cuda"
num_trials = 64
K = 1
temperature = 0.6
target_logits_1d = torch.randn(VOCAB_SIZE, device=device) / temperature
draft_logits_1d = torch.randn(VOCAB_SIZE, device=device) / temperature
inputs = _build_rejection_sample_inputs(
target_logits_1d,
draft_logits_1d,
K,
temperature=temperature,
num_trials=num_trials,
)
inputs["draft_sampled"].view(num_trials, K + 1)[:, 1:] = -1
sampled, num_sampled = rejection_sample(**inputs, num_speculative_steps=K)
assert torch.equal(num_sampled, torch.ones_like(num_sampled))
recovered = sampled[:, 0]
assert (recovered >= 0).all() and (recovered < VOCAB_SIZE).all()
@pytest.mark.parametrize(
"num_speculative_steps,temperature",
[
(1, 0.6),
(3, 0.6),
(1, 1.0),
(3, 1.0),
(5, 1.0),
],
)
@pytest.mark.parametrize("has_draft_logits", [True, False])
def test_block_verification_rejection_sample(
num_speculative_steps: int, temperature: float, has_draft_logits: bool
):
"""
Verify that block verification (Sun et al.) preserves the target
distribution at every accepted position, for both the full draft-logits
case and the one-hot (no draft logits) case. Block verification changes
*which* prefix is accepted, but the marginal of every output position must
still match the target distribution p(x).
"""
torch.manual_seed(42)
device = "cuda"
num_trials = 10 * VOCAB_SIZE
target_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32)
draft_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32)
if temperature > 0:
target_logits_1d /= temperature
draft_logits_1d /= temperature
inputs = _build_rejection_sample_inputs(
target_logits_1d,
draft_logits_1d,
num_speculative_steps,
temperature=temperature,
num_trials=num_trials,
)
if not has_draft_logits:
inputs["draft_logits"] = None
sampled, num_sampled = rejection_sample(
**inputs,
num_speculative_steps=num_speculative_steps,
use_block_verification=True,
)
target_probs = torch.softmax(target_logits_1d, dim=0)
for pos in range(num_speculative_steps + 1):
accepted_mask = num_sampled >= pos + 1
_assert_distribution_match(
sampled[accepted_mask, pos], target_probs, device, label=f"position {pos}"
)
@pytest.mark.parametrize("num_speculative_steps", [3, 5])
def test_block_verification_accepts_at_least_as_many(num_speculative_steps: int):
"""
Block verification is designed to accept at least as long a prefix as
token verification in expectation. Verify the mean accepted length is no
worse than the standard method on the same inputs.
"""
torch.manual_seed(0)
device = "cuda"
num_trials = 20 * VOCAB_SIZE
temperature = 1.0
target_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32)
# A draft close to the target gives block verification room to recover
# prefixes that token verification would have truncated.
draft_logits_1d = target_logits_1d + 0.5 * torch.randn(
VOCAB_SIZE, device=device, dtype=torch.float32
)
inputs = _build_rejection_sample_inputs(
target_logits_1d,
draft_logits_1d,
num_speculative_steps,
temperature=temperature,
num_trials=num_trials,
)
_, num_sampled_standard = rejection_sample(
**inputs, num_speculative_steps=num_speculative_steps
)
_, num_sampled_block = rejection_sample(
**inputs,
num_speculative_steps=num_speculative_steps,
use_block_verification=True,
)
mean_standard = (num_sampled_standard - 1).float().mean().item()
mean_block = (num_sampled_block - 1).float().mean().item()
# Allow a small slack for sampling noise.
assert mean_block >= mean_standard - 1e-2, (
f"Block verification mean accepted length {mean_block:.4f} is worse "
f"than standard {mean_standard:.4f}."
)