Prhub

#42785 [MM][CG] Enable encoder CUDA Graph for MiniCPM-V

原始 PR 作者 YunzhuLu 合并时间 2026-09-10 14:28 文件变更 26 提交数 19 评论 36 代码增减 +843 / -103

执行摘要

为 MiniCPM-V 2.5/2.6/4.0 启用编码器 CUDA Graph,并引入通用捕获轴机制。

MiniCPM-V 模型依赖的 ViT 编码器涉及大量小 CUDA 内核的启动,在生产服务中存在不可忽视的开销。为延续 #38061 中为 Qwen3-VL 引入的编码器 CUDA Graph 框架,本 PR 旨在将此性能优化扩展到 MiniCPM-V 系列。关键动机在于,MiniCPM-V 的编码器捕获形状不仅取决于 token budget,还依赖于离散的 patch-grid/像素布局层级,因此需要一个新的“二次捕获轴”机制来区分这些共享同一 token budget 但形状不同的输入,从而保持调度和内存分配逻辑不变。

本 PR 是一个有价值的性能与架构改进,值得精读。核心亮点在于引入的“捕获轴”机制,这是一个解决多模态编码器 CUDA Graph 中输入形状多样性问题的优雅方案,设计模式具有借鉴意义。实现者对 Idefics2 位置编码的重构也展示了如何使遗留代码适应 CUDA Graph 约束。建议关注以下几点:1)捕获轴机制的通用性如何在其他模型上应用;2)Idefics2 重构对其他模型的影响;3)MiniCPM-V 4.5 未来支持的可能性。

讨论亮点

