Prhub

#55897 [LoRA] Add LoRA support for DeepSeek-V4 Flash Vision

原始 PR 作者 linitra24 合并时间 2026-09-13 22:10 文件变更 3 提交数 10 评论 15 代码增减 +42 / -3

执行摘要

为 DeepSeek-V4 视觉模型增加 LoRA 支持并修正连续性断言

Issue#55683 的诉求是“requesting to add support for https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash-Vision-Exp for fine tuning.”,即希望该视觉模型可用于微调;PR body 也明确写“Add LoRA support for DeepSeek-V4-Flash-Vision-Exp. Fixes #55683.”。此前 DeepseekV4ForConditionalGeneration 只声明了 SupportsMultiModal / SupportsPP / SupportsEagle3,既不在 LoRA 协议的识别范围内,也缺少 packed 权重映射与多模态 token 计数契约,导致 PEFT 训练出的适配器无法被 vLLM 正确加载与执行。

值得精读,尤其是做多模态 LoRA / 后训练链路的同学。建议重点关注三点设计:(1) get_mm_lora_token_counts 为什么必须区分 tower 侧 patch token 与 LM 侧 mm embed 两套计数、以及 dummy run 下的倍率反推;(2) tower + connector LoRA 在混合 LoRA 批次下的映射语义是否真的成立(对应 P1 未解决项,建议补多图 + 多适配器的 e2e 用例再上线);(3) “把 .contiguous() 兜底放进 LoRA 算子而不是模型调用点”这一分层取舍——它降低了调用方负担,但把契约变更扩散到了全部 LoRA 前向。若只是使用该模型,按文档打勾的 LoRA 列直接使用即可,但建议先单适配器验证。

讨论亮点
  • P1(chatgpt-codex-connector[bot]):“When enable_tower_connector_lora serves a batch containing images from requests with different LoRA IDs, this flag is unsafe … later images receive the first request's adapter (or a mixed prefix), silently corrupting their embeddings.” 指出 v1 多模态路径只安装一份拼接后的 token 映射,而 _process_image_input 是逐图调用 _encode_image,混合 LoRA 批次下后序图像会取错映射;建议“Process the mapped tokens in one forward or advance/install the mapping per image”。合并时未见作者回复或代码处理。
  • P2(chatgpt-codex-connector[bot]):“this newly LoRA-capable outer class does not define lora_skip_prefixes = ["mtp."], so adapter validation sees projections such as e_proj and h_proj as unexpected and rejects the entire adapter.” 建议与文本主干保持一致,跳过本就被 weights mapper 丢弃的 MTP 投影。
  • jeejeelee 与作者的修复位置之争:jeejeelee 对 common/vision.py 中 self.w1(x) 改为 self.w1(x.contiguous()) 直接发问“QQ: why change this line”;作者解释 F.unfold(...).transpose 产物非连续、而 LoRA shrink op 有 assert inputs.is_contiguous(),开启视觉 LoRA 会断言失败;jeejeelee 回“If so, we should add this in lora code”,作者回复“addressed”。最终 .contiguous() 落在 lora_shrink_op.py,模型侧改动被撤销,是本 PR 最有价值的一处分层取舍。
  • 文档确认:jeejeelee 问“do we need to update doc info?”,作者答“Yes, it has been added.”,对应 supported_models.md 的 LoRA 列打勾;随后 jeejeelee 以 APPROVED 结束 review。此外 mergify 提示合并冲突、jeejeelee 要求“please address the conflict”,作者通过 /ci run、/ci retry 驱动 CI(重试 35 个失败 job)后由 vllm-bot 合并。

