深入理解 vLLM 的 Block 机制

深入理解 vLLM 的 Block 机制

基于 vLLM v1 架构源码分析,涵盖 BlockPool 核心数据结构、分配/释放/驱逐流程、Prefix Caching 实现,以及分布式场景下 Block ID 的统一机制。


1. 整体架构:Block 管理的层次结构

vLLM v1 的 KV cache 管理采用分层设计,BlockPool 是整个 block 生命周期的核心管理者。

flowchart TD EC[EngineCore - 单进程全局唯一] --> S[Scheduler] S --> KVM[KVCacheManager] KVM --> KVC[KVCacheCoordinator] KVC -->|创建并持有| BP[BlockPool - 全局唯一实例] KVC --> STMs[SingleTypeKVCacheManager数组] STMs -->|共享引用| BP KVM -->|快捷引用| BP

关键 :在标准部署中,BlockPool 实例全局只有一个。它在 KVCacheCoordinator.__init__ 中创建,被所有 SingleTypeKVCacheManager 共享。

代码出处:

  • vllm/v1/core/kv_cache_coordinator.py --- BlockPool 创建
  • vllm/v1/core/kv_cache_coordinator.py --- 所有 STM 共享 block_pool
  • vllm/v1/core/kv_cache_manager.py --- KVCacheManager 的快捷引用

2. 核心数据结构

2.1 KVCacheBlock --- Block 的元数据

每个 block 的元数据由 KVCacheBlock 表示,它不存储实际的 KV 数据,只管理逻辑状态。

python 复制代码
# vllm/v1/core/kv_cache_utils.py
@dataclass(slots=True)
class KVCacheBlock:
    block_id: int           # 逻辑 ID,范围 [0, num_gpu_blocks)
    ref_cnt: int = 0        # 引用计数,被多少个请求共享
    _block_hash: BlockHashWithGroupId | None = None  # 哈希键(仅满块缓存后设置)
    prev_free_block: "KVCacheBlock | None" = None    # 双向链表前驱
    next_free_block: "KVCacheBlock | None" = None    # 双向链表后继

关键属性解读:

属性 含义 何时变化
block_id 逻辑索引,从 0 开始递增 创建后不变
ref_cnt 引用计数,用于共享 block(prefix cache hit 时多个请求引用同一 block) touch() +1,free_blocks() -1
_block_hash block 内容的哈希 + group_id,用于 prefix cache 查找 cache_full_blocks() 设置,_maybe_evict_cached_block() 清除
prev/next_free_block 空闲链表指针 仅由 FreeKVCacheBlockQueue 操作

代码出处:vllm/v1/core/kv_cache_utils.py

2.2 FreeKVCacheBlockQueue --- 空闲块的双向链表

空闲块通过双向链表组织,支持 O(1) 的头部弹出和中间删除。

flowchart LR FH[fake_head id=-1] --> B0[Block 0 最久未用] B0 --> B1[Block 1] B1 --> B2[Block 2] B2 --> FT[fake_tail id=-1] FT --> B2 B2 --> B1 B1 --> B0 B0 --> FH

驱逐顺序:链表头部是 LRU(最久未使用)的 block,尾部是最近释放的 block。分配时从头部取,释放时追加到尾部。当请求释放 block 时,block 按逆序释放(尾 block 先释放),确保尾部 block 是"最有价值"的缓存。

代码出处:vllm/v1/core/kv_cache_utils.py

2.3 BlockHashToBlockMap --- Prefix Cache 哈希表

用于通过 block hash 快速查找已缓存的 block,支持 prefix caching。

python 复制代码
class BlockHashToBlockMap:
    _cache: dict[BlockHashWithGroupId, KVCacheBlock | dict[int, KVCacheBlock]]

