vLLM 推理内核深度解析

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

作者 杨艺韬 · 8,128 字

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

“The cheapest, fastest, and most reliable components are those that aren’t there.” — Gordon Bell

本章要点

  • 理解 BlockPool 作为”块级元数据管理器”的定位:为什么 V1 要把分配、引用计数、前缀缓存索引从 Scheduler / Attention 里独立出来
  • 掌握 KVCacheBlock 的字段设计:block_idref_cnt_block_hash 与空闲链表指针分别承担什么不变式
  • 看懂 FreeKVCacheBlockQueue 一个数据结构兼任三种角色:空闲池 + LRU 队列 + 缓存驱逐候选池
  • 读懂 KVCacheManager 的核心接口(get_computed_blocks / allocate_slots / free / reset_prefix_cache)如何串起 Scheduler 的每一步
  • 掌握引用计数协议:新分配、缓存命中、请求结束、缓存驱逐分别什么时候增减引用
  • 理解前缀缓存的哈希链结构:block_hash 怎么算,为什么只缓存满块,以及 LoRA / 多模态输入为什么要进 extra keys
  • 学会看 determine_available_memory 的”试探性前向”:它实际测的是 peak memory + 非 PyTorch 显存占用
  • 掌握 available_memory -> KVCacheConfig -> num_blocks 的完整换算链路
  • 理解多 worker 下 unify_kv_cache_configs() 为什么把 num_blocks 收敛到最小值

5.1 定位:KV Cache Manager 是什么

上一章我们看清了 PagedAttention 的”虚拟内存模型”:逻辑连续、物理离散、Block Table 做地址翻译。但所有这些都建立在一个假设之上——有人在背后做块的分配和释放。那个人就是 KV Cache Manager。

准确说这是两个协作的组件:

  • BlockPoolvllm/v1/core/block_pool.py)——底层的块级元数据管理器。它拥有所有 KVCacheBlock 对象,维护 free queue、cached-block hash 索引和引用计数,暴露 get_new_blocks()touch()free_blocks()cache_full_blocks() 等动作。
  • KVCacheManagervllm/v1/core/kv_cache_manager.py)——上层的请求级 API。它把请求语义(“查这个 prompt 的前缀是否已算过”、“为本轮 scheduled tokens 预留 slots”、“请求结束后释放 blocks”)翻译成一系列 BlockPool 操作。
graph TB
    S["Scheduler"] -->|请求级指令| M["KVCacheManager"]
    M -->|块级动作| P["BlockPool"]
    P -->|物理块状态| G["GPU 显存中的<br/>key_cache / value_cache 张量"]

    style S fill:#f59e0b,color:#fff,stroke:none
    style M fill:#3b82f6,color:#fff,stroke:none
    style P fill:#8b5cf6,color:#fff,stroke:none
    style G fill:#10b981,color:#fff,stroke:none

这个两层结构的动因是关注点分离

  • BlockPool 只关心”块”:某个 block 是否被引用、是否带 hash、是否在 free queue 里、是否可以被驱逐。
  • KVCacheManager 只关心”请求的 KV 需求”:某个请求已经持有哪些 blocks、还需要几个新 blocks、哪些满块可以进入前缀缓存。

常规调度路径里,Scheduler 面向 KVCacheManager,KVCacheManager 再面向 BlockPool。一条清晰的依赖链让代码易读、也更容易测试。

5.2 KVCacheBlock:一块 KV 的全部元数据

BlockPool 管理的基本单元是 KVCacheBlock。这不是一个普通的 dataclass:

# vllm/v1/core/kv_cache_utils.py
@dataclass
class KVCacheBlock:
    block_id: int
    ref_cnt: int = 0
    _block_hash: Optional[BlockHashType] = None
    prev_free_block: Optional["KVCacheBlock"] = None
    next_free_block: Optional["KVCacheBlock"] = None

    def incr_ref(self): self.ref_cnt += 1
    def decr_ref(self): self.ref_cnt -= 1

这五个字段,每一个都有明确的工程动因。

5.2.1 为什么对象保持得很薄

KVCacheBlock 是 CPU 侧元数据,不存真实 K/V。真实 K/V 在 GPU 上的 per-layer tensor 里;block 对象只记录”第几个物理块”以及这个块能不能被复用/驱逐。源码把它压到少数几个字段,是为了让 BlockPool 可以把所有块对象长期持有,不在请求路径上创建/销毁 Python 对象。

这里原稿把 KVCacheBlock 写成 __slots__ 类,这是不准确的:当前 V1 源码使用 @dataclass,没有声明 __slots__kv_cache_utils.py:111-125)。但”把对象保持很薄”这个设计动机仍然成立:

  • block_id 是唯一身份,范围是 0..num_gpu_blocks-1
  • ref_cnt 决定这个块是否正被请求引用;只有降到 0 才能进入 free queue。
  • _block_hash 只在块已满且可用于 prefix caching 时设置。
  • prev_free_block / next_free_block 只由 FreeKVCacheBlockQueue 操作,用来把 block 对象直接挂进双向链表。

这套设计的重点不是 Python 对象大小的精确节省,而是对象身份稳定:同一个 block_id 始终对应同一个 KVCacheBlock 实例,请求侧只需要持有 block 对象列表,BlockPool 负责改变它的引用计数和链表位置。

