引言:为什么 Batching 策略决定了推理服务的天花板
在大模型推理服务的工程实践中,有一个经常被低估却至关重要的设计决策:如何将并发的用户请求组织成批次(Batch)送入 GPU 执行。这个看似简单的调度问题,实际上直接决定了服务的吞吐量(Throughput)、延迟(Latency)和资源利用率(GPU Utilization)。
早期的大模型推理引擎采用的是静态批处理(Static Batching)策略——将一批请求凑齐后一次性送入模型,等所有请求都完成生成后再接收下一批。这种方式简单直观,但存在致命缺陷:短文本请求必须等待同批次中最长的请求完成,造成大量的算力空转与时间浪费。
随着 vLLM、TensorRT-LLM、TGI 等现代推理引擎的崛起,连续批处理(Continuous Batching,又称 Iteration-Level Scheduling / In-Flight Batching)成为了事实标准。它允许在每一个解码步(Decode Step)之间动态地插入新请求、移除已完成请求,彻底消除了”等待最慢请求”的问题。
本文将从底层原理出发,深入剖析 Static Batching 与 Continuous Batching 的差异,结合 PagedAttention 和 KV Cache 管理机制,揭示 Iteration-Level Scheduling 的工程实现细节,并给出生产环境下的调优实战建议。
一、Static Batching 的工作原理与性能瓶颈
1.1 静态批处理的基本流程
Static Batching 的核心思路非常简单:收集一批请求 → 填充(Padding)到统一长度 → 整批执行 → 等待全部完成 → 返回结果。以下伪代码展示了其基本逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 class StaticBatchScheduler:
def __init__(self, max_batch_size=32):
self.max_batch_size = max_batch_size
self.pending_queue = []
def step(self):
# 1. 从队列中取出一批请求
batch = self.pending_queue[:self.max_batch_size]
self.pending_queue = self.pending_queue[self.max_batch_size:]
# 2. 填充到最大序列长度(Padding)
max_len = max(len(req.tokens) for req in batch)
padded_tokens = pad_to_length(batch, max_len)
# 3. 整批前向传播,循环直到所有请求完成
while not all(req.finished for req in batch):
logits = model.forward(padded_tokens)
next_tokens = sample(logits)
append_tokens(batch, next_tokens)
# 4. 返回全部结果,批次生命周期结束
return [req.output for req in batch]
这种模式的关键特征是批次的生命周期由最慢的请求决定。假设一个批次中有 3 个请求,生成长度分别为 10、50、200 个 token,那么即使前两个请求在第 10 和第 50 步就已完成,它们仍然会占用 GPU 的计算槽位和 KV Cache 显存,直到第 200 步结束。
1.2 Padding 开销:被浪费的算力
静态批处理需要对齐序列长度,这意味着短序列需要被填充(Pad)到批次中最长序列的长度。以下表格直观展示了 Padding 带来的算力浪费:
| 场景 | 请求 A 长度 | 请求 B 长度 | 填充后长度 | 算力浪费 |
|---|---|---|---|---|
| 长度相近 | 100 | 120 | 120 | 约17% |
| 长度悬殊 | 50 | 500 | 500 | 约90% |
| 极端不均 | 10 | 1000 | 1000 | 约99% |
在实际生产环境中,用户请求的输入长度差异极大——有的只是”帮我写个标题”(10 token),有的是”总结这份 50 页的 PDF”(5000+ token)。Static Batching 在面对这种长度分布时,有效计算利用率可能低至 20%-30%。
1.3 Head-of-Line Blocking:延迟的灾难
静态批处理的另一个严重问题是队头阻塞(Head-of-Line Blocking)。当一个批次正在执行时,新到达的请求必须排队等待整个批次完成。如果当前批次碰巧包含一个超长请求,所有后续请求的延迟都会被严重拖累。
假设当前批次中有一个需要生成 2000 token 的长文本请求,此时又来了 10 个只需要生成 20 token 的短请求。在 Static Batching 模式下,这 10 个短请求必须等那个长请求跑完,平均排队延迟可能高达数秒——这在实时对话场景中是完全不可接受的。
二、Continuous Batching 的核心设计思想
2.1 从 Batch-Level 到 Iteration-Level 的范式切换
Continuous Batching 的核心思想是:将调度粒度从”批次级”降低到”迭代级”。在每一次解码步(Decode Iteration)结束后,调度器有机会重新审视当前的活跃请求集合:
- 已生成 EOS token 的请求可以立即移除,释放其占用的 KV Cache 显存和计算槽位
- 等待队列中的新请求可以在下一个迭代步立即插入
- 不再需要等待同一批次中的其他请求完成
这个看似简单的改变带来了质的飞跃:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26 class ContinuousBatchScheduler:
def __init__(self, max_batch_size=32):
self.max_batch_size = max_batch_size
self.running_requests = []
self.pending_queue = []
def step(self):
# 1. 移除已完成的请求(每个迭代步都检查!)
self.running_requests = [
req for req in self.running_requests
if not req.finished
]
# 2. 用新请求填满空出的槽位
available_slots = self.max_batch_size - len(self.running_requests)
new_requests = self.pending_queue[:available_slots]
self.pending_queue = self.pending_queue[available_slots:]
self.running_requests.extend(new_requests)
# 3. 对当前活跃请求执行单步前向传播
logits = model.forward_step(self.running_requests)
next_tokens = sample(logits)
append_next_token(self.running_requests, next_tokens)
# 4. 返回已完成请求的结果(无需等待其他请求)
return [req.output for req in self.running_requests if req.finished]
关键差异在于:
1 | model.forward_step() |
只执行一个解码步,而非循环直到所有请求完成。这使得调度器可以在每一步之间进行干预,实现真正的细粒度动态调度。
2.2 微批次视角:流式执行模型
我们可以将 Continuous Batching 理解为一个”流式执行模型”。想象一条传送带,每个解码步是传送带上的一个工位。请求像零件一样在传送带上流动——有的零件加工步骤少,很快就从传送带上下来;有的零件加工步骤多,留在传送带上继续处理。关键是:传送带不会因为某个零件还没加工完而停转。
这种流式模型的核心指标是活跃请求数(Active Request Count),而非”当前批次号”。在任何时刻,活跃集合中的请求可能处于完全不同的生成阶段:有的刚开始 Prefill,有的已经 Decode 了 100 步,有的只剩最后 2 步就要结束。
2.3 与 KV Cache 管理的协同:PagedAttention 的角色
Continuous Batching 的实现高度依赖高效的 KV Cache 管理。因为请求可以在任意时刻被插入或移除,KV Cache 的分配和回收必须是动态的、细粒度的。这正是 vLLM 的 PagedAttention 技术大显身手的地方。
PagedAttention 借鉴了操作系统中的虚拟内存分页机制:
- 物理块(Physical Block):GPU 显存中固定大小的 KV Cache 存储单元(通常每块存一个 Token 的 Key/Value 向量)
- 逻辑块(Logical Block):每个请求的 KV Cache 逻辑地址空间,通过块表(Block Table)映射到物理块
- 动态分配:新请求插入时按需分配物理块;请求完成时立即回收物理块
- 块共享:多个请求如果共享相同的 Prompt 前缀(Prefix),可以共享物理块,大幅减少显存占用
这种设计使 Continuous Batching 的显存管理效率极高。以下是一个简化的块表映射示意:
1
2
3
4
5
6
7
8
9
10
11 # 请求 A: Prompt 长度 128 token,已生成 32 token
# 请求 B: Prompt 长度 64 token,已生成 16 token
# 请求 C: 与 A 共享前 64 token 的 Prompt 前缀
Request A: Logic [0,1,2,3] -> Physical [10,11,12,13] # 4 个块
Request B: Logic [0,1] -> Physical [20,21] # 2 个块
Request C: Logic [0,1,2] -> Physical [10,11,30] # 前 2 块与 A 共享!
# 当请求 B 完成后,Physical [20,21] 立即被回收
# 当请求 A 完成后,Physical [12,13] 被回收
# 但 Physical [10,11] 要等 A 和 C 都完成才能回收(引用计数 > 0)
三、Iteration-Level Scheduling 的工程实现细节
3.1 Prefill 与 Decode 的混合调度
在 Continuous Batching 中,一个常见的挑战是如何处理新请求的 Prefill 阶段。Prefill 是计算密集型操作(需要一次性处理整个 Prompt),而 Decode 是访存密集型操作(每次只处理 1 个 Token)。将两者放在同一个批次中会导致计算效率下降——Prefill 请求的计算量远大于 Decode 请求,GPU 的 SM 利用率不均衡。
现代推理引擎通常采用以下策略之一来处理这个问题:
| 策略 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 混合调度 | Prefill 和 Decode 在同一批次中执行 | 实现简单,延迟低 | 计算效率不均衡 |
| Chunked Prefill | 将长 Prompt 分块,逐步混入 Decode 批次 | 计算更均衡,Prefill 不阻塞 Decode | 实现复杂,需要跨 Chunk 的 KV Cache 管理 |
| PD 分离 | Prefill 和 Decode 在不同 GPU 上执行 | 各阶段可独立优化 | 需要跨设备传输 KV Cache,增加系统复杂度 |
其中,Chunked Prefill 是目前最主流的方案。它的核心思想是:将长 Prompt 切分成固定大小的 Chunk(如 512 token),每个迭代步最多处理一个 Chunk 的 Prefill + 当前所有 Decode 请求的一步。这样 Prefill 的计算量被均摊到多个迭代步中,不会突然压垮 GPU。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 # Chunked Prefill 调度伪代码
def schedule_step(running, pending, chunk_size=512):
decode_requests = [r for r in running if r.phase == 'decode']
prefill_requests = [r for r in running if r.phase == 'prefill']
# 为当前步选取最多 chunk_size 个 token 做 Prefill
prefill_tokens_this_step = 0
for req in prefill_requests:
remaining = req.prompt_len - req.prefilled
chunk = min(remaining, chunk_size - prefill_tokens_this_step)
if chunk > 0:
req.next_chunk = chunk
prefill_tokens_this_step += chunk
# 构建混合批次:Prefill chunk + Decode step
batch = build_mixed_batch(prefill_requests, decode_requests)
logits = model.forward_step(batch)
process_results(logits)
3.2 请求优先级与调度策略
在 Iteration-Level Scheduling 中,调度器在每个迭代步都有机会做决策,因此可以实现更精细的优先级策略:
- FIFO(先到先服务):最简单的策略,按请求到达顺序分配槽位
- 最短任务优先(SJF):优先处理预估生成长度短的请求,降低平均延迟
- 优先级队列:为不同等级的用户分配不同的优先级(VIP 优先)
- 延迟感知调度:根据请求的 SLO(Service Level Objective)剩余时间动态调整优先级,确保即将超时的请求被优先处理
实际生产中,延迟感知调度是最实用的策略。它的核心思路是给每个请求维护一个”剩余时间预算”,调度器在每次迭代时优先选择剩余预算最少的请求:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 class LatencyAwareScheduler:
def __init__(self, slo_timeout_ms=2000):
self.slo_timeout_ms = slo_timeout_ms
def select_next_requests(self, pending, available_slots):
now = time.time()
# 计算每个请求的紧迫度(剩余时间/SLO 的比例)
scored = []
for req in pending:
elapsed = (now - req.arrival_time) * 1000
urgency = 1.0 - (elapsed / self.slo_timeout_ms)
scored.append((urgency, req))
# 越紧迫的请求优先级越高
scored.sort(key=lambda x: -x[0])
return [req for _, req in scored[:available_slots]]
3.3 显存压力下的反压机制
Continuous Batching 的一个关键挑战是显存管理。当大量请求同时涌入时,KV Cache 的显存消耗可能快速接近 GPU 的容量上限。此时调度器必须实施反压(Backpressure)——暂停接收新请求,直到有足够的请求完成并释放显存。
vLLM 的做法是维护一个显存水位线(Watermark)机制:
- 高水位线:当已用 KV Cache 超过总显存的 90% 时,停止接收新 Prefill 请求
- 低水位线:当已用 KV Cache 降到总显存的 70% 以下时,恢复接收新请求
- 紧急水位:超过 95% 时,强制对活跃请求进行 Preemption(抢占),将低优先级请求的 KV Cache 换出到 CPU 内存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25 class KVCacheWatermark:
def __init__(self, total_blocks, high=0.9, low=0.7, critical=0.95):
self.total = total_blocks
self.high = int(total_blocks * high)
self.low = int(total_blocks * low)
self.critical = int(total_blocks * critical)
self.used = 0
def can_admit_new(self) -> bool:
return self.used < self.high
def should_preempt(self) -> bool:
return self.used >= self.critical
def can_resume_admission(self) -> bool:
return self.used < self.low
def allocate(self, n_blocks) -> bool:
if self.used + n_blocks > self.total:
return False
self.used += n_blocks
return True
def free(self, n_blocks):
self.used = max(0, self.used - n_blocks)
四、性能对比:Static vs Continuous Batching 实测数据
4.1 吞吐量与延迟的帕累托前沿
Continuous Batching 的最大优势在于它打破了 Static Batching 的吞吐-延迟权衡困境。在 Static Batching 中,提高吞吐量(增大 Batch Size)必然导致延迟增加(因为每个请求的等待时间更长)。但在 Continuous Batching 中,由于请求可以随时进出,增大并发度并不会线性增加单个请求的延迟。
以下是典型的基准测试数据(基于 Llama-2-70B 在 4x A100 上,输入长度 128-1024,输出长度 32-512):
| 指标 | Static Batching | Continuous Batching | 提升幅度 |
|---|---|---|---|
| 吞吐量 (tokens/s) | 1,200 | 3,800 | 3.2x |
| 平均延迟 (ms) | 850 | 210 | 4.0x 降低 |
| P99 延迟 (ms) | 3,200 | 450 | 7.1x 降低 |
| GPU 计算利用率 | 35% | 82% | 2.3x 提升 |
| KV Cache 显存利用率 | 45% | 88% | 1.95x 提升 |
P99 延迟的改善尤为显著——在 Static Batching 中,尾部延迟主要由超长请求决定,而 Continuous Batching 通过及时移除已完成请求,彻底消除了这种尾部延迟的放大效应。
4.2 不同请求分布下的表现差异
Continuous Batching 的优势在请求长度分布越不均匀时越明显。以下是三种不同负载模式下的对比:
- 均匀分布(所有请求长度接近):Continuous Batching 相比 Static Batching 提升约 1.5-2x,因为 Padding 开销本身就小
- 中等差异(输入 100-500 token,输出 50-200 token):提升约 2.5-3x,这是最常见的生产负载模式
- 极端差异(输入 10-4000 token,输出 10-2000 token):提升可达 5-8x,Static Batching 在这种负载下几乎不可用
这说明一个重要的工程决策:如果你的服务面对的请求长度分布非常不均匀(如同时支持短对话和长文档摘要),Continuous Batching 不是可选的优化,而是必需的基础能力。
五、生产环境调优实战
5.1 关键参数配置指南
以下是基于 vLLM 的 Continuous Batching 核心调优参数:
1
2 # vLLM 启动参数(Continuous Batching 默认启用)
python -m vllm.entrypoints.openai.api_server --model meta-llama/Llama-3-70B --tensor-parallel-size 4 --max-num-seqs 256 --max-num-batched-tokens 8192 --gpu-memory-utilization 0.92 --swap-space 16 --max-model-len 8192 --enable-prefix-caching --enable-chunked-prefill
参数解析:
| 参数 | 含义 | 调优建议 |
|---|---|---|
| max-num-seqs | 最大并发请求数 | 根据 GPU 显存设置,A100-80G 跑 70B 模型建议 128-256 |
| max-num-batched-tokens | 每步最大 Prefill Token 数 | 控制 Chunked Prefill 的 Chunk 大小,通常 2048-16384 |
| gpu-memory-utilization | GPU 显存水位线上限 | 0.90-0.95,留余量给 CUDA 临时分配 |
| swap-space | CPU 换出空间大小(GB) | 根据峰值并发和平均序列长度估算 |
| enable-prefix-caching | 启用 Prompt 前缀缓存 | 有大量共享 System Prompt 的场景建议开启 |
| enable-chunked-prefill | 启用分块 Prefill | 推荐开启,避免 Prefill 阻塞 Decode |
5.2 监控指标与告警
Continuous Batching 的运行状态需要通过一组关键指标来监控。以下是必须采集的监控指标:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 # Prometheus 指标示例(vLLM 暴露的指标)
# 1. 活跃请求数 - 反映当前负载
vllm:num_running_requests
# 2. 等待队列长度 - 反映反压程度
vllm:num_waiting_requests
# 3. KV Cache 利用率 - 最关键的显存指标
vllm:gpu_cache_usage_perc
vllm:cpu_cache_usage_perc
# 4. 平均 Prefill 延迟
vllm:e2e_request_latency_seconds{quantile="0.5"}
vllm:e2e_request_latency_seconds{quantile="0.99"}
# 5. 吞吐量
vllm:iteration_tokens_total # 每迭代步生成的 token 总数
建议的告警阈值:
- KV Cache 利用率 > 90%:表明系统接近显存上限,可能需要限制并发或扩容
- 等待队列长度 > max_num_seqs 的 50%:反压严重,延迟可能正在飙升
- P99 延迟 > SLO 的 80%:即将违反服务等级协议,需要立即排查
5.3 常见性能瓶颈与排障思路
瓶颈一:Prefill 突刺导致 Decode 延迟抖动
当一个非常长的 Prompt(如 8000 token)进入 Continuous Batching 时,即使启用了 Chunked Prefill,其计算量仍然可能导致当前迭代步的 Decode 延迟突增。解决方案是调整 max-num-batched-tokens 参数,限制每步的 Prefill 计算量。经验值:将此参数设置为 Decode 阶段单步计算量的 2-4 倍,可以在 Prefill 吞吐和 Decode 延迟之间取得良好平衡。
瓶颈二:KV Cache 碎片化导致显存利用率低
在长时间运行后,频繁的分配和释放可能导致物理块的碎片化——虽然总空闲块数足够,但无法找到连续的空闲块来满足新的分配请求。PagedAttention 的设计本身就是为了对抗碎片化,但在极端情况下仍可能出现。解决方案是定期执行块压缩(Block Compaction),即重排物理块的映射关系以消除碎片。
瓶颈三:调度开销随并发数增长
Continuous Batching 的调度逻辑在每个迭代步都要执行一次,当并发数很高时(如 256+),调度本身可能成为瓶颈。优化方向包括:使用优先队列代替线性扫描、将调度逻辑用 C++/CUDA 实现(而非 Python)、以及批量处理请求的插入和移除操作。
六、未来趋势:从 Continuous Batching 到更精细的调度
Continuous Batching 已经成为大模型推理的事实标准,但调度优化的脚步并未停止。以下几个方向正在快速发展:
- Token-Level Scheduling:比 Iteration-Level 更细的粒度,在同一迭代步内对不同请求使用不同的计算策略(如混合不同量化精度)
- Speculative Decoding + Continuous Batching:将推测解码的 Draft Model 与 Continuous Batching 结合,让 Draft Model 为多个请求同时生成候选 Token,进一步降低延迟
- 跨 GPU 调度:在多卡 / 多节点场景下,将请求动态路由到负载最轻的 GPU,实现全局最优调度
- 自适应 Batch Size:根据当前请求的长度分布和显存使用率,动态调整每个迭代步的最大并发数,而非使用固定上限
这些方向的核心思路是一致的:调度粒度越细,资源利用越高效。从 Batch-Level 到 Iteration-Level 再到 Token-Level,每一次粒度的细化都带来了显著的性能提升,同时也对调度器的复杂度和延迟提出了更高的要求。如何在调度精细度和调度开销之间找到最优平衡,将是未来推理引擎架构设计的核心命题。
结语
Continuous Batching(Iteration-Level Scheduling)是大模型推理服务从”能跑”到”好用”的关键一步。它从根本上解决了 Static Batching 的 Padding 浪费、队头阻塞和显存利用率低下问题,与 PagedAttention 等 KV Cache 管理技术形成了天作之合。
对于正在搭建大模型推理服务的工程师而言,理解 Continuous Batching 的原理不仅有助于正确配置和调优现有引擎(vLLM、TensorRT-LLM 等),更能在面对性能瓶颈时做出准确的判断——是调度策略的问题,还是显存管理的问题,亦或是 Prefill/Decode 混合比例的问题。
推理优化是一个系统工程,没有银弹,只有对每一层原理的深入理解和对每一个参数的精心调整。Continuous Batching 正是这座系统大厦中承上启下的关键一层。
汤不热吧