连续批处理的调度、抢占与重算

在一组 8 卡 **** ****(单卡 64GB HBM,卡间互联 392 GB/s)上,用 **** 的基础软件栈配合 vLLM 起 OpenAI 兼容服务,压测并发从 16 提到 64。吞吐没有随并发同比例上升,在某个并发点后转为持平,再往上甚至下降;vllm:num_requests_waiting 长期大于零,调度日志里出现抢占计数,重算的 token 量接近本可产生新输出的算力。原因在调度循环里的抢占与重算:显存不足以容纳全部待运行序列时,调度器抢占部分序列,用重算腾出 KV 块,被抢占序列随后重新排队、重新计算已经处理过的 token,这部分算力不产生新输出。问题分三层:一步调度如何选序列、显存不足时走哪条路、重算对吞吐的挤占。

从静态批处理到按步组批

静态批处理把一批请求按最长序列对齐,组一次批后整批算到结束,短请求要陪着长请求把空位填满,算力按最长序列浪费。连续批处理把组批改为按步进行:每一步都把已经完成输出的序列移出,把等待队列里的新序列补进,批次随步变化。补入的新序列要先拿到 KV 块,这一步把调度问题从「凑一批」变成了「在固定的显存里持续地进出序列」。

调度循环每一步做什么

连续批处理按步推进,每步重新决定哪些序列进入本批。调度策略决定选取顺序,默认按到达顺序(FCFS)取等待队列头部的序列。一步的顺序是:

  1. 从运行中集合取出仍可推进的序列,为每个序列追加一个 token 的解码位置。
  2. 从等待队列取出新序列,尝试为它分配 KV 块;prefill 需要与 prompt 长度相称的块数,解码只需要一个块。
  3. 分配成功则把该序列并入本步批次;块不足则触发抢占,腾出空间。
  4. 组成本步批次,做一次前向,取回每个序列的下一个 token。

KV 块的分配是进入批次的前置条件。序列无法拿到块,就无法进入本步,只能留在等待队列或被抢占。分配由 KV cache manager 完成:先按已算 token 查命中块(get_computed_blocks()),再申请本步所需的新块(allocate_slots());申请时若算不出足够的新块,该次分配直接返回失败,这就是抢占的触发点。逐出按 LRU 从空闲队列头部取块,取到的块若是缓存块则一并逐出。

序列在调度器里由 RequestStatus 标记,被抢占时先进入独立的 PREEMPTED 状态,再退回等待队列:

# 字段名示意:请求状态迁移
WAITING ──(scheduled for prefill,拿到块)──► RUNNING ──(stop condition met)──► FINISHED_STOPPED / FINISHED_LENGTH_CAPPED / FINISHED_ABORTED / ...
   ▲                                            │
   │                                            ▼
   └──────────── (re-queued) ────────────  PREEMPTED

RequestStatus.WAITING      # 在等待队列,等块分配
RequestStatus.RUNNING      # 正在处理(prefill 或 decode)
RequestStatus.PREEMPTED    # 被抢占,KV 块已释放,待重新入队
RequestStatus.FINISHED_*   # 具名结束态:FINISHED_STOPPED / FINISHED_LENGTH_CAPPED / FINISHED_ABORTED 等

被抢占的序列进入 PREEMPTED,它的 KV 块被 kv_cache_manager.free(request) 释放,随后退回等待队列,下一次调度必须重新分配块并重算 prompt。

显存不足时的抢占

块不足时,调度器挑选被抢占对象(按调度策略,通常是最低优先级或最后加入的序列),把它置为 PREEMPTED 并释放其 KV 块:

策略 动作 代价
recompute(重算) 释放该序列的 KV 块,退回等待队列,下次从头重算 prefill 已处理 token 被重算,算力重复消耗

v1 的抢占恒为重算,没有换出这条替代路径,代价落在算力上。

重算对吞吐的挤占

被抢占序列回到等待队列,重新被调度时,它的 prompt 要从头重算。重算的 token 数与「该序列已被抢占前算过的 token 数」成正比。并发越高,KV 池被占满越快,抢占越频繁,重算量随之上升。

到某个并发点后,新增算力被重算抵消:--max-num-batched-tokens 的预算里被重算 token 挤占,真正产生新输出的 token 变少。表现为吞吐曲线先升后平,越过拐点后下降;同时调度步骤变多,每步固定开销上升,进一步压低有效吞吐。