5.2.2 为什么把”空闲链表指针”也放进 block 对象

"prev_free_block",
"next_free_block",

乍一看奇怪——链表节点的前驱后继本该在”链表节点”对象上,而不是”业务对象”上。把它们塞进 KVCacheBlock 本身,意味着每个块对象都天生能被挂到空闲链表上,不需要额外的 wrapper 节点

这是一个”intrusive linked list”(侵入式链表)的设计。对比两种写法:

# 非侵入式:额外的 wrapper
class FreeListNode:
    value: KVCacheBlock
    prev: FreeListNode
    next: FreeListNode
# 每个空闲块 → 2 个对象,指针访问要跳两次

# 侵入式:指针直接在业务对象上
class KVCacheBlock:
    prev_free_block: KVCacheBlock  # None 表示"不在空闲链表里"
    next_free_block: KVCacheBlock
# 每个空闲块 → 1 个对象,指针访问直接

Linux 内核的 list_head 也是类似思路。好处是:

  • 内存紧凑——无 wrapper 对象。
  • 状态判断集中——块是否空闲由 ref_cnt 和是否在 free_block_queue 管理;链表指针只由 queue 维护,外部不需要额外 wrapper 集合。
  • 分配/释放对象指针稳定——整个进程生命周期里,同一个物理 block 永远是同一个 Python 对象,不会因为”进链表/出链表”而换对象。外部(比如请求的 block_table)可以安全地保留指向该对象的引用。

5.2.3 五个字段覆盖的协议矩阵

graph LR
    subgraph "一个 block 的生命状态"
        S1["FREE<br/>(ref_cnt=0, hash=None)<br/>在空闲链表"]
        S2["FREE + CACHED<br/>(ref_cnt=0, hash=set)<br/>在空闲链表 + 哈希索引"]
        S3["IN_USE<br/>(ref_cnt=1, hash=None)<br/>某请求独占"]
        S4["IN_USE + CACHED<br/>(ref_cnt=1, hash=set)<br/>独占 + 可共享"]
        S5["SHARED<br/>(ref_cnt>1, hash=set)<br/>多请求共享"]
    end

    S1 -->|alloc| S3
    S3 -->|"request.finish()"| S1
    S3 -->|"seal,block 写满后计算 hash"| S4
    S4 -->|"request.finish()"| S2
    S2 -->|cache_hit on another req| S5
    S4 -->|cache_hit on another req| S5
    S5 -->|请求 finish| S4
    S5 -->|最后一个引用释放| S2

    style S1 fill:#94a3b8,color:#fff,stroke:none
    style S2 fill:#3b82f6,color:#fff,stroke:none
    style S3 fill:#f59e0b,color:#fff,stroke:none
    style S4 fill:#10b981,color:#fff,stroke:none
    style S5 fill:#8b5cf6,color:#fff,stroke:none

一个 block 的可用动作由 (ref_cnt, block_hash, 是否在 free queue) 共同决定。block_hash 不等于”正在被缓存占用”:一个带 hash 的 block 既可能被运行请求引用(ref_cnt > 0),也可能已经释放到 free queue 里等待下一次命中或被驱逐(ref_cnt == 0)。这个区分是理解后面 touch()_maybe_evict_cached_block() 的关键。

5.3 FreeKVCacheBlockQueue:一个数据结构,三种角色

5.3.1 为什么是双向链表

空闲块需要 O(1) 的插入(释放时)和删除(分配时)。Python 内置的 list 虽然 append 是 O(1),但从头部取(pop(0))是 O(n);collections.deque 两端都是 O(1),但任意位置删除是 O(n)。

而 V1 的空闲池同时需要:

  • 插入(释放) — 尾部
  • 分配(获取) — 头部
  • 任意位置删除 — 当一个本来在空闲列表里的”缓存块”被重新引用时,必须从链表里拔掉

只有双向链表三者都是 O(1)。所以 V1 手写了一个 FreeKVCacheBlockQueue,节点复用 KVCacheBlock 自身的 prev_free_block / next_free_block 字段。看真实源码(vllm/v1/core/kv_cache_utils.py:161):

class FreeKVCacheBlockQueue:
    """...this class does not allocate any Python objects when
    manipulating the linked list. Instead, this class manipulates the
    prev_free_block and next_free_block attributes of the given blocks."""

    def __init__(self, blocks: list[KVCacheBlock]) -> None:
        self.num_free_blocks = len(blocks)
        self.free_list_head: Optional[KVCacheBlock] = blocks[0]
        self.free_list_tail: Optional[KVCacheBlock] = blocks[-1]
        for i in range(self.num_free_blocks):
            if i > 0:
                blocks[i].prev_free_block = blocks[i - 1]
            if i < self.num_free_blocks - 1:
                blocks[i].next_free_block = blocks[i + 1]

    def popleft(self) -> KVCacheBlock:                          # O(1)
        if not self.free_list_head:
            raise ValueError("No free blocks available")
        block = self.free_list_head
        self.remove(block)
        return block

    def remove(self, block: KVCacheBlock) -> None:              # O(1)
        if block.prev_free_block is not None:
            block.prev_free_block.next_free_block = block.next_free_block
        if block.next_free_block is not None:
            block.next_free_block.prev_free_block = block.prev_free_block
        if block == self.free_list_head:
            self.free_list_head = block.next_free_block
        if block == self.free_list_tail:
            self.free_list_tail = block.prev_free_block
        block.prev_free_block = block.next_free_block = None
        self.num_free_blocks -= 1

    def append(self, block: KVCacheBlock) -> None:              # O(1)
        if self.free_list_tail is not None:
            self.free_list_tail.next_free_block = block
            block.prev_free_block = self.free_list_tail
            self.free_list_tail = block
        else:
            assert self.free_list_head is None
            self.free_list_head = self.free_list_tail = block
        block.next_free_block = None
        self.num_free_blocks += 1

