Prhub

#43792 offload prompt_embeds decode in render_prompts_async to avoid blocking

原始 PR 作者 gagandhakrey 合并时间 2026-05-30 09:36 文件变更 1 提交数 3 评论 2 代码增减 +21 / -1

执行摘要

修复 render_prompts_async 假异步引起的事件循环阻塞

PR body 明确指出:render_prompts_async 是一个假异步包装器,同步调用 render_prompts,导致事件循环在 pybase64.b64decode + torch.load + to_dense + dtype-cast 等工作期间完全被阻塞。实测对于 2048×4096 bf16 张量,阻塞时间约 10–40 ms;seq_len=8192 时约 50–150 ms。在这段时间内,SSE 块不会流式传输、其他请求无法推进、并发聊天/补全的尾部延迟严重恶化。受影响路径为 /v1/completionspreprocess_cmplrender_cmpl_asyncrender_prompts_async → (sync) safe_load_prompt_embeds

此 PR 是一次精准的性能修复,值得合并。建议未来添加一个简单的集成测试来验证 render_prompts_async 不阻塞事件循环,可作为跟进项。

讨论亮点

此 PR 的讨论较少,但有一条有价值的意见值得关注:

  • @qthequartermasterman 指出:“This is a great find! This actually would explain some mystery slowness I've been having with /v1/completions under high concurrency loads.” 并提出需要回归测试以确保类似问题不再重演。不过当前 PR 未包含测试变更。

实现拆解

  1. 新增异步包装的辅助方法 _safe_load_prompt_embeds_async:在 BaseRenderer.__init__ 中,通过 make_async(safe_load_prompt_embeds, executor=self._executor) 创建一个异步版本,与其他阻塞操作(如 _clear_mm_cache_async)共用同一个线程池 executor。

  2. 新增 _render_prompt_async 协程方法:镜像原有的同步 render_prompt 方法,但对于 bytes 类型的输入,通过 await self._safe_load_prompt_embeds_async 卸载解码工作,而非直接同步调用。非 bytes 输入则直接返回。

  3. 重写 render_prompts_async 方法:原本直接调用同步的 self.render_prompts(prompts),现在改为先校验空列表(与同步版本保持一致),然后通过 asyncio.gather 并行执行每个 prompt 的 _render_prompt_async,实现多个 prompt_embeds 同时解码(受线程池大小限制)。

  4. 语义保持DictPrompt 的快速路径、VLLMValidationError 传播等行为均与原同步代码一致。

文件 模块 状态 重要度
vllm/renderers/base.py 渲染器 modified 6.85

关键符号

_render_prompt_async render_prompts_async

关键源码片段

vllm/renderers/base.py core-logic

唯一变更文件;修复 render_prompts_async 的假异步问题,新增 _render_prompt_async 协程和预创建的异步包装器。

# vllm/renderers/base.py — 关键变更片段class BaseRenderer(ABC):
    def __init__(self, config: "VllmConfig", tokenizer: _T | None):
        ...
        self._executor = ThreadPoolExecutor(max_workers=pool_workers)
        # 新增:预创建 safe_load_prompt_embeds 的异步版本,
        # 复用 _executor 线程池以避免事件循环阻塞
        self._safe_load_prompt_embeds_async = make_async(
            safe_load_prompt_embeds, executor=self._executor
        )
        ...
​
    async def _render_prompt_async(
        self,
        prompt: DictPrompt | bytes,
    ) -> DictPrompt:
        """异步版本的 render_prompt:将 bytes 解码卸载到线程池"""
        if isinstance(prompt, bytes):
            # await 让出控制权,解码在线程池中执行,不阻塞事件循环
            embeds = await self._safe_load_prompt_embeds_async(
                self.model_config, prompt
            )
            return EmbedsPrompt(prompt_embeds=embeds)
        # 非 bytes 类型直接返回(例如已经是 DictPrompt)
        return prompt
​
    async def render_prompts_async(
        self,
        prompts: Sequence[DictPrompt | bytes],
    ) -> list[DictPrompt]:
        """重写:改为真正的异步实现,支持多个 prompt_embeds 并行解码"""
        if len(prompts) == 0:
            raise ValueError("You must pass at least one prompt")
        # asyncio.gather 并发执行所有解码任务,
        # 线程池容量由 renderer_num_workers 控制
        return await asyncio.gather(
            *(self._render_prompt_async(prompt) for prompt in prompts)
        )

评论区精华

缺失回归测试 测试

@qthequartermasterman 评论指出需要回归测试防止假异步包装器再次引入。

结论:未在本 PR 中添加测试,但 reviewer 认为变更直观且 approved,建议后续跟进。 · 已解决

风险与影响

  • 回归风险:虽然变更仅涉及单个文件且逻辑直观,但 render_prompts_async 被多个端点调用(/v1/completions 等),若线程池 executor 未正确初始化或 make_async 包装有误,可能导致崩溃或死锁。不过由于现有测试通过且 reviewer 均 approved,风险较低。
  • 并发资源竞争:多个 prompt_embeds 解码共享同一个线程池,可能导致线程池过载,但通过 renderer_num_workers 可调参数已存在,风险可控。
  • 用户/系统影响:显著改善 /v1/completions 高并发场景下的尾部延迟和事件循环响应性,尤其是使用 prompt_embeds 时。SSE 流式传输不再因解码而暂停。
  • 团队影响:代码清晰且模式一致(复用已有线程池),维护成本低。
核心路径变更

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论