跳到正文
LLM 推理
S10批处理与调度·442

调度器

每一步调度器都要决定哪些请求能跑。公平性、延迟 SLO、显存不足后的恢复策略,都在这里。

  • 等待/运行队列
  • token 预算
  • 抢占
  • 换出 vs 重算
  • 准入控制

为什么重要

问题

S09 结尾出现了 while self.can_admit(),一个我们从没写过的函数。它必须每秒回答几百次这样的问题:

  • 这一步应该运行多少个请求?
  • 该不该启动一个新的 8000 token prefill,还是把整步都留给已经在流式输出的四十个请求?
  • KV block 用光了,而一个运行中的请求还要再来一块。谁该失去自己的缓存?
  • 有个请求已经卡在一个长 prompt 后面排了九秒。这可以接受吗?

这些问题没有放之四海皆准的答案,你能拿出的只是一套策略;而这套策略就是你的引擎对外可见的行为。

核心思路

解法

一个带两个队列、两种预算和一条抢占路径的调度器。结构短到可以完整读完:

调度器python
def schedule(self) -> Batch:
    budget = self.max_num_batched_tokens
    scheduled = []

    # --- 1. running decodes first: a started request should finish ---
    for req in self.running:
        if budget < 1:
            break
        if not self.blocks.can_append(req):
            victim = self.pick_victim()      # last admitted, usually
            self.preempt(victim)
            if victim is req:
                continue
        self.blocks.append(req)
        scheduled.append(req)
        budget -= 1

    # --- 2. spend what is left admitting new prefills ---------------
    while self.waiting and budget > 0:
        req = self.waiting[0]
        if req.prompt_len > budget:
            break                            # cannot fit this step
        if not self.blocks.can_allocate(req, watermark=0.01):
            break                            # keep headroom for growth
        self.waiting.popleft()
        self.blocks.allocate(req)
        scheduled.append(req)
        budget -= req.prompt_len

    return Batch(scheduled)

「decode 优先」不是随便定的

已经完成 prefill 的请求正占着 KV 显存。它每有一步不运行,那块显存就被白占着,毫无产出。优先跑 decode 能压低缓存被持有的时长,从而提高能容纳的请求数。所以「decode 优先」是延迟策略,同样也是显存策略。

工作原理

工作原理

示意图调度器的一步
一个调度步等待队列请求 4 · prompt 120请求 5 · prompt 1800请求 6 · prompt 64请求 7 · prompt 240运行队列请求 0 · 解码中请求 1 · 解码中请求 2 · 解码中请求 3 · 解码中schedule()1. decode 优先(每个 1 tok)2. 剩余预算用于 prefill, 按队列顺序取3. 到 token 预算即停4. 到 KV 水位线即停5. 若 OOM → 抢占每次前向跑一遍TOKEN 预算 = 5124 个 decode1 个 prefill(240)空闲请求 5(1,800 token)塞不进剩余预算,只能再等一步。KV 水位线准入到此为止留出余量是为了让在跑的请求还能继续增长。准入到 100%,整个系统就死锁。抢占:两条回头路重算丢弃 KV,之后重新 prefill换出把 KV 拷到主机内存代价 = prompt + 已生成个 token 的 prefill,重来一遍代价 = 2 × 缓存字节数走 PCIe,出去再回来短上下文:重算更划算。长上下文:换出更划算。vLLM 默认用重算。用户能感受到的一切(排队延迟、延迟尖刺、公平性、超长 prompt 会不会饿死),都由这一个函数决定。它是推理引擎里最要命的 200 行。队头阻塞:严格 FCFS 会让一个 32k 的 prompt 拖住排在它后面的所有请求。
两个队列、一份 token 预算、一条显存水位线,和一条抢占路径。一个服务系统几乎所有用户可见的性质,都是在这里决定的。

两种预算是两种不同的约束

  • token 预算max_num_batched_tokens)限制每步的算力。它给一次前向的耗时设了上限,也就给 batch 中所有人的 token 间延迟设了上限。
  • 显存水位线限制 KV block。一直准入到池子 100% 占满,必然死锁:每个运行中的请求最终都还要再来一块,而一块都没有了。真实引擎会留出百分之几的余量。

调高 token 预算,吞吐变好、每步延迟变差;调低则相反。不存在对两者都最优的设置;这正是 SLO 的意义所在。

抢占:重算还是换出

显存耗尽时,某个运行中的请求必须交出它的缓存。把它取回来有两条路,两者的交叉点很清晰:

  • 重算 —— 扔掉 KV,等它被重新调度时把整个请求重新 prefill 一遍。代价是 (prompt + 已生成) 个 token 的 prefill:受算力限制,也相当快。
  • 换出 —— 把 KV block 拷到主机内存再拷回来。代价是缓存大小的两倍经由 PCIe:字节很多,算术为零。

短上下文下,重算轻松取胜。极长上下文下 prefill 成本上升,换出开始有竞争力。vLLM 默认重算;有了分块和前缀缓存之后,prefill 已经便宜了很多。

公平性,以及纯 FCFS 的问题

严格先到先服务简单、且不会饿死任何人,但它有一个糟糕的失效模式:队首阻塞。队首那个 32000 token 的 prompt 在整份预算腾出来之前无法被调度,而在它等待期间,它后面的一切也都不动。

短 prompt 优先能改善小请求的尾延迟,却会饿死大请求。优先级队列让你能表达业务规则,也逼你必须先有业务规则。调度策略属于运维者,所以把它做成可插拔的,并且同时测量 P50 P99。一个改善均值却毁掉尾部的策略,在仪表盘上很好看,在用户那里很难受。

