Prhub

#47699 [Frontend] Overlap preprocessing and computation for pooling models offline inference

原始 PR 作者 noooop 合并时间 2026-07-16 22:00 文件变更 10 提交数 33 评论 26 代码增减 +673 / -150

执行摘要

pooling 离线推理新增多线程预处理与 tiling 重叠调度

PR body 指出 pooling 离线推理当前不采用多线程预处理,且所有输入预处理完成后 GPU 才开始工作;更糟的是「preprocessing time currently grows with input length, even though the data will be truncated to a small prompt size afterward」——典型 embedding 场景(如对几十万 token 的 wiki 页预处理后截断到 512 token)耗时极长。关联 issue #45733 报告 LLM.embed 在 0.19 后出现回归,#41390 报告 Llama-Nemotron embedding 离线 batch-32 相对 Transformers 慢约 3 倍。作者希望通过多线程 + tiling 重叠尽可能逼近 HF 路径的性能,思想源自 #28458 与 #41678 的讨论。

值得精读。重点看三点:① request_factory + render 的逐请求流式预处理抽象,如何把批处理拆成可并行单元交给线程池;② _run_tiling_engine 的窗口式调度(max_num_seqs * 2)与异常清理逻辑;③ 作者对 _render_and_run_requests 与 tiling 语义差异的分析,这解释了为什么需要做接口破坏性重构。同时可把 conversations 分支死代码、executor.map 无界提交作为后续跟踪项。

讨论亮点

DarkLight1337(issue 内追问):OfflineInferenceMixin._render_and_run_requests 本来就通过 generator 重叠预处理与计算,pooling 为什么不适用?

noooop:_render_and_run_requests 每次调用必须把所有 unfinished_request 跑完,不支持 tiling;而 run_tiling_engine 需要迭代 _render_and_add_requests 的全部输入(prompts、params、lora、priority),_render_and_run_requests 只迭代 prompts,因此必须整体重设计接口。

claude[bot]:指出异常路径 abort_request 的外部/内部 id 语义问题(测试断言 internal=True 与实现不符)、CrossEncoder 的 token_type_ids 压缩时序问题、render() conversations 分支 engine_input[0] 会 KeyError,以及 ThreadPoolExecutor.map 会同步提交全部 render 任务削弱有界 windows 设计。

noooop(对 map 问题):贴出 _BoundedPreprocessor(deque + Future 的滑动窗口)草稿,但认为「A simple BoundedPreprocessor implementation will slow things down, so I won't let this small optimization block the PR — I'll improve it later」,留待后续。

DarkLight1337(最终):「Ok let's get this merged then, thanks for the explanations」

实现拆解

  1. 离线接口流式化:在 vllm/entrypoints/pooling/base/io_processor.py 中把原来的 pre_process_offline 拆成 get_request_factory_offline(返回 request_factory 生成器工厂与请求数)和 render(单条请求渲染,返回 PoolingEngineInput)。生成器每次 yield 一条 RenderParams,天然适配线程池逐条消费,这是 tiling 能够工作的前提。
  2. 类型契约拆分:vllm/entrypoints/pooling/typing.py 把 OfflineInputsContext 拆为 OfflineEncodeInputsContext / OfflineScoringInputsContext / OfflinePluginInputsContext 三个具体上下文,并新增 RenderParams 系 TypedDict(EncodeCMPLRenderParams / EncodeChatRenderParams / ScoringRenderParams)与 PoolingEngineInput、RequestFactory、RequestGenerator 类型,使各任务的输入在类型层面分叉。
  3. tiling 引擎实现重叠执行:vllm/entrypoints/pooling/offline.py 新增 _run_tiling_engine,复用 renderer 的线程池 self._executor.map(io_processor.render, request_factory()) 做多线程预处理;主循环以 max_requests_in_core = max_num_seqs * 2 为窗口,用 next(it) 逐个补充请求进引擎,每次循环调用一次 llm_engine.step() 消费完成输出,异常时对已加入请求调用 abort_request 清理,最后按 request_id 排序还原输入顺序。
  4. 各子类适配与入口调整:scoring/io_processor.py(CrossEncoder、LateInteraction、JinaRanking)、embed/io_processor.py(JinaForRanking)、pooling/io_processor.py(plugin)分别实现自己的 get_request_factory_offline / render;vllm/entrypoints/llm.py 中取消 pooling runner 下 renderer_num_workers 的无效警告(pooling 离线已真正使用该线程池)。
  5. 测试与 CI 配套:新增 tests/entrypoints/pooling/basic/test_tiling_engine.py,覆盖基本路径、超多请求、PoolingParams 单/多/None 及 step 抛异常时的 abort 清理;tests/models/language/pooling/test_multi_vector_retrieval.py 将 dtype 从 half 改为 float(作者称 half 本地不过且暂无时间排查);.buildkite/test_areas/models_language.yaml 将 Extended Pooling 测试超时从 70 分钟提到 120 分钟并绑定 h200_35gb(近期 CI 超时)。
