Block Manager:vLLM 内存分配与调度的核心
核心原理:从连续大块到 Paged 内存块
在传统深度学习推理框架中,KV Cache 通常为每个请求预先分配一块连续显存。这种方式的致命问题是:显存大小无法预知,申请过大造成浪费,申请过小则直接 OOM。vLLM 借鉴操作系统虚拟内存的分页思想,将 KV Cache 切分为固定大小的 逻辑块(Logical Block),每个块可独立分配、释放和映射到物理显存中的任意位置。这一机制由 Block Manager 统一调度,它维护一张“逻辑块 → 物理块”的映射表,并跟踪每个块的引用计数与空闲状态。
每个逻辑块默认大小为 16 个 token(可通过 --block-size 调整),对应一组连续的 KV 张量存储单元。Block Manager 并不关心 token 的具体内容,只负责回答三个问题:这个序列需要哪些块?这些块在物理显存的哪里?哪些块可以被回收? 下图展示了 Block Manager 的核心工作流程:
内存池设计:预分配与分层管理
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 执行以下流程:
- 查找空闲块:从 free_blocks 队列弹出队首块,将其标记为“已分配”,ref_count 置为 1。
- 建立映射:在块映射表中添加 (sequence_id, logical_block) → physical_block 的条目。
- 写入 KV 数据:将当前 token 的 K/V 张量写入该物理块对应的显存地址。
- 触发 Copy-on-Write:如果该逻辑块被多个序列共享(例如 beam search 中的分叉),且某个序列需要修改内容,则分配新块并复制数据,原块 ref_count 减 1。
释放流程则相反:当序列结束或发生截断时,Block Manager 遍历该序列的所有逻辑块,将对应物理块的 ref_count 减 1。当 ref_count 降为 0 时,该块被回收到 free_blocks 队列。若块在缓存池中,则先进入缓存池 LRU 队列。
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,并未进入空闲队列。这种精细的引用计数管理避免了显存浪费,也防止了误回收。
常见误区与调优复盘
增大 block_size 会减少块数量,降低 Block Manager 的调度开销,但也会增加内部碎片。例如 block_size=32 时,一个仅生成 17 个 token 的短请求仍占用一整块,浪费近 50% 显存。实测中,block_size=16 在多数场景下是吞吐与碎片率的最佳平衡点。
Prefix Cache 需要对每个块计算哈希值(默认 xxhash),当块数量超过 10 万时,哈希计算本身会占用约 5-10% 的 GPU 时间。如果业务中前缀复用率很低(例如每次 prompt 都不同),建议通过 --disable-prefix-cache 关闭该功能。
通过 vllm.engine.metrics 暴露的 Prometheus 指标,重点关注 vllm:num_blocks_waiting(等待块分配的序列数)和 vllm:gpu_cache_usage_percent。若 waiting 持续大于 0,说明显存池不足,应增大 --gpu-memory-utilization 或减小 --max-num-seqs。
Block Manager 的设计精髓在于将显存管理的复杂度从“连续大块分配”转化为“小块引用计数”,从而在动态请求场景下实现接近零碎片的显存利用。理解其分配、共享、释放的完整链路,是后续优化吞吐和延迟的基础。