Prhub

#51076 [Bugfix][Multimodal] Fix PyNvVideoCodec video backend returning NCHW instead of NHWC

原始 PR 作者 davidjpyu 合并时间 2026-08-08 12:49 文件变更 2 提交数 5 评论 7 代码增减 +39 / -1

执行摘要

修复 PyNvVideoCodec 视频后端 NCHW/NHWC 布局错乱

PR body 明确指出 video-loader 契约要求返回 NHWC,PyNvVideoCodecVideoBackendMixin._decode_to_pinned_host 的 docstring 也声明帧是 "NHWC RGB format ... identical to the OpenCV/PyAV backends",且 OpenCV/PyAV/TorchCodec/DeepStream 各后端均返回 NHWC;唯独 pynvvc 路径多做了 permute(0, 3, 1, 2),把 torch.stack 得到的 (N, H, W, C) 翻转成 (N, C, H, W),任何通过 HF video processor 逐帧物化 PIL 图像的模型都会失败:BadRequestError: Cannot handle this data type: (1, 1, 320), |u1(在 gemma-4-31b 上复现)。mgoin 在 review 中追问该 bug 是否一直存在,作者确认“it's been broken since the PyNvVideoCodec integration in #44465”。

值得精读的小型 bugfix,是“实现与契约背离导致整条链路失败”的典型案例:docstring 声明 NHWC、其余后端均遵守、唯独一个后端悄悄违反。值得关注的决策:

1) 不盲目删除 permute,而是用通道轴检测兼容 PyNvVideoCodec 版本间布局漂移(源自 Isotr0py 对 #44465 内部讨论的引用);
2) 用纯 CPU 构造张量的参数化单测直接钉住回归点,避免单测依赖 GPU 专有后端;
3) 修复后对 mp4/webm/mov 三种容器格式做了端到端验证,补齐了“单测覆盖逻辑、e2e 覆盖行为”的验证闭环。

讨论亮点

