[CI] Solidify speculative decoding E2E coverage (#50713)

Signed-off-by: Andreas Karatzas <Andreas.Karatzas@amd.com> Signed-off-by: Andreas Karatzas <akaratza@amd.com> Co-authored-by: OpenAI Codex <codex@openai.com>

Andreas Karatzas committed Aug 10, 2026 at 20:56 UTC 1a1727330a14cfa9f4bdcebfaba403fd02ae7011
18 files changed +749 -471
.buildkite/test_areas/spec_decode.yaml
+98 -6
@@ -29,8 +29,8 @@ steps:
29 depends_on:
30 - image-build-amd
31
32 -- label: Spec Decode Eagle
33 - key: spec-decode-eagle
32 +- label: "Spec Decode Eagle 1: DeepSeek + Qwen"
33 + key: spec-decode-eagle-1-deepseek-qwen
34 timeout_in_minutes: 25
35 device: h200_35gb
36 source_file_dependencies:
@@ -38,12 +38,38 @@ steps:
38 - vllm/v1/worker/gpu/spec_decode/
39 - tests/v1/e2e/spec_decode/
40 commands:
41 - - pytest -v -s v1/e2e/spec_decode/eagle/
41 + - pytest -v -s v1/e2e/spec_decode/eagle/ -k "deepseek_eagle or qwen3_eagle3"
42 mirror:
43 amd:
44 dind: false
45 device: mi300_1
46 - timeout_in_minutes: 55
46 + timeout_in_minutes: 25
47 + depends_on:
48 + - image-build-amd
49 + source_file_dependencies:
50 + - vllm/v1/spec_decode/
51 + - vllm/v1/worker/gpu/spec_decode/
52 + - vllm/model_executor/model_loader/
53 + - vllm/v1/sample/
54 + - vllm/model_executor/layers/
55 + - tests/v1/e2e/spec_decode/
56 + - vllm/platforms/rocm.py
57 +
58 +- label: "Spec Decode Eagle 2: Llama 3 + Qwen VL + Other"
59 + key: spec-decode-eagle-2-llama3-qwen-vl-other
60 + timeout_in_minutes: 25
61 + device: h200_35gb
62 + source_file_dependencies:
63 + - vllm/v1/spec_decode/
64 + - vllm/v1/worker/gpu/spec_decode/
65 + - tests/v1/e2e/spec_decode/
66 + commands:
67 + - pytest -v -s v1/e2e/spec_decode/eagle/ -k "not deepseek_eagle and not qwen3_eagle3"
68 + mirror:
69 + amd:
70 + dind: false
71 + device: mi300_1
72 + timeout_in_minutes: 25
73 depends_on:
74 - image-build-amd
75 source_file_dependencies:
@@ -192,8 +218,8 @@ steps:
218 - export VLLM_ALLOW_INSECURE_SERIALIZATION=1
219 - pytest -v -s v1/spec_decode/test_speculators_correctness.py -m slow_test
220
195 -- label: Spec Decode DeepSeek MTP Parallel Load (B200)
196 - key: spec-decode-deepseek-mtp-parallel-load-b200
221 +- label: Spec Decode DeepSeek MTP Parallel Load (2xB200-2xMI300)
222 + key: spec-decode-deepseek-mtp-parallel-load-2xb200-2xmi300
223 timeout_in_minutes: 30
224 device: b200-k8s
225 optional: true
@@ -207,6 +233,26 @@ steps:
233 - tests/v1/e2e/spec_decode/test_mtp_parallel_load.py
234 commands:
235 - pytest -v -s v1/e2e/spec_decode/test_mtp_parallel_load.py
236 + mirror:
237 + amd:
238 + dind: false
239 + device: mi300_2
240 + timeout_in_minutes: 45
241 + depends_on:
242 + - image-build-amd
243 + source_file_dependencies:
244 + - vllm/distributed/
245 + - vllm/model_executor/model_loader/
246 + - vllm/model_executor/layers/
247 + - vllm/model_executor/models/deepseek_mtp.py
248 + - vllm/model_executor/models/deepseek_v2.py
249 + - vllm/v1/attention/
250 + - vllm/v1/engine/
251 + - vllm/v1/spec_decode/
252 + - vllm/v1/worker/gpu/spec_decode/
253 + - vllm/v1/worker/gpu_model_runner.py
254 + - tests/v1/e2e/spec_decode/test_mtp_parallel_load.py
255 + - vllm/platforms/rocm.py
256
257 - label: Spec Decode AL DFlash Nightly
258 key: spec-decode-dflash-nightly
@@ -221,6 +267,27 @@ steps:
267 - tests/v1/e2e/spec_decode/
268 commands:
269 - pytest -v -s v1/e2e/spec_decode/acceptance_rates/dflash/
270 + mirror:
271 + amd:
272 + dind: false
273 + device: mi300_1
274 + timeout_in_minutes: 120
275 + depends_on:
276 + - image-build-amd
277 + source_file_dependencies:
278 + - vllm/v1/spec_decode/
279 + - vllm/v1/worker/gpu/spec_decode/
280 + - vllm/v1/attention/
281 + - vllm/v1/sample/
282 + - vllm/model_executor/model_loader/
283 + - vllm/model_executor/layers/
284 + - vllm/model_executor/models/qwen2.py
285 + - vllm/model_executor/models/qwen3.py
286 + - vllm/model_executor/models/qwen3_dflash.py
287 + - vllm/model_executor/models/laguna.py
288 + - vllm/model_executor/models/laguna_dflash.py
289 + - tests/v1/e2e/spec_decode/
290 + - vllm/platforms/rocm.py
291
292 - label: Spec Decode AL DSpark Nightly
293 key: spec-decode-dspark-nightly
@@ -235,6 +302,31 @@ steps:
302 - tests/v1/e2e/spec_decode/
303 commands:
304 - pytest -v -s v1/e2e/spec_decode/acceptance_rates/dspark/
305 + mirror:
306 + amd:
307 + dind: false
308 + device: mi300_1
309 + timeout_in_minutes: 90
310 + depends_on:
311 + - image-build-amd
312 + source_file_dependencies:
313 + - vllm/v1/spec_decode/
314 + - vllm/v1/worker/gpu/spec_decode/
315 + - vllm/v1/attention/
316 + - vllm/v1/sample/
317 + - vllm/model_executor/model_loader/
318 + - vllm/model_executor/layers/
319 + - vllm/model_executor/models/qwen2.py
320 + - vllm/model_executor/models/qwen3.py
321 + - vllm/model_executor/models/qwen3_dflash.py
322 + - vllm/model_executor/models/qwen3_dspark.py
323 + - vllm/model_executor/models/gemma4.py
324 + - vllm/model_executor/models/gemma4_mm.py
325 + - vllm/model_executor/models/gemma4_unified.py
326 + - vllm/model_executor/models/gemma4_mtp.py
327 + - vllm/model_executor/models/gemma4_dspark.py
328 + - tests/v1/e2e/spec_decode/
329 + - vllm/platforms/rocm.py
330
331 - label: Spec Decode AL MTP + Other Acceptance Nightly
332 key: spec-decode-mtp-other-acceptance-nightly
tests/v1/e2e/spec_decode/acceptance_rates/dflash/test_dflash.py
+34 -25
@@ -4,12 +4,10 @@
4 from dataclasses import dataclass
5
6 import pytest
7 -import torch
7
8 from tests.evals.gsm8k.gsm8k_eval import evaluate_gsm8k_offline
9 from tests.utils import single_gpu_only
11 -from vllm import LLM
12 -from vllm.distributed import cleanup_dist_env_and_memory
10 +from vllm.config import CompilationConfig
11
12 from ...utils import compute_acceptance_len
13 from ..utils import run_acceptance_length_eval
@@ -56,9 +54,11 @@ LAGUNA_DFLASH_NVFP4 = DFlashCorrectnessConfig(
54 def test_dflash_reference_acceptance_lengths(
55 monkeypatch: pytest.MonkeyPatch,
56 use_mrv2: bool,
57 + vllm_runner,
58 ):
59 run_acceptance_length_eval(
60 monkeypatch,
61 + vllm_runner,
62 spec_config={
63 "model": QWEN3_DFLASH.model,
64 "trust_remote_code": True,
@@ -110,14 +110,16 @@ def test_dflash_correctness(
110 monkeypatch: pytest.MonkeyPatch,
111 config: DFlashCorrectnessConfig,
112 use_mrv2: bool,
113 + vllm_runner,
114 ):
115 """Guard GSM8K accuracy and batched acceptance length for DFlash models."""
116 monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1" if use_mrv2 else "0")
117 if config.disable_flashinfer_sampler:
118 monkeypatch.setenv("VLLM_USE_FLASHINFER_SAMPLER", "0")
119
119 - spec_llm = LLM(
120 - model=config.model,
120 + with vllm_runner(
121 + config.model,
122 + block_size=None,
123 trust_remote_code=True,
124 speculative_config={
125 "method": "dflash",
@@ -130,23 +132,30 @@ def test_dflash_correctness(
132 gpu_memory_utilization=0.85,
133 enforce_eager=config.enforce_eager,
134 disable_log_stats=False,
133 - )
134 -
135 - results = evaluate_gsm8k_offline(
136 - spec_llm,
137 - num_questions=config.num_questions,
138 - use_chat_completions=config.use_chat_completions,
139 - )
140 - accuracy = results["accuracy"]
141 - acceptance_len = compute_acceptance_len(spec_llm.get_metrics())
142 - print(
143 - f"{config.model}: GSM8K accuracy={accuracy:.3f}, "
144 - f"acceptance_len={acceptance_len:.2f}"
145 - )
146 -
147 - assert accuracy >= config.expected_accuracy
148 - assert acceptance_len >= config.expected_acceptance_len
149 -
150 - del spec_llm
151 - torch.accelerator.empty_cache()
152 - cleanup_dist_env_and_memory()
135 + enable_chunked_prefill=None,
136 + compilation_config=CompilationConfig(),
137 + ) as spec_runner:
138 + spec_llm = spec_runner.llm
139 + results = evaluate_gsm8k_offline(
140 + spec_llm,
141 + num_questions=config.num_questions,
142 + use_chat_completions=config.use_chat_completions,
143 + )
144 + accuracy = results["accuracy"]
145 + acceptance_len = compute_acceptance_len(spec_llm.get_metrics())
146 + context = (
147 + f"DFlash target={config.model}, draft={config.draft_model}, MRV2={use_mrv2}"
148 + )
149 + print(
150 + f"{context}: GSM8K accuracy={accuracy:.3f}, "
151 + f"acceptance_len={acceptance_len:.2f}"
152 + )
153 +
154 + assert accuracy >= config.expected_accuracy, (
155 + f"{context}: GSM8K accuracy {accuracy:.3f} is below "
156 + f"{config.expected_accuracy:.3f}; acceptance_len={acceptance_len:.3f}"
157 + )
158 + assert acceptance_len >= config.expected_acceptance_len, (
159 + f"{context}: acceptance_len {acceptance_len:.3f} is below "
160 + f"{config.expected_acceptance_len:.3f}; accuracy={accuracy:.3f}"
161 + )
tests/v1/e2e/spec_decode/acceptance_rates/dspark/test_dspark.py
+32 -21
@@ -4,11 +4,10 @@
4 from dataclasses import dataclass
5
6 import pytest
7 -import torch
7
8 from tests.evals.gsm8k.gsm8k_eval import evaluate_gsm8k_offline
10 -from vllm import LLM
11 -from vllm.distributed import cleanup_dist_env_and_memory
9 +from vllm.config import CompilationConfig
10 +from vllm.platforms import current_platform
11
12 from ...utils import compute_acceptance_len, compute_acceptance_rate
13
@@ -44,6 +43,7 @@ QWEN3_DSPARK_DEEPSPEC = DSparkCorrectnessConfig(
43 reference_accuracy=0.801,
44 reference_acceptance_rate=0.428,
45 reference_acceptance_len=3.994,
46 + attention_backend="FLASH_ATTN",
47 )
48
49 # References from five 200-question runs at temperature 1.0: accuracy
@@ -58,6 +58,9 @@ GEMMA4_DSPARK_DEEPSPEC = DSparkCorrectnessConfig(
58 num_questions=200,
59 use_chat_completions=True,
60 gpu_memory_utilization=0.85,
61 + enforce_eager=True,
62 + disable_flashinfer_sampler=True,
63 + language_model_only=True,
64 )
65
66 # Reference from 200 GSM8K questions at temperature 1.0.
@@ -88,34 +91,40 @@ QWEN3_6_DSPARK_SPECULATORS = DSparkCorrectnessConfig(
91 def test_dspark_correctness_and_acceptance_rate(
92 monkeypatch: pytest.MonkeyPatch,
93 config: DSparkCorrectnessConfig,
94 + vllm_runner,
95 ):
96 """Guard GSM8K accuracy and acceptance metrics for DSpark models."""
97 + if config.disable_flashinfer_sampler:
98 + monkeypatch.setenv("VLLM_USE_FLASHINFER_SAMPLER", "0")
99 +
100 speculative_config = {
101 "method": "dspark",
102 "model": config.draft_model,
103 "num_speculative_tokens": config.num_speculative_tokens,
104 "draft_sample_method": "probabilistic",
105 }
99 - if config.attention_backend is not None:
106 + if config.attention_backend is not None and current_platform.is_cuda():
107 speculative_config["attention_backend"] = config.attention_backend
108
102 - llm_kwargs = {
103 - "model": config.model,
109 + runner_config = {
110 + "block_size": None,
111 "trust_remote_code": True,
112 "speculative_config": speculative_config,
113 "max_model_len": config.max_model_len,
114 "gpu_memory_utilization": config.gpu_memory_utilization,
115 "enforce_eager": config.enforce_eager,
116 + "enable_chunked_prefill": None,
117 "enable_prefix_caching": False,
118 "disable_log_stats": False,
119 + "compilation_config": CompilationConfig(),
120 }
121 if config.max_num_seqs is not None:
113 - llm_kwargs["max_num_seqs"] = config.max_num_seqs
122 + runner_config["max_num_seqs"] = config.max_num_seqs
123 if config.language_model_only:
115 - llm_kwargs["language_model_only"] = True
124 + runner_config["language_model_only"] = True
125
117 - spec_llm = LLM(**llm_kwargs)
118 - try:
126 + with vllm_runner(config.model, **runner_config) as spec_runner:
127 + spec_llm = spec_runner.llm
128 results = evaluate_gsm8k_offline(
129 spec_llm,
130 num_questions=config.num_questions,
@@ -128,18 +137,20 @@ def test_dspark_correctness_and_acceptance_rate(
137 metrics = spec_llm.get_metrics()
138 acceptance_rate = compute_acceptance_rate(metrics)
139 acceptance_len = compute_acceptance_len(metrics)
131 - print(
132 - f"{config.model}: GSM8K accuracy={accuracy:.3f}, "
133 - f"acceptance_rate={acceptance_rate:.2f}, "
134 - f"acceptance_len={acceptance_len:.2f}"
140 + context = f"DSpark target={config.model}, draft={config.draft_model}"
141 + metrics_summary = (
142 + f"gsm8k_accuracy={accuracy:.3f}, "
143 + f"acceptance_rate={acceptance_rate:.3f}, "
144 + f"acceptance_len={acceptance_len:.3f}"
145 )
146 + print(f"{context}: {metrics_summary}")
147
137 - assert accuracy >= config.reference_accuracy * REGRESSION_TOLERANCE
148 + assert accuracy >= config.reference_accuracy * REGRESSION_TOLERANCE, (
149 + f"{context}: {metrics_summary}"
150 + )
151 assert (
152 acceptance_rate >= config.reference_acceptance_rate * REGRESSION_TOLERANCE
140 - )
141 - assert acceptance_len >= config.reference_acceptance_len * REGRESSION_TOLERANCE
142 - finally:
143 - del spec_llm
144 - torch.accelerator.empty_cache()
145 - cleanup_dist_env_and_memory()
153 + ), f"{context}: {metrics_summary}"
154 + assert (
155 + acceptance_len >= config.reference_acceptance_len * REGRESSION_TOLERANCE
156 + ), f"{context}: {metrics_summary}"
tests/v1/e2e/spec_decode/acceptance_rates/mtp_other/test_medusa.py
+13 -13
@@ -1,17 +1,16 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -import torch
5 -
4 from tests.evals.gsm8k.gsm8k_eval import _build_gsm8k_prompts
7 -from vllm import LLM, SamplingParams
8 -from vllm.distributed import cleanup_dist_env_and_memory
5 +from vllm import SamplingParams
6 +from vllm.config import CompilationConfig
7
8 from ...utils import compute_acceptance_rate
9
10
11 def test_medusa_acceptance_rate(
12 sampling_config: SamplingParams,
13 + vllm_runner,
14 ):
15 """Verify a trained Medusa checkpoint achieves nonzero acceptance rate.
16
@@ -23,8 +22,10 @@ def test_medusa_acceptance_rate(
22 medusa_model = "FasterDecoding/medusa-vicuna-7b-v1.3"
23 prompts = _build_gsm8k_prompts(num_questions=10, num_shots=1)[0]
24
26 - spec_llm = LLM(
27 - model=target_model,
25 + with vllm_runner(
26 + target_model,
27 + block_size=None,
28 + trust_remote_code=False,
29 speculative_config={
30 "method": "medusa",
31 "model": medusa_model,
@@ -32,14 +33,13 @@ def test_medusa_acceptance_rate(
33 },
34 max_model_len=1024,
35 enforce_eager=True,
36 + enable_chunked_prefill=None,
37 disable_log_stats=False,
36 - )
37 - spec_llm.generate(prompts, sampling_config)
38 - metrics = spec_llm.get_metrics()
39 - acceptance_rate = compute_acceptance_rate(metrics)
40 - del spec_llm
41 - torch.accelerator.empty_cache()
42 - cleanup_dist_env_and_memory()
38 + compilation_config=CompilationConfig(),
39 + ) as spec_runner:
40 + spec_runner.llm.generate(prompts, sampling_config)
41 + metrics = spec_runner.llm.get_metrics()
42 + acceptance_rate = compute_acceptance_rate(metrics)
43
44 min_acceptance_rate = 0.198
45 print(f"Medusa acceptance rate: {acceptance_rate:.4f} (min {min_acceptance_rate})")
tests/v1/e2e/spec_decode/acceptance_rates/mtp_other/test_mtp.py
+2
@@ -10,9 +10,11 @@ from ..utils import run_acceptance_length_eval
10 def test_gemma4_mtp_acceptance_lengths(
11 monkeypatch: pytest.MonkeyPatch,
12 use_mrv2: bool,
13 + vllm_runner,
14 ):
15 run_acceptance_length_eval(
16 monkeypatch,
17 + vllm_runner,
18 spec_config={
19 "model": "google/gemma-4-E4B-it",
20 "trust_remote_code": True,
tests/v1/e2e/spec_decode/acceptance_rates/mtp_other/test_synthetic.py
+29 -31
@@ -1,25 +1,24 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -import torch
5 -
4 from tests.utils import single_gpu_only
7 -from vllm import LLM, SamplingParams
8 -from vllm.distributed import cleanup_dist_env_and_memory
5 +from vllm import SamplingParams
6 +from vllm.config import CompilationConfig
7
8 from ...utils import compute_acceptance_len, get_test_prompts
9
10
11 @single_gpu_only
14 -def test_synthetic_acceptance_rate():
12 +def test_synthetic_acceptance_rate(vllm_runner):
13 """Verify that synthetic rejection sampling produces an acceptance
14 length close to the requested mean acceptance length."""
15 num_spec_tokens = 3
16 expected_acceptance_len = 1.875
17 tolerance = 0.15
18
21 - spec_llm = LLM(
22 - model="meta-llama/Llama-3.2-1B-Instruct",
19 + with vllm_runner(
20 + "meta-llama/Llama-3.2-1B-Instruct",
21 + block_size=None,
22 trust_remote_code=True,
23 speculative_config={
24 "method": "eagle3",
@@ -31,28 +30,27 @@ def test_synthetic_acceptance_rate():
30 },
31 max_model_len=2048,
32 enforce_eager=True,
33 + enable_chunked_prefill=None,
34 disable_log_stats=False,
35 - )
36 -
37 - test_prompts = get_test_prompts(mm_enabled=False, num_prompts=50)
38 - spec_llm.chat(
39 - test_prompts,
40 - SamplingParams(temperature=0, max_tokens=64, ignore_eos=True),
41 - )
42 -
43 - metrics = spec_llm.get_metrics()
44 - acceptance_len = compute_acceptance_len(metrics)
45 -
46 - print(
47 - f"Synthetic acceptance length: {acceptance_len:.3f}"
48 - f" (expected={expected_acceptance_len:.3f},"
49 - f" tolerance=±{tolerance})"
50 - )
51 - assert abs(acceptance_len - expected_acceptance_len) <= tolerance, (
52 - f"Synthetic acceptance length {acceptance_len:.3f} is not within"
53 - f" ±{tolerance} of expected {expected_acceptance_len:.3f}"
54 - )
55 -
56 - del spec_llm
57 - torch.accelerator.empty_cache()
58 - cleanup_dist_env_and_memory()
35 + compilation_config=CompilationConfig(),
36 + ) as spec_runner:
37 + spec_llm = spec_runner.llm
38 +
39 + test_prompts = get_test_prompts(mm_enabled=False, num_prompts=50)
40 + spec_llm.chat(
41 + test_prompts,
42 + SamplingParams(temperature=0, max_tokens=64, ignore_eos=True),
43 + )
44 +
45 + metrics = spec_llm.get_metrics()
46 + acceptance_len = compute_acceptance_len(metrics)
47 +
48 + print(
49 + f"Synthetic acceptance length: {acceptance_len:.3f}"
50 + f" (expected={expected_acceptance_len:.3f},"
51 + f" tolerance=±{tolerance})"
52 + )
53 + assert abs(acceptance_len - expected_acceptance_len) <= tolerance, (
54 + f"Synthetic acceptance length {acceptance_len:.3f} is not within"
55 + f" ±{tolerance} of expected {expected_acceptance_len:.3f}"
56 + )
tests/v1/e2e/spec_decode/acceptance_rates/utils.py
+50 -52
@@ -4,11 +4,9 @@
4 from typing import Any
5
6 import pytest
7 -import torch
8 -from tqdm import tqdm
7
10 -from vllm import LLM, SamplingParams
11 -from vllm.distributed import cleanup_dist_env_and_memory
8 +from vllm import SamplingParams
9 +from vllm.config import CompilationConfig
10
11 from ..utils import compute_acceptance_len
12
@@ -39,6 +37,7 @@ def load_and_process_dataset(data_name: str):
37
38 def run_acceptance_length_eval(
39 monkeypatch: pytest.MonkeyPatch,
40 + vllm_runner,
41 spec_config: dict[str, Any],
42 expected_acceptance_lengths: dict[str, float],
43 chat_template_kwargs: dict[str, Any],
@@ -52,54 +51,53 @@ def run_acceptance_length_eval(
51 stays within tolerance of the reference figure for each dataset.
52 """
53 monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1" if use_mrv2 else "0")
55 - spec_llm = LLM(**spec_config)
56 -
57 - max_prompts_per_dataset = 200 # mt-bench has 80, humaneval has 164, truncates gsm8k
58 -
59 - tokenizer = spec_llm.get_tokenizer()
60 - for dataset_name, expected_len in expected_acceptance_lengths.items():
61 - dataset = load_and_process_dataset(dataset_name)
62 - prev_metrics = None
63 - acceptance_lengths = []
64 - for i in tqdm(
65 - range(min(max_prompts_per_dataset, len(dataset))),
66 - desc=f"Processing {dataset_name}",
67 - ):
68 - user_content = dataset[i]["turns"][0]
69 - prompt_text = tokenizer.apply_chat_template(
70 - [{"role": "user", "content": user_content}],
71 - tokenize=False,
72 - add_generation_prompt=True,
73 - **chat_template_kwargs,
54 + runner_config = spec_config.copy()
55 + model = runner_config.pop("model")
56 + runner_config.setdefault("block_size", None)
57 + runner_config.setdefault("enable_chunked_prefill", None)
58 + runner_config.setdefault("compilation_config", CompilationConfig())
59 + with vllm_runner(model, **runner_config) as spec_runner:
60 + spec_llm = spec_runner.llm
61 + # MT-Bench has 80 prompts and HumanEval has 164; truncate GSM8K.
62 + max_prompts_per_dataset = 200
63 +
64 + tokenizer = spec_llm.get_tokenizer()
65 + for dataset_name, expected_len in expected_acceptance_lengths.items():
66 + dataset = load_and_process_dataset(dataset_name)
67 + prev_metrics = None
68 + acceptance_lengths = []
69 + for i in range(min(max_prompts_per_dataset, len(dataset))):
70 + user_content = dataset[i]["turns"][0]
71 + prompt_text = tokenizer.apply_chat_template(
72 + [{"role": "user", "content": user_content}],
73 + tokenize=False,
74 + add_generation_prompt=True,
75 + **chat_template_kwargs,
76 + )
77 +
78 + # Greedy (temp=0) so acceptance length is deterministic and comparable
79 + # across runs.
80 + spec_llm.generate(
81 + [prompt_text],
82 + SamplingParams(temperature=0, max_tokens=2048),
83 + use_tqdm=False,
84 + )
85 + current_metrics = spec_llm.get_metrics()
86 + acceptance_len = compute_acceptance_len(current_metrics, prev_metrics)
87 + prev_metrics = current_metrics
88 + acceptance_lengths.append(acceptance_len)
89 +
90 + mean_acceptance_length = sum(acceptance_lengths) / len(acceptance_lengths)
91 + # Fairly tight tolerance of 95% against the reference figures,
92 + # watching for regressions. Can be relaxed if test is flaky but be sure to
93 + # check for genuine issues such as #40727.
94 + expected_len = expected_len * 0.95
95 + print(
96 + f"acceptance_len for {dataset_name}: {mean_acceptance_length:.2f}"
97 + f" (expected at least {expected_len:.2f})"
98 )
99
76 - # Greedy (temp=0) so acceptance length is deterministic and comparable
77 - # across runs.
78 - spec_llm.generate(
79 - [prompt_text],
80 - SamplingParams(temperature=0, max_tokens=2048),
81 - use_tqdm=False,
100 + assert mean_acceptance_length >= expected_len, (
101 + f"acceptance_len for {dataset_name} is below expected threshold: "
102 + f"{mean_acceptance_length:.2f} < {expected_len:.2f}"
103 )
83 - current_metrics = spec_llm.get_metrics()
84 - acceptance_len = compute_acceptance_len(current_metrics, prev_metrics)
85 - prev_metrics = current_metrics
86 - acceptance_lengths.append(acceptance_len)
87 -
88 - mean_acceptance_length = sum(acceptance_lengths) / len(acceptance_lengths)
89 - # Fairly tight tolerance of 95% against the reference figures,
90 - # watching for regressions. Can be relaxed if test is flaky but be sure to
91 - # check for genuine issues such as #40727.
92 - expected_len = expected_len * 0.95
93 - print(
94 - f"acceptance_len for {dataset_name}: {mean_acceptance_length:.2f}"
95 - f" (expected at least {expected_len:.2f})"
96 - )
97 -
98 - assert mean_acceptance_length >= expected_len, (
99 - f"acceptance_len for {dataset_name} is below expected threshold: "
100 - f"{mean_acceptance_length:.2f} < {expected_len:.2f}"
101 - )
102 -
103 - del spec_llm
104 - torch.accelerator.empty_cache()
105 - cleanup_dist_env_and_memory()
tests/v1/e2e/spec_decode/draft_model/test_async.py
+32 -22
@@ -102,14 +102,15 @@ def test_no_sync_with_spec_decode(
102 spec_model: str,
103 method: str,
104 num_spec_tokens: int,
105 + vllm_runner,
106 ):
107 """
108 Test that no implicit GPU-CPU sync occurs during speculative decoding
109 generation.
110 """
111 # Import vLLM AFTER sync_tracker fixture has applied the patch
111 - from vllm import LLM, SamplingParams
112 - from vllm.distributed import cleanup_dist_env_and_memory
112 + from vllm import SamplingParams
113 + from vllm.config import CompilationConfig
114
115 # Qwen3.5 is a VLM; without this, profile_run runs the ViT warmup
116 # and peaks well above the 18GB MIG slice used by one of the CI lanes.
@@ -119,8 +120,10 @@ def test_no_sync_with_spec_decode(
120 if "Qwen3.5" in model:
121 extra_kwargs["limit_mm_per_prompt"] = {"image": 0, "video": 0}
122
122 - llm = LLM(
123 - model=model,
123 + with vllm_runner(
124 + model,
125 + block_size=None,
126 + trust_remote_code=False,
127 max_model_len=256,
128 speculative_config={
129 "method": method,
@@ -129,24 +132,31 @@ def test_no_sync_with_spec_decode(
132 },
133 enforce_eager=True,
134 async_scheduling=True,
135 + enable_chunked_prefill=None,
136 + compilation_config=CompilationConfig(),
137 **extra_kwargs,
133 - )
134 -
135 - # Assert async scheduling is actually active before running inference.
136 - assert llm.llm_engine.vllm_config.scheduler_config.async_scheduling, (
137 - f"Expected async_scheduling=True for spec decode, got False. method={method}"
138 - )
139 -
140 - outputs = llm.generate(
141 - ["Hello, my name is"],
142 - SamplingParams(temperature=0, max_tokens=10),
143 - )
144 -
145 - assert len(outputs) == 1
146 - assert len(outputs[0].outputs[0].text) > 0
147 -
148 - del llm
149 - torch.accelerator.empty_cache()
150 - cleanup_dist_env_and_memory()
138 + ) as runner:
139 + llm = runner.llm
140 +
141 + # Assert async scheduling is actually active before running inference.
142 + assert llm.llm_engine.vllm_config.scheduler_config.async_scheduling, (
143 + f"Expected async_scheduling=True for spec decode, got False. "
144 + f"method={method}, target={model}, draft={spec_model}"
145 + )
146 +
147 + outputs = llm.generate(
148 + ["Hello, my name is"],
149 + SamplingParams(temperature=0, max_tokens=10),
150 + )
151 +
152 + assert len(outputs) == 1, (
153 + f"{method} target={model}: expected one request output, got {len(outputs)}"
154 + )
155 + assert outputs[0].outputs, (
156 + f"{method} target={model}: request output has no completion candidates"
157 + )
158 + assert outputs[0].outputs[0].text, (
159 + f"{method} target={model}: generated completion text is empty"
160 + )
161
162 sync_tracker.assert_no_sync()
tests/v1/e2e/spec_decode/draft_model/test_draft_model.py
+80 -54
@@ -4,17 +4,16 @@
4 from dataclasses import dataclass
5
6 import pytest
7 -import torch
7
8 from tests.utils import multi_gpu_only, single_gpu_only
10 -from vllm import LLM, SamplingParams
11 -from vllm.config import VllmConfig, replace
12 -from vllm.distributed import cleanup_dist_env_and_memory
9 +from vllm import SamplingParams
10 +from vllm.config import CompilationConfig, VllmConfig, replace
11 +from vllm.config.kernel import MoEBackend
12 from vllm.engine.arg_utils import EngineArgs
13 +from vllm.platforms import current_platform
14
15 from ..utils import (
16 Messages,
17 - _skip_if_insufficient_gpus_for_tp,
17 compute_acceptance_len,
18 compute_acceptance_rate,
19 evaluate_llm_for_gsm8k,
@@ -42,7 +41,8 @@ class ArgsTest:
41 max_model_len: int = 2048
42 gpu_memory_utilization: float = 0.5
43 dataset: str = "test_prompts"
45 - num_prompts: int = 100
44 + # Doubling 100 to 200 reduces sampling standard error by about 29%.
45 + num_prompts: int = 200
46
47
48 def get_messages(dataset: str, n: int) -> list[Messages]:
@@ -89,13 +89,13 @@ cases = [
89 @pytest.mark.parametrize("args", cases)
90 @pytest.mark.parametrize("enforce_eager", [True, False])
91 @single_gpu_only
92 -def test_draft_model_correctness(args: ArgsTest, enforce_eager: bool):
92 +def test_draft_model_correctness(args: ArgsTest, enforce_eager: bool, vllm_runner):
93 args.enforce_eager = enforce_eager
94 - assert_draft_model_correctness(args)
94 + assert_draft_model_correctness(args, vllm_runner)
95
96
97 @single_gpu_only
98 -def test_draft_model_realistic_example():
98 +def test_draft_model_realistic_example(vllm_runner):
99 args = ArgsTest(
100 target_model="Qwen/Qwen3-1.7B",
101 draft_model="Qwen/Qwen3-0.6B",
@@ -106,11 +106,11 @@ def test_draft_model_realistic_example():
106 expected_acceptance_len=2.6, # ref: 2.86
107 expected_acceptance_rate=0.5, # ref: 0.62
108 )
109 - assert_draft_model_correctness(args)
109 + assert_draft_model_correctness(args, vllm_runner)
110
111
112 @single_gpu_only
113 -def test_draft_model_parallel_drafting():
113 +def test_draft_model_parallel_drafting(vllm_runner):
114 args = ArgsTest(
115 target_model="Qwen/Qwen3-1.7B",
116 draft_model="amd/PARD-Qwen3-0.6B",
@@ -122,7 +122,7 @@ def test_draft_model_parallel_drafting():
122 expected_acceptance_len=2.3, # ref: 2.52
123 expected_acceptance_rate=0.4, # ref: 0.51
124 )
125 - assert_draft_model_correctness(args)
125 + assert_draft_model_correctness(args, vllm_runner)
126
127
128 @pytest.mark.parametrize(
@@ -136,7 +136,9 @@ def test_draft_model_parallel_drafting():
136 )
137 @pytest.mark.parametrize("enforce_eager", [True, False])
138 @single_gpu_only
139 -def test_draft_model_quantization(models: tuple[str, str], enforce_eager: bool):
139 +def test_draft_model_quantization(
140 + models: tuple[str, str], enforce_eager: bool, vllm_runner
141 +):
142 tgt_model, draft_model = models
143 sd_case = ArgsTest(
144 target_model=tgt_model,
@@ -144,13 +146,12 @@ def test_draft_model_quantization(models: tuple[str, str], enforce_eager: bool):
146 **some_high_acceptance_metrics(),
147 enforce_eager=enforce_eager,
148 )
147 - assert_draft_model_correctness(sd_case)
149 + assert_draft_model_correctness(sd_case, vllm_runner)
150
151
152 @multi_gpu_only(num_gpus=2)
151 -def test_draft_model_tensor_parallelism():
153 +def test_draft_model_tensor_parallelism(vllm_runner):
154 """Ensure spec decode works when running with TP > 1."""
153 - _skip_if_insufficient_gpus_for_tp(2)
155 sd_case = ArgsTest(
156 target_model="Qwen/Qwen3-1.7B",
157 target_tensor_parallel_size=2,
@@ -160,15 +161,13 @@ def test_draft_model_tensor_parallelism():
161 enforce_eager=False,
162 expected_gsm8k_accuracy=0.5,
163 )
163 - assert_draft_model_correctness(sd_case)
164 + assert_draft_model_correctness(sd_case, vllm_runner)
165
166
167 @multi_gpu_only(num_gpus=2)
168 def test_draft_model_engine_args_tensor_parallelism():
169 """Ensure the vllm_config for the draft model is created correctly,
170 and independently of the target model (quantization, TP, etc.)"""
170 - _skip_if_insufficient_gpus_for_tp(2)
171 -
171 engine_args = EngineArgs(
172 model="Qwen/Qwen3-1.7B-FP8", # <<< tgt quantized
173 tensor_parallel_size=2,
@@ -212,13 +211,24 @@ def _apply_draft_moe_backend(vllm_config: VllmConfig) -> VllmConfig:
211 return vllm_config
212
213
215 -def test_draft_model_moe_backend_override():
214 +def _platform_moe_backend(monkeypatch: pytest.MonkeyPatch) -> MoEBackend:
215 + if current_platform.is_rocm():
216 + monkeypatch.setenv("VLLM_ROCM_USE_AITER", "1")
217 + monkeypatch.setenv("VLLM_ROCM_USE_AITER_MOE", "1")
218 + return "aiter"
219 + if current_platform.is_cuda():
220 + return "flashinfer_trtllm"
221 + return "auto"
222 +
223 +
224 +def test_draft_model_moe_backend_override(monkeypatch: pytest.MonkeyPatch):
225 """When moe_backend is set in speculative_config, the draft VllmConfig
226 should use it while the target keeps its own setting."""
227 + target_moe_backend = _platform_moe_backend(monkeypatch)
228 engine_args = EngineArgs(
229 model="Qwen/Qwen3-1.7B",
230 tensor_parallel_size=1,
221 - moe_backend="flashinfer_trtllm",
231 + moe_backend=target_moe_backend,
232 speculative_config={
233 "model": "Qwen/Qwen3-0.6B",
234 "method": "draft_model",
@@ -227,22 +237,23 @@ def test_draft_model_moe_backend_override():
237 },
238 )
239 tgt_config: VllmConfig = engine_args.create_engine_config()
230 - assert tgt_config.kernel_config.moe_backend == "flashinfer_trtllm"
240 + assert tgt_config.kernel_config.moe_backend == target_moe_backend
241 assert tgt_config.speculative_config.moe_backend == "triton"
242
243 draft_config = _apply_draft_moe_backend(tgt_config)
244 assert draft_config.kernel_config.moe_backend == "triton"
245 # Target config must be unaffected.
236 - assert tgt_config.kernel_config.moe_backend == "flashinfer_trtllm"
246 + assert tgt_config.kernel_config.moe_backend == target_moe_backend
247
248
239 -def test_draft_model_moe_backend_inherits_target():
249 +def test_draft_model_moe_backend_inherits_target(monkeypatch: pytest.MonkeyPatch):
250 """When moe_backend is not set in speculative_config, the draft should
251 inherit the target's moe_backend."""
252 + target_moe_backend = _platform_moe_backend(monkeypatch)
253 engine_args = EngineArgs(
254 model="Qwen/Qwen3-1.7B",
255 tensor_parallel_size=1,
245 - moe_backend="flashinfer_cutlass",
256 + moe_backend=target_moe_backend,
257 speculative_config={
258 "model": "Qwen/Qwen3-0.6B",
259 "method": "draft_model",
@@ -250,11 +261,11 @@ def test_draft_model_moe_backend_inherits_target():
261 },
262 )
263 tgt_config: VllmConfig = engine_args.create_engine_config()
253 - assert tgt_config.kernel_config.moe_backend == "flashinfer_cutlass"
264 + assert tgt_config.kernel_config.moe_backend == target_moe_backend
265 assert tgt_config.speculative_config.moe_backend is None
266
267 draft_config = _apply_draft_moe_backend(tgt_config)
257 - assert draft_config.kernel_config.moe_backend == "flashinfer_cutlass"
268 + assert draft_config.kernel_config.moe_backend == target_moe_backend
269 assert draft_config is tgt_config
270
271
@@ -293,19 +304,23 @@ def test_draft_model_engine_args_rejects_invalid_tp_argname():
304 "tensor_parallel_size": 1, # <<< invalid arg name
305 },
306 )
296 - with pytest.raises(ValueError):
307 + with pytest.raises(ValueError, match="draft_tensor_parallel_size"):
308 engine_args.create_engine_config()
309
310
300 -def assert_draft_model_correctness(args: ArgsTest):
311 +def assert_draft_model_correctness(args: ArgsTest, vllm_runner):
312 """Compare the outputs using and not using speculative decoding.
313 In the greedy decoding case, the outputs must match EXACTLY."""
314 test_prompts: list[Messages] = get_messages(
315 dataset=args.dataset, n=args.num_prompts
316 )
317
307 - spec_llm = LLM(
308 - model=args.target_model,
318 + with vllm_runner(
319 + args.target_model,
320 + block_size=None,
321 + trust_remote_code=False,
322 + enable_chunked_prefill=None,
323 + compilation_config=CompilationConfig(),
324 speculative_config={
325 "model": args.draft_model,
326 "method": "draft_model",
@@ -321,30 +336,41 @@ def assert_draft_model_correctness(args: ArgsTest):
336 tensor_parallel_size=args.target_tensor_parallel_size,
337 enforce_eager=args.enforce_eager,
338 disable_log_stats=False, # enables get_metrics()
324 - )
325 -
326 - # we don't check the outputs, only check the metrics
327 - spec_llm.chat(test_prompts, args.sampling_config)
328 - metrics = spec_llm.get_metrics()
329 - acceptance_rate: float = compute_acceptance_rate(metrics)
330 - acceptance_len: float = compute_acceptance_len(metrics)
339 + ) as spec_runner:
340 + spec_llm = spec_runner.llm
341 +
342 + # we don't check the outputs, only check the metrics
343 + spec_llm.chat(test_prompts, args.sampling_config)
344 + metrics = spec_llm.get_metrics()
345 + acceptance_rate: float = compute_acceptance_rate(metrics)
346 + acceptance_len: float = compute_acceptance_len(metrics)
347 +
348 + # Need to evaluate after getting metrics to avoid polluting the AR
349 + evaluate_llm_for_gsm8k(
350 + spec_llm, expected_accuracy_threshold=args.expected_gsm8k_accuracy
351 + )
352
332 - # Need to evaluate after getting metrics to avoid polluting the AR
333 - evaluate_llm_for_gsm8k(
334 - spec_llm, expected_accuracy_threshold=args.expected_gsm8k_accuracy
335 - )
353 + print(
354 + f"spec-decode: target={args.target_model}, draft={args.draft_model}, "
355 + f"temperature={args.sampling_config.temperature:.2f}, "
356 + f"acceptance_rate={acceptance_rate:.2f}, "
357 + f"acceptance_len={acceptance_len:.2f}, "
358 + )
359
337 - print(
338 - f"spec-decode: target={args.target_model}, draft={args.draft_model}, "
339 - f"temperature={args.sampling_config.temperature:.2f}, "
340 - f"acceptance_rate={acceptance_rate:.2f}, "
341 - f"acceptance_len={acceptance_len:.2f}, "
342 - )
360 + context = (
361 + f"draft_model target={args.target_model}, draft={args.draft_model}, "
362 + f"eager={args.enforce_eager}, "
363 + f"acceptance_rate={acceptance_rate:.3f}, "
364 + f"acceptance_len={acceptance_len:.3f}"
365 + )
366 + assert acceptance_rate >= args.expected_acceptance_rate, (
367 + f"{context}; expected acceptance_rate >= "
368 + f"{args.expected_acceptance_rate:.3f}"
369 + )
370 + assert acceptance_len >= args.expected_acceptance_len, (
371 + f"{context}; expected acceptance_len >= {args.expected_acceptance_len:.3f}"
372 + )
373 + # draft_model supports async scheduling; assert it is active by default.
374 + has_async = spec_llm.llm_engine.vllm_config.scheduler_config.async_scheduling
375
344 - assert acceptance_rate >= args.expected_acceptance_rate
345 - assert acceptance_len >= args.expected_acceptance_len
346 - has_async = spec_llm.llm_engine.vllm_config.scheduler_config.async_scheduling
347 - del spec_llm # CLEANUP
348 - torch.accelerator.empty_cache()
349 - cleanup_dist_env_and_memory()
376 assert has_async, "Expected async_scheduling=True for draft_model spec decode"
tests/v1/e2e/spec_decode/draft_model/test_lora.py
+38 -41
@@ -8,12 +8,14 @@ This script contains:
8 import pytest
9 import torch
10
11 -from vllm import LLM, SamplingParams
12 -from vllm.distributed import cleanup_dist_env_and_memory
11 +from vllm import SamplingParams
12 +from vllm.config import CompilationConfig
13 from vllm.lora.request import LoRARequest
14 from vllm.platforms import current_platform
15 from vllm.utils.torch_utils import set_random_seed
16
17 +from ..utils import assert_request_outputs_match
18 +
19 LORA_TEST_PROMPT_MAP: dict[str, str] = {}
20
21 LORA_TEST_PROMPT_MAP["premjatin/qwen-linear-algebra-coder"] = """
@@ -36,7 +38,6 @@ Numbers should be represented as integers only.
38 SEED = 42
39
40
39 -@pytest.mark.skipif(not current_platform.is_cuda(), reason="CUDA not available")
41 @pytest.mark.parametrize(
42 "model_setup",
43 [
@@ -49,9 +50,13 @@ SEED = 42
50 )
51 ],
52 )
53 +@pytest.mark.skipif(
54 + not current_platform.is_cuda_alike(), reason="Requires CUDA or ROCm"
55 +)
56 def test_batch_inference_correctness(
57 monkeypatch: pytest.MonkeyPatch,
58 model_setup: tuple[str, str, str, str, int],
59 + vllm_runner,
60 ):
61 """
62 Compare the outputs of a LLM with only Lora and a LLM with both SD and Lora.
@@ -60,10 +65,11 @@ def test_batch_inference_correctness(
65 """
66 with monkeypatch.context() as m:
67 # Disable randomness
63 - m.setenv("CUBLAS_WORKSPACE_CONFIG", ":4096:8")
68 + if current_platform.is_cuda():
69 + m.setenv("CUBLAS_WORKSPACE_CONFIG", ":4096:8")
70 set_random_seed(SEED)
65 - torch.backends.cudnn.benchmark = False
66 - torch.backends.cudnn.deterministic = True
71 + m.setattr(torch.backends.cudnn, "benchmark", False)
72 + m.setattr(torch.backends.cudnn, "deterministic", True)
73
74 method, model_name, spec_model_name, lora_path, tp_size = model_setup
75
@@ -74,8 +80,9 @@ def test_batch_inference_correctness(
80 )
81
82 # without speculative decoding
77 - ref_llm = LLM(
78 - model=model_name,
83 + with vllm_runner(
84 + model_name,
85 + block_size=None,
86 trust_remote_code=True,
87 tensor_parallel_size=tp_size,
88 max_model_len=2048,
@@ -84,18 +91,16 @@ def test_batch_inference_correctness(
91 max_loras=1,
92 max_cpu_loras=1,
93 max_lora_rank=16,
87 - )
88 - try:
89 - ref_outputs = ref_llm.generate(
94 + enable_chunked_prefill=None,
95 + compilation_config=CompilationConfig(),
96 + ) as ref_runner:
97 + ref_outputs = ref_runner.llm.generate(
98 prompts, sampling_params, lora_request=lora_request
99 )
92 - finally:
93 - del ref_llm
94 - torch.accelerator.empty_cache()
95 - cleanup_dist_env_and_memory()
100
97 - lora_spec_llm = LLM(
98 - model=model_name,
101 + with vllm_runner(
102 + model_name,
103 + block_size=None,
104 trust_remote_code=True,
105 tensor_parallel_size=tp_size,
106 speculative_config={
@@ -110,30 +115,22 @@ def test_batch_inference_correctness(
115 max_loras=1,
116 max_cpu_loras=1,
117 max_lora_rank=16,
113 - )
114 - try:
115 - lora_spec_outputs = lora_spec_llm.generate(
118 + enable_chunked_prefill=None,
119 + compilation_config=CompilationConfig(),
120 + ) as spec_runner:
121 + lora_spec_outputs = spec_runner.llm.generate(
122 prompts, sampling_params, lora_request=lora_request
123 )
124
119 - matches = 0
120 - for ref_output, spec_output in zip(ref_outputs, lora_spec_outputs):
121 - if ref_output.outputs[0].text == spec_output.outputs[0].text:
122 - matches += 1
123 - else:
124 - print(f"ref_output: {ref_output.outputs[0].text}")
125 - print(f"spec_output: {spec_output.outputs[0].text}")
126 -
127 - # Heuristic threshold: under greedy verification, the spec-decode
128 - # output should equal the non-spec output (modulo FP noise from the
129 - # target's verify-path matmul running at seqlen
130 - # num_speculative_tokens+1 vs 1). 90% leaves slack for that noise.
131 - threshold = int(0.90 * len(ref_outputs))
132 - print(f"match ratio: {matches}/{len(ref_outputs)}")
133 - assert matches > threshold, (
134 - f"match ratio {matches}/{len(ref_outputs)} <= {threshold}"
135 - )
136 - finally:
137 - del lora_spec_llm
138 - torch.accelerator.empty_cache()
139 - cleanup_dist_env_and_memory()
125 + # Under greedy verification, the spec-decode output should equal the
126 + # non-spec output (modulo FP noise from the target's verify-path matmul
127 + # running at seqlen num_speculative_tokens+1 vs 1). 90% leaves slack.
128 + assert_request_outputs_match(
129 + ref_outputs,
130 + lora_spec_outputs,
131 + required_matches=int(0.90 * len(ref_outputs)) + 1,
132 + context=(
133 + f"LoRA + {method}: target={model_name}, draft={spec_model_name}, "
134 + f"adapter={lora_path}"
135 + ),
136 + )
tests/v1/e2e/spec_decode/eagle/test_eagle_correctness.py
+12 -7
@@ -52,6 +52,7 @@ def test_eagle_correctness_light(
52 enable_chunked_prefill: bool,
53 model_impl: str,
54 attn_backend: str,
55 + vllm_runner,
56 ):
57 _run_eagle_correctness(
58 monkeypatch,
@@ -62,6 +63,7 @@ def test_eagle_correctness_light(
63 enable_chunked_prefill,
64 model_impl,
65 attn_backend,
66 + vllm_runner,
67 )
68
69
@@ -89,11 +91,12 @@ def test_eagle_correctness_light(
91 "transformers",
92 0.8,
93 # TODO(hmellor): figure out why memory usage is so high
92 - marks=pytest.mark.skip(
93 - reason="Feature is experimental and uses too much memory in CI",
94 + marks=pytest.mark.skipif(
95 + not current_platform.is_rocm(),
96 + reason="Feature is experimental and uses too much memory in CUDA CI",
97 ),
98 ),
96 - pytest.param(
99 + (
100 (
101 "eagle3",
102 "Qwen/Qwen3-VL-8B-Instruct",
@@ -104,9 +107,6 @@ def test_eagle_correctness_light(
107 False,
108 "auto",
109 0.8,
107 - marks=pytest.mark.skip(
108 - reason="architecture of its eagle3 is LlamaForCausalLMEagle3"
109 - ),
110 ),
111 pytest.param(
112 (
@@ -119,8 +119,11 @@ def test_eagle_correctness_light(
119 False,
120 "auto",
121 0.7,
122 + # TODO: Re-measure the reference threshold when re-enabling this case.
123 + # Text-only mode starts but loses target correctness with ROCm/TRITON;
124 + # full multimodal profiling currently fails during engine startup.
125 marks=pytest.mark.skip(
123 - reason="Skipping due to its head_dim not being a multiple of 32"
126 + reason="Qwen2.5-VL Eagle3 is not yet reliable in this test setup"
127 ),
128 ),
129 (
@@ -154,6 +157,7 @@ def test_eagle_correctness_medium(
157 enable_chunked_prefill: bool,
158 model_impl: str,
159 attn_backend: str,
160 + vllm_runner,
161 ):
162 _run_eagle_correctness(
163 monkeypatch,
@@ -164,4 +168,5 @@ def test_eagle_correctness_medium(
168 enable_chunked_prefill,
169 model_impl,
170 attn_backend,
171 + vllm_runner,
172 )
tests/v1/e2e/spec_decode/eagle/utils.py
+58 -59
@@ -1,16 +1,17 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +from typing import Any
5 +
6 import pytest
5 -import torch
7
7 -from tests.utils import wait_for_rocm_memory_to_settle
8 -from vllm import LLM, SamplingParams
9 -from vllm.distributed import cleanup_dist_env_and_memory
8 +from vllm import SamplingParams
9 +from vllm.config import CompilationConfig
10 from vllm.platforms import current_platform
11
12 from ..utils import (
13 _skip_if_insufficient_gpus_for_tp,
14 + assert_request_outputs_match,
15 evaluate_llm_for_gsm8k,
16 get_test_prompts,
17 )
@@ -25,26 +26,24 @@ def _run_eagle_correctness(
26 enable_chunked_prefill: bool,
27 model_impl: str,
28 attn_backend: str,
29 + vllm_runner,
30 ):
31 """
32 Compare the outputs of an original LLM and a speculative LLM
33 which should be the same when using eagle speculative decoding.
34 """
33 - if model_impl == "transformers":
34 - import transformers
35 - from packaging.version import Version
36 -
37 - installed = Version(transformers.__version__)
38 - required = Version("5.0.0")
39 - if installed < required:
40 - pytest.skip(
41 - "Eagle3 with the Transformers modeling backend requires "
42 - f"transformers>={required}, but got {installed}"
43 - )
35 + method, model_name, spec_model_name, tp_size = model_setup
36 + _skip_if_insufficient_gpus_for_tp(tp_size)
37
38 test_prompts = get_test_prompts(mm_enabled)
39
47 - if "Llama-4-Scout" in model_setup[1] and attn_backend == "FLASH_ATTN":
40 + extra_kwargs: dict[str, Any] = {}
41 + if not mm_enabled and "Qwen3-VL" in model_name:
42 + # These cases only exercise text generation. Avoid profiling an unused
43 + # vision tower, which adds substantial memory to both reference runs.
44 + extra_kwargs["limit_mm_per_prompt"] = {"image": 0, "video": 0}
45 +
46 + if "Llama-4-Scout" in model_name and attn_backend == "FLASH_ATTN":
47 if current_platform.is_rocm():
48 print(
49 "FLASH_ATTN for spec_decode not supported on "
@@ -66,38 +65,36 @@ def _run_eagle_correctness(
65 m.setenv("VLLM_MLA_DISABLE", "1")
66
67 if attn_backend == "ROCM_AITER_FA" and current_platform.is_rocm():
69 - if "deepseek" in model_setup[1].lower():
68 + if "deepseek" in model_name.lower():
69 m.setenv("VLLM_ROCM_USE_AITER", "1")
70 m.delenv("VLLM_MLA_DISABLE", raising=False)
71 attention_config = {"backend": "ROCM_AITER_MLA"}
72 else:
73 m.setenv("VLLM_ROCM_USE_AITER", "1")
74
76 - method, model_name, spec_model_name, tp_size = model_setup
77 - _skip_if_insufficient_gpus_for_tp(tp_size)
78 -
75 max_model_len = 2048
76 max_num_batched_tokens = 128 if enable_chunked_prefill else max_model_len
77
82 - ref_llm = LLM(
83 - model=model_name,
78 + with vllm_runner(
79 + model_name,
80 + block_size=None,
81 + trust_remote_code=False,
82 max_model_len=max_model_len,
83 tensor_parallel_size=tp_size,
84 attention_config=attention_config,
87 - )
88 - evaluate_llm_for_gsm8k(
89 - ref_llm, expected_accuracy_threshold=expected_accuracy_threshold
90 - )
91 - ref_outputs = ref_llm.chat(test_prompts, sampling_config)
92 - del ref_llm
93 - torch.accelerator.empty_cache()
94 - cleanup_dist_env_and_memory()
95 - # ROCm frees VRAM lazily; wait so the spec engine started right after
96 - # does not OOM on its startup memory guard.
97 - wait_for_rocm_memory_to_settle()
98 -
99 - spec_llm = LLM(
100 - model=model_name,
85 + enable_chunked_prefill=None,
86 + compilation_config=CompilationConfig(),
87 + **extra_kwargs,
88 + ) as ref_runner:
89 + evaluate_llm_for_gsm8k(
90 + ref_runner.llm,
91 + expected_accuracy_threshold=expected_accuracy_threshold,
92 + )
93 + ref_outputs = ref_runner.llm.chat(test_prompts, sampling_config)
94 +
95 + with vllm_runner(
96 + model_name,
97 + block_size=None,
98 trust_remote_code=True,
99 tensor_parallel_size=tp_size,
100 speculative_config={
@@ -111,27 +108,29 @@ def _run_eagle_correctness(
108 enable_chunked_prefill=enable_chunked_prefill,
109 model_impl=model_impl,
110 attention_config=attention_config,
111 + compilation_config=CompilationConfig(),
112 + **extra_kwargs,
113 + ) as spec_runner:
114 + # EAGLE/EAGLE3 supports async scheduling by default.
115 + has_async = (
116 + spec_runner.llm.llm_engine.vllm_config.scheduler_config.async_scheduling
117 + )
118 + assert has_async, (
119 + f"Expected async scheduling for {method}: target={model_name}, "
120 + f"draft={spec_model_name}, backend={attn_backend}; got {has_async}"
121 + )
122 + evaluate_llm_for_gsm8k(
123 + spec_runner.llm,
124 + expected_accuracy_threshold=expected_accuracy_threshold,
125 + )
126 + spec_outputs = spec_runner.llm.chat(test_prompts, sampling_config)
127 +
128 + assert_request_outputs_match(
129 + ref_outputs,
130 + spec_outputs,
131 + required_matches=int(0.6 * len(ref_outputs)) + 1,
132 + context=(
133 + f"{method} target={model_name}, draft={spec_model_name}, "
134 + f"backend={attn_backend}"
135 + ),
136 )
115 - # EAGLE/EAGLE3 supports async scheduling; assert it is active by default.
116 - assert spec_llm.llm_engine.vllm_config.scheduler_config.async_scheduling
117 - evaluate_llm_for_gsm8k(
118 - spec_llm, expected_accuracy_threshold=expected_accuracy_threshold
119 - )
120 - spec_outputs = spec_llm.chat(test_prompts, sampling_config)
121 - matches = 0
122 - misses = 0
123 - for ref_output, spec_output in zip(ref_outputs, spec_outputs):
124 - if ref_output.outputs[0].text == spec_output.outputs[0].text:
125 - matches += 1
126 - else:
127 - misses += 1
128 - print(f"ref_output: {ref_output.outputs[0].text}")
129 - print(f"spec_output: {spec_output.outputs[0].text}")
130 -
131 - assert matches > int(0.6 * len(ref_outputs))
132 - del spec_llm
133 - torch.accelerator.empty_cache()
134 - cleanup_dist_env_and_memory()
135 - # ROCm frees VRAM lazily; wait so the next parametrization's engine does
136 - # not OOM on its startup memory guard.
137 - wait_for_rocm_memory_to_settle()
tests/v1/e2e/spec_decode/mtp/test_mtp.py
+48 -46
@@ -4,15 +4,15 @@
4 from typing import Any
5
6 import pytest
7 -import torch
7
8 from tests.utils import single_gpu_only
10 -from vllm import LLM, SamplingParams
11 -from vllm.distributed import cleanup_dist_env_and_memory
9 +from vllm import SamplingParams
10 +from vllm.config import CompilationConfig
11 from vllm.platforms import current_platform
12
13 from ..utils import (
14 _skip_if_insufficient_gpus_for_tp,
15 + assert_request_outputs_match,
16 evaluate_llm_for_gsm8k,
17 get_test_prompts,
18 )
@@ -21,9 +21,10 @@ from ..utils import (
21 @pytest.mark.parametrize(
22 ["model_setup", "mm_enabled", "expected_accuracy_threshold"],
23 [
24 - (("mtp", "XiaomiMiMo/MiMo-7B-Base", 1), False, 0.5), # ref: 65%-70%
24 + # Reference accuracy: 65%-70%.
25 + (("mtp", "XiaomiMiMo/MiMo-7B-Base", 1, None), False, 0.5),
26 pytest.param(
26 - ("mtp", "ZixiQi/DeepSeek-V3-4layers-MTP-FP8", 1),
27 + ("mtp", "ZixiQi/DeepSeek-V3-4layers-MTP-FP8", 1, None),
28 False,
29 0.0,
30 marks=pytest.mark.skipif(
@@ -32,7 +33,7 @@ from ..utils import (
33 ),
34 ), # dummy model
35 (
35 - ("mtp", "Qwen/Qwen3.5-0.8B-Base", 1),
36 + ("mtp", "Qwen/Qwen3.5-0.8B-Base", 1, None),
37 False,
38 0.20,
39 ), # hybrid + MTP, ref: ~34%-35%
@@ -48,9 +49,10 @@ from ..utils import (
49 def test_mtp_correctness(
50 monkeypatch: pytest.MonkeyPatch,
51 sampling_config: SamplingParams,
51 - model_setup: tuple[str, str, int] | tuple[str, str, int, str],
52 + model_setup: tuple[str, str, int, str | None],
53 mm_enabled: bool,
54 expected_accuracy_threshold: float,
55 + vllm_runner,
56 ):
57 """
58 Compare the outputs of a original LLM and a speculative LLM
@@ -59,18 +61,14 @@ def test_mtp_correctness(
61 6/10 output tokens match exactly, and that the GSM8k accuracy is above a precomputed
62 reference threshold for each model.
63 """
64 + method, model_name, tp_size, draft_model = model_setup
65 + _skip_if_insufficient_gpus_for_tp(tp_size)
66 +
67 # Generate test prompts inside the function instead of using fixture
68 test_prompts = get_test_prompts(mm_enabled)
69 with monkeypatch.context() as m:
70 m.setenv("VLLM_MLA_DISABLE", "1")
71
67 - if len(model_setup) == 4:
68 - method, model_name, tp_size, draft_model = model_setup
69 - else:
70 - method, model_name, tp_size = model_setup
71 - draft_model = None
72 - _skip_if_insufficient_gpus_for_tp(tp_size)
73 -
72 attn_backend = "TRITON_ATTN" if current_platform.is_rocm() else "auto"
73
74 # Skip multimodal profiling for models that don't need it in this test.
@@ -90,21 +88,22 @@ def test_mtp_correctness(
88 f"got {transformers.__version__}"
89 )
90
93 - ref_llm = LLM(
94 - model=model_name,
91 + with vllm_runner(
92 + model_name,
93 + block_size=None,
94 max_model_len=2048,
95 tensor_parallel_size=tp_size,
96 trust_remote_code=True,
97 attention_backend=attn_backend,
98 + enable_chunked_prefill=None,
99 + compilation_config=CompilationConfig(),
100 **extra_kwargs,
100 - )
101 - ref_outputs = ref_llm.chat(test_prompts, sampling_config)
102 - evaluate_llm_for_gsm8k(
103 - ref_llm, expected_accuracy_threshold=expected_accuracy_threshold
104 - )
105 - del ref_llm
106 - torch.accelerator.empty_cache()
107 - cleanup_dist_env_and_memory()
101 + ) as ref_runner:
102 + ref_outputs = ref_runner.llm.chat(test_prompts, sampling_config)
103 + evaluate_llm_for_gsm8k(
104 + ref_runner.llm,
105 + expected_accuracy_threshold=expected_accuracy_threshold,
106 + )
107
108 speculative_config: dict[str, Any] = {
109 "method": method,
@@ -115,34 +114,37 @@ def test_mtp_correctness(
114 speculative_config["model"] = draft_model
115 speculative_config["num_speculative_tokens"] = 2
116
118 - spec_llm = LLM(
119 - model=model_name,
117 + with vllm_runner(
118 + model_name,
119 + block_size=None,
120 trust_remote_code=True,
121 tensor_parallel_size=tp_size,
122 speculative_config=speculative_config,
123 max_model_len=2048,
124 attention_backend=attn_backend,
125 + enable_chunked_prefill=None,
126 + compilation_config=CompilationConfig(),
127 **extra_kwargs,
126 - )
127 - # MTP supports async scheduling; assert it is active by default.
128 - assert spec_llm.llm_engine.vllm_config.scheduler_config.async_scheduling
129 - evaluate_llm_for_gsm8k(
130 - spec_llm, expected_accuracy_threshold=expected_accuracy_threshold
131 - )
132 - spec_outputs = spec_llm.chat(test_prompts, sampling_config)
133 - matches = 0
134 - misses = 0
135 - for ref_output, spec_output in zip(ref_outputs, spec_outputs):
136 - if ref_output.outputs[0].text == spec_output.outputs[0].text:
137 - matches += 1
138 - else:
139 - misses += 1
140 - print(f"ref_output: {ref_output.outputs[0].text}")
141 - print(f"spec_output: {spec_output.outputs[0].text}")
128 + ) as spec_runner:
129 + # MTP supports async scheduling by default.
130 + has_async = (
131 + spec_runner.llm.llm_engine.vllm_config.scheduler_config.async_scheduling
132 + )
133 + assert has_async, (
134 + f"Expected async scheduling for {method}: target={model_name}, "
135 + f"draft={draft_model}; got {has_async}"
136 + )
137 + evaluate_llm_for_gsm8k(
138 + spec_runner.llm,
139 + expected_accuracy_threshold=expected_accuracy_threshold,
140 + )
141 + spec_outputs = spec_runner.llm.chat(test_prompts, sampling_config)
142
143 # Heuristic: expect at least 80% of the prompts to match exactly
144 # Upon failure, inspect the outputs to check for inaccuracy.
145 - assert matches > int(0.8 * len(ref_outputs))
146 - del spec_llm
147 - torch.accelerator.empty_cache()
148 - cleanup_dist_env_and_memory()
145 + assert_request_outputs_match(
146 + ref_outputs,
147 + spec_outputs,
148 + required_matches=int(0.8 * len(ref_outputs)) + 1,
149 + context=f"{method} target={model_name}, draft={draft_model}",
150 + )
tests/v1/e2e/spec_decode/ngram_suffix/test_ngram_suffix.py
+67 -39
@@ -2,15 +2,17 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import pytest
5 -import torch
5
6 from tests.utils import single_gpu_only
8 -from vllm import LLM, SamplingParams
7 +from vllm import SamplingParams
8 from vllm.config import CompilationConfig
10 -from vllm.distributed import cleanup_dist_env_and_memory
9 from vllm.platforms import current_platform
10
13 -from ..utils import evaluate_llm_for_gsm8k, get_test_prompts
11 +from ..utils import (
12 + evaluate_llm_for_gsm8k,
13 + get_spec_decode_metric_value,
14 + get_test_prompts,
15 +)
16
17
18 @pytest.fixture
@@ -43,6 +45,7 @@ def test_ngram_and_suffix_correctness(
45 ):
46 with vllm_runner(
47 model_name,
48 + block_size=None,
49 # Keep LLM defaults; VllmRunner only provides lifecycle cleanup here.
50 trust_remote_code=False,
51 enable_chunked_prefill=None,
@@ -59,6 +62,7 @@ def test_ngram_and_suffix_correctness(
62 @single_gpu_only
63 def test_ngram_gpu_default_with_async_scheduling(
64 async_scheduling: bool,
65 + vllm_runner,
66 ):
67 """
68 Test ngram_gpu speculative decoding (k=3) correctness with and without
@@ -66,8 +70,10 @@ def test_ngram_gpu_default_with_async_scheduling(
70 Uses Qwen/Qwen3-8B (ref GSM8K accuracy: 87%-92%).
71 """
72 qwen3_model = "Qwen/Qwen3-8B"
69 - spec_llm = LLM(
70 - model=qwen3_model,
73 + with vllm_runner(
74 + qwen3_model,
75 + block_size=None,
76 + trust_remote_code=False,
77 speculative_config={
78 "method": "ngram_gpu",
79 "prompt_lookup_max": 3,
@@ -76,22 +82,22 @@ def test_ngram_gpu_default_with_async_scheduling(
82 },
83 max_model_len=4096,
84 async_scheduling=async_scheduling,
79 - )
80 - # Assert the resolved async_scheduling config matches what was requested.
81 - assert (
82 - spec_llm.llm_engine.vllm_config.scheduler_config.async_scheduling
83 - == async_scheduling
84 - )
85 - evaluate_llm_for_gsm8k(spec_llm, expected_accuracy_threshold=0.8)
86 - del spec_llm
87 - cleanup_dist_env_and_memory()
85 + enable_chunked_prefill=None,
86 + compilation_config=CompilationConfig(),
87 + ) as spec_runner:
88 + # Assert the resolved async_scheduling config matches what was requested.
89 + assert (
90 + spec_runner.llm.llm_engine.vllm_config.scheduler_config.async_scheduling
91 + == async_scheduling
92 + )
93 + evaluate_llm_for_gsm8k(spec_runner.llm, expected_accuracy_threshold=0.8)
94
95
96 @single_gpu_only
97 def test_suffix_decoding_acceptance(
92 - monkeypatch: pytest.MonkeyPatch,
98 sampling_config: SamplingParams,
99 model_name: str,
100 + vllm_runner,
101 ):
102 """
103 Check that suffix decoding caching takes effect and improves acceptance
@@ -99,8 +105,10 @@ def test_suffix_decoding_acceptance(
105 """
106 test_prompts = get_test_prompts(mm_enabled=False)
107
102 - spec_llm = LLM(
103 - model=model_name,
108 + with vllm_runner(
109 + model_name,
110 + block_size=None,
111 + trust_remote_code=False,
112 speculative_config={
113 "method": "suffix",
114 "suffix_decoding_max_spec_factor": 2.0,
@@ -108,40 +116,60 @@ def test_suffix_decoding_acceptance(
116 },
117 max_model_len=1024,
118 disable_log_stats=False,
111 - )
112 -
113 - # Run several times and check that the accepted tokens increase.
114 - num_draft = []
115 - num_accept = []
116 - for i in range(10): # Run multiple times to warm up the cache.
117 - spec_llm.chat(test_prompts, sampling_config)
118 - # Collect draft and acceptance stats.
119 - metrics = spec_llm.get_metrics()
120 - for metric in metrics:
121 - if metric.name == "vllm:spec_decode_num_draft_tokens":
122 - num_draft.append(metric.value)
123 - if metric.name == "vllm:spec_decode_num_accepted_tokens":
124 - num_accept.append(metric.value)
119 + enable_chunked_prefill=None,
120 + compilation_config=CompilationConfig(),
121 + ) as spec_runner:
122 + # Run several times and check that the accepted tokens increase.
123 + num_draft = []
124 + num_accept = []
125 + for _ in range(10): # Run multiple times to warm up the cache.
126 + spec_runner.llm.chat(test_prompts, sampling_config)
127 + # Collect draft and acceptance stats.
128 + metrics = spec_runner.llm.get_metrics()
129 + num_draft.append(
130 + get_spec_decode_metric_value(
131 + metrics, "vllm:spec_decode_num_draft_tokens"
132 + )
133 + )
134 + num_accept.append(
135 + get_spec_decode_metric_value(
136 + metrics, "vllm:spec_decode_num_accepted_tokens"
137 + )
138 + )
139
140 # Calculate the acceptance rates for the first and last runs.
141 first_accept_tokens = num_accept[0]
142 first_draft_tokens = num_draft[0]
143 + assert first_draft_tokens > 0, (
144 + "Suffix decoder produced no draft tokens on the first run: "
145 + f"accepted={first_accept_tokens}, drafted={first_draft_tokens}"
146 + )
147 first_accept_rate = first_accept_tokens / first_draft_tokens
148
149 # Take the diff since the stats are cumulative.
150 last_accept_tokens = num_accept[-1] - num_accept[-2]
151 last_draft_tokens = num_draft[-1] - num_draft[-2]
152 + assert last_draft_tokens > 0, (
153 + "Suffix decoder produced no draft tokens on the last run: "
154 + f"accepted_delta={last_accept_tokens}, drafted_delta={last_draft_tokens}; "
155 + f"cumulative_drafted={num_draft[-2:]}"
156 + )
157 last_accept_rate = last_accept_tokens / last_draft_tokens
158 + summary = (
159 + f"first accepted/drafted={first_accept_tokens}/{first_draft_tokens} "
160 + f"(rate={first_accept_rate:.3f}); last delta accepted/drafted="
161 + f"{last_accept_tokens}/{last_draft_tokens} (rate={last_accept_rate:.3f})"
162 + )
163
164 # Expect the acceptance length to improve.
137 - assert first_accept_tokens < last_accept_tokens
165 + assert first_accept_tokens < last_accept_tokens, (
166 + f"Expected accepted tokens to increase after cache warmup; {summary}"
167 + )
168
169 # Expect the acceptance rate to improve.
140 - assert first_accept_rate < last_accept_rate
170 + assert first_accept_rate < last_accept_rate, (
171 + f"Expected acceptance rate to increase after cache warmup; {summary}"
172 + )
173
174 # Heuristic: expect at least 80.0% acceptance rate at the end.
143 - assert last_accept_rate > 0.80
144 -
145 - del spec_llm
146 - torch.accelerator.empty_cache()
147 - cleanup_dist_env_and_memory()
175 + assert last_accept_rate > 0.80, f"Expected final acceptance rate > 0.80; {summary}"
tests/v1/e2e/spec_decode/speculators/test_speculators.py
+16 -13
@@ -7,14 +7,20 @@ from tests.utils import single_gpu_only
7 from vllm import SamplingParams
8 from vllm.config import CompilationConfig
9
10 -from ..utils import evaluate_llm_for_gsm8k, get_test_prompts
10 +from ..utils import (
11 + assert_request_outputs_match,
12 + evaluate_llm_for_gsm8k,
13 + get_test_prompts,
14 +)
15
16
17 @pytest.mark.parametrize(
18 ["model_path", "expected_accuracy_threshold"],
19 [
16 - ("RedHatAI/Llama-3.1-8B-Instruct-speculator.eagle3", 0.7), # ref: 75%-80%
17 - ("RedHatAI/Qwen3-8B-speculator.eagle3", 0.8), # ref: 87%-92%
20 + # Measured reference: 75%-80%.
21 + ("RedHatAI/Llama-3.1-8B-Instruct-speculator.eagle3", 0.72),
22 + # Measured reference: 87%-92%.
23 + ("RedHatAI/Qwen3-8B-speculator.eagle3", 0.84),
24 ],
25 ids=["llama3_eagle3_speculator", "qwen3_eagle3_speculator"],
26 )
@@ -49,6 +55,7 @@ def test_speculators_model_integration(
55 # First run: Direct speculator model (simplified integration)
56 with vllm_runner(
57 model_path,
58 + block_size=None,
59 trust_remote_code=False,
60 enable_chunked_prefill=None,
61 compilation_config=CompilationConfig(),
@@ -82,6 +89,7 @@ def test_speculators_model_integration(
89 # Second run: Reference without speculative decoding
90 with vllm_runner(
91 verifier_model,
92 + block_size=None,
93 trust_remote_code=False,
94 enable_chunked_prefill=None,
95 compilation_config=CompilationConfig(),
@@ -90,15 +98,10 @@ def test_speculators_model_integration(
98 ) as ref_runner:
99 ref_outputs = ref_runner.llm.chat(test_prompts, sampling_config)
100
93 - # Compare outputs
94 - matches = sum(
95 - 1
96 - for ref, spec in zip(ref_outputs, spec_outputs)
97 - if ref.outputs[0].text == spec.outputs[0].text
98 - )
99 -
101 # Heuristic: expect at least 66% of prompts to match exactly
101 - assert matches >= int(0.66 * len(ref_outputs)), (
102 - f"Only {matches}/{len(ref_outputs)} outputs matched. "
103 - f"Expected at least {int(0.66 * len(ref_outputs))} matches."
102 + assert_request_outputs_match(
103 + ref_outputs,
104 + spec_outputs,
105 + required_matches=int(0.66 * len(ref_outputs)),
106 + context=f"speculator={model_path}, verifier={verifier_model}",
107 )
tests/v1/e2e/spec_decode/test_mtp_parallel_load.py
+48 -22
@@ -11,8 +11,10 @@ import torch
11
12 from tests.utils import multi_gpu_marks
13 from vllm import LLM, SamplingParams
14 +from vllm.config import CompilationConfig
15 from vllm.distributed import cleanup_dist_env_and_memory
16 from vllm.engine.arg_utils import AsyncEngineArgs
17 +from vllm.platforms import current_platform
18 from vllm.sampling_params import RequestOutputKind
19 from vllm.v1.engine.async_llm import AsyncLLM
20 from vllm.v1.metrics.reader import Metric
@@ -59,7 +61,7 @@ INLINE_CONFIGS = [
61 pp_size=2,
62 skip_reason=(
63 "DeepSeek MTP pipeline-parallel support is in flight upstream; "
62 - "see https://github.com/vllm-project/vllm/pull/38104"
64 + "see https://github.com/vllm-project/vllm/pull/44698"
65 ),
66 ),
67 ]
@@ -109,7 +111,11 @@ def _generate_token_ids_inline(llm: LLM) -> tuple[int, ...]:
111 SamplingParams(temperature=0.0, max_tokens=MAX_TOKENS, ignore_eos=True),
112 )
113 assert outputs and outputs[0].outputs, "expected one completion"
112 - return tuple(outputs[0].outputs[0].token_ids)
114 + token_ids = tuple(outputs[0].outputs[0].token_ids)
115 + assert len(token_ids) == MAX_TOKENS, (
116 + f"expected {MAX_TOKENS} generated tokens, got {len(token_ids)}: {token_ids}"
117 + )
118 + return token_ids
119
120
121 def _spec_decode_num_drafts(metrics: list[Metric]) -> int:
@@ -121,6 +127,16 @@ def _spec_decode_num_drafts(metrics: list[Metric]) -> int:
127 return int(counter.value)
128
129
130 +def _enable_batch_invariance(monkeypatch: pytest.MonkeyPatch) -> None:
131 + # This test exercises DeepSeek MLA prefill. ROCm's upstream FlashAttention
132 + # varlen API lacks the num_splits argument used by batch invariance, so
133 + # explicitly override any ambient setting on that path.
134 + if current_platform.is_cuda():
135 + monkeypatch.setenv("VLLM_BATCH_INVARIANT", "1")
136 + else:
137 + monkeypatch.setenv("VLLM_BATCH_INVARIANT", "0")
138 +
139 +
140 @pytest.mark.parametrize(
141 "config",
142 [
@@ -131,6 +147,7 @@ def _spec_decode_num_drafts(metrics: list[Metric]) -> int:
147 def test_deepseek_mtp_load_inline(
148 monkeypatch: pytest.MonkeyPatch,
149 config: InlineConfig,
150 + vllm_runner,
151 ):
152 """MTP loads and drafts under TP/EP/EPLB; spec output matches no-spec greedy."""
153 if config.skip_reason is not None:
@@ -138,24 +155,30 @@ def test_deepseek_mtp_load_inline(
155
156 # Reduces run-to-run nondeterminism so spec / no-spec stay close;
157 # see tests/v1/distributed/test_eagle_dp.py for the same pattern.
141 - monkeypatch.setenv("VLLM_BATCH_INVARIANT", "1")
142 -
143 - spec_llm = LLM(**_inline_kwargs(config, with_spec=True))
144 - try:
145 - spec_tokens = _generate_token_ids_inline(spec_llm)
146 - n_drafts = _spec_decode_num_drafts(spec_llm.get_metrics())
147 - finally:
148 - del spec_llm
149 - torch.accelerator.empty_cache()
150 - cleanup_dist_env_and_memory()
151 -
152 - no_spec_llm = LLM(**_inline_kwargs(config, with_spec=False))
153 - try:
154 - no_spec_tokens = _generate_token_ids_inline(no_spec_llm)
155 - finally:
156 - del no_spec_llm
157 - torch.accelerator.empty_cache()
158 - cleanup_dist_env_and_memory()
158 + _enable_batch_invariance(monkeypatch)
159 +
160 + spec_kwargs = _inline_kwargs(config, with_spec=True)
161 + spec_model = spec_kwargs.pop("model")
162 + with vllm_runner(
163 + spec_model,
164 + block_size=None,
165 + enable_chunked_prefill=None,
166 + compilation_config=CompilationConfig(),
167 + **spec_kwargs,
168 + ) as spec_runner:
169 + spec_tokens = _generate_token_ids_inline(spec_runner.llm)
170 + n_drafts = _spec_decode_num_drafts(spec_runner.llm.get_metrics())
171 +
172 + no_spec_kwargs = _inline_kwargs(config, with_spec=False)
173 + no_spec_model = no_spec_kwargs.pop("model")
174 + with vllm_runner(
175 + no_spec_model,
176 + block_size=None,
177 + enable_chunked_prefill=None,
178 + compilation_config=CompilationConfig(),
179 + **no_spec_kwargs,
180 + ) as no_spec_runner:
181 + no_spec_tokens = _generate_token_ids_inline(no_spec_runner.llm)
182
183 # Non-vacuity: a silently-broken MTP drafter that falls back to
184 # verifier-only decoding would still produce matching output below.
@@ -185,7 +208,7 @@ async def test_deepseek_mtp_load_dp(monkeypatch: pytest.MonkeyPatch, dp_size: in
208 if torch.accelerator.device_count() < dp_size:
209 pytest.skip(f"dp{dp_size} requires at least {dp_size} GPUs")
210
188 - monkeypatch.setenv("VLLM_BATCH_INVARIANT", "1")
211 + _enable_batch_invariance(monkeypatch)
212
213 base_args = AsyncEngineArgs(
214 model=DEEPSEEK_MTP_MAIN_RANDOM,
@@ -225,7 +248,10 @@ async def test_deepseek_mtp_load_dp(monkeypatch: pytest.MonkeyPatch, dp_size: in
248 sampling_params=sampling_params,
249 ):
250 token_ids = tuple(out.outputs[0].token_ids)
228 - assert len(token_ids) == MAX_TOKENS
251 + assert len(token_ids) == MAX_TOKENS, (
252 + f"{request_id}: expected {MAX_TOKENS} generated tokens, "
253 + f"got {len(token_ids)}: {token_ids}"
254 + )
255 return token_ids
256 raise AssertionError("AsyncLLM produced no output")
257 finally:
tests/v1/e2e/spec_decode/utils.py
+91 -19
@@ -2,7 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import random
5 -from collections.abc import Iterable
5 +from collections.abc import Iterable, Sequence
6 from typing import Any
7
8 import pytest
@@ -12,11 +12,13 @@ from tests.evals.gsm8k.gsm8k_eval import _build_gsm8k_prompts, evaluate_gsm8k_of
12 from vllm import LLM, SamplingParams
13 from vllm.assets.base import VLLM_S3_BUCKET_URL
14 from vllm.assets.image import VLM_IMAGES_DIR
15 +from vllm.outputs import RequestOutput
16 +from vllm.utils.torch_utils import set_random_seed
17 from vllm.v1.metrics.reader import Metric
18
19
20 def _skip_if_insufficient_gpus_for_tp(tp_size: int):
19 - """Skip test if available GPUs < tp_size on ROCm."""
21 + """Skip if fewer than ``tp_size`` accelerators are available."""
22 available_gpus = torch.accelerator.device_count()
23 if available_gpus < tp_size:
24 pytest.skip(
@@ -43,7 +45,7 @@ def get_test_prompts(mm_enabled: bool, num_prompts: int = 100) -> list[Messages]
45
46 # Generate a mixed batch of prompts, some of which can be easily
47 # predicted by n-gram matching and some which likely cannot.
46 - random.seed(0)
48 + set_random_seed(0)
49 for _ in range(num_repeat_prompts):
50 word_choices = ["test", "temp", "hello", "where"]
51 word = random.choice(word_choices)
@@ -120,20 +122,88 @@ def evaluate_llm_for_gsm8k(llm: LLM, expected_accuracy_threshold: float = 0.70)
122 )
123
124
125 +def assert_request_outputs_match(
126 + ref_outputs: Sequence[RequestOutput],
127 + spec_outputs: Sequence[RequestOutput],
128 + *,
129 + required_matches: int,
130 + context: str,
131 + max_mismatches: int = 3,
132 +) -> None:
133 + """Assert a thresholded exact-text match with bounded failure diagnostics."""
134 + assert ref_outputs, f"{context}: no reference outputs"
135 + assert len(ref_outputs) == len(spec_outputs), (
136 + f"{context}: output count differs: "
137 + f"reference={len(ref_outputs)}, speculative={len(spec_outputs)}"
138 + )
139 + assert 0 <= required_matches <= len(ref_outputs), (
140 + f"{context}: invalid required_matches={required_matches} for "
141 + f"{len(ref_outputs)} outputs"
142 + )
143 +
144 + mismatches: list[str] = []
145 + matches = 0
146 + for index, (ref_output, spec_output) in enumerate(zip(ref_outputs, spec_outputs)):
147 + assert ref_output.outputs, (
148 + f"{context}: reference output {index} has no candidate"
149 + )
150 + assert spec_output.outputs, (
151 + f"{context}: speculative output {index} has no candidate"
152 + )
153 + ref_candidate = ref_output.outputs[0]
154 + spec_candidate = spec_output.outputs[0]
155 + if ref_candidate.text == spec_candidate.text:
156 + matches += 1
157 + elif len(mismatches) < max_mismatches:
158 + mismatches.append(
159 + f"[{index}] ref_text={ref_candidate.text[:240]!r}, "
160 + f"spec_text={spec_candidate.text[:240]!r}\n"
161 + f" ref_token_ids={list(ref_candidate.token_ids)[:64]}\n"
162 + f" spec_token_ids={list(spec_candidate.token_ids)[:64]}"
163 + )
164 +
165 + print(
166 + f"{context}: exact text matches={matches}/{len(ref_outputs)} "
167 + f"(required={required_matches})"
168 + )
169 + mismatch_summary = "\n".join(mismatches) or "no mismatches captured"
170 + assert matches >= required_matches, (
171 + f"{context}: only {matches}/{len(ref_outputs)} outputs matched; "
172 + f"required at least {required_matches}. First mismatches:\n"
173 + f"{mismatch_summary}"
174 + )
175 +
176 +
177 +def get_spec_decode_metric_value(metrics: Sequence[Metric], metric_name: str) -> float:
178 + """Get a spec-decode metric with an actionable error when stats are absent."""
179 + name2metric = {metric.name: metric for metric in metrics}
180 + metric = name2metric.get(metric_name)
181 + assert metric is not None, (
182 + f"Missing metric {metric_name!r}. Ensure disable_log_stats=False. "
183 + "Available spec-decode metrics: "
184 + f"{sorted(name for name in name2metric if 'spec_decode' in name) or ['<none>']}"
185 + )
186 + return float(metric.value)
187 +
188 +
189 def compute_acceptance_rate(
190 metrics: list[Metric], prev_metrics: list[Metric] | None = None
191 ) -> float:
126 - name2metric = {metric.name: metric for metric in metrics}
127 - n_draft_toks = name2metric["vllm:spec_decode_num_draft_tokens"].value
192 + n_draft_toks = get_spec_decode_metric_value(
193 + metrics, "vllm:spec_decode_num_draft_tokens"
194 + )
195 if n_draft_toks == 0:
196 return float("nan")
130 - n_accepted_toks = name2metric["vllm:spec_decode_num_accepted_tokens"].value
197 + n_accepted_toks = get_spec_decode_metric_value(
198 + metrics, "vllm:spec_decode_num_accepted_tokens"
199 + )
200 if prev_metrics is not None:
132 - prev_name2metric = {metric.name: metric for metric in prev_metrics}
133 - n_draft_toks -= prev_name2metric["vllm:spec_decode_num_draft_tokens"].value
134 - n_accepted_toks -= prev_name2metric[
135 - "vllm:spec_decode_num_accepted_tokens"
136 - ].value
201 + n_draft_toks -= get_spec_decode_metric_value(
202 + prev_metrics, "vllm:spec_decode_num_draft_tokens"
203 + )
204 + n_accepted_toks -= get_spec_decode_metric_value(
205 + prev_metrics, "vllm:spec_decode_num_accepted_tokens"
206 + )
207 if n_draft_toks <= 0:
208 return float("nan")
209 return n_accepted_toks / n_draft_toks
@@ -142,17 +212,19 @@ def compute_acceptance_rate(
212 def compute_acceptance_len(
213 metrics: list[Metric], prev_metrics: list[Metric] | None = None
214 ) -> float:
145 - name2metric = {metric.name: metric for metric in metrics}
146 - n_drafts = name2metric["vllm:spec_decode_num_drafts"].value
147 - n_accepted_toks = name2metric["vllm:spec_decode_num_accepted_tokens"].value
215 + n_drafts = get_spec_decode_metric_value(metrics, "vllm:spec_decode_num_drafts")
216 + n_accepted_toks = get_spec_decode_metric_value(
217 + metrics, "vllm:spec_decode_num_accepted_tokens"
218 + )
219 if n_drafts == 0:
220 return 1
221 if prev_metrics is not None:
151 - prev_name2metric = {metric.name: metric for metric in prev_metrics}
152 - n_drafts -= prev_name2metric["vllm:spec_decode_num_drafts"].value
153 - n_accepted_toks -= prev_name2metric[
154 - "vllm:spec_decode_num_accepted_tokens"
155 - ].value
222 + n_drafts -= get_spec_decode_metric_value(
223 + prev_metrics, "vllm:spec_decode_num_drafts"
224 + )
225 + n_accepted_toks -= get_spec_decode_metric_value(
226 + prev_metrics, "vllm:spec_decode_num_accepted_tokens"
227 + )
228 if n_drafts <= 0:
229 return 1
230 return 1 + (n_accepted_toks / n_drafts)
vllm/config/speculative.py
+1 -1
@@ -121,7 +121,7 @@ class SpeculativeConfig:
121 attention_backend: AttentionBackendEnum | None = None
122 """Attention backend to use for the draft model. When `None`, the backend is
123 automatically selected. Useful when the drafter requires a different attention
124 - backend (e.g. DFlash needs a non-causal-capable backend like FLASH_ATTN)."""
124 + backend (e.g. DFlash needs a backend that supports non-causal attention)."""
125 kv_cache_dtype: CacheDType | None = None
126 """KV cache dtype for the draft model. When `None`, the draft inherits the
127 target model's `--kv-cache-dtype`."""