vLLM内核探秘-第5章 KV Cache 管理:寸土寸金的显存

《vLLM 内核探秘》完整目录

第5章 KV Cache 管理:寸土寸金的显存

"The cheapest, fastest, and most reliable components are those that aren't there." -- Gordon Bell

:::tip 本章要点

  • 理解 BlockPool 的数据结构设计:为什么用 __slots__ 和空闲链表
  • 掌握 KVCacheManager 的分配与释放策略
  • 深入引用计数机制:块共享与 Copy-on-Write 的工程实现
  • 理解 LRU 驱逐策略:当显存不足时如何选择牺牲者
  • 认识前缀缓存的基础设施(为第 10 章做铺垫) :::

5.1 BlockPool:块的资源池

上一章我们知道了物理块的概念。现在的问题是:谁来管理这些块?

答案是 BlockPoolvllm/v1/core/block_pool.py)。它是所有物理块的容器和管理者,职责包括:

  • 维护空闲块列表
  • 分配块给请求
  • 回收已释放的块
  • 管理块的引用计数
  • 维护前缀缓存的哈希索引

KVCacheBlock:极致精简的数据结构

先看 BlockPool 管理的基本单元------KVCacheBlockvllm/v1/core/kv_cache_utils.py):

python 复制代码
class KVCacheBlock:
    __slots__ = (
        "block_id",
        "ref_cnt",
        "block_hash",
        "prev_free_block",
        "next_free_block",
    )

    def __init__(self, block_id: int):
        self.block_id = block_id
        self.ref_cnt = 0
        self.block_hash: Optional[BlockHash] = None
        self.prev_free_block: Optional[KVCacheBlock] = None
        self.next_free_block: Optional[KVCacheBlock] = None

注意 __slots__ 声明------这不是偶然的。vLLM 可能管理数万个块(一张 80 GB A100 上,如果每个块 320 KB,大约有 170,000 个块)。使用 __slots__ 代替普通的 __dict__,每个对象节省约 100 字节的内存和显著的属性访问时间。在管理十万级别对象时,这种微优化累积起来相当可观。

五个字段各有含义:

字段 作用
block_id 物理块编号(对应 GPU 显存中的位置)
ref_cnt 引用计数。0 = 空闲,1 = 独占,>1 = 共享
block_hash 哈希值(用于前缀缓存匹配)
prev_free_block 空闲双向链表:前驱
next_free_block 空闲双向链表:后继

FreeKVCacheBlockQueue:空闲块管理

空闲块用一个双向链表 管理------FreeKVCacheBlockQueue。为什么用链表而不是列表(Python list)?

因为需要 O(1) 的插入和删除 。块的释放(插入)和分配(删除)是最频繁的操作,每一步推理都会发生。用 Python list 的 appendpop(0) 虽然简单,但 pop(0) 是 O(n) 的。双向链表保证了两端操作都是 O(1)。

graph LR HEAD["头(最久未使用)"] --> B3["Block 3"] B3 --> B7["Block 7"] B7 --> B12["Block 12"] B12 --> B1["Block 1"] B1 --> TAIL["尾(最近释放)"] TAIL --> B1 B1 --> B12 B12 --> B7 B7 --> B3 B3 --> HEAD style HEAD fill:#ef4444,color:#fff,stroke:none style TAIL fill:#10b981,color:#fff,stroke:none

空闲链表同时充当 LRU 队列的角色:

  • 释放块 时,插入到链表尾部(最近释放)
  • 分配块 时,从链表头部取出(最久未使用)
  • 驱逐缓存块 时,同样从头部驱逐(LRU 策略)

一个数据结构,三种用途------这是工程上的精巧设计。

5.2 KVCacheManager:调度器的左右手

KVCacheManagervllm/v1/core/kv_cache_manager.py)是调度器与 BlockPool 之间的桥梁。调度器不直接操作 BlockPool,而是通过 KVCacheManager 的高层接口。

分配:为请求分配块

当调度器决定让一个新请求参与计算时,KVCacheManager 需要为它分配足够的块:

python 复制代码
# 简化逻辑
def allocate_blocks(self, request, num_tokens):
    num_blocks_needed = ceil(num_tokens / block_size)
    current_blocks = len(request.block_ids)
    new_blocks_needed = num_blocks_needed - current_blocks

    if new_blocks_needed <= 0:
        return  # 已有足够的块

    if self.block_pool.free_count < new_blocks_needed:
        return None  # 显存不足,告知调度器

    # 从 BlockPool 分配
    new_block_ids = []
    for _ in range(new_blocks_needed):
        block = self.block_pool.allocate()
        block.ref_cnt = 1
        new_block_ids.append(block.block_id)

    request.block_ids.extend(new_block_ids)
    return new_block_ids

关键点:分配可能失败(显存不足)。此时 KVCacheManager 返回 None,调度器需要决定是等待、还是抢占其他请求来腾出空间。

释放:请求完成后回收

