一、为什么需要 Continuous Batching
1.1 三种 Batching 策略对比
① 静态批处理 (Static Batching) — 传统方式 ┌──────────────────────────────────────────────┐ │ Req A: [Prefill][D][D][D][D][D][D][D][D][D][D] │ ← 生成长,一直占着 │ Req B: [Prefill][D][D] │ ← 早就结束了,slot 空着 │ Req C: [Prefill][D][D][D] │ ← 也结束了 │ Req D: [Prefill][D][D][D][D][D] │ └──────────────────────────────────────────────┘ → B/C 结束后必须等 A 完成才能处理新请求 → GPU 利用率极低,大量 slot 空转 ② 动态批处理 (Dynamic Batching) — TGI/DeepSpeed ┌──────────────────────────────────────────────┐ │ Req A: [Prefill][D][D][D][D][D][D][D][D][D][D] │ │ Req B: [Prefill][D][D] │ │ Req E: [Prefill][D][D][D] │ ← B 结束后 E 等到下一个 iter │ Req D: [Prefill][D][D][D][D][D] │ └──────────────────────────────────────────────┘ → 只在 iteration 边界检查请求状态 → 粒度还是太粗,E 要等 1 个 iteration ③ 连续批处理 (Continuous Batching) — vLLM ┌──────────────────────────────────────────────┐ │ Req A: [Prefill][D][D][D][D][D][D][D][D][D][D] │ │ Req B: [Prefill][D][D] │ │ Req E: [ ][Prefill][D][D][D][D] │ ← B 结束的下一 step 立刻插入 │ Req C: [Prefill][D][D][D] │ │ Req F: [ ][ ][Prefill][D][D] │ ← C 结束后 F 立刻插入 │ Req D: [Prefill][D][D][D][D][D] │ └──────────────────────────────────────────────┘ → 每个 step 都可以动态加入/移除请求 → GPU slot 始终被有效请求占满1.2 吞吐量对比
| Batching 策略 | 并发 128 时吞吐量 | GPU 利用率 |
|---|---|---|
| 静态批处理 | ~1200 t/s | 25%-35% |
| 动态批处理 | ~2500 t/s | 50%-60% |
| 连续批处理 | ~5800 t/s | 85%-95% |
二、Continuous Batching 的核心机制
2.1 迭代调度的本质
Continuous Batching 的核心是一个迭代循环。每个 iteration(即每生成一个 token)都是一个独立的调度决策点:
2.2 三队列模型
class Scheduler: def __init__(self, ...): # 三个核心队列 self.waiting: Deque[SequenceGroup] = deque() # waiting: 新到达的请求,尚未做 Prefill # 只有 Prefill 后才能开始 Decode self.running: Deque[SequenceGroup] = deque() # running: 正在 Decode 的请求 # 每个 step 为它们各生成一个 token self.swapped: Deque[SequenceGroup] = deque() # swapped: 被 swap out 到 CPU 的请求 # 显存不足时的临时中转站 请求生命周期: arrived → [waiting] → Prefill → [running] → Decode... → finished ↓ 显存不足 → [swapped] → 显存恢复 → [running]三、调度循环核心
3.1 schedule():每步的调度决策
class Scheduler: def schedule(self) -> SchedulerOutputs: """ 每个 iteration 调用一次 决定这个 step 处理哪些请求 """ scheduler_outputs = SchedulerOutputs() # ── Step 1: 处理已完成的请求 ── # 上一 step 生成的 token 可能触发 EOS # 完成的请求从 running 移出,释放 KV Cache Block self._schedule_finished_seqs() # ── Step 2: 检查显存压力,必要时抢占 ── if self._check_memory_pressure(): self._preempt_running_seqs() # ── Step 3: 从 waiting 队列补充新请求 ── # 这是 Continuous Batching 的核心 # 每 step 都检查能否加入新请求 self._schedule_waiting_seqs() # ── Step 4: 构建 batch ── # 将 running 队列中的请求组成一个 batch # 同时可能包含 waiting 队列中新请求的 Prefill self._build_batch(scheduler_outputs) return scheduler_outputs3.2 Continuous Batching 的精髓:每步都可以插入新请求
def _schedule_waiting_seqs(self): """ 关键逻辑:每个 step 都尝试从 waiting 队列拉取新请求 这就是 "Continuous" 的含义 — 不是等到整个 batch 完成才换人 而是每个 token 生成完后都可以换人 """ # 计算当前显存余量 free_blocks = self.block_manager.get_num_free_gpu_blocks() while self.waiting: seq_group = self.waiting[0] # 判断显存是否足够做 Prefill required_blocks = self._get_prefill_blocks(seq_group) if required_blocks > free_blocks: break # 显存不够,停止补充 # 从 waiting 移到 running self.waiting.popleft() self.running.append(seq_group) free_blocks -= required_blocks # 标记:这个请求这个 step 做 Prefill seq_group.set_schedule_type(ScheduleType.PREFILL)3.3 两种抢占策略
当显存不足时,vLLM 需要从 running 队列中抢占请求释放显存:
def _preempt_running_seqs(self): """ 显存不足时,从 running 队列尾部抢占请求 两种策略:SWAP 或 RECOMPUTE """ while self._check_memory_pressure() and self.running: # 取最后加入的请求(FIFO 的反面,LIFO 抢占) seq_group = self.running.pop() if self.preemption_mode == PreemptionMode.SWAP: # 策略 A: SWAP — 将 KV Cache 拷贝到 CPU self.block_manager.swap_out(seq_group) self.swapped.append(seq_group) elif self.preemption_mode == PreemptionMode.RECOMPUTE: # 策略 B: RECOMPUTE — 直接丢弃 KV Cache # 下次恢复时重新做 Prefill self.block_manager.free(seq_group) self.waiting.appendleft(seq_group) # 放回 waiting 头部 策略 A: SWAP running → [GPU KV Cache] 拷贝到 CPU → swapped 恢复: swapped → [CPU] 拷贝回 GPU → running 优点: 恢复快(只需拷贝,不需重新计算) 缺点: 需要额外 CPU 内存 策略 B: RECOMPUTE running → 丢弃 [GPU KV Cache] → waiting 恢复: waiting → 重新 Prefill → running 优点: 不占 CPU 内存 缺点: 恢复慢(要重新算 Prefill)| 策略 | 恢复延迟 | 内存需求 | 适用场景 |
|---|---|---|---|
| SWAP | 低(~5ms) | CPU 内存 = GPU KV Cache 大小 | 显存压力大但不极端 |
| RECOMPUTE | 高(~200ms) | 无 | 显存极端不足 |
3.4 Prefill 和 Decode 的分离
vLLM 的一个重要优化是Prefill-Decode 分离调度。一个 iteration 要么全做 Prefill,要么全做 Decode:
def _build_batch(self, scheduler_outputs): """ 判断这个 step 做 Prefill 还是 Decode """ # 如果有新请求从 waiting 进入,这个 step 做 Prefill has_new_seqs = any( sg.is_prefill() for sg in self.running ) if has_new_seqs: # Prefill step: 处理新请求的完整输入 # Prefill 是计算密集型,GPU 利用率接近 100% batch = self._build_prefill_batch() else: # Decode step: 为所有 running 请求各生成一个 token # Decode 是访存密集型,GPU 利用率可能只有 30% # 这就是为什么 Continuous Batching 重要: # Decode step 可以塞入更多请求来提高 GPU 利用率 batch = self._build_decode_batch()为什么要分离?因为 Prefill 和 Decode 的计算特性完全不同:
Prefill(首 token 生成): 输入: prompt(几百到几千 token) 计算: 并行处理所有输入 token GPU 利用率: ~95% (compute-bound) 耗时: 50-200ms Decode(后续 token 生成): 输入: 上一步生成的 1 个 token 计算: 只处理 1 个 token GPU 利用率: ~20-30% (memory-bound) 耗时: 5-20ms → Decode 时 GPU 大量空闲 → Continuous Batching 利用这些空闲周期塞入更多请求四、连续批处理的数学模型
4.1 吞吐量建模
吞吐量 = (batch_size × 1 token) / (decode_time_per_step) 传统静态批处理: batch_size = 32 (固定) 每步所有 32 个请求都 decode(包括已完成的空 slot) decode_time ≈ 15ms (固定) 但实际有效请求平均只有 18 个 吞吐量 = 18 / 0.015 = 1200 t/s 连续批处理: batch_size 动态变化 (20-128) 每步所有 running 请求都 decode(都是有效请求) decode_time ≈ 15ms (固定) 平均有效 batch_size ≈ 87 吞吐量 = 87 / 0.015 = 5800 t/s4.2 GPU 利用率建模
Decode step 的 GPU 时间分解: ┌─────────────────────────────────────────┐ │ Kernel 执行 4ms (27%) ← 计算 │ │ 显存读取 10ms (67%) ← 访存 │ │ Kernel Launch 1ms (6%) ← 开销 │ └─────────────────────────────────────────┘ Total: 15ms GPU SM 利用率 ≈ 27% → 浪费了 73% 的 GPU 计算能力 Continuous Batching 的对策: 把更多请求塞进同一个 step → 显存读取量增加 (每个请求多读 KV Cache) → 但 Kernel 执行可以 batch 化 → SM 利用率提升到 60-80%五、实测数据
5.1 测试环境
GPU: NVIDIA A100 80GB × 1 模型: Llama-2-7B-Chat (FP16) vLLM 版本: v0.6.0 输入长度: 512 tokens (固定) 输出长度: 128 tokens (固定)5.2 不同并发下的吞吐量
┌──────────────┬──────────┬──────────┬──────────┬──────────┐ │ 并发数 │ 静态 Batch│ 动态 Batch│ 连续 Batch│ 提升 │ ├──────────────┼──────────┼──────────┼──────────┼──────────┤ │ 1 │ 42 t/s │ 48 t/s │ 48 t/s │ 1.14x │ │ 8 │ 180 t/s │ 520 t/s │ 520 t/s │ 2.89x │ │ 16 │ 280 t/s │ 1050 t/s │ 1050 t/s │ 3.75x │ │ 32 │ OOM │ 2100 t/s │ 2100 t/s │ N/A │ │ 64 │ OOM │ 3500 t/s │ 3800 t/s │ N/A │ │ 128 │ OOM │ OOM │ 5800 t/s │ N/A │ └──────────────┴──────────┴──────────┴──────────┴──────────┘关键发现:
低并发(1-8):三种策略差距不大,瓶颈在计算而非显存
中并发(16-64):动态和连续批处理远超静态
高并发(128):只有 Continuous Batching 能扛住,吞吐量达 5800 t/s
5.3 变长输出场景的对比
真实场景中请求的输出长度是不均匀的,这最能体现 Continuous Batching 的价值:
测试: 100 个请求,输出长度分布 [32, 256, 64, 512, 128, ...] 静态批处理 (batch=32): Step 0-31: 所有 32 个请求同时 Decode Step 32: Req 3 完成 (32 token) Step 64: Req 7 完成 ... Step 512: 最后一个请求完成 (512 token) → 大量 step 中有效请求远小于 32 连续批处理: Step 0-31: 32 个请求 Decode Step 32: Req 3 完成 → 从 waiting 立刻拉入 Req 33 Step 64: Req 7 完成 → 拉入 Req 34 ... → batch 始终保持满载| 场景 | 静态 Batch 吞吐 | 连续 Batch 吞吐 | 提升 |
|---|---|---|---|
| 等长输出 (128) | 1200 t/s | 2100 t/s | 1.75x |
| 变长输出 [32-512] | 650 t/s | 3200 t/s | 4.9x |
| 极端变长 [16-1024] | 380 t/s | 2900 t/s | 7.6x |
结论:输出越不均匀,Continuous Batching 的优势越明显。
六、调度器实战代码
6.1 完整推理循环
from vllm import LLM, SamplingParams # 初始化 — vLLM 自动开启 Continuous Batching llm = LLM( model="meta-llama/Llama-2-7b-chat-hf", tensor_parallel_size=1, gpu_memory_utilization=0.90, max_model_len=4096, # 调度相关参数 block_size=16, # PagedAttention 块大小 enable_prefix_caching=True, # 前缀缓存优化 swap_space=4, # CPU swap 空间 (GB) ) # 模拟变长请求 prompts = [ "写一首关于秋天的诗", # 短输出 "详细解释 PagedAttention 的工作原理", # 长输出 "什么是 RESTful API?", # 中等输出 "用 Python 实现快速排序", # 中等输出 "总结一下 TCP 三次握手", # 短输出 ] * 50 # 250 个请求 sampling_params = SamplingParams( temperature=0.7, top_p=0.9, max_tokens=512, ) # vLLM 内部自动 Continuous Batching # 250 个请求会被动态调度,不需要手动分批 outputs = llm.generate(prompts, sampling_params) # 统计吞吐量 total_tokens = sum( len(o.outputs[0].token_ids) for o in outputs ) # vLLM 会打印吞吐量统计: # "Throughput: 5234.5 tokens/s"6.2 流式输出场景
from vllm import LLM, SamplingParams llm = LLM(model="meta-llama/Llama-2-7b-chat-hf") # 流式生成 — Continuous Batching 在流式场景同样生效 sampling_params = SamplingParams( temperature=0.7, max_tokens=512, stream=True, # 流式输出 ) # 多个请求并发流式 for output in llm.generate(prompts, sampling_params, use_tqdm=False): for chunk in output: print(chunk.outputs[0].text, end="", flush=True)七、性能调优建议
7.1 最大化吞吐量的配置
llm = LLM( model="...", gpu_memory_utilization=0.92, # 尽量用满显存 max_num_seqs=256, # 最大并发数,影响 batch 上限 max_model_len=4096, block_size=16, swap_space=4, # 给 swap 留空间 # 吞吐量优化: enforce_eager=False, # 用 CUDA Graph 减少 launch 开销 enable_prefix_caching=True, # 前缀缓存 max_num_batched_tokens=8192, # 单 step 最大 token 数 )7.2 参数对吞吐量的影响
| 参数 | 推荐值 | 影响 |
|---|---|---|
gpu_memory_utilization | 0.90-0.92 | 越高 → 可支持更多并发 → 吞吐越高 |
max_num_seqs | 128-256 | 并发上限,受显存限制 |
max_num_batched_tokens | 8192 | 单 step token 上限 |
block_size | 16 | 影响显存碎片和 kernel 效率 |
swap_space | 4-8 (GB) | Swap 策略的缓冲空间 |
enable_prefix_caching | True | 共享前缀的请求加速 |
7.3 延迟 vs 吞吐量的权衡
追求延迟 (交互式场景): max_num_seqs = 16-32 # 小 batch,每个请求更快 max_num_batched_tokens = 2048 → 牺牲吞吐量换取低延迟 追求吞吐量 (批量推理): max_num_seqs = 128-256 # 大 batch max_num_batched_tokens = 8192 → 牺牲延迟换取高吞吐量八、总结
Continuous Batching 的三个核心贡献:
每步动态调度:每个 token 生成后都可以加入/移除请求,GPU slot 不空转
Prefill-Decode 分离:利用 Decode step 的 GPU 空闲周期塞入更多请求
抢占与恢复:显存不足时优雅降级,Swap 或 Recompute 都能恢复
| 优化点 | 贡献的吞吐量提升 |
|---|---|
| 连续批处理(vs 静态) | 3-5x |
| Prefill-Decode 分离 | 1.5-2x |
| 抢占与恢复(防 OOM 崩溃) | 可用性保障 |
这三者加上 PagedAttention 的显存优化,共同构成了 vLLM 5800 t/s 的技术基石。
下一篇预告:下一篇进入大数据领域,对 Iceberg、Hudi、Delta Lake 三大数据湖格式进行深度压测对比,在写入吞吐、读取性能、Time Travel、Compaction 效率四个维度横评,附选型决策树。
往期回顾:AI 推理优化系列—vLLM PagedAttention 解析:显存利用率从 40% 提升到 90% 的秘密。
llama.cpp Q4 量化原理拆解:10GB 显存跑 70B 模型的秘密。
Kafka acks 机制性能实测:acks=all 在百万级吞吐下的延迟代价有多大。
GPTQ vs AWQ vs GGUF:三大量化方案性能与精度横评。
Kafka 深度解剖 2:消费者组再均衡 Rebalance 全流程。
觉得有帮助请点赞收藏。关注专栏「AI大模型+大数据+硬件编程」,每周更新大模型推理部署的深度实战内容。