main
py 372 lines 12.9 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import os
5 import tempfile
6 import threading
7 from contextlib import contextmanager, suppress
8 from typing import ClassVar, NamedTuple
9
10 import numpy as np
11 import numpy.typing as npt
12
13 from vllm.logger import init_logger
14 from vllm.utils.mem_constants import MiB_bytes
15
16 from .base import (
17 PYNVVIDEOCODEC_DEFAULT_HW_DECODERS,
18 VideoSourceMetadata,
19 VideoTargetMetadata,
20 check_frame_pixel_limit,
21 )
22
23 logger = init_logger(__name__)
24
25
26 def decode_pynvvideocodec(
27 loader_cls,
28 data: bytes,
29 target: VideoTargetMetadata,
30 sampling_kwargs: dict,
31 *,
32 hw_decoders: int = PYNVVIDEOCODEC_DEFAULT_HW_DECODERS,
33 ) -> tuple[npt.NDArray, VideoSourceMetadata, list[int], list[int]]:
34 PyNvVideoCodecVideoBackendMixin._configure_decoder_slots(hw_decoders)
35 return PyNvVideoCodecVideoBackendMixin.decode_frames_pynvvideocodec(
36 loader_cls,
37 data,
38 target,
39 **sampling_kwargs,
40 )
41
42
43 class PyNvVideoCodecSourceMetadata(NamedTuple):
44 """Metadata needed before GPU video decode."""
45
46 source: VideoSourceMetadata
47 width: int
48 height: int
49
50
51 # Per-decoder upper bound reserved for persistent PyNvVideoCodec surfaces.
52 PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES = 128 * MiB_bytes
53 PYNVVIDEOCODEC_DECODER_CACHE_SIZE = 2
54 # Per-API-server CUDA context and driver allocation, measured with
55 # PyNvVideoCodec 2.0.4 on H100.
56 PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES = int(1.8 * 1024 * MiB_bytes)
57
58
59 def validate_pynvvideocodec_hw_decoders(hw_decoders: object) -> int:
60 if (
61 isinstance(hw_decoders, bool)
62 or not isinstance(hw_decoders, int)
63 or hw_decoders < 1
64 ):
65 raise ValueError("hw_decoders must be a positive integer")
66 return hw_decoders
67
68
69 def _pynvvideocodec_exception_types(nvc) -> tuple[type[Exception], ...]:
70 return tuple(
71 exception_type
72 for name in dir(nvc)
73 if name.startswith("PyNvVCException")
74 and isinstance((exception_type := getattr(nvc, name)), type)
75 and issubclass(exception_type, Exception)
76 )
77
78
79 def _pynvvc_frames_to_nhwc(frames):
80 """Return a stacked PyNvVideoCodec frame batch as contiguous NHWC."""
81 if frames.shape[-1] != 3 and frames.shape[-3] == 3:
82 frames = frames.permute(0, 2, 3, 1)
83 return frames.contiguous()
84
85
86 class PyNvVideoCodecDecoderSlot:
87 """A retained PyNv decoder slot and its CUDA stream.
88
89 The decoder is reused across requests: ``reconfigure_decoder`` repoints the
90 existing decoder at each new source instead of paying a fresh
91 ``SimpleDecoder`` construction per request. Construction (CUVID parser +
92 decoder + surface-pool allocation) is the dominant per-request cost, so
93 reconfiguring is far cheaper. A single decoder serves both metadata
94 (``len``/``get_stream_metadata``) and frame decode -- no separate
95 metadata decoder.
96 """
97
98 def __init__(self, stream) -> None:
99 self.stream = stream
100 self.decoder = None
101 self.source_path: str | None = None
102
103 def invalidate(self) -> None:
104 self.decoder = None
105 self.source_path = None
106
107 def _construct(self, file_path: str, nvc, device_index: int) -> None:
108 self.invalidate()
109 decoder = nvc.SimpleDecoder(
110 file_path,
111 output_color_type=nvc.OutputColorType.RGB,
112 use_device_memory=True,
113 need_scanned_stream_metadata=True,
114 gpu_id=device_index,
115 cuda_stream=self.stream.cuda_stream,
116 decoder_cache_size=PYNVVIDEOCODEC_DECODER_CACHE_SIZE,
117 )
118 self.decoder = decoder
119 self.source_path = file_path
120
121 def get_decoder(self, file_path: str, nvc, device_index: int):
122 if self.decoder is None:
123 self._construct(file_path, nvc, device_index)
124 elif self.source_path != file_path:
125 try:
126 self.decoder.reconfigure_decoder(file_path)
127 self.source_path = file_path
128 except Exception:
129 # reconfigure unsupported/unsafe for this source -> rebuild.
130 self._construct(file_path, nvc, device_index)
131 return self.decoder
132
133
134 class _PyNvDecoderPool:
135 """Process-wide singleton managing PyNvVideoCodec decoder slot state.
136
137 Prevents subclass counter shadowing (GHSA-j682-9xp5-rrf3) by storing
138 all mutable pool state in a single module-level instance rather than
139 in ClassVar attributes that get shadowed by Python's augmented
140 assignment semantics on subclasses.
141 """
142
143 def __init__(self) -> None:
144 self.slots: list[PyNvVideoCodecDecoderSlot] = []
145 self.active: int = 0
146 self.cond: threading.Condition = threading.Condition()
147 self.max_slots: int | None = None
148
149 def configure(self, hw_decoders: int) -> None:
150 with self.cond:
151 if self.max_slots is None:
152 self.max_slots = hw_decoders
153 elif self.max_slots != hw_decoders:
154 raise RuntimeError(
155 "PyNvVideoCodec decoder count is already configured as "
156 f"{self.max_slots}, got {hw_decoders}"
157 )
158
159
160 _pynv_decoder_pool = _PyNvDecoderPool()
161
162
163 class PyNvVideoCodecVideoBackendMixin:
164 """PyNvVideoCodec utilities for GPU-backed frame decode."""
165
166 _DEVICE_INDEX: ClassVar[int] = 0
167
168 @classmethod
169 def _create_decoder_slot(cls) -> PyNvVideoCodecDecoderSlot:
170 import torch
171
172 return PyNvVideoCodecDecoderSlot(torch.cuda.Stream(device=cls._DEVICE_INDEX))
173
174 @classmethod
175 def _configure_decoder_slots(cls, hw_decoders: object) -> None:
176 hw_decoders = validate_pynvvideocodec_hw_decoders(hw_decoders)
177 _pynv_decoder_pool.configure(hw_decoders)
178
179 @staticmethod
180 @contextmanager
181 def _torch_stream_context(stream):
182 import torch
183
184 torch.accelerator.set_device_index(stream.device.index)
185 previous_stream = torch.accelerator.current_stream()
186 torch.accelerator.set_stream(stream)
187 try:
188 yield
189 finally:
190 torch.accelerator.set_stream(previous_stream)
191
192 @classmethod
193 @contextmanager
194 def _borrow_decoder_slot(cls):
195 pool = _pynv_decoder_pool
196 create_slot = False
197 with pool.cond:
198 if pool.max_slots is None:
199 raise RuntimeError("PyNvVideoCodec decoder slots are not configured")
200 while True:
201 if pool.slots:
202 slot = pool.slots.pop()
203 break
204 if pool.active < pool.max_slots:
205 pool.active += 1
206 create_slot = True
207 break
208 pool.cond.wait()
209
210 if create_slot:
211 try:
212 slot = cls._create_decoder_slot()
213 except Exception:
214 with pool.cond:
215 pool.active -= 1
216 pool.cond.notify()
217 raise
218
219 borrow_succeeded = False
220 try:
221 yield slot
222 borrow_succeeded = True
223 finally:
224 if not borrow_succeeded:
225 slot.invalidate()
226 with pool.cond:
227 pool.slots.append(slot)
228 pool.cond.notify()
229
230 @staticmethod
231 def _metadata_value(metadata, *names: str, default=None):
232 for name in names:
233 value = getattr(metadata, name, None)
234 if value is not None:
235 return value
236 return default
237
238 @classmethod
239 def _read_source_metadata(
240 cls,
241 file_path: str,
242 nvc,
243 ) -> PyNvVideoCodecSourceMetadata:
244 with cls._borrow_decoder_slot() as decoder_slot:
245 with cls._torch_stream_context(decoder_slot.stream):
246 decoder = decoder_slot.get_decoder(
247 file_path, nvc, device_index=cls._DEVICE_INDEX
248 )
249 metadata = decoder.get_stream_metadata()
250 total_frames_num = len(decoder)
251 width = int(cls._metadata_value(metadata, "width", default=0))
252 height = int(cls._metadata_value(metadata, "height", default=0))
253 original_fps = float(
254 cls._metadata_value(
255 metadata,
256 "average_fps",
257 "avg_frame_rate",
258 "frame_rate",
259 "frameRate",
260 default=0.0,
261 )
262 )
263 duration = float(
264 cls._metadata_value(metadata, "duration", default=0.0)
265 or (total_frames_num / original_fps if original_fps > 0 else 0.0)
266 )
267 if total_frames_num <= 0:
268 raise ValueError("Could not determine video frame count")
269 if width <= 0 or height <= 0:
270 raise ValueError("Could not determine video dimensions")
271 return PyNvVideoCodecSourceMetadata(
272 source=VideoSourceMetadata(total_frames_num, original_fps, duration),
273 width=width,
274 height=height,
275 )
276
277 @classmethod
278 def _decode_to_pinned_host(
279 cls,
280 file_path: str,
281 frame_idx: list[int],
282 nvc,
283 ) -> npt.NDArray:
284 import torch
285
286 if not frame_idx:
287 return np.empty((0,), dtype=np.uint8)
288
289 with cls._borrow_decoder_slot() as decoder_slot:
290 stream = decoder_slot.stream
291 with cls._torch_stream_context(stream):
292 try:
293 decoder = decoder_slot.get_decoder(
294 file_path, nvc, device_index=cls._DEVICE_INDEX
295 )
296 decoded_frames = decoder.get_batch_frames_by_index(frame_idx)
297 except Exception as exc:
298 if not isinstance(
299 exc,
300 _pynvvideocodec_exception_types(nvc) + (IndexError,),
301 ):
302 raise
303 raise ValueError("Invalid or unsupported video file.") from exc
304 if len(decoded_frames) < len(frame_idx):
305 logger.warning(
306 "pynvvideocodec video loading: expected %d frames but got %d.",
307 len(frame_idx),
308 len(decoded_frames),
309 )
310 torch_frames = [torch.from_dlpack(frame) for frame in decoded_frames]
311 if not torch_frames:
312 return np.empty((0,), dtype=np.uint8)
313 device_frames = torch.stack(torch_frames)
314 if device_frames.ndim != 4:
315 raise ValueError(
316 "PyNvVideoCodec returned frames with unexpected shape "
317 f"{tuple(device_frames.shape)}"
318 )
319 device_frames = _pynvvc_frames_to_nhwc(device_frames)
320 host_frames = torch.empty(
321 device_frames.shape,
322 dtype=device_frames.dtype,
323 device="cpu",
324 pin_memory=True,
325 )
326 host_frames.copy_(device_frames, non_blocking=True)
327 stream.synchronize()
328 host_array = host_frames.numpy()
329 del decoded_frames, torch_frames, device_frames
330 return host_array
331
332 @classmethod
333 def decode_frames_pynvvideocodec(
334 cls,
335 loader_cls,
336 data: bytes,
337 target: VideoTargetMetadata,
338 **kwargs,
339 ) -> tuple[npt.NDArray, VideoSourceMetadata, list[int], list[int]]:
340 import PyNvVideoCodec as nvc
341
342 from vllm.multimodal.gpu_ipc_memory import get_mm_gpu_ipc_pool
343
344 temp_fd, temp_path = tempfile.mkstemp(suffix=".mp4")
345 try:
346 with os.fdopen(temp_fd, "wb") as temp_file:
347 temp_file.write(data)
348
349 try:
350 gpu_source = cls._read_source_metadata(temp_path, nvc)
351 except Exception as exc:
352 if not isinstance(exc, _pynvvideocodec_exception_types(nvc)):
353 raise
354 raise ValueError("Invalid or unsupported video file.") from exc
355 check_frame_pixel_limit(gpu_source.width, gpu_source.height)
356 source = loader_cls._prepare_source(gpu_source.source)
357 frame_idx = loader_cls.compute_frames_index_to_sample(
358 source=source, target=target, **kwargs
359 )
360 raw_frame_bytes = len(frame_idx) * gpu_source.height * gpu_source.width * 3
361 pool = get_mm_gpu_ipc_pool()
362 if pool is None or raw_frame_bytes == 0:
363 frames = cls._decode_to_pinned_host(temp_path, frame_idx, nvc)
364 else:
365 with pool.acquire(raw_frame_bytes):
366 frames = cls._decode_to_pinned_host(temp_path, frame_idx, nvc)
367 finally:
368 with suppress(FileNotFoundError):
369 os.unlink(temp_path)
370
371 valid_frame_indices = frame_idx[: int(frames.shape[0])]
372 return frames, source, frame_idx, valid_frame_indices