Prhub

#52925 [Core][Multimodal] Skip redundant placeholder scan when token match succeeds

原始 PR 作者 Yiqin-17 合并时间 2026-08-20 23:04 文件变更 4 提交数 4 评论 9 代码增减 +539 / -175

执行摘要

token 匹配成功时跳过重复 placeholder 扫描

PR body 指出:_apply_prompt_updates 先调用 _apply_token_matches,即使全部 token 匹配成功,仍会无条件调用 _find_mm_placeholders 对整条 prompt 再扫描一遍;其中 prompt[start:end] == content_tokens 的切片比较占 _apply_prompt_updates 总耗时 57%–95%,3k 分辨率下可达 75 ms。PR 的论点是“When all token matches succeed, token matching already knows each placeholder position. The code does not need to scan the prompt again.”同时关联 Issue #52924:PromptInsertion 插入 token 与相邻原始 token 重叠时,find_mm_placeholders 会定位到更早的位置(start_idx=3 而非 4),本 PR 直接记录插入区间,修正这一位置语义。

值得精读。重点关注 _apply_token_matches_with_placeholders“边匹配边收集”的设计,以及从运行时检查演进到可覆写实例方法的过程——这是处理基类扩展点时的典型模式。维护多模态模型适配的同学应留意新覆写契约;性能结论基于 PR 自带 benchmark,建议在真实负载和 Gemma3 等模型上复测。

讨论亮点

核心讨论围绕快速路径是否会绕过处理器子类覆写展开。DarkLight1337 在 processor.py 的 diff 上提出:“Not sure whether this is entirely correct, since some MM processors override the _apply_token_matches method”。Yiqin-17 承认早期返回路径绕过了处理器特定覆写,先用 uses_base_token_matching 运行时检查兜底;DarkLight1337 随后建议“have an overridable _apply_token_matches_with_placeholders and implement it for those models as well”。作者确认理解后,最终删除运行时检查,将方法提升为实例方法,并为 Gemma3/Gemma3n 增加 override。claude[bot] 复核表示该设计问题已解决、未发现 bug,但提醒这是所有模型共用的核心性能路径,合入前值得 maintainer 再看一眼最终设计。DarkLight1337 最终以 “LGTM, thanks for your patience!” 批准。

实现拆解

  1. 新增 _apply_token_matches_with_placeholders(vllm/multimodal/processing/processor.py):基于 _plan_prompt_updates 返回的 matched_updates,在拼接 new_token_ids 的同时按 modality 收集 PlaceholderFeaturesInfo,start_idx 取 len(new_token_ids),即实际插入/替换内容的精确起点。这样不再需要事后重扫整条序列。
  2. 改造 BaseMultiModalProcessor._apply_prompt_updates 入口:统一调用新增的可覆写实例方法,替代“先 _apply_token_matches,再无条件 _find_mm_placeholders”的两段式流程。当 match_result 中所有 modality 的 update_idx 均非 None 时直接返回收集结果;任一匹配失败仍走 _apply_text_matches + _find_mm_placeholders 的文本回退路径,回退行为不变。
  3. 设计演进(4 次 commit):初版用运行时检查 uses_base_token_matching 限制快速路径适用面;review 讨论后改为把 _apply_token_matches_with_placeholders 提升为实例方法,删除运行时检查,并让 Gemma3/Gemma3n(vllm/model_executor/models/gemma3_mm.py、gemma3n_mm.py)显式覆写:先调用各自的 _apply_token_matches(含 \n 系 token 合并),再在全部匹配成功时调用各自的 _find_mm_placeholders,避免快速路径跳过模型专用逻辑。
  4. 测试配套:tests/multimodal/test_processing.py 将 test_find_update_tokens 的参数化用例抽成 FIND_UPDATE_TOKENS_TEST_CASES,新增 test_apply_token_matches_with_placeholders,覆盖多 modality 排他匹配、PromptInsertion / PromptReplacement / PromptIndexTargets 等场景,验证新旧路径产出一致的 new_token_ids 与 placeholder 位置。
  5. 性能配套:在上游 PR #51774 的 10,000 placeholder 合成基准之上,本 PR 将平均延迟再降低约 45%,与单路径基准互相印证。
文件 模块 状态 重要度
vllm/multimodal/processing/processor.py 多模态处理 modified 8.0
vllm/model_executor/models/gemma3_mm.py 模型适配 modified 6.92
vllm/model_executor/models/gemma3n_mm.py 模型适配 modified 6.92
tests/multimodal/test_processing.py 测试 modified 6.38

关键符号

_apply_token_matches_with_placeholders _matched_updates_from_result BaseMultiModalProcessor._apply_token_matches_with_placeholders Gemma3MultiModalProcessor._apply_token_matches_with_placeholders Gemma3nMultiModalProcessor._apply_token_matches_with_placeholders test_apply_token_matches_with_placeholders

关键源码片段

vllm/multimodal/processing/processor.py core-logic

核心改动文件:新增 `_apply_token_matches_with_placeholders` 与 `_matched_updates_from_result`,`_apply_prompt_updates` 在 token 匹配全成功时跳过 `_find_mm_placeholders` 二次扫描,性能与正确性收益都集中在这里。

def _apply_token_matches_with_placeholders(
    token_ids: list[int],
    mm_prompt_updates: "MultiModalPromptUpdates",
    tokenizer: TokenizerLike | None,
) -> tuple[
    list[int],
    "MultiModalPromptUpdatesApplyResult",
    Mapping[str, list[PlaceholderFeaturesInfo]],
]:
    # 一次 `_plan_prompt_updates` 遍历同时完成 token 替换与 placeholder 收集;
    # 全部 token 匹配成功时调用方直接使用这里的位置,跳过整段二次扫描
    matched_updates, result = _plan_prompt_updates(
        token_ids,
        mm_prompt_updates,
        tokenizer,
    )
    placeholders: dict[str, list[PlaceholderFeaturesInfo]] = {
        modality: [] for modality in mm_prompt_updates
    }
