07 · Prefill 与 Decode:两条不同的执行路径
先建立直觉:同一条请求为什么会换节奏?
用户看见的是同一段回复;GPU 看见的却是先“批量读入”、再“逐 token 推进”的两类工作负载。
先观看动画。它把一条请求从 prompt 到流式输出的节奏拆开:Prefill 如何批量建立 KV Cache,Decode 为什么每轮只有一个新 token 却要读取不断增长的历史,以及 is_prefill 如何将两条路径带回代码。
观看时只抓住一个结论:Prefill 和 Decode 不是同一个 batch 的不同命名。它们的输入形状、所需元数据、KV Cache 关系与优化目标都不同;is_prefill 选择的是一条执行路径,而不只是一个注释标签。
Prefill:把整段未缓存 prompt 建成 KV
Prefill 处理尚未缓存的 prompt token。若 A 有 8 个、B 有 12 个未缓存 token,概念上本轮要处理的工作量是 20 个 token。输入准备会把 token 拼接为一批,并用累计序列长度 cu_seqlens 标出请求边界;一次前向计算同时写入这些 token 的 K/V。
| 观察项 | Prefill 中你应看到什么 | 它告诉你什么 |
|---|---|---|
input_ids | 多个尚未缓存的 prompt token | 输入在 token 维度上较宽。 |
cu_seqlens | 每条 Sequence 在拼接批次中的边界 | 变长请求仍能在同一批中定位自己的 token。 |
| KV Cache | 本轮多份 K/V 被建立 | 后续 Decode 不必重新计算 prompt。 |
| 用户感受 | 首 token 前的等待 | 排队与 Prefill 是 TTFT 的重要组成。 |
Decode:每条一个新 token,带着各自历史
Prefill 完成后,每个活跃 Sequence 在一轮 Decode 中通常只提交最后一个 token。两条活跃请求的概念输入形状因而是 [2, 1]:两行,每行一个 last_token。但这个“小输入”并不表示没有历史工作;每条请求还需要自己的 context_lens、block_tables 与缓存位置,读取长度不同的历史 K/V。
小输入,长读取是 Decode 最容易被忽略的反差。它的单步算术强度较低,却不断访问增长中的缓存,因此会更敏感于内存带宽、kernel launch 与调度开销。
从内部节奏到用户指标
TTFT(首 token 时间)衡量用户从发送请求到看见第一个 token 的等待,它受排队和 Prefill 影响很大。TPOT(每输出 token 时间)描述流式输出中相邻 token 的间隔,更接近 Decode 的逐轮节奏。总 tokens/s 仍然重要,但不能替代这两个体验指标。
| 指标 | 首先问什么 | 更直接对应的阶段 |
|---|---|---|
| TTFT | “第一个字为什么还没出现?” | 排队 + Prefill |
| TPOT | “后续文字为什么一顿一顿?” | Decode 每轮推进 |
| 总吞吐 | “系统一共服务了多少 token?” | 两阶段与 batching 的组合 |
代码锚点:从一个布尔分支开始读
下面是概念化阅读路径,不等同于上游源码的逐行转录。先顺着一次 step 的调用链来到 ModelRunner.call("run", seqs, is_prefill),再比较两种输入准备方式。1
Scheduler.schedule
→ ModelRunner.call("run", seqs, is_prefill)
→ prepare_prefill 或 prepare_decode
→ model forward
→ Scheduler.postprocess| 锚点 | 先问的问题 | 实验里的可观察结果 |
|---|---|---|
is_prefill | 这轮是在建立 prompt KV,还是在推进生成? | 阶段从 Prefill 切到 Decode。 |
prepare_prefill | 为什么输入包含整段未缓存 token? | A=8、B=12 时本轮合计 20 token。 |
prepare_decode | 为什么每条只带一个 last_token? | 两条请求显示概念形状 [2, 1]。 |
context_lens / block_tables | Decode 如何读到各自的历史? | 新 token 只加一个,历史 KV 长度继续增长。 |
动手验证:预测 → 运行 → 解释
不要先点击按钮。先根据 A=8 与 B=12 写下 Prefill 的总 token 数,并判断切入 Decode 后的形状。然后再改变 B 的 prompt 长度,观察“总 token 改变”与“Decode 每条一个 last_token”这两件事为何可以同时成立。
| 步骤 | 你要做什么 | 你应该观察什么 |
|---|---|---|
| 1. 预测 | 装载默认任务 A=8、B=12。预测首轮 Prefill 总 token 数,以及两条请求进入 Decode 后的 input_ids 概念形状。 | Prefill 汇总未缓存 token;Decode 汇总活跃 Sequence 的最后一个 token。 |
| 2. 运行 | 点击“运行 Prefill”,再点击“运行一个 Decode step”。 | prompt token 变为已缓存;Decode 时每条新增一个 token,形状变为 [2, 1]。 |
| 3. 解释 | 点击核对,然后将 B 改成 20 token 重新预测。 | Prefill 工作量从 20 改为 28;但只要仍有两条活跃请求,Decode 的每轮输入仍是 [2, 1]。 |
为什么两条请求在 Decode 中的概念输入形状可以是 `[2, 1]`,即使它们的历史 prompt 长度不同?
完成 A=8、B=12 的默认任务后,将 B 改为 20。写下两次 Prefill 总 token 的差异,并说明为什么 Decode 形状仍保持 `[2, 1]`。
下一节:Attention