vLLM 源码精读:block_pool.py —— PagedAttention 的灵魂组件

— 逐方法拆解 vLLM V1 的物理 block 管理器
源码精读 AI Infra vLLM V1 PagedAttention block_pool.py Stage 1 / Week 2
目录
  1. 1. 文件定位:block_pool.py 在整个 vLLM 中的角色
  2. 2. 全局架构图:两个类,三个核心数据结构
  3. 3. BlockHashToBlockMap:Prefix Cache 的索引
  4. 4. BlockPool.__init__:初始化时发生了什么
  5. 5. get_new_blocks:从 pool 里拿空闲 block
  6. 6. free_blocks:释放 block(但不一定真删)
  7. 7. touch:Prefix Cache 命中时的处理
  8. 8. cache_full_blocks:block 写满后注册到 hash map
  9. 9. _maybe_evict_cached_block:延迟驱逐(Lazy Eviction)
  10. 10. null_block:一个特殊的占位 block
  11. 11. 论文到代码的完整映射
  12. 12. ⭐ 高频卡点答疑(8 个 Q)
  13. 13. 参考资料

1. 文件定位:block_pool.py 在整个 vLLM 中的角色

vLLM V1 的 KV Cache 管理分三层:

┌─────────────────────────────────────────────────┐ │ kv_cache_manager.py(上层,面向请求) │ │ - 每个请求维护一张 block table │ │ - 决定什么时候分配/释放/fork/swap │ ├─────────────────────────────────────────────────┤ │ block_pool.py(本文,面向物理 block) ⬅ 今天读这个 │ - 管理全局物理 block 池 │ │ - 分配/释放/引用计数/Prefix Cache 索引 │ ├─────────────────────────────────────────────────┤ │ kv_cache_utils.py(底层,数据结构定义) │ │ - KVCacheBlock / FreeKVCacheBlockQueue 等 │ │ - block hash 计算函数 │ └─────────────────────────────────────────────────┘

block_pool.py 对应论文 §4.2 KV Cache Manager 中的 Block Allocator——管理物理 block 的分配、释放、引用计数和 Prefix Cache 索引。它不关心"哪个请求用了哪个 block"(那是上层 kv_cache_manager 的事),只关心"物理 block 池子里还有多少空闲、谁能复用"。

🔧 Kafka 类比
block_pool.py 的角色类似 Kafka 里的 LogSegment 文件管理——管理物理存储单元的分配/回收/索引,不关心上层 Partition 的逻辑映射。上层 kv_cache_manager 则类似 Kafka 的 ReplicaManager——对外暴露请求级接口,内部调用物理存储管理。

2. 全局架构图:两个类,三个核心数据结构

文件里有两个类:

类角色对应 OS 概念
BlockHashToBlockMapPrefix Cache 的索引(hash → block)文件系统的 inode cache / dentry cache
BlockPool物理 block 池管理器OS 的物理页帧分配器(buddy allocator)

三个核心数据结构:

数据结构在哪里作用
self.blocks: list[KVCacheBlock]BlockPool.__init__全局物理 block 数组,按 block_id 索引。初始化时一次性创建所有 block 对象。
self.free_block_queue: FreeKVCacheBlockQueueBlockPool.__init__空闲 block 的双向链表。分配时从左端取(popleft),释放时追加到右端(append)或左端(prepend)。
self.cached_block_hash_to_block: BlockHashToBlockMapBlockPool.__init__hash → block 的映射。block 写满后注册进来,新请求来时先查这里看能否复用。
✓ 一句话理解整个文件
BlockPool = 物理 block 数组 + 空闲链表 + hash 索引。分配从空闲链表取,释放归还空闲链表,Prefix Cache 通过 hash 索引实现。

3. BlockHashToBlockMap:Prefix Cache 的索引

数据结构

class BlockHashToBlockMap:
    self._cache: dict[
        BlockHashWithGroupId,              # key = (block_hash, group_id)
        KVCacheBlock | dict[int, KVCacheBlock]  # value = 单个 block 或多个 block
    ]

key 是什么? (block_hash, group_id) 的组合。block_hash 基于 block 内 token id 序列计算,group_id 标识 KV cache 组(多头 attention 可以分组管理)。

value 为什么是 union 类型? 大多数情况下一个 hash 只对应一个 block(直接存 KVCacheBlock)。极少数情况下同一个 hash 有多个 block(存 dict[block_id, KVCacheBlock])。代码注释说得很清楚:这么做是为了减少 GC 开销——单个 block 时不创建 dict 对象。

