ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

vllm continue batching

vllm continue batching

vLLM Continuous Batching:从 Forward Shape 理解两个核心参数

核心一句话:
传统推理里的 batch 通常是一个补齐后的二维矩阵[B, L_max];vLLM 的 continuous batching 把 batch 改造成“每一轮 GPU forward 临时拼出来的一批 token”。
max_num_seqs限制本轮有多少条序列参与,max_num_batched_tokens限制本轮实际喂给模型多少个 token。它们就是 continuous batching 调度器的两个方向盘。


0. 先把三件事拆开

学习 vLLM continuous batching 时,最容易把三套机制混成一团:

机制解决的问题你可以先记成
一维展开 token + offset 元数据本轮输入的 Q 怎么组织,怎么避免[B, L_max]paddinginput_tokens_flat+query_start_loc
PagedAttention + block table历史 KV cache 放在哪里,attention 怎么读不连续的 KVblock_tables找物理 KV blocks
Iteration-level scheduling哪些序列本轮进 batch,哪些序列结束后退出每轮 forward 后重新组 batch

用一句更口语的话说:

seq_start_loc / query_start_loc: 管“本轮新进来的 Query token 怎么切给不同序列” block_table: 管“这条序列过去的 Key/Value cache 去显存哪里读” scheduler: 管“这一轮谁上车,下一轮谁下车、谁补位”

所以这句话:

vLLM 内部使用一维展开的 token 列表 + 序列偏移元数据配合 PagedAttention 实现不等长 Attention。

可以拆成:

1. 本轮要算的新 token 被 flatten 成 [T]。 2. offset 元数据告诉 kernel: [T] 里哪一段属于哪条序列。 3. 每条序列用自己的 block_table 去读历史 KV cache。 4. attention 只在各自序列内部发生,A 不会看 B。 5. 本轮结束后,完成的序列退出;下一轮重新构造全新的 [T] 和元数据。

先把这个框架立住,再看后面的 shape 和例子会轻松很多。

0.1 先纠正一个最容易卡住的点

很多人会把max_num_batched_tokens理解成:

本轮所有序列的历史上下文长度之和。

这个理解只对了一半。更准确地说:

max_num_batched_tokens约束的是一次 engine iteration / GPU forward 中“被调度进模型计算的新 token 数”。

这在两个阶段里含义不同:

阶段本轮每条请求喂给模型的 token 数max_num_batched_tokens约束的是什么
Prefillprompt token,可能是几十、几百、几千个本轮吃进去的 prompt token 总数,长 prompt 可被切块
Decode通常每条活跃序列只喂 1 个新 token本轮一起生成下一 token 的序列数,通常约等于 decode 序列数

关键区别:

decode 阶段: input_ids 可能只有 [S] 或逻辑上的 [S, 1] 但 attention 会读取每条序列自己的历史 KV cache 所以“本轮新 token 数”不等于“本轮会访问的历史上下文总长度”

也就是说,如果 100 个请求都在 decode,每个请求历史上下文长 2000:

本轮送入模型的新 token 数 ≈ 100 attention 读取的 KV 历史 ≈ 100 * 2000

max_num_batched_tokens管的是前者;KV cache 容量、上下文长度、attention kernel 的代价还会影响后者。

所以它不是一个完美的 FLOPs 公式,而是一个调度预算。
Prefill 的 attention 代价和 prompt 长度强相关,decode 的 attention 代价和历史 KV 长度强相关;但“本轮喂给模型的新 token 数”仍然是最实用的装箱指标,因为 embedding、QKV/FFN 激活、KV 写入、prefill chunk 大小都会跟它强相关。


1. 传统 HuggingFace / PyTorch 推理的 Forward Shape

先看最朴素的 decoder-only Transformer 推理。假设一次凑了 4 条请求:

A: 16 tokens B: 128 tokens C: 1024 tokens D: 2048 tokens

传统静态 batch 会补齐到最长长度:

