“加了批处理,吞吐涨了,用户却说变慢了。”两句可以同时为真:批处理提高的是单位时间的 token 数,用户感知的是首 token 与 token 间隔。前者靠摊薄,后者靠排队。
批处理不是开关,而是一组决定“让谁等、等多久、谁先走”的参数。按顺序拆:收益来源 → 静态批与连续批 → 三个旋钮 → 抢占与公平 → 容量上限 → 观测 → 估算。
1. 收益与代价的来源
| 来源 | 机制 | 收益形态 | 代价形态 |
|---|---|---|---|
| 权重读取摊薄 | 一次前向读一遍权重,服务多个请求 | 吞吐近似线性上升(未饱和前) | 需攒批,产生等待 |
| GPU 利用率 | 矩阵计算填满,空转减少 | 单位算力成本下降 | 批越大,排队越久 |
| 显存带宽 | decode 阶段受带宽限制,批越大越划算 | decode 吞吐提升明显 | KV 缓存占用同步增长 |
关键分界:prefill 是算力密集,decode 是带宽密集。批处理对 decode 的收益远大于 prefill,这也是两者后来被分开调度的原因。
2. 静态批与连续批
| 维度 | 静态批 | 连续批(continuous batching) |
|---|---|---|
| 批的构成 | 攒满或超时后固定,整批同进同出 | 每步重排,完成的请求立刻出队、新请求立刻入队 |
| 长请求影响 | 短请求被拖到与长请求同时结束 | 短请求先完成并释放槽位 |
| GPU 空转 | 批内最长请求决定整体耗时,尾部空转 | 大幅减少空转 |
| 实现复杂度 | 低 | 高(需分页 KV、增量调度) |
| 结论 | 仅在请求长度高度一致时有意义 | 通用场景几乎总是更好 |
静态批:for step in range(max_len): forward(batch) # 全员陪跑 max_len 步
连续批:loop:
finished = [r for r in running if r.done]
running = running - finished + admit(waiting, free_slots)
forward(running) # 每步重排
连续批的代价是调度器与 KV 管理变复杂。它几乎总是更优,因为它把“批内同质”从假设变成每步维持的不变量。
3. 三个旋钮与它们的相互挤压
| 旋钮 | 调大 | 调小 | 挤压的对象 |
|---|---|---|---|
| 最大批大小 | 吞吐↑、显存占用↑ | 吞吐↓、单请求更快 | KV 缓存容量 |
| 最大等待时间 | 批更容易攒满、吞吐↑ | TTFT↓ | 尾延迟分布 |
| 抢占策略 | 激进的抢占保吞吐 | 温和策略保单请求延迟 | 重算成本、尾延迟 |
三者不独立:批越大,攒批等待越容易超时;抢占越频繁,被抢占请求要重算 prefill,反而更耗算力。调参前先明确保护哪个指标。
# 调度参数(字段名按 vLLM / TGI 版本核对)
scheduler:
max_num_seqs: 256 # 同时在跑的请求数上限
max_num_batched_tokens: 8192 # 一步的总 token 预算
max_waiting_time_ms: 50 # 攒批等待上限
preemption_mode: recompute # recompute | swap
enable_chunked_prefill: true
两个参数维度不同:前者限一步算多少 token,后者限同时在跑几个请求。只调后者,长提示会一步吞掉 token 预算,把 decode 挤到停摆。
4. prefill 与 decode 是否该分开调度
| 方案 | 机制 | 收益 | 代价 |
|---|---|---|---|
| 混在一起 | 一步里既有 prefill 也有 decode | 实现简单 | 长 prefill 阻塞所有 decode,TTFT 与 TPOT 一起抖 |
| chunked prefill | 把长提示切成多个小片,与 decode 交错执行 | decode 不被长时间饿死,尾延迟更稳 | 首 token 变慢,调度更复杂 |
| 完全分离 | prefill 与 decode 走不同实例/队列 | 各自最优 | 需要 KV 传输,架构复杂度高 |
chunked prefill 的动机明确:用一点 TTFT 换掉大部分 TPOT 抖动。长提示与短对话混跑时收益通常为正;请求都很短时收益有限。
5. 长请求阻塞与公平性
| 现象 | 机制 | 缓解 |
|---|---|---|
| HOL blocking | 长请求占住批内槽位,短请求排在后面 | 连续批 + 按优先级入队 |
| 长输出独占带宽 | decode 每步都要读 KV,长输出占用更久 | 输出长度上限、分段返回 |
| 大请求饥饿小请求 | 调度按到达顺序 | 分队列(按租户/接口分级) |
| 小请求反复插队 | 高优先级队列无上限 | 给高优先级设容量上限,防饿死低优先级 |
公平性的本质是给快请求一条不被慢请求堵住的通道,同时防它无限插队。常见做法是加权轮询加队列上限,而非纯优先级。
6. KV 缓存才是并发上限
批大小的真实上限由显存决定,而不是由参数决定。KV 缓存占用 ≈ 2 × 层数 × 序列长度 × 隐藏维度 × 精度字节数 × 并发数。把序列长度取平均值,就能反解出能同时容纳多少请求。
| 约束 | 决定 | 超限表现 |
|---|---|---|
| KV 缓存容量 | 实际并发数 | 抢占变频繁,或请求被拒 |
| 权重占用 | 可用于 KV 的显存 | 上下文长度上限被压低 |
| swap_space | 被抢占请求的落地空间 | 换出/换入抖动,延迟尖刺 |
可并发数 ≈ (显存总量 × gpu_memory_utilization − 权重占用)
÷ (2 × layers × avg_seq_len × hidden × dtype_bytes)
典型量级:7B 级模型单卡通常落在数十并发,以实测显存占用为准。
7. PagedAttention 这类方案解决什么
它解决 KV 缓存的碎片与共享:传统做法按请求预留连续显存,长度不定必然浪费;分页后按块分配,接近按需增长。
| 问题 | 传统连续分配 | 分页方案 |
|---|---|---|
| 内部碎片 | 预留过长 → 浪费 | 块粒度分配,浪费有界 |
| 前缀共享 | 需复制多份 | 相同前缀块可共享引用 |
| 调度灵活性 | 无法动态扩缩 | 可逐块增长,支持抢占换出 |
对决策者的意义:它抬高的是同等显存下的并发上限,不是单请求速度;能让你把批调大而不立刻 OOM,但不会消除排队。
8. SLA 分级:不同租户走不同队列
| 队列 | 参数取向 | 适用 |
|---|---|---|
| 交互式(在线对话) | 小批、短等待、保 TTFT | 人直接等待的场景 |
| 批处理(离线摘要) | 大批、长等待、保吞吐 | 可接受分钟级完成 |
| 内部评估 | 尽力而为,可被抢占 | 评测、回归跑批 |
分级的实质是把不兼容的延迟要求物理隔开:离线批任务只要走进交互队列,就一定拉坏 TTFT 分布,调参救不回来。
9. 超时与取消的传播
用户断开后算力是否释放,取决于取消信号有没有传到调度器。没传到时,已离开的客户端仍占着批槽与 KV,表现为“利用率高但没人收到结果”。
# 取消必须落到三步,缺一步算力就漏
async def stream(req):
try:
async for tok in engine.generate(req):
yield tok
finally:
await engine.abort(req.id) # 1 从等待/运行队列移除
# 2 释放其 KV 块
# 3 取消未完成的 chunked prefill 片段
反向也要设服务端超时:客户端一直连着但不读,等于把算力挂在一个不消费的连接上。
10. 扩容与批处理的交互
| 事实 | 后果 |
|---|---|
| 批越大,单实例的“最小有效负载”越高 | 扩容颗粒变粗,流量低谷时余量浪费 |
| 实例数增加,单实例批变小 | 吞吐收益变差,扩容边际递减 |
| 延迟目标是硬约束时 | 先定延迟,再反推批与实例数 |
批处理与扩容是同一件事的两种解法:攒批在时间上摊平负载,扩容在空间上摊平。低谷扩容没用,高峰攒批来不及——边界由流量形状决定。
11. 观测:四个分布,不是四个平均值
| 指标 | 看什么 | 异常 |
|---|---|---|
| 队列深度 | 等待中的请求数随时间 | 持续上升 → 容量不足或调度卡死 |
| 批大小分布 | 实际运行的批有多大 | 长期远低于上限 → 流量稀疏,批没意义 |
| TTFT 分布 | 首 token 延迟的 P50/P99 | P99 远高于 P50 → 被长请求阻塞 |
| TPOT 分布 | token 间隔 | 抖动 → 抢占频繁、换出换入 |
| GPU 利用率 | SM 与显存带宽 | 高利用率 + 高排队 → 该扩容 |
只看均值会掩盖尾部:批处理最典型的副作用是 P50 变好、P99 变差。指标必须按分位、按队列分开看。
12. 容量估算的顺序
1 定延迟目标:TTFT P99 ≤ A,TPOT P99 ≤ B (先定这个)
2 由 B 反推最大批与最长等待时间
3 由显存反推最大并发(KV 缓存容量)
4 取 2、3 的较小值作为单实例容量
5 用高峰 QPS × 平均输出长度 估算所需实例数
6 压测验证,看 P99 是否守住;守不住则减批或扩实例
顺序不能反。先定批再定延迟,结论只能是“实测后才知道”,而实测已经发生在生产上。
13. 排错:六种“批处理惹的祸”
| 现象 | 原因 | 处理 |
|---|---|---|
| 吞吐涨、P99 延迟恶化 | 攒批等待 + 抢占 | 降 max_waiting_time,减小批上限 |
| 长提示一来全体卡顿 | prefill 与 decode 混跑 | 开 chunked prefill |
| 利用率高但吞吐不涨 | 抢占重算消耗算力 | 检查抢占频率与换出策略 |
| 短请求延迟随机尖刺 | 被长输出请求占住槽位 | 按输出长度上限或分队列 |
| 显存充足但并发上不去 | KV 块粒度、碎片或 swap 抖动 | 核对块大小与 swap_space |
| 用户断开后显存不下降 | 取消未传播到调度器 | 在 abort 路径释放 KV 块 |
14. 边界:什么时候批处理本就不该上
| 场景 | 更合适的做法 |
|---|---|
| 请求稀疏(分钟级一次) | 直接单请求推理,攒批只增加延迟 |
| 强实时(需确定性响应) | 不排队,用预留容量换确定性 |
| 单请求极大(超长上下文) | 批不起来,靠量化/并行或分片处理 |
| 全部请求长度高度一致 | 静态批即可,连续批的复杂度不划算 |
| 模型极小、单请求已打满带宽 | 批处理的边际收益接近零 |
一句话:批处理的价值来自“同时有多个请求”。没有并发,它只是给每个请求多加一段等待。
15. 自测
- 为什么批处理对 decode 的收益大于对 prefill 的收益?
- 连续批为什么几乎总是优于静态批?它牺牲了什么复杂度?
max_num_batched_tokens与max_num_seqs各限制什么?只调一个会怎样?- chunked prefill 是用什么换什么?
- 为什么说并发上限由 KV 缓存决定而不是由批大小参数决定?
- 容量估算为什么必须先定延迟目标再定批大小?