2582 字
约 8 分钟
17
S23-A18 · 攒批窗口:为什么"再多等 20ms"换不来吞吐

一个推理服务设了"攒批窗口 20ms":请求到达后等 20ms,把这段时间内的请求凑成一批一起算。上线后吞吐涨了 40%,而 P99 从 300ms 涨到 900ms——因为高峰期排队叠加:每批都等 20ms,而队列在增长,于是等待被放大成"批次 × 20ms"。

攒批(batching window)是用延迟换吞吐的经典手段,而它的收益与代价都不是线性的:而"拥塞时爆炸"这一点很少被写进配置说明:同一个 20ms 在低负载下是 20ms,在高负载下可能是 400ms,因为等待被"排队"放大了。
窗口过短没效果,过长会让延迟在拥塞时爆炸。


1. 三个参数的关系

参数 作用
窗口时长(window) 最多等多久
最大批大小(max_batch) 一批最多几个请求
并发批数(in-flight) 同时能算几批

三者的组合决定行为:窗口决定"最小延迟",批大小决定"吞吐上限",并发批数决定"能否重叠"。

关键机制是"先到者与后到者互相牵制":一个请求最多等窗口时长,但如果它到的时候队列已满,它要等前面的批次算完。这个牵制有个"正反馈":队列越长,新请求等待越久,而等待越久又有更多请求进入队列。所以攒批系统在过载时的退化是"非线性"的,而不是缓慢变差。

2. 为什么"再等一点"可能完全没收益

情形 结果
请求本来就密集(>100/s) 窗口内自然凑满,再加窗口无收益
请求稀疏(<10/s) 窗口内只有 1–2 个,攒批无意义
长请求混入 短请求被强绑长请求
批次内长度差异大 按最长者对齐,浪费算力

第一项是最常见的误判:"加窗口 → 吞吐上升"其实是因为"批次更满",而当批次已满时,再加窗口只会增加延迟。

第四项还有"显存"层面的后果:一批的显存需求按"批次内 token 总和 + 最长序列的 KV"算,所以混入长请求会同时抬高"峰值显存"与"计算时间"。
第四项是"批次内长度不齐"的代价:一批里 9 个短请求 + 1 个超长请求,整批按最长者算,于是"9 个短请求等 1 个长的"。

3. 延迟的三段构成

段 来源
窗口等待 0 到 window
排队等待 取决于队列长度与批数
计算 批次大小 × 每 token 时间

第二段是"被忽略的那一段":它不在配置里,而在负载里。第二段的估算可用一个粗公式:排队时长 ≈ 队列长度 × 单批耗时 / 批数。它解释了"为什么批数翻倍能减半排队"——但代价是显存与调度开销。
队列长度 = 到达率 × 服务时间,而当它超过"批数 × 批大小"时,等待就开始累积。

所以"窗口 20ms"在低负载下意味着"最多多等 20ms",在高负载下意味着"多等到能挤进下一批"——同一配置在两种负载下体验完全不同。

// 同一窗口在两种负载下的延迟
低负载(20 QPS,批数 4,批大小 32):
  窗口 0–20ms + 排队 ≈ 0 + 计算 50ms ≈ 70ms
高负载(400 QPS,队列满):
  窗口 0–20ms + 排队(队列 30 × 50ms / 4 批)≈ 375ms + 计算 ≈ 425ms
// 结论:延迟爆炸来自"排队",不是"窗口"

4. 三种缓解手段

手段 效果
窗口自适应(按队列长度调整) 拥塞时缩短窗口
短请求快速通道 不受长请求牵制
队列长度上限 + 拒绝 把排队变成拒绝

第二项最有效但改造大:把"短请求"单独成池,按预估 token 数分流。代价是两池不能互相借用容量。

第三项的前提是"客户端会尊重 Retry-After"。若客户端是"立即重试"的,那拒绝会变成"重试风暴"——所以拒绝策略要与客户端的重试行为配套设计。
第三项是"诚实"的做法:当排队超过阈值,直接返回 429 + Retry-After,而不是让用户等 2 秒。它把"不确定的慢"变成"确定的快失败"。

// 自适应窗口(按队列长度分档)
batching:
  window_ms: { low: 20, normal: 10, high: 4 }
  queue_thresholds: { normal: 16, high: 64 }
  max_batch_tokens: 16384
  reject_when_queue_gt: 128        # 超过即拒绝,不做无限排队
// 关键:把"拥塞时的窗口"缩短,把"排队"变成"拒绝"

"攒批是否值得"可以先用一个算式粗判,再决定要不要上线:

