01

_schedule_default() 入口 — 策略选择和调用链

_schedule_default() 是 vLLM 在非 Chunked Prefill 模式下的默认调度实现。 上层的 _schedule() 方法根据配置选择调度路径,当 scheduler_config.chunked_prefill_enabled == False 时进入此函数:

scheduler.py — _schedule() 策略分叉入口 L630-636
def _schedule(self) -> SchedulerOutputs:
    """Schedule sequence groups.

    The current policy is designed to optimize the throughput. First,
    it batches as many prefill requests as possible. And it schedules
    decodes. If there's a pressure on GPU memory, preemption or
    swapping is performed.
    """
    if self.scheduler_config.chunked_prefill_enabled:
        return self._schedule_chunked_prefill()
    else:
        return self._schedule_default()

进入 _schedule_default() 后,函数的第一件事是创建本次调度的预算对象, 然后依次调用三个子调度函数,最终将结果汇总为一个 SchedulerOutputs 返回:

scheduler.py — _schedule_default() 主体骨架 L637-720
def _schedule_default(self) -> SchedulerOutputs:
    """Schedule queues to generate sequences.

    Simple, but effective policy. First, the running queue is
    scheduled (with possible preemption), then swapped queue, finally
    the waiting queue.
    """
    # Include running requests to the budget.
    budget = SchedulingBudget(
        token_budget=self.scheduler_config.max_num_batched_tokens,
        max_num_seqs=self.scheduler_config.max_num_seqs,
    )
    # Make sure we include num_seqs from scheduled sequence groups.
    for seq_group in self.running:
        budget.add_num_seqs(seq_group.request_id,
                            seq_group.get_max_num_running_seqs())

    curr_loras: Optional[Set[int]] = set(
        ..) if self.lora_enabled else None

    # 三阶段调度(顺序调用)
    running_scheduled = self._schedule_running(budget, curr_loras)
    swapped_in = self._schedule_swapped(budget, curr_loras)
    prefills = self._schedule_prefills(budget, curr_loras)

    # 断言:Swap In 和 Swap Out 不能同时发生
    assert (not running_scheduled.blocks_to_swap_out
            or not swapped_in.blocks_to_swap_in)

    # 汇总三阶段结果
    scheduled_seq_groups = (
        [ScheduledSequenceGroup(seq_group=sg, token_chunk_size=1)
         for sg in running_scheduled.decode_seq_groups]
      + [ScheduledSequenceGroup(seq_group=sg,
                                token_chunk_size=sg.get_seqs(SequenceStatus.RUNNING)[0].data.get_num_uncomputed_tokens())
         for sg in running_scheduled.prefill_seq_groups]
      + [ScheduledSequenceGroup(seq_group=sg, token_chunk_size=1)
         for sg in swapped_in.decode_seq_groups]
      + [ScheduledSequenceGroup(seq_group=sg, token_chunk_size=sg....)
         for sg in prefills.seq_groups]
    )

    blocks_to_swap_in = dict(swapped_in.blocks_to_swap_in)
    blocks_to_swap_out = dict(running_scheduled.blocks_to_swap_out)
    blocks_to_copy = merge_dicts(running_scheduled.blocks_to_copy,
                                 swapped_in.blocks_to_copy)

    return SchedulerOutputs(
        scheduled_seq_groups=scheduled_seq_groups,
        num_prefill_groups=len(prefills.seq_groups),
        num_batched_tokens=budget.num_batched_tokens,
        blocks_to_swap_in=blocks_to_swap_in,
        blocks_to_swap_out=blocks_to_swap_out,
        blocks_to_copy=blocks_to_copy,
        ignored_seq_groups=prefills.ignored_seq_groups,
        num_lookahead_slots=running_scheduled.num_lookahead_slots,
        running_queue_size=len(self.running),
    )

预算预填充(Budget Pre-population)

注意函数开头有一个关键细节:在创建 SchedulingBudget 之后, 立即将 所有 running 队列中的请求num_seqs 预先计入预算:

为什么要提前把 running 请求计入预算?

这是 Default 路径与 Chunked Prefill 路径最重要的区别之一。 Default 路径假设 running 队列中的所有请求必然会继续 decode(不会被完全驱逐), 因此在预算中预留好它们的 num_seqs 名额,防止后续的 swapped/waiting 请求占据过多序列槽位。

这也是 SchedulingBudget 中 request_id 去重机制存在的原因: running 请求在这里已被计数,在后续 _schedule_running() 中不会重复计数。

调用链总结

整个调用链层次清晰:

调用链

LLMEngine.step()Scheduler.schedule()Scheduler._schedule()Scheduler._schedule_default()_schedule_running() + _schedule_swapped() + _schedule_prefills()SchedulerOutputs

02

_schedule_running() — Running 队列处理:decode 令牌预算、抢占判断

_schedule_running() 是三阶段中最复杂的部分,它负责处理所有 当前正在 GPU 上执行的请求(Decode 阶段)。 核心逻辑是:先尝试为每个 running 请求分配下一步 Decode 所需的 KV Cache 块; 若显存不足,则对队列尾部的请求执行抢占(Preemption)。

