尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

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_locPagedAttention + block table历史 KV cache 放在哪里,attention 怎么读不连续的 KVblock_tables找物理 KV blocksIteration-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 * 2000max_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 forwardPrefill 是“把 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_loc、qo_indptr每条请求的 KV 长度seq_lens每条请求的 paged KV 映射表block_table_tensor、block_tables每个新 token 写入 KV cache 的位置slot_mappingdecode / prefill 数量num_decodes、num_prefills、num_decode_tokens、num_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 0slot_mapping就是把 flat token index 映射到这些 KV cache 写入位置。真实 KV cache 的物理布局还会包含 layer、head、head_dim 等维度;这里把它简化成 block/slot,是为了先抓住核心。第二步:attention 时按block_tables读取自己的历史 KVAttention 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 - slotB 是 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 21C 也是 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_tableC block_tableA block_table只读 B 的 KVpos 0..7只读 C 的 KVpos 0..2只读 A 的 KVcausal pos 0..4Block 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 层只执行两大任务:把本轮新 token 的 K/V 写入全局显存 KV Cache;对本轮 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 = 3B 的 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 0slot_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=7B 需要访问整条序列全部 pos 0 ~ pos7positions 0..3 → logical_block 0 → block_table[0]=10 → physical block 10 positions 4..7 → logical_block 1 → block_table[1]=21 → physical block 21Kernel拿着block_table,直接去物理块10、21读取B全部历史KV。关键安全特性:每条请求只用自己的 block_table,天然隔离KV缓存!处理B的时候只会访问B对应的物理块,永远不会读到A、C的KV数据。作用②:会话持续变长时,动态扩充KV存储空间对话持续生成新token,pos不断增大,会产生新的 logical_block。KV管理器发现逻辑块不存在对应的物理块时:在GPU空闲物理块池中分配一块新物理块;在这条请求的 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 21Kernel读取物理块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约束:不能看见未来tokena0 只能看 A positions 0 a1 可以看 A positions 0..1 a2 可以看 A positions 0..2 a3 可以看 A positions 0..3 a4 可以看 A positions 0..4Kernel依靠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大矩阵:使用query_start_loc把扁平一维Q切分成每条请求独立的query片段;每条请求使用自身专属block_tables找到自己全部历史KV物理内存;单独执行该序列的注意力计算,天然支持不同序列长度,消除大量padding无效算力。
返回列表