input_ids: [B, L_max] = [4, 2048] attention_mask: [4, 2048]

真实 token 数:

16 + 128 + 1024 + 2048 = 3216

实际矩阵 token 槽位:

4 * 2048 = 8192

浪费的 padding 槽位:

8192 - 3216 = 4976

传统 prefill forward

Prefill 是“把 prompt 全部读进去,建立 KV cache,并拿到最后一个位置的 logits”。

input_ids [4, 2048] ↓ embedding hidden_states [4, 2048, d_model] ↓ 每一层 Transformer Q/K/V [4, n_heads, 2048, head_dim] ↓ causal attention + FFN hidden_states [4, 2048, d_model] ↓ LM head logits [4, 2048, vocab_size]

虽然有attention_mask,但很多算子仍然围绕补齐后的矩阵形状组织计算。动态 padding 可以把L_max缩小到当前 batch 的最长长度,但它仍然是:

一个固定 batch 一个 [B, L_max] 矩阵 batch 组好后中途不能塞新请求

传统 decode forward

有 KV cache 以后,每一步 decode 通常只喂最后一个 token:

input_ids: [4, 1] past_key_values: 每层缓存之前所有 token 的 K/V new Q: [4, n_heads, 1, head_dim] attention scores: [4, n_heads, 1, past_len + 1] logits: [4, 1, vocab_size]

注意,decode 的 input 很小。如果只有 1 个请求:

input_ids: [1, 1]

GPU 很容易吃不饱。大模型权重很大,但这一步只有一个 token 的活儿,kernel launch、显存读写、矩阵乘法规模都不够饱满。服务端要高吞吐,就必须把很多请求的 decode token 合起来算。


2. vLLM 把 Batch 从[B, L]变成了[T]

vLLM 的重点不是“把 padding 做得更聪明”这么简单,而是把调度单位改了。

传统 batch 的核心单位:

sequence batch: input_ids = [B, L_max]

vLLM continuous batching 的核心单位:

token batch: input_tokens = [T]

这里:

S = 本轮参与计算的序列数 T = 本轮被送入模型的新 token 数

调度器每一轮都要满足:

S <= max_num_seqs T <= max_num_batched_tokens

概念上,vLLM 一次 forward 更像这样:

input_tokens_flat: [T] positions: [T] seq_lens: [S] block_tables: [S, max_blocks_per_seq] slot_mapping: [T]

然后进入模型:

input_tokens_flat [T] ↓ embedding hidden_states [T, d_model] ↓ QKV projection Q/K/V for scheduled tokens [T, n_heads, head_dim] ↓ paged/ragged attention hidden_states [T, d_model] ↓ LM head / sampling next tokens for decode positions

这里的[T]不是说 attention 没有序列概念了,而是序列边界、位置、历史 KV 地址都由 metadata 管:

token 属于哪条 sequence token 在 sequence 里的 position 是多少 这条 sequence 的 KV cache 分布在哪些 block/page 本轮哪些 token 需要 logits

所以 vLLM 不需要把所有请求塞成一个全局[S, L_max]大矩阵。

2.1 “一维展开 + 序列偏移元数据”到底是什么?

先不要想 CUDA kernel,先想一个更普通的数据结构问题。

有 3 条序列本轮要被推进:

B: decode 1 个 token,历史上下文长度已经是 7 C: decode 1 个 token,历史上下文长度已经是 2 A: prefill 5 个 prompt token,是新请求

token id 假设是:

B decode input: [b7] C decode input: [c2] A prefill input: [a0, a1, a2, a3, a4]

传统 padding batch 会下意识想成:

[ [b7, PAD, PAD, PAD, PAD], [c2, PAD, PAD, PAD, PAD], [a0, a1, a2, a3, a4 ], ] shape = [3, 5]

vLLM 更接近这样:

input_tokens_flat = [b7, c2, a0, a1, a2, a3, a4] shape = [7]

然后再配一组“这段一维数组怎么切回每条序列”的元数据:

