본문으로 건너뛰기

[vllm] vLLM, DeepStream NVDEC 백엔드로 비디오 디코딩 성능 혁신: GPU 가속의 힘

PR 링크: vllm-project/vllm#42424 상태: Merged | 변경: +212 / -11

들어가며

대규모 언어 모델(LLM)이 텍스트를 넘어 이미지, 오디오, 비디오 등 다양한 모달리티를 이해하는 멀티모달 AI 시대로 접어들면서, 입력 데이터 처리의 효율성은 전체 시스템 성능의 핵심 요소가 되고 있습니다. 특히 비디오 데이터는 그 용량이 크고 처리 과정이 복잡하여, 모델 추론의 병목 현상을 유발하기 쉽습니다.

vLLM은 기존에 OpenCV나 PyAV와 같은 CPU 기반 라이브러리를 사용하여 비디오를 디코딩했습니다. 이는 범용적이지만, 고해상도 또는 대량의 비디오를 처리할 때 CPU에 상당한 부담을 주어 전체 시스템의 처리량(throughput)과 응답 시간(latency)을 저하시키는 주범이었습니다. 이러한 문제를 해결하기 위해, vLLM은 NVIDIA DeepStream 기반의 새로운 비디오 로더 백엔드를 도입하는 PR을 병합했습니다. 이 PR은 NVIDIA GPU의 전용 하드웨어 비디오 디코더(NVDEC)를 활용하여 비디오 디코딩을 GPU에서 직접 수행함으로써, CPU 부하를 줄이고 비디오 처리 성능을 대폭 향상시키는 것을 목표로 합니다.

초기 PR에는 실시간 RTSP 스트리밍 캡셔닝 기능도 포함되어 있었으나, 리뷰어들의 피드백을 반영하여 DeepStream 기반 비디오 로더 백엔드 통합에 집중하고, 스트리밍 기능은 추후 별도의 PR로 분리하여 점진적으로 개선하는 방향으로 진행되었습니다. 이는 복잡한 기능을 한 번에 도입하기보다 핵심 최적화에 집중하여 안정성을 확보하려는 좋은 개발 관행을 보여줍니다.

코드 분석

이번 PR의 핵심 변경사항들을 파일별로 살펴보며, 각 변경이 어떤 최적화와 개선을 가져왔는지 분석해 보겠습니다.

setup.py: DeepStream 의존성 추가

가장 먼저 눈에 띄는 변화는 setup.pydeepstream이라는 새로운 extra_require가 추가된 것입니다. 이를 통해 사용자는 pip install vllm[deepstream] 명령으로 DeepStream 백엔드에 필요한 의존성을 쉽게 설치할 수 있게 되었습니다.

Before:

--- a/setup.py
+++ b/setup.py
@@ -1257,6 +1257,9 @@ def add_vllm_package_data(filename: str) -> None:
             "mistral_common[audio]",
         ],  # Required for audio processing
         "video": [],  # Kept for backwards compatibility

After:

--- a/setup.py
+++ b/setup.py
@@ -1257,6 +1257,9 @@ def add_vllm_package_data(filename: str) -> None:
             "mistral_common[audio]",
         ],  # Required for audio processing
         "video": [],  # Kept for backwards compatibility
+        # NVIDIA DeepStream (NVDEC) GPU video-decode backend. Linux x86-64
+        # only; also needs system GStreamer + libv4l (see docs).
+        "deepstream": ["nvidia-deepstream-videodecode-cu13>=9.0.2"],
         "flashinfer": [],  # Kept for backwards compatibility
         # Optional deps for Helion kernel development
         # NOTE: When updating helion version, also update CI files:

이 변경은 DeepStream 백엔드를 사용하려는 사용자에게 필요한 nvidia-deepstream-videodecode-cu13 패키지를 명시적으로 제공하여 설치 과정을 간소화합니다. 또한, DeepStream이 Linux x86-64 환경에서만 지원되며 GStreamer와 같은 시스템 패키지가 필요하다는 점을 주석으로 명시하여 사용자에게 중요한 정보를 제공합니다.

