Prhub

#42662 [LoRA][Gemma4] Support vision tower LoRA

原始 PR 作者 linitra24 合并时间 2026-08-13 22:07 文件变更 10 提交数 39 评论 23 代码增减 +309 / -48

执行摘要

Gemma4 多模态 LoRA 运行时管道与 token 计数接口

issue #40693 中用户使用 unsloth 微调 gemma-4-E4B-it 后启动 vLLM 0.19.1 报错 ValueError: Gemma4ForConditionalGeneration does not support LoRA yet.。PR body 说明在 #43798 之后 Gemma4-MM 视觉线性层已通过 Transformers 后端路径转换,因此本 PR 不再重实现 vision tower,而是补齐运行时 LoRA 映射与 token 计数:为多模态模型引入可按 modality 分别上报 tower 与 connector token 数的接口,并用各模态最大预算来 size LoRA 包装器。

值得精读,重点看三处设计:一是 get_mm_lora_token_counts 如何用 (tower_tokens, connector_tokens | None) 统一不同模态的计数差异;二是 models/gemma4_mm.py 中图像 padding 与 LoRA mapping 对齐的取舍(禁用 bucketing 换正确性);三是 model_manager.py 按模态最大预算 size Punica wrapper 的做法。建议同时关注 taroshi 反馈的 Gemma4ClippableLinear 无法包装问题是否在后续 PR 修复,以及 anshulkulhari7 指出的 stub 返回 None 的健壮性缺口。

讨论亮点

