Prhub

#56721 [Bugfix][Gemma 4] Don't read fft_length when profiling unified audio

原始 PR 作者 averma12 合并时间 2026-09-15 22:57 文件变更 2 提交数 3 评论 6 代码增减 +67 / -2

执行摘要

Gemma 4 unified 变体按音频预算推算 dummy 长度,修启动崩溃

PR body 明确指出,Gemma4AudioFeatureExtractor.__init__ 会设置 self.fft_length,而 unified 变体使用的 Gemma4UnifiedAudioFeatureExtractor 未定义该属性,于是 memory profiling 在引擎启动完成前就抛出 AttributeError: 'Gemma4UnifiedAudioFeatureExtractor' object has no attribute 'fft_length'。这个 bug 之所以潜伏,是因为 compute_mm_encoder_budget 对 get_mm_max_tokens_per_item 取 max() 并只 profile 胜出的那个模态:默认 per-item token 数为 image 280、audio 750(audio_seq_length)、video 2496(32 帧 x 78),video 胜出,音频分支根本不执行。但 _compute_audio_num_tokens 以 return min(t, audio_seq_length) 收尾、mel 分帧约 25 token/s,所以要把音频上限提到 30 s 以上就必须调大 audio_seq_length,此时 audio 赢得 max(),profiler 切到音频分支,引擎从此无法启动——这正是该修复要覆盖的场景。

值得精读,但重点不在 diff 本身(新增一个 24 行的 helper),而在两处:一是 PR 描述对 latent bug 触发条件的完整推导——compute_mm_encoder_budget 的 max() 选择机制、per-item token 对比表(image 280 / audio 750 / video 2496)、以及 min(t, audio_seq_length) 与 25 token/s 分帧如何把 750 token 绑定到 30.0 s,这套分析可以复用到其它多模态模型的 dummy 输入与 profiling 问题上;二是 getattr 探测改为 isinstance 分流的评审交锋,作者在 commit message 中对「症状 vs 类型区分」的总结值得借鉴。阅读时建议同时关注 PR 描述末尾关于 fft_length 语义的遗留问题,它是后续可能的独立修复入口。

讨论亮点

评审只有一条代码级评论,但指向了实现的关键表达方式:DarkLight1337 在 vllm/model_executor/models/gemma4_mm.py 上指出 "Perhaps it's better to use isinstance checks explicitly"。作者随后在 commit 8ba5f21 中把初版的 getattr(processor.feature_extractor, "fft_length", None) 探测改写为显式 isinstance 判断,并在 commit message 里总结得很清楚:"The getattr probe described the symptom -- 'this object happens to have fft_length' -- rather than the distinction that actually matters, which is which feature extractor the variant is using."。

第二个讨论点是 PR 描述末尾的遗留问题:把 fft_length 当样本数在 tower 路径上也不合理,会让 dummy 音频远短于模型可接受的音频、profiling 预留偏小。DarkLight1337 在 issue 评论里专门 @Isotr0py:"could you take a look as well? Especially that last comment in the PR description",但这条线索在本次讨论中没有得出「是否值得跟进」的结论。

第三点是验证状态:作者在 Test Result 主动更正了早前版本"测试通过"的表述,说明环境缺少 transformers.models.gemma4、两个新测试未本地执行,CI 也因 pre-run-check 门控未跑;随后 Isotr0py 评论 /ci run 触发 Buildkite CI #89090(commit a4fda6c2),PR 最终被 DarkLight1337 与 Isotr0py 双双 approve 并合并。

实现拆解

