Prhub

#50408 [Renderer] Warm up the renderer properly.

原始 PR 作者 noooop 合并时间 2026-07-31 19:46 文件变更 6 提交数 8 评论 20 代码增减 +50 / -46

执行摘要

预热 renderer,消除多模态请求 22 秒首延迟

PR body 中给出明确数据:get_hf_processor 首次运行需要约 22 秒,主分支上第一个多模态请求耗时 22.13 秒,而第二个请求仅 0.147 秒;本 PR 后分别为 0.19 秒和 0.144 秒。作者的目标是 'Warm up the renderer properly',把一次性初始化开销搬到启动阶段,避免第一个用户请求承担冷启动延迟。

值得精读。该 PR 展示了一个典型的冷启动延迟优化:先定位一次性初始化开销,再将其从首个请求迁移到启动阶段,并通过统一入口覆盖所有调用路径。过程中对后台预热与 CI 挂起的排查、以及 set_default_torch_num_threads(1) 防挂起 workaround 都是可借鉴的实战经验。

讨论亮点

为什么已有 _warmup_mm_processor 还不够

DarkLight1337 首先质疑:"Why do we need this when BaseRenderer._warmup_mm_processor already exists?" 作者调查后确认,_warmup_mm_processor 只执行 processor.apply,没有触发 get_hf_processor() 的初始化缓存,因此约 22 秒的一次性开销仍被留在首个请求。解决方式是显式调用 processor.info.get_hf_processor()

预热代码位置的权衡

作者最初把 warmup 放在 OpenAIServingChat,讨论后决定上移到 OnlineRenderer:"I moved the warm-up code from OpenAIServingChat to OnlineRenderer, so all entrypoints should be covered.",最终被接受。

后台预热导致 CI 超时

作者尝试 warmup_in_background 后台线程预热,但 CI 上 test_moss_audio 出现 25 分钟超时挂起("the test hang"),即使不调用 warmup_future.result() 也超时。最终放弃后台方案,DarkLight1337 给出关键建议:"Try set_default_torch_num_threads, it is known to prevent MM processor hangs",被采纳到最终实现。

实现拆解

1. 预热路径核心改造(vllm/renderers/base.py)

  • _warmup_mm_processor 中显式调用 processor.info.get_hf_processor(),真正初始化 HF processor 并写入缓存,解决此前只 processor.apply 不初始化的问题。
  • warmup 方法的 chat 模板预热、多模态处理器预热、只读处理器预热整体移入 with set_default_torch_num_threads(1) 上下文,规避 MM processor 初始化时的偶发挂起。

2. 预热入口统一(vllm/renderers/online_renderer.py)

  • 新增 OnlineRenderer.warmup(),把原来 OpenAIServingChat.warmup 的逻辑上移,构造 ChatParams(chat_template、content_format、default kwargs)后委托给底层 BaseRenderer.warmup,使 generate、chat、responses 等所有在线入口共享同一预热逻辑。

3. 调用点接线(入口层)

  • vllm/entrypoints/openai/api_server.py:在 init_app_stateinit_render_app_state 构建 state.online_renderer 后立即调用 warmup()
  • vllm/entrypoints/llm.py:离线 LLM.__init__load_chat_template 之后调用 self.renderer.warmup(ChatParams(chat_template=self.chat_template))
  • vllm/entrypoints/generate/api_router.py:删除 init_generate_state 中延迟到路由初始化阶段的 openai_serving_chat.warmup() 调用。
  • vllm/entrypoints/openai/chat_completion/serving.py:删除 OpenAIServingChat.warmup() 方法及不再需要的 ChatParams 导入。

4. 方案演进与回退

  • 曾尝试在 LLMEngine.__init__ 中通过 warmup_in_background 后台线程预热,但 CI 多模态测试(test_moss_audio)出现 25 分钟超时挂起,最终放弃后台方案,保留同步 warmup,并采纳 set_default_torch_num_threads(1) 防挂起建议。
  • 本次没有新增直接对应的单测文件,回归保障依赖现有 CI 多模态测试。
文件 模块 状态 重要度
vllm/renderers/base.py 渲染器基类 modified 7.3
vllm/renderers/online_renderer.py 在线渲染器 modified 6.62
vllm/entrypoints/openai/chat_completion/serving.py 服务层 modified 6.31
vllm/entrypoints/llm.py 离线入口 modified 4.99
vllm/entrypoints/openai/api_server.py 服务入口 modified 4.93
vllm/entrypoints/generate/api_router.py 请求路由 modified 4.7

关键符号

BaseRenderer.warmup BaseRenderer._warmup_mm_processor OnlineRenderer.warmup LLM.__init__

关键源码片段

vllm/renderers/base.py core-logic

核心改动所在:`_warmup_mm_processor` 显式调用 `get_hf_processor()` 初始化缓存,`warmup` 用 `set_default_torch_num_threads(1)` 包裹防挂起,并重构了 chat 模板 / 多模态 / 只读预热控制流。

def warmup(self, chat_params: ChatParams) -> None:
    """
    Warm up this renderer to avoid first-request latency.    For chat requests:
    - Jinja2 template compilation
    """
    from vllm.entrypoints.chat_utils import ChatTemplateResolutionError
​
    # 将整个预热过程限制在单线程内执行,避免 HF processor
    # 初始化时的偶发死锁(MM processor hangs)。
    with set_default_torch_num_threads(1):
        try:
            logger.debug("Warming up chat template processing...")
            start_time = time.perf_counter()