实现拆解

  1. 能力声明(vllm/models/deepseek_v4/common/vl_model.py,类 DeepseekV4ForConditionalGeneration):基类列表加入 SupportsLoRA,使外层 VL 包装类进入 LoRA 管理器的识别与适配器解析范围;新增 supports_tower_connector_lora = True,显式放开 vision tower 与 aligner 上的适配器挂载。这是从“只有 language_model 能挂 LoRA”到“视觉分支也可微调”的关键开关,影响所有走 SupportsLoRA 协议的能力探测与校验路径。
  2. 融合权重映射(packed_modules_mapping):新增 gate_up_proj -> ["w1", "w3"]、fused_wqa_wkv -> ["wq_a", "wkv"]、fused_wkv_wgate -> ["wkv", "wgate"],并补上视觉编码器的 wqkv -> ["wqkv"]、w1 -> ["w1"] 自映射。原因是 Checkpoint / PEFT 适配器里的名字是融合后的投影名,而 module 上是拆分后的子投影,没有这张表,LoRA 权重无法回填到正确参数;视觉侧自映射项则为 ViT 的融合投影预留挂载点。
  3. 多模态 LoRA 契约(get_mm_mapping / get_mm_lora_token_counts):get_mm_mapping() 通过 MultiModelKeys.from_string_field(language_model="language_model", connector="aligner", tower_model="vision.") 告知 LoRA 运行时各前缀的归属,决定适配器按子模块分组以及 enable_tower_connector_lora 的生效范围。get_mm_lora_token_counts() 返回 (tower_tokens, num_mm_embeds):tower 侧优先取 mm_kwargs["patches"].data.shape[0] 的真实 patch 数,dummy / profile 场景退化为 num_mm_embeds * config.vision_downsample_ratio**2 反推;语言模型侧直接返回 num_mm_embeds。因为 ViT 吃的是下采样前的 patch token,语言模型吃的是 aligner 之后合并进来的 mm embedding,两者差一个 vision_downsample_ratio**2,LoRA 需要按两个长度分别切 token 映射。非 image 模态直接抛 ValueError,与 get_placeholder_str 仅支持 image 的现状一致。
  4. 算子入口连续性兜底(vllm/lora/ops/triton_ops/lora_shrink_op.py 的 _lora_shrink):把 assert inputs.is_contiguous() 改为 inputs = inputs.contiguous()。触发场景是 ViT 中 F.unfold(...).squeeze(0).transpose(0, 1) 产出的非连续张量在 w1 被 LoRA 包裹后进入 shrink 算子;review 讨论后没有在模型调用点加 .contiguous(),而是把“调用方保证连续”的隐含契约下沉到算子内部。影响所有 LoRA 前向路径:上游不再需要保证连续,代价是非连续输入多一次显式拷贝(连续输入时 .contiguous() 是 no-op)。
  5. 文档配套与验证缺口:docs/models/supported_models.md 中 DeepseekV4ForConditionalGeneration 行的 LoRA 列由空改为 ✅︎,对应 reviewer 的 “do we need to update doc info?” 一问。本 PR 没有任何 tests/ 变更,验证依赖 Buildkite CI 与人工 review;10 个 commit 中混有 3 次 main 合并和多次 “move” / “pre-commit” 提交,功能改动集中在最后几个 commit。
文件 模块 状态 重要度
vllm/models/deepseek_v4/common/vl_model.py 多模态模型 modified 7.5
vllm/lora/ops/triton_ops/lora_shrink_op.py LoRA 算子 modified 3.75
docs/models/supported_models.md 模型文档 modified 1.93

关键符号

get_mm_mapping get_mm_lora_token_counts _lora_shrink

关键源码片段

vllm/models/deepseek_v4/common/vl_model.py data-contract

本 PR 的功能核心:DeepseekV4ForConditionalGeneration 通过 SupportsLoRA、packed_modules_mapping、supports_tower_connector_lora 以及 get_mm_mapping / get_mm_lora_token_counts 完成 LoRA 接入。注意 review 评论记录的文件路径是 vllm/models/deepseek_v4/nvidia/vl_model.py,与最终落地的 common/vl_model.py 指向同一个类定义(PR 含多次 move 提交与 main 合并),代码与符号一致。

class DeepseekV4ForConditionalGeneration(
    nn.Module,
    SupportsMultiModal,
    SupportsPP,
    SupportsEagle3,
    SupportsLoRA, # 新增:让外层 VL 包装类本身成为 LoRA 可挂载对象
):
    # 适配器里的名字是融合后的名字,module 上是拆分后的子投影,
    # 没有这张表,LoRA 权重无法回填到正确的参数;
    # 视觉编码器的 wqkv / w1 用自映射留出挂载点。
    packed_modules_mapping = {
        "gate_up_proj": ["w1", "w3"],
        "fused_wqa_wkv": ["wq_a", "wkv"],
        "fused_wkv_wgate": ["wkv", "wgate"],
        # for visual encoder
        "wqkv": ["wqkv"],
        "w1": ["w1"],
    }
