Prhub

#48215 [Model][LoRA] Add tower/connector LoRA support for Ultravox

原始 PR 作者 arthurgao2003 合并时间 2026-08-13 13:58 文件变更 2 提交数 18 评论 46 代码增减 +345 / -129

执行摘要

为 Ultravox 音频塔和连接器添加 LoRA 支持

issue #31479 要求为更多多模态模型启用 tower/connector LoRA 支持,并明确指出根因是语言模型中多模态 token 数与 tower/connector 线性层输入长度不一致,需要实现 get_num_mm_encoder_tokens 和 get_num_mm_connector_tokens 来桥接。Ultravox 此前无法对音频塔和连接器应用 LoRA:tower/projector 路径未由 LoRA 可包裹的 vLLM 模块构建,适配器可能静默失效;音频按 30s 分块且 mel 特征只填充到 batch 最大长度,导致逐项行数无法从 placeholder 数量恢复。

值得精读。核心设计——用 cu_seqlens 把有效帧和填充尾部分段、兼顾注意力掩码与 LoRA 行数恒定——有很强的通用性;get_num_mm_connector_tokens 的逐 chunk ceil 计算也揭示了多模态 token 映射的常见陷阱。建议关注后续是否有补回的自动化测试,并留意 whisper.py 接口扩展对其他模型的影响。

讨论亮点

评审中主要交锋包括:

  • 复用 WhisperEncoder 而非自研镜像:linitra24 建议按 Voxtral 做法直接复用 WhisperEncoder。作者在 8a5d87d4 重写,通过在 whisper.py 中给 encoder 自注意力加 cu_seqlens 分段支持,既保持 LoRA 行数恒定,又不让注意力跨越有效/填充边界。这是本 PR 最有价值的设计决策。

  • LoRA 端到端正确性测试:linitra24 指出 logprob 非零只能证明非 no-op,应使用真实训练过的 adapter 或与 PEFT 参考实现对比。作者解释 EpochEcho 发布 adapter 的 lora_B 全零、HF 原生 forward 与 transformers v5 不兼容,最终采用「动态 LoRA 对比独立合并权重」的回归测试并强化 logprob 数值比对。

  • 移除测试脚本:jeejeelee 明确要求 'Could you please remove all the test scripts?',作者在 1507808e 删除测试文件,仅保留实现。

  • connector token 计数简化问题:linitra24 问 get_num_mm_connector_tokens 可否简化为 num_encoder_tokens // stack_factor。作者指出 StackAudioFrames 逐 chunk 填充,floor 会少计,必须按 chunk 计算 ceil,并用处理器测试固定该行为。

实现拆解

实现拆解如下:

  1. 复用原生 WhisperEncoder(ultravox.py + whisper.py):删掉自研的 HF WhisperEncoder 镜像 ModifiedWhisperEncoder,改为通过薄子类 UltravoxWhisperEncoder 直接复用 whisper.py 中的 WhisperEncoder/WhisperEncoderLayer,与 Voxtral 的做法一致,降低长期维护成本。

  2. 扩展 Whisper 编码器自注意力支持 varlen 元数据(whisper.py)WhisperEncoderLayerWhisperAttentionWhisperEncoderAttention 的 forward 增加可选参数 cu_seqlens/max_seqlen/sequence_lengths,仅 encoder 自注意力支持(decoder/cross-attention 不变)。ultravox.py 新增 _build_chunk_attn_metadata:把每个填充 chunk 的「有效帧」和「填充尾部」拆成两个独立序列段,注意力不跨越有效/填充边界,等价于 HF 的 key-padding mask,同时所有行仍流经(可能被 LoRA 包裹的)线性层,保证逐 chunk 行数恒定。

  3. connector 线性层 vLLM 原生化(ultravox.py)UltravoxFeedForwardProjectorUltravoxTransformerProjector 中的 nn.Linear 全部替换为 ReplicatedLinear,传入 quant_configprefix,使 connector 可被 from_layer 正确包裹为 LoRA。

  4. 精确 token 映射(ultravox.py):实现 _get_max_tokens_per_chunkget_num_mm_encoder_tokensget_num_mm_connector_tokens。connector 计数必须按 chunk 计算:StackAudioFrames 在堆叠前会先把每个 chunk 填充到 stack_factor 的倍数,若对全部 encoder token 做一次 floor 除法会少计多 chunk 音频(如 2 个完整 chunk 应得 376 而不是 375)。

  5. Whisper tower 权重加载(ultravox.py)_load_whisper_layer_weights 使用 AutoWeightsLoader + WeightsMapperorig_to_new_stacked 做 q/k/v 融合、orig_to_new_substr 做 fc1/fc2 嵌套),并复用 whisper.py_create_fake_bias_for_k_proj 合成 k 投影偏置;顶层 hf_to_vllm_mapper 显式丢弃 whisper decoder 权重,因此可以移除 ignore_unexpected_prefixes

  6. 配套与修复:tower/connector LoRA 模式下所有音频 chunk 填充到完整 30s 上下文;修复 PP=2 下音频子配置继承外层 pipeline parallel 的问题(commit c2cdf8bf);按评审要求移除全部测试脚本,最终 PR 只含实现。

