Block Manager:vLLM 内存分配与调度的核心

核心原理:从连续大块到 Paged 内存块

在传统深度学习推理框架中,KV Cache 通常为每个请求预先分配一块连续显存。这种方式的致命问题是:显存大小无法预知,申请过大造成浪费,申请过小则直接 OOM。vLLM 借鉴操作系统虚拟内存的分页思想,将 KV Cache 切分为固定大小的 逻辑块(Logical Block),每个块可独立分配、释放和映射到物理显存中的任意位置。这一机制由 Block Manager 统一调度,它维护一张“逻辑块 → 物理块”的映射表,并跟踪每个块的引用计数与空闲状态。

每个逻辑块默认大小为 16 个 token(可通过 --block-size 调整),对应一组连续的 KV 张量存储单元。Block Manager 并不关心 token 的具体内容,只负责回答三个问题:这个序列需要哪些块?这些块在物理显存的哪里?哪些块可以被回收? 下图展示了 Block Manager 的核心工作流程:

逻辑块视图(Sequence A) Block 0 token 0-15 → P0 Block 1 token 16-31 → P3 Block 2 token 32-47 → P1 Block 3 token 48-63 → P5 Block 4 (新) token 64-79 → P2 Block 0 (释放) ref=0 → 空闲 Block Manager 块映射表 (L→P) 引用计数表 空闲块队列 物理显存块 P0: 被 Block 0 占用 ref=1 P1: 被 Block 2 占用 ref=1 P2: 被 Block 4 占用 ref=1 (新) P3: 被 Block 1 占用 ref=1 P4: 空闲 ref=0 P5: 被 Block 3 占用 ref=1 P0: 待回收 ref=0 → 空闲 逻辑映射 分配路径 释放路径

内存池设计:预分配与分层管理

Block Manager 在初始化阶段会从 CUDA 显存中一次性申请一大块连续内存,称为 内存池(Memory Pool)。这个池子被划分为等大小的物理块,每个物理块的大小 = block_size × num_kv_heads × head_dim × 2(K 和 V 各一份)× dtype_size。例如,block_size=16、num_kv_heads=8、head_dim=128、FP16 时,单块大小为 16 × 8 × 128 × 2 × 2 = 64 KB。

内存池采用 分层空闲链表 管理:所有空闲块通过 next 指针串联,Block Manager 维护一个 free_blocks 队列。分配时从队头弹出,释放时压入队尾。为避免频繁分配小对象,Block Manager 还引入了 块缓存(Block Cache):当某个序列的块被释放时,并不立即归还给 CUDA,而是先放入缓存池,供同 shape 的新序列复用。只有当缓存池超过阈值(默认 2000 块)时,才真正释放给 CUDA。

# vLLM 内存池关键参数(通过 LLM 引擎初始化)
--gpu-memory-utilization 0.90      # GPU 显存利用率上限
--block-size 16                    # 逻辑块大小(token 数)
--max-num-seqs 256                 # 最大并发序列数
--num-gpu-blocks 4096              # 物理块总数(自动计算)
--num-cpu-blocks 512               # CPU 回退块(用于 swap)

内存池的分配策略遵循 首次适配(First Fit) 原则:Block Manager 维护一个按地址排序的空闲块列表,每次分配时从头遍历,找到第一个足够大的空闲块即分配。由于所有块大小相同,实际上就是取队首块。这种设计保证了 O(1) 的分配复杂度,避免了外部碎片。

动态分配与释放:引用计数与 Copy-on-Write

每个物理块都有一个 引用计数(ref_count),表示当前有多少个序列正在使用该块。当序列需要新块时,Block Manager 执行以下流程:

  1. 查找空闲块:从 free_blocks 队列弹出队首块,将其标记为“已分配”,ref_count 置为 1。
  2. 建立映射:在块映射表中添加 (sequence_id, logical_block) → physical_block 的条目。
  3. 写入 KV 数据:将当前 token 的 K/V 张量写入该物理块对应的显存地址。
  4. 触发 Copy-on-Write:如果该逻辑块被多个序列共享(例如 beam search 中的分叉),且某个序列需要修改内容,则分配新块并复制数据,原块 ref_count 减 1。

释放流程则相反:当序列结束或发生截断时,Block Manager 遍历该序列的所有逻辑块,将对应物理块的 ref_count 减 1。当 ref_count 降为 0 时,该块被回收到 free_blocks 队列。若块在缓存池中,则先进入缓存池 LRU 队列。

关键机制:Prefix Caching(前缀缓存)

Block Manager 额外维护一张哈希表,将每个物理块的内容哈希值作为 key。当新序列的前缀与已有序列完全相同时(例如多轮对话中的 system prompt),直接复用对应物理块,ref_count 加 1,无需重新计算 KV。这能显著降低首 token 延迟,但对哈希碰撞和内存开销有一定代价。

案例:多序列并发下的块调度

假设显存池共有 6 个物理块(P0-P5),同时处理两个请求:

  • 请求 A:已生成 64 个 token,占用 P0、P3、P1、P5(逻辑块 0-3)。
  • 请求 B:新到达,prompt 长度为 32 个 token,需要 2 个逻辑块。

Block Manager 的处理过程如下:

步骤 操作 空闲块变化 ref_count 变化
1 请求 B 申请 2 块 空闲:P2, P4 P2.ref=1, P4.ref=1
2 请求 B 前缀与 A 的前 16 token 相同 空闲:P4 P0.ref=2(共享)
3 请求 B 继续生成,需要第 3 块 空闲:无 触发 swap 或等待
4 请求 A 完成,释放 4 块 空闲:P0, P3, P1, P5 P0.ref=1(仍被 B 使用)
5 请求 B 申请第 3 块 空闲:P3, P1, P5 P3.ref=1

注意步骤 4 中,P0 虽然被 A 释放,但 B 仍在使用,因此 ref_count 从 2 降为 1,并未进入空闲队列。这种精细的引用计数管理避免了显存浪费,也防止了误回收。

常见误区与调优复盘

误区 1:认为 block_size 越大越好

增大 block_size 会减少块数量,降低 Block Manager 的调度开销,但也会增加内部碎片。例如 block_size=32 时,一个仅生成 17 个 token 的短请求仍占用一整块,浪费近 50% 显存。实测中,block_size=16 在多数场景下是吞吐与碎片率的最佳平衡点。

误区 2:忽略 Prefix Cache 的哈希开销

Prefix Cache 需要对每个块计算哈希值(默认 xxhash),当块数量超过 10 万时,哈希计算本身会占用约 5-10% 的 GPU 时间。如果业务中前缀复用率很低(例如每次 prompt 都不同),建议通过 --disable-prefix-cache 关闭该功能。

调优建议:监控 Block Manager 指标

通过 vllm.engine.metrics 暴露的 Prometheus 指标,重点关注 vllm:num_blocks_waiting(等待块分配的序列数)和 vllm:gpu_cache_usage_percent。若 waiting 持续大于 0,说明显存池不足,应增大 --gpu-memory-utilization 或减小 --max-num-seqs。

Block Manager 的设计精髓在于将显存管理的复杂度从“连续大块分配”转化为“小块引用计数”,从而在动态请求场景下实现接近零碎片的显存利用。理解其分配、共享、释放的完整链路,是后续优化吞吐和延迟的基础。