vlm: parallelize multimodal preprocessing with customized worker num (#31438)
This commit is contained in:
@@ -19,6 +19,7 @@ from sglang.srt.managers.schedule_batch import (
|
||||
MultimodalInputFormat,
|
||||
MultimodalProcessorOutput,
|
||||
)
|
||||
from sglang.srt.multimodal.processors.executor import MultimodalProcessorExecutor
|
||||
from sglang.srt.runtime_context import get_server_args
|
||||
from sglang.srt.utils import (
|
||||
envs,
|
||||
@@ -180,6 +181,9 @@ class MultimodalSpecialTokens:
|
||||
class BaseMultimodalProcessor(ABC):
|
||||
models = []
|
||||
gpu_image_decode = True # Enable GPU decoding by default
|
||||
auto_mm_processor_worker_num = 1
|
||||
auto_mm_io_worker_num = 4
|
||||
supports_mm_processor_concurrency = False
|
||||
|
||||
def __init__(
|
||||
self, hf_config, server_args, _processor, transport_mode, *args, **kwargs
|
||||
@@ -222,9 +226,64 @@ class BaseMultimodalProcessor(ABC):
|
||||
# FIXME: not accurate, model and image specific
|
||||
self.NUM_TOKEN_PER_FRAME = 330
|
||||
|
||||
requested_mm_io_worker_num = self.server_args.mm_io_worker_num
|
||||
env_mm_io_worker_num = os.environ.get("SGLANG_IO_WORKERS")
|
||||
if requested_mm_io_worker_num:
|
||||
self.mm_io_worker_num = requested_mm_io_worker_num
|
||||
io_worker_source = "explicit"
|
||||
elif env_mm_io_worker_num is not None:
|
||||
self.mm_io_worker_num = int(env_mm_io_worker_num)
|
||||
io_worker_source = "environment"
|
||||
else:
|
||||
self.mm_io_worker_num = self.auto_mm_io_worker_num
|
||||
io_worker_source = "auto"
|
||||
self.io_executor = concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=int(os.environ.get("SGLANG_IO_WORKERS", 4))
|
||||
max_workers=self.mm_io_worker_num,
|
||||
thread_name_prefix="sglang-mm-io",
|
||||
)
|
||||
if self.mm_io_worker_num > 4:
|
||||
logger.info(
|
||||
"Multimodal data loading enabled with %d worker threads (%s).",
|
||||
self.mm_io_worker_num,
|
||||
io_worker_source,
|
||||
)
|
||||
skip_mm_pool = kwargs.get("skip_mm_pool", False)
|
||||
requested_mm_processor_worker_num = self.server_args.mm_processor_worker_num
|
||||
self.mm_processor_worker_num = (
|
||||
1
|
||||
if skip_mm_pool
|
||||
else requested_mm_processor_worker_num or self.auto_mm_processor_worker_num
|
||||
)
|
||||
if (
|
||||
self.mm_processor_worker_num > 1
|
||||
and not self.supports_mm_processor_concurrency
|
||||
):
|
||||
logger.warning(
|
||||
"Concurrent multimodal processing is not supported by %s; "
|
||||
"using synchronous processing.",
|
||||
type(self).__name__,
|
||||
)
|
||||
self.mm_processor_worker_num = 1
|
||||
self.mm_processor_executor = None
|
||||
if self.mm_processor_worker_num > 1:
|
||||
try:
|
||||
self.mm_processor_executor = MultimodalProcessorExecutor(
|
||||
self._processor, self.mm_processor_worker_num
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Unable to clone the multimodal processor for concurrent "
|
||||
"workers; falling back to synchronous processing.",
|
||||
exc_info=True,
|
||||
)
|
||||
self.mm_processor_worker_num = 1
|
||||
if self.mm_processor_executor is not None:
|
||||
logger.info(
|
||||
"Multimodal processor concurrency enabled with %d isolated "
|
||||
"worker threads (%s).",
|
||||
self.mm_processor_worker_num,
|
||||
"auto" if requested_mm_processor_worker_num == 0 else "explicit",
|
||||
)
|
||||
self.cpu_executor = concurrent.futures.ProcessPoolExecutor(
|
||||
mp_context=mp.get_context("fork"),
|
||||
max_workers=int(os.environ.get("SGLANG_CPU_WORKERS", os.cpu_count())),
|
||||
@@ -274,8 +333,6 @@ class BaseMultimodalProcessor(ABC):
|
||||
"input_features",
|
||||
]
|
||||
|
||||
skip_mm_pool = kwargs.get("skip_mm_pool", False)
|
||||
|
||||
if self.use_cuda_ipc and not skip_mm_pool:
|
||||
# SGLANG_MM_FEATURE_CACHE_MB is the total pool budget across all
|
||||
# tokenizer workers. Each worker gets an equal share so that adding
|
||||
@@ -420,12 +477,25 @@ class BaseMultimodalProcessor(ABC):
|
||||
video_token_id=getattr(self, "VIDEO_TOKEN_ID", None),
|
||||
)
|
||||
|
||||
def _resolve_processor(self, processor=None):
|
||||
if processor is None:
|
||||
return self._processor, self._tokenizer
|
||||
return processor, processor.tokenizer
|
||||
|
||||
def process_mm_data(
|
||||
self, input_text, images=None, videos=None, audios=None, **kwargs
|
||||
self,
|
||||
input_text,
|
||||
images=None,
|
||||
videos=None,
|
||||
audios=None,
|
||||
processor=None,
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
"""
|
||||
process multimodal data with transformers AutoProcessor
|
||||
"""
|
||||
processor, tokenizer = self._resolve_processor(processor)
|
||||
|
||||
if images:
|
||||
kwargs["images"] = images
|
||||
if self.image_config:
|
||||
@@ -435,7 +505,7 @@ class BaseMultimodalProcessor(ABC):
|
||||
if self.video_config:
|
||||
kwargs.setdefault("videos_kwargs", {}).update(self.video_config)
|
||||
if audios:
|
||||
if self._processor.__class__.__name__ in {
|
||||
if processor.__class__.__name__ in {
|
||||
"Gemma3nProcessor",
|
||||
"Gemma4Processor",
|
||||
"Gemma4UnifiedProcessor",
|
||||
@@ -453,7 +523,6 @@ class BaseMultimodalProcessor(ABC):
|
||||
if self.audio_config:
|
||||
kwargs.setdefault("audio_kwargs", {}).update(self.audio_config)
|
||||
|
||||
processor = self._processor
|
||||
if (
|
||||
hasattr(processor, "image_processor")
|
||||
and isinstance(processor.image_processor, BaseImageProcessor)
|
||||
@@ -487,7 +556,7 @@ class BaseMultimodalProcessor(ABC):
|
||||
|
||||
# Avoid double BOS when the chat template already wrote one.
|
||||
if self._tokenizer_auto_adds_specials and isinstance(input_text, str):
|
||||
bos = getattr(self._tokenizer, "bos_token", None)
|
||||
bos = getattr(tokenizer, "bos_token", None)
|
||||
if bos and input_text.startswith(bos):
|
||||
kwargs.setdefault("add_special_tokens", False)
|
||||
|
||||
@@ -1213,7 +1282,13 @@ class BaseMultimodalProcessor(ABC):
|
||||
return list(items.values())
|
||||
|
||||
def _process_and_collect_mm_items(
|
||||
self, input_text: str, images=None, audios=None, videos=None, **kwargs
|
||||
self,
|
||||
input_text: str,
|
||||
images=None,
|
||||
audios=None,
|
||||
videos=None,
|
||||
processor=None,
|
||||
**kwargs,
|
||||
) -> Tuple[List[MultimodalDataItem], torch.Tensor, dict]:
|
||||
"""
|
||||
Helper method to process multimodal data and create mm_items in one step.
|
||||
@@ -1221,8 +1296,14 @@ class BaseMultimodalProcessor(ABC):
|
||||
Returns:
|
||||
Tuple of (created mm_items, input_ids)
|
||||
"""
|
||||
if processor is not None:
|
||||
kwargs["processor"] = processor
|
||||
ret = self.process_mm_data(
|
||||
input_text=input_text, images=images, audios=audios, videos=videos, **kwargs
|
||||
input_text=input_text,
|
||||
images=images,
|
||||
audios=audios,
|
||||
videos=videos,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
input_ids = ret["input_ids"].flatten()
|
||||
@@ -1321,6 +1402,7 @@ class BaseMultimodalProcessor(ABC):
|
||||
self,
|
||||
base_output: BaseMultiModalProcessorOutput,
|
||||
mm_tokens: MultimodalSpecialTokens,
|
||||
processor=None,
|
||||
**kwargs,
|
||||
) -> Tuple[List[MultimodalDataItem], torch.Tensor, dict]:
|
||||
"""
|
||||
@@ -1330,11 +1412,14 @@ class BaseMultimodalProcessor(ABC):
|
||||
Returns:
|
||||
Tuple of (list of mm_items, input_ids)
|
||||
"""
|
||||
processor_override = processor
|
||||
processor, tokenizer = self._resolve_processor(processor)
|
||||
|
||||
# Collect all items and categorize them
|
||||
all_loaded_data = base_output.organize_results()
|
||||
# Handle text-only case
|
||||
if not all_loaded_data:
|
||||
input_ids = self._tokenizer(
|
||||
input_ids = tokenizer(
|
||||
base_output.input_text,
|
||||
return_tensors="pt",
|
||||
add_special_tokens=True,
|
||||
@@ -1358,6 +1443,8 @@ class BaseMultimodalProcessor(ABC):
|
||||
input_ids = None
|
||||
# Handle raw items (need processing)
|
||||
if raw_images or raw_audios or raw_videos:
|
||||
if processor_override is not None:
|
||||
kwargs["processor"] = processor
|
||||
collected_items, input_ids, ret = self._process_and_collect_mm_items(
|
||||
input_text=base_output.input_text,
|
||||
images=raw_images,
|
||||
@@ -1447,7 +1534,7 @@ class BaseMultimodalProcessor(ABC):
|
||||
break
|
||||
|
||||
if input_ids is None:
|
||||
input_ids = self._tokenizer(
|
||||
input_ids = tokenizer(
|
||||
base_output.input_text,
|
||||
return_tensors="pt",
|
||||
add_special_tokens=True,
|
||||
@@ -1496,3 +1583,20 @@ class BaseMultimodalProcessor(ABC):
|
||||
)
|
||||
|
||||
return all_collected_items, input_ids, ret
|
||||
|
||||
async def process_and_combine_mm_data_async(
|
||||
self,
|
||||
base_output: BaseMultiModalProcessorOutput,
|
||||
mm_tokens: MultimodalSpecialTokens,
|
||||
**kwargs,
|
||||
) -> Tuple[List[MultimodalDataItem], torch.Tensor, dict]:
|
||||
"""Run multimodal preprocessing without blocking the event loop."""
|
||||
if self.mm_processor_executor is None:
|
||||
return self.process_and_combine_mm_data(base_output, mm_tokens, **kwargs)
|
||||
|
||||
return await self.mm_processor_executor.run(
|
||||
self.process_and_combine_mm_data,
|
||||
base_output,
|
||||
mm_tokens,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import copy
|
||||
import threading
|
||||
from typing import Any, Callable, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class _WorkerState(threading.local):
|
||||
def __init__(self):
|
||||
self.processor = None
|
||||
|
||||
|
||||
class MultimodalProcessorExecutor:
|
||||
"""Run processor calls on isolated, thread-local processor clones."""
|
||||
|
||||
def __init__(self, processor: Any, max_workers: int):
|
||||
self._processor = processor
|
||||
self._processor_clones = [copy.deepcopy(processor) for _ in range(max_workers)]
|
||||
self._executor = concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=max_workers,
|
||||
thread_name_prefix="sglang-mm-processor",
|
||||
)
|
||||
self._worker_state = _WorkerState()
|
||||
self._clone_lock = threading.Lock()
|
||||
|
||||
async def run(self, function: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
self._executor, self._run, function, args, kwargs
|
||||
)
|
||||
|
||||
def _run(
|
||||
self,
|
||||
function: Callable[..., T],
|
||||
args: tuple[Any, ...],
|
||||
kwargs: dict[str, Any],
|
||||
) -> T:
|
||||
processor = self._worker_state.processor
|
||||
if processor is None:
|
||||
with self._clone_lock:
|
||||
processor = (
|
||||
self._processor_clones.pop()
|
||||
if self._processor_clones
|
||||
else copy.deepcopy(self._processor)
|
||||
)
|
||||
self._worker_state.processor = processor
|
||||
return function(*args, processor=processor, **kwargs)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._executor.shutdown()
|
||||
@@ -273,6 +273,22 @@ class QwenVLImageProcessor(SGLangBaseProcessor):
|
||||
|
||||
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
|
||||
self.model_type = hf_config.model_type
|
||||
if self.model_type in (
|
||||
"qwen2_vl",
|
||||
"qwen2_5_vl",
|
||||
"qwen3_vl",
|
||||
"qwen3_vl_moe",
|
||||
"qwen3_5",
|
||||
"qwen3_5_moe",
|
||||
"intern_s2_preview",
|
||||
):
|
||||
# Two workers overlap CPU preprocessing without over-fragmenting
|
||||
# burst arrivals into smaller GPU prefill batches. Higher counts can
|
||||
# improve short-output TTFT, but regress long-output throughput on
|
||||
# Blackwell when requests reach the scheduler too far apart.
|
||||
self.auto_mm_processor_worker_num = 2
|
||||
self.auto_mm_io_worker_num = 16
|
||||
self.supports_mm_processor_concurrency = True
|
||||
if hf_config.model_type == "qwen3_omni_moe":
|
||||
hf_config = hf_config.thinker_config
|
||||
|
||||
@@ -711,14 +727,14 @@ class QwenVLImageProcessor(SGLangBaseProcessor):
|
||||
"qwen3_5_moe",
|
||||
"intern_s2_preview",
|
||||
):
|
||||
mm_items, input_ids, ret = self.process_and_combine_mm_data(
|
||||
mm_items, input_ids, ret = await self.process_and_combine_mm_data_async(
|
||||
base_output,
|
||||
self.mm_tokens,
|
||||
video_metadata=video_metadata,
|
||||
do_sample_frames=False,
|
||||
)
|
||||
else:
|
||||
mm_items, input_ids, ret = self.process_and_combine_mm_data(
|
||||
mm_items, input_ids, ret = await self.process_and_combine_mm_data_async(
|
||||
base_output, self.mm_tokens
|
||||
)
|
||||
|
||||
|
||||
@@ -2283,6 +2283,18 @@ class ServerArgs:
|
||||
type_parser=json.loads,
|
||||
),
|
||||
] = None
|
||||
mm_processor_worker_num: A[
|
||||
int,
|
||||
"Number of threads for multimodal processor calls. 0 selects the "
|
||||
"model-specific default. Only processors with isolated-worker support "
|
||||
"can use more than one thread.",
|
||||
] = 0
|
||||
mm_io_worker_num: A[
|
||||
int,
|
||||
"Number of threads for multimodal data loading and decoding. 0 selects "
|
||||
"the model-specific default. SGLANG_IO_WORKERS remains supported as an "
|
||||
"environment override when this argument is 0.",
|
||||
] = 0
|
||||
limit_mm_data_per_request: A[
|
||||
Optional[Union[str, Dict[str, int]]],
|
||||
Arg(
|
||||
@@ -7630,6 +7642,10 @@ class ServerArgs:
|
||||
|
||||
assert self.tokenizer_worker_num > 0, "Tokenizer worker num must >= 1"
|
||||
assert self.detokenizer_worker_num > 0, "Detokenizer worker num must >= 1"
|
||||
assert (
|
||||
self.mm_processor_worker_num >= 0
|
||||
), "Multimodal processor worker num must >= 0"
|
||||
assert self.mm_io_worker_num >= 0, "Multimodal I/O worker num must >= 0"
|
||||
self.validate_buckets_rule(
|
||||
"--prompt-tokens-buckets", self.prompt_tokens_buckets
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user