# PR #53675 完整报告

- 仓库：`vllm-project/vllm`
- 标题：[Multimodal] Use GPU NVDEC for EPD encoder-only instance video media IO
- 合并时间：2026-09-11 22:20
- 原文链接：http://prhub.com.cn/vllm-project/vllm/pull/53675

---

# 执行摘要

- 一句话：EPD 编码实例视频 IO 默认启用 NVDEC 解码
- 推荐动作：值得精读，属于「小改动、系统级收益」的典型样本。重点关注三处设计决策：一是 `_resolve_mm_video_decode_device` 的守卫顺序与「只在 encode-only 实例自动开启」的边界划分，它把资源独占性作为默认开启 GPU 解码的前提；二是把解码设备与多模态处理器设备绑定的决定（`mm_config.get_mm_processor_device_type()`），这是 review 中被作者自己推翻初版设计后得出的结论，避免了 D2H/H2D 重复搬运；三是 `VideoMediaIO.merge_kwargs` 中「启动期旋钮 vs 请求级旋钮」的划分和 `incompatible_backend_options` 的差集清理思路。建议同时阅读 PR body 的消融表（配置 A/B/D 对比）来理解收益来源，并跟踪作者预告的 kwargs 结构化重构 PR，因为当前的字典式选项合并已经出现过一次真实 bug（`device` 泄漏到 `opencv`）。如果团队正在做 EPD 规模扩展，这组数字可以直接用于容量规划：encoder 侧不再是瓶颈，PD 副本数成为吞吐决定因素。

# 功能与动机

PR body 明确动机："Given that ec_producer doesn't need to manage kv cache, it's safe to move media io to use NVDEC." 基线数据里 encode 阶段（fetch + decode + HF preprocess + ViT + EC publish）是 encoder 实例的主要瓶颈：fps=2 档 baseline 单请求编码耗时 18.5 s、Mean TTFT 14.5~18.9 s；fps=4 档编码耗时升到 31.0 s，吞吐仅 1.17~1.24 req/s。作者给出的消融显示：仅把预处理搬到 GPU（配置 B）相对纯 CPU（配置 A）已有 +220% step gain，再把 opencv CPU 解码换成 torchcodec NVDEC 且零拷贝（配置 D）又 +54%，encoder 侧合计 4.9x。因此需要在 encode-only 实例上把视频媒体 IO 整体迁移到设备侧，消除 D2H/H2D 往返。

# 实现拆解

**变更入口**：启动解析链。`vllm/config/vllm.py` 在 `_resolve_mm_processor_device()` 之后插入新的 `_resolve_mm_video_decode_device()` 调用，让「处理器放在哪个设备」与「视频解码放在哪个设备」在同一处、按同一顺序确定，避免两处逻辑各自推导设备。

1. **配置期自动注入 NVDEC（`vllm/config/vllm.py::VllmConfig._resolve_mm_video_decode_device`）**：新增方法，仅在 EPD 纯编码实例上生效，采用逐级短路守卫：

| 守卫条件 | 不满足时的行为 |
|---|---|
| `ec_transfer_config` 存在且 `is_encode_only` 为真 | 直接返回，普通实例与非 encode-only 角色完全不受影响 |
| `current_platform.device_type == "cuda"` | 非 CUDA 平台返回 |
| `mm_config.get_mm_processor_device_type()` 与 `device_type` 相同 | 处理器不在 GPU 上时返回（这是 review 后追加的关键条件） |
| `media_io_kwargs["video"]` 未显式给出 `backend` / `device` | 尊重用户显式配置 |
| `check_torchcodec_available()` 不抛异常 | 捕获 `ImportError` / `RuntimeError`，`info_once` 提示保留 CPU 解码 |

命中后写入 `{"backend": "torchcodec", "device": "cuda"}`。改这样做的原因是：encode-only 的 EC producer 不跑前向、不分配 KV cache，加速器整卡归前端使用，GPU 解码不会与模型争显存；把解码帧留在 GPU 又能省掉经 CPU media path 的 host 往返。副作用是解码结果类型从 `np.ndarray` 变为 CUDA `torch.Tensor`，向下游传播出新的类型契约。