几个工程细节值得展开:

1. 不用哨兵节点、直接裸 head/tail 指针——remove 里显式判断 block == self.free_list_head / == self.free_list_tail 更新指针,appendif free_list_tail is not None 处理空链表。这里不需要把原因脑补成某个微小内存收益;源码事实是:它选择了最直白的 head/tail 表示,边界分支少而清楚,测试时也容易看出队列头尾是谁。

2. 构造函数接受 blocks: list[KVCacheBlock] 一次性装配整个链表——而不是空 init 后调 append N 次。构造时所有块按 block id 排好,for 循环直接设置 prev_free_block / next_free_block,初始 free queue 的顺序就和 block id 顺序一致。

3. 从 KVCacheBlock 自身借用 prev_free_block / next_free_block 字段——源码 docstring 明确写 “does not allocate any Python objects when manipulating the linked list”。Python 的 collections.deque 是 C 实现、也不分配 wrapper 对象,但不支持 O(1) 中间位置删除;这里需要在 cache hit 的 touch() 路径中把任意 cached block 从 free queue 里拔出来,所以必须要直接指针。

4. num_free_blocks 是显式冗余计数器。可以每次 popleft/append/remove 都 O(N) 数一遍、但选择维护计数——因为 Scheduler 每拍都要查”剩余 block 数”做预算决策、频繁到一次 O(N) 都不合适。维护计数的代价只是 popleft/append/remove 里各多一次 += 1/-= 1

5.3.2 三种角色共用同一个链表

同一张链表同时承担三种语义:

  1. 空闲池popleft 拿一个块出来分配给新请求。
  2. LRU 队列append 把刚释放的块追加到尾部;最久没动的块停留在头部。popleft 天然就是”取最冷的那个”。
  3. 前缀缓存驱逐候选:带 block_hash 的块释放后也会进入 free queue;下一次 get_new_blocks() 从队头取到它时,_maybe_evict_cached_block() 会先清掉 hash 索引,再把它当普通新块分配出去。

一个数据结构兼任三角色,意味着新块释放 = LRU 时间更新 = 潜在驱逐候选更新——三件事在一次 O(1) 的 append 里全部完成。如果拆成三个独立数据结构,需要同步三次、各有各的同步开销、bug 可能性 × 3。V1 的选择是典型的”少即是多”。

graph LR
    subgraph "同一个双向链表"
        direction LR
        H["head (最冷)"]
        H --> B1["Block 42<br/>hash=h1<br/>20分钟前释放"]
        B1 --> B2["Block 17<br/>hash=null<br/>10分钟前释放"]
        B2 --> B3["Block 93<br/>hash=h2<br/>5分钟前释放"]
        B3 --> B4["Block 5<br/>hash=h3<br/>1分钟前释放"]
        B4 --> T["tail (最热)"]
    end

    style H fill:#ef4444,color:#fff,stroke:none
    style T fill:#10b981,color:#fff,stroke:none
    style B1 fill:#94a3b8,color:#fff,stroke:none
    style B4 fill:#fbbf24,color:#000,stroke:none

分配一个新块时,从 head 开始拿。拿到的如果有 hash 值,就相当于驱逐了一个 prefix-cache block;拿到的如果没 hash(比如上面的 Block 17),就是普通空闲块再利用。同一个 popleft() 动作覆盖了两种情况,差别只在 BlockPool._maybe_evict_cached_block() 是否需要清 hash。

5.3.3 null_block:给 sliding window 留的占位块

BlockPool.__init__() 里还有一个很容易被忽略的细节:初始化完所有 blocks 和 free queue 后,它立刻 popleft() 取出第一个块作为 null_blockblock_pool.py:54-57)。注释说得很清楚:null_blockblock_id=0,引用计数不按普通块维护,释放时也要特殊处理,避免把它重新放回 free queue。

这个占位块主要服务 sliding-window 场景。SlidingWindowManager.remove_skipped_blocks() 把窗口外的旧 blocks 从请求的 block 列表中移除时,不是直接缩短列表,而是把对应位置替换成 null_blockspecialized_manager.py:132-148)。这样 block table 的逻辑位置仍然稳定,后续 attention metadata 不需要因为窗口滑动而整体重排;真正的物理块则通过 free_blocks() 归还给 BlockPool。

这也解释了为什么 BlockPool.free_blocks() 会写 if block.ref_cnt == 0 and block != self.null_blockblock_pool.py:235-239)。null_block 是一个哨位,不是可分配资源;它存在的目的,是让”这个逻辑位置已经被 sliding window 跳过”能用一个合法 block id 表示出来。