docs/features/multimodal_inputs.md: DeepStream 사용법 문서화

새로운 기능이 추가되면 그에 대한 명확한 문서화는 필수적입니다. multimodal_inputs.md 파일에 DeepStream 백엔드의 설치 및 사용 방법에 대한 상세한 가이드가 추가되었습니다.

Before:

--- a/docs/features/multimodal_inputs.md
+++ b/docs/features/multimodal_inputs.md
@@ -879,6 +879,55 @@ vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
 
 Works with common video formats like MP4 when using OpenCV backends.
 

After:

--- a/docs/features/multimodal_inputs.md
+++ b/docs/features/multimodal_inputs.md
@@ -879,6 +879,55 @@ vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
 
 Works with common video formats like MP4 when using OpenCV backends.
 
+#### GPU Video Decoding with DeepStream (NVDEC)
+
+By default vLLM decodes video on the CPU. On NVIDIA GPUs you can instead decode
+directly on the hardware video engine (NVDEC) with the DeepStream backend, which
+keeps decoding off the CPU and can significantly increase video throughput.
+
+Install the backend (Linux x86-64 only):
+
+```bash
+pip install vllm[deepstream]
+```
+
+The pip wheel bundles the DeepStream libraries but still relies on a few system
+packages that pip cannot install. On Ubuntu:
+
+```bash
+apt-get install -y \
+  gstreamer1.0-tools gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
+  gstreamer1.0-plugins-bad gstreamer1.0-libav \
+  python3-gi python3-gst-1.0 libv4l-0 cuda-libraries-13-0
+```
+
+Select the backend either with an environment variable:
+
+```bash
+export VLLM_VIDEO_LOADER_BACKEND=deepstream
+vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct
+```
+
+or per request via `--media-io-kwargs`:
+
+```bash
+vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
+  --media-io-kwargs '{"video": {"backend": "deepstream"}}'
+```
+
+**Parameters:**
+
+- `pool_size`: Number of GPU decode workers in the process-wide decode pool
+  (clamped to `[1, 16]`). When unset it defaults to
+  `VLLM_MEDIA_LOADING_THREAD_COUNT` (default `8`). The pool is a singleton, so
+  the first request's value wins.
+
+```bash
+# Example: 12 decode workers
+vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
+  --media-io-kwargs '{"video": {"backend": "deepstream", "pool_size": 12}}'
+```
+
 #### Pre-extracted Frame Sequences with `media_io_kwargs`
 
 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.

이 문서는 DeepStream 백엔드의 장점을 설명하고, pip installapt-get install을 통한 설치 방법을 안내합니다. 특히, VLLM_VIDEO_LOADER_BACKEND 환경 변수 또는 --media-io-kwargs를 통해 백엔드를 선택하는 유연한 방법을 제공하며, pool_size와 같은 DeepStream 관련 파라미터 설정 방법까지 상세히 설명합니다. pool_size는 디코딩 워커 스레드의 수를 제어하여 시스템 리소스 활용을 최적화하는 데 중요합니다.

vllm/multimodal/video.py: DeepStream 백엔드 핵심 로직

이 파일은 DeepStream 백엔드의 핵심 구현을 담고 있습니다. DeepStreamVideoBackendMixin 클래스가 새로 추가되었고, 기존 VideoBackend 클래스에 통합되었습니다.

DeepStreamVideoBackendMixin 클래스 추가

Before: (해당 클래스 없음)

After:

