执行摘要
- 一句话:加固 Transformers 后端多模态路径,支持按模态跳过权重
- 推荐动作:值得精读。重点关注
_mark_model_components 上下文管理器与 _pre_trained_model_classes 缓存的配合方式,以及按类去重处理 image/video 同 encoder 的设计;这两个设计对后续多模态模型支持有直接借鉴价值。同时建议关注 apply() 校验收紧可能带来的兼容性问题。
功能与动机
此 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 输出。修复这些问题是为了让该后端在多模态场景下行为可预期、失败可诊断。
实现拆解
-
引入组件标记机制(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 供多处以统一方式复用。
-
统一模型类探测:新增 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 装饰(按类去重避免重复装饰)。
-
多模态路径加固:在 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)。
-
测试配套:在 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(模块 模型层;类别 source;类型 core-logic;符号 _get_supported_modalities, get_image_size_with_most_features, _find_encoder_classes, _mark_model_components): 多模态路径核心文件:新增 _mark_model_components 按模态标记塔子模块,统一模态检测到 _get_supported_modalities,修复 _get_prompt_updates 契约、apply() 占位符校验、_split_embeddings 警告、音频 encoder torch compile,并重构 encoder 类探测。
vllm/model_executor/models/transformers/base.py(模块 模型层;类别 source;类型 core-logic;符号 PreTrainedModelClasses, _find_encoder_classes, _mark_model_components, _from_config_kwargs): 后端基类重构:新增 PreTrainedModelClasses、_mark_model_components 上下文管理器、_from_config_kwargs 与 _pre_trained_model_classes 缓存,将 class 探测从 3 次模型构建降为 1 次,并让 _decorate_for_torch_compile 统一消费缓存结果。
tests/models/transformers/test_backend.py(模块 模型层;类别 test;类型 test-coverage;符号 MarkingStub, build_marked_model, test_tower_weights_skipped_when_modality_disabled, test_marking_skipped_without_tokenizer): 新增组件标记相关测试:MarkingStub 组合基类与多模态 Mixin 的标记逻辑,验证 image=0 时 vision tower 被替换为 StageMissingLayer,以及无 tokenizer 时安全降级。
关键符号:_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
多模态路径核心文件:新增 _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
后端基类重构:新增 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
新增组件标记相关测试: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
评论区精华
该 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 与模型自身而降级为不标记。
风险与影响
- 风险:
- 启动路径变更:
Base.__init__ 现在在 _mark_model_components 上下文内创建模型,且类探测缓存在 cached_property 中;若 _find_encoder_classes 对某种模型的探测结果不稳定(例如 HF 对同一类返回 image/video 双模态),可能导致塔权重误跳过或编译装饰重复。
- 行为收紧:
apply() 新增 placeholder 数量校验,任何此前依赖宽松行为(占位符数量与 item 数不一致)的模型或自定义 processor 现在会抛 RuntimeError,属于潜在的兼容性破坏。
skip_tokenizer_init 降级:无 tokenizer 时标记被整体跳过,意味着 --mm-encoder-only 等限制在该场景下不生效(塔权重正常加载),这是有意的降级但可能在用户侧表现为限制失效。
_split_embeddings 警告:从静默填充/截断改为告警,可能暴露此前被掩盖的模型配置问题;若用户依赖该行为,日志会出现新警告。
- 测试依赖外部模型:新增测试使用
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 降级, 测试依赖外部模型
关联脉络
- PR #51408 [1/N] Harden Transformers modelling backend multi-modal path: 本 PR 明确标注 Continues from #51408,是该系列的第一部分,二者构成同一加固目标的连续改动。
- PR #49797 Fix Gemma 4 for upcoming Transformers version: 涉及 Transformers modeling backend 的适配与加强(模型架构转换、config 处理),与本 PR 同属该后端的演进脉络。
参与讨论