Prhub

#49397 [Bugfix] Skip Qwen3 deepstack buffers without vision

原始 PR 作者 waynehacking8 合并时间 2026-08-05 12:46 文件变更 3 提交数 3 评论 11 代码增减 +64 / -61

执行摘要

跳过无视觉塔时 Qwen3 deepstack 缓冲区创建,修复启动崩溃

Issue #49384 报告:以 limit_mm_per_prompt={"image": 0, "video": 0, "audio": 1} 启动 Qwen/Qwen3-Omni-30B-A3B-Instruct 时,在 profile_run 阶段崩溃,报 RuntimeError: Tensor on device meta is not on the expected device cuda:0!。报告者明确这是纯音频用例(“This model is audio-only for my use case, so ideally I should be able to disable image/video entirely.”),根因是 deepstack 缓冲区在无 dummy 图片时停留在 meta 设备,而守卫只检查容器非 None。作者在 PR body 中补充说明:这不是重复工作,并指出 #43617 保持 zero-backed 缓冲区是为了避免编译图特化;本修复只在该行为与 tower-skipping 冲突时跳过分配。

值得精读。重点关注:

  1. vLLM 的 tower-skipping(StageMissingLayer)机制与“在 meta 设备上构建模块”的交互,这是理解多模态模型初始化的关键。
  2. “编译图输入契约稳定”的设计取舍(延续 #43617):zero-backed 缓冲区在 vision 启用时保留,仅在 tower 被替换时跳过,避免破坏 CUDA graph 特化。
  3. 三文件一致修改模式:qwen3_vl.py 是基准实现,qwen3_vl_moe.py 与 qwen3_omni_moe_thinker.py 同步复刻,是模型家族修复的典型结构。
  4. 对 maintainer 而言,这是一个结构清晰、证据充分(运行时 A/B 验证)的 bugfix 示例;对开发者而言,其“先构建 tower 再判定能力开关”的初始化顺序值得借鉴。
讨论亮点

1. use_deepstack 应与 vision tower 存在性绑定(design)

Isotr0py 在 review 中建议:use_deepstack 也应该用 not isinstance(self.visual, StageMissingLayer) 守卫,而不仅仅依赖 config 属性。作者同意并在 3 个模型中统一落实,将判定移到 tower 构建之后。

2. 缓冲区缺失时的 AttributeError 担忧(question)

DarkLight1337 提问:即使修复了,如果 deepstack_input_embeds 未定义,访问时不会抛出 AttributeError 吗?作者回应:_get_deepstack_input_embeds()、_set_deepstack_input_embeds()、_clear_deepstack_input_embeds() 都用 getattr(self, "deepstack_input_embeds", None) 打头,getter 在 vision 被跳过时返回 None,forward 传入 None 而非访问未定义属性。

3. 是否新增回归测试(testing)

Isotr0py 表示“No need to add tests for now. Our CI has been too heavy.”,作者移除了新增的 focused test,改用手工 A/B 启动验证与现有 2 个用例覆盖。

实现拆解

步骤 1:定位根因(tower-skipping 与 meta 设备逃逸)

  • 问题来自 vLLM 的 _mark_tower_model 机制:它在构建阶段把 vision 模块创建在 meta 设备上用于 stage 计数,之后若用户禁用多模态输入,则替换为 StageMissingLayer。
  • 原实现把 use_deepstack 判定放在 self.visual 构建之前,deepstack 的 zero 缓冲区(torch.zeros 位于 meta 设备)在这个上下文内被创建,逃过了替换。
  • 当 limit_mm_per_prompt 将 image/video 设为 0 时,profile_run 的 dummy batch 不含图片,缓冲区永远不会被 _set_deepstack_input_embeds materialize 到 CUDA;而 decoder 循环里的守卫只检查容器非 None,最终 hidden_states + deepstack_input_embeds[layer] 触发 check_same_device 崩溃。

步骤 2:三个模型文件统一修复

  • qwen3_omni_moe_thinker.py、qwen3_vl.py、qwen3_vl_moe.py 统一调整为:先构建 self.visual,再计算 use_deepstack = hasattr(config.vision_config, 'deepstack_visual_indexes') and not isinstance(self.visual, StageMissingLayer)。
  • deepstack_input_embeds 的分配(以及 deepstack_input_embeds_num_tokens 初始化)整体移入 if self.use_deepstack 保护块;vision 被跳过时完全不再分配缓冲区。
  • 该改动的关键依赖是:StageMissingLayer 的判定必须发生在 tower 构建之后,因此 use_deepstack 相关的属性初始化顺序被整体后移。

步骤 3:保持编译契约稳定并收敛测试

  • vision 启用时仍保留 zero-backed 缓冲区(延续 #43617 的设计),避免 CUDA graph / torch.compile 因 deepstack 输入时有时无而发生图特化。
  • 按 maintainer 建议不新增回归测试(CI 已过载),以运行时 A/B 启动验证替代:修复前复现崩溃,修复后 audio-only 与 vision-enabled 两种启动均通过(R51_AUDIO_ONLY_STARTUP_OK / R51_VISION_STARTUP_OK)。

配套验证

  • 现有 tests/model_executor/test_qwen3_omni.py 2 个用例通过;Ruff / format 检查通过;最终未保留新增测试文件。
文件 模块 状态 重要度
vllm/model_executor/models/qwen3_omni_moe_thinker.py 模型实现 modified 6.88
vllm/model_executor/models/qwen3_vl.py 模型实现 modified 6.61
vllm/model_executor/models/qwen3_vl_moe.py 模型实现 modified 6.74

关键符号

Qwen3VLForConditionalGeneration.__init__ Qwen3VLMoeForConditionalGeneration.__init__ qwen3_omni_moe_thinker 模型类 __init__ _get_deepstack_input_embeds

关键源码片段

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

Issue #49384 的直接作用文件;修复 thinker 模型初始化顺序,把 use_deepstack 判定移到 visual tower 构建之后并用 StageMissingLayer 守卫,缓冲区分配收敛到 use_deepstack 为真时。

# 关键修复点:先构建 vision tower,再判定 deepstack 是否可用。
# tower-skipping 机制会在 meta 设备上创建 visual 模块后替换为 StageMissingLayer;
# 若按 config 提前判定 use_deepstack,会在 meta 设备上分配 zero 缓冲区,
# 随后被 forward 中 hidden_states + deepstack_input_embeds[...] 触发
# "Tensor on device meta is not on the expected device cuda:0" 崩溃。
with self._mark_tower_model(vllm_config, {"image", "video"}):
    self.visual = Qwen3Omni_VisionTransformer(
        vision_config=thinker_config.vision_config,
        norm_eps=getattr(thinker_config.text_config, "rms_norm_eps", 1e-6),
        quant_config=quant_config,
        prefix=maybe_prefix(prefix, "visual"),
    )
​
# 只有 visual tower 真实存在(未被 StageMissingLayer 替换)时才启用 deepstack
self.use_deepstack = hasattr(
    thinker_config.vision_config, "deepstack_visual_indexes"
) and not isinstance(self.visual, StageMissingLayer)
self.deepstack_num_level = (
    len(thinker_config.vision_config.deepstack_visual_indexes)
    if self.use_deepstack
    else 0
)
self.visual_dim = thinker_config.vision_config.out_hidden_size
self.multiscale_dim = self.visual_dim * self.deepstack_num_level
​
# 缓冲区仅在 deepstack 真正可用时分配;vision 启用路径保持 zero-backed 输入,
# 避免破坏 CUDA graph / torch.compile 的输入契约稳定。
if self.use_deepstack:
    self.deepstack_input_embeds = [
        torch.zeros(
            vllm_config.scheduler_config.max_num_batched_tokens,
            thinker_config.text_config.hidden_size,
        )
        for _ in range(self.deepstack_num_level)
    ]
    # 记录缓冲区中有效的 token 跨度,0 表示当前没有可消费的 deepstack 负载
    self.deepstack_input_embeds_num_tokens = 0
vllm/model_executor/models/qwen3_vl.py data-contract

Qwen3-VL 基准实现,修复模式与 omni 一致;同时展示了 _get_deepstack_input_embeds 的 getattr 守卫如何与“不创建缓冲区”配合,是理解整体修复安全性的关键文件。

def _get_deepstack_input_embeds(
    self,
    num_tokens: int,
) -> IntermediateTensors | None:
    # 用 getattr 而非直接属性访问:vision tower 被跳过时不创建缓冲区,
    # 这里返回 None,上层 forward 把 None 传给 language_model 即可跳过残差合并,
    # 不会触发 AttributeError。
    if not getattr(self, "deepstack_input_embeds", None):
        return None # If vision tower is skipped
    # 动态扩容:profile_run 的 dummy token 数可能超过默认缓冲上限
    if num_tokens > self.deepstack_input_embeds[0].size(0):
        self._resize_deepstack_input_embeds(num_tokens)
​
    # 取出前 num_tokens 行构建 IntermediateTensors,供 decoder 残差合并使用
    return IntermediateTensors(
        {
            f"deepstack_input_embeds_{idx}": self.deepstack_input_embeds[idx][
                :num_tokens
            ]
            for idx in range(self.deepstack_num_level)
        }
    )

评论区精华

use_deepstack 应与 vision tower 存在性绑定 设计

Isotr0py 在 review 中建议:`use_deepstack` 也应该用 `not isinstance(self.visual, StageMissingLayer)` 守卫,而不仅仅依赖 config 属性。作者同意并在 3 个模型中统一落实,将判定移到 tower 构建之后。

结论:在 qwen3_vl.py、qwen3_vl_moe.py、qwen3_omni_moe_thinker.py 中统一将 use_deepstack 判定与 vision tower 存在性绑定。 · 已解决

缓冲区缺失时的 AttributeError 担忧 question

DarkLight1337 提问:即使修复了,如果 `deepstack_input_embeds` 未定义,访问时不会抛出 `AttributeError` 吗?作者回应:`_get/_set/_clear_deepstack_input_embeds` 都用 `getattr(self, "deepstack_input_embeds", None)` 打头,getter 在 vision 被跳过时返回 None,forward 传入 None。

结论:现有访问器的 getattr 守卫已覆盖缺失属性场景,无需额外处理。 · 已解决

是否新增回归测试 测试

Isotr0py 表示 CI 已经太重("No need to add tests for now. Our CI has been too heavy."),建议暂不加测试;作者移除了新增的 focused test,改用手工 A/B 启动验证。

结论:不新增测试,依赖现有 test_qwen3_omni.py 与运行时启动验证。 · 已解决

风险与影响

  • 回归风险:use_deepstack 相关属性的初始化被推迟到 self.visual 构建之后,若 __init__ 早期路径(如 weights loader、_mark_tower_model 内部回调)读取 self.deepstack_num_level 或 self.multiscale_dim,可能遇到属性未初始化;现有访问器均有 getattr 守卫,实际风险较低。
  • 测试覆盖:未新增回归测试,主要依赖手工 A/B 验证;未来若 StageMissingLayer 或 tower-skipping 逻辑演化,此修复可能回归而不被 CI 捕获。
  • 一致性风险:三个模型文件(qwen3_omni_moe_thinker.py、qwen3_vl.py、qwen3_vl_moe.py)需要同步维护,若只改其一易造成行为分叉。
  • 性能/内存影响:无;正常 vision 路径行为不变,仅 vision 被跳过时不再分配缓冲区,反而减少 meta 设备上的无用分配。
  • 用户影响:audio-only 场景(禁用 image/video)的 Qwen3-Omni 部署从启动即崩溃变为可用,这正是 Issue #49384 报告者(纯音频用例)的核心诉求。
  • 系统影响:仅影响 vision tower 被替换为 StageMissingLayer 的初始化分支;多模态正常路径完全不变。
  • 团队/代码库影响:形成统一的“deepstack 与 vision tower 存在性绑定”约定,对后续 Qwen 系列模型接入 deepstack 有参考价值。
启动路径变更 缺少新增回归测试 跨三模型同步修改

关联 Issue

#49384 [Bug]: Qwen3-Omni crashes at startup with "Tensor on device meta" during profile_run when limit_mm_per_prompt disables images

完整报告

参与讨论