scheduler.py — _schedule_running() 核心逻辑 L718-800
def _schedule_running(
    self,
    budget: SchedulingBudget,
    curr_loras: Optional[Set[int]],
    enable_chunking: bool = False,
) -> SchedulerRunningOutputs:
    """Schedule sequence groups that are running.

    Running queue should include decode and chunked prefill requests.

    Args:
        budget: The scheduling budget. The argument is in-out, so it
            will be updated based on the scheduled sequence groups.
        curr_loras: Currently batched lora request ids. The argument is
            in-out, so it will be updated based on the scheduled
            sequence groups.
        enable_chunking: If True, seq_group with prefills may be
            chunked.

    Returns:
        SchedulerRunningOutputs.
    """
    ret = SchedulerRunningOutputs.create_empty()
    # Tracks requests that failed to schedule (and should be preempted).
    victim_queue = deque()

    running_queue = self.running  # NOTE: in-place modification below

    while running_queue:
        seq_group = running_queue[0]
        # The total number of sequences in the RUNNING state.
        num_running_seqs = seq_group.get_max_num_running_seqs()
        # How many tokens need to be scheduled this step?
        # Decode = 1 token per seq; Chunked Prefill = chunk size.
        num_new_tokens = self._get_num_new_tokens(
            seq_group, SequenceStatus.RUNNING, enable_chunking, budget)

        if not enable_chunking:
            num_new_tokens = num_running_seqs

        # Try to allocate blocks for the next decode step.
        if not self._can_append_slots(seq_group, num_new_tokens):
            # 分配失败 → 需要抢占
            # 从 running 队列尾部取受害者(最近进入 running 的请求)
            budget_exhausted = False
            while running_queue:
                victim = running_queue[-1]
                if victim == seq_group:
                    # 当前请求自身无法分配且没有可驱逐的受害者
                    budget_exhausted = True
                    break
                victim_queue.appendleft(victim)
                running_queue.pop()   # 从 running 队列移除受害者

            if budget_exhausted:
                break

            # 对受害者执行抢占
            for victim in victim_queue:
                preempted_mode = self._preempt(victim, ...)
                if preempted_mode == PreemptionMode.RECOMPUTE:
                    ret.preempted.append(victim)
                else:
                    ret.swapped_out.append(victim)

            # 重新尝试分配(受害者释放了足够显存)
            if not self._can_append_slots(seq_group, num_new_tokens):
                break  # 依然不足,放弃本请求

        # 分配成功:执行 append_slots
        self._append_slots(seq_group, ret.blocks_to_copy)
        is_prefill = seq_group.is_prefill()
        if is_prefill:
            ret.prefill_seq_groups.append(seq_group)
        else:
            ret.decode_seq_groups.append(seq_group)
        budget.add_num_batched_tokens(seq_group.request_id, num_new_tokens)
        # num_seqs 已在 _schedule_default 预填充,此处不再重复
        running_queue.popleft()

    return ret

Decode Token 预算

在 Default 路径(enable_chunking=False)下,每个 running 请求每次 step 消耗的 token 数固定为 num_running_seqs(通常为 1,Beam Search 场景下为 beam width)。 这个值极小,因此 running 请求几乎不会触发 token 预算超限, 真正的瓶颈是 KV Cache 块分配_can_append_slots)。

_can_append_slots — 能否追加新的 KV Cache 块?

该方法委托给 block_manager.can_append_slots(seq_group, num_new_tokens), 检查 GPU 上是否有足够的空闲块来存放下一步 Decode 产生的新 KV。 如果当前序列的最后一个块已满,就需要分配新块;如果没有空闲块,则返回 False。

抢占判断:从队尾选受害者

_can_append_slots 返回 False 时,调度器不会立即放弃当前请求, 而是尝试从 running 队列的尾部驱逐请求(即最近才进入 running 的请求):

为什么从队尾抢占?

running 队列按 FCFS 顺序排列,队首是等待最久的请求(对系统贡献最大), 队尾是最近刚进入 running 的新请求。牺牲队尾请求, 能以最小的公平性代价换取队首请求的持续执行, 避免长期运行的请求因短暂显存压力而功亏一篑。

抢占循环会持续弹出受害者并执行 _preempt(),直到当前请求能够分配到槽位为止。 被抢占的请求根据 PreemptionMode 进入 preempted(Recompute) 或 swapped_out(Swap Out)列表,最终由上层将其移至对应的队列。

scheduler.py — _preempt() 抢占执行 L552-590
def _preempt(
    self,
    seq_group: SequenceGroup,
    blocks_to_swap_out: Dict[int, int],
    preemption_mode: Optional[PreemptionMode] = None,
) -> PreemptionMode:
    # Determine the preemption mode.
    if preemption_mode is None:
        if seq_group.get_max_num_running_seqs() == 1:
            preemption_mode = PreemptionMode.RECOMPUTE
        else:
            preemption_mode = PreemptionMode.SWAP

    if preemption_mode == PreemptionMode.RECOMPUTE:
        self._preempt_by_recompute(seq_group)
    elif preemption_mode == PreemptionMode.SWAP:
        self._preempt_by_swap(seq_group, blocks_to_swap_out)
    else:
        raise AssertionError("Unknown preemption mode.")

    return preemption_mode
