[multimodal] Make PyNvVideoCodec decoder concurrency configurable (#49753)
Signed-off-by: Brandon Pelfrey <bpelfrey@nvidia.com> Co-authored-by: OpenAI Codex <noreply@openai.com>
Brandon Pelfrey committed
Jul 24, 2026 at 23:31 UTC
94682b79f40239369bcefdbebee683a6bed90c85
7 files changed
+190
-24
docs/features/multimodal_inputs.md
+21
-4
@@ -818,16 +818,18 @@ Full example: [examples/generate/multimodal/openai_chat_completion_client_for_mu
818
819
#### Video Decoding Backend
820
821
-vLLM decodes video bytes into frames using a selectable decoding backend. Three
821
+vLLM decodes video bytes into frames using a selectable decoding backend. Five
822
backends are supported:
823
824
- `opencv` (default): OpenCV-based decoder.
825
- `pyav`: PyAV decoder.
826
- `torchcodec`: TorchCodec (PyTorch-native) decoder.
827
+- `pynvvideocodec`: NVIDIA NVDEC-based decoder.
828
+- `deepstream`: NVIDIA DeepStream NVDEC-based decoder.
829
828
-All three backends are ultimately backed by FFmpeg. `torchcodec` lets
829
-you choose which FFmpeg version is used while `opencv` and `pyav` rely on
830
-whichever FFmpeg build they were linked against.
830
+The CPU backends are backed by FFmpeg. `torchcodec` lets you choose which FFmpeg
831
+version is used while `opencv` and `pyav` rely on whichever FFmpeg build they
832
+were linked against.
833
834
Select the backend by passing the `backend` parameter via `--media-io-kwargs`:
835
@@ -854,6 +856,21 @@ vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
856
--media-io-kwargs '{"video": {"backend": "torchcodec", "seek_mode": "approximate", "num_ffmpeg_threads": 4}}'
857
```
858
859
+**PyNvVideoCodec-specific parameters:**
860
+
861
+- `hw_decoders`: Maximum number of concurrent hardware decoder slots retained
862
+ by each API server process. It must be a positive integer and defaults to `2`,
863
+ which is the recommended starting point for concurrent video workloads.
864
+ Because vLLM reserves GPU memory for these slots at startup, this value cannot
865
+ be overridden per request. Benchmark before increasing it because each
866
+ additional slot increases the GPU memory reservation.
867
+
868
+```bash
869
+# Example: explicitly use the recommended 2 hardware decoders
870
+vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
871
+ --media-io-kwargs '{"video": {"backend": "pynvvideocodec", "hw_decoders": 2}}'
872
+```
873
+
874
#### Video Frame Recovery
875
876
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.
tests/multimodal/media/test_video.py
+17
@@ -404,6 +404,23 @@ class TestMergeKwargsGpuBackendPolicy:
404
)
405
assert result["backend"] == "pynvvideocodec"
406
407
+ def test_strips_request_level_hw_decoders_when_not_static(self):
408
+ result = VideoMediaIO.merge_kwargs(
409
+ default_kwargs={"video_backend": "pynvvideocodec"},
410
+ runtime_kwargs={"hw_decoders": 4},
411
+ )
412
+ assert "hw_decoders" not in result
413
+
414
+ def test_prevents_request_level_hw_decoders_override(self):
415
+ result = VideoMediaIO.merge_kwargs(
416
+ default_kwargs={
417
+ "video_backend": "pynvvideocodec",
418
+ "hw_decoders": 2,
419
+ },
420
+ runtime_kwargs={"hw_decoders": 4},
421
+ )
422
+ assert result["hw_decoders"] == 2
423
+
424
@pytest.mark.parametrize("backend", ["opencv", "pyav", "torchcodec"])
425
def test_software_video_backend_passes_through(self, backend: str):
426
result = VideoMediaIO.merge_kwargs(
tests/multimodal/test_gpu_ipc_memory.py
+31
-4
@@ -18,7 +18,6 @@ from vllm.multimodal.gpu_ipc_memory import (
18
from vllm.multimodal.video import (
19
PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES,
20
PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES,
21
- PYNVVIDEOCODEC_MAX_RETAINED_DECODERS,
21
PYNVVIDEOCODEC_VIDEO_BACKEND,
22
)
23
from vllm.utils.mem_constants import GiB_bytes
@@ -28,17 +27,26 @@ def _mm_config(
27
*,
28
mm_ipc_gpu_memory_gb: float = 0,
29
video_backend: str | None = None,
30
+ hw_decoders: int | None = None,
31
) -> MultiModalConfig:
32
- video_kwargs = {} if video_backend is None else {"video_backend": video_backend}
32
+ video_kwargs: dict[str, object] = (
33
+ {} if video_backend is None else {"video_backend": video_backend}
34
+ )
35
+ if hw_decoders is not None:
36
+ video_kwargs["hw_decoders"] = hw_decoders
37
+
38
return MultiModalConfig(
39
mm_ipc_gpu_memory_gb=mm_ipc_gpu_memory_gb,
40
media_io_kwargs={"video": video_kwargs} if video_kwargs else {},
41
)
42
43
39
-def _pynvvideocodec_decoder_budget(api_process_count: int = 1) -> int:
44
+def _pynvvideocodec_decoder_budget(
45
+ api_process_count: int = 1,
46
+ hw_decoders: int = 2,
47
+) -> int:
48
return api_process_count * (
41
- PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES * PYNVVIDEOCODEC_MAX_RETAINED_DECODERS
49
+ PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES * hw_decoders
50
+ PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES
51
)
52
@@ -240,3 +248,22 @@ def test_reserve_mm_ipc_gpu_memory_scales_decoder_budget_by_api_servers(
248
_mm_config(),
249
api_process_count=3,
250
) == available_bytes - _pynvvideocodec_decoder_budget(api_process_count=3)
251
+
252
+
253
+def test_reserve_mm_ipc_gpu_memory_uses_configured_hw_decoders(
254
+ monkeypatch: pytest.MonkeyPatch,
255
+):
256
+ monkeypatch.setattr(
257
+ multimodal_config_module.envs,
258
+ "VLLM_VIDEO_LOADER_BACKEND",
259
+ "opencv",
260
+ )
261
+ available_bytes = 4 * GiB_bytes
262
+ mm_config = _mm_config(
263
+ video_backend=PYNVVIDEOCODEC_VIDEO_BACKEND,
264
+ hw_decoders=3,
265
+ )
266
+
267
+ assert reserve_mm_ipc_gpu_memory(available_bytes, mm_config) == (
268
+ available_bytes - _pynvvideocodec_decoder_budget(hw_decoders=3)
269
+ )
tests/multimodal/test_video.py
+34
-4
@@ -16,7 +16,6 @@ from transformers.video_utils import VideoMetadata
16
from vllm.assets.base import get_vllm_public_assets
17
from vllm.multimodal.video import (
18
PYNVVIDEOCODEC_DECODER_CACHE_SIZE,
19
- PYNVVIDEOCODEC_MAX_RETAINED_DECODERS,
19
PYNVVIDEOCODEC_VIDEO_BACKEND,
20
VIDEO_LOADER_REGISTRY,
21
DynamicVideoBackend,
@@ -26,6 +25,7 @@ from vllm.multimodal.video import (
25
PyNvVideoCodecVideoBackend,
26
Qwen2VLVideoBackend,
27
Qwen3VLVideoBackend,
28
+ VideoBackend,
29
VideoLoader,
30
VideoSourceMetadata,
31
VideoTargetMetadata,
@@ -199,7 +199,11 @@ def test_pynvvideocodec_codec_uses_dynamic_sampling_strategy(
199
assert metadata["frames_indices"] == [0, 9]
200
201
202
-def test_pynvvideocodec_decoder_slots_are_bounded(monkeypatch: pytest.MonkeyPatch):
202
+@pytest.mark.parametrize("hw_decoders", [1, 3])
203
+def test_pynvvideocodec_decoder_slots_are_bounded(
204
+ monkeypatch: pytest.MonkeyPatch,
205
+ hw_decoders: int,
206
+):
207
class FakeSlot:
208
pass
209
@@ -207,10 +211,13 @@ def test_pynvvideocodec_decoder_slots_are_bounded(monkeypatch: pytest.MonkeyPatc
211
old_slots = PyNvVideoCodecVideoBackend._decoder_slots
212
old_active_slots = PyNvVideoCodecVideoBackend._active_decoder_slots
213
old_cond = PyNvVideoCodecVideoBackend._decoder_slot_cond
214
+ old_max_slots = PyNvVideoCodecVideoBackend._max_decoder_slots
215
try:
216
PyNvVideoCodecVideoBackend._decoder_slots = []
217
PyNvVideoCodecVideoBackend._active_decoder_slots = 0
218
PyNvVideoCodecVideoBackend._decoder_slot_cond = threading.Condition()
219
+ PyNvVideoCodecVideoBackend._max_decoder_slots = None
220
+ PyNvVideoCodecVideoBackend._configure_decoder_slots(hw_decoders)
221
222
def fake_create_slot(cls):
223
nonlocal create_count
@@ -229,7 +236,7 @@ def test_pynvvideocodec_decoder_slots_are_bounded(monkeypatch: pytest.MonkeyPatc
236
with ExitStack() as stack:
237
retained_slots = [
238
stack.enter_context(PyNvVideoCodecVideoBackend._borrow_decoder_slot())
232
- for _ in range(PYNVVIDEOCODEC_MAX_RETAINED_DECODERS)
239
+ for _ in range(hw_decoders)
240
]
241
242
def borrow_extra_slot():
@@ -246,11 +253,34 @@ def test_pynvvideocodec_decoder_slots_are_bounded(monkeypatch: pytest.MonkeyPatc
253
assert not thread.is_alive()
254
255
assert seen_slots[0] in retained_slots
249
- assert create_count == PYNVVIDEOCODEC_MAX_RETAINED_DECODERS
256
+ assert create_count == hw_decoders
257
finally:
258
PyNvVideoCodecVideoBackend._decoder_slots = old_slots
259
PyNvVideoCodecVideoBackend._active_decoder_slots = old_active_slots
260
PyNvVideoCodecVideoBackend._decoder_slot_cond = old_cond
261
+ PyNvVideoCodecVideoBackend._max_decoder_slots = old_max_slots
262
+
263
+
264
+def test_pynvvideocodec_decoder_slots_are_configured_once(
265
+ monkeypatch: pytest.MonkeyPatch,
266
+):
267
+ monkeypatch.setattr(PyNvVideoCodecVideoBackend, "_max_decoder_slots", None)
268
+
269
+ PyNvVideoCodecVideoBackend._configure_decoder_slots(2)
270
+ PyNvVideoCodecVideoBackend._configure_decoder_slots(2)
271
+
272
+ with pytest.raises(RuntimeError, match="already configured as 2, got 3"):
273
+ PyNvVideoCodecVideoBackend._configure_decoder_slots(3)
274
+
275
+
276
+@pytest.mark.parametrize("hw_decoders", [0, -1, 1.5, True, "2"])
277
+def test_pynvvideocodec_rejects_invalid_hw_decoders(hw_decoders: object):
278
+ with pytest.raises(ValueError, match="hw_decoders must be a positive integer"):
279
+ VideoBackend.load_bytes(
280
+ b"fake video",
281
+ backend=PYNVVIDEOCODEC_VIDEO_BACKEND,
282
+ hw_decoders=hw_decoders, # type: ignore[arg-type]
283
+ )
284
285
286
def test_pynvvideocodec_decoder_slot_retains_simple_decoder():
vllm/multimodal/gpu_ipc_memory.py
+50
-9
@@ -157,19 +157,43 @@ def reserve_mm_ipc_gpu_memory(
157
mm_config: "MultiModalConfig | None",
158
api_process_count: int = 1,
159
) -> int:
160
- """Carve frontend multimodal GPU memory out of the KV cache.
161
-
162
- Raw decoded frames are bounded by ``mm_ipc_gpu_memory_gb`` and acquired by
163
- the frontend semaphore. Some decoders also keep persistent surfaces around;
164
- reserve a fixed upper bound for those when a GPU backend is configured.
160
+ """Return KV-cache memory remaining after frontend multimodal reservations.
161
+
162
+ The reservation covers:
163
+
164
+ * The total ``mm_ipc_gpu_memory_gb`` budget for transient decoded-frame
165
+ buffers. This budget is divided among API processes, so it is not
166
+ multiplied by ``api_process_count``.
167
+ * For GPU video backends, a fixed upper bound for each API process's
168
+ retained decoder surfaces and CUDA context. The PyNvVideoCodec surface
169
+ reservation scales with its configured ``hw_decoders`` value, and the
170
+ entire decoder reservation scales with ``api_process_count`` because
171
+ these resources are not shared between processes.
172
+
173
+ Args:
174
+ available_kv_cache_memory_bytes: KV-cache capacity before reserving
175
+ memory for frontend multimodal processing.
176
+ mm_config: Multimodal configuration, or ``None`` when multimodal
177
+ processing is disabled.
178
+ api_process_count: Number of frontend API processes sharing the GPU.
179
+ Values below one are treated as one.
180
+
181
+ Returns:
182
+ KV-cache capacity after subtracting the frontend reservation.
183
+
184
+ Raises:
185
+ ValueError: If the reservation leaves no memory for the KV cache.
186
"""
187
if mm_config is None:
188
return available_kv_cache_memory_bytes
189
190
+ from vllm import envs
191
from vllm.multimodal.video import (
192
PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES,
193
PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES,
172
- PYNVVIDEOCODEC_MAX_RETAINED_DECODERS,
194
+ PYNVVIDEOCODEC_DEFAULT_HW_DECODERS,
195
+ PYNVVIDEOCODEC_VIDEO_BACKEND,
196
+ validate_pynvvideocodec_hw_decoders,
197
)
198
199
raw_frame_reserved_bytes = int(mm_config.mm_ipc_gpu_memory_gb * GiB_bytes)
@@ -177,8 +201,24 @@ def reserve_mm_ipc_gpu_memory(
201
# context on the GPU, outside the worker memory pool. Reserve that footprint
202
# per process so gpu_memory_utilization bounds total GPU usage across them.
203
num_api_servers = max(1, api_process_count)
204
+ video_kwargs = mm_config.media_io_kwargs.get("video", {})
205
+ video_loader_backend = (
206
+ video_kwargs.get("video_backend") or envs.VLLM_VIDEO_LOADER_BACKEND
207
+ )
208
+ codec_backend = video_kwargs.get("backend")
209
+ uses_pynvvideocodec = (
210
+ video_loader_backend == PYNVVIDEOCODEC_VIDEO_BACKEND
211
+ or codec_backend == PYNVVIDEOCODEC_VIDEO_BACKEND
212
+ )
213
+ hw_decoders = (
214
+ validate_pynvvideocodec_hw_decoders(
215
+ video_kwargs.get("hw_decoders", PYNVVIDEOCODEC_DEFAULT_HW_DECODERS)
216
+ )
217
+ if uses_pynvvideocodec
218
+ else 1
219
+ )
220
per_server_decoder_bytes = (
181
- PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES * PYNVVIDEOCODEC_MAX_RETAINED_DECODERS
221
+ PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES * hw_decoders
222
+ PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES
223
)
224
decoder_reserved_bytes = (
@@ -198,8 +238,9 @@ def reserve_mm_ipc_gpu_memory(
238
f"({format_gib(raw_frame_reserved_bytes)} GiB raw-frame budget, "
239
f"{format_gib(decoder_reserved_bytes)} GiB decoder cache budget), "
240
f"but only {format_gib(available_kv_cache_memory_bytes)} GiB is "
201
- "available for the KV cache. Reduce mm_ipc_gpu_memory_gb, use a "
202
- "different video backend, or increase gpu_memory_utilization."
241
+ "available for the KV cache. Reduce mm_ipc_gpu_memory_gb or "
242
+ "hw_decoders, use a different video backend, or increase "
243
+ "gpu_memory_utilization."
244
)
245
logger.info_once(
246
"Reserving %s GiB of GPU memory for frontend multimodal decoding "
vllm/multimodal/media/video.py
+4
@@ -32,6 +32,10 @@ class VideoMediaIO(MediaIO[tuple[npt.NDArray, dict[str, Any]]]):
32
runtime_kwargs: dict[str, Any] | None,
33
) -> dict[str, Any]:
34
if runtime_kwargs:
35
+ # Decoder GPU memory is reserved from the startup value.
36
+ runtime_kwargs = dict(runtime_kwargs)
37
+ runtime_kwargs.pop("hw_decoders", None)
38
+
39
# Block request-level selection of GPU video backends that
40
# were not configured (and VRAM-reserved) at startup.
41
for key in ("video_backend", "backend"):
vllm/multimodal/video.py
+33
-3
@@ -210,15 +210,25 @@ class VideoLoader:
210
VIDEO_LOADER_REGISTRY = VideoLoaderRegistry()
211
212
PYNVVIDEOCODEC_VIDEO_BACKEND: Literal["pynvvideocodec"] = "pynvvideocodec"
213
-# Fixed upper bound reserved for persistent PyNvVideoCodec decoder surfaces.
213
+# Per-decoder upper bound reserved for persistent PyNvVideoCodec surfaces.
214
PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES = 128 * MiB_bytes
215
PYNVVIDEOCODEC_DECODER_CACHE_SIZE = 2
216
-PYNVVIDEOCODEC_MAX_RETAINED_DECODERS = 1
216
+PYNVVIDEOCODEC_DEFAULT_HW_DECODERS = 2
217
# Per-API-server CUDA context and driver allocation, measured with
218
# PyNvVideoCodec 2.0.4 on H100.
219
PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES = int(1.8 * 1024 * MiB_bytes)
220
221
222
+def validate_pynvvideocodec_hw_decoders(hw_decoders: object) -> int:
223
+ if (
224
+ isinstance(hw_decoders, bool)
225
+ or not isinstance(hw_decoders, int)
226
+ or hw_decoders < 1
227
+ ):
228
+ raise ValueError("hw_decoders must be a positive integer")
229
+ return hw_decoders
230
+
231
+
232
class PyNvVideoCodecDecoderSlot:
233
"""A retained PyNv decoder slot and its CUDA stream.
234
@@ -628,6 +638,7 @@ class PyNvVideoCodecVideoBackendMixin:
638
_decoder_slots: ClassVar[list[PyNvVideoCodecDecoderSlot]] = []
639
_active_decoder_slots: ClassVar[int] = 0
640
_decoder_slot_cond: ClassVar[threading.Condition] = threading.Condition()
641
+ _max_decoder_slots: ClassVar[int | None] = None
642
_DEVICE_INDEX: ClassVar[int] = 0
643
644
@classmethod
@@ -651,6 +662,18 @@ class PyNvVideoCodecVideoBackendMixin:
662
663
return PyNvVideoCodecDecoderSlot(torch.cuda.Stream(device=cls._DEVICE_INDEX))
664
665
+ @classmethod
666
+ def _configure_decoder_slots(cls, hw_decoders: object) -> None:
667
+ hw_decoders = validate_pynvvideocodec_hw_decoders(hw_decoders)
668
+ with cls._decoder_slot_cond:
669
+ if cls._max_decoder_slots is None:
670
+ cls._max_decoder_slots = hw_decoders
671
+ elif cls._max_decoder_slots != hw_decoders:
672
+ raise RuntimeError(
673
+ "PyNvVideoCodec decoder count is already configured as "
674
+ f"{cls._max_decoder_slots}, got {hw_decoders}"
675
+ )
676
+
677
@staticmethod
678
@contextmanager
679
def _torch_stream_context(stream):
@@ -669,11 +692,14 @@ class PyNvVideoCodecVideoBackendMixin:
692
def _borrow_decoder_slot(cls):
693
create_slot = False
694
with cls._decoder_slot_cond:
695
+ max_decoder_slots = cls._max_decoder_slots
696
+ if max_decoder_slots is None:
697
+ raise RuntimeError("PyNvVideoCodec decoder slots are not configured")
698
while True:
699
if cls._decoder_slots:
700
slot = cls._decoder_slots.pop()
701
break
676
- if cls._active_decoder_slots < PYNVVIDEOCODEC_MAX_RETAINED_DECODERS:
702
+ if cls._active_decoder_slots < max_decoder_slots:
703
cls._active_decoder_slots += 1
704
create_slot = True
705
break
@@ -999,6 +1025,7 @@ class VideoBackend(
1025
] = "opencv",
1026
num_ffmpeg_threads: int = 0,
1027
seek_mode: Literal["exact", "approximate"] = "exact",
1028
+ hw_decoders: int = PYNVVIDEOCODEC_DEFAULT_HW_DECODERS,
1029
**kwargs,
1030
) -> tuple[npt.NDArray, dict[str, Any]]:
1031
"""Load sampled frames from raw video bytes.
@@ -1025,6 +1052,8 @@ class VideoBackend(
1052
at the cost of relying on the file's metadata. See
1053
https://meta-pytorch.org/torchcodec/stable/generated_examples/decoding/approximate_mode.html
1054
for details.
1055
+ hw_decoders: Maximum number of concurrent PyNvVideoCodec decoder
1056
+ slots. Defaults to 2 and must be a positive integer.
1057
1058
Returns:
1059
Tuple of ``(frames_array, metadata_dict)``.
@@ -1088,6 +1117,7 @@ class VideoBackend(
1117
"frame_recovery is not supported for "
1118
f"`{PYNVVIDEOCODEC_VIDEO_BACKEND}` backend"
1119
)
1120
+ cls._configure_decoder_slots(hw_decoders)
1121
frames, source, frame_idx, valid = cls.decode_frames_pynvvideocodec(
1122
data,
1123
target,