Skip to content

异步rl:pause/resume #1

Description

@floatlibai

异步rl:pause/resume

vLLM文档:https://docs.vllm.ai/en/stable/training/async_rl/

Overview

在标准 RL 训练循环中,生成(generation)和训练(training)是顺序执行的:策略模型先生成 rollout 样本,然后在这些样本上进行训练,如此往复循环。在生成阶段,训练加速器处于空闲状态,反之亦然。

one-off pipelining 方法将生成阶段和训练阶段拆分为两个并行的协程,使模型能够一边生成新样本,一边对先前生成的数据进行训练。这可以带来更高的 GPU 利用率和更大的训练吞吐量。

然而,这种重叠引入了一个复杂性:推理引擎必须在运行过程中更新权重,而此时可能仍有正在处理的请求。

Pause 与 Resume API

为了在推理引擎运行时安全地更新权重,vLLM 提供了 pause_generationresume_generation 方法。这让训练器能够协调出一个干净的窗口来进行权重同步,而不会丢失正在进行的任务。

pause_generation

await engine.pause_generation(mode="keep", clear_cache=True)

mode 参数控制如何处理正在进行的请求:

Mode Behavior
"abort" 立即中止所有正在进行的请求并返回部分结果(默认)
"wait" 等待所有正在进行的请求完成后再暂停
"keep" 冻结队列中的请求;调用 resume_generation 后它们会恢复执行

clear_cache 参数控制暂停后是否清空 KV 缓存和前缀缓存。

resume_generation

await engine.resume_generation()

暂停后恢复调度器。任何通过 mode="keep" 冻结的请求将继续生成。

HTTP 端点

使用 vLLM HTTP 服务器时,可以通过以下端点使用相同的功能:

  • POST /pause?mode=keep - 暂停生成
  • POST /resume - 恢复生成

注意:数据并行

当使用 vLLM 的内部负载均衡器(即 data_parallel_backend="ray")进行数据并行时,pause 和 resume 会在所有 DP rank 上自动处理——一次调用即可。当使用外部负载均衡器(即代理后的多个独立 vLLM 实例)时,必须在权重更新前后分别向每个引擎实例发送 pause 和 resume 请求。

典型异步 RL 流程

典型的带权重同步的异步 RL 循环如下:

关键洞察在于:使用 mode="keep" 暂停的请求,在暂停前产生的 token 来自权重,而在恢复后产生的 token 则来自权重。clear_cache 参数控制在暂停期间是否使 KV 缓存失效:

  • clear_cache=True 时,之前缓存的 key-value 条目将被丢弃,因此恢复后生成的所有 token 都将完全使用新权重计算。
  • clear_cache=False 时,现有的 KV 缓存条目将被保留,这意味着上下文中的某些 token 可能仍然反映旧权重的状态(即过期的 KV 缓存)。

Partial rollout 就是clear_cache=False吗?


源码实现分析

调用链路

HTTP API (api_router.py)
  → AsyncLLM (async_llm.py)
    → EngineCoreClient (core_client.py)  [跨进程 ZMQ 通信]
      → EngineCoreProc/DPEngineCoreProc (core.py)
        → Scheduler.set_pause_state() (scheduler.py)
        → Scheduler.schedule() / _process_input_queue() / idle callbacks

核心数据结构

PauseState 枚举 (vllm/v1/core/sched/interface.py:22-32)

class PauseState(enum.IntEnum):
    UNPAUSED = 0     # 正常运行
    PAUSED_NEW = 1   # 不调度新请求,但已有 running 请求继续执行
    PAUSED_ALL = 2   # 不调度任何请求

PauseMode 类型 (vllm/v1/engine/__init__.py:26)

PauseMode = Literal["abort", "wait", "keep"]

pause_generation

pause_generation(mode="keep", clear_cache=True) 逐层实现

第一层:AsyncLLM (vllm/v1/engine/async_llm.py:750-793)

