Prhub

#51139 [Bugfix][Multimodal] Invalidate retained PyNvVideoCodec decoder after failure

原始 PR 作者 dmai-afk 合并时间 2026-08-12 19:02 文件变更 3 提交数 5 评论 6 代码增减 +134 / -1

执行摘要

失败后失效 PyNvVideoCodec 解码器,防止后续视频请求被污染

issue #51138 复现:一次不支持的 8K 请求后,同样的合法视频(240p/480p)反而持续返回 500 并带有过期的 7680x4320 分辨率。根因是 _borrow_decoder_slot() 无论借用期间是否抛错都会把槽位归还池,而 PyNvVideoCodec 的 reconfigure 是惰性的,失败的 native decoder 会在内部保留错误的最大分辨率,且没有可靠的 reset 操作。PR 作者进一步通过 H200 上的原生回调插桩确认,干净源会收到 864x480 的回调,但错误仍读取保留解码器的 7680x4320 上限。

该 PR 值得精读。它不仅修复了一个真实的生产问题,还展示了资源池防御性失效的设计:事务性构造、借用上下文中的异常处理、以及 fake 与真实硬件测试的分层。review 中 Isotr0py 关于“fake 无法覆盖失败路径”的质疑与作者的应对过程也值得学习。

讨论亮点

审查中 Isotr0py 对测试方式提出质疑:

Can we implement the test to call pynv decoder directly instead of using fake UT? I think using FakeStream cannot cover the failure case very well.

作者回应并补了一个 H200 上的真实 PyNvVideoCodec 回归测试(valid→unsupported 8K→valid),同时保留窄范围的 CPU fake 构造失败测试,删除了冗余的通用 FakeStream borrow 失败测试,最终获得 APPROVE。讨论的结论是:fake 测试用于 CPU CI 的快速覆盖,真实硬件测试用于验证 native 层行为。

实现拆解

  1. 新增无效化入口:在 vllm/multimodal/video.pyPyNvVideoCodecDecoderSlot 上新增 invalidate() 方法,把 self.decoderself.source_path 一并清空,作为唯一“作废保留解码器”的入口。
  2. 重建事务化:修改 _construct(),在构造 SimpleDecoder 之前先调用 invalidate() 清空槽位,并把构造结果先放入局部变量,成功后才发布到 self.decoderself.source_path;这样若构造抛出异常,槽位不会悬挂旧解码器或半成品。
  3. 借用上下文异常失效:在 _borrow_decoder_slot() 中引入 borrow_succeeded 标志,yield 正常返回后置 Truefinally 中若为 False 则先 slot.invalidate() 再归还池;任何借用期间逃逸的异常都会触发失效,成功路径保持原样,继续享受 reconfiguration 复用带来的性能收益。
  4. 测试配套:tests/multimodal/test_video.py 新增 CPU 可跑的 test_pynvvideocodec_failed_rebuild_invalidates_decoder_slot(用 FakeDecoder/FakeNvc 模拟 reconfigure 失败 + 构造失败,断言槽位被事务性清空),以及受 CUDA/H200 门控的真实回归 test_pynvvideocodec_h200_recovers_after_unsupported_8k(valid→8K failure→valid);同时新增 tests/multimodal/assets/unsupported_8k_h264.mp4 作为紧凑 8K 测试素材。按 review 意见保留了 CPU fake 测试、删除了冗余的通用 borrow 失败测试。
文件 模块 状态 重要度
vllm/multimodal/video.py 视频解码 modified 6.37
tests/multimodal/test_video.py 视频测试 modified 6.92
tests/multimodal/assets/unsupported_8k_h264.mp4 8K 素材 added 2.71

关键符号

invalidate _construct _borrow_decoder_slot get_decoder test_pynvvideocodec_failed_rebuild_invalidates_decoder_slot test_pynvvideocodec_h200_recovers_after_unsupported_8k

关键源码片段

vllm/multimodal/video.py core-logic

核心源码改动:新增 invalidate() 事务性失效逻辑,修复解码器槽位中毒问题。

# vllm/multimodal/video.py# 保留解码器槽位:跨请求复用 SimpleDecoder,只有借用异常时才整体作废
class PyNvVideoCodecDecoderSlot:
    def __init__(self, stream) -> None:
        self.stream = stream
        self.decoder = None
        self.source_path: str | None = None
​
    def invalidate(self) -> None:
        # PyNvVideoCodec 的 stop() 在 2.0.5 的 Python wrapper 下不可用,
        # 失败后的 native decoder 会保留错误的最大分辨率等状态,只能整体丢弃
        self.decoder = None
        self.source_path = None
​
    def _construct(self, file_path: str, nvc, device_index: int) -> None:
        # 构造前先清空旧状态;SimpleDecoder 创建失败时槽位保持干净,
        # 不会向池中发布旧解码器或半成品
        self.invalidate()
        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.decoder = decoder
        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:
                # reconfigure 失败(例如源不受支持)时回退到重建路径
                self._construct(file_path, nvc, device_index)
        return self.decoder# 后端类内的借用上下文:异常逃逸时先失效再归还,成功路径不变
@classmethod
@contextmanager
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 < max_decoder_slots:
                cls._active_decoder_slots += 1
                create_slot = True
                break
            cls._decoder_slot_cond.wait()
​
    if create_slot:
        try:
            slot = cls._create_decoder_slot()
        except Exception:
            with cls._decoder_slot_cond:
                cls._active_decoder_slots -= 1
                cls._decoder_slot_cond.notify()
            raise
