Prhub

#53464 [Pooling] Improve BGE-M3 sync pooling throughput by up to 3.13%

原始 PR 作者 taneem-ibrahim 合并时间 2026-08-24 20:33 文件变更 10 提交数 5 评论 11 代码增减 +107 / -67

执行摘要

MRV2 pooling 免除 GPU token ID 拷贝,BGE-M3 吞吐最高 +3.13%

PR body 明确指出:"BGE-M3's sparse embedding path uses SPLADE, which only needs prompt token IDs on CPU, so copying the full prompt-token-ID batch to GPU adds unnecessary serving overhead",即当前 MRV2 在 requires_token_ids=True 时会将完整 prompt token ID 批拷贝到 GPU,而对 BGE-M3/Jina 这类只在 CPU 上做 token 级后处理的池化任务而言属于纯开销。yewentao256 在 review 中补充了架构动机:"One design change from v1 to v2 is that we remove the consistent batch",即该优化也是 MRV2 去 consistent-batch 设计的一部分。

值得精读。重点关注三个设计决策:一是在 CPU 上完成 doc/query 索引计算、仅异步拷贝最终索引(async_tensor_h2d),这是减少 GPU-CPU 间无谓数据搬运的通用模式;二是 PoolingCursor 预计算 CPU 状态(partial_prefill、finished_mask)并随切片重算,避免每步 .tolist();三是 v1→v2 移除 consistent batch 的架构讨论,理解为什么 prompt_token_ids 不应作为默认 GPU 元数据存在。

讨论亮点

核心讨论围绕“是否值得为 GPU token IDs 保留单独路径”展开:yewentao256 在 vllm/pooling_params.py 的评论中提出能否删除 requires_token_ids_gpu 属性,因为 v1 到 v2 的设计方向是移除 consistent batch;随后又建议“在 CPU 上计算 query_indexes,只 H2D 拷贝最终的 indexes”,作者回复采纳并实现。noooop 在 metadata.py 上建议 partial_prefill 与 finished_mask 不应为 None 以简化比较,作者将其收紧为始终具体值并在切片时重算。另外 noooop 质疑 pin_memory 条件修改(PIN_MEMORY and device.type != "cpu")的必要性,作者解释是为了 CPU-only 单测便利并同意 revert,最终提交 "Restore pinned pooling cursor allocation" 恢复原状。

实现拆解

  1. 在 vllm/model_executor/models/jina.py 的 JinaForRankingPool 中新增 get_pooling_updates,声明 requires_token_ids=True;forward 改为通过 pooling_metadata.get_prompt_token_ids_cpu() 读取 CPU 端 token IDs,在 CPU 上完成 doc/query 分隔 token 的匹配与索引拼接,再通过 async_tensor_h2d 只将最终选中索引拷到 GPU 后做 projector 投影,同时移除 gpu_sync_allowed 包装。
  2. 在 vllm/v1/worker/gpu/pool/pooling_runner.py 的 _get_pooling_metadata 中删除 prompt_token_ids 的 GPU 副本创建,PoolingMetadata.prompt_token_ids 固定传 None;prompt_token_ids_cpu 的 pin_memory 改为仅在非 CPU 设备启用;_dummy_pooler_run_task 同步改为只创建 CPU 张量。pool() 中 finished_mask 改用缓存的 get_finished_mask()。
  3. 在 vllm/v1/worker/gpu_input_batch.py 中,add_request 不再把 pooling_params.requires_token_ids 写入 logits_processing_needs_token_ids(恒为 False),并更新注释明确“设备端 prompt tokens 仅用于 penalties 或显式请求 GPU token IDs 的池化方法”;get_pooling_metadata 同样将 prompt_token_ids 置 None。
  4. 在 vllm/v1/pool/metadata.py 中为 PoolingCursor 新增 partial_prefill 与 finished_mask 字段,build_pooling_cursor 用 numpy 一次性预计算,getitem 切片时重算,并提供 get_finished_mask();vllm/v1/worker/gpu_model_runner.py 的 _pool 与 pooling_runner.pool 均改用该方法,避免每步执行 .tolist() 与张量比较。
  5. 测试配套:新增 test_build_pooling_cursor_caches_cpu_status 与 test_pooling_params_update_combines_token_id_requirements(tests/model_executor/layers/test_pooler_methods.py),并更新 tests/v1/worker/test_gpu_input_batch.py 与 tests/models/language/pooling/test_splade_sparse_pooler.py 以适配新数据契约。
