Prhub

#56231 [LoRA][Nemotron] Add LoRA support for Nemotron VL models (for the language model only)

原始 PR 作者 danisereb 合并时间 2026-09-16 12:20 文件变更 2 提交数 5 评论 16 代码增减 +34 / -2

执行摘要

为 Nemotron VL 启用语言模型侧 LoRA,绕开编码器内 token 的索引映射缺失

PR body 明确写出当前故障:"At this time Nemotron VL models fail if used with LoRA adapters: ValueError: NemotronH_Nano_VL_V2 does not support LoRA yet." 目标是打开 Nemotron VL(含 Omni 变体)的 LoRA 服务能力,并明确边界——媒体 token 在 merge 之后进入语言模型,因此也享受适配器;但 LoRA 不覆盖 encoder 与 connector(RADIO、mlp1、Parakeet),因为这些模块需要模型声明 supports_tower_connector_lora 才能启用,本模型并未声明。作者用 gsm8k eval 对比了 main 基线(flexible-extract 0.8203 / strict-match 0.8415)、本 PR 基线(0.8241 / 0.8423)与带 LoRA 适配器(0.8256 / 0.8218)三组结果来验证没有精度回退。

值得精读,重点看两处:一是为什么 VL 包装类必须把 is_non_gated_moe / packed_modules_mapping / embedding_modules / lora_skip_prefixes 从 NemotronHForCausalLM 抄一遍(LoRA 目标模块解析对元数据完整性的依赖);二是 _create_final_video_embeddings 里 encoder 内部生成 token 与 per-token 适配器索引映射的不匹配,以及用 base_layer 兜底的取舍——这是多模态 + LoRA 场景中值得沉淀的通用模式。阅读时请把 linitra24 的提问和 danisereb 的回复对照看:功能虽然打开了,但 embedding LoRA 场景的正确性缺口是被“实际适配器不带 embedding 权重”这一经验事实掩盖的,如果要把它纳入线上能力,建议补一个检测到 embedding LoRA 时的告警或明确文档说明,并加一条多模态 + LoRA 的自动化回归用例。

讨论亮点

评审整体是认可性的,唯一有技术含量的交锋来自 linitra24 在 nano_nemotron_vl.py 第 1357 行的提问:

"QQ: If embed_tokens is adapted during training, wouldn't the internally generated text/special tokens that are not replaced by video embeddings also receive the LoRA delta?"

danisereb 在回复中承认这是真实存在的偏差,并把它定义为已知且被接受的限制:

"Video marker tokens - the per-frame separator text (e.g. Frame 1 sampled at 0.50 seconds:) and the <img> / </img> tags wrapping each frame do not get the embedding LoRA delta. ... Without this PR, that path raises an error, so video + LoRA doesn't work at all today."

他给出的两点辩护是:(1) peft 的 target_modules="all-linear" 不包含 nn.Embedding,必须显式点名 embed_tokens 才会训练 embedding 权重;(2) 他实际测试的公开适配器(oridos/nemotron-tinystories-lora)没有 embedding 权重,所以该改动在真实场景下是 no-op,结论是 "For basic enablement ... I think this change is OK"。这条结论没有被追加代码侧校验(例如在检测到 embedding LoRA 时打告警)。

其余讨论偏流程:qthequartermasterman 直接 APPROVED 并说明 "This is timely. I was just looking this afternoon what would be needed for LoRA support for Nemotron 3 Omni.";linitra24 指出模型未登记在 docs/models/supported_models.md,jeejeelee 在 issue 评论中推动作者回应,作者补上文档后 linitra24 与 jeejeelee 相继 APPROVED;CI 侧因分支落后 upstream 282 / 3 个提交被拒绝触发,作者 rebase 后由 benchislett 重新 /ci run 才拿到 Buildkite #89118。