5.4 KVCacheManager:Scheduler 的四个核心动作

Scheduler 主要通过 KVCacheManager 的几个方法完成 KV 生命周期管理。当前 V1 的真实方法名是这些:

class KVCacheManager:
    def get_computed_blocks(request) -> tuple[list[KVCacheBlock], int]: ...
    def allocate_slots(request, num_tokens, new_computed_blocks=None,
                       num_lookahead_tokens=0) -> Optional[list[KVCacheBlock]]: ...
    def free(request) -> None: ...
    def reset_prefix_cache() -> bool: ...
    @property
    def usage(self) -> float: ...

让我们看每个方法背后的逻辑。

5.4.1 allocate_slots:为本轮 scheduled tokens 预留块

def allocate_slots(self, request, num_tokens, new_computed_blocks=None,
                   num_lookahead_tokens=0):
    req_blocks = self.req_to_blocks[request.request_id]
    removed_blocks = self.specialized_manager.remove_skipped_blocks(
        req_blocks, request.num_computed_tokens)
    self.block_pool.free_blocks(removed_blocks)

    num_computed_tokens = (
        request.num_computed_tokens + len(new_computed_blocks) * self.block_size)
    num_required_blocks = cdiv(
        num_computed_tokens + num_tokens + num_lookahead_tokens,
        self.block_size)
    num_new_blocks = (
        num_required_blocks - len(req_blocks) - len(new_computed_blocks))

    if cannot_allocate(num_new_blocks):
        return None

    self.block_pool.touch(new_computed_blocks)
    req_blocks.extend(new_computed_blocks)
    new_blocks = self.block_pool.get_new_blocks(num_new_blocks)
    req_blocks.extend(new_blocks)
    self.block_pool.cache_full_blocks(...)
    return new_blocks

这段逻辑对应 kv_cache_manager.py:164-294。它做了五件事:

  1. 先移除 skipped blocks。对 sliding-window attention,旧到窗口外的 blocks 不再参与后续 attention,可以通过 specialized_manager.remove_skipped_blocks() 释放;full attention 路径返回空列表。
  2. 把 prefix-cache 命中的块计入已计算 tokensnew_computed_blocks 来自 get_computed_blocks(),这些满块不需要重新 prefill。
  3. 按 token 数向上取整计算需要的 block 数。它同时考虑本轮 num_tokens 和投机解码的 num_lookahead_tokens
  4. 容量不足时返回 None。这让 scheduler 可以选择抢占、推迟或调整本轮调度,而不是在底层抛出 OOM。
  5. 缓存满块。如果开启 prefix caching,cache_full_blocks() 会把已满且可复用的 blocks 写入 hash 索引。

注意 decode 一拍通常只新增 1 个 token。多数时候末尾 block 还没满,num_new_blocks <= 0allocate_slots() 返回空列表;只有跨过 block 边界时才会真正 get_new_blocks()。这也是 paged KV 的效率来源之一:请求可以逐 token 增长,但物理 block 分配按较粗粒度发生。

5.4.2 get_computed_blocks:前缀缓存查询

一个新请求到来时,最应该先问的问题是:它的前 N 个 prompt token 是不是已经被别人算过、KV 还在池里? get_computed_blocks() 做的就是这件事(kv_cache_manager.py:92-162)。

def get_computed_blocks(self, request):
    if not self.enable_caching:
        return [], 0
    block_hashes = self.req_to_block_hashes[request.request_id]
    if not block_hashes:
        block_hashes = hash_request_tokens(self.caching_hash_fn,
                                           self.block_size, request)
        self.req_to_block_hashes[request.request_id] = block_hashes
    if request.sampling_params.prompt_logprobs is not None:
        return [], 0
    computed_blocks = self.specialized_manager.find_longest_cache_hit(
        block_hashes)
    num_computed_tokens = len(computed_blocks) * self.block_size
    return computed_blocks, num_computed_tokens

几个要点:

链式哈希(chain hash):第 k 个 block 的哈希不只取决于这 16 个 token,还取决于前面所有 block 的哈希。这保证了一个 block 只在”前面所有 block 完全相同”的前提下才命中——避免了”内容一样但上下文不同的块被错误共享”的正确性问题。

半块不缓存hash_request_tokens() 遇到长度不足 block_size 的最后一段会 break(kv_cache_utils.py:443-449)。这避免了”半块”随着 decode 推进不断改 hash。

prompt logprobs 会跳过 prefix caching:源码在 kv_cache_manager.py:120-122 明确检查 request.sampling_params.prompt_logprobs。原因是 prompt logprobs 需要对 prompt token 的计算路径更完整,不能简单复用已经算好的 KV。

命中后的 touchallocate_slots() 里发生get_computed_blocks() 只找命中 blocks;真正把它们从 free queue 里移除并 ref_cnt += 1 的,是后续 allocate_slots() 中的 block_pool.touch(new_computed_blocks)kv_cache_manager.py:236-247)。这个顺序很重要:如果本轮最终因为容量不足没法调度,就不应该提前改变 cache block 的引用状态。

5.4.3 free:请求结束时的归还

free() 的源码短,但语义很重(kv_cache_manager.py:296-313):

