Prhub

#46762 [ModelRunner V2] Support realtime embeddings

原始 PR 作者 njhill 合并时间 2026-06-27 10:42 文件变更 6 提交数 4 评论 1 代码增减 +52 / -36

执行摘要

支持实时模型解码步嵌入收集

实时模型(如 Voxtral)在解码步骤也需要多模态嵌入,原有方案只在预填充阶段收集,导致解码时嵌入缺失。PR body 明确说明:'Realtime models like voxtral require embeddings for decode steps too.'

值得精读,特别是区分实时与非实时嵌入收集的设计、以及通过 dummy_inputs_embeds 解耦 CUDA 图捕获与编码器执行的方法。对理解 vLLM V2 多模态流程有参考价值。

讨论亮点

reviewer yewentao256 直接批准(LGTM),无实质讨论。claude[bot] 自动提示该 PR 来自 fork,未启动审查。

实现拆解

  1. 识别实时模型:在 EncoderRunner.__init__ 中通过 supports_realtime(model) 设置 self.is_realtime 标志(encoder_runner.py)。
  2. 修改嵌入收集逻辑:在 EncoderRunner.gather_mm_embeddings 中根据 is_realtime 决定是否对所有请求(包括解码)收集嵌入;非实时模型行为不变(encoder_runner.py)。
  3. 引入 dummy_inputs_embeds 接口:在 ModelState 抽象基类(interface.py)中添加方法(默认返回 None);在 DefaultModelStatedefault.py)中实现为返回 self.encoder_runner.inputs_embeds[:num_tokens]
  4. 调整模型执行路径:在 ModelRunner.execute_model 中,若为 dummy run 则调用 dummy_inputs_embeds 而非 get_mm_embeddings,避免 dummy 阶段运行编码器(model_runner.py)。
  5. 测试与类型修正:同步更新 test_encoder_runner.py 中的参数名;修正 diffusion_gemma.pyget_mm_embeddings 的返回类型为 torch.Tensor | None
文件 模块 状态 重要度
vllm/v1/worker/gpu/mm/encoder_runner.py 多模态编码器 modified 7.09
vllm/v1/worker/gpu/model_runner.py 模型运行器 modified 6.78
vllm/v1/worker/gpu/model_states/interface.py 模型状态 modified 6.11
vllm/v1/worker/gpu/model_states/default.py 模型状态 modified 5.5
vllm/model_executor/models/diffusion_gemma.py 模型实现 modified 4.53
tests/v1/worker/test_encoder_runner.py 测试 modified 3.71

关键符号

EncoderRunner.__init__ EncoderRunner.gather_mm_embeddings ModelState.dummy_inputs_embeds DefaultModelState.dummy_inputs_embeds ModelRunner.execute_model DiffusionGemmaModel.get_mm_embeddings

关键源码片段

vllm/v1/worker/gpu/mm/encoder_runner.py core-logic

核心变更:添加 is_realtime 标志,重构 gather_mm_embeddings 以支持解码步收集

def gather_mm_embeddings(
    self,
    req_ids: list[str],
    total_num_scheduled_tokens: int,
    num_scheduled_tokens: np.ndarray,
    query_start_loc: np.ndarray,
    prefill_lens: np.ndarray,
    num_computed_tokens: np.ndarray,
    draft_lookahead: int = 0,
) -> tuple[list[torch.Tensor], torch.Tensor]:
    if draft_lookahead:
        num_computed_tokens = num_computed_tokens + draft_lookahead
​
    is_mm_embed = torch.zeros(
        total_num_scheduled_tokens, dtype=torch.bool, device="cpu"
    )
​
    # 判断是否需要排除嵌入收集(非实时模型在解码步跳过)
    exclude_embeddings: list[bool] | None = None
    if not self.is_realtime:
        # 非实时模型的媒体嵌入只出现在 prompt 中
        is_decode = num_computed_tokens >= prefill_lens
        if is_decode.all():
            # 所有请求都是解码,无需收集嵌入
            return [], is_mm_embed
        exclude_embeddings = is_decode.tolist()
​
    query_start = num_computed_tokens.tolist()
    query_end = (num_computed_tokens + num_scheduled_tokens).tolist()
​
    mm_embeds: list[torch.Tensor] = []
    for i, req_id in enumerate(req_ids):
        if exclude_embeddings is not None and exclude_embeddings[i]:
            # 非实时解码请求,跳过
            continue
​
        cur_query_start = query_start[i]
        cur_query_end = query_end[i]