// 收益比 = 批大小带来的吞吐提升 / 窗口带来的延迟增加
无攒批:T 次/秒,每次延迟 L
攒批后:T × k 次/秒(k = 平均批大小),每次延迟 L + w/2
// 判据:k 的提升是否值得 (w/2) 的延迟
// 例:k=4(吞吐 4 倍)、w=20ms(延迟 +10ms)→ 值得
//      k=1.2、w=20ms → 不值得(只多 20% 吞吐,多 10ms 延迟)

5. 观测:三个必须能分开的数

指标 说明
窗口等待 应稳定在 window/2 左右
排队时长(P99) 拥塞信号
批大小分布 判断"攒批是否有效"

第三项最容易被忽略:如果批大小长期在 1–2,攒批完全没生效第一项(窗口等待)的期望值是"窗口的一半"(均匀到达时)。如果它显著高于一半,说明到达不均匀或系统在拖,而不是"配置问题"。
(说明请求太稀疏或窗口太短)。而这个事实只看"延迟"看不出来。

第二项是"延迟爆炸"的直接来源:它的 P99 应当被单独监控并告警,而不是混在"总延迟"里。

运维上还有一个"灰度"问题:窗口参数变更的影响是"延迟分布",而不是"错误率"。所以灰度时不能只看"有没有报错",要看"P99 与排队时长"——否则"改坏了"不会触发任何告警。

// 参数变更的验证三件套(灰度 10% 流量)
1 P99 延迟是否在预算内(如 +50ms 容忍)
2 排队时长 P99 是否上升(拥塞信号)
3 批大小均值是否上升(收益信号)
// 只看 1 → 可能"延迟没超但收益也没"; 只看 3 → 可能"收益高但延迟爆"

6. 排错:五种"攒批没效果或反效果"

现象 成因 处理方向
吞吐没涨、延迟涨了 批大小没变 检查窗口是否太短
高峰期延迟爆炸 排队叠加 自适应窗口 + 拒绝
短请求被拖长 混批 长短分池
批大小长期为 1 请求稀疏 关掉攒批
长尾特别差 长度不齐 按 token 分批

第四项还有个"时长"判据:若 95% 的请求都在窗口内独自成批,那这个窗口就是纯延迟税。所以判断应当基于"批大小分布",而不是"吞吐有没有涨"。
第四项是"该关掉"的信号:请求稀疏时攒批只会增加延迟,而"关掉"这个动作很少有人做——于是它成了一个"永久的 20ms 税"。

最后一个"实现"边界:攒批逻辑放在"请求入口"还是"推理引擎"里,行为不同。放在入口(应用侧)时,你可以控制"哪些请求能合批";放在引擎里(如 vLLM 的 continuous batching)时,引擎会持续动态合批,而"窗口"概念被替换成"调度周期"。

// 两种实现的差别
应用侧攒批:显式窗口 + 显式批,可控但需自己管队列
引擎侧合批:continuous batching,吞吐更高,但"延迟"由调度决定,不可直接按窗口调
// 结论:已在用 continuous batching 时,不要再叠一层窗口

7. 边界:什么情况不该攒批

场景 为什么 做法
交互式单请求(对话) 用户等不起 窗口 0 或极小
请求稀疏(<10/s) 攒不起来 关闭
强实时(<100ms SLO) 窗口已占满预算 关闭
单请求超长(>4k token) 批次装不下 关掉或单列

判断口径一句话:"每秒请求数 × 窗口时长"是否远大于 1。最后一条与"产品"有关的边界:攒批的延迟预算应当与"用户可感知的等待"对齐。对话类产品里 20ms 的额外等待无感,但 200ms 就明显;而批量任务里 2 秒都无所谓——所以同一个参数不该跨场景复用。
远大于(如 200 QPS × 20ms = 4),攒批有意义;接近 1(如 10 QPS × 10ms = 0.1),它只是延迟税。


8. 自测

  1. 三个参数分别决定什么?为什么"先到者与后到者互相牵制"是关键机制?
  2. 为什么"请求本来就密集时,再加窗口无收益"?第四项"长度不齐"浪费了什么?
  3. 延迟三段里哪一段"不在配置里,而在负载里"?同配置在两种负载下为什么体验不同?
  4. 三种缓解手段里,哪一种最有效但改造大?"把排队变成拒绝"为什么更诚实?
  5. 三个观测数里,哪一个能揭示"攒批完全没生效"?只看延迟为什么看不出来?
  6. 判断"该不该攒批"的那个问题是什么?
S23-A18 · 攒批窗口:为什么"再多等 20ms"换不来吞吐
https://www.clxhxhhr.top/posts/6539/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。