Prhub

#51657 [2/N] Harden Transformers modelling backend multi-modal path

原始 PR 作者 hmellor 合并时间 2026-08-10 20:57 文件变更 3 提交数 8 评论 2 代码增减 +276 / -136

执行摘要

加固 Transformers 后端多模态路径,支持按模态跳过权重

此 PR 继续自 #51408,目标是加固 Transformers modeling backend 的多模态路径。PR body 指出此前 --mm-encoder-only 和 --limit-mm-per-prompt <modality>=0 无法真正跳过对应塔的权重,音频 encoder 也无法 torch 编译(compile_mm_encoder 被硬编码为图像 encoder)。同时存在多处契约违背:_get_prompt_updates 返回 None 但声明返回 Sequence[PromptUpdate],覆盖后的 apply() 跳过了基类的 placeholder/item count 校验,_split_embeddings 会静默填充或截断 encoder 输出。修复这些问题是为了让该后端在多模态场景下行为可预期、失败可诊断。

值得精读。重点关注 _mark_model_components 上下文管理器与 _pre_trained_model_classes 缓存的配合方式,以及按类去重处理 image/video 同 encoder 的设计;这两个设计对后续多模态模型支持有直接借鉴价值。同时建议关注 apply() 校验收紧可能带来的兼容性问题。

讨论亮点

该 PR 的 review 讨论很少:合并者 DarkLight1337 仅触发 CI 并直接批准,claude[bot] 因是 fork PR 自动审查被禁用。真正的设计权衡体现在 PR body 的 Notes 中,包括:HF 的 get_decoder/get_encoder 是基于构造模型的 hasattr 探测,没有类级等价物,因此需要一次性的 meta 模型;image 与 video 的 encoder 会报告为同一个类,因此标记和 torch compile 装饰都必须按类去重,否则 image=0 可能误删视频塔、support_torch_compile 会被应用两次;支持的模态必须先与限制取交集再标记,因为 get_limit_per_prompt 对未指定的模态返回 999;Emu3 与 granite-speech 因 HF 无法区分 encoder 与模型自身而降级为不标记。

实现拆解

  1. 引入组件标记机制(contextmanager):在 vllm/model_executor/models/transformers/base.py 中新增 _mark_model_components(vllm_config) 上下文管理器(Base 中为空操作,MultiModalMixin 覆盖),并在 __init__ 中用它包裹 AutoModel.from_config 调用,使子模块在创建时即可被标记为跳过;同时把 from_config_kwargs 提取为 _from_config_kwargs cached_property 供多处以统一方式复用。

  2. 统一模型类探测:新增 PreTrainedModelClasses(NamedTuple) 持有 decoder 类和按模态的 encoder 类;_pre_trained_model_classes cached_property 通过单次 AutoModel.from_config(meta 设备)同时获取 decoder 与 encoder 类,替代原先 _get_decoder_cls 和 _get_encoder_cls 各自构建 disposable 模型的做法,将启动期构建次数从 3 次降为 1 次。_decorate_for_torch_compile 改为使用该缓存结果,并支持对音频 encoder 也做 torch compile 装饰(按类去重避免重复装饰)。

  3. 多模态路径加固:在 vllm/model_executor/models/transformers/multimodal.py 中,将 _is_video_model 移除并统一为 _get_supported_modalities()(基于 audio/image processor 探测,不支持时报错);_get_prompt_updates 改为返回 [] 并修正 docstring;apply() 新增与基类等价的 placeholder 数量校验;_split_embeddings 对填充/截断行为增加警告;_embed_audio/_embed_vision 更名为 _process_audio_input/_process_image_input,embed_multimodal 返回 [];get_max_image_size 改为 get_image_size_with_most_features 并返回 ImageSize 类型。标记逻辑中,先对支持模态与 limit-mm-per-prompt 取交集,再决定跳过哪些塔(避免 all(limit == 0) 因未指定模态返回 999 而失效);skip_tokenizer_init 时跳过标记(无 tokenizer 无法获取 processor)。

  4. 测试配套:在 tests/models/transformers/test_backend.py 中新增 MarkingStub(组合 MultiModalMixin 与 Base 的相关方法)和 build_marked_model 辅助构造,新增 test_tower_weights_skipped_when_modality_disabled(用 llava-hf/llava-onevision-qwen2-0.5b-ov-hf 验证 image=0 时 vision tower 被替换为 StageMissingLayer)以及 test_marking_skipped_without_tokenizer(验证无 tokenizer 时不会失败,塔保留原样)。

