Prhub

#44465 Vram semaphore infra

原始 PR 作者 brandonpelfrey 合并时间 2026-06-27 08:32 文件变更 11 提交数 15 评论 22 代码增减 +1087 / -15

执行摘要

引入 GPU 硬件视频解码后端及 VRAM 信号量

延续 RFC #30839 的工作,需要在 API 服务进程中安全地进行硬件加速视频预处理,避免大量并发请求解码时 GPU 显存溢出(OOM)。

值得深入阅读,尤其是 VRAM 信号量的设计模式和 GPU 内存预算从 KV Cache 扣除的思路。该 PR 为后续零拷贝预处理奠定了基础,建议关注后续演进。

讨论亮点
  1. 临时文件开销:Isotr0py 担心将视频写入临时文件再解码带来额外 I/O 开销。brandonpelfrey 承认开销存在,但指出直接内存解码存在 seeking 限制,待库修复后可改进,当前方案已能缓解 CPU 瓶颈。
  2. 解码器重建逻辑:Isotr0py 建议将解码器重建逻辑移到 DecoderSlot,brandonpelfrey 确认早期实验遗留代码已不需要,并简化设计。
  3. 内存预留逻辑位置:njhill 建议将 gpu_worker.py 中的 _reserve_mm_ipc_gpu_memory 方法移至独立工具文件中,增强清晰性。brandonpelfrey 同意并计划后续 PR 处理。
  4. 超大视频拒绝:Isotr0py 询问如果视频解码所需内存超过信号量总大小是否会安全拒绝,brandonpelfrey 确认 acquire 会因 ValueError 拒绝。

实现拆解

  1. VRAM 信号量模块:新增 vllm/multimodal/gpu_ipc_memory.py,实现 MultiModalGPUMemoryPool(阻塞字节计数信号量)和 MultiModalGPUMemoryLease(资源句柄,支持 context manager)。
  2. 硬件视频解码后端:在 vllm/multimodal/video.py 中注册 PyNvVideoCodecVideoBackend,集成 PyNvVideoCodecDecoderSlot 复用解码器。先通过元数据查询帧数、尺寸,计算所需字节数并从信号量获取,解码到 GPU 后拷贝到 pinned 主机内存,然后释放 GPU 内存。
  3. Worker 内存预算集成:在 vllm/v1/worker/gpu_worker.pydetermine_available_memory 中,通过 _reserve_mm_ipc_gpu_memory 从可用 KV Cache 内存中扣除前端解码预算(包括原始帧预算和解码器固定开销)。多 API-server 进程按进程数等比分割。
  4. 配置与 CLI:在 vllm/config/multimodal.pyvllm/engine/arg_utils.py 中添加 mm_ipc_gpu_memory_gb 参数;新增依赖 pynvvideocodecrequirements/cuda.txt
  5. 测试覆盖:新增 tests/multimodal/test_gpu_ipc_memory.py 测试信号量行为;tests/multimodal/test_video.py 测试后端集成;tests/v1/worker/test_gpu_worker.py 测试 Worker 预算计算(含多进程缩放)。
文件 模块 状态 重要度
vllm/multimodal/gpu_ipc_memory.py 信号量 added 8.9
vllm/multimodal/video.py 视频解码 modified 8.84
vllm/v1/worker/gpu_worker.py Worker 集成 modified 7.82
tests/multimodal/test_gpu_ipc_memory.py 信号量测试 added 7.29
tests/multimodal/test_video.py 视频测试 modified 7.22
tests/v1/worker/test_gpu_worker.py Worker 测试 added 7.21

关键符号

MultiModalGPUMemoryPool.acquire MultiModalGPUMemoryPool._release PyNvVideoCodecDecoderSlot.get_decoder PyNvVideoCodecVideoBackendMixin.load_bytes Worker._reserve_mm_ipc_gpu_memory Worker._uses_pynvvideocodec_video_backend

关键源码片段

vllm/multimodal/gpu_ipc_memory.py core-logic

新增 VRAM 信号量核心实现,包括 MultiModalGPUMemoryPool(阻塞字节计数信号量)和 MultiModalGPUMemoryLease(资源句柄),是多模态 GPU 内存准入控制的基础。

import threading
from vllm.logger import init_logger
from vllm.utils.mem_constants import GiB_byteslogger = init_logger(__name__)class MultiModalGPUMemoryPool:
    # 阻塞字节计数信号量,用于前端多模态 GPU 内存。
    # 线程安全:acquire(阻塞)和 release 通常由渲染器的多模态执行器线程调用。
​
    def __init__(self, total_bytes: int):
        if total_bytes <= 0:
            raise ValueError(f'total_bytes 必须为正数,收到 {total_bytes}')
        self._total_bytes = total_bytes
        self._available = total_bytes
        self._cond = threading.Condition()
        self._next_lease_id = 0
        self._outstanding: set[int] = set()
​
    @property
    def total_bytes(self) -> int:
        return self._total_bytes
​
    @property
    def available_bytes(self) -> int:
        with self._cond:
            return self._available
​
    def acquire(self, nbytes: int) -> 'MultiModalGPUMemoryLease':
        if nbytes < 0:
            raise ValueError(f'不能获取负字节:{nbytes}')
        if nbytes > self._total_bytes:
            raise ValueError(
                f'多模态 GPU 解码请求 {nbytes} 字节,超过总池大小 '
                f'{self._total_bytes} 字节。请增加 --mm-ipc-gpu-memory-gb 或减小输入。'
            )
        with self._cond:
            while self._available < nbytes:
                self._cond.wait()
            self._available -= nbytes
            lease_id = self._next_lease_id
            self._next_lease_id += 1
            self._outstanding.add(lease_id)
        return MultiModalGPUMemoryLease(self, lease_id, nbytes)
