欢迎光临

大模型推理中的 Continuous Batching 深度解析:从 Static Batching 到 Iteration-Level Scheduling 的演进之路

引言:为什么 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 正是这座系统大厦中承上启下的关键一层。

【本站文章皆为原创,未经允许不得转载】:汤不热吧 » 大模型推理中的 Continuous Batching 深度解析:从 Static Batching 到 Iteration-Level Scheduling 的演进之路
分享到: 更多 (0)