Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion python/sglang/srt/disaggregation/encoder/preprocessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
EncoderPreprocessOutput,
invoke_encoder_preprocessor,
)
from sglang.srt.multimodal.processors.qwen3_vl import (
preprocess_video as qwen3_preprocess_video,
)
from sglang.srt.multimodal.processors.qwen_vl import preprocess_video
from sglang.srt.runtime_context import (
get_device,
Expand Down Expand Up @@ -411,8 +414,16 @@ async def _flatten_and_load_videos(self, mm_items):

video_processor_kwargs = {}
if "qwen" in self.model_type:
# Qwen3-VL/3.5 defer spatial resizing to the model processor; the
# legacy path pre-resizes and would double-resize them.
qwen3_model_types = ("qwen3_vl", "qwen3_vl_moe", "qwen3_5", "qwen3_5_moe")
video_preprocess = (
qwen3_preprocess_video
if self.model_type in qwen3_model_types
else preprocess_video
)
video_processed = [
await preprocess_video(
await video_preprocess(
video, video_config=self.vision_config.get("video", {})
)
for video in video_items
Expand Down
32 changes: 17 additions & 15 deletions python/sglang/srt/managers/rust_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,25 +110,27 @@ def serves(self, mm_processor_cls: Any, model_type: Optional[str]) -> bool:
return mm_processor_cls is cls and model_type in self.model_types


_QWEN_VL_IMAGE_PROCESSORS = {
"Qwen2VLImageProcessor": "aten_u8",
"Qwen2VLImageProcessorFast": "aten_u8",
"Qwen2VLImageProcessorPil": "pil",
}

NATIVE_MM_FAMILIES: Tuple[NativeMmFamily, ...] = (
NativeMmFamily(
name="qwen_vl",
mm_processor="sglang.srt.multimodal.processors.qwen_vl:QwenVLImageProcessor",
model_types=frozenset(
(
"qwen2_vl",
"qwen2_5_vl",
"qwen3_vl",
"qwen3_vl_moe",
"qwen3_5",
"qwen3_5_moe",
)
),
image_processors={
"Qwen2VLImageProcessor": "aten_u8",
"Qwen2VLImageProcessorFast": "aten_u8",
"Qwen2VLImageProcessorPil": "pil",
},
model_types=frozenset(("qwen2_vl", "qwen2_5_vl")),
image_processors=_QWEN_VL_IMAGE_PROCESSORS,
),
# Qwen3-VL/3.5 register their own processor subclass (qwen3_vl.py); the
# identity check in `serves` does not follow subclassing, so they need a
# separate entry pointing at that class.
NativeMmFamily(
name="qwen_vl",
mm_processor="sglang.srt.multimodal.processors.qwen3_vl:Qwen3VLImageProcessor",
model_types=frozenset(("qwen3_vl", "qwen3_vl_moe", "qwen3_5", "qwen3_5_moe")),
image_processors=_QWEN_VL_IMAGE_PROCESSORS,
),
)

Expand Down
65 changes: 65 additions & 0 deletions python/sglang/srt/multimodal/processors/qwen3_vl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Multimodal preprocessing for Qwen3-VL and Qwen3.5."""

import time
from typing import Optional

import numpy as np

from sglang.srt.models.qwen3_5 import (
Qwen3_5ForConditionalGeneration,
Qwen3_5MoeForConditionalGeneration,
)
from sglang.srt.models.qwen3_5_mtp import Qwen3_5ForCausalLMMTP
from sglang.srt.models.qwen3_vl import Qwen3VLForConditionalGeneration
from sglang.srt.models.qwen3_vl_moe import Qwen3VLMoeForConditionalGeneration
from sglang.srt.multimodal.processors.qwen_vl import (
QwenVLImageProcessor,
smart_nframes,
)
from sglang.srt.utils.video_decoder import VideoDecoderWrapper
from sglang.utils import logger


async def preprocess_video(vr, video_config: Optional[dict] = None):
# Spatial resize stays with the model-native processor; pre-resizing here
# (as the Qwen2 path does) double-resizes with the wrong geometry.
if not isinstance(vr, VideoDecoderWrapper):
return vr, None

video_config = video_config or {}
entry_time = time.perf_counter()
total_frames, video_fps = len(vr), vr.avg_fps
nframes = smart_nframes(
video_config, total_frames=total_frames, video_fps=video_fps
)
indices = np.linspace(0, total_frames - 1, num=nframes, dtype=np.int64)
indices = np.unique(indices)

video = vr.get_frames_as_tensor(indices.tolist())
video = video.permute(0, 3, 1, 2).pin_memory()
metadata = {
"fps": video_fps,
"duration": total_frames / video_fps,
"total_num_frames": total_frames,
"frames_indices": indices,
"video_backend": "torchvision",
}
logger.debug(
f"[Qwen3VL preprocess_video Perf], "
f"spatial_resize: downstream_processor, "
f"total_time: {(time.perf_counter() - entry_time) * 1000:.2f} ms"
)
return video, metadata


class Qwen3VLImageProcessor(QwenVLImageProcessor):
models = [
Qwen3VLForConditionalGeneration,
Qwen3VLMoeForConditionalGeneration,
Qwen3_5ForConditionalGeneration,
Qwen3_5MoeForConditionalGeneration,
Qwen3_5ForCausalLMMTP,
]

async def _preprocess_video(self, video):
return await preprocess_video(video, video_config=self.video_config)
18 changes: 4 additions & 14 deletions python/sglang/srt/multimodal/processors/qwen_vl.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,7 @@
from sglang.srt.models.interns2preview import InternS2PreviewForConditionalGeneration
from sglang.srt.models.qwen2_5_vl import Qwen2_5_VLForConditionalGeneration
from sglang.srt.models.qwen2_vl import Qwen2VLForConditionalGeneration
from sglang.srt.models.qwen3_5 import (
Qwen3_5ForConditionalGeneration,
Qwen3_5MoeForConditionalGeneration,
)
from sglang.srt.models.qwen3_5_mtp import Qwen3_5ForCausalLMMTP
from sglang.srt.models.qwen3_omni_moe import Qwen3OmniMoeForConditionalGeneration
from sglang.srt.models.qwen3_vl import Qwen3VLForConditionalGeneration
from sglang.srt.models.qwen3_vl_moe import Qwen3VLMoeForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor as SGLangBaseProcessor,
)
Expand Down Expand Up @@ -292,11 +285,6 @@ class QwenVLImageProcessor(SGLangBaseProcessor):
models = [
Qwen2VLForConditionalGeneration,
Qwen2_5_VLForConditionalGeneration,
Qwen3VLForConditionalGeneration,
Qwen3VLMoeForConditionalGeneration,
Qwen3_5ForConditionalGeneration,
Qwen3_5MoeForConditionalGeneration,
Qwen3_5ForCausalLMMTP,
InternS2PreviewForConditionalGeneration,
InternS2MobiusForConditionalGeneration,
Qwen3OmniMoeForConditionalGeneration,
Expand Down Expand Up @@ -357,6 +345,9 @@ def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
def spatial_merge_size(self):
return self._spatial_merge_size

async def _preprocess_video(self, video):
return await preprocess_video(video, video_config=self.video_config)

def build_input_ids_with_timestamps(
self, prompt, embeddings, img_grid_thw, video_grid_thw, video_timestamps
):
Expand Down Expand Up @@ -745,8 +736,7 @@ async def process_mm_data_async(
video_metadata = None
if base_output.videos and not isinstance(base_output.videos[0], dict):
videos_processed = [
await preprocess_video(video, video_config=self.video_config)
for video in base_output.videos
await self._preprocess_video(video) for video in base_output.videos
]
base_output.videos, video_metadata = map(list, zip(*videos_processed))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from sglang.srt.managers.schedule_batch import Modality, MultimodalDataItem
from sglang.srt.models.qwen3_vl import Qwen3VLForConditionalGeneration
from sglang.srt.multimodal.processors.qwen_vl import QwenVLImageProcessor
from sglang.srt.multimodal.processors.qwen3_vl import Qwen3VLImageProcessor
from sglang.srt.multimodal.transport.cuda_ipc import (
DEFER_CUDA_IPC_FEATURE_RECONSTRUCTION_KEY,
)
Expand Down Expand Up @@ -44,7 +44,7 @@ def _model(visual, *, use_data_parallel):
def test_processor_defers_gpu_transport_for_encoder_dp(self):
for transport in ("cuda_ipc", "cuda_vmm"):
with self.subTest(transport=transport):
processor = QwenVLImageProcessor.__new__(QwenVLImageProcessor)
processor = Qwen3VLImageProcessor.__new__(Qwen3VLImageProcessor)
processor.mm_feature_transport = transport
processor.server_args = SimpleNamespace(mm_enable_dp_encoder=True)
processor.model_type = "qwen3_vl"
Expand Down Expand Up @@ -72,7 +72,7 @@ def test_processor_defers_gpu_transport_for_encoder_dp(self):
)

def test_processor_does_not_defer_cpu_transport(self):
processor = QwenVLImageProcessor.__new__(QwenVLImageProcessor)
processor = Qwen3VLImageProcessor.__new__(Qwen3VLImageProcessor)
processor.mm_feature_transport = "cpu"
processor.server_args = SimpleNamespace(mm_enable_dp_encoder=True)
processor.model_type = "qwen3_vl"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ def test_qwen_vl_resolves_its_family(self):
family = native_mm_family_for(cls, "qwen2_5_vl")
self.assertEqual(family and family.name, "qwen_vl")

def test_qwen3_vl_resolves_its_family(self):
# The Qwen3 processor is a separate class from QwenVLImageProcessor;
# the gate must still match it or launch hard-errors.
cls = processor_cls_for("Qwen3VLForConditionalGeneration", "qwen3_vl")
family = native_mm_family_for(cls, "qwen3_vl")
self.assertEqual(family and family.name, "qwen_vl")

def test_inkling_keeps_its_python_processor(self):
from sglang.srt.multimodal.processors.inkling import InklingMultimodalProcessor

Expand Down
93 changes: 93 additions & 0 deletions test/registered/unit/multimodal/test_qwen3_vl_video_preprocess.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""CPU tests for model-specific Qwen3-VL video preprocessing."""

from sglang.test.ci.ci_register import register_cpu_ci

register_cpu_ci(est_time=5, suite="base-a-test-cpu")

import asyncio
import unittest
from unittest.mock import patch

import numpy as np

from sglang.srt.multimodal.processors import qwen3_vl, qwen_vl
from sglang.test.test_utils import CustomTestCase


class _FakeTensor:
def __init__(self, shape):
self.shape = shape

def permute(self, *dims):
self.shape = tuple(self.shape[dim] for dim in dims)
return self

def pin_memory(self):
return self


class _FakeVideoDecoder:
def __init__(self, *, total_frames, fps, height=5, width=7):
self.total_frames = total_frames
self.avg_fps = fps
self.height = height
self.width = width
self.requested_indices = None

def __len__(self):
return self.total_frames

def get_frames_as_tensor(self, indices):
self.requested_indices = indices
return _FakeTensor((len(indices), self.height, self.width, 3))


class TestQwen3VLVideoPreprocess(CustomTestCase):
def test_qwen3_preserves_source_geometry_for_model_processor(self):
decoder = _FakeVideoDecoder(total_frames=10, fps=2.0)
with patch.object(qwen3_vl, "VideoDecoderWrapper", _FakeVideoDecoder):
video, metadata = asyncio.run(
qwen3_vl.preprocess_video(decoder, video_config={"nframes": 4})
)

self.assertEqual(video.shape, (4, 3, 5, 7))
self.assertEqual(decoder.requested_indices, [0, 3, 6, 9])
np.testing.assert_array_equal(metadata["frames_indices"], [0, 3, 6, 9])

def test_qwen3_max_frames_still_spans_full_video(self):
decoder = _FakeVideoDecoder(total_frames=3000, fps=30.0)
with patch.object(qwen3_vl, "VideoDecoderWrapper", _FakeVideoDecoder):
video, _ = asyncio.run(
qwen3_vl.preprocess_video(
decoder,
video_config={"fps": 2, "max_frames": 10},
)
)

self.assertEqual(video.shape, (10, 3, 5, 7))
self.assertEqual(decoder.requested_indices[0], 0)
self.assertEqual(decoder.requested_indices[-1], 2999)
self.assertTrue(np.all(np.diff(np.asarray(decoder.requested_indices)) > 0))

def test_qwen2_keeps_legacy_factor_28_resize(self):
decoder = _FakeVideoDecoder(total_frames=10, fps=2.0, height=56, width=84)
resized = _FakeTensor((4, 3, 56, 84))
with (
patch.object(qwen_vl, "VideoDecoderWrapper", _FakeVideoDecoder),
patch.object(
qwen_vl.torchvision.transforms.functional,
"resize",
return_value=resized,
) as resize,
):
video, _ = asyncio.run(
qwen_vl.preprocess_video(decoder, video_config={"nframes": 4})
)

resize.assert_called_once()
self.assertEqual(resize.call_args.args[1], [280, 392])
self.assertIs(video, resized)


if __name__ == "__main__":
unittest.main(verbosity=2)
Loading