文件 模块 状态 重要度
vllm/entrypoints/pooling/scoring/io_processor.py 评分预处理 modified 8.78
vllm/entrypoints/pooling/base/io_processor.py 基类处理 modified 8.7
vllm/entrypoints/pooling/typing.py 类型契约 modified 7.99
vllm/entrypoints/pooling/offline.py 离线调度 modified 7.88
vllm/entrypoints/pooling/pooling/io_processor.py 插件接入 modified 7.0
vllm/entrypoints/pooling/embed/io_processor.py 嵌入适配 modified 5.83
vllm/entrypoints/llm.py 入口配置 modified 4.49
tests/entrypoints/pooling/basic/test_tiling_engine.py 调度测试 added 7.4
tests/models/language/pooling/test_multi_vector_retrieval.py 多向量测试 modified 4.05
.buildkite/test_areas/models_language.yaml CI 配置 modified 3.58

关键符号

get_request_factory_offline render _run_tiling_engine _lora_request_to_seq _priority_to_seq _preprocess_cmpl_offline encode score

关键源码片段

vllm/entrypoints/pooling/scoring/io_processor.py core-logic

CrossEncoder / LateInteraction / JinaRanking 等评分路径的核心改造:pre_process_offline 重构为 get_request_factory_offline + render,新增 token_type_ids 压缩与单条打分渲染逻辑,是最复杂的子类适配。

def render(
    self,
    render_params: EncodeCMPLRenderParams
    | EncodeChatRenderParams
    | ScoringRenderParams,
) -> PoolingEngineInput:
    # 仅接受 ScoringRenderParams,其余类型直接报错,保证类型安全
    if 'data_1' not in render_params:
        raise ValueError(
            f'Unsupported render_params type {render_params.__class__.__name__}'
        )
    render_params = cast(ScoringRenderParams, render_params)
​
    arrival_time = time.time()
    tok_params = render_params['tok_params']
    params = render_params['params']
    prompt_extras = render_params['prompt_extras']
​
    # 按 query / document 各自的 token 上限截断并拼接 score prompt
    max_tokens_per_query, max_tokens_per_doc = self._get_token_limits(
        pooling_params=params
    )
    _, engine_prompt = self.get_score_prompt(
        data_1=render_params['data_1'],
        data_2=render_params['data_2'],
        encode_kwargs=tok_params.get_encode_kwargs(),
        chat_template=render_params['chat_template'],
        max_tokens_per_query=max_tokens_per_query,
        max_tokens_per_doc=max_tokens_per_doc,
        chat_template_kwargs=prompt_extras.get('chat_template_kwargs')
        if prompt_extras else None,
    )
​
    # 必须先执行 post-tokenization(可能 pad / truncate)再压缩
    # token_type_ids,否则 query / document 边界会按旧序列计算
    tok_params.apply_post_tokenization(self.tokenizer, engine_prompt)
    if token_type_ids := engine_prompt.pop('token_type_ids', None):
        params = params.clone()
        compressed = compress_token_type_ids(token_type_ids)
        params.extra_kwargs = {'compressed_token_type_ids': compressed}
​
    engine_input = self.renderer.process_for_engine(engine_prompt, arrival_time)
​
    return PoolingEngineInput(
        prompts=engine_input,
        params=params,
        lora_requests=render_params['lora_requests'],
        priorities=render_params['priorities'],
    )
vllm/entrypoints/pooling/base/io_processor.py core-logic

PoolingIOProcessor 基类的接口契约变更:新增 get_request_factory_offline、render、_lora_request_to_seq、_priority_to_seq,删除共享的离线批处理入口,是所有 pooling 子类的基座。

def get_request_factory_offline(
    self, ctx: ALLOfflineInputsContext
) -> tuple[RequestFactory, int]:
    assert isinstance(ctx, OfflineEncodeInputsContext)
    prompts_seq = prompt_to_seq(ctx.prompts)
    num_requests = len(prompts_seq)
    pooling_task = ctx.pooling_task
​
    # 在进入并行渲染前,先把 prompt 解析成模型输入单元,并展开
    # pooling_params / lora_request / priority 到与请求数等长的序列
    parsed_prompts = [
        (prompt if isinstance(prompt, bytes)
         else parse_model_prompt(self.model_config, prompt))
        for prompt in prompts_seq
    ]
    tok_params = self.renderer.default_cmpl_tok_params.with_kwargs(
        **(ctx.tokenization_kwargs or {})
    )
    pooling_params: PoolingParams | Sequence[PoolingParams]
    if ctx.pooling_params is None:
        pooling_params = PoolingParams()
    else:
        pooling_params = ctx.pooling_params
    params_seq = self._params_to_seq(pooling_params, num_requests)