request_order: [B, C, A] query_lens: [1, 1, 5] query_start_loc: [0, 1, 2, 7]

query_start_loc是 prefix sum,也就是偏移表:

B 的本轮 token = input_tokens_flat[0:1] C 的本轮 token = input_tokens_flat[1:2] A 的本轮 token = input_tokens_flat[2:7]

这就是“序列偏移元数据”的直觉。
它不是神秘算法,本质就是:

一维数组 + 每条序列在一维数组里的起止位置

再加上每个 token 在自己序列里的 position:

positions: [7, 2, 0, 1, 2, 3, 4] 解释: b7 是 B 的 position 7 c2 是 C 的 position 2 a0..a4 是 A 的 position 0..4

还要有每条序列本轮结束后的有效 KV 长度:

seq_lens: B: 8 # 原来 7 个历史 token + 本轮 b7 C: 3 # 原来 2 个历史 token + 本轮 c2 A: 5 # 新请求 prefill 5 个 token

概念上,本轮进入 attention 的 metadata 像这样:

input_tokens_flat: [b7, c2, a0, a1, a2, a3, a4] positions: [7, 2, 0, 1, 2, 3, 4] query_start_loc: [0, 1, 2, 7] seq_lens: [8, 3, 5] block_tables: 每条序列的逻辑 KV block -> 物理 KV block slot_mapping: 每个新 token 的 K/V 应该写到 KV cache 哪个物理槽

不同 vLLM 版本、不同 attention backend 的字段名会略有差异。你可以把下面这些名字按概念对应起来:

概念常见字段名
本轮实际 token 总数num_actual_tokens
每条请求本轮 token 的起止偏移query_start_locqo_indptr
每条请求的 KV 长度seq_lens
每条请求的 paged KV 映射表block_table_tensorblock_tables
每个新 token 写入 KV cache 的位置slot_mapping
decode / prefill 数量num_decodesnum_prefillsnum_decode_tokensnum_prefill_tokens

这一组 metadata 的作用就是把:

不等长序列集合

变成:

GPU 友好的一维 token batch + 能恢复序列边界和 KV 地址的索引表

图:

B decode: [b7]

input_tokens_flat
[b7, c2, a0, a1, a2, a3, a4]

C decode: [c2]

A prefill: [a0..a4]

query_start_loc
[0,1,2,7]

positions
[7,2,0,1,2,3,4]

seq_lens
[8,3,5]

block_tables
每条序列的 KV 页表

slot_mapping
每个 token 写 KV 的槽位

2.2 Transformer 里哪些地方可以直接吃[T]

现在看 forward。

本轮输入:

input_tokens_flat: [7]

Embedding:

hidden_states = embedding(input_tokens_flat) shape = [7, d_model]

QKV projection:

qkv = hidden_states @ W_qkv Q: [7, n_q_heads, head_dim] K: [7, n_kv_heads, head_dim] V: [7, n_kv_heads, head_dim]

FFN:

hidden_states @ W_up activation hidden_states @ W_down shape 一直是: [7, d_model]

这些层为什么可以吃[T]

因为 embedding、linear、MLP/FFN 都是逐 token 或逐 token 向量批处理:

同一个权重矩阵 W 作用在每个 token 的 hidden vector 上

它们不需要知道:

b7 属于 B c2 属于 C a0..a4 属于 A

真正必须知道序列边界的是 attention,因为 attention 要回答:

这个 query token 可以看哪些 key/value?

所以难点集中在 attention。

2.3 PagedAttention 怎么用 metadata 实现不等长 attention?

Attention 层收到的是:

Q/K/V for new tokens: Q: [T, n_q_heads, head_dim] K: [T, n_kv_heads, head_dim] V: [T, n_kv_heads, head_dim] metadata: query_start_loc positions seq_lens block_tables slot_mapping

它做两件事:

1. 把本轮新 token 的 K/V 写入 KV cache。 2. 对本轮 query token 做 attention,且每个 query 只看自己序列的 KV。

