执行摘要
- 一句话:为 DeepSeek-V4 视觉模型增加 LoRA 支持并修正连续性断言
- 推荐动作:值得精读,尤其是做多模态 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 列直接使用即可,但建议先单适配器验证。
功能与动机
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 正确加载与执行。
实现拆解
- 能力声明(vllm/models/deepseek_v4/common/vl_model.py,类 DeepseekV4ForConditionalGeneration):基类列表加入
SupportsLoRA,使外层 VL 包装类进入 LoRA 管理器的识别与适配器解析范围;新增 supports_tower_connector_lora = True,显式放开 vision tower 与 aligner 上的适配器挂载。这是从“只有 language_model 能挂 LoRA”到“视觉分支也可微调”的关键开关,影响所有走 SupportsLoRA 协议的能力探测与校验路径。
- 融合权重映射(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 的融合投影预留挂载点。
- 多模态 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 的现状一致。
- 算子入口连续性兜底(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)。
- 文档配套与验证缺口:
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(模块 多模态模型;类别 source;类型 data-contract;符号 DeepseekV4ForConditionalGeneration, packed_modules_mapping, supports_tower_connector_lora, get_mm_mapping): 本 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 合并),代码与符号一致。
vllm/lora/ops/triton_ops/lora_shrink_op.py(模块 LoRA 算子;类别 source;类型 core-logic;符号 _lora_shrink): 改动只有一行,却把 LoRA shrink 算子的输入契约从“调用方必须保证连续”改为“算子内部自动连续化”,是 review 讨论后确定的修复落点(替代在 ViT 模型侧加 .contiguous())。
docs/models/supported_models.md(模块 模型文档;类别 docs;类型 documentation): 把 DeepseekV4ForConditionalGeneration 的 LoRA 列由空改为 ✅︎,使支持矩阵与代码能力一致,对应 reviewer 的文档询问。
关键符号:get_mm_mapping, get_mm_lora_token_counts, _lora_shrink
关键源码片段
vllm/models/deepseek_v4/common/vl_model.py
本 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
改动只有一行,却把 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 内核
评论区精华
风险与影响
- 风险:
- 混合 LoRA 批次下 tower / connector 的 token 映射错配(高):
supports_tower_connector_lora = True 打开后,若同一批次里有带不同 LoRA ID 的图片请求,v1 多模态路径安装的是一份拼接映射,而 _process_image_input 逐图调用 _encode_image,每张图都会向 Punica 索取前 x.size(0) 条映射,后续图像可能拿到第一个请求的适配器,图像 embedding 静默算错且无报错。
- VL 包装类缺少
lora_skip_prefixes = ["mtp."](中高):target_modules="all-linear" 训练出的适配器若包含 mtp.* 投影(e_proj、h_proj 等),适配器校验会判为 unexpected 并整体拒绝,表现为直接加载失败;文本主干已有该 skip,外层类未同步。
_lora_shrink 行为变更的拷贝开销(中):inputs = inputs.contiguous() 位于所有 LoRA 前向的 shrink 路径,连续输入无开销,但一旦上游传入非连续张量(如 ViT 的 unfold + transpose 产物)就会多一次显式拷贝,需要观察其对显存与带宽的影响。
- 缺少测试覆盖(中):无任何测试文件变更,
get_mm_lora_token_counts 的计数分支(真实 patches / 倍率反推)与 tower LoRA 的映射语义只能靠端到端用例暴露问题,回归成本转嫁给社区。
- 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 算子行为变更
关联脉络
- PR #56349 [ROCm] Auto-enable breakable CUDA graphs for DeepseekV41ForCausalLM: 同属 DeepSeek-V4 模型族的可用性启用改动(vllm/config/vllm.py 层为该模型补齐运行能力),与本 PR 在模型接入链路上前后呼应。
- PR #56157 [PCP][DCP] Enable PCP+DCP on sparse-MLA models: 同样是 DeepSeek-V4 系列在 v1 推理路径上的能力扩展(稀疏 MLA / 并行),说明该模型族仍在密集补齐特性,本 PR 补的是微调侧的 LoRA 能力。
- PR #56594 [Bugfix][CI] Update CuTeDSL indexer Q sentinel test for migrated wrappers: 改动落在 vllm/models/deepseek_v4/nvidia 目录(fused_indexer_q_cutedsl.py),与本 PR 的 deepseek_v4/nvidia 包装层属同一近期维护面。
参与讨论