[Perf] Optimize Qwen3-VL unique-image serving on H100 (#36411)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xiaoyu Zhang
2026-09-12 15:16:59 +08:00
committed by GitHub
co-authored by Cursor
parent 6ba96d329f
commit 0a57403468
30 changed files with 1370 additions and 76 deletions
@@ -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(
+21 -11
View File
@@ -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.",
+8 -1
View File
@@ -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
+2
View File
@@ -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
+12 -5
View File
@@ -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",
+1 -1
View File
@@ -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.
+124 -8
View File
@@ -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,
+8
View File
@@ -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.