欢迎光临
我们一直在努力

解构 vLLM 的物理内存映射:当 Prompt 长度超过预设 Block 时,底层是如何动态借调空间的?

vLLM(Virtual Large Language Model)框架凭借其创新的内存管理技术 PagedAttention,极大地提升了LLM推理的吞吐量和效率。PagedAttention借鉴了传统操作系统中的虚拟内存和分页思想,核心目标是解决KV(Key/Value)缓存的碎片化问题。

本文将深入探讨一个核心问题:当一个Prompt(或Sequence)的长度增长,超出其当前已分配的KV缓存块时,vLLM底层是如何动态地“借用”或分配新的物理内存空间的?

1. PagedAttention 的基础概念

在vLLM中,KV缓存不再是连续的大块内存,而是被切分为固定大小的块(Blocks)。假设块大小 $B=16$ 个Token。

  1. 逻辑块 (Logical Blocks): 序列(Sequence)需要的存储空间,用索引表示。
  2. 物理块 (Physical Blocks): 实际存储在GPU显存中的内存单元。
  3. 块表 (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$ 个逻辑块。

  1. 初始化分配: vLLM的调度器(Scheduler)向 BlockAllocator 请求两个物理块(例如 $P_1, P_2$)。
  2. 更新块表: 序列的 BlockTable 被初始化为:[0: P_1, 1: P_2]

Prompt 超长时的动态扩展

现在,Prompt继续增长,生成第 $T=9$ 个Token。此时,序列需要第 $3$ 个逻辑块(索引为 2)。

  1. 检查: vLLM检测到序列的当前长度需要访问逻辑块 2,但其 BlockTable 中只有两个条目(0和1)。
  2. 申请新块: 调度器调用 BlockAllocatorallocate_block 方法,从全局空闲池中获取一个新的物理块 $P_3$。
  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长度变化时,内存分配的粒度是块级别的,避免了不必要的内存拷贝和浪费,这是其实现高吞吐量推理的关键。

【本站文章皆为原创,未经允许不得转载】:汤不热吧 » 解构 vLLM 的物理内存映射:当 Prompt 长度超过预设 Block 时,底层是如何动态借调空间的?
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址