文件 模块 状态 重要度
vllm/model_executor/models/jina.py 模型实现 modified 7.3
vllm/v1/pool/metadata.py 池化元数据 modified 6.47
vllm/v1/worker/gpu/pool/pooling_runner.py 池化执行 modified 6.15
vllm/v1/worker/gpu_input_batch.py 输入批处理 modified 5.34
vllm/v1/worker/gpu_model_runner.py 模型执行器 modified 5.34
tests/model_executor/layers/test_pooler_methods.py 池化方法 modified 5.92

关键符号

JinaForRankingPool.get_pooling_updates JinaForRankingPool.forward PoolingCursor.get_finished_mask PoolingCursor.__getitem__ PoolingMetadata.build_pooling_cursor PoolingRunner._get_pooling_metadata PoolingRunner.pool GPUModelRunner._pool GPUInputBatch.get_pooling_metadata

关键源码片段

vllm/model_executor/models/jina.py data-contract

核心优化点:JinaForRankingPool 新增 get_pooling_updates 声明 requires_token_ids,forward 改为 CPU 端计算 doc/query 索引,仅 H2D 拷贝选中索引,移除 gpu_sync_allowed。

class JinaForRankingPool(StepPool):
    def __init__(self, projector: nn.Sequential):
        super().__init__()
        # doc/query 分隔 token id,用于在 prompt 中定位文档与查询边界
        self.doc_token_id = 151670
        self.query_token_id = 151671
        self.projector = projector
​
    def get_supported_tasks(self) -> set[PoolingTask]:
        return {"token_embed"}
​
    def get_pooling_updates(self, task: PoolingTask) -> PoolingParamsUpdate:
        # 声明本池化器需要 prompt token ids;MRV2 会据此在 CPU 上保留该元数据
        return PoolingParamsUpdate(requires_token_ids=True)
​
    def forward(
        self,
        hidden_states: torch.Tensor,
        pooling_metadata: PoolingMetadata,
    ) -> list[TokenPoolingMethodOutputItem]:
        pooled_data_lst = super().forward(hidden_states, pooling_metadata)
        # 读取 CPU 端 token ids,避免把完整 batch 拷到 GPU
        prompt_token_ids_cpu = pooling_metadata.get_prompt_token_ids_cpu()
​
        embeds_list = list[torch.Tensor | None]()
        for data, token_ids_cpu in zip(pooled_data_lst, prompt_token_ids_cpu):
            # 未完成的 chunked prefill 返回 None,直接透传
            if data is None:
                embeds_list.append(None)
                continue
​
            # 分隔 token 匹配全部在 CPU 上完成
            doc_match = torch.eq(token_ids_cpu, self.doc_token_id)
            docs_indexes_cpu = torch.where(doc_match)[0]
            query_indexes_cpu = torch.where(
                torch.eq(token_ids_cpu, self.query_token_id)
            )[0]
​
            # JinaForRanking 模型先拼接文档再拼接查询,保持该顺序
            indexes_cpu = torch.cat([docs_indexes_cpu, query_indexes_cpu])
            # 只把最终选中的索引异步拷到 GPU,随后在 hidden states 上投影
            indexes = async_tensor_h2d(
                indexes_cpu, device=data.device, dtype=torch.long
            )
            embeds = self.projector(data[indexes])
            embeds_list.append(embeds)
​
        return embeds_list
vllm/v1/pool/metadata.py core-logic

PoolingCursor 新增预计算的 partial_prefill 与 finished_mask,并提供 get_finished_mask(),消除 pooling 每步的设备比较与 .tolist() 开销。

@dataclass
class PoolingCursor:
    first_token_indices_gpu: torch.Tensor
    last_token_indices_gpu: torch.Tensor
    prompt_lens_cpu: torch.Tensor
    seq_lens_cpu: torch.Tensor
    num_scheduled_tokens_cpu: torch.Tensor
    # 预计算的 CPU 侧状态,避免每次调用都做设备同步或张量比较
    partial_prefill: bool
    finished_mask: list[bool]
​
    def __getitem__(self, indices: slice) -> "PoolingCursor":
        prompt_lens_cpu = self.prompt_lens_cpu[indices]
        seq_lens_cpu = self.seq_lens_cpu[indices]
        num_scheduled_tokens_cpu = self.num_scheduled_tokens_cpu[indices]
