[MM] Cache common token sequences (#53560)

Signed-off-by: DarkLight1337 <tlleungac@connect.ust.hk> Signed-off-by: Cyrus Leung <tlleungac@connect.ust.hk>

Cyrus Leung committed Aug 24, 2026 at 22:48 UTC ecfa7bb37316a3c1dab345fea4178d81f63b1ce4
14 files changed +40 -23
tests/multimodal/test_processing.py
+2 -6
@@ -1082,7 +1082,7 @@ class _FakeTokenizer:
1082 _MERGES = {"food": (1000,), "foo": (101, 111, 111)}
1083 _INVERSE = {ids: text for text, ids in _MERGES.items()}
1084
1085 - def encode(self, text: str, add_special_tokens: bool = True) -> list[int]:
1085 + def encode(self, text: str, **kwargs) -> list[int]:
1086 token_ids = list[int]()
1087 pos = 0
1088 while pos < len(text):
@@ -1097,11 +1097,7 @@ class _FakeTokenizer:
1097 pos += 1
1098 return token_ids
1099
1100 - def decode(
1101 - self,
1102 - token_ids: list[int],
1103 - skip_special_tokens: bool = False,
1104 - ) -> str:
1100 + def decode(self, token_ids: list[int], **kwargs) -> str:
1101 chars = list[str]()
1102 pos = 0
1103 while pos < len(token_ids):
vllm/model_executor/models/llava_onevision2.py
+2 -1
@@ -98,6 +98,7 @@ from vllm.multimodal.processing import (
98 PromptReplacement,
99 PromptUpdate,
100 PromptUpdateDetails,
101 + cached_encode,
102 )
103 from vllm.multimodal.processing.dummy_inputs import BaseDummyInputsBuilder
104 from vllm.multimodal.video import (
@@ -1894,7 +1895,7 @@ class LlavaOnevision2MultiModalProcessor(
1895 video_pad_id = vocab["<|video_pad|>"]
1896 vision_start_id = vocab["<|vision_start|>"]
1897 vision_end_id = vocab["<|vision_end|>"]
1897 - newline_ids = tokenizer.encode("\n", add_special_tokens=False)
1898 + newline_ids = cached_encode(tokenizer, "\n", add_special_tokens=False)
1899 merge_length = image_processor.merge_size**2
1900 decimals = int(hf_processor_mm_kwargs.get("timestamp_decimals", 1))
1901
vllm/model_executor/models/minicpmv.py
+1 -1
@@ -1050,7 +1050,7 @@ class MiniCPMVMultiModalProcessor(BaseMultiModalProcessor[_I]):
1050 tokenizer = self.info.get_tokenizer()
1051 for modality, pattern in placeholders:
1052 sub_pattern = tokenizer.decode(
1053 - tokenizer.encode(pattern, add_special_tokens=False)
1053 + cached_encode(tokenizer, pattern, add_special_tokens=False)
1054 )
1055 if sub_pattern != pattern:
1056 additional_placeholders.append((modality, sub_pattern))
vllm/model_executor/models/minicpmv4_6.py
+1 -1
@@ -328,7 +328,7 @@ class MiniCPMV4_6MultiModalProcessor(MiniCPMVMultiModalProcessor):
328 additional_placeholders = []
329 for modality, pattern in placeholders:
330 sub_pattern = tokenizer.decode(
331 - tokenizer.encode(pattern, add_special_tokens=False)
331 + cached_encode(tokenizer, pattern, add_special_tokens=False)
332 )
333 if sub_pattern != pattern:
334 additional_placeholders.append((modality, sub_pattern))
vllm/model_executor/models/moondream3.py
+3 -1
@@ -57,6 +57,7 @@ from vllm.multimodal.processing import (
57 PromptReplacement,
58 PromptUpdate,
59 PromptUpdateDetails,
60 + cached_encode,
61 )
62 from vllm.sequence import IntermediateTensors
63 from vllm.transformers_utils.configs.moondream3 import (
@@ -945,7 +946,8 @@ class Moondream3MultiModalProcessor(BaseMultiModalProcessor[Moondream3Processing
946 @cached_property
947 def bos_image_placeholder_tokens(self) -> list[int]:
948 tokenizer = self.info.get_tokenizer()
948 - token_ids = tokenizer.encode(
949 + token_ids = cached_encode(
950 + tokenizer,
951 self.bos_image_placeholder,
952 add_special_tokens=False,
953 )
vllm/model_executor/models/moss_audio.py
+7 -2
@@ -53,6 +53,7 @@ from vllm.multimodal.processing import (
53 PromptReplacement,
54 PromptUpdate,
55 PromptUpdateDetails,
56 + cached_encode,
57 )
58 from vllm.sequence import IntermediateTensors
59 from vllm.transformers_utils.repo_utils import get_hf_file_to_dict
@@ -1376,6 +1377,8 @@ class MossAudioMultiModalProcessor(BaseMultiModalProcessor[MossAudioProcessingIn
1377 out_mm_kwargs: MultiModalKwargsItems,
1378 ) -> Sequence[PromptUpdate]:
1379 processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
1380 + tokenizer = processor.tokenizer
1381 +
1382 out_mm_data = out_mm_kwargs.get_data()
1383 audio_data_seqlens = out_mm_data.get("audio_data_seqlens")
1384 if audio_data_seqlens is None:
@@ -1431,11 +1434,13 @@ class MossAudioMultiModalProcessor(BaseMultiModalProcessor[MossAudioProcessingIn
1434 )
1435 ]
1436 for suffix in ("", "\n"):
1434 - tokenizer_target = processor.tokenizer.encode(
1437 + tokenizer_target = cached_encode(
1438 + tokenizer,
1439 MOSS_AUDIO_PLACEHOLDER + suffix,
1440 add_special_tokens=False,
1441 )
1438 - suffix_token_ids = processor.tokenizer.encode(
1442 + suffix_token_ids = cached_encode(
1443 + tokenizer,
1444 suffix,
1445 add_special_tokens=False,
1446 )
vllm/model_executor/models/moss_transcribe_diarize.py
+4 -2
@@ -449,10 +449,12 @@ class MossTranscribeDiarizeDummyInputsBuilder(
449 dummy_mm_items = self.info.parse_mm_data(dummy_mm_data)
450 num_audios = mm_counts.get("audio", 0)
451 tokenizer = self.info.get_tokenizer()
452 - prompt = tokenizer.encode(
452 + prompt = cached_encode(
453 + tokenizer,
454 AUDIO_PLACEHOLDER * num_audios,
455 add_special_tokens=False,
455 - ) or tokenizer.encode(
456 + ) or cached_encode(
457 + tokenizer,
458 "\n",
459 add_special_tokens=False,
460 )
vllm/model_executor/models/paligemma.py
+3 -2
@@ -216,8 +216,9 @@ class PaliGemmaMultiModalProcessor(BaseMultiModalProcessor[PaliGemmaProcessingIn
216 prompt_token_ids = mm_inputs["prompt_token_ids"]
217
218 tokenizer = self.info.get_tokenizer()
219 - newline_prompt = "\n"
220 - newline_token_id = tokenizer.encode(newline_prompt)[-1] # 108
219 + vocab = tokenizer.get_vocab()
220 + newline_token_id = vocab["\n"]
221 +
222 # Force to add newline at the end of prompt for paligemma's format
223 # This step can NOT be replacemented by current PromptUpdate methods
224 if len(prompt_token_ids) and prompt_token_ids[-1] != newline_token_id:
vllm/model_executor/models/phi3v.py
+2 -1
@@ -535,7 +535,8 @@ class Phi3VMultiModalProcessor(BaseMultiModalProcessor[Phi3VProcessingInfo]):
535
536 # Keep the behavior in line with HF processor
537 if len(mm_prompt_updates) and (
538 - token_ids[:2] == tokenizer.encode("<s> <|image|>", add_special_tokens=False)
538 + token_ids[:2]
539 + == cached_encode(tokenizer, "<s> <|image|>", add_special_tokens=False)
540 ):
541 token_ids = [token_ids[0], *token_ids[2:]]
542 placeholders = {
vllm/model_executor/models/qwen3_asr.py
+2 -1
@@ -81,6 +81,7 @@ from vllm.multimodal.processing import (
81 BaseProcessingInfo,
82 PromptReplacement,
83 PromptUpdate,
84 + cached_encode,
85 )
86 from vllm.sequence import IntermediateTensors
87 from vllm.tokenizers import cached_tokenizer_from_config
@@ -691,7 +692,7 @@ class Qwen3ASRForConditionalGeneration(
692 full_lang_name = cls.supported_languages.get(lang_code, lang_code)
693 prompt += f"language {full_lang_name}{_ASR_TEXT_TAG}"
694
694 - prompt_token_ids = tokenizer.encode(prompt)
695 + prompt_token_ids = cached_encode(tokenizer, prompt)
696
697 return TokensPrompt(
698 prompt_token_ids=prompt_token_ids,
vllm/model_executor/models/qwen3_asr_realtime.py
+3 -2
@@ -39,10 +39,11 @@ from vllm.multimodal import MULTIMODAL_REGISTRY
39 from vllm.multimodal.cache import _I, BaseMultiModalProcessorCache
40 from vllm.multimodal.inputs import MultiModalKwargsOptionalItems
41 from vllm.multimodal.parse import MultiModalDataItems
42 -from vllm.multimodal.processing import BaseDummyInputsBuilder
42 from vllm.multimodal.processing.processor import (
43 + BaseDummyInputsBuilder,
44 MultiModalPromptUpdates,
45 PlaceholderFeaturesInfo,
46 + cached_encode,
47 )
48 from vllm.tokenizers import cached_tokenizer_from_config
49 from vllm.transformers_utils.processor import cached_processor_from_config
@@ -205,7 +206,7 @@ class Qwen3ASRRealtimeGeneration(Qwen3ASRForConditionalGeneration, SupportsRealt
206 f"<|im_start|>user\n{audio_placeholder}<|im_end|>\n<|im_start|>assistant\n"
207 )
208
208 - prompt_token_ids = tokenizer.encode(prompt_template)
209 + prompt_token_ids = cached_encode(tokenizer, prompt_template)
210
211 async for audio_chunk in audio_stream:
212 buffer.write_audio(audio_chunk)
vllm/model_executor/models/transformers/multimodal.py
+1 -1
@@ -841,7 +841,7 @@ class OffsetsMultiModalProcessor(_MultiModalProcessorBase):
841 replacements = defaultdict[str, list[list[int]]](list)
842 for entry in offsets[0]:
843 replacements[entry["type"]].append(
844 - tokenizer.encode(entry["replacement"], add_special_tokens=False)
844 + cached_encode(tokenizer, entry["replacement"], add_special_tokens=False)
845 )
846
847 for modality, seqs in replacements.items():
vllm/multimodal/processing/dummy_inputs.py
+3 -1
@@ -91,7 +91,9 @@ class BaseDummyInputsBuilder(ABC, Generic[_I]):
91 # prompt tokens.
92 dummy_prompt = []
93 else:
94 - dummy_prompt = tokenizer.encode(dummy_text, truncation=False)
94 + from .processor import cached_encode
95 +
96 + dummy_prompt = cached_encode(tokenizer, dummy_text, truncation=False)
97
98 return ProcessorInputs(
99 prompt=dummy_prompt,
vllm/multimodal/processing/processor.py
+6 -1
@@ -57,10 +57,15 @@ def cached_encode(
57 tokenizer: TokenizerLike,
58 text: str,
59 *,
60 + truncation: bool | None = None,
61 add_special_tokens: bool = True,
62 ) -> list[int]:
63 """Encode text while caching repeated tokenizer calls."""
63 - return tokenizer.encode(text, add_special_tokens=add_special_tokens)
64 + return tokenizer.encode(
65 + text,
66 + truncation=truncation,
67 + add_special_tokens=add_special_tokens,
68 + )
69
70
71 class _GetMatchIndex(Protocol):