​
    # 显式放开 vision tower 与 aligner 的 LoRA 挂载,
    # 否则只有 language_model 部分能收到适配器。
    supports_tower_connector_lora = True
​
    def get_mm_mapping(self) -> MultiModelKeys:
        """向 LoRA 运行时声明多模态子模块的前缀归属。"""
        return MultiModelKeys.from_string_field(
            language_model="language_model", # 文本主干
            connector="aligner", # 图像 embedding 与文本对齐的 connector
            tower_model="vision.", # ViT 视觉塔
        )
​
    def get_mm_lora_token_counts(
        self,
        *,
        modality: str,
        mm_kwargs: MultiModalKwargsItem | None,
        num_mm_embeds: int,
    ) -> tuple[int, int | None]:
        # tower 侧吃下采样前的 patch token,语言模型侧吃 aligner 之后的 mm embedding,
        # 两者数量相差 vision_downsample_ratio ** 2,必须分别返回给 LoRA 运行时。
        if modality != "image":
            raise ValueError(f"Unsupported modality: {modality!r}")
​
        patches = mm_kwargs.get("patches") if mm_kwargs else None
        if patches is not None and isinstance(patches.data, torch.Tensor):
            # 有真实输入时以实际 patch 数为准,避免与 processor 的展开结果不一致
            tower_tokens = patches.data.shape[0]
        else:
            # dummy run / profile run 没有 patches,用下采样倍率从 mm embed 数反推
            tower_tokens = num_mm_embeds * self.config.vision_downsample_ratio**2
​
        return tower_tokens, num_mm_embeds
vllm/lora/ops/triton_ops/lora_shrink_op.py core-logic

改动只有一行,却把 LoRA shrink 算子的输入契约从“调用方必须保证连续”改为“算子内部自动连续化”,是 review 讨论后确定的修复落点(替代在 ViT 模型侧加 .contiguous())。

# vllm/lora/ops/triton_ops/lora_shrink_op.py
# _lora_shrink(...) 内部的参数校验段
​
assert weight.dtype in [torch.float16, torch.bfloat16]
assert inputs.size(1) == lora_a_weights[0].size(-1)
​
# 变更点:原本这里是 assert inputs.is_contiguous()。
# ViT 中 F.unfold(...).squeeze(0).transpose(0, 1) 的产物是非连续张量,
# 一旦 w1 被 LoRA 包裹就会进入本算子,原断言会直接失败;
# review 中 jeejeelee 建议 “If so, we should add this in lora code”,
# 因此把“调用方保证连续”的隐含契约收敛到算子入口兜底,
# 对连续输入而言 contiguous() 是 no-op,不产生额外拷贝。
inputs = inputs.contiguous()
​
assert output_tensor.is_contiguous()
# metadata sanity check
# ... 之后按 prompt_mapping 切分 token 并调用 Triton shrink 内核

评论区精华

tower/connector LoRA 在混合 LoRA 批次下会取错 token 映射 正确性

chatgpt-codex-connector[bot] 给出 P1:v1 多模态路径只安装一份拼接后的 token 映射,而 `_process_image_input` 逐图调用 `_encode_image`,被 LoRA 包裹的线性层每次都会向 Punica 取前 `x.size(0)` 条映射,导致批次中后序图像拿到第一个请求的适配器或混合前缀,图像 embedding 被静默污染;建议改为一次 forward 处理全部映射 token,或在每张图前推进 / 安装映射。

结论:合并时未看到作者回复或代码处理,`supports_tower_connector_lora = True` 仍按原样合入,属于遗留的静默正确性风险。 · 未解决

VL 包装类缺少 lora_skip_prefixes = ["mtp."] 正确性