Review 讨论主要集中在接口设计和实现细节上:

  1. 捕获轴机制设计:审阅者 shen-shanshan 建议将二级捕获轴的启用逻辑下沉到 EncoderCudaGraphConfig 中(如 enable_secondary_capture_axis=True),并让 prepare_encoder_cudagraph_capture_inputs 接口直接接收 secondary_capture_axis_key 参数,从而避免在 EncoderCudaGraphManager 中增加额外的包装方法。最终实现采纳了更通用的 capture_axes 机制。
  2. 接口一致性:审阅者 Isotr0py 探讨了是否支持任意数量的捕获轴,并推动了将实现从固定的“二级轴”泛化为可配置的 capture_axes 元组。这提升了框架的扩展性。
  3. 文档与代码风格:审阅者 shen-shanshan 指出文档表格中可合并同架构的 MiniCPM-V 2.6 和 4.0 行,以保持简洁。
  4. CI 问题解决:讨论中发现并修复了 tgt_sizes 的 GPU 同步阻塞问题(因 #52827 引入的 keep_on_cpu 导致),通过使用 async_tensor_h2d 解决。还修复了因方法返回类型注解意外触发 test_tensor_schema 测试失败的问题。

实现拆解

  1. 引入捕获轴机制:在 EncoderCudaGraphConfig 数据类中新增 capture_axes 字段,类型为 tuple[tuple[Hashable, ...], ...]。这允许模型声明其编码器 CUDA Graph 捕获需要额外的维度。EncoderCudaGraphManager 相应地将其 budget_graphs 字典的键类型从 int 扩展为 int | tuple[int, CaptureAxisKeys],以支持按 (token_budget, axis_keys) 的组合来存储和查找捕获的图。
  2. MiniCPM-V 模型集成:在 vllm/model_executor/models/minicpmv.py 中,创建了 _MiniCPMVEncoderCudaGraphMixin 类,它实现了 SupportsEncoderCudaGraph 接口。该 mixin 为 MiniCPM-V 2.5/2.6/4.0 实现了所有必需的方法,如 get_encoder_cudagraph_config(配置了 capture_axes)、prepare_encoder_cudagraph_capture_inputs(创建包含展平像素值、patch 掩码、tgt_sizes 等的缓冲区)和 encoder_cudagraph_forward。关键辅助函数 _mcpmv_pack_flat_pixels 负责将图像切片打包成固定大小的缓冲区。
  3. 重构 Idefics2 位置编码:为使 Idefics2VisionEmbeddings.get_position_ids 适应 CUDA Graph 捕获,将其从基于循环的 CPU 计算重构为完全向量化的 GPU 计算。关键变更是使用 async_tensor_h2d 异步传输 tgt_sizes,并使用 torch.where 处理填充掩码,避免了 GPU 同步。
  4. 更新接口与契约:在 SupportsEncoderCudaGraph 协议的 select_encoder_cudagraph_items 方法文档中,明确要求支持捕获轴的模型必须在返回的字典中通过 ENCODER_CUDAGRAPH_AXIS_KEYS_KWARG 键传递解析出的轴键。所有已实现此协议的模型文件(如 cohere_compass.py, qwen2_vl.py 等)的 prepare_encoder_cudagraph_capture_inputs 方法签名均添加了 axis_keys 参数以保持兼容。
  5. 测试覆盖:在 tests/models/multimodal/generation/test_vit_cudagraph.py 中,为 MiniCPM-V 2.5、2.6、4.0 添加了端到端的 CUDA Graph 测试配置,覆盖图像和视频模态,并设置了适合 CI 环境(35GB MIG)的小 token budget 集合以避免 OOM。
文件 模块 状态 重要度
vllm/model_executor/models/minicpmv.py MiniCPM-V 模型 modified 9.21
vllm/v1/worker/encoder_cudagraph.py CUDA Graph 管理器 modified 8.32
vllm/model_executor/models/idefics2_vision_model.py Idefics2 编码器 modified 7.51
tests/models/multimodal/generation/test_vit_cudagraph.py 编码器 CUDA Graph 测试 modified 6.79
vllm/v1/worker/encoder_cudagraph_defs.py 数据结构定义 modified 6.34

关键符号

_MiniCPMVEncoderCudaGraphMixin.get_encoder_cudagraph_config _MiniCPMVEncoderCudaGraphMixin.prepare_encoder_cudagraph_capture_inputs _MiniCPMVEncoderCudaGraphMixin.encoder_cudagraph_forward _mcpmv_pack_flat_pixels _mcpmv_normalize_tgt_sizes Idefics2VisionEmbeddings.get_position_ids EncoderCudaGraphManager.capture EncoderCudaGraphManager._capture_budget_graph EncoderCudaGraphManager._run_budget_graph

关键源码片段

vllm/model_executor/models/minicpmv.py core-logic

核心模型实现文件,包含 MiniCPM-V 编码器 CUDA Graph 的全部逻辑,包括缓冲区准备、前向传播、像素打包以及新的捕获轴配置。

# --- MiniCPM-V 编码器 CUDA Graph 核心实现 ---
​
class _MiniCPMVEncoderCudaGraphMixin(MiniCPMVBaseModel, SupportsEncoderCudaGraph):
    """为 MiniCPM-V 2.5/2.6/4.0 提供编码器 CUDA Graph 支持的 mixin 类。"""
​
    # 标记该模型支持编码器 CUDA Graph
    supports_encoder_cudagraph: ClassVar[Literal[True]] = True
​
    def get_encoder_cudagraph_config(self) -> EncoderCudaGraphConfig:
        """返回编码器 CUDA Graph 配置,包括捕获轴。"""
        # MiniCPM-V 的捕获轴是 patch-grid 尺寸 (nb_h, nb_w)
        capture_axes = (
            (
                (32, 32),
                (32, 64),
                (64, 32),
                # ... 其他可能的 grid 尺寸
            ),
        )
        return EncoderCudaGraphConfig(
            modalities=["image", "video"],
            buffer_keys=[
                _MINICPMV_CUDAGRAPH_BUF_KEY_TGT_SIZES,
                _MINICPMV_CUDAGRAPH_BUF_KEY_PATCH_MASK,
            ],
            out_hidden_size=..., # 模型隐藏层维度
            capture_axes=capture_axes,
            # ... 其他配置
        )
​
    def prepare_encoder_cudagraph_capture_inputs(
        self,
        token_budget: int,
        max_batch_size: int,
        max_frames_per_batch: int,
        device: torch.device,
        dtype: torch.dtype,
        path: str = "default",
        axis_keys: tuple[Hashable, ...] | None = None,
    ) -> EncoderCudaGraphCaptureInputs:
        """为 CUDA Graph 捕获准备输入缓冲区。
​
        当提供 axis_keys(来自捕获轴)时,缓冲区大小将根据
        该 patch-grid 尺寸精确调整,避免填充浪费。
        """
        # 根据 axis_keys 计算像素高度、宽度和最大补丁数
        pixel_h, pixel_w = self._mcpmv_patch_grid_pixel_hw(axis_keys)
        max_num_patches = self._mcpmv_patch_grid_num_patches(axis_keys)
​
        # 创建固定形状的缓冲区,用于记录到 CUDA Graph 中
        values = {
            _MINICPMV_CUDAGRAPH_BUF_KEY_TGT_SIZES: torch.zeros(
                (max_batch_size, 2), device=device, dtype=torch.long
            ),
            # ... 其他缓冲区
        }
        return EncoderCudaGraphCaptureInputs(values=values)
​
    # ... 其他接口方法
vllm/v1/worker/encoder_cudagraph.py dependency-wiring

编码器 CUDA Graph 管理器的核心逻辑,被扩展以支持捕获轴机制,负责图的捕获、查找和回放调度。

# --- 捕获轴机制在管理器中的集成 ---
​
class EncoderCudaGraphManager:
    def __init__(self, ...):
        # ... 初始化 token_budgets, max_batch_size 等
​
        # 从模型配置中读取捕获轴
        self._capture_axes: tuple[CaptureAxisKeys, ...] = tuple(
            tuple(axis) for axis in self.config.capture_axes
        )
        if any(len(axis) == 0 for axis in self._capture_axes):
            raise ValueError("Encoder cudagraph capture axes must be non-empty.")
​
        # 图的查找键类型扩展为 : int (token_budget) 或 tuple[int, CaptureAxisKeys]
        self.budget_graphs: dict[str, dict[BudgetGraphMapKey, BudgetGraphMetadata]] = {}
​
    def capture(self, graph_pool: Any):
        """为所有配置的路径和 token 预算捕获 CUDA 图。
​
        如果配置了捕获轴,则为每个预算和每个轴键组合捕获一个图。"""
        for path, budgets in self.path_token_budgets.items():
            for token_budget in sorted(budgets, reverse=True):
                if token_budget == 0:
                    continue
                # 为每个捕获轴键组合生成一个图
                for axis_keys in itertools.product(*self._capture_axes):
                    self._capture_budget_graph(
                        token_budget,
                        path=path,
                        axis_keys=axis_keys,
                    )
​
    def _capture_budget_graph(self, token_budget: int, path: str = "default",
                             axis_keys: CaptureAxisKeys = ()):
        """为单个 token 预算和轴键组合捕获一个 CUDA 图。"""
        # 构建图的查找键
        graph_map_key: BudgetGraphMapKey = (
            token_budget if not axis_keys else (token_budget, axis_keys)
        )
        # ... 捕获图并存入 graph_set[graph_map_key]
​
    def _run_budget_graph(self, mm_kwargs, token_budget, path, axis_keys=()):
        """执行预算图。"""
        # 构建查找键
        graph_map_key = (
            token_budget if not axis_keys else (token_budget, axis_keys)
        )
        if graph_map_key not in self._get_graph_set(path):
            self.graph_misses += num_items
            return None
        # ... 找到图并执行回放
vllm/model_executor/models/idefics2_vision_model.py data-contract

MiniCPM-V 使用的 Idefics2 视觉编码器的位置编码计算被重构以适应 CUDA Graph 捕获,从 CPU 循环改为 GPU 向量化操作。

# --- Idefics2VisionEmbeddings.get_position_ids 重构 ---
​
class Idefics2VisionEmbeddings(nn.Module):
    def get_position_ids(
        self,
        patch_attention_mask: torch.BoolTensor,
        tgt_sizes: torch.IntTensor | None = None,
    ) -> torch.Tensor:
        batch_size = patch_attention_mask.shape[0]
        device = patch_attention_mask.device
        boundaries = torch.arange(
            1 / self.num_patches_per_side,
            1.0,
            1 / self.num_patches_per_side,
            device=device,
        )
​
        flat_patch_attention_mask = patch_attention_mask.view(batch_size, -1)
        max_patches = flat_patch_attention_mask.shape[-1]
​
        if tgt_sizes is not None:
            # tgt_sizes 可能来自 CPU 端的 mm_kwargs;使用 pinned async
            # 复制以保持与 VLLM_GPU_SYNC_CHECK 的兼容性。
            if tgt_sizes.device != device:
                tgt_sizes = async_tensor_h2d(tgt_sizes, device)
            nb_patches_h = tgt_sizes[:, 0]
            nb_patches_w = tgt_sizes[:, 1]
        else:
            # ... 回退逻辑
​
        # 完全向量化的 GPU 计算
        nb_h = nb_patches_h.clamp(min=1).unsqueeze(1)
        nb_w = nb_patches_w.clamp(min=1).unsqueeze(1)
        i = torch.arange(max_patches, device=device)
​
        # ... 计算 h_idx, w_idx, fractional_h, fractional_w
        bucket_h = torch.bucketize(fractional_h, boundaries, right=True)
        bucket_w = torch.bucketize(fractional_w, boundaries, right=True)
        pos_ids = bucket_h * self.num_patches_per_side + bucket_w
​
        position_ids = torch.where(flat_patch_attention_mask, pos_ids.to(torch.long), 0)
​
        target_device = self.position_embedding.weight.device
        if position_ids.device != target_device:
            position_ids = async_tensor_h2d(position_ids, target_device)
        return position_ids

评论区精华

捕获轴机制的接口设计 设计

审阅者 shen-shanshan 建议将二级捕获轴的启用逻辑和键参数直接放入 `EncoderCudaGraphConfig` 和 `prepare_encoder_cudagraph_capture_inputs` 接口中,以避免在管理器中添加包装方法。Isotr0py 进一步建议将其泛化为支持任意数量的捕获轴。最终实现采纳了更通用的 `capture_axes` 元组配置。

结论:采用可配置的 `capture_axes` 元组,每个轴是一个可能的键值集合。管理器根据预算和轴键的笛卡尔乘积捕获多个图。 · 已解决

tgt_sizes 的 GPU 同步阻塞 性能

CI 运行中发现 `tgt_sizes` 的 `.to(device)` 调用因 #52827 引入的 `keep_on_cpu` 而变成阻塞的 H2D 拷贝,导致 GPU-CPU 同步。作者 YunzhuLu 指出此时只需拷贝到图缓冲区,无需读回主机,因此 `async_tensor_h2d` 是合适的解决方案。

结论:在 `minicpmv.py` 的 `forward` 和 `idefics2_vision_model.py` 的 `get_position_ids` 中使用 `async_tensor_h2d` 替代阻塞拷贝。 · 已解决

空缓冲区的数据类型 正确性

gemini-code-assist[bot] 指出 `_mcpmv_pack_flat_pixels` 中为填充创建的空缓冲区硬编码为 `torch.float32`,应与模型参数 dtype 保持一致以避免类型不匹配。

结论:已修复,使用模型参数的实际 dtype。 · 已解决

select_encoder_cudagraph_items 返回格式 正确性

gemini-code-assist[bot] 指出 `select_encoder_cudagraph_items` 返回的 `tgt_sizes` 是张量列表,但 `_mcpmv_normalize_tgt_sizes` 期望单个张量,在数据并行场景下会崩溃。

结论:通过扩展 `_mcpmv_normalize_tgt_sizes` 以处理 `list[torch.Tensor]` 来修复。 · 已解决

风险与影响

  1. 回归风险:对 Idefics2 视觉编码器(Idefics2VisionEmbeddings.get_position_ids)的重构改变了其位置编码计算从 CPU 循环到 GPU 向量化操作的路径,可能影响共享此编码器的其他模型(如 InternVL、Gemma3 等)的行为和性能。需要确保所有相关模型在 eager 和 CUDA Graph 模式下均通过测试。
  2. 兼容性风险:SupportsEncoderCudaGraph 接口的 prepare_encoder_cudagraph_capture_inputs 方法新增了 axis_keys 参数。所有已实现此接口的模型文件都需要同步更新其签名(本 PR 已完成),否则可能导致调用错误。未来新增模型需遵循此契约。
  3. 性能风险:引入捕获轴机制后,每个 token budget 的图捕获数量可能倍增。代码中已设置 _CAPTURE_GRAPH_COUNT_WARNING_THRESHOLD = 64 进行告警,并在文档中提醒用户合理设置 capture_axes 以避免内存爆炸。
  4. 平台兼容性:async_tensor_h2d 的使用假定存在异步 H2D 传输通道。在非 CUDA/ROCm 平台上可能需要回退到同步传输,但当前实现依赖于 current_platform。
  1. 用户影响:MiniCPM-V 2.5、2.6、4.0 的用户现在可以通过启用 --compilation-config '{ "cudagraph_mm_encoder": true }' 来获得编码器推理的性能提升(延迟降低,吞吐增加)。视频输入在 MiniCPM-V 2.6/4.0 上也得到支持。MiniCPM-V 4.5 因动态帧融合不兼容静态图捕获而未包含,可能成为后续优化点。
  2. 系统影响:为多模态编码器 CUDA Graph 框架引入了一个重要的泛化设计(捕获轴),为未来支持更多具有复杂输入形状依赖的模型(如混合分辨率、动态 patching)奠定了基础。这增强了 vLLM 在多模态推理优化方面的基础设施。
  3. 团队影响:本 PR 是 tracker #38175 的重要组成部分,展示了社区协作模式(原作者 YunzhuLu 与维护者 Isotr0py 合作)。它为其他模型贡献者提供了实现编码器 CUDA Graph 的清晰范例和文档。
核心编码器逻辑重构 接口契约变更影响多模型 捕获轴机制可能增加图捕获数量与内存占用

关联 Issue

#38061 [MM][Perf][CG] Support ViT full CUDA graph for Qwen3-VL video inference
#38175 [RFC]: Support ViT Full CUDA Graph (Tracker)

完整报告

参与讨论