nano-vllm-omni · 提示 ← P07P08 →
#17 · Track B · Stage 进程与调度 · 书 p.76

P06 · BlockManager:paged KV + 前缀缓存

PagedAttention 的内存管理层:引用计数、哈希链前缀匹配、LRU 可驱逐池、原子分配。

⏱ 75 min 难度 难 实现 mine/p06_block_manager.py 验收 ./check.sh p06 提示 A-17 · 参考解 C-17 solutions/p06_block_manager.py

题面速览

prompt: [0..9](bs=4) → 块 [0,1,2,3] [4,5,6,7] [8,9,_,_]
可匹配块数 = (len - 1) // bs = 2    # 至少留 1 个 token 要算(否则拿不到下一个 token 的 logits)
num_cached_tokens = 命中块数 × bs

验收要点:slot 映射 · append 跨 block 边界 · OutOfBlocks 原子性 · free 全部归还 · 前缀共享与引用计数 · free 后缓存仍可命中、最后一个 token 永不命中 · 驱逐后不再命中 · append 填满的 block 也进入缓存。

直觉与类比

酒店客房与房卡清单

物理 block = 一间四人间(block_size=4 张床),num_blocks = 酒店一共几间房。一个序列 = 一个旅行团,前台给它一张房卡清单(block table):第 1 批住 2 号房、第 2 批住 7 号房……房间号不需要连号——这正是 paged 的意义。slot(seq, pos) 就是“第 pos 号客人睡哪张床”:房号 × 每间床数 + 床位号。

图书馆的“还书推车”(evictable LRU 池)

读者还书后,书先放在还书推车上:① 别的读者正好要这本书,可以直接从推车上拿走(命中一个 ref==0 的块,“复活”它);② 推车满了,馆员从最早放上来的那一端拿走一本,抹掉内容改装成别的书(LRU 驱逐)。抹掉的那一刻必须把目录卡片(hash → block)也撕掉,否则下一个读者按目录拿到封面对、内容全错的书——KV 错乱,模型不报错,只会悄悄说胡话。

哈希链 = 快递面单上的“全程路径”

如果块的标签只写“本块装了 [7, 8]”,两个前缀完全不同的序列会误认为能共享它。可 KV 的值依赖从句首到这里的全部上下文。所以标签写成“上一块的标签 + 本块内容”,只有整条前缀都一样的序列才会算出同一个 h_i。

框架视角 · 三个操作 · 一张账本 · 不变量