抢占模式的自动选择逻辑

preemption_mode 未显式指定时,调度器根据序列数量自动选择:

  • 单序列请求(num_running_seqs == 1):选择 RECOMPUTE。 单序列请求没有 Beam Search 分叉,Recompute 开销相对可控,且不消耗 CPU 内存。
  • 多序列请求(Beam Search 等):选择 SWAP。 多序列的 KV Cache 量大且共享结构复杂,Recompute 代价过高; Swap Out 虽然消耗 PCIe 带宽,但能保留已计算的中间状态。
03

_schedule_swapped() — Swapped 队列恢复:swap-in 条件、预算检查

_schedule_swapped() 负责将之前因显存压力被换出到 CPU 的请求换回 GPU, 使其恢复 Decode 执行。这是三阶段中第二优先级的任务。

scheduler.py — _schedule_swapped() 核心逻辑 L800-860
def _schedule_swapped(
    self,
    budget: SchedulingBudget,
    curr_loras: Optional[Set[int]],
    enable_chunking: bool = False,
) -> SchedulerSwappedInOutputs:
    """Schedule sequence groups that are swapped out.

    It schedules swapped requests as long as it fits `budget`.
    """
    # Swapped-in requests are ordered by their arrival time
    # (same as waiting queue ordering).
    swapped_queue = self.policy.sort_by_priority(
        time.time(), self.swapped)

    ret = SchedulerSwappedInOutputs.create_empty()
    leftover_swapped: Deque[SequenceGroup] = deque()

    while swapped_queue:
        seq_group = swapped_queue[0]
        # How many tokens to schedule?
        num_new_tokens = self._get_num_new_tokens(
            seq_group, SequenceStatus.SWAPPED, enable_chunking, budget)

        # Budget checks (both token and seq dimension)
        if not budget.can_schedule(
            num_new_tokens=num_new_tokens,
            num_new_seqs=seq_group.get_max_num_running_seqs()
        ):
            break

        # LoRA constraint check
        if self.lora_enabled and not self._can_lora_fetch(seq_group, curr_loras):
            leftover_swapped.append(seq_group)
            swapped_queue.popleft()
            continue

        # Check if we can swap in (enough GPU blocks for the KV cache)
        if not self.block_manager.can_swap_in(seq_group,
                                               self._get_num_lookahead_slots()):
            # Infeasible: not enough GPU free blocks
            ret.infeasible_seq_groups.append(seq_group)
            swapped_queue.popleft()
            continue

        # Execute swap-in
        alloc_status = self.block_manager.swap_in(
            seq_group, ret.blocks_to_swap_in)
        assert alloc_status == AllocStatus.OK

        self._append_slots(seq_group, ret.blocks_to_copy)
        is_prefill = seq_group.is_prefill()
        if is_prefill:
            ret.prefill_seq_groups.append(seq_group)
        else:
            ret.decode_seq_groups.append(seq_group)

        budget.add_num_batched_tokens(seq_group.request_id, num_new_tokens)
        budget.add_num_seqs(seq_group.request_id,
                            seq_group.get_max_num_running_seqs())
        swapped_queue.popleft()

    # Put back leftover (LoRA conflict) requests
    self.swapped = leftover_swapped + swapped_queue

    return ret

Swap-In 的三重检查

一个 swapped 请求要成功换回 GPU,必须通过三关检查:

第一关:预算检查(budget.can_schedule)

同时检查 token 预算和序列数预算。 Decode 请求每次消耗 1 个 token × 序列数个 token_budget, 以及 num_running_seqs 个序列槽位。 任何一个维度超出都会立即停止本阶段调度(break)。 注意这里用 break 而非 continue—— swapped 队列已按优先级排序,若当前请求预算不足,后续更大的请求也不可能通过, 直接退出循环效率更高。

第二关:can_swap_in 检查(GPU 空闲块)

委托给 block_manager.can_swap_in(seq_group, num_lookahead_slots), 检查当前 GPU 是否有足够的连续空闲块来容纳整个序列的 KV Cache 以及预留的 Lookahead 块(用于 speculative decoding)。

如果无法换入,该请求进入 infeasible_seq_groups, 但不会阻止后续其他 swapped 请求的检查(continue)。 这与预算检查的 break 形成对比:预算耗尽是全局限制, 而单个请求的块不足是局部问题,不影响其他(可能更小的)请求。

第三关:LoRA 约束检查

当启用 LoRA 时,同一批次中的 LoRA 适配器数量受限。 若当前 swapped 请求的 LoRA ID 超出限制,该请求暂时跳过(放入 leftover_swapped), 但不计为失败,后续 step 仍有机会被调度。