python 复制代码
def free_blocks(self, request):
    for block_id in request.block_ids:
        block = self.block_pool.blocks[block_id]
        block.ref_cnt -= 1
        if block.ref_cnt == 0:
            if block.block_hash is not None:
                # 有哈希值 → 放入前缀缓存(不立即回收)
                self.block_pool.free_to_cache(block)
            else:
                # 无哈希值 → 直接回收
                self.block_pool.free_block(block)
    request.block_ids.clear()

注意带 block_hash 的块不会立即回收------它们进入前缀缓存,留待后续请求复用。这个机制是第 10 章"前缀缓存"的基础。

引用计数与 Copy-on-Write

当多个请求共享同一个块(如并行采样的 Prompt 部分)时,ref_cnt > 1。当某个请求需要修改共享块时(追加新 Token),需要先复制:

python 复制代码
def cow_block(self, block_id, request):
    """Copy-on-Write:复制共享块"""
    block = self.block_pool.blocks[block_id]
    if block.ref_cnt == 1:
        return block_id  # 独占,无需复制

    # 分配新块
    new_block = self.block_pool.allocate()

    # 复制 KV Cache 数据(GPU 显存中的拷贝)
    copy_kv_cache(src=block_id, dst=new_block.block_id)

    # 更新引用计数
    block.ref_cnt -= 1
    new_block.ref_cnt = 1

    return new_block.block_id

GPU 显存中的数据拷贝是通过 CUDA memcpy 完成的,成本不低。但相比完全不共享(3 份 Prompt KV Cache),COW 只在写入时复制一个块(320 KB),远比复制整个序列(可能几 MB)便宜。

5.3 显存预算的计算

系统启动时,vLLM 需要确定能分配多少个 KV Cache 块。这是一个关键的资源分配决策------块数越多,能服务的并发请求越多(吞吐量越高);但如果预留不足,模型权重和运行时激活值可能导致 OOM(Out of Memory)崩溃。

vLLM 采用了一种巧妙的试探法来精确计算可用显存------不是通过理论公式估算(容易不准确),而是实际执行一次虚拟的前向传播来测量 GPU 显存消耗。这个方法在 Worker 初始化阶段完成:

flowchart TD A["总 GPU 显存"] --> B["减去模型权重"] B --> C["减去激活值临时空间"] C --> D["减去 CUDA 内核临时空间"] D --> E["剩余显存 ÷ 单块大小"] E --> F["= 可用 KV Cache 块数"] style A fill:#ef4444,color:#fff,stroke:none style F fill:#10b981,color:#fff,stroke:none

具体来说,Worker 通过 determine_available_memory() 方法执行一次"试探性"的前向传播:

  1. 加载模型权重
  2. 构造一个最大批次大小的虚拟输入
  3. 执行一次前向传播(不保存 KV Cache)
  4. 测量 GPU 显存的峰值使用量
  5. 总显存 - 峰值使用量 × 安全系数 = 可用于 KV Cache 的显存

这种"先试后算"的方法比静态估算更准确------它考虑了 PyTorch 的内存碎片、CUDA 上下文占用、NCCL 通信缓冲区等难以预测的因素。

gpu_memory_utilization 参数

用户可以通过 --gpu-memory-utilization(默认 0.9)控制 vLLM 使用多少比例的 GPU 显存。设为 0.9 意味着 vLLM 最多使用 90% 的显存,留 10% 给系统和其他进程。

复制代码
可用显存 = 总显存 × gpu_memory_utilization - 非 KV Cache 开销
KV Cache 块数 = 可用显存 ÷ 单块大小

对于 80 GB A100,模型 26 GB,其他开销 4 GB:

  • 可用显存 = 80 × 0.9 - 26 - 4 = 42 GB
  • 块大小 = 320 KB
  • 块数 = 42 GB ÷ 320 KB ≈ 137,000 个块
  • 可容纳 Token 数 = 137,000 × 16 ≈ 220 万个 Token

这意味着理论上可以同时服务 1,100 个各 2,048 Token 的请求。当然,实际数量会因模型大小、序列长度分布和工作负载特征而异。

5.4 驱逐策略

当所有块都被占用且需要为新请求分配块时,KVCacheManager 可以驱逐前缀缓存中的块。

驱逐遵循 LRU(最近最少使用) 策略:空闲链表头部的块是最久没有被任何请求引用的,优先被驱逐。

