LLM 推理系统 · 调度

Continuous batching:每一轮重新组队

短答案先离场,新请求马上补上。

按 → 推进 · 动画可播放、拖动和倒回

2026-10-20 · 图中的容量、时长与 token 均为教学示意

01 · 空转

短请求结束了,为什么还在占位置?

同一批请求,一起开始 生成轮次 → 请求 A token token 空等 空等 空等 空等 请求 B token token token token 空等 空等 请求 C token token token token token token A 的位置已经空了,但 D 仍在等整批结束

把 A、B、C 一起送进一个静态 batch。它们分别需要生成 2、4、6 个 token。

两轮后 A 就结束了。固定的批次还要陪 B、C 走完,A 对应的位置开始空转。

直到 C 也完成,下一批才开始。长短不一的答案,把同一张 GPU 切出了许多等待。

方格表示生成一轮的工作,不表示真实耗时;静态 batch 这里指批次成员在完成前不变。

02 · 调度单位

一轮 forward,就是一次换人的机会

调度器 本轮:[A, B, C] GPU forward 输出 token + 更新结束状态 a b c

自回归生成是一个循环:读历史,算下一个 token,再把它接回历史。

调度器把当前可运行的请求组成 batch,GPU 为每条普通 decode 请求处理一个 token。

拿到新 token 后检查结束条件,再决定下一轮谁留下、谁离开、谁加入。

普通 decode 每请求每轮推进一个 token;投机解码等机制可一次验证多个 token。

工程细节

调度器每轮读的,是一张不断变化的状态表

字段含义为何需要
arrival请求到达时刻衡量排队与公平性
prompt_remaining还未计算的输入 token决定 prefill chunk
emitted / max_output已经生成 / 允许生成的数量决定继续 decode 或完成
block_table / KV length已驻留的历史及位置检查显存与构造 attention 输入

状态通常经历 waiting → prefill → decode → finished;取消也应释放资源。被抢占的请求要保留 token ID,准备恢复计算。

工程细节

一条请求有三把时钟

TTFT = 首 token 到达时间 − 请求到达时间
ITLᵢ = 第 i 个输出时间 − 第 i−1 个输出时间
E2E = 最后一个输出时间 − 请求到达时间

TTFT 包含排队、tokenization、prefill 和传输;下面的教学模拟只建模队列与 forward,真实测量用客户端时钟。

吞吐也要说清:输入 token/s、输出 token/s、完成请求/s 不可混写。平均 ITL 之外还要看 P95/P99,用户会感觉到偶发停顿。

工程细节

一个 token 预算,不是一个毫秒预算

Σ prefill chunk 长度 + 本轮 decode 请求数 ≤ C

普通 decode 每条请求本轮计算 1 个输入位置;投机验证、并行草稿等情况需要单独计数。

Prefill
同一请求有多个 query,算子更容易形成大矩阵;chunk 越靠后,还会读取之前的 KV。

Decode
每条只有一个 query,读取长度不同的历史;同样 32 个 token 预算,可能有完全不同的时长。

调度性能模型至少需要 prefill token 数、decode 条数和历史长度分布,随后用实际 profiling 校准。

03 · 连续批处理

A 刚离场,D 就能补进来

本轮运行的槽位 A B C 等待队列 D A 完成 A 下一轮:[D, B, C]

这一轮跑 A、B、C,D 在等待。batch 的成员不需要从头到尾固定。

A 输出结束 token,调度器移除 A,并归还它不再使用的资源。

下一轮把已准备好的 D 加进来。B、C 继续生成,不必停下来重开整批。

D 这里已完成 prefill;新提示词的 prefill 也需要被调度,不能凭空跳过。

04 · 两种工作

长提示词和逐字生成,节奏不一样

Prefill:提示词的许多 token 我 想 了解 GPU 推理 系统 Decode:每条请求的新 token Aₙ Bₙ Cₙ 大批矩阵运算 反复读取权重与 KV