2.3 补充:PagedAttention 实现 [T] 不等长 attention 的完整过程

第一步:用slot_mapping写 KV cache

假设 KV cache 的 block size 是 4。
B 原来有 7 个历史 token,位置是 0…6;现在b7是 position 7。

position 7 对应:

logical_block = 7 // 4 = 1 slot_in_block = 7 % 4 = 3

如果 B 的 block table 是:

B logical block 0 -> physical block 10 B logical block 1 -> physical block 21

那么b7的 K/V 应该写到:

physical block 21, slot 3

概念上:

slot_mapping[flat_index_of_b7] = physical_slot(block=21, slot=3)

对 A 的a0..a4也一样。A 是新请求,KV manager 先给它分配物理 blocks:

A logical block 0 -> physical block 5 A logical block 1 -> physical block 18

于是:

a0 position 0 -> physical block 5, slot 0 a1 position 1 -> physical block 5, slot 1 a2 position 2 -> physical block 5, slot 2 a3 position 3 -> physical block 5, slot 3 a4 position 4 -> physical block 18, slot 0

slot_mapping就是把 flat token index 映射到这些 KV cache 写入位置。

真实 KV cache 的物理布局还会包含 layer、head、head_dim 等维度;这里把它简化成 block/slot,是为了先抓住核心。

第二步:attention 时按block_tables读取自己的历史 KV

Attention kernel 不会把这 3 条请求恢复成一个 padding 矩阵:

[3, 5]

它会按 metadata 处理每条序列的 query 范围。

伪代码:

forreq_idx,request_idinenumerate(request_order):q_start=query_start_loc[req_idx]q_end=query_start_loc[req_idx+1]# 这一条请求本轮新来的 query tokenq_slice=Q[q_start:q_end]# 这一条请求本轮结束后的 KV 总长度seq_len=seq_lens[req_idx]# 这一条请求的 KV 页表block_table=block_tables[req_idx]# 对 q_slice 里的每个 query token:# 只读取 request_id 自己的 positions 0..current_position 的 K/V# 逻辑 position -> logical block -> physical block -> slot

B 是 decode:

B query slice: input_tokens_flat[0:1] = [b7] B 的 query position: 7 B 可以 attend: B positions 0..7 读取方式: positions 0..3 -> B logical block 0 -> physical block 10 positions 4..7 -> B logical block 1 -> physical block 21

C 也是 decode:

C query slice: input_tokens_flat[1:2] = [c2] C 可以 attend: C positions 0..2 它只读 C 的 block table,绝不会读 B/A 的 KV。

A 是 prefill:

A query slice: input_tokens_flat[2:7] = [a0, a1, a2, a3, a4] A 的 query positions: 0,1,2,3,4

因为是 causal attention:

a0 只能看 A positions 0 a1 可以看 A positions 0..1 a2 可以看 A positions 0..2 a3 可以看 A positions 0..3 a4 可以看 A positions 0..4

这就是“不等长 attention”的实现直觉:

不是构造一个补齐后的大 attention mask, 而是每条序列通过 offset 找到自己的 query 范围, 再通过 block table 找到自己的 KV 范围。

图:

Flat Q
[b7, c2, a0, a1, a2, a3, a4]

query_start_loc
[0,1,2,7]

B q slice
[0:1]

C q slice
[1:2]

A q slice
[2:7]

B block_table

C block_table

A block_table

只读 B 的 KV
pos 0..7

只读 C 的 KV
pos 0..2

只读 A 的 KV
causal pos 0..4

Block Size = 4;
本轮batch三条请求:B、C、A
扁平新token序列:[b7, c2, a0,a1,a2,a3,a4]
query_start_loc = [0,1,2,7]

