Prhub

#51734 replace batch_norm to numerically identical without cudnn

原始 PR 作者 khushali9 合并时间 2026-08-11 12:43 文件变更 3 提交数 4 评论 13 代码增减 +80 / -19

执行摘要

用等价仿射变换替代 batch_norm,修复大图预处理 cuDNN 崩溃

issue #51717 报告启用 --mm-device-do-normalize 后,图像预处理阶段抛出 RuntimeError: cuDNN error: CUDNN_STATUS_INTERNAL_ERROR。PR body 定位了根因:FusedInputNormF.batch_norm(running_mean=0、running_var=1、eps=0)实现逐通道仿射,而 CUDA 上 F.batch_norm 派发到 cuDNN 内核,其 batch 维度受 CUDA grid 上限(约 65535)约束;这里的 batch 维度是 patch 数,对 2560x1440 等大图请求会超限。PR body 明确判断:"The operation is a plain per-channel affine map — there is no statistical behavior to preserve",因此直接把 F.batch_norm 换成等价的广播乘加,而不是去规避 cuDNN 限制。

值得精读。这是一个小而清晰的 bugfix 范例:识别出 F.batch_norm 在此场景属于“用错工具”——纯仿射映射被包装成 batch norm,无谓引入 cuDNN 派发路径与 CUDA grid 维度限制;替换为直接乘加后代码更简单、覆盖面更广。回归测试中 70000 patch 用例特意跨越旧上限,是锁定平台相关崩溃的良好示范。适合作为多模态预处理管线与算子选择权衡的阅读材料。

讨论亮点