​
            self.render_chat([[{"role": "user", "content": "warmup"}]], chat_params)
​
            elapsed = time.perf_counter() - start_time
            logger.debug("Chat template warmup completed in %.3fs", elapsed)
        except ChatTemplateResolutionError:
            logger.debug("This model does not support chat template.")
        except Exception:
            logger.warning("Chat template warmup failed", exc_info=True)
​
        if self.mm_processor:
            try:
                logger.debug("Warming up multi-modal processing...")
                self._warmup_mm_processor(
                    self.mm_processor,
                    log_prefix="Multi-modal",
                )
            except Exception:
                logger.warning("Multi-modal warmup failed")
            finally:
                self.clear_mm_cache()
​
        if self._readonly_mm_processor is not None:
            try:
                logger.debug("Warming up readonly multi-modal processing...")
                self._warmup_mm_processor(
                    self._readonly_mm_processor,
                    log_prefix="Readonly multi-modal",
                )
            except Exception:
                logger.warning("Readonly multi-modal warmup failed")
            finally:
                self._clear_processor_cache(self._readonly_mm_processor)
def _warmup_mm_processor(
    self,
    processor: "BaseMultiModalProcessor",
    *,
    log_prefix: str,
) -> None:
    from vllm.multimodal.processing import TimingContext
​
    model_config = self.model_config
    mm_config = model_config.get_multimodal_config()
    mm_limits = {k: v for k, v in processor.info.allowed_mm_limits.items() if v > 0}
​
    start_time = time.perf_counter()
​
    # 关键:显式触发 HF processor 的初始化并写入缓存。
    # 此前这里只调用 processor.apply(),而 get_hf_processor()
    # 约 22 秒的一次性初始化开销被推迟到了首个真实请求。
    processor.info.get_hf_processor()
​
    processor_inputs = processor.dummy_inputs.get_dummy_processor_inputs(
        seq_len=model_config.max_model_len,
        mm_counts=dict.fromkeys(mm_limits, 1),
        mm_options=mm_config.limit_per_prompt,
    )
    _ = processor.apply(processor_inputs, timing_ctx=TimingContext(enabled=False))
​
    elapsed = time.perf_counter() - start_time
    logger.info("%s warmup completed in %.3fs", log_prefix, elapsed)

评论区精华

已有 _warmup_mm_processor 为何不够 question

DarkLight1337 质疑:"Why do we need this when `BaseRenderer._warmup_mm_processor` already exists?" 作者调查后确认现有预热只执行 `processor.apply`,没有触发 `get_hf_processor()` 的初始化。

结论:在 `_warmup_mm_processor` 中显式调用 `processor.info.get_hf_processor()` 完成缓存初始化。 · 已解决

预热代码位置:OpenAIServingChat → OnlineRenderer 设计

作者最初将 warmup 放在 `OpenAIServingChat.warmup`,讨论后认为应上移到 `OnlineRenderer` 以覆盖所有在线入口:"I moved the warm-up code from OpenAIServingChat to OnlineRenderer, so all entrypoints should be covered."。

结论:接受重构,删除 `OpenAIServingChat.warmup`,统一由 `OnlineRenderer.warmup` 承担。 · 已解决

后台预热导致 CI 多模态测试超时 性能

作者尝试 `warmup_in_background` 后台线程预热,CI 上 test_moss_audio 出现 25 分钟超时挂起,DarkLight1337 询问是挂起还是变慢,作者确认 "the test hang" 并给出了 Buildkite 超时日志。

结论:放弃后台预热方案,改用同步 warmup,配合 `set_default_torch_num_threads(1)` 防止 MM processor 挂起。 · 已解决

set_default_torch_num_threads 防挂起建议 设计

DarkLight1337 给出关键建议:"Try `set_default_torch_num_threads`, it is known to prevent MM processor hangs"。作者采纳并应用于 `BaseRenderer.warmup`。

结论:预热期间通过 `with set_default_torch_num_threads(1)` 限制单线程执行,规避处理器初始化死锁。 · 已解决

风险与影响

  • 启动时间增加:每次启动都会执行一次 chat 模板渲染和 dummy 多模态预处理,耗时取决于模型与模态数量,但换来首个请求延迟从 22 秒级降到亚秒级。
  • torch 线程数临时受限:set_default_torch_num_threads(1) 在 warmup 期间限制全局 torch 线程数,若 warmup 与其他初始化并发执行可能互相影响;不过该上下文仅在预热阶段生效。
  • 全入口覆盖带来的副作用:离线 LLM 即使只做文本生成或 pooling 也会触发 renderer.warmup,虽然异常被捕获,但增加了不必要的启动路径。
  • CI 曾出现多模态测试超时(test_moss_audio),最终同步方案通过,但后续新增多模态模型或入口时需警惕同类挂起回归。
  • 缺少直接针对 warmup 行为(如 get_hf_processor 被调用的断言)的单元测试,回归保障依赖 CI。
  • 用户侧:多模态聊天/生成的首个请求响应速度大幅提升(22 秒 → 0.19 秒),对在线推理服务尤其明显。
  • 系统侧:启动阶段新增一次模拟请求开销,CPU 占用约数百毫秒到数秒,整体可接受。
  • 团队侧:预热逻辑从 OpenAIServingChat 收敛到 OnlineRenderer,消除了重复代码;后续新增入口必须显式调用 warmup(),否则会回到冷启动延迟。
启动路径新增 warmup 调用 torch 线程数临时受限 CI 曾现多模态超时 无新增测试覆盖

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论