main
py 1,659 lines 50.3 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import time
5 from collections.abc import Sequence
6 from contextlib import nullcontext
7 from copy import deepcopy
8 from types import SimpleNamespace
9 from typing import TypedDict
10
11 import numpy as np
12 import pytest
13
14 from vllm.config import ModelConfig, SchedulerConfig
15 from vllm.config.multimodal import MultiModalConfig
16 from vllm.exceptions import VLLMValidationError
17 from vllm.multimodal import MULTIMODAL_REGISTRY
18 from vllm.multimodal.hasher import MultiModalHasher
19 from vllm.multimodal.parse import MultiModalDataParser
20 from vllm.multimodal.processing.context import (
21 BaseProcessingInfo,
22 InputProcessingContext,
23 _resolve_mm_processor_kwargs,
24 )
25 from vllm.multimodal.processing.inputs import ProcessorInputs
26 from vllm.multimodal.processing.processor import (
27 BaseMultiModalProcessor,
28 PlaceholderFeaturesInfo,
29 PromptIndexTargets,
30 PromptInsertion,
31 PromptReplacement,
32 _apply_matches,
33 _apply_token_matches_with_placeholders,
34 apply_token_matches,
35 find_mm_placeholders,
36 iter_token_matches,
37 replace_token_matches,
38 )
39 from vllm.utils.collection_utils import flatten_2d_lists
40
41 from ..models.utils import build_model_context
42 from .utils import random_image
43
44 pytestmark = pytest.mark.cpu_test
45
46
47 @pytest.mark.parametrize(
48 ("token_ids", "match_ids", "expected"),
49 [
50 ([], [], []),
51 ([], [32000], []),
52 (
53 [32000, 32000, 32000],
54 [32000],
55 [
56 {"start_idx": 0, "end_idx": 1},
57 {"start_idx": 1, "end_idx": 2},
58 {"start_idx": 2, "end_idx": 3},
59 ],
60 ),
61 (
62 [32000, 32000, 32000],
63 [32000, 32000],
64 [{"start_idx": 0, "end_idx": 2}],
65 ),
66 (
67 [32000, 32000, 32000],
68 [32000, 32000, 32000],
69 [{"start_idx": 0, "end_idx": 3}],
70 ),
71 (
72 [9833, 28747, 32000, 32000, 32000, 9833, 28747, 32000, 32000, 918],
73 [28747, 32000],
74 [
75 {"start_idx": 1, "end_idx": 3},
76 {"start_idx": 6, "end_idx": 8},
77 ],
78 ),
79 (
80 [9833, 28747, 32000, 32000, 32000, 9833, 28747, 32000, 32000, 918],
81 [28747, 32000, 32000, 32000],
82 [
83 {"start_idx": 1, "end_idx": 5},
84 ],
85 ),
86 (
87 [9833, 28747, 32000, 32000, 32000, 9833, 28747, 32000, 32000, 918],
88 [28747, 0, 32000],
89 [],
90 ),
91 ],
92 )
93 @pytest.mark.parametrize("start_idx", [0, 4, 8])
94 def test_iter_token_matches(token_ids, match_ids, expected, start_idx):
95 result = list(iter_token_matches(token_ids, match_ids, start_idx=start_idx))
96
97 # Manually constructed results
98 assert [item._asdict() for item in result] == [
99 item for item in expected if item["start_idx"] >= start_idx
100 ]
101
102 # Invariants
103 match_lens = [end - start for start, end in result]
104 print("match_lens:", match_lens) # Only displayed on error
105 assert all(match_len == len(match_ids) for match_len in match_lens)
106
107
108 @pytest.mark.parametrize(
109 ("token_ids", "match_ids", "new_ids", "expected"),
110 [
111 ([], [], [-1], []),
112 ([], [32000], [-1], []),
113 (
114 [32000, 32000, 32000],
115 [32000],
116 [-1],
117 [-1, -1, -1],
118 ),
119 (
120 [32000, 32000, 32000],
121 [32000, 32000],
122 [-1],
123 [-1, 32000],
124 ),
125 (
126 [32000, 32000, 32000],
127 [32000, 32000, 32000],
128 [-1],
129 [-1],
130 ),
131 (
132 [9833, 28747, 32000, 32000, 32000, 9833, 28747, 32000, 32000, 918],
133 [28747, 32000],
134 [-1],
135 [9833, -1, 32000, 32000, 9833, -1, 32000, 918],
136 ),
137 (
138 [9833, 28747, 32000, 32000, 32000, 9833, 28747, 32000, 32000, 918],
139 [28747, 32000, 32000, 32000],
140 [-1],
141 [9833, -1, 9833, 28747, 32000, 32000, 918],
142 ),
143 (
144 [9833, 28747, 32000, 32000, 32000, 9833, 28747, 32000, 32000, 918],
145 [28747, 0, 32000],
146 [-1],
147 [9833, 28747, 32000, 32000, 32000, 9833, 28747, 32000, 32000, 918],
148 ),
149 ],
150 )
151 def test_replace_token_matches(token_ids, match_ids, new_ids, expected):
152 result = replace_token_matches(token_ids, match_ids, new_ids)
153
154 # Manually constructed results
155 assert result == expected
156
157
158 @pytest.mark.parametrize(
159 ("prompt", "target_by_key", "expected_by_key"),
160 [
161 (
162 [],
163 {
164 "pattern_1": [],
165 "pattern_2": [32000],
166 "pattern_3": PromptIndexTargets.start(),
167 "pattern_4": PromptIndexTargets.prefix([32000]),
168 "pattern_5": PromptIndexTargets.end(),
169 },
170 {
171 "pattern_1": [],
172 "pattern_2": [],
173 "pattern_3": [
174 {"start_idx": 0, "end_idx": 0},
175 ],
176 "pattern_4": [],
177 "pattern_5": [
178 {"start_idx": 0, "end_idx": 0},
179 ],
180 },
181 ),
182 (
183 [32000, 32000, 32000, 32000],
184 {
185 "pattern_1": [32000],
186 "pattern_2": [32000, 32000],
187 "pattern_3": [32000, 32000, 32000],
188 "pattern_4": PromptIndexTargets.start(),
189 "pattern_5": PromptIndexTargets.prefix([32000]),
190 "pattern_6": PromptIndexTargets.end(),
191 },
192 {
193 "pattern_1": [
194 {"start_idx": 0, "end_idx": 1},
195 {"start_idx": 1, "end_idx": 2},
196 {"start_idx": 2, "end_idx": 3},
197 {"start_idx": 3, "end_idx": 4},
198 ],
199 "pattern_2": [
200 {"start_idx": 0, "end_idx": 2},
201 {"start_idx": 2, "end_idx": 4},
202 ],
203 "pattern_3": [
204 {"start_idx": 0, "end_idx": 3},
205 ],
206 "pattern_4": [
207 {"start_idx": 0, "end_idx": 0},
208 ],
209 "pattern_5": [
210 {"start_idx": 1, "end_idx": 1},
211 ],
212 "pattern_6": [
213 {"start_idx": 4, "end_idx": 4},
214 ],
215 },
216 ),
217 (
218 [9833, 28747, 32000, 32000, 32000, 9833, 28747, 32000, 32000, 918],
219 {
220 "pattern_1": [28747, 32000],
221 "pattern_2": [28747, 32000, 32000, 32000],
222 "pattern_3": [28747, 0, 32000],
223 "pattern_4": PromptIndexTargets.start(),
224 "pattern_5": PromptIndexTargets.prefix([28747, 32000]),
225 "pattern_6": PromptIndexTargets.end(),
226 },
227 {
228 "pattern_1": [
229 {"start_idx": 1, "end_idx": 3},
230 {"start_idx": 6, "end_idx": 8},
231 ],
232 "pattern_2": [
233 {"start_idx": 1, "end_idx": 5},
234 ],
235 "pattern_3": [],
236 "pattern_4": [
237 {"start_idx": 0, "end_idx": 0},
238 ],
239 "pattern_5": [],
240 "pattern_6": [
241 {"start_idx": 10, "end_idx": 10},
242 ],
243 },
244 ),
245 ],
246 )
247 @pytest.mark.parametrize("update_type", [PromptInsertion, PromptReplacement])
248 def test_find_token_matches(
249 prompt,
250 target_by_key,
251 expected_by_key,
252 update_type,
253 ):
254 prompt_updates = {
255 key: update_type(key, target, []).resolve(0)
256 for key, target in target_by_key.items()
257 }
258 result = {
259 key: list(update.iter_token_matches(prompt))
260 for key, update in prompt_updates.items()
261 }
262
263 # Only displayed on error
264 print("result:", result)
265
266 # Manually constructed results
267 assert {
268 key: [
269 dict(start_idx=item.start_idx, end_idx=item.end_idx)
270 for item in result.get(key, [])
271 ]
272 for key in expected_by_key
273 } == expected_by_key
274
275
276 FIND_UPDATE_TOKENS_TEST_CASES = [
277 # Tokenized test cases of `test_find_update_text`
278 # using the vocab of llava-hf/llava-v1.6-mistral-7b-hf
279 (
280 [1, 9833, 28747, 32000, 9833, 28747, 32000, 32000, 918],
281 {
282 # We use `<image>` before `Image:` to test matches that
283 # occur out of order
284 "pattern_1": [32000],
285 "pattern_2": [9833, 28747],
286 "pattern_3": [918],
287 },
288 {
289 # Test whether target is confused with replacement
290 "pattern_1": [32000, 32000],
291 # Test empty replacement
292 "pattern_2": [],
293 # Test dynamic replacement (beyond the form of `unit * count`)
294 "pattern_3": [1550, 918, 1550],
295 },
296 {
297 PromptInsertion: {
298 0: [1, 9833, 28747, 32000, 9833, 28747, 32000, 32000, 918],
299 1: [
300 1,
301 9833,
302 28747,
303 32000,
304 32000,
305 32000,
306 9833,
307 28747,
308 32000,
309 32000,
310 918,
311 1550,
312 918,
313 1550,
314 ], # noqa: E501
315 2: [
316 1,
317 9833,
318 28747,
319 32000,
320 32000,
321 32000,
322 32000,
323 32000,
324 9833,
325 28747,
326 32000,
327 32000,
328 918,
329 1550,
330 918,
331 1550,
332 1550,
333 918,
334 1550,
335 ], # noqa: E501
336 },
337 PromptReplacement: {
338 0: [1, 9833, 28747, 32000, 9833, 28747, 32000, 32000, 918],
339 1: [1, 32000, 32000, 9833, 28747, 32000, 32000, 1550, 918, 1550], # noqa: E501
340 2: [1, 32000, 32000, 32000, 32000, 32000, 1550, 918, 1550],
341 },
342 },
343 ),
344 # Test index targets
345 (
346 [],
347 {
348 "pattern_1": PromptIndexTargets.start(),
349 "pattern_2": PromptIndexTargets.prefix([32000]),
350 "pattern_3": PromptIndexTargets.end(),
351 },
352 {
353 "pattern_1": [-1],
354 "pattern_2": [-2],
355 "pattern_3": [-3],
356 },
357 {
358 PromptInsertion: {
359 0: [],
360 1: [-1, -3],
361 2: [-1, -1, -3, -3],
362 },
363 PromptReplacement: {
364 0: [],
365 1: [-1, -3],
366 2: [-1, -1, -3, -3],
367 },
368 },
369 ),
370 (
371 [32000],
372 {
373 "pattern_1": PromptIndexTargets.start(),
374 "pattern_2": PromptIndexTargets.prefix([32000]),
375 "pattern_3": PromptIndexTargets.end(),
376 },
377 {
378 "pattern_1": [-1],
379 "pattern_2": [-2],
380 "pattern_3": [-3],
381 },
382 {
383 PromptInsertion: {
384 0: [32000],
385 1: [-1, 32000, -2, -3],
386 2: [-1, -1, 32000, -2, -2, -3, -3],
387 },
388 PromptReplacement: {
389 0: [32000],
390 1: [-1, 32000, -2, -3],
391 2: [-1, -1, 32000, -2, -2, -3, -3],
392 },
393 },
394 ),
395 # Test different replacement per item
396 (
397 [32000, 32000, 32000],
398 {
399 "pattern_1": [32000],
400 },
401 {
402 "pattern_1": lambda idx: [-(idx + 1)],
403 },
404 {
405 PromptInsertion: {
406 0: [32000, 32000, 32000],
407 1: [32000, -1, 32000, 32000],
408 2: [32000, -1, -2, 32000, 32000],
409 },
410 PromptReplacement: {
411 0: [32000, 32000, 32000],
412 1: [-1, 32000, 32000],
413 2: [-1, -2, 32000],
414 },
415 },
416 ),
417 (
418 [32000, 32000, 32000],
419 {
420 "pattern_1": PromptIndexTargets.prefix([32000]),
421 },
422 {
423 "pattern_1": lambda idx: [-(idx + 1)],
424 },
425 {
426 PromptInsertion: {
427 0: [32000, 32000, 32000],
428 1: [32000, -1, 32000, 32000],
429 2: [32000, -1, -2, 32000, 32000],
430 },
431 PromptReplacement: {
432 0: [32000, 32000, 32000],
433 1: [32000, -1, 32000, 32000],
434 2: [32000, -1, -2, 32000, 32000],
435 },
436 },
437 ),
438 ]
439
440
441 def _placeholder(modality, item_idx, start_idx, tokens):
442 return PlaceholderFeaturesInfo(
443 modality=modality,
444 item_idx=item_idx,
445 start_idx=start_idx,
446 tokens=tokens,
447 is_embed=None,
448 )
449
450
451 FIND_UPDATE_TOKENS_PLACEHOLDER_EXPECTED = [
452 {
453 PromptInsertion: {
454 0: {},
455 1: {
456 "pattern_1": [_placeholder("pattern_1", 0, 4, [32000, 32000])],
457 "pattern_3": [_placeholder("pattern_3", 0, 11, [1550, 918, 1550])],
458 },
459 2: {
460 "pattern_1": [
461 _placeholder("pattern_1", 0, 4, [32000, 32000]),
462 _placeholder("pattern_1", 1, 6, [32000, 32000]),
463 ],
464 "pattern_3": [
465 _placeholder("pattern_3", 0, 13, [1550, 918, 1550]),
466 _placeholder("pattern_3", 1, 16, [1550, 918, 1550]),
467 ],
468 },
469 },
470 PromptReplacement: {
471 0: {},
472 1: {
473 "pattern_1": [_placeholder("pattern_1", 0, 1, [32000, 32000])],
474 "pattern_3": [_placeholder("pattern_3", 0, 7, [1550, 918, 1550])],
475 },
476 2: {},
477 },
478 },
479 {
480 PromptInsertion: {0: {}, 1: {}, 2: {}},
481 PromptReplacement: {0: {}, 1: {}, 2: {}},
482 },
483 {
484 PromptInsertion: {
485 0: {},
486 1: {
487 "pattern_1": [_placeholder("pattern_1", 0, 0, [-1])],
488 "pattern_2": [_placeholder("pattern_2", 0, 2, [-2])],
489 "pattern_3": [_placeholder("pattern_3", 0, 3, [-3])],
490 },
491 2: {
492 "pattern_1": [
493 _placeholder("pattern_1", 0, 0, [-1]),
494 _placeholder("pattern_1", 1, 1, [-1]),
495 ],
496 "pattern_2": [
497 _placeholder("pattern_2", 0, 3, [-2]),
498 _placeholder("pattern_2", 1, 4, [-2]),
499 ],
500 "pattern_3": [
501 _placeholder("pattern_3", 0, 5, [-3]),
502 _placeholder("pattern_3", 1, 6, [-3]),
503 ],
504 },
505 },
506 PromptReplacement: {
507 0: {},
508 1: {
509 "pattern_1": [_placeholder("pattern_1", 0, 0, [-1])],
510 "pattern_2": [_placeholder("pattern_2", 0, 2, [-2])],
511 "pattern_3": [_placeholder("pattern_3", 0, 3, [-3])],
512 },
513 2: {
514 "pattern_1": [
515 _placeholder("pattern_1", 0, 0, [-1]),
516 _placeholder("pattern_1", 1, 1, [-1]),
517 ],
518 "pattern_2": [
519 _placeholder("pattern_2", 0, 3, [-2]),
520 _placeholder("pattern_2", 1, 4, [-2]),
521 ],
522 "pattern_3": [
523 _placeholder("pattern_3", 0, 5, [-3]),
524 _placeholder("pattern_3", 1, 6, [-3]),
525 ],
526 },
527 },
528 },
529 {
530 PromptInsertion: {
531 0: {},
532 1: {"pattern_1": [_placeholder("pattern_1", 0, 1, [-1])]},
533 2: {
534 "pattern_1": [
535 _placeholder("pattern_1", 0, 1, [-1]),
536 _placeholder("pattern_1", 1, 2, [-2]),
537 ]
538 },
539 },
540 PromptReplacement: {
541 0: {},
542 1: {"pattern_1": [_placeholder("pattern_1", 0, 0, [-1])]},
543 2: {
544 "pattern_1": [
545 _placeholder("pattern_1", 0, 0, [-1]),
546 _placeholder("pattern_1", 1, 1, [-2]),
547 ]
548 },
549 },
550 },
551 {
552 PromptInsertion: {
553 0: {},
554 1: {"pattern_1": [_placeholder("pattern_1", 0, 1, [-1])]},
555 2: {
556 "pattern_1": [
557 _placeholder("pattern_1", 0, 1, [-1]),
558 _placeholder("pattern_1", 1, 2, [-2]),
559 ]
560 },
561 },
562 PromptReplacement: {
563 0: {},
564 1: {"pattern_1": [_placeholder("pattern_1", 0, 1, [-1])]},
565 2: {
566 "pattern_1": [
567 _placeholder("pattern_1", 0, 1, [-1]),
568 _placeholder("pattern_1", 1, 2, [-2]),
569 ]
570 },
571 },
572 },
573 ]
574
575
576 @pytest.mark.parametrize(
577 ("prompt", "target_by_key", "repl_by_key", "expected_by_update_type_mm_count"), # noqa: E501
578 FIND_UPDATE_TOKENS_TEST_CASES,
579 )
580 def test_find_update_tokens(
581 prompt,
582 target_by_key,
583 repl_by_key,
584 expected_by_update_type_mm_count,
585 ):
586 for (
587 update_type,
588 expected_by_mm_count,
589 ) in expected_by_update_type_mm_count.items():
590 for mm_count, expected in expected_by_mm_count.items():
591 mm_prompt_updates = {
592 key: [
593 [update_type(key, target, repl_by_key[key]).resolve(i)]
594 for i in range(mm_count)
595 ]
596 for key, target in target_by_key.items()
597 }
598
599 new_prompt, result = apply_token_matches(prompt, mm_prompt_updates)
600
601 # Only displayed on error
602 print("update_type:", update_type)
603 print("mm_count:", mm_count)
604 print("mm_prompt_updates:", mm_prompt_updates)
605 print("new_prompt:", new_prompt)
606 print("result:", result)
607
608 # Manually constructed results
609 assert new_prompt == expected
610
611
612 @pytest.mark.parametrize(
613 (
614 "prompt",
615 "target_by_key",
616 "repl_by_key",
617 "expected_by_update_type_mm_count",
618 "expected_placeholders_by_update_type_mm_count",
619 ),
620 [
621 (*case, placeholder_expected)
622 for case, placeholder_expected in zip(
623 FIND_UPDATE_TOKENS_TEST_CASES,
624 FIND_UPDATE_TOKENS_PLACEHOLDER_EXPECTED,
625 strict=True,
626 )
627 ],
628 )
629 def test_apply_token_matches_with_placeholders(
630 prompt,
631 target_by_key,
632 repl_by_key,
633 expected_by_update_type_mm_count,
634 expected_placeholders_by_update_type_mm_count,
635 ):
636 for update_type, expected_by_mm_count in expected_by_update_type_mm_count.items():
637 for mm_count, expected in expected_by_mm_count.items():
638 mm_prompt_updates = {
639 key: [
640 [update_type(key, target, repl_by_key[key]).resolve(i)]
641 for i in range(mm_count)
642 ]
643 for key, target in target_by_key.items()
644 }
645
646 new_prompt, result, placeholders = _apply_token_matches_with_placeholders(
647 prompt,
648 mm_prompt_updates,
649 )
650
651 if any(
652 update_idx is None
653 for update_idxs in result.values()
654 for update_idx in update_idxs
655 ):
656 continue
657
658 expected_placeholders = expected_placeholders_by_update_type_mm_count[
659 update_type
660 ][mm_count]
661
662 # Only displayed on error
663 print("update_type:", update_type)
664 print("mm_count:", mm_count)
665 print("mm_prompt_updates:", mm_prompt_updates)
666 print("new_prompt:", new_prompt)
667 print("result:", result)
668 print("placeholders:", placeholders)
669
670 assert new_prompt == expected
671 assert {
672 modality: ph_list
673 for modality, ph_list in placeholders.items()
674 if ph_list
675 } == expected_placeholders
676
677
678 @pytest.mark.parametrize(
679 "repl_by_key",
680 [
681 {
682 "pattern_1": [32000, 32000],
683 "pattern_2": [],
684 "pattern_3": [1550, 918, 1550],
685 # Test different modalities having the same tokens (32000)
686 "pattern_4": [32000],
687 },
688 ],
689 )
690 @pytest.mark.parametrize(
691 ("prompt", "expected"),
692 [
693 (
694 [1, 9833, 28747, 32000, 9833, 28747, 32000, 32000, 918],
695 {
696 "pattern_1": [
697 PlaceholderFeaturesInfo(
698 modality="pattern_1",
699 item_idx=0,
700 start_idx=6,
701 tokens=[32000, 32000],
702 is_embed=None,
703 ),
704 ],
705 "pattern_4": [
706 PlaceholderFeaturesInfo(
707 modality="pattern_4",
708 item_idx=0,
709 start_idx=3,
710 tokens=[32000],
711 is_embed=None,
712 ),
713 ],
714 },
715 ),
716 (
717 [1, 32000, 32000, 9833, 28747, 32000, 32000, 1550, 918, 1550],
718 {
719 "pattern_1": [
720 PlaceholderFeaturesInfo(
721 modality="pattern_1",
722 item_idx=0,
723 start_idx=1,
724 tokens=[32000, 32000],
725 is_embed=None,
726 ),
727 PlaceholderFeaturesInfo(
728 modality="pattern_1",
729 item_idx=1,
730 start_idx=5,
731 tokens=[32000, 32000],
732 is_embed=None,
733 ),
734 ],
735 "pattern_3": [
736 PlaceholderFeaturesInfo(
737 modality="pattern_3",
738 item_idx=0,
739 start_idx=7,
740 tokens=[1550, 918, 1550],
741 is_embed=None,
742 ),
743 ],
744 # No match for pattern_4 as it has lower priority than pattern_1
745 },
746 ),
747 (
748 [1, 32000, 32000, 32000, 32000, 32000, 1550, 918, 1550],
749 {
750 "pattern_1": [
751 PlaceholderFeaturesInfo(
752 modality="pattern_1",
753 item_idx=0,
754 start_idx=1,
755 tokens=[32000, 32000],
756 is_embed=None,
757 ),
758 PlaceholderFeaturesInfo(
759 modality="pattern_1",
760 item_idx=1,
761 start_idx=3,
762 tokens=[32000, 32000],
763 is_embed=None,
764 ),
765 ],
766 "pattern_4": [
767 PlaceholderFeaturesInfo(
768 modality="pattern_4",
769 item_idx=0,
770 start_idx=5,
771 tokens=[32000],
772 is_embed=None,
773 ),
774 ],
775 "pattern_3": [
776 PlaceholderFeaturesInfo(
777 modality="pattern_3",
778 item_idx=0,
779 start_idx=6,
780 tokens=[1550, 918, 1550],
781 is_embed=None,
782 ),
783 ],
784 },
785 ),
786 ],
787 )
788 @pytest.mark.parametrize("update_type", [PromptInsertion, PromptReplacement])
789 def test_find_mm_placeholders(
790 repl_by_key,
791 prompt,
792 expected,
793 update_type,
794 ):
795 mm_prompt_updates = {
796 key: [[update_type(key, [], repl).resolve(i)] for i in range(3)]
797 for key, repl in repl_by_key.items()
798 }
799
800 result = find_mm_placeholders(prompt, mm_prompt_updates)
801
802 # Only displayed on error
803 print("result:", result)
804
805 # Manually constructed results
806 assert result == expected
807
808
809 @pytest.mark.parametrize("model_id", ["llava-hf/llava-v1.6-mistral-7b-hf"])
810 @pytest.mark.parametrize(
811 ("num_images", "limit", "is_valid"),
812 [
813 (0, 0, True),
814 (0, 1, True),
815 (1, 0, False),
816 (1, 1, True),
817 (1, 2, True),
818 (2, 1, False),
819 (2, 2, True),
820 ],
821 )
822 def test_limit_mm_per_prompt_apply(model_id, num_images, limit, is_valid):
823 limit_mm_per_prompt = {"image": limit}
824
825 model_config = ModelConfig(
826 model=model_id,
827 limit_mm_per_prompt=limit_mm_per_prompt,
828 )
829
830 processor = MULTIMODAL_REGISTRY.create_processor(model_config)
831
832 rng = np.random.RandomState(0)
833 image = random_image(rng, min_wh=128, max_wh=256)
834 if num_images == 0:
835 mm_data = {}
836 elif num_images == 1:
837 mm_data = {"image": image}
838 else:
839 mm_data = {"image": [image] * num_images}
840
841 exc_ctx = (
842 nullcontext()
843 if is_valid
844 else pytest.raises(VLLMValidationError, match="At most")
845 )
846
847 with exc_ctx:
848 processor(
849 "<image>" * num_images,
850 mm_items=processor.info.parse_mm_data(mm_data),
851 hf_processor_mm_kwargs={},
852 )
853
854
855 @pytest.mark.parametrize("model_id", ["llava-hf/llava-v1.6-mistral-7b-hf"])
856 @pytest.mark.parametrize(
857 ("user_limit", "supported_limit"),
858 [
859 (0, 0),
860 (0, 1),
861 (1, 0), # user wants 1, model supports 0 → capped to 0
862 (1, 1),
863 (1, 2),
864 (2, 1), # user wants 2, model supports 1 → capped to 1
865 (2, 2),
866 (5, 1), # large user limit, low model support → capped to 1
867 (1, 5),
868 (10, 0), # large user limit, no model support → capped to 0
869 ],
870 )
871 def test_budget_caps_prevent_dummy_input_validation_failure(
872 model_id, user_limit, supported_limit, monkeypatch
873 ):
874 limit_mm_per_prompt = {"image": user_limit}
875
876 model_config = ModelConfig(
877 model=model_id,
878 limit_mm_per_prompt=limit_mm_per_prompt,
879 )
880
881 processor = MULTIMODAL_REGISTRY.create_processor(model_config)
882 monkeypatch.setattr(
883 processor.info, "get_supported_mm_limits", lambda: {"image": supported_limit}
884 )
885
886 # This is what budget.py uses to derive mm_counts
887 allowed = processor.info.allowed_mm_limits
888
889 assert allowed["image"] <= supported_limit, (
890 f"allowed_mm_limits['image']={allowed['image']} exceeds "
891 f"supported_limit={supported_limit}"
892 )
893
894 assert allowed["image"] <= user_limit, (
895 f"allowed_mm_limits['image']={allowed['image']} exceeds user_limit={user_limit}"
896 )
897
898 assert allowed["image"] == min(user_limit, supported_limit)
899
900
901 class DummyProcessor:
902 def __init__(self, a: int = 0, b: int = 0) -> None:
903 super().__init__()
904
905 self.a = a
906 self.b = b
907
908 def __call__(
909 self,
910 a: int = 0,
911 c: int = 0,
912 return_tensors: str | None = None,
913 ) -> dict[str, int]:
914 return dict(a=a, c=c)
915
916
917 @pytest.mark.parametrize("model_id", ["Qwen/Qwen2-VL-2B-Instruct"]) # Dummy
918 @pytest.mark.parametrize(
919 ("config_kwargs", "inference_kwargs", "expected_kwargs"),
920 [
921 ({"a": 1}, {}, {"a": 1, "b": 0}),
922 ({}, {"a": 1}, {"a": 1, "b": 0}),
923 # inference_kwargs should take precedence
924 ({"a": 1}, {"a": 2}, {"a": 2, "b": 0}),
925 # Should ignore extra kwargs
926 ({"a": 1, "c": 1}, {}, {"a": 1, "b": 0}),
927 ({"b": 1, "c": 1}, {}, {"a": 0, "b": 1}),
928 ],
929 )
930 def test_hf_processor_init_kwargs(
931 model_id,
932 config_kwargs,
933 inference_kwargs,
934 expected_kwargs,
935 ):
936 ctx = InputProcessingContext(
937 model_config=ModelConfig(model_id, mm_processor_kwargs=config_kwargs),
938 tokenizer=None,
939 )
940
941 processor = ctx.get_hf_processor(
942 DummyProcessor,
943 **inference_kwargs,
944 )
945 assert processor.a == expected_kwargs["a"]
946 assert processor.b == expected_kwargs["b"]
947
948
949 @pytest.mark.parametrize("model_id", ["Qwen/Qwen2-VL-2B-Instruct"]) # Dummy
950 @pytest.mark.parametrize(
951 ("config_kwargs", "inference_kwargs", "expected_kwargs"),
952 [
953 ({"a": 1}, {}, {"a": 1, "c": 0}),
954 ({}, {"a": 1}, {"a": 1, "c": 0}),
955 # inference_kwargs should take precedence
956 ({"a": 1}, {"a": 2}, {"a": 2, "c": 0}),
957 # Should ignore extra kwargs
958 ({"a": 1, "c": 1}, {}, {"a": 1, "c": 1}),
959 ({"b": 1, "c": 1}, {}, {"a": 0, "c": 1}),
960 ],
961 )
962 def test_hf_processor_call_kwargs(
963 model_id,
964 config_kwargs,
965 inference_kwargs,
966 expected_kwargs,
967 ):
968 ctx = InputProcessingContext(
969 model_config=ModelConfig(model_id, mm_processor_kwargs=config_kwargs),
970 tokenizer=None,
971 )
972
973 processor = ctx.get_hf_processor(DummyProcessor)
974
975 result = ctx.call_hf_processor(processor, {}, inference_kwargs)
976 assert result == expected_kwargs
977
978
979 def test_apply_matches_no_match_exits_quickly():
980 """Test that _apply_matches exits quickly when no matches are found.
981
982 Previously, _apply_matches had O(n²) behavior when no match was found
983 because it would increment start_idx by 1 each iteration while
984 re-scanning the entire prompt from prev_end_idx=0.
985
986 With the fix, it should exit immediately when no match is found.
987 """
988 # Create a long prompt with no placeholder
989 long_prompt = [1] * 10000
990
991 # Create update looking for a placeholder that doesn't exist
992 mm_prompt_updates = {"image": [[PromptReplacement("image", [0], [-1]).resolve(0)]]}
993
994 start = time.perf_counter()
995 result, _ = _apply_matches(long_prompt, mm_prompt_updates)
996 elapsed = time.perf_counter() - start
997
998 # Should complete in < 100ms (was taking seconds before the fix)
999 assert elapsed < 0.1, f"_apply_matches took {elapsed:.2f}s, expected < 0.1s"
1000 assert flatten_2d_lists(result) == long_prompt
1001
1002
1003 def test_apply_matches_many_shared_targets_scales_linearly():
1004 """Shared replacement targets must not trigger per-item rescanning."""
1005 replacement = [1] * 50
1006 update = PromptReplacement("image", [0], replacement)
1007
1008 def measure(item_count: int) -> float:
1009 mm_prompt_updates = {
1010 "image": [[update.resolve(item_idx)] for item_idx in range(item_count)]
1011 }
1012 prompt = [0] * item_count
1013
1014 start = time.perf_counter()
1015 result, match_result = apply_token_matches(prompt, mm_prompt_updates)
1016 elapsed = time.perf_counter() - start
1017
1018 assert len(result) == item_count * len(replacement)
1019 assert all(token_id == 1 for token_id in result)
1020 assert match_result == {"image": [0] * item_count}
1021
1022 return elapsed
1023
1024 measure(100)
1025 small_time = measure(1_000)
1026 large_time = measure(4_000)
1027
1028 time_ratio = large_time / small_time
1029 assert time_ratio < 8, f"Expected linear scaling, got {time_ratio:.1f}x"
1030
1031
1032 def test_iter_token_matches_rejects_negative_start_idx():
1033 with pytest.raises(ValueError, match="non-negative"):
1034 list(iter_token_matches([1, 2, 3], [2], start_idx=-1))
1035
1036
1037 def test_find_mm_placeholders_avoids_quadratic_false_prefixes():
1038 """Test that placeholder scanning stays linear under adversarial candidates.
1039
1040 The fast-forward scan must not rescan the prompt tail per position when
1041 one candidate's first token never occurs (forcing a full search) while
1042 another's occurs at every position (forcing single-step advances).
1043 """
1044 prompt = [1] * 30_000
1045 mm_prompt_updates = {
1046 "absent": [[PromptReplacement("absent", [0], [999, 0]).resolve(0)]],
1047 "frequent_false_prefix": [
1048 [PromptReplacement("frequent_false_prefix", [0], [1, 2]).resolve(0)]
1049 ],
1050 }
1051
1052 start = time.perf_counter()
1053 result = find_mm_placeholders(prompt, mm_prompt_updates)
1054 elapsed = time.perf_counter() - start
1055
1056 assert result == {}
1057 assert elapsed < 0.5, f"find_mm_placeholders took {elapsed:.2f}s, expected < 0.5s"
1058
1059
1060 @pytest.mark.parametrize(
1061 "prompt",
1062 [
1063 # Empty prompt: the scan loop is never entered
1064 [],
1065 # Non-empty prompt: the scan runs but never finds the first item,
1066 # so the second item must stay unresolved
1067 [1, 2, 3, 4, 5],
1068 ],
1069 )
1070 def test_find_mm_placeholders_stops_at_missing_item(prompt):
1071 """Test that the scan returns no placeholders once it fails to find
1072 an item's placeholder, leaving later items unresolved.
1073 """
1074 result = find_mm_placeholders(
1075 prompt,
1076 {
1077 "image": [
1078 [PromptReplacement("image", [0], [999]).resolve(0)],
1079 [PromptReplacement("image", [0], [998]).resolve(1)],
1080 ]
1081 },
1082 )
1083
1084 assert result == {}
1085
1086
1087 class _FakeTokenizer:
1088 """Character-level tokenizer where "foo" merges into one token differently
1089 depending on whether it is followed by "d", like BPE merging "foo" in
1090 "food" across the search-text boundary.
1091 """
1092
1093 _MERGES = {"food": (1000,), "foo": (101, 111, 111)}
1094 _INVERSE = {ids: text for text, ids in _MERGES.items()}
1095
1096 def encode(self, text: str, **kwargs) -> list[int]:
1097 token_ids = list[int]()
1098 pos = 0
1099 while pos < len(text):
1100 for length in (4, 3):
1101 word = text[pos : pos + length]
1102 if word in self._MERGES:
1103 token_ids.extend(self._MERGES[word])
1104 pos += length
1105 break
1106 else:
1107 token_ids.append(ord(text[pos]))
1108 pos += 1
1109 return token_ids
1110
1111 def decode(self, token_ids: list[int], **kwargs) -> str:
1112 chars = list[str]()
1113 pos = 0
1114 while pos < len(token_ids):
1115 for length in (3, 1):
1116 key = tuple(token_ids[pos : pos + length])
1117 if key in self._INVERSE:
1118 chars.append(self._INVERSE[key])
1119 pos += length
1120 break
1121 else:
1122 chars.append(chr(token_ids[pos]))
1123 pos += 1
1124 return "".join(chars)
1125
1126
1127 class _FakeProcessingInfo:
1128 def __init__(self, tokenizer) -> None:
1129 self._tokenizer = tokenizer
1130
1131 def get_tokenizer(self):
1132 return self._tokenizer
1133
1134
1135 class _TextFallbackProcessor(BaseMultiModalProcessor):
1136 """Only `self.info.get_tokenizer()` is needed by the text fallback."""
1137
1138 def __init__(self, tokenizer: _FakeTokenizer) -> None:
1139 self.info = _FakeProcessingInfo(tokenizer)
1140
1141 def _get_mm_fields_config(self, hf_inputs, hf_processor_mm_kwargs):
1142 raise NotImplementedError
1143
1144 def _get_prompt_updates(self, mm_items, hf_processor_mm_kwargs, out_mm_kwargs):
1145 raise NotImplementedError
1146
1147
1148 def _text_fallback_processor() -> BaseMultiModalProcessor:
1149 return _TextFallbackProcessor(_FakeTokenizer())
1150
1151
1152 def test_apply_prompt_updates_falls_back_to_text_matching():
1153 """Test that the fallback in `_apply_prompt_updates` finds targets that
1154 tokenize differently inside the prompt ("foo" in "food").
1155 """
1156 processor = _text_fallback_processor()
1157
1158 new_token_ids, placeholders = processor._apply_prompt_updates(
1159 [1000], # "food"
1160 {
1161 "image": [
1162 [PromptReplacement("image", [101, 111, 111], [200, 201]).resolve(0)]
1163 ]
1164 },
1165 )
1166
1167 assert new_token_ids == [200, 201, ord("d")]
1168 assert [p.to_range().offset for p in placeholders["image"]] == [0]
1169 assert [p.tokens for p in placeholders["image"]] == [[200, 201]]
1170
1171
1172 def test_apply_prompt_updates_falls_back_with_prefix_target():
1173 """Test that `PromptIndexTargets.prefix` targets are resolved against the
1174 decoded text in the fallback path of `_apply_prompt_updates`.
1175 """
1176 processor = _text_fallback_processor()
1177
1178 new_token_ids, placeholders = processor._apply_prompt_updates(
1179 [1000], # "food"
1180 {
1181 "image": [
1182 [
1183 PromptInsertion(
1184 "image",
1185 PromptIndexTargets.prefix([101, 111, 111]),
1186 [9],
1187 ).resolve(0)
1188 ]
1189 ]
1190 },
1191 )
1192
1193 assert new_token_ids == [101, 111, 111, 9, ord("d")]
1194 assert [p.tokens for p in placeholders["image"]] == [[9]]
1195
1196
1197 def test_apply_prompt_updates_falls_back_with_index_targets():
1198 """Test that the text resolvers of `PromptIndexTargets.start`/`end`
1199 match against the decoded text when another item forces the
1200 fallback in `_apply_prompt_updates`.
1201 """
1202 processor = _text_fallback_processor()
1203
1204 new_token_ids, placeholders = processor._apply_prompt_updates(
1205 [1000], # "food"
1206 {
1207 "image": [
1208 [PromptReplacement("image", [101, 111, 111], [200, 201]).resolve(0)],
1209 [PromptInsertion("image", PromptIndexTargets.end(), [9]).resolve(1)],
1210 ]
1211 },
1212 )
1213
1214 assert new_token_ids == [200, 201, ord("d"), 9]
1215 assert [p.tokens for p in placeholders["image"]] == [[200, 201], [9]]
1216
1217
1218 class _TextProcessorKwargs(TypedDict, total=False):
1219 padding: bool
1220
1221
1222 class _AudioProcessorKwargs(TypedDict, total=False):
1223 sampling_rate: int
1224
1225
1226 class _ProcessorKwargs(TypedDict, total=False):
1227 text_kwargs: _TextProcessorKwargs
1228 audio_kwargs: _AudioProcessorKwargs
1229
1230
1231 class _ImageProcessorKwargs(TypedDict, total=False):
1232 size: dict[str, int]
1233 min_pixels: int
1234
1235
1236 class _VideoProcessorKwargs(TypedDict, total=False):
1237 size: dict[str, int]
1238 fps: float
1239
1240
1241 @pytest.mark.parametrize(
1242 ("supported_mm_limits", "processor", "expected"),
1243 [
1244 (
1245 {"image": None, "video": None},
1246 SimpleNamespace(
1247 valid_processor_kwargs=_ProcessorKwargs,
1248 image_processor=SimpleNamespace(valid_kwargs=_ImageProcessorKwargs),
1249 video_processor=SimpleNamespace(valid_kwargs=_VideoProcessorKwargs),
1250 ),
1251 {
1252 "text_kwargs": {"padding"},
1253 "images_kwargs": {"size", "min_pixels"},
1254 "videos_kwargs": {"size", "fps"},
1255 },
1256 ),
1257 (
1258 {"image": None},
1259 SimpleNamespace(
1260 valid_processor_kwargs=_ProcessorKwargs,
1261 image_processor=SimpleNamespace(valid_kwargs=_ImageProcessorKwargs),
1262 ),
1263 {
1264 "text_kwargs": {"padding"},
1265 "images_kwargs": {"size", "min_pixels"},
1266 },
1267 ),
1268 (
1269 {"audio": None},
1270 SimpleNamespace(valid_processor_kwargs=_ProcessorKwargs),
1271 {
1272 "text_kwargs": {"padding"},
1273 "audio_kwargs": {"sampling_rate"},
1274 },
1275 ),
1276 ],
1277 )
1278 def test_get_supported_mm_processor_kwargs_uses_supported_modalities(
1279 monkeypatch: pytest.MonkeyPatch,
1280 supported_mm_limits: dict[str, int | None],
1281 processor: SimpleNamespace,
1282 expected: dict[str, set[str]],
1283 ) -> None:
1284 info = BaseProcessingInfo(SimpleNamespace())
1285 info.__dict__["supported_mm_limits"] = supported_mm_limits
1286 monkeypatch.setattr(info, "get_hf_processor", lambda **_: processor)
1287
1288 assert info.get_supported_mm_processor_kwargs() == expected
1289
1290
1291 def test_supported_mm_processor_kwargs_is_cached(
1292 monkeypatch: pytest.MonkeyPatch,
1293 ) -> None:
1294 info = BaseProcessingInfo(SimpleNamespace())
1295 expected = {"images_kwargs": {"size"}}
1296 calls = 0
1297
1298 def get_supported_mm_processor_kwargs() -> dict[str, set[str]]:
1299 nonlocal calls
1300 calls += 1
1301 return expected
1302
1303 monkeypatch.setattr(
1304 info,
1305 "get_supported_mm_processor_kwargs",
1306 get_supported_mm_processor_kwargs,
1307 )
1308
1309 first = info.supported_mm_processor_kwargs
1310 second = info.supported_mm_processor_kwargs
1311
1312 assert first is expected
1313 assert second is first
1314 assert calls == 1
1315
1316
1317 def test_resolve_mm_processor_kwargs_routes_supported_flat_kwargs():
1318 kwargs = {
1319 "size": {"shortest_edge": 64},
1320 "padding": True,
1321 "sampling_rate": 16000,
1322 "unknown": "keep",
1323 "images_kwargs": {
1324 "size": {"longest_edge": 1024},
1325 "custom_image_kwarg": "keep",
1326 },
1327 }
1328 kwargs_before = deepcopy(kwargs)
1329
1330 resolved = _resolve_mm_processor_kwargs(
1331 kwargs,
1332 supported_mm_processor_kwargs={
1333 "text_kwargs": {"padding"},
1334 "images_kwargs": {"size"},
1335 "videos_kwargs": {"size"},
1336 "audio_kwargs": {"sampling_rate"},
1337 },
1338 )
1339
1340 assert resolved == {
1341 "unknown": "keep",
1342 "text_kwargs": {"padding": True},
1343 "images_kwargs": {
1344 "size": {
1345 "shortest_edge": 64,
1346 "longest_edge": 1024,
1347 },
1348 "custom_image_kwarg": "keep",
1349 },
1350 "videos_kwargs": {
1351 "size": {"shortest_edge": 64},
1352 },
1353 "audio_kwargs": {"sampling_rate": 16000},
1354 }
1355 assert kwargs == kwargs_before
1356
1357 video_kwargs = resolved["videos_kwargs"]
1358 image_kwargs = resolved["images_kwargs"]
1359 assert isinstance(video_kwargs, dict)
1360 assert isinstance(image_kwargs, dict)
1361 video_size = video_kwargs["size"]
1362 image_size = image_kwargs["size"]
1363 assert isinstance(video_size, dict)
1364 assert isinstance(image_size, dict)
1365 video_size["shortest_edge"] = 999
1366
1367 assert image_size["shortest_edge"] == 64
1368 assert kwargs == kwargs_before
1369
1370
1371 def test_resolve_mm_processor_kwargs_rejects_non_mapping_destination_scope():
1372 with pytest.raises(TypeError, match="images_kwargs"):
1373 _resolve_mm_processor_kwargs(
1374 {
1375 "size": {"shortest_edge": 64},
1376 "images_kwargs": 123,
1377 },
1378 supported_mm_processor_kwargs={
1379 "images_kwargs": {"size"},
1380 },
1381 )
1382
1383
1384 def test_resolve_mm_processor_kwargs_without_schema_deduplicates_existing_scopes():
1385 kwargs = {
1386 "size": {"shortest_edge": 64},
1387 "num_frames": 16,
1388 "fps": 4,
1389 "images_kwargs": {
1390 "size": {"longest_edge": 1024},
1391 },
1392 "videos_kwargs": {
1393 "num_frames": 8,
1394 },
1395 "audio_kwargs": 123,
1396 "common_kwargs": {
1397 "fps": 2,
1398 },
1399 }
1400 kwargs_before = deepcopy(kwargs)
1401
1402 resolved = _resolve_mm_processor_kwargs(kwargs)
1403
1404 assert resolved == {
1405 "fps": 4,
1406 "images_kwargs": {
1407 "size": {"longest_edge": 1024},
1408 },
1409 "videos_kwargs": {
1410 "num_frames": 8,
1411 },
1412 "audio_kwargs": 123,
1413 "common_kwargs": {
1414 "fps": 2,
1415 },
1416 }
1417 assert kwargs == kwargs_before
1418 assert "text_kwargs" not in resolved
1419
1420
1421 @pytest.mark.parametrize(
1422 "inference_kwargs",
1423 [
1424 {"size": {"shortest_edge": 128}},
1425 {"size": {"shortest_edge": 128}, "images_kwargs": None},
1426 {"size": {"shortest_edge": 128}, "images_kwargs": {}},
1427 ],
1428 )
1429 def test_get_merged_mm_kwargs_treats_empty_scopes_as_absent_before_routing(
1430 inference_kwargs: dict[str, object],
1431 ):
1432 mm_config = MultiModalConfig(
1433 mm_processor_kwargs={
1434 "size": {
1435 "shortest_edge": 64,
1436 "longest_edge": 512,
1437 },
1438 "images_kwargs": {
1439 "size": {"longest_edge": 1024},
1440 },
1441 },
1442 mm_device_do_normalize=False,
1443 )
1444 model_config = SimpleNamespace(get_multimodal_config=lambda: mm_config)
1445 ctx = InputProcessingContext(model_config, tokenizer=None)
1446
1447 merged = ctx.get_merged_mm_kwargs(
1448 inference_kwargs,
1449 supported_mm_processor_kwargs={
1450 "images_kwargs": {"size"},
1451 "videos_kwargs": {"size"},
1452 },
1453 )
1454
1455 assert merged == {
1456 "images_kwargs": {
1457 "size": {
1458 "shortest_edge": 128,
1459 "longest_edge": 1024,
1460 },
1461 },
1462 "videos_kwargs": {
1463 "size": {
1464 "shortest_edge": 128,
1465 "longest_edge": 512,
1466 },
1467 },
1468 }
1469
1470
1471 def test_get_merged_mm_kwargs_merges_before_routing():
1472 mm_config = MultiModalConfig(
1473 mm_processor_kwargs={
1474 "size": {
1475 "shortest_edge": 64,
1476 "longest_edge": 512,
1477 },
1478 "images_kwargs": {
1479 "size": {"longest_edge": 1024},
1480 },
1481 },
1482 mm_device_do_normalize=False,
1483 )
1484 model_config = SimpleNamespace(get_multimodal_config=lambda: mm_config)
1485 ctx = InputProcessingContext(model_config, tokenizer=None)
1486
1487 merged = ctx.get_merged_mm_kwargs(
1488 {
1489 "size": {
1490 "shortest_edge": 128,
1491 "longest_edge": 768,
1492 },
1493 "images_kwargs": {
1494 "size": {"longest_edge": 2048},
1495 },
1496 },
1497 supported_mm_processor_kwargs={
1498 "images_kwargs": {"size"},
1499 "videos_kwargs": {"size"},
1500 },
1501 )
1502
1503 assert merged == {
1504 "images_kwargs": {
1505 "size": {
1506 "shortest_edge": 128,
1507 "longest_edge": 2048,
1508 },
1509 },
1510 "videos_kwargs": {
1511 "size": {
1512 "shortest_edge": 128,
1513 "longest_edge": 768,
1514 },
1515 },
1516 }
1517
1518
1519 def test_processor_inputs_hashes_partial_uuids():
1520 rng = np.random.RandomState(0)
1521 images = [random_image(rng, min_wh=8, max_wh=9) for _ in range(2)]
1522 inputs = ProcessorInputs(
1523 prompt=[],
1524 mm_data_items=MultiModalDataParser().parse_mm_data({"image": images}),
1525 mm_uuid_items={"image": ["image-uuid", None]},
1526 )
1527
1528 assert inputs.get_mm_hashes("test-model", "blake3") == {
1529 "image": [
1530 "image-uuid",
1531 MultiModalHasher.hash_kwargs(
1532 "blake3", model_id="test-model", image=images[1]
1533 ),
1534 ]
1535 }
1536
1537
1538 def test_processor_inputs_hashes_scope_kwargs_by_modality():
1539 """Changing one modality's options must not invalidate another item."""
1540 rng = np.random.RandomState(0)
1541 mm_data_items = MultiModalDataParser().parse_mm_data(
1542 {
1543 "image": [random_image(rng, min_wh=8, max_wh=9)],
1544 "video": [np.zeros((2, 8, 8, 3), dtype=np.uint8)],
1545 }
1546 )
1547 mm_uuid_items: dict[str, Sequence[str | None]] = {
1548 "image": ["image-uuid"],
1549 "video": ["video-uuid"],
1550 }
1551
1552 def get_hashes(video_frames: int, image_size: int, video_size: int):
1553 return ProcessorInputs(
1554 prompt=[],
1555 mm_data_items=mm_data_items,
1556 mm_uuid_items=mm_uuid_items,
1557 media_io_kwargs={"video": {"num_frames": video_frames}},
1558 hf_processor_mm_kwargs={
1559 "images_kwargs": {"size": {"longest_edge": image_size}},
1560 "videos_kwargs": {"size": {"longest_edge": video_size}},
1561 },
1562 ).get_mm_hashes("test-model", "blake3")
1563
1564 base = get_hashes(video_frames=4, image_size=224, video_size=224)
1565 changed_video = get_hashes(video_frames=16, image_size=224, video_size=448)
1566 changed_image = get_hashes(video_frames=4, image_size=448, video_size=224)
1567
1568 assert changed_video["image"] == base["image"]
1569 assert changed_video["video"] != base["video"]
1570 assert changed_image["image"] != base["image"]
1571 assert changed_image["video"] == base["video"]
1572
1573
1574 def test_processor_inputs_hashes_ignore_unrelated_kwargs():
1575 """An image-only request ignores video-only processing configuration."""
1576 image = random_image(np.random.RandomState(0), min_wh=8, max_wh=9)
1577 inputs = ProcessorInputs(
1578 prompt=[],
1579 mm_data_items=MultiModalDataParser().parse_mm_data({"image": [image]}),
1580 mm_uuid_items={"image": ["image-uuid"]},
1581 media_io_kwargs={"video": {"num_frames": 16}},
1582 hf_processor_mm_kwargs={"videos_kwargs": {"size": {"longest_edge": 448}}},
1583 )
1584
1585 assert inputs.get_mm_hashes("test-model", "blake3") == {"image": ["image-uuid"]}
1586
1587
1588 @pytest.mark.parametrize(
1589 ("left", "right"),
1590 [
1591 # Shifting the key/value boundary: both flatten to the dotted key
1592 # "mm_processor_kwargs.abc" followed by no value bytes.
1593 ({"ab": "c"}, {"a": "bc"}),
1594 # A nested mapping and a caller-supplied dotted key flatten alike.
1595 ({"size": {"shortest_edge": 224}}, {"size.shortest_edge": 224}),
1596 # A sequence and a mapping keyed by stringified indices flatten alike.
1597 ({"fps": [2, 4]}, {"fps": {"0": 2, "1": 4}}),
1598 # None contributes the key alone, which a zero-byte value also does.
1599 ({"video_pruning_rate": None}, {"video_pruning_rate": ""}),
1600 # An empty container contributes nothing, as does omitting the key.
1601 ({"size": {}}, {}),
1602 ],
1603 )
1604 def test_processor_inputs_hashes_distinguish_kwargs_shapes(left, right):
1605 """Distinct processor kwargs must not share a multi-modal hash.
1606
1607 ``hf_processor_mm_kwargs`` is per-request input, so both the keys and the
1608 values here are caller-controlled. The hash is the identity of the
1609 processor cache entry and is mixed into the prefix-cache block key, so two
1610 requests sharing one is a cross-request cache hit.
1611 """
1612 image = random_image(np.random.RandomState(0), min_wh=8, max_wh=9)
1613 mm_data_items = MultiModalDataParser().parse_mm_data({"image": [image]})
1614
1615 def hash_with(hf_processor_mm_kwargs):
1616 return ProcessorInputs(
1617 prompt=[],
1618 mm_data_items=mm_data_items,
1619 hf_processor_mm_kwargs=hf_processor_mm_kwargs,
1620 ).get_mm_hashes("test-model", "blake3")["image"][0]
1621
1622 assert hash_with(left) != hash_with(right)
1623
1624
1625 @pytest.mark.parametrize(
1626 ("chunked_prefill", "max_model_len", "expected_seq_len"),
1627 [(None, 491520, 491520), (True, 491520, 8192), (True, 128, 128), (False, 128, 128)],
1628 )
1629 def test_dummy_inputs_scheduler_budget(
1630 chunked_prefill, max_model_len, expected_seq_len, monkeypatch
1631 ):
1632 ctx = build_model_context(
1633 "llava-hf/llava-v1.6-mistral-7b-hf",
1634 mm_processor_kwargs=None,
1635 limit_mm_per_prompt={"image": 1},
1636 )
1637 ctx.model_config.max_model_len = max_model_len
1638
1639 processor = MULTIMODAL_REGISTRY.create_processor(
1640 ctx.model_config,
1641 tokenizer=ctx.tokenizer,
1642 )
1643 scheduler_config = None
1644 if chunked_prefill is not None:
1645 scheduler_config = SchedulerConfig(
1646 max_model_len=max_model_len,
1647 is_encoder_decoder=False,
1648 max_num_batched_tokens=8192,
1649 max_num_seqs=1,
1650 enable_chunked_prefill=chunked_prefill,
1651 )
1652
1653 monkeypatch.setattr(
1654 processor, "apply", lambda *args, **kwargs: {"prompt_token_ids": [7]}
1655 )
1656 result = processor.get_dummy_mm_inputs(
1657 {"image": 1}, scheduler_config=scheduler_config
1658 )
1659 assert len(result["prompt_token_ids"]) == expected_seq_len