swap_in 的实际操作

通过三重检查后,调用 block_manager.swap_in() 执行实际的块映射更新: 将 CPU 块 ID 映射到 GPU 块 ID,记录在 ret.blocks_to_swap_in 字典中。 真正的内存搬运(cudaMemcpyAsync)在 Worker 端执行,调度器只负责生成映射指令。

注意:swapped 队列按 FCFS 排序

_schedule_swapped() 调用 policy.sort_by_priority() 对 swapped 队列重新排序(与 waiting 队列的排序逻辑相同)。 这保证了等待换回时间最长的请求优先被换入,符合 FCFS 的公平性原则。

04

_schedule_prefills() — Waiting 队列处理:新请求调度、prefill 预算

_schedule_prefills() 负责从 waiting 队列中选取新请求进行 Prefill(首次计算 KV Cache)。 它是三阶段中最后执行的,因此只能使用 running 和 swapped 阶段剩下的预算。

scheduler.py — _schedule_prefills() 核心逻辑 L860-940
def _schedule_prefills(
    self,
    budget: SchedulingBudget,
    curr_loras: Optional[Set[int]],
    enable_chunking: bool = False,
) -> SchedulerPrefillOutputs:
    """Schedule sequence groups that are in prefill stage.

    Note that the current scheduler treats PREEMPTED_FOR_RECOMPUTE
    as a new seq_group in the waiting queue (restart from scratch).
    """
    ret = SchedulerPrefillOutputs.create_empty()
    waiting_queue = self.waiting

    # Important guard: in Default path, do NOT schedule prefills
    # if there are swapped-out requests waiting to be restored.
    # Mixing prefill with swap-in creates memory pressure oscillation.
    leftover_waiting_seqs: Deque[SequenceGroup] = deque()

    # Sort waiting queue by FCFS priority
    waiting_queue = self.policy.sort_by_priority(
        time.time(), waiting_queue)

    while waiting_queue:
        seq_group = waiting_queue[0]

        # Skip if there are still swapped requests
        # (guard against Prefill+Swap coexistence in Default path)
        if len(self.swapped) + len(ret.seq_groups) > 0:
            # NOTE: in Default scheduling, if there are swapped sequences,
            # we skip all prefills to prevent memory oscillation.
            break

        waiting_seqs = seq_group.get_seqs(SequenceStatus.WAITING)
        assert len(waiting_seqs) == 1, \
            "Waiting sequence group should have only one prompt sequence."

        num_new_tokens = self._get_num_new_tokens(
            seq_group, SequenceStatus.WAITING, enable_chunking, budget)

        # Guard 1: prompt exceeds model limit
        if num_new_tokens > self.prompt_limit:
            logger.warning(
                "Input prompt (%d tokens) is too long and exceeds "
                "limit of %d", num_new_tokens, self.prompt_limit)
            for seq in waiting_seqs:
                seq.status = SequenceStatus.FINISHED_IGNORED
            ret.ignored_seq_groups.append(seq_group)
            waiting_queue.popleft()
            continue  # 跳过,不阻塞后续请求

        # Guard 2: can we allocate GPU blocks for this request?
        can_allocate = self.block_manager.can_allocate(seq_group)
        if can_allocate == AllocStatus.LATER:
            break  # 当前无可用块,但以后可能有,停止本轮
        elif can_allocate == AllocStatus.NEVER:
            # 即使独占全部 GPU 也无法满足,直接放弃
            logger.warning(
                "Input prompt (%d tokens) is too long and exceeds "
                "the capacity of block_manager", num_new_tokens)
            for seq in waiting_seqs:
                seq.status = SequenceStatus.FINISHED_IGNORED
            ret.ignored_seq_groups.append(seq_group)
            waiting_queue.popleft()
            continue

        # Guard 3: budget check
        num_new_seqs = seq_group.get_max_num_running_seqs()
        if not budget.can_schedule(num_new_tokens=num_new_tokens,
                                   num_new_seqs=num_new_seqs):
            break

        # All guards passed: allocate blocks and schedule
        seq_group.maybe_set_first_scheduled_time(time.time())
        self._allocate_and_set_running(seq_group)

        budget.add_num_batched_tokens(seq_group.request_id, num_new_tokens)
        budget.add_num_seqs(seq_group.request_id, num_new_seqs)
        ret.seq_groups.append(seq_group)
        waiting_queue.popleft()

    # Re-queue leftover (LoRA conflict) requests
    self.waiting = leftover_waiting_seqs + waiting_queue
    return ret

四重守卫机制

_schedule_prefills() 设置了四重守卫,任何一关失败都会阻止请求进入本次批次:

守卫 0:Swapped 队列非空则跳过全部 Prefill

这是 Default 路径最关键的约束。 代码检查 len(self.swapped) > 0,若为真则直接 break, 放弃本轮所有 Prefill 调度。目的是防止 Prefill 与 Swap 同时发生导致显存震荡 (参见第 5 节的详细分析)。

