Prhub

#50931 [ModelRunner v2] Enable decoder token-wise pooling

原始 PR 作者 taneem-ibrahim 合并时间 2026-08-07 06:21 文件变更 8 提交数 12 评论 19 代码增减 +86 / -58

执行摘要

MRV2 放开 decoder 模型 token 级 pooling 任务

PR body 明确提到 Advances #41286,目标是在 Model Runner V2 上启用 decoder token-wise pooling。池化本身已支持 chunked accumulation,但 MRV2 对非 encoder 模型过滤了 token_embedtoken_classify;移除该过滤可解锁文本 decoder embedding、reward/process-reward 模型与 reranker。复现用例表明 main 上会抛出 ValueError: Model Runner V2 supports pooling tasks ['classify', 'embed'] ...

值得精读。虽然是 8 个文件的小改动,但它体现了两个可复用的设计决策:一是任务门控从保守过滤收敛为模型能力声明(get_supported_tasks 只依赖模型自身),二是推理引擎在异步调度下对张量所有权的处理(clone 条件精细到 chunk 与 async 开关)。MRV2 相关开发者应重点关注 AllPool.forward 的克隆策略。

讨论亮点

核心讨论来自维护者 njhill。他在 approve 时说明补了一个 commit:I pushed an additional commit with some small changes related to cloning the tensors。动机是 async scheduling 下下一个 step 可能覆盖 hidden states 输入 buffer,输出拷贝到 CPU 之前必须持有独立存储。原测试语义从 rejects/filters decoder token 任务改为 supports/keeps,也直接体现了门控放宽。此外,Claude Code 自动评审因 fork PR 被禁用,未产生额外技术评论;19 条 issue 评论均为 /ci 触发与 Buildkite 状态。

实现拆解

  1. 解锁任务门控:vllm/v1/worker/gpu/pool/pooling_runner.py 删除 _TOKEN_TASKS_get_enabled_tasksPoolingRunner.__init__ 改为直接以 _SUPPORTED_TASKS 校验模型选中的任务;get_supported_tasks 的签名从 (model, model_config) 简化为 (model),调用方 vllm/v1/worker/gpu/model_runner.py 同步更新。这样 token 级任务不再因 attn_type == encoder_only 被过滤,decoder 型 embedding、reward/PRM 与 reranker 均可进入 MRV2。
  2. 补齐输出所有权:vllm/model_executor/layers/pooler/tokwise/methods.pyAllPool 读取 scheduler_config.async_scheduling 得到 clone_finished。非 chunked 路径直接克隆输出;chunked 路径在缓存时按“未完成”或“async 且缓存为空”条件克隆,确保跨 step 保留或返回给 PoolerHead 的数据不依赖可能被复用的输入 buffer。
  3. 测试配套:test_splade_sparse_pooler.py 将 decoder token 任务断言从 rejects/filters 改写为 supports/keepstest_pooler_methods.py 扩展 _FakeSchedulerConfig 支持 async_scheduling 并新增所有权断言;test_all_pooling_plus_chunked_prefill.pytest_embed_models 改为 test_decoder_token_embed_model_runner_v2 并强制 VLLM_USE_V2_MODEL_RUNNER=1test_reward.pytest_jina_reranker_v3.py 在非 CPU 平台启用 MRV2 并断言 use_v2_model_runner
  4. 配置与部署:无新增配置项;行为由 VLLM_USE_V2_MODEL_RUNNER(仍为试验开关)与 async_scheduling 联动,CPU 平台 reward 测试保留 MRV1 路径。
文件 模块 状态 重要度
vllm/v1/worker/gpu/pool/pooling_runner.py 池化运行器 modified 7.38
vllm/model_executor/layers/pooler/tokwise/methods.py 分词池化 modified 6.62
vllm/v1/worker/gpu/model_runner.py 模型运行器 modified 5.18
tests/models/language/pooling/test_splade_sparse_pooler.py 稀疏池化 modified 6.93
tests/model_executor/layers/test_pooler_methods.py 池化方法 modified 6.82
tests/models/language/pooling/test_all_pooling_plus_chunked_prefill.py 池化集成 modified 5.44
tests/models/language/pooling/test_reward.py 奖励模型 modified 5.08
tests/models/language/pooling/test_jina_reranker_v3.py 重排序器 modified 4.72

