服务引擎内部:PagedAttention、连续批处理与分块 Prefill 本节摘要:现代推理引擎的吞吐量靠的不是某一个小技巧,而是三个互相叠加的默认项。PagedAttention 永远开着;连续批处理(continuous batching)在 decode 迭代之间把新请求注入活跃批次;分块 prefill(chunked prefill)把长提示切片,让 decode token 不至于饿死。三项全开时,单张 H100 SXM5 上的 Llama 3.3 70B FP8 在 128 并发下能跑到 22002400 tok/s——比 vLLM 自家默认约高 25%,是朴素 PyTorch 循环的 34 倍。
本节摘要:现代推理引擎的吞吐量靠的不是某一个小技巧,而是三个互相叠加的默认项。PagedAttention 永远开着;连续批处理(continuous batching)在 decode 迭代之间把新请求注入活跃批次;分块 prefill(chunked prefill)把长提示切片,让 decode token 不至于饿死。三项全开时,单张 H100 SXM5 上的 Llama 3.3 70B FP8 在 128 并发下能跑到 22002400 tok/s——比 vLLM 自家默认约高 25%,是朴素 PyTorch 循环的 34 倍。本节带你读懂 vLLM(这三项技术的参考实现)的调度器与注意力内核,细到你能画出来,最后用一个玩具连续批处理调度器收尾。
对应原课程:Phase 17 · Lesson 04 ·
04-vllm-serving-internals(原英文phases/17-infrastructure-and-production/04-vllm-serving-internals/docs/en.md)。
阅读完本节,你应当能够:
朴素的 PyTorch 服务循环一次只跑一个请求:分词、prefill、decode 到 EOS、返回。一个用户时没问题,一百个用户时就是一群耐心排队的人。显眼的修法——静态批处理——会把窗口里每个请求 pad 到最长提示的长度,把每个 decode pad 到最长预期输出,还要让整批卡在最慢的序列上。你为你根本用不到的 padding 买单,快的请求等慢的。
vLLM 一次解决三个问题:PagedAttention 阻止 KV 缓存碎片像经典连续分配那样吃掉 60~80% 的 GPU 显存;连续批处理让请求在每个 decode 迭代之间进出批次,使批次始终装满真实工作;分块 prefill 把一个 32k token 的提示切成约 512 token 的片,与 decode 交替,这样一个长提示不会冻住 GPU 上每个 decode token。
2026 年的生产默认就是三项全开。你必须理解每一项在干什么,因为失败模式全在调度器,不在模型。
一条序列的 KV 缓存大小是 num_layers × 2 × num_heads × head_dim × seq_len × bytes_per_element。对 Llama 3.3 70B、8192 token,单序列在 BF16 下约 1.25 GB。如果你为每个请求预留 8192 槽位,而平均请求只用 1500 token,你就浪费了约 82% 预留的 HBM。经典批处理就在付这笔浪费。
PagedAttention 借用了操作系统的虚拟内存思想。KV 缓存不按序列连续分配,而是按固定大小的 block(默认 16 token)分配。每个序列有一张 block table,把它的逻辑 token 位置映射到物理 block ID。序列增长超过已分配 block 时,再加一个 block;序列结束,它的 block 回收进池子。
碎片率从经典分配的 60~80% 降到 PagedAttention 的 4% 以下。你不用 flag 开启 PagedAttention——它是 vLLM 唯一自带的分配器。那个旋钮是 --gpu-memory-utilization(默认 0.9),它告诉 vLLM 在加载权重和激活后,留多少 HBM 给 KV block。
老的「动态批处理」是等一个窗口(比如 10 ms)填满一批,然后跑 prefill + decode + decode……直到所有序列完成。快序列早早完成却干等,而 GPU 在跑慢序列。
连续批处理在每个 decode 步骤之间运作。把运行中的序列集合叫做 RUNNING 列表。每次迭代:
RUNNING 里刚命中 EOS 或 max_tokens 的序列被移除。RUNNING 里的所有序列跑,每条序列产出一个新 token。批次大小从不 pad 到固定数字。处于各自输出不同位置的序列共享一次融合前向。2026 年 vLLM 里这叫 V1 调度器。关键不变量:调度器每个 decode 迭代跑一次,不是每个请求跑一次。
Prefill 是计算受限的。Llama 3.3 70B 上一个 32k token 的提示,单张 H100 纯 prefill 约 800 ms。Prefill 在跑时,批次里所有其他序列的 decode token 都得等。在服务循环里,一个长提示的首 token 延迟(TTFT)就变成了几十个其他用户的 token 间延迟(ITL)抖动。
分块 prefill 把 prefill 切成固定大小的 chunk(默认 512 token),把每个 chunk 当一个调度单元。chunk 之间,调度器可以把 decode 序列推进一个 token。你用一点点 prefill 绝对延迟(每个 chunk 几 ms)换来 decode 时低得多的抖动。已发表基准里,混合负载下 P99 ITL 从约 50 ms 降到约 15 ms。
三项特性都假设彼此存在。PagedAttention 给调度器一个细粒度的 KV 资源去调度;连续批处理需要这个细粒度资源,这样接纳新序列不必触发全局重排;分块 prefill 是调度器在同一个 RUNNING 列表上做的决策——它是又一条调度策略,不是独立系统。
你不必记每个 flag。你需要记住的是调度器优化的是什么:在 KV block 预算下最大化 goodput,并受分块 prefill 切分约束。
在 vLLM v0.18.0 里,你不能把 --enable-chunked-prefill 和 draft-model 投机解码(--speculative-model)同时开。文档化的例外是 V1 调度器里的 N-gram GPU 投机解码。不看发布说明就把每个 flag 都打开的团队,启动时会直接撞运行时错误,而不是软回归。如果你的投机收益值得为此放弃分块 prefill,那就重新评估——2026 年的正确答案常常是 EAGLE-3 配合不开分块 prefill,而不是「draft model + 分块 prefill」这种编译不过的组合。
while True: finished = [s for s in RUNNING if s.is_done()] for s in finished: release_blocks(s); RUNNING.remove(s) while WAITING and have_free_blocks_for(WAITING[0]): s = WAITING.pop(0) allocate_initial_blocks(s) RUNNING.append(s) # 在一个批次里调度 prefill chunk + decode batch = [] for s in RUNNING: if s.in_prefill: batch.append(next_prefill_chunk(s)) # 比如 512 token else: batch.append(decode_one_token(s)) # 1 token run_forward(batch) # 一次融合 GPU 调用
code/main.py 就是这段循环的 stdlib Python 版,token 数和前向延迟是假的。跑起来你能直观看到分块 prefill 如何在长 prefill 期间让 decode 序列保持存活。
原课程 code/main.py 模拟一个可切换特性的 vLLM 风格调度器,跑出四种模式:
NAIVE:一次一个请求,无批处理。STATIC:pad 后等齐,经典批处理。CONTINUOUS:迭代级接纳与释放。CONTINUOUS + CHUNKED:prefill 片与 decode 交替。输出含总吞吐(每虚拟秒 token 数)、TTFT 均值、P99 ITL。CONTINUOUS + CHUNKED 行在混合流量下应当碾压其余。下面给一个最简可读骨架,聚焦连续批处理的核心循环。
def continuous_batch(requests, prefill_chunks=512, max_blocks=64, block_tokens=16): """玩具级连续批处理调度器(展示思路,非生产可用)。 requests: [(req_id, prompt_len, target_out_len), ...] 返回: 每个虚拟 tick 的吞吐统计与 P99 ITL 估计。 """ RUNNING, WAITING = [], list(requests) free_blocks = max_blocks done = [] itl_samples = [] tick = 0 while RUNNING or WAITING: # 1) 移除已完成 for s in list(RUNNING): if s["decoded"] >= s["target"]: free_blocks += s["blocks"]; RUNNING.remove(s); done.append(s) # 2) 接纳新请求(只要还有 KV block) while WAITING and free_blocks > 0: s = WAITING.pop(0) s.update({"prefilled": 0, "decoded": 0, "blocks": (s["target"] + block_tokens - 1) // block_tokens}) free_blocks -= s["blocks"] RUNNING.append(s) # 3) 一个融合前向:推进每个序列的 prefill 或 decode for s in RUNNING: if s["prefilled"] < s["prompt"]: s["prefilled"] += min(prefill_chunks, s["prompt"] - s["prefilled"]) else: s["decoded"] += 1 itl_samples.append(1) # 真实场景记录墙钟间隔 tick += 1 p99_itl = sorted(itl_samples)[int(len(itl_samples) * 0.99)] if itl_samples else 0 total_tokens = sum(s["target"] for s in done) return {"ticks": tick, "tokens": total_tokens, "throughput": round(total_tokens / max(tick, 1), 2), "p99_itl": p99_itl} # 混合流量:几个短请求 + 一个长 prompt 长输出 reqs = [{"id": i, "prompt": 200 + 30000 * (i % 3 == 0), "target": 100 + 400 * (i % 4)} for i in range(20)] print(continuous_batch(reqs)) # 注意:玩具版省略了 PagedAttention 的逐 block 分配与真实 GPU 延迟建模
💡 把玩具版与
STATIC模式对比:同样 20 个请求,静态批处理会把所有输出 pad 到最长那个(约 500 token),而连续批处理让短请求先走——这正是吞吐差距的来源。
| 范式 | 批次构成 | KV 分配 | 长提示影响 | 适用 |
|---|---|---|---|---|
| 静态批处理 | 窗口内 pad 到最长 | 连续预分配,碎片 60~80% | 冻住整批 | 已废弃,仅教学 |
| 动态批处理(老) | 等窗口填满再整批跑 | 连续预分配 | 快序列等慢序列 | 早期 TGI 默认 |
| 连续批处理(vLLM) | 迭代级进/出 | PagedAttention,碎片 <4% | 仍冻 decode | 2026 主流 |
| 连续 + 分块 prefill | 迭代级 + prefill 切片 | PagedAttention | decode 不饿死 | 生产默认 |
配套引擎:vLLM(V1 调度器,三项全默认)、SGLang(RadixAttention + 连续批处理,见第 06 节)、TensorRT-LLM(自研 in-flight batching,思路同连续批处理)、TGI(连续批处理但 KV 分配较旧)。
⚠️ 别只盯着「谁的 tok/s 数字高」。在混合长短流量下,P99 ITL 与 TTFT 尾部往往比平均吞吐更能决定用户体验——这正是分块 prefill 存在的理由。
本节产出 outputs/skill-vllm-scheduler-reader.md(原课程目录)。给定一个服务配置(批次大小、KV 内存利用率、分块 prefill 大小、投机配置),它会产出一份调度器诊断:
--gpu-memory-utilization、--max-num-batched-tokens、chunk 大小)。跑通模拟器:运行 code/main.py。在长短混合的工作负载上对比 STATIC 与 CONTINUOUS。吞吐差距来自哪——prefill 效率、decode 效率,还是尾部延迟?
加参数:给玩具调度器加 --max-num-batched-tokens。对跑 Llama 3.3 70B FP8 的 H100,合理值是多少?(提示:它是 KV block 大小与空闲 block 数的函数,不是裸 HBM。)
读发布说明:重读 vLLM v0.18.0 发布说明,列出哪些 flag 组合互斥。
算碎片浪费:对一份 1000 请求、均值 1500 输出 token、标准差 600 的 trace,分别在(a)每请求连续分配到 8192 上限、(b)PagedAttention 16-token block 下,算 KV 缓存碎片浪费。
一段话解释:为什么分块 prefill 帮 P99 ITL 但孤立看不帮吞吐?实际生产里吞吐提升又来自哪里?
--enable-chunked-prefill 与 --speculative-model(draft model)互斥,例外是 V1 的 N-gram GPU 投机。下一节,我们把投机解码从「概念」推进到生产——看 EAGLE-3 如何在 vLLM 里稳定拿到 2~3 倍加速,以及它与传统 draft model 的取舍。