守卫 1:prompt 长度超过 prompt_limit

若请求的 prompt token 数超过 prompt_limit, 请求被标记为 FINISHED_IGNORED 并加入 ignored_seq_groups, 直接报错返回给用户。使用 continue 而非 break, 允许后续更短的请求继续参与调度。

守卫 2:AllocStatus — 块管理器的三态返回

block_manager.can_allocate() 返回三种状态:

  • OK:当前有足够空闲块,可以立即分配。
  • LATER:当前空闲块不足,但等待其他请求完成释放后可能满足 → break(停止本轮)
  • NEVER:即使全部 GPU 块都空闲也无法满足(请求太大)→ continue(跳过该请求)

LATER 触发 break 是因为 FCFS 排序下, 若当前最高优先级请求无法分配,继续调度后续(同等或更大的)请求意义不大, 直接结束本阶段更高效。

守卫 3:预算检查(token + seq 双维度)

Prefill 请求消耗的 token 预算远大于 Decode 请求(prompt 长度 vs 1 token)。 此处同时检查 token_budgetmax_num_seqs 两个维度, 任一超出则 break

_allocate_and_set_running — 首次分配 KV Cache

通过全部守卫后,调用 _allocate_and_set_running()

scheduler.py — _allocate_and_set_running() L540-552
def _allocate_and_set_running(self, seq_group: SequenceGroup) -> None:
    self.block_manager.allocate(seq_group)
    for seq in seq_group.get_seqs(SequenceStatus.WAITING):
        seq.status = SequenceStatus.RUNNING

此函数完成两件事:

  • 分配 GPU 块block_manager.allocate() 为整个 prompt 分配足够的 KV Cache 块, 建立 block table(逻辑块号 → 物理块号的映射)。
  • 状态转换:将序列状态从 WAITING 改为 RUNNING, 使该请求在下一次 step 出现在 running 队列中继续 Decode。
05

三阶段优先级 — Running > Swapped > Waiting 的优先级设计

_schedule_default() 的三阶段顺序不是随意的,它背后蕴含了一套精心设计的优先级哲学。 理解这一设计,有助于深刻把握 vLLM 调度器的权衡取舍。

为什么 Running 优先级最高?

Running > 一切:保护已投入的计算成本

Running 队列中的请求已经完成了 Prefill(计算了整个 prompt 的 KV Cache), 进入 Decode 阶段。每一步 Decode 的计算成本极低(仅 1 个新 token), 但若被抢占并走 Recompute 路径,之前所有的 Prefill 计算将白费。 因此,优先保障 running 请求的 Decode 能够持续进行, 是保护已投入计算成本、降低整体延迟的最优选择。

此外,running 请求的 token 预算消耗极小(每个请求每步 1 个 token), 优先满足它们几乎不会对整体吞吐量造成影响。

为什么 Swapped 优先级高于 Waiting?

Swapped > Waiting:FCFS 公平性 + 避免资源浪费

Swapped 请求在 Swap Out 之前已经在 Running 队列中执行过, 积累了完整的 KV Cache,换入后可以立即继续 Decode 而无需重新计算 prompt。 相比之下,Waiting 中的新请求还需要执行开销较大的 Prefill。

从 FCFS 公平性角度看,swapped 请求到达系统的时间比新 waiting 请求更早, 理应具有更高优先级。让已等待更久的请求先恢复服务,符合先进先出的基本原则。

Prefill 与 Swap 互斥的深层原因

显存震荡(Memory Oscillation)问题

假设当前 GPU 显存处于紧张状态(否则不会有 swapped 请求), 若此时同时执行 Prefill(申请大量新 KV Cache 块)和 Swap In(从 CPU 换回 KV Cache), 两者同时争夺有限的 GPU 空闲块,很可能导致:

  1. Swap In 的请求刚换回 GPU,因为 Prefill 占用了大量新块,显存再次紧张
  2. 调度器不得不将刚换入的请求再次换出
  3. 形成"换进→换出→换进"的振荡,大量消耗 PCIe 带宽,吞吐量骤降

通过禁止在 swapped 队列非空时进行 Prefill,Default 路径避免了这一问题, 代价是部分 GPU 计算资源在 Swap 等待期间可能空闲(利用率下降)。 Chunked Prefill 路径通过精细的 token 预算管理打破了这一约束。

三阶段的预算消耗模式

从预算(SchedulingBudget)视角看三阶段的关系:

三阶段预算消耗时序(伪代码) 概念说明
# 初始预算
budget = SchedulingBudget(token_budget=T, max_num_seqs=N)

# Phase 1:running 请求预填充 num_seqs(token 小,seq 槽位已预占)
for seq_group in self.running:
    budget.add_num_seqs(...)  # 预占 seq 槽位,几乎不消耗 token_budget

# _schedule_running:追加实际消耗(每请求约 1 token × num_seqs)
#   token_budget 消耗 ≈ len(running) * 1(极小)
#   max_num_seqs 消耗 ≈ len(running)(已预占,无额外消耗)