文件 模块 状态 重要度
vllm/model_executor/models/ultravox.py 模型实现 modified 9.05
vllm/model_executor/models/whisper.py 编码器 modified 7.49

关键符号

_build_chunk_attn_metadata get_num_mm_encoder_tokens get_num_mm_connector_tokens _load_whisper_layer_weights UltravoxWhisperEncoder.forward UltravoxTransformerProjector.forward UltravoxFeedForwardProjector.forward WhisperAttention.forward WhisperEncoderLayer.forward WhisperEncoder.__init__

关键源码片段

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

主实现文件:重写音频塔为 WhisperEncoder 薄子类、connector 线性层 vLLM 原生化、实现 token 映射与权重加载。

# vllm/model_executor/models/ultravox.py
# 核心创新:把每个填充 chunk 拆为“有效帧 + 填充尾部”两段 cu_seqlens 序列,
# 从而使注意力不跨越有效 / 填充边界(等价于 HF key-padding mask),
# 同时所有行仍流经 LoRA 可包裹的线性层,保证逐 chunk 行数恒定。
def _build_chunk_attn_metadata(
    attn: MMEncoderAttention,
    feature_lens: torch.Tensor,
    seq_len: int,
    hidden_size: int,
    device: torch.device,
) -> dict[str, torch.Tensor | None]:
    """为一批填充后的音频块生成分段 varlen 注意力元数据。    每一填充行最多贡献两段序列:有效帧段和填充尾段。
    因此注意力不会跨越有效/填充边界,等价于 HF 实现的 key-padding mask;
    与此同时每一行仍然流经(可能被 LoRA 包裹的)线性层,
    从而让每个 chunk 的行数保持恒定,满足 get_num_mm_encoder_tokens 的依赖。
    padding 位置的 query 会产生(垃圾)输出,由下游 audio_token_len 裁剪。
    """
    batch_size = feature_lens.shape[0]
    starts = np.arange(batch_size, dtype=np.int64) * seq_len
    lens_np = feature_lens.cpu().numpy().astype(np.int64)
    # 每个 chunk 的边界: [start, start+len) 和 [start+len, start+seq_len)
    bounds = np.stack([starts + lens_np, starts + seq_len], axis=1).reshape(-1)
    cu_seqlens_np = np.concatenate(([0], bounds))
    # 完全有效的行会产生空填充段,去掉重复边界
    cu_seqlens_np = np.unique(cu_seqlens_np).astype(np.int32)
​
    attn_backend = attn.attn_backend
    # 不同 attention backend 对序列长度元数据的处理不同,统一走 MMEncoderAttention 的辅助方法
    sequence_lengths = MMEncoderAttention.maybe_compute_seq_lens(
        attn_backend, cu_seqlens_np, device
    )
    max_seqlen = torch.tensor(
        MMEncoderAttention.compute_max_seqlen(attn_backend, cu_seqlens_np),
        dtype=torch.int32,
    )
    cu_seqlens = MMEncoderAttention.maybe_recompute_cu_seqlens(
        attn_backend,
        cu_seqlens_np,
        hidden_size,
        get_tensor_model_parallel_world_size(),
        device,
    )
    return {
        "cu_seqlens": cu_seqlens,
        "max_seqlen": max_seqlen,
        "sequence_lengths": sequence_lengths,
    }
​
​
# connector token 数必须按 chunk 逐个计算,不能对全部 encoder token 做一次 floor 除法:
# StackAudioFrames 在堆叠前会先把每个 chunk 填充到 stack_factor 的倍数。
# 例如 2 个完整 chunk(每个 1500 帧):ceil(1500/8)=188,实际总数 376,
# 而 3000 // 8 = 375,少计 1 行,且差距随 chunk 数线性增长。
def get_num_mm_connector_tokens(self, num_encoder_tokens: int) -> int:
    num_chunks = math.ceil(num_encoder_tokens / self.config.audio_config.max_source_positions)
    return num_chunks * math.ceil(
        self.config.audio_config.max_source_positions / self.config.stack_factor
    )
vllm/model_executor/models/whisper.py data-contract

支撑文件:为 Whisper 编码器自注意力增加 cu_seqlens/max_seqlen/sequence_lengths 可选参数,使 Ultravox 能复用 WhisperEncoder 且不破坏其他模型。