​
        mm_features = self.encoder_cache.mm_features[req_id]
        lo, hi = get_mm_features_in_window(
            mm_features, start=cur_query_start, end=cur_query_end
        )
        for idx in range(lo, hi):
            mm_feature = mm_features[idx]
            pos_info = mm_feature.mm_position
            start_pos = pos_info.offset
            num_encoder_tokens = pos_info.length
​
            start_idx = max(cur_query_start - start_pos, 0)
            end_idx = min(cur_query_end - start_pos, num_encoder_tokens)
            assert start_idx < end_idx
            curr_embeds_start, curr_embeds_end = (
                pos_info.get_embeds_indices_in_range(start_idx, end_idx)
            )
            if curr_embeds_start == curr_embeds_end:
                continue
​
            mm_hash = mm_feature.identifier
            encoder_output = self.encoder_cache.encoder_outputs.get(mm_hash, None)
            if encoder_output is None:
                continue # 缓存未命中,通常不应发生
            mm_embeds.append(encoder_output[curr_embeds_start:curr_embeds_end])
            # 标记对应 token 位置为多模态嵌入
            is_mm_embed[cur_query_start + start_idx: cur_query_start + end_idx] = True
​
    return mm_embeds, is_mm_embed
vllm/v1/worker/gpu/model_runner.py core-logic

修改 execute_model 中多模态嵌入准备路径,dummy run 使用 dummy_inputs_embeds

if self.supports_mm_inputs and self.is_first_pp_rank:
    # 对于 dummy runs(CUDA 图捕获阶段),直接从预分配缓冲区
    # 获取嵌入,避免调用实际编码器,同时保持正确的形状以匹配
    # 编译模型。
    if dummy_run:
        inputs_embeds = self.model_state.dummy_inputs_embeds(
            input_batch.num_tokens_after_padding
        )
    else:
        scheduled_encoder_inputs = scheduler_output.scheduled_encoder_inputs
        if self.lora_config is not None:
            set_active_mm_loras(
                model=self.model,
                lora_manager=self.lora_manager,
                encoder_cache=self.encoder_cache,
                req_id_to_index=self.req_states.req_id_to_index,
                lora_state=self.lora_state,
                scheduled_encoder_inputs=scheduled_encoder_inputs,
            )
        inputs_embeds = self.model_state.get_mm_embeddings(
            scheduled_encoder_inputs, input_batch, self.req_states
        )
    if inputs_embeds is not None and not self.model.requires_raw_input_tokens:
        input_ids = None
vllm/v1/worker/gpu/model_states/interface.py data-contract

添加 dummy_inputs_embeds 抽象方法,更新 gather_mm_embeddings 签名

class ModelState(ABC):
    # ...
    def dummy_inputs_embeds(self, num_tokens: int) -> torch.Tensor | None:
        """Pre-allocated inputs_embeds buffer for dummy runs (contents unused)."""
        return None
​
    def gather_mm_embeddings(
        self, input_batch: InputBatch, draft_lookahead: int = 0
    ) -> tuple[list[torch.Tensor], torch.Tensor]:
        """Gather cached multimodal embeddings."""
        return self.encoder_runner.gather_mm_embeddings(
            input_batch.req_ids,
            input_batch.num_tokens,
            input_batch.num_scheduled_tokens,
            input_batch.query_start_loc_np,
            input_batch.prefill_len_np,
            input_batch.num_computed_tokens_np, # 由 computed_prefill_tokens_np 重命名而来
            draft_lookahead=draft_lookahead,
        )

评论区精华

Overall approval other

Yewentao256 approved the PR with LGTM.

结论:No changes requested. · 已解决

风险与影响

  1. 回归风险:非实时模型的行为通过 is_decode.all()exclude_embeddings 保持原逻辑,但 draft_lookahead 与 num_computed_tokens 的叠加计算需注意边界。
  2. 性能影响:实时模型每个步骤都执行嵌入收集循环与缓存查找,可能增加解码延迟;dummy run 不再调用编码器,略有改善。
  3. 兼容性:参数 computed_prefill_lens 重命名为 num_computed_tokens,内部调用已同步更新,外部直接调用 gather_mm_embeddings 的代码需适配。
  4. 测试覆盖:测试仅覆盖基本场景,缺少实时与非实时混合批处理及 dummy run 状态下实时模型的行为验证。

用户影响:Voxtral 等实时模型可正常解码,不再因缺少嵌入报错;非实时模型无感知。系统影响:模型执行路径增加分支判断,开销极小。团队影响:自定义 ModelState 子类需实现 dummy_inputs_embeds 方法(默认返回 None 即可),其他外部接口无需改动。

核心路径变更 参数重命名 新功能分支

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论