​
        # 切片时同步重算 CPU 状态,保证子 cursor 语义一致
        return PoolingCursor(
            first_token_indices_gpu=self.first_token_indices_gpu[indices],
            last_token_indices_gpu=self.last_token_indices_gpu[indices],
            prompt_lens_cpu=prompt_lens_cpu,
            seq_lens_cpu=seq_lens_cpu,
            num_scheduled_tokens_cpu=num_scheduled_tokens_cpu,
            partial_prefill=not torch.equal(prompt_lens_cpu, num_scheduled_tokens_cpu),
            finished_mask=torch.eq(prompt_lens_cpu, seq_lens_cpu).tolist(),
        )
​
    def is_partial_prefill(self) -> bool:
        return self.partial_prefill
​
    def is_finished(self) -> torch.Tensor:
        return self.prompt_lens_cpu == self.seq_lens_cpu
​
    def get_finished_mask(self) -> list[bool]:
        return self.finished_mask

评论区精华

PoolingParams.requires_token_ids_gpu 是否应保留 设计

yewentao256 指出 v1→v2 的设计方向是移除 consistent batch,询问能否删除 requires_token_ids_gpu 属性。作者回复已移除 MRV2 的 GPU token-id 需求路径。

结论:采纳:不再为 GPU token IDs 保留单独需求标志,统一由 CPU 侧索引计算替代。 · 已解决

doc/query 索引计算放在 CPU 还是 GPU 性能

yewentao256 建议在 CPU 上计算 query_indexes,只把最终 indexes 做 H2D 拷贝。作者采纳并实现为 async_tensor_h2d(indexes_cpu)。

结论:采纳:CPU 完成匹配与拼接,仅异步拷贝选中索引。 · 已解决

PoolingCursor 的 partial_prefill/finished_mask 是否允许 None 设计

noooop 质疑两个字段可为 None 的设计,建议始终具体值以简化比较。作者将其收紧为必填并在切片时重算。

结论:采纳:去除 None 分支,构建时预计算、切片时重算。 · 已解决

pin_memory 条件修改是否必要 question

noooop 询问 PIN_MEMORY and device.type != "cpu" 的改动原因,作者解释是为 CPU-only 单测便利并同意 revert;noooop 检索仓库后认为无先例,最终提交恢复原状。

结论:revert:保持原有 pin_memory 行为。 · 已解决

风险与影响

契约风险:PoolingMetadata.prompt_token_ids 现在恒为 None,任何仍依赖 model-device token IDs 的池化方法(非 CPU 索引计算类)会直接拿到 None 而报错;PR 通过 requires_token_ids / 移除 GPU 路径来约束,但 vllm/model_executor/layers/pooler/tokwise/methods.py 与 common.py 的小改动说明该契约仍在迁移期,后续新增池化方法容易踩坑。
回归风险:JinaForRankingPool.forward 移除 gpu_sync_allowed 后,CPU 索引计算与 GPU 投影之间的同步依赖 async_tensor_h2d 的异步语义,chunked prefill 未完成时 data 为 None 虽有保护,但若设备驱动或后端变更可能引入隐式同步差异,需保留数值精度测试。
性能风险:PR 提供的 benchmark 中 concurrency 32 时 p99 从 568.8 ms 升到 633.8 ms(约 +11%),PR 未解释原因,高并发下可能出现尾部延迟波动。
兼容性:CPU-only 路径下 prompt_token_ids_cpu 不再启用 pin_memory,该写法最终被 revert,实际 GPU 行为不变,但 PoolingCursor 构造签名新增两个必填字段,所有直接构造 PoolingCursor 的代码(包括测试)必须同步更新。

用户影响:启用 MRV2 的 BGE-M3 / Jina 类 embedding 与 ranking 服务在 sync 模式低并发下吞吐提升约 3%(concurrency 16 时 +3.13%,concurrency 32 时约 +1%);SPLADE 稀疏输出与 dense 输出数值误差均在 5e-4 容差内,行为保持一致。
系统影响:PoolingMetadata 的数据契约发生实质变化(prompt_token_ids 恒为 None),影响 pooling_runner、gpu_input_batch、gpu_model_runner 三个执行路径;PoolingCursor 的 CPU 状态缓存让 finished_mask 的计算从 per-step 变成 per-cursor,减少重复设备比较。
团队影响:为 MRV2 pooling 后续彻底删除 prompt_token_ids 字段铺路,并沉淀了“CPU 上算索引、只 H2D 结果”的优化模式;review 中确认了 MRV2 无 consistent batch 的设计方向。

池化元数据契约变更 核心执行路径修改 数值精度依赖验证 高并发 p99 抖动

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论