graph LR subgraph "空闲链表(LRU 顺序)" direction LR H["头
最冷"] --> B1["Block A
10 分钟前释放"] B1 --> B2["Block B
5 分钟前释放"] B2 --> B3["Block C
1 分钟前释放"] B3 --> T["尾
最热"] end style H fill:#ef4444,color:#fff,stroke:none style T fill:#10b981,color:#fff,stroke:none style B1 fill:#fbbf24,color:#000,stroke:none

驱逐时需要注意**链式块(Chain Blocks)**的处理。前缀缓存中,块通过哈希链关联(第 10 章会详细讲)。驱逐一个块时,依赖它的子块也需要被驱逐,否则哈希链会断裂。

V1 的实现中有一个精巧的优化:同一时间戳释放的块中,链尾的块优先被驱逐。因为链尾块是最具体的(包含最长前缀的最后一段),被复用的概率最低;而链头块(包含公共前缀的前几段)被复用的概率最高,应该尽量保留。

这种驱逐策略与操作系统的页面置换算法有有趣的对比:

操作系统 vLLM KV Cache
LRU 置换最久未使用的页面 LRU 驱逐最久未被引用的块
工作集理论:活跃页面保留 公共前缀块是"热"块,保留
脏页写回磁盘 被驱逐的块只丢弃(可从 prefill 重算)
预取:提前加载可能需要的页面 前缀缓存:预留可能被复用的块

两者的关键区别在于:操作系统丢失一页数据是灾难性的(必须写回磁盘),而 KV Cache 丢失一个块只意味着需要重新计算这部分的注意力------代价是计算时间而非数据丢失。这也是为什么 KV Cache 管理可以更"激进"地驱逐------最坏情况也只是多算几毫秒。

5.5 显存是如何被精确到字节管理的

让我们把本章的所有知识串联起来,看一个完整的场景。

假设系统有 100 个空闲块,收到两个请求:

yaml 复制代码
请求 A: 输入 50 Token, 最终生成 30 Token
请求 B: 输入 80 Token, 最终生成 20 Token
sequenceDiagram participant S as Scheduler participant KV as KVCacheManager participant BP as BlockPool Note over S,BP: Step 1: 请求 A 预填充(50 Token) S->>KV: 分配 ⌈50/16⌉ = 4 个块给 A KV->>BP: 从空闲链表取 4 个块 Note over BP: 空闲: 100 → 96 Note over S,BP: Step 2: 请求 B 预填充(80 Token) S->>KV: 分配 ⌈80/16⌉ = 5 个块给 B KV->>BP: 从空闲链表取 5 个块 Note over BP: 空闲: 96 → 91 Note over S,BP: Step 3-32: A 和 B 交替解码 Note over BP: A 每 16 个新 Token 多分配 1 块
B 同理 Note over S,BP: Step 33: A 完成(共 80 Token = 5 块) S->>KV: 释放 A 的 5 个块 KV->>BP: 5 个块归还(或进入前缀缓存) Note over S,BP: Step 42: B 完成(共 100 Token = 7 块) S->>KV: 释放 B 的 7 个块 KV->>BP: 7 个块归还 Note over BP: 空闲: 恢复到 100(扣除缓存保留)

整个过程中:

  • 最大同时占用:A(5块) + B(7块) = 12 块 = 5.9 MB
  • 如果用传统方案(假设 max_len=2048):A(128块) + B(128块) = 256 块 = 81.9 MB
  • 节省了 93% 的显存

这就是 PagedAttention + 按需分配的威力。

5.6 本章小结

KV Cache Manager 是 PagedAttention 设计理念的工程落地:

  • BlockPool__slots__ 对象和双向链表管理十万级别的物理块,O(1) 分配/释放
  • KVCacheManager 提供高层接口(分配、释放、COW),屏蔽底层细节
  • 引用计数 + COW 实现了高效的块共享,在并行采样中节省大量显存
  • LRU 驱逐 在显存紧张时释放最不可能被复用的缓存块
  • 显存预算 通过试探性前向传播精确计算,配合 gpu_memory_utilization 控制总量

到此为止,我们已经完整理解了 vLLM 引擎核心的四大组件:EngineCore、调度器、PagedAttention、KV Cache 管理。它们构成了 vLLM 的基石。

下一章,我们将走出引擎核心,进入执行层------看看 Worker 和 Executor 如何将调度决策变成真正的 GPU 计算。


源码导航

  • BlockPool:vllm/v1/core/block_pool.py
  • KVCacheBlock:vllm/v1/core/kv_cache_utils.py
  • KVCacheManager:vllm/v1/core/kv_cache_manager.py
  • KV Cache 接口定义:vllm/v1/kv_cache_interface.py
  • Worker 显存探测:vllm/v1/worker/gpu_worker.pydetermine_available_memory
相关推荐
杨艺韬3 小时前
vLLM内核探秘-第8章 前向计算与 CUDA Graph
agent
杨艺韬3 小时前
vLLM内核探秘-前言
agent
杨艺韬3 小时前
vLLM内核探秘-第16章 LoRA 适配器热切换
agent
Aaron_Chou3136 小时前
保姆级codex配置教程
gpt·ai·agent·ai编程·codex
Tony沈哲15 小时前
多智能体不是终点,而是起点:OpenVitamin 的 Agent Orchestration 的工程实现
架构·llm·agent
大模型真好玩15 小时前
GitHub 85K Star 新王挑战 357K Star 霸主:Hermes 还是 OpenClaw?最强Agent框架怎么选
人工智能·agent·deepseek
后端小肥肠17 小时前
Hermes Agent喂饭级教程:安装、迁移 OpenClaw、接入飞书全流程
人工智能·agent
HIT_Weston19 小时前
50、【Agent】【OpenCode】本地代理增强版分析(超时机制实现)
人工智能·agent·opencode
Pkmer19 小时前
Agentic workflow实践:模拟邮件助手工作流
llm·agent