[multimodal] Make PyNvVideoCodec decoder concurrency configurable (#49753)

Signed-off-by: Brandon Pelfrey <bpelfrey@nvidia.com>
Co-authored-by: OpenAI Codex <noreply@openai.com>
This commit is contained in:
Brandon Pelfrey
2026-07-24 23:31:41 -07:00
committed by GitHub
co-authored by OpenAI Codex
parent 0ba2aa35a8
commit 94682b79f4
7 changed files with 189 additions and 23 deletions
+21 -4
View File
@@ -818,16 +818,18 @@ Full example: [examples/generate/multimodal/openai_chat_completion_client_for_mu
#### Video Decoding Backend
vLLM decodes video bytes into frames using a selectable decoding backend. Three
vLLM decodes video bytes into frames using a selectable decoding backend. Five
backends are supported:
- `opencv` (default): OpenCV-based decoder.
- `pyav`: PyAV decoder.
- `torchcodec`: TorchCodec (PyTorch-native) decoder.
- `pynvvideocodec`: NVIDIA NVDEC-based decoder.
- `deepstream`: NVIDIA DeepStream NVDEC-based decoder.
All three backends are ultimately backed by FFmpeg. `torchcodec` lets
you choose which FFmpeg version is used while `opencv` and `pyav` rely on
whichever FFmpeg build they were linked against.
The CPU backends are backed by FFmpeg. `torchcodec` lets you choose which FFmpeg
version is used while `opencv` and `pyav` rely on whichever FFmpeg build they
were linked against.
Select the backend by passing the `backend` parameter via `--media-io-kwargs`:
@@ -854,6 +856,21 @@ vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
--media-io-kwargs '{"video": {"backend": "torchcodec", "seek_mode": "approximate", "num_ffmpeg_threads": 4}}'
```
**PyNvVideoCodec-specific parameters:**
- `hw_decoders`: Maximum number of concurrent hardware decoder slots retained
by each API server process. It must be a positive integer and defaults to `2`,
which is the recommended starting point for concurrent video workloads.
Because vLLM reserves GPU memory for these slots at startup, this value cannot
be overridden per request. Benchmark before increasing it because each
additional slot increases the GPU memory reservation.
```bash
# Example: explicitly use the recommended 2 hardware decoders
vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
--media-io-kwargs '{"video": {"backend": "pynvvideocodec", "hw_decoders": 2}}'
```
#### Video Frame Recovery
For improved robustness when processing potentially corrupted or truncated video files, vLLM supports optional frame recovery using a dynamic window forward-scan approach. When enabled, if a target frame fails to load during sequential reading, the next successfully grabbed frame (before the next target frame) will be used in its place.
+17
View File
@@ -404,6 +404,23 @@ class TestMergeKwargsGpuBackendPolicy:
)
assert result["backend"] == "pynvvideocodec"
def test_strips_request_level_hw_decoders_when_not_static(self):
result = VideoMediaIO.merge_kwargs(
default_kwargs={"video_backend": "pynvvideocodec"},
runtime_kwargs={"hw_decoders": 4},
)
assert "hw_decoders" not in result
def test_prevents_request_level_hw_decoders_override(self):
result = VideoMediaIO.merge_kwargs(
default_kwargs={
"video_backend": "pynvvideocodec",
"hw_decoders": 2,
},
runtime_kwargs={"hw_decoders": 4},
)
assert result["hw_decoders"] == 2
@pytest.mark.parametrize("backend", ["opencv", "pyav", "torchcodec"])
def test_software_video_backend_passes_through(self, backend: str):
result = VideoMediaIO.merge_kwargs(
+31 -4
View File
@@ -18,7 +18,6 @@ from vllm.multimodal.gpu_ipc_memory import (
from vllm.multimodal.video import (
PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES,
PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES,
PYNVVIDEOCODEC_MAX_RETAINED_DECODERS,
PYNVVIDEOCODEC_VIDEO_BACKEND,
)
from vllm.utils.mem_constants import GiB_bytes
@@ -28,17 +27,26 @@ def _mm_config(
*,
mm_ipc_gpu_memory_gb: float = 0,
video_backend: str | None = None,
hw_decoders: int | None = None,
) -> MultiModalConfig:
video_kwargs = {} if video_backend is None else {"video_backend": video_backend}
video_kwargs: dict[str, object] = (
{} if video_backend is None else {"video_backend": video_backend}
)
if hw_decoders is not None:
video_kwargs["hw_decoders"] = hw_decoders
return MultiModalConfig(
mm_ipc_gpu_memory_gb=mm_ipc_gpu_memory_gb,
media_io_kwargs={"video": video_kwargs} if video_kwargs else {},
)
def _pynvvideocodec_decoder_budget(api_process_count: int = 1) -> int:
def _pynvvideocodec_decoder_budget(
api_process_count: int = 1,
hw_decoders: int = 2,
) -> int:
return api_process_count * (
PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES * PYNVVIDEOCODEC_MAX_RETAINED_DECODERS
PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES * hw_decoders
+ PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES
)
@@ -240,3 +248,22 @@ def test_reserve_mm_ipc_gpu_memory_scales_decoder_budget_by_api_servers(
_mm_config(),
api_process_count=3,
) == available_bytes - _pynvvideocodec_decoder_budget(api_process_count=3)
def test_reserve_mm_ipc_gpu_memory_uses_configured_hw_decoders(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setattr(
multimodal_config_module.envs,
"VLLM_VIDEO_LOADER_BACKEND",
"opencv",
)
available_bytes = 4 * GiB_bytes
mm_config = _mm_config(
video_backend=PYNVVIDEOCODEC_VIDEO_BACKEND,
hw_decoders=3,
)
assert reserve_mm_ipc_gpu_memory(available_bytes, mm_config) == (
available_bytes - _pynvvideocodec_decoder_budget(hw_decoders=3)
)
+34 -4
View File
@@ -16,7 +16,6 @@ from transformers.video_utils import VideoMetadata
from vllm.assets.base import get_vllm_public_assets
from vllm.multimodal.video import (
PYNVVIDEOCODEC_DECODER_CACHE_SIZE,
PYNVVIDEOCODEC_MAX_RETAINED_DECODERS,
PYNVVIDEOCODEC_VIDEO_BACKEND,
VIDEO_LOADER_REGISTRY,
DynamicVideoBackend,
@@ -26,6 +25,7 @@ from vllm.multimodal.video import (
PyNvVideoCodecVideoBackend,
Qwen2VLVideoBackend,
Qwen3VLVideoBackend,
VideoBackend,
VideoLoader,
VideoSourceMetadata,
VideoTargetMetadata,
@@ -199,7 +199,11 @@ def test_pynvvideocodec_codec_uses_dynamic_sampling_strategy(
assert metadata["frames_indices"] == [0, 9]
def test_pynvvideocodec_decoder_slots_are_bounded(monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize("hw_decoders", [1, 3])
def test_pynvvideocodec_decoder_slots_are_bounded(
monkeypatch: pytest.MonkeyPatch,
hw_decoders: int,
):
class FakeSlot:
pass
@@ -207,10 +211,13 @@ def test_pynvvideocodec_decoder_slots_are_bounded(monkeypatch: pytest.MonkeyPatc
old_slots = PyNvVideoCodecVideoBackend._decoder_slots
old_active_slots = PyNvVideoCodecVideoBackend._active_decoder_slots
old_cond = PyNvVideoCodecVideoBackend._decoder_slot_cond
old_max_slots = PyNvVideoCodecVideoBackend._max_decoder_slots
try:
PyNvVideoCodecVideoBackend._decoder_slots = []
PyNvVideoCodecVideoBackend._active_decoder_slots = 0
PyNvVideoCodecVideoBackend._decoder_slot_cond = threading.Condition()
PyNvVideoCodecVideoBackend._max_decoder_slots = None
PyNvVideoCodecVideoBackend._configure_decoder_slots(hw_decoders)
def fake_create_slot(cls):
nonlocal create_count
@@ -229,7 +236,7 @@ def test_pynvvideocodec_decoder_slots_are_bounded(monkeypatch: pytest.MonkeyPatc
with ExitStack() as stack:
retained_slots = [
stack.enter_context(PyNvVideoCodecVideoBackend._borrow_decoder_slot())
for _ in range(PYNVVIDEOCODEC_MAX_RETAINED_DECODERS)
for _ in range(hw_decoders)
]
def borrow_extra_slot():
@@ -246,11 +253,34 @@ def test_pynvvideocodec_decoder_slots_are_bounded(monkeypatch: pytest.MonkeyPatc
assert not thread.is_alive()
assert seen_slots[0] in retained_slots
assert create_count == PYNVVIDEOCODEC_MAX_RETAINED_DECODERS
assert create_count == hw_decoders
finally:
PyNvVideoCodecVideoBackend._decoder_slots = old_slots
PyNvVideoCodecVideoBackend._active_decoder_slots = old_active_slots
PyNvVideoCodecVideoBackend._decoder_slot_cond = old_cond
PyNvVideoCodecVideoBackend._max_decoder_slots = old_max_slots
def test_pynvvideocodec_decoder_slots_are_configured_once(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setattr(PyNvVideoCodecVideoBackend, "_max_decoder_slots", None)
PyNvVideoCodecVideoBackend._configure_decoder_slots(2)
PyNvVideoCodecVideoBackend._configure_decoder_slots(2)
with pytest.raises(RuntimeError, match="already configured as 2, got 3"):
PyNvVideoCodecVideoBackend._configure_decoder_slots(3)
@pytest.mark.parametrize("hw_decoders", [0, -1, 1.5, True, "2"])
def test_pynvvideocodec_rejects_invalid_hw_decoders(hw_decoders: object):
with pytest.raises(ValueError, match="hw_decoders must be a positive integer"):
VideoBackend.load_bytes(
b"fake video",
backend=PYNVVIDEOCODEC_VIDEO_BACKEND,
hw_decoders=hw_decoders, # type: ignore[arg-type]
)
def test_pynvvideocodec_decoder_slot_retains_simple_decoder():
+49 -8
View File
@@ -157,19 +157,43 @@ def reserve_mm_ipc_gpu_memory(
mm_config: "MultiModalConfig | None",
api_process_count: int = 1,
) -> int:
"""Carve frontend multimodal GPU memory out of the KV cache.
"""Return KV-cache memory remaining after frontend multimodal reservations.
Raw decoded frames are bounded by ``mm_ipc_gpu_memory_gb`` and acquired by
the frontend semaphore. Some decoders also keep persistent surfaces around;
reserve a fixed upper bound for those when a GPU backend is configured.
The reservation covers:
* The total ``mm_ipc_gpu_memory_gb`` budget for transient decoded-frame
buffers. This budget is divided among API processes, so it is not
multiplied by ``api_process_count``.
* For GPU video backends, a fixed upper bound for each API process's
retained decoder surfaces and CUDA context. The PyNvVideoCodec surface
reservation scales with its configured ``hw_decoders`` value, and the
entire decoder reservation scales with ``api_process_count`` because
these resources are not shared between processes.
Args:
available_kv_cache_memory_bytes: KV-cache capacity before reserving
memory for frontend multimodal processing.
mm_config: Multimodal configuration, or ``None`` when multimodal
processing is disabled.
api_process_count: Number of frontend API processes sharing the GPU.
Values below one are treated as one.
Returns:
KV-cache capacity after subtracting the frontend reservation.
Raises:
ValueError: If the reservation leaves no memory for the KV cache.
"""
if mm_config is None:
return available_kv_cache_memory_bytes
from vllm import envs
from vllm.multimodal.video import (
PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES,
PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES,
PYNVVIDEOCODEC_MAX_RETAINED_DECODERS,
PYNVVIDEOCODEC_DEFAULT_HW_DECODERS,
PYNVVIDEOCODEC_VIDEO_BACKEND,
validate_pynvvideocodec_hw_decoders,
)
raw_frame_reserved_bytes = int(mm_config.mm_ipc_gpu_memory_gb * GiB_bytes)
@@ -177,8 +201,24 @@ def reserve_mm_ipc_gpu_memory(
# context on the GPU, outside the worker memory pool. Reserve that footprint
# per process so gpu_memory_utilization bounds total GPU usage across them.
num_api_servers = max(1, api_process_count)
video_kwargs = mm_config.media_io_kwargs.get("video", {})
video_loader_backend = (
video_kwargs.get("video_backend") or envs.VLLM_VIDEO_LOADER_BACKEND
)
codec_backend = video_kwargs.get("backend")
uses_pynvvideocodec = (
video_loader_backend == PYNVVIDEOCODEC_VIDEO_BACKEND
or codec_backend == PYNVVIDEOCODEC_VIDEO_BACKEND
)
hw_decoders = (
validate_pynvvideocodec_hw_decoders(
video_kwargs.get("hw_decoders", PYNVVIDEOCODEC_DEFAULT_HW_DECODERS)
)
if uses_pynvvideocodec
else 1
)
per_server_decoder_bytes = (
PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES * PYNVVIDEOCODEC_MAX_RETAINED_DECODERS
PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES * hw_decoders
+ PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES
)
decoder_reserved_bytes = (
@@ -198,8 +238,9 @@ def reserve_mm_ipc_gpu_memory(
f"({format_gib(raw_frame_reserved_bytes)} GiB raw-frame budget, "
f"{format_gib(decoder_reserved_bytes)} GiB decoder cache budget), "
f"but only {format_gib(available_kv_cache_memory_bytes)} GiB is "
"available for the KV cache. Reduce mm_ipc_gpu_memory_gb, use a "
"different video backend, or increase gpu_memory_utilization."
"available for the KV cache. Reduce mm_ipc_gpu_memory_gb or "
"hw_decoders, use a different video backend, or increase "
"gpu_memory_utilization."
)
logger.info_once(
"Reserving %s GiB of GPU memory for frontend multimodal decoding "
+4
View File
@@ -32,6 +32,10 @@ class VideoMediaIO(MediaIO[tuple[npt.NDArray, dict[str, Any]]]):
runtime_kwargs: dict[str, Any] | None,
) -> dict[str, Any]:
if runtime_kwargs:
# Decoder GPU memory is reserved from the startup value.
runtime_kwargs = dict(runtime_kwargs)
runtime_kwargs.pop("hw_decoders", None)
# Block request-level selection of GPU video backends that
# were not configured (and VRAM-reserved) at startup.
for key in ("video_backend", "backend"):
+33 -3
View File
@@ -210,15 +210,25 @@ class VideoLoader:
VIDEO_LOADER_REGISTRY = VideoLoaderRegistry()
PYNVVIDEOCODEC_VIDEO_BACKEND: Literal["pynvvideocodec"] = "pynvvideocodec"
# Fixed upper bound reserved for persistent PyNvVideoCodec decoder surfaces.
# Per-decoder upper bound reserved for persistent PyNvVideoCodec surfaces.
PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES = 128 * MiB_bytes
PYNVVIDEOCODEC_DECODER_CACHE_SIZE = 2
PYNVVIDEOCODEC_MAX_RETAINED_DECODERS = 1
PYNVVIDEOCODEC_DEFAULT_HW_DECODERS = 2
# Per-API-server CUDA context and driver allocation, measured with
# PyNvVideoCodec 2.0.4 on H100.
PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES = int(1.8 * 1024 * MiB_bytes)
def validate_pynvvideocodec_hw_decoders(hw_decoders: object) -> int:
if (
isinstance(hw_decoders, bool)
or not isinstance(hw_decoders, int)
or hw_decoders < 1
):
raise ValueError("hw_decoders must be a positive integer")
return hw_decoders
class PyNvVideoCodecDecoderSlot:
"""A retained PyNv decoder slot and its CUDA stream.
@@ -628,6 +638,7 @@ class PyNvVideoCodecVideoBackendMixin:
_decoder_slots: ClassVar[list[PyNvVideoCodecDecoderSlot]] = []
_active_decoder_slots: ClassVar[int] = 0
_decoder_slot_cond: ClassVar[threading.Condition] = threading.Condition()
_max_decoder_slots: ClassVar[int | None] = None
_DEVICE_INDEX: ClassVar[int] = 0
@classmethod
@@ -651,6 +662,18 @@ class PyNvVideoCodecVideoBackendMixin:
return PyNvVideoCodecDecoderSlot(torch.cuda.Stream(device=cls._DEVICE_INDEX))
@classmethod
def _configure_decoder_slots(cls, hw_decoders: object) -> None:
hw_decoders = validate_pynvvideocodec_hw_decoders(hw_decoders)
with cls._decoder_slot_cond:
if cls._max_decoder_slots is None:
cls._max_decoder_slots = hw_decoders
elif cls._max_decoder_slots != hw_decoders:
raise RuntimeError(
"PyNvVideoCodec decoder count is already configured as "
f"{cls._max_decoder_slots}, got {hw_decoders}"
)
@staticmethod
@contextmanager
def _torch_stream_context(stream):
@@ -669,11 +692,14 @@ class PyNvVideoCodecVideoBackendMixin:
def _borrow_decoder_slot(cls):
create_slot = False
with cls._decoder_slot_cond:
max_decoder_slots = cls._max_decoder_slots
if max_decoder_slots is None:
raise RuntimeError("PyNvVideoCodec decoder slots are not configured")
while True:
if cls._decoder_slots:
slot = cls._decoder_slots.pop()
break
if cls._active_decoder_slots < PYNVVIDEOCODEC_MAX_RETAINED_DECODERS:
if cls._active_decoder_slots < max_decoder_slots:
cls._active_decoder_slots += 1
create_slot = True
break
@@ -999,6 +1025,7 @@ class VideoBackend(
] = "opencv",
num_ffmpeg_threads: int = 0,
seek_mode: Literal["exact", "approximate"] = "exact",
hw_decoders: int = PYNVVIDEOCODEC_DEFAULT_HW_DECODERS,
**kwargs,
) -> tuple[npt.NDArray, dict[str, Any]]:
"""Load sampled frames from raw video bytes.
@@ -1025,6 +1052,8 @@ class VideoBackend(
at the cost of relying on the file's metadata. See
https://meta-pytorch.org/torchcodec/stable/generated_examples/decoding/approximate_mode.html
for details.
hw_decoders: Maximum number of concurrent PyNvVideoCodec decoder
slots. Defaults to 2 and must be a positive integer.
Returns:
Tuple of ``(frames_array, metadata_dict)``.
@@ -1088,6 +1117,7 @@ class VideoBackend(
"frame_recovery is not supported for "
f"`{PYNVVIDEOCODEC_VIDEO_BACKEND}` backend"
)
cls._configure_decoder_slots(hw_decoders)
frames, source, frame_idx, valid = cls.decode_frames_pynvvideocodec(
data,
target,