review 中唯一实质讨论是死代码清理。DarkLight1337 在 vllm/model_executor/models/vision.py 的 forward 变更处提问 running_meanrunning_var 是否还需要;noooop 解释“这两者仅为配合 BatchNorm 正常使用而存在”(The running_mean and running_var are only there to allow it to use BatchNorm properly);DarkLight1337 随即要求删除(Let's remove them then),noooop 确认已删除。讨论简短,体现了“替换实现后及时清理由原实现造成的状态负担”的代码卫生意识,无未解决疑虑。

实现拆解

整个修复围绕 FusedInputNorm 展开,共 5 步:

  1. 根因定位:在 vllm/model_executor/models/vision.py 中,FusedInputNorm.forward 先用 grid_thw.view(patches, channel, patch_size) 把输入拆成逐通道视图,再调用 F.batch_norm(..., training=False, eps=0.0)。此时 batch 维度就是 patch 数,CUDA 路径派发到 cuDNN 后受 grid 上限约束。
  2. 核心替换:forward 改为 x = grid_thw.to(self.dtype).view(patches, channel, patch_size) 后直接计算 x * self.weight.view(1, channel, 1) + self.bias.view(1, channel, 1),再 view(patches, size) 并转回 visual_dtype。由于 running_var=1、eps=0,batch_norm 在代数上与本乘加完全等价。
  3. 状态与导入清理:__init__ 中删除 running_meanrunning_var 两个缓冲的注册(含 identity 分支的 None 注册),并移除 import torch.nn.functional as F,使模块状态收敛为仅 weight / bias
  4. 测试配套:tests/models/test_vision.py 新增朴素参考实现 _reference_input_norm、参数化测试 test_fused_input_norm_matches_reference(num_patches 取 1、37、70000,其中 70000 特意超过旧 cuDNN 上限)、test_fused_input_norm_identity_passthrough(验证 identity 配置仅做 dtype 转换);模块级 pytestmark = pytest.mark.cpu_test,在 CPU 上验证数值等价。
  5. 文档配套:docs/design/mm_processing.mdFusedInputNorm 的描述从“冻结的 BatchNorm1d”改为 weight/bias 单步仿射(y = x * weight + bias),说明 weight 同时承载 rescale 与 std、bias 负责 mean 定心,避免文档与实现脱节。

合入过程共 4 个 commit:贡献者初版修复、维护者(noooop / wang.yuqi)补文档、两次 merge main 保持分支同步,期间多次触发 CI(/ci run)后合入。

文件 模块 状态 重要度
vllm/model_executor/models/vision.py 视觉模型 modified 6.44
tests/models/test_vision.py 视觉模型 modified 6.33
docs/design/mm_processing.md 设计文档 modified 2.23

关键符号

FusedInputNorm.forward FusedInputNorm.__init__ _reference_input_norm test_fused_input_norm_matches_reference test_fused_input_norm_identity_passthrough

关键源码片段

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

修复核心文件。`FusedInputNorm.forward` 用广播乘加替代 `F.batch_norm`,删除 `running_mean` / `running_var` 缓冲与不再使用的 `torch.nn.functional` 导入,从根源上消除 cuDNN grid 上限引发的崩溃。

# vllm/model_executor/models/vision.py —— FusedInputNorm 修复后的核心路径
# 构造时仅保留 weight / bias 两个仿射参数;原先为配合 F.batch_norm 而注册的
# running_mean / running_var 缓冲已在 review 中确认并删除def __init__(self, image_mean, image_std, rescale_factor, channel=3, dtype=torch.float32):
    self.channel = channel
    # ...(is_identity 判定等与修复无关的逻辑省略)
​
    if not self.is_identity:
        # 将 rescale 因子折入仿射参数:
        # image_std_tensor = image_std / rescale_factor
        # weight = 1 / image_std_tensor = rescale_factor / image_std
        # bias = -image_mean_tensor / image_std_tensor = -image_mean / image_std
        # 输出等价于 (x * rescale_factor - image_mean) / image_std
        image_mean_tensor = torch.tensor(image_mean, dtype=dtype) * (1.0 / rescale_factor)
        image_std_tensor = torch.tensor(image_std, dtype=dtype) * (1.0 / rescale_factor)
        self.register_buffer("weight", 1.0 / image_std_tensor)
        self.register_buffer("bias", -image_mean_tensor / image_std_tensor)
    else:
        # identity 配置不注册参数,forward 直接透传
        self.register_buffer("weight", None)
        self.register_buffer("bias", None)
​
​
def forward(self, grid_thw: torch.Tensor, visual_dtype: torch.dtype) -> torch.Tensor:
    # identity 配置仅做 dtype 转换后透传
    if self.is_identity:
        return grid_thw.to(visual_dtype)
​
    assert grid_thw.ndim == 2
    patches, size = grid_thw.shape
    patch_size = size // self.channel
​
    # 直接执行逐通道仿射 x * weight + bias,替代 F.batch_norm:
    # CUDA 上 F.batch_norm 会派发到 cuDNN 内核,其 batch 维度受 CUDA grid
    # 上限(约 65535)约束;而这里的 batch 维度是 patch 数,会随图像分辨率
    # 与批内图像数量无界增长,大图请求(如 Qwen2.5-VL 2560x1440)会触发
    # CUDNN_STATUS_INTERNAL_ERROR(见 issue #51717)。
    # 由于 running_var = 1、eps = 0,batch_norm 在代数上与乘加完全等价,
    # 直接展开不会改变数值结果,且不再受 grid 上限限制。
    x = grid_thw.to(self.dtype).view(patches, self.channel, patch_size)
    x = x * self.weight.view(1, self.channel, 1) + self.bias.view(
        1, self.channel, 1
    )
    return x.view(patches, size).to(visual_dtype)
tests/models/test_vision.py test-coverage

新增数值等价性回归测试:`_reference_input_norm` 朴素参考实现与两个测试用例,其中 70000 patch 用例特意超过原 cuDNN 上限(约 65535)锁定崩溃场景;identity 透传测试保证无归一化配置行为不变。

# tests/models/test_vision.py —— 新增的 FusedInputNorm 数值等价性回归测试def _reference_input_norm(pixel_values, image_mean, image_std, rescale_factor,
                          channel, out_dtype):
    """朴素逐通道仿射实现:(x * rescale - mean) / std,作为对照基准。"""
    patches, size = pixel_values.shape
    patch_size = size // channel
    mean = torch.tensor(image_mean, dtype=torch.float32).view(1, channel, 1)
    std = torch.tensor(image_std, dtype=torch.float32).view(1, channel, 1)
    x = pixel_values.to(torch.float32).view(patches, channel, patch_size)
    x = (x * rescale_factor - mean) / std
    return x.view(patches, size).to(out_dtype)