文件 模块 状态 重要度
vllm/model_executor/models/transformers/multimodal.py 模型层 modified 8.86
vllm/model_executor/models/transformers/base.py 模型层 modified 8.15
tests/models/transformers/test_backend.py 模型层 modified 6.92

关键符号

_mark_model_components _find_encoder_classes _get_supported_modalities get_image_size_with_most_features _pre_trained_model_classes _decorate_for_torch_compile

关键源码片段

vllm/model_executor/models/transformers/multimodal.py core-logic

多模态路径核心文件:新增 `_mark_model_components` 按模态标记塔子模块,统一模态检测到 `_get_supported_modalities`,修复 `_get_prompt_updates` 契约、`apply()` 占位符校验、`_split_embeddings` 警告、音频 encoder torch compile,并重构 encoder 类探测。

# vllm/model_executor/models/transformers/multimodal.py
# 统一探测模型支持的模态:之前用 `_is_audio_model` / `_is_image_model` / `_is_video_model`
# 三处重复判断,现在收敛为一个入口,保证 limits 和 max tokens 计算使用同一份结论。
def _get_supported_modalities(self) -> list[str]:
    modalities = []
    if self._is_audio_model():
        modalities.append("audio")
    if self._is_image_model():
        modalities.append("image")
    if not modalities:
        raise ValueError(
            f"{type(self.get_hf_processor()).__name__} exposes neither an image "
            "processor nor an audio processor, so the Transformers modeling "
            "backend cannot serve this model as multi-modal."
        )
    return modalities
​
​
# 在 `AutoModel.from_config` 创建模型期间标记语言模型与各模态塔子模块。
# Base 中为空实现,这里根据 `limit_mm_per_prompt` 决定哪些塔需要被替换为
# `StageMissingLayer`(跳过硬解码与权重加载)。
@contextmanager
def _mark_model_components(self, vllm_config: VllmConfig):
    # 支持的模态必须先与 limits 取交集:`get_limit_per_prompt` 对未指定的
    # 模态返回 999,直接 `all(limit == 0)` 永远不会成立,导致 `image=0` 失效。
    with ExitStack() as stack:
        if not vllm_config.model_config.skip_tokenizer_init:
            for modality in self._get_supported_modalities():
                if vllm_config.model_config.get_limit_per_prompt(modality) == 0:
                    model_cls = self._get_encoder_cls(modality)
                    stack.enter_context(
                        self._maybe_stage_missing_layer(model_cls)
                    )
        yield
​
​
# 探测每个模态对应的 encoder 类:HF 的 `get_encoder` 是 `hasattr` 探测,
# image 与 video 可能报告同一个类,所以这里按类收集并去重,
# 避免 `image=0` 误删视频塔,也避免 torch compile 装饰被应用两次。
def _find_encoder_classes(
    self, model: PreTrainedModel
) -> dict[str, type[PreTrainedModel]]:
    encoder_classes = defaultdict(set)
    for modality in self._get_supported_modalities():
        cls = type(model.get_encoder(modality))
        # 若 encoder 与模型本身同类型(如 Emu3、granite-speech),HF 无法区分,
        # 则丢弃该模态,保证标记逻辑不会误伤整个模型。
        if cls is not type(model):
            encoder_classes[cls].add(modality)
    return {
        next(iter(modalities)): cls
        for cls, modalities in encoder_classes.items()
    }
vllm/model_executor/models/transformers/base.py core-logic

后端基类重构:新增 `PreTrainedModelClasses`、`_mark_model_components` 上下文管理器、`_from_config_kwargs` 与 `_pre_trained_model_classes` 缓存,将 class 探测从 3 次模型构建降为 1 次,并让 `_decorate_for_torch_compile` 统一消费缓存结果。

# vllm/model_executor/models/transformers/base.py
# 一次探测同时拿到 decoder 类和每个模态的 encoder 类,
# 替代原先 `_get_decoder_cls` / `_get_encoder_cls` 各自构建一次模型的做法:
# 每次加载从 3 次 `AutoModel.from_config` 降为 1 次。
class PreTrainedModelClasses(NamedTuple):
    decoder: type[PreTrainedModel]
    encoders: dict[str, type[PreTrainedModel]]
    """Modality -> encoder class, for each modality that has one."""
​
​
class Base(nn.Module, VllmModel, SupportsQuant, SupportsLoRA, SupportsPP,
            SupportsEagle, SupportsEagle3):
    # 在模型创建期间提供标记子模块的钩子;多模态 Mixin 会覆盖此方法。
    @contextmanager
    def _mark_model_components(self, vllm_config: VllmConfig):
        """Mark language model and tower submodules as `self.model` is created.
​
        Nothing to do in `Base`, `MultiModalMixin` will override."""
        yield
