[CI] Split (H200 MIG 35GB) Spec Decode Speculators + MTP into 4 named jobs (#57237)

Signed-off-by: Thang Nguyen <thangnguyenvn647@gmail.com> Co-authored-by: Thang Nguyen <thang.nguyen@inferact.ai> Co-authored-by: Kimi Code <noreply@moonshot.ai> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

Thang Nguyen committed Sep 27, 2026 at 08:01 UTC 924707f1bf94ff583d89bff7522ee12ff032c286
12 files changed +499 -258
.buildkite/test_areas/spec_decode.yaml
+98 -6
@@ -98,9 +98,11 @@ steps:
98 commands:
99 - pytest -v -s v1/e2e/spec_decode/eagle/
100
101 -- label: ":nvidia: (H200 MIG 35GB) Spec Decode Speculators + MTP"
102 - key: spec-decode-speculators-mtp
103 - timeout_in_minutes: 50
101 +# MTP correctness is one parametrized test split into per-model folders;
102 +# each job below runs whole directories.
103 +- label: ":nvidia: (H200 MIG 35GB) Spec Decode Speculators"
104 + key: spec-decode-speculators
105 + timeout_in_minutes: 30
106 device: h200_35gb
107 source_file_dependencies:
108 - vllm/v1/spec_decode/
@@ -110,13 +112,103 @@ steps:
112 - tests/v1/e2e/spec_decode/
113 commands:
114 - pytest -v -s v1/e2e/spec_decode/speculators/
113 - - pytest -v -s v1/e2e/spec_decode/mtp/
115 mirror:
116 amd:
116 - label: ":amd: (MI300) Spec Decode Speculators + MTP"
117 + label: ":amd: (MI300) Spec Decode Speculators"
118 dind: false
119 device: mi300_1
119 - timeout_in_minutes: 75
120 + timeout_in_minutes: 40
121 + depends_on:
122 + - image-build-amd
123 + source_file_dependencies:
124 + - vllm/v1/spec_decode/
125 + - vllm/v1/worker/gpu/spec_decode/
126 + - vllm/model_executor/model_loader/
127 + - vllm/v1/sample/
128 + - vllm/model_executor/layers/
129 + - vllm/transformers_utils/configs/speculators/
130 + - tests/v1/e2e/spec_decode/
131 + - vllm/platforms/rocm.py
132 +
133 +- label: ":nvidia: (H200 MIG 35GB) Spec Decode MTP Gemma4"
134 + key: spec-decode-mtp-gemma4
135 + timeout_in_minutes: 30
136 + device: h200_35gb
137 + source_file_dependencies:
138 + - vllm/v1/spec_decode/
139 + - vllm/v1/worker/gpu/spec_decode/
140 + - vllm/v1/attention/backends/
141 + - vllm/transformers_utils/configs/speculators/
142 + - tests/v1/e2e/spec_decode/
143 + commands:
144 + - pytest -v -s v1/e2e/spec_decode/mtp/gemma4/
145 + mirror:
146 + amd:
147 + label: ":amd: (MI300) Spec Decode MTP Gemma4"
148 + dind: false
149 + device: mi300_1
150 + timeout_in_minutes: 40
151 + depends_on:
152 + - image-build-amd
153 + source_file_dependencies:
154 + - vllm/v1/spec_decode/
155 + - vllm/v1/worker/gpu/spec_decode/
156 + - vllm/model_executor/model_loader/
157 + - vllm/v1/sample/
158 + - vllm/model_executor/layers/
159 + - vllm/transformers_utils/configs/speculators/
160 + - tests/v1/e2e/spec_decode/
161 + - vllm/platforms/rocm.py
162 +
163 +- label: ":nvidia: (H200 MIG 35GB) Spec Decode MTP DeepSeek + MiMo"
164 + key: spec-decode-mtp-deepseek-mimo
165 + timeout_in_minutes: 30
166 + device: h200_35gb
167 + source_file_dependencies:
168 + - vllm/v1/spec_decode/
169 + - vllm/v1/worker/gpu/spec_decode/
170 + - vllm/v1/attention/backends/
171 + - vllm/transformers_utils/configs/speculators/
172 + - tests/v1/e2e/spec_decode/
173 + commands:
174 + - pytest -v -s v1/e2e/spec_decode/mtp/deepseek/
175 + - pytest -v -s v1/e2e/spec_decode/mtp/mimo/
176 + mirror:
177 + amd:
178 + label: ":amd: (MI300) Spec Decode MTP DeepSeek + MiMo"
179 + dind: false
180 + device: mi300_1
181 + timeout_in_minutes: 40
182 + depends_on:
183 + - image-build-amd
184 + source_file_dependencies:
185 + - vllm/v1/spec_decode/
186 + - vllm/v1/worker/gpu/spec_decode/
187 + - vllm/model_executor/model_loader/
188 + - vllm/v1/sample/
189 + - vllm/model_executor/layers/
190 + - vllm/transformers_utils/configs/speculators/
191 + - tests/v1/e2e/spec_decode/
192 + - vllm/platforms/rocm.py
193 +
194 +- label: ":nvidia: (H200 MIG 35GB) Spec Decode MTP Qwen3.5"
195 + key: spec-decode-mtp-qwen3-5
196 + timeout_in_minutes: 30
197 + device: h200_35gb
198 + source_file_dependencies:
199 + - vllm/v1/spec_decode/
200 + - vllm/v1/worker/gpu/spec_decode/
201 + - vllm/v1/attention/backends/
202 + - vllm/transformers_utils/configs/speculators/
203 + - tests/v1/e2e/spec_decode/
204 + commands:
205 + - pytest -v -s v1/e2e/spec_decode/mtp/qwen3_5/
206 + mirror:
207 + amd:
208 + label: ":amd: (MI300) Spec Decode MTP Qwen3.5"
209 + dind: false
210 + device: mi300_1
211 + timeout_in_minutes: 40
212 depends_on:
213 - image-build-amd
214 source_file_dependencies:
tests/v1/e2e/spec_decode/mtp/_correctness.py new
+120
@@ -0,0 +1,120 @@
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
7 +
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 +)
18 +
19 +
20 +def check_mtp_correctness(
21 + monkeypatch: pytest.MonkeyPatch,
22 + sampling_config: SamplingParams,
23 + model_setup: tuple[str, str, int, str | None],
24 + mm_enabled: bool,
25 + expected_accuracy_threshold: float,
26 + vllm_runner,
27 +):
28 + """Compare the outputs of a original LLM and a speculative LLM
29 + which should be the same when using MTP speculative decoding. Due to some variance
30 + in the engine, it is possible for some outputs to differ, so we expect that at least
31 + 6/10 output tokens match exactly, and that the GSM8k accuracy is above a precomputed
32 + reference threshold for each model.
33 + """
34 + method, model_name, tp_size, draft_model = model_setup
35 + _skip_if_insufficient_gpus_for_tp(tp_size)
36 +
37 + # Generate test prompts inside the function instead of using fixture
38 + test_prompts = get_test_prompts(mm_enabled)
39 + with monkeypatch.context() as m:
40 + m.setenv("VLLM_MLA_DISABLE", "1")
41 +
42 + attn_backend = "TRITON_ATTN" if current_platform.is_rocm() else "auto"
43 +
44 + # Skip multimodal profiling for models that don't need it in this test.
45 + extra_kwargs: dict[str, Any] = {}
46 + if "Qwen3.5" in model_name:
47 + extra_kwargs["limit_mm_per_prompt"] = {"image": 0, "video": 0}
48 + elif "gemma-4" in model_name:
49 + extra_kwargs["limit_mm_per_prompt"] = {"image": 0, "audio": 0}
50 +
51 + if draft_model is not None and "gemma-4" in draft_model:
52 + import transformers
53 + from packaging.version import Version
54 +
55 + if Version(transformers.__version__) < Version("5.8.0"):
56 + pytest.skip(
57 + "Gemma4 MTP assistant requires transformers>=5.8.0, "
58 + f"got {transformers.__version__}"
59 + )
60 +
61 + with vllm_runner(
62 + model_name,
63 + block_size=None,
64 + max_model_len=2048,
65 + tensor_parallel_size=tp_size,
66 + trust_remote_code=True,
67 + attention_backend=attn_backend,
68 + enable_chunked_prefill=None,
69 + compilation_config=CompilationConfig(),
70 + **extra_kwargs,
71 + ) as ref_runner:
72 + ref_outputs = ref_runner.llm.chat(test_prompts, sampling_config)
73 + evaluate_llm_for_gsm8k(
74 + ref_runner.llm,
75 + expected_accuracy_threshold=expected_accuracy_threshold,
76 + )
77 +
78 + speculative_config: dict[str, Any] = {
79 + "method": method,
80 + "num_speculative_tokens": 1,
81 + "max_model_len": 2048,
82 + }
83 + if draft_model is not None:
84 + speculative_config["model"] = draft_model
85 + speculative_config["num_speculative_tokens"] = 2
86 +
87 + with vllm_runner(
88 + model_name,
89 + block_size=None,
90 + trust_remote_code=True,
91 + tensor_parallel_size=tp_size,
92 + speculative_config=speculative_config,
93 + max_model_len=2048,
94 + attention_backend=attn_backend,
95 + enable_chunked_prefill=None,
96 + compilation_config=CompilationConfig(),
97 + **extra_kwargs,
98 + ) as spec_runner:
99 + # MTP supports async scheduling by default.
100 + has_async = (
101 + spec_runner.llm.llm_engine.vllm_config.scheduler_config.async_scheduling
102 + )
103 + assert has_async, (
104 + f"Expected async scheduling for {method}: target={model_name}, "
105 + f"draft={draft_model}; got {has_async}"
106 + )
107 + evaluate_llm_for_gsm8k(
108 + spec_runner.llm,
109 + expected_accuracy_threshold=expected_accuracy_threshold,
110 + )
111 + spec_outputs = spec_runner.llm.chat(test_prompts, sampling_config)
112 +
113 + # Heuristic: expect at least 80% of the prompts to match exactly
114 + # Upon failure, inspect the outputs to check for inaccuracy.
115 + assert_request_outputs_match(
116 + ref_outputs,
117 + spec_outputs,
118 + required_matches=int(0.8 * len(ref_outputs)) + 1,
119 + context=f"{method} target={model_name}, draft={draft_model}",
120 + )
tests/v1/e2e/spec_decode/mtp/deepseek/__init__.py new
+2
@@ -0,0 +1,2 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
tests/v1/e2e/spec_decode/mtp/deepseek/test_mtp.py new
+44
@@ -0,0 +1,44 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import pytest
5 +
6 +from tests.utils import single_gpu_only
7 +from vllm import SamplingParams
8 +from vllm.platforms import current_platform
9 +
10 +from .._correctness import check_mtp_correctness
11 +
12 +
13 +@pytest.mark.parametrize(
14 + ["model_setup", "mm_enabled", "expected_accuracy_threshold"],
15 + [
16 + pytest.param(
17 + ("mtp", "ZixiQi/DeepSeek-V3-4layers-MTP-FP8", 1, None),
18 + False,
19 + 0.0,
20 + marks=pytest.mark.skipif(
21 + current_platform.is_device_capability_family(100),
22 + reason="DeepSeek MTP: TRTLLM MoE top_k check fails on Blackwell",
23 + ),
24 + ), # dummy model
25 + ],
26 + ids=["deepseek"],
27 +)
28 +@single_gpu_only
29 +def test_mtp_correctness(
30 + monkeypatch: pytest.MonkeyPatch,
31 + sampling_config: SamplingParams,
32 + model_setup: tuple[str, str, int, str | None],
33 + mm_enabled: bool,
34 + expected_accuracy_threshold: float,
35 + vllm_runner,
36 +):
37 + check_mtp_correctness(
38 + monkeypatch,
39 + sampling_config,
40 + model_setup,
41 + mm_enabled,
42 + expected_accuracy_threshold,
43 + vllm_runner,
44 + )
tests/v1/e2e/spec_decode/mtp/gemma4/__init__.py new
+2
@@ -0,0 +1,2 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
tests/v1/e2e/spec_decode/mtp/gemma4/test_mtp.py new
+39
@@ -0,0 +1,39 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import pytest
5 +
6 +from tests.utils import single_gpu_only
7 +from vllm import SamplingParams
8 +
9 +from .._correctness import check_mtp_correctness
10 +
11 +
12 +@pytest.mark.parametrize(
13 + ["model_setup", "mm_enabled", "expected_accuracy_threshold"],
14 + [
15 + (
16 + ("mtp", "google/gemma-4-E4B-it", 1, "google/gemma-4-E4B-it-assistant"),
17 + False,
18 + 0.50,
19 + ), # gemma4 MTP with assistant model, ref: ~62%
20 + ],
21 + ids=["gemma4-e4b"],
22 +)
23 +@single_gpu_only
24 +def test_mtp_correctness(
25 + monkeypatch: pytest.MonkeyPatch,
26 + sampling_config: SamplingParams,
27 + model_setup: tuple[str, str, int, str | None],
28 + mm_enabled: bool,
29 + expected_accuracy_threshold: float,
30 + vllm_runner,
31 +):
32 + check_mtp_correctness(
33 + monkeypatch,
34 + sampling_config,
35 + model_setup,
36 + mm_enabled,
37 + expected_accuracy_threshold,
38 + vllm_runner,
39 + )
tests/v1/e2e/spec_decode/mtp/mimo/__init__.py new
+2
@@ -0,0 +1,2 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
tests/v1/e2e/spec_decode/mtp/mimo/test_mtp.py new
+36
@@ -0,0 +1,36 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import pytest
5 +
6 +from tests.utils import single_gpu_only
7 +from vllm import SamplingParams
8 +
9 +from .._correctness import check_mtp_correctness
10 +
11 +
12 +@pytest.mark.parametrize(
13 + ["model_setup", "mm_enabled", "expected_accuracy_threshold"],
14 + [
15 + # Reference accuracy: 65%-70%.
16 + (("mtp", "XiaomiMiMo/MiMo-7B-Base", 1, None), False, 0.5),
17 + ],
18 + ids=["mimo"],
19 +)
20 +@single_gpu_only
21 +def test_mtp_correctness(
22 + monkeypatch: pytest.MonkeyPatch,
23 + sampling_config: SamplingParams,
24 + model_setup: tuple[str, str, int, str | None],
25 + mm_enabled: bool,
26 + expected_accuracy_threshold: float,
27 + vllm_runner,
28 +):
29 + check_mtp_correctness(
30 + monkeypatch,
31 + sampling_config,
32 + model_setup,
33 + mm_enabled,
34 + expected_accuracy_threshold,
35 + vllm_runner,
36 + )
tests/v1/e2e/spec_decode/mtp/qwen3_5/__init__.py new
+2
@@ -0,0 +1,2 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
tests/v1/e2e/spec_decode/mtp/qwen3_5/test_mtp.py new
+39
@@ -0,0 +1,39 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import pytest
5 +
6 +from tests.utils import single_gpu_only
7 +from vllm import SamplingParams
8 +
9 +from .._correctness import check_mtp_correctness
10 +
11 +
12 +@pytest.mark.parametrize(
13 + ["model_setup", "mm_enabled", "expected_accuracy_threshold"],
14 + [
15 + (
16 + ("mtp", "Qwen/Qwen3.5-0.8B-Base", 1, None),
17 + False,
18 + 0.20,
19 + ), # hybrid + MTP, ref: ~34%-35%
20 + ],
21 + ids=["qwen3_5-hybrid"],
22 +)
23 +@single_gpu_only
24 +def test_mtp_correctness(
25 + monkeypatch: pytest.MonkeyPatch,
26 + sampling_config: SamplingParams,
27 + model_setup: tuple[str, str, int, str | None],
28 + mm_enabled: bool,
29 + expected_accuracy_threshold: float,
30 + vllm_runner,
31 +):
32 + check_mtp_correctness(
33 + monkeypatch,
34 + sampling_config,
35 + model_setup,
36 + mm_enabled,
37 + expected_accuracy_threshold,
38 + vllm_runner,
39 + )
tests/v1/e2e/spec_decode/mtp/qwen3_5/test_rejected_placeholders.py new
+115
@@ -0,0 +1,115 @@
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
7 +
8 +from tests.utils import single_gpu_only
9 +from vllm import SamplingParams
10 +from vllm.config import CompilationConfig, CUDAGraphMode
11 +from vllm.platforms import current_platform
12 +
13 +from ...utils import get_spec_decode_metric_value
14 +
15 +PLACEHOLDER_REGRESSION_MODEL = "Qwen/Qwen3.5-0.8B-Base"
16 +PLACEHOLDER_REGRESSION_PROMPT = "The capital of France is"
17 +PLACEHOLDER_REGRESSION_MAX_TOKENS = 16
18 +PLACEHOLDER_REGRESSION_ALLOWED_TOKEN_ID = 42
19 +PLACEHOLDER_REGRESSION_NUM_SPEC_TOKENS = 3
20 +
21 +
22 +@pytest.mark.parametrize(
23 + "cudagraph_mode",
24 + [CUDAGraphMode.PIECEWISE, CUDAGraphMode.FULL_AND_PIECEWISE],
25 + ids=["piecewise", "full-and-piecewise"],
26 +)
27 +@single_gpu_only
28 +def test_mtp_rejected_placeholders_match_greedy_token_ids(
29 + cudagraph_mode: CUDAGraphMode,
30 + vllm_runner,
31 +):
32 + """Rejected MTP padding must not alter the non-speculative token stream."""
33 + common_kwargs: dict[str, Any] = {
34 + "block_size": None,
35 + "max_model_len": 256,
36 + "max_num_seqs": 1,
37 + "trust_remote_code": True,
38 + "enable_chunked_prefill": None,
39 + "limit_mm_per_prompt": {"image": 0, "video": 0},
40 + "attention_backend": ("TRITON_ATTN" if current_platform.is_rocm() else "auto"),
41 + }
42 + sampling_params = SamplingParams(
43 + temperature=0.0,
44 + max_tokens=PLACEHOLDER_REGRESSION_MAX_TOKENS,
45 + ignore_eos=True,
46 + allowed_token_ids=[PLACEHOLDER_REGRESSION_ALLOWED_TOKEN_ID],
47 + logprobs=1,
48 + )
49 +
50 + def generate_token_ids(runner) -> tuple[int, ...]:
51 + outputs = runner.llm.generate([PLACEHOLDER_REGRESSION_PROMPT], sampling_params)
52 + assert outputs and outputs[0].outputs, "Expected one completion"
53 + token_ids = tuple(outputs[0].outputs[0].token_ids)
54 + assert len(token_ids) == PLACEHOLDER_REGRESSION_MAX_TOKENS
55 + return token_ids
56 +
57 + def compilation_config() -> CompilationConfig:
58 + return CompilationConfig(
59 + cudagraph_mode=cudagraph_mode,
60 + cudagraph_capture_sizes=[1, PLACEHOLDER_REGRESSION_NUM_SPEC_TOKENS + 1],
61 + )
62 +
63 + with vllm_runner(
64 + PLACEHOLDER_REGRESSION_MODEL,
65 + compilation_config=compilation_config(),
66 + **common_kwargs,
67 + ) as ref_runner:
68 + ref_mode = (
69 + ref_runner.llm.llm_engine.vllm_config.compilation_config.cudagraph_mode
70 + )
71 + assert ref_mode == cudagraph_mode
72 + ref_token_ids = generate_token_ids(ref_runner)
73 + expected_token_ids = (PLACEHOLDER_REGRESSION_ALLOWED_TOKEN_ID,) * (
74 + PLACEHOLDER_REGRESSION_MAX_TOKENS
75 + )
76 + assert ref_token_ids == expected_token_ids
77 +
78 + with vllm_runner(
79 + PLACEHOLDER_REGRESSION_MODEL,
80 + speculative_config={
81 + "method": "mtp",
82 + "num_speculative_tokens": PLACEHOLDER_REGRESSION_NUM_SPEC_TOKENS,
83 + "max_model_len": 256,
84 + "rejection_sample_method": "synthetic",
85 + "synthetic_acceptance_rates": [0.0]
86 + * PLACEHOLDER_REGRESSION_NUM_SPEC_TOKENS,
87 + },
88 + disable_log_stats=False,
89 + compilation_config=compilation_config(),
90 + **common_kwargs,
91 + ) as spec_runner:
92 + spec_mode = (
93 + spec_runner.llm.llm_engine.vllm_config.compilation_config.cudagraph_mode
94 + )
95 + assert spec_mode == cudagraph_mode
96 + spec_token_ids = generate_token_ids(spec_runner)
97 + metrics = spec_runner.llm.get_metrics()
98 + num_drafts = get_spec_decode_metric_value(
99 + metrics, "vllm:spec_decode_num_drafts"
100 + )
101 + num_draft_tokens = get_spec_decode_metric_value(
102 + metrics, "vllm:spec_decode_num_draft_tokens"
103 + )
104 + num_accepted_tokens = get_spec_decode_metric_value(
105 + metrics, "vllm:spec_decode_num_accepted_tokens"
106 + )
107 +
108 + assert num_drafts > 0, "MTP drafter did not run"
109 + assert num_draft_tokens > 0, "MTP drafter did not produce draft tokens"
110 + assert num_accepted_tokens == 0, "Synthetic sampler accepted a draft token"
111 + assert spec_token_ids == ref_token_ids, (
112 + f"MTP changed greedy token ids under {cudagraph_mode.name}:\n"
113 + f" reference={ref_token_ids}\n"
114 + f" speculative={spec_token_ids}"
115 + )
tests/v1/e2e/spec_decode/mtp/test_mtp.py deleted
-252
@@ -1,252 +0,0 @@
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
7 -
8 -from tests.utils import single_gpu_only
9 -from vllm import SamplingParams
10 -from vllm.config import CompilationConfig, CUDAGraphMode
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_spec_decode_metric_value,
18 - get_test_prompts,
19 -)
20 -
21 -PLACEHOLDER_REGRESSION_MODEL = "Qwen/Qwen3.5-0.8B-Base"
22 -PLACEHOLDER_REGRESSION_PROMPT = "The capital of France is"
23 -PLACEHOLDER_REGRESSION_MAX_TOKENS = 16
24 -PLACEHOLDER_REGRESSION_ALLOWED_TOKEN_ID = 42
25 -PLACEHOLDER_REGRESSION_NUM_SPEC_TOKENS = 3
26 -
27 -
28 -@pytest.mark.parametrize(
29 - "cudagraph_mode",
30 - [CUDAGraphMode.PIECEWISE, CUDAGraphMode.FULL_AND_PIECEWISE],
31 - ids=["piecewise", "full-and-piecewise"],
32 -)
33 -@single_gpu_only
34 -def test_mtp_rejected_placeholders_match_greedy_token_ids(
35 - cudagraph_mode: CUDAGraphMode,
36 - vllm_runner,
37 -):
38 - """Rejected MTP padding must not alter the non-speculative token stream."""
39 - common_kwargs: dict[str, Any] = {
40 - "block_size": None,
41 - "max_model_len": 256,
42 - "max_num_seqs": 1,
43 - "trust_remote_code": True,
44 - "enable_chunked_prefill": None,
45 - "limit_mm_per_prompt": {"image": 0, "video": 0},
46 - "attention_backend": ("TRITON_ATTN" if current_platform.is_rocm() else "auto"),
47 - }
48 - sampling_params = SamplingParams(
49 - temperature=0.0,
50 - max_tokens=PLACEHOLDER_REGRESSION_MAX_TOKENS,
51 - ignore_eos=True,
52 - allowed_token_ids=[PLACEHOLDER_REGRESSION_ALLOWED_TOKEN_ID],
53 - logprobs=1,
54 - )
55 -
56 - def generate_token_ids(runner) -> tuple[int, ...]:
57 - outputs = runner.llm.generate([PLACEHOLDER_REGRESSION_PROMPT], sampling_params)
58 - assert outputs and outputs[0].outputs, "Expected one completion"
59 - token_ids = tuple(outputs[0].outputs[0].token_ids)
60 - assert len(token_ids) == PLACEHOLDER_REGRESSION_MAX_TOKENS
61 - return token_ids
62 -
63 - def compilation_config() -> CompilationConfig:
64 - return CompilationConfig(
65 - cudagraph_mode=cudagraph_mode,
66 - cudagraph_capture_sizes=[1, PLACEHOLDER_REGRESSION_NUM_SPEC_TOKENS + 1],
67 - )
68 -
69 - with vllm_runner(
70 - PLACEHOLDER_REGRESSION_MODEL,
71 - compilation_config=compilation_config(),
72 - **common_kwargs,
73 - ) as ref_runner:
74 - ref_mode = (
75 - ref_runner.llm.llm_engine.vllm_config.compilation_config.cudagraph_mode
76 - )
77 - assert ref_mode == cudagraph_mode
78 - ref_token_ids = generate_token_ids(ref_runner)
79 - expected_token_ids = (PLACEHOLDER_REGRESSION_ALLOWED_TOKEN_ID,) * (
80 - PLACEHOLDER_REGRESSION_MAX_TOKENS
81 - )
82 - assert ref_token_ids == expected_token_ids
83 -
84 - with vllm_runner(
85 - PLACEHOLDER_REGRESSION_MODEL,
86 - speculative_config={
87 - "method": "mtp",
88 - "num_speculative_tokens": PLACEHOLDER_REGRESSION_NUM_SPEC_TOKENS,
89 - "max_model_len": 256,
90 - "rejection_sample_method": "synthetic",
91 - "synthetic_acceptance_rates": [0.0]
92 - * PLACEHOLDER_REGRESSION_NUM_SPEC_TOKENS,
93 - },
94 - disable_log_stats=False,
95 - compilation_config=compilation_config(),
96 - **common_kwargs,
97 - ) as spec_runner:
98 - spec_mode = (
99 - spec_runner.llm.llm_engine.vllm_config.compilation_config.cudagraph_mode
100 - )
101 - assert spec_mode == cudagraph_mode
102 - spec_token_ids = generate_token_ids(spec_runner)
103 - metrics = spec_runner.llm.get_metrics()
104 - num_drafts = get_spec_decode_metric_value(
105 - metrics, "vllm:spec_decode_num_drafts"
106 - )
107 - num_draft_tokens = get_spec_decode_metric_value(
108 - metrics, "vllm:spec_decode_num_draft_tokens"
109 - )
110 - num_accepted_tokens = get_spec_decode_metric_value(
111 - metrics, "vllm:spec_decode_num_accepted_tokens"
112 - )
113 -
114 - assert num_drafts > 0, "MTP drafter did not run"
115 - assert num_draft_tokens > 0, "MTP drafter did not produce draft tokens"
116 - assert num_accepted_tokens == 0, "Synthetic sampler accepted a draft token"
117 - assert spec_token_ids == ref_token_ids, (
118 - f"MTP changed greedy token ids under {cudagraph_mode.name}:\n"
119 - f" reference={ref_token_ids}\n"
120 - f" speculative={spec_token_ids}"
121 - )
122 -
123 -
124 -@pytest.mark.parametrize(
125 - ["model_setup", "mm_enabled", "expected_accuracy_threshold"],
126 - [
127 - # Reference accuracy: 65%-70%.
128 - (("mtp", "XiaomiMiMo/MiMo-7B-Base", 1, None), False, 0.5),
129 - pytest.param(
130 - ("mtp", "ZixiQi/DeepSeek-V3-4layers-MTP-FP8", 1, None),
131 - False,
132 - 0.0,
133 - marks=pytest.mark.skipif(
134 - current_platform.is_device_capability_family(100),
135 - reason="DeepSeek MTP: TRTLLM MoE top_k check fails on Blackwell",
136 - ),
137 - ), # dummy model
138 - (
139 - ("mtp", "Qwen/Qwen3.5-0.8B-Base", 1, None),
140 - False,
141 - 0.20,
142 - ), # hybrid + MTP, ref: ~34%-35%
143 - (
144 - ("mtp", "google/gemma-4-E4B-it", 1, "google/gemma-4-E4B-it-assistant"),
145 - False,
146 - 0.50,
147 - ), # gemma4 MTP with assistant model, ref: ~62%
148 - ],
149 - ids=["mimo", "deepseek", "qwen3_5-hybrid", "gemma4-e4b"],
150 -)
151 -@single_gpu_only
152 -def test_mtp_correctness(
153 - monkeypatch: pytest.MonkeyPatch,
154 - sampling_config: SamplingParams,
155 - model_setup: tuple[str, str, int, str | None],
156 - mm_enabled: bool,
157 - expected_accuracy_threshold: float,
158 - vllm_runner,
159 -):
160 - """Compare the outputs of a original LLM and a speculative LLM
161 - which should be the same when using MTP speculative decoding. Due to some variance
162 - in the engine, it is possible for some outputs to differ, so we expect that at least
163 - 6/10 output tokens match exactly, and that the GSM8k accuracy is above a precomputed
164 - reference threshold for each model.
165 - """
166 - method, model_name, tp_size, draft_model = model_setup
167 - _skip_if_insufficient_gpus_for_tp(tp_size)
168 -
169 - # Generate test prompts inside the function instead of using fixture
170 - test_prompts = get_test_prompts(mm_enabled)
171 - with monkeypatch.context() as m:
172 - m.setenv("VLLM_MLA_DISABLE", "1")
173 -
174 - attn_backend = "TRITON_ATTN" if current_platform.is_rocm() else "auto"
175 -
176 - # Skip multimodal profiling for models that don't need it in this test.
177 - extra_kwargs: dict[str, Any] = {}
178 - if "Qwen3.5" in model_name:
179 - extra_kwargs["limit_mm_per_prompt"] = {"image": 0, "video": 0}
180 - elif "gemma-4" in model_name:
181 - extra_kwargs["limit_mm_per_prompt"] = {"image": 0, "audio": 0}
182 -
183 - if draft_model is not None and "gemma-4" in draft_model:
184 - import transformers
185 - from packaging.version import Version
186 -
187 - if Version(transformers.__version__) < Version("5.8.0"):
188 - pytest.skip(
189 - "Gemma4 MTP assistant requires transformers>=5.8.0, "
190 - f"got {transformers.__version__}"
191 - )
192 -
193 - with vllm_runner(
194 - model_name,
195 - block_size=None,
196 - max_model_len=2048,
197 - tensor_parallel_size=tp_size,
198 - trust_remote_code=True,
199 - attention_backend=attn_backend,
200 - enable_chunked_prefill=None,
201 - compilation_config=CompilationConfig(),
202 - **extra_kwargs,
203 - ) as ref_runner:
204 - ref_outputs = ref_runner.llm.chat(test_prompts, sampling_config)
205 - evaluate_llm_for_gsm8k(
206 - ref_runner.llm,
207 - expected_accuracy_threshold=expected_accuracy_threshold,
208 - )
209 -
210 - speculative_config: dict[str, Any] = {
211 - "method": method,
212 - "num_speculative_tokens": 1,
213 - "max_model_len": 2048,
214 - }
215 - if draft_model is not None:
216 - speculative_config["model"] = draft_model
217 - speculative_config["num_speculative_tokens"] = 2
218 -
219 - with vllm_runner(
220 - model_name,
221 - block_size=None,
222 - trust_remote_code=True,
223 - tensor_parallel_size=tp_size,
224 - speculative_config=speculative_config,
225 - max_model_len=2048,
226 - attention_backend=attn_backend,
227 - enable_chunked_prefill=None,
228 - compilation_config=CompilationConfig(),
229 - **extra_kwargs,
230 - ) as spec_runner:
231 - # MTP supports async scheduling by default.
232 - has_async = (
233 - spec_runner.llm.llm_engine.vllm_config.scheduler_config.async_scheduling
234 - )
235 - assert has_async, (
236 - f"Expected async scheduling for {method}: target={model_name}, "
237 - f"draft={draft_model}; got {has_async}"
238 - )
239 - evaluate_llm_for_gsm8k(
240 - spec_runner.llm,
241 - expected_accuracy_threshold=expected_accuracy_threshold,
242 - )
243 - spec_outputs = spec_runner.llm.chat(test_prompts, sampling_config)
244 -
245 - # Heuristic: expect at least 80% of the prompts to match exactly
246 - # Upon failure, inspect the outputs to check for inaccuracy.
247 - assert_request_outputs_match(
248 - ref_outputs,
249 - spec_outputs,
250 - required_matches=int(0.8 * len(ref_outputs)) + 1,
251 - context=f"{method} target={model_name}, draft={draft_model}",
252 - )