2. **解码链路支持 `device`（`vllm/multimodal/video_decoders/torchcodec.py`）**：`decode_torchcodec()`、`TorchCodecVideoBackendMixin.make_torchcodec_decoder()` 与 `decode_torchcodec_frames()` 全部新增 `device: str = "cpu"` 形参并逐层传递；`make_torchcodec_decoder` 增加平台校验，`device="cuda"` 时要求 `current_platform.is_cuda()`，且只接受 `cpu` / `cuda` 两种取值，把非法组合在构造解码器前就拦下；`decode_torchcodec_frames` 不再无条件调用 `batch.data.numpy()`，而是直接返回 `batch.data`（CPU 下仍是 ndarray，CUDA 下是设备 Tensor），并在 `device` 非 CPU 且 `decoder.cpu_fallback` 为真时 `warning_once`，暴露 NVDEC 静默回退到 CPU 的情况。

3. **统一帧类型契约（`vllm/multimodal/video.py`、`vllm/multimodal/parse.py`、`vllm/multimodal/hasher.py`）**：`video.py` 新增 `DecodedFrames: TypeAlias = npt.NDArray | torch.Tensor`，并把 `VideoLoader.load_bytes` / `VideoBackend.load_bytes` 的返回类型与 docstring（新增 `device` 参数说明）改为该别名；`parse.py::MultiModalDataParser._get_video_with_metadata` 把 `isinstance(video, torch.Tensor)` 的 `video.numpy()` 分支合并为 `isinstance(video, (np.ndarray, torch.Tensor))` 直接透传，设备张量不再被拷回主机；`hasher.py` 做了小幅控制流调整，使 tensor 帧的哈希不触发 D2H 且与 ndarray 帧结果一致。

4. **请求级 kwargs 语义收紧（`vllm/multimodal/media/video.py`、`vllm/multimodal/video_decoders/__init__.py`）**：`VideoMediaIO.merge_kwargs` 现在会剥离请求级 `device`（与既有的 `hw_decoders` / `pool_size` 一致，属于启动期预留资源的旋钮）；当请求把 `backend` 换成与启动值不同的后端时，调用新增的 `incompatible_backend_options(old_backend, new_backend)`（基于 `_BACKEND_OPTION_DEFAULTS` 的键差集）清掉旧后端专属选项，避免残留的 `device=cuda` 传给 `opencv` 触发选项校验失败；torchcodec 的默认选项表同时补上 `"device": "cpu"`。

5. **测试与文档配套**：`tests/config/test_multimodal_config.py` 新增辅助函数 `_resolve_mm_video_decode_device` 与 5 个用例，覆盖 NVDEC 启用、非 encoder 角色保持 CPU、跟随显式处理器设备、尊重显式 `media_io_kwargs`、torchcodec 缺失时不注入；`tests/multimodal/media/test_video.py` 覆盖请求级 `device` 剥离与 backend 切换清理；`tests/multimodal/test_parse.py` 新增张量透传与设备张量保持用例；`tests/multimodal/test_hasher.py` 断言 tensor 帧与 ndarray 帧哈希相同；`tests/multimodal/test_video.py` 新增 torchcodec CUDA 解码用例。文档层面主要是 `VideoMediaIO` / `load_bytes` / 配置解析方法的 docstring 更新（含 `--media-io-kwargs '{"video": {"backend": "torchcodec", "device": "cuda"}}'` 的用法说明），由 PR body 的 GB200 基准数据承担效果说明。