新请求先处理提示词,建立 KV cache,这叫 prefill。之后进入 decode,逐轮生成。

prefill 一次处理许多 token,矩阵运算更容易摊薄权重读取;长提示词可能占住 GPU 较久。

decode 每轮的计算较少,却要读取权重和历史 KV。调度器需要照顾正在等下一字的人。

计算与带宽的占比随模型、batch 和上下文长度变化;这里展示常见工作形态。

工程细节

从三条请求开始,手工当一次调度器

请求到达时间输入长度总输出 token
A0 ms63
B2 ms22
C6 ms32
C = 4;单轮时长 = 2 + 0.5P + 1D ms

P 是本轮 prefill token 数,D 是 decode 请求数。先安排 decode,再按到达顺序填入 prefill;轮内到达只在下个边界加入。

这是便于手算的教学成本模型。最后一个 prefill chunk 直接采样首 token,后续每轮 decode 再产出一个。

计算动画 · 离散事件时间轴

看着时钟走,就知道谁在等待

P4 / D0P4 / D0P2 / D2P1 / D1P0 / D1本轮结束时间:0ms累计输出 token:0A: 8 → 13 → 16.5;B: 8 → 13;C: 16.5 → 19.5 ms

0–4 ms 只计算 A。B 在 2 ms 到达,要等到 4 ms 才能参加;C 在 6 ms 到达,要等到 8 ms。

工程细节

现在能把延迟和吞吐都算出来

请求输出时刻 / msTTFTITL / ms
A8, 13, 16.58 − 0 = 8 ms5, 3.5
B8, 138 − 2 = 6 ms5
C16.5, 19.516.5 − 6 = 10.5 ms3
输出吞吐 = 7 / 0.0195 ≈ 358.97 token/s

C 只有 3 个输入 token,却比 A 的首字更晚;它到达时已有两条 decode 占用预算。只按 prompt 长度估 TTFT 会漏掉排队。

工程细节

首 token 从哪里来,决定计数会不会差一轮

prefill(prompt_tokens) -> logits_at_last_prompt_position
first_output = sample(logits)
# 此时 first_output 的 KV 还没写入
decode(first_output, cached_prompt) -> next_logits
second_output = sample(next_logits)

输出长度为 3,只需要首轮 prefill 加 2 次 decode。停止后,最后一个输出 token 的 KV 未必需要再计算。

调度表里的“已采样 token 数”和“已计算 KV 长度”是不同字段。错把两者相等,会引入无效位置、重复计算或错位的位置编码。

工程细节

一轮调度,分成计划、预留、执行、提交

ready = collect_arrived_requests(now)
plan = pick_decode(ready, token_budget)
plan += fill_prefill_chunks(ready, remaining_budget(plan))
plan = fit_kv_capacity(plan)
reserve_blocks(plan)             # 先让本轮所需位置有地址
outputs = execute(plan)
commit_kv_lengths_and_samples(outputs)
release_finished_or_cancelled_requests()

token 上限、序列数量上限、KV 块容量是三个约束。实现可以把选择与分配交错,但失败路径不能留下半完成的状态。

05 · Chunked prefill

先留出下一字,再把空余预算给提示词

本轮 token 预算 = 8 · · · · · · · · A B C p0 p1 p2 p3 p4 3 个 decode token 剩余 5 个位置,给长提示词的一段 p5…p11

把每轮处理的 token 总数限制为 8。现在 A、B、C 各需要一个 decode token。

先把 3 个 decode token 放进去,剩余预算是 5。

新请求有 12 个提示词 token,这一轮先算 5 个,其余留到后续轮次。这叫 chunked prefill。

这是 vLLM 的 decode 优先策略示意;总 token 数上限对应 max_num_batched_tokens。

06 · 时间轴

长提示词分几轮完成,老请求持续前进

请求 P:12 个提示词 token 第 1 轮 A、B、C decode P:0–4 第 2 轮 A、B、C decode P:5–9 第 3 轮 A、B、C decode P:10–11 P 可进入 decode

