主题
调度与连续批处理
一句话:连续批处理让每个 decode step 后重新组批,谁完成谁走、谁新到谁进,消除了“等最慢请求”的空转;吞吐的相对提升取决于长度分布和并发,代价是调度、抢占和尾延迟更难控制。
为什么需要
静态批处理把一批请求打包送进 GPU,等整批全部生成完才换下一批。设一批 10 个请求,9 个要 20 token、1 个要 2000 token:前 20 步 10 路都在干活,之后 1980 步只有 1 路在跑,其余 9 路空转。新请求也只能排队等整批结束。
推理请求的长度分布通常极不均匀,输出长度更是不可预测。按“批”管理会把最慢请求的成本摊给所有人,这就是连续批处理要解决的核心矛盾。
核心机制
吞吐倍数怎么估
先给结论:常被引用的“10–20×”是相对静态批处理的经验区间,不是可承诺的倍数。能算的只有当前这组数据的上限。
演示里的 1/10 和 1/4 是两套场景,不是同一组数据:
- 9×20 + 1×2000:10 路并发,1 路长请求,长度比 100×。静态批处理的 makespan 是 2000 步,有效输出只有 2,180 token,槽位利用率约 11%,上限约 9×。
- A20 / B50 / C30 / D200:4 路并发,1 路长请求,长度比 10×。静态批处理等 200 步,有效输出 300 token,槽位利用率 300/800=37.5%,上限只有约 2.7×。
换成 64 路并发的另一组数(10 长 54 短),静态批处理在 20 步之后基本只剩 10 路有活干,粗略按“64 路里只有 10 路在跑”算,上限是 64/10≈6.4×;把短请求那 1,080 个输出 token 的有效工作也算进去,理想情况需要 (10×2000+54×20)/64≈330 步,对应约 6.1×。
所以倍数是长度分布和并发共同决定的:短请求越多、长短差距越大、并发越高,静态批处理的空转越严重,相对收益越大;请求长度整齐时,连续批处理几乎没有收益,只剩调度开销。
三级演进
动态批处理改善了“等固定数量”,但批一旦组好仍然不能变。连续批处理把调度粒度从“批”降到“每个 decode step”:每步结束后,完成的序列退出,等待队列里的新序列补进来,显存不够时换出或重算。
一步之内发生了什么
- 调度器查看等待队列和运行队列。
- 决定这一步给 prefill 多少 token、给 decode 多少 token。
- 为需要的序列分配 KV 块;分配失败则触发抢占或让新请求继续等待。
- 组出这一步的 batch,送进模型。
- 采样出下一个 token,完成的序列释放块并返回结果。
- 重复,直到没有可运行序列。
关键在“每步重排”。这让 GPU 在每个 decode step 都尽量填满,而不是等一整批结束。吞吐提升来自空转减少,不是单步算得更快。
分块 prefill
长 prompt 的 prefill 会占用大量 token 预算和瞬时显存。如果一次性处理完整段,decode 请求在这一步得不到资源,TPOT 出现尖峰;新请求的 TTFT 也被拉长。
分块 prefill 把长 prompt 切成多个 chunk,分散到多个 step 处理,每步给 decode 留出固定预算。收益是尾延迟更平稳,代价是 prefill 的总步数增加,首 token 可能略晚。
调度器每步都在做预算分配:
text
总预算 = max_num_batched_tokens
decode 预算 = 总预算 - 本步 prefill chunk如果 prefill chunk 占满总预算,decode 只能等;如果 decode 序列很多,prefill 又会被压缩,长请求首字变慢。这个参数没有通用最优值,要按在线 / 离线场景调。
它的默认值随引擎版本和是否开启 chunked prefill 变化:常见 8192,旧版本可能是 2048 或跟随 max_num_seqs。不要按某个固定数字写死,以目标版本的 --help 为准。
调度策略
| 策略 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 先来先服务 | 延迟可预测,实现简单 | 长请求会阻塞后面 | 大多数在线服务 |
| 优先级 | 关键请求先跑 | 低优先级可能饥饿 | 在线与离线混跑 |
| 最短剩余优先 | 平均完成时间短 | 长请求易饥饿 | 批处理 |
| 公平调度 | 多租户不互相挤占 | 实现复杂 | 平台多租户 |
FCFS 在推理里是合理默认:请求到达顺序与服务体验大体一致,P99 可预测。优先级适合“实时客服 + 离线报告”共享集群,但必须配老化机制,否则低优先级永远排不上。
抢占:显存不够时怎么办
当新请求或序列增长需要块,而空闲块不足,调度器必须选一个受害者:
- 换出:把受害者的 KV 复制到 CPU,保留进度;需要时换回。适合序列不长、CPU 内存充足、互连带宽高的场景。
- 重算:丢弃 KV,之后从 token 重新计算。省 CPU 内存,但长序列重算代价高。
- 拒绝 / 排队:不抢占,让新请求等待。适合延迟不敏感或容量可预测的场景。
FCFS 下通常从最近进入运行的请求开始选,因为它的已算进度最少,重算代价最低。频繁抢占说明容量不足,应该降并发、加显存或分池,而不是把抢占当常态。
尾延迟从哪里来
| 现象 | 可能原因 | 处理 |
|---|---|---|
| 平均 TTFT 正常,P99 很高 | 长 prompt prefill 挤占预算 | 分块 prefill、限制单请求长度 |
| 平均 TPOT 正常,P99 很高 | 抢占、batch 波动、显存压力 | 降并发、留显存余量、分池 |
| 两者都高 | 排队过长或 GPU 饱和 | 加副本或降负载 |
| 吞吐高但体验差 | batch 太大 | 限制 batch,按 SLA 调 |
只看平均会把尾延迟问题藏起来。在线服务应该按 P95 / P99 设告警,并分别看 TTFT 和 TPOT。
实战场景
场景:静态组批 64 路并发,槽位利用率只有 16.5%
现象:自研推理服务按 64 条上限、50 ms 窗口组批,整批跑完才返回。夜间报告生成 64 路并发,输出长度 P50 为 20 token、P99 为 2,000 token,吞吐 420 tok/s(经验值,需校准),显存占用 61%、SM 利用率 34%,P99 完成时间 52 s,4 小时的任务跑到 6 小时。
排查路径:先读已有利用率指标(零成本),再试加卡证伪(可回滚),然后看组批方式(改代码),长度分布最后用来算收益上限(数据侧,只读)。
| 排除对象 | 判定证据 | 结论 |
|---|---|---|
| 算力或显存不足 | 加一张卡后吞吐 420→440 tok/s,显存 61%,SM 34% | 排除:资源有余量,加卡无效 |
| 网关或后处理阻塞 | 引擎 step 间隔稳定 24–26 ms,网关等待占比不到 3% | 排除:时间空在 GPU 步与整批等待里 |
| 静态组批的槽位空转 | 批内 64 路在第 20 步后只剩 10 路在跑;makespan 2,000 步、有效输出 21,080 token | 确认:槽位利用率 16.5% |
| 长度分布 | 输出 P50 为 20、P99 为 2,000,长度比 100×;按 (10×2000+54×20)/64≈330 步算,上限 6.1× | 确认:分布决定收益上限 |
对症做法:迁移前先用这组数算清倍数上限,再换连续批处理,避免拿 10–20× 当验收标准。
python
long_, short_, n = 2000, 20, 64
steps_static = long_ # 整批等最慢请求,2,000 步
steps_ideal = (10 * long_ + 54 * short_) / n # ≈ 330 步
print(f"slot_util={21080 / (n * long_):.1%} tps={21080 / (steps_static * 0.025):.0f}")
print(f"speedup_cap={steps_static / steps_ideal:.1f}x") # 6.1x,实测切换后 4.9x
# 引擎侧:scheduler=continuous, max_num_seqs=64, max_num_batched_tokens=8192静态批处理的 makespan 由批内最长请求决定,槽位利用率 = 有效输出 / (并发 × 最长输出) = 21,080/128,000 = 16.5%,倍数上限 = 2,000/330 ≈ 6.1;实测 4.9 倍与上限的差额是重排与调度开销,正是正文说的倍数不可承诺。
复盘要点:判据是“先算槽位利用率再决定换不换调度器,利用率低于 50% 才有 2 倍以上的空间”。更早的信号是批内活跃序列数:第 20 步就掉到 10 路,而当时只监控 GPU 利用率(34% 看着像没压满),把活跃序列数 / 并发数低于 30% 设成告警,可以提前一周发现。
场景:8K 摘要吃满 prefill 预算,对话首字从 1.1 s 涨到 6.9 s
现象:混合池同时跑 8K 文档摘要(30%)与 300 token 对话(70%),max_num_batched_tokens=32768,开了分块 prefill 但分块上限等于总预算。对话 TTFT P99 从 1.1 s 涨到 6.9 s(经验值,需校准),P50 TPOT 仍是 58 ms,P99 TPOT 出现 6.8 s 尖峰;摘要自身首字反而从 2.4 s 降到 1.9 s,团队因此认为参数没问题。
排查路径:先看单条 prefill 是否退化(复测一次,零成本),再区分网关排队与引擎排队(读两侧指标),然后看单步预算分配(改配置),最后看长度与到达顺序(只读)。
| 排除对象 | 判定证据 | 结论 |
|---|---|---|
| 模型或算力退化 | 300 token prompt 的 prefill 仍为 0.07 s,与上周同一镜像一致 | 排除:算力没变 |
| 网关排队 | 网关等待 0.1 s;引擎 waiting 队列 0→31,最长等待 6.4 s | 排除:排队在引擎侧 |
| prefill 预算分配 | 单步 token 分布:90% 的步吃满 32,768,prefill 占 100%、decode token 为 0 | 确认:chunk 上限等于总预算 |
| 请求长度与顺序 | 8K 摘要占 30%,FCFS 下四条 8K 先拼满 32,768 的步,对话排在其后约 6.5 s | 确认:预算与顺序叠加 |
对症做法:把 chunk 上限压到总预算以下,差额留给 decode,长 prompt 多走几步换稳定输出。
yaml
# 预算要同时喂 prefill 与 decode:chunk 上限必须小于总预算
max_num_batched_tokens: 8192
enable_chunked_prefill: true
max_num_partial_prefills: 1
long_prefill_token_threshold: 2048 # 超过则按 2,048 一块切
# 单步 prefill 不超 2,048 token ≈ 0.4 s,剩 6,144 token 预算留给 decode正文的预算是 decode 预算 = 总预算 − 本步 prefill chunk;chunk 上限等于总预算时 decode 预算恒为 0,decode 只能在两个 prefill 步之间走一步,于是 TPOT 尖峰等于一个 prefill 步的时长,首字被排队拖长。
复盘要点:判据是“分块 prefill 的 chunk 必须小于总预算,留出的差额就是 decode 的预算,chunk 等于预算等于没开”。更早的信号是单步 prefill 占比:它到 100% 那天对话 P99 才 2 s,把“prefill 步占比超过 70%,或 decode token 为 0 的步超过 10%”设成告警,比首字告警早约 40 分钟。
场景:大促平均延迟只涨 30%,P99 完成时间涨到 47 s
现象:客服池 max_num_seqs=128,日常 96 并发,大促 20:00 起并发到 140,到达 980 req/min、平均输出 280 token。平均 TTFT 只从 0.95 s 涨到 1.25 s,P99 TTFT 从 1.1 s 涨到 9.4 s(经验值,需校准),P99 完成时间从 8 s 到 47 s,超时率 0.5%→12%;抢占 0→11 次/分钟,其中 78% 落在输出超过 1,200 token 的请求上,这类请求只占流量 6%,却贡献了六成投诉。
排查路径:先排除重试放大(读入口指标,零成本),再确认单请求没退化(batch 1 复测),然后看排队(只读),最后看抢占与重算(需要日志关联)。
| 排除对象 | 判定证据 | 结论 |
|---|---|---|
| 重试风暴 | 429 比例 0.1%,客户端重试率 2%,入口 QPS 与并发同步上涨 | 排除:不是重试放大 |
| 单纯算力不足 | SM 利用率 78%,batch 1 复测 TPOT 21 ms 未变 | 排除:算力不是唯一瓶颈 |
| 排队 | waiting 队列 0→38,4 分钟内单调上涨;max_num_seqs 128 小于并发 140 | 确认:排队是尾延迟起点 |
| 抢占与重算 | 11 次/分钟抢占,78% 命中长请求;被抢占后重算 prefill 再回队尾 | 确认:排队与抢占叠加 |
对症做法:按容量缺口加副本,按 4K 长度而不是均值定并发,超长等待快速失败而不是无限排队。
yaml
replicas: 3 # 2 → 3:KV 上限下每副本约 47 路,2 副本接不住 140 并发
scheduler:
max_num_seqs: 40 # 38 GB / (4096 × 192 KiB) ≈ 47,留 15%
max_num_batched_tokens: 8192
queue:
max_waiting: 24 # 超过即 429,别让排队把 P99 拖到 47 s
aging_after_s: 20 # 低优先级等 20 s 升一级,防饥饿尾延迟是排队 + prefill + 生成三项相加,利用率逼近 1 时排队项非线性上涨;抢占把已完成进度清零,等于让同一个长请求再付一次 prefill,所以 P50 被 94% 的短请求摊平,P99 落在“长请求 × 抢占次数”上。
复盘要点:判据是“P99/P50 超过 10 且 preemption 大于 0 时按容量问题处理,先加副本、再降 max_num_seqs,不要先调 batch”。更早的信号是 waiting 队列:它到 10 时 P99 还是 1.8 s,而当前告警设在 P99 超 5 s,比队列开始增长晚约 20 分钟。
最小上手
用固定负载跑三组配置,观察拐点:
text
配置 A:max_num_batched_tokens 小,max_num_seqs 适中 → 低延迟
配置 B:两者都大 → 高吞吐,尾延迟差
配置 C:开启分块 prefill,限制单请求长度 → 平稳
每组跑 5 分钟,记录 P50/P95/P99、吞吐、抢占次数判据:找到“吞吐不再明显上升、P99 开始陡增”的位置,生产配置应停在这个拐点之前,而不是贴着极限跑。
生产动作
- 在线离线分池:不要让离线大 batch 和在线低延迟共享同一个引擎实例。
- 限制单请求长度:超长请求单独路由或排队,避免挤占公共预算。
- 监控抢占:抢占次数、换出量、重算量都要有指标;持续非零说明容量不足。
- 按 SLA 调参:先定 P99 目标,再调 batch 和预算,不要反过来。
- 灰度变更:调度参数、引擎版本、块大小变更都按灰度发布,比较尾延迟和吞吐。
- 保留余量:GPU 利用率和 KV 使用率都不要长期贴近 100%,峰值没有缓冲。
边界
- 连续批处理提升的是吞吐,不是单请求速度:单请求的 TPOT 仍受带宽和模型大小限制。
- 倍数不可承诺:10–20× 是特定负载分布下的经验区间;长度整齐或并发很低时,收益可能只有一两倍。
- batch 越大尾延迟越差:高并发下每个请求分到的算力和带宽减少,P99 会先恶化。
- 分块 prefill 有额外开销:chunk 边界带来重复的元数据和调度成本,短 prompt 场景收益有限。
- 抢占策略依赖硬件:CPU 内存、PCIe 带宽、序列长度都会改变 swap 与 recompute 的取舍。
- 调度器不能解决容量不足:KV 不够时,任何策略都只是决定谁先受苦。
讲清楚
连续批处理把调度粒度从“整批”降到“每个 decode step”:完成的退出、新到的补入、显存不够时换出或重算。吞吐提升来自减少 GPU 空转,不是单步变快;相对提升由长度分布和并发决定——按 64 路并发、10 长 54 短这组数只能算出约 6.4×,10–20× 只是经验区间,不能当承诺。分块 prefill 用“把长 prompt 切开”换取 decode 的稳定预算,max_num_batched_tokens 同时决定 prefill chunk 和 decode 并发,默认值随版本和 chunked prefill 开关变化,要按目标版本确认。生产上先定 P99 目标,再找吞吐拐点,在线与离线分池,持续监控抢占;容量不足时,调度策略只能决定谁先受影响,不能凭空变出显存。
适用:vLLM 等连续批处理引擎;调度参数与默认值以实际版本为准 | 初稿 2026-10