415 lines
13 KiB
Python
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}."
|
|
)
|