Skip to content

Commit e23b193

Browse files
ViranjanPagarclaudeIsotr0pyywang96
authored
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>
1 parent f36284a commit e23b193

3 files changed

Lines changed: 212 additions & 11 deletions

File tree

docs/features/multimodal_inputs.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,55 @@ vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
879879

880880
Works with common video formats like MP4 when using OpenCV backends.
881881

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+
882931
#### Pre-extracted Frame Sequences with `media_io_kwargs`
883932

884933
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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1257,6 +1257,9 @@ def add_vllm_package_data(filename: str) -> None:
12571257
"mistral_common[audio]",
12581258
], # Required for audio processing
12591259
"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"],
12601263
"flashinfer": [], # Kept for backwards compatibility
12611264
# Optional deps for Helion kernel development
12621265
# NOTE: When updating helion version, also update CI files:

vllm/multimodal/video.py

Lines changed: 160 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -826,21 +826,130 @@ def decode_frames_pynvvideocodec(
826826
return frames, source, frame_idx, valid_frame_indices
827827

828828

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+
829937
@VIDEO_LOADER_REGISTRY.register("opencv")
830938
class VideoBackend(
831939
VideoLoader,
832940
OpenCVVideoBackendMixin,
833941
PyAVVideoBackendMixin,
834942
TorchCodecVideoBackendMixin,
835943
PyNvVideoCodecVideoBackendMixin,
944+
DeepStreamVideoBackendMixin,
836945
):
837946
"""Uniform-sampling video backend.
838947
839948
Samples ``num_frames`` uniformly across the video (or one frame every
840949
``1/fps`` seconds, whichever produces fewer frames). The decoding codec
841950
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"``.
844953
"""
845954

846955
_sampling_suffix: ClassVar[str] = ""
@@ -885,7 +994,9 @@ def load_bytes(
885994
max_duration: int = 300,
886995
frame_recovery: bool = False,
887996
*,
888-
backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
997+
backend: Literal[
998+
"opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
999+
] = "opencv",
8891000
num_ffmpeg_threads: int = 0,
8901001
seek_mode: Literal["exact", "approximate"] = "exact",
8911002
**kwargs,
@@ -901,7 +1012,7 @@ def load_bytes(
9011012
frame_recovery: Enable forward-scan recovery for failed frames.
9021013
Only honored by the OpenCV codec.
9031014
backend: Decoding codec — ``"opencv"``, ``"pyav"``,
904-
``"torchcodec"`` or ``"pynvvideocodec"``.
1015+
``"torchcodec"``, ``"pynvvideocodec"`` or ``"deepstream"``.
9051016
num_ffmpeg_threads: Number of FFmpeg decoding threads, only used by
9061017
TorchCodec: ``0`` (default) relies on the FFmpeg default value
9071018
which is ``min(cpu_count + 1, 16)``.
@@ -982,11 +1093,37 @@ def load_bytes(
9821093
target,
9831094
**kwargs,
9841095
)
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+
)
9851122
else:
9861123
raise ValueError(
9871124
f"Unknown video codec backend {backend!r}; "
9881125
"valid options: 'opencv', 'pyav', 'torchcodec', "
989-
"'pynvvideocodec'."
1126+
"'pynvvideocodec' and 'deepstream'."
9901127
)
9911128

9921129
if len(valid) < len(frame_idx):
@@ -1073,7 +1210,9 @@ def load_bytes(
10731210
max_duration: int = 300,
10741211
frame_recovery: bool = False,
10751212
*,
1076-
backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
1213+
backend: Literal[
1214+
"opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
1215+
] = "opencv",
10771216
**kwargs,
10781217
) -> tuple[npt.NDArray, dict[str, Any]]:
10791218
return super().load_bytes(
@@ -1152,7 +1291,9 @@ def load_bytes(
11521291
max_duration: int = 300,
11531292
frame_recovery: bool = False,
11541293
*,
1155-
backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
1294+
backend: Literal[
1295+
"opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
1296+
] = "opencv",
11561297
**kwargs,
11571298
) -> tuple[npt.NDArray, dict[str, Any]]:
11581299
return super().load_bytes(
@@ -1244,7 +1385,9 @@ def load_bytes(
12441385
max_duration: int = 300,
12451386
frame_recovery: bool = False,
12461387
*,
1247-
backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
1388+
backend: Literal[
1389+
"opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
1390+
] = "opencv",
12481391
**kwargs,
12491392
) -> tuple[npt.NDArray, dict[str, Any]]:
12501393
return super().load_bytes(
@@ -1369,7 +1512,9 @@ def load_bytes(
13691512
max_duration: int = 300,
13701513
frame_recovery: bool = False,
13711514
*,
1372-
backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
1515+
backend: Literal[
1516+
"opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
1517+
] = "opencv",
13731518
**kwargs,
13741519
) -> tuple[npt.NDArray, dict[str, Any]]:
13751520
return super().load_bytes(
@@ -1467,7 +1612,9 @@ def load_bytes(
14671612
max_duration: int = 300,
14681613
frame_recovery: bool = False,
14691614
*,
1470-
backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
1615+
backend: Literal[
1616+
"opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
1617+
] = "opencv",
14711618
**kwargs,
14721619
) -> tuple[npt.NDArray, dict[str, Any]]:
14731620
frames, metadata = super().load_bytes(
@@ -1790,7 +1937,9 @@ def load_bytes(
17901937
max_duration: int = 300,
17911938
frame_recovery: bool = False,
17921939
*,
1793-
backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
1940+
backend: Literal[
1941+
"opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
1942+
] = "opencv",
17941943
**kwargs,
17951944
) -> tuple[npt.NDArray, dict[str, Any]]:
17961945
frames, metadata = super().load_bytes(

0 commit comments

Comments
 (0)