推理服务 · 内存
- 块
- 这里 4 个词元 vLLM 16
- 内存
- 256 个词元槽位
- 请求
- 16 个,最长 64 个词元
- 使用率,连续分配
- 35%
- 使用率,分页
- 94%
全部步骤
按最坏情况预留
由于不知道每个回答会有多长,简单的服务器会在一段连续内存中按最长可能的长度预留空间。大部分从来用不上,而新请求却在内存闲置时等待;vLLM 测得这类系统中只有 20 到 38% 的 KV 内存真正存了词元。
每个请求按最大长度预留按需分块
PagedAttention 把缓存切成固定大小的块(vLLM 中是 16 个词元,这里是 4 个),只在请求的最后一个块满了时,才从内存中任意位置给它一个新块。每个请求有一张块表,按顺序列出它的块,就像虚拟内存的页表。
逻辑块 → 物理块跨块的注意力
注意力内核跟着块表走:对请求的每个块,从它所在的位置取出键和值,然后做和之前一样的注意力计算。只是内存布局变了。
对每个块:K, V ← memory[table[b]]共享与写时复制
同一个提示词的两个样本可以共享提示词的块;引用计数记录每个块被多少条序列使用。当一个样本要写入一个部分填充的共享块时,它先复制一份(写时复制),就像操作系统里 fork 出的进程一样。
共享块 · 引用计数 · 写时复制同时容纳更多请求
同样的 16 个请求,同样的内存。分页让批次同时容纳更多请求,所以整个队列更早完成;论文中,在相同延迟下 vLLM 每秒服务的请求数是早期系统的 2 到 4 倍。
每批更多序列 → 每秒更多词元
代码
# the KV cache as a pool of fixed-size blocks
free = list(range(num_blocks)); block_table = {seq: [] for seq in running}
# before writing token t of a sequence, make sure its block exists
if t % BLOCK == 0: block_table[seq].append(free.pop())
b = block_table[seq][t // BLOCK]; kv[b, t % BLOCK] = (k, v)
# attention gathers the sequence's blocks through its table
K = torch.cat([kv_k[b] for b in block_table[seq]])
# sharing: forked sequences copy the table, not the blocks
block_table[child] = list(block_table[parent]); ref[b] += 1 for each b
if ref[b] > 1 and writing: b = copy_block(b); ref[b_old] -= 1