执行摘要
- 一句话:归一化下沉视觉塔,修复编码器 CUDA Graph 下图像嵌入错乱
- 推荐动作:值得精读。关键看点有三个:一是「归一化所有权从外层下沉到视觉塔」这一结构性决策,以及它如何一次性覆盖 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 的回归用例。
功能与动机
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 后能正确描述图片。动机本质是消除一条「不崩溃但静默错误」的视觉输入路径。
实现拆解
- 变更入口:把归一化模块变成视觉塔的注入依赖。
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(),归一化只有放在视觉塔内部才能覆盖全部调用链。
- 视觉塔 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 路径的调用链完全一致。
- 外层模型改造:删除自有
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(...) 调用,否则视觉塔会二次归一化。
- 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)。
- 测试配套:让测试预处理与 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(模块 视觉编码器;类别 source;类型 core-logic;符号 Qwen2_5_VisionTransformer.init, Qwen2_5_VisionTransformer.forward, Qwen2_5_VLForConditionalGeneration.init, Qwen2_5_VLForConditionalGeneration._process_image_input): 主修复文件: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。
vllm/model_executor/models/qwen2_vl.py(模块 视觉编码器;类别 source;类型 core-logic;符号 Qwen2VisionTransformer.init, Qwen2VisionTransformer.forward, Qwen2VLForConditionalGeneration.init, Qwen2VLForConditionalGeneration._process_image_input): 同构修复的第二个模型文件:Qwen2VisionTransformer.__init__ 增加 input_norm 注入、forward 首行改为 self.input_norm(...),Qwen2VLForConditionalGeneration 同步删除 self.input_norm 并在 _process_image_input / _process_video_input 的 DP 分支补 .type(self.visual.dtype)。
tests/models/multimodal/generation/test_qwen2_vl.py(模块 多模态测试;类别 test;类型 test-coverage;符号 batch_make_image_embeddings, batch_make_video_embeddings): 测试配套:batch_make_image_embeddings 与 batch_make_video_embeddings 引入 merge_mm_processor_kwargs({}),让测试预处理与推理侧一致,修复因视觉塔内部归一化导致的 double normalization(mi250 CI 失败根因)。
关键符号: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
主修复文件: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
同构修复的第二个模型文件: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
测试配套: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()
评论区精华
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 中未落地。
- vision tower 调用前的 dtype 预 cast 是否损失精度 (correctness): bot 撤回该结论并确认 uint8 值在 bf16 中可精确表示,预 cast 与
FusedInputNorm 内部 cast 结果一致,finding 不适用于当前输入契约。
- input_norm 是否应该移到 EncoderRunner (design): 维持把归一化放在视觉塔 forward 内的方案,理由是它与 eager 调用链一致且可被 encoder CUDA Graph 捕获;放回 EncoderRunner 会破坏融合路径。
- 编码器 CUDA Graph 下归一化的隐藏开销 (performance): 本 PR 未消解该开销,只作为已知代价接受,后续由 #56711(uint8 CG buffer)与 #56798(fp32 立即数内存)分头解决。
- mi250 CI 失败源于双重归一化 (testing): 在
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 自动化回归
关联脉络
- PR #56729 [Security] Cap Qwen-VL video sampling knobs: 同属 Qwen-VL 多模态输入处理链路的近期改动(改
vllm/multimodal/video.py 的采样上限),与本 PR 一样在调整视觉输入进入模型前的契约与约束,可对照阅读。
- PR #56786 [Bugfix][EPD] Preserve media processing options in encoder requests: 关注编码器(encoder)请求中媒体处理选项的透传,与本 PR 中「encoder 路径必须与 eager 路径行为一致」的问题域重叠,也解释了测试里合并 mm processor kwargs 的必要性。
参与讨论