# _schedule_swapped:消耗剩余 token_budget 的一小部分
#   每个 swapped 请求 ≈ 1 token × num_seqs
#   max_num_seqs 消耗 = swapped_in 的序列数

# _schedule_prefills:使用剩余预算
#   每个 prefill 请求消耗 prompt_len 个 token(可能很大)
#   token_budget 通常在第一个 prefill 请求后接近耗尽

这一预算消耗模式反映了设计意图: Prefill 请求消耗绝大部分 token 预算,而 Running/Swapped 请求消耗绝大部分 seq 槽位预算。 两者在不同维度上竞争资源,调度器通过双维度预算约束实现精细的资源控制。

06

SchedulerRunningOutputs / SchedulerSwappedInOutputs 数据结构

每个子调度函数返回一个专属的数据类,记录本阶段的调度决策。 这三个数据类最终在 _schedule_default() 中被汇总合并为 SchedulerOutputs

SchedulerRunningOutputs

scheduler.py — SchedulerRunningOutputs 定义 L176-230
@dataclass
class SchedulerRunningOutputs:
    """The requests scheduled from a running queue.

    Could contain prefill (prefill that needs to be chunked or
    continued) or decode requests. Note that decode requests are the
    majority, and prefill requests are only added when chunked prefill
    is enabled.

    Attributes:
        decode_seq_groups: Sequence groups that are going to be decoded.
        prefill_seq_groups: Sequence groups that are going to be (continued)
            prefilled.
        preempted: Sequence groups that are preempted.
        swapped_out: Sequence groups that are swapped out.
        blocks_to_swap_out: GPU -> CPU block number to swap out.
        blocks_to_copy: GPU -> GPU block numbers to copy.
        num_lookahead_slots: a number of speculative tokens that will be
            decoded, currently used by draft models in speculative decoding.
    """
    decode_seq_groups: List[SequenceGroup]
    prefill_seq_groups: List[SequenceGroup]
    preempted: List[SequenceGroup]
    swapped_out: List[SequenceGroup]
    blocks_to_swap_out: Dict[int, int]        # GPU 块 → CPU 块
    blocks_to_copy: Dict[int, List[int]]      # Copy-on-Write 指令
    num_lookahead_slots: int

    @classmethod
    def create_empty(cls) -> "SchedulerRunningOutputs":
        return cls(
            decode_seq_groups=[],
            prefill_seq_groups=[],
            preempted=[],
            swapped_out=[],
            blocks_to_swap_out={},
            blocks_to_copy={},
            num_lookahead_slots=0,
        )

字段语义详解:

decode_seq_groups vs prefill_seq_groups

在 Default 路径中(enable_chunking=False),running 队列理论上只包含 Decode 请求, 因此 prefill_seq_groups 通常为空。 只有在 Chunked Prefill 路径中(_schedule_running(enable_chunking=True)), running 队列才会包含尚未完成 Prefill 的分块请求,才会填充 prefill_seq_groups

preempted vs swapped_out — 两种抢占的去向

preempted:走 Recompute 路径,KV Cache 已被丢弃,请求将被移回 waiting 队列重新 Prefill。
swapped_out:走 Swap Out 路径,KV Cache 已移至 CPU,请求将被移入 swapped 队列等待换回。

这两个列表在 _schedule_default() 中被消费,用于更新队列状态:

# _schedule_default 汇总后处理
for seq_group in running_scheduled.preempted:
    self.waiting.appendleft(seq_group)  # 移回 waiting 队列头部(优先调度)
for seq_group in running_scheduled.swapped_out:
    self.swapped.appendleft(seq_group)  # 移入 swapped 队列头部

SchedulerSwappedInOutputs

scheduler.py — SchedulerSwappedInOutputs 定义 L234-290
@dataclass
class SchedulerSwappedInOutputs:
    """The requests scheduled from a swapped queue.

    Could contain prefill (when enable_chunking=True) or decode requests.

    Attributes:
        decode_seq_groups: Sequence groups that are going to be decoded.
        prefill_seq_groups: Sequence groups that are going to be prefilled.
        blocks_to_swap_in: CPU -> GPU block number to swap in.
        blocks_to_copy: GPU -> GPU block numbers to copy.
        num_lookahead_slots: A number of speculative tokens that will be
            decoded.
        infeasible_seq_groups: Sequence groups that are infeasible due to
            resource constraints.
    """
    decode_seq_groups: List[SequenceGroup]
    prefill_seq_groups: List[SequenceGroup]
    blocks_to_swap_in: Dict[int, int]         # CPU 块 → GPU 块
    blocks_to_copy: Dict[int, List[int]]      # Copy-on-Write 指令
    num_lookahead_slots: int
    infeasible_seq_groups: List[SequenceGroup]  # 无法换入的请求

    @classmethod
    def create_empty(cls) -> "SchedulerSwappedInOutputs":
        return cls(
            decode_seq_groups=[],
            prefill_seq_groups=[],
            blocks_to_swap_in={},
            blocks_to_copy={},
            num_lookahead_slots=0,
            infeasible_seq_groups=[],
        )