--- a/vllm/multimodal/video.py
+++ b/vllm/multimodal/video.py
@@ -826,6 +826,114 @@ def decode_frames_pynvvideocodec(
         return frames, source, frame_idx, valid_frame_indices
 
 
+class DeepStreamVideoBackendMixin:
+    """NVIDIA DeepStream (NVDEC) GPU-decode codec utilities.
+
+    Decoding runs on a shared pool of daemon threads inside one CUDA
+    context (see the ``nvidia-deepstream-videodecode-cu13`` package). The
+    container bytes are pushed into an ``appsrc`` GStreamer pipeline, so no
+    local file path is required — HTTP and base64 sources decode identically
+    to local files.
+
+    Like the OpenCV/PyAV mixins, this provides only the codec layer.
+    Frame *selection* lives in the loader's
+    ``compute_frames_index_to_sample`` and arrives here as an explicit
+    list of frame indices.
+    """
+
+    # Process-wide lazy decode pool, shared across all DeepStream backends.
+    _pool: ClassVar[Any] = None
+    _pool_lock: ClassVar[Any] = None
+
+    @classmethod
+    def _get_pool(cls, pool_size: int | None = None):
+        """Lazy-initialize the shared decode pool on first use.
+
+        ``pool_size`` (number of decode worker threads) comes from
+        ``--media-io-kwargs`` (``{"video": {"pool_size": N}}``); when unset it
+        defaults to the existing ``VLLM_MEDIA_LOADING_THREAD_COUNT`` so no
+        DeepStream-specific env var is needed. The pool is a process-wide
+        singleton, so the first decode's value wins.
+        """
+        if cls._pool is not None:
+            return cls._pool
+        if cls._pool_lock is None:
+            cls._pool_lock = threading.Lock()
+        with cls._pool_lock:
+            if cls._pool is not None:
+                return cls._pool
+            import os
+
+            from nvidia.deepstream_videodecode import DecodePool
+
+            if pool_size is None:
+                pool_size = int(os.environ.get("VLLM_MEDIA_LOADING_THREAD_COUNT", 8))
+            pool_size = max(1, min(int(pool_size), 16))
+            logger.info(
+                "[DeepStream] initializing decode pool with %d workers",
+                pool_size,
+            )
+            cls._pool = DecodePool(num_workers=pool_size)
+            return cls._pool
+
+    @classmethod
+    def decode_indices(
+        cls,
+        data: bytes,
+        frame_indices: list[int],
+        source: VideoSourceMetadata,
+        codec: str = "",
+        pool_size: int | None = None,
+        timeout_sec: float = 120.0,
+    ) -> tuple[npt.NDArray, list[int]]:
+        """Decode the requested frame indices from raw container bytes.
+
+        The whole stream is decoded; the pool keeps exactly the frames whose
+        decode-order index is in ``frame_indices`` (1:1, frame-exact) and
+        sends EOS once the last one is matched.
+
+        ``codec`` (e.g. ``"h264"``/``"hevc"``) lets the pool keep its NVDEC
+        session warm across same-codec streams and rebuild only on a codec
+        change. Frames are returned as a CPU NHWC uint8 array so the
+        upstream multimodal parser sees the same shape as the other
+        backends.
+        """
+        if not frame_indices:
+            raise ValueError("DeepStream backend received no frame indices")
+
+        result = cls._get_pool(pool_size).decode(
+            data,
+            target_indices=frame_indices,
+            codec=codec,
+            max_frames=len(frame_indices),
+            timeout_sec=timeout_sec,
+        )
+        if result.error:
+            raise ValueError(f"DeepStream decode failed: {result.error}")
+        if result.frames is None or result.n_kept == 0:
+            raise ValueError("DeepStream decode produced no frames")
+
+        valid = frame_indices[: result.n_kept]
+        # GPU -> CPU NHWC uint8 at the codec boundary (one PCIe copy); keeps
+        # the array shape identical to the OpenCV/PyAV backends. Copy into
+        # PINNED host memory (reused across calls by PyTorch's pinned caching
+        # allocator) so the D2H runs at full PCIe bandwidth (~13 GB/s) rather
+        # than the ~1 GB/s pageable path that plain ``.cpu()`` takes — ~12x
+        # faster for a 1080p x8 frame batch (~46ms -> ~4ms). ``numpy()`` keeps
+        # the pinned tensor alive via the array's base.
+        import torch
+
+        gpu = result.frames
+        if gpu.is_cuda:
+            host = torch.empty(gpu.shape, dtype=gpu.dtype, pin_memory=True)
+            host.copy_(gpu, non_blocking=True)
+            torch.cuda.current_stream().synchronize()
+            arr = host.numpy()
+        else:
+            arr = gpu.numpy()
+        return arr, valid
+
+

DeepStreamVideoBackendMixin은 DeepStream을 이용한 GPU 디코딩 로직을 캡슐화합니다. 특히 다음 두 가지 중요한 최적화가 포함되어 있습니다:

  1. 지연 초기화(Lazy Initialization) 및 공유 디코딩 풀(_get_pool): _get_pool 메서드는 DeepStream의 DecodePool을 프로세스 전역 싱글톤으로 지연 초기화합니다. 이는 리소스 집약적인 디코딩 풀을 첫 요청 시에만 생성하고, 모든 DeepStream 백엔드가 이 풀을 공유하도록 하여 리소스 낭비를 줄입니다. pool_sizeVLLM_MEDIA_LOADING_THREAD_COUNT 환경 변수 또는 --media-io-kwargs를 통해 설정할 수 있으며, 1에서 16 사이로 제한되어 과도한 리소스 할당을 방지합니다. 리뷰어의 지적처럼, nvidia-deepstream-videodecode-cu13 패키지는 임포트 시 GStreamer 플러그인 검색 및 .so 파일 로딩을 수행하므로, 모듈 최상단 임포트 대신 지연 임포트를 사용하여 불필요한 오버헤드를 피합니다.

  2. 최적화된 GPU-to-CPU 메모리 전송(decode_indices): decode_indices 메서드는 DeepStream 디코딩 풀에서 특정 프레임 인덱스를 디코딩합니다. 여기서 가장 주목할 만한 최적화는 디코딩된 GPU 텐서를 CPU로 전송하는 방식입니다. 일반적인 .cpu() 호출은 페이지 가능한(pageable) 메모리 경로를 사용하지만, 이 구현은 torch.empty(..., pin_memory=True)를 사용하여 고정(pinned) 호스트 메모리를 할당하고 host.copy_(gpu, non_blocking=True)로 비동기 복사를 수행합니다. 이후 torch.cuda.current_stream().synchronize()를 통해 복사가 완료될 때까지 기다립니다. 이 방식은 PCIe 대역폭을 최대한 활용하여 1080p x8 프레임 배치 기준으로 약 12배 빠른 전송 속도(46ms -> 4ms)를 달성합니다. 이는 GPU에서 디코딩된 데이터를 CPU로 가져와 후처리하는 과정에서 발생하는 병목을 크게 줄여줍니다.

VideoBackend 클래스 수정

VideoBackend 클래스는 DeepStreamVideoBackendMixin을 상속받아 DeepStream 기능을 통합합니다.

Before:

--- a/vllm/multimodal/video.py
+++ b/vllm/multimodal/video.py
@@ -833,14 +941,15 @@ class VideoBackend(
     PyAVVideoBackendMixin,
     TorchCodecVideoBackendMixin,
     PyNvVideoCodecVideoBackendMixin,
 ):
     """Uniform-sampling video backend.
 
     Samples ``num_frames`` uniformly across the video (or one frame every
     ``1/fps`` seconds, whichever produces fewer frames). The decoding codec
     is selected via the ``backend`` kwarg (``"opencv"``, ``"pyav"``,
-    ``"torchcodec"`` or ``"pynvvideocodec"``), which can be passed through
-    ``--media-io-kwargs``. Defaults to ``"opencv"``.
+    ``"torchcodec"``, ``"pynvvideocodec"``, or ``"deepstream"``),
+    which can be passed through ``--media-io-kwargs``. Defaults to ``"opencv"``.
     """
 
     _sampling_suffix: ClassVar[str] = ""

After:

--- a/vllm/multimodal/video.py
+++ b/vllm/multimodal/video.py
@@ -833,14 +941,15 @@ class VideoBackend(
     PyAVVideoBackendMixin,
     TorchCodecVideoBackendMixin,
     PyNvVideoCodecVideoBackendMixin,
+    DeepStreamVideoBackendMixin,
 ):
     """Uniform-sampling video backend.
 
     Samples ``num_frames`` uniformly across the video (or one frame every
     ``1/fps`` seconds, whichever produces fewer frames). The decoding codec
     is selected via the ``backend`` kwarg (``"opencv"``, ``"pyav"``,
-    ``"torchcodec"`` or ``"pynvvideocodec"``), which can be passed through
-    ``--media-io-kwargs``. Defaults to ``"opencv"``.
+    ``"torchcodec"``, ``"pynvvideocodec"``, or ``"deepstream"``),
+    which can be passed through ``--media-io-kwargs``. Defaults to ``"opencv"``.
     """
 
     _sampling_suffix: ClassVar[str] = ""

이 변경은 VideoBackend가 DeepStream 기능을 사용할 수 있도록 하며, backend 타입 힌트에 "deepstream" 옵션을 추가하여 코드의 명확성과 유효성을 높입니다. 이는 Isotr0py 리뷰어의 제안을 반영하여 DeepStreamVideoBackend와 같은 별도의 클래스 대신 Mixin 패턴을 사용하여 기존 구조에 자연스럽게 통합한 결과입니다.

load_bytes 메서드 수정

load_bytes 메서드는 backend 인수에 따라 적절한 디코딩 백엔드를 선택하도록 확장되었습니다.

Before:

--- a/vllm/multimodal/video.py
+++ b/vllm/multimodal/video.py
@@ -885,7 +994,9 @@ def load_bytes(
         max_duration: int = 300,
         frame_recovery: bool = False,
         *,
-        backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
+        backend: Literal[
+            "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
+        ] = "opencv",
         num_ffmpeg_threads: int = 0,
         seek_mode: Literal["exact", "approximate"] = "exact",
         **kwargs,
@@ -901,7 +1012,7 @@ def load_bytes(
             frame_recovery: Enable forward-scan recovery for failed frames.
                 Only honored by the OpenCV codec.
             backend: Decoding codec — ``"opencv"``, ``"pyav"``,
-                ``"torchcodec"`` or ``"pynvvideocodec"``.
+                ``"torchcodec"``, ``"pynvvideocodec"`` or ``"deepstream"``.
             num_ffmpeg_threads: Number of FFmpeg decoding threads, only used by
                 TorchCodec: ``0`` (default) relies on the FFmpeg default value
                 which is ``min(cpu_count + 1, 16)``.
@@ -982,11 +1093,37 @@ def load_bytes(
                 target,
                 **kwargs,
             )
+        elif backend == "deepstream":
+            assert not frame_recovery, (
+                "frame_recovery is only available for `opencv` backend"
+            )
+            # Decode-pool size comes from media-io-kwargs (no env var); the
+            # pool is a process-wide singleton so the first decode's value
+            # wins. Pop it so it isn't forwarded to the frame sampler.
+            pool_size = kwargs.pop("pool_size", None)
+            # Probe container metadata from the bytes via GStreamer (in
+            # the deepstream video-decode wheel) — no PyAV/pymediainfo, no path.
+            from nvidia.deepstream_videodecode import probe_metadata
+
+            total_frames, original_fps, duration, _w, _h, codec = probe_metadata(data)
+            source = cls._prepare_source(
+                VideoSourceMetadata(
+                    total_frames_num=total_frames,
+                    original_fps=original_fps,
+                    duration=duration,
+                )
+            )
+            frame_idx = cls.compute_frames_index_to_sample(
+                source=source, target=target, **kwargs
+            )
+            frames, valid = cls.decode_indices(
+                data, frame_idx, source, codec=codec, pool_size=pool_size
+            )
         else:
             raise ValueError(
                 f"Unknown video codec backend {backend!r}; "
                 

## 참고 자료
- https://developer.nvidia.com/deepstream-sdk
- https://gstreamer.freedesktop.org/documentation/
- https://pytorch.org/docs/stable/notes/cuda.html#pinned-memory-host-to-gpu-copy
- https://pytorch.org/docs/stable/generated/torch.cuda.current_stream.html

> ⚠️ **알림:** 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.

댓글

관련 포스트

PR Analysis 의 다른글