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:
2
third_party/vllm/tests/v1/spec_decode/__init__.py
vendored
Normal file
2
third_party/vllm/tests/v1/spec_decode/__init__.py
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
312
third_party/vllm/tests/v1/spec_decode/test_acceptance_length.py
vendored
Normal file
312
third_party/vllm/tests/v1/spec_decode/test_acceptance_length.py
vendored
Normal 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]}")
|
||||
1154
third_party/vllm/tests/v1/spec_decode/test_eagle.py
vendored
Normal file
1154
third_party/vllm/tests/v1/spec_decode/test_eagle.py
vendored
Normal file
File diff suppressed because it is too large
Load Diff
175
third_party/vllm/tests/v1/spec_decode/test_eagle_step_kernel.py
vendored
Normal file
175
third_party/vllm/tests/v1/spec_decode/test_eagle_step_kernel.py
vendored
Normal 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
|
||||
334
third_party/vllm/tests/v1/spec_decode/test_extract_hidden_states.py
vendored
Normal file
334
third_party/vllm/tests/v1/spec_decode/test_extract_hidden_states.py
vendored
Normal 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)
|
||||
85
third_party/vllm/tests/v1/spec_decode/test_max_len.py
vendored
Normal file
85
third_party/vllm/tests/v1/spec_decode/test_max_len.py
vendored
Normal 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
|
||||
219
third_party/vllm/tests/v1/spec_decode/test_mtp.py
vendored
Normal file
219
third_party/vllm/tests/v1/spec_decode/test_mtp.py
vendored
Normal 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)
|
||||
204
third_party/vllm/tests/v1/spec_decode/test_ngram.py
vendored
Normal file
204
third_party/vllm/tests/v1/spec_decode/test_ngram.py
vendored
Normal 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([]))
|
||||
70
third_party/vllm/tests/v1/spec_decode/test_speculators_eagle3.py
vendored
Normal file
70
third_party/vllm/tests/v1/spec_decode/test_speculators_eagle3.py
vendored
Normal 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}"
|
||||
502
third_party/vllm/tests/v1/spec_decode/test_tree_attention.py
vendored
Normal file
502
third_party/vllm/tests/v1/spec_decode/test_tree_attention.py
vendored
Normal 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)
|
||||
Reference in New Issue
Block a user