返回机制实验室

W04 · S22—S28 · 总 Day 112118

推理服务:吞吐、等待与长尾的交换

比较逐请求与固定窗口批处理,逐条追踪等待时间。

作者准备的学习示例 · 不计真实学习进度 · 不代表生产 / GPU / 真机结果

在仓库根目录运行;只输出合成示例,不写文件、不访问网络

npm run learning:p2 -- w04
跳到完整源码 ↓

核心问题

单个请求只要几毫秒,用户为什么等很久?因为服务时间不等于响应时间。排队、凑批、共享资源、长请求阻塞和返回过程都可能进入用户等待。

对应 S22~S28。先用不含模型的离散事件例子拆清量,再回到真实 LLM 的 prefill/decode 与 KV cache。

1. 本例的时间模型

八个请求按固定时间到达,其中第一个 work=20,其余 work=2。每批有 2ms 固定开销,同批计算时间假设为最大 work,所有成员一起结束。这是假设可以理想并行的乐观模型,不是实测 GPU kernel。

每批开始时间至少为“上一批结束”和“最老待处理请求到达 + 固定窗口”两者的最大值。窗口为 0、batch size 为 1 时相当于串行;窗口为 3、batch size 为 4 时允许聚合。这里没有抢占,也不在 decode 每步重新调度,因而不是 continuous batching。

wait = start - arrivallatency = finish - arrival。吞吐按有限工作负载的总请求数除以首个到达至最后结束的时间,包含起停效应,不是稳态容量。

2. 跟着一条请求计算

npm run learning:p2 -- w04

串行中 r1 在 0 开始、22 结束;r2 同在 0 到达,只能在 22 开始、26 结束。因此 r2 工作量为 2,但响应延迟为 26。排队才是主要部分。

批处理中第一批在 3 开始、25 结束;r1 的延迟从 22 增到 25,部分后续请求却更早完成。整组有限请求的吞吐从 160 到约 275.86 请求/秒,nearest-rank p95 从 44 降到 26ms。八个样本的 p95 实际取最大值,不能代表稳定生产分位数。

这个结果说明“整体改善可能让某个请求变慢”,不说明“批越大越好”。默认输入有密集到达与理想批内并行,已经偏向批处理。换成稀疏请求,凑批时间可能成为纯粹的额外等待。

3. 改动与解释

只把各请求 arrival 改为 i * 100,保持 work 不变。大多数批只能收进一个请求;固定窗口无法摊薄开销,却增加单请求延迟。再恢复输入,把第一个 work 改成 200,观察同批短请求怎样被最长请求拖住。

如果想进一步理解调度,可以在纸上设计“短请求专用队列”,同时问:它是否让长请求饥饿?谁决定短长分类?分类错误怎样影响公平性?不需要增加复杂调度框架。

4. 接回真实 LLM 服务

真实系统还要区分排队时间、TTFT、相邻 token 延迟、总延迟、tokens/s 和满足时限的成功任务数。Prefill 消耗、decode 步数、KV 内存、取消请求、cache 命中和抢占会一起改变容量。当前输出没有 token,因此不得把 latencyMs 改名为 TTFT。

5. 向实时智能延伸

Agent 的在线规划可能有交互等待预算;机器人控制还可能有严格截止时间和过期动作风险。提高吞吐不一定改善闭环稳定性。此处只能迁移“端到端时延和排队必须一起分析”的原则,不能把毫秒模拟当作满足真机实时要求。

一句话笔记:在这组输入中,哪个请求付出了代价,哪个假设最有利于批处理?

配套日课:按需要补充理论

本实验贯穿一周,不要求一天做完。

可运行源码

src/learning/ai-systems/w04-serving-queue.ts · 构建时直接读取源文件,避免讲义代码与实现各自漂移。

export interface Request { id: string; arrival: number; work: number }
export const requests: Request[] = [20, 2, 2, 2, 2, 2, 2, 2]
  .map((work, i) => ({ id: `r${i + 1}`, arrival: Math.max(0, i - 1), work }))

export function simulate(input: Request[], batchSize: number, windowMs: number) {
  if (!Number.isInteger(batchSize) || batchSize < 1 || windowMs < 0) throw new Error('Invalid batch configuration')
  if (!input.length) return { rows: [], p95Ms: null, throughputPerSecond: 0 }
  const pending = [...input].sort((a, b) => a.arrival - b.arrival)
  const rows: { id: string; start: number; finish: number; waitMs: number; latencyMs: number }[] = []
  let now = pending[0].arrival
  while (pending.length) {
    now = Math.max(now, pending[0].arrival + windowMs)
    const ready = pending.filter(r => r.arrival <= now).length
    const batch = pending.splice(0, Math.min(ready, batchSize))
    // Optimistic parallel work, fixed 2ms overhead; all members finish together.
    const finish = now + 2 + Math.max(...batch.map(r => r.work))
    for (const r of batch) rows.push({ id: r.id, start: now, finish, waitMs: now - r.arrival, latencyMs: finish - r.arrival })
    now = finish
  }
  const latencies = rows.map(r => r.latencyMs).sort((a, b) => a - b)
  return {
    rows, p95Ms: latencies[Math.ceil(0.95 * latencies.length) - 1],
    throughputPerSecond: +(rows.length * 1000 / (now - Math.min(...input.map(r => r.arrival)))).toFixed(2),
  }
}

export function run() {
  return {
    units: 'simulated milliseconds', input: requests,
    serial: simulate(requests, 1, 0), fixedWindowBatch: simulate(requests, 4, 3),
    boundary: 'No model, tokens, KV cache, continuous batching, GPU, TTFT, or measured production latency.',
  }
}