Continuous Batching 深入实现:从 attention mask 到 chunked prefill
Continuous Batching 深入实现:从 attention mask 到 chunked prefill
前情说明:技术深度 D5。前四篇挖了各种"计算侧"细节。这最后一篇挖 调度侧 / batching 侧——Continuous Batching 是所有推理引擎的基石,但很多人只知道"它让 GPU 利用率高",不知道底层是怎么让 prefill 和 decode 混跑、怎么处理 attention mask、chunked prefill 怎么切、preemption 怎么做。 素材来自 vLLM 官方 optimization 文档、Orca 原始论文(OSDI 2022)、DigitalOcean 的机制解读文章、以及 vLLM V1 scheduler 源码。
起手:为什么 continuous batching 是革命性的
2022 年之前,LLM 推理是这样的:
时间轴(batch of 4 requests):
Request 0 (长度 100): ▓▓▓▓▓▓▓▓▓▓
Request 1 (长度 50): ▓▓▓▓▓░░░░░ ← 完成后闲置
Request 2 (长度 30): ▓▓▓░░░░░░░ ← 完成后闲置
Request 3 (长度 80): ▓▓▓▓▓▓▓▓░░
↑ 整个 batch 等最慢的
这是 static batching——四个 request 必须一起开始、一起结束。Request 1、2 完成后 GPU 那部分算力空转,等 Request 0 完成才能开下一个 batch。
GPU 利用率非常低——在真实 workload 里通常 20-40%。
Orca 论文(OSDI 2022,Yu et al.)提出 Continuous Batching(论文里叫 iteration-level scheduling)——每一次 forward 都是独立调度决策,request 完成一个就立刻从 batch 里移出,新 request 立刻加入。
时间轴(continuous batching):
Request 0: ▓▓▓▓▓▓▓▓▓▓ ┐
Request 1: ▓▓▓▓▓ ├─ Request 4 从这里加入
Request 4: ▓▓▓▓▓▓▓ ┘
Request 2: ▓▓▓ Request 5 从这里加入
Request 5: ▓▓▓▓▓▓
Request 3: ▓▓▓▓▓▓▓▓
↑ GPU 永远不停
GPU 利用率提升到 60-90%——这就是为什么所有现代推理引擎都必用 continuous batching。
vLLM、SGLang、TGI、TensorRT-LLM 全部实现了这个机制——但实现细节各家差异巨大。这篇挖一挖 vLLM V1 是怎么做的。
第一层:Iteration-Level Scheduling
Continuous batching 的核心是 "每一步 forward = 一次独立调度决策"。
一次 forward step 的三阶段
while True:
# 阶段 1: 调度决策
batch = scheduler.get_next_batch()
# 决定这一步要跑哪些 request、每个 request 跑多少 token
# 阶段 2: Forward
logits = model.forward(batch)
# 阶段 3: 后处理
for request in batch:
token = sample(logits[request])
request.append_token(token)
if is_finished(request):
scheduler.remove(request)
# else: 保持在 running set 里,下一次 iter 继续
关键点:Request 只在 finish 时才离开——不是 batch 结束才离开。这就是"每 iteration 独立"的含义。
Scheduler 的三个数据结构
vLLM V1 的 scheduler 维护三个 queue:
waiting: [req_10, req_11, req_12, ...] # 新到达,还没开始 prefill
running: [req_5, req_7, req_8, ...] # 正在跑,可能是 prefill 也可能是 decode
swapped: [req_2, req_4] # 被 preempt 出去的(KV 换出)
每次 iteration,scheduler 做的事情:
def get_next_batch():
batch = []
# 1. 优先跑 running 里的 request(延迟低)
for req in running:
if req.needs_prefill():
add_prefill_tokens(batch, req)
else:
add_decode_token(batch, req)
# 2. 如果 GPU 还有空间,从 waiting 里加
while gpu_has_capacity() and waiting:
req = waiting.pop()
add_prefill_tokens(batch, req)
running.append(req)
# 3. 如果需要 swap out(preemption),选择 victim
if not gpu_has_capacity() and batch is empty:
preempt_and_swap()
return batch
没有魔法——就是 request 的 lifecycle 管理。魔法在下面几层。
第二层:Prefill 和 Decode 混跑的 Attention Mask
这是 continuous batching 最烧脑的部分。batch 里可能同时有 prefill 和 decode 请求——怎么让它们一起过 attention?
两种请求的形状差异
Request A (prefill, 500 tokens):
Q shape: (500, D) ← query 数量 = 上下文长度
K/V shape: (500, D) ← 就是它自己
Attention: (500, 500) causal mask
Request B (decode, next token):
Q shape: (1, D) ← query 只有 1 个(新 token)
K/V shape: (2048, D) ← 完整历史 (2048 tokens)
Attention: (1, 2048) 无 mask(新 token 可以看所有历史)
Q 长度完全不同——一个 500 一个 1。要塞进一个 batch 怎么办?
方案:Ragged Batch + cu_seqlens
vLLM V1 的做法:Front-pack 成一维(跟前一篇 CUDA Graph 那个 front-pack 思路一致):
拼接 batch:
[A_q_0, A_q_1, ..., A_q_499, B_q_0] ← Q 拼成一维,总长 501
└────── Request A (500) ──────┘└─ B (1)
同样拼接 K/V:
[A_kv_0, ..., A_kv_499, B_kv_0, ..., B_kv_2047] ← 总长 2548
Metadata:
cu_seqlens_q = [0, 500, 501] # Q 的分界
cu_seqlens_k = [0, 500, 2548] # K/V 的分界
Attention kernel 通过 cu_seqlens 知道每个 sequence 的边界——FlashAttention 天然支持这个(flash_attn_varlen_func)。
Attention Mask 的处理
每个 request 内部:causal mask(正常)
Request 之间:完全隔离,不 attend
Kernel 层面通过 block-diagonal mask 实现:
A_q_0 A_q_1 ... A_q_499 B_q_0
A_kv_0 ▓ ▓ ▓ ✗
A_kv_1 ▓ ▓ ✗
... ... ▓ ✗
A_kv_499 ▓ ✗
B_kv_0 ✗ ✗ ✗ ▓
...
B_kv_2047 ✗ ✗ ✗ ▓
块对角线上的每个块内部是 causal mask,块之间完全禁止 attend。FlashAttention varlen 版本原生实现这个——不需要显式传 mask 矩阵。
这就是为什么 vLLM 一定要用 FlashAttention 后端——传统 attention 实现不支持 varlen batch。
第三层:Chunked Prefill
Prefill 一次算 500 甚至 5000 个 token——算力 bound,占用 GPU 很久。同一 batch 的 decode 请求会被拖住——延迟劣化严重。
Chunked Prefill 的做法:把长 prefill 切成小块,一次只处理一段:
原始 Request X (prefill 4000 tokens):
iter 1: ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ← 一次跑完 4000 token
其他 decode 请求全被拖 100ms+
Chunked Prefill (chunk_size=512):
iter 1: ▓ (500 tok prefill) + ▓ (30 decode) ← 混跑
iter 2: ▓ (500 tok prefill) + ▓ (30 decode)
iter 3: ▓ (500 tok prefill) + ▓ (30 decode)
...
iter 8: ▓ (500 tok prefill) + ▓ (30 decode)
收益:
- Decode 请求 ITL (Inter-Token Latency) 稳定——不会被长 prefill 打断
- Prefill 时间几乎不损失——分块跑总和相当
- 系统整体 goodput 提升
vLLM V1 chunked prefill 默认开启——--enable-chunked-prefill 或者自动检测。
数学细节:怎么切?
Chunk size 是关键超参:
- 太小(如 128):overhead 大(每个 chunk 都有 fixed cost)
- 太大(如 8192):一个 chunk 又变成"独占 GPU"
经验值:chunk_size = 512 或 1024 是常见默认。也可以按 token budget 动态决定——比如 batch total token 数 ≤ 8192。
vLLM V1 的 --max-num-batched-tokens 就是这个 budget(默认 8192)。Scheduler 每 iter 会选择 mix 使得 total token ≤ budget。
一个 chunked prefill 请求怎么组织 attention
Chunked prefill 稍微 tricky——同一 request 分了多个 chunk,跨 iter 依然要 attend 前面的 chunk:
iter 1: 处理 token 0-511
KV cache: [0-511]
iter 2: 处理 token 512-1023
KV cache: [0-1023] ← 用 iter 1 存的 KV
Q shape: (512, D) ← 只有新 chunk 的 Q
K/V shape: (1024, D) ← 全部历史
这本质上是**"跨 iter 复用 KV cache"**——跟正常 decode 是同一个机制(都是从 KV cache 里读历史)。PagedAttention 天然支持。
第四层:Preemption 与 Recompute vs Swap
问题:GPU 显存有限。当 running 里的 request 太多、KV cache 装不下时怎么办?
答案:Preempt——把某个 request 从 GPU 上暂停,腾出 KV 空间给别的。
两种 preemption 策略
策略 A:Swap-based(vLLM V0 默认)
Preempt request X:
1. 把 X 的 KV cache 从 GPU HBM 复制到 CPU DRAM
2. 释放 GPU 上的 KV block
3. 恢复时把 KV 从 CPU 复制回来
优点:不用重算 → 恢复快 缺点:需要双向传输 KV cache——PCIe 慢,一次 swap 传输时间 100ms 级别
策略 B:Recompute-based(vLLM V1 默认)
Preempt request X:
1. 直接释放 X 的 KV block
2. 记住 X 的 prompt tokens
3. 恢复时重新 prefill 一遍(重新算 KV)
优点:无 PCIe 传输 缺点:需要重跑 prefill → 重算成本 = prefill 时间
为什么 V1 改成 recompute?
数字对比:
- Swap:一个 8K context 需要传 ~200 MB KV,PCIe 25 GB/s = 8ms 传输 × 2(进+出)= 16ms
- Recompute:8K context 的 prefill 也是几十 ms 到几百 ms
看起来 swap 更快?关键在于——Recompute 可以 chunked prefill!
Swap (16 ms) → 阻塞所有其他 request 16ms 等 KV 传输
Recompute (200 ms 但 chunked) → 分成 20 个 chunk,跟 decode 混跑,其他 request 每 iter 只慢 5%
总吞吐更高——这是 V1 改 recompute 的关键理由。
Preemption 优先级
LRU(least recently used) 是最常见的——被 preempt 的是最久没有被服务的 request。
vLLM V1 有更精细的策略:
- 优先 preempt 短请求(重算便宜)
- 保留 KV cache hit 率高的 request(换出损失大)
- 考虑用户 priority(付费用户不 preempt)
这一块目前还在快速迭代——每一版 vLLM 都在调 preemption 策略。
第五层:Zero-Overhead Scheduling(SGLang v0.4)
前面几个机制都是"CPU 先决定,GPU 再跑"——CPU 决定的时候 GPU 空闲。
SGLang v0.4 的 zero-overhead scheduler(前面 vLLM vs SGLang 那篇 讲过):CPU 决定和 GPU 计算并发跑。
时间轴(zero-overhead):
CPU: [调度批 1] [调度批 2] [调度批 3] [调度批 4]
GPU: [跑批 1 ] [跑批 2 ] [跑批 3 ] [跑批 4]
↑ GPU 从批 2 起永远不停
关键 tricks:
- CPU 提前一步调度——决定完 batch N+1 时,GPU 还在跑 batch N
- Future tokens——batch N+1 的输入依赖 batch N 的输出,SGLang 用"占位 token"绕过依赖
- 精心编排 CUDA event——确保数据依赖正确的同时不阻塞 CPU
vLLM V1 也在做类似优化(Async Engine),但集成程度还不如 SGLang。
为什么这个优化在低 batch 时特别重要
高 batch 时 CPU 决定时间可以忽略(GPU 跑得慢)。低 batch 时 CPU 决定时间可能比 GPU 跑还长——这时候 zero-overhead 就是 2x 加速。
SGLang v0.4 blog 里的 Nsight profile 显示:5 个连续 decode 批的 GPU 没有一次空闲——这就是 zero-overhead 的直观效果。
第六层:Async Output Processing
即使调度做得完美,还有一个隐藏开销——每一步 forward 后的 sampling + tokenize + response 构造。
传统流程:
step 1:
GPU: forward → logits
CPU: 从 logits sample → tokenize → JSON 序列化 → 发给客户端
step 2:
GPU: forward → ...
CPU 后处理时 GPU 空闲——又是一次浪费。
Async Output Processing(vLLM V1 默认):
step 1:
GPU: forward → logits ┐
CPU (async thread): 后处理 step 0 ┴─ 并行
step 2:
GPU: forward
CPU (async thread): 后处理 step 1
效果:GPU 完全不 等 CPU tokenize/序列化——又挤出 5-10% 吞吐。
关键设计:sampling 结果先"predict"(用假设的 token 继续跑 next step),实际结果 async 处理完再校对。如果 predict 错了就 rollback——但错误率极低(sampling 是纯 GPU 操作)。
一次完整 decode step 的 timeline
把所有优化串起来看,一次 decode step 长这样:
时刻: 0μs 100μs 200μs 300μs 400μs
│ │ │ │ │
CPU: [Schedule batch N+1 ][Async post-process step N-1]
GPU: [Batch N: attn + FFN + attn + FFN + ... x 80 layer ]
└──────────────────── forward ──────────────────────┘
↑ ↑
CUDA Graph launch (1 μs) Sample (0.5 μs)
CPU 和 GPU 完全并行:
- CPU 边跑当前 batch 的后处理(step N-1 sampling / tokenize / send response)
- CPU 边为下一 batch 做 scheduling(future token 假设 next 决策)
- GPU 用 CUDA Graph 一次 launch 全部 80 层 kernel
- GPU 用 continuous batching 塞满 prefill + decode 混合
这就是"生产级"推理引擎的样子——每一纳秒都被榨干。
三个通用启示
启示 1:Continuous Batching 的核心是"decoupled lifecycle"
传统 batching 把 request 的 lifecycle 绑定到 batch lifecycle——同生同死。Continuous batching 把这两者解绑——request 独立进出,batch 只是每次 iteration 的一个 snapshot。
这是一种普适的架构解耦——任何"批处理"系统都可以用这个思路(比如 SIMD 数据处理、GPU 训练 batch、Ray 批推理)。
启示 2:调度是"层层递归"的
看这一整篇的六层机制,你会发现——每一层都在"更细粒度地调度":
- 第 1 层:request-level 调度(iteration 级)
- 第 2 层:token-level 拼装(varlen batch)
- 第 3 层:chunk-level 切分(prefill 切碎)
- 第 4 层:memory-level 决策(preempt 谁)
- 第 5 层:CPU-GPU 并行调度
- 第 6 层:output pipeline 异步
每一层递归的思想都是同一个——"当上一层还有空隙时,就再切一刀塞进去"。
这是一种非常朴素的性能优化哲学——没有魔法,只有一层层榨干。
启示 3:设计一个好 scheduler 需要"三重视角"
好的 scheduler 需要同时考虑:
- 单请求视角:这个 request 的 TTFT / ITL / TPOT 怎么优化?
- 批量视角:整个 batch 的 goodput 怎么最大?
- 系统视角:memory / compute / network 三资源怎么协调?
三个视角经常冲突:
- 单请求要低延迟 → 需要单独 GPU
- 批量要高吞吐 → 需要大 batch
- 系统要公平 → 需要 preempt hot request
vLLM V1 用 priority + fairness + resource-aware 的综合决策模型——但没有绝对最优,只有 "对你的 workload 最合适"。
这就是为什么调优 vLLM 是艺术不是科学——max_num_seqs / max_num_batched_tokens / chunk_size / swap_space 这些参数都是跟 workload 强相关。没有 workload 分析就没有正确的参数。
附:素材来源
主线
- vLLM Docs · Optimization and Tuning
- DigitalOcean · Continuous Batching Mechanics
- DeepWiki · Scheduler & Continuous Batching
论文
- Orca (原始 continuous batching 论文) · OSDI 2022
- SARATHI(chunked prefill 原始论文) · arxiv 2308.16369
代码
- vLLM V1 Scheduler ·
vllm/v1/core/sched/ - SGLang zero-overhead scheduler ·
python/sglang/srt/managers/scheduler.py - vllm-lite - 教学用简化版 vLLM V1
姊妹篇
- 《现代 LLM 推理引擎剖析》 - 讲了 vLLM V1 完整架构
- 《vLLM vs SGLang 架构哲学》 - 讲了 zero-overhead scheduler
- 《CUDA Graph 在 LLM 推理实战》 - front-pack 机制
- 《FlashAttention 三代演进》 - varlen attention kernel
整理于 2026-07-31 深夜。原文技术细节归属 vLLM/SGLang 团队与 Orca 作者。中文表达可能有解读偏差,请以原文为准。

Written by
Zoe
AI Infra Engineer · LLM Serving · GPU/RDMA · 造工具的偏执狂