Review 中两条关键反馈推动了最终实现形态:

  • mgoin:"I believe you, but has this just been broken forever? Could you add a unit or e2e test that would catch this?" —— 促使作者补充了纯 CPU 的参数化单测,并确认 bug 从 #44465 集成起就存在。
  • Isotr0py:指出"PyNvVideoCodec will always convert NHWC to NCHW"(引用 #44465 内部讨论 r3391283504),建议不要只删 permute 而是回退到同时兼容 NHWC 与 NCHW 的原始实现。作者采纳,在 Issue 评论中说明:"Rather than swapping one hardcoded layout for another, I now detect the channel axis and normalize to NHWC... NHWC always has shape[-1] == 3",最终落地为 _pynvvc_frames_to_nhwc。两人最终均 approve,无未解决疑虑。

实现拆解

  1. 根因定位:在 vllm/multimodal/video.py 的 PyNvVideoCodecVideoBackendMixin._decode_to_pinned_host 中,torch.stack(torch_frames) 已把逐帧 HWC 图像堆叠为 (N, H, W, C);原有代码多余的 permute(0, 3, 1, 2) 将布局翻转为 NCHW,与方法 docstring 以及其余各视频后端行为相悖。
  2. 方案演进:第一版提交直接删除 permute 并保留 contiguous();经 Isotr0py 审查提醒(PyNvVideoCodec 存在 NHWC 与 NCHW 两种布局能力,行为随版本变化),改为新增模块级函数 _pynvvc_frames_to_nhwc:当 shape[-1] != 3 and shape[-3] == 3 时判定为 NCHW 并 permute(0, 2, 3, 1) 转回 NHWC,末尾统一 contiguous() 保证后续 pinned 内存异步拷贝(copy_ non_blocking)安全;该函数固化在 vllm/multimodal/video.py 模块顶层,调用点替换原 permute。
  3. 测试配套:在 tests/multimodal/media/test_video.py 新增 test_pynvvc_frames_normalized_to_nhwc,用 @pytest.mark.parametrize("layout", ["nhwc", "nchw"]) 覆盖两种输入布局,断言输出形状 (N, H, W, C)、张量连续性与内容保真;通过 pytest.importorskip("torch") 和 cpu_test 标记保持纯 CPU 可运行,不依赖 GPU 与 PyNvVideoCodec 专有依赖。
  4. 端到端验证:作者在 B200 + gemma-4-31b(NVFP4)+ VLLM_VIDEO_LOADER_BACKEND=pynvvideocodec 环境下对 mp4 / webm / mov 三种容器格式的原生 video_url 请求验证:修复前全部 HTTP 400,修复后三种格式均输出正确的 SMPTE 彩条描述,且 load_bytes 返回形状确认为 (N, H, W, 3)。
    本 PR 无配置、schema 或部署配套改动。
文件 模块 状态 重要度
vllm/multimodal/video.py 视频加载 modified 6.82
tests/multimodal/media/test_video.py 视频测试 modified 5.29

关键符号

_pynvvc_frames_to_nhwc _decode_to_pinned_host test_pynvvc_frames_normalized_to_nhwc

关键源码片段

vllm/multimodal/video.py core-logic

核心修复文件。`PyNvVideoCodecVideoBackendMixin._decode_to_pinned_host` 中错误的 `permute(0, 3, 1, 2)` 把 NHWC 帧翻转为 NCHW,违反 video-loader 契约并导致 gemma-4 等模型原生视频请求 HTTP 400。新增 `_pynvvc_frames_to_nhwc` 通过通道轴检测兼容 NHWC/NCHW 两种输入,统一归一化到连续 NHWC。

def _pynvvc_frames_to_nhwc(frames):
    """将 PyNvVideoCodec 堆叠帧批量归一化为连续 NHWC 布局。
​
    PyNvVideoCodec 的逐帧布局在不同版本间有变化(HWC vs CHW),
    因此这里检测通道轴而非假设固定顺序。NHWC 是其他视频后端
    返回的布局,也是 HF 视频处理器期望的布局。
​
    Args:
        frames: ``(N, ?, ?, ?)`` 的 uint8 张量,NHWC 或 NCHW 顺序均可。
​
    Returns:
        归一化后连续排列的 ``(N, H, W, C)`` 张量。
    """
    # NHWC 布局下最后一维恒为 3 通道;若末维非 3 而倒数第三维为 3,
    # 则判定输入为 NCHW,转置回 NHWC(内容保持不变)
    if frames.shape[-1] != 3 and frames.shape[-3] == 3:
        frames = frames.permute(0, 2, 3, 1) # NCHW -> NHWC
    # contiguous() 保证后续 pinned 内存异步拷贝(copy_ non_blocking)安全;
    # 输入已连续时是 no-op,无额外开销
    return frames.contiguous()
        # ...
        device_frames = torch.stack(torch_frames) # 逐帧 HWC 堆叠为 (N, H, W, C)
        if device_frames.ndim != 4:
            raise ValueError(
                "PyNvVideoCodec returned frames with unexpected shape "
                f"{tuple(device_frames.shape)}"
            )
        # 统一到 NHWC:旧实现在这里错误地 permute 成 NCHW,破坏 video-loader 契约
        device_frames = _pynvvc_frames_to_nhwc(device_frames)
        host_frames = torch.empty(
            device_frames.shape,
            dtype=device_frames.dtype,
            device="cpu",
            pin_memory=True,
        )
        # 异步拷贝到 pinned host 内存,再同步流,最后转 numpy 返回
        host_frames.copy_(device_frames, non_blocking=True)
        stream.synchronize()
        host_array = host_frames.numpy()
tests/multimodal/media/test_video.py test-coverage

新增参数化 CPU 单测 `test_pynvvc_frames_normalized_to_nhwc`,覆盖 NHWC/NCHW 两种输入布局,钉住回归点(形状、连续性、内容保真),且不依赖 GPU 与 PyNvVideoCodec 专有依赖,可在常规 CI 运行。

@pytest.mark.parametrize("layout", ["nhwc", "nchw"])
def test_pynvvc_frames_normalized_to_nhwc(layout):
    """无论解码器输出哪种逐帧布局,PyNvVideoCodec 帧批量都应归一化到
    NHWC,保证 HF 视频处理器(逐帧构造 PIL 图像)拿到与其他后端一致的形状。"""
    torch = pytest.importorskip("torch") # 保持纯 CPU 可运行,不依赖 GPU 与专有后端
​
    n, h, w, c = 4, 5, 6, 3
    nhwc = torch.arange(n * h * w * c, dtype=torch.uint8).reshape(n, h, w, c)
    # NCHW 输入由 NHWC 基准转置而来,保证内容可精确对照
    frames = nhwc if layout == "nhwc" else nhwc.permute(0, 3, 1, 2).contiguous()
​
    out = _pynvvc_frames_to_nhwc(frames)
​
    assert out.shape == (n, h, w, c) # 形状统一为 NHWC
    assert out.is_contiguous() # 保证 pinned 异步拷贝路径可用
    assert torch.equal(out, nhwc) # 内容保真:未损坏或错位

评论区精华

PyNvVideoCodec 帧布局是否需要兼容 NHWC 与 NCHW 两种顺序 设计

Isotr0py 在 `vllm/multimodal/video.py:810` 评论:"This is a simplified implementation because it's said that PyNvVideoCodec will always convert NHWC to NCHW. (see #44465 discussion r3391283504) Perhaps we can roll back to the original implementation which considers both NHWC and NCHW order."

结论:作者采纳,实现 `_pynvvc_frames_to_nhwc`:以 `shape[-1] == 3` 判定 NHWC、`shape[-3] == 3` 判定 NCHW,统一归一化到 NHWC,兼容两种布局而非硬编码单一顺序。 · 已解决

bug 是否长期存在以及是否需要补充测试 测试

mgoin 在 review 中询问:"I believe you, but has this just been broken forever? Could you add a unit or e2e test that would catch this?"

结论:作者确认 bug 自 #44465 PyNvVideoCodec 集成起就存在,并新增纯 CPU 参数化单测 `test_pynvvc_frames_normalized_to_nhwc` 覆盖 NHWC/NCHW 两种输入;端到端行为由作者在 B200 上手动验证。 · 已解决

风险与影响

  • 布局推断依赖 3 通道假设:_pynvvc_frames_to_nhwc 以 shape[-1] == 3 判定 NHWC;若未来出现非 RGB 帧(灰度 C=1 或 4 通道),检测可能误判或静默跳过转置。当前 video-loader 契约固定 RGB,风险有限,但该启发式最好固化到函数契约中。
  • 回归面:改动仅涉及 vllm/multimodal/video.py 中 pynvvc 分支的单一调用点,其他后端不受影响;但也意味着该缺陷在常规 CI GPU 测试中不易暴露。
  • 测试缺口:单测只覆盖纯张量归一化逻辑,未覆盖真实 decode_frames_pynvvideocodec 解码路径(依赖 GPU 与 PyNvVideoCodec 专有依赖),CI 无法自动回归该端到端场景,后续布局再次漂移仍可能漏出。
  • 性能:contiguous() 对已连续张量是 no-op,permute 仅在检测到 NCHW 时执行,无实质开销。
  • 用户影响:恢复 VLLM_VIDEO_LOADER_BACKEND=pynvvideocodec 下原生 video 请求的可用性,对 gemma-4 等逐帧 PIL 处理模型是直接修复;使用其他视频后端或非逐帧 processor 的用户无行为变化。
  • 系统影响:仅触及多模态视频加载管线中 pynvvc 分支,无接口、配置或部署变更。
  • 团队影响:改动小、聚焦(+39/-1),风险低;新函数 docstring 把 NHWC 契约显式文档化,为后续后端实现提供了参考。
布局推断依赖 3 通道假设 真实解码路径缺 CI 覆盖 回归面集中于单一后端

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论