关键文件：
- `vllm/config/vllm.py`（模块 配置解析；类别 source；类型 dependency-wiring；符号 _resolve_mm_video_decode_device, _resolve_mm_processor_device, _validate_mm_processor_device）: 新增 `_resolve_mm_video_decode_device` 并在启动解析链中调用，是整条 NVDEC 自动注入能力的入口与唯一决策点，5 层守卫条件决定了该特性对哪些实例生效。
- `vllm/multimodal/media/video.py`（模块 视频媒体；类别 source；类型 core-logic；符号 VideoMediaIO, VideoMediaIO.merge_kwargs, VideoMediaIO.load_bytes, VideoMediaIO.load_base64）: `VideoMediaIO.merge_kwargs` 是启动配置与请求级 `media_io_kwargs` 的合并点，本 PR 在此剥离请求级 `device` 并清理跨后端的残留选项；返回类型改用 `DecodedFrames`，是设备张量进入媒体 IO 层的接口文件。
- `vllm/multimodal/video_decoders/torchcodec.py`（模块 视频解码；类别 source；类型 core-logic；符号 decode_torchcodec, TorchCodecVideoBackendMixin.make_torchcodec_decoder, TorchCodecVideoBackendMixin.decode_torchcodec_frames）: 把 `device` 参数贯穿 `decode_torchcodec` / `make_torchcodec_decoder` / `decode_torchcodec_frames`，去掉强制 `.numpy()`，并新增平台校验与 NVDEC 回退告警，是 GPU 解码真正落地的地方。
- `vllm/multimodal/parse.py`（模块 多模态解析；类别 source；类型 dependency-wiring；符号 MultiModalDataParser._get_video_with_metadata, MultiModalDataParser._parse_video_data）: `MultiModalDataParser._get_video_with_metadata` 从「Tensor 转 numpy」改为张量透传，是设备帧能够不落回主机、直达 HF 处理器的关键一环，返回类型同步改为 `DecodedFrames`。
- `vllm/multimodal/video.py`（模块 视频加载；类别 source；类型 data-contract；符号 DecodedFrames, VideoLoader.load_bytes, VideoBackend.load_bytes）: 定义 `DecodedFrames: TypeAlias = npt.NDArray | torch.Tensor` 并更新 `VideoLoader.load_bytes` / `VideoBackend.load_bytes` 的签名与 docstring，是整条帧类型契约的声明来源。
- `vllm/multimodal/video_decoders/__init__.py`（模块 解码注册；类别 source；类型 core-logic；符号 incompatible_backend_options, _BACKEND_OPTION_DEFAULTS）: 新增 `incompatible_backend_options` 助手并为 torchcodec 默认选项表补上 `device`，是修复请求级跨后端选项泄漏的实现依据。
- `tests/config/test_multimodal_config.py`（模块 配置测试；类别 test；类型 test-coverage；符号 _resolve_mm_video_decode_device, test_auto_video_decode_uses_nvdec_on_encoder_instance, test_auto_video_decode_stays_on_cpu_off_encoder_instance, test_auto_video_decode_follows_explicit_processor_device）: 用 5 个用例把 `_resolve_mm_video_decode_device` 的守卫矩阵固定下来（启用、非编码角色、跟随处理器设备、尊重显式配置、torchcodec 缺失），是本次自动注入行为的主要回归防线。
- `tests/multimodal/media/test_video.py`（模块 媒体测试；类别 test；类型 test-coverage；符号 test_strips_request_level_device, test_switching_backend_drops_stale_codec_options, test_same_backend_keeps_codec_options）: 覆盖请求级 `device` 剥离与跨后端选项清理两个行为变更，正是 gty111 review 意见对应的回归测试。
- `tests/multimodal/test_parse.py`（模块 解析测试；类别 test；类型 test-coverage；符号 test_video_with_metadata_tensor_passthrough, test_video_with_metadata_keeps_device_tensor）: 断言张量帧透传且设备张量保持原对象，验证 parse 层不再做 numpy 转换这一契约变化。
- `tests/multimodal/test_hasher.py`（模块 多模态测试；类别 test；类型 test-coverage；符号 test_hash_video_tensor_frames, item_for_hash）: 验证 tensor 帧与 ndarray 帧哈希结果一致且不触发 D2H，保护多模态缓存键在设备帧场景下的稳定性。
- `vllm/multimodal/hasher.py`（模块 多模态哈希；类别 source；类型 core-logic；符号 MultiModalHasher）: 小幅控制流调整，使 tensor 帧在哈希时不做 D2H 拷贝，是新帧类型在缓存键路径上的适配点。
- `tests/multimodal/test_video.py`（模块 视频测试；类别 test；类型 test-coverage；符号 test_torchcodec_backend_cuda_decodes_on_gpu）: 新增 torchcodec CUDA 解码用例，直接覆盖新 device 参数在真实 GPU 上的行为（无 CUDA 时跳过）。

