深入理解 vLLM 的 KV-cache Block 机制

38 阅读8分钟

深入理解 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 数据,只管理逻辑状态。

# 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_hashblock 内容的哈希 + 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。

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

# 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 被"触摸"——增加引用计数:

# 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)

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

驱逐发生的场景

假设有两个请求:

请求 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 内容即将/已经被覆盖。保留映射会导致后续请求"假命中"到错误数据

# 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

# 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 保证):

    # 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 实际访问的物理位置。

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

关键文件索引

文件职责
vllm/v1/core/block_pool.pyBlockPool、BlockHashToBlockMap 定义
vllm/v1/core/kv_cache_utils.pyKVCacheBlock、FreeKVCacheBlockQueue、BlockHash 类型定义
vllm/v1/core/kv_cache_coordinator.pyBlockPool 创建、多 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.pyWorker 端 block_table GPU tensor 管理
vllm/v1/worker/gpu_model_runner.pyWorker 端接收 block_ids 并更新 block_table