第 1 轮:A、B、C 生成下一字,P 处理最前面的 5 个提示词 token。

第 2 轮:沿用 P 已算好的前缀 KV,继续处理后面 5 个。A、B、C 又各前进一步。

第 3 轮:P 处理最后 2 个 token,prefill 完成。后续就能和大家一起 decode。

表中每轮只标记调度内容,不按真实耗时等比例绘制;后续 chunk 仍会读前面 chunk 的 KV。

工程细节

把长 prompt 切成三段,究竟换来了什么?

已有 2 条持续 decode。新 prompt 长 12,仍用 t = 2 + 0.5P + D ms;忽略网络,下一轮从 0 ms 开始。

策略每轮 P / Dprompt 完成时刻老请求下一 token
大段:C=1412 / 210 ms10 ms
小段:C=64 / 2,连续 3 轮18 ms6、12、18 ms

小段让首个 decode 响应更早,也产生更多次固定调度开销。新请求的 prefill 反而更晚完成。

两种策略在 prompt 完成前做的 decode 工作量不同;这是用户等待时间的比较,不是同等总工作量的 GPU 加速比。

计算动画 · 计算粒度与等待

屏障切短,老请求就能更早收到字

大段:一个 10 ms 屏障prefill:0小段:每 6 ms 放出一次 decode 结果6 ms12 ms18 ms小段已处理 prompt:0/ 12 token

条形表示轮次经过的时间;数字只统计已完成的 prefill。在 6 ms 时小段已交付下一字,到 18 ms 才完成全部 prompt。

工程细节

本轮能算 100 个 token,不代表还能再接 100 条

教学例:KV 池 1,000 块,已有活跃请求占 900 块。新请求输入长度 1,600,块大小 16,光 prompt 就需要 100 块。

首次入场需求 = ceil(1,600 / 16) = 100 块

如果全部接入,它处理完 prompt 时空闲块归零;其他请求一旦跨块边界,就可能触发抢占。chunked prefill 可以分段分配,但不会消除最终驻留的 KV。

入场规则应结合当前块需求、预计增长、并发上限与抢占成本;设预留余量时,要用业务长度分布验证。

工程细节

吞吐到了平台期,排队就成了主要延迟

稳定系统:平均在途请求 L = 到达率 λ × 平均逗留时间 W

若完成能力约 20 请求/s,输入却持续 25 请求/s,队列平均每秒多 5 条。增加等待时间不能解决算力缺口。

在稳定运行的另一个观测窗口,λ = 16 请求/s,平均 W = 0.75 s,则平均在途 L = 12 条。这里包含等待和正在运行的请求。

先设置有界队列、超时和 admission control,再选择扩容或降级。Little 定律用于稳定窗口的平均值,不能拿它预测某一条请求的 P99。

工程细节

decode 优先也要给长 prompt 留下一条路

饥饿怎样发生
token 预算 C=8,恰好总有 8 条 decode 等待;prefill 每轮拿到 0,输入队列一直增长。

可执行的修复
限制活跃 decode 数;为超时 prefill 保留少量预算;采用年龄权重或每租户配额,控制单个大请求占用。

有些服务愿意为现有会话保持低 ITL,有些更看重新请求 TTFT。把目标写成明确的 SLO,再选择调度权重。

“先到先服务”也需说明粒度:请求级、chunk 级或 token 级会给出不同结果。

工程细节

吞吐提高了,用户却开始抱怨卡顿

教学实验:同一请求 trace方案 A方案 B
prefill token 预算5124,096
输出吞吐900 token/s1,050 token/s
P99 ITL35 ms130 ms
P95 TTFT700 ms430 ms

若 SLO 要求 P99 ITL ≤ 80 ms,B 不合格。先减小 chunk,或按观测耗时限制本轮 prefill;再检查吞吐仍能否覆盖到达率。

不要看到 GPU 利用率提高就宣布成功。把每轮 prefill 数量与 ITL 尖峰对齐,才能验证卡顿确实来自长轮次。

