vLLM 推理内核深度解析
第5章 KV Cache 管理:寸土寸金的显存
第5章 KV Cache 管理:寸土寸金的显存
“The cheapest, fastest, and most reliable components are those that aren’t there.” — Gordon Bell
本章要点
- 理解 BlockPool 作为”块级元数据管理器”的定位:为什么 V1 要把分配、引用计数、前缀缓存索引从 Scheduler / Attention 里独立出来
- 掌握
KVCacheBlock的字段设计:block_id、ref_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。
准确说这是两个协作的组件:
BlockPool(vllm/v1/core/block_pool.py)——底层的块级元数据管理器。它拥有所有KVCacheBlock对象,维护 free queue、cached-block hash 索引和引用计数,暴露get_new_blocks()、touch()、free_blocks()、cache_full_blocks()等动作。KVCacheManager(vllm/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 更新指针,append 里 if 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 三种角色共用同一个链表
同一张链表同时承担三种语义:
- 空闲池:
popleft拿一个块出来分配给新请求。 - LRU 队列:
append把刚释放的块追加到尾部;最久没动的块停留在头部。popleft天然就是”取最冷的那个”。 - 前缀缓存驱逐候选:带
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_block(block_pool.py:54-57)。注释说得很清楚:null_block 的 block_id=0,引用计数不按普通块维护,释放时也要特殊处理,避免把它重新放回 free queue。
这个占位块主要服务 sliding-window 场景。SlidingWindowManager.remove_skipped_blocks() 把窗口外的旧 blocks 从请求的 block 列表中移除时,不是直接缩短列表,而是把对应位置替换成 null_block(specialized_manager.py:132-148)。这样 block table 的逻辑位置仍然稳定,后续 attention metadata 不需要因为窗口滑动而整体重排;真正的物理块则通过 free_blocks() 归还给 BlockPool。
这也解释了为什么 BlockPool.free_blocks() 会写 if block.ref_cnt == 0 and block != self.null_block(block_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。它做了五件事:
- 先移除 skipped blocks。对 sliding-window attention,旧到窗口外的 blocks 不再参与后续 attention,可以通过
specialized_manager.remove_skipped_blocks()释放;full attention 路径返回空列表。 - 把 prefix-cache 命中的块计入已计算 tokens。
new_computed_blocks来自get_computed_blocks(),这些满块不需要重新 prefill。 - 按 token 数向上取整计算需要的 block 数。它同时考虑本轮
num_tokens和投机解码的num_lookahead_tokens。 - 容量不足时返回
None。这让 scheduler 可以选择抢占、推迟或调整本轮调度,而不是在底层抛出 OOM。 - 缓存满块。如果开启 prefix caching,
cache_full_blocks()会把已满且可复用的 blocks 写入 hash 索引。
注意 decode 一拍通常只新增 1 个 token。多数时候末尾 block 还没满,num_new_blocks <= 0,allocate_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。
命中后的 touch 在 allocate_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_blocks(kv_cache_manager.py:71-78、block_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 hash(kv_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=16,num_kv_heads=40,head_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 把这个差异塞进 SpecializedManager(vllm/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):
- 先按
kv_cache_spec.type_id排序各 worker 的kv_cache_groups,避免 group 顺序不一致。 - 校验各 worker 的 group spec 相同。
- 取所有 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 = 48token 分配新块;如果还有 decode 步骤就再多分配。 - 整个请求级分配/释放流程不涉及为每个请求动态创建 KV tensor;它只在预分配 KV 池上移动 block 元数据。
5.9.1 实测:v1/core/ KV 子系统真实代码量
§5.1 把架构画成”两层(BlockPool / KVCacheManager)“——把整个 vllm/v1/core/ 目录里 KV 相关文件实测——
| 文件 | 行 | 角色 |
|---|---|---|
kv_cache_utils.py | 744 | 本子系统最大——KVCacheBlock + FreeKVCacheBlockQueue + BlockHashType + 哈希链 + LRU helpers——本章 §5.2/§5.3/§5.5.3 的全部实现都在这一文件里 |
kv_cache_manager.py | 385 | KVCacheManager——本章 §5.4 的”四个核心方法”主体 |
block_pool.py | 281 | BlockPool——块级 malloc 抽象 |
kv_cache_interface.py | 166 | KVCacheSpec / FullAttentionSpec / SlidingWindowSpec——给”不同 attention 层有不同 KV 形状”的接口契约 |
specialized_manager.py | 161 | FullAttentionManager + SlidingWindowManager 两个 specialized manager + spec_manager_map 路由表 |
encoder_cache_manager.py | 149 | 见 §15.4(Encoder Cache 给 VLM 用) |
| KV 子系统合计 | 1886 | — |
两条值得记住的物理事实——
kv_cache_utils.py744 行 ≈kv_cache_manager.py385 行 × 1.93——helper 比主类还大近 2 倍。大量底层机制(KVCacheBlock、双向链表、hash extra keys、KVCacheConfig 生成、跨 worker config 收敛)都集中在 utils.py;KVCacheManager 反而保持成请求级编排层。specialized_manager.py161 行是 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_id、ref_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_memory跑profile_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.py(determine_available_memory/profile_run)- 多 worker KV 配置收敛:
vllm/v1/core/kv_cache_utils.py(unify_kv_cache_configs)