2228 字
约 7 分钟
14
S23-A23 · 批处理与流式:为什么"加了流式"吞吐掉一半

一个推理服务原本"攒批 300ms 再返回",P99 1.2 秒、吞吐 900 tok/s。改成"流式返回首 token"后,首字延迟从 800ms 降到 200ms,而吞吐掉到 520 tok/s——因为流式让"批内序列"无法再等彼此,于是"批变小、GPU 利用率下降"。

批处理与流式是两种不同的优化目标:批处理优化"吞吐",流式优化"首字延迟"。而它们在"调度"这一层是冲突的——因为"攒批"必须等,"流式"必须尽快发。

1. 两种目标的度量

目标 指标
吞吐 tokens/s(整体)
首字延迟 TTFT(p50/p99)
完整延迟 端到端 p99

第一项与第二项常常"反向":"等 300ms 攒批"让吞吐高,但 TTFT 至少 300ms。这个"反向"可以量化:把等待从 300ms 降到 30ms,TTFT 改善 270ms,而 batch 可能从 16 掉到 4——吞吐掉一半,首字快一倍,而这正是"体验与成本"的交换。

2. "攒批"为什么提高吞吐

机制 说明
GPU 并行 一个 batch 的矩阵运算"摊薄"了 per-token 成本
权重读取复用 一次读权重,服务多条序列
调度开销摊薄 每次调度服务更多 token

第一项的"数字感"很直观:一条序列占不满一个 SM 阵列,于是"batch 8"相当于"把 8 条挤进同一批矩阵乘"——per-token 成本降到 1/6–1/4。
第一项是"根本":GPU 的算力在"小 batch"下大量闲置——于是"batch 从 1 到 8"通常能带来"2–4 倍吞吐"。

3. "流式"为什么会掉吞吐

原因 机制
批内"最长者"拖尾 短序列等长序列
无法"等更多请求" 每个请求都要快发
KV 缓存碎片 序列结束时释放,新序列插入

第一项在"长度差异大"时最明显:**批里一个 4000 token 的请求,让 20 个短请求"陪跑"**第一项的"利用率"可以算:批内平均有效长度 / 最长长度——20 个 100 token + 1 个 4000 token,利用率只有 ~50%。
——而流式下"短请求早就结束了却占着位置"。

"连续批"的核心是一张"每步状态表",而它解释了为什么"短序列能退出":

// 一次"每步调度"(示意)
step k:  活跃序列 [A(剩 3), B(剩 7), C(剩 1)]
step k+1: C 完成 → 退出并释放 KV;新请求 D 加入(若有空位)
step k+2: A、B、D 共享"同一批矩阵运算"
// 收益:B 不再"陪跑到 7",而 D 不必"等整批结束"

4. 三种"共存"的做法

做法 效果
连续批(continuous batching) 兼顾(主流)
双队列(流式队列 + 批队列) 简单(按场景分流)
分池(交互/离线两套) 最稳(成本高)

第一项的"难点"是"调度开销":每步都要"算谁完成、谁能加入"——而在"批很大"时这点开销可忽略,在"批很小"时反而变显著。
第一项是"现代引擎的解法":每步重新组批,完成的序列退出、新请求加入——于是"流式"与"高吞吐"可以同时成立。

第三项是"产品层面的解法":**交互式请求进"低延迟池",离线批任务进"高吞吐池"**第三项的"风险"是"峰值互挤":批任务把显存占满时,交互请求"排队到超时"——所以要"硬配额"(每池固定显存),而不是"谁先来谁占"。
——于是"各自优化自己的目标"。

// 分池(两套"调度策略",共享同一批 GPU)
pools:
  interactive: { max_batch: 8,  max_wait_ms: 30,  stream: true }   # 低延迟
  batch:       { max_batch: 64, max_wait_ms: 300, stream: false }  # 高吞吐
// 关键:两池"配额"要留余量,否则"批任务"会把"交互"挤住

"该不该流式"可以用"两个数"决策,而不是"凭体验":