​
    def _release(self, lease: 'MultiModalGPUMemoryLease') -> None:
        with self._cond:
            if lease.lease_id not in self._outstanding:
                return
            self._outstanding.discard(lease.lease_id)
            self._available += lease.nbytes
            self._cond.notify_all()
​
​
class MultiModalGPUMemoryLease:
    # 从 MultiModalGPUMemoryPool 获取的字节租约。
    # 释放是幂等的,同时可作为上下文管理器,确保解码异常时预算仍能归还。
​
    def __init__(self, pool: MultiModalGPUMemoryPool, lease_id: int, nbytes: int):
        self.lease_id = lease_id
        self.nbytes = nbytes
        self._pool = pool
​
    def release(self) -> None:
        self._pool._release(self)
​
    def __enter__(self) -> 'MultiModalGPUMemoryLease':
        return self
​
    def __exit__(self, *exc_info) -> None:
        self.release()
vllm/multimodal/video.py dependency-wiring

注册 PyNvVideoCodecVideoBackend,实现硬件视频解码后端,包含 PyNvVideoCodecDecoderSlot(解码器复用)和信号量集成,是功能核心。

import os, tempfile, threading
from contextlib import contextmanager, suppress# 配置常量
PYNVVIDEOCODEC_VIDEO_BACKEND: Literal['pynvvideocodec'] = 'pynvvideocodec'
PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES = 128 * MiB_bytes # 每个解码器持久 surface 的上限
PYNVVIDEOCODEC_DECODER_CACHE_SIZE = 2
PYNVVIDEOCODEC_MAX_RETAINED_DECODERS = 1
PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES = int(1.8 * 1024 * MiB_bytes) # H100 上测得的 CUDA 上下文开销
​
​
class PyNvVideoCodecDecoderSlot:
    # 保留的 PyNv 解码器槽及其 CUDA 流。
    # 解码器在请求间复用,避免重复构造 CUVID 解析器 + 解码器 +surface pool,
    # 这是每个请求的最大开销。一个解码器同时服务元数据和帧解码。
​
    def __init__(self, stream) -> None:
        self.stream = stream
        self.decoder = None
        self.source_path: str | None = None
​
    def _construct(self, file_path: str, nvc, device_index: int) -> None:
        self.decoder = nvc.SimpleDecoder(
            file_path,
            output_color_type=nvc.OutputColorType.RGB,
            use_device_memory=True,
            need_scanned_stream_metadata=True,
            gpu_id=device_index,
            cuda_stream=self.stream.cuda_stream,
            decoder_cache_size=PYNVVIDEOCODEC_DECODER_CACHE_SIZE,
        )
        self.source_path = file_path
​
    def get_decoder(self, file_path: str, nvc, device_index: int):
        if self.decoder is None:
            self._construct(file_path, nvc, device_index)
        elif self.source_path != file_path:
            try:
                self.decoder.reconfigure_decoder(file_path)
                self.source_path = file_path
            except Exception:
                self._construct(file_path, nvc, device_index)
        return self.decoder

评论区精华

临时文件开销是否可接受 性能

Isotr0py 询问将视频数据写入临时文件再解码是否会引入额外 I/O 开销。brandonpelfrey 承认开销存在,但解释直接内存解码存在 seeking 库限制,已经与库作者沟通修复,当前方案是务实的中间步骤。

结论:接受临时方案,待库修复后切换到内存解码。 · 已解决

解码器重建逻辑是否需要 设计

Isotr0py 指出解码器重建逻辑看起来冗余,建议清理。brandonpelfrey 确认是从早期实验遗留,实际不需要重建,后续会移除。

结论:移除不必要的重建逻辑,保持解码器在 DecoderSlot 中创建后永久复用。 · 已解决

内存预留逻辑应移至独立文件 设计

njhill 认为 `gpu_worker.py` 中的 `_reserve_mm_ipc_gpu_memory` 方法应移到单独工具文件,保持 Worker 职责单一。

结论:brandonpelfrey 同意并计划在后续 PR 中处理。 · pending

超大视频解码请求的安全拒绝 正确性

Isotr0py 关心如果视频解码所需内存超过信号量总容量,系统是否会安全拒绝。

结论:brandonpelfrey 确认 `acquire` 会在 nbytes > total_bytes 时抛出 `ValueError`,安全拒绝。 · 已解决

风险与影响

  1. 新依赖稳定性pynvvideocodec 为新增第三方库,目前仅提供 x86_64 和 aarch64 CUDA wheel,平台兼容性有限。
  2. 临时文件 I/O 影响:当前实现将视频数据写入临时文件后交给解码库,可能在高并发下产生大量临时文件,导致磁盘 I/O 瓶颈。
  3. 预算配置敏感mm_ipc_gpu_memory_gb 需用户手动设定,过低会拒绝合法请求,过高会压缩 KV Cache 影响推理吞吐。
  4. 多进程预算分割:多 API-server 进程场景下,预算按进程数均分,但各进程负载不均时可能导致浪费或争抢。
  1. 用户影响:启用硬件解码需添加 --media-io-kwargs '{"video":{"backend":"pynvvideocodec"}}' 并设置 --mm-ipc-gpu-memory-gb,视频预处理速度显著提升,但需权衡 KV Cache 容量。
  2. 系统影响:GPU 显存使用更可控,OOM 风险降低;但 Worker 的内存预算计算逻辑变得更复杂。
  3. 团队影响:后续需跟进零拷贝解码(跳过 pinned 内存拷贝)和自适应预算调优。CI 需新增 pynvvideocodec 兼容性测试。
依赖 pynvvideocodec 临时文件 I/O 预算配置敏感 多进程预算分割风险

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论