关键符号

PoolingRunner.get_supported_tasks PoolingRunner._get_enabled_tasks AllPool.forward AllPool.__init__ ModelRunner.get_supported_tasks

关键源码片段

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

核心变更文件:删除 `_TOKEN_TASKS` 过滤与 `_get_enabled_tasks`,开放 decoder 模型的 token 级 pooling 任务,并简化 `get_supported_tasks` 签名。

# vllm/v1/worker/gpu/pool/pooling_runner.py
# MRV2 的 pooling 任务全集;`embed&token_classify` 会把定长 embedding
# 与逐 token 权重拼接,输出长度随 prompt 增长,因此归入 token 级任务。
_SUPPORTED_TASKS: frozenset[PoolingTask] = frozenset(
    {'embed', 'classify', 'token_embed', 'token_classify', 'embed&token_classify'}
)
​
​
class PoolingRunner:
    def __init__(self, model: nn.Module, vllm_config: VllmConfig):
        self.model = cast(VllmModelForPooling, model)
        self.model_config = vllm_config.model_config
        self.max_num_reqs = vllm_config.scheduler_config.max_num_seqs
        model_tasks = tuple(sorted(self.model.pooler.get_supported_tasks()))
        selected_task = self.model_config.get_pooling_task(model_tasks)
        # 不再按 encoder/decoder 过滤:decoder 模型的 token 级任务同样开放,
        # 只要任务属于 MRV2 支持全集即可通过校验。
        if selected_task not in _SUPPORTED_TASKS:
            hint = (
                'Set an explicitly supported task or VLLM_USE_V2_MODEL_RUNNER=0.'
                if _SUPPORTED_TASKS.intersection(model_tasks)
                else 'Set VLLM_USE_V2_MODEL_RUNNER=0 to use this model.'
            )
            raise ValueError(
                'Model Runner V2 supports pooling tasks '
                f'{sorted(_SUPPORTED_TASKS)}, but this model selects '
                f'{selected_task!r} from {list(model_tasks)}. {hint}'
            )
        self.supported_tasks = frozenset(self.get_supported_tasks(model))
        if not self.supported_tasks:
            raise ValueError(
                'Model Runner V2 supports pooling tasks '
                f'{sorted(_SUPPORTED_TASKS)}, but this model supports '
                f'{list(model_tasks)}. '
                'Set VLLM_USE_V2_MODEL_RUNNER=0 to use this model.'
            )
​
    @staticmethod
    def get_supported_tasks(model: nn.Module) -> list[PoolingTask]:
        if not is_pooling_model(model):
            return []
        # 直接求模型任务与 MRV2 全集之交,任务集合由模型自身能力决定。
        return sorted(model.pooler.get_supported_tasks() & _SUPPORTED_TASKS)
vllm/model_executor/layers/pooler/tokwise/methods.py data-contract

`AllPool` 新增 `clone_finished` 逻辑,解决 async scheduling 下 hidden states buffer 复用导致输出悬空的问题,是正确性关键。

# vllm/model_executor/layers/pooler/tokwise/methods.py
class AllPool(TokenPoolingMethod):
    def __init__(self):
        super().__init__()
        vllm_config = get_current_vllm_config()
        scheduler_config = vllm_config.scheduler_config
        self.enable_chunked_prefill = scheduler_config.enable_chunked_prefill
        # async scheduling 下,下一步可能复用 hidden states 输入 buffer,
        # 输出在被拷贝到 CPU 之前必须拥有独立存储,因此需要克隆。
        self.clone_finished = bool(scheduler_config.async_scheduling)
​
    def forward(self, hidden_states: torch.Tensor,
                pooling_metadata: PoolingMetadata) -> list[TokenPoolingMethodOutputItem]:
        pooling_cursor = pooling_metadata.get_pooling_cursor()
        # 用已在 CPU 上的 num_scheduled_tokens 做 split,避免 GPU 到 CPU 同步。
        hidden_states_lst = list(
            torch.split(hidden_states,
                        pooling_cursor.num_scheduled_tokens_cpu.tolist())
        )
​
        # 非 chunked 路径:async scheduling 时直接克隆,防止返回悬空视图。
        if not self.enable_chunked_prefill:
            if self.clone_finished:
                return [hs.clone() for hs in hidden_states_lst]
            return hidden_states_lst