关键符号：VllmConfig._resolve_mm_video_decode_device, VllmConfig._resolve_mm_processor_device, VideoMediaIO.merge_kwargs, VideoMediaIO.load_bytes, decode_torchcodec, TorchCodecVideoBackendMixin.make_torchcodec_decoder, TorchCodecVideoBackendMixin.decode_torchcodec_frames, incompatible_backend_options, MultiModalDataParser._get_video_with_metadata, MultiModalHasher.hash_kwargs

## 关键源码片段

### `vllm/config/vllm.py`

新增 `_resolve_mm_video_decode_device` 并在启动解析链中调用，是整条 NVDEC 自动注入能力的入口与唯一决策点，5 层守卫条件决定了该特性对哪些实例生效。

```python
# vllm/config/vllm.py —— 启动期决策：EPD 编码实例是否把视频解码放到 GPU
    def _resolve_mm_video_decode_device(self) -> None:
        """Default video decoding to torchcodec GPU backend for EPD encoder-only
        instance if the mm processor runs on CUDA.

        The processor consumes the decoded frames on-device in that case, so
        keeping the frames on the GPU skips the host round-trip through the
        CPU media path. An explicit codec/backend choice in
        `--media-io-kwargs` is left alone, and the default is skipped where
        torchcodec (or its FFmpeg runtime) is unavailable.
        """
        if self.model_config is None or self.model_config.multimodal_config is None:
            return
        mm_config = self.model_config.multimodal_config

        ec_config = self.ec_transfer_config
        # 第一道守卫：只有「非 consumer 的 EC producer」才自动开启。
        # 它不跑前向、不分配 KV cache，加速器整卡归前端使用，
        # 因此 GPU 解码不会和模型计算抢显存（这也是默认开启的合法性依据）。
        if ec_config is None or not ec_config.is_encode_only:
            return

        from vllm.platforms import current_platform

        device_type = current_platform.device_type
        # 第二道守卫：解码设备必须与 mm processor 的落点一致。
        # review 中作者修正的初版设计——若解码在 GPU 而预处理在 CPU，
        # 反而会多出一次 D2H + H2D 往返，收益转负。
        if (
            device_type != "cuda"
            or mm_config.get_mm_processor_device_type() != device_type
        ):
            return

        # 第三道守卫：用户显式配置过 backend / device 就不再覆盖
        video_kwargs = mm_config.media_io_kwargs.setdefault("video", {})
        if "backend" in video_kwargs or "device" in video_kwargs:
            return

        from vllm.utils.import_utils import check_torchcodec_available

        try:
            check_torchcodec_available()
        except (ImportError, RuntimeError):
            # torchcodec 未安装，或装了但没有可用的 FFmpeg 运行时
            # （它抛异常而不是返回 False），此时保持 CPU 解码并给出一条提示
            logger.info_once(
                "EPD encoder instance: keeping CPU video decoding because "
                "torchcodec is not available (needs a CUDA build with FFmpeg)."
            )
            return

        # 注入后的效果：VideoMediaIO 以 torchcodec + cuda 构造，解码帧直接是设备张量
        video_kwargs["backend"] = "torchcodec"
        video_kwargs["device"] = device_type
        logger.info_once(
            "EPD encoder instance: decoding video with NVDEC (torchcodec device=%s).",
            device_type,
        )

```

### `vllm/multimodal/media/video.py`

`VideoMediaIO.merge_kwargs` 是启动配置与请求级 `media_io_kwargs` 的合并点，本 PR 在此剥离请求级 `device` 并清理跨后端的残留选项；返回类型改用 `DecodedFrames`，是设备张量进入媒体 IO 层的接口文件。