实现拆解

  1. 声明 LoRA 能力接口:在 vllm/model_executor/models/nano_nemotron_vl.py 中新增 from vllm.lora.layers.base import BaseLayerWithLoRA,并从 vllm.model_executor.models.interfaces 导入 SupportsLoRA;NemotronH_Nano_VL_V2 的基类列表追加 SupportsLoRA。这是 vLLM 判定模型能否加载适配器的唯一开关,缺少它就会抛出 body 中引用的 does not support LoRA yet。
  2. 复用语言模型的 LoRA 元数据:类体新增四个类属性赋值 is_non_gated_moe、packed_modules_mapping、embedding_modules、lora_skip_prefixes,全部直接取自 NemotronHForCausalLM,并用注释标注 "LoRA covers the language model only"。原因是 LoRA 解析目标模块名时需要与纯文本 NemotronH 完全一致的 packed 模块、embedding 模块与跳过前缀视图,否则 MoE 融合权重或 embedding 层会匹配失败。注意这些属性是在类定义时对对象做的一次性引用,上游若把某个属性重新赋值为新对象,这里不会自动跟随。
  3. 修复编码器内部 token 的 embedding 路径:_create_final_video_embeddings 原先把 self.get_language_model().embed_input_ids(repl_token_ids) 作为替换 token 的文本 embedding 来源;现在改为取出 self.get_language_model().model.embed_tokens,若它是 BaseLayerWithLoRA 则回退到 embed_tokens.base_layer 再调用。触发条件是这些替换 token(帧分隔符文本、<img> / </img> 标记)由编码器在模型内部生成,不在调度器提交的 token 批次内,而 LoRA 的 per-token 适配器索引映射正是按该批次构建、按 max_num_batched_tokens 定长分配的——这些 token 既没有映射条目,数量上也可能超过映射长度。使用基座权重可让该分支在无 embedding LoRA 时成为严格 no-op。
  4. 文本路径保持原样:正常的请求 token 仍走 embed_input_ids 与既有的适配器索引映射,LoRA delta 正常生效;改动只影响模型内部生成、未参与调度的 token,因此对非 LoRA 部署完全无感(isinstance 分支仅在 LoRA 启用时命中)。
  5. 文档与验证配套:docs/models/supported_models.md 在多模态生成模型表中新增 NemotronH_Nano_Omni_Reasoning_V3、NemotronH_Nano_VL_V2 一行,并把 LoRA 列标为 ✅︎,同时补一条 note 说明两个架构名映射到同一实现、音频仅 Omni 变体支持。测试方面没有新增任何自动化用例,只有作者手工执行的 gsm8k eval(纯文本任务,未覆盖图片/视频/音频 + LoRA 路径)。
文件 模块 状态 重要度
vllm/model_executor/models/nano_nemotron_vl.py 多模态模型 modified 7.21
docs/models/supported_models.md 模型文档 modified 2.68

关键符号

NemotronH_Nano_VL_V2 _create_final_video_embeddings

关键源码片段

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

唯一的功能源码改动:新增 SupportsLoRA 能力声明、复用 NemotronHForCausalLM 的 LoRA 元数据,并在 _create_final_video_embeddings 中让编码器内生成的替换 token 绕开 LoRA 包装层使用基座 embed_tokens,是本 PR 能否跑通 Nemotron VL + LoRA 的关键。

# vllm/model_executor/models/nano_nemotron_vl.py
# 1) 新增导入:LoRA 能力接口 + LoRA 包装层类型(用于 isinstance 判断)
from vllm.lora.layers.base import BaseLayerWithLoRA
from vllm.model_executor.models.interfaces import (
    HasInnerState,
    IsHybrid,
    MultiModalEmbeddings,
    SupportsLoRA, # 关键开关:没有它,加载适配器会直接抛 "does not support LoRA yet"
    SupportsMultiModal,
    SupportsMultiModalPruning,
)
​
​
class NemotronH_Nano_VL_V2(
    nn.Module,
    HasInnerState,
    IsHybrid,
    SupportsMultiModal,
    SupportsMultiModalPruning,
    SupportsLoRA,
):
    requires_sequential_video_encoding = True
​
    # LoRA covers the language model only
    # 2) 直接复用语言模型的 LoRA 元数据,让适配器目标模块解析结果
    # 与纯文本 NemotronH 完全一致(MoE 融合模块 / embedding / 跳过前缀)。
    # 注意:这里持有的是 NemotronHForCausalLM 上的对象引用,
    # 若上游把同名属性重新赋值为新对象,本类别会与语言模型产生漂移。
    is_non_gated_moe = NemotronHForCausalLM.is_non_gated_moe
    packed_modules_mapping = NemotronHForCausalLM.packed_modules_mapping
    embedding_modules = NemotronHForCausalLM.embedding_modules
    lora_skip_prefixes = NemotronHForCausalLM.lora_skip_prefixes
​
    # 视觉塔 RADIO、connector mlp1 与音频塔 Parakeet 不在覆盖范围内:
    # 模型未声明 supports_tower_connector_lora,因此塔侧 LoRA 被 vLLM 主动拒绝。
​
    def _create_final_video_embeddings(self, ...):
        # ... 省略视频替换 token 的构造过程 ...
        # repl_token_ids:编码器内部生成的帧分隔符与 <img> / </img> 标记 token
​
        # 3) 这些 token 不在调度器提交的 token 批次里,而 per-token LoRA
        # 适配器索引映射正是按该批次构建、按 max_num_batched_tokens 定长分配的:
        # 它们既没有映射条目,数量上也可能超出映射长度。
        # 因此这里绕过 LoRA 包装层,直接用基座权重做 embedding ——
        # 对没有映射条目的 token,LoRA delta 本身就没有定义。
        embed_tokens = self.get_language_model().model.embed_tokens
        if isinstance(embed_tokens, BaseLayerWithLoRA):
            embed_tokens = embed_tokens.base_layer
        text_embeddings = embed_tokens(repl_token_ids)
​
        # 4) 合并后,正常请求 token 仍通过 embed_input_ids 走带 LoRA 的路径,
        # 只有模型内部新造的这些 token 使用基座权重,非 LoRA 部署下等价于原实现。
        final_video_embeddings = _merge_multimodal_embeddings(
            inputs_embeds=text_embeddings,
            multimodal_embeddings=video_embeddings,
            is_multimodal=is_video_embed,
        )