三个核心方法

方法做什么什么时候调用
get_one_block(key)按 hash 查找一个 block新请求到来,查 Prefix Cache 是否命中
insert(key, block)注册一个 block 到 hash mapblock 写满后缓存进来
pop(key, block_id)按 hash + block_id 从 map 中移除驱逐缓存 block 时
设计取舍
代码注释里有一个重要的 NOTE:"We currently don't de-duplicate the blocks in the cache"。意思是:同一个 hash 可能有多个 block(不做去重),因为要保证 block ID 不变(block table 是 append-only 的)。这是为了避免修改已有请求的 block table——一旦分配就不变,简化了并发管理。

4. BlockPool.__init__:初始化时发生了什么

def __init__(self, num_gpu_blocks, enable_caching, hash_block_size, ...):
    # 1. 创建全部物理 block(一次性预分配)
    self.blocks = [KVCacheBlock(idx) for idx in range(num_gpu_blocks)]

    # 2. 全部 block 放进空闲链表
    self.free_block_queue = FreeKVCacheBlockQueue(self.blocks)

    # 3. 创建空的 hash 索引
    self.cached_block_hash_to_block = BlockHashToBlockMap()

    # 4. 取一个 block 出来作为 null_block(占位用)
    self.null_block = self.free_block_queue.popleft()
    self.null_block.is_null = True

几个要点:

🔧 Infra 视角
"预分配 + 空闲链表"是经典的池化(pooling)模式——Kafka 的 ByteBuffer pool、Netty 的 PooledByteBufAllocator、内核的 slab allocator 都是这个思路。好处:分配 O(1)、无碎片、无 GC 压力。

5. get_new_blocks:从 pool 里拿空闲 block

def get_new_blocks(self, num_blocks: int) -> list[KVCacheBlock]:
    # 检查空闲数量
    if num_blocks > self.get_num_free_blocks():
        raise ValueError(...)

    # 从空闲链表左端取 n 个
    ret = self.free_block_queue.popleft_n(num_blocks)

    for block in ret:
        # 如果开启了缓存,取出的 block 可能还带着旧 hash → 驱逐它
        if self.enable_caching:
            self._maybe_evict_cached_block(block)
        assert block.ref_cnt == 0
        block.ref_cnt += 1   # 标记为"被使用"
    return ret

核心逻辑:

  1. 从空闲链表的左端取 block(FIFO 顺序——最早释放的最先被重新分配)
  2. 如果开了 Prefix Cache,取出来的 block 可能还在 hash map 里(lazy eviction 的结果)——此时要先把它从 hash map 清除
  3. ref_cnt 从 0 变为 1
⚠ 关键设计:Lazy Eviction 的代价在这里付
释放 block 时不急着从 hash map 删除(期望未来还能命中)。但当 block 被重新分配给新请求时,必须把旧 hash 清掉——否则新数据写进去后,旧 hash 指向的 block 内容已经变了,Prefix Cache 会返回错误结果。_maybe_evict_cached_block 就是在这里做的"延迟清理"。

6. free_blocks:释放 block(但不一定真删)

def free_blocks(self, ordered_blocks, prepend=False):
    for block in blocks_list:
        block.ref_cnt -= 1         # 引用计数减 1

    # 只有 ref_cnt 真正到 0 的才放回空闲链表
    freed_blocks = [
        block for block in blocks_list
        if block.ref_cnt == 0 and not block.is_null
    ]

    if prepend:
        self.free_block_queue.prepend_n(freed_blocks)  # 放到队列前端(优先被重用)
    else:
        self.free_block_queue.append_n(freed_blocks)   # 放到队列尾部(最后被重用)

核心逻辑:

  1. ref_cnt - 1,不是直接释放。如果还有其他序列引用(Beam Search / Parallel Sampling 共享),减完后 ref_cnt > 0,不释放。
  2. ref_cnt 到 0 才真正归还空闲链表。
  3. 不从 hash map 删除——block 的 hash 还留着(Lazy Eviction)。下次有新请求前缀匹配时还能命中。
  4. prepend 参数控制优先级:prepend=True 放到队列前端,意味着会被优先重新分配(用于被抢占请求的 block——这些 block 不太可能被复用,优先回收)。
✓ 理解 free 的核心
free 不是"销毁",是"归还"。block 对象本身永远存在(init 时就创建了,不会被 GC),只是在"被使用"和"空闲"两个状态之间切换。空闲的 block 可能还带着旧 hash 做 Prefix Cache 候选——直到被重新分配时才清除。