​
    new_token_ids = list[int]()
    prev_end_idx = 0
    for matched_update in matched_updates:
        update = matched_update.update
        match = matched_update.match
        matched_content = update.content.full
​
        # INSERT 在目标之后插入,REPLACE 覆盖目标本身,二者终点不同
        if update.mode == UpdateMode.INSERT:
            end_idx_to_insert = match.end_idx
        elif update.mode == UpdateMode.REPLACE:
            end_idx_to_insert = match.start_idx
        else:
            assert_never(update.mode)
​
        new_token_ids.extend(token_ids[prev_end_idx:end_idx_to_insert])
        # 这里记录的是刚拼入的精确区间,而不是重扫可能命中的更早位置
        start_idx = len(new_token_ids)
​
        tokens = _seq2tokens(tokenizer, matched_content)
        if tokens:
            content_is_embed = update.content.is_embed
            if content_is_embed is not None:
                content_is_embed = content_is_embed(tokenizer, matched_content)
​
            placeholders[update.modality].append(
                PlaceholderFeaturesInfo(
                    modality=update.modality,
                    item_idx=update.item_idx,
                    start_idx=start_idx,
                    tokens=tokens,
                    is_embed=content_is_embed,
                )
            )
            new_token_ids.extend(tokens)
​
        prev_end_idx = match.end_idx
​
    new_token_ids.extend(token_ids[prev_end_idx:])
    return new_token_ids, result, placeholders
vllm/model_executor/models/gemma3_mm.py data-contract

Gemma3 覆写 `_apply_token_matches` 会把换行 token 合并,快速路径若不覆写会跳过该逻辑;新增同名 override 先走自己的 token 匹配,再在全部成功时调用自己的 `_find_mm_placeholders`。

def _apply_token_matches_with_placeholders(
    self,
    token_ids: list[int],
    mm_prompt_updates: MultiModalPromptUpdates,
) -> tuple[
    list[int],
    MultiModalPromptUpdatesApplyResult,
    Mapping[str, list[PlaceholderFeaturesInfo]],
]:
    # Gemma3 的 `_apply_token_matches` 会把 `\n` 相关 token 合并成
    # 单个 newline token,因此不能复用基类快速路径,必须走自己的实现
    new_token_ids, match_result = self._apply_token_matches(
        token_ids,
        mm_prompt_updates,
    )
​
    placeholders: dict[str, list[PlaceholderFeaturesInfo]] = {
        modality: [] for modality in mm_prompt_updates
    }
​
    # 只有全部 modality 的 token 匹配成功时才直接采用 `_find_mm_placeholders`
    # 的结果;任何一个匹配失败都会回落到文本 fallback 路径
    if all(
        all(update_idx is not None for update_idx in update_idxs)
        for update_idxs in match_result.values()
    ):
        placeholders = dict(
            self._find_mm_placeholders(
                new_token_ids,
                self._matched_updates_from_result(
                    mm_prompt_updates,
                    match_result,
                ),
            )
        )
​
    return new_token_ids, match_result, placeholders

评论区精华

早期返回路径绕过处理器覆写的 `_apply_token_matches` / `_find_mm_placeholders` 设计

DarkLight1337 指出部分多模态处理器覆写了 `_apply_token_matches`,直接提前返回可能不正确;Yiqin-17 先用 `uses_base_token_matching` 运行时检查兜底,DarkLight1337 进一步建议改为可覆写的实例方法并为受影响模型实现;作者随后删除运行时检查,为 Gemma3/Gemma3n 增加 override。

结论:最终采用可覆写 `_apply_token_matches_with_placeholders` 实例方法 + Gemma3/Gemma3n override,claude[bot] 确认设计问题已解决,DarkLight1337 批准合并。 · 已解决

风险与影响

_apply_prompt_updates 是所有多模态模型预填共享的核心路径,placeholder 位置语义的任何偏差都会影响图像特征与文本 token 的对齐。新路径对 PromptInsertion 的位置记录与旧重扫路径在重叠场景下刻意不同(直接记录插入区间,而非重扫选中最早匹配),属于行为修正,但依赖旧语义的下游逻辑需要重新确认。_matched_updates_from_result 内含 assert update_idx is not None,只在全部匹配成功时调用,路径受约束。外部 fork 或第三方子类若覆写了 _apply_token_matches / _find_mm_placeholders 而未同步覆写新方法,会静默丢失自定义逻辑;仓库内 Gemma3/Gemma3n 是仅有的两个特例,已补齐 override。测试以单测和合成基准为主,缺少真实模型 e2e 图像对齐回归验证。

该优化直接作用于所有走 BaseMultiModalProcessor 的多模态模型。基准显示 _apply_prompt_updates 在 2,500 token 输入下提速 8.5×(480p)到 44.0×(3k),固定分辨率下最高 50.9×;PromptInsertion 位置错误问题(Issue #52924)随新路径一并修复。对团队而言,多模态处理器新增了一个可覆写契约:未来若自定义 _apply_token_matches 或 _find_mm_placeholders,必须同步覆写 _apply_token_matches_with_placeholders,否则快速路径会跳过专用逻辑。

核心路径变更 子类覆写契约变更 行为差异(PromptInsertion 位置语义) 外部子类兼容性风险

关联 Issue

#52924 [Bug]: `find_mm_placeholders` may return an incorrect position for `PromptInsertion`

完整报告

参与讨论