动手观察

动手试试

模拟器带抢占的调度器
队列策略
抢占方式
第 80 / 80 步
已完成
0/20
平均 TTFT
0.0 步
p95 TTFT
0 步
抢占次数
0
等待队列 · 20
  • r11873 prompt
  • r12140 prompt
  • r171324 prompt
  • r681 prompt
  • r1464 prompt
  • r11114 prompt
  • r1669 prompt
  • r131109 prompt
  • r18194 prompt
  • r01461 prompt
  • r19146 prompt
  • r8182 prompt
  • r10929 prompt
  • r15219 prompt
  • r3136 prompt
  • r7156 prompt
  • r980 prompt
  • r244 prompt
  • r493 prompt
  • r5228 prompt

比 token 预算还长的 prompt 永远排不上,只会被无限饿死。把预算降到 128,看着长 prompt 永久堆积。解决它的是 S11。

运行中 · 0
  • 当前没有运行中的请求

decode 的调度优先于 prefill。已经开始的请求应该尽快结束,否则它的 KV 缓存一直占着,对其他所有人都更糟。

每步 batch 构成(prefill 与 decode 的 token 数)
已用 block
0/180
prefill token
0
decode token
0
重算的 token
0

紫色尖峰是 prefill。一个长 prompt 就能吃掉整步预算,排在它后面的 decode 全部干等:对每个正在流式输出的用户,这都是一次看得见的延迟毛刺,也正是分块 prefill 要解决的问题。

三个实验,每一个都能复现一次真实的生产事故:

  • 把 token 预算设成 128。长 prompt 永远无法被调度,只能一直排队,这就是永久饥饿;而引擎自始至终看起来都很健康。FCFS 队首那个排不上的请求还会挡住它后面的所有人,于是少数几个超长 prompt 就能拖住大部分流量。
  • 把 KV 池缩到 60 个 block。抢占次数飙升,同样的 token 被反复重算,这就是抖动:吞吐塌陷,而 GPU 利用率显示 100%。
  • 持续负载下切到短 prompt 优先。中位数大幅改善,长 prompt 则差得多。如果队列有限且能排空,最短优先会全面胜出;只有当短请求源源不断地到来、可以不断插队时,饥饿才会出现。

亲手实现

实现

code/s10_scheduler.py(节选)python
def preempt(self, req: Request):
    """Give a running request's KV memory back to the pool."""
    self.blocks.free(req)
    req.preemption_count += 1

    if self.preemption_mode == "recompute":
        # Cheapest for short contexts: forget everything, re-prefill later.
        req.computed = 0
        req.kv_blocks = []
    else:
        # Cheapest for long contexts: park the blocks in host memory.
        req.swapped_blocks = self.blocks.swap_out(req)

    self.waiting.appendleft(req)     # front of the queue — do not restart it last

被抢占的请求要插到队列的最前面

把被抢占的请求放到队尾,你可能陷入活锁:它一次次被准入、被抢占、再被送到队尾,只有在这两者之间的那几步里才有进展。插到队首保证了前进性。挑牺牲品用的是同一套道理:最后准入的请求生成的 token 最少,损失的工作也最少。
在本地运行
用可配置的策略,在一份合成到达轨迹上运行完整调度器,然后扫描 token 预算和 KV 池大小,生成一张吞吐/延迟表。还包含一个抖动检测器:当重算量超过有效产出时发出提示。
$ python code/s10_scheduler.py
预期输出: 一条把 60 个请求中 46 个堵在区区 5 个超长 prompt 后面的基线、一次任何预算下都不饿死人的分块 prefill 扫描、一份按请求分组给出 TTFT 的策略对比,以及一张以毫秒为单位的吞吐/延迟表,因为「步」并不是时间单位。

只需要 NumPy — 查看环境准备.

生产实践

在生产环境中

  • vLLM —— Scheduler.schedule(),带 max_num_batched_tokensmax_num_seqs、GPU 水位线,以及默认使用重算的抢占。抢占率过高时它会打一条警告,那条警告就是你的抖动信号。
  • SGLang —— 缓存感知调度,优先选择前缀已经驻留的请求,把 S07 的缓存变成了一个调度输入。
  • Kubernetes 规模的部署 —— 在这个调度器之上还有第二层调度器,在副本之间路由请求,理想情况下是前缀感知的,以免轮询负载均衡把缓存命中全毁掉。

练习

  1. 1
    实现一个带老化的优先级队列:请求等得越久优先级越高,这样短优先也饿不死任何人。演示改造前后的饥饿情况。
  2. 2
    加一个 SLO 感知的准入控制器:当队列意味着 TTFT 会超过目标时,直接拒绝新请求。早点拒绝,通常是比先接受再超时更像样的服务。
  3. 3
    给抢占抖动加上度量:记录「重算 token 数 / 新生成 token 数」的比值,超过 20% 时触发背压信号。

继续学习

接下来

模拟器暴露了缺陷:一次长 prefill 吃掉整整一步,每个流式用户都感到卡顿。S11 把 prefill 切成小块,混进 decode 批次。尖峰因此消失,吞吐并不受损。

自测

习题

先作答,再看解析。答错比答对更有价值,因为解析会指出你该回头重读哪一部分。

自测 1 题 / 共 6

调度器为什么要先跑完所有待处理的 decode,再准入任何新的 prefill?

得分 0/6