[Perf] Optimize Qwen3-VL unique-image serving on H100 (#36411)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -41,8 +41,12 @@ class Memory(msgspec.Struct):
|
||||
"for what each policy optimizes for."
|
||||
),
|
||||
choices=RADIX_EVICTION_POLICY_CHOICES,
|
||||
resolvable=True,
|
||||
),
|
||||
] = "lru"
|
||||
# The value alone cannot distinguish the default from an explicit LRU
|
||||
# choice, which model-specific defaults must preserve.
|
||||
_radix_eviction_policy_explicitly_set: A[bool, Arg(no_cli=True)] = False
|
||||
radix_eviction_policy_config: A[
|
||||
Optional[Dict[str, Any]],
|
||||
Arg(
|
||||
|
||||
@@ -99,10 +99,15 @@ class Mm(msgspec.Struct):
|
||||
] = 64
|
||||
mm_preprocess_cache_size_mb: A[
|
||||
Optional[int],
|
||||
"CPU memory budget for content-addressed multimodal preprocessing "
|
||||
"artifacts. Unset selects a model-specific default (256 MiB for "
|
||||
"Kimi-K3); 0 disables the cache. The budget is divided across "
|
||||
"tokenizer workers and does not reserve GPU memory.",
|
||||
Arg(
|
||||
help=(
|
||||
"CPU memory budget for content-addressed multimodal preprocessing "
|
||||
"artifacts. Unset selects a model-specific default (256 MiB for "
|
||||
"Kimi-K3); 0 disables the cache. The budget is divided across "
|
||||
"tokenizer workers and does not reserve GPU memory."
|
||||
),
|
||||
resolvable=True,
|
||||
),
|
||||
] = None
|
||||
trust_mm_content_hashes: A[
|
||||
bool,
|
||||
@@ -139,13 +144,18 @@ class Mm(msgspec.Struct):
|
||||
] = False
|
||||
mm_feature_transport: A[
|
||||
Optional[Literal["cpu", "cuda_ipc", "cuda_vmm"]],
|
||||
"Transport multimodal features through CPU memory, a bounded CUDA IPC "
|
||||
"pool, or a bounded CUDA VMM pool. "
|
||||
"Unset uses cpu except for validated multi-node GB200/GB300 MNNVL models, "
|
||||
"which use cuda_vmm when an IMEX channel is available. Select cuda_ipc "
|
||||
"explicitly for single-node GPU transport. GPU transports reserve "
|
||||
"SGLANG_MM_FEATURE_CACHE_MB (default 1024 MiB) on the base GPU and fall "
|
||||
"back to CPU transport when the pool is full.",
|
||||
Arg(
|
||||
help=(
|
||||
"Transport multimodal features through CPU memory, a bounded CUDA IPC "
|
||||
"pool, or a bounded CUDA VMM pool. "
|
||||
"Unset uses cpu except for validated multi-node GB200/GB300 MNNVL models, "
|
||||
"which use cuda_vmm when an IMEX channel is available. Select cuda_ipc "
|
||||
"explicitly for single-node GPU transport. GPU transports reserve "
|
||||
"SGLANG_MM_FEATURE_CACHE_MB (default 1024 MiB) on the base GPU and fall "
|
||||
"back to CPU transport when the pool is full."
|
||||
),
|
||||
resolvable=True,
|
||||
),
|
||||
] = None
|
||||
keep_mm_feature_on_device: A[
|
||||
bool,
|
||||
|
||||
@@ -61,9 +61,12 @@ class Schedule(msgspec.Struct):
|
||||
"The maximum number of tokens in a chunk for the chunked prefill. Setting this to -1 means disabling chunked prefill.",
|
||||
] = None
|
||||
prefill_decode_interval: A[
|
||||
int,
|
||||
"The number of decode rounds to run after a prefill batch before scheduling the next prefill. In data-parallel attention mode, the interval is synchronized across all DP ranks. Set to 0 to disable.",
|
||||
] = 0
|
||||
Optional[int],
|
||||
Arg(
|
||||
help="The number of decode rounds to run after a prefill batch before scheduling the next prefill. By default, this is disabled except for profiled Qwen3-VL serving configurations on Hopper. In data-parallel attention mode, the interval is synchronized across all DP ranks. Set to 0 to disable.",
|
||||
resolvable=True,
|
||||
),
|
||||
] = None
|
||||
enable_dynamic_chunking: A[
|
||||
bool,
|
||||
"Enable dynamic chunk size adjustment for pipeline parallelism. When enabled, chunk sizes are dynamically calculated based on fitted function to maintain consistent execution time across chunks.",
|
||||
|
||||
@@ -170,10 +170,17 @@ def handle_gpu_memory_settings(server_args: Any, gpu_mem):
|
||||
if decode_cuda_graph_config.max_bs is None:
|
||||
decode_cuda_graph_config.max_bs = 160
|
||||
|
||||
from sglang.srt.arg_groups.model_overrides.qwen3_vl import (
|
||||
expand_multimodal_decode_graph_to_running_limit,
|
||||
)
|
||||
|
||||
expand_multimodal_decode_graph_to_running_limit(
|
||||
server_args, decode_cuda_graph_config, gpu_mem
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CUDA graph batch-size materialization
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
if cfg.device != "cpu":
|
||||
if decode_cuda_graph_config.bs is None:
|
||||
decode_cuda_graph_config.bs = generate_decode_cuda_graph_batch_sizes(
|
||||
|
||||
@@ -1,20 +1,69 @@
|
||||
"""Config-time override declarations for qwen3_vl.
|
||||
|
||||
Architectures: Qwen3VLForConditionalGeneration.
|
||||
Architectures: Qwen3VLForConditionalGeneration, Qwen3VLMoeForConditionalGeneration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
|
||||
from sglang.srt.arg_groups.model_override_base import (
|
||||
_register_for,
|
||||
model_config_of,
|
||||
resolving_view,
|
||||
)
|
||||
from sglang.srt.environ import envs
|
||||
from sglang.srt.runtime_context import get_platform
|
||||
from sglang.srt.utils.common import get_device_memory_capacity, is_sm90_supported
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HOPPER_QWEN3_VL_TYPES = {"qwen3_vl", "qwen3_vl_moe"}
|
||||
|
||||
|
||||
def large_hopper_qwen3_vl_model_type(server_args: Any, gpu_mem=None) -> Optional[str]:
|
||||
"""Return the HF model_type on large Hopper Qwen3-VL, else None."""
|
||||
cfg = resolving_view(server_args)
|
||||
if gpu_mem is None:
|
||||
gpu_mem = get_device_memory_capacity(cfg.device)
|
||||
if not is_sm90_supported() or gpu_mem is None or gpu_mem < 60 * 1024:
|
||||
return None
|
||||
model_config = model_config_of(server_args)
|
||||
model_type = getattr(model_config.hf_config, "model_type", "")
|
||||
if model_config.is_multimodal and model_type in _HOPPER_QWEN3_VL_TYPES:
|
||||
return model_type
|
||||
return None
|
||||
|
||||
|
||||
def expand_multimodal_decode_graph_to_running_limit(
|
||||
server_args: Any, decode_config: Any, gpu_mem
|
||||
) -> None:
|
||||
"""Keep profiled high-concurrency Qwen3-VL decode inside CUDA graph."""
|
||||
from sglang.srt.model_executor.cuda_graph_config import Phase
|
||||
|
||||
cfg = resolving_view(server_args)
|
||||
locked = getattr(server_args, "_cuda_graph_config_locked", set())
|
||||
max_running_requests = cfg.max_running_requests
|
||||
if not (
|
||||
gpu_mem is not None
|
||||
and max_running_requests is not None
|
||||
and max_running_requests <= 512
|
||||
and decode_config.max_bs is not None
|
||||
and decode_config.max_bs < max_running_requests
|
||||
and (Phase.DECODE, "max_bs") not in locked
|
||||
and (Phase.DECODE, "bs") not in locked
|
||||
):
|
||||
return
|
||||
if large_hopper_qwen3_vl_model_type(server_args, gpu_mem) is None:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Expanding multimodal decode CUDA graph max_bs from %d to "
|
||||
"max_running_requests=%d.",
|
||||
decode_config.max_bs,
|
||||
max_running_requests,
|
||||
)
|
||||
decode_config.max_bs = max_running_requests
|
||||
|
||||
|
||||
@_register_for("Qwen3VLForConditionalGeneration")
|
||||
def _qwen3vl_overrides(server_args: Any, hf_config: Any) -> dict:
|
||||
@@ -30,3 +79,63 @@ def _qwen3vl_overrides(server_args: Any, hf_config: Any) -> dict:
|
||||
)
|
||||
return {"page_size": 16}
|
||||
return {}
|
||||
|
||||
|
||||
@_register_for(
|
||||
"Qwen3VLForConditionalGeneration",
|
||||
"Qwen3VLMoeForConditionalGeneration",
|
||||
)
|
||||
def _qwen3vl_hopper_serving_overrides(server_args: Any, hf_config: Any) -> dict:
|
||||
"""Select the profiled Qwen3-VL serving path on large Hopper GPUs."""
|
||||
model_type = large_hopper_qwen3_vl_model_type(server_args)
|
||||
if model_type is None:
|
||||
return {}
|
||||
|
||||
cfg = resolving_view(server_args)
|
||||
if not envs.SGLANG_VLM_CACHE_SIZE_MB.is_set():
|
||||
# Repeated-image traffic can opt in to embedding retention. Disable
|
||||
# it by default for streaming traffic, where every image is used once.
|
||||
envs.SGLANG_VLM_CACHE_SIZE_MB.set(0)
|
||||
|
||||
updates = {}
|
||||
preprocess_cache_size_mb = cfg.mm_preprocess_cache_size_mb
|
||||
if preprocess_cache_size_mb is None:
|
||||
preprocess_cache_size_mb = 0
|
||||
updates["mm_preprocess_cache_size_mb"] = 0
|
||||
cache_retention_enabled = (
|
||||
preprocess_cache_size_mb > 0 or envs.SGLANG_VLM_CACHE_SIZE_MB.get() > 0
|
||||
)
|
||||
if (
|
||||
cfg.mm_feature_transport is None
|
||||
and cfg.nnodes == 1
|
||||
and not cache_retention_enabled
|
||||
):
|
||||
updates["mm_feature_transport"] = "cuda_ipc"
|
||||
resolved_transport = updates.get("mm_feature_transport", cfg.mm_feature_transport)
|
||||
if (
|
||||
not envs.SGLANG_MM_FEATURE_CACHE_MB.is_set()
|
||||
and cfg.max_running_requests is not None
|
||||
and cfg.max_running_requests >= 400
|
||||
and not cache_retention_enabled
|
||||
and resolved_transport == "cuda_ipc"
|
||||
):
|
||||
# Keep a full high-concurrency wave GPU-resident instead of
|
||||
# falling back to CPU transport while the scheduler drains it.
|
||||
envs.SGLANG_MM_FEATURE_CACHE_MB.set(3 * 1024)
|
||||
if (
|
||||
cfg.radix_eviction_policy == "lru"
|
||||
and not cfg._radix_eviction_policy_explicitly_set
|
||||
):
|
||||
updates["radix_eviction_policy"] = "priority"
|
||||
if cfg.prefill_decode_interval is None:
|
||||
updates["prefill_decode_interval"] = 22
|
||||
if cfg.attention_backend is None and cfg.decode_attention_backend is None:
|
||||
updates["decode_attention_backend"] = "flashinfer"
|
||||
|
||||
if updates:
|
||||
logger.info(
|
||||
"Applying profiled %s serving defaults on a large Hopper GPU: %s",
|
||||
model_type,
|
||||
updates,
|
||||
)
|
||||
return updates
|
||||
|
||||
@@ -93,6 +93,7 @@ def run_resolution_pipeline(server_args: Any) -> None:
|
||||
|
||||
handle_offload_compatibility(server_args)
|
||||
from sglang.srt.arg_groups.validation_hook import (
|
||||
default_unset_prefill_decode_interval,
|
||||
validate_experimental_sgl_marlin,
|
||||
validate_prefill_decode_interval,
|
||||
validate_sampling_mask_max_tokens,
|
||||
@@ -234,6 +235,7 @@ def run_resolution_pipeline(server_args: Any) -> None:
|
||||
)
|
||||
|
||||
handle_model_specific_adjustments(server_args)
|
||||
default_unset_prefill_decode_interval(server_args)
|
||||
# After the model overrides: Qwen4-Exp declares the PLE offload default there.
|
||||
handle_offload_compatibility(server_args)
|
||||
|
||||
|
||||
@@ -435,10 +435,23 @@ def validate_experimental_sgl_marlin(server_args: Any):
|
||||
|
||||
def validate_prefill_decode_interval(server_args: Any):
|
||||
cfg = resolving_view(server_args)
|
||||
if cfg.prefill_decode_interval < 0:
|
||||
if cfg.prefill_decode_interval is not None and cfg.prefill_decode_interval < 0:
|
||||
raise ValueError("--prefill-decode-interval must be non-negative.")
|
||||
|
||||
|
||||
def default_unset_prefill_decode_interval(server_args: Any):
|
||||
"""Leave Qwen3-VL Hopper free to pick 22; everyone else stays disabled."""
|
||||
from sglang.srt.arg_groups.overrides import declare_resolution
|
||||
|
||||
cfg = resolving_view(server_args)
|
||||
if cfg.prefill_decode_interval is None:
|
||||
declare_resolution(
|
||||
server_args,
|
||||
"prefill_decode_interval_default",
|
||||
prefill_decode_interval=0,
|
||||
)
|
||||
|
||||
|
||||
def validate_sampling_mask_max_tokens(server_args: Any):
|
||||
if envs.SGLANG_DISAGGREGATION_SAMPLING_MASK_MAX_TOKENS.is_set():
|
||||
raise ValueError(
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
import math
|
||||
import time
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from enum import Enum
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, List, Optional, Union
|
||||
@@ -112,6 +113,7 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MEDIA_CONTENT_PART_TYPES = frozenset({"image_url", "video_url", "audio_url"})
|
||||
_CHAT_TEMPLATE_CACHE_MAX_SIZE = 128
|
||||
|
||||
|
||||
def normalize_tool_content(role: str, content):
|
||||
@@ -351,6 +353,9 @@ class OpenAIServingChat(OpenAIServingBase):
|
||||
)
|
||||
except Exception:
|
||||
self._tokenizer_auto_adds_specials = True
|
||||
self._chat_template_cache: OrderedDict[
|
||||
bytes, tuple[str, tuple[int, ...], str]
|
||||
] = OrderedDict()
|
||||
|
||||
def _handle_last_assistant_message(
|
||||
self,
|
||||
@@ -1342,6 +1347,7 @@ class OpenAIServingChat(OpenAIServingBase):
|
||||
"""Apply Jinja chat template"""
|
||||
prompt = ""
|
||||
prompt_ids = []
|
||||
decoded_prompt = None
|
||||
openai_compatible_messages = []
|
||||
image_data = []
|
||||
video_data = []
|
||||
@@ -1515,16 +1521,14 @@ class OpenAIServingChat(OpenAIServingBase):
|
||||
else {}
|
||||
)
|
||||
try:
|
||||
rendered_prompt = self.tokenizer_manager.tokenizer.apply_chat_template(
|
||||
openai_compatible_messages,
|
||||
tokenize=False,
|
||||
add_generation_prompt=True,
|
||||
tools=tools,
|
||||
return_dict=False,
|
||||
**extra_template_kwargs,
|
||||
)
|
||||
prompt_ids = self.tokenizer_manager.tokenizer.encode(
|
||||
rendered_prompt, **encode_kwargs
|
||||
rendered_prompt, prompt_ids, decoded_prompt = (
|
||||
self._render_and_encode_chat_template(
|
||||
openai_compatible_messages,
|
||||
tools=tools,
|
||||
template_kwargs=extra_template_kwargs,
|
||||
encode_kwargs=encode_kwargs,
|
||||
use_cache=is_multimodal,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# If the first attempt fails, try with flat function-only format.
|
||||
@@ -1535,19 +1539,15 @@ class OpenAIServingChat(OpenAIServingBase):
|
||||
else None
|
||||
)
|
||||
try:
|
||||
rendered_prompt = (
|
||||
self.tokenizer_manager.tokenizer.apply_chat_template(
|
||||
rendered_prompt, prompt_ids, decoded_prompt = (
|
||||
self._render_and_encode_chat_template(
|
||||
openai_compatible_messages,
|
||||
tokenize=False,
|
||||
add_generation_prompt=True,
|
||||
tools=tools,
|
||||
return_dict=False,
|
||||
**extra_template_kwargs,
|
||||
template_kwargs=extra_template_kwargs,
|
||||
encode_kwargs=encode_kwargs,
|
||||
use_cache=is_multimodal,
|
||||
)
|
||||
)
|
||||
prompt_ids = self.tokenizer_manager.tokenizer.encode(
|
||||
rendered_prompt, **encode_kwargs
|
||||
)
|
||||
except _CHAT_TEMPLATE_CLIENT_ERRORS as template_error:
|
||||
# Template errors (e.g., from raise_exception in Jinja templates)
|
||||
# and TypeError (e.g., tojson filter on Jinja2 Undefined variables)
|
||||
@@ -1559,9 +1559,15 @@ class OpenAIServingChat(OpenAIServingBase):
|
||||
prompt_ids = self._append_assistant_prefix_to_prompt_ids(
|
||||
prompt_ids, assistant_prefix
|
||||
)
|
||||
# The cached decode corresponds to prompt_ids before the prefix.
|
||||
decoded_prompt = None
|
||||
|
||||
if is_multimodal:
|
||||
prompt = self.tokenizer_manager.tokenizer.decode(prompt_ids)
|
||||
prompt = (
|
||||
decoded_prompt
|
||||
if decoded_prompt is not None
|
||||
else self.tokenizer_manager.tokenizer.decode(prompt_ids)
|
||||
)
|
||||
|
||||
stop = request.stop
|
||||
image_data = image_data if image_data else None
|
||||
@@ -1578,6 +1584,70 @@ class OpenAIServingChat(OpenAIServingBase):
|
||||
stop=stop,
|
||||
)
|
||||
|
||||
def _render_and_encode_chat_template(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
tools: Optional[List[Dict]],
|
||||
template_kwargs: Dict[str, Any],
|
||||
encode_kwargs: Dict[str, Any],
|
||||
use_cache: bool,
|
||||
) -> tuple[str, List[int], Optional[str]]:
|
||||
cache_key = None
|
||||
if use_cache:
|
||||
try:
|
||||
cache_key = orjson.dumps(
|
||||
(
|
||||
getattr(
|
||||
self.tokenizer_manager.tokenizer,
|
||||
"chat_template",
|
||||
None,
|
||||
),
|
||||
messages,
|
||||
tools,
|
||||
template_kwargs,
|
||||
encode_kwargs,
|
||||
),
|
||||
option=orjson.OPT_SORT_KEYS,
|
||||
)
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
if cache_key is not None:
|
||||
cached = self._chat_template_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
self._chat_template_cache.move_to_end(cache_key)
|
||||
rendered_prompt, prompt_ids, decoded_prompt = cached
|
||||
return rendered_prompt, list(prompt_ids), decoded_prompt
|
||||
|
||||
rendered_prompt = self.tokenizer_manager.tokenizer.apply_chat_template(
|
||||
messages,
|
||||
tokenize=False,
|
||||
add_generation_prompt=True,
|
||||
tools=tools,
|
||||
return_dict=False,
|
||||
**template_kwargs,
|
||||
)
|
||||
prompt_ids = self.tokenizer_manager.tokenizer.encode(
|
||||
rendered_prompt, **encode_kwargs
|
||||
)
|
||||
decoded_prompt = (
|
||||
self.tokenizer_manager.tokenizer.decode(prompt_ids)
|
||||
if cache_key is not None
|
||||
else None
|
||||
)
|
||||
|
||||
if cache_key is not None:
|
||||
self._chat_template_cache[cache_key] = (
|
||||
rendered_prompt,
|
||||
tuple(prompt_ids),
|
||||
decoded_prompt,
|
||||
)
|
||||
if len(self._chat_template_cache) > _CHAT_TEMPLATE_CACHE_MAX_SIZE:
|
||||
self._chat_template_cache.popitem(last=False)
|
||||
|
||||
return rendered_prompt, prompt_ids, decoded_prompt
|
||||
|
||||
def _apply_conversation_template(
|
||||
self,
|
||||
request: ChatCompletionRequest,
|
||||
|
||||
@@ -8,6 +8,7 @@ import torch
|
||||
from sglang.srt.managers.schedule_batch import MultimodalDataItem
|
||||
from sglang.srt.mem_cache.multimodal_cache import EmbeddingResult, MultiModalStaticCache
|
||||
from sglang.srt.multimodal.evs import EVSEmbeddingResult
|
||||
from sglang.srt.multimodal.transport.cuda_ipc import BORROW_CUDA_IPC_FEATURE_KEY
|
||||
from sglang.srt.runtime_context import get_parallel, get_schedule
|
||||
from sglang.srt.utils import is_hip, is_npu, is_xpu
|
||||
from sglang.srt.utils.async_probe import maybe_assert_sum
|
||||
@@ -368,6 +369,12 @@ def _batch_encode_per_image_misses(
|
||||
)
|
||||
unique_misses[cache_key] = (item, expected_token_count)
|
||||
elif cache_key not in unique_misses:
|
||||
if (
|
||||
start >= chunk_start
|
||||
and end < chunk_end
|
||||
and item.can_defer_cuda_ipc_feature_reconstruction()
|
||||
):
|
||||
item.model_specific_data[BORROW_CUDA_IPC_FEATURE_KEY] = True
|
||||
unique_misses[cache_key] = (item, expected_token_count)
|
||||
|
||||
# Phase 1b: single ViT call for all unique cache misses
|
||||
|
||||
@@ -126,7 +126,9 @@ from sglang.srt.model_executor.forward_batch_info import (
|
||||
ForwardMode,
|
||||
)
|
||||
from sglang.srt.multimodal.transport.cuda_ipc import (
|
||||
CUDA_IPC_FEATURE_COPY_EVENT_KEY,
|
||||
DEFER_CUDA_IPC_FEATURE_RECONSTRUCTION_KEY,
|
||||
RETAINED_CUDA_IPC_FEATURE_PROXY_KEY,
|
||||
CudaIpcTensorTransportProxy,
|
||||
)
|
||||
from sglang.srt.observability.metrics_collector import (
|
||||
@@ -494,6 +496,8 @@ class MultimodalDataItem(msgspec.Struct, kw_only=True, dict=True, array_like=Tru
|
||||
self.precomputed_embeddings.reconstruct_on_target_device(target_device)
|
||||
)
|
||||
for extra_key in self.model_specific_data:
|
||||
if extra_key == RETAINED_CUDA_IPC_FEATURE_PROXY_KEY:
|
||||
continue
|
||||
if isinstance(
|
||||
self.model_specific_data[extra_key], CudaIpcTensorTransportProxy
|
||||
):
|
||||
@@ -533,7 +537,11 @@ class MultimodalDataItem(msgspec.Struct, kw_only=True, dict=True, array_like=Tru
|
||||
|
||||
def release_transport_proxies(self, consumer_count: int = 1) -> None:
|
||||
"""Best-effort release of proxies left by an abandoned request."""
|
||||
values = [self.feature, self.precomputed_embeddings]
|
||||
retained_proxy = self.model_specific_data.pop(
|
||||
RETAINED_CUDA_IPC_FEATURE_PROXY_KEY, None
|
||||
)
|
||||
self.model_specific_data.pop(CUDA_IPC_FEATURE_COPY_EVENT_KEY, None)
|
||||
values = [self.feature, self.precomputed_embeddings, retained_proxy]
|
||||
values.extend(self.model_specific_data.values())
|
||||
for value in values:
|
||||
if not isinstance(value, CudaIpcTensorTransportProxy):
|
||||
@@ -682,10 +690,9 @@ class MultimodalInputs:
|
||||
"""Release feature tensors to free GPU memory."""
|
||||
for item in self.mm_items:
|
||||
try:
|
||||
# A request can be rejected before a deferred GPU feature is
|
||||
# reconstructed. Acknowledge that transport lease before the
|
||||
# proxy is dropped so the tokenizer pool can reuse its slice.
|
||||
item.acknowledge_deferred_cuda_ipc_feature()
|
||||
# Release both deferred features that were never used and
|
||||
# borrowed features retained for possible re-prefill.
|
||||
item.release_transport_proxies()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to release an unused multimodal feature transport",
|
||||
|
||||
@@ -1283,7 +1283,7 @@ class Scheduler(
|
||||
|
||||
def init_chunked_prefill(self):
|
||||
self.chunked_prefill_size = get_schedule().chunked_prefill_size
|
||||
self.prefill_decode_interval = get_schedule().prefill_decode_interval
|
||||
self.prefill_decode_interval = get_schedule().prefill_decode_interval or 0
|
||||
self._prefill_decode_interval_remaining = 0
|
||||
uses_transformers_backend = (
|
||||
get_resolved_model_impl(self.model_config) == ModelImpl.TRANSFORMERS
|
||||
|
||||
@@ -976,6 +976,7 @@ class TokenizerManager(TokenizerControlMixin, TokenizerManagerScoreMixin):
|
||||
input_embeds = None
|
||||
input_text = obj.text
|
||||
token_type_ids = None
|
||||
contains_mm_input = obj.contains_mm_input()
|
||||
is_cross_encoder_request = (
|
||||
isinstance(obj, EmbeddingReqInput) and obj.is_cross_encoder_request
|
||||
)
|
||||
@@ -998,9 +999,21 @@ class TokenizerManager(TokenizerControlMixin, TokenizerManagerScoreMixin):
|
||||
"the engine with skip_tokenizer_init=False."
|
||||
)
|
||||
|
||||
# Avoid tokenizing a raw multimodal prompt when the processor will
|
||||
# expand its placeholders and replace input_ids below.
|
||||
if (
|
||||
self.mm_processor
|
||||
and contains_mm_input
|
||||
and getattr(
|
||||
self.mm_processor,
|
||||
"generates_input_ids_from_raw_prompt",
|
||||
False,
|
||||
)
|
||||
):
|
||||
input_ids = None
|
||||
# For audio-only requests (e.g., Whisper), text may be empty.
|
||||
# The multimodal processor will provide input_ids later.
|
||||
if not input_text and self.mm_processor and obj.contains_mm_input():
|
||||
elif not input_text and self.mm_processor and contains_mm_input:
|
||||
# Use empty placeholder - multimodal processor will override
|
||||
input_ids = []
|
||||
else:
|
||||
@@ -1008,7 +1021,6 @@ class TokenizerManager(TokenizerControlMixin, TokenizerManagerScoreMixin):
|
||||
input_text, is_cross_encoder_request
|
||||
)
|
||||
|
||||
contains_mm_input = obj.contains_mm_input()
|
||||
if contains_mm_input and get_disagg().language_model_only:
|
||||
raise ValueError(
|
||||
"Multimodal inputs are not supported when --language-model-only "
|
||||
|
||||
@@ -517,6 +517,31 @@ class RadixCache(BasePrefixCache):
|
||||
result = self.insert(
|
||||
InsertParams(key=radix_key, value=values, priority=priority)
|
||||
)
|
||||
# A request that was never cached while unfinished can add its
|
||||
# whole prompt and generated output as one leaf. Split that leaf at
|
||||
# the prompt boundary so LRU eviction can discard output KV without
|
||||
# also losing the reusable prompt KV. Reinserting a prefix only
|
||||
# changes radix topology; it reuses the indices inserted above.
|
||||
prompt_key = RadixKey(
|
||||
token_ids[: len(req.origin_input_ids)],
|
||||
req.extra_key,
|
||||
is_bigram=self.is_eagle,
|
||||
cache_salt=req.cache_salt,
|
||||
).page_aligned(self.page_size)
|
||||
if 0 < len(prompt_key) < key_len:
|
||||
self.insert(
|
||||
InsertParams(
|
||||
key=prompt_key,
|
||||
value=values[: len(prompt_key)],
|
||||
priority=priority + 1,
|
||||
# Topology-only re-insert: this request created these
|
||||
# nodes moments ago, so counting it as a hit is the
|
||||
# same self-referencing inflation `chunked` exists to
|
||||
# suppress. hit_count drives eviction order, so an
|
||||
# extra bump would silently promote every prompt node.
|
||||
chunked=True,
|
||||
)
|
||||
)
|
||||
freed_end = result.prefix_len
|
||||
else:
|
||||
freed_end = key_len
|
||||
|
||||
@@ -995,6 +995,44 @@ class UnifiedRadixCache(BasePrefixCache):
|
||||
insert_params.value = values
|
||||
result = self.insert(insert_params)
|
||||
|
||||
# Keep the prompt as an independent radix node. Finished requests
|
||||
# append a short, request-specific output to a much longer prompt;
|
||||
# without this split the prompt and output form one leaf and are
|
||||
# evicted together. Re-inserting the prompt only changes topology:
|
||||
# prev_prefix_len prevents the overlapping KV indices from being
|
||||
# treated as duplicate allocations and freed. A declined rotation
|
||||
# tail releases everything past the protected prefix below, so the
|
||||
# split is skipped there rather than handing the tree rows that
|
||||
# are about to be freed.
|
||||
prompt_key = RadixKey(
|
||||
req.origin_input_ids,
|
||||
req.extra_key,
|
||||
is_bigram=self.tree_core.is_eagle,
|
||||
cache_salt=req.cache_salt,
|
||||
).page_aligned(self.page_size)
|
||||
if (
|
||||
not result.rotation_tail_declined
|
||||
and len(self._components_tuple) == 1
|
||||
and self._components_tuple[0].component_type == BASE_COMPONENT_TYPE
|
||||
and 0 < len(prompt_key) < len(radix_key)
|
||||
):
|
||||
self.insert(
|
||||
replace(
|
||||
insert_params,
|
||||
key=prompt_key,
|
||||
value=values[: len(prompt_key)],
|
||||
prev_prefix_len=len(prompt_key),
|
||||
priority=insert_params.priority + 1,
|
||||
# Topology-only re-insert: the request itself created
|
||||
# these nodes moments ago, so counting it as a hit is
|
||||
# the same self-referencing inflation `chunked` exists
|
||||
# to suppress. hit_count drives eviction order, so an
|
||||
# extra bump here would silently promote every prompt
|
||||
# node into the protected segment.
|
||||
chunked=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Free unaligned tail (+ deferred truncation tail). A rotation
|
||||
# decline inserted nothing, so the whole span past the protected
|
||||
# prefix stayed request-owned and is released here instead.
|
||||
|
||||
@@ -72,6 +72,12 @@ from sglang.srt.multimodal.mm_utils import (
|
||||
materialize_multimodal_features,
|
||||
run_dp_sharded_mrope_vision_model,
|
||||
)
|
||||
from sglang.srt.multimodal.transport.cuda_ipc import (
|
||||
BORROW_CUDA_IPC_FEATURE_KEY,
|
||||
CUDA_IPC_FEATURE_COPY_EVENT_KEY,
|
||||
RETAINED_CUDA_IPC_FEATURE_PROXY_KEY,
|
||||
CudaIpcTensorTransportProxy,
|
||||
)
|
||||
from sglang.srt.multimodal.vit_cuda_graph_runner import ViTCudaGraphRunner
|
||||
from sglang.srt.runtime_context import get_exec, get_mm, get_parallel
|
||||
from sglang.srt.utils import (
|
||||
@@ -1435,13 +1441,23 @@ class Qwen3VLForConditionalGeneration(nn.Module):
|
||||
pixel_values_device=self.visual.device,
|
||||
pixel_values_dtype=self.visual.dtype,
|
||||
)
|
||||
pixel_values = self._materialize_visual_items(items, range(len(items)))
|
||||
pixel_values, borrowed_items, packed_ready = self._materialize_visual_items(
|
||||
items, range(len(items)), preserve_for_reprefill=True
|
||||
)
|
||||
assert pixel_values.dim() == 2, pixel_values.dim()
|
||||
return self.visual(pixel_values, grid_thw=grid_thw)
|
||||
visual_features = self.visual(pixel_values, grid_thw=grid_thw)
|
||||
if borrowed_items:
|
||||
self._offload_packed_visual_inputs(
|
||||
pixel_values, borrowed_items, packed_ready
|
||||
)
|
||||
return visual_features
|
||||
|
||||
def _materialize_visual_items(
|
||||
self, items: List[MultimodalDataItem], indices: Iterable[int]
|
||||
) -> torch.Tensor:
|
||||
self,
|
||||
items: List[MultimodalDataItem],
|
||||
indices: Iterable[int],
|
||||
preserve_for_reprefill: bool = False,
|
||||
) -> Union[torch.Tensor, Tuple[torch.Tensor, list, Optional[torch.cuda.Event]]]:
|
||||
device = self.visual.device
|
||||
device_index = device.index
|
||||
if device.type == "cuda" and device_index is None:
|
||||
@@ -1451,14 +1467,114 @@ class Qwen3VLForConditionalGeneration(nn.Module):
|
||||
consumer_count = max(parallel.tp_size, 1)
|
||||
|
||||
features = []
|
||||
borrowed_items = []
|
||||
feature_offset = 0
|
||||
for index in indices:
|
||||
item = items[index]
|
||||
if device.type == "cuda":
|
||||
item.reconstruct(device_index, ipc_consumer_count=consumer_count)
|
||||
proxy = item.feature
|
||||
model_specific_data = getattr(item, "model_specific_data", {})
|
||||
pending_copy = model_specific_data.pop(
|
||||
CUDA_IPC_FEATURE_COPY_EVENT_KEY, None
|
||||
)
|
||||
if pending_copy is not None:
|
||||
torch.cuda.current_stream(device_index).wait_event(pending_copy)
|
||||
borrow_requested = model_specific_data.pop(
|
||||
BORROW_CUDA_IPC_FEATURE_KEY, False
|
||||
)
|
||||
can_borrow = (
|
||||
preserve_for_reprefill
|
||||
and consumer_count == 1
|
||||
and isinstance(proxy, CudaIpcTensorTransportProxy)
|
||||
and borrow_requested
|
||||
)
|
||||
borrowed = (
|
||||
proxy.borrow_on_target_device(device_index) if can_borrow else None
|
||||
)
|
||||
if borrowed is not None:
|
||||
item.feature = borrowed
|
||||
model_specific_data[RETAINED_CUDA_IPC_FEATURE_PROXY_KEY] = proxy
|
||||
borrowed_items.append(
|
||||
(item, proxy, feature_offset, borrowed.shape[0])
|
||||
)
|
||||
elif RETAINED_CUDA_IPC_FEATURE_PROXY_KEY not in model_specific_data:
|
||||
item.reconstruct(device_index, ipc_consumer_count=consumer_count)
|
||||
features.append(item.feature)
|
||||
return materialize_multimodal_features(
|
||||
features, device=device, dtype=self.visual.dtype
|
||||
)
|
||||
feature_offset += item.feature.shape[0]
|
||||
try:
|
||||
materialized = materialize_multimodal_features(
|
||||
features, device=device, dtype=self.visual.dtype
|
||||
)
|
||||
except Exception:
|
||||
for item, proxy, _, _ in borrowed_items:
|
||||
try:
|
||||
proxy.release_borrowed_on_current_stream()
|
||||
item.model_specific_data.pop(
|
||||
RETAINED_CUDA_IPC_FEATURE_PROXY_KEY, None
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to release a borrowed CUDA IPC feature after "
|
||||
"materialization failed",
|
||||
exc_info=True,
|
||||
)
|
||||
item.feature = None
|
||||
raise
|
||||
packed_ready = None
|
||||
if borrowed_items:
|
||||
source_stream = torch.cuda.current_stream(device_index)
|
||||
packed_ready = torch.cuda.Event()
|
||||
packed_ready.record(source_stream)
|
||||
for item, proxy, offset, length in borrowed_items:
|
||||
item.feature = materialized.narrow(0, offset, length)
|
||||
item.model_specific_data[CUDA_IPC_FEATURE_COPY_EVENT_KEY] = packed_ready
|
||||
try:
|
||||
proxy.release_borrowed_on_current_stream()
|
||||
item.model_specific_data.pop(
|
||||
RETAINED_CUDA_IPC_FEATURE_PROXY_KEY, None
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to release a copied CUDA IPC feature; retaining "
|
||||
"its lease until request cleanup",
|
||||
exc_info=True,
|
||||
)
|
||||
if preserve_for_reprefill:
|
||||
return materialized, borrowed_items, packed_ready
|
||||
return materialized
|
||||
|
||||
def _offload_packed_visual_inputs(
|
||||
self, materialized: torch.Tensor, borrowed_items: list, packed_ready
|
||||
) -> None:
|
||||
"""Preserve inputs for re-prefill without delaying the ViT launch."""
|
||||
try:
|
||||
host_features = torch.empty(
|
||||
materialized.shape,
|
||||
dtype=materialized.dtype,
|
||||
device="cpu",
|
||||
pin_memory=torch.cuda.is_available(),
|
||||
)
|
||||
copy_stream = getattr(self, "_mm_feature_copy_stream", None)
|
||||
if copy_stream is None:
|
||||
copy_stream = torch.cuda.Stream(device=self.visual.device)
|
||||
self._mm_feature_copy_stream = copy_stream
|
||||
with torch.cuda.stream(copy_stream):
|
||||
copy_stream.wait_event(packed_ready)
|
||||
host_features.copy_(materialized, non_blocking=True)
|
||||
host_ready = torch.cuda.Event()
|
||||
host_ready.record(copy_stream)
|
||||
if materialized.is_cuda:
|
||||
materialized.record_stream(copy_stream)
|
||||
for item, _, offset, length in borrowed_items:
|
||||
item.feature = host_features.narrow(0, offset, length)
|
||||
item.model_specific_data[CUDA_IPC_FEATURE_COPY_EVENT_KEY] = host_ready
|
||||
except Exception:
|
||||
# The generic multimodal path will offload the owned CUDA slices.
|
||||
logger.warning(
|
||||
"Failed to preserve CUDA IPC features on the copy stream; "
|
||||
"falling back to the generic offload path",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def get_input_embeddings(self):
|
||||
return self.model.embed_tokens
|
||||
|
||||
@@ -412,6 +412,66 @@ class MediaArtifactCacheMixin:
|
||||
raise RuntimeError("Artifact cache did not resolve every media item")
|
||||
return [artifact for artifact in artifacts if artifact is not None]
|
||||
|
||||
async def prepare_media_artifacts_without_cache(
|
||||
self,
|
||||
media_data: Sequence[Any],
|
||||
*,
|
||||
content_hashes: Optional[Sequence[Optional[str]]] = None,
|
||||
modality: Optional[Modality] = None,
|
||||
) -> list[MediaArtifact]:
|
||||
"""Build request-local artifacts without cache lookup or retention."""
|
||||
modality = self._resolve_artifact_modality(modality)
|
||||
media_count = len(media_data)
|
||||
if content_hashes is None:
|
||||
content_hashes = [None] * media_count
|
||||
if len(content_hashes) != media_count:
|
||||
raise ValueError(
|
||||
f"mm_content_hashes has {len(content_hashes)} entries for "
|
||||
f"{media_count} {modality.name.lower()} items"
|
||||
)
|
||||
content_hashes = [parse_content_hash(value) for value in content_hashes]
|
||||
|
||||
snapshot_futures = [
|
||||
self.io_executor.submit(self.snapshot_media_source, source, modality)
|
||||
for source in media_data
|
||||
]
|
||||
snapshots = []
|
||||
for index, future in enumerate(snapshot_futures):
|
||||
snapshot = await asyncio.wrap_future(future)
|
||||
caller_hash = content_hashes[index]
|
||||
if caller_hash is not None and caller_hash != snapshot.content_digest:
|
||||
raise ValueError(
|
||||
f"content hash mismatch for media_data[{index}]: "
|
||||
f"expected {caller_hash}, got {snapshot.content_digest}"
|
||||
)
|
||||
snapshots.append(snapshot)
|
||||
|
||||
decode_futures = [
|
||||
self.io_executor.submit(self.decode_media_snapshot, snapshot, modality)
|
||||
for snapshot in snapshots
|
||||
]
|
||||
entries = []
|
||||
for source, snapshot, future in zip(media_data, snapshots, decode_futures):
|
||||
entries.append(
|
||||
MediaArtifactInput(
|
||||
content_digest=snapshot.content_digest,
|
||||
artifact_key=self._artifact_key(
|
||||
snapshot.content_digest, source, modality=modality
|
||||
),
|
||||
modality=modality,
|
||||
media=await asyncio.wrap_future(future),
|
||||
)
|
||||
)
|
||||
|
||||
artifacts = await self._run_preprocess_and_build_artifact_batch(entries)
|
||||
if len(artifacts) != len(entries):
|
||||
raise ValueError(
|
||||
"prepare_artifact_batch must return one artifact per media input"
|
||||
)
|
||||
for artifact, entry in zip(artifacts, entries):
|
||||
self.validate_artifact(artifact, entry)
|
||||
return artifacts
|
||||
|
||||
async def _compute_cache_misses(
|
||||
self,
|
||||
misses_to_compute: Sequence[CacheMiss[str, MediaArtifact]],
|
||||
|
||||
@@ -227,6 +227,9 @@ class BaseMultimodalProcessor(ABC):
|
||||
# Models opt in by assigning a non-zero default. A user-provided server
|
||||
# argument overrides this value; zero disables storage and cache-key work.
|
||||
auto_mm_preprocess_cache_size_mb = 0
|
||||
# Artifact-based processors may keep their prompt/M-RoPE fast path even
|
||||
# when artifact retention is disabled.
|
||||
uses_media_artifacts_without_cache = False
|
||||
# Processors opt out only when their preprocessing is not thread-safe. The
|
||||
# worker pool gives each thread its own `copy.deepcopy` of the HF processor
|
||||
# and injects it, and the single function it runs --
|
||||
@@ -287,6 +290,7 @@ class BaseMultimodalProcessor(ABC):
|
||||
self.processor_fingerprint = (
|
||||
build_processor_fingerprint(self, hf_config)
|
||||
if self.mm_preprocess_cache.enabled
|
||||
or self.uses_media_artifacts_without_cache
|
||||
else None
|
||||
)
|
||||
if self.mm_preprocess_cache.enabled:
|
||||
|
||||
@@ -2,13 +2,17 @@ import math
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import List, Optional, Union
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, replace
|
||||
from functools import lru_cache
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torchvision
|
||||
from PIL import Image
|
||||
from torchvision.transforms import InterpolationMode
|
||||
from transformers import BaseImageProcessor
|
||||
|
||||
from sglang.srt.environ import envs
|
||||
from sglang.srt.layers.rotary_embedding import MRotaryEmbedding
|
||||
@@ -33,6 +37,11 @@ from sglang.srt.models.qwen3_omni_moe import Qwen3OmniMoeForConditionalGeneratio
|
||||
from sglang.srt.models.qwen3_vl import Qwen3VLForConditionalGeneration
|
||||
from sglang.srt.models.qwen3_vl_moe import Qwen3VLMoeForConditionalGeneration
|
||||
from sglang.srt.models.qwen4_exp import Qwen4ExpForConditionalGeneration
|
||||
from sglang.srt.multimodal.cache import resolve_multimodal_item_hash
|
||||
from sglang.srt.multimodal.media_artifacts.base import (
|
||||
MediaArtifactCacheMixin,
|
||||
MediaArtifactInput,
|
||||
)
|
||||
from sglang.srt.multimodal.processors.base_processor import (
|
||||
BaseMultimodalProcessor as SGLangBaseProcessor,
|
||||
)
|
||||
@@ -42,7 +51,7 @@ from sglang.srt.multimodal.processors.base_processor import (
|
||||
from sglang.srt.multimodal.transport.cuda_ipc import (
|
||||
DEFER_CUDA_IPC_FEATURE_RECONSTRUCTION_KEY,
|
||||
)
|
||||
from sglang.srt.runtime_context import get_mm
|
||||
from sglang.srt.runtime_context import get_mm, get_parallel
|
||||
from sglang.srt.utils import cpu_has_amx_support, is_cpu
|
||||
from sglang.srt.utils.video_decoder import VideoDecoderWrapper
|
||||
from sglang.utils import logger
|
||||
@@ -68,6 +77,37 @@ FPS = 2.0
|
||||
FPS_MIN_FRAMES = 4
|
||||
FPS_MAX_FRAMES = 768
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QwenVLImagePreprocessArtifact:
|
||||
"""Prompt-independent Qwen-VL processor output for one image."""
|
||||
|
||||
content_digest: str
|
||||
artifact_key: str
|
||||
feature_hash: int
|
||||
feature: Optional[torch.Tensor]
|
||||
model_specific_data: dict[str, Any]
|
||||
|
||||
@property
|
||||
def has_feature(self) -> bool:
|
||||
return self.feature is not None
|
||||
|
||||
def cache_value(self) -> "QwenVLImagePreprocessArtifact":
|
||||
"""Keep CPU processor outputs but never retain a CUDA tensor."""
|
||||
if self.feature is None or self.feature.device.type == "cpu":
|
||||
return self
|
||||
return replace(self, feature=None)
|
||||
|
||||
def cache_size_items(self) -> tuple:
|
||||
return (
|
||||
self.content_digest,
|
||||
self.artifact_key,
|
||||
self.feature_hash,
|
||||
self.feature,
|
||||
self.model_specific_data,
|
||||
)
|
||||
|
||||
|
||||
QWEN_VIDEO_PREPROCESS_CONFIG_KEYS = frozenset(
|
||||
{
|
||||
"fps",
|
||||
@@ -289,8 +329,10 @@ async def preprocess_video(
|
||||
|
||||
|
||||
# Compatible with Qwen-VL & Qwen-Omni Series
|
||||
class QwenVLImageProcessor(SGLangBaseProcessor):
|
||||
class QwenVLImageProcessor(MediaArtifactCacheMixin, SGLangBaseProcessor):
|
||||
supports_transformers_backend = True
|
||||
generates_input_ids_from_raw_prompt = True
|
||||
artifact_modality = Modality.IMAGE
|
||||
models = [
|
||||
Qwen2VLForConditionalGeneration,
|
||||
Qwen2_5_VLForConditionalGeneration,
|
||||
@@ -308,6 +350,10 @@ class QwenVLImageProcessor(SGLangBaseProcessor):
|
||||
|
||||
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
|
||||
self.model_type = hf_config.model_type
|
||||
self.uses_media_artifacts_without_cache = self.model_type in (
|
||||
"qwen3_vl",
|
||||
"qwen3_vl_moe",
|
||||
)
|
||||
if self.model_type in (
|
||||
"qwen2_vl",
|
||||
"qwen2_5_vl",
|
||||
@@ -732,7 +778,194 @@ class QwenVLImageProcessor(SGLangBaseProcessor):
|
||||
mrope_position_delta=mrope_position_delta,
|
||||
)
|
||||
|
||||
async def process_mm_data_async(
|
||||
def prepare_artifact_batch(
|
||||
self,
|
||||
entries: list[MediaArtifactInput],
|
||||
*,
|
||||
processor=None,
|
||||
) -> list[QwenVLImagePreprocessArtifact]:
|
||||
"""Preprocess image cache misses without retaining prompt-specific state."""
|
||||
if not entries:
|
||||
return []
|
||||
|
||||
processor, _ = self._resolve_processor(processor)
|
||||
image_kwargs = dict(self.image_config or {})
|
||||
processor_device = None
|
||||
if (
|
||||
isinstance(processor.image_processor, BaseImageProcessor)
|
||||
and not self.disable_fast_image_processor
|
||||
):
|
||||
processor_device = self._fast_image_processor_device(processor)
|
||||
if processor_device is not None:
|
||||
image_kwargs["device"] = processor_device
|
||||
|
||||
with self._temporary_fast_processor_cuda_pool(processor_device):
|
||||
result = processor.image_processor(
|
||||
images=[entry.media for entry in entries],
|
||||
return_tensors="pt",
|
||||
**image_kwargs,
|
||||
)
|
||||
features = self._get_processor_output_value(result, "pixel_values")
|
||||
image_grid_thw = self._get_processor_output_value(result, "image_grid_thw")
|
||||
if (
|
||||
isinstance(features, torch.Tensor)
|
||||
and (
|
||||
self.mm_preprocess_cache.enabled
|
||||
or not self.keep_mm_features_on_device
|
||||
)
|
||||
and not self.precompute_hash_before_cpu_transfer
|
||||
):
|
||||
features = features.cpu()
|
||||
|
||||
if not isinstance(features, torch.Tensor):
|
||||
raise TypeError("Qwen-VL image processor must return pixel_values")
|
||||
if not isinstance(image_grid_thw, torch.Tensor):
|
||||
image_grid_thw = torch.as_tensor(image_grid_thw, dtype=torch.long)
|
||||
if image_grid_thw.ndim != 2 or image_grid_thw.shape != (len(entries), 3):
|
||||
raise ValueError(
|
||||
"Qwen-VL image processor returned an invalid image_grid_thw shape: "
|
||||
f"{tuple(image_grid_thw.shape)}"
|
||||
)
|
||||
feature_lengths = image_grid_thw.prod(dim=1).tolist()
|
||||
if sum(feature_lengths) != features.shape[0]:
|
||||
raise ValueError(
|
||||
"Qwen-VL image processor feature count does not match image grids: "
|
||||
f"{features.shape[0]} != {sum(feature_lengths)}"
|
||||
)
|
||||
|
||||
artifacts = []
|
||||
feature_offset = 0
|
||||
for entry, grid, feature_length in zip(
|
||||
entries, image_grid_thw, feature_lengths
|
||||
):
|
||||
feature = features[
|
||||
feature_offset : feature_offset + feature_length
|
||||
].contiguous()
|
||||
feature_offset += feature_length
|
||||
# The artifact key already commits to the media content, processor
|
||||
# fingerprint, and every preprocessing kwarg. Derive the downstream
|
||||
# cache identity from it so independently preprocessed copies in
|
||||
# different tokenizer workers share the same radix/VLM cache key.
|
||||
feature_hash = resolve_multimodal_item_hash(
|
||||
existing_hash=0, namespace=entry.artifact_key
|
||||
)
|
||||
artifacts.append(
|
||||
QwenVLImagePreprocessArtifact(
|
||||
content_digest=entry.content_digest,
|
||||
artifact_key=entry.artifact_key,
|
||||
feature_hash=feature_hash,
|
||||
feature=feature,
|
||||
model_specific_data={"image_grid_thw": grid.unsqueeze(0)},
|
||||
)
|
||||
)
|
||||
return artifacts
|
||||
|
||||
def compose_image_artifacts(
|
||||
self,
|
||||
input_text,
|
||||
artifacts: list[QwenVLImagePreprocessArtifact],
|
||||
) -> MultimodalProcessorOutput:
|
||||
"""Compose prompt tokens and request-owned items from cached images."""
|
||||
image_grids = []
|
||||
for artifact in artifacts:
|
||||
grid = self._as_grid_batch(
|
||||
artifact.model_specific_data.get("image_grid_thw")
|
||||
)
|
||||
if grid is None or grid.shape[0] != 1:
|
||||
raise ValueError("Each Qwen-VL image artifact requires one image grid")
|
||||
image_grids.append(grid)
|
||||
image_grid_thw = torch.cat(image_grids, dim=0)
|
||||
|
||||
grid_key = tuple(
|
||||
tuple(int(value) for value in row.tolist()) for row in image_grid_thw
|
||||
)
|
||||
if isinstance(input_text, str):
|
||||
(
|
||||
input_ids_tuple,
|
||||
offsets,
|
||||
mrope_positions,
|
||||
mrope_position_delta,
|
||||
) = self._cached_image_prompt_template(input_text, grid_key)
|
||||
else:
|
||||
(
|
||||
input_ids_tuple,
|
||||
offsets,
|
||||
mrope_positions,
|
||||
mrope_position_delta,
|
||||
) = self._build_image_prompt_template(input_text, grid_key)
|
||||
input_ids_list = list(input_ids_tuple)
|
||||
|
||||
mm_items = []
|
||||
for artifact, offset in zip(artifacts, offsets):
|
||||
item = MultimodalDataItem(
|
||||
modality=Modality.IMAGE,
|
||||
feature=artifact.feature,
|
||||
offsets=[offset],
|
||||
model_specific_data=deepcopy(artifact.model_specific_data),
|
||||
)
|
||||
item.set_hash(artifact.feature_hash)
|
||||
mm_items.append(item)
|
||||
|
||||
padded_input_ids = MultimodalProcessorOutput.build_padded_input_ids(
|
||||
input_ids_list, mm_items
|
||||
)
|
||||
mm_items = self._prepare_mm_items_for_transport(mm_items)
|
||||
self._mark_cuda_ipc_features_for_deferred_reconstruction(mm_items)
|
||||
return MultimodalProcessorOutput(
|
||||
input_ids=input_ids_list,
|
||||
padded_input_ids=padded_input_ids,
|
||||
mm_items=mm_items,
|
||||
im_start_id=self.vision_start_token_id,
|
||||
im_end_id=self.vision_end_token_id,
|
||||
im_token_id=self.mm_tokens.image_token_id,
|
||||
video_token_id=self.mm_tokens.video_token_id,
|
||||
audio_token_id=self.mm_tokens.audio_token_id,
|
||||
mrope_positions=mrope_positions.clone(),
|
||||
mrope_position_delta=mrope_position_delta.clone(),
|
||||
)
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def _cached_image_prompt_template(self, input_text: str, grid_key: tuple):
|
||||
"""Cache prompt expansion and M-RoPE by prompt and image-grid shape."""
|
||||
return self._build_image_prompt_template(input_text, grid_key)
|
||||
|
||||
def _build_image_prompt_template(self, input_text, grid_key: tuple):
|
||||
image_grid_thw = torch.tensor(grid_key, dtype=torch.long)
|
||||
input_ids_list, offsets, modalities = self.build_input_ids(
|
||||
input_text, img_grid_thw=image_grid_thw
|
||||
)
|
||||
if modalities != [Modality.IMAGE] * len(grid_key):
|
||||
raise ValueError("Qwen-VL image artifacts do not match prompt placeholders")
|
||||
|
||||
template_items = [
|
||||
MultimodalDataItem(
|
||||
modality=Modality.IMAGE,
|
||||
feature=None,
|
||||
offsets=[offset],
|
||||
model_specific_data={"image_grid_thw": grid.unsqueeze(0)},
|
||||
)
|
||||
for grid, offset in zip(image_grid_thw, offsets)
|
||||
]
|
||||
input_ids = torch.tensor(input_ids_list, dtype=torch.long)
|
||||
mrope_result = self._compute_image_only_mrope_positions_from_offsets(
|
||||
input_len=input_ids.numel(),
|
||||
mm_items=template_items,
|
||||
dtype=input_ids.dtype,
|
||||
device=input_ids.device,
|
||||
)
|
||||
if mrope_result is None:
|
||||
mrope_result = self.compute_mrope_positions(input_ids_list, template_items)
|
||||
mrope_positions, mrope_position_delta = mrope_result
|
||||
if mrope_positions is not None and mrope_positions.ndim == 3:
|
||||
mrope_positions = mrope_positions.squeeze(1)
|
||||
return (
|
||||
tuple(input_ids_list),
|
||||
tuple(offsets),
|
||||
mrope_positions,
|
||||
mrope_position_delta,
|
||||
)
|
||||
|
||||
async def _process_mm_data_uncached(
|
||||
self,
|
||||
image_data: List[Union[str, bytes]],
|
||||
input_text,
|
||||
@@ -788,7 +1021,7 @@ class QwenVLImageProcessor(SGLangBaseProcessor):
|
||||
base_output, self.mm_tokens, **processor_kwargs
|
||||
)
|
||||
|
||||
self._mark_dp_encoder_features_for_deferred_reconstruction(mm_items)
|
||||
self._mark_cuda_ipc_features_for_deferred_reconstruction(mm_items)
|
||||
|
||||
audio_feature_lengths = None
|
||||
|
||||
@@ -902,10 +1135,46 @@ class QwenVLImageProcessor(SGLangBaseProcessor):
|
||||
mrope_position_delta=mrope_position_delta,
|
||||
)
|
||||
|
||||
def _mark_dp_encoder_features_for_deferred_reconstruction(self, mm_items):
|
||||
async def process_mm_data_async(
|
||||
self,
|
||||
image_data: List[Union[str, bytes]],
|
||||
input_text,
|
||||
request_obj,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
if (
|
||||
not image_data
|
||||
or request_obj.video_data
|
||||
or request_obj.audio_data
|
||||
or any(self._is_preprocessed_input(item) for item in image_data)
|
||||
or (
|
||||
not self.mm_preprocess_cache.enabled
|
||||
and not self.uses_media_artifacts_without_cache
|
||||
)
|
||||
):
|
||||
return await self._process_mm_data_uncached(
|
||||
image_data, input_text, request_obj, *args, **kwargs
|
||||
)
|
||||
|
||||
prepare_artifacts = (
|
||||
self.prepare_media_artifacts
|
||||
if self.mm_preprocess_cache.enabled
|
||||
else self.prepare_media_artifacts_without_cache
|
||||
)
|
||||
artifacts = await prepare_artifacts(
|
||||
image_data, content_hashes=getattr(request_obj, "mm_content_hashes", None)
|
||||
)
|
||||
return self.compose_image_artifacts(input_text, artifacts)
|
||||
|
||||
def _mark_cuda_ipc_features_for_deferred_reconstruction(self, mm_items):
|
||||
supports_deferred_reconstruction = get_mm().mm_enable_dp_encoder or (
|
||||
get_parallel().tp_size == 1
|
||||
and self.model_type in ("qwen3_vl", "qwen3_vl_moe")
|
||||
)
|
||||
if not (
|
||||
self.keep_mm_features_on_device
|
||||
and get_mm().mm_enable_dp_encoder
|
||||
and supports_deferred_reconstruction
|
||||
and self.model_type
|
||||
in ("qwen3_vl", "qwen3_vl_moe", "qwen3_5", "qwen3_5_moe")
|
||||
):
|
||||
|
||||
@@ -25,6 +25,9 @@ MM_ITEM_MEMORY_POOL_RECYCLE_INTERVAL = (
|
||||
DEFER_CUDA_IPC_FEATURE_RECONSTRUCTION_KEY = (
|
||||
"_sglang_defer_cuda_ipc_feature_reconstruction"
|
||||
)
|
||||
BORROW_CUDA_IPC_FEATURE_KEY = "_sglang_borrow_cuda_ipc_feature"
|
||||
CUDA_IPC_FEATURE_COPY_EVENT_KEY = "_sglang_cuda_ipc_feature_copy_event"
|
||||
RETAINED_CUDA_IPC_FEATURE_PROXY_KEY = "_sglang_retained_cuda_ipc_feature_proxy"
|
||||
|
||||
|
||||
def get_mm_feature_pool_size_per_worker(
|
||||
@@ -233,6 +236,9 @@ class CudaIpcTensorTransportProxy(StreamOrderedPoolConsumerMixin):
|
||||
# Keep uncached mappings alive until the work enqueued on the consumer
|
||||
# stream has completed.
|
||||
self._pool_storage = None
|
||||
self._borrowed_storage = None
|
||||
self._borrowed_base_address = None
|
||||
self._borrowed_device_id = None
|
||||
|
||||
def _reconstruct_from_ipc_extra(
|
||||
self, ipc_extra, *, use_cache: bool, rebuild_device_idx: int
|
||||
@@ -321,9 +327,53 @@ class CudaIpcTensorTransportProxy(StreamOrderedPoolConsumerMixin):
|
||||
)
|
||||
self._retain_storage_until_stream_completes(storage, device_id)
|
||||
|
||||
def borrow_on_target_device(
|
||||
self, rebuild_device_idx: int
|
||||
) -> Optional[torch.Tensor]:
|
||||
"""Return a zero-copy view whose lease remains owned by this proxy."""
|
||||
ipc_extra = self.proxy_state["ipc_extra"]
|
||||
if not ipc_extra["use_pool_handle_cache"] or self._consumer_acknowledged:
|
||||
return None
|
||||
|
||||
with torch.cuda.device(rebuild_device_idx):
|
||||
slice_tensor, storage = self._open_pool_slice(rebuild_device_idx)
|
||||
base_address = storage.data_ptr()
|
||||
self._wait_until_ready(base_address, rebuild_device_idx)
|
||||
borrowed = slice_tensor.view(ipc_extra["recons_dtype"]).reshape(
|
||||
ipc_extra["recons_shape"]
|
||||
)
|
||||
|
||||
self._borrowed_storage = storage
|
||||
self._borrowed_base_address = base_address
|
||||
self._borrowed_device_id = rebuild_device_idx
|
||||
return borrowed
|
||||
|
||||
def release_borrowed_on_current_stream(
|
||||
self, consumer_count: int = 1, consumer_rank: Optional[int] = None
|
||||
) -> None:
|
||||
"""Release a borrowed view after all current-stream reads are enqueued."""
|
||||
storage = getattr(self, "_borrowed_storage", None)
|
||||
if storage is None:
|
||||
return
|
||||
device_id = self._borrowed_device_id
|
||||
with torch.cuda.device(device_id):
|
||||
self._acknowledge_on_stream(
|
||||
self._borrowed_base_address,
|
||||
device_id,
|
||||
consumer_count,
|
||||
consumer_rank,
|
||||
)
|
||||
self._retain_storage_until_stream_completes(storage, device_id)
|
||||
self._borrowed_storage = None
|
||||
self._borrowed_base_address = None
|
||||
self._borrowed_device_id = None
|
||||
|
||||
def release_without_reconstruction(self, consumer_count: int = 1) -> None:
|
||||
"""Release a pool slice when its request abandons this proxy."""
|
||||
self.acknowledge_consumption(consumer_count)
|
||||
if getattr(self, "_borrowed_storage", None) is not None:
|
||||
self.release_borrowed_on_current_stream(consumer_count)
|
||||
else:
|
||||
self.acknowledge_consumption(consumer_count)
|
||||
|
||||
def reconstruct_on_target_device(
|
||||
self,
|
||||
|
||||
@@ -710,7 +710,15 @@ def prepare_server_args(argv: list[str]) -> ServerArgs:
|
||||
config_merger = ConfigArgumentMerger(parser)
|
||||
argv = config_merger.merge_config_with_args(argv)
|
||||
|
||||
radix_eviction_policy_explicitly_set = any(
|
||||
arg == "--radix-eviction-policy" or arg.startswith("--radix-eviction-policy=")
|
||||
for arg in argv
|
||||
)
|
||||
|
||||
raw_args = parser.parse_args(argv)
|
||||
raw_args._radix_eviction_policy_explicitly_set = (
|
||||
radix_eviction_policy_explicitly_set
|
||||
)
|
||||
|
||||
# Set up basic logging before ServerArgs.__post_init__ so that
|
||||
# logger.info / logger.warning calls there are properly formatted.
|
||||
|
||||
Reference in New Issue
Block a user