Attention 层收到完整输入
Q/K/V for new tokens: Q: [T, n_q_heads, head_dim] K: [T, n_kv_heads, head_dim] V: [T, n_kv_heads, head_dim] metadata: query_start_loc # 一维扁平Q数组的「书签」,划分每条请求本轮query范围 positions # 每个新token在【自身对话内的位置pos】 seq_lens # 每条对话本轮执行完成后的总token数量 block_tables # 重点讲解:每条对话独立拥有的KV页表 slot_mapping # 写入KV缓存专用地址清单

Attention 层只执行两大任务:

  1. 把本轮新 token 的 K/V 写入全局显存 KV Cache;
  2. 对本轮 query 执行注意力计算:每个请求只能读取自己这条对话的历史KV,禁止跨请求访问KV

第一步:用 slot_mapping 写 KV cache(写入阶段)

作用:解决「本轮新生成的K/V向量要写到显存哪个物理位置」
假设 KV cache 的 block size 是 4。
B 原来有 7 个历史 token,位置是 0…6;现在b7是 position 7。

position 7 对应:

logical_block = 7 // 4 = 1 slot_in_block = 7 % 4 = 3

B 的 block table:

B logical block 0 -> physical block 10 B logical block 1 -> physical block 21

那么b7的 K/V 应该写到:

physical block 21, slot 3

概念表达式:

slot_mapping[flat_index_of_b7] = physical_slot(block=21, slot=3)

对 A 的a0..a4也一样。A 是新请求,KV manager 先给它分配物理 blocks:

A logical block 0 -> physical block 5 A logical block 1 -> physical block 18

于是:

a0 position 0 -> physical block 5, slot 0 a1 position 1 -> physical block 5, slot 1 a2 position 2 -> physical block 5, slot 2 a3 position 3 -> physical block 5, slot 3 a4 position 4 -> physical block 18, slot 0

slot_mapping将扁平数组下标 flat_index 映射到 KV cache 的写入物理位置

重要区分:
slot_mapping 只管【本轮新增token的写入】;
当后续注意力需要读取整条序列全部历史KV(旧token+新token),靠的不是 slot_mapping,而是block_tables

真实 KV cache 的物理布局还会包含 layer、head、head_dim 等维度;这里简化成 block/slot,优先抓住核心逻辑。


第二步:重点扩充 —— block_tables 是什么、干什么、什么时候用

1)block_tables 定义

block_tables
一个二维数组,每条对话请求拥有一张独立私有的页表

block_tables[req_idx] = 当前这条请求的页表list list下标 = logical_block(逻辑块编号) list值 = physical_block(物理块编号)

映射关系:逻辑块(logical_block) → 物理块(physical_block)

名词翻译
  • 逻辑块 Logical Block(虚拟视角)
    站在对话角度:我这条对话的token位置pos是连续不断的。
    按照 block_size 分段切割,切割出来的分段编号 = 逻辑块。
    程序只认连续的pos,完全不用关心这些数据真实散落在显存哪里。

  • 物理块 Physical Block(硬件显存视角)
    GPU显存中真实开辟出来、存放K/V张量的一块连续内存;
    所有请求共享同一个物理块资源池;物理块在显存中可以不连续、随机散落。

2)block_tables 核心三大作用

作用①:注意力计算时,读取整条序列所有历史KV(最核心用途)

传统注意力实现方式:
把所有batch内序列padding补齐,拼成规整[batch, max_seq_len]矩阵,搭配巨大attention mask。

PagedAttention 完全不走这条路:
Attention kernel不会构建补齐padding的大矩阵
代码循环逻辑:遍历每一条请求,单独处理。

forreq_idx,request_idinenumerate(request_order):q_start=query_start_loc[req_idx]q_end=query_start_loc[req_idx+1]# 这一条请求本轮新来的 query tokenq_slice=Q[q_start:q_end]# 这一条请求本轮结束后的 KV 总长度seq_len=seq_lens[req_idx]# ========== 核心:取出本条请求专属页表 ==========block_table=block_tables[req_idx]# 对 q_slice 里的每个 query token:# 只读取 request_id 自己这条对话 positions 0..current_position 的 K/V# 寻址链路:position → logical_block → 查询block_table → physical_block → slot

