main
py 424 lines 14.2 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 from contextlib import nullcontext
5 from types import SimpleNamespace
6 from unittest.mock import Mock, patch
7
8 import pytest
9 import torch
10 import torch.nn as nn
11 from transformers import PreTrainedConfig
12
13 from vllm.config import ParallelConfig
14 from vllm.multimodal.processing import InputProcessingContext
15
16
17 # Helper function to print input IDs with coalesced audio/video tokens.
18 def print_input_ids(input_ids):
19 """Print input IDs, compressing consecutive special tokens.
20 - 151675: <|audio_pad|>
21 - 151656: <|video_pad|>
22 """
23 if not input_ids:
24 print("[]")
25 return
26
27 result = []
28 i = 0
29
30 while i < len(input_ids):
31 current_id = input_ids[i]
32
33 # Check if it's a special token that should be compressed
34 if current_id in [151675, 151656]:
35 # Count consecutive occurrences
36 count = 1
37 while i + count < len(input_ids) and input_ids[i + count] == current_id:
38 count += 1
39
40 # Add compressed representation
41 token_name = "<|audio_pad|>" if current_id == 151675 else "<|video_pad|>"
42 result.append(f"{token_name} * {count}")
43 i += count
44 else:
45 # Regular token, just add it
46 result.append(str(current_id))
47 i += 1
48
49 print(", ".join(result))
50
51
52 @pytest.fixture
53 def mock_qwen3_omni_config():
54 """Create a mock Qwen3OmniMoeThinker config."""
55 config = Mock(spec=PreTrainedConfig)
56 # Token IDs from https://huggingface.co/Qwen/Qwen3-Omni-30B-A3B-Instruct/blob/main/tokenizer_config.json
57 config.audio_token_id = 151675 # <|audio_pad|>
58 config.video_token_id = 151656 # <|video_pad|>
59 config.image_token_id = 151655 # <|image_pad|>
60 config.audio_start_token_id = 151669 # <|audio_start|>
61 config.audio_end_token_id = 151670 # <|audio_end|>
62 config.vision_start_token_id = 151652 # <|vision_start|>
63 config.position_id_per_seconds = 12.5
64
65 # Vision config
66 vision_config = Mock()
67 vision_config.spatial_merge_size = 2
68 config.vision_config = vision_config
69
70 return config
71
72
73 @pytest.fixture
74 def mock_processor():
75 """Create a mock HF processor."""
76 from transformers.models.whisper import WhisperFeatureExtractor
77
78 processor = Mock()
79 processor.audio_token = "<|audio_pad|>"
80 processor.image_token = "<|image_pad|>"
81 processor.video_token = "<|video_pad|>"
82
83 # Create a real WhisperFeatureExtractor instance for the feature_extractor attribute
84 feature_extractor = WhisperFeatureExtractor()
85 processor.feature_extractor = feature_extractor
86
87 return processor
88
89
90 @pytest.fixture
91 def mock_tokenizer():
92 """Create a mock tokenizer."""
93 tokenizer = Mock()
94 # Token IDs from https://huggingface.co/Qwen/Qwen3-Omni-30B-A3B-Instruct/blob/main/tokenizer_config.json
95 tokenizer.get_vocab = Mock(
96 return_value={
97 "<|audio_pad|>": 151675,
98 "<|video_pad|>": 151656,
99 "<|image_pad|>": 151655,
100 "<|audio_start|>": 151669,
101 "<|audio_end|>": 151670,
102 "<|vision_start|>": 151652,
103 "<|vision_end|>": 151653,
104 }
105 )
106 tokenizer.encode = Mock(
107 side_effect=lambda x: {
108 "<|vision_start|>": [151652],
109 "<|vision_end|>": [151653],
110 "<|audio_start|>": [151669],
111 "<|audio_end|>": [151670],
112 "<|audio_pad|>": [151675],
113 "<|image_pad|>": [151655],
114 "<|video_pad|>": [151656],
115 }.get(x, [0])
116 )
117 tokenizer.vision_bos_token = "<|vision_start|>"
118 tokenizer.vision_eos_token = "<|vision_end|>"
119 tokenizer.audio_bos_token = "<|audio_start|>"
120 tokenizer.audio_eos_token = "<|audio_end|>"
121 return tokenizer
122
123
124 @pytest.fixture
125 def mock_image_processor():
126 """Create a mock image processor."""
127 image_processor = Mock()
128 image_processor.merge_size = 2
129 return image_processor
130
131
132 def test_qwen3_omni_get_updates_use_audio_in_video(
133 mock_qwen3_omni_config,
134 mock_processor,
135 mock_tokenizer,
136 mock_image_processor,
137 ):
138 """Test the get_updates_use_audio_in_video method directly."""
139 from vllm.model_executor.models.qwen3_omni_moe_thinker import (
140 Qwen3OmniMoeThinkerMultiModalProcessor,
141 Qwen3OmniMoeThinkerProcessingInfo,
142 )
143
144 # Create a mock context
145 mock_ctx = Mock(spec=InputProcessingContext)
146 mock_ctx.tokenizer = mock_tokenizer
147 # `model_config` is an instance attribute, so it is not covered by the
148 # spec; the data parser reads it for `allow_missing_mm_embeddings`.
149 mock_ctx.model_config = SimpleNamespace(
150 multimodal_config=SimpleNamespace(allow_missing_mm_embeddings=False)
151 )
152
153 # Create processing info
154 info = Qwen3OmniMoeThinkerProcessingInfo(mock_ctx)
155 info._get_expected_hidden_size = lambda: 100
156 info.get_hf_config = Mock(return_value=mock_qwen3_omni_config)
157 info.get_hf_processor = Mock(return_value=mock_processor)
158 info.get_tokenizer = Mock(return_value=mock_tokenizer)
159 info.get_image_processor = Mock(return_value=mock_image_processor)
160
161 # Create a mock dummy_inputs builder
162 mock_dummy_inputs = Mock()
163
164 # Create the processor
165 processor = Qwen3OmniMoeThinkerMultiModalProcessor(info, mock_dummy_inputs)
166
167 # Test parameters from reference video
168 # https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-Omni/demo/draw.mp4
169 audio_len = 85
170 video_grid_thw = [6, 36, 64]
171 video_second_per_grid_t = 2.0
172
173 # Call the method
174 updates = processor.get_updates_use_audio_in_video(
175 thinker_config=mock_qwen3_omni_config,
176 audio_len=audio_len,
177 video_grid_thw=video_grid_thw,
178 video_second_per_grid_t=video_second_per_grid_t,
179 )
180
181 # Updated input ids should align with HF implementation.
182 # 151669,
183 # <|video_pad|> * 576, <|audio_pad|> * 25,
184 # <|video_pad|> * 576, <|audio_pad|> * 25,
185 # <|video_pad|> * 576, <|audio_pad|> * 25,
186 # <|video_pad|> * 576, <|audio_pad|> * 10,
187 # <|video_pad|> * 1152,
188 # 151670
189 print_input_ids(updates)
190
191 # Verify structure
192 assert isinstance(updates, list)
193 assert len(updates) > 0
194
195 # Verify start and end tokens
196 audio_start_token_id = mock_qwen3_omni_config.audio_start_token_id
197 audio_end_token_id = mock_qwen3_omni_config.audio_end_token_id
198
199 assert updates[0] == audio_start_token_id
200 assert updates[-1] == audio_end_token_id
201
202 # Verify both audio and video tokens are present
203 audio_token_id = mock_qwen3_omni_config.audio_token_id
204 video_token_id = mock_qwen3_omni_config.video_token_id
205
206 audio_count = updates.count(audio_token_id)
207 video_count = updates.count(video_token_id)
208
209 assert audio_count == audio_len, (
210 f"Expected {audio_len} audio tokens, got {audio_count}"
211 )
212
213 # Calculate expected video token count
214 spatial_merge_size = mock_qwen3_omni_config.vision_config.spatial_merge_size
215 height = video_grid_thw[1] // spatial_merge_size
216 width = video_grid_thw[2] // spatial_merge_size
217 expected_video_count = video_grid_thw[0] * height * width
218
219 assert video_count == expected_video_count, (
220 f"Expected {expected_video_count} video tokens, got {video_count}"
221 )
222
223 # Total tokens should be: 1 (start) + audio_len + video_count + 1 (end)
224 expected_total = 1 + audio_len + expected_video_count + 1
225 assert len(updates) == expected_total, (
226 f"Expected {expected_total} total tokens, got {len(updates)}"
227 )
228
229
230 @pytest.mark.skip_global_cleanup
231 def test_qwen3_omni_exposes_eagle3_to_its_text_backbone():
232 from vllm.model_executor.models.interfaces import EagleModelMixin, supports_eagle3
233 from vllm.model_executor.models.qwen3_omni_moe_thinker import (
234 Qwen3OmniMoeThinkerForConditionalGeneration,
235 )
236
237 class DummyBackbone(nn.Module, EagleModelMixin):
238 def __init__(self):
239 super().__init__()
240 self.layers = nn.ModuleList([nn.Identity(), nn.Identity()])
241
242 class DummyLanguageModel(nn.Module):
243 def __init__(self):
244 super().__init__()
245 self.model = DummyBackbone()
246
247 def embed_input_ids(self, input_ids):
248 return input_ids
249
250 model = Qwen3OmniMoeThinkerForConditionalGeneration.__new__(
251 Qwen3OmniMoeThinkerForConditionalGeneration
252 )
253 nn.Module.__init__(model)
254 model.language_model = DummyLanguageModel()
255
256 assert supports_eagle3(model)
257 model.set_aux_hidden_state_layers((1, 2))
258 assert model.language_model.model.aux_hidden_state_layers == (1, 2)
259
260
261 @pytest.mark.skip_global_cleanup
262 def test_qwen3_omni_text_model_collects_post_deepstack_aux_hidden_states():
263 from vllm.model_executor.models.qwen3_omni_moe_thinker import Qwen3MoeLLMModel
264
265 class DummyLayer(nn.Module):
266 def forward(self, positions, hidden_states, residual):
267 return hidden_states + 1, torch.full_like(hidden_states, 10)
268
269 class DummyNorm(nn.Module):
270 def forward(self, hidden_states, residual):
271 return hidden_states + residual, None
272
273 model = Qwen3MoeLLMModel.__new__(Qwen3MoeLLMModel)
274 nn.Module.__init__(model)
275 model.start_layer = 0
276 model.end_layer = 1
277 model.layers = nn.ModuleList([DummyLayer()])
278 model.norm = DummyNorm()
279 model.aux_hidden_state_layers = (1,)
280
281 pp_group = Mock(is_first_rank=True, is_last_rank=True)
282 inputs_embeds = torch.tensor([[1.0]])
283 deepstack_inputs = {"deepstack_input_embeds_0": torch.tensor([[3.0]])}
284 with patch(
285 "vllm.model_executor.models.qwen3_omni_moe_thinker.get_pp_group",
286 return_value=pp_group,
287 ):
288 output, aux_hidden_states = model.forward(
289 input_ids=None,
290 positions=torch.tensor([0]),
291 inputs_embeds=inputs_embeds,
292 deepstack_input_embeds=deepstack_inputs,
293 )
294
295 torch.testing.assert_close(output, torch.tensor([[15.0]]))
296 assert len(aux_hidden_states) == 1
297 torch.testing.assert_close(aux_hidden_states[0], torch.tensor([[15.0]]))
298
299
300 def _dspark_vocab_stub(input_vocab_size: int, draft_vocab_size: int):
301 """A Qwen3 DSpark model with only what `load_weights` reads."""
302 from vllm.model_executor.models.qwen3_dspark import Qwen3DSparkForCausalLM
303
304 model = Qwen3DSparkForCausalLM.__new__(Qwen3DSparkForCausalLM)
305 nn.Module.__init__(model)
306 object.__setattr__(
307 model,
308 "config",
309 SimpleNamespace(
310 vocab_size=input_vocab_size,
311 draft_vocab_size=draft_vocab_size,
312 ),
313 )
314 object.__setattr__(model, "target_vocab_size", 100)
315 object.__setattr__(model, "model", Mock(confidence_head=None))
316 return model
317
318
319 @pytest.mark.skip_global_cleanup
320 @pytest.mark.parametrize(
321 ("input_vocab_size", "draft_vocab_size", "weights", "error"),
322 [
323 (101, 100, [], "must include embed_tokens weights"),
324 (99, 99, [], "must include lm_head weights"),
325 (100, 40, [], "must include lm_head weights"),
326 (
327 100,
328 40,
329 [("lm_head.weight", torch.empty(40, 8))],
330 "must include a d2t mapping",
331 ),
332 ],
333 )
334 def test_qwen3_dspark_rejects_incomplete_vocab_weights(
335 input_vocab_size, draft_vocab_size, weights, error
336 ):
337 model = _dspark_vocab_stub(input_vocab_size, draft_vocab_size)
338
339 with pytest.raises(ValueError, match=error):
340 model.load_weights(weights)
341
342
343 @pytest.mark.skip_global_cleanup
344 @pytest.mark.parametrize("draft_vocab_size", [100, 101])
345 def test_qwen3_dspark_accepts_draft_vocab_at_least_target(draft_vocab_size):
346 """A draft vocab >= the target's maps identically, so needs no lm_head/d2t.
347
348 Guards the padded case (draft > target): the draft's unembedding is padded
349 to its physical size, which is not a reduced vocabulary.
350 """
351 model = _dspark_vocab_stub(100, draft_vocab_size)
352
353 model.load_weights([])
354
355
356 @pytest.mark.skip_global_cleanup
357 def test_dspark_shares_target_embedding_with_smaller_draft_vocabulary():
358 from vllm.v1.worker.gpu.spec_decode.dspark import utils as dspark_utils
359
360 target_embedding = nn.Embedding(100, 8)
361 draft_embedding = nn.Embedding(99, 8)
362 target_model = SimpleNamespace(model=SimpleNamespace(embed_tokens=target_embedding))
363 draft_model = SimpleNamespace(
364 model=SimpleNamespace(embed_tokens=draft_embedding),
365 has_own_embed_tokens=False,
366 )
367 draft_model_config = SimpleNamespace(
368 hf_config=SimpleNamespace(model_type="qwen3"),
369 get_vocab_size=Mock(return_value=99),
370 )
371 vllm_config = SimpleNamespace(
372 speculative_config=SimpleNamespace(
373 draft_model_config=draft_model_config,
374 draft_parallel_config=SimpleNamespace(tensor_parallel_size=1),
375 attention_backend=None,
376 kv_cache_dtype=None,
377 draft_load_config=None,
378 ),
379 parallel_config=ParallelConfig(),
380 attention_config=SimpleNamespace(backend=None),
381 cache_config=SimpleNamespace(),
382 load_config=SimpleNamespace(load_format="auto"),
383 model_config=SimpleNamespace(get_vocab_size=Mock(return_value=100)),
384 )
385
386 def fake_replace(config, **changes):
387 values = vars(config).copy()
388 values.update(changes)
389 return SimpleNamespace(**values)
390
391 with (
392 patch.object(dspark_utils, "replace", side_effect=fake_replace),
393 patch(
394 "vllm.v1.worker.gpu.spec_decode.eagle.utils.get_pp_group",
395 return_value=SimpleNamespace(world_size=1),
396 ),
397 patch(
398 "vllm.v1.worker.gpu.spec_decode.utils.get_pp_group",
399 return_value=SimpleNamespace(world_size=1),
400 ),
401 patch(
402 "vllm.compilation.backends.set_model_tag",
403 return_value=nullcontext(),
404 ),
405 patch(
406 "vllm.model_executor.model_loader.get_model",
407 return_value=draft_model,
408 ),
409 patch(
410 "vllm.model_executor.models.qwen3_dflash.dflash_has_any_non_causal",
411 return_value=False,
412 ),
413 patch(
414 "vllm.model_executor.models.utils.get_draft_quant_config",
415 return_value=None,
416 ),
417 ):
418 loaded_model = dspark_utils.load_dspark_model(target_model, vllm_config)
419
420 assert loaded_model.model.embed_tokens is target_embedding
421
422
423 if __name__ == "__main__":
424 pytest.main([__file__, "-v"])