核心讨论集中在接口设计与正确性上:

  • depthfirst-app 指出 vllm/v1/worker/gpu/mm/lora.pyor not mm_mapping.connector and all(...) 存在 Python 运算符优先级问题(and 优先于 or),导致 connector 计数含 None 时早退守卫失效。最终版本已改为 or not all(count is not None ...),问题修复。

  • chatgpt-codex-connector[bot] 提出两点 P2 建议:一是在量化层(如 GGUF 替换 weight 属性)上不应直接读 weight;二是 MM LoRA 容量不应被 max_num_batched_tokens 截断,而应使用 mm_budget.get_encoder_budget()。最终版本均体现:早期 vision tower 重实现被移除,max_lora_tokens 直接取 get_encoder_budget()

  • gemini-code-assist 曾报告 zip(strict=True) 在 Python 3.9 不兼容,但该问题位于早期 vision tower 实现中,随 #43798 路线精简后已不适用。

  • 用户 taroshi 实测发现 vision_tower.encoder.layers.0~15self_attn.o_projmlp.down_projGemma4ClippableLinear)无法被任何 LoRA 层包装而被忽略,意味着这些层的 LoRA 权重不会生效,该问题在 PR 内未见明确结论。

  • 用户 anshulkulhari7 高度认可新接口(对可变长度音频塔模型是 O(1) 形状读取),但指出 hasattr 门控会放过只继承 SupportsMultiModal stub 的模型,未实现时返回 None 并直接喂给 get_punica_wrapper,存在崩溃风险;他正基于此接口推进 Ultravox connector-LoRA(#45771、#45944、#45697)。

实现拆解

实现按 5 步拆解:

  1. 接口层新增统一计数入口vllm/model_executor/models/interfaces.pySupportsMultiModal 上新增 get_mm_lora_token_counts(modality, mm_kwargs, num_mm_embeds) 默认实现,内部委托给旧的 get_num_mm_encoder_tokens / get_num_mm_connector_tokens,并把非 int 的 connector 计数归一为 None。这样未覆盖多模态差异的模型无需改动即可兼容新管道。

  2. Gemma4-MM 按模态实现计数与编码调整vllm/model_executor/models/gemma4_mm.py 新增 get_mm_lora_token_counts 重载——image 用 vision_config.default_output_length(或 mm_processor_kwargs 中的 max_soft_tokens)乘以 pooling_kernel_size**2 得到 tower token 数;video 用 _VIDEO_MAX_SOFT_TOKENS,必要时从 mm_kwargs.pixel_values_videos 形状推导;audio 用 input_features_paddedbatch_size * ceil(features/4) 估算。同时 _process_image_inputenable_tower_connector_lora 开启时禁用分辨率 bucketing,把所有图像 padding 到最大 patch 数(超限抛 ValueError),并跳过内存感知分块,保证一次 encoder 调用与 tower LoRA mapping 对齐。

  3. LoRA 管理器按模态最大预算分配vllm/lora/model_manager.py_maybe_init_mm 遍历 mm_budget.mm_max_toks_per_item,对每个 modality 调用 get_mm_lora_token_counts(modality, mm_kwargs=None, num_mm_embeds=mm_budget.get_encoder_budget()),tower 取最大值、connector 取非 None 最大值来创建 Punica wrapper,替代原先单一 get_num_mm_encoder_tokens 调用。

  4. V1 执行器与 V2 runner 路径同步改造vllm/v1/worker/gpu_model_runner.py_execute_mm_encodervllm/v1/worker/gpu/mm/lora.pyset_active_mm_loras 都改为一次调用返回 (tower_tokens, connector_tokens),并用 all(count is not None ...) 判断是否设置 connector mapping,避免继承 stub 的模型误入 connector 分支。

  5. 测试、CI 与文档配套:新增 tests/lora/test_gemma4_tp.py 端到端测试(单卡、TP2、TP4,使用真实 vision LoRA adapter EpochEcho/gemma4-e2b-it-lora-pokemon);tests/lora/conftest.py 增加 gemma4_vision_lora_files fixture;.buildkite/test_areas/lora.yaml 把该测试加入 CI;docs/models/supported_models.md 将 Gemma4 行的 LoRA 支持标记为可用;tests/v1/worker/test_gpu_model_runner.py 同步更新 mock 以匹配新 API。

文件 模块 状态 重要度
vllm/model_executor/models/gemma4_mm.py 模型层 modified 8.03
tests/lora/test_gemma4_tp.py 测试 added 7.47
vllm/model_executor/models/interfaces.py 接口层 modified 7.19
vllm/lora/model_manager.py LoRA 管理 modified 6.64
vllm/v1/worker/gpu_model_runner.py 执行器 modified 6.41
vllm/v1/worker/gpu/mm/lora.py 编码器路径 modified 6.05
tests/v1/worker/test_gpu_model_runner.py 测试 modified 4.68
tests/lora/conftest.py 测试 modified 4.48
.buildkite/test_areas/lora.yaml CI 配置 modified 2.6
docs/models/supported_models.md 文档 modified 1.89

关键符号

get_mm_lora_token_counts get_num_mm_encoder_tokens get_num_mm_connector_tokens _maybe_init_mm _execute_mm_encoder set_active_mm_loras _process_image_input

关键源码片段

vllm/model_executor/models/gemma4_mm.py data-contract

实现核心:新增 get_mm_lora_token_counts 按 image/video/audio 模态计算 tower/connector token 数;_process_image_input 在 MM LoRA 开启时统一 padding 并禁用 bucketing,保证 encoder 调用与 LoRA mapping 对齐。

    def get_mm_lora_token_counts(
        self,
        *,
        modality: str,
        mm_kwargs: "MultiModalKwargsItem | None",
        num_mm_embeds: int,
    ) -> tuple[int, int | None]:
        # 返回 (tower_tokens, connector_tokens),供 MM LoRA 构建 adapter
        # mapping。Gemma4 的图像 / 视频共享 vision tower,但 token 预算不同:
        # 图像按 default_output_length(或用户覆盖的 max_soft_tokens)计算,
        # 视频按 _VIDEO_MAX_SOFT_TOKENS 计算;音频则按打包后的特征帧估算。
        if modality in ("image", "video"):
            vision_config = self.config.vision_config
            pooling_k2 = vision_config.pooling_kernel_size**2
​
            if modality == "image":
                pixel_values_key = "pixel_values"
                max_soft_tokens = vision_config.default_output_length
                mm_processor_kwargs = getattr(
                    getattr(self, "multimodal_config", None),
                    "mm_processor_kwargs",
                    None,
                )
                # 用户可通过 mm_processor_kwargs 覆盖 soft token 数量,
                # 但只接受白名单 _SUPPORTED_SOFT_TOKENS 内的取值。
                if isinstance(mm_processor_kwargs, Mapping):
                    val, _ = _get_max_soft_tokens(mm_processor_kwargs)
                    if isinstance(val, int) and val in _SUPPORTED_SOFT_TOKENS:
                        max_soft_tokens = val
            else:
                pixel_values_key = "pixel_values_videos"
                max_soft_tokens = _VIDEO_MAX_SOFT_TOKENS
​
            # image 的 tower token 数是确定的;video 需要从 mm_kwargs 的
            # 张量形状读实际 patch 数,否则用最小 soft token 估算。
            tower_tokens = max_soft_tokens * pooling_k2 if modality == "image" else None
            connector_tokens = num_mm_embeds
            if tower_tokens is None and mm_kwargs is not None:
                field = mm_kwargs.get(pixel_values_key)
                if field is not None:
                    data = field.data
                    if isinstance(data, torch.Tensor) and data.ndim >= 2:
                        tower_tokens = int(math.prod(data.shape[:-1]))
​
            if tower_tokens is None:
                min_soft_tokens = min(_SUPPORTED_SOFT_TOKENS)
                tower_tokens = (
                    math.ceil(num_mm_embeds / min_soft_tokens)
                    * max_soft_tokens
                    * pooling_k2
                )
​
        if modality == "audio":
            # 音频塔与连接器共享同一 token 预算,默认取 num_mm_embeds;
            # 若提供 padding 后的特征,则按每 4 帧打包 1 个 token 重新估算。
            tower_tokens = num_mm_embeds
            connector_tokens = num_mm_embeds
​
            if mm_kwargs is not None:
                field = mm_kwargs.get("input_features_padded")
                if field is not None:
                    data = field.data
                    if isinstance(data, torch.Tensor) and data.ndim >= 2:
                        batch_size = math.prod(data.shape[:-2])
                        audio_tokens = batch_size * math.ceil(data.shape[-2] / 4)
                        tower_tokens = audio_tokens
                        connector_tokens = audio_tokens
​
        return tower_tokens, connector_tokens
vllm/lora/model_manager.py data-contract

LoRA 管理器初始化改为按模态取 tower/connector token 预算最大值来创建 Punica wrapper,是运行时正确性的核心配置逻辑。

        mm_budget = MultiModalBudget(vllm_config, mm_registry)
        limit_per_prompt = max(mm_budget.mm_max_items_per_prompt.values())
        max_lora_tokens = mm_budget.get_encoder_budget()
        # 对每个 modality 分别询问 LoRA token 预算,避免单一模态的
        # 计数口径掩盖其他模态(如图像 / 视频 / 音频)的更大需求。
        lora_token_counts_by_modality = [
            self.model.get_mm_lora_token_counts(
                modality=modality,
                mm_kwargs=None,
                num_mm_embeds=max_lora_tokens,
            )
            for modality in mm_budget.mm_max_toks_per_item
        ]
        num_encoder_tokens = max(
            tower_tokens for tower_tokens, _ in lora_token_counts_by_modality
        )
​
        # Tower wrappers 统一用最大 tower token 预算,保证任何模态
        # 的 encoder 输出都能被 Punica wrapper 容纳。
        tower_punica_wrapper = get_punica_wrapper(
            num_encoder_tokens,
            max_batches=self.max_num_seqs * limit_per_prompt,
            device=self.device,
            lora_config=self.lora_config,
        )
        for prefix in self.mm_mapping.tower_model:
            self.punica_wrapper_mapping[prefix] = tower_punica_wrapper
​
        # Use wrapper for connector if present.
        if self.mm_mapping.connector:
            connector_tokens = max(
                (
                    connector_tokens
                    for _, connector_tokens in lora_token_counts_by_modality
                    if connector_tokens is not None
                ),
                default=None,
            )
            if connector_tokens is not None:
                connector_punica_wrapper = get_punica_wrapper(
                    connector_tokens,
                    max_batches=self.max_num_seqs * limit_per_prompt,
                    device=self.device,
                    lora_config=self.lora_config,
                )
                for prefix in self.mm_mapping.connector:
                    self.punica_wrapper_mapping[prefix] = connector_punica_wrapper
            else:
                logger.warning_once(
                    "Connector LoRA support disabled: model does not implement "
                    "get_num_mm_connector_tokens(). This method is required to "
                    "determine the connector's token budget for LoRA operations."
                )

评论区精华

gemini-code-assist: zip(strict=True) 在 Python 3.9 不兼容 正确性

早期 vision tower 实现中使用了 Python 3.10 才支持的 zip(strict=True),而 vLLM 仍支持 Python 3.9。

结论:最终版本按 #43798 路线移除了 vision tower 重实现,该问题随代码删除而消失;但历史评论仍指向已删除的 diff。 · 已解决

codex: 量化线性层上不应直接读 weight 正确性

GGUF 等量化方法会用 qweight/qweight_type 替换 weight 属性,直接读 weight 会导致 vision tower 前向失败。

结论:该问题针对早期 vision tower 重实现,最终代码改为走 Transformers 后端路径,不再直接构造 vLLM 量化线性层。 · 已解决

codex: V2 MM LoRA 应使用新 count API 设计

早期版本只改了 gpu_model_runner.py,V2 runner 路径 mm/lora.py 仍调用旧接口,VLLM_USE_V2_MODEL_RUNNER=1 时会因 stub 返回 None 崩溃。

结论:最终版本同步更新了 vllm/v1/worker/gpu/mm/lora.py 的 set_active_mm_loras,问题已解决。 · 已解决

codex: MM LoRA 容量不应被 max_num_batched_tokens 截断 设计

chunked MM 输入下 encoder budget 可大于 decoder token 预算,若 wrapper 按 max_num_batched_tokens 截断,高分辨率输入会产生 index_mapping 超出 wrapper 缓冲而失败。

结论:最终 model_manager.py 直接使用 mm_budget.get_encoder_budget(),未做 min 截断,符合建议。 · 已解决

depthfirst-app: mm/lora.py 守卫条件运算符优先级 bug 正确性

`or not mm_mapping.connector and all(...)` 因 and 优先级高于 or,导致 connector 计数含 None 时守卫失效,np.array 构造会崩溃。

结论:最终版本改为 `or not all(count is not None ...)`,守卫语义正确。 · 已解决

jeejeelee: 建议拆分 vision tower 支持为独立 PR 设计

为加速落地,维护者建议将 vision tower 支持拆到另一个 PR,只保留 runtime LoRA 管道。

结论:PR 最终按此建议聚焦运行时 mapping 与 token 计数,vision tower 转换交由 #43798 完成。 · 已解决

anshulkulhari7: hasattr 门控放行 stub 模型 设计

get_num_mm_encoder_tokens/get_num_mm_connector_tokens 在 SupportsMultiModal 上是 `...` stub,hasattr 恒真;未实现模型返回 None 后 sizing 会直接抛错。新接口对 Ultravox 等变长音频塔模型很有价值,但需要更健壮的门控。

结论:PR 内未看到针对该问题的新增守卫;后续消费者(#45771、#45944、#45697)会基于此接口继续演进。 · unresolved

taroshi: 部分 Gemma4 视觉层 LoRA 无法被包装 正确性

实测加载 vision LoRA 时,第 0~15 层的 o_proj 与 down_proj(Gemma4ClippableLinear)输出 WARNING:matched but could not be wrapped,对应 LoRA 权重被忽略。

结论:PR 内未见修复或回复;该问题可能使部分层 LoRA 不生效,需要后续 PR 处理。 · unresolved

风险与影响

主要风险点:

  • LoRA 覆盖不完整taroshi 实测多组 Gemma4ClippableLinearo_projdown_proj)无法被包装而静默忽略,用户微调这些层时适配器行为不完整,可能产生错误输出而难以察觉。

  • 新接口默认实现返回 None 的连锁问题get_mm_lora_token_counts 默认实现委托给 get_num_mm_encoder_tokens,而该方法是带 ... 的 stub,所有 SupportsMultiModal 模型都能通过 hasattr 检查。一旦某模型未真正实现且 enable_tower_connector_lora=Truemodel_manager.pymax(tower_tokens for ...) 会收到 None 并抛 TypeError。虽然该功能为实验性开关,但默认路径缺少显式校验。

  • 图像批处理显存上升_process_image_input 在 MM LoRA 启用时放弃分辨率 bucketing 与内存感知分块,所有图像 padding 到最大 patch 并整批编码,高分辨率或大批次场景峰值显存明显增加;超限图像直接抛 ValueError,可能影响在线服务稳定性。

  • 测试配置矛盾tests/lora/test_gemma4_tp.py 文件头注明“不会在 CI 触发,仅本地验证”,但 .buildkite/test_areas/lora.yaml 已将其加入 CI,两者不一致可能导致 CI 行为与预期不符(或文件头注释过时)。

  • 早期 review 指向已删除代码:量化层读取 weightzip(strict=True) 等评论针对的是早期 vision tower 重实现,最终版本已移除该实现,历史讨论可能误导后来阅读者。

影响范围:

  • 用户侧:Gemma4(gemma-4-E2B-it 等)用户可直接加载经 unsloth 等工具微调的 vision LoRA,issue #40693 得到修复;但部分视觉层 LoRA 不生效问题会影响效果一致性。

  • 系统侧get_mm_lora_token_counts 成为多模态 LoRA 的新契约,V1 主执行器与 V2 runner 路径(mm/lora.py)同步改造,未来任何多模态模型接入 tower/connector LoRA 都必须遵循该接口;model_manager 的 wrapper 配置逻辑从单一模态预算升级为跨模态最大预算。

  • 团队侧:该接口为 Ultravox 等可变长度音频塔模型的 connector-LoRA 铺路(#45771、#45944、#45697),属于 multi-modality + LoRA 能力线的关键前置。

部分视觉层 LoRA 权重被静默忽略 新接口 stub 返回 None 可能引发崩溃 MM LoRA 下图像批处理显存峰值上升 测试文件与 CI 配置不一致 早期 review 指向已删除代码

关联 Issue

#40693 [Bug]: ValueError: Gemma4ForConditionalGeneration does not support LoRA yet.

完整报告

参与讨论