async def pause_generation(self, *, mode="abort", clear_cache=True):
    if clear_cache:
        await self.renderer.clear_mm_cache_async()  # ① 清理多模态缓存
    await self.engine_core.pause_scheduler_async(
        mode=mode, clear_cache=clear_cache
    )  # ② 跨进程 RPC
    await asyncio.sleep(0.02)  # ③ 等待 in-flight 输出的最终返回

三步详解:

  1. 清理多模态缓存: 如果 clear_cache=True,清空 renderer 中缓存的 image/audio 等多模态编码特征
  2. 跨进程 RPC: 通过 ZMQ 将 ("pause_scheduler", mode, clear_cache) 发送到 EngineCore 进程
  3. 20ms 延迟: 确保 in-flight 请求的最终输出在 pause_generation() 返回前被 output_handler 取出并推送给调用方。这不是正确性要求,只是为了让调用方的视角更直观(看到所有输出后才返回)

第二层:EngineCoreClient (vllm/v1/engine/core_client.py:1130-1133)

async def pause_scheduler_async(self, mode, clear_cache):
    await self.call_utility_async("pause_scheduler", mode, clear_cache)

call_utility_async 将方法名和参数序列化为 EngineCoreRequestType.UTILITY 类型的消息,通过 ZMQ 发送到 EngineCore 进程。如果 engine core 返回了一个 Future,则等待该 Future 完成。

async def call_utility_async(self, method: str, *args) -> Any:
    return await self._call_utility_async(method, *args, engine=self.core_engine)

async def _call_utility_async(
    self, method: str, *args, engine: EngineIdentity
) -> Any:
    call_id = uuid.uuid1().int >> 64
    future = asyncio.get_running_loop().create_future()
    self.utility_results[call_id] = future
    message = (
        EngineCoreRequestType.UTILITY.value,
        *self.encoder.encode((self.client_index, call_id, method, args)),
    )
    await self._send_input_message(message, engine, args)
    self._ensure_output_queue_task()
    return await future

第三层:EngineCoreProc — 核心逻辑 (vllm/v1/engine/core.py:1665-1705)

def pause_scheduler(self, mode, clear_cache):
    if mode == "abort":
        # 立即终止所有请求,发送 ABORT 完成信号
        aborted_reqs = self.scheduler.finish_requests(
            None, RequestStatus.FINISHED_ABORTED
        )
        self._send_abort_outputs(aborted_reqs)

    # mode="keep" → PAUSED_ALL(冻结一切)
    # mode="abort"/"wait" → PAUSED_NEW(冻结新请求,running 继续)
    pause_state = PauseState.PAUSED_ALL if mode == "keep" else PauseState.PAUSED_NEW
    self.scheduler.set_pause_state(pause_state)

    if self._pause_complete():  # 引擎已空闲 → 同步返回
        if clear_cache:
            self._reset_caches()
        return None  # Future 已完成,call_utility_async 直接返回

    # 引擎还有工作在进行中 → 异步等待
    future = Future()
    self._idle_state_callbacks.append(
        partial(engine_idle_callback, future=future)
    )
    return future  # call_utility_async 会等待这个 Future

engine_idle_callback 的注册与触发

  run_busy_loop()
    │
    ├─ _process_input_queue()
    │    │
    │    ├─ has_work() → PAUSED_ALL 时返回 False
    │    │    │
    │    │    └─ 进入 while not has_work() 死循环
    │    │         │
    │    │         ├─ _notify_idle_state_callbacks()  ← engine_idle_callback 在这里被调用
    │    │         │    future.set_result(None)        ← pause 的 RPC 返回
    │    │         │
    │    │         └─ input_queue.get(block=True)      ← 阻塞等待新消息
    │    │              │
    │    │              └─ 收到 resume → _handle_client_request()
    │    │                   → resume_scheduler() → set_pause_state(UNPAUSED)
    │    │                   → has_work() 重新返回 True,退出内层循环
    │    │
    │    └─ 退出 _process_input_queue
    │
    └─ _process_engine_step()  ← 恢复正常的调度+推理

