main
py 214 lines 7.61 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import pytest
5
6 from vllm import SamplingParams
7 from vllm.platforms import current_platform
8 from vllm.v1.kv_cache_interface import MambaSpec
9
10 from ...utils import large_gpu_mark
11
12 QWEN_MODEL = "Qwen/Qwen3.5-4B"
13 QWEN_KV_CACHE_BYTES = 12 << 30
14 HYBRID_MTP_MODELS = [
15 pytest.param(
16 QWEN_MODEL,
17 marks=[large_gpu_mark(min_gb=30)],
18 id="qwen",
19 ),
20 pytest.param(
21 "nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4",
22 marks=[large_gpu_mark(min_gb=80)]
23 + [
24 pytest.mark.skipif(
25 not current_platform.is_cuda(),
26 reason="modelopt quantization is supported only on CUDA",
27 )
28 ],
29 id="nemotron",
30 ),
31 ]
32
33 # A trivial request with a short prompt to ensure we run a mixed batch
34 SMALL_MESSAGE = [
35 {
36 "role": "user",
37 "content": "The secret beta value is 64. What is the secret beta?",
38 }
39 ]
40
41 # Sample prompt with a bunch of filler in between the critical fact and the request.
42 # Both parts need to be processed properly for the model to generate the correct answer
43 MESSAGES = [
44 {
45 "role": "user",
46 "content": (
47 "Important: The secret number is 42. "
48 "The sky is green in this hypothetical world. "
49 "Apples grow on trees in the forest. "
50 "Rivers flow through the valleys and mountains. "
51 "Birds sing songs in the early morning light. "
52 "The weather today is sunny with clear skies ahead. "
53 "Flowers bloom in the garden during spring season. "
54 "Now answer with ONLY the number and nothing else: "
55 "What is the secret number plus one?"
56 ),
57 }
58 ]
59
60
61 @pytest.mark.parametrize(
62 "model_name",
63 HYBRID_MTP_MODELS,
64 )
65 @pytest.mark.parametrize("enable_prefix_caching", [False, True])
66 def test_mtp_speculative_mixed_batch_short_prefill(
67 vllm_runner, model_name, enable_prefix_caching
68 ):
69 """Test to ensure MTP speculative decoding correctly handles
70 short prefill chunks that fall below the reorder_batch_threshold."""
71 # Set so large that both prefills will be classified as decodes in a mixed batch
72 # note, with prefix caching we require chunk_size >= mamba_block_size
73 chunk_size = 256 if not enable_prefix_caching else 2048
74 num_draft_tokens = 100
75
76 with vllm_runner(
77 model_name,
78 speculative_config={
79 "method": "mtp",
80 "num_speculative_tokens": num_draft_tokens,
81 },
82 max_num_batched_tokens=chunk_size,
83 max_num_seqs=4,
84 max_model_len=512,
85 kv_cache_memory_bytes=QWEN_KV_CACHE_BYTES if model_name == QWEN_MODEL else None,
86 enforce_eager=True,
87 tensor_parallel_size=1,
88 trust_remote_code=True,
89 enable_chunked_prefill=True,
90 enable_prefix_caching=enable_prefix_caching,
91 mamba_cache_mode="align" if enable_prefix_caching else "none",
92 ) as llm:
93 sampling_params = SamplingParams(
94 temperature=0.0,
95 max_tokens=128,
96 )
97
98 # First small message gets prefilled first, under normal conditions since the
99 # batch is not yet mixed. Then the second prefill arrives as a mixed batch, but
100 # is shorter than num_speculative_tokens, so it gets misclassified as a decode
101 # and processed with the wrong state management logic, causing the critical
102 # fact from the first chunk to be lost and the model to generate nonsense.
103 outputs = llm.get_llm().chat(
104 [SMALL_MESSAGE, MESSAGES],
105 sampling_params,
106 chat_template_kwargs={"enable_thinking": False},
107 )
108
109 responses = []
110 for output in outputs:
111 generated_text = output.outputs[0].text
112 print(f"Generated text: {generated_text!r}")
113 responses.append(generated_text)
114
115 assert "64" in responses[0], (
116 "The first response should contain the correct value of 64."
117 )
118 assert "43" in responses[1], (
119 "The second response should contain the correct value of 42+1=43."
120 )
121
122
123 def _get_mamba_block_size(llm) -> int:
124 scheduler = llm.llm_engine.engine_core.engine_core.scheduler
125 block_sizes = {
126 group.kv_cache_spec.block_size
127 for group in scheduler.kv_cache_config.kv_cache_groups
128 if isinstance(group.kv_cache_spec, MambaSpec)
129 }
130 assert len(block_sizes) == 1
131 block_size = block_sizes.pop()
132 assert scheduler.cache_config.block_size == block_size
133 return block_size
134
135
136 def _build_access_code_manual(tokenizer, target_tokens: int) -> tuple[str, list[str]]:
137 codes = ["605341", "693278", "597596", "751982"]
138 header = "Memorize this facility manual and its access codes.\n\n"
139 facts = "".join(
140 f"The access code for vault-{i:02d} is {code}.\n"
141 for i, code in enumerate(codes)
142 )
143 filler = "Routine facility inspections are recorded in the audit ledger every day. "
144 manual = header + facts
145 while len(tokenizer.encode(manual)) < target_tokens:
146 manual = header + filler + manual.removeprefix(header)
147 return manual, codes
148
149
150 @large_gpu_mark(min_gb=30)
151 def test_qwen_mtp_mamba_prefix_cache_hit_is_bounded(vllm_runner, monkeypatch):
152 """MTP must not extend a hybrid Mamba hit past the attention hit."""
153 monkeypatch.setenv("VLLM_ENABLE_V1_MULTIPROCESSING", "0")
154
155 with vllm_runner(
156 QWEN_MODEL,
157 tensor_parallel_size=1,
158 max_model_len=8192,
159 kv_cache_memory_bytes=QWEN_KV_CACHE_BYTES,
160 block_size=None,
161 enforce_eager=True,
162 enable_chunked_prefill=True,
163 enable_prefix_caching=True,
164 mamba_cache_mode="align",
165 speculative_config={"method": "mtp", "num_speculative_tokens": 2},
166 ) as runner:
167 llm = runner.get_llm()
168 block_size = _get_mamba_block_size(llm)
169 manual, codes = _build_access_code_manual(
170 llm.get_tokenizer(), 2 * block_size - 192
171 )
172 manual_tokens = len(llm.get_tokenizer().encode(manual))
173 assert block_size < manual_tokens < 2 * block_size
174
175 wave1_prompts = [
176 manual
177 + f"\nDescribe the audit procedure for vault-{i:02d} in detail.\nAnswer:"
178 for i in range(len(codes))
179 ]
180 decode_tokens = 2 * block_size - manual_tokens + 96
181 wave1_params = SamplingParams(
182 temperature=0.0,
183 min_tokens=decode_tokens,
184 max_tokens=decode_tokens,
185 ignore_eos=True,
186 )
187 wave1_outputs = llm.generate(wave1_prompts, wave1_params)
188
189 wave2_prompts = [
190 prompt
191 + output.outputs[0].text
192 + f"\n\nWhat is the access code for vault-{i:02d}? Answer:"
193 for i, (prompt, output) in enumerate(zip(wave1_prompts, wave1_outputs))
194 ]
195 warm_params = SamplingParams(temperature=0.0, max_tokens=24, stop=["\n"])
196 cold_params = SamplingParams(
197 temperature=0.0,
198 max_tokens=24,
199 stop=["\n"],
200 skip_reading_prefix_cache=True,
201 )
202 warm_outputs = llm.generate(wave2_prompts, warm_params)
203 cold_outputs = llm.generate(wave2_prompts, cold_params)
204
205 cached_tokens = [output.num_cached_tokens for output in warm_outputs]
206 assert max(cached_tokens) == block_size
207
208 for arm, outputs in (("warm", warm_outputs), ("cold", cold_outputs)):
209 missed = [
210 i
211 for i, (code, output) in enumerate(zip(codes, outputs))
212 if code not in output.outputs[0].text
213 ]
214 assert not missed, f"{arm} cache missed access codes for prompts {missed}"