​
​
@pytest.mark.parametrize("num_patches", [1, 37, 70000])
def test_fused_input_norm_matches_reference(num_patches: int):
    # 70000 这个用例特意超过旧 batch_norm 实现的 cuDNN grid 上限(约 65535),
    # 用于回归锁定 issue #51717 的崩溃场景在替换实现后不再出现
    channel = 3
    patch_size = 14 * 14 # 与 CLIP / Qwen2.5-VL 视觉塔的 patch 尺寸一致
    image_mean = [0.48145466, 0.4578275, 0.40821073]
    image_std = [0.26862954, 0.26130258, 0.27577711]
    rescale_factor = 1.0 / 255.0
​
    set_random_seed(0)
    pixel_values = torch.randint(
        0, 256, (num_patches, channel * patch_size), dtype=torch.float32
    )
​
    norm = FusedInputNorm(
        image_mean=image_mean,
        image_std=image_std,
        rescale_factor=rescale_factor,
        channel=channel,
    )
    assert not norm.is_identity
​
    out = norm(pixel_values, visual_dtype=torch.float32)
    expected = _reference_input_norm(
        pixel_values, image_mean, image_std, rescale_factor, channel, torch.float32
    )
    torch.testing.assert_close(out, expected)
​
​
def test_fused_input_norm_identity_passthrough():
    # identity 配置应返回原输入(仅 dtype 转换),保证无归一化时不引入计算
    norm = FusedInputNorm.identity()
    assert norm.is_identity
​
    pixel_values = torch.randn(8, 3 * 196, dtype=torch.float32)
    out = norm(pixel_values, visual_dtype=torch.bfloat16)
    torch.testing.assert_close(out, pixel_values.to(torch.bfloat16))

评论区精华

清理不再需要的 running_mean / running_var 缓冲 设计

DarkLight1337 在 `vllm/model_executor/models/vision.py` 的 forward 变更处提问 running_mean 和 running_var 是否还需要;noooop 解释两者仅为让 BatchNorm 正常工作而存在;DarkLight1337 随即要求删除。

结论:确认删除两个缓冲及其 None 注册分支,FusedInputNorm 状态简化为仅 weight / bias。 · 已解决

风险与影响

  1. 数值等价性:批量归一化在 running_var=1、eps=0 时与乘加代数等价,但 cuDNN 与朴素乘加的浮点运算顺序不同,极端情况下可能有微小数值差异;测试用 torch.testing.assert_close 默认容差验证了该风险可控。
  2. CUDA 真实路径缺少回归验证:测试文件模块级标记 cpu_test,70000 patch 用例在 CPU 上运行,验证的是数值等价与超限 patch 数下的正确性,并未在真实 CUDA/cuDNN 环境复现原崩溃场景,GPU 行为依赖实现推理。
  3. state_dict 契约变化:FusedInputNorm 的缓冲从 4 个减为 2 个,若下游代码依赖 running_mean / running_var 键(如保存/加载该模块状态)会受影响;该模块引入不久且缓冲由处理器配置现场计算,风险低。
  4. 性能:移除 cuDNN 派发后走纯逐元素乘加内核,理论上有益;PR 未提供 benchmark,但无回归预期。

用户侧:仅影响启用 --mm-device-do-normalize 的多模态路径,修复大型图(如 Qwen2.5-VL 2560x1440)及图像批处理时的崩溃,行为恢复可用。系统侧:全局行为无变化,仅 FusedInputNorm 模块实现与状态集变化。团队侧:外部贡献者提交(注明使用 Claude 辅助开发且逐行人工复核),维护者 noooop 与 DarkLight1337 审核后合入,并纳入 milestone v0.27.0 cherry picks,将随 0.27.0 发布。

CUDA 真实路径缺少回归覆盖 state_dict 缓冲键移除 浮点运算顺序导致微小数值差异

关联 Issue

#51717 [Bug]: `CUDNN_STATUS_INTERNAL_ERROR` at image preprocessing w/ `--mm-device-do-normalize`

完整报告

参与讨论