blocks = self.req_to_blocks.pop(request.request_id, [])
ordered_blocks = reversed(blocks) if self.enable_caching else blocks
self.block_pool.free_blocks(ordered_blocks)
self.num_cached_block.pop(request.request_id, None)

开启 prefix caching 时按反序释放,是为了让同一请求的一串 blocks 在 free queue 里呈现”链尾更靠前”的驱逐顺序。FreeKVCacheBlockQueue 的 docstring 明确说:同一序列同时释放时,hash token 更多的链尾 block 应该排在更前面(kv_cache_utils.py:170-177)。这不是额外的复杂数据结构,而是通过 free() 选择释放顺序实现的。

5.4.4 num_free_blocks:余量通报

当前源码没有 num_free_blocks property;对外更常见的是 usage,它返回 block_pool.get_usage(),也就是 1 - free_blocks / num_gpu_blockskv_cache_manager.py:71-78block_pool.py:267-281)。真正要分配时,allocate_slots() 直接调用 block_pool.get_num_free_blocks() 做容量判断。

这里的 “free blocks” 包括可被驱逐的 cached blocks:带 hash 但 ref_cnt == 0 的块仍在 free queue 里。Scheduler 只需要知道还能拿出多少 blocks;如果拿到的是 cached block,BlockPool 会在分配时清 hash,完成驱逐。

5.4.5 reset_prefix_cache:为什么要求没有在用块

reset_prefix_cache() 用于 RLHF 或压测等场景:权重更新后,旧 KV 的语义已经变了;或者压测前希望清掉历史 prefix cache。KVCacheManager 的实现只是转调 BlockPool.reset_prefix_cache(),但 BlockPool 会先检查当前使用量(block_pool.py:241-265)。

它要求 num_used_blocks == 1,这里的 1 就是前面提到的 null_block。如果还有其他 block 没释放,函数会记录 warning 并返回 false,而不是强行清 cache。原因很直接:正在运行的请求可能还引用着某些 cached blocks;这时删 hash 虽然不会立刻破坏 KV tensor 内容,但会破坏管理层对”哪些块可复用、哪些块可驱逐”的判断。只有确认除 null_block 外没有在用块,才会清空 cached_block_hash_to_block 并遍历所有 blocks 调 reset_hash()

这个接口体现了 KV 管理层的一个基本原则:宁可拒绝重置,也不在有运行请求时偷改全局缓存索引。

5.5 引用计数协议:共享满块,不共享半块

上一章把 COW 作为 PagedAttention 的概念能力讲过;到当前 V1 源码层面,要更精确一点:prefix caching 主路径复用的是满块,新写入的 token 会进入新分配或当前请求独占的块。因此本章不把”半块写时复制”写成 V1 当前实现事实,而是聚焦源码里真实存在的引用计数协议。

5.5.1 引用计数的增减规则

+1 事件:
  - BlockPool.get_new_blocks() 从 free queue 取新块,调用 curr_block.incr_ref()
  - BlockPool.touch() 处理 prefix-cache 命中块,必要时先从 free queue 移除,再 incr_ref()

-1 事件:
  - KVCacheManager.free() 调 BlockPool.free_blocks()
  - sliding-window remove_skipped_blocks() 返回窗口外旧块后由 free_blocks() 释放

V1 在代码里把 ref_cnt 的增减集中到 BlockPool:get_new_blocks()touch()free_blocks()。KVCacheManager 只决定”哪些块应该被分配/触碰/释放”,不直接写 ref_cnt。这就是用封装保证不变式。

5.5.2 Prefix caching 为什么只共享满块

KVCacheManager.get_computed_blocks() 的 docstring 直接写着:computed blocks must be full。源码还在返回前说明 num_computed_tokens 总是 block_size 的倍数(kv_cache_manager.py:92-101:158-161)。这带来三个好处:

  • 哈希稳定:满块的 token 内容不会再变,可以安全放进 cached_block_hash_to_block
  • 写入简单:后续 decode 只会写新 token 的 slot,不会修改被多个请求共享的满块。
  • 驱逐简单ref_cnt == 0 且在 free queue 中的 cached block 可以被分配路径驱逐;正在被请求引用的块不会留在 free queue 里。

如果一个 prompt 最后一段不足 block_size,它不会进入 prefix cache。这个选择牺牲了一点潜在复用,但换来了非常强的不变式:共享块都是满块、不可变块。

5.5.3 前缀缓存的哈希链结构

前缀缓存把多个 block 串成一个逻辑链条,通过哈希识别:

block[0]: hash = H(None, [t0...t15])
block[1]: hash = H(block[0].hash, [t16...t31])
block[2]: hash = H(block[1].hash, [t32...t47])
...

H 可以是 Python builtin hash,也可以是 sha256,取决于 caching_hash_algo;KVCacheManager 初始化时用 sha256 if caching_hash_algo == "sha256" else hashkv_cache_manager.py:40-42)。实际的 hash key 不是裸整数,而是 BlockHashType(hash_value, token_ids, extra_keys)kv_cache_utils.py:21-32)。保留 token tuple 和 extra keys,是为了在 hash value 相同的情况下进一步降低误共享风险。

链式结构保证:

  • 两个请求的 block[i] 能命中(哈希一样)当且仅当它们的 block[0..i] 内容完全相同。
  • 从中间任意一段开始的”部分相同前缀”不会被错误识别。