操作 读(只读,不能有副作用) 写(顺序很重要) 可能 raise 的位置
allocate ① 满块的哈希链(只给 len//bs 个满块算)② 在前 (len-1)//bs 块内从头连续查 hash→block,第一次 miss 就停 ③ 容量:需要新块数 ≤ num_free_blocks − 命中块中 ref==0 的个数 ④ 命中块:ref==0 的先移出可驱逐池 → ref+1 ⑤ 其余块:取新块(空闲优先,否则驱逐最旧 + 删哈希登记)⑥ 满块登记哈希 ⑦ 保存 table / tokens / 哈希链 / 命中数 只在 ③(以及 ⑤ 的兜底);必须在任何写之前
append_slot pos = len(tokens[seq]);pos // bs == len(table) 吗(要不要新块) ① 先取新块(唯一会 raise 的事)② 再 append token ③ 若 (pos+1) % bs == 0:这块刚满 → 用链尾当父哈希,算新哈希、入链、登记 只在 ①;顺序反了会留下“多了一个 token”的脏状态
free 该序列的 block table 逆序遍历:ref−1;归零时有哈希 → 进可驱逐池(最新一端),否则 → 空闲表;最后删掉该序列的四份记录 不会 raise
不变量 违反时的症状
I1 num_free_blocks == len(空闲表) + len(可驱逐池),二者不相交,块都满足 ref == 0 同一个块被分配给两个序列,内容互相覆盖
I2 b ∈ 可驱逐池 ⟺ ref[b] == 0 且 block_hash[b] is not None 命中后忘了移出池子 → 正在用的块被当候补驱逐
I3 hash_to_block[h] == b ⟹ block_hash[b] == h 驱逐后没删映射 → 旧前缀“命中”一块已被覆盖的块
I4 ref[b] == 包含 b 的 block table 个数;全部 free 后 num_free_blocks == num_blocks block 泄漏:跑一会儿就 OutOfBlocks
I5 num_cached_tokens(seq) % bs == 0 且 < len(token_ids) 全部命中时模型没有 token 可 forward,采不出下一个 token

易错点

逐步图:前缀共享 + 引用计数 + 可驱逐池(例 2)

▶ block_size=2, num_blocks=8, enable_prefix_cache=True
1. allocate("a", [1,2,3,4,5]) → [0, 1, 2]

块0=[1,2]、块1=[3,4] 是满块,登记 h0、h1;块2=[5] 不满,不登记。num_cached_tokens("a") = 0。

ref:11100000

2. allocate("b", [1,2,3,4,9]) → [0, 1, 3]

可匹配 (5-1)//2 = 2 块,两块都命中(ref+1);块 3 是新块。num_cached_tokens("b") = 4,num_free_blocks = 4。

ref:22110000

3. free("a")

逆序:块2 ref→0 无哈希 → 空闲表;块1、块0 ref→1。num_free_blocks = 5。

ref:11010000

4. free("b")

逆序:块3 → 空闲表;块1、块0 ref→0 有哈希 → 可驱逐池 [1, 0](块1 先入池 = 最旧)。num_free_blocks = 8(空闲 6 + 可驱逐 2)。

ref:00000000(绿色 = 在可驱逐池里)

5. allocate("c", [1,2,3,4]) → [0, 4]

只有 (4-1)//2 = 1 块可匹配!块0 从可驱逐池“复活”;可驱逐池剩 [1]。num_cached_tokens("c") = 2。

第二块 [3,4] 明明在缓存里,但“至少留 1 个 token 要算”的规则不允许命中,于是分配了新块 4。

三级提示

L1I/O 示例与关键问题I/O 示例 + 关键问题 + 细节(轻度剧透)

例 1:不开前缀缓存(block_size=4, num_blocks=6,空闲表 FIFO):

bm.allocate("x", [11, 12, 13, 14, 15, 16])   # -> [0, 1]   ceil(6/4)=2 块
bm.num_free_blocks                            # -> 4        空闲表 [2, 3, 4, 5]
bm.slot("x", 5)                               # -> 5        = table[5//4]*4 + 5%4 = 1*4 + 1
bm.append_slot("x", 17)                       # -> 6        pos=6,块 1 还有空位
bm.append_slot("x", 18)                       # -> 7        pos=7,块 1 填满
bm.append_slot("x", 19)                       # -> 8        pos=8,需要第 3 块 -> table [0, 1, 2]
bm.num_free_blocks                            # -> 3
bm.free("x")                                  # 逆序归还 2, 1, 0 -> 空闲表 [3, 4, 5, 2, 1, 0]
bm.allocate("y", [1, 2, 3])                   # -> [3]

例 2:见逐步图。

例 3:命中可驱逐块也会占用名额——原子性陷阱(block_size=2, num_blocks=3):

bm.allocate("a", [1, 2, 3, 4, 5]); bm.free("a")
# 空闲表 [2],可驱逐池 [1, 0],num_free_blocks = 3
bm.allocate("b", [1, 2, 3, 4, 5, 6, 7])   # 需要 4 块;命中块0、块1(都在可驱逐池里)
# -> raise OutOfBlocks
bm.num_free_blocks                          # -> 3,空闲表 [2],可驱逐池 [1, 0](完全没变)
bm.allocate("b", [1, 2, 3, 4, 5, 6])        # -> [0, 1, 2],num_cached_tokens = 4,num_free_blocks -> 0

第一次:需要新块 4−2=2 个,但“可用”的 3 块里有 2 块正是被命中的那两块,真正能当新块的只有 1 块,所以必须失败,且失败前不能动任何状态。

关键问题

  1. prompt 10 个 token、bs=4:需要几块?哪几块是满块、会得到哈希?半满的块会不会进前缀缓存?
  2. prompt 恰好 8 个 token、bs=4,两块都已在缓存里。允许命中几块?num_cached_tokens 是多少?全部命中的话模型这一步能产出什么?
  3. 两个块内容都是 [7, 8],一个前面是 [1, 2]、一个前面是 [5, 6]。哈希应该相同吗?
  4. 命中的块可能 ref>0,也可能 ref==0。两种情况 allocate 时分别做什么?哪一种会让“可用块数”变少?
  5. 从可驱逐池拿一个块装新内容时,除了移出池子,还必须清理哪两处记录?
  6. append_slot 新 token 的 pos 是多少?什么条件下拿新块?拿块失败时哪些状态已被你改掉了?
  7. free 时为什么逆序遍历?

细节

  • 哈希链起点选一个固定哨兵(如 None)当 h_{-1};allocate 和 append_slot 必须同一套规则。
  • 从头连续匹配——第一次 miss 就停。
  • can_allocate(num_tokens) 只是 ceil(num_tokens/bs) <= num_free_blocks 的粗检查。
  • enable_prefix_cache=False:不登记任何哈希,free 后的块全部回空闲表,num_cached_tokens 恒为 0。
  • 同内容多副本:哈希表里一个哈希只指向一个物理块;登记时若已存在保留旧的。驱逐时只有映射正指向这一块才删除。
L2思路与常见坑思路拆分与常见坑(中度剧透)
结构 用途
free: deque[int] 无哈希的空闲块,popleft 取、append 还
ref: list[int] 每个物理块的引用计数
block_hash: list[int|None] 每个物理块当前登记的哈希(None = 不在缓存里)
hash_to_block: dict[int,int] 命中查找
evictable: OrderedDict[int,None] ref==0 但仍有哈希的块,插入顺序 = LRU 顺序
tables / tokens / hashes / cached: dict[seq_id, ...] 每个序列的 block table、全部 token、哈希链、命中 token 数
  • allocate = 先算、再查、最后改。容量检查公式:新块数 = 总块数 − 命中数;真正可用 = num_free_blocks − 命中块中 ref==0 的个数。只写 新块数 <= num_free_blocks 时例 3 会“通过检查”,然后在取第 2 个新块时才 raise——此时命中块的 ref 已经加过了,状态被破坏。
  • 取块 helper _take_block():空闲表非空就 popleft;否则从可驱逐池弹出最旧的,删除它的哈希登记(两处),否则 raise;最后 ref 设为 1。allocate 和 append_slot 都用它。
  • append_slot 顺序:pos = len(tokens[seq]);若 pos // bs == len(table) 先取新块;再 append token;若 (pos + 1) % bs == 0 用链尾当父节点算新哈希、追加进链并登记。最后返回 slot(seq, pos)。
  • free:逆序,ref−=1;归零看 block_hash:有 → 可驱逐池最新端,无 → 空闲表。删掉该序列的四份记录。
L3详解:答案与驱逐追踪详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
  1. 3 块;满块是第 0、1 块(token 0..7),各有哈希;第 2 块只有 2 个 token,不进缓存。
  2. 只能命中 (8-1)//4 = 1 块,num_cached_tokens = 4。全命中的话没有任何 token 要 forward,拿不到最后位置的 logits,采样不出下一个 token。
  3. 不同。链式哈希把前缀编码进去,防止“块内容相同但前缀不同”的块被误命中。
  4. ref>0:只需 ref+1。ref==0:先从可驱逐池删掉再 ref=1;它原本算在 num_free_blocks 里,命中后不再可用,让“可用于新块的数量”减 1。
  5. block_hash[b] 置 None,以及 hash_to_block 中指向 b 的映射。不清理:之后同前缀请求会“命中”被覆盖的块,KV 错乱。
  6. pos = 当前 token 数;条件 pos // bs == len(table);拿块是第一步,失败时什么都还没改。
  7. 逆序时最后一块最先进入可驱逐池,成为“最旧”,最先被驱逐;越靠前的前缀块(更可能被共享)越晚被驱逐。

追踪:append 填满的块进入缓存,随后被驱逐(block_size=2, num_blocks=3):

操作 返回 空闲表 可驱逐池 (旧→新) 说明
allocate("a",[7,8,9]) [0, 1] [2] [] 块0=[7,8] 满,登记 h0;块1=[9] 不满
append_slot("a",10) 3 [2] [] pos=3 → 块1 offset 1;块1 刚满,用父哈希 h0 算 h1 并登记
free("a") [2] [1, 0] 逆序:块1 先入池(最旧)
allocate("b",[7,8,9,10,11]) [0, 1, 2] [] [] 可匹配 2 块,全中,num_cached_tokens=4
free("b") [2] [1, 0] 块2=[11] 无哈希 → 空闲表
allocate("z",[50..55]) [2, 1, 0] [] [] 先用空闲块 2,再驱逐 1、0,并删除 h1、h0 的登记
free("z"); allocate("b2",[7,8,9,10,11]) ?? num_cached_tokens = ??(h0 还在吗?)
最后一行

返回 3 个新块、num_cached_tokens = 0——h0、h1 在驱逐时已被删除。

自检:例 1~3 逐行跑一遍;再构造“同一块被两个序列共享、先 free 一个、再 free 另一个”,确认第一次 free 后 num_free_blocks 只增加了非共享的块。

面试官追问 & 对照源码

对照源码:vllm/v1/core/kv_cache_manager.py · vllm/v1/core/block_pool.py