```python
# vllm/multimodal/media/video.py —— 启动配置与请求级 kwargs 的合并边界
class VideoMediaIO(MediaIO[MediaWithBytes[tuple[DecodedFrames, dict[str, Any]]]]):
    """Configuration values can be user-provided either by --media-io-kwargs or
    by the runtime API field "media_io_kwargs". Ensure proper validation and
    error handling.

    Use --media-io-kwargs '{"video": {"backend": "torchcodec",
    "device": "cuda"}}' to decode with NVDEC and keep frames on the GPU,
    which pairs with a device-side multi-modal processor
    (--mm-processor-device, EPD encode-only instances).
    """

    @classmethod
    def merge_kwargs(
        cls,
        default_kwargs: dict[str, Any] | None,
        runtime_kwargs: dict[str, Any] | None,
    ) -> dict[str, Any]:
        if runtime_kwargs:
            # 解码器显存按启动值预留，请求级不允许改
            runtime_kwargs = dict(runtime_kwargs)
            runtime_kwargs.pop("hw_decoders", None)
            runtime_kwargs.pop("pool_size", None)
            # 解码设备决定解码帧（以及整条处理器流水线）落在哪个设备上，
            # 属于启动期开关：请求既不能把解码推上 GPU，也不能把它拉回 CPU
            runtime_kwargs.pop("device", None)

            # Block request-level selection of GPU video backends that
            # were not configured (and VRAM-reserved) at startup.
            for key in ("video_backend", "backend"):
                requested = runtime_kwargs.get(key)
                if requested and VIDEO_LOADER_REGISTRY.backend_requires_gpu(requested):
                    static_val = (default_kwargs or {}).get(key)
                    if static_val != requested:
                        logger.warning_once(
                            "Stripping request-level %s=%r: GPU video "
                            "backend not configured at startup.",
                            key,
                            requested,
                        )
                        runtime_kwargs = {
                            k: v for k, v in runtime_kwargs.items() if k != key
                        }

        merged = super().merge_kwargs(default_kwargs, runtime_kwargs)
        # fps and num_frames interact with each other, so if either is
        # overridden at request time, wipe the other from defaults to
        # avoid unintuitive cross-field interactions.
        if runtime_kwargs:
            if "num_frames" in runtime_kwargs and "fps" not in runtime_kwargs:
                merged.pop("fps", None)
            elif "fps" in runtime_kwargs and "num_frames" not in runtime_kwargs:
                merged.pop("num_frames", None)

            # 请求切到别的 backend 时，清掉启动期那份 backend 专属选项：
            # 例如 torchcodec 的 device / seek_mode 传给 opencv 会直接报
            # ValueError: device is not supported by the 'opencv' backend
            # （gty111 在 review 中提出的场景）
            requested_backend = runtime_kwargs.get("backend")
            static_backend = (default_kwargs or {}).get("backend")
            if requested_backend and requested_backend != static_backend:
                for name in incompatible_backend_options(
                    static_backend, requested_backend
                ):
                    merged.pop(name, None)
        return merged

```

### `vllm/multimodal/video_decoders/torchcodec.py`

把 `device` 参数贯穿 `decode_torchcodec` / `make_torchcodec_decoder` / `decode_torchcodec_frames`，去掉强制 `.numpy()`，并新增平台校验与 NVDEC 回退告警，是 GPU 解码真正落地的地方。

```python
# vllm/multimodal/video_decoders/torchcodec.py —— GPU 解码的设备选择与帧返回
    @staticmethod
    def make_torchcodec_decoder(
        data: bytes,
        *,
        num_ffmpeg_threads: int = 0,
        seek_mode: Literal["exact", "approximate"] = "exact",
        device: str = "cpu",
    ) -> "VideoDecoder":
        torch_device = torch.device(device)
        # 只有 CUDA 平台才允许请求 GPU 解码，避免在非 NVIDIA 环境静默失败
        if torch_device.type == "cuda" and not current_platform.is_cuda():
            raise ValueError(
                f"torchcodec video decoding on device {device!r} requires "
                "a CUDA-capable platform."
            )
        elif torch_device.type not in ("cpu", "cuda"):
            raise ValueError(
                f"torchcodec video decoding only supports 'cpu' and 'cuda' "
                f"devices, got {device!r}."
            )
        # NHWC 与下游期望的 (num_frames, H, W, 3) uint8 RGB 布局一致，省一次 transpose
        return VideoDecoder(
            data,
            dimension_order="NHWC",
            num_ffmpeg_threads=num_ffmpeg_threads,
            seek_mode=seek_mode,
            device=device,
        )

    @staticmethod
    def decode_torchcodec_frames(
        decoder: "VideoDecoder",
        frame_indices: list[int],
        *,
        device: str = "cpu",
    ) -> tuple[npt.NDArray | torch.Tensor, list[int]]:
        """Decode the requested indices in one batched, index-exact call."""
        if not frame_indices:
            return np.empty((0,), dtype=np.uint8), []
        # Note: torchcodec releases the GIL for the entire call
        batch = decoder.get_frames_at(frame_indices)
        # 关键行为变化：不再无条件 .numpy()。device="cpu" 时 batch.data 是
        # ndarray；device="cuda" 时是 CUDA Tensor，直接返回可避免一次 D2H 拷贝，
        # 供设备侧 HF 视频处理器零拷贝消费。
        frames = batch.data
        # NVDEC 不可用（编码格式不支持 / 缺少 libnvcuvid）时 torchcodec 会静默
        # 回退到 CPU 解码，这里给出一次性告警，避免用户误以为在用硬件解码
        if (
            torch.device(device).type != "cpu"
            and (status := getattr(decoder, "cpu_fallback", None)) is not None
            and bool(status)
        ):
            logger.warning_once(
                "torchcodec could not use NVDEC for this video and "
                "decoded on CPU instead; check codec support and "
                "libnvcuvid."
            )
        return frames, list(frame_indices)

```

