Vram semaphore infra (#44465)
Signed-off-by: Brandon Pelfrey <bpelfrey@nvidia.com> Co-authored-by: Roger Wang <hey@rogerw.io>
Brandon Pelfrey committed
Jun 26, 2026 at 17:32 UTC
af16446bf39de047ab57649c933063cf1cbf1e50
11 files changed
+1087
-15
requirements/cuda.txt
+1
@@ -8,6 +8,7 @@ torch==2.11.0
8
torchaudio==2.11.0
9
# These must be updated alongside torch
10
torchvision==0.26.0 # Required for phi3v processor. See https://github.com/pytorch/vision?tab=readme-ov-file#installation for corresponding version
11
+PyNvVideoCodec==2.0.4
12
# FlashInfer should be updated together with the Dockerfile
13
flashinfer-python==0.6.12
14
flashinfer-cubin==0.6.12
tests/multimodal/test_gpu_ipc_memory.py
new
+145
@@ -0,0 +1,145 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import threading
5
+import time
6
+
7
+import pytest
8
+
9
+from vllm.multimodal.gpu_ipc_memory import (
10
+ MultiModalGPUMemoryPool,
11
+ get_mm_gpu_ipc_pool,
12
+ maybe_init_mm_gpu_ipc_pool,
13
+ set_mm_gpu_ipc_pool,
14
+)
15
+from vllm.utils.mem_constants import GiB_bytes
16
+
17
+
18
+def test_acquire_release_accounting():
19
+ pool = MultiModalGPUMemoryPool(total_bytes=100)
20
+ assert pool.available_bytes == 100
21
+
22
+ lease = pool.acquire(40)
23
+ assert pool.available_bytes == 60
24
+
25
+ lease.release()
26
+ assert pool.available_bytes == 100
27
+
28
+
29
+def test_acquire_too_large_raises():
30
+ pool = MultiModalGPUMemoryPool(total_bytes=100)
31
+ with pytest.raises(ValueError):
32
+ pool.acquire(101)
33
+ # Nothing should have been reserved.
34
+ assert pool.available_bytes == 100
35
+
36
+
37
+def test_negative_acquire_raises():
38
+ pool = MultiModalGPUMemoryPool(total_bytes=100)
39
+ with pytest.raises(ValueError):
40
+ pool.acquire(-1)
41
+
42
+
43
+def test_double_release_is_noop():
44
+ pool = MultiModalGPUMemoryPool(total_bytes=100)
45
+ lease = pool.acquire(50)
46
+ lease.release()
47
+ assert pool.available_bytes == 100
48
+ # Releasing again must not inflate the pool past its capacity.
49
+ lease.release()
50
+ assert pool.available_bytes == 100
51
+
52
+
53
+def test_context_manager_releases_on_exception():
54
+ pool = MultiModalGPUMemoryPool(total_bytes=100)
55
+ with pytest.raises(RuntimeError), pool.acquire(50):
56
+ assert pool.available_bytes == 50
57
+ raise RuntimeError("boom")
58
+ assert pool.available_bytes == 100
59
+
60
+
61
+def test_acquire_blocks_until_release():
62
+ pool = MultiModalGPUMemoryPool(total_bytes=100)
63
+ first = pool.acquire(80)
64
+
65
+ acquired = threading.Event()
66
+
67
+ def waiter():
68
+ # Needs 50 bytes but only 20 are free; must block until `first`
69
+ # is released.
70
+ with pool.acquire(50):
71
+ acquired.set()
72
+
73
+ t = threading.Thread(target=waiter)
74
+ t.start()
75
+
76
+ # The waiter cannot proceed yet.
77
+ assert not acquired.wait(timeout=0.2)
78
+
79
+ # Releasing the first lease frees enough budget to unblock the waiter.
80
+ first.release()
81
+ assert acquired.wait(timeout=2.0)
82
+ t.join(timeout=2.0)
83
+ assert not t.is_alive()
84
+ assert pool.available_bytes == 100
85
+
86
+
87
+def test_concurrent_acquires_serialize():
88
+ pool = MultiModalGPUMemoryPool(total_bytes=100)
89
+ # Each task needs 60 bytes, so only one can hold the budget at a time.
90
+ in_section = []
91
+ max_concurrent = 0
92
+ lock = threading.Lock()
93
+
94
+ def task():
95
+ nonlocal max_concurrent
96
+ with pool.acquire(60):
97
+ with lock:
98
+ in_section.append(1)
99
+ max_concurrent = max(max_concurrent, len(in_section))
100
+ time.sleep(0.05)
101
+ with lock:
102
+ in_section.pop()
103
+
104
+ threads = [threading.Thread(target=task) for _ in range(4)]
105
+ for t in threads:
106
+ t.start()
107
+ for t in threads:
108
+ t.join(timeout=5.0)
109
+ assert not t.is_alive()
110
+
111
+ assert max_concurrent == 1
112
+ assert pool.available_bytes == 100
113
+
114
+
115
+def test_zero_total_bytes_rejected():
116
+ with pytest.raises(ValueError):
117
+ MultiModalGPUMemoryPool(total_bytes=0)
118
+
119
+
120
+def test_global_pool_accessor():
121
+ try:
122
+ assert maybe_init_mm_gpu_ipc_pool(0) is None
123
+ assert get_mm_gpu_ipc_pool() is None
124
+
125
+ pool = maybe_init_mm_gpu_ipc_pool(2)
126
+ assert pool is not None
127
+ assert get_mm_gpu_ipc_pool() is pool
128
+ assert pool.total_bytes == 2 * GiB_bytes
129
+ finally:
130
+ set_mm_gpu_ipc_pool(None)
131
+
132
+
133
+def test_global_pool_splits_budget_across_api_processes():
134
+ try:
135
+ pool = maybe_init_mm_gpu_ipc_pool(2, api_process_count=4)
136
+ assert pool is not None
137
+ assert get_mm_gpu_ipc_pool() is pool
138
+ assert pool.total_bytes == GiB_bytes // 2
139
+ finally:
140
+ set_mm_gpu_ipc_pool(None)
141
+
142
+
143
+def test_global_pool_rejects_invalid_api_process_count():
144
+ with pytest.raises(ValueError):
145
+ maybe_init_mm_gpu_ipc_pool(2, api_process_count=0)
tests/multimodal/test_video.py
+234
@@ -1,6 +1,10 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
import itertools
5
+import sys
6
+import threading
7
+from contextlib import ExitStack, contextmanager
8
from pathlib import Path
9
10
import numpy as np
@@ -11,10 +15,15 @@ from transformers.video_utils import VideoMetadata
15
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,
20
+ PYNVVIDEOCODEC_VIDEO_BACKEND,
21
VIDEO_LOADER_REGISTRY,
22
DynamicVideoBackend,
23
GLM46VVideoBackend,
24
Molmo2VideoBackend,
25
+ PyNvVideoCodecDecoderSlot,
26
+ PyNvVideoCodecVideoBackend,
27
Qwen2VLVideoBackend,
28
Qwen3VLVideoBackend,
29
VideoLoader,
@@ -22,6 +31,7 @@ from vllm.multimodal.video import (
31
VideoTargetMetadata,
32
get_video_loader_backend_for_processor,
33
)
34
+from vllm.platforms import current_platform
35
from vllm.transformers_utils.processor import get_video_processor_cls_name_from_config
36
37
from .utils import create_long_gop_video, create_video_from_image
@@ -65,6 +75,230 @@ def test_video_loader_type_doesnt_exist():
75
VIDEO_LOADER_REGISTRY.load("non_existing_video_loader")
76
77
78
+@pytest.mark.skipif(not current_platform.is_cuda(), reason="Requires CUDA")
79
+def test_pynvvideocodec_backend_accounts_raw_decoded_frames(
80
+ monkeypatch: pytest.MonkeyPatch,
81
+):
82
+ decoder_cache_sizes = []
83
+
84
+ class FakeMetadata:
85
+ width = 10
86
+ height = 20
87
+ average_fps = 5.0
88
+ duration = 2.0
89
+
90
+ class FakeDecoder:
91
+ def __init__(self, *args, **kwargs):
92
+ decoder_cache_sizes.append(kwargs["decoder_cache_size"])
93
+
94
+ def __len__(self):
95
+ return 10
96
+
97
+ def get_stream_metadata(self):
98
+ return FakeMetadata()
99
+
100
+ class FakeNvc:
101
+ class OutputColorType:
102
+ RGB = "rgb"
103
+
104
+ SimpleDecoder = FakeDecoder
105
+
106
+ class RecordingPool:
107
+ def __init__(self):
108
+ self.acquired: list[int] = []
109
+
110
+ @contextmanager
111
+ def acquire(self, size: int):
112
+ self.acquired.append(size)
113
+ yield
114
+
115
+ def fake_decode(cls, file_path: str, frame_idx: list[int], nvc):
116
+ return np.zeros((len(frame_idx), 20, 10, 3), dtype=np.uint8)
117
+
118
+ pool = RecordingPool()
119
+ monkeypatch.setitem(sys.modules, "PyNvVideoCodec", FakeNvc)
120
+ monkeypatch.setattr(
121
+ "vllm.multimodal.gpu_ipc_memory.get_mm_gpu_ipc_pool", lambda: pool
122
+ )
123
+ monkeypatch.setattr(
124
+ PyNvVideoCodecVideoBackend, "_decode_to_pinned_host", classmethod(fake_decode)
125
+ )
126
+
127
+ loader = VIDEO_LOADER_REGISTRY.load(PYNVVIDEOCODEC_VIDEO_BACKEND)
128
+ frames, metadata = loader.load_bytes(b"fake video", num_frames=4)
129
+
130
+ assert frames.shape == (4, 20, 10, 3)
131
+ assert pool.acquired == [4 * 20 * 10 * 3]
132
+ assert decoder_cache_sizes == [PYNVVIDEOCODEC_DECODER_CACHE_SIZE]
133
+ assert metadata["video_backend"] == PYNVVIDEOCODEC_VIDEO_BACKEND
134
+ assert metadata["frames_indices"] == [0, 3, 6, 9]
135
+
136
+
137
+@pytest.mark.skipif(not current_platform.is_cuda(), reason="Requires CUDA")
138
+def test_pynvvideocodec_codec_uses_dynamic_sampling_strategy(
139
+ monkeypatch: pytest.MonkeyPatch,
140
+):
141
+ decoded_indices = []
142
+
143
+ class FakeMetadata:
144
+ width = 10
145
+ height = 20
146
+ average_fps = 5.0
147
+ duration = 2.0
148
+
149
+ class FakeDecoder:
150
+ def __init__(self, *args, **kwargs):
151
+ pass
152
+
153
+ def __len__(self):
154
+ return 10
155
+
156
+ def get_stream_metadata(self):
157
+ return FakeMetadata()
158
+
159
+ class FakeNvc:
160
+ class OutputColorType:
161
+ RGB = "rgb"
162
+
163
+ SimpleDecoder = FakeDecoder
164
+
165
+ class RecordingPool:
166
+ def __init__(self):
167
+ self.acquired: list[int] = []
168
+
169
+ @contextmanager
170
+ def acquire(self, size: int):
171
+ self.acquired.append(size)
172
+ yield
173
+
174
+ def fake_decode(cls, file_path: str, frame_idx: list[int], nvc):
175
+ decoded_indices.append(frame_idx)
176
+ return np.zeros((len(frame_idx), 20, 10, 3), dtype=np.uint8)
177
+
178
+ pool = RecordingPool()
179
+ monkeypatch.setitem(sys.modules, "PyNvVideoCodec", FakeNvc)
180
+ monkeypatch.setattr(
181
+ "vllm.multimodal.gpu_ipc_memory.get_mm_gpu_ipc_pool", lambda: pool
182
+ )
183
+ monkeypatch.setattr(
184
+ DynamicVideoBackend, "_decode_to_pinned_host", classmethod(fake_decode)
185
+ )
186
+
187
+ loader = VIDEO_LOADER_REGISTRY.load("opencv_dynamic")
188
+ frames, metadata = loader.load_bytes(
189
+ b"fake video",
190
+ fps=2,
191
+ max_duration=1,
192
+ backend=PYNVVIDEOCODEC_VIDEO_BACKEND,
193
+ )
194
+
195
+ assert frames.shape == (2, 20, 10, 3)
196
+ assert decoded_indices == [[0, 9]]
197
+ assert pool.acquired == [2 * 20 * 10 * 3]
198
+ assert metadata["video_backend"] == f"{PYNVVIDEOCODEC_VIDEO_BACKEND}_dynamic"
199
+ assert metadata["frames_indices"] == [0, 9]
200
+
201
+
202
+def test_pynvvideocodec_decoder_slots_are_bounded(monkeypatch: pytest.MonkeyPatch):
203
+ class FakeSlot:
204
+ pass
205
+
206
+ create_count = 0
207
+ old_slots = PyNvVideoCodecVideoBackend._decoder_slots
208
+ old_active_slots = PyNvVideoCodecVideoBackend._active_decoder_slots
209
+ old_cond = PyNvVideoCodecVideoBackend._decoder_slot_cond
210
+ try:
211
+ PyNvVideoCodecVideoBackend._decoder_slots = []
212
+ PyNvVideoCodecVideoBackend._active_decoder_slots = 0
213
+ PyNvVideoCodecVideoBackend._decoder_slot_cond = threading.Condition()
214
+
215
+ def fake_create_slot(cls):
216
+ nonlocal create_count
217
+ create_count += 1
218
+ return FakeSlot()
219
+
220
+ monkeypatch.setattr(
221
+ PyNvVideoCodecVideoBackend,
222
+ "_create_decoder_slot",
223
+ classmethod(fake_create_slot),
224
+ )
225
+
226
+ borrowed = threading.Event()
227
+ seen_slots = []
228
+
229
+ with ExitStack() as stack:
230
+ retained_slots = [
231
+ stack.enter_context(PyNvVideoCodecVideoBackend._borrow_decoder_slot())
232
+ for _ in range(PYNVVIDEOCODEC_MAX_RETAINED_DECODERS)
233
+ ]
234
+
235
+ def borrow_extra_slot():
236
+ with PyNvVideoCodecVideoBackend._borrow_decoder_slot() as extra_slot:
237
+ seen_slots.append(extra_slot)
238
+ borrowed.set()
239
+
240
+ thread = threading.Thread(target=borrow_extra_slot)
241
+ thread.start()
242
+ assert not borrowed.wait(timeout=0.2)
243
+
244
+ assert borrowed.wait(timeout=2.0)
245
+ thread.join(timeout=2.0)
246
+ assert not thread.is_alive()
247
+
248
+ assert seen_slots[0] in retained_slots
249
+ assert create_count == PYNVVIDEOCODEC_MAX_RETAINED_DECODERS
250
+ finally:
251
+ PyNvVideoCodecVideoBackend._decoder_slots = old_slots
252
+ PyNvVideoCodecVideoBackend._active_decoder_slots = old_active_slots
253
+ PyNvVideoCodecVideoBackend._decoder_slot_cond = old_cond
254
+
255
+
256
+def test_pynvvideocodec_decoder_slot_retains_simple_decoder():
257
+ events: list[tuple[object, ...]] = []
258
+
259
+ class FakeStream:
260
+ cuda_stream = "cuda-stream"
261
+
262
+ class FakeDecoder:
263
+ def __init__(self, file_path: str, **kwargs):
264
+ events.append(
265
+ (
266
+ "create",
267
+ file_path,
268
+ kwargs["gpu_id"],
269
+ kwargs["cuda_stream"],
270
+ kwargs["decoder_cache_size"],
271
+ )
272
+ )
273
+
274
+ def reconfigure_decoder(self, file_path: str):
275
+ events.append(("reconfigure", file_path))
276
+
277
+ class FakeNvc:
278
+ class OutputColorType:
279
+ RGB = "rgb"
280
+
281
+ SimpleDecoder = FakeDecoder
282
+
283
+ slot = PyNvVideoCodecDecoderSlot(FakeStream())
284
+
285
+ decoder = slot.get_decoder("first.mp4", FakeNvc, device_index=7)
286
+ assert slot.get_decoder("first.mp4", FakeNvc, device_index=7) is decoder
287
+ assert slot.get_decoder("second.mp4", FakeNvc, device_index=7) is decoder
288
+
289
+ assert events == [
290
+ (
291
+ "create",
292
+ "first.mp4",
293
+ 7,
294
+ "cuda-stream",
295
+ PYNVVIDEOCODEC_DECODER_CACHE_SIZE,
296
+ ),
297
+ ("reconfigure", "second.mp4"),
298
+ ]
299
+ assert slot.source_path == "second.mp4"
300
+
301
+
302
# ============================================================================
303
# Video Processor → Video Loader Tests (via model repo)
304
# ============================================================================
tests/v1/worker/test_gpu_worker.py
new
+116
@@ -0,0 +1,116 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from types import SimpleNamespace
5
+
6
+import pytest
7
+
8
+import vllm.v1.worker.gpu_worker as gpu_worker_module
9
+from vllm.multimodal.video import (
10
+ PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES,
11
+ PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES,
12
+ PYNVVIDEOCODEC_MAX_RETAINED_DECODERS,
13
+ PYNVVIDEOCODEC_VIDEO_BACKEND,
14
+)
15
+from vllm.utils.mem_constants import GiB_bytes
16
+from vllm.v1.worker.gpu_worker import Worker
17
+
18
+
19
+def _worker_with_mm_config(
20
+ mm_config: SimpleNamespace,
21
+ *,
22
+ api_process_count: int = 1,
23
+) -> Worker:
24
+ worker = object.__new__(Worker)
25
+ worker.model_config = SimpleNamespace(multimodal_config=mm_config)
26
+ worker.parallel_config = SimpleNamespace(_api_process_count=api_process_count)
27
+ return worker
28
+
29
+
30
+def _mm_config(
31
+ *,
32
+ mm_ipc_gpu_memory_gb: float = 0,
33
+ video_backend: str | None = None,
34
+) -> SimpleNamespace:
35
+ video_kwargs = {} if video_backend is None else {"video_backend": video_backend}
36
+ return SimpleNamespace(
37
+ mm_ipc_gpu_memory_gb=mm_ipc_gpu_memory_gb,
38
+ media_io_kwargs={"video": video_kwargs} if video_kwargs else {},
39
+ )
40
+
41
+
42
+def _pynvvideocodec_decoder_budget(api_process_count: int = 1) -> int:
43
+ return api_process_count * (
44
+ PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES * PYNVVIDEOCODEC_MAX_RETAINED_DECODERS
45
+ + PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES
46
+ )
47
+
48
+
49
+@pytest.mark.parametrize("video_backend", [None, "opencv"])
50
+def test_reserve_mm_ipc_gpu_memory_raw_frame_budget_only(
51
+ monkeypatch: pytest.MonkeyPatch,
52
+ video_backend: str | None,
53
+):
54
+ monkeypatch.setattr(
55
+ gpu_worker_module.envs,
56
+ "VLLM_VIDEO_LOADER_BACKEND",
57
+ "opencv",
58
+ )
59
+ worker = _worker_with_mm_config(
60
+ _mm_config(mm_ipc_gpu_memory_gb=0.25, video_backend=video_backend)
61
+ )
62
+
63
+ assert worker._reserve_mm_ipc_gpu_memory(GiB_bytes) == int(0.75 * GiB_bytes)
64
+
65
+
66
+def test_reserve_mm_ipc_gpu_memory_includes_pynvvideocodec_decoder_budget(
67
+ monkeypatch: pytest.MonkeyPatch,
68
+):
69
+ monkeypatch.setattr(
70
+ gpu_worker_module.envs,
71
+ "VLLM_VIDEO_LOADER_BACKEND",
72
+ "opencv",
73
+ )
74
+ worker = _worker_with_mm_config(
75
+ _mm_config(
76
+ mm_ipc_gpu_memory_gb=0.25,
77
+ video_backend=PYNVVIDEOCODEC_VIDEO_BACKEND,
78
+ )
79
+ )
80
+ available_bytes = 4 * GiB_bytes
81
+
82
+ assert worker._reserve_mm_ipc_gpu_memory(available_bytes) == (
83
+ available_bytes - int(0.25 * GiB_bytes) - _pynvvideocodec_decoder_budget()
84
+ )
85
+
86
+
87
+def test_reserve_mm_ipc_gpu_memory_uses_env_video_backend(
88
+ monkeypatch: pytest.MonkeyPatch,
89
+):
90
+ monkeypatch.setattr(
91
+ gpu_worker_module.envs,
92
+ "VLLM_VIDEO_LOADER_BACKEND",
93
+ PYNVVIDEOCODEC_VIDEO_BACKEND,
94
+ )
95
+ worker = _worker_with_mm_config(_mm_config())
96
+ available_bytes = 4 * GiB_bytes
97
+
98
+ assert worker._reserve_mm_ipc_gpu_memory(available_bytes) == (
99
+ available_bytes - _pynvvideocodec_decoder_budget()
100
+ )
101
+
102
+
103
+def test_reserve_mm_ipc_gpu_memory_scales_pynvvideocodec_budget_by_api_servers(
104
+ monkeypatch: pytest.MonkeyPatch,
105
+):
106
+ monkeypatch.setattr(
107
+ gpu_worker_module.envs,
108
+ "VLLM_VIDEO_LOADER_BACKEND",
109
+ PYNVVIDEOCODEC_VIDEO_BACKEND,
110
+ )
111
+ worker = _worker_with_mm_config(_mm_config(), api_process_count=3)
112
+ available_bytes = 8 * GiB_bytes
113
+
114
+ assert worker._reserve_mm_ipc_gpu_memory(available_bytes) == (
115
+ available_bytes - _pynvvideocodec_decoder_budget(api_process_count=3)
116
+ )
vllm/config/model.py
+4
@@ -351,6 +351,7 @@ class ModelConfig:
351
skip_mm_profiling: InitVar[bool | None] = None
352
video_pruning_rate: InitVar[float | None] = None
353
mm_tensor_ipc: InitVar[MMTensorIPC] = None
354
+ mm_ipc_gpu_memory_gb: InitVar[float | None] = None
355
356
def compute_hash(self) -> str:
357
"""
@@ -397,6 +398,7 @@ class ModelConfig:
398
"mm_encoder_tp_mode",
399
"interleave_mm_strings",
400
"skip_mm_profiling",
401
+ "mm_ipc_gpu_memory_gb",
402
}
403
404
from vllm.config.utils import get_hash_factors, hash_factors
@@ -477,6 +479,7 @@ class ModelConfig:
479
skip_mm_profiling: bool | None,
480
video_pruning_rate: float | None,
481
mm_tensor_ipc: MMTensorIPC,
482
+ mm_ipc_gpu_memory_gb: float | None,
483
) -> None:
484
# Keep set served_model_name before maybe_model_redirect(self.model)
485
self.served_model_name = get_served_model_name(
@@ -690,6 +693,7 @@ class ModelConfig:
693
skip_mm_profiling=skip_mm_profiling,
694
video_pruning_rate=video_pruning_rate,
695
mm_tensor_ipc=mm_tensor_ipc,
696
+ mm_ipc_gpu_memory_gb=mm_ipc_gpu_memory_gb,
697
)
698
699
mm_config_kwargs = {
vllm/config/multimodal.py
+10
@@ -197,6 +197,16 @@ class MultiModalConfig:
197
- "direct_rpc": Use msgspec serialization via RPC
198
- "torch_shm": Use torch.multiprocessing shared memory for zero-copy IPC
199
Defaults to "direct_rpc". """
200
+ mm_ipc_gpu_memory_gb: float = Field(default=0, ge=0)
201
+ """Amount of GPU memory (in GiB) sequestered on the engine's device for
202
+ GPU-side multimodal work in the API-server (frontend) process, such as
203
+ hardware video decoding.
204
+
205
+ This budget is carved out of the engine's KV-cache memory so the headroom
206
+ physically exists, and frontend GPU decode paths acquire from a blocking
207
+ byte-counting semaphore of this size before allocating on the device.
208
+
209
+ Set to `0` (default) to disable frontend GPU multimodal memory gating."""
210
211
@field_validator("limit_per_prompt", mode="before")
212
@classmethod
vllm/engine/arg_utils.py
+6
@@ -582,6 +582,7 @@ class EngineArgs:
582
skip_mm_profiling: bool = MultiModalConfig.skip_mm_profiling
583
video_pruning_rate: float | None = MultiModalConfig.video_pruning_rate
584
mm_tensor_ipc: MMTensorIPC = MultiModalConfig.mm_tensor_ipc
585
+ mm_ipc_gpu_memory_gb: float = MultiModalConfig.mm_ipc_gpu_memory_gb
586
# LoRA fields
587
enable_lora: bool = False
588
max_loras: int = LoRAConfig.max_loras
@@ -1294,6 +1295,10 @@ class EngineArgs:
1295
multimodal_group.add_argument(
1296
"--mm-tensor-ipc", **multimodal_kwargs["mm_tensor_ipc"]
1297
)
1298
+ multimodal_group.add_argument(
1299
+ "--mm-ipc-gpu-memory-gb",
1300
+ **multimodal_kwargs["mm_ipc_gpu_memory_gb"],
1301
+ )
1302
1303
# LoRA related configs
1304
lora_kwargs = get_kwargs(LoRAConfig)
@@ -1660,6 +1665,7 @@ class EngineArgs:
1665
logits_processors=self.logits_processors,
1666
video_pruning_rate=self.video_pruning_rate,
1667
mm_tensor_ipc=self.mm_tensor_ipc,
1668
+ mm_ipc_gpu_memory_gb=self.mm_ipc_gpu_memory_gb,
1669
io_processor_plugin=self.io_processor_plugin,
1670
renderer_num_workers=self.renderer_num_workers,
1671
)
vllm/multimodal/gpu_ipc_memory.py
new
+147
@@ -0,0 +1,147 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Admission control for frontend GPU-side multimodal work.
4
+
5
+When multimodal media is decoded on the GPU in the API-server (frontend)
6
+process, the decoded buffers compete for the same device memory that the
7
+engine reserves for weights, activations, and the KV cache. To keep the
8
+frontend's GPU usage within a sequestered budget (see
9
+``MultiModalConfig.mm_ipc_gpu_memory_gb``), decode paths acquire the number of
10
+bytes they need from a process-global :class:`MultiModalGPUMemoryPool` before
11
+allocating on the device and release them once the device memory is freed.
12
+
13
+The pool is a simple byte-counting semaphore: ``acquire`` blocks until enough
14
+budget is free, so concurrent requests serialize rather than oversubscribe the
15
+GPU. It lives only in the frontend process; the engine carves the matching
16
+amount out of its KV-cache budget so the headroom physically exists.
17
+"""
18
+
19
+import threading
20
+
21
+from vllm.logger import init_logger
22
+from vllm.utils.mem_constants import GiB_bytes
23
+
24
+logger = init_logger(__name__)
25
+
26
+
27
+class MultiModalGPUMemoryLease:
28
+ """A handle for bytes acquired from a :class:`MultiModalGPUMemoryPool`.
29
+
30
+ Releasing is idempotent and the lease doubles as a context manager so the
31
+ budget is returned even if the decode raises.
32
+ """
33
+
34
+ def __init__(self, pool: "MultiModalGPUMemoryPool", lease_id: int, nbytes: int):
35
+ self.lease_id = lease_id
36
+ self.nbytes = nbytes
37
+ self._pool = pool
38
+
39
+ def release(self) -> None:
40
+ self._pool._release(self)
41
+
42
+ def __enter__(self) -> "MultiModalGPUMemoryLease":
43
+ return self
44
+
45
+ def __exit__(self, *exc_info) -> None:
46
+ self.release()
47
+
48
+
49
+class MultiModalGPUMemoryPool:
50
+ """Blocking byte-counting semaphore for frontend GPU multimodal memory.
51
+
52
+ Thread-safe in both directions: ``acquire`` (blocking) and ``release`` are
53
+ typically called from the renderer's multimodal executor threads.
54
+ """
55
+
56
+ def __init__(self, total_bytes: int):
57
+ if total_bytes <= 0:
58
+ raise ValueError(f"total_bytes must be positive, got {total_bytes}")
59
+ self._total_bytes = total_bytes
60
+ self._available = total_bytes
61
+ self._cond = threading.Condition()
62
+ self._next_lease_id = 0
63
+ # Outstanding lease ids, so a double release is a no-op.
64
+ self._outstanding: set[int] = set()
65
+
66
+ @property
67
+ def total_bytes(self) -> int:
68
+ return self._total_bytes
69
+
70
+ @property
71
+ def available_bytes(self) -> int:
72
+ with self._cond:
73
+ return self._available
74
+
75
+ def acquire(self, nbytes: int) -> MultiModalGPUMemoryLease:
76
+ """Reserve ``nbytes``, blocking until that much budget is free.
77
+
78
+ Raises ``ValueError`` if ``nbytes`` exceeds the pool's total capacity,
79
+ since such a request could never be satisfied.
80
+ """
81
+ if nbytes < 0:
82
+ raise ValueError(f"Cannot acquire negative bytes: {nbytes}")
83
+ if nbytes > self._total_bytes:
84
+ raise ValueError(
85
+ f"Multimodal GPU decode requested {nbytes} bytes, which exceeds "
86
+ f"the total pool size of {self._total_bytes} bytes. Increase "
87
+ f"--mm-ipc-gpu-memory-gb or reduce the multimodal input size."
88
+ )
89
+ with self._cond:
90
+ while self._available < nbytes:
91
+ self._cond.wait()
92
+ self._available -= nbytes
93
+ lease_id = self._next_lease_id
94
+ self._next_lease_id += 1
95
+ self._outstanding.add(lease_id)
96
+ return MultiModalGPUMemoryLease(self, lease_id, nbytes)
97
+
98
+ def _release(self, lease: MultiModalGPUMemoryLease) -> None:
99
+ with self._cond:
100
+ if lease.lease_id not in self._outstanding:
101
+ # Already released — idempotent.
102
+ return
103
+ self._outstanding.discard(lease.lease_id)
104
+ self._available += lease.nbytes
105
+ self._cond.notify_all()
106
+
107
+
108
+_GLOBAL_POOL: MultiModalGPUMemoryPool | None = None
109
+
110
+
111
+def set_mm_gpu_ipc_pool(pool: MultiModalGPUMemoryPool | None) -> None:
112
+ """Install the process-global pool (frontend process only)."""
113
+ global _GLOBAL_POOL
114
+ _GLOBAL_POOL = pool
115
+
116
+
117
+def get_mm_gpu_ipc_pool() -> MultiModalGPUMemoryPool | None:
118
+ """Return the process-global pool, or ``None`` when gating is disabled."""
119
+ return _GLOBAL_POOL
120
+
121
+
122
+def maybe_init_mm_gpu_ipc_pool(
123
+ mm_ipc_gpu_memory_gb: float,
124
+ api_process_count: int = 1,
125
+) -> MultiModalGPUMemoryPool | None:
126
+ """Create and install the global pool from the configured GiB budget.
127
+
128
+ Returns ``None`` (and leaves gating disabled) when the budget is 0. When
129
+ multiple API-server processes share one engine, each process gets an equal
130
+ slice of the user-provided frontend budget.
131
+ """
132
+ if mm_ipc_gpu_memory_gb <= 0:
133
+ set_mm_gpu_ipc_pool(None)
134
+ return None
135
+ if api_process_count <= 0:
136
+ raise ValueError(f"api_process_count must be positive, got {api_process_count}")
137
+ total_bytes = int(mm_ipc_gpu_memory_gb * GiB_bytes) // api_process_count
138
+ pool = MultiModalGPUMemoryPool(total_bytes)
139
+ set_mm_gpu_ipc_pool(pool)
140
+ logger.info(
141
+ "Initialized multimodal GPU IPC memory pool with %d bytes for this API "
142
+ "process (%.2f GiB total budget across %d API process(es)).",
143
+ total_bytes,
144
+ mm_ipc_gpu_memory_gb,
145
+ api_process_count,
146
+ )
147
+ return pool
vllm/multimodal/video.py
+331
-13
@@ -1,7 +1,11 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
import math
4
+import os
5
+import tempfile
6
+import threading
7
from abc import abstractmethod
8
+from contextlib import contextmanager, suppress
9
from io import BytesIO
10
from typing import Any, ClassVar, Literal, NamedTuple, cast
11
@@ -11,6 +15,7 @@ import torch
15
16
from vllm.logger import init_logger
17
from vllm.utils.import_utils import PlaceholderModule
18
+from vllm.utils.mem_constants import MiB_bytes
19
from vllm.utils.registry import ExtensionManager
20
21
try:
@@ -130,6 +135,14 @@ class VideoSourceMetadata(NamedTuple):
135
duration: float
136
137
138
+class PyNvVideoCodecSourceMetadata(NamedTuple):
139
+ """Metadata needed before GPU video decode."""
140
+
141
+ source: VideoSourceMetadata
142
+ width: int
143
+ height: int
144
+
145
+
146
class VideoLoader:
147
@classmethod
148
def compute_frames_index_to_sample(
@@ -170,6 +183,57 @@ class VideoLoader:
183
184
VIDEO_LOADER_REGISTRY = VideoLoaderRegistry()
185
186
+PYNVVIDEOCODEC_VIDEO_BACKEND: Literal["pynvvideocodec"] = "pynvvideocodec"
187
+# Fixed upper bound reserved for persistent PyNvVideoCodec decoder surfaces.
188
+PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES = 128 * MiB_bytes
189
+PYNVVIDEOCODEC_DECODER_CACHE_SIZE = 2
190
+PYNVVIDEOCODEC_MAX_RETAINED_DECODERS = 1
191
+# Per-API-server CUDA context and driver allocation, measured with
192
+# PyNvVideoCodec 2.0.4 on H100.
193
+PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES = int(1.8 * 1024 * MiB_bytes)
194
+
195
+
196
+class PyNvVideoCodecDecoderSlot:
197
+ """A retained PyNv decoder slot and its CUDA stream.
198
+
199
+ The decoder is reused across requests: ``reconfigure_decoder`` repoints the
200
+ existing decoder at each new source instead of paying a fresh
201
+ ``SimpleDecoder`` construction per request. Construction (CUVID parser +
202
+ decoder + surface-pool allocation) is the dominant per-request cost, so
203
+ reconfiguring is far cheaper. A single decoder serves both metadata
204
+ (``len``/``get_stream_metadata``) and frame decode -- no separate
205
+ metadata decoder.
206
+ """
207
+
208
+ def __init__(self, stream) -> None:
209
+ self.stream = stream
210
+ self.decoder = None
211
+ self.source_path: str | None = None
212
+
213
+ def _construct(self, file_path: str, nvc, device_index: int) -> None:
214
+ self.decoder = nvc.SimpleDecoder(
215
+ file_path,
216
+ output_color_type=nvc.OutputColorType.RGB,
217
+ use_device_memory=True,
218
+ need_scanned_stream_metadata=True,
219
+ gpu_id=device_index,
220
+ cuda_stream=self.stream.cuda_stream,
221
+ decoder_cache_size=PYNVVIDEOCODEC_DECODER_CACHE_SIZE,
222
+ )
223
+ self.source_path = file_path
224
+
225
+ def get_decoder(self, file_path: str, nvc, device_index: int):
226
+ if self.decoder is None:
227
+ self._construct(file_path, nvc, device_index)
228
+ elif self.source_path != file_path:
229
+ try:
230
+ self.decoder.reconfigure_decoder(file_path)
231
+ self.source_path = file_path
232
+ except Exception:
233
+ # reconfigure unsupported/unsafe for this source -> rebuild.
234
+ self._construct(file_path, nvc, device_index)
235
+ return self.decoder
236
+
237
238
class OpenCVVideoBackendMixin:
239
@staticmethod
@@ -485,15 +549,223 @@ class PyAVVideoBackendMixin:
549
return np.stack(frames_list), valid_indices
550
551
552
+class PyNvVideoCodecVideoBackendMixin:
553
+ """PyNvVideoCodec utilities for GPU-backed frame decode."""
554
+
555
+ _decoder_slots: ClassVar[list[PyNvVideoCodecDecoderSlot]] = []
556
+ _active_decoder_slots: ClassVar[int] = 0
557
+ _decoder_slot_cond: ClassVar[threading.Condition] = threading.Condition()
558
+ _DEVICE_INDEX: ClassVar[int] = 0
559
+
560
+ @classmethod
561
+ @abstractmethod
562
+ def compute_frames_index_to_sample(
563
+ cls,
564
+ source: VideoSourceMetadata,
565
+ target: VideoTargetMetadata,
566
+ **kwargs,
567
+ ) -> list[int]:
568
+ raise NotImplementedError
569
+
570
+ @classmethod
571
+ @abstractmethod
572
+ def _prepare_source(cls, source: VideoSourceMetadata) -> VideoSourceMetadata:
573
+ raise NotImplementedError
574
+
575
+ @classmethod
576
+ def _create_decoder_slot(cls) -> PyNvVideoCodecDecoderSlot:
577
+ import torch
578
+
579
+ return PyNvVideoCodecDecoderSlot(torch.cuda.Stream(device=cls._DEVICE_INDEX))
580
+
581
+ @staticmethod
582
+ @contextmanager
583
+ def _torch_stream_context(stream):
584
+ import torch
585
+
586
+ torch.accelerator.set_device_index(stream.device.index)
587
+ previous_stream = torch.accelerator.current_stream()
588
+ torch.accelerator.set_stream(stream)
589
+ try:
590
+ yield
591
+ finally:
592
+ torch.accelerator.set_stream(previous_stream)
593
+
594
+ @classmethod
595
+ @contextmanager
596
+ def _borrow_decoder_slot(cls):
597
+ create_slot = False
598
+ with cls._decoder_slot_cond:
599
+ while True:
600
+ if cls._decoder_slots:
601
+ slot = cls._decoder_slots.pop()
602
+ break
603
+ if cls._active_decoder_slots < PYNVVIDEOCODEC_MAX_RETAINED_DECODERS:
604
+ cls._active_decoder_slots += 1
605
+ create_slot = True
606
+ break
607
+ cls._decoder_slot_cond.wait()
608
+
609
+ if create_slot:
610
+ try:
611
+ slot = cls._create_decoder_slot()
612
+ except Exception:
613
+ with cls._decoder_slot_cond:
614
+ cls._active_decoder_slots -= 1
615
+ cls._decoder_slot_cond.notify()
616
+ raise
617
+
618
+ try:
619
+ yield slot
620
+ finally:
621
+ with cls._decoder_slot_cond:
622
+ cls._decoder_slots.append(slot)
623
+ cls._decoder_slot_cond.notify()
624
+
625
+ @staticmethod
626
+ def _metadata_value(metadata, *names: str, default=None):
627
+ for name in names:
628
+ value = getattr(metadata, name, None)
629
+ if value is not None:
630
+ return value
631
+ return default
632
+
633
+ @classmethod
634
+ def _read_source_metadata(
635
+ cls,
636
+ file_path: str,
637
+ nvc,
638
+ ) -> PyNvVideoCodecSourceMetadata:
639
+ with cls._borrow_decoder_slot() as decoder_slot:
640
+ with cls._torch_stream_context(decoder_slot.stream):
641
+ decoder = decoder_slot.get_decoder(
642
+ file_path, nvc, device_index=cls._DEVICE_INDEX
643
+ )
644
+ metadata = decoder.get_stream_metadata()
645
+ total_frames_num = len(decoder)
646
+ width = int(cls._metadata_value(metadata, "width", default=0))
647
+ height = int(cls._metadata_value(metadata, "height", default=0))
648
+ original_fps = float(
649
+ cls._metadata_value(
650
+ metadata,
651
+ "average_fps",
652
+ "avg_frame_rate",
653
+ "frame_rate",
654
+ "frameRate",
655
+ default=0.0,
656
+ )
657
+ )
658
+ duration = float(
659
+ cls._metadata_value(metadata, "duration", default=0.0)
660
+ or (total_frames_num / original_fps if original_fps > 0 else 0.0)
661
+ )
662
+ if total_frames_num <= 0:
663
+ raise ValueError("Could not determine video frame count")
664
+ if width <= 0 or height <= 0:
665
+ raise ValueError("Could not determine video dimensions")
666
+ return PyNvVideoCodecSourceMetadata(
667
+ source=VideoSourceMetadata(total_frames_num, original_fps, duration),
668
+ width=width,
669
+ height=height,
670
+ )
671
+
672
+ @classmethod
673
+ def _decode_to_pinned_host(
674
+ cls,
675
+ file_path: str,
676
+ frame_idx: list[int],
677
+ nvc,
678
+ ) -> npt.NDArray:
679
+ import torch
680
+
681
+ if not frame_idx:
682
+ return np.empty((0,), dtype=np.uint8)
683
+
684
+ with cls._borrow_decoder_slot() as decoder_slot:
685
+ stream = decoder_slot.stream
686
+ with cls._torch_stream_context(stream):
687
+ decoder = decoder_slot.get_decoder(
688
+ file_path, nvc, device_index=cls._DEVICE_INDEX
689
+ )
690
+ decoded_frames = decoder.get_batch_frames_by_index(frame_idx)
691
+ if len(decoded_frames) < len(frame_idx):
692
+ logger.warning(
693
+ "pynvvideocodec video loading: expected %d frames but got %d.",
694
+ len(frame_idx),
695
+ len(decoded_frames),
696
+ )
697
+ torch_frames = [torch.from_dlpack(frame) for frame in decoded_frames]
698
+ if not torch_frames:
699
+ return np.empty((0,), dtype=np.uint8)
700
+ device_frames = torch.stack(torch_frames)
701
+ if device_frames.ndim != 4:
702
+ raise ValueError(
703
+ "PyNvVideoCodec returned frames with unexpected shape "
704
+ f"{tuple(device_frames.shape)}"
705
+ )
706
+ device_frames = device_frames.permute(0, 3, 1, 2).contiguous()
707
+ host_frames = torch.empty(
708
+ device_frames.shape,
709
+ dtype=device_frames.dtype,
710
+ device="cpu",
711
+ pin_memory=True,
712
+ )
713
+ host_frames.copy_(device_frames, non_blocking=True)
714
+ stream.synchronize()
715
+ host_array = host_frames.numpy()
716
+ del decoded_frames, torch_frames, device_frames
717
+ return host_array
718
+
719
+ @classmethod
720
+ def decode_frames_pynvvideocodec(
721
+ cls,
722
+ data: bytes,
723
+ target: VideoTargetMetadata,
724
+ **kwargs,
725
+ ) -> tuple[npt.NDArray, VideoSourceMetadata, list[int], list[int]]:
726
+ import PyNvVideoCodec as nvc
727
+
728
+ from vllm.multimodal.gpu_ipc_memory import get_mm_gpu_ipc_pool
729
+
730
+ temp_fd, temp_path = tempfile.mkstemp(suffix=".mp4")
731
+ try:
732
+ with os.fdopen(temp_fd, "wb") as temp_file:
733
+ temp_file.write(data)
734
+
735
+ gpu_source = cls._read_source_metadata(temp_path, nvc)
736
+ source = cls._prepare_source(gpu_source.source)
737
+ frame_idx = cls.compute_frames_index_to_sample(
738
+ source=source, target=target, **kwargs
739
+ )
740
+ raw_frame_bytes = len(frame_idx) * gpu_source.height * gpu_source.width * 3
741
+ pool = get_mm_gpu_ipc_pool()
742
+ if pool is None or raw_frame_bytes == 0:
743
+ frames = cls._decode_to_pinned_host(temp_path, frame_idx, nvc)
744
+ else:
745
+ with pool.acquire(raw_frame_bytes):
746
+ frames = cls._decode_to_pinned_host(temp_path, frame_idx, nvc)
747
+ finally:
748
+ with suppress(FileNotFoundError):
749
+ os.unlink(temp_path)
750
+
751
+ valid_frame_indices = frame_idx[: int(frames.shape[0])]
752
+ return frames, source, frame_idx, valid_frame_indices
753
+
754
+
755
@VIDEO_LOADER_REGISTRY.register("opencv")
489
-class VideoBackend(VideoLoader, OpenCVVideoBackendMixin, PyAVVideoBackendMixin):
756
+class VideoBackend(
757
+ VideoLoader,
758
+ OpenCVVideoBackendMixin,
759
+ PyAVVideoBackendMixin,
760
+ PyNvVideoCodecVideoBackendMixin,
761
+):
762
"""Uniform-sampling video backend.
763
764
Samples ``num_frames`` uniformly across the video (or one frame every
765
``1/fps`` seconds, whichever produces fewer frames). The decoding codec
494
- is selected via the ``backend`` kwarg (``"opencv"`` or ``"pyav"``),
495
- which can be passed through ``--media-io-kwargs``. Defaults to
496
- ``"pyav"`` for concurrent decoding.
766
+ is selected via the ``backend`` kwarg (``"opencv"``, ``"pyav"``, or
767
+ ``"pynvvideocodec"``), which can be passed through
768
+ ``--media-io-kwargs``. Defaults to ``"opencv"``.
769
"""
770
771
_sampling_suffix: ClassVar[str] = ""
@@ -538,7 +810,7 @@ class VideoBackend(VideoLoader, OpenCVVideoBackendMixin, PyAVVideoBackendMixin):
810
max_duration: int = 300,
811
frame_recovery: bool = False,
812
*,
541
- backend: Literal["opencv", "pyav"] = "opencv",
813
+ backend: Literal["opencv", "pyav", "pynvvideocodec"] = "opencv",
814
**kwargs,
815
) -> tuple[npt.NDArray, dict[str, Any]]:
816
"""Load sampled frames from raw video bytes.
@@ -551,7 +823,8 @@ class VideoBackend(VideoLoader, OpenCVVideoBackendMixin, PyAVVideoBackendMixin):
823
dynamic subclass; ignored here.
824
frame_recovery: Enable forward-scan recovery for failed frames.
825
Only honored by the OpenCV codec.
554
- backend: Decoding codec — ``"opencv"`` or ``"pyav"`` .
826
+ backend: Decoding codec — ``"opencv"``, ``"pyav"``, or
827
+ ``"pynvvideocodec"``.
828
829
Returns:
830
Tuple of ``(frames_array, metadata_dict)``.
@@ -584,10 +857,21 @@ class VideoBackend(VideoLoader, OpenCVVideoBackendMixin, PyAVVideoBackendMixin):
857
frames, valid = cls.decode_frames(
858
container, frame_idx, source.original_fps, source.duration
859
)
860
+ elif backend == PYNVVIDEOCODEC_VIDEO_BACKEND:
861
+ if frame_recovery:
862
+ raise ValueError(
863
+ "frame_recovery is not supported for "
864
+ f"`{PYNVVIDEOCODEC_VIDEO_BACKEND}` backend"
865
+ )
866
+ frames, source, frame_idx, valid = cls.decode_frames_pynvvideocodec(
867
+ data,
868
+ target,
869
+ **kwargs,
870
+ )
871
else:
872
raise ValueError(
873
f"Unknown video codec backend {backend!r}; "
590
- "valid options: 'opencv', 'pyav'."
874
+ "valid options: 'opencv', 'pyav', 'pynvvideocodec'."
875
)
876
877
if len(valid) < len(frame_idx):
@@ -605,6 +889,40 @@ class VideoBackend(VideoLoader, OpenCVVideoBackendMixin, PyAVVideoBackendMixin):
889
)
890
891
892
+@VIDEO_LOADER_REGISTRY.register(PYNVVIDEOCODEC_VIDEO_BACKEND)
893
+class PyNvVideoCodecVideoBackend(VideoBackend):
894
+ """Hardware-accelerated video backend using PyNvVideoCodec.
895
+
896
+ The backend first opens the stream only to read metadata and compute the
897
+ sampled frame indices. It then acquires the raw decoded RGB byte count from
898
+ the process-local multimodal GPU memory pool before decoding the selected
899
+ frames into VRAM. Decoded frames are copied into pinned host memory before
900
+ the lease is released, so downstream preprocessing continues to receive a
901
+ CPU ``np.ndarray`` in NHWC RGB format.
902
+ """
903
+
904
+ @classmethod
905
+ def load_bytes(
906
+ cls,
907
+ data: bytes,
908
+ num_frames: int = -1,
909
+ fps: int = -1,
910
+ max_duration: int = 300,
911
+ frame_recovery: bool = False,
912
+ **kwargs,
913
+ ) -> tuple[npt.NDArray, dict[str, Any]]:
914
+ kwargs.pop("backend", None)
915
+ return super().load_bytes(
916
+ data,
917
+ num_frames=num_frames,
918
+ fps=fps,
919
+ max_duration=max_duration,
920
+ frame_recovery=frame_recovery,
921
+ backend=PYNVVIDEOCODEC_VIDEO_BACKEND,
922
+ **kwargs,
923
+ )
924
+
925
+
926
@VIDEO_LOADER_REGISTRY.register(
927
"qwen3_vl",
928
video_processor="Qwen3VLVideoProcessor",
@@ -640,7 +958,7 @@ class Qwen3VLVideoBackend(VideoBackend):
958
max_duration: int = 300,
959
frame_recovery: bool = False,
960
*,
643
- backend: Literal["opencv", "pyav"] = "opencv",
961
+ backend: Literal["opencv", "pyav", "pynvvideocodec"] = "opencv",
962
**kwargs,
963
) -> tuple[npt.NDArray, dict[str, Any]]:
964
return super().load_bytes(
@@ -719,7 +1037,7 @@ class Qwen2VLVideoBackend(VideoBackend):
1037
max_duration: int = 300,
1038
frame_recovery: bool = False,
1039
*,
722
- backend: Literal["opencv", "pyav"] = "opencv",
1040
+ backend: Literal["opencv", "pyav", "pynvvideocodec"] = "opencv",
1041
**kwargs,
1042
) -> tuple[npt.NDArray, dict[str, Any]]:
1043
return super().load_bytes(
@@ -811,7 +1129,7 @@ class DynamicVideoBackend(VideoBackend):
1129
max_duration: int = 300,
1130
frame_recovery: bool = False,
1131
*,
814
- backend: Literal["opencv", "pyav"] = "opencv",
1132
+ backend: Literal["opencv", "pyav", "pynvvideocodec"] = "opencv",
1133
**kwargs,
1134
) -> tuple[npt.NDArray, dict[str, Any]]:
1135
return super().load_bytes(
@@ -936,7 +1254,7 @@ class GLM46VVideoBackend(VideoBackend):
1254
max_duration: int = 300,
1255
frame_recovery: bool = False,
1256
*,
939
- backend: Literal["opencv", "pyav"] = "opencv",
1257
+ backend: Literal["opencv", "pyav", "pynvvideocodec"] = "opencv",
1258
**kwargs,
1259
) -> tuple[npt.NDArray, dict[str, Any]]:
1260
return super().load_bytes(
@@ -1034,7 +1352,7 @@ class GLMGAVideoBackend(VideoBackend):
1352
max_duration: int = 300,
1353
frame_recovery: bool = False,
1354
*,
1037
- backend: Literal["opencv", "pyav"] = "opencv",
1355
+ backend: Literal["opencv", "pyav", "pynvvideocodec"] = "opencv",
1356
**kwargs,
1357
) -> tuple[npt.NDArray, dict[str, Any]]:
1358
frames, metadata = super().load_bytes(
@@ -1353,7 +1671,7 @@ class NemotronVLVideoBackend(VideoBackend):
1671
max_duration: int = 300,
1672
frame_recovery: bool = False,
1673
*,
1356
- backend: Literal["opencv", "pyav"] = "opencv",
1674
+ backend: Literal["opencv", "pyav", "pynvvideocodec"] = "opencv",
1675
**kwargs,
1676
) -> tuple[npt.NDArray, dict[str, Any]]:
1677
frames, metadata = super().load_bytes(
vllm/renderers/base.py
+11
@@ -29,6 +29,7 @@ from vllm.inputs import (
29
from vllm.logger import init_logger
30
from vllm.multimodal import MULTIMODAL_REGISTRY as mm_registry
31
from vllm.multimodal.cache import BaseMultiModalProcessorCache
32
+from vllm.multimodal.gpu_ipc_memory import maybe_init_mm_gpu_ipc_pool
33
from vllm.multimodal.parse import (
34
MultiModalDataItems,
35
MultiModalUUIDItems,
@@ -111,6 +112,16 @@ class BaseRenderer(ABC, Generic[_T]):
112
safe_load_prompt_embeds, executor=self._executor
113
)
114
if mm_registry.supports_multimodal_inputs(config.model_config):
115
+ # Install the process-global GPU memory pool used to gate
116
+ # frontend GPU-side multimodal decoding (no-op when the budget
117
+ # is 0). Lives in the API-server process only.
118
+ mm_config = config.model_config.multimodal_config
119
+ if mm_config is not None:
120
+ maybe_init_mm_gpu_ipc_pool(
121
+ mm_config.mm_ipc_gpu_memory_gb,
122
+ config.parallel_config._api_process_count,
123
+ )
124
+
125
mm_processor_cache = mm_registry.processor_cache_from_config(config)
126
127
with set_default_torch_num_threads():
vllm/v1/worker/gpu_worker.py
+82
-2
@@ -50,6 +50,12 @@ from vllm.distributed.weight_transfer import (
50
from vllm.logger import init_logger
51
from vllm.lora.request import LoRARequest
52
from vllm.model_executor.warmup.kernel_warmup import kernel_warmup
53
+from vllm.multimodal.video import (
54
+ PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES,
55
+ PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES,
56
+ PYNVVIDEOCODEC_MAX_RETAINED_DECODERS,
57
+ PYNVVIDEOCODEC_VIDEO_BACKEND,
58
+)
59
from vllm.platforms import current_platform
60
from vllm.profiler.wrapper import CudaProfilerWrapper, TorchProfilerWrapper
61
from vllm.sequence import IntermediateTensors
@@ -428,7 +434,7 @@ class Worker(WorkerBase):
434
"correspondingly."
435
)
436
logger.info(msg)
431
- return kv_cache_memory_bytes
437
+ return self._reserve_mm_ipc_gpu_memory(kv_cache_memory_bytes)
438
439
# Execute a forward pass with dummy inputs to profile the memory usage
440
# of the model.
@@ -550,7 +556,81 @@ class Worker(WorkerBase):
556
suggested_util,
557
)
558
553
- return int(self.available_kv_cache_memory_bytes)
559
+ return self._reserve_mm_ipc_gpu_memory(
560
+ int(self.available_kv_cache_memory_bytes)
561
+ )
562
+
563
+ @staticmethod
564
+ def _uses_pynvvideocodec_video_backend(mm_config) -> bool:
565
+ video_kwargs = mm_config.media_io_kwargs.get("video", {})
566
+ video_loader_backend = (
567
+ video_kwargs.get("video_backend") or envs.VLLM_VIDEO_LOADER_BACKEND
568
+ )
569
+ codec_backend = video_kwargs.get("backend")
570
+ return (
571
+ video_loader_backend == PYNVVIDEOCODEC_VIDEO_BACKEND
572
+ or codec_backend == PYNVVIDEOCODEC_VIDEO_BACKEND
573
+ )
574
+
575
+ def _reserve_mm_ipc_gpu_memory(self, available_kv_cache_memory_bytes: int) -> int:
576
+ """Carve frontend multimodal GPU memory out of the KV cache.
577
+
578
+ The frontend (API-server) process allocates GPU memory for hardware
579
+ multimodal decoding. Raw decoded frames are bounded by
580
+ ``mm_ipc_gpu_memory_gb`` and acquired by the frontend semaphore. Some
581
+ decoders also keep persistent surfaces around; reserve a fixed upper
582
+ bound for those when the corresponding backend is configured.
583
+ """
584
+ mm_config = self.model_config.multimodal_config
585
+ if mm_config is None:
586
+ return available_kv_cache_memory_bytes
587
+
588
+ raw_frame_reserved_bytes = int(mm_config.mm_ipc_gpu_memory_gb * GiB_bytes)
589
+ # Each api_server_count process runs its OWN decoder surfaces + NVDEC/CUVID
590
+ # CUDA context on the GPU, outside this (worker) memory pool. Reserve that
591
+ # per-server footprint x api_server_count so gpu_memory_utilization bounds
592
+ # TOTAL GPU usage across all API-server processes. Without the multiply,
593
+ # HW decode overshoots the budget by ~(api_server_count-1) x per-server and
594
+ # OOMs at high gmu, while SW decode (no per-server GPU allocation) does not.
595
+ num_api_servers = max(1, getattr(self.parallel_config, "_api_process_count", 1))
596
+ per_server_decoder_bytes = (
597
+ PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES
598
+ * PYNVVIDEOCODEC_MAX_RETAINED_DECODERS
599
+ + PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES
600
+ )
601
+ decoder_reserved_bytes = (
602
+ num_api_servers * per_server_decoder_bytes
603
+ if self._uses_pynvvideocodec_video_backend(mm_config)
604
+ else 0
605
+ )
606
+ reserved_bytes = raw_frame_reserved_bytes + decoder_reserved_bytes
607
+ if reserved_bytes <= 0:
608
+ return available_kv_cache_memory_bytes
609
+
610
+ remaining = available_kv_cache_memory_bytes - reserved_bytes
611
+ if remaining <= 0:
612
+ raise ValueError(
613
+ f"frontend multimodal GPU decoding reserves "
614
+ f"{format_gib(reserved_bytes)} GiB "
615
+ f"({format_gib(raw_frame_reserved_bytes)} GiB raw-frame budget, "
616
+ f"{format_gib(decoder_reserved_bytes)} GiB decoder cache budget), "
617
+ f"but only {format_gib(available_kv_cache_memory_bytes)} GiB is "
618
+ "available for the KV cache. Reduce mm_ipc_gpu_memory_gb, use a "
619
+ "different video backend, or increase gpu_memory_utilization."
620
+ )
621
+ logger.info_once(
622
+ "Reserving %s GiB of GPU memory for frontend multimodal decoding "
623
+ "(%s GiB raw-frame semaphore budget, %s GiB decoder+CUDA-context "
624
+ "across %d API server(s) @ %s GiB/server); "
625
+ "KV cache memory reduced to %s GiB.",
626
+ format_gib(reserved_bytes),
627
+ format_gib(raw_frame_reserved_bytes),
628
+ format_gib(decoder_reserved_bytes),
629
+ num_api_servers,
630
+ format_gib(per_server_decoder_bytes),
631
+ format_gib(remaining),
632
+ )
633
+ return remaining
634
635
def get_kv_connector_handshake_metadata(
636
self,