​
        pooling_states = pooling_metadata.pooling_states
        finished_mask = pooling_cursor.is_finished().tolist()
​
        # chunked 路径:先把当前 chunk 缓存进 pooling_states。
        # 未完成的 chunk 需跨 step 保留,必须克隆;
        # 已完成的 chunk 在 async scheduling 且缓存为空时也要克隆,
        # 否则缓存的是输入 buffer 视图,buffer 复用后内容会被覆盖。
        for p, hs_chunk, finished in zip(
            pooling_states, hidden_states_lst, finished_mask
        ):
            needs_owned_storage = not finished or (
                self.clone_finished and not p.hidden_states_cache
            )
            p.hidden_states_cache.append(
                hs_chunk.clone() if needs_owned_storage else hs_chunk
            )
​
        # prefill 全部完成时,把缓存交给 PoolerHead,未完成保持 None。
        output_list: list[TokenPoolingMethodOutputItem] = []
        for p, finished in zip(pooling_states, finished_mask):
            if finished:
                hidden_states_cache = p.hidden_states_cache
                if len(hidden_states_cache) == 1:
                    output_list.append(hidden_states_cache[0])
                else:
                    output_list.append(torch.concat(hidden_states_cache, dim=0))
                p.clean()
            else:
                output_list.append(None)
        return output_list

评论区精华

async scheduling 下张量所有权 正确性

njhill 在 approve 时说明补提交了 clone 相关改动:async scheduling 时下一个 step 可能覆盖 hidden states buffer,输出拷贝到 CPU 前必须拥有独立存储。

结论:`AllPool` 根据 `scheduler_config.async_scheduling` 在需要时克隆已完成输出与跨 step 缓存 chunk。 · 已解决

移除 encoder-only 门控 设计

PR body 指出 pooling 已支持 chunked accumulation,MRV2 对非 encoder 模型过滤 `token_embed`/`token_classify` 属保守限制;测试从 rejects/filters 改写为 supports/keeps。

结论:删除 `_TOKEN_TASKS` 与 `_get_enabled_tasks`,token 级任务对所有模型开放。 · 已解决

CPU 平台 reward 测试保留 MRV1 测试

提交 `[CI] Keep CPU reward test on V1` 表明 CPU 平台继续使用 MRV1;`test_prm_models_with_golden_outputs` 按平台条件设置开关。

结论:非 CPU 平台启用 MRV2,CPU 保留 MRV1 路径。 · 已解决

fork PR 自动评审禁用 other

Claude Code 提示该 PR 来自 fork,自动评审被禁用,需维护者触发 `@claude review`。

结论:未触发 Claude review;njhill 直接人工 approve。 · 已解决

风险与影响

  • 内存放大:AllPool 在 async scheduling 下会对已完成输出或跨 step 缓存做 clone,token_embed/token_classify 输出长度随 prompt 增长,长 prompt 场景下显存与 CPU 拷贝开销可能显著上升。
  • 行为回归:移除 encoder_only 过滤后,MRV2 下任何 decoder pooling 模型都可能选择 token 级任务;若模型池化实现并未实际支持 chunked 累积或语义不同,会产生错误结果。
  • 接口兼容性:get_supported_tasks 移除了 model_config 参数,仓库内部调用已更新,但依赖旧签名的第三方扩展会编译失败。
  • 覆盖空白:测试集中于 GPU、Qwen3-Embedding、PRM 与 Jina reranker,CPU 仍走 MRV1,PP/TP、cudagraph 与更多 decoder embedding 模型组合未覆盖。
  • 用户侧:decoder 文本 embedding、reward/process-reward、reranker 类模型可在 VLLM_USE_V2_MODEL_RUNNER=1 下运行,统一到 MRV2 执行路径。
  • 系统侧:pooling 任务校验模型从“按注意力类型过滤”简化为“模型能力与 MRV2 全集求交”;async scheduling 下输出所有权语义更安全,避免悬空视图。
  • 团队侧:MRV2 对 pooling 模型的支持面扩大,向默认化目标前进一步;后续需补齐不同后端与模型族的回归。
MRV2 任务门控放宽 async scheduling 输出克隆开销 decoder token 输出内存放大 测试覆盖集中于 GPU

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论