Continuous Batching 深入实现:从 attention mask 到 chunked prefill

17 分钟阅读

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:

  1. CPU 提前一步调度——决定完 batch N+1 时,GPU 还在跑 batch N
  2. Future tokens——batch N+1 的输入依赖 batch N 的输出,SGLang 用"占位 token"绕过依赖
  3. 精心编排 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 需要同时考虑:

  1. 单请求视角:这个 request 的 TTFT / ITL / TPOT 怎么优化?
  2. 批量视角:整个 batch 的 goodput 怎么最大?
  3. 系统视角: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 分析就没有正确的参数。


附:素材来源

主线

论文

  • 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

姊妹篇


整理于 2026-07-31 深夜。原文技术细节归属 vLLM/SGLang 团队与 Orca 作者。中文表达可能有解读偏差,请以原文为准。

Zoe

Written by

Zoe

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

评论