A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 5, 22:15 UTC
[Bugfix] Offload blocking tokenizer ops to shared thread pool to unblock event loop (#34789)
Signed-off-by: Bvicii <yizhanhuang2002@gmail.com> Co-authored-by: Cyrus Leung <tlleungac@connect.ust.hk>
Bvicii committed
Mar 26, 2026 at 22:17 UTC
999dfc1622d042dffbe4e273056fa4554453c8e7
15 files changed
+195
-28
tests/entrypoints/openai/chat_completion/test_chat_error.py
+1
@@ -55,6 +55,7 @@ class MockModelConfig:
55
skip_tokenizer_init = False
56
is_encoder_decoder: bool = False
57
is_multimodal_model: bool = False
58
+ renderer_num_workers: int = 1
59
60
def get_diff_sampling_param(self):
61
return self.diff_sampling_param or {}
tests/entrypoints/openai/chat_completion/test_serving_chat.py
+1
@@ -536,6 +536,7 @@ class MockModelConfig:
536
skip_tokenizer_init: bool = False
537
is_encoder_decoder: bool = False
538
is_multimodal_model: bool = False
539
+ renderer_num_workers: int = 1
540
541
def get_diff_sampling_param(self):
542
return self.diff_sampling_param or {}
tests/entrypoints/openai/completion/test_completion_error.py
+1
@@ -54,6 +54,7 @@ class MockModelConfig:
54
skip_tokenizer_init = False
55
is_encoder_decoder: bool = False
56
is_multimodal_model: bool = False
57
+ renderer_num_workers: int = 1
58
59
def get_diff_sampling_param(self):
60
return self.diff_sampling_param or {}
tests/entrypoints/openai/completion/test_lora_resolvers.py
+1
@@ -54,6 +54,7 @@ class MockModelConfig:
54
skip_tokenizer_init: bool = False
55
is_encoder_decoder: bool = False
56
is_multimodal_model: bool = False
57
+ renderer_num_workers: int = 1
58
59
def get_diff_sampling_param(self):
60
return self.diff_sampling_param or {}
tests/renderers/test_completions.py
+1
@@ -38,6 +38,7 @@ class MockModelConfig:
38
skip_tokenizer_init: bool = False
39
is_encoder_decoder: bool = False
40
is_multimodal_model: bool = False
41
+ renderer_num_workers: int = 1
42
43
44
@dataclass
tests/renderers/test_mistral.py
+1
@@ -37,6 +37,7 @@ class MockModelConfig:
37
skip_tokenizer_init: bool = False
38
is_encoder_decoder: bool = False
39
is_multimodal_model: bool = False
40
+ renderer_num_workers: int = 1
41
42
43
@dataclass
vllm/config/model.py
+4
@@ -291,6 +291,10 @@ class ModelConfig:
291
definitions"""
292
io_processor_plugin: str | None = None
293
"""IOProcessor plugin name to load at model startup"""
294
+ renderer_num_workers: int = 1
295
+ """Number of worker threads in the renderer thread pool. This pool
296
+ handles async tokenization, chat template rendering, and multimodal
297
+ preprocessing."""
298
299
# Pooler config
300
pooler_config: PoolerConfig | None = None
vllm/engine/arg_utils.py
+6
@@ -508,6 +508,7 @@ class EngineArgs:
508
MultiModalConfig.mm_encoder_attn_backend
509
)
510
io_processor_plugin: str | None = None
511
+ renderer_num_workers: int = 1
512
skip_mm_profiling: bool = MultiModalConfig.skip_mm_profiling
513
video_pruning_rate: float | None = MultiModalConfig.video_pruning_rate
514
mm_tensor_ipc: MMTensorIPC = MultiModalConfig.mm_tensor_ipc
@@ -767,6 +768,10 @@ class EngineArgs:
768
model_group.add_argument(
769
"--io-processor-plugin", **model_kwargs["io_processor_plugin"]
770
)
771
+ model_group.add_argument(
772
+ "--renderer-num-workers",
773
+ **model_kwargs["renderer_num_workers"],
774
+ )
775
776
# Model loading arguments
777
load_kwargs = get_kwargs(LoadConfig)
@@ -1438,6 +1443,7 @@ class EngineArgs:
1443
video_pruning_rate=self.video_pruning_rate,
1444
mm_tensor_ipc=self.mm_tensor_ipc,
1445
io_processor_plugin=self.io_processor_plugin,
1446
+ renderer_num_workers=self.renderer_num_workers,
1447
)
1448
1449
def validate_tensorizer_args(self):
vllm/renderers/base.py
+118
-6
@@ -5,6 +5,7 @@ import copy
5
import time
6
from abc import ABC, abstractmethod
7
from collections.abc import Mapping, Sequence
8
+from concurrent.futures import Executor, ThreadPoolExecutor
9
from functools import cached_property
10
from typing import TYPE_CHECKING, Any, Generic, overload
11
@@ -38,7 +39,10 @@ from vllm.multimodal.processing import BaseMultiModalProcessor
39
from vllm.multimodal.processing import ProcessorInputs as MMProcessorInputs
40
from vllm.multimodal.registry import MultiModalTimingRegistry
41
from vllm.tokenizers import TokenizerLike
41
-from vllm.utils.async_utils import AsyncMicrobatchTokenizer
42
+from vllm.utils.async_utils import (
43
+ AsyncMicrobatchTokenizer,
44
+ make_async,
45
+)
46
from vllm.utils.counter import AtomicCounter
47
from vllm.utils.torch_utils import set_default_torch_num_threads
48
from vllm.v1.metrics.stats import MultiModalCacheStats
@@ -78,11 +82,28 @@ class BaseRenderer(ABC, Generic[_T]):
82
83
self.tokenizer = tokenizer
84
85
+ # Shared thread pool executor for blocking tokenizer and
86
+ # multimodal preprocessing operations. The multimodal processor
87
+ # receives a deep-copied tokenizer (see #36557) so it is safe to
88
+ # run tokenization and MM preprocessing concurrently.
89
+ pool_workers = config.model_config.renderer_num_workers
90
+ self._executor = ThreadPoolExecutor(max_workers=pool_workers)
91
+
92
+ # Multimodal preprocessing is always offloaded to the thread pool
93
+ # to keep the asyncio event loop responsive under concurrent load.
94
+ self._mm_executor: Executor = self._executor
95
+
96
# Lazy initialization since offline LLM doesn't use async
97
self._async_tokenizer: AsyncMicrobatchTokenizer | None = None
98
99
self.mm_processor: BaseMultiModalProcessor | None = None
100
self._mm_cache_stats: MultiModalCacheStats | None = None
101
+ self._clear_mm_cache_async = make_async(
102
+ self.clear_mm_cache, executor=self._executor
103
+ )
104
+ self._process_multimodal_async = make_async(
105
+ self._process_multimodal, executor=self._mm_executor
106
+ )
107
if config.model_config.is_multimodal_model:
108
mm_processor_cache = mm_registry.processor_cache_from_config(config)
109
@@ -119,7 +140,9 @@ class BaseRenderer(ABC, Generic[_T]):
140
141
def get_async_tokenizer(self) -> AsyncMicrobatchTokenizer:
142
if self._async_tokenizer is None:
122
- self._async_tokenizer = AsyncMicrobatchTokenizer(self.get_tokenizer())
143
+ self._async_tokenizer = AsyncMicrobatchTokenizer(
144
+ self.get_tokenizer(), executor=self._executor
145
+ )
146
147
return self._async_tokenizer
148
@@ -211,11 +234,24 @@ class BaseRenderer(ABC, Generic[_T]):
234
finally:
235
self.clear_mm_cache()
236
237
+ async def clear_mm_cache_async(self) -> None:
238
+ """Serialize clear_mm_cache through the shared executor to avoid
239
+ races with concurrent process_inputs on the mm_processor_cache."""
240
+ await self._clear_mm_cache_async()
241
+
242
def shutdown(self) -> None:
243
mm_processor_cache = self.mm_processor_cache
244
if mm_processor_cache is not None:
245
mm_processor_cache.close()
246
247
+ if executor := getattr(self, "_executor", None):
248
+ executor.shutdown(wait=False)
249
+
250
+ if (
251
+ mm_executor := getattr(self, "_mm_executor", None)
252
+ ) is not None and mm_executor is not executor:
253
+ mm_executor.shutdown(wait=False)
254
+
255
def get_bos_token_id(self) -> int | None:
256
if self.tokenizer is None:
257
logger.warning_once(
@@ -621,6 +657,9 @@ class BaseRenderer(ABC, Generic[_T]):
657
self,
658
prompt: TokensPrompt,
659
) -> TokensInput | MultiModalInput:
660
+ """Process token inputs, with multimodal preprocessing offloaded
661
+ to the shared thread pool in the async variant.
662
+ """
663
prompt_token_ids = prompt["prompt_token_ids"]
664
665
engine_input: TokensInput | MultiModalInput
@@ -670,12 +709,46 @@ class BaseRenderer(ABC, Generic[_T]):
709
cache_salt=prompt.get("cache_salt"),
710
)
711
712
+ async def _process_tokens_async(
713
+ self,
714
+ prompt: TokensPrompt,
715
+ ) -> TokensInput | MultiModalInput:
716
+ prompt_token_ids = prompt["prompt_token_ids"]
717
+
718
+ engine_input: TokensInput | MultiModalInput
719
+ if multi_modal_data := prompt.get("multi_modal_data"):
720
+ engine_input = await self._process_multimodal_async(
721
+ prompt_token_ids,
722
+ multi_modal_data,
723
+ mm_processor_kwargs=prompt.get("mm_processor_kwargs"),
724
+ tokenization_kwargs=None,
725
+ mm_uuids=prompt.get("multi_modal_uuids"),
726
+ )
727
+ else:
728
+ engine_input = tokens_input(prompt_token_ids)
729
+
730
+ if prompt_text := prompt.get("prompt"):
731
+ engine_input["prompt"] = prompt_text
732
+ if cache_salt := prompt.get("cache_salt"):
733
+ engine_input["cache_salt"] = cache_salt
734
+
735
+ return engine_input
736
+
737
def _process_singleton(self, prompt: SingletonTokPrompt) -> SingletonInput:
738
if "prompt_embeds" in prompt:
739
return self._process_embeds(prompt) # type: ignore[arg-type]
740
741
return self._process_tokens(prompt) # type: ignore[arg-type]
742
743
+ async def _process_singleton_async(
744
+ self,
745
+ prompt: SingletonTokPrompt,
746
+ ) -> SingletonInput:
747
+ if "prompt_embeds" in prompt:
748
+ return self._process_embeds(prompt) # type: ignore[arg-type]
749
+
750
+ return await self._process_tokens_async(prompt) # type: ignore[arg-type]
751
+
752
def _process_enc_dec(
753
self,
754
prompt: EncoderDecoderTokPrompt,
@@ -699,6 +772,28 @@ class BaseRenderer(ABC, Generic[_T]):
772
skip_decoder_start_token=skip_decoder_start_token,
773
)
774
775
+ async def _process_enc_dec_async(
776
+ self,
777
+ prompt: EncoderDecoderTokPrompt,
778
+ ) -> EncoderDecoderInput:
779
+ enc_prompt = prompt["encoder_prompt"]
780
+ dec_prompt = prompt["decoder_prompt"]
781
+
782
+ encoder_input, decoder_input = await asyncio.gather(
783
+ self._process_singleton_async(enc_prompt),
784
+ (
785
+ asyncio.sleep(0)
786
+ if dec_prompt is None
787
+ else self._process_singleton_async(dec_prompt)
788
+ ),
789
+ )
790
+
791
+ return build_enc_dec_input(
792
+ encoder_input=encoder_input,
793
+ decoder_input=decoder_input,
794
+ decoder_start_token_id=self.get_dec_start_token_id(),
795
+ )
796
+
797
def process_for_engine(self, prompt: TokPrompt, arrival_time: float) -> EngineInput:
798
engine_input: EngineInput
799
if "encoder_prompt" in prompt:
@@ -710,6 +805,21 @@ class BaseRenderer(ABC, Generic[_T]):
805
806
return engine_input
807
808
+ async def process_for_engine_async(
809
+ self, prompt: TokPrompt, arrival_time: float
810
+ ) -> EngineInput:
811
+ engine_input: EngineInput
812
+ if "encoder_prompt" in prompt:
813
+ engine_input = await self._process_enc_dec_async(
814
+ prompt # type: ignore[arg-type]
815
+ )
816
+ else:
817
+ engine_input = await self._process_singleton_async(prompt)
818
+
819
+ engine_input["arrival_time"] = arrival_time
820
+
821
+ return engine_input
822
+
823
# Top-level methods
824
def render_cmpl(
825
self,
@@ -747,7 +857,9 @@ class BaseRenderer(ABC, Generic[_T]):
857
858
self._apply_prompt_extras(tok_prompts, prompt_extras)
859
750
- return [self.process_for_engine(prompt, arrival_time) for prompt in tok_prompts]
860
+ return await asyncio.gather(
861
+ *(self.process_for_engine_async(p, arrival_time) for p in tok_prompts)
862
+ )
863
864
def render_chat(
865
self,
@@ -811,8 +923,8 @@ class BaseRenderer(ABC, Generic[_T]):
923
924
self._apply_prompt_extras(tok_prompts, prompt_extras)
925
814
- eng_prompts = [
815
- self.process_for_engine(prompt, arrival_time) for prompt in tok_prompts
816
- ]
926
+ eng_prompts = await asyncio.gather(
927
+ *(self.process_for_engine_async(p, arrival_time) for p in tok_prompts)
928
+ )
929
930
return out_conversations, eng_prompts
vllm/renderers/deepseek_v32.py
+18
-4
@@ -1,6 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+from vllm.config import VllmConfig
5
from vllm.entrypoints.chat_utils import (
6
ChatCompletionMessageParam,
7
ConversationMessage,
@@ -9,6 +10,7 @@ from vllm.entrypoints.chat_utils import (
10
)
11
from vllm.logger import init_logger
12
from vllm.tokenizers.deepseek_v32 import DeepseekV32Tokenizer
13
+from vllm.utils.async_utils import make_async
14
15
from .base import BaseRenderer
16
from .inputs import DictPrompt
@@ -19,12 +21,25 @@ logger = init_logger(__name__)
21
22
23
class DeepseekV32Renderer(BaseRenderer[DeepseekV32Tokenizer]):
24
+ def __init__(
25
+ self,
26
+ config: VllmConfig,
27
+ tokenizer: DeepseekV32Tokenizer | None,
28
+ ) -> None:
29
+ super().__init__(config, tokenizer)
30
+
31
+ self._apply_chat_template_async = make_async(
32
+ self._apply_chat_template, executor=self._executor
33
+ )
34
+
35
+ def _apply_chat_template(self, *args, **kwargs):
36
+ return self.get_tokenizer().apply_chat_template(*args, **kwargs)
37
+
38
def render_messages(
39
self,
40
messages: list[ChatCompletionMessageParam],
41
params: ChatParams,
42
) -> tuple[list[ConversationMessage], DictPrompt]:
27
- tokenizer = self.get_tokenizer()
43
conversation, mm_data, mm_uuids = parse_chat_messages(
44
messages,
45
self.model_config,
@@ -33,7 +48,7 @@ class DeepseekV32Renderer(BaseRenderer[DeepseekV32Tokenizer]):
48
mm_processor_kwargs=params.mm_processor_kwargs,
49
)
50
36
- prompt_raw = tokenizer.apply_chat_template(
51
+ prompt_raw = self._apply_chat_template(
52
conversation=conversation,
53
messages=messages,
54
**params.get_apply_chat_template_kwargs(),
@@ -52,7 +67,6 @@ class DeepseekV32Renderer(BaseRenderer[DeepseekV32Tokenizer]):
67
messages: list[ChatCompletionMessageParam],
68
params: ChatParams,
69
) -> tuple[list[ConversationMessage], DictPrompt]:
55
- tokenizer = self.get_tokenizer()
70
conversation, mm_data, mm_uuids = await parse_chat_messages_async(
71
messages,
72
self.model_config,
@@ -61,7 +75,7 @@ class DeepseekV32Renderer(BaseRenderer[DeepseekV32Tokenizer]):
75
mm_processor_kwargs=params.mm_processor_kwargs,
76
)
77
64
- prompt_raw = tokenizer.apply_chat_template(
78
+ prompt_raw = await self._apply_chat_template_async(
79
conversation=conversation,
80
messages=messages,
81
**params.get_apply_chat_template_kwargs(),
vllm/renderers/grok2.py
+18
-4
@@ -1,6 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+from vllm.config import VllmConfig
5
from vllm.entrypoints.chat_utils import (
6
ChatCompletionMessageParam,
7
ConversationMessage,
@@ -9,6 +10,7 @@ from vllm.entrypoints.chat_utils import (
10
)
11
from vllm.logger import init_logger
12
from vllm.tokenizers.grok2 import Grok2Tokenizer
13
+from vllm.utils.async_utils import make_async
14
15
from .base import BaseRenderer
16
from .inputs import DictPrompt
@@ -19,12 +21,25 @@ logger = init_logger(__name__)
21
22
23
class Grok2Renderer(BaseRenderer[Grok2Tokenizer]):
24
+ def __init__(
25
+ self,
26
+ config: VllmConfig,
27
+ tokenizer: Grok2Tokenizer | None,
28
+ ) -> None:
29
+ super().__init__(config, tokenizer)
30
+
31
+ self._apply_chat_template_async = make_async(
32
+ self._apply_chat_template, executor=self._executor
33
+ )
34
+
35
+ def _apply_chat_template(self, *args, **kwargs):
36
+ return self.get_tokenizer().apply_chat_template(*args, **kwargs)
37
+
38
def render_messages(
39
self,
40
messages: list[ChatCompletionMessageParam],
41
params: ChatParams,
42
) -> tuple[list[ConversationMessage], DictPrompt]:
27
- tokenizer = self.get_tokenizer()
43
conversation, mm_data, mm_uuids = parse_chat_messages(
44
messages,
45
self.model_config,
@@ -33,7 +48,7 @@ class Grok2Renderer(BaseRenderer[Grok2Tokenizer]):
48
mm_processor_kwargs=params.mm_processor_kwargs,
49
)
50
36
- prompt_raw = tokenizer.apply_chat_template(
51
+ prompt_raw = self._apply_chat_template(
52
conversation=conversation,
53
messages=messages,
54
**params.get_apply_chat_template_kwargs(),
@@ -52,7 +67,6 @@ class Grok2Renderer(BaseRenderer[Grok2Tokenizer]):
67
messages: list[ChatCompletionMessageParam],
68
params: ChatParams,
69
) -> tuple[list[ConversationMessage], DictPrompt]:
55
- tokenizer = self.get_tokenizer()
70
conversation, mm_data, mm_uuids = await parse_chat_messages_async(
71
messages,
72
self.model_config,
@@ -61,7 +75,7 @@ class Grok2Renderer(BaseRenderer[Grok2Tokenizer]):
75
mm_processor_kwargs=params.mm_processor_kwargs,
76
)
77
64
- prompt_raw = tokenizer.apply_chat_template(
78
+ prompt_raw = await self._apply_chat_template_async(
79
conversation=conversation,
80
messages=messages,
81
**params.get_apply_chat_template_kwargs(),
vllm/renderers/hf.py
+20
-9
@@ -30,6 +30,7 @@ from vllm.logger import init_logger
30
from vllm.tokenizers.hf import HfTokenizer
31
from vllm.transformers_utils.chat_templates import get_chat_template_fallback_path
32
from vllm.transformers_utils.processor import cached_get_processor
33
+from vllm.utils.async_utils import make_async
34
from vllm.utils.func_utils import supports_kw
35
36
from .base import BaseRenderer
@@ -614,6 +615,10 @@ class HfRenderer(BaseRenderer[HfTokenizer]):
615
config.model_config.hf_config, "use_unified_vision_chunk", False
616
)
617
618
+ self._apply_chat_template_async = make_async(
619
+ safe_apply_chat_template, executor=self._executor
620
+ )
621
+
622
def render_messages(
623
self,
624
messages: list[ChatCompletionMessageParam],
@@ -656,10 +661,13 @@ class HfRenderer(BaseRenderer[HfTokenizer]):
661
video_placeholder = getattr(
662
model_config.hf_config, "video_placeholder", None
663
)
659
- prompt_raw = replace_vision_chunk_video_placeholder(
660
- prompt_raw,
661
- mm_data,
662
- video_placeholder,
664
+ prompt_raw = cast(
665
+ list[int],
666
+ replace_vision_chunk_video_placeholder(
667
+ prompt_raw,
668
+ mm_data,
669
+ video_placeholder,
670
+ ),
671
)
672
673
prompt = parse_dec_only_prompt(prompt_raw)
@@ -692,7 +700,7 @@ class HfRenderer(BaseRenderer[HfTokenizer]):
700
mm_processor_kwargs=params.mm_processor_kwargs,
701
)
702
695
- prompt_raw = safe_apply_chat_template(
703
+ prompt_raw = await self._apply_chat_template_async(
704
model_config,
705
tokenizer,
706
conversation,
@@ -710,10 +718,13 @@ class HfRenderer(BaseRenderer[HfTokenizer]):
718
video_placeholder = getattr(
719
model_config.hf_config, "video_placeholder", None
720
)
713
- prompt_raw = replace_vision_chunk_video_placeholder(
714
- prompt_raw,
715
- mm_data,
716
- video_placeholder,
721
+ prompt_raw = cast(
722
+ list[int],
723
+ replace_vision_chunk_video_placeholder(
724
+ prompt_raw,
725
+ mm_data,
726
+ video_placeholder,
727
+ ),
728
)
729
730
prompt = parse_dec_only_prompt(prompt_raw)
vllm/renderers/mistral.py
+1
-3
@@ -1,6 +1,5 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-from concurrent.futures import ThreadPoolExecutor
3
4
from vllm.config import VllmConfig
5
from vllm.entrypoints.chat_utils import (
@@ -56,9 +55,8 @@ class MistralRenderer(BaseRenderer[MistralTokenizer]):
55
) -> None:
56
super().__init__(config, tokenizer)
57
59
- self._apply_chat_template_executor = ThreadPoolExecutor(max_workers=1)
58
self._apply_chat_template_async = make_async(
61
- safe_apply_chat_template, executor=self._apply_chat_template_executor
59
+ safe_apply_chat_template, executor=self._executor
60
)
61
62
def render_messages(
vllm/utils/async_utils.py
+3
-1
@@ -34,6 +34,7 @@ class AsyncMicrobatchTokenizer:
34
tokenizer,
35
max_batch_size: int = 32,
36
batch_wait_timeout_s: float = 0.002,
37
+ executor: ThreadPoolExecutor | None = None,
38
) -> None:
39
self.tokenizer = tokenizer
40
self.max_batch_size = max_batch_size
@@ -47,7 +48,8 @@ class AsyncMicrobatchTokenizer:
48
self._batcher_tasks: list[Task] = []
49
50
# Single-thread executor for blocking tokenizer calls.
50
- self._executor = ThreadPoolExecutor(max_workers=1)
51
+ # Accept an external executor to serialize with other tokenizer users.
52
+ self._executor = executor or ThreadPoolExecutor(max_workers=1)
53
54
# === Public async API ===
55
async def __call__(self, prompt, **kwargs) -> BatchEncoding:
vllm/v1/engine/async_llm.py
+1
-1
@@ -889,7 +889,7 @@ class AsyncLLM(EngineClient):
889
await asyncio.gather(*coros)
890
891
async def reset_mm_cache(self) -> None:
892
- self.renderer.clear_mm_cache()
892
+ await self.renderer.clear_mm_cache_async()
893
await self.engine_core.reset_mm_cache_async()
894
895
async def reset_prefix_cache(