当引擎进入空闲循环 (_process_input_queuehas_work() == False) 时触发:

def _process_input_queue(self):
    while not self.has_work() and self.is_running():
        self._notify_idle_state_callbacks()  # ← 触发所有注册的回调
        # ... 等待新输入 ...

def _notify_idle_state_callbacks(self):
    while self._idle_state_callbacks:
        callback = self._idle_state_callbacks.pop()
        callback(self)  # 调用 engine_idle_callback

def engine_idle_callback(engine, future):
    if clear_cache:
        engine._reset_caches()  # 清空所有缓存
    future.set_result(None)     # Future 完成 → pause 调用返回

has_work() 的判断逻辑

def has_work(self):
    return (
        self.engines_running or
        self.scheduler.has_requests() or
        bool(self.batch_queue)
    )

PAUSED_ALL 时,scheduler 的 get_num_unfinished_requests() 返回 0,所以 has_requests() 返回 False。如果也没有 batch_queue 工作,has_work() 返回 False,引擎进入空闲循环,触发 idle callbacks。

第四层:Scheduler — 暂停状态如何影响调度

schedule() 方法中的 token_budget 控制 (vllm/v1/core/sched/scheduler.py:409-411):

if self._pause_state == PauseState.PAUSED_ALL:
    # 不调度任何请求
    token_budget = 0

get_num_unfinished_requests() 对暂停状态的响应 (scheduler.py:2136-2145):

def get_num_unfinished_requests(self):
    if self._pause_state == PauseState.PAUSED_ALL:
        return 0   # 对外宣称没有未完成的请求 → has_work() 返回 False
    if self._pause_state == PauseState.PAUSED_NEW:
        return len(self.running)  # 只算 running,不算 waiting
    return len(self.waiting) + len(self.skipped_waiting) - ... + len(self.running)

schedule() 中新请求的处理 (scheduler.py:629):

# 只有在 UNPAUSED 状态才会从 waiting 队列取新请求
if not preempted_reqs and self._pause_state == PauseState.UNPAUSED:
    step_skipped_waiting = create_request_queue(self.policy)
    while (self.waiting or self.skipped_waiting) and token_budget > 0:
        # ... 调度 waiting 中的请求 ...

这意味着在 PAUSED_NEWPAUSED_ALL 状态下,新添加的请求会被放入 waiting 队列,但不会被调度执行。

_reset_caches() (core.py:706-719)

def _reset_caches(self):
    self.reset_prefix_cache(
        reset_running_requests=True, reset_connector=True
    )
    self.reset_mm_cache()
    self.reset_encoder_cache()

清空三类缓存:

  • Prefix Cache: 前缀匹配缓存(最关键的 KV 缓存)
  • MM Cache: 多模态特征缓存
  • Encoder Cache: 编码器输出缓存

resume_generation

第一层:AsyncLLM (vllm/v1/engine/async_llm.py:795-797)

async def resume_generation(self):
    await self.engine_core.resume_scheduler_async()

第二层:EngineCoreClient (vllm/v1/engine/core_client.py:1135-1136)

async def resume_scheduler_async(self):
    await self.call_utility_async("resume_scheduler")

第三层:EngineCore — 同步返回 (vllm/v1/engine/core.py:753-755)

def resume_scheduler(self):
    self.scheduler.set_pause_state(PauseState.UNPAUSED)

只需将调度器状态切回 UNPAUSED。下一次 schedule() 调用时:

  • token_budget 恢复正常计算
  • waiting 队列中的请求重新被调度
  • 之前被 mode="keep" 冻结的请求继续执行

DPEngineCoreProc — Data Parallel 场景 (vllm/v1/engine/core.py:1852-1876)