与 prefill 干扰的关系

吞吐下降不一定只来自重算。高并发下若同时有长 prompt 到达,一次大 prefill 会把单步设备打满,同批 decode 序列的 ITL 陡增,等待队列里的序列被拖住,调度循环整体变慢。两类现象常并发出现,须分开记录:抢占的特征是 vllm:num_preemptions_total 上升、KV 池占用贴近满;prefill 干扰的特征是 ITL 随长 prompt 到达出现尖峰,而 KV 池占用未必满。前者压并发,后者按 chunked prefill 处理。

现象

第二种现象与显存不足的静默形态一致:没有报错,服务不崩,但实际同时运行的序列数低于期望。

定位步骤

  1. 读队列与运行数:vllm:num_requests_running 与 vllm:num_requests_waiting。等待队列持续非零且运行数低于设定值,指向 KV 块不足。
  2. 拆等待原因:vllm:num_requests_waiting_by_reason{reason="capacity"} 与 reason="deferred"。capacity 是没有容量、需加资源;deferred 是被瞬时约束(如 adapter 预算、KV 传输)挡住,通常自行恢复。两类处置不同,不能合并看。
  3. 读 KV 池占用:vllm:kv_cache_usage_perc 贴近 1,说明池子已满。
  4. 读抢占计数与重算量:vllm:num_preemptions_total 是累计计数器,取窗口差分得近段抢占次数;重算 token 数按同法取。
  5. 算重算比例:重算 token 数除以本段时间处理的总 token 数。比例上升说明算力被重复消耗。
  6. 单变量对照:固定请求集与并发,只改一个参数(先改 --max-num-seqs),看吞吐曲线拐点是否右移。拐点右移说明此前受 KV 块约束。

处置

参数 作用 代价 / 前提
--max-num-seqs 调小 降低同时驻留序列数,减少 KV 压力与抢占 峰值并发下降
--gpu-memory-utilization 调大 增大 KV 池,容纳更多序列,减少抢占 挤占激活与工作区;prefill 峰值上升时仍可能失败
--max-num-batched-tokens 调小 压平 prefill 峰值,为 KV 让出预算 单批吞吐下降
--max-model-len 调小 压小单序列占用,减少池子压力 上下文窗口变小

处置顺序:先压 --max-num-seqs 或 --max-model-len,把并发或单序列占用降到池子能容纳的范围;再考虑调大 --gpu-memory-utilization。v1 的抢占恒为重算,没有换出可调,池子不足只能靠压并发或扩预算解决。

记录字段

# 一条连续批处理抢占记录(字段名示意)
scheduler:
  policy:                     # 调度策略,如 FCFS
  max_num_seqs:               # 设定值
  max_num_seqs_effective:     # 实际同时运行数
  gpu_memory_utilization:
  preemption_mode:            # v1 恒为 recompute
counters:
  num_requests_running:       # vllm:num_requests_running
  num_requests_waiting:       # vllm:num_requests_waiting
  waiting_capacity:           # vllm:num_requests_waiting_by_reason{reason="capacity"}
  waiting_deferred:           # vllm:num_requests_waiting_by_reason{reason="deferred"}
  kv_cache_usage_perc:        # vllm:kv_cache_usage_perc
  num_preemptions_total:      # vllm:num_preemptions_total(累计,取窗口差分)
  recomputed_tokens:          # 重算 token 数
  recompute_ratio:            # 重算 token / 总处理 token
workload:
  model_name:
  concurrency:
  input_len_distribution:
  output_len_distribution:
perf:
  throughput_by_concurrency:  # 并发-吞吐曲线(含拐点)
  itl_mean:
  itl_p95:
  ttft_p95:
status:                       # 受 KV 块约束 / 受单序列长度约束 / 不成立

小结

连续批处理每步重新组批,进入批次的前置条件是拿到 KV 块。块不足时调度器抢占序列,重算释放 KV 块后重新计算已处理 token。被抢占与重算的量随并发上升,到某个并发点后新增算力被重算挤占,吞吐曲线转为下降。定位先读 vllm:num_requests_waiting、vllm:num_requests_running 与 vllm:kv_cache_usage_perc,再算重算比例,最后用只改 --max-num-seqs 的单变量对照确认拐点。处置按压并发、调预算的顺序推进,并记录每步的代价。