Prhub

#55370 [Bugfix] Make `mm_device_do_normalize` encoder-cudagraph safe

原始 PR 作者 cjackal 合并时间 2026-09-10 01:43 文件变更 3 提交数 4 评论 18 代码增减 +28 / -18

执行摘要

归一化下沉视觉塔,修复编码器 CUDA Graph 下图像嵌入错乱

PR body 明确说明:mm_device_do_normalize(由 #50411 引入)只在 _process_*_input 中应用,而 encoder cudagraph 路径(cg_manager.execute → encoder_eager_forward() / encoder_cudagraph_forward())直接调用 self.visual(...),于是「unscaled uint8 pixels are passed to the vision tower and image embedding is silently wrong - resulting in complete gibberish on vision questions (mostly whitespaces in case of Qwen2.5)」。作者给出的 before/after 对照显示 main 上模型只输出空白字符,本 PR 后能正确描述图片。动机本质是消除一条「不崩溃但静默错误」的视觉输入路径。

值得精读。关键看点有三个:一是「归一化所有权从外层下沉到视觉塔」这一结构性决策,以及它如何一次性覆盖 eager / encoder cudagraph / DP 三条路径;二是 coderabbitai 与 cjackal 关于 dtype 预 cast 是否损失精度的争论,说明 FusedInputNorm 的 uint8 输入契约是这套设计的前提;三是评论区揭示的未解性能代价(归一化被捕获进 CG、padding 行被重复计算、每 replay 一次 D2D cast),阅读时应把它当作后续 #56711 / #56798 的背景。建议顺手检查其他启用了 mm_device_do_normalize 的 VL 模型是否也把 self.input_norm 留在了外层,并考虑补一个带 cudagraph_mm_encoder 的回归用例。

讨论亮点

1) coderabbitai 在 qwen2_5_vl.py:1447 提出「移除 vision tower 调用前的预 cast」,cjackal 以 FusedInputNorm 只接受 uint8、与 cast 可交换(bit-identical)为由反驳,bot 随后撤回并承认 uint8 在 bf16 中可精确表示。
2) cjackal 在测试文件说明 mi250 CI 失败源于 double normalization,并把 mm_kwargs 引入测试以对齐推理行为。
3) noooop 在 issue 评论中质疑是否应把 input_norm 放到 EncoderRunner,Isotr0py 认为那样很难融进 encoder CUDA Graph;cjackal 进一步承认 CG buffer dtype 固定为 bf16 导致每次 replay 有 D2D cast、且归一化对 CG padding 行也执行,是隐藏算力浪费。Isotr0py 建议启用 fused input norm 时把 buffer dtype 改成 uint8(#56711),cjackal 认为 #56798 解决 fp32 立即数内存膨胀更关键,两者都需要。该性能议题在本 PR 中未落地。

实现拆解

  1. 变更入口:把归一化模块变成视觉塔的注入依赖。vllm/model_executor/models/qwen2_5_vl.py 的 Qwen2_5_VisionTransformer.__init__ 与 vllm/model_executor/models/qwen2_vl.py 的 Qwen2VisionTransformer.__init__ 新增形参 input_norm: nn.Module | None = None(插在 prefix 之前),并在 patch_embed 构造之后保存 self.input_norm = input_norm if input_norm is not None else FusedInputNorm.identity()。原因是 encoder cudagraph 路径只经过 self.visual(),归一化只有放在视觉塔内部才能覆盖全部调用链。
  2. 视觉塔 forward:归一化成为第一道工序。两个 VisionTransformer.forward 的首行由 x.to(device=self.device, dtype=self.dtype) 改为 self.input_norm(x.to(device=self.device), self.dtype);先搬 device、保持整数像素,再交给 FusedInputNorm 做 rescale + normalize 并输出 self.dtype。归一化因此被 CUDA Graph 捕获,与 eager 路径的调用链完全一致。
  3. 外层模型改造:删除自有 self.input_norm,并清理两个消费点。Qwen2_5_VLForConditionalGeneration / Qwen2VLForConditionalGeneration 的 __init__ 改为 input_norm=FusedInputNorm.from_model_config(self.model_config) 传入视觉塔;_process_image_input、_process_video_input 删除显式的 self.input_norm(...) 调用,否则视觉塔会二次归一化。
  4. DP 分支的 dtype 契约保留。mm_encoder_tp_mode == "data" 分支改为传 pixel_values.type(self.visual.dtype) / pixel_values_videos.type(self.visual.dtype) 给 run_dp_sharded_mrope_vision_model。原先它收到的是「已归一化且已 cast」的浮点张量,现在归一化推迟到视觉塔内,必须显式对齐 dtype,避免 helper 拿到 uint8(其分片与 all-gather 输出构造依赖 dtype)。
  5. 测试配套:让测试预处理与 E2E 推理对齐。tests/models/multimodal/generation/test_qwen2_vl.py 的 batch_make_image_embeddings 与 batch_make_video_embeddings 在调用 image_processor.preprocess / video_processor.preprocess 前,先取 llm.get_llm().model_config.multimodal_config.merge_mm_processor_kwargs({}) 并透传 **mm_kwargs。原因是测试此前依赖 HF processor 的归一化,而视觉塔现在也会归一化,形成 double normalization,直接导致 mi250 CI 数值比对失败(作者在测试文件评论中确认)。该改动无新增的 cudagraph_mm_encoder 自动化断言,修复验证依赖 PR body 里的手动 e2e 脚本。
文件 模块 状态 重要度
vllm/model_executor/models/qwen2_5_vl.py 视觉编码器 modified 6.62
vllm/model_executor/models/qwen2_vl.py 视觉编码器 modified 6.33
tests/models/multimodal/generation/test_qwen2_vl.py 多模态测试 modified 4.41

关键符号

Qwen2_5_VisionTransformer.__init__ Qwen2_5_VisionTransformer.forward Qwen2_5_VLForConditionalGeneration._process_image_input Qwen2_5_VLForConditionalGeneration._process_video_input Qwen2VisionTransformer.__init__ Qwen2VisionTransformer.forward Qwen2VLForConditionalGeneration._process_image_input Qwen2VLForConditionalGeneration._process_video_input batch_make_image_embeddings batch_make_video_embeddings

关键源码片段

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

主修复文件:`Qwen2_5_VisionTransformer.__init__` 新增 `input_norm` 注入参数、`forward` 首行改为 `self.input_norm(...)`,外层 `Qwen2_5_VLForConditionalGeneration` 删除 `self.input_norm` 并在 `_process_image_input` / `_process_video_input` 中改为 DP 分支显式 dtype cast。

class Qwen2_5_VisionTransformer(nn.Module):
    def __init__(
        self,
        vision_config: Qwen2_5_VLVisionConfig,
        norm_eps: float = 1e-6,
        quant_config: QuantizationConfig | None = None,
        # 新增:归一化模块改由外层 conditional model 注入,
        # 这样视觉塔自己就对「一定被归一化」负责,
        # 而不再依赖调用方是否走 _process_*_input
        input_norm: nn.Module | None = None,
        prefix: str = "",
    ) -> None:
        super().__init__()
        ...
        self.patch_embed = Qwen2_5_VisionPatchEmbed(
            patch_size=patch_size,
            temporal_patch_size=temporal_patch_size,
            in_channels=in_channels,
            hidden_size=self.hidden_size,
        )
        # 默认 identity:旧调用方不会因缺参报错,但也不会归一化,
        # 属于需要上游保证输入已归一化的历史行为
        self.input_norm = (
            input_norm if input_norm is not None else FusedInputNorm.identity()
        )
        ...
​
    def forward(
        self,
        x: torch.Tensor,
        grid_thw: list[list[int]] | None,
        *,
        encoder_metadata: dict[str, torch.Tensor] | None = None,
    ) -> torch.Tensor:
        # 核心修复:归一化被提到 forward 的第一行。
        # encoder cudagraph 路径(EncoderRunner -> encoder_cudagraph_forward)
        # 只调用 self.visual(),此前会跳过归一化,把未缩放的 uint8 像素直接送进
        # patch_embed,图像 embedding 静默错误(Qwen2.5 上表现为大量空白字符)。
        # 这里先搬到 device(仍是整数像素),再由 FusedInputNorm 完成
        # rescale + normalize 并按 self.dtype 输出。
        hidden_states = self.input_norm(x.to(device=self.device), self.dtype)
        hidden_states = self.patch_embed(hidden_states)
        ...
​
​
class Qwen2_5_VLForConditionalGeneration(nn.Module, SupportsMultiModal, SupportsPP):
    def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
        ...
        with self._mark_tower_model(vllm_config, {"image", "video"}):
            self.visual = Qwen2_5_VisionTransformer(
                vision_config=config.vision_config,
                norm_eps=getattr(config, "rms_norm_eps", 1e-6),
                quant_config=self.quant_config,
                # 所有权下沉:外层不再持有 self.input_norm,
                # 而是把配置好的归一化模块作为依赖注入视觉塔,
                # 顺带让它能被 encoder CUDA Graph 捕获
                input_norm=FusedInputNorm.from_model_config(self.model_config),
                prefix=maybe_prefix(prefix, "visual"),
            )
​
    def _process_image_input(self, image_input):
        ...
        if image_input["type"] == "image_embeds":
            image_embeds = image_input["image_embeds"].type(self.visual.dtype)
        else:
            pixel_values = image_input["pixel_values"]
            # 删除了原先的 self.input_norm(pixel_values, self.visual.dtype):
            # 归一化已在 self.visual.forward 内完成,保留会造成 double normalize
            if self.use_data_parallel:
                # DP 分支绕过 visual.forward 的外部归一化,必须自己保持输入契约:
                # 之前的输入是已归一化 + 已 cast 的浮点张量,
                # run_dp_sharded_mrope_vision_model 依赖该 dtype,因此显式 cast
                return run_dp_sharded_mrope_vision_model(
                    self.visual,
                    pixel_values.type(self.visual.dtype),
                    grid_thw_list,
                    rope_type="rope_3d",
                )
            else:
                image_embeds = self.visual(pixel_values, grid_thw=grid_thw_list)
vllm/model_executor/models/qwen2_vl.py core-logic

同构修复的第二个模型文件:`Qwen2VisionTransformer.__init__` 增加 `input_norm` 注入、`forward` 首行改为 `self.input_norm(...)`,`Qwen2VLForConditionalGeneration` 同步删除 `self.input_norm` 并在 `_process_image_input` / `_process_video_input` 的 DP 分支补 `.type(self.visual.dtype)`。

class Qwen2VisionTransformer(nn.Module):
    # Vision checkpoints store qkv pre-fused; merge separate q/k/v into qkv.
    hf_to_vllm_mapper = WeightsMapper(
        orig_to_new_stacked={
            ".q_proj": (".qkv", "q"),
            ".k_proj": (".qkv", "k"),
            ".v_proj": (".qkv", "v"),
        }
    )
​
    def __init__(
        self,
        vision_config: Qwen2VLVisionConfig,
        norm_eps: float = 1e-6,
        quant_config: QuantizationConfig | None = None,
        # 与 Qwen2.5-VL 保持同一契约:归一化由外部注入
        input_norm: nn.Module | None = None,
        prefix: str = "",
    ) -> None:
        super().__init__()
        ...
        self.patch_embed = Qwen2VisionPatchEmbed(
            patch_size=patch_size,
            temporal_patch_size=temporal_patch_size,
            in_channels=in_channels,
            embed_dim=embed_dim,
        )
        # identity 兜底,避免直接构造视觉塔时报错
        self.input_norm = (
            input_norm if input_norm is not None else FusedInputNorm.identity()
        )
        ...
​
    def forward(
        self,
        x: torch.Tensor,
        grid_thw: torch.Tensor | list[list[int]] | None,
        *,
        encoder_metadata: dict[str, torch.Tensor] | None = None,
    ) -> torch.Tensor:
        # patchify
        # 归一化前移:encoder cudagraph 直接调用视觉塔时也能正确缩放像素
        x = self.input_norm(x.to(device=self.device), self.dtype)
        x = self.patch_embed(x)
        ...
tests/models/multimodal/generation/test_qwen2_vl.py test-coverage

测试配套:`batch_make_image_embeddings` 与 `batch_make_video_embeddings` 引入 `merge_mm_processor_kwargs({})`,让测试预处理与推理侧一致,修复因视觉塔内部归一化导致的 double normalization(mi250 CI 失败根因)。

# tests/models/multimodal/generation/test_qwen2_vl.py
def batch_make_image_embeddings(
    image_batches: list[Union[Image.Image, list[Image.Image]]],
    processor: AutoProcessor,
    llm: VllmRunner,
) -> list[Qwen2VLPromptImageEmbeddingInput]:
    ...
    # image to pixel values
    image_processor = processor.image_processor
​
    # 关键:让测试预处理与 E2E 推理使用同一套 mm processor kwargs。
    # 当启用 on-device normalization(mm_device_do_normalize)时,推理侧会让 processor
    # 跳过 CPU 侧归一化;测试若仍按 processor 默认参数处理,像素会先在 CPU 上被归一化一次,
    # 而视觉塔 forward 现在也会归一化一次,形成 double normalization,
    # 这正是 mi250 CI 数值比对失败的根因。
    mm_config = llm.get_llm().model_config.multimodal_config
    mm_kwargs = mm_config.merge_mm_processor_kwargs({})
    preprocess_result = image_processor.preprocess(
        images=images, return_tensors="pt", **mm_kwargs
    ).data
    pixel_values = preprocess_result["pixel_values"]
    image_grid_thw = preprocess_result["image_grid_thw"]
​
    # pixel values to embeddings & grid_thws
    def get_image_embeds(model):
        with torch.no_grad():
            visual = model.visual
            pixel_values_on_device = pixel_values.to(visual.device, dtype=visual.dtype)
            # 直接调用视觉塔:归一化现在发生在它内部,
            # 与推理(含 encoder cudagraph)的调用链保持一致
            return visual(pixel_values_on_device, grid_thw=image_grid_thw).cpu()

评论区精华

vision tower 调用前的 dtype 预 cast 是否损失精度 正确性

coderabbitai 建议移除 `pixel_values.type(self.visual.dtype)` / `pixel_values_videos.type(...)` 预 cast,让原始输入不变地进入 `FusedInputNorm`;cjackal 回应「FusedInputNorm explicitly assumes that the input is uint8, so I think cast-normalization ops is commutative (i.e. bit-identical). At least floats are invalid as an input for FusedInputNorm.」

结论:bot 撤回该结论并确认 uint8 值在 bf16 中可精确表示,预 cast 与 `FusedInputNorm` 内部 cast 结果一致,finding 不适用于当前输入契约。 · 已解决

input_norm 是否应该移到 EncoderRunner 设计

noooop 提议把 input_norm 移到 `EncoderRunner`;Isotr0py 认为「If we move input_norm there, I think it will be a bit difficult to fuse input_norm into encoder cuda graph」,noooop 又指出 cudagraph 的输入 dtype 是 `visual.dtype`(bf16)而 input_norm 需要 uint8,怀疑两者是否该融合。

结论:维持把归一化放在视觉塔 forward 内的方案,理由是它与 eager 调用链一致且可被 encoder CUDA Graph 捕获;放回 EncoderRunner 会破坏融合路径。 · 已解决

编码器 CUDA Graph 下归一化的隐藏开销 性能

cjackal 承认 CG buffer dtype 固定为 `visual.dtype`(bf16)导致「we currently do D2D cast for each replay」,并且「we now capture normalization so we do cost the same normalization to CG padded rows, Which is a waste of compute resource」;Isotr0py 建议启用 fused input norm 时把 buffer dtype 改成 uint8(#56711);cjackal 认为 #56798 解决 fp32 立即数在 CG padding 下的内存膨胀更关键,两者都需要。

结论:本 PR 未消解该开销,只作为已知代价接受,后续由 #56711(uint8 CG buffer)与 #56798(fp32 立即数内存)分头解决。 · unresolved

mi250 CI 失败源于双重归一化 测试

cjackal 在测试文件评论中说明:「The tests in mi250 CI pipeline fails due to double normalization, previously `visual.forward` did not normalize the input but now it normalize the already normalized input.」

结论:在 `batch_make_image_embeddings` / `batch_make_video_embeddings` 中通过 `merge_mm_processor_kwargs({})` 透传 mm processor kwargs,使测试预处理与 E2E 推理一致,CI 恢复。 · 已解决

风险与影响

1) 数值回归面较宽:归一化位置变化影响 Qwen2-VL / Qwen2.5-VL 的所有视觉输入路径(eager、encoder CUDA Graph、DP 的 run_dp_sharded_mrope_vision_model,以及测试中直接调用 visual 的路径),任何未同步的调用方都会出现「归一化一次」或「归一化两次」的偏差。
2) input_norm=None 时默认 FusedInputNorm.identity(),即忘记注入就静默不归一化——与本 PR 要修的失效模式同类(silent wrong),缺少断言兜底。
3) 性能开销未消解:cjackal 确认归一化被捕获进 encoder CG 后,padding 出来的行也会跑归一化;CG buffer dtype 固定为 bf16 导致每次 replay 需要一次 uint8 → bf16 的 D2D cast。
4) 内存风险:归一化产生的 fp32 立即数张量在 CG padding 放大下可能达到数百 MB,依赖 #56798。
5) 测试覆盖不足:仓库没有针对 cudagraph_mm_encoder 的自动化用例,修复验证依赖手动 e2e;测试改动仅对齐 mm_kwargs 以修复既有 CI。
6) 兼容性细节:新增 input_norm 形参插在 prefix 之前,若存在位置参数调用 Qwen2VisionTransformer / Qwen2_5_VisionTransformer 的第三方代码会错位(仓库内调用点均为关键字传参)。

用户侧:开启 --compilation-config.cudagraph_mm_encoder True 的 Qwen2-VL / Qwen2.5-VL 用户从「视觉问答输出乱码但不报错」恢复为可用,eager 路径用户预期无感知变化。系统侧:改变了视觉塔的输入契约——现在接受未归一化的整数像素,所有多模态 encoder 入口(含 CUDA Graph 与数据并行)都必须经由视觉塔 forward 才会被归一化;DP 分支需要自行保证 dtype 对齐。工程侧:确立了「归一化归属于视觉塔 forward」的约定,为其他 VL 模型迁移 mm_device_do_normalize 提供范式;同时把 encoder CG buffer dtype(#56711)与 fp32 立即数内存(#56798)两个后续议题显式暴露出来,形成清晰的演进路线。

视觉输入契约变更 DP 分支 dtype 依赖 默认 identity 静默失败 编码器 CG 计算浪费 缺少 CUDA Graph 自动化回归

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论