变更入口:vllm/model_executor/models/gemma4_mm.py 中 Gemma4DummyInputsBuilder.get_dummy_mm_data 的音频分支,原先用 processor.feature_extractor.fft_length 作为 _get_dummy_audios 的 length。

  1. 抽出可单测的长度计算函数:新增模块级 _dummy_audio_num_samples(processor: Any) -> int,把「一条 dummy 音频有多少采样点」从 builder 方法里解耦成纯函数,使其可以脱离 transformers.models.gemma4 的真实处理器被直接断言。
  2. 按提取器类型分流:isinstance(feature_extractor, Gemma4AudioFeatureExtractor) 为真时返回 feature_extractor.fft_length,tower(带 mel 前端)路径行为不变;否则读取 feature_extractor.sampling_rate,用 processor.audio_seq_length * processor.audio_ms_per_token / 1000.0 换算出模型真正接受的最大音频时长(秒),再乘采样率取整返回。选这两个字段是因为本文件 _call_hf_processor 里判断「音频过长」告警用的就是同一对配置,保证 dummy 与校验口径一致。
  3. 替换调用点与导入:get_dummy_mm_data 中 if num_audios > 0: 分支改为 audio_len = _dummy_audio_num_samples(processor);文件顶部从 transformers.models.gemma4 追加导入 Gemma4AudioFeatureExtractor,与既有的 Gemma4Processor、Gemma4Config 同源。
  4. 测试配套:tests/models/multimodal/processing/test_gemma4_unified.py 新增两个测试。test_dummy_audio_length_falls_back_for_the_unified_extractor 用桩类 _UnifiedFeatureExtractor(只有 sampling_rate = 16000)和 _UnifiedProcessor(audio_seq_length = 3000、audio_ms_per_token = 40)断言结果为 120 * 16000 = 1,920,000;test_dummy_audio_length_still_uses_fft_length_for_the_tower_extractor 用继承 Gemma4AudioFeatureExtractor 并绕过真实 __init__ 的 _TowerFeatureExtractor 断言仍返回 512,锁定旧行为。
  5. 有意未覆盖的部分:tower 路径继续把 fft_length(FFT 窗口长度,2 ** ceil(log2(frame_length)),通常只有几百采样点)当作样本数,作者在 PR 描述的 Note for reviewers 中标记为可疑但刻意不在本 PR 修改,以保证修复范围只限崩溃本身。
文件 模块 状态 重要度
vllm/model_executor/models/gemma4_mm.py 多模态模型 modified 7.25
tests/models/multimodal/processing/test_gemma4_unified.py 多模态测试 modified 6.85

关键符号

_dummy_audio_num_samples Gemma4DummyInputsBuilder.get_dummy_mm_data test_dummy_audio_length_falls_back_for_the_unified_extractor test_dummy_audio_length_still_uses_fft_length_for_the_tower_extractor

关键源码片段

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

核心修复文件:新增 `_dummy_audio_num_samples` 并按提取器类型分流,替换 `get_dummy_mm_data` 中无条件读取的 `processor.feature_extractor.fft_length`,直接决定 unified 变体能否完成 memory profiling 启动。

# vllm/model_executor/models/gemma4_mm.py
​
def _dummy_audio_num_samples(processor: Any) -> int:
    """Length in samples of a worst-case dummy audio item.
​
    `fft_length` 由 `Gemma4AudioFeatureExtractor.__init__` 设置,
    而 encoder-free 的 Gemma 4 变体使用的 `Gemma4UnifiedAudioFeatureExtractor`
    并未定义该属性;无条件读取会在该变体的显存 profiling 阶段抛 `AttributeError`,
    只要 audio 成为 per-item token 数最大的模态,就会命中这条路径。
​
    对其它提取器,回退到 processor 自己声明的音频预算 —— 也就是本文件里
    用于告警「音频过长」的同一对配置值。
    """
    feature_extractor = processor.feature_extractor
    # tower(带 mel 前端)路径:保持原行为,直接拿 FFT 窗口长度当样本数
    if isinstance(feature_extractor, Gemma4AudioFeatureExtractor):
        return feature_extractor.fft_length