7. touch:Prefix Cache 命中时的处理

def touch(self, blocks: Sequence[KVCacheBlock]):
    for block in blocks:
        if block.ref_cnt == 0 and not block.is_null:
            # ref_cnt=0 说明在空闲链表里(eviction candidate)
            # 命中了 → 从空闲链表移除(不能被驱逐了)
            self.free_block_queue.remove(block)
        block.ref_cnt += 1

场景:新请求到来,其前缀的 hash 在 Prefix Cache 中命中了。命中的 block 可能 ref_cnt=0(之前的请求已结束,block 在空闲链表里等待驱逐)。此时:

  1. 把 block 从空闲链表中移除(不能被驱逐了,因为有新用户了)
  2. ref_cnt + 1
🔧 跟 OS Page Cache 的类比
这跟 Linux 的 page cache 行为完全一致:文件读完后 page 进入 inactive list(类似 free_block_queue),如果再次被访问就移到 active list(从空闲链表移除)。touch = OS 的 mark_page_accessed()。

8. cache_full_blocks:block 写满后注册到 hash map

这是 Prefix Cache 的写入路径。当一个 block 被 token 填满后,需要计算 hash 并注册到索引中。

def cache_full_blocks(self, request, blocks, num_cached_blocks,
                      num_full_blocks, block_size, kv_cache_group_id, ...):
    # 只处理新变满的 block(跳过已经缓存的)
    new_full_blocks = blocks[num_cached_blocks:num_full_blocks]

    for i, blk in enumerate(new_full_blocks):
        if blk.is_null:  # 跳过空 block(SWA 等稀疏 attention 场景)
            continue

        block_hash = new_block_hashes[i]

        # 给 block 打上 hash 标记
        block_hash_with_group_id = make_block_hash_with_group_id(
            block_hash, kv_cache_group_id)
        blk.block_hash = block_hash_with_group_id

        # 注册到 hash map
        self.cached_block_hash_to_block.insert(
            block_hash_with_group_id, blk)

核心逻辑:

  1. 只处理区间 [num_cached_blocks, num_full_blocks) 里的 block——已经 cache 过的不重复处理
  2. 跳过 null block(稀疏 attention 场景如 SWA 可能有 null block)
  3. 给 block 设置 block_hash 属性
  4. 注册到 cached_block_hash_to_block hash map
  5. 如果开了 KV Cache Events(分布式场景),还会发 BlockStored 事件通知其他节点
hash 是什么时候算的?
hash 不是在 block_pool 里算的——是在 Request 对象创建时和 append token 时就算好了(request.block_hashes)。block_pool 只是消费这些已计算好的 hash。

9. _maybe_evict_cached_block:延迟驱逐(Lazy Eviction)

def _maybe_evict_cached_block(self, block: KVCacheBlock) -> bool:
    block_hash = block.block_hash
    if block_hash is None:
        return False  # 没有 hash,不需要驱逐

    # 从 hash map 中移除
    if self.cached_block_hash_to_block.pop(
            block_hash, block.block_id) is None:
        return False  # hash map 里找不到,不需要驱逐

    block.reset_hash()  # 清除 block 上的 hash 元数据
    return True

调用时机:在 get_new_blocks 中,当一个空闲 block 被重新分配时。

完整的 Lazy Eviction 生命周期:

block 被请求 A 使用 → block 写满 → hash 注册到 hash map(cache_full_blocks) → 请求 A 结束 → block 释放回空闲链表(free_blocks),但 hash 还在 map 里 → 新请求 B 来了,前缀匹配命中 → touch,ref_cnt + 1,从空闲链表移除 (✓ Lazy Eviction 成功省下一次重算) 或者: → 没人来匹配 → block 一直在空闲链表里 → 新请求 C 需要 block → get_new_blocks 取到它 → _maybe_evict_cached_block:从 hash map 删除,清 hash → block 被分配给 C,写入全新数据 (× Lazy Eviction 没省到,但也没浪费)
✓ Lazy Eviction 的设计哲学
"释放时保留 hash,分配时才清理"——乐观策略。赌的是:释放后短时间内可能有新请求命中同一前缀。跟 OS page cache 的"释放文件描述符但保留 page cache"是同一个道理。最坏情况不会比"立即清理"更差(只多了一次 hash map pop 操作),最好情况省掉一次完整的 Prefill 重算。