​
    # 校验 task:plugin 允许覆盖 pooling_task,其余任务类型必须一致
    for param in params_seq:
        if param.task is None:
            param.task = pooling_task
        elif pooling_task == 'plugin':
            pass # 插件用自己的 io_processor.parse_request 校验输入
        elif param.task != pooling_task:
            msg = f'You cannot overwrite {param.task=!r} with {pooling_task=!r}!'
            raise ValueError(msg)
​
    seq_lora_requests = self._lora_request_to_seq(ctx.lora_request, num_requests)
    seq_priority = self._priority_to_seq(ctx.priorities, num_requests)
​
    # 返回「每调用一次就产出单个请求渲染参数」的生成器工厂,
    # 使线程池可以逐条消费,支撑 tiling 流水线
    def request_factory() -> RequestGenerator:
        for i in range(num_requests):
            yield EncodeCMPLRenderParams(
                prompts=parsed_prompts[i],
                tok_params=tok_params,
                prompt_extras=None,
                skip_mm_cache=False,
                params=params_seq[i],
                lora_requests=seq_lora_requests[i],
                priorities=seq_priority[i],
            )
​
    return request_factory, num_requests
vllm/entrypoints/pooling/offline.py core-logic

tiling 引擎 _run_tiling_engine 的核心实现:线程池并行渲染 + 窗口化请求注入 + 每轮 step 消费 + 异常 abort,是重叠预处理与计算的关键所在。

def _run_tiling_engine(
    self,
    io_processor: PoolingIOProcessor,
    request_factory: RequestFactory,
    num_requests: int,
    use_tqdm: bool | Callable[..., tqdm] = True,
):
    # 核心窗口:保持 max_num_seqs * 2 个请求在引擎中即可让 GPU 充分饱和,
    # 其余请求停留在预处理侧,形成「边预处理、边计算」的流水线。
    max_requests_in_core = (
        self.llm_engine.vllm_config.scheduler_config.max_num_seqs * 2
    )
    num_requests_in_core = 0
    num_waited_requests = num_requests
​
    if use_tqdm:
        tqdm_func = use_tqdm if callable(use_tqdm) else tqdm
        pbar = tqdm_func(total=num_requests, desc='Processed prompts',
                         dynamic_ncols=True)
​
    outputs: list[PoolingRequestOutput] = []
    added_request_ids: set[str] = set()
​
    # 将 request_factory 产出的渲染参数逐条交给线程池预处理;
    # 注意 ThreadPoolExecutor.map 会先同步提交全部任务,真正的
    # 前瞻窗口约束由下方「按窗口补请求、按 step 消费输出」保证。
    it = self._executor.map(io_processor.render, request_factory())
​
    try:
        while num_waited_requests or self.llm_engine.has_unfinished_requests():
            requests = []
            # 只补足到窗口上限,避免一次性把整批输入塞进引擎
            for _ in range(max_requests_in_core - num_requests_in_core):
                if num_waited_requests == 0:
                    break
                try:
                    request = next(it)
                    requests.append(request)
                except StopIteration:
                    num_waited_requests = 0
                    break
                num_waited_requests -= 1
                num_requests_in_core += 1
​
            if requests:
                request_ids = self._render_and_add_requests(
                    prompts=[x['prompts'] for x in requests],
                    params=[x['params'] for x in requests],
                    lora_requests=[x['lora_requests'] for x in requests],
                    priorities=[x['priorities'] for x in requests],
                )
                for request_id in request_ids:
                    # 撤销 assign_request_id 追加的内部后缀,恢复外部 id 用于输出匹配
                    request_id = request_id.split('-', 1)[0]
                    added_request_ids.add(request_id)
​
            step_outputs = self.llm_engine.step()
            for output in step_outputs:
                assert isinstance(output, PoolingRequestOutput)
                assert output.finished # pooling 为单步任务,step 返回即结束
                outputs.append(output)
                added_request_ids.discard(output.request_id)
                num_requests_in_core -= 1
                if use_tqdm:
                    pbar.update(1)
    except Exception:
        # 出错时清空引擎内未完成请求,避免 KV cache 泄漏到下一次调用
        if added_request_ids:
            self.llm_engine.abort_request(list(added_request_ids))
        raise
    finally:
        if use_tqdm:
            pbar.close()
​
    # 引擎完成顺序与输入顺序不一致,按 request_id(纯数字)还原原始顺序
    return sorted(outputs, key=lambda x: int(x.request_id))

评论区精华