另外,hash 还会带上请求级 extra keys。need_extra_keys() 对多模态请求和 LoRA 请求返回 true;多模态会把 mm_hash 纳入 block hash,LoRA 会把 lora_int_id 纳入 hash(kv_cache_utils.py:266-389)。这非常关键:同一段 token 在不同 LoRA adapter 或不同图像输入下,不能共享同一块 KV。

哈希链也影响驱逐顺序。源码没有维护一棵显式的”hash 子孙树”,而是通过释放顺序表达偏好:请求释放 blocks 时反序进入 free queue,让链尾更早被取出、链头更晚被取出。

graph LR
    h0["block 42<br/>hash=h0<br/>ref=0"] --> h1["block 73<br/>hash=h1<br/>ref=0"]
    h1 --> h2["block 91<br/>hash=h2<br/>ref=0"]
    h2 --> h3["block 18<br/>hash=h3<br/>ref=0"]

    note["释放入队顺序:先 h3(链尾、最具体)<br/>再 h2、h1、h0<br/>驱逐从队头开始"]

    style h0 fill:#10b981,color:#fff,stroke:none
    style h1 fill:#3b82f6,color:#fff,stroke:none
    style h2 fill:#f59e0b,color:#fff,stroke:none
    style h3 fill:#ef4444,color:#fff,stroke:none

链尾 block(最具体的)通常复用价值更低,链头 block(更通用的系统提示或共享上下文)通常复用价值更高。V1 用”反序释放 + 队头分配/驱逐”这个简单机制,把这种偏好编码进 free queue,而不是再维护一套复杂的优先队列。

5.6 显存预算:如何精确到字节地”试探”

5.6.1 静态估算为什么不靠谱

理论上 KV 池容量 = 可用显存 / (block_size × num_kv_heads × head_dim × 2 × num_layers × dtype_size)。但”可用显存”是什么?

  • 显卡总量(nvidia-smi 的 total)是固定的
  • 但 PyTorch caching allocator、CUDA context、NCCL 通信 buffer、cuBLAS workspace、模型权重的实际占用(包括它 fragmentation 出来的那部分)——都是运行时才确定的

V1 放弃了只靠静态公式的路线,改走”试探”:先跑一次 profiling forward,测量峰值显存占用,再把 gpu_memory_utilization 允许的总量减掉峰值,得到 KV 预算

5.6.2 determine_available_memory 的实现

def determine_available_memory(self) -> int:
    torch.cuda.empty_cache()
    torch.cuda.reset_peak_memory_stats()
    _, total_gpu_memory = torch.cuda.mem_get_info()
    self.model_runner.profile_run()
    peak_memory = torch.cuda.memory_stats()["allocated_bytes.all.peak"]

    torch.cuda.empty_cache()
    torch_allocated_bytes = torch.cuda.memory_stats()[
        "allocated_bytes.all.current"]
    total_allocated_bytes = (
        torch.cuda.mem_get_info()[1] - torch.cuda.mem_get_info()[0])
    non_torch_allocations = total_allocated_bytes - torch_allocated_bytes
    if non_torch_allocations > 0:
        peak_memory += non_torch_allocations

    available_kv_cache_memory = (
        total_gpu_memory * self.cache_config.gpu_memory_utilization
        - peak_memory)
    return int(available_kv_cache_memory)

这个流程有几个细节值得强调:

  • 测的是 peak allocated,不是当前 allocated。源码用 allocated_bytes.all.peak,因此会覆盖 profiling forward 中间的激活峰值。
  • 非 PyTorch 显存也会补进去。NCCL 等库可能绕过 PyTorch allocator 占用显存,源码用 mem_get_info() 和 PyTorch current allocated 的差值估算 non_torch_allocations,大于 0 时加到 peak memory。
  • gpu_memory_utilization 是安全阀。最终预算是 total_gpu_memory * gpu_memory_utilization - peak_memory,不是”当前 free memory 全部给 KV”。

5.6.3 从 KV 预算到并发数

拿到 available_memory 后,get_kv_cache_config() 会先检查够不够至少容纳一个 max_model_len 请求,然后为 uniform KV cache 计算 block 数。真实公式在 kv_cache_utils.py:617-653

page_size = one_layer_one_block_bytes
num_blocks = available_memory // page_size // num_layers
per_layer_size = page_size * num_blocks
KVCacheConfig(num_blocks=num_blocks,
              tensors={layer_name: KVCacheTensor(size=per_layer_size), ...})

这里的 page_size 来自 KVCacheSpec.page_size_bytes。对普通 attention,AttentionSpec.page_size_bytes 是:

2 (K+V) * block_size * num_kv_heads * head_size * dtype_size

如果是 MLA,系数从 2 变成 1,因为 MLA 只存单个 latent vector(kv_cache_interface.py:64-70)。如果是 sliding-window attention,最大内存需求也不是 max_model_len 个 token,而是 sliding_window - 1 + max_num_batched_tokens 再加一个边界 block(kv_cache_interface.py:95-111)。

举一个只用于数量级理解的例子:Llama-2-13B,FP16,block_size=16num_kv_heads=40head_size=128,40 层。单层单 block:

2 * 16 * 40 * 128 * 2 = 327,680 bytes

