Add vLLM v0.18.1 source tree with KV transfer abort fix

third_party/vllm/ now tracked in git for direct patch management.
Based on vLLM v0.18.1 release with one patch applied:

  vllm/v1/core/sched/scheduler.py:
    Replace fatal assert with graceful skip when KV transfer callback
    arrives for an already-aborted request during PD disaggregated serving.

Future vLLM modifications should be made directly in third_party/vllm/
and committed normally. The patches/ directory is kept as documentation
of what changed from upstream.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 00:30:38 +08:00
parent b6591950bc
commit 445e491123
4285 changed files with 1111303 additions and 1 deletions

View File

@@ -0,0 +1,2 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project

View File

@@ -0,0 +1,312 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
EAGLE3 Acceptance Length Regression Tests.
These tests verify that acceptance lengths for EAGLE3 speculative decoding
do not regress across vLLM commits. Each test runs inference on the MT-Bench
dataset and asserts that the mean acceptance length is within tolerance of
the expected baseline.
"""
from dataclasses import dataclass, field
from types import SimpleNamespace
import pytest
import torch
from tests.conftest import VllmRunner
from tests.utils import large_gpu_mark
from vllm import SamplingParams
from vllm.benchmarks.datasets import get_samples
from vllm.inputs import TokensPrompt
from vllm.platforms import current_platform
from vllm.v1.attention.backends.registry import AttentionBackendEnum
from vllm.v1.attention.selector import AttentionSelectorConfig
from vllm.v1.metrics.reader import Counter, Vector
@dataclass
class Eagle3ModelConfig:
verifier: str
drafter: str
expected_acceptance_length: float
expected_acceptance_lengths_per_pos: list[float] = field(default_factory=list)
id: str = ""
# Backends that are incompatible with this model (will be skipped)
excluded_backends: set[AttentionBackendEnum] = field(default_factory=set)
# Pytest marks for this configuration
marks: list = field(default_factory=list)
# Custom relative tolerance (defaults to DEFAULT_RTOL if None)
rtol: float | None = None
# Model configurations for EAGLE3 acceptance length tests.
# Expected acceptance lengths are determined by running baseline benchmarks
# using examples/offline_inference/spec_decode.py with the MT-Bench dataset.
EAGLE3_MODEL_CONFIGS = [
Eagle3ModelConfig(
verifier="meta-llama/Llama-3.1-8B-Instruct",
drafter="RedHatAI/Llama-3.1-8B-Instruct-speculator.eagle3",
expected_acceptance_length=2.60,
expected_acceptance_lengths_per_pos=[0.7296, 0.5208, 0.3545],
id="llama3-8b-eagle3",
),
Eagle3ModelConfig(
verifier="Qwen/Qwen3-8B",
drafter="RedHatAI/Qwen3-8B-speculator.eagle3",
expected_acceptance_length=2.26,
expected_acceptance_lengths_per_pos=[0.6541, 0.3993, 0.2020],
id="qwen3-8b-eagle3",
),
Eagle3ModelConfig(
verifier="openai/gpt-oss-20b",
drafter="RedHatAI/gpt-oss-20b-speculator.eagle3",
expected_acceptance_length=2.56,
expected_acceptance_lengths_per_pos=[0.7165, 0.5120, 0.3337],
id="gpt-oss-20b-eagle3",
# FLASHINFER incompatible: gpt-oss-20b uses sink attention which
# FLASHINFER does not support ("sink setting not supported")
excluded_backends={AttentionBackendEnum.FLASHINFER},
),
Eagle3ModelConfig(
verifier="Qwen/Qwen3-VL-30B-A3B-Instruct-FP8",
drafter="nm-testing/Speculator-Qwen3-30B-MOE-VL-Eagle3",
expected_acceptance_length=1.35,
expected_acceptance_lengths_per_pos=[0.2900, 0.0620, 0.0115],
id="qwen3-30b-moe-vl-eagle3",
marks=[
pytest.mark.slow_test,
],
rtol=0.15, # Higher tolerance due to small absolute values at position 2
),
]
# Default test parameters
DEFAULT_NUM_SPEC_TOKENS = 3
DEFAULT_NUM_PROMPTS = 80
DEFAULT_OUTPUT_LEN = 256
DEFAULT_MAX_MODEL_LEN = 16384
DEFAULT_RTOL = 0.05
# TP sizes to test
TP_SIZES = [1, 2, 4]
# Backends excluded from testing due to significantly different behavior
EXCLUDED_BACKENDS = {AttentionBackendEnum.FLEX_ATTENTION}
def get_available_attention_backends() -> list[str]:
# Check if get_valid_backends is actually defined in the platform class
# (not just returning None from __getattr__)
get_valid_backends = getattr(current_platform.__class__, "get_valid_backends", None)
if get_valid_backends is None:
if current_platform.is_rocm():
# ROCm uses Triton as its default attention backend since
# Flash Attention is not supported.
return ["TRITON_ATTN"]
else:
return ["FLASH_ATTN"]
device_capability = current_platform.get_device_capability()
if device_capability is None:
return ["FLASH_ATTN"]
attn_selector_config = AttentionSelectorConfig(
head_size=128,
dtype=torch.bfloat16,
kv_cache_dtype=None,
block_size=None,
use_mla=False,
has_sink=False,
use_sparse=False,
use_mm_prefix=False,
)
valid_backends, _ = current_platform.get_valid_backends(
device_capability=device_capability,
attn_selector_config=attn_selector_config,
)
return [
backend.name
for backend, _ in valid_backends
if backend not in EXCLUDED_BACKENDS
]
def get_attention_backend_params() -> list[str]:
return get_available_attention_backends()
def get_tp_size_params() -> list[pytest.param]:
num_gpus = torch.accelerator.device_count() if torch.cuda.is_available() else 1
return [pytest.param(tp, id=f"tp{tp}") for tp in TP_SIZES if tp <= num_gpus]
def get_mt_bench_prompts(
tokenizer, num_prompts: int = DEFAULT_NUM_PROMPTS
) -> list[list[int]]:
args = SimpleNamespace(
dataset_name="hf",
dataset_path="philschmid/mt-bench",
num_prompts=num_prompts,
seed=42,
no_oversample=False,
endpoint_type="openai-chat",
input_len=None,
output_len=DEFAULT_OUTPUT_LEN,
sharegpt_output_len=DEFAULT_OUTPUT_LEN,
hf_name=None,
hf_split="train",
hf_subset=None,
hf_output_len=DEFAULT_OUTPUT_LEN,
no_stream=True,
disable_shuffle=False,
skip_chat_template=False,
)
samples = get_samples(args, tokenizer)
prompt_ids = [
tokenizer.encode(sample.prompt, add_special_tokens=False) for sample in samples
]
return prompt_ids
def extract_acceptance_metrics(metrics, num_spec_tokens: int) -> dict:
num_drafts = 0
num_accepted_tokens = 0
acceptance_counts = [0] * num_spec_tokens
for metric in metrics:
if metric.name == "vllm:spec_decode_num_drafts":
assert isinstance(metric, Counter)
num_drafts += metric.value
elif metric.name == "vllm:spec_decode_num_accepted_tokens":
assert isinstance(metric, Counter)
num_accepted_tokens += metric.value
elif metric.name == "vllm:spec_decode_num_accepted_tokens_per_pos":
assert isinstance(metric, Vector)
for pos in range(min(len(metric.values), num_spec_tokens)):
acceptance_counts[pos] += metric.values[pos]
# Calculate mean acceptance length
# Formula: 1 + (accepted_tokens / num_drafts)
acceptance_length = 1 + (num_accepted_tokens / num_drafts) if num_drafts > 0 else 1
# Calculate per-position acceptance lengths (contribution to total)
# Each position contributes: accepted_at_pos / num_drafts
acceptance_lengths_per_pos = [
count / num_drafts if num_drafts > 0 else 0.0 for count in acceptance_counts
]
return {
"acceptance_length": acceptance_length,
"acceptance_lengths_per_pos": acceptance_lengths_per_pos,
"num_drafts": num_drafts,
"num_accepted_tokens": num_accepted_tokens,
}
@large_gpu_mark(min_gb=40)
@pytest.mark.skipif(
not current_platform.is_cuda(),
reason="This test is only supported on CUDA platform.",
)
@pytest.mark.parametrize(
"model_config",
[
pytest.param(config, id=config.id, marks=config.marks)
for config in EAGLE3_MODEL_CONFIGS
],
)
@pytest.mark.parametrize("num_spec_tokens", [DEFAULT_NUM_SPEC_TOKENS])
@pytest.mark.parametrize("tp_size", get_tp_size_params())
@pytest.mark.parametrize("attention_backend", get_attention_backend_params())
def test_eagle3_acceptance_length(
model_config: Eagle3ModelConfig,
num_spec_tokens: int,
tp_size: int,
attention_backend: str,
monkeypatch: pytest.MonkeyPatch,
):
# Skip if this backend is incompatible with the model
backend_enum = AttentionBackendEnum[attention_backend]
if backend_enum in model_config.excluded_backends:
pytest.skip(f"{attention_backend} is incompatible with {model_config.id}")
with monkeypatch.context() as m:
m.setenv("VLLM_ALLOW_INSECURE_SERIALIZATION", "1")
with VllmRunner(
model_name=model_config.verifier,
speculative_config={
"method": "eagle3",
"model": model_config.drafter,
"num_speculative_tokens": num_spec_tokens,
},
attention_config={"backend": attention_backend},
tensor_parallel_size=tp_size,
gpu_memory_utilization=0.7,
disable_log_stats=False,
max_model_len=DEFAULT_MAX_MODEL_LEN,
) as vllm_runner:
tokenizer = vllm_runner.llm.get_tokenizer()
prompt_ids = get_mt_bench_prompts(tokenizer, DEFAULT_NUM_PROMPTS)
sampling_params = SamplingParams(
temperature=0,
max_tokens=DEFAULT_OUTPUT_LEN,
)
vllm_runner.llm.generate(
[TokensPrompt(prompt_token_ids=ids) for ids in prompt_ids],
sampling_params=sampling_params,
)
metrics = vllm_runner.llm.get_metrics()
results = extract_acceptance_metrics(metrics, num_spec_tokens)
actual_acceptance_length = results["acceptance_length"]
expected = model_config.expected_acceptance_length
actual_per_pos = results["acceptance_lengths_per_pos"]
expected_per_pos = model_config.expected_acceptance_lengths_per_pos
rel_error = abs(actual_acceptance_length - expected) / expected
# Overall acceptance length always uses DEFAULT_RTOL
assert rel_error <= DEFAULT_RTOL, (
f"Acceptance length regression detected for {model_config.id}!\n"
f" Expected: {expected:.3f}\n"
f" Actual: {actual_acceptance_length:.3f}\n"
f" Relative error: {rel_error:.2%} (tolerance: {DEFAULT_RTOL:.2%})\n"
f" Drafts: {results['num_drafts']}, "
f"Accepted tokens: {results['num_accepted_tokens']}"
)
if expected_per_pos and len(expected_per_pos) == len(actual_per_pos):
# Per-position checks use model-specific rtol if provided
rtol = (
model_config.rtol if model_config.rtol is not None else DEFAULT_RTOL
)
for pos, (actual, exp) in enumerate(
zip(actual_per_pos, expected_per_pos)
):
if exp > 0:
pos_rel_error = abs(actual - exp) / exp
assert pos_rel_error <= rtol, (
f"Per-position acceptance length regression at pos {pos} "
f"for {model_config.id}!\n"
f" Expected: {exp:.3f}\n"
f" Actual: {actual:.3f}\n"
f" Relative error: {pos_rel_error:.2%} "
f"(tolerance: {rtol:.2%})"
)
print(
f"\n{model_config.id} [tp={tp_size}, backend={attention_backend}]: "
f"acceptance_length={actual_acceptance_length:.3f}"
f" (expected={expected:.3f}, rel_error={rel_error:.2%})"
)
print(f" Per-position: {[f'{v:.3f}' for v in actual_per_pos]}")
if expected_per_pos:
print(f" Expected: {[f'{v:.3f}' for v in expected_per_pos]}")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,175 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for the fused EAGLE slot mapping kernel."""
import pytest
import torch
from vllm.v1.spec_decode.utils import (
PADDING_SLOT_ID,
eagle_step_update_slot_mapping_and_metadata,
)
# Skip if no CUDA - Triton kernel requires GPU
pytest.importorskip("triton")
if not torch.cuda.is_available():
pytest.skip("CUDA required for EAGLE kernel tests", allow_module_level=True)
def _reference_eagle_step_slot_mapping(
positions_1d: torch.Tensor,
block_table_tensor: torch.Tensor,
seq_lens: torch.Tensor,
block_size: int,
max_model_len: int,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Python reference for eagle_step_update_slot_mapping_and_metadata."""
new_positions = positions_1d + 1
exceeds_max = new_positions >= max_model_len
clamped_positions = torch.where(
exceeds_max, torch.zeros_like(positions_1d), new_positions
)
block_numbers = (clamped_positions // block_size).clamp(
max=block_table_tensor.shape[1] - 1
)
block_ids = block_table_tensor[
torch.arange(positions_1d.shape[0], device=positions_1d.device),
block_numbers.long(),
].long()
slot_mapping = block_ids * block_size + (clamped_positions % block_size)
slot_mapping = torch.where(
exceeds_max, torch.full_like(slot_mapping, PADDING_SLOT_ID), slot_mapping
)
new_seq_lens = torch.where(exceeds_max, torch.ones_like(seq_lens), seq_lens + 1)
new_seq_lens = new_seq_lens.clamp(max=max_model_len)
return clamped_positions, slot_mapping, new_seq_lens
def test_eagle_step_slot_mapping_kernel():
"""Test fused kernel matches Python reference for slot mapping and metadata."""
device = torch.device("cuda")
batch_size = 32
block_size = 16
max_model_len = 4096
n_blocks_per_req = (max_model_len + block_size - 1) // block_size
positions_1d = torch.randint(
0, max_model_len - 10, (batch_size,), dtype=torch.int64, device=device
)
block_table_tensor = torch.randint(
0, 1000, (batch_size, n_blocks_per_req), dtype=torch.int32, device=device
)
seq_lens = torch.randint(1, 100, (batch_size,), dtype=torch.int32, device=device)
ref_clamped, ref_slot, ref_seq_lens = _reference_eagle_step_slot_mapping(
positions_1d.clone(),
block_table_tensor,
seq_lens.clone(),
block_size,
max_model_len,
)
out_clamped = torch.zeros(batch_size, dtype=torch.int64, device=device)
out_slot = torch.zeros(batch_size, dtype=torch.int64, device=device)
seq_lens_copy = seq_lens.clone()
eagle_step_update_slot_mapping_and_metadata(
positions_1d=positions_1d,
block_table_tensor=block_table_tensor,
seq_lens=seq_lens_copy,
block_size=block_size,
max_model_len=max_model_len,
out_clamped_positions=out_clamped,
out_slot_mapping=out_slot,
)
assert torch.equal(out_clamped, ref_clamped), (
f"clamped: {out_clamped} vs {ref_clamped}"
)
assert torch.equal(out_slot, ref_slot), f"slot: {out_slot} vs {ref_slot}"
assert torch.equal(seq_lens_copy, ref_seq_lens), (
f"seq_lens: {seq_lens_copy} vs {ref_seq_lens}"
)
def test_eagle_step_slot_mapping_kernel_exceeds_max():
"""Test fused kernel when position exceeds max_model_len."""
device = torch.device("cuda")
batch_size = 4
block_size = 16
max_model_len = 100
n_blocks_per_req = (max_model_len + block_size - 1) // block_size
positions_1d = torch.tensor([50, 98, 99, 100], dtype=torch.int64, device=device)
block_table_tensor = torch.randint(
0, 100, (batch_size, n_blocks_per_req), dtype=torch.int32, device=device
)
seq_lens = torch.tensor([51, 99, 100, 101], dtype=torch.int32, device=device)
out_clamped = torch.zeros(batch_size, dtype=torch.int64, device=device)
out_slot = torch.zeros(batch_size, dtype=torch.int64, device=device)
eagle_step_update_slot_mapping_and_metadata(
positions_1d=positions_1d,
block_table_tensor=block_table_tensor,
seq_lens=seq_lens,
block_size=block_size,
max_model_len=max_model_len,
out_clamped_positions=out_clamped,
out_slot_mapping=out_slot,
)
assert out_clamped[0].item() == 51
assert out_clamped[1].item() == 99
assert out_clamped[2].item() == 0
assert out_clamped[3].item() == 0
assert out_slot[2].item() == PADDING_SLOT_ID
assert out_slot[3].item() == PADDING_SLOT_ID
assert seq_lens[2].item() == 1
assert seq_lens[3].item() == 1
def test_eagle_step_slot_mapping_kernel_cudagraph_padding():
"""Test that padding threads write PADDING_SLOT_ID when
input_batch_size > batch_size (cudagraph padding)."""
device = torch.device("cuda")
batch_size = 4
input_batch_size = 8
block_size = 16
max_model_len = 4096
n_blocks_per_req = (max_model_len + block_size - 1) // block_size
positions_1d = torch.tensor([10, 20, 30, 40], dtype=torch.int64, device=device)
block_table_tensor = torch.randint(
0, 100, (batch_size, n_blocks_per_req), dtype=torch.int32, device=device
)
seq_lens = torch.tensor([11, 21, 31, 41], dtype=torch.int32, device=device)
ref_clamped, ref_slot, ref_seq_lens = _reference_eagle_step_slot_mapping(
positions_1d.clone(),
block_table_tensor,
seq_lens.clone(),
block_size,
max_model_len,
)
out_clamped = torch.zeros(batch_size, dtype=torch.int64, device=device)
out_slot = torch.full((input_batch_size,), -999, dtype=torch.int64, device=device)
seq_lens_copy = seq_lens.clone()
eagle_step_update_slot_mapping_and_metadata(
positions_1d=positions_1d,
block_table_tensor=block_table_tensor,
seq_lens=seq_lens_copy,
block_size=block_size,
max_model_len=max_model_len,
out_clamped_positions=out_clamped,
out_slot_mapping=out_slot,
input_batch_size=input_batch_size,
)
# Real slots should match the reference
assert torch.equal(out_clamped, ref_clamped)
assert torch.equal(out_slot[:batch_size], ref_slot)
assert torch.equal(seq_lens_copy, ref_seq_lens)
# Padding slots should be PADDING_SLOT_ID
for i in range(batch_size, input_batch_size):
assert out_slot[i].item() == PADDING_SLOT_ID

View File

@@ -0,0 +1,334 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from unittest import mock
import pytest
import torch
from tests.v1.attention.utils import (
BatchSpec,
create_common_attn_metadata,
)
from vllm.config import (
AttentionConfig,
CacheConfig,
DeviceConfig,
ModelConfig,
ParallelConfig,
SchedulerConfig,
SpeculativeConfig,
VllmConfig,
)
from vllm.config.load import LoadConfig
from vllm.platforms import current_platform
from vllm.v1.spec_decode.extract_hidden_states import ExtractHiddenStatesProposer
from vllm.v1.worker.gpu_input_batch import CachedRequestState, InputBatch
model_dir = "TinyLlama/TinyLlama-1.1B-Chat-v1.0"
def _create_proposer(
num_speculative_tokens: int = 1,
layer_ids: list[int] | None = None,
) -> ExtractHiddenStatesProposer:
"""Create an ExtractHiddenStatesProposer for testing."""
if layer_ids is None:
layer_ids = [1, 2, 3, 4]
model_config = ModelConfig(model=model_dir, runner="generate", max_model_len=100)
speculative_config = SpeculativeConfig(
target_model_config=model_config,
target_parallel_config=ParallelConfig(),
method="extract_hidden_states",
num_speculative_tokens=num_speculative_tokens,
draft_model_config={
"hf_config": {
"eagle_aux_hidden_state_layer_ids": layer_ids,
}
},
)
device = current_platform.device_type
vllm_config = VllmConfig(
model_config=model_config,
cache_config=CacheConfig(),
speculative_config=speculative_config,
device_config=DeviceConfig(device=device),
parallel_config=ParallelConfig(),
load_config=LoadConfig(),
scheduler_config=SchedulerConfig(
max_model_len=model_config.max_model_len,
is_encoder_decoder=model_config.is_encoder_decoder,
),
attention_config=AttentionConfig(),
)
return ExtractHiddenStatesProposer(vllm_config=vllm_config, device=device)
def test_proposer_initialization():
"""Test that the proposer initializes correctly with the right parameters."""
layer_ids = [1, 2, 3, 4]
proposer = _create_proposer(num_speculative_tokens=1, layer_ids=layer_ids)
assert proposer.num_hidden_states == len(layer_ids)
assert proposer.vllm_config.speculative_config is not None
assert proposer.vllm_config.speculative_config.num_speculative_tokens == 1
# Verify the hidden states buffer is correctly shaped
expected_shape = (
proposer.max_num_tokens,
len(layer_ids),
proposer.hidden_size,
)
assert proposer.hidden_states.shape == expected_shape
def test_proposer_initialization_missing_layer_ids():
"""Test that initialization fails when layer_ids are not provided."""
model_config = ModelConfig(model=model_dir, runner="generate", max_model_len=100)
speculative_config = SpeculativeConfig(
target_model_config=model_config,
target_parallel_config=ParallelConfig(),
method="extract_hidden_states",
num_speculative_tokens=1,
draft_model_config={
"hf_config": {} # Missing eagle_aux_hidden_state_layer_ids
},
)
device = current_platform.device_type
vllm_config = VllmConfig(
model_config=model_config,
cache_config=CacheConfig(),
speculative_config=speculative_config,
device_config=DeviceConfig(device=device),
parallel_config=ParallelConfig(),
load_config=LoadConfig(),
scheduler_config=SchedulerConfig(
max_model_len=model_config.max_model_len,
is_encoder_decoder=model_config.is_encoder_decoder,
),
attention_config=AttentionConfig(),
)
with pytest.raises(
ValueError, match="eagle_aux_hidden_state_layer_ids must be set"
):
ExtractHiddenStatesProposer(vllm_config=vllm_config, device=device)
def test_prepare_next_token_ids_padded():
"""
Test for prepare_next_token_ids_padded with extract_hidden_states.
Since num_speculative_tokens == 1, sampled_token_ids has shape (batch_size, 1).
For each request we either use the sampled token (if valid and not discarded)
or a backup token from the request state.
"""
device = torch.device(current_platform.device_type)
num_requests = 4
batch_spec = BatchSpec(
seq_lens=[5] * num_requests,
query_lens=[5] * num_requests,
)
req_ids = [f"req_{i + 1}" for i in range(num_requests)]
mock_input_batch = mock.MagicMock(spec=InputBatch)
mock_input_batch.req_ids = req_ids
mock_input_batch.num_reqs = num_requests
mock_input_batch.vocab_size = 100
mock_requests = {}
for req_id in req_ids:
mock_request = mock.MagicMock(spec=CachedRequestState)
# Each request will have a backup next token id of 10, 20, 30, 40
mock_request.get_token_id.return_value = int(req_id.split("_")[1]) * 10
mock_requests[req_id] = mock_request
# explicitly discard the last request
discarded_req_mask = torch.tensor(
[False, False, False, True], dtype=torch.bool, device=device
)
# With num_speculative_tokens=1, sampled_token_ids has shape [batch_size, 1]
sampled_token_ids = torch.tensor(
[
[1], # valid, use 1
[4], # valid, use 4
[-1], # invalid, use backup token "30"
[2], # explicitly discarded, use backup token "40"
],
dtype=torch.int32,
device=device,
)
expected_next_token_ids_cpu = [1, 4, 30, 40]
expected_next_token_ids_tensor = torch.tensor(
expected_next_token_ids_cpu, dtype=torch.int32, device=device
)
proposer = _create_proposer(num_speculative_tokens=1)
common_attn_metadata = create_common_attn_metadata(
batch_spec,
block_size=16,
device=device,
)
# valid_sampled_tokens_count tracks if token is valid (not -1 and in vocab range)
# It doesn't depend on whether the request is discarded
expected_valid_sampled_tokens_count = torch.tensor(
[1, 1, 0, 1], dtype=torch.int32, device=device
)
next_token_ids, valid_sampled_tokens_count = proposer.prepare_next_token_ids_padded(
common_attn_metadata,
sampled_token_ids,
mock_requests,
mock_input_batch,
discarded_req_mask,
)
assert torch.equal(next_token_ids, expected_next_token_ids_tensor)
assert torch.equal(valid_sampled_tokens_count, expected_valid_sampled_tokens_count)
def test_propose():
"""
Test the propose() method of ExtractHiddenStatesProposer.
This should:
1. Accept target hidden states and sampled token IDs
2. Return the sampled tokens as "draft" tokens (shape [batch_size, 1])
3. Cache the hidden states in the model's KV cache
"""
device = torch.device(current_platform.device_type)
# Setup test parameters
batch_size = 2
num_tokens = 5
num_hidden_layers = 4
proposer = _create_proposer(
num_speculative_tokens=1, layer_ids=list(range(num_hidden_layers))
)
hidden_size = proposer.hidden_size
# Create mock model
model_mock = mock.MagicMock()
proposer.model = model_mock
# Mock attention layer names
proposer.attn_layer_names = ["cache_only_layers.28"]
# Mock attention metadata builder
mock_attn_metadata = mock.MagicMock()
mock_attn_metadata_builder = mock.MagicMock()
mock_attn_metadata_builder.build_for_drafting.return_value = mock_attn_metadata
proposer.attn_metadata_builder = mock_attn_metadata_builder
# Create input tensors
batch_spec = BatchSpec(
seq_lens=[3, 2],
query_lens=[3, 2],
)
common_attn_metadata = create_common_attn_metadata(
batch_spec,
block_size=16,
device=device,
)
# Create target hidden states: list of tensors, one per layer
# Each tensor has shape [num_tokens, hidden_size]
target_hidden_states = [
torch.randn(num_tokens, hidden_size, dtype=proposer.dtype, device=device)
for _ in range(num_hidden_layers)
]
# Sampled token IDs from target model
sampled_token_ids = torch.tensor(
[42, 60], dtype=torch.int32, device=device
).unsqueeze(-1)
# Call propose
draft_tokens = proposer.propose(
sampled_token_ids=sampled_token_ids,
target_hidden_states=target_hidden_states,
common_attn_metadata=common_attn_metadata,
slot_mappings=None,
)
# Verify draft tokens match sampled tokens
# Shape should be [batch_size, 1] for num_speculative_tokens=1
assert draft_tokens.shape == (batch_size, 1)
assert torch.equal(draft_tokens, sampled_token_ids)
# Verify the model was called
model_mock.assert_called_once()
# Verify hidden states were copied to the buffer The stacked hidden states
# should have shape [num_tokens, num_hidden_layers, hidden_size]
expected_stacked = torch.stack(target_hidden_states, dim=1)
assert torch.allclose(
proposer.hidden_states[:num_tokens], expected_stacked, atol=1e-6
)
@pytest.mark.parametrize("num_hidden_layers", [1, 4, 8])
def test_propose_different_layer_counts(num_hidden_layers):
"""Test that propose works correctly with different numbers of hidden layers."""
device = torch.device(current_platform.device_type)
batch_size = 2
num_tokens = 5
proposer = _create_proposer(
num_speculative_tokens=1, layer_ids=list(range(num_hidden_layers))
)
hidden_size = proposer.hidden_size
# Setup mocks
model_mock = mock.MagicMock()
proposer.model = model_mock
proposer.attn_layer_names = ["cache_only_layers.28"]
mock_attn_metadata_builder = mock.MagicMock()
mock_attn_metadata_builder.build_for_drafting.return_value = mock.MagicMock()
proposer.attn_metadata_builder = mock_attn_metadata_builder
batch_spec = BatchSpec(
seq_lens=[3, 2],
query_lens=[3, 2],
)
common_attn_metadata = create_common_attn_metadata(
batch_spec,
block_size=16,
device=device,
)
# Create target hidden states
target_hidden_states = [
torch.randn(num_tokens, hidden_size, dtype=proposer.dtype, device=device)
for _ in range(num_hidden_layers)
]
sampled_token_ids = torch.tensor(
[42, 60], dtype=torch.int32, device=device
).unsqueeze(-1)
draft_tokens = proposer.propose(
sampled_token_ids=sampled_token_ids,
target_hidden_states=target_hidden_states,
common_attn_metadata=common_attn_metadata,
slot_mappings=None,
)
assert draft_tokens.shape == (batch_size, 1)
assert torch.equal(draft_tokens, sampled_token_ids)

View File

@@ -0,0 +1,85 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Test whether spec decoding handles the max model length properly."""
import pytest
from tests.utils import get_attn_backend_list_based_on_platform
from vllm import LLM, SamplingParams
from vllm.platforms import current_platform
from vllm.sampling_params import StructuredOutputsParams
_PROMPTS = [
"1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1",
"Repeat the following sentence 10 times: Consistency is key to mastering any skill.", # noqa: E501
"Who won the Turing Award in 2018, and for what contribution? Describe in detail.", # noqa: E501
]
@pytest.mark.parametrize("num_speculative_tokens", [1, 3, 10])
def test_ngram_max_len(num_speculative_tokens: int):
llm = LLM(
model="facebook/opt-125m",
max_model_len=100,
enforce_eager=True, # For faster initialization.
speculative_config={
"method": "ngram",
"prompt_lookup_max": 5,
"prompt_lookup_min": 3,
"num_speculative_tokens": num_speculative_tokens,
},
)
sampling_params = SamplingParams(max_tokens=100, ignore_eos=True)
llm.generate(_PROMPTS, sampling_params)
@pytest.mark.parametrize("num_speculative_tokens", [1, 3, 10])
@pytest.mark.parametrize("attn_backend", get_attn_backend_list_based_on_platform())
def test_eagle_max_len(
monkeypatch: pytest.MonkeyPatch, num_speculative_tokens: int, attn_backend: str
):
if attn_backend == "TRITON_ATTN" and not current_platform.is_rocm():
pytest.skip(
"TRITON_ATTN does not support "
"multi-token eagle spec decode on current platform"
)
if attn_backend == "ROCM_AITER_FA" and current_platform.is_rocm():
monkeypatch.setenv("VLLM_ROCM_USE_AITER", "1")
llm = LLM(
model="meta-llama/Meta-Llama-3-8B-Instruct",
enforce_eager=True, # For faster initialization.
speculative_config={
"method": "eagle",
"model": "yuhuili/EAGLE-LLaMA3-Instruct-8B",
"num_speculative_tokens": num_speculative_tokens,
"max_model_len": 80,
},
max_model_len=200,
attention_config={"backend": attn_backend},
)
sampling_params = SamplingParams(max_tokens=200, ignore_eos=True)
outputs = llm.generate(_PROMPTS, sampling_params)
for o in outputs:
assert o.outputs[0].finish_reason == "length", (
"This test is only meaningful if the output is truncated due to max length"
)
sampling_params = SamplingParams(
max_tokens=200,
structured_outputs=StructuredOutputsParams(regex="^" + "a b c d e " * 15 + "$"),
)
output = llm.generate(_PROMPTS, sampling_params)
for o in output:
assert o.prompt_token_ids is not None
assert (
len(o.prompt_token_ids)
< 80
< len(o.prompt_token_ids) + len(o.outputs[0].token_ids)
<= 200
), (
"This test is only meaningful if the output "
"is longer than the eagle max length"
)
assert o.outputs[0].text == "a b c d e " * 15

View File

@@ -0,0 +1,219 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from unittest import mock
import pytest
import torch
from tests.v1.attention.utils import (
BatchSpec,
create_common_attn_metadata,
create_standard_kv_cache_spec,
try_get_attention_backend,
)
from vllm.config import (
CacheConfig,
DeviceConfig,
ModelConfig,
ParallelConfig,
SchedulerConfig,
SpeculativeConfig,
VllmConfig,
)
from vllm.config.load import LoadConfig
from vllm.model_executor.models.llama import LlamaForCausalLM
from vllm.platforms import current_platform
from vllm.v1.attention.backends.registry import AttentionBackendEnum
from vllm.v1.spec_decode.eagle import EagleProposer
mimo_7b_dir = "XiaomiMiMo/MiMo-7B-Base"
def _create_mtp_proposer(num_speculative_tokens: int) -> EagleProposer:
"""Create an MTP proposer with unified model configuration."""
model_config = ModelConfig(
model=mimo_7b_dir, runner="generate", max_model_len=100, trust_remote_code=True
)
speculative_config = SpeculativeConfig(
target_model_config=model_config,
target_parallel_config=ParallelConfig(),
model=mimo_7b_dir,
method="mtp",
num_speculative_tokens=num_speculative_tokens,
)
vllm_config = VllmConfig(
model_config=model_config,
cache_config=CacheConfig(),
speculative_config=speculative_config,
device_config=DeviceConfig(device=current_platform.device_type),
parallel_config=ParallelConfig(),
load_config=LoadConfig(),
scheduler_config=SchedulerConfig(
max_model_len=model_config.max_model_len,
is_encoder_decoder=model_config.is_encoder_decoder,
),
)
return EagleProposer(vllm_config=vllm_config, device=current_platform.device_type)
@mock.patch("vllm.v1.spec_decode.eagle.get_pp_group")
@mock.patch("vllm.v1.spec_decode.eagle.get_layers_from_vllm_config")
@mock.patch("vllm.v1.spec_decode.eagle.get_model")
def test_mtp_load_model_unified(mock_get_model, mock_get_layers, mock_get_pp_group):
"""Test MTP-specific model loading with unified model approach."""
# Setup mocks
mock_model = mock.MagicMock()
mock_model.model.embed_tokens.weight.shape = (131072, 4096)
mock_get_model.return_value = mock_model
# MTP does not have its own embed_tokens or lm_head
# so it should share them with the target model
mock_model.has_own_embed_tokens = False
mock_model.has_own_lm_head = False
target_attn_layers = {"target_attn_1": mock.MagicMock()}
all_attn_layers = {**target_attn_layers, "draft_attn_1": mock.MagicMock()}
target_indexer_layers: dict = {}
all_indexer_layers: dict = {}
mock_get_layers.side_effect = [
target_attn_layers,
target_indexer_layers,
all_attn_layers,
all_indexer_layers,
]
mock_pp_group = mock.MagicMock()
mock_pp_group.world_size = 1
mock_get_pp_group.return_value = mock_pp_group
# Create target model
class _TargetModelStub(LlamaForCausalLM):
model: mock.MagicMock
lm_head: mock.MagicMock
target_model = mock.create_autospec(_TargetModelStub, instance=True)
target_model.model = mock.MagicMock()
target_model.model.embed_tokens.weight.shape = (131072, 4096)
target_model.lm_head = mock.MagicMock()
# Create MTP proposer
proposer = _create_mtp_proposer(num_speculative_tokens=4)
proposer.load_model(target_model)
# Verify MTP-specific behavior:
# Model is loaded
mock_get_model.assert_called_once()
# MTP shares lm_head with target model
assert proposer.model.lm_head == target_model.lm_head
# MTP shares embed_tokens with target model
assert proposer.model.model.embed_tokens == target_model.model.embed_tokens
@pytest.mark.parametrize("num_speculative_tokens", [1])
def test_mtp_propose(num_speculative_tokens, monkeypatch):
"""Test that MTP's forward method returns hidden states directly"""
device = torch.device(current_platform.device_type)
batch_size = 2
seq_lens = [5, 3]
total_tokens = sum(seq_lens)
vocab_size = 100
proposer = _create_mtp_proposer(num_speculative_tokens)
hidden_size = proposer.hidden_size
# Mock the MTP model to verify it returns hidden states directly
model_mock = mock.MagicMock()
# MTP returns hidden states directly
if num_speculative_tokens == 1:
model_mock.return_value = torch.zeros(total_tokens, hidden_size, device=device)
else:
# Multiple forward passes for multi-token speculation
forward_returns = []
for i in range(num_speculative_tokens):
if i == 0:
h_states = torch.zeros(total_tokens, hidden_size, device=device)
else:
h_states = torch.zeros(batch_size, hidden_size, device=device)
forward_returns.append(h_states)
model_mock.side_effect = forward_returns
# Mock compute_logits
def create_deterministic_logits(batch_size, vocab_size, token_offset):
logits = torch.full((batch_size, vocab_size), -100.0, device=device)
logits[:, token_offset] = 100.0
return logits
if num_speculative_tokens == 1:
model_mock.compute_logits.return_value = create_deterministic_logits(
batch_size, vocab_size, 42
)
else:
logits_returns = [
create_deterministic_logits(batch_size, vocab_size, 42 + i)
for i in range(num_speculative_tokens)
]
model_mock.compute_logits.side_effect = logits_returns
proposer.model = model_mock
proposer._draft_attn_layer_names = {"layer.0"}
# Prepare inputs
batch_spec = BatchSpec(seq_lens=seq_lens, query_lens=seq_lens)
common_attn_metadata = create_common_attn_metadata(
batch_spec, block_size=16, device=device
)
target_token_ids = torch.randint(0, vocab_size, (total_tokens,), device=device)
target_positions = torch.cat(
[
torch.arange(seq_lens[0], device=device),
torch.arange(seq_lens[1], device=device),
]
)
target_hidden_states = torch.randn(total_tokens, hidden_size, device=device)
next_token_ids = torch.randint(
0, vocab_size, (batch_size,), dtype=torch.int32, device=device
)
sampling_metadata = mock.MagicMock()
# Setup attention metadata
attn_metadata_builder_cls, _ = try_get_attention_backend(
AttentionBackendEnum.FLASH_ATTN
)
attn_metadata_builder = attn_metadata_builder_cls(
kv_cache_spec=create_standard_kv_cache_spec(proposer.vllm_config),
layer_names=list(proposer._draft_attn_layer_names),
vllm_config=proposer.vllm_config,
device=device,
)
proposer.runner = mock.MagicMock()
mock_attn_group = mock.MagicMock()
mock_attn_group.get_metadata_builder.return_value = attn_metadata_builder
mock_attn_group.layer_names = list(proposer._draft_attn_layer_names)
mock_attn_group.kv_cache_spec = attn_metadata_builder.kv_cache_spec
proposer.draft_attn_groups = [mock_attn_group]
# Run propose
result = proposer.propose(
target_token_ids=target_token_ids,
target_positions=target_positions,
target_hidden_states=target_hidden_states,
next_token_ids=next_token_ids,
token_indices_to_sample=None,
common_attn_metadata=common_attn_metadata,
sampling_metadata=sampling_metadata,
)
# Verify the model was called correctly
assert model_mock.called
# Verify output shape
assert result.shape == (batch_size, num_speculative_tokens)

View File

@@ -0,0 +1,204 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import numpy as np
from vllm.config import (
ModelConfig,
SpeculativeConfig,
VllmConfig,
)
from vllm.v1.spec_decode.ngram_proposer import (
NgramProposer,
_find_longest_matched_ngram_and_propose_tokens,
)
def test_find_longest_matched_ngram_and_propose_tokens():
tokens = np.array([1, 2, 3, 4, 1, 2, 3, 5, 6])
result = _find_longest_matched_ngram_and_propose_tokens(
origin_tokens=tokens, min_ngram=2, max_ngram=2, max_model_len=1024, k=2
)
assert len(result) == 0
tokens = np.array([1, 2, 3, 4, 1, 2, 3])
np.testing.assert_array_equal(
_find_longest_matched_ngram_and_propose_tokens(
origin_tokens=tokens, min_ngram=2, max_ngram=2, max_model_len=1024, k=3
),
np.array([4, 1, 2]),
)
np.testing.assert_array_equal(
_find_longest_matched_ngram_and_propose_tokens(
origin_tokens=tokens, min_ngram=2, max_ngram=2, max_model_len=1024, k=2
),
np.array([4, 1]),
)
np.testing.assert_array_equal(
_find_longest_matched_ngram_and_propose_tokens(
origin_tokens=tokens, min_ngram=1, max_ngram=1, max_model_len=1024, k=3
),
np.array([4, 1, 2]),
)
np.testing.assert_array_equal(
_find_longest_matched_ngram_and_propose_tokens(
origin_tokens=tokens, min_ngram=1, max_ngram=1, max_model_len=1024, k=2
),
np.array([4, 1]),
)
tokens = np.array([1, 3, 6, 2, 3, 4, 1, 2, 3])
np.testing.assert_array_equal(
_find_longest_matched_ngram_and_propose_tokens(
origin_tokens=tokens, min_ngram=2, max_ngram=2, max_model_len=1024, k=3
),
np.array([4, 1, 2]),
)
# Return on the first match
np.testing.assert_array_equal(
_find_longest_matched_ngram_and_propose_tokens(
origin_tokens=tokens, min_ngram=1, max_ngram=1, max_model_len=1024, k=2
),
np.array([6, 2]),
)
def test_ngram_proposer():
def get_ngram_proposer(min_n: int, max_n: int, k: int) -> NgramProposer:
# Dummy model config. Just to set max_model_len.
model_config = ModelConfig(model="facebook/opt-125m")
return NgramProposer(
vllm_config=VllmConfig(
model_config=model_config,
speculative_config=SpeculativeConfig(
prompt_lookup_min=min_n,
prompt_lookup_max=max_n,
num_speculative_tokens=k,
method="ngram",
),
)
)
# No match.
token_ids_cpu = np.array([[1, 2, 3, 4, 5]])
result = get_ngram_proposer(min_n=2, max_n=2, k=2).propose(
sampled_token_ids=[[0]],
num_tokens_no_spec=np.array([len(c) for c in token_ids_cpu]),
token_ids_cpu=token_ids_cpu,
)
assert len(result[0]) == 0
# No match for 4-gram.
token_ids_cpu = np.array([[1, 2, 3, 4, 1, 2, 3]])
result = get_ngram_proposer(min_n=4, max_n=4, k=2).propose(
sampled_token_ids=[[0]],
num_tokens_no_spec=np.array([len(c) for c in token_ids_cpu]),
token_ids_cpu=token_ids_cpu,
)
assert len(result[0]) == 0
# No match for 4-gram but match for 3-gram.
token_ids_cpu = np.array([[1, 2, 3, 4, 1, 2, 3]])
result = get_ngram_proposer(min_n=3, max_n=4, k=2).propose(
sampled_token_ids=[[0]],
num_tokens_no_spec=np.array([len(c) for c in token_ids_cpu]),
token_ids_cpu=token_ids_cpu,
)
assert np.array_equal(result, np.array([[4, 1]]))
# Match for both 4-gram and 3-gram.
# In this case, the proposer should return the 4-gram match.
token_ids_cpu = np.array([[2, 3, 4, 5, 1, 2, 3, 4, 1, 2, 3, 4]])
result = get_ngram_proposer(min_n=3, max_n=4, k=2).propose(
sampled_token_ids=[[0]],
num_tokens_no_spec=np.array([len(c) for c in token_ids_cpu]),
token_ids_cpu=token_ids_cpu,
)
assert np.array_equal(result, np.array([[1, 2]])) # Not [5, 1]]
# Match for 2-gram and 3-gram, but not 4-gram.
token_ids_cpu = np.array([[3, 4, 5, 2, 3, 4, 1, 2, 3, 4]])
result = get_ngram_proposer(min_n=2, max_n=4, k=2).propose(
sampled_token_ids=[[0]],
num_tokens_no_spec=np.array([len(c) for c in token_ids_cpu]),
token_ids_cpu=token_ids_cpu,
)
assert np.array_equal(result, np.array([[1, 2]])) # Not [5, 2]]
# Multiple 3-gram matched, but always pick the first one.
token_ids_cpu = np.array([[1, 2, 3, 100, 1, 2, 3, 200, 1, 2, 3, 300, 1, 2, 3]])
result = get_ngram_proposer(min_n=3, max_n=3, k=2).propose(
sampled_token_ids=[[0]],
num_tokens_no_spec=np.array([len(c) for c in token_ids_cpu]),
token_ids_cpu=token_ids_cpu,
)
assert np.array_equal(result, np.array([[100, 1]]))
# check empty input
token_ids_cpu = np.array([[]])
result = get_ngram_proposer(min_n=2, max_n=2, k=2).propose(
sampled_token_ids=[[0]],
num_tokens_no_spec=np.array([len(c) for c in token_ids_cpu]),
token_ids_cpu=token_ids_cpu,
)
assert len(result[0]) == 0
# check multibatch input
# first request has 5 tokens and a match
# second request has 3 tokens and no match. Padded with -1 for max len 5
token_ids_cpu = np.array([[1, 2, 3, 1, 2], [4, 5, 6, -1, -1]])
result = get_ngram_proposer(min_n=2, max_n=2, k=2).propose(
sampled_token_ids=[[0], [1]],
num_tokens_no_spec=np.array([5, 3]),
token_ids_cpu=token_ids_cpu,
)
assert len(result[0]) == 2
assert np.array_equal(result[0], np.array([3, 1]))
assert np.array_equal(result[1], np.array([]))
# Test non-contiguous indices: requests 0 and 2 need proposals,
# request 1 is in prefill
proposer = get_ngram_proposer(min_n=2, max_n=2, k=2)
max_model_len = 20
token_ids_cpu = np.zeros((3, max_model_len), dtype=np.int32)
token_ids_cpu[0, :5] = [1, 2, 3, 1, 2]
token_ids_cpu[1, :3] = [4, 5, 6]
token_ids_cpu[2, :5] = [7, 8, 9, 7, 8]
num_tokens_no_spec = np.array([5, 3, 5], dtype=np.int32)
sampled_token_ids = [[2], [], [8]] # Empty list for request 1 simulates prefill
result = proposer.propose(
sampled_token_ids=sampled_token_ids,
num_tokens_no_spec=num_tokens_no_spec,
token_ids_cpu=token_ids_cpu,
)
assert len(result) == 3
assert np.array_equal(result[0], [3, 1])
assert len(result[1]) == 0
assert np.array_equal(result[2], [9, 7])
# Verify internal arrays written to correct indices
assert proposer.valid_ngram_num_drafts[0] == 2
assert proposer.valid_ngram_num_drafts[1] == 0
assert proposer.valid_ngram_num_drafts[2] == 2
assert np.array_equal(proposer.valid_ngram_draft[0, :2], [3, 1])
assert np.array_equal(proposer.valid_ngram_draft[2, :2], [9, 7])
# test if 0 threads available: can happen if TP size > CPU count
ngram_proposer = get_ngram_proposer(min_n=2, max_n=2, k=2)
ngram_proposer.num_numba_thread_available = 0
# set max_model_len to 2 * threshold to ensure multithread is used
num_tokens_threshold = ngram_proposer.num_tokens_threshold
ngram_proposer.max_model_len = 2 * num_tokens_threshold
# using multibatch test
middle_integer = num_tokens_threshold // 2
input_1 = [_ for _ in range(num_tokens_threshold)]
input_1 += [middle_integer, middle_integer + 1]
input_2 = [-1] * len(input_1)
input_2[:3] = [4, 5, 6]
token_ids_cpu = np.array([input_1, input_2])
result = ngram_proposer.propose(
sampled_token_ids=[[0], [1]],
num_tokens_no_spec=np.array([len(input_1), 3]),
token_ids_cpu=token_ids_cpu,
)
assert len(result[0]) == 2
assert np.array_equal(result[0], np.array([middle_integer + 2, middle_integer + 3]))
assert np.array_equal(result[1], np.array([]))

View File

@@ -0,0 +1,70 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
import torch
from vllm.config import SpeculativeConfig
from vllm.model_executor.models.interfaces import supports_eagle3
from vllm.platforms import current_platform
@pytest.mark.parametrize(
"model_path",
[
pytest.param(
"nm-testing/SpeculatorLlama3-1-8B-Eagle3-converted-0717-quantized",
id="llama3-eagle3-speculator",
),
pytest.param(
"nm-testing/Speculator-Qwen3-8B-Eagle3-converted-071-quantized",
id="qwen3-eagle3-speculator",
),
pytest.param(
"nm-testing/Speculator-Qwen3-8B-Eagle3-converted-071-quantized-w4a16",
id="qwen3-eagle3-speculator-w4a16-verifier",
marks=pytest.mark.skipif(
current_platform.is_rocm(),
reason="The tests are skipped on rocm platform.",
),
),
],
)
def test_eagle3_speculators_model(
vllm_runner, example_prompts, model_path, monkeypatch
):
"""
Test Eagle3 speculators models properly initialize speculative decoding.
This test verifies:
1. Eagle3 support is detected for the model
2. Speculative config is automatically initialized from embedded config
3. The draft model path is correctly set to the speculators model
4. Speculative tokens count is valid
5. Text generation works with speculative decoding enabled
"""
# Set environment variable for V1 engine serialization
monkeypatch.setenv("VLLM_ALLOW_INSECURE_SERIALIZATION", "1")
with vllm_runner(model_path, dtype=torch.bfloat16) as vllm_model:
# Verify Eagle3 support is detected
eagle3_supported = vllm_model.apply_model(supports_eagle3)
assert eagle3_supported, f"Eagle3 should be supported for {model_path}"
vllm_config = vllm_model.llm.llm_engine.vllm_config
assert isinstance(vllm_config.speculative_config, SpeculativeConfig), (
"Speculative config should be initialized for speculators model"
)
spec_config = vllm_config.speculative_config
assert spec_config.num_speculative_tokens > 0, (
f"Expected positive speculative tokens, "
f"got {spec_config.num_speculative_tokens}"
)
assert spec_config.model == model_path, (
f"Draft model should be {model_path}, got {spec_config.model}"
)
vllm_outputs = vllm_model.generate_greedy(example_prompts, max_tokens=20)
assert vllm_outputs, f"No outputs generated for speculators model {model_path}"

View File

@@ -0,0 +1,502 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import math
import pytest
import torch
from tests.v1.attention.utils import (
create_standard_kv_cache_spec,
create_vllm_config,
try_backend_includes_kv_cache_update,
try_get_attention_backend,
)
from vllm.config import ParallelConfig, SpeculativeConfig
from vllm.platforms import current_platform
from vllm.v1.attention.backend import CommonAttentionMetadata
from vllm.v1.attention.backends.fa_utils import is_flash_attn_varlen_func_available
from vllm.v1.attention.backends.registry import AttentionBackendEnum
if not is_flash_attn_varlen_func_available():
pytest.skip(
"This test requires flash_attn_varlen_func, but it's not available.",
allow_module_level=True,
)
# --------------------------------------------------------------------------- #
# KV cache layout adaptation
# --------------------------------------------------------------------------- #
# Two KV cache layouts exist across backends:
#
# Flash layout: (2, num_blocks, block_size, num_kv_heads, head_size)
# - dim 0 separates key (index 0) and value (index 1)
# - Used by: FLASH_ATTN, TREE_ATTN, ROCM_AITER_FA, ROCM_ATTN
#
# Block layout: (num_blocks, 2, block_size, num_kv_heads, head_size)
# - dim 1 separates key (index 0) and value (index 1)
# - Used by: TRITON_ATTN
#
# The test creates KV caches in flash layout (the canonical format used by
# tree attention). When a reference backend needs block layout we transpose
# dims 0 and 1.
#
# Note: ROCM_ATTN uses flash layout for storage but its forward path calls
# PagedAttention.split_kv_cache which reinterprets the raw memory as paged
# layout (num_blocks, num_kv_heads, head_size//x, block_size, x). This is
# a view-level incompatibility, not a transpose - see the TODO in
# _get_available_reference_backends for details.
#
# TODO: Replace this mapping with a `KV_CACHE_LAYOUT` class attribute on each
# AttentionImpl so the layout is self-documented by the backend itself, e.g.:
# class TritonAttentionImpl(AttentionImpl):
# KV_CACHE_LAYOUT = "block"
# --------------------------------------------------------------------------- #
_BLOCK_KV_LAYOUT_BACKENDS = frozenset(
{
AttentionBackendEnum.TRITON_ATTN,
}
)
# Backends whose do_kv_cache_update requires engine-level state (e.g.
# ForwardContext) that is not available in this test harness, but whose
# KV cache is flash layout and can be written with reshape_and_cache_flash.
# When a backend is listed here, forward_attention() bypasses
# do_kv_cache_update and writes directly to the cache.
_NEEDS_DIRECT_CACHE_UPDATE = frozenset(
{
AttentionBackendEnum.ROCM_AITER_FA,
}
)
# Backends with known test-harness incompatibilities - see the TODOs
# inside _get_available_reference_backends for details.
_INCOMPATIBLE_REFERENCE_BACKENDS = frozenset(
{
AttentionBackendEnum.ROCM_AITER_FA,
AttentionBackendEnum.ROCM_ATTN,
}
)
def _adapt_kv_cache_for_backend(
kv_cache: torch.Tensor,
backend: AttentionBackendEnum,
) -> torch.Tensor:
"""Convert kv_cache from flash layout ``(2, num_blocks, ...)`` to block
layout ``(num_blocks, 2, ...)`` if the backend requires it. Returns the
original tensor unchanged when no conversion is needed."""
if backend in _BLOCK_KV_LAYOUT_BACKENDS:
return kv_cache.transpose(0, 1).contiguous()
return kv_cache
def _get_platform_default_backend() -> AttentionBackendEnum:
"""Ask the platform what backend it would auto-select at runtime."""
from vllm.v1.attention.selector import AttentionSelectorConfig
config = AttentionSelectorConfig(
block_size=32,
kv_cache_dtype="auto",
use_mla=False,
use_sparse=False,
head_size=128,
dtype=torch.bfloat16,
)
backend_path = current_platform.get_attn_backend_cls(
selected_backend=None,
attn_selector_config=config,
)
for backend in AttentionBackendEnum:
try:
if backend.get_path() == backend_path:
return backend
except ValueError:
continue
raise RuntimeError(
f"Platform returned backend path '{backend_path}' "
f"that doesn't match any AttentionBackendEnum member."
)
def _get_available_reference_backends() -> list[AttentionBackendEnum]:
"""Collect all reference backends the current platform can run.
On CUDA this is just FLASH_ATTN. On ROCm this includes the platform
default plus every backend the hardware supports, so the test validates
tree attention against all of them.
"""
if current_platform.is_rocm():
backends: list[AttentionBackendEnum] = []
# 1. Whatever the platform would auto-select at runtime.
default_backend = _get_platform_default_backend()
if default_backend not in _INCOMPATIBLE_REFERENCE_BACKENDS:
backends.append(default_backend)
# 2. TRITON_ATTN - always available on ROCm.
if AttentionBackendEnum.TRITON_ATTN not in backends:
backends.append(AttentionBackendEnum.TRITON_ATTN)
# TODO: Enable ROCM_ATTN. Its forward path uses
# PagedAttention.split_kv_cache which reinterprets the raw
# cache memory as paged layout:
# key: (num_blocks, num_kv_heads, head_size//x, block_size, x)
# value: (num_blocks, num_kv_heads, head_size, block_size)
# Tree attention writes prefix data in NHD flash layout, so the
# same bytes produce completely different values when read in
# paged format. Supporting ROCM_ATTN would require writing
# prefix data via PagedAttention.write_to_paged_cache into a
# separate paged-format KV cache.
# TODO: Enable ROCM_AITER_FA. Its metadata builder reads head
# counts from the model config at construction time and
# allocates extend_workspace with those dimensions. The test
# uses independent head count parameters (num_heads=2/4,
# num_kv_heads=2) that don't match the model config
# (Llama-3-8B: 32 q heads, 8 kv heads), causing a head count
# mismatch in flash_attn_varlen_func during extend_forward.
# Fixing this requires either matching test head counts to the
# model config or decoupling the builder from model config
# head geometry. The direct cache update path
# (_NEEDS_DIRECT_CACHE_UPDATE) is already in place for when
# this is resolved.
return backends
# CUDA: flash attention.
return [AttentionBackendEnum.FLASH_ATTN]
class MockAttentionLayer(torch.nn.Module):
_q_scale = torch.tensor(1.0, dtype=torch.float32, device="cuda")
_k_scale = torch.tensor(1.0, dtype=torch.float32, device="cuda")
_v_scale = torch.tensor(1.0, dtype=torch.float32, device="cuda")
layer_name = "mock_layer"
def __init__(self):
super().__init__()
def forward(self, x):
return x
def forward_attention(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
kv_cache: torch.Tensor,
block_table: torch.Tensor,
slot_mapping: torch.Tensor,
seqlen_k: int,
backend: AttentionBackendEnum,
spec_token_tree: str | None = None,
num_spec_tokens: int = 0,
) -> torch.Tensor:
"""Run a single attention forward pass through the given backend.
``kv_cache`` is expected in **flash layout**
``(2, num_blocks, block_size, num_kv_heads, head_size)``.
It is automatically converted when the target backend needs a
different layout.
"""
batch_size, q_len, num_heads, dim_per_head = q.shape
num_kv_heads = k.shape[-2]
# Initialize the query and KV sequence lengths.
query_start_loc = q_len * torch.arange(
batch_size + 1, device=q.device, dtype=torch.int32
)
query_lens = torch.diff(query_start_loc)
seq_lens = torch.full(
(batch_size,),
seqlen_k,
device=q.device,
dtype=torch.int32,
)
context_lens = seq_lens - query_lens
max_seq_len = int(seq_lens.max())
max_query_len = q_len
num_actual_tokens = query_start_loc[-1]
softmax_scale = q.shape[-1] ** (-0.5)
layer = MockAttentionLayer()
# Build common metadata.
model_name = "meta-llama/Meta-Llama-3-8B"
builder_cls, impl_cls = try_get_attention_backend(backend)
vllm_config = create_vllm_config(model_name=model_name, max_model_len=max(seq_lens))
if spec_token_tree is not None:
# Create speculative config if token tree is specified.
vllm_config.speculative_config = SpeculativeConfig(
target_model_config=vllm_config.model_config,
target_parallel_config=ParallelConfig(),
model=model_name,
method="eagle",
num_speculative_tokens=num_spec_tokens,
speculative_token_tree=spec_token_tree,
)
kv_cache_spec = create_standard_kv_cache_spec(vllm_config)
builder = builder_cls(kv_cache_spec, [], vllm_config, q.device)
common_attn_metadata = CommonAttentionMetadata(
query_start_loc=query_start_loc,
query_start_loc_cpu=query_start_loc.cpu(),
seq_lens=seq_lens,
_seq_lens_cpu=seq_lens.cpu(),
_num_computed_tokens_cpu=context_lens.cpu(),
num_reqs=batch_size,
num_actual_tokens=num_actual_tokens,
max_query_len=max_query_len,
max_seq_len=max_seq_len,
block_table_tensor=block_table,
slot_mapping=slot_mapping,
)
# Build attention metadata.
attn_metadata = builder.build(
common_prefix_len=0,
common_attn_metadata=common_attn_metadata,
)
# Initialize the backend implementation.
instance = impl_cls(
num_heads=num_heads,
head_size=dim_per_head,
scale=softmax_scale,
num_kv_heads=num_kv_heads,
alibi_slopes=None,
sliding_window=None,
kv_cache_dtype="auto",
)
# Adapt KV cache layout for this backend.
adapted_kv_cache = _adapt_kv_cache_for_backend(kv_cache, backend)
# Run forward pass and return output.
query = q.view(-1, num_heads, dim_per_head)
key = k.view(-1, num_kv_heads, dim_per_head)
value = v.view(-1, num_kv_heads, dim_per_head)
output = torch.empty_like(query)
if not try_backend_includes_kv_cache_update(backend):
if backend in _NEEDS_DIRECT_CACHE_UPDATE:
# This backend's do_kv_cache_update requires engine-level
# ForwardContext that isn't available in this test harness.
# Write directly using reshape_and_cache_flash since the
# KV cache layout is identical (flash layout, unbind on dim 0).
key_cache, value_cache = adapted_kv_cache.unbind(0)
torch.ops._C_cache_ops.reshape_and_cache_flash(
key,
value,
key_cache,
value_cache,
attn_metadata.slot_mapping,
"auto",
layer._k_scale,
layer._v_scale,
)
else:
instance.do_kv_cache_update(
layer=layer,
key=key,
value=value,
kv_cache=adapted_kv_cache,
slot_mapping=attn_metadata.slot_mapping,
)
return instance.forward(
layer=layer,
query=query,
key=key,
value=value,
kv_cache=adapted_kv_cache.clone(),
attn_metadata=attn_metadata,
output=output,
)
@pytest.mark.parametrize(
"reference_backend",
_get_available_reference_backends(),
ids=lambda b: b.name,
)
def test_tree_attn_correctness(
reference_backend: AttentionBackendEnum,
) -> None:
torch.manual_seed(42)
torch.cuda.manual_seed_all(42)
device = "cuda"
tree_attn_masks = {
# Chain.
"[(0,), (0, 0), (0, 0, 0)]": torch.tensor(
[
[1, 0, 0, 0],
[1, 1, 0, 0],
[1, 1, 1, 0],
[1, 1, 1, 1],
],
device=device,
dtype=torch.int32,
),
# Tree.
"[(0,), (1,), (0, 0), (0, 1), (1, 0), (1, 1)]": torch.tensor(
[
[1, 0, 0, 0, 0, 0, 0],
[1, 1, 0, 0, 0, 0, 0],
[1, 0, 1, 0, 0, 0, 0],
[1, 1, 0, 1, 0, 0, 0],
[1, 1, 0, 0, 1, 0, 0],
[1, 0, 1, 0, 0, 1, 0],
[1, 0, 1, 0, 0, 0, 1],
],
device=device,
dtype=torch.int32,
),
}
dim_per_head = 128
num_kv_heads = 2
block_size = 32
max_sequence_length = 8192
randomize_blocks = True
for batch_size in [1, 16, 32]:
for num_heads in [2, 4]:
for sequence_position in [16, 1024, 2048]:
for spec_token_tree, tree_attn_mask in tree_attn_masks.items():
# Assert that the number of heads is divisible
# by the number of KV heads.
assert num_heads % num_kv_heads == 0
# Initialize q, k, and v.
tree_size_q = tree_attn_mask.shape[0]
seqlen_k = sequence_position + tree_size_q
q = torch.randn(
(batch_size, tree_size_q, num_heads, dim_per_head),
device=device,
dtype=torch.bfloat16,
)
k = torch.randn(
(batch_size, tree_size_q, num_kv_heads, dim_per_head),
device=device,
dtype=torch.bfloat16,
)
v = torch.randn(
(batch_size, tree_size_q, num_kv_heads, dim_per_head),
device=device,
dtype=torch.bfloat16,
)
# KV cache in flash layout - the canonical format for
# tree attention. forward_attention() handles conversion
# when needed.
assert max_sequence_length % block_size == 0
max_blocks_per_batch = max_sequence_length // block_size
kv_cache = torch.randn(
(
2,
batch_size * max_blocks_per_batch,
block_size,
num_kv_heads,
dim_per_head,
),
device=q.device,
dtype=torch.bfloat16,
)
num_alloc_blocks_per_batch = math.ceil(seqlen_k / block_size)
block_table = torch.zeros(
(batch_size, max_blocks_per_batch),
device=q.device,
dtype=torch.int32,
)
block_ids = torch.arange(
0,
batch_size * num_alloc_blocks_per_batch,
device=q.device,
dtype=torch.int32,
)
if randomize_blocks:
# Randomize the block ids.
block_ids = block_ids[torch.randperm(block_ids.numel())]
block_table[:, :num_alloc_blocks_per_batch] = block_ids.view(
-1, num_alloc_blocks_per_batch
)
# Set up the slot mapping for the input KVs.
tree_positions = sequence_position + torch.arange(
0,
tree_size_q,
device=q.device,
dtype=torch.int64,
).repeat(batch_size, 1)
tree_slot_mapping = _gen_slot_mapping(
tree_positions, block_table, block_size
)
# Compute attention for the tree.
tree_attn_output = forward_attention(
q=q,
k=k,
v=v,
kv_cache=kv_cache,
block_table=block_table,
slot_mapping=tree_slot_mapping,
seqlen_k=seqlen_k,
backend=AttentionBackendEnum.TREE_ATTN,
spec_token_tree=spec_token_tree,
num_spec_tokens=tree_size_q - 1,
).view(batch_size, -1, num_heads, dim_per_head)
# Verify each branch against the reference backend.
for q_index in range(tree_size_q):
# Get the q, k, and v for the branch.
branch_mask = tree_attn_mask[q_index, :]
branch_indices = torch.nonzero(branch_mask, as_tuple=True)[0]
q_len = branch_indices.shape[0]
q_branch = q[:, branch_indices]
k_branch = k[:, branch_indices]
v_branch = v[:, branch_indices]
# Setup slot mapping for the branch.
branch_positions = sequence_position + torch.arange(
0,
q_len,
device=q.device,
dtype=torch.int64,
).repeat(batch_size, 1)
branch_slot_mapping = _gen_slot_mapping(
branch_positions, block_table, block_size
)
# Reference attention for this branch.
ref_output = forward_attention(
q=q_branch,
k=k_branch,
v=v_branch,
kv_cache=kv_cache,
block_table=block_table,
slot_mapping=branch_slot_mapping,
seqlen_k=sequence_position + q_len,
backend=reference_backend,
).view(batch_size, -1, num_heads, dim_per_head)
# Compare the outputs.
assert torch.allclose(
tree_attn_output[:, branch_indices],
ref_output,
atol=7.81e-3,
), (
f"outputs are not close for "
f"reference_backend: {reference_backend.name}, "
f"batch_size: {batch_size}, "
f"num_heads: {num_heads}, "
f"sequence_position: {sequence_position}, "
f"tree_attn_mask: {tree_attn_mask}, "
f"q_index: {q_index}."
)
def _gen_slot_mapping(
positions: torch.Tensor, block_table: torch.Tensor, block_size: int
):
block_indices = positions // block_size
blocks = block_table.gather(dim=1, index=block_indices)
return (blocks * block_size + positions % block_size).view(-1)