Chunked Prefill:切片长 Prompt
一个 4096 token 的长 prompt 做 Prefill 要 1.3 秒——期间 8 个正在 Decode 的用户全部卡住。Chunked Prefill 把长 Prefill 切成小块,每步只消化一小口,剩余时间留给 Decode。
这一章做什么?
给调度器加上 chunk_size 参数,让长 prompt 的 Prefill 分多步完成,每步最多处理 chunk_size 个 token。实现后你会看到:Decode 请求的 TPOT(每 token 延迟)从"被阻塞 1.3 秒"变成"每步多 20ms"。
上一章 Continuous Batching 解决了 Decode 空转。但新请求进来时还是要一次性做完整个 Prefill——如果 prompt 很长,这段时间 GPU 被 Prefill 独占,Decode 请求全部停摆。这一章要打碎这个瓶颈。
Prefill 和 Decode 的根本差异
先回顾一下 单请求 KV Cache 引入的两个阶段:
Prefill(处理 prompt):
输入:整个 prompt,比如 512 个 token
一次前向传播处理 512 个 token
计算量:O(n²) ← n=512,注意力矩阵 512×512
耗时:较长(比如 100ms)
Decode(逐步生成):
输入:上一步生成的 1 个 token
一次前向传播处理 1 个 token
计算量:O(n) ← 只需新 token 和历史 KV 做注意力
耗时:较短(比如 10ms/token)关键区别:Prefill 是计算密集型,Decode 是内存带宽密集型。
Prefill:大矩阵乘法,GPU 的算力(FLOPS)是瓶颈
[512, 1024] @ [1024, 1024] → 大量乘法运算
Decode:小矩阵 + 大 KV Cache 读取,GPU 的显存带宽是瓶颈
[1, 1024] @ [1024, 1024] → 矩阵小,但要从显存读整个 KV Cache这意味着:Prefill 和 Decode 不能简单地用「谁快谁慢」来比较—— 它们消耗的是 GPU 的不同资源。
那么问题来了:长 Prefill任务一定会阻塞Decode任务
Continuous Batching 调度器 的 Continuous Batching 让短请求完成后立刻补充新请求, 但有一个新问题:新请求进来时需要先做 Prefill。
Prefill 的计算量随 prompt 长度的平方增长:
prompt 长度 Prefill 耗时(Qwen3-0.6B,A100 估算)
128 tokens 约 5ms ← 还好
512 tokens 约 20ms ← 勉强可接受
1024 tokens 约 80ms ← 开始明显
4096 tokens 约 1300ms ← 1.3 秒!严重阻塞这时候问题来了——如果系统里已经有 8 个请求正在 Decode, 突然来了一个 4096-token 的长 prompt:
时间轴:
时刻 0:8个 decode 请求正在运行,每步约 10ms
↓
时刻 0:新请求到来,需要先做 4096-token 的 Prefill
↓
时刻 0~1300ms:整个 GPU 被 Prefill 独占
┌─────────────────────────────────────────────────────────┐
│ Prefill: 处理 4096 个 token [████████████████████████] │
│ Decode请求A: 等待... ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ Decode请求B: 等待... ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ Decode请求C: 等待... ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
└─────────────────────────────────────────────────────────┘
时刻 1300ms:Prefill 结束,8个 decode 请求才能继续
对于正在生成文字的用户A/B/C 来说:
他们看到屏幕上的字突然停了 1.3 秒,然后才继续——这体验很差!这就是「Prefill 阻塞 Decode」的问题。
用指标来衡量:
- TTFT(Time to First Token,首个 token 延迟):长 prompt 的新请求需要等整个 Prefill 完成
- TPOT(Time Per Output Token,已有请求的生成速度):被 Prefill 阻塞,期间完全停止
为什么 Continuous Batching 调度器 的 Continuous Batching 没有解决这个问题?
Continuous Batching 调度器 解决了「短请求完成后槽位空转」的问题, 但 Continuous Batching 本身对每个 step 的调度粒度是:
每个 step 要么全是 decode,要么有某个请求做 prefill
当有 prefill 请求时,整个 step 的时间由 prefill 决定Continuous Batching 调度器 的 scheduler.py 里,prefill_seqs 的 prefill 是一次性完成的:
# Continuous Batching 调度器/scheduler.py — 问题所在
for seq in prefill_seqs:
logits, seq.past_kv = model(seq.prompt) # ← 整个 prompt 一次性 prefill
seq.append_token(...)如果 seq.prompt 有 4096 个 token,一次性 prefill 这一行就会执行 1300ms, 期间所有 decode_seqs 都在等待。
Chunked Prefill 的解决思路
把长 Prefill 切成小块(chunk),每步只处理 chunk_size 个 token, 剩余时间留给 decode 请求。
chunk_size = 512 时,4096-token 的 Prefill 分 8 步完成:
时刻 0: [Prefill chunk1: 512tok][Decode A/B/C/D/E/F/G/H] ← 约 20ms+80ms
时刻 100ms:[Prefill chunk2: 512tok][Decode A/B/C/D/E/F/G/H]
时刻 200ms:[Prefill chunk3: 512tok][Decode A/B/C/D/E/F/G/H]
...
时刻 700ms:[Prefill chunk8: 512tok][Decode A/B/C/D/E/F/G/H]
时刻 800ms:新请求 Prefill 完成,开始 Decode
对比无 Chunked Prefill:
Decode 请求被阻塞 1300ms
对比有 Chunked Prefill:
每步 Decode 只多延迟 20ms(一个 chunk 的 prefill 时间)Decode 请求的 TPOT 从「阻塞 1300ms」变成「每步多 20ms」。
实现细节
每个 Sequence 新增 prefill_offset 字段,记录已处理了多少 prompt token:
class Sequence:
prefill_offset: int = 0 # 已 prefill 的 token 数
# 调度时:每步最多处理 chunk_size 个新 token
start = seq.prefill_offset
end = min(start + chunk_size, len(seq.prompt))
chunk = seq.prompt[start:end]
logits, seq.past_kv = model(chunk, past_kv=seq.past_kv) # 增量 prefill
seq.prefill_offset = end注意:增量 prefill 依赖 KV Cache——第二块 chunk 处理时, 第一块 chunk 的 K/V 已经存在
past_kv里,不需要重算。 这和 Decode 阶段复用 KV Cache 的机制完全相同。
教学版的设计选择:严格 1-chunk / 1-decode 交替
engine.py 每步只处理一块 prefill 和一个 decode,是刻意为教学清晰度做出的设计选择:
# engine.py — 教学版:严格分时交替,逻辑一目了然
prefill_chunk, decode_seq = scheduler.schedule()
if prefill_chunk:
seq, start, end = prefill_chunk[0] # 至多 1 块
self.model(chunk, ...) # forward ①
if decode_seq:
seq = decode_seq[0] # 至多 1 个
self.model(seq.get_last_token(), ...) # forward ②调度器每次只返回 1 个 prefill chunk 和 1 个 decode 序列,while 循环的每次迭代严格对应一个时间片:
iteration 1: prefill seq_new chunk1 (50 tok) → decode A
iteration 2: prefill seq_new chunk2 (50 tok) → decode B
iteration 3: prefill seq_new chunk3 (50 tok) → decode C
iteration 4: prefill seq_new chunk4 (50 tok) → decode D
iteration 5: (prefill done) → decode seq_new
iteration 6: → decode A
...这种写法的价值在于:调度逻辑(prefill_offset、chunk_size、状态机)和执行逻辑完全对应,读代码时能直接看出每步在做什么,不需要追踪 batch 拼接的下标管理。
真实 vLLM 的做法:所有序列合并为一次 forward
教学版每步有 2 次独立的 GPU forward(1 次 prefill + 1 次 decode),GPU 的矩阵乘法没有被 batch 利用。真实 vLLM 把所有 prefill chunk token 和所有 decode token 拼成一个连续张量,一次 forward 处理完:
教学版(本章):
forward ①: prefill chunk [50 tokens]
forward ②: decode seq A [1 token]
→ 2 次 GPU kernel 调用,矩阵小,GPU 利用率低
真实 vLLM:
一次 forward: [chunk(50) | decode_A(1) | decode_B(1) | ... | decode_H(1)]
= [58 tokens] → 1 次 GPU kernel 调用,矩阵更大,GPU 利用率高为什么要这么做?
GPU 的矩阵乘法有固定的 kernel 启动开销(约 0.05ms/次)。decode 阶段每步每个序列只有 1 个 token,矩阵极小,计算量不足以填满 GPU——如果每个序列单独 forward,绝大多数时间都在 kernel 启动上浪费。把所有序列拼在一起,变成一个更大的矩阵,GPU 才能真正并行发力。
为什么需要 FlashAttention varlen?
各序列虽然 token 拼在一起,但注意力计算必须各自独立(prefill chunk 只能看自己的 token,decode_A 只能看自己的历史 KV)。FlashAttention 的 varlen 接口用 cu_seqlens 偏移量数组标记每个序列的边界,在一次 kernel 调用内完成所有序列的独立注意力计算,将在 FlashAttention:SRAM-aware 注意力计算 详细介绍。
关键参数
chunk_size:每步最多处理的 prefill token 数(nano-vllm 默认 512)prefill_offset:当前序列已完成 prefill 的 token 数
代价与权衡
Chunked Prefill 不是免费的:
无 Chunked Prefill:
新请求的 TTFT = Prefill 时间 = 1300ms(但期间 decode 被阻塞)
有 Chunked Prefill(chunk_size=512):
新请求的 TTFT = 8步 × 每步时间 ≈ 8 × 100ms = 800ms(比 1300ms 快,因为有并发)
已有请求的 TPOT 影响:每步多约 20ms
chunk_size 越小:
→ decode 请求受影响越小(每步 prefill 时间短)
→ 新请求的 TTFT 越长(需要更多步才能完成 prefill)
chunk_size 越大:
→ 新请求 TTFT 越短
→ decode 请求每步等待越久nano-vllm 的默认 chunk_size=512,在 TTFT 和 TPOT 之间取了一个平衡点。
运行
python run.py小结
Chunked Prefill 把长 Prefill 切成 chunk_size 大小的小块,每步只处理一块,剩余时间留给 Decode 请求。Decode 的 TPOT 从"被整个 Prefill 阻塞"变成"每步多一个 chunk 的时间"。代价是新请求的 TTFT 变长(需要多步才能完成 Prefill),chunk_size 控制这个权衡。教学版每步严格 1-chunk + 1-decode 交替;真实 vLLM 把所有 token 拼成一次 forward,用 FlashAttention varlen 并行处理。
下一步
Continuous Batching + Chunked Prefill 让调度越来越灵活,但有一个隐患:系统不断接入新请求,每个请求的 KV Cache 随 decode 不断增长——生成长度在请求开始时是未知的。如果所有 running 请求的 KV Cache 总量超过了显存上限,怎么办?直接崩溃?
→ Preemption:抢占避免 OOM——当 KV Cache 不够用时,主动驱逐低优先级请求,释放显存,系统继续运行而不是崩溃。