寻址完整链路(读KV的时候):
token位置pos → logical_block = pos // block_size
physical_block = block_table[logical_block]
slot_in_block = pos % block_size

举例 B 请求:
B 本轮query:b7,pos=7
B 需要访问整条序列全部 pos 0 ~ pos7

positions 0..3 → logical_block 0 → block_table[0]=10 → physical block 10 positions 4..7 → logical_block 1 → block_table[1]=21 → physical block 21

Kernel拿着block_table,直接去物理块10、21读取B全部历史KV。

关键安全特性:
每条请求只用自己的 block_table,天然隔离KV缓存!
处理B的时候只会访问B对应的物理块,永远不会读到A、C的KV数据。

作用②:会话持续变长时,动态扩充KV存储空间

对话持续生成新token,pos不断增大,会产生新的 logical_block。
KV管理器发现逻辑块不存在对应的物理块时:

  1. 在GPU空闲物理块池中分配一块新物理块;
  2. 在这条请求的 block_table 末尾追加映射关系;
    对话越长,block_table列表长度不断变长。

例子:
B后续继续生成token,pos到达8,得到logical_block=2
系统分配新物理块 33,执行block_table.append(33)
之后logical_block2 → physical block33

作用③:支持KV缓存复用、交换、回收(分页高级特性)
  • 序列结束、会话关闭:可以根据block_table精准回收这条请求占用的全部物理块,归还显存资源池;
  • 支持前缀复用(多个请求共享上文prompt):多条请求block_table可以复用相同物理块;
  • 支持KV Swap:显存不足时,依据block_table把冷物理块交换到CPU内存。

3)逐条实例演示 block_table 在读取阶段如何工作

B(Decode 单token生成)
B query slice: input_tokens_flat[0:1] = [b7] B 的 query position: 7 B 可以 attend: B positions 0..7 读取寻址依靠B自己的block_table: positions 0..3 → B logical block 0 → physical block 10 positions 4..7 → B logical block 1 → physical block 21

Kernel读取物理块10、21内有效的KV,计算 1×8 的注意力。

C(Decode)
C query slice: input_tokens_flat[1:2] = [c2] C 可以 attend: C positions 0..2 依靠C专属block_table,只访问C对应的物理块,绝不会读取B/A的KV。
A(Prefill,一次性输入多个token)
A query slice: input_tokens_flat[2:7] = [a0, a1, a2, a3, a4] A 的 query positions: 0,1,2,3,4

因果Causal Attention约束:不能看见未来token

a0 只能看 A positions 0 a1 可以看 A positions 0..1 a2 可以看 A positions 0..2 a3 可以看 A positions 0..3 a4 可以看 A positions 0..4

Kernel依靠A的block_table访问物理块5、18,读取A全部KV,计算下三角自注意力。

4)核心对比:block_tables VS slot_mapping(90%小白混淆点)

参数使用时机操作对象映射关系生命周期
slot_mapping写入KV阶段本轮新增tokenflat_index → (phys_block, slot)仅本轮前向写入KV时有效,用完即可丢弃
block_tables读取KV阶段(Attention)整条序列所有历史token+本轮新tokenlogical_block → phys_block跟随整个对话会话持续保存,每一轮decode都会重复使用

通俗大白话总结:
slot_mapping = 快递入库清单:新来的数据放到哪个柜子
block_tables = 用户终身档案目录:后续每次查历史记录,根据目录找到全部柜子

5)整体逻辑一句话概括「不等长Attention实现原理」

不去构造统一padding大矩阵:

  1. 使用query_start_loc把扁平一维Q切分成每条请求独立的query片段;
  2. 每条请求使用自身专属block_tables找到自己全部历史KV物理内存;
  3. 单独执行该序列的注意力计算,天然支持不同序列长度,消除大量padding无效算力。
返回列表