设计要点:

  • 大多数情况下,一个 hash 只对应一个 block(直接存 KVCacheBlock
  • 当多个 block 内容相同(hash 冲突),退化为 dict[block_id, KVCacheBlock]
  • 这种 union 类型设计是为了减少 GC 开销(避免为每个 key 都创建一个 dict)

代码出处:vllm/v1/core/block_pool.py

2.4 BlockPool --- Block 管理的入口

BlockPool 整合了上述所有数据结构:

flowchart TD BP[BlockPool] --> BLOCKS[self.blocks: list of KVCacheBlock, 索引=block_id] BP --> FBQ[self.free_block_queue: FreeKVCacheBlockQueue] BP --> BHTB[self.cached_block_hash_to_block: BlockHashToBlockMap] BP --> NULL[self.null_block: block_id=0 占位块] FBQ -->|持有引用| BLOCKS

初始化过程 vllm/v1/core/block_pool.py

  1. 创建 num_gpu_blocksKVCacheBlock,block_id 从 0 递增
  2. 用所有 block 构造 FreeKVCacheBlockQueue
  3. 弹出 block_id=0 作为 null_block(占位符,ref_cnt 不维护)

3. Block 生命周期:分配、缓存、驱逐、释放

3.1 完整时序图

sequenceDiagram participant S as Scheduler participant KVM as KVCacheManager participant KVC as KVCacheCoordinator participant STM as SingleTypeKVCacheManager participant BP as BlockPool rect rgb(230, 245, 255) Note over S,BP: Phase 1: 查找 Prefix Cache Hit S->>KVM: get_computed_blocks(request) KVM->>KVC: find_longest_cache_hit(block_hashes) KVC->>STM: find_longest_cache_hit(...) STM->>BP: get_cached_block(hash, group_ids) BP-->>STM: cached_blocks 或 None STM->>BP: touch(cached_blocks) Note over BP: ref_cnt += 1<br/>若 ref_cnt 从 0 变 1<br/>则从 free_queue 移除 STM-->>KVC: hit_blocks KVC-->>KVM: (computed_blocks, num_tokens) KVM-->>S: (KVCacheBlocks, num_computed_tokens) end rect rgb(255, 245, 230) Note over S,BP: Phase 2: 分配新 Block S->>KVM: allocate_slots(request, num_new_tokens, ...) KVM->>KVC: allocate_new_blocks(req_id, num_tokens, ...) KVC->>STM: allocate_new_blocks(req_id, num_tokens, ...) STM->>BP: get_new_blocks(num_new_blocks) Note over BP: 从 free_queue 头部取出<br/>若启用 caching 则先驱逐旧缓存<br/>ref_cnt = 1 BP-->>STM: new_blocks [KVCacheBlock] STM-->>KVC: new_blocks KVC-->>KVM: new_blocks end rect rgb(230, 255, 230) Note over S,BP: Phase 3: 缓存满块(Prefix Caching) KVM->>KVC: cache_blocks(request, num_tokens) KVC->>STM: cache_blocks(request, num_tokens) STM->>BP: cache_full_blocks(request, blocks, ...) Note over BP: 计算 block_hash<br/>写入 cached_block_hash_to_block end rect rgb(255, 230, 230) Note over S,BP: Phase 4: 请求完成,释放 Block S->>KVM: free(request) KVM->>KVC: free(request_id) KVC->>STM: free(request_id) STM->>BP: free_blocks(ordered_blocks) Note over BP: ref_cnt -= 1<br/>ref_cnt == 0 则归还 free_queue 尾部 end

3.2 分配:get_new_blocks

python 复制代码
# block_pool.py
def get_new_blocks(self, num_blocks: int) -> list[KVCacheBlock]:
    ret = self.free_block_queue.popleft_n(num_blocks)
    # In order to only iterate the list once, we duplicated code a bit
    if self.enable_caching:
        for block in ret:
            self._maybe_evict_cached_block(block)
            assert block.ref_cnt == 0
            block.ref_cnt += 1
    else:
        for block in ret:
            assert block.ref_cnt == 0
            block.ref_cnt += 1
    return ret

分配逻辑:

  1. 从空闲链表头部弹出 N 个 block(LRU 优先分配)
  2. 若启用 prefix caching,检查 block 是否有缓存哈希,有则驱逐
  3. 设置 ref_cnt = 1

3.3 缓存命中:touch

当 prefix cache 命中时,已有 block 被"触摸"------增加引用计数:

python 复制代码
# block_pool.py
def touch(self, blocks: Sequence[KVCacheBlock]) -> None:
    for block in blocks:
        # ref_cnt=0 means this block is in the free list (i.e. eviction
        # candidate), so remove it.
        if block.ref_cnt == 0 and not block.is_null:
            self.free_block_queue.remove(block)  # 从空闲链表移除
        block.ref_cnt += 1

ref_cnt == 0 意味着 block 在空闲链表中(是驱逐候选),需要先移除。

3.4 驱逐:evict_blocks

驱逐的本质:数据即将失效

当空闲 block 不足时,需要驱逐 block 给新的请求使用 关键在于理解 block 在空闲链表中的状态。一个 block 在空闲链表中可能还有 hash,这意味着:

  • 它的 KV cache 数据仍然在 GPU 显存中

  • 没有任何请求正在使用它(ref_cnt == 0)

  • 它是一个驱逐候选------如果新请求有相同前缀可以命中,如果显存紧张则被回收

驱逐发生的场景

假设有两个请求:

bash 复制代码
请求 A: "The cat sat on the mat" → Block 3 (hash=0xAB)
请求 A 完成,Block 3 被释放 → ref_cnt=0,进入空闲链表,但 hash=0xAB 仍在哈希表中

此时 Block 3 的 GPU KV cache 数据仍然存在,是 "The cat sat on the mat" 的 KV。

--- 场景 1:不驱逐(正确情况)---

请求 B: "The cat sat on the roof" → hash=0xAB 命中 Block 3
→ 前缀 "The cat sat on the" 复用 Block 3 的 KV cache ✅
→ 只需计算 " roof" 部分

--- 场景 2:显存不足,需要驱逐 ---

请求 C: "Completely different text" → 需要新 block
→ 从空闲链表取出 Block 3
→ Block 3 的 KV cache 将被 "Completely different text" 的 KV 覆盖
→ 必须从哈希表移除 hash=0xAB → Block 3 的映射
→ 否则后续请求 D: "The cat sat on the..." 会命中 Block 3
→ 但 Block 3 的内容已经是 "Completely different text" 的 KV ❌
sequenceDiagram participant BP as BlockPool participant FQ as FreeKVCacheBlockQueue<br/>(空闲链表) participant HM as BlockHashToBlockMap<br/>(哈希表) participant GPU as GPU KV Cache Tensor Note over BP,GPU: 初始状态:Block 3 在空闲链表中<br/>hash=0xAB,KV数据仍在GPU上 rect rgb(255, 230, 230) Note over BP,GPU: 场景:显存不足,需要分配新 block BP->>FQ: popleft_n(1) → [Block 3] BP->>BP: _maybe_evict_cached_block(Block 3) BP->>HM: pop(hash=0xAB, block_id=3) Note over HM: 从哈希表中移除<br/>后续请求无法再通过 hash 命中 BP->>BP: Block 3.reset_hash() Note over BP,GPU: Block 3 的 KV 数据将被新请求覆盖<br/>旧数据失效,必须移除映射 end rect rgb(230, 255, 230) Note over BP,GPU: 对比:如果保留哈希映射会怎样? Note over GPU: 新请求写入 Block 3 的 KV 数据<br/>覆盖了旧内容 Note over HM: hash=0xAB 仍指向 Block 3 Note over BP,GPU: ❌ 后续请求通过 hash=0xAB 命中<br/>读到的却是新请求的数据<br/>结果完全错误! end

驱逐 = 从哈希表中移除映射,因为 block 的 KV cache 内容即将/已经被覆盖。保留映射会导致后续请求"假命中"到错误数据

python 复制代码
# block_pool.py

# 由 KV Connector 调用,当 Worker 报告某些 block 的 KV 数据已失效(如分布式 KV 传输中远端数据过期),需要主动从哈希表中移除,防止后续请求命中到过期数据
def evict_blocks(self, block_ids: set[int]) -> None:
    for block_id in block_ids:
        block = self.blocks[block_id]
        self._maybe_evict_cached_block(block)

# 分配新 block 时,如果取出的空闲 block 还有缓存哈希,必须驱逐 ------ 因为该 block 即将被新请求的 KV 数据覆盖。
def get_new_blocks(self, num_blocks: int) -> list[KVCacheBlock]:
    # In order to only iterate the list once, we duplicated code a bit
    if self.enable_caching:
        for block in ret:
            self._maybe_evict_cached_block(block)
            assert block.ref_cnt == 0
            block.ref_cnt += 1
    else:
        for block in ret:
            assert block.ref_cnt == 0
            block.ref_cnt += 1
    return ret

def _maybe_evict_cached_block(self, block: KVCacheBlock) -> bool:
    block_hash = block.block_hash
    if block_hash is None:
        return False  # 无哈希,无需驱逐
    if self.cached_block_hash_to_block.pop(block_hash, block.block_id) is None:
        return False  # 哈希表中找不到
    block.reset_hash()  # 清除哈希
    return True

3.5 释放:free_blocks

python 复制代码
# block_pool.py
def free_blocks(self, ordered_blocks, prepend=False) -> None:
    for block in blocks_list:
        block.ref_cnt -= 1
    freed_blocks = [b for b in blocks_list if b.ref_cnt == 0 and not b.is_null]
    if prepend:
        self.free_block_queue.prepend_n(freed_blocks)  # 优先复用
    else:
        self.free_block_queue.append_n(freed_blocks)    # 追加到尾部

释放逻辑:

  1. 减少引用计数
  2. 只有 ref_cnt 降为 0 的 block 才真正归还空闲链表
  3. 共享 block(prefix cache hit)在所有引用者释放后才归还

4. Prefix Caching 机制

Prefix Caching 是 vLLM 的核心优化:当不同请求共享相同前缀 token 时,可以复用已计算的 KV cache block,避免重复计算。

4.1 工作原理

flowchart LR A1[Request A: The cat sat] -->|hash=0xAB| BH[BlockHashToBlockMap] B1[Request B: The cat sat] -->|hash=0xAB| BH BH -->|hit| BL0[Block 3 ref_cnt=2 A和B共享] A2[Request A: on the mat] -->|hash=0xCD| BL1[Block 7] B2[Request B: by the door] -->|hash=0xEF| BL2[Block 9]

4.2 Block Hash 的计算

Block hash 由 Request 对象在创建时和追加新 token 时计算:

  • BlockHash = NewType("BlockHash", bytes),本质是 bytes 类型
  • BlockHashWithGroupId = BlockHash + KV cache group ID 的组合,用于区分不同 group 中相同内容的 block

代码出处:vllm/v1/core/kv_cache_utils.py

4.3 缓存查找流程

  1. Scheduler 调用 KVCacheManager.get_computed_blocks(request)
  2. 遍历 request 的 block_hashes,在 BlockHashToBlockMap 中逐块查找
  3. 找到匹配 block 后调用 touch() 增加引用计数
  4. 返回所有命中 block 及其对应的 token 数

5. 分布式场景:Block ID 的统一机制

5.1 架构总览

flowchart TD subgraph EC[EngineCore 进程 - 单实例] SCH[Scheduler] --> KVM[KVCacheManager] --> BP2[BlockPool block_id: 0到N-1] end subgraph SO[SchedulerOutput 广播] NRD[NewRequestData block_ids: 5,7,12] CRD[CachedRequestData new_block_ids: 8] end SCH -->|生成| SO subgraph W0[GPU Worker 0 - TP rank 0] MR0[GPUModelRunner] --> BT0[BlockTables GPU] --> KV0[KV Cache Tensor] end subgraph W1[GPU Worker 1 - TP rank 1] MR1[GPUModelRunner] --> BT1[BlockTables GPU] --> KV1[KV Cache Tensor] end SO -->|相同 block_ids| MR0 SO -->|相同 block_ids| MR1

5.2 为什么不同卡的 Block ID 天然一致?

核心原因:Block ID 是逻辑索引,不是物理地址。

  1. 所有 Worker 的 num_blocks 相同(有 assert 保证):

    python 复制代码
    # kv_cache_utils.py
    assert all(
        [cfg.num_blocks == kv_cache_configs[0].num_blocks for cfg in kv_cache_configs]
    )

    代码出处:vllm/v1/core/kv_cache_utils.py

  2. Block ID 直接作为 KV cache tensor 的第一维下标 :Worker 端的 KV cache tensor 形状为 [num_blocks, 2, block_size, num_kv_heads, head_size],block_id=5 直接索引第 5 行。

  3. Tensor Parallelism 下,同一 block_id 在不同卡存的是不同 head 分片:各卡独立计算自己负责的 KV head,最后通过 all-reduce 聚合结果。

5.3 Block ID 从 Scheduler 到 Worker 的完整数据流

flowchart LR subgraph SchedulerSide[Scheduler 侧] A1[BlockPool 分配 KVCacheBlock block_id=5] --> A2[KVCacheBlocks get_block_ids 返回 5,7,12] A2 --> A3[NewRequestData block_ids=5,7,12] A3 --> A4[SchedulerOutput] end subgraph WorkerSide[Worker 侧] B1[GPUModelRunner 接收 SchedulerOutput] --> B2[req_state.block_ids extend 5,7,12] B2 --> B3[block_table.append_row 写入 GPU tensor] B3 --> B4[BlockTables GPU tensor req_idx = 5,7,12] B4 --> B5[Attention Kernel slot = block_table * bs + offset] B5 --> B6[KV Cache Tensor kv_cache slot 读写] end A4 -->|IPC / ZMQ| B1

关键代码文件:

  1. Scheduler 生成 block_ids:vllm/v1/core/sched/scheduler.py
  2. Worker 接收并更新:vllm/v1/worker/gpu_model_runner.py
  3. 写入 BlockTables:vllm/v1/worker/gpu_model_runner.py
  4. Attention kernel 查表:vllm/v1/worker/block_table.py

6. Block 与 Slot 的关系

Block 是 KV cache 管理的逻辑单位,Slot 是 attention kernel 实际访问的物理位置。

ini 复制代码
Slot 计算:
  block_index = position // block_size
  block_number = block_table[request_index][block_index]
  slot = block_number * block_size + (position % block_size)
flowchart LR subgraph BT[BlockTable GPU] R0[req 0: 3, 7, 12] R1[req 1: 5, 9] end subgraph KV[KV Cache Tensor GPU] B3[Block 3: token_0 到 token_bs] B5[Block 5: token_0 到 token_bs] B7[Block 7: token_0 到 token_bs] end R0 -->|block_index=0 到 block_id=3| B3 R0 -->|block_index=1 到 block_id=7| B7 R1 -->|block_index=0 到 block_id=5| B5

关键代码文件:vllm/v1/worker/block_table.py


7. 特殊场景

7.1 Null Block

BlockPool 初始化时,block_id=0 被弹出作为 null_block。它是一个占位符,用于:

  • 滑动窗口注意力中被跳过的 block 位置
  • Mamba 模型中 align 模式下的填充

null_block 的 ref_cnt 不被维护,释放时需要特殊跳过(not block.is_null)。

7.2 混合模型(Hybrid KV Cache Coordinator)

当模型同时包含 Full Attention 和 Sliding Window Attention 层时,使用 HybridKVCacheCoordinator。此时:

  • 所有 KV cache group 共享同一个 BlockPool
  • 不同 group 的 block_size 可能不同,但 hash_block_size 是统一的
  • BlockHashListWithBlockSize 负责将 hash_block_size 粒度的哈希转换为实际 block_size 粒度

7.3 Preemption 与 Block 恢复

当 GPU 显存不足时,Scheduler 会抢占(preempt)低优先级请求:

  1. 调用 KVCacheManager.free(request) 释放该请求的所有 block
  2. 被释放的 block 归还空闲链表,可被高优先级请求使用
  3. 被抢占的请求后续重新调度时,需要重新分配 block 并重算 KV cache

8. 总结

概念 说明
BlockPool 全局唯一,管理所有 GPU block 的分配、释放和缓存
KVCacheBlock Block 的元数据(逻辑 ID、引用计数、哈希、链表指针),不存实际数据
FreeKVCacheBlockQueue 空闲块的双向链表,LRU 驱逐顺序
BlockHashToBlockMap Prefix cache 的哈希表,hash → block 映射
Block ID 逻辑索引 [0, N),直接作为 Worker 端 KV cache tensor 的下标
Slot Attention kernel 的物理访问位置 = block_id * block_size + offset
分布式统一 所有 Worker 的 num_blocks 相同,block_id 天然一致,无需额外协调
TP 下的 block 同一 block_id 在不同卡存不同 head 分片,独立计算后 all-reduce

关键文件索引

文件 职责
vllm/v1/core/block_pool.py BlockPool、BlockHashToBlockMap 定义
vllm/v1/core/kv_cache_utils.py KVCacheBlock、FreeKVCacheBlockQueue、BlockHash 类型定义
vllm/v1/core/kv_cache_coordinator.py BlockPool 创建、多 group 协调
vllm/v1/core/kv_cache_manager.py 对外接口层,组合 coordinator
vllm/v1/core/single_type_kv_cache_manager.py 单类型 KV cache 的分配/释放/缓存逻辑
vllm/v1/core/sched/scheduler.py 调度入口,持有 KVCacheManager
vllm/v1/worker/block_table.py Worker 端 block_table GPU tensor 管理
vllm/v1/worker/gpu_model_runner.py Worker 端接收 block_ids 并更新 block_table
相关推荐
一切皆是因缘际会1 小时前
频域特征解构底层机理与双域融合鉴伪算法优化
人工智能·算法·ai·架构
codeking1 小时前
3 步把 AI 桌面自动化从失控拉回可用
javascript·架构
zyk_computer1 小时前
AI Agent ,让循环收敛的那套闭环控制系统
人工智能·后端·python·ai·架构·agent·ai agent
逐光老顽童2 小时前
用 Jetpack Compose + MVI 开发了一个 Authenticator 双因素认证应用
架构·kotlin
故渊at2 小时前
第十三板块:Android 综合架构与未来演进 | 第三十二篇:Android 内存管理与 LMK 机制的深度剖析
android·架构·内存管理·内存回收·lmk机制·收割算法
某林2123 小时前
ROS2 并行编译死锁与 Linux 后台声卡/提权踩坑实录:大型轮足机器人架构复盘
linux·架构·机器人·iassc
AI科技星3 小时前
第四卷:橡皮泥江湖(拓扑学)――诸同奥义,九同立境贯拓扑
网络·人工智能·线性代数·架构·概率论·学习方法·拓扑学
DianSan_ERP3 小时前
架构师视角:电商大促高并发下的订单API限流与防漏单架构演进
java·运维·网络·安全·微服务·架构·自动化