abort_request 使用外部 id 与 internal 参数语义 正确性

claude[bot] 指出异常路径中 added_request_ids 存的是剥掉内部后缀的外部 id,若按 internal=True 查询会全部 miss 成为静默 no-op;且新测试曾断言 internal=True,与实现不符。

结论:最终实现不传 internal(等价 False),对外部 id 是正确的;测试已改为仅断言 request_ids 为非空 list。 · 已解决

CrossEncoder 的 token_type_ids 压缩时序 正确性

claude[bot] 指出 render() 若在 apply_post_tokenization 之前压缩 token_type_ids,query/document 边界按未 pad/truncate 的旧序列计算,截断配置下打分结果会错。

结论:最新 head 已调整为先 apply_post_tokenization 再 pop 并压缩,与在线路径语义一致。 · 已解决

render 的 conversations 分支对 EngineInput 取 [0] 会 KeyError 正确性

claude[bot] 指出 conversations 分支解包后 engine_input 已是单个 EngineInput dict,最后的 engine_input[0] 会 KeyError;当前无路径产出 EncodeChatRenderParams,属死代码。

结论:未修复,合入时作为已知隐患;将来接线 chat 离线流时必须修正。 · unresolved

ThreadPoolExecutor.map 同步提交全部任务,削弱有界窗口 性能

claude[bot] 指出 stdlib map 会 drain 整个 request_factory() 并提交全部 render 任务,只有进引擎的请求被节流,与 PR body 描述的「bounded lookahead」不符;noooop 给出 _BoundedPreprocessor(deque + Future)草稿。

结论:作者认为简单实现反而变慢,决定后续再优化,不在本 PR 阻塞合入。 · unresolved

tiling 是否应下沉到通用 _render_and_run_requests 设计

DarkLight1337 质疑 _run_tiling_engine 并非 pooling 特有;noooop 解释 _render_and_run_requests 每次必须跑完整批请求、只迭代 prompts,而 tiling 需要迭代 render_and_add_requests 的全部输入,接口必须重设计,并计划后续推广到生成模型。

结论:本轮限定 pooling 实现,生成模型路径(含 beam_search)留待未来重构复用。 · 已解决

CI timeout / device 与 dtype 改动是否夹带 other

DarkLight1337 询问 .buildkite 超时与 device 绑定是否 intended、dtype half 改 float 的原因;noooop 回复 CI 近期超时、half 本地不过且暂无时间调查。

结论:合入时接受;dtype 改动保留但未深入验证 fp16 精度覆盖损失。 · 已解决

风险与影响

  1. 破坏性接口变更:pre_process_offline 被移除,所有自定义 IOProcessor 插件与下游代码需迁移到 get_request_factory_offline + render,PR body 也为此道歉。
  2. 异常路径中止一致性:_run_tiling_engine 靠 request_id.split('-', 1)[0] 还原外部 id,且 abort_request 不传 internal 参数(等价 internal=False),依赖 assign_request_id 内部 id 生成规则的隐式约定;若引擎 id 规则变化,异常时 abort 可能静默失效,未完成请求泄漏在 core 中占用 KV cache。
  3. 内存峰值风险:ThreadPoolExecutor.map 会先同步提交全部 render 任务,大输入批时所有预处理结果在内存中累积,与「窗口外等待」的设计意图不符(作者已确认并计划后续修复)。
  4. 死代码隐患:base/io_processor.py 的 render() conversations 分支对 EngineInput dict 执行 engine_input[0] 会在未来 chat 离线流接入时直接 KeyError(当前无代码产出 EncodeChatRenderParams)。
  5. 测试覆盖削弱:test_multi_vector_retrieval.py 从 half 改 float,丢失 fp16 精度一致性验证;CI timeout 放宽可能掩盖慢速回归。

对用户:pooling 离线推理(embed / classify / score / plugin)在长输入、多请求场景吞吐显著提升,13 万 token 输入约 8.5 倍加速,renderer_num_workers 参数在 pooling runner 下首次真正生效。对系统:仅在入口层新增 tiling 调度循环,在线 serve 路径完全不变,kernel / worker 无改动。对团队:PoolingIOProcessor 拆成 online(原流程)与 offline(流式化)两套语义,维护成本上升;作者已计划把 tiling 优化推广到生成模型与 beam_search 路径。整体影响面集中、可控,属于中高重要度的前端功能演进。

接口破坏性变更 异常路径中止一致性 内存峰值风险 死代码分支隐患 测试覆盖削弱

关联 Issue

#41390 [Performance]: Llama-Nemotron embedding is slower than Transformers for offline batch-32 pooling after compile-cache warmup
#45733 [Bug]: LLM.embed regression for text inputs after 0.19

完整报告

参与讨论