# 评论区精华

讨论共 5 条 review 评论，集中在两个技术点。其一，DarkLight1337 在 `vllm/config/vllm.py` 的 `_resolve_mm_video_decode_device` 处直接质疑："Am I missing something? Where does the check for encode-only instance occur?"——初版实现漏掉了 `ec_transfer_config.is_encode_only` 守卫，作者在 commit `84b1d07..62adfad` 修复，随后又主动追加了一次设计反思："Wait, after second thought, I think we should tie video decode device with mm processor device instead, otherwise it will cause duplicated d2h/h2d transfer."，由此产生最终版里 `mm_config.get_mm_processor_device_type() != device_type` 的判断，避免「解码在 GPU、预处理在 CPU」造成二次往返。其二，gty111 指出请求级 backend 覆盖的残留问题：编码实例默认注入 `{"backend": "torchcodec", "device": "cuda"}` 后，请求把 `backend` 改成 `"opencv"` 会保留 `device="cuda"`，报 `ValueError: device is not supported by the 'opencv' backend`，建议在切换后端时清理自动注入的 codec 专属选项；作者在最后一个 commit `8effb2c` 用 `incompatible_backend_options` 实现并在回复中确认。两位参与者都点到同一处技术债：作者两次表示 "I think we should clean the processor/mediaio device resolver function in followup PR, it's a bit messy" 与 "current dictionary is not flexible and clear enough"，即 kwargs 字典式解析需要结构化重构，属于本 PR 明确留下的后续工作。

- 初版实现缺少 encode-only 实例守卫 (correctness): 作者确认遗漏并在 commit `84b1d07..62adfad` 中补上 `if ec_config is None or not ec_config.is_encode_only: return`，同时保留注释说明 encode-only 实例不跑前向、不占 KV cache 才适合独占加速器。
- 解码设备必须与多模态处理器设备绑定 (design): 最终实现加入 `mm_config.get_mm_processor_device_type() != device_type` 判断，并在 `tests/config/test_multimodal_config.py::test_auto_video_decode_follows_explicit_processor_device` 中以 `--mm-processor-device=cpu` 场景固定该行为。
- 请求级 backend 覆盖残留 device=cuda 导致校验失败 (correctness): 作者在 commit `8effb2c` 中新增 `vllm/multimodal/video_decoders/__init__.py::incompatible_backend_options`（基于各后端选项默认表求键差集），在 merge_kwargs 中按新 backend 丢弃旧 backend 专属键，并补 `tests/multimodal/media/test_video.py::test_switching_backend_drops_stale_codec_options` 覆盖。
- kwargs 字典式解析需要结构化重构 (design): 本 PR 不做重构，作为已知技术债保留并计划在后续 PR 处理；评审者接受了这一安排（gty111 与 DarkLight1337 最终均 APPROVED）。

# 风险与影响