​
    @cached_property
    def _pre_trained_model_classes(self) -> PreTrainedModelClasses:
        """Decoder 类与各模态 encoder 类,均来自同一个一次性 meta 模型。"""
        with torch.device("meta"):
            model = AutoModel.from_config(**self._from_config_kwargs)
        model_classes = PreTrainedModelClasses(
            decoder=type(model.get_decoder()),
            encoders=self._find_encoder_classes(model),
        )
        del model
        logger.debug("Identified model classes as: %s", model_classes)
        return model_classes
tests/models/transformers/test_backend.py test-coverage

新增组件标记相关测试:`MarkingStub` 组合基类与多模态 Mixin 的标记逻辑,验证 `image=0` 时 vision tower 被替换为 `StageMissingLayer`,以及无 tokenizer 时安全降级。

# tests/models/transformers/test_backend.py
# 用最小的桩类组合 Base 与 MultiModalMixin 的标记逻辑,避免加载完整 vLLM 模型。
class MarkingStub(SupportsMultiModal, nn.Module):
    """Just enough of the backend to exercise component marking."""
    _mark_model_components = MultiModalMixin._mark_model_components
    _find_encoder_classes = MultiModalMixin._find_encoder_classes
    _from_config_kwargs = Base._from_config_kwargs
    _pre_trained_model_classes = Base._pre_trained_model_classes
​
​
# 在标记上下文内构建 HF 模型并返回,便于断言 tower 是否被替换。
def build_marked_model(image_limit: int, skip_tokenizer_init: bool = False):
    model_config = ModelConfig(
        model=MULTIMODAL_MODEL,
        model_impl="transformers",
        limit_mm_per_prompt={"image": image_limit},
    )
    # ModelConfig 构造本身需要 tokenizer,故而事后设置该属性以模拟跳过初始化。
    model_config.skip_tokenizer_init = skip_tokenizer_init
    stub = MarkingStub()
    stub.config = AutoConfig.from_pretrained(MULTIMODAL_MODEL)
    stub.model_config = model_config
    vllm_config = VllmConfig(model_config=model_config)
    with stub._mark_model_components(vllm_config), torch.device("meta"):
        stub.model = AutoModel.from_config(**stub._from_config_kwargs)
    return stub.model
​
​
# image=0 时 vision tower 应被标记为 StageMissingLayer;image=4 时保持原样。
@pytest.mark.parametrize(("image_limit", "skipped"), [(0, True), (4, False)])
def test_tower_weights_skipped_when_modality_disabled(image_limit, skipped):
    vision_tower = build_marked_model(image_limit).vision_tower
    assert isinstance(vision_tower, StageMissingLayer) is skipped

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

  1. 启动路径变更:Base.__init__ 现在在 _mark_model_components 上下文内创建模型,且类探测缓存在 cached_property 中;若 _find_encoder_classes 对某种模型的探测结果不稳定(例如 HF 对同一类返回 image/video 双模态),可能导致塔权重误跳过或编译装饰重复。
  2. 行为收紧:apply() 新增 placeholder 数量校验,任何此前依赖宽松行为(占位符数量与 item 数不一致)的模型或自定义 processor 现在会抛 RuntimeError,属于潜在的兼容性破坏。
  3. skip_tokenizer_init 降级:无 tokenizer 时标记被整体跳过,意味着 --mm-encoder-only 等限制在该场景下不生效(塔权重正常加载),这是有意的降级但可能在用户侧表现为限制失效。
  4. _split_embeddings 警告:从静默填充/截断改为告警,可能暴露此前被掩盖的模型配置问题;若用户依赖该行为,日志会出现新警告。
  5. 测试依赖外部模型:新增测试使用 llava-hf/llava-onevision-qwen2-0.5b-ov-hf,受网络和模型可用性影响,在离线或受限 CI 环境可能不稳定。

影响 Transformers modeling backend 加载多模态模型的所有用户:--limit-mm-per-prompt image=0 等现在会真正跳过塔权重,减少显存占用和加载时间;音频模型可获得 torch compile 支持;启动时元模型构建从 3 次减为 1 次,缩短冷启动时间。行为收紧(placeholder 校验)可能使少数模型报错,但错误信息更明确。对团队而言,该 PR 为此后端后续的支持(如视频、音频多模态)奠定了更稳固的组件标记基础。

启动路径变更 行为收紧可能破坏兼容性 skip_tokenizer_init 降级 测试依赖外部模型

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论