infeasible_seq_groups — 不是错误,是暂时搁置

infeasible_seq_groups 中的请求并非被永久放弃, 它们依然留在 swapped 队列中,在后续显存充裕时会重新尝试换入。 与 SchedulerPrefillOutputs.ignored_seq_groups 的永久放弃形成对比: 后者是因为请求本身超过系统能力上限(prompt 过长或 KV Cache 永远无法满足), 属于配置/请求错误,不可恢复。

SchedulerPrefillOutputs

scheduler.py — SchedulerPrefillOutputs 定义 L294-330
@dataclass
class SchedulerPrefillOutputs:
    """The requests scheduled from a waiting queue.

    Attributes:
        seq_groups: Sequence groups that are going to be prefilled.
        ignored_seq_groups: Sequence groups that are going to be ignored.
        num_lookahead_slots: A number of speculative tokens that will be
            decoded.
    """
    seq_groups: List[SequenceGroup]
    ignored_seq_groups: List[SequenceGroup]
    num_lookahead_slots: int

    @classmethod
    def create_empty(cls) -> "SchedulerPrefillOutputs":
        return cls(
            seq_groups=[],
            ignored_seq_groups=[],
            num_lookahead_slots=0,
        )

三个数据类的汇总过程

_schedule_default() 的结尾,三个子数据类被合并为一个 SchedulerOutputs

scheduler.py — 三阶段结果汇总(_schedule_default 尾部) L700-720
# 更新队列状态
self.running.extendleft(reversed(running_scheduled.decode_seq_groups))
self.running.extendleft(reversed(swapped_in.decode_seq_groups))
for seq_group in running_scheduled.preempted:
    self.waiting.appendleft(seq_group)
for seq_group in running_scheduled.swapped_out:
    self.swapped.appendleft(seq_group)
for seq_group in prefills.seq_groups:
    self.running.appendleft(seq_group)

# 构建最终输出
return SchedulerOutputs(
    scheduled_seq_groups=(
        [ScheduledSequenceGroup(sg, token_chunk_size=1)
         for sg in running_scheduled.decode_seq_groups]
      + [ScheduledSequenceGroup(sg, token_chunk_size=1)
         for sg in swapped_in.decode_seq_groups]
      + [ScheduledSequenceGroup(sg, token_chunk_size=sg.get_seqs(...)[0].data.get_num_uncomputed_tokens())
         for sg in prefills.seq_groups]
    ),
    num_prefill_groups=len(prefills.seq_groups),
    num_batched_tokens=budget.num_batched_tokens,
    blocks_to_swap_in=dict(swapped_in.blocks_to_swap_in),
    blocks_to_swap_out=dict(running_scheduled.blocks_to_swap_out),
    blocks_to_copy=merge_dicts(running_scheduled.blocks_to_copy,
                               swapped_in.blocks_to_copy),
    ignored_seq_groups=prefills.ignored_seq_groups,
    num_lookahead_slots=running_scheduled.num_lookahead_slots,
    running_queue_size=len(self.running),
)

注意 token_chunk_size 的赋值:Decode 请求固定为 1, Prefill 请求为该请求尚未计算的 token 数(get_num_uncomputed_tokens())。 这是 ScheduledSequenceGroup 中区分 Prefill 与 Decode 的关键字段, Worker 会据此决定 forward pass 的实际输入长度。

07

整体调度流程图

以下流程图从不同维度展示 _schedule_default() 的完整执行逻辑。

三阶段调度主流程