chatgpt-codex-connector[bot] 给出 P2:用 `target_modules="all-linear"` 训练的适配器可能包含本就被 weights mapper 丢弃的 `mtp.*` 投影(`e_proj`、`h_proj`),新具备 LoRA 能力的外层类没有同步文本主干的 skip 前缀,适配器校验会判为 unexpected 并整体拒绝。

结论:建议镜像文本主干的 MTP skip 设置;合并时未见对应改动,使用 all-linear 适配器的用户可能遇到加载被拒。 · 未解决

非连续张量的 contiguous 修复应该放在模型调用点还是 LoRA 算子内 设计

jeejeelee 对 `common/vision.py` 中 `self.w1(x)` 改为 `self.w1(x.contiguous())` 发问 “QQ: why change this line”;作者解释 F.unfold + transpose 产物非连续而 LoRA shrink op 有连续性断言;jeejeelee 回 “If so, we should add this in lora code”,作者答 “addressed”。

结论:最终模型侧改动被撤销,`.contiguous()` 下沉到 `lora_shrink_op.py`,所有 LoRA 调用方受益,属于正确的分层决策。 · 已解决

是否需要同步更新支持模型文档 documentation

jeejeelee 问 “do we need to update doc info?”,作者回复 “Yes, it has been added.”,随后 `docs/models/supported_models.md` 的 LoRA 列被改为 ✅︎。

结论:已完成并获 reviewer 认可,jeejeelee 最终 APPROVED。 · 已解决

风险与影响

  1. 混合 LoRA 批次下 tower / connector 的 token 映射错配(高):supports_tower_connector_lora = True 打开后,若同一批次里有带不同 LoRA ID 的图片请求,v1 多模态路径安装的是一份拼接映射,而 _process_image_input 逐图调用 _encode_image,每张图都会向 Punica 索取前 x.size(0) 条映射,后续图像可能拿到第一个请求的适配器,图像 embedding 静默算错且无报错。
  2. VL 包装类缺少 lora_skip_prefixes = ["mtp."](中高):target_modules="all-linear" 训练出的适配器若包含 mtp.* 投影(e_proj、h_proj 等),适配器校验会判为 unexpected 并整体拒绝,表现为直接加载失败;文本主干已有该 skip,外层类未同步。
  3. _lora_shrink 行为变更的拷贝开销(中):inputs = inputs.contiguous() 位于所有 LoRA 前向的 shrink 路径,连续输入无开销,但一旦上游传入非连续张量(如 ViT 的 unfold + transpose 产物)就会多一次显式拷贝,需要观察其对显存与带宽的影响。
  4. 缺少测试覆盖(中):无任何测试文件变更,get_mm_lora_token_counts 的计数分支(真实 patches / 倍率反推)与 tower LoRA 的映射语义只能靠端到端用例暴露问题,回归成本转嫁给社区。
  5. packed 映射的维护耦合(低):视觉侧 "wqkv": ["wqkv"]、"w1": ["w1"] 是自映射,若 ViT 权重命名或融合方式调整而未同步,LoRA 挂载会静默失效。

对用户:DeepSeek-V4-Flash-Vision-Exp 从“可推理”变成“可加载 LoRA 适配器微调”,包含 vision tower 与 aligner 的适配器(enable_tower_connector_lora),属于明确的能力解锁;但多份不同 LoRA 与图片请求混合的线上场景需要先规避 P1 风险。对系统:LoRA 运行时首次为 DeepSeek V4 视觉模型明确声明多模态前缀分组与 token 计数契约,get_mm_mapping + get_mm_lora_token_counts 的组合可被其他 VLM 复用;lora_shrink_op 把输入契约从“必须连续”放宽为“自动连续化”,改变的是全仓 LoRA 前向的公共算子语义。对团队与文档:支持矩阵(docs/models/supported_models.md)与代码能力保持一致,减少“文档说支持、实际报错”的沟通成本,但零测试意味着后续重构缺少护栏。

多 LoRA 批次映射错配 缺少测试覆盖 MTP skip 前缀缺失 LoRA 算子行为变更

关联 Issue

#55683 [Feature]: LoRA support for deepseek v4 flash vision

完整报告

参与讨论