[Perf] Avoid repeated multimodal prompt update scans (#51774)
Signed-off-by: Tianyu Guo <guoty@inferact.ai> Co-authored-by: OpenAI Codex <codex@openai.com>
Tianyu Guo committed
Aug 11, 2026 at 17:23 UTC
3fb7bb46d6202e23988c96746891ec788ee6aaf5
2 files changed
+226
-121
tests/multimodal/test_processing.py
+33
@@ -1099,6 +1099,39 @@ def test_apply_matches_no_match_exits_quickly():
1099
assert "".join(result) == long_prompt
1100
1101
1102
+def test_apply_matches_many_shared_targets_scales_linearly():
1103
+ """Shared replacement targets must not trigger per-item rescanning."""
1104
+ replacement = [1] * 50
1105
+ update = PromptReplacement("image", [0], replacement)
1106
+
1107
+ def measure(item_count: int) -> float:
1108
+ mm_prompt_updates = {
1109
+ "image": [[update.resolve(item_idx)] for item_idx in range(item_count)]
1110
+ }
1111
+ prompt = [0] * item_count
1112
+
1113
+ start = time.perf_counter()
1114
+ result, match_result = apply_token_matches(
1115
+ prompt,
1116
+ mm_prompt_updates,
1117
+ tokenizer=None,
1118
+ )
1119
+ elapsed = time.perf_counter() - start
1120
+
1121
+ assert len(result) == item_count * len(replacement)
1122
+ assert all(token_id == 1 for token_id in result)
1123
+ assert match_result == {"image": [0] * item_count}
1124
+
1125
+ return elapsed
1126
+
1127
+ measure(100)
1128
+ small_time = measure(1_000)
1129
+ large_time = measure(4_000)
1130
+
1131
+ time_ratio = large_time / small_time
1132
+ assert time_ratio < 8, f"Expected linear scaling, got {time_ratio:.1f}x"
1133
+
1134
+
1135
def test_iter_token_matches_rejects_negative_start_idx():
1136
with pytest.raises(ValueError, match="non-negative"):
1137
list(iter_token_matches([1, 2, 3], [2], start_idx=-1))
vllm/multimodal/processing/processor.py
+193
-121
@@ -1,7 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
from abc import ABC, abstractmethod
4
-from collections import defaultdict
4
+from collections import defaultdict, deque
5
from collections.abc import Callable, Generator, ItemsView, Iterable, Mapping, Sequence
6
from dataclasses import dataclass, field, replace
7
from enum import Enum
@@ -706,91 +706,191 @@ class PlaceholderFeaturesInfo:
706
)
707
708
709
-_MatchToApply = tuple[tuple[str, int], tuple[PromptTargetMatch, int]]
709
+class _MatchedUpdate(NamedTuple):
710
+ """A resolved update selected for a match in the original prompt."""
711
712
+ priority: int
713
+ """The original item order used to preserve match tie-breaking."""
714
712
-def _find_matches(
713
- prompt: _S,
715
+ update: ResolvedPromptUpdate
716
+ """The selected update for the multimodal item."""
717
+
718
+ update_idx: int
719
+ """The selected update's index within the item's alternatives."""
720
+
721
+ match: PromptTargetMatch
722
+ """The target range in the original prompt."""
723
+
724
+
725
+_UpdateQueue: TypeAlias = deque[tuple[int, Sequence[ResolvedPromptUpdate]]]
726
+"""
727
+Items with the same ordered match rules, stored as `(priority, alternatives)`.
728
+"""
729
+
730
+_QueueMatch: TypeAlias = tuple[_UpdateQueue, PromptTargetMatch, int]
731
+"""A queue together with its next match and selected alternative index."""
732
+
733
+
734
+def _target_key(target: UpdateTarget) -> tuple[str, object]:
735
+ """Return a hashable key that preserves target matching semantics."""
736
+ if isinstance(target, PromptIndex):
737
+ return ("index", id(target))
738
+ if isinstance(target, str):
739
+ return ("text", target)
740
+
741
+ return ("tokens", tuple(target))
742
+
743
+
744
+def _compile_prompt_update_queues(
745
mm_prompt_updates: "MultiModalPromptUpdates",
746
+) -> dict[
747
+ tuple[tuple[UpdateMode, tuple[str, object]], ...],
748
+ _UpdateQueue,
749
+]:
750
+ """Group items with identical match rules into ordered queues."""
751
+ queues_by_signature = dict[
752
+ tuple[tuple[UpdateMode, tuple[str, object]], ...],
753
+ _UpdateQueue,
754
+ ]()
755
+ priority = 0
756
+
757
+ for modality_updates in mm_prompt_updates.values():
758
+ for updates in modality_updates:
759
+ signature = tuple(
760
+ (update.mode, _target_key(update.target)) for update in updates
761
+ )
762
+ queues_by_signature.setdefault(signature, deque()).append(
763
+ (priority, updates)
764
+ )
765
+ priority += 1
766
+
767
+ return queues_by_signature
768
+
769
+
770
+def _find_queue_match(
771
+ queue: _UpdateQueue,
772
+ prompt: _S,
773
tokenizer: TokenizerLike | None,
774
*,
717
- prev_end_idx: int = 0,
718
- current_result: "MultiModalPromptUpdatesApplyResult",
719
-) -> tuple[UpdateMode | None, list[_MatchToApply]]:
720
- mode: UpdateMode | None = None
721
- mm_matches = dict[tuple[str, int], tuple[PromptTargetMatch, int]]()
722
-
723
- # Items commonly share the same target object (a static PromptUpdate
724
- # target resolves to the same object for every item); scan the prompt once
725
- # per distinct target instead of once per item. The cache is keyed by
726
- # object identity: it implies value equality, costs O(1) per lookup
727
- # (hashing a long target value per lookup would defeat the point), and the
728
- # target objects are kept alive by `mm_prompt_updates` for the duration of
729
- # this call. Items with equal-valued but distinct target objects simply
730
- # miss the cache and scan independently.
731
- # NOTE: ResolvedPromptUpdate.iter_matches depends only on the target,
732
- # prompt, tokenizer and start_idx, so updates sharing a target share the
733
- # same first match within this invocation. Revisit this cache if matching
734
- # ever depends on other update attributes.
735
- first_match_by_target_id = dict[int, PromptTargetMatch | None]()
736
-
737
- def get_first_match(update: "ResolvedPromptUpdate") -> PromptTargetMatch | None:
738
- target = update.target
739
- if isinstance(target, PromptIndex): # Matches are not cacheable
740
- return next(
741
- update.iter_matches(prompt, tokenizer, start_idx=prev_end_idx),
742
- None,
743
- )
775
+ start_idx: int,
776
+ mode: UpdateMode | None = None,
777
+) -> tuple[PromptTargetMatch, int] | None:
778
+ """Find the first matching alternative for the next queued item."""
779
+ _, updates = queue[0]
780
+ for update_idx, update in enumerate(updates):
781
+ if mode is not None and update.mode != mode:
782
+ continue
783
+
784
+ match = next(
785
+ update.iter_matches(prompt, tokenizer, start_idx=start_idx),
786
+ None,
787
+ )
788
+ if match is not None:
789
+ return match, update_idx
790
745
- key = id(target)
746
- if key not in first_match_by_target_id:
747
- first_match_by_target_id[key] = next(
748
- update.iter_matches(prompt, tokenizer, start_idx=prev_end_idx),
749
- None,
750
- )
791
+ return None
792
752
- return first_match_by_target_id[key]
793
754
- for modality, modality_updates in mm_prompt_updates.items():
755
- for item_idx, item_updates in enumerate(modality_updates):
756
- if current_result[modality][item_idx] is not None:
757
- continue # Updates have already been applied for this item
794
+def _next_priority(queue: _UpdateQueue) -> int:
795
+ """Return the original priority of the next queued item."""
796
+ priority, _ = queue[0]
797
+ return priority
798
759
- for update_idx, update in enumerate(item_updates):
760
- if (modality, item_idx) in mm_matches:
761
- break # Already found a match for this item
799
763
- match = get_first_match(update)
764
- if match is None:
765
- continue
800
+def _plan_prompt_updates(
801
+ prompt: _S,
802
+ mm_prompt_updates: "MultiModalPromptUpdates",
803
+ tokenizer: TokenizerLike | None,
804
+) -> tuple[list[_MatchedUpdate], "MultiModalPromptUpdatesApplyResult"]:
805
+ """Plan non-overlapping prompt updates before rendering the output."""
806
+ queues = list(_compile_prompt_update_queues(mm_prompt_updates).values())
807
+ result: MultiModalPromptUpdatesApplyResult = {
808
+ modality: [None] * len(items) for modality, items in mm_prompt_updates.items()
809
+ }
810
+ planned_updates = list[_MatchedUpdate]()
811
+ prev_end_idx = 0
812
767
- # All matches should share the same mode
768
- if mode is None:
769
- mode = update.mode
770
- elif mode != update.mode:
771
- continue
813
+ while queues:
814
+ first_matches = list[_QueueMatch]()
815
+ for queue in queues:
816
+ queue_match = _find_queue_match(
817
+ queue,
818
+ prompt,
819
+ tokenizer,
820
+ start_idx=prev_end_idx,
821
+ )
822
+ if queue_match is not None:
823
+ prompt_match, update_idx = queue_match
824
+ first_matches.append((queue, prompt_match, update_idx))
825
773
- mm_matches[(modality, item_idx)] = match, update_idx
826
+ if not first_matches:
827
+ break
828
775
- # Prioritize earlier matches
776
- matches_to_apply = sorted(mm_matches.items(), key=lambda item: item[1][0])
829
+ mode_queue, _, mode_update_idx = min(
830
+ first_matches,
831
+ key=lambda item: _next_priority(item[0]),
832
+ )
833
+ _, mode_updates = mode_queue[0]
834
+ mode = mode_updates[mode_update_idx].mode
835
778
- # To avoid conflicts, only replace one non-empty item at a time
779
- if mode == UpdateMode.REPLACE:
780
- matches_to_apply_ = list[_MatchToApply]()
781
- has_non_empty_matches = False
836
+ mode_matches = list[_QueueMatch]()
837
+ for queue, prompt_match, first_update_idx in first_matches:
838
+ if queue[0][1][first_update_idx].mode == mode:
839
+ mode_matches.append((queue, prompt_match, first_update_idx))
840
+ continue
841
+
842
+ queue_match = _find_queue_match(
843
+ queue,
844
+ prompt,
845
+ tokenizer,
846
+ start_idx=prev_end_idx,
847
+ mode=mode,
848
+ )
849
+ if queue_match is not None:
850
+ prompt_match, update_idx = queue_match
851
+ mode_matches.append((queue, prompt_match, update_idx))
852
+
853
+ updates_to_apply = list[_MatchedUpdate]()
854
+ non_empty_replacements = list[_QueueMatch]()
855
+ for queue, match, update_idx in mode_matches:
856
+ if mode == UpdateMode.REPLACE and match.start_idx != match.end_idx:
857
+ non_empty_replacements.append((queue, match, update_idx))
858
+ else:
859
+ while queue:
860
+ priority, updates = queue.popleft()
861
+ updates_to_apply.append(
862
+ _MatchedUpdate(
863
+ priority=priority,
864
+ update=updates[update_idx],
865
+ update_idx=update_idx,
866
+ match=match,
867
+ )
868
+ )
869
783
- for item in matches_to_apply:
784
- _, (match, _) = item
785
- if match.start_idx == match.end_idx:
786
- matches_to_apply_.append(item)
787
- elif not has_non_empty_matches:
788
- has_non_empty_matches = True
789
- matches_to_apply_.append(item)
870
+ if non_empty_replacements:
871
+ queue, match, update_idx = min(
872
+ non_empty_replacements,
873
+ key=lambda item: (item[1], _next_priority(item[0])),
874
+ )
875
+ priority, updates = queue.popleft()
876
+ updates_to_apply.append(
877
+ _MatchedUpdate(
878
+ priority=priority,
879
+ update=updates[update_idx],
880
+ update_idx=update_idx,
881
+ match=match,
882
+ )
883
+ )
884
791
- matches_to_apply = matches_to_apply_
885
+ updates_to_apply.sort(key=lambda item: (item.match, item.priority))
886
+ for matched_update in updates_to_apply:
887
+ update = matched_update.update
888
+ result[update.modality][update.item_idx] = matched_update.update_idx
889
+ prev_end_idx = matched_update.match.end_idx
890
+ planned_updates.extend(updates_to_apply)
891
+ queues = [queue for queue in queues if queue]
892
793
- return mode, matches_to_apply
893
+ return planned_updates, result
894
895
896
def _all_items_found(
@@ -808,65 +908,37 @@ def _apply_matches(
908
mm_prompt_updates: "MultiModalPromptUpdates",
909
tokenizer: TokenizerLike | None,
910
) -> tuple[list[_S], "MultiModalPromptUpdatesApplyResult"]:
811
- mm_item_counts = {m: len(items) for m, items in mm_prompt_updates.items()}
812
-
911
out_seqs = list[str | list[int]]()
814
- out_result: MultiModalPromptUpdatesApplyResult = {
815
- m: [None] * len(items) for m, items in mm_prompt_updates.items()
816
- }
817
-
818
- # Early exit if no items to find
819
- mm_found_counts = {
820
- m: sum(r is not None for r in res) for m, res in out_result.items()
821
- }
822
- if _all_items_found(mm_item_counts, mm_found_counts):
823
- return [prompt], out_result
912
+ matched_updates, result = _plan_prompt_updates(
913
+ prompt,
914
+ mm_prompt_updates,
915
+ tokenizer,
916
+ )
917
918
prev_end_idx = 0
826
- while True:
827
- mode, matches_to_apply = _find_matches(
828
- prompt,
829
- mm_prompt_updates,
830
- tokenizer,
831
- prev_end_idx=prev_end_idx,
832
- current_result=out_result,
833
- )
834
-
835
- if mode is None:
836
- break # No more matches to find
837
-
838
- for (modality, item_idx), (match, update_idx) in matches_to_apply:
839
- matched_update = mm_prompt_updates[modality][item_idx][update_idx]
840
- matched_content = matched_update.content.full
841
-
842
- if mode == UpdateMode.INSERT:
843
- end_idx_to_insert = match.end_idx
844
- elif mode == UpdateMode.REPLACE:
845
- end_idx_to_insert = match.start_idx
846
- else:
847
- assert_never(mode)
848
-
849
- out_seqs.append(prompt[prev_end_idx:end_idx_to_insert])
850
- out_seqs.append(
851
- _seq2text(tokenizer, matched_content)
852
- if isinstance(prompt, str)
853
- else _seq2tokens(tokenizer, matched_content)
854
- )
855
- out_result[modality][item_idx] = update_idx
856
-
857
- # Exclude overlapping matches
858
- prev_end_idx = match.end_idx
919
+ for matched_update in matched_updates:
920
+ update = matched_update.update
921
+ match = matched_update.match
922
+ matched_content = update.content.full
923
+
924
+ if update.mode == UpdateMode.INSERT:
925
+ end_idx_to_insert = match.end_idx
926
+ elif update.mode == UpdateMode.REPLACE:
927
+ end_idx_to_insert = match.start_idx
928
+ else:
929
+ assert_never(update.mode)
930
860
- # Early exit if all items found
861
- mm_found_counts = {
862
- m: sum(r is not None for r in res) for m, res in out_result.items()
863
- }
864
- if _all_items_found(mm_item_counts, mm_found_counts):
865
- break
931
+ out_seqs.append(prompt[prev_end_idx:end_idx_to_insert])
932
+ out_seqs.append(
933
+ _seq2text(tokenizer, matched_content)
934
+ if isinstance(prompt, str)
935
+ else _seq2tokens(tokenizer, matched_content)
936
+ )
937
+ prev_end_idx = match.end_idx
938
939
out_seqs.append(prompt[prev_end_idx:])
940
869
- return cast(list[_S], out_seqs), out_result
941
+ return cast(list[_S], out_seqs), result
942
943
944
def apply_token_matches(