全模型一个逻辑 block 覆盖 40 层,约 327,680 * 40 = 13,107,200 bytes,也就是约 12.5 MiB。如果 profiling 后可给 KV 的预算是 40 GiB,粗略就是三千多个 blocks、五万多个 token 容量。生产里还要结合 max_num_seqs、平均 prompt/output 长度、抢占策略和 prefix-cache 命中率来定并发,不要把这个例子当固定配置建议。

5.7 SpecializedManager:Full Attention 与 Sliding Window 的分岔

第 4 章主要按 full attention 讲:历史所有 token 都可能被后续 token 看到,所以一旦某个请求持有 block,就要保留到请求结束。但 sliding-window attention 不一样:窗口外的旧 token 后续不会再参与 attention,继续占着 KV block 就浪费。

V1 把这个差异塞进 SpecializedManagervllm/v1/core/specialized_manager.py):

class SpecializedManager(ABC):
    def find_longest_cache_hit(block_hashes) -> list[KVCacheBlock]: ...
    def remove_skipped_blocks(blocks, num_computed_tokens) -> list[KVCacheBlock]: ...

spec_manager_map = {
    FullAttentionSpec: FullAttentionManager,
    SlidingWindowSpec: SlidingWindowManager,
}

FullAttentionManager.remove_skipped_blocks() 直接返回空列表,因为没有旧 block 可以跳过。SlidingWindowManager.remove_skipped_blocks() 会计算 last_useful_token = num_computed_tokens - sliding_window + 1,把 last_useful_block 之前的 blocks 替换成 null_block,并把这些 removed blocks 交给 KVCacheManager 释放(specialized_manager.py:132-148)。

这解释了为什么 KVCacheManager 初始化时不是直接 new 一个 FullAttentionManager,而是调用 get_specialized_manager(kv_cache_spec, block_pool)kv_cache_manager.py:47-51)。同一个 allocate_slots(),对 full attention 是”只追加新块”,对 sliding window 则可能先释放窗口外旧块,再分配新块。

5.8 多 Worker 下的 KV 配置收敛

多 GPU 场景里,最容易出错的是每个 worker 可用显存不同。某张卡上可能因为 CUDA context、NCCL workspace 或模型分片差异,profiling 后算出的可用 KV blocks 比别的卡少。如果调度器按”最多的那张卡”来分配,少 blocks 的 worker 就会越界。

V1 的解决方式在 unify_kv_cache_configs()kv_cache_utils.py:711-744):

  1. 先按 kv_cache_spec.type_id 排序各 worker 的 kv_cache_groups,避免 group 顺序不一致。
  2. 校验各 worker 的 group spec 相同。
  3. 取所有 worker 的 num_blocks 最小值,把每个 KVCacheConfig.num_blocks 都改成这个最小值。

源码注释还解释了一个细节:不需要 shrink 已经计划好的 tensor size,因为只使用前 num_blocks 个 blocks 就是合法的(kv_cache_utils.py:736-738)。这和第 4 章的 worker 初始化校验是同一件事的两面:GPUModelRunner.initialize_kv_cache() 允许本地 tensor 的 blocks 数大于全局 kv_cache_config.num_blocks,但 KVCacheManager 只按全局最小值分配。

所以,本章不再写”rank 0 广播 BlockPool 状态”这类没有源码锚点的说法。可以确定的是:调度层分配出的 block ids 必须落在统一后的 num_blocks 范围内;每个 worker 用自己的 KV tensor 执行 attention,但逻辑 block id 空间被统一配置约束住。

5.9 一个完整场景:两个请求的生命周期

让我们把本章所学串起来,看一个具体的时间线。

sequenceDiagram
    participant S as Scheduler
    participant M as KVCacheManager
    participant P as BlockPool
    participant G as GPU KV Cache 张量

    Note over S,G: 初始:100 个 block,全空闲,链表:[0..99]

    Note over S,G: T1 请求 A 到达(prompt=48 token)
    S->>M: get_computed_blocks(A)
    M-->>S: ([], 0)  —— 没命中缓存
    S->>M: allocate_slots(A, 48)
    M->>P: get_new_blocks(3)
    P->>G: 物理分配 block 1/2/3 给 A(引用 1,未 hash)

    Note over S,G: T2 A 预填充完成、开始 decode
    S->>M: 每步 decode 追加 1 token(大多不触发新分配)

    Note over S,G: T3 请求 B 到达(prompt=80 token,和 A 完全无关)
    S->>M: get_computed_blocks(B)
    M-->>S: ([], 0)
    S->>M: allocate_slots(B, 80)
    M->>P: get_new_blocks(5)
    P->>G: 分配 block 4/5/6/7/8 给 B

    Note over S,G: T4 A 完成(生成了 16 token,总长 64 token=4 block)
    S->>M: free(A)
    M->>P: free_blocks(reversed(A.blocks))
    P->>P: 满块保留 hash;ref_cnt 到 0 后入 free queue

    Note over S,G: T5 请求 C 到达(prompt 和 A 前 32 token 完全相同)
    S->>M: get_computed_blocks(C)
    M->>P: hash 查询 via specialized_manager
    P-->>M: 命中 block 1 和 block 2
    S->>M: allocate_slots(C, 剩余 tokens, new_computed_blocks=[1,2])
    M->>P: touch(1), touch(2)  —— 从 free queue 拔出并加引用
    M-->>S: 前 32 token 可复用