​
    # unified(encoder-free)路径:按最大可接受时长反推最坏情况采样点数,
    # `audio_seq_length` 与 `audio_ms_per_token` 即 `_call_hf_processor` 中
    # 判断超长音频所用的同一对字段
    sampling_rate = feature_extractor.sampling_rate
    max_duration_s = processor.audio_seq_length * processor.audio_ms_per_token / 1000.0
    return int(max_duration_s * sampling_rate)
​
​
# … 同一文件内,`Gemma4DummyInputsBuilder.get_dummy_mm_data` 的调用点:
class Gemma4DummyInputsBuilder(BaseDummyInputsBuilder[Gemma4ProcessingInfo]):
    def get_dummy_mm_data(self, seq_len, mm_counts, mm_options=None):
        # …(省略 image / video 分支与 override 校验)
        if num_audios > 0:
            # 不再直接访问 `processor.feature_extractor.fft_length`,
            # 统一走 helper,避免 unified 变体在启动期 profiling 崩溃
            audio_len = _dummy_audio_num_samples(processor)
            data["audio"] = self._get_dummy_audios(
                length=audio_len,
                num_audios=num_audios,
                overrides=audio_overrides,
            )
        return data
tests/models/multimodal/processing/test_gemma4_unified.py test-coverage

回归测试:用桩 processor 分别模拟 unified 与 tower 两种提取器形状,同时锁定新的回退公式和 tower 路径的旧行为,是本 PR 唯一的自动化验证手段。

# tests/models/multimodal/processing/test_gemma4_unified.py
​
def test_dummy_audio_length_falls_back_for_the_unified_extractor():
    """unified 音频提取器不定义 `fft_length`。
​
    旧实现无条件读取该属性,导致所有「audio 是 per-item token 数最大模态」的
    unified Gemma 4 部署在显存 profiling 阶段抛 `AttributeError`,
    而只要把 `audio_seq_length` 抬到 video 预算之上就会进入该情形。
    """
​
    class _UnifiedFeatureExtractor:
        # unified 变体只有采样率,没有 `fft_length`
        sampling_rate = 16000
​
    class _UnifiedProcessor:
        feature_extractor = _UnifiedFeatureExtractor()
        audio_seq_length = 3000
        audio_ms_per_token = 40
​
    # 3000 token * 40 ms = 120 s,16 kHz 下即 1,920,000 个采样点
    assert _dummy_audio_num_samples(_UnifiedProcessor()) == 120 * 16000
​
​
def test_dummy_audio_length_still_uses_fft_length_for_the_tower_extractor():
    """mel/tower 提取器保持原有行为。"""
​
    class _TowerFeatureExtractor(Gemma4AudioFeatureExtractor):
        def __init__(self): # noqa: D107 - 绕过真实特征提取器的初始化
            self.fft_length = 512
            self.sampling_rate = 16000
​
    class _TowerProcessor:
        feature_extractor = _TowerFeatureExtractor()
        audio_seq_length = 750
        audio_ms_per_token = 40
​
    # 走 `isinstance` 分支,返回值与修复前逐字一致
    assert _dummy_audio_num_samples(_TowerProcessor()) == 512

评论区精华

用 isinstance 显式判断替代 getattr 属性探测 设计

DarkLight1337 在 `vllm/model_executor/models/gemma4_mm.py` 上评论 "Perhaps it's better to use `isinstance` checks explicitly",指向初版的 `getattr(processor.feature_extractor, "fft_length", None)` 写法。

结论:作者在 commit 8ba5f21 改为 `isinstance(feature_extractor, Gemma4AudioFeatureExtractor)`,并从 `transformers.models.gemma4` 显式导入该类,同时把理由写进 commit message:getattr 探测描述的是症状(这对象恰好有 fft_length),而真正要区分的是变体在用哪种特征提取器。 · 已解决

tower 路径把 fft_length 当 dummy 采样数是否合理 设计

