3100 字
约 10 分钟
20
S23-A06 · 推理批处理与调度:吞吐换延迟的具体账

“加了批处理,吞吐涨了,用户却说变慢了。”两句可以同时为真:批处理提高的是单位时间的 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. 自测

  1. 为什么批处理对 decode 的收益大于对 prefill 的收益?
  2. 连续批为什么几乎总是优于静态批?它牺牲了什么复杂度?
  3. max_num_batched_tokens 与 max_num_seqs 各限制什么?只调一个会怎样?
  4. chunked prefill 是用什么换什么?
  5. 为什么说并发上限由 KV 缓存决定而不是由批大小参数决定?
  6. 容量估算为什么必须先定延迟目标再定批大小?
S23-A06 · 推理批处理与调度:吞吐换延迟的具体账
https://www.clxhxhhr.top/posts/6162/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。