vLLM(Virtual Large Language Model)框架凭借其创新的内存管理技术 PagedAttention,极大地提升了LLM推理的吞吐量和效率。PagedAttention借鉴了传统操作系统中的虚拟内存和分页思想,核心目标是解决KV(Key/Value)缓存的碎片化问题。
本文将深入探讨一个核心问题:当一个Prompt(或Sequence)的长度增长,超出其当前已分配的KV缓存块时,vLLM底层是如何动态地“借用”或分配新的物理内存空间的?
1. PagedAttention 的基础概念
在vLLM中,KV缓存不再是连续的大块内存,而是被切分为固定大小的块(Blocks)。假设块大小 $B=16$ 个Token。
- 逻辑块 (Logical Blocks): 序列(Sequence)需要的存储空间,用索引表示。
- 物理块 (Physical Blocks): 实际存储在GPU显存中的内存单元。
- 块表 (Block Table): 每个序列都维护一个块表,它将序列的逻辑块索引映射到物理块ID。
2. 传统 KV Cache 的困境
在没有PagedAttention的系统中,如果Prompt长度超过了预分配的缓冲区,要么必须停止处理,要么需要将整个KV缓存数据迁移(Copy)到一个更大的连续内存区域,这会导致显著的延迟(Latency)和GPU资源浪费。
3. vLLM的动态借调机制
vLLM通过一个集中的物理块分配器(Block Allocator)来管理所有可用的GPU物理块内存池(Free Block Pool)。当一个序列需要存储更多的KV状态时,它不再需要关心自己是否有足够的连续内存,只需要向分配器申请一个新的物理块。
动态分配流程(Dynamic Allocation Flow)
假设我们的块大小 $B=4$ Tokens。
一个新序列开始处理,Prompt长度 $L=5$。它需要 $\lceil 5/4 \rceil = 2$ 个逻辑块。
- 初始化分配: vLLM的调度器(Scheduler)向 BlockAllocator 请求两个物理块(例如 $P_1, P_2$)。
- 更新块表: 序列的 BlockTable 被初始化为:[0: P_1, 1: P_2]。
Prompt 超长时的动态扩展
现在,Prompt继续增长,生成第 $T=9$ 个Token。此时,序列需要第 $3$ 个逻辑块(索引为 2)。
- 检查: vLLM检测到序列的当前长度需要访问逻辑块 2,但其 BlockTable 中只有两个条目(0和1)。
- 申请新块: 调度器调用 BlockAllocator 的 allocate_block 方法,从全局空闲池中获取一个新的物理块 $P_3$。
- 追加映射(Append Mapping): $P_3$ 的物理ID被追加到序列的 BlockTable 中。
- BlockTable 变为:[0: P_1, 1: P_2, 2: P_3]。
这个过程的关键是:内存分配是按需、动态且非连续的。 序列的逻辑块在物理上可以分散在GPU内存的任何位置,只有在需要时才进行映射,从而实现了高效的内存复用和长序列支持。
4. 概念代码示例:模拟 BlockAllocator
下面是一个简化的Python类,用于演示vLLM中物理块分配器的核心逻辑:
class PhysicalBlockAllocator:
def __init__(self, total_blocks):
# 模拟GPU上的物理内存块池
self.free_blocks = list(range(1, total_blocks + 1))
self.allocated_blocks = set()
def allocate_block(self):
"""从空闲池中动态分配一个物理块"""
if not self.free_blocks:
raise RuntimeError("GPU 物理内存块已耗尽")
block_id = self.free_blocks.pop(0)
self.allocated_blocks.add(block_id)
print(f"-> 分配了物理块 ID: {block_id}")
return block_id
def free_block(self, block_id):
"""释放物理块,归还给空闲池"""
if block_id in self.allocated_blocks:
self.allocated_blocks.remove(block_id)
self.free_blocks.append(block_id)
print(f"<- 释放了物理块 ID: {block_id}")
class Sequence:
def __init__(self, seq_id, allocator):
self.seq_id = seq_id
self.allocator = allocator
# 存储逻辑块索引到物理块ID的映射
self.block_table = []
self.token_count = 0
self.block_size = 4
def process_new_tokens(self, num_new_tokens):
self.token_count += num_new_tokens
required_logical_blocks = (self.token_count + self.block_size - 1) // self.block_size
print(f"\n序列 {self.seq_id}: 当前 Token数 {self.token_count}, 需要 {required_logical_blocks} 个逻辑块")
# 检查是否需要动态扩展
while len(self.block_table) < required_logical_blocks:
print(f"--- 逻辑块 {len(self.block_table)} 缺失,触发动态分配 ---")
new_physical_block = self.allocator.allocate_block()
self.block_table.append(new_physical_block)
print(f"当前块表映射: {self.block_table}")
# 运行模拟
allocator = PhysicalBlockAllocator(total_blocks=10)
seq1 = Sequence(seq_id=1, allocator=allocator)
# 步骤 1: 初始Prompt (长度 3)
seq1.process_new_tokens(3) # 需 1 块
# 步骤 2: Prompt 延伸 (新增 4 tokens), 总长 7
seq1.process_new_tokens(4) # 需 2 块
# 步骤 3: 继续生成 (新增 5 tokens), 总长 12
seq1.process_new_tokens(5) # 需 3 块
运行结果展示 (部分输出):
序列 1: 当前 Token数 3, 需要 1 个逻辑块
--- 逻辑块 0 缺失,触发动态分配 ---
-> 分配了物理块 ID: 1
当前块表映射: [1]
序列 1: 当前 Token数 7, 需要 2 个逻辑块
--- 逻辑块 1 缺失,触发动态分配 ---
-> 分配了物理块 ID: 2
当前块表映射: [1, 2]
序列 1: 当前 Token数 12, 需要 3 个逻辑块
--- 逻辑块 2 缺失,触发动态分配 ---
-> 分配了物理块 ID: 3
当前块表映射: [1, 2, 3]
通过这种动态借调机制,vLLM保证了当Prompt长度变化时,内存分配的粒度是块级别的,避免了不必要的内存拷贝和浪费,这是其实现高吞吐量推理的关键。
汤不热吧