​
    borrow_succeeded = False
    try:
        yield slot
        borrow_succeeded = True
    finally:
        # 借用期间任何异常都会使该槽位失效,后续请求重建全新解码器;
        # 成功路径保持不变,继续复用解码器与 reconfiguration 的性能收益
        if not borrow_succeeded:
            slot.invalidate()
        with cls._decoder_slot_cond:
            cls._decoder_slots.append(slot)
            cls._decoder_slot_cond.notify()
tests/multimodal/test_video.py test-coverage

新增 CPU fake 回归与 H200 真实硬件回归,验证重建失败与原生解码失败后的恢复路径。

# tests/multimodal/test_video.pydef test_pynvvideocodec_failed_rebuild_invalidates_decoder_slot():
    events: list[tuple[str, str]] = []
​
    class FakeStream:
        cuda_stream = "cuda-stream"
​
    class FakeDecoder:
        poisoned = False
​
        def reconfigure_decoder(self, file_path: str):
            # 模拟被污染的保留解码器:重配置途中抛错
            self.poisoned = True
            events.append(("reconfigure", file_path))
            raise RuntimeError("reconfigure failed")
​
    old_decoder = FakeDecoder()
    slot = PyNvVideoCodecDecoderSlot(FakeStream())
    slot.decoder = old_decoder
    slot.source_path = "valid.mp4"
​
    class FakeNvc:
        class OutputColorType:
            RGB = "rgb"
​
        @staticmethod
        def SimpleDecoder(file_path: str, **kwargs):
            events.append(("construct", file_path))
            # 构造前槽位必须已被清空,否则说明事务性失效没有生效
            assert slot.decoder is None
            assert slot.source_path is None
            raise RuntimeError("construct failed")
​
    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 = [slot]
        PyNvVideoCodecVideoBackend._active_decoder_slots = 1
        PyNvVideoCodecVideoBackend._decoder_slot_cond = threading.Condition()
        PyNvVideoCodecVideoBackend._max_decoder_slots = 1
​
        with (
            pytest.raises(RuntimeError, match="construct failed"),
            PyNvVideoCodecVideoBackend._borrow_decoder_slot() as borrowed,
        ):
            assert borrowed is slot
            borrowed.get_decoder("unsupported-8k.mp4", FakeNvc, device_index=0)
​
        # 重配置失败触发重建,重建也失败;事件顺序应符合预期
        assert events == [
            ("reconfigure", "unsupported-8k.mp4"),
            ("construct", "unsupported-8k.mp4"),
        ]
        # 旧解码器确实被污染,但槽位已被事务性清空并回到池中
        assert old_decoder.poisoned
        assert slot.decoder is None
        assert slot.source_path is None
        assert PyNvVideoCodecVideoBackend._decoder_slots == [slot]
    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

评论区精华

测试方式:用 Fake 还是真实 PyNvVideoCodec 回归 测试

Isotr0py 提出“Can we implement the test to call pynv decoder directly instead of using fake UT? I think using FakeStream cannot cover the failure case very well.”作者随后补充了 H200 上的真实 PyNvVideoCodec 回归,并保留窄范围的 CPU fake 构造失败测试。

结论:作者新增 CUDA 门控的直接回归测试(valid→unsupported 8K→valid),删除冗余的通用 FakeStream borrow 失败测试,保留 CPU fake 回归与 H200 真实回归并存的方案。 · 已解决

风险与影响

  1. 上游依赖未修复:PyNvVideoCodec 2.0.5 的 native 层在 HandleVideoSequence 失败后仍保留被污染的 decoder session,vLLM 侧只是防御性失效,后续若其他路径复用仍可能踩到上游问题;建议按 PR 正文的步骤向 PyNvVideoCodec 团队提交 upstream 报告。
  2. 真实回归依赖硬件:test_pynvvideocodec_h200_recovers_after_unsupported_8k 依赖设备名包含 H200 且必须有 PyNvVideoCodec 与支持 8K 解码失败的驱动,在普通 CI 上会被跳过,真实回归覆盖有限。
  3. 热路径变更:_borrow_decoder_slot() 是每次视频请求都要走的路径,新增 borrow_succeeded 标志与 finally 分支只增加极小的 Python 开销,但改动发生在资源池核心路径,需关注多线程并发下的行为(invalidate 在锁外执行,槽位所有权转移依赖条件变量与 _active_decoder_slots 计数)。
  4. 防御性失效可能掩盖上游问题:invalidate 后重建会吞掉部分失败信号,但错误本身仍会向上抛出,不会变成静默成功;只是后续请求会付出一次全新 SimpleDecoder 构造的额外成本。

对用户:使用 PyNvVideoCodec 后端的 NVIDIA 视频多模态推理服务不再被一次不支持的视频请求持续污染,后续合法请求可恢复;失败请求本身仍会返回错误(如 HTTP 500),但错误分类是另一个 PR 的范围。对系统:改动集中在 vllm/multimodal/video.py,影响面窄;正常请求的解码器复用与 reconfiguration 路径不变,性能无回退;异常路径下后续请求会增加一次解码器构造开销,但属于可接受的恢复成本。对团队:测试策略上“CPU fake 回归 + 真实硬件集成回归”并存的分层方式值得借鉴,也明确了与 #51120 的边界与 merge 顺序。

上游 PyNvVideoCodec 行为未修复 真实硬件回归依赖 H200 解码器池热路径变更 防御性失效可能掩盖上游问题

关联 Issue

#51138 [Bug][Multimodal] Failed PyNvVideoCodec decode poisons retained decoder slot

完整报告

参与讨论