Prhub

#50574 [Model Runner V2] Enable encoder token embedding

原始 PR 作者 taneem-ibrahim 合并时间 2026-08-01 19:55 文件变更 4 提交数 6 评论 4 代码增减 +106 / -7

执行摘要

MRV2 启用 token_embed 并接入 ColBERT 打分

MRV2 是 vLLM 新一代模型执行器,pooling 支持按任务分步落地。PR body 明确说明:This PR enables the token_embed pooling task on Model Runner V2 for encoder-only models, and wires the existing LateInteractionRunner into the V2 pooling path so ColBERT-style scoring works under MRV2. 这是继 embed/classify(#48791)与 token_classify(#50293)之后的第三个任务,目的是补齐词级嵌入能力,并让检索型模型的 late-interaction 打分在 V2 下与 V1 行为一致。

值得精读。重点关注三点:一是「组合 LateInteractionRunner 而非把打分逻辑写进 PoolingRunner」的模块化决策,代码量虽小但扩展性好;二是生命周期钩子的放置顺序与缓存失效语义的精确定义——finish_requests 先于 preempted 合并、reset_encoder_cachereset_mm_cache 的语义区分,都有测试锁定契约;三是 review 中「少改 MRV2 核心文件」的权衡过程,体现了核心模块演进时的改动面控制意识。

讨论亮点

核心讨论围绕「尽量少改 MRV2 核心文件」展开:

  • yewentao256 在 model_runner.pyreset_mm_cache diff 上提出:we should try to edit MRv2 file less, is there any chance we can move to other files?
  • taneem-ibrahim 回应:三处改动(add_requestsfinish_requestsreset_*)都是只有 runner 才能观察到的生命周期事件;并主动提议删掉 reset_mm_cache 中的 clear()(-2 行),因为 reload_weights 会同时调用两个 reset,权重更新覆盖不变。
  • yewentao256 认可:Sounds good! Thanks。

最终实现(commit 552d6bf)正是该讨论的落地:只保留 reset_encoder_cache 的清理,且新增测试同时断言两个方向(mm 缓存重置不清、encoder 缓存重置清)。该线程展示了 MRV2 演进过程中对核心文件改动面的敏感度权衡。

实现拆解

实现分四步:

  1. 任务白名单与筛选(vllm/v1/worker/gpu/pool/pooling_runner.py_SUPPORTED_TASKS{embed, classify, token_classify} 扩展为 {embed, classify, token_embed, token_classify},新增 _TOKEN_TASKS 常量统一标识 token 级任务;_get_enabled_tasks 对 decoder 模型统一剔除 _TOKEN_TASKS(原实现只剔除 token_classify)。原因:token 级 pooling 只在非分块的 encoder-only prefill 下才有语义。

  2. LateInteractionRunner 组合集成(pooling_runner.py__init__ 创建 self.late_interaction_runner = LateInteractionRunner()add_request 签名新增 req_id 参数,并在登记 pooling 状态时调用 register_request(req_id, pooling_params),使 doc/query 关联按 req_id 记账;pool() 在 pooler 输出后追加 postprocess_pooler_output(...),借助 finished_mask 决定 query 缓存回收时机。非 late-interaction 任务(embed/classify/token_classify)也会经过该后处理,但 LateInteractionRunner 对无关任务原样透传。

  3. 生命周期挂钩(vllm/v1/worker/gpu/model_runner.pyfinish_requestspreempted_req_ids 合并之前调用 on_requests_finished(finished_req_ids),注释明确被抢占的 doc 保留 query-use 预留直到重新调度;reset_encoder_cache 追加 pooling_runner.clear(),保证权重重载后失效的 query 嵌入缓存被清空;add_requestspooling_runner.add_request 透传 req_id。review 后按 yewentao256 的建议删除了 reset_mm_cache 中的 clear(),因为多模态缓存重置与打分缓存无关。

  4. 测试配套tests/models/language/pooling/test_splade_sparse_pooler.py 新增 4 个用例,覆盖任务筛选(test_pooling_runner_supports_encoder_token_embeddingtest_pooling_runner_filters_decoder_token_embedding)、aborted doc 释放(test_pooling_runner_releases_aborted_late_interaction_doc)与缓存重置语义锁定(test_encoder_cache_reset_clears_late_interaction_state);tests/models/language/pooling/test_colbert.pytest_colbert_hf_comparison 参数化 (backend, use_v2),bert/modernbert 走 V2、jina/lfm2 走 V1,通过 monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", ...) 强制路径并断言配置实际生效。

文件 模块 状态 重要度
vllm/v1/worker/gpu/pool/pooling_runner.py 池化执行 modified 7.11
vllm/v1/worker/gpu/model_runner.py 模型执行 modified 5.8
tests/models/language/pooling/test_splade_sparse_pooler.py 池化测试 modified 7.0
tests/models/language/pooling/test_colbert.py 检索打分 modified 5.41

关键符号

PoolingRunner.__init__ PoolingRunner._get_enabled_tasks PoolingRunner.add_request PoolingRunner.pool PoolingRunner.on_requests_finished PoolingRunner.clear GPUModelRunner.finish_requests GPUModelRunner.reset_encoder_cache GPUModelRunner.add_requests LateInteractionRunner.postprocess_pooler_output LateInteractionRunner.register_request

关键源码片段

vllm/v1/worker/gpu/pool/pooling_runner.py core-logic

核心源码:扩展 `_SUPPORTED_TASKS` 支持 `token_embed`,新增 `_TOKEN_TASKS` 常量,把 `LateInteractionRunner` 组合进 `PoolingRunner` 并在 `pool()` 中完成后处理接线,同时新增 `on_requests_finished`/`clear` 生命周期转发方法。

# vllm/v1/worker/gpu/pool/pooling_runner.py(整理后实现片段)
# token 级任务统一收敛到 _TOKEN_TASKS,decoder 模型据此整体剔除
_TOKEN_TASKS: frozenset[PoolingTask] = frozenset({"token_embed", "token_classify"})
​
​
class PoolingRunner:
    def __init__(self, model: nn.Module, vllm_config: VllmConfig) -> None:
        ...
        self.pooling_params: dict[int, PoolingParams] = {}
        self.pooling_states: dict[int, PoolingStates] = {}
        self.prompt_token_ids: dict[int, torch.Tensor] = {}
        # 组合 LateInteractionRunner:让池化路径复用既有的 ColBERT query 缓存
        # 与 late-interaction 后处理,避免把打分逻辑硬编码进 PoolingRunner
        self.late_interaction_runner = LateInteractionRunner()
​
    @staticmethod
    def _get_enabled_tasks(model_config: ModelConfig) -> frozenset[PoolingTask]:
        # token 级 pooling 只允许在非分块的 encoder-only prefill 上使用;
        # decoder 模型(如生成式模型)一律剔除 token_embed / token_classify
        if model_config.attn_type == "encoder_only":
            return _SUPPORTED_TASKS
        return _SUPPORTED_TASKS - _TOKEN_TASKS
​
    def add_request(
        self,
        req_id: str, # 新增参数:late-interaction 状态按 req_id 记账
        req_index: int,
        pooling_params: PoolingParams,
        prompt_token_ids: list[int],
    ) -> None:
        ...
        self.model.pooler.get_pooling_updates(task).apply(pooling_params)
        # req_id 是 doc-query 关联与引用计数的键,必须在此注册
        self.late_interaction_runner.register_request(req_id, pooling_params)
        self.pooling_params[req_index] = pooling_params
        ...
​
    def pool(
        self,
        hidden_states: torch.Tensor,
        input_batch: InputBatch,
        req_states: RequestState,
    ) -> tuple[PoolerOutput, list[bool]]:
        ...
        pooler_output = self.model.pooler(hidden_states, pooling_metadata)
        # 从 pooling cursor 得到本步内各请求是否到达终点(finished_mask)
        finished_mask = pooling_metadata.get_pooling_cursor().is_finished().tolist()
        # late-interaction 后处理:只有注册过 query 缓存的请求会被改写输出,
        # 其余任务(embed/classify 等)原样透传;finished_mask 用于决定
        # query 缓存何时可以安全释放
        pooler_output = self.late_interaction_runner.postprocess_pooler_output(
            raw_pooler_output=pooler_output,
            pooling_params=pooling_metadata.pooling_params,
            req_ids=input_batch.req_ids,
            finished_mask=finished_mask,
        )
        return pooler_output, finished_mask
vllm/v1/worker/gpu/model_runner.py lifecycle-hook

生命周期钩子接入点:`finish_requests` 在 preempted 合并前通知 late-interaction 清理,`reset_encoder_cache` 触发 `clear()`,`add_requests` 透传 `req_id`;review 讨论集中在这些改动的必要性与放置位置。

# vllm/v1/worker/gpu/model_runner.py(整理后实现片段)
def finish_requests(self, scheduler_output: SchedulerOutput) -> None:
    finished_req_ids = scheduler_output.finished_req_ids
    if self.pooling_runner is not None:
        # 必须在 preempted 合并之前通知:被抢占的文档请求仍然保留
        # 其 query-use 预留(引用计数),等重新调度时继续使用;
        # 过早清理会造成状态错乱,这是顺序敏感的生命周期契约
        self.pooling_runner.on_requests_finished(finished_req_ids)
    preempted_req_ids = scheduler_output.preempted_req_ids
    if preempted_req_ids:
        finished_req_ids = finished_req_ids.union(preempted_req_ids)
    for req_id in finished_req_ids:
        self._remove_request(req_id)
​
​
def reset_encoder_cache(self) -> None:
    # 权重重载会让缓存的 query 嵌入失效,必须同步清空 late-interaction
    # 状态,防止用旧权重算出的嵌入继续参与打分;多模态缓存重置
    # (reset_mm_cache)与打分缓存无关,因此不清空(review 后收敛)
    if self.encoder_cache is not None:
        self.encoder_cache.reset_encoder_cache()
    if self.pooling_runner is not None:
        self.pooling_runner.clear()
tests/models/language/pooling/test_splade_sparse_pooler.py test-coverage

新增 4 个测试用例,覆盖任务筛选、aborted doc 释放与缓存重置语义,其中 `test_encoder_cache_reset_clears_late_interaction_state` 精确锁定了 review 讨论得出的 reset 语义。

# tests/models/language/pooling/test_splade_sparse_pooler.py(整理后片段)
def test_encoder_cache_reset_clears_late_interaction_state() -> None:
    # 缓存的 query 嵌入只被权重重载失效(经由 reset_encoder_cache 入口);
    # 多模态缓存重置与之无关,必须保留这些状态——这个断言锁定了
    # review 讨论中得出的缓存失效语义
    runner = GPUModelRunner.__new__(GPUModelRunner)
    runner.encoder_cache = MagicMock()
    runner.pooling_runner = MagicMock()
​
    runner.reset_mm_cache()
    runner.encoder_cache.reset_mm_cache.assert_called_once_with()
    runner.pooling_runner.clear.assert_not_called()
​
    runner.reset_encoder_cache()
    runner.encoder_cache.reset_encoder_cache.assert_called_once_with()
    runner.pooling_runner.clear.assert_called_once_with()

评论区精华

MRV2 核心文件最小改动与生命周期钩子归属 设计

yewentao256 在 `model_runner.py` 的 `reset_mm_cache` diff 上提出希望少改 MRV2 文件、能否把逻辑移到其他文件;taneem-ibrahim 解释 `add_requests`/`finish_requests`/`reset_*` 三处都是只有 runner 能观察到的生命周期事件,并提议删掉 `reset_mm_cache` 中的 `clear()`,因为 `reload_weights` 会同时调用两个 reset。

结论:接受折中方案:保留 `model_runner.py` 中的生命周期钩子,但只保留 `reset_encoder_cache` 的清理,移除 `reset_mm_cache` 中的 `clear()`;新增测试同时断言两个方向的语义。 · 已解决

风险与影响

  1. 生命周期时序敏感(model_runner.py::finish_requestson_requests_finished 必须在 preempted_req_ids 合并之前执行,否则被抢占 doc 的 query 引用计数会被提前释放,重调度后状态错乱;现有测试只覆盖 aborted 场景,未直接覆盖 preempted 场景。

  2. 缓存失效依赖单一入口(model_runner.py::reset_encoder_cache:query 嵌入缓存只在 reset_encoder_cache 时清空,依赖 reload_weights 同时调用两个 reset 的前提;未来若新增其它权重更新路径,需确保同样经过该入口,否则会用陈旧嵌入打分。测试 test_encoder_cache_reset_clears_late_interaction_state 已锁定该契约,但不会阻止新增入口。

  3. add_request 签名变更(新增 req_id:内部 API 变化,当前调用方仅 GPUModelRunner.add_requests,已同步;若外部测试或插件直接调用需适配。

  4. V2 对比覆盖有限:HF 对比仅对 bert/modernbert 后端启用 V2,Jina/LFM2 仍走 V1;Jina/LFM2 在 V2 下的行为未直接验证,但强制 V2 的 14 个 online score/rerank 用例全部通过,缓解了该风险。

  5. 本 PR 未包含文档改动,MRV2 支持任务矩阵尚无 token_embed 的说明,存在轻度可发现性风险。

对用户:ColBERT 系列模型(如 answerdotai/answerai-colbert-small-v1lightonai/GTE-ModernColBERT-v1)在 VLLM_USE_V2_MODEL_RUNNER=1 下可直接使用 token_embed、late-interaction score/rerank 接口,输出与 HuggingFace 参考在 1e-2 容差内一致;V1 路径完全不受影响。

对系统:PoolingRunner 成为 V2 pooling 后处理的统一入口,后续新增其它 late-interaction 类型任务可复用该「组合 LateInteractionRunner」的模式;finish_requests/reset_encoder_cache 的生命周期钩子成为 pooling 状态管理的固定接点。

对团队:MRV2 pooling 四任务已完成三个(embed/classify、token_classify、token_embed),与 #52425 等同期 PR 共同推动 MRV2 收敛 pooling 场景;测试中 VLLM_USE_V2_MODEL_RUNNER 参数化 + 断言生效的做法,成为 MRV2 回归测试的标准模式。

生命周期钩子顺序敏感 缓存失效依赖 reset_encoder_cache add_request 签名变更 V2 对比覆盖仅部分后端

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论