// 两个数一起看(同一业务)
TTFT_p50:流式 200ms / 非流式 800ms      → 用户感知差 4 倍
tokens_per_sec:流式 520 / 非流式 900    → 成本差 1.7 倍
// 判据:若"首字延迟"影响转化(如搜索/对话),接受成本;否则保吞吐

5. "TTFT"的三个来源

来源 典型量级
排队(等批) 0–300ms
prefill(算首 token) 100ms–1s(与输入长度成正比)
调度/传输 10–50ms

第二项的"优化"是"前缀缓存":同一系统提示在多个请求间复用时,prefill 可以"跳过已算部分"——于是"长输入"的 TTFT 主要取决于"新增部分"。
第二项是"长输入的痛点":4000 token 的 prefill 可能 800ms——于是"等批"只占 TTFT 的一小部分,而"优化攒批时间"收益有限。

"流式断线"要能"续",否则"用户看到半句话然后从头":

// 三段"可续"的实现
1 服务端按"请求 ID"保留已生成片段(如 30 秒)
2 客户端断线后带"已收到的 token 数"重连
3 服务端从"saved_len"继续生成(或直接返回剩余)
// 判据:断线重连"不重算已生成部分",用户"看不到重复文字"

6. 排错:五种"流式相关问题"

现象 成因 处理方向
加了流式吞吐腰斩 批变小 连续批/分池
首字快但整段慢 prefill 未优化 前缀缓存/分块 prefill
尾包延迟大 批内最长者拖尾 限制批内长度差
断线后重连重算 无"续传" 记录已生成片段
流式下 OOM KV 碎片 限制并发/固定形状

"批内长度差"这条也可以直接约束,而不必"换引擎":

// 一条"低成本"的调度规则
1 组批时限制"最长/最短 ≤ 4"
2 超了另开一批(批可能装不满,但利用率上升)
3 流式请求"单独成批"(不与长请求混)
// 收益:不用换引擎,吞吐常能回来 20–40%

7. 边界:什么情况不必追求"两者兼得"

场景 为什么 做法
离线批任务(无人等) 只需吞吐 纯批
低流量(QPS < 1) 批攒不起来 纯流式
极短输出(< 20 token) 流式收益小 非流式
已用托管服务(引擎自优化) 由平台管 跟随

判断口径一句话:"用户是在"等第一句",还是"等全部结果"。最后一条与"成本"有关的边界:"流式"不便宜:它牺牲吞吐换延迟,而吞吐直接换算成"每百万 token 的成本"——于是"全站流式"往往意味着"成本翻倍"。
等第一句(对话、搜索联想),流式必要;等全部(报表、批处理),攒批更好——而"两者都要"时,先看"有没有连续批",没有就"分池"。

再补一条"分池"的实操细节:两池的"配额"要按"显存"分,而不是按"请求数"分——因为"批任务的显存占用"是"批大小 × 序列长度",而"请求数"完全反映不出它。于是"批任务 20 个长请求"可能比"交互 200 个短请求"占得多得多;只按"请求数"限流,必然在"批任务高峰"把交互池挤死。

而"TTFT"这个指标本身也有口径问题:它算不算"排队时间"——算(用户感知),还是不算(引擎能力)。报告里不写清,两个团队会拿出"同一系统、差 5 倍"的数字互相质疑。

还有一个"产品"细节:流式只在"真的会滚动显示"时才有意义——如果前端"收完再渲染",那 TTFT 的改善是零,而吞吐的损失是实的。所以"改流式"应当先确认"前端是否边收边画"。

8. 自测

  1. 批处理与流式分别优化什么指标?为什么它们"在调度层冲突"?
  2. "攒批"提高吞吐的三个机制是什么?为什么"batch 1→8"常带来 2–4 倍?
  3. "流式掉吞吐"的三个原因里,哪一个在"长度差异大"时最明显?
  4. "连续批"如何让两者共存?它与"分池"的差别是什么?
  5. TTFT 的三个来源里,长输入时哪一个占主导?它意味着什么?
  6. 判断"该流式还是该攒批"的那个问题是什么?
S23-A23 · 批处理与流式:为什么"加了流式"吞吐掉一半
https://www.clxhxhhr.top/posts/6653/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。