10. null_block:一个特殊的占位 block

# 初始化时取出一个 block 作为 null_block
self.null_block = self.free_block_queue.popleft()
self.null_block.is_null = True

null_block 是什么? 一个"占位符"block,block_id=0。用于以下场景:

特殊规则:

11. 论文到代码的完整映射

论文概念(§4.2)代码对应说明
Physical KV blockKVCacheBlock 对象定义在 kv_cache_utils.py,有 block_id / ref_cnt / block_hash 属性
Block pool (全局物理 block 池)BlockPool.blocks: list[KVCacheBlock]init 时一次性创建
Free block listBlockPool.free_block_queue: FreeKVCacheBlockQueue双向链表,支持 O(1) 的 popleft / append / remove
Block allocateBlockPool.get_new_blocks()从空闲链表左端取
Block freeBlockPool.free_blocks()ref_cnt-1,到 0 归还空闲链表
Reference countKVCacheBlock.ref_cnttouch +1、free -1、get_new_blocks 分配时设为 1
Prefix Cache 索引BlockPool.cached_block_hash_to_block: BlockHashToBlockMaphash + group_id → block 的映射
Cache hit (Prefix Cache 命中)BlockPool.get_cached_block() + touch()查 hash map → 命中 → ref_cnt+1 + 从空闲链表移除
Cache register (注册)BlockPool.cache_full_blocks()block 写满后算 hash 注册到 map
Eviction (驱逐)BlockPool._maybe_evict_cached_block()Lazy:释放时不删 hash,重新分配时才清理
论文没提到的null_block论文没讲,代码里为稀疏 attention 加的占位符
论文没提到的kv_event_queue分布式 KV Cache 事件通知(V1 新增)

12. ⭐ 高频卡点答疑(8 个 Q)

Q1:block_pool.py 最核心的三个数据结构是什么?
① blocks(全局物理 block 数组),② free_block_queue(空闲 block 双向链表),③ cached_block_hash_to_block(hash → block 的 Prefix Cache 索引)。
Q2:ref_cnt 在哪些方法里加/减?
加:get_new_blocks(分配,0→1)、touch(cache 命中,+1)。
减:free_blocks(释放,-1,到 0 归还空闲链表)。
Q3:为什么释放 block 时不从 hash map 里删除?
Lazy Eviction——乐观地赌短时间内有新请求命中同一前缀。释放后 hash 留在 map 里,直到 block 被重新分配时才清理(_maybe_evict_cached_block)。最坏情况不比立即清理差,最好情况省下一次 Prefill 重算。
Q4:CoW 在 block_pool.py 里有体现吗?
block_pool 本身不直接做 CoW——它只提供基础能力(ref_cnt、get_new_blocks、free_blocks)。CoW 的触发判断和"复制老 block 内容到新 block"的逻辑在上层 kv_cache_manager.py 和 GPU 侧的 fused block copy kernel 里。block_pool 的 ref_cnt 机制是 CoW 能工作的前提条件。
Q5:free_block_queue 是双向链表,为什么不用简单的 list?
因为 touch 需要 O(1) 地从链表中间移除一个 block——当 Prefix Cache 命中时,命中的 block 可能在空闲链表的任意位置。Python list 的 remove 是 O(n),双向链表是 O(1)。
Q6:free_blocks 的 prepend 参数什么时候用?
被抢占(preempt)请求的 block 释放时用 prepend=True——这些 block 被放到空闲链表前端,意味着优先被重新分配。因为被抢占请求的 KV Cache 不太可能再被复用(请求已经中断了),优先回收它们比回收还有 Prefix Cache 价值的 block 更合理。
Q7:null_block 为什么需要?论文里有吗?
论文里没有。null_block 是代码实现中为稀疏 attention(如 Sliding Window Attention)和非 attention 模型(如 Mamba)加的占位符——逻辑上需要一个 block table 条目但物理上不需要存储数据的位置,用 null_block 填充。
Q8:kv_event_queue 是干什么的?
分布式 KV Cache 场景下的事件通知机制。当 block 被缓存(BlockStored)、驱逐(BlockRemoved)或全部清除(AllBlocksCleared)时,产生事件放入队列,由上层消费并通知其他节点。这是 vLLM V1 新增的分布式特性,论文里没有。

13. 参考资料


这是 Stage 1 / Week 2 的第二篇,对应 AI Infra 转型学习计划"读 vLLM 源码"任务。