推理服务器的调度器:连续批处理与请求生命周期管理
推理服务器的性能上限不取决于模型,取决于调度器------同样的模型,换一个调度策略,吞吐可以差三倍。传统深度学习服务的静态批处理在 LLM 场景下完全失灵:请求的输出长度不可预知,短请求被长请求拖着浪费算力。这篇文章实现 LLM 时代的标准答案------连续批处理(continuous batching),并管理一个请求从到达到完成的完整生命周期。
一、静态批处理的死穴
传统服务把一批请求凑齐、一起前向、全部完成后才开始下一批。LLM 场景下这有两重灾难。其一,队头阻塞 :批内一个请求生成 1000 token、另一个只生成 5 个,后者必须陪跑到 1000------单请求延迟被批内最长者决定。其二,填充浪费:凑批等待时间与批内计算不均衡,GPU 利用率反而上不去。静态批处理的假设------"每个请求的计算量相同"------在自回归生成场景下根本不成立。调度器必须改成 token 级粒度。
二、连续批处理:每步重组批次
连续批处理把调度粒度从"请求"细化到"单步解码":每完成一个解码步就检查批内状态------完成的请求立即退出、等待队列的请求立即插入。批的成员每步都在变,GPU 永远满载:
python
class Scheduler:
def __init__(self, engine, max_batch=32, max_kv_tokens=32768):
self.engine = engine # 封装模型+KV缓存池
self.waiting: deque[Request] = deque()
self.running: list[Request] = []
self.max_batch = max_batch
self.kv_budget = max_kv_tokens # 全局KV显存预算(token数)
def step(self):
"""执行一个解码步: 这就是调度的最小粒度"""
self.admit() # 先看能否接纳新请求
# 1. 对 running 中所有请求各前向一步(连续批处理核心)
tokens, kv_ptrs = self.engine.forward_step(
[r.last_token for r in self.running], kv_ptrs=[r.kv for r in self.running])
for r, tok, kv in zip(self.running, tokens, kv_ptrs):
r.output.append(tok)
r.kv = kv
if r.is_finished(tok): # EOS 或达到长度上限
self.finish(r)
# 2. 资源紧张时抢占: 从显存压力最小的策略出发
self.maybe_preempt()
def admit(self):
"""按 KV 预算与批上限逐个接纳等待请求"""
while self.waiting and len(self.running) < self.max_batch:
r = self.waiting[0]
need = r.prompt_len + r.max_new # 预留式接纳
if r.kv_tokens_used(need) > self.free_kv():
break # 显存不够, 拒绝或等待
self.running.append(self.waiting.popleft())
self.engine.prefill(r) # 吃完整个 prompt, 产出首 token
三个关键设计。接纳控制(admission)按 KV 显存预算而非批大小 :批大小的上限是软约束,KV 显存是硬约束------一个 8000 token 的 prompt 与五个 500 token 的 prompt 占用相同显存,调度器只数请求个数就是自欺。prefill 与 decode 的交错是第二个真正的难点:新请求的 prefill(一次性吃数千 prompt token)是计算密集型,与批内 decode(内存带宽密集型)混跑会互相拖慢------生产系统把 prefill 拆块(chunked prefill),每步只处理 prompt 的一段,让两种负载在同一批里平滑混布。**抢占(preempt)**是第三点:KV 预算耗尽时,调度器必须选择牺牲谁------常见策略是抢占"已生成 token 数最少"的请求(重算成本最低),把它的 KV 释放给更接近完成的请求。
三、抢占与重算:被牺牲请求的处置
被抢占的请求有两种处置。换出(swap) :把它的 KV 缓存搬到主存,恢复时换回------延迟低但搬运有成本,且主存容量是另一重预算。重算(recompute):直接丢弃 KV,恢复时重新 prefill 整个序列------用计算换显存,在 prefill 很快的小模型上反而更划算(且现代 Hopper 卡上重算比 PCIe 搬运更快)。选择的标准是"重算时间 vs 搬运时间"的比值,与模型大小、硬件带宽相关,没有普适答案。抢占的深度也分层:先抢"最年轻"的请求,避免反复抢占同一请求造成活锁(记录抢占计数,同一请求被抢两次后降级为拒绝)。
四、指标与调参:吞吐、延迟与公平的三角
调度器的评估三角:吞吐 (tokens/s,越多越好)、首 token 延迟 (TTFT,用户感知的"开始响应"时间)、单请求延迟 (TPOT,每 token 时间)。三者互相牵制:批越大吞吐越高但 TPOT 越差(每步计算分给更多请求);prefill 插队会压 TTFT 但拖慢批内 decode。实际的调参顺序:先定 KV 预算(显存大头,预留 10% 余量防 OOM)、再定 chunked prefill 的块大小(通常与 decode 的批 token 数同量级,让每步计算量稳定)、最后按流量特征调抢占策略------交互式服务压 TPOT(抢占激进、批适中),离线批处理压吞吐(抢占关闭、批拉满)。一个容易忽视的指标是排队公平性:连续批处理天然偏向短请求(更快完成、更快腾位),长文档场景需要对同一用户的并发请求做限流,否则调度器会在高负载时系统性饿死重请求。
连续批处理加上分页 KV 管理,构成了现代推理引擎的调度底座------两者本质是同一个思想在两层的应用:把"按最大值预留"改为"按实际用量细粒度管理",把"以请求为单位"改为"以 token 为单位"。理解了这两点,任何推理服务器的性能问题都能定位到具体机制:慢在接纳控制、慢在 prefill 交错、还是慢在抢占风暴,每一层都有明确的旋钮可拧。