# vllm/model_executor/models/whisper.py
# 新增可选 cu_seqlens 支持:仅 encoder 自注意力允许 varlen 元数据,
# decoder/cross-attention 保持原有行为不变,向后兼容。
def forward(
    self,
    hidden_states: torch.Tensor,
    cu_seqlens: torch.Tensor | None = None,
    max_seqlen: torch.Tensor | None = None, # 仅 Flash Attention 使用
    sequence_lengths: torch.Tensor | None = None, # 仅 FlashInfer CuDNN 后端使用
):
    qkv, _ = self.qkv_proj(hidden_states)
    q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1)
​
    if cu_seqlens is None:
        # 原路径:普通 encoder 自注意力
        attn_output = self.attn(q, k, v)
    else:
        # 新路径:分段 varlen 注意力。仅允许 ENCODER 类型,
        # 防止误用在 decoder/cross-attention 上。
        assert self.attn_type == AttentionType.ENCODER, (
            "Variable-length attention metadata is only supported for "
            "encoder self-attention."
        )
        attn_output = self.attn(
            q,
            k,
            v,
            cu_seqlens=cu_seqlens,
            max_seqlen=max_seqlen,
            sequence_lengths=sequence_lengths,
        )
​
    output, _ = self.out_proj(attn_output)
    return output

评论区精华

复用 WhisperEncoder 替代自研镜像 设计

linitra24 建议按 Voxtral 的做法直接复用 whisper.py 中的 WhisperEncoder,便于长期维护。

结论:作者在 8a5d87d4 重写,改为薄子类复用 WhisperEncoder,并为其自注意力增加 cu_seqlens 分段支持。 · 已解决

LoRA 端到端正确性测试覆盖 测试

linitra24 指出 logprob 非零不足以证明适配器权重加载正确,建议参考 test_qwen35_densemodel_lora.py 使用真实 adapter 对比稳定输出。

结论:作者解释发布 adapter 的 lora_B 全零,改用动态 LoRA 对比独立合并权重的回归测试,并强化为精确 token 路径 + logprob 数值比对;但最终测试脚本因评审要求被移除。 · 已解决

移除 PR 中的测试脚本 设计

jeejeelee 请求 'Could you please remove all the test scripts?',作者随后移除了所有测试文件。

结论:测试脚本全部移除,PR 仅剩实现变更,手动验证记录在 PR 描述中。 · 已解决

get_num_mm_connector_tokens 能否简化为 floor 除法 正确性

linitra24 问可否写成 num_encoder_tokens // stack_factor。作者解释 StackAudioFrames 逐 chunk 填充导致 floor 会少计(2 chunk 为 375 而非 376),必须按 chunk 计算 ceil。

结论:保留逐 chunk ceil 计算,并补充注释说明。 · 已解决

权重加载方式 refactor

linitra24 建议复用 WhisperForConditionalGeneration 使用的 WeightsMapper + AutoWeightsLoader + k_proj fake bias,而非自定义参数遍历。

结论:作者在 2966de390 改为 mapper-based 加载,decoder 权重容忍逻辑移入顶层 hf_to_vllm_mapper。 · 已解决

风险与影响

主要风险集中在:

  • whisper.py 核心路径变更WhisperAttention/WhisperEncoderLayer 的 forward 签名增加新参数,影响所有复用 WhisperEncoder 的模型(Whisper、Voxtral、KimiAudio 等)。虽然参数可选、默认走原路径,但仍存在回归风险,需要这些模型的相关 CI 验证。

  • 缺少自动化测试覆盖:最终 PR 按评审要求移除了全部测试脚本,tower/connector LoRA 的新路径(_build_chunk_attn_metadata、token 映射、权重加载)没有持续集成保障;PR 描述中记录了手动验证,但后续改动容易引入静默回归。

  • token 映射的强假设get_num_mm_encoder_tokens 依赖「tower/connector LoRA 模式下所有 chunk 都填充到完整上下文」的预处理约定,若未来预处理逻辑变化(如填充策略调整),计数会失配,导致 LoRA 映射错位。

  • CI 无关失败干扰:构建中出现的 test_structured_output 和 Anthropic stop_sequences 失败虽与 PR 无关,但暴露出主干 CI 的稳定性问题。

对用户:Ultravox 用户现在可以对音频塔和连接器应用 LoRA,这是 issue #31479 长期请求的能力;对系统:whisper.py 的注意力接口向后兼容扩展,所有 Whisper 系模型的现有行为保持不变;对团队:该 PR 提供了一个「在保持注意力正确性的同时让 LoRA 行数恒定」的可复用范式,后续为其他音频多模态模型(Voxtral 之外)添加 tower/connector LoRA 时可参考 _build_chunk_attn_metadata 的 cu_seqlens 拆分方案。

核心路径变更 缺少自动化测试 跨模型影响 多模态映射精确性

关联 Issue

#31479 [Feature]: Enable LoRA support for tower and connector in more MM models

完整报告

参与讨论