Deepstream video backend (#42424)

Signed-off-by: Viranjan Pagar <vpagar@nvidia.com> Signed-off-by: Isotr0py <Isotr0py@outlook.com> Signed-off-by: Isotr0py <mozf@mail2.sysu.edu.cn> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Isotr0py <mozf@mail2.sysu.edu.cn> Co-authored-by: Isotr0py <Isotr0py@outlook.com> Co-authored-by: Roger Wang <hey@rogerw.io>

ViranjanPagar committed Jul 10, 2026 at 14:53 UTC e23b19309b8705b21c3b3ff4129c9974ba15a419
3 files changed +212 -11
docs/features/multimodal_inputs.md
+49
@@ -879,6 +879,55 @@ vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
879
880 Works with common video formats like MP4 when using OpenCV backends.
881
882 +#### GPU Video Decoding with DeepStream (NVDEC)
883 +
884 +By default vLLM decodes video on the CPU. On NVIDIA GPUs you can instead decode
885 +directly on the hardware video engine (NVDEC) with the DeepStream backend, which
886 +keeps decoding off the CPU and can significantly increase video throughput.
887 +
888 +Install the backend (Linux x86-64 only):
889 +
890 +```bash
891 +pip install vllm[deepstream]
892 +```
893 +
894 +The pip wheel bundles the DeepStream libraries but still relies on a few system
895 +packages that pip cannot install. On Ubuntu:
896 +
897 +```bash
898 +apt-get install -y \
899 + gstreamer1.0-tools gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
900 + gstreamer1.0-plugins-bad gstreamer1.0-libav \
901 + python3-gi python3-gst-1.0 libv4l-0 cuda-libraries-13-0
902 +```
903 +
904 +Select the backend either with an environment variable:
905 +
906 +```bash
907 +export VLLM_VIDEO_LOADER_BACKEND=deepstream
908 +vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct
909 +```
910 +
911 +or per request via `--media-io-kwargs`:
912 +
913 +```bash
914 +vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
915 + --media-io-kwargs '{"video": {"backend": "deepstream"}}'
916 +```
917 +
918 +**Parameters:**
919 +
920 +- `pool_size`: Number of GPU decode workers in the process-wide decode pool
921 + (clamped to `[1, 16]`). When unset it defaults to
922 + `VLLM_MEDIA_LOADING_THREAD_COUNT` (default `8`). The pool is a singleton, so
923 + the first request's value wins.
924 +
925 +```bash
926 +# Example: 12 decode workers
927 +vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
928 + --media-io-kwargs '{"video": {"backend": "deepstream", "pool_size": 12}}'
929 +```
930 +
931 #### Pre-extracted Frame Sequences with `media_io_kwargs`
932
933 When you extract video frames on the client side and send them as `video/jpeg` (base64-concatenated JPEG frames), you can preserve the original video metadata by using `media_io_kwargs` in your request. This enables more accurate video understanding by preserving temporal information that would otherwise be lost during client-side frame extraction.
setup.py
+3
@@ -1257,6 +1257,9 @@ setup(
1257 "mistral_common[audio]",
1258 ], # Required for audio processing
1259 "video": [], # Kept for backwards compatibility
1260 + # NVIDIA DeepStream (NVDEC) GPU video-decode backend. Linux x86-64
1261 + # only; also needs system GStreamer + libv4l (see docs).
1262 + "deepstream": ["nvidia-deepstream-videodecode-cu13>=9.0.2"],
1263 "flashinfer": [], # Kept for backwards compatibility
1264 # Optional deps for Helion kernel development
1265 # NOTE: When updating helion version, also update CI files:
vllm/multimodal/video.py
+160 -11
@@ -826,6 +826,114 @@ class PyNvVideoCodecVideoBackendMixin:
826 return frames, source, frame_idx, valid_frame_indices
827
828
829 +class DeepStreamVideoBackendMixin:
830 + """NVIDIA DeepStream (NVDEC) GPU-decode codec utilities.
831 +
832 + Decoding runs on a shared pool of daemon threads inside one CUDA
833 + context (see the ``nvidia-deepstream-videodecode-cu13`` package). The
834 + container bytes are pushed into an ``appsrc`` GStreamer pipeline, so no
835 + local file path is required — HTTP and base64 sources decode identically
836 + to local files.
837 +
838 + Like the OpenCV/PyAV mixins, this provides only the codec layer.
839 + Frame *selection* lives in the loader's
840 + ``compute_frames_index_to_sample`` and arrives here as an explicit
841 + list of frame indices.
842 + """
843 +
844 + # Process-wide lazy decode pool, shared across all DeepStream backends.
845 + _pool: ClassVar[Any] = None
846 + _pool_lock: ClassVar[Any] = None
847 +
848 + @classmethod
849 + def _get_pool(cls, pool_size: int | None = None):
850 + """Lazy-initialize the shared decode pool on first use.
851 +
852 + ``pool_size`` (number of decode worker threads) comes from
853 + ``--media-io-kwargs`` (``{"video": {"pool_size": N}}``); when unset it
854 + defaults to the existing ``VLLM_MEDIA_LOADING_THREAD_COUNT`` so no
855 + DeepStream-specific env var is needed. The pool is a process-wide
856 + singleton, so the first decode's value wins.
857 + """
858 + if cls._pool is not None:
859 + return cls._pool
860 + if cls._pool_lock is None:
861 + cls._pool_lock = threading.Lock()
862 + with cls._pool_lock:
863 + if cls._pool is not None:
864 + return cls._pool
865 + import os
866 +
867 + from nvidia.deepstream_videodecode import DecodePool
868 +
869 + if pool_size is None:
870 + pool_size = int(os.environ.get("VLLM_MEDIA_LOADING_THREAD_COUNT", 8))
871 + pool_size = max(1, min(int(pool_size), 16))
872 + logger.info(
873 + "[DeepStream] initializing decode pool with %d workers",
874 + pool_size,
875 + )
876 + cls._pool = DecodePool(num_workers=pool_size)
877 + return cls._pool
878 +
879 + @classmethod
880 + def decode_indices(
881 + cls,
882 + data: bytes,
883 + frame_indices: list[int],
884 + source: VideoSourceMetadata,
885 + codec: str = "",
886 + pool_size: int | None = None,
887 + timeout_sec: float = 120.0,
888 + ) -> tuple[npt.NDArray, list[int]]:
889 + """Decode the requested frame indices from raw container bytes.
890 +
891 + The whole stream is decoded; the pool keeps exactly the frames whose
892 + decode-order index is in ``frame_indices`` (1:1, frame-exact) and
893 + sends EOS once the last one is matched.
894 +
895 + ``codec`` (e.g. ``"h264"``/``"hevc"``) lets the pool keep its NVDEC
896 + session warm across same-codec streams and rebuild only on a codec
897 + change. Frames are returned as a CPU NHWC uint8 array so the
898 + upstream multimodal parser sees the same shape as the other
899 + backends.
900 + """
901 + if not frame_indices:
902 + raise ValueError("DeepStream backend received no frame indices")
903 +
904 + result = cls._get_pool(pool_size).decode(
905 + data,
906 + target_indices=frame_indices,
907 + codec=codec,
908 + max_frames=len(frame_indices),
909 + timeout_sec=timeout_sec,
910 + )
911 + if result.error:
912 + raise ValueError(f"DeepStream decode failed: {result.error}")
913 + if result.frames is None or result.n_kept == 0:
914 + raise ValueError("DeepStream decode produced no frames")
915 +
916 + valid = frame_indices[: result.n_kept]
917 + # GPU -> CPU NHWC uint8 at the codec boundary (one PCIe copy); keeps
918 + # the array shape identical to the OpenCV/PyAV backends. Copy into
919 + # PINNED host memory (reused across calls by PyTorch's pinned caching
920 + # allocator) so the D2H runs at full PCIe bandwidth (~13 GB/s) rather
921 + # than the ~1 GB/s pageable path that plain ``.cpu()`` takes — ~12x
922 + # faster for a 1080p x8 frame batch (~46ms -> ~4ms). ``numpy()`` keeps
923 + # the pinned tensor alive via the array's base.
924 + import torch
925 +
926 + gpu = result.frames
927 + if gpu.is_cuda:
928 + host = torch.empty(gpu.shape, dtype=gpu.dtype, pin_memory=True)
929 + host.copy_(gpu, non_blocking=True)
930 + torch.cuda.current_stream().synchronize()
931 + arr = host.numpy()
932 + else:
933 + arr = gpu.numpy()
934 + return arr, valid
935 +
936 +
937 @VIDEO_LOADER_REGISTRY.register("opencv")
938 class VideoBackend(
939 VideoLoader,
@@ -833,14 +941,15 @@ class VideoBackend(
941 PyAVVideoBackendMixin,
942 TorchCodecVideoBackendMixin,
943 PyNvVideoCodecVideoBackendMixin,
944 + DeepStreamVideoBackendMixin,
945 ):
946 """Uniform-sampling video backend.
947
948 Samples ``num_frames`` uniformly across the video (or one frame every
949 ``1/fps`` seconds, whichever produces fewer frames). The decoding codec
950 is selected via the ``backend`` kwarg (``"opencv"``, ``"pyav"``,
842 - ``"torchcodec"`` or ``"pynvvideocodec"``), which can be passed through
843 - ``--media-io-kwargs``. Defaults to ``"opencv"``.
951 + ``"torchcodec"``, ``"pynvvideocodec"``, or ``"deepstream"``),
952 + which can be passed through ``--media-io-kwargs``. Defaults to ``"opencv"``.
953 """
954
955 _sampling_suffix: ClassVar[str] = ""
@@ -885,7 +994,9 @@ class VideoBackend(
994 max_duration: int = 300,
995 frame_recovery: bool = False,
996 *,
888 - backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
997 + backend: Literal[
998 + "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
999 + ] = "opencv",
1000 num_ffmpeg_threads: int = 0,
1001 seek_mode: Literal["exact", "approximate"] = "exact",
1002 **kwargs,
@@ -901,7 +1012,7 @@ class VideoBackend(
1012 frame_recovery: Enable forward-scan recovery for failed frames.
1013 Only honored by the OpenCV codec.
1014 backend: Decoding codec — ``"opencv"``, ``"pyav"``,
904 - ``"torchcodec"`` or ``"pynvvideocodec"``.
1015 + ``"torchcodec"``, ``"pynvvideocodec"`` or ``"deepstream"``.
1016 num_ffmpeg_threads: Number of FFmpeg decoding threads, only used by
1017 TorchCodec: ``0`` (default) relies on the FFmpeg default value
1018 which is ``min(cpu_count + 1, 16)``.
@@ -982,11 +1093,37 @@ class VideoBackend(
1093 target,
1094 **kwargs,
1095 )
1096 + elif backend == "deepstream":
1097 + assert not frame_recovery, (
1098 + "frame_recovery is only available for `opencv` backend"
1099 + )
1100 + # Decode-pool size comes from media-io-kwargs (no env var); the
1101 + # pool is a process-wide singleton so the first decode's value
1102 + # wins. Pop it so it isn't forwarded to the frame sampler.
1103 + pool_size = kwargs.pop("pool_size", None)
1104 + # Probe container metadata from the bytes via GStreamer (in
1105 + # the deepstream video-decode wheel) — no PyAV/pymediainfo, no path.
1106 + from nvidia.deepstream_videodecode import probe_metadata
1107 +
1108 + total_frames, original_fps, duration, _w, _h, codec = probe_metadata(data)
1109 + source = cls._prepare_source(
1110 + VideoSourceMetadata(
1111 + total_frames_num=total_frames,
1112 + original_fps=original_fps,
1113 + duration=duration,
1114 + )
1115 + )
1116 + frame_idx = cls.compute_frames_index_to_sample(
1117 + source=source, target=target, **kwargs
1118 + )
1119 + frames, valid = cls.decode_indices(
1120 + data, frame_idx, source, codec=codec, pool_size=pool_size
1121 + )
1122 else:
1123 raise ValueError(
1124 f"Unknown video codec backend {backend!r}; "
1125 "valid options: 'opencv', 'pyav', 'torchcodec', "
989 - "'pynvvideocodec'."
1126 + "'pynvvideocodec' and 'deepstream'."
1127 )
1128
1129 if len(valid) < len(frame_idx):
@@ -1073,7 +1210,9 @@ class Qwen3VLVideoBackend(VideoBackend):
1210 max_duration: int = 300,
1211 frame_recovery: bool = False,
1212 *,
1076 - backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
1213 + backend: Literal[
1214 + "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
1215 + ] = "opencv",
1216 **kwargs,
1217 ) -> tuple[npt.NDArray, dict[str, Any]]:
1218 return super().load_bytes(
@@ -1152,7 +1291,9 @@ class Qwen2VLVideoBackend(VideoBackend):
1291 max_duration: int = 300,
1292 frame_recovery: bool = False,
1293 *,
1155 - backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
1294 + backend: Literal[
1295 + "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
1296 + ] = "opencv",
1297 **kwargs,
1298 ) -> tuple[npt.NDArray, dict[str, Any]]:
1299 return super().load_bytes(
@@ -1244,7 +1385,9 @@ class DynamicVideoBackend(VideoBackend):
1385 max_duration: int = 300,
1386 frame_recovery: bool = False,
1387 *,
1247 - backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
1388 + backend: Literal[
1389 + "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
1390 + ] = "opencv",
1391 **kwargs,
1392 ) -> tuple[npt.NDArray, dict[str, Any]]:
1393 return super().load_bytes(
@@ -1369,7 +1512,9 @@ class GLM46VVideoBackend(VideoBackend):
1512 max_duration: int = 300,
1513 frame_recovery: bool = False,
1514 *,
1372 - backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
1515 + backend: Literal[
1516 + "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
1517 + ] = "opencv",
1518 **kwargs,
1519 ) -> tuple[npt.NDArray, dict[str, Any]]:
1520 return super().load_bytes(
@@ -1467,7 +1612,9 @@ class GLMGAVideoBackend(VideoBackend):
1612 max_duration: int = 300,
1613 frame_recovery: bool = False,
1614 *,
1470 - backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
1615 + backend: Literal[
1616 + "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
1617 + ] = "opencv",
1618 **kwargs,
1619 ) -> tuple[npt.NDArray, dict[str, Any]]:
1620 frames, metadata = super().load_bytes(
@@ -1790,7 +1937,9 @@ class NemotronVLVideoBackend(VideoBackend):
1937 max_duration: int = 300,
1938 frame_recovery: bool = False,
1939 *,
1793 - backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
1940 + backend: Literal[
1941 + "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
1942 + ] = "opencv",
1943 **kwargs,
1944 ) -> tuple[npt.NDArray, dict[str, Any]]:
1945 frames, metadata = super().load_bytes(