工程细节

让压测保留真实的“什么时候到达”

固定并发压测
上一条结束后再发下一条。适合测饱和吞吐,但服务变慢时,发出的请求也会变少。

按时间重放
按预定到达时间发送,不等前一条结束。能看到突发、队列增长和 SLO 失守。

保存每条请求的到达时刻、首字时刻、所有流式 token 时刻、结束原因;同时保存服务器轮次、预算与 KV 占用。

按长短 prompt、租户、缓存命中状态分别分组,避免平均值掩盖某一群请求被饿住。

07 · 两道预算

token 预算够,显存也得装得下

这一轮算多少?

token 预算限制本轮计算规模;序列数限制同时服务多少条请求。

历史要存多少?

KV cache 累积的是所有在途请求的历史。长上下文会持续占用显存块。

资源不足时,调度器会让请求等待;某些策略也会暂停请求,之后重算或恢复。

08 · 调参的目标

吞吐、首字时间、字间间隔,一起看

Prefill chunk 小一些

已有请求更快拿到下一字;长提示词需要更多轮才能处理完。

Prefill chunk 大一些

长提示词更快完成、计算更集中;同一轮的 decode 可能要等更久。

TTFT:到第一个输出 token 的时间。ITL:相邻输出 token 的间隔。吞吐:单位时间完成的工作量。用真实到达率与长度分布一起测。

练习 1 · 先计算,再展开

同一条请求,四个延迟数该怎么填?

请求在 10 ms 到达,30 ms 开始 prefill,70 ms 收到首 token,接着在 90、125、150 ms 收到 token。求排队、TTFT、平均 ITL 和 E2E;平均 ITL 能说明最坏停顿吗?
展开参考解答

排队 = 30−10 = 20 ms。TTFT = 70−10 = 60 ms。
ITL = [20,35,25] ms,均值 = 80/3 ≈ 26.67 ms。
E2E = 150−10 = 140 ms。最大间隔是 35 ms,均值不能替代尾延迟;多条请求还需统计分位数。

练习 2 · 先计算,再展开

12 个位置,先 decode 以后怎么切 prompt?

预算 C=12,已有 5 条普通 decode;等待中的 A 还剩 10 个 prefill token,B 还剩 3 个。decode 优先、prefill 按 FIFO。单轮成本 2 + 0.5P + D ms,求本轮安排和用时。
展开参考解答

先给 decode 5 个位置,剩 12−5=7。A 处理 7,A 剩 3;B 本轮处理 0。
P=7、D=5,时长 2+0.5×7+5=10.5 ms。
若计划还要跨块分配,必须再通过 KV 容量检查;token 预算满足只是第一个条件。

练习 3 · 先计算,再展开

平均负载不高,为何新请求一直等?

每轮预算 8,始终有 8 条长 decode 会话;prefill 队列累计 20 条。CPU 空闲,GPU 每轮都在工作。指出瓶颈并提出两种能验证效果的改法。
展开参考解答

这是策略造成的 prefill 饥饿:每轮 8 个位置都被 decode 消耗。
改法一:降低活跃 decode 上限或给老化的 prefill 留 1–2 个位置;观察 TTFT 降幅与 ITL 代价。
改法二:限制新长会话入场,按租户或等待年龄轮换;观察队列年龄和最慢租户,而不只看总体吞吐。

动手实验 · CPU 教学模拟

修改预算,重新计算每一条时间线

下载代码后,在保存代码的目录运行:

python3 lab.py
python3 lab.py --budget 6

默认预算 4:A 的 TTFT=8 ms,B=6 ms,C=10.5 ms;总用时 19.5 ms,输出吞吐 358.97 token/s。

先在纸上改成预算 6,再比较输出。尝试加入一次 1,000-token 突发,观察短请求的首字等待;再实现每请求最大 chunk,让状态转换与 token 守恒断言继续通过。

下载 lab.py · Python 3 标准库
参考与延伸阅读

把一个 batch,变成持续流动的队伍