flowchart TD ENTRY([_schedule_default 入口]) --> BUDGET["创建 SchedulingBudget\ntoken_budget=max_num_batched_tokens\nmax_num_seqs=max_num_seqs"] BUDGET --> PREPOP["预填充 num_seqs 预算\n将所有 running 请求的序列数\n预先计入 budget"] PREPOP --> P1["Phase 1\n_schedule_running()"] subgraph P1DETAIL ["Running 队列处理"] P1 --> R_LOOP["遍历 running 队列\n(队首 = 等待最久)"] R_LOOP --> CAN_APPEND{"_can_append_slots()\nGPU 有空闲块?"} CAN_APPEND -- "Yes" --> APPEND["append_slots()\n分配 Decode 所需新块\n加入 decode_seq_groups"] CAN_APPEND -- "No" --> VICTIM["从队尾取受害者\n_preempt(victim)"] VICTIM --> PREEMPT_MODE{"抢占模式?"} PREEMPT_MODE -- "RECOMPUTE" --> RECOMP["preempted 列表\n→ 移回 waiting 队首"] PREEMPT_MODE -- "SWAP" --> SWAPOUT["swapped_out 列表\n→ blocks_to_swap_out\n→ 移入 swapped 队首"] RECOMP --> CAN_APPEND SWAPOUT --> CAN_APPEND APPEND --> R_LOOP end P1 --> P2["Phase 2\n_schedule_swapped()"] subgraph P2DETAIL ["Swapped 队列处理"] P2 --> S_SORT["FCFS 排序 swapped 队列"] S_SORT --> S_LOOP["遍历 swapped 队列"] S_LOOP --> S_BUDGET{"budget.can_schedule()?"} S_BUDGET -- "No" --> S_BREAK["break 退出"] S_BUDGET -- "Yes" --> S_ALLOC{"can_swap_in()?"} S_ALLOC -- "No" --> S_INFEAS["加入 infeasible\n→ continue"] S_ALLOC -- "Yes" --> S_SWAPIN["swap_in()\nblocks_to_swap_in\n加入 decode_seq_groups"] S_INFEAS --> S_LOOP S_SWAPIN --> S_LOOP end P2 --> P3["Phase 3\n_schedule_prefills()"] subgraph P3DETAIL ["Waiting 队列处理"] P3 --> SWAPPED_CHECK{"swapped 队列\n是否非空?"} SWAPPED_CHECK -- "Yes" --> P3_SKIP["跳过全部 Prefill\nbreak"] SWAPPED_CHECK -- "No" --> W_SORT["FCFS 排序 waiting 队列"] W_SORT --> W_LOOP["遍历 waiting 队列"] W_LOOP --> W_LEN{"prompt 超过\nprompt_limit?"} W_LEN -- "Yes" --> W_IGN["ignored_seq_groups\ncontinue"] W_LEN -- "No" --> W_ALLOC{"can_allocate()?"} W_ALLOC -- "LATER" --> W_BREAK["break"] W_ALLOC -- "NEVER" --> W_IGN W_ALLOC -- "OK" --> W_BUDGET{"budget.can_schedule()?"} W_BUDGET -- "No" --> W_BREAK W_BUDGET -- "Yes" --> W_RUN["_allocate_and_set_running()\n加入 seq_groups"] W_IGN --> W_LOOP W_RUN --> W_LOOP end P3 --> MERGE["汇总三阶段结果\n构建 SchedulerOutputs"] MERGE --> RETURN([返回 SchedulerOutputs])

队列状态变化追踪

stateDiagram-v2 direction LR state "waiting 队列" as W state "running 队列" as R state "swapped 队列" as S state "完成(释放)" as DONE [*] --> W : add_seq_group()\n新请求到达 W --> R : _schedule_prefills()\n_allocate_and_set_running()\n分配 KV Cache 块 R --> R : _schedule_running()\n_append_slots() 成功\nDecode 继续 R --> W : _schedule_running()\n_preempt(RECOMPUTE)\n丢弃 KV Cache 重排队 R --> S : _schedule_running()\n_preempt(SWAP)\nKV Cache 换出 CPU S --> R : _schedule_swapped()\nswap_in() 成功\nKV Cache 换回 GPU R --> DONE : generate_end / stop\n序列生成完毕 W --> DONE : ignored_seq_groups\nprompt 过长或永久无法分配

预算消耗时序图

sequenceDiagram participant D as _schedule_default() participant B as SchedulingBudget participant RUN as _schedule_running() participant SWP as _schedule_swapped() participant PRE as _schedule_prefills() D->>B: create(token_budget=T, max_num_seqs=N) Note over D,B: 阶段 0:预填充 running 序列数 loop 每个 running 请求 D->>B: add_num_seqs(req_id, num_running_seqs) end Note over D,RUN: 阶段 1:Running 队列 D->>RUN: _schedule_running(budget) loop 每个成功 decode 的请求 RUN->>B: add_num_batched_tokens(req_id, ~1) end RUN-->>D: SchedulerRunningOutputs Note over D,SWP: 阶段 2:Swapped 队列 D->>SWP: _schedule_swapped(budget) loop 每个成功 swap-in 的请求 SWP->>B: add_num_batched_tokens(req_id, ~1) SWP->>B: add_num_seqs(req_id, num_seqs) end SWP-->>D: SchedulerSwappedInOutputs Note over D,PRE: 阶段 3:Waiting 队列 D->>PRE: _schedule_prefills(budget) loop 每个成功 prefill 的请求 PRE->>B: add_num_batched_tokens(req_id, prompt_len) PRE->>B: add_num_seqs(req_id, num_seqs) end PRE-->>D: SchedulerPrefillOutputs D->>D: 汇总 → SchedulerOutputs

与 Chunked Prefill 路径的关键差异对比

Default 路径 vs Chunked Prefill 路径

两条路径在以下三个关键点存在根本差异:

  • Prefill + Swap 互斥:Default 路径禁止同时进行; Chunked Prefill 路径通过精细预算允许共存。
  • 运行时 prefill_seq_groups:Default 路径中 running 队列几乎只有 Decode 请求; Chunked Prefill 路径中 running 队列混合了分块 Prefill 和 Decode 请求。
  • token_chunk_size:Default 路径的 Prefill 请求 token_chunk_size = 完整 prompt 长度; Chunked Prefill 路径每次只处理 chunk_size 个 token。