PR 描述 Note for reviewers 指出 `fft_length` 本质是 FFT 窗口长度(`2 ** ceil(log2(frame_length))`,通常几百采样点),被当作样本数会让 dummy 音频远短于模型可接受的音频,profiling 预留偏小;DarkLight1337 在 issue 评论中专门 @Isotr0py:"could you take a look as well? Especially that last comment in the PR description"。

结论:作者明确表示有意不在本 PR 修改,以把范围限制在崩溃修复上,并愿意另行跟进;评论串中未给出是否值得跟进或如何修正的结论。 · 待处理

新测试未在本地执行、CI 需 maintainer 手动触发 测试

作者在 Test Result 中声明环境缺少 `transformers.models.gemma4` 导致测试模块无法收集,并主动更正早前版本 " 测试通过 " 的错误表述;同时说明 `pre-run-check` 会因缺少 label/ 合并 PR 数而拦截 CI。

结论:Isotr0py 评论 `/ci run` 触发 Buildkite CI #89090(commit a4fda6c2),DarkLight1337 与 Isotr0py 均 approve 并合并;材料中未展示 CI 最终结果。 · 已解决

风险与影响

  1. 回退分支的样本量可能显著放大 profiling 开销:unified 路径按最坏情况推算,示例中 3000 token x 40 ms = 120 s,在 16 kHz 下得到 1,920,000 采样点(float32 约 7.7 MB/条),远大于原先 fft_length 的几百采样点;当 limit_mm_per_prompt 中 audio 数量较大时,memory profiling 的内存与耗时都会上升。这是「修正确性换启动成本」的取舍,但 PR 未给出量级评估。
  2. 依赖处理器字段的隐式契约:该分支假设 processor.audio_seq_length、processor.audio_ms_per_token、feature_extractor.sampling_rate 三者在所有非 tower 提取器上都存在且与真实分帧逻辑一致;若未来出现第三种提取器或采样率与实际重采样不符,dummy 长度会与实际处理路径偏离(getattr 改为 isinstance 后,非 Gemma4AudioFeatureExtractor 但缺字段的情况会直接 AttributeError,只是报错位置从 fft_length 换到了别的字段)。
  3. 新增测试未经本地执行:作者明确说明因环境缺少 transformers.models.gemma4 而未能运行,且 CI 靠 maintainer 手动触发;若测试模块的模块级导入或断言在目标环境不成立,这次回归保护的有效性存疑(最终 CI 已触发,但材料未展示结果)。
  4. 遗留的 profiling 低估:tower 路径仍以 fft_length 作为 dummy 样本数,使 encoder/activation 的峰值显存被低估,属于本 PR 之外但已被点名的隐患。
  5. 兼容性:Gemma4AudioFeatureExtractor 从 transformers.models.gemma4 顶层导入,需要 transformers 版本提供该类;该文件已依赖同模块的 Gemma4Config/Gemma4Processor,因此实际风险有限,但属于新增的模块级导入依赖。

用户侧:默认配置(video 2496 token 胜出 profile)下行为与之前完全一致,没有回归;受影响的是把 audio_seq_length 调到超过 video 预算、用 unified 变体服务 30 s 以上音频的部署,这些部署从「启动即 AttributeError 崩溃」恢复为可正常启动。tower 变体走 isinstance 分支,返回值与旧实现逐字相同。
系统侧:unified 变体的 memory profiling 阶段会构造更长的 dummy 音频(按最坏音频预算),启动阶段的内存占用与耗时上升,但换来的是 profiler 不再依赖一个在 encoder-free 变体上不存在的属性。
团队侧:该 PR 为多模态 processor 的「变体分支判断」提供了一个可复用范式——按类类型 isinstance 判断分支,而不是用 getattr 探测属性是否存在;这条评审意见来自核心 maintainer,值得作为后续同类修复的默认写法。

启动期 profiling 成本上升 回退分支依赖隐式字段契约 新测试未在作者环境执行 tower 路径 profiling 低估遗留 新增 transformers 模块级导入依赖

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论