几个观察:

  • T4 A 完成时,free() 只改引用计数和 free queue;是否还能被 prefix cache 命中,取决于满块是否已经通过 cache_full_blocks() 写入 hash 索引。
  • T5 C 命中前缀后,只需为剩下 80 - 32 = 48 token 分配新块;如果还有 decode 步骤就再多分配。
  • 整个请求级分配/释放流程不涉及为每个请求动态创建 KV tensor;它只在预分配 KV 池上移动 block 元数据。

5.9.1 实测:v1/core/ KV 子系统真实代码量

§5.1 把架构画成”两层(BlockPool / KVCacheManager)“——把整个 vllm/v1/core/ 目录里 KV 相关文件实测——

文件角色
kv_cache_utils.py744本子系统最大——KVCacheBlock + FreeKVCacheBlockQueue + BlockHashType + 哈希链 + LRU helpers——本章 §5.2/§5.3/§5.5.3 的全部实现都在这一文件里
kv_cache_manager.py385KVCacheManager——本章 §5.4 的”四个核心方法”主体
block_pool.py281BlockPool——块级 malloc 抽象
kv_cache_interface.py166KVCacheSpec / FullAttentionSpec / SlidingWindowSpec——给”不同 attention 层有不同 KV 形状”的接口契约
specialized_manager.py161FullAttentionManager + SlidingWindowManager 两个 specialized manager + spec_manager_map 路由表
encoder_cache_manager.py149见 §15.4(Encoder Cache 给 VLM 用)
KV 子系统合计1886

两条值得记住的物理事实——

  1. kv_cache_utils.py 744 行 ≈ kv_cache_manager.py 385 行 × 1.93——helper 比主类还大近 2 倍。大量底层机制(KVCacheBlock、双向链表、hash extra keys、KVCacheConfig 生成、跨 worker config 收敛)都集中在 utils.py;KVCacheManager 反而保持成请求级编排层。
  2. specialized_manager.py 161 行是 full/sliding-window 策略钩子。滑动窗口的旧块可以主动释放,全 attention 必须保留;这解释了为什么 KVCacheManager 要依赖 specialized manager,而不是把所有 attention 类型写进一个大 if/else。

串联 ch04 §4.9.1 实测:vllm/v1/attention/ 3115 行(attention 计算)+ 本节 KV 子系统 1886 行(KV 管理)= 5001 行 = “PagedAttention 抽象”完整生产实现——印证 §4.10 末段 “Paged 抽象的自然能力” 在源码层是 attention/ 和 core/ 两个目录配合实现的、不是某一个文件包揽全部

5.10 本章小结

KV Cache Manager 把 PagedAttention 的”虚拟内存模型”落地为高效、可靠的工程实现:

  • 两层架构——BlockPool 做块级 malloc,KVCacheManager 做请求级语义翻译;Scheduler 只看 KVCacheManager
  • KVCacheBlock 元数据稳定——block_idref_cnt_block_hash、free-list 指针共同定义一块 KV 的生命周期;当前源码是 dataclass,不是 __slots__
  • FreeKVCacheBlockQueue 三角色合一——空闲池 + LRU + 缓存驱逐候选池;一次 append 同时完成三件事;O(1) 头部出、尾部入、任意位置拔
  • 核心方法——get_computed_blocks / allocate_slots / free / reset_prefix_cache;Scheduler 不直接碰底层 block
  • 引用计数协议——新分配和缓存命中加引用,请求结束和 sliding-window 跳过块减引用;prefix caching 主路径共享满块,不写半块
  • 链式哈希前缀缓存——每个 block 的 hash 包含 parent hash、token tuple 和 extra keys;LoRA / 多模态请求会把额外身份纳入 hash
  • 显存预算试探式探测——determine_available_memoryprofile_run() 测 peak allocated,并补上非 PyTorch 显存占用;gpu_memory_utilization 当安全阀
  • 多 worker 配置收敛——unify_kv_cache_configs() 把各 worker 的 num_blocks 统一到最小值,保证 block id 空间一致

到此为止,我们已经完整理解了 vLLM V1 的四大基石:EngineCore、Scheduler、PagedAttention、KV Cache Manager。它们共同构成了”逻辑上按请求调度,物理上按 block 复用显存”的引擎骨架。

物理事实:KV 子系统 6 文件 1886 行(kv_cache_utils.py 744 helper 比 manager 385 大近 2 倍);specialized_manager.py 161 行负责滑动窗口/全 attention 策略路由;串联 ch04 §4.9.1 attention/ 3115 + 本节 KV 1886 = 5001 行 PagedAttention 抽象完整生产实现。


源码导航

  • BlockPool:vllm/v1/core/block_pool.py
  • KVCacheBlock / FreeKVCacheBlockQueue / BlockHash:vllm/v1/core/kv_cache_utils.py
  • KVCacheManager:vllm/v1/core/kv_cache_manager.py
  • KV Cache 接口:vllm/v1/kv_cache_interface.py
  • 显存探测:vllm/v1/worker/gpu_worker.pydetermine_available_memory / profile_run
  • 多 worker KV 配置收敛:vllm/v1/core/kv_cache_utils.pyunify_kv_cache_configs