- 风险：
 1) **运行环境依赖**：自动注入依赖 torchcodec 含 CUDA 版与可用 FFmpeg 运行时，`check_torchcodec_available()` 抛错时只是 `info_once` 后降级，用户可能误以为已在用 NVDEC；此外 NVDEC 对个别编码格式会回退到 CPU（新增的 `decoder.cpu_fallback` 告警可缓解），此时帧仍在主机而处理器在 GPU，反而增加一次 H2D，收益会退化。
 2) **显存与并发**：NVDEC 解码器与 GPU 上的帧批次占用前端显存，虽然 encode-only 实例不分配 KV cache，但高分辨率、多帧（如 PR 中 60 帧 / 26k video token 一档）叠加高并发时仍有显存压力，且没有新增的显存预留在配置层声明。
 3) **请求级语义变更（兼容性）**：`merge_kwargs` 现在一律剥离请求级 `device`，此前能通过请求体 `media_io_kwargs` 指定解码设备的用法会静默失效；同时 backend 切换会连带丢弃 `seek_mode`、`hw_decoders` 等选项，属于行为收紧，需要 release note 提示。
 4) **类型契约变更**：`VideoLoader.load_bytes` 的返回值现在可能是 CUDA `torch.Tensor`，仓库外的自定义 video loader 实现、以及假定输入为 `np.ndarray` 的下游处理器（如 JPEG 拼接路径、`Image.fromarray`）如果不跟随 `DecodedFrames` 契约更新，可能在设备张量上出错；`parse.py` 的透传也意味着处理器必须自行处理 device tensor。
 5) **验证覆盖**：新增 CUDA 解码用例在无 GPU 环境会被 skip，真实 NVDEC 端到端回归依赖人工基准（PR body 的 GB200 数据），缺少进入常规 CI 的硬件路径，后续改动容易无声回归。
 - 影响：**用户 / 部署侧**：只影响 EPD 分离部署中充当 EC producer（`is_encode_only`）的编码实例，普通单实例或多模态服务因 `ec_config is None` 直接返回，行为完全不变；非 CUDA 平台同样不受影响。开启后编码阶段耗时显著下降（fps=2 档单请求 encode 18.5 s → 1.3 s，fps=4 档 31.0 s → 2.7 s），端到端吞吐 +94%~+209%、Mean TTFT 下降约 84%~87%，1600/1600 请求全部成功且无 NVDEC 回退。**系统侧**：优化后 PD 副本成为新瓶颈（`decode_ttfb` 从 0.40 s 升到 0.82~1.55 s），说明 EPD 系统的限流点从 encoder 转移到 PD，这是扩展 EPD 时预期的运行点，但运维需要相应增加 PD 副本数才能兑现全部收益。**团队 / 代码侧**：`DecodedFrames` 与 `incompatible_backend_options` 把「帧可能在设备上」这一事实显式化，属跨模块契约演进；同时配置解析层新增第三个 `_resolve_mm_*_device` 方法，作者已承认该处逻辑凌乱并规划跟进重构，评审者需要关注后续清理是否引入回归。
 - 风险标记：GPU 解码依赖 torchcodec 与 FFmpeg 运行时 , NVDEC 静默回退 CPU 仅告警 , 请求级 device 语义收紧（兼容性）, 帧类型契约变为设备 Tensor, 真实 NVDEC 路径缺少 CI 覆盖

# 关联脉络

- PR #56379 [EC] Automatically enable embedding inputs on EC/KV consumers: 同样改动 `vllm/config/vllm.py` 的 EC/EPD 启动配置推导，属于 EC 角色（producer/consumer）自动配置这条功能线的延续，本 PR 的 `ec_transfer_config.is_encode_only` 判断与之共享同一套 EC 语义。
- PR #55326 [Bugfix][Multimodal] Parse decoded video frame lists as a single video: 改动同一批文件 `vllm/multimodal/parse.py` 与 `tests/multimodal/test_parse.py`；本 PR 的 commit 记录显示合并 upstream/main 时在这两个文件产生过冲突，说明二者在视频解析路径上紧邻演进。
- PR #56385 [Bugfix][MM] Fix swapped H/W in dummy video profiling inputs: 同属多模态视频输入链路（视频 loader 的帧形状 / 尺寸语义），本 PR 改变的帧类型与布局约定（NHWC、设备张量）会影响该类 video 输入相关逻辑，是同一子系统的相邻修复。