​
        return final_video_embeddings

评论区精华

编码器内部生成的视频替换 token 是否会拿到 embedding LoRA delta 正确性

linitra24 在 nano_nemotron_vl.py 第 1357 行提问:如果训练时对 embed_tokens 做了适配,那些没有被视频 embedding 替换、而是模型内部生成的文本 / 特殊 token 是否也会拿到 LoRA delta?danisereb 承认帧分隔符文本与 <img> / </img> 标记确实不会获得 delta,因为它们由 _create_final_video_embeddings 在编码器内部产生,从不进入调度器构建适配器索引映射所用的 token 批次,数量还可能超过 max_num_batched_tokens;他同时强调没有本 PR 时该路径直接报错,video + LoRA 目前完全不可用。

结论:以“已知且可接受的限制”结案:peft 的 target_modules="all-linear" 不含 nn.Embedding,作者测试的公开适配器(oridos/nemotron-tinystories-lora)也没有 embedding 权重,因此该分支在真实场景是 no-op;未追加运行时校验或告警。 · 已解决

是否把 Nemotron Nano 模型补登到 supported_models.md documentation

linitra24 在评审中指出 "I also noticed that this model is not listed in docs/models/supported_models.md. Should we add it there as well?",jeejeelee 在 issue 评论中要求作者回应("please respond to @linitra24's comment")。

结论:作者新增表格行并在 LoRA 列打勾、补 note 说明架构名与音频支持范围,linitra24 随后 APPROVED("LGTM, thanks!")。 · 已解决

功能边界:只覆盖语言模型,不含塔与 connector 设计

PR body 明确 LoRA 不覆盖 RADIO、mlp1、Parakeet 等 encoder / connector,因为 vLLM 要求模型声明 supports_tower_connector_lora,而本模型没有声明;文中的 supported_models.md 引用也说明塔侧 LoRA 属于 experimental opt-in。

结论:评审接受该分期策略:qthequartermasterman 直接 APPROVED 并表示这正是他在找的 Nemotron 3 Omni LoRA 支持,能力边界以文档形式对外说明。 · 已解决

分支落后 upstream 导致 CI 无法触发 other

CI bot 先后提示分支落后 282 与 3 个提交,拒绝启动 Buildkite("No new CI build was started"),作者 rebase 后再次 /ci run,最终由 benchislett 触发 Buildkite CI #89118。

结论:流程性问题,通过 rebase 解决,不影响代码评审结论。 · 已解决

风险与影响

  1. 已知功能缺口(正确性,已被接受):视频帧内的分隔符文本与 <img> / </img> 标记不会获得 embedding LoRA delta。如果适配器显式训练了 embed_tokens(例如手工在 target_modules 里加 Embedding),推理期与训练期行为不一致;当前既没有运行时校验,也没有文档提示。
  2. 缺少测试覆盖:改动没有任何单元测试或 e2e 用例,唯一证据是 gsm8k(纯文本)。多模态 + LoRA 的视频路径恰恰是被修改的分支,未来重构 _create_final_video_embeddings 时极易被无意改回 embed_input_ids 而无人察觉。
  3. 隐式接口契约:isinstance(embed_tokens, BaseLayerWithLoRA) 依赖 LoRA 注入后 language_model.model.embed_tokens 一定是该包装类型,也依赖 base_layer 属性名。若 vLLM 后续把 LoRA 注入换成别的包装类或改属性命名,该判断会静默失效,直接调用包装层并再次撞上索引映射缺失/越界问题。
  4. 元数据硬编码:四个类属性是从 NemotronHForCausalLM 复制引用。若上游后续把 packed_modules_mapping 等重新赋值为新字典(而非原地修改),VL 包装类仍持有旧对象,会与语言模型产生元数据漂移。
  5. 影响面有限,无性能/安全风险:非 LoRA 场景下 isinstance 分支不命中,路径与改动前等价;改动集中在单个模型文件,不触及调度器、KV cache 或通用 LoRA 运行时。

用户侧:Nemotron Nano VL / Omni 系列(含 nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-BF16)现在可以用 --enable-lora 服务,文本与媒体 token 都能吃到适配器增量,此前是直接抛异常不可用;但若用户想让塔或 connector 也带 LoRA,仍会因为未声明 supports_tower_connector_lora 而被拒,属于需要提前说明的能力边界。系统侧:只扩大 SupportsLoRA 的模型覆盖面,不改变 LoRA 运行时的索引映射机制,其他模型与既有部署不受影响。团队侧:这类“内部生成 token 绕过 LoRA 包装层”的写法为后续同类多模态模型(encoder 内构造 token 的路径)提供了可直接复用的模式,同时也暴露了“模型侧硬编码敏感点 + 零测试”的维护债,建议在 Nemotron VL 的 LoRA e2e 配置里补一条最小用例。

缺少自动化测试 已知功能缺口(embedding LoRA 场景) 隐式依赖 LoRA 包装层结构 元数据硬编码可能与上游漂移

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论