def resume_scheduler(self):
    # 安全检查:确保 pause 已完成
    if self.pending_pause or (self.engines_running and self.ignore_start_dp_wave):
        raise RuntimeError(
            "resume_scheduler called while pause is still in flight..."
        )
    if self.engines_running:
        return  # 已经在运行

    super().resume_scheduler()      # 设为 UNPAUSED
    self.ignore_start_dp_wave = False

    # Barrier: 通过 all-reduce 等待所有 DP rank 都完成 resume
    has_global_unfinished = ParallelConfig.has_unfinished_dp(
        self.dp_group, self.scheduler.has_unfinished_requests()
    )
    if has_global_unfinished:
        self.engines_running = True  # 启动 step 循环

DP 场景的 resume 需要额外的同步:

  1. 确保 pending_pause 已清除、ignore_start_dp_wave 已重置
  2. 调用父类的 resume_scheduler() 将状态设为 UNPAUSED
  3. 通过 all-reduce barrier 等待所有 DP rank 都完成 resume
  4. 如果全局有未完成的请求,启动 step 循环

完整时序:mode="keep" 的 pause → resume 流程

  调用方                          AsyncLLM                   EngineCore                    Scheduler
    │                                │                           │                             │
    │  pause_generation(mode="keep")  │                           │                             │
    │───────────────────────────────>│                           │                             │
    │                                │  clear_mm_cache_async()   │                             │
    │                                │──────────────────────────>│                             │
    │                                │                           │                             │
    │                                │  pause_scheduler_async    │                             │
    │                                │  ("keep", clear_cache)    │                             │
    │                                │──────────────────────────>│                             │
    │                                │                           │  set_pause_state(PAUSED_ALL) │
    │                                │                           │────────────────────────────>│
    │                                │                           │                             │
    │                                │                           │  [schedule() 中 token_budget=0]│
    │                                │                           │  [get_num_unfinished 返回 0] │
    │                                │                           │                             │
    │                                │                           │  [_process_input_queue 空闲循环]│
    │                                │                           │──┐                          │
    │                                │                           │  │ _notify_idle_callbacks   │
    │                                │                           │<─┘                          │
    │                                │                           │  engine_idle_callback:      │
    │                                │                           │    _reset_caches()          │
    │                                │                           │    future.set_result(None)  │
    │                                │                           │                             │
    │                                │  <── RPC 返回 ───────────│                             │
    │                                │                           │                             │
    │                                │  await asyncio.sleep(0.02)│                             │
    │  <──── pause 完成 ─────────────│                           │                             │
    │                                │                           │                             │
    │  [权重更新期间]                  │                           │                             │
    │                                │                           │                             │
    │  resume_generation()           │                           │                             │
    │───────────────────────────────>│                           │                             │
    │                                │  resume_scheduler_async   │                             │
    │                                │──────────────────────────>│                             │
    │                                │                           │  set_pause_state(UNPAUSED)  │
    │                                │                           │────────────────────────────>│
    │                                │  <── RPC 返回 ────────────│                             │
    │  <──── resume 完成 ────────────│                           │                             │
    │                                │                           │                             │
    │  [下一个 generate() 调用]        │                           │  [schedule() 恢复正常]       │
    │                                │                           │  [waiting 中请求恢复执行]     │

mode 对比总结

方面 mode="abort" mode="wait" mode="keep"
调度器状态 PAUSED_NEW PAUSED_NEW PAUSED_ALL
正在运行的请求 立即终止,发送 ABORT 继续执行直到完成 冻结保留(不终止不执行)
新请求 排队但不调度 排队但不调度 排队但不调度
KV Cache 空闲后由 callback 清空 空闲后由 callback 清空 空闲后由 callback 清空
pause 返回时机 等 aborted 输出发送完毕 等所有 running 请求完成 等引擎进入空闲循环
resume 后 被 abort 的请求不可恢复 所有请求已完成 冻结的请求继续执行
适用场景 快速权重切换,丢弃旧结果 优雅暂停,保留所有结果 异步 RL 训练,保留请求上下文

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions