Rust in Everything

分页注意力:块、块池与块表

主线位置:一个请求的一生里「调度 → KV 块分配」这一步,和前向里「按块表读写 KV」那一步。vLLM 的块管理在调度器进程(EngineCore)里,是 Python;读写 KV 的是 CUDA / Triton kernel。打开 Rust 前端不影响这一段。SGLang 对应的分配器也是 Python。
版本:vLLM v0.32.0、SGLang v0.5.22

结论先说:

  • vLLM 把 KV 显存切成等大的块,默认每块 16 个 token。块是分配、共享、驱逐的最小单位。
  • 所有块的元数据在一个 Python 对象 BlockPool 里:引用计数、可选的块哈希,和一条双向链表做的空闲块队列。
  • 队列头是「最先被重用」的块。没有哈希的块释放时插到队头,有哈希(可能被前缀命中)的块插到队尾,于是这条队列同时就是前缀缓存的 LRU。
  • 注意力 kernel 不知道块池的存在。它只拿到两个张量:每个请求一行的块表,和每个新 token 写到哪个槽位的 slot_mapping。

块与块大小

块大小的默认值写在 CacheConfig 上:

vllm/config/cache.py

class CacheConfig:
    DEFAULT_BLOCK_SIZE: ClassVar[int] = 16

    block_size: int = Field(default=None, gt=0)
    # ...

用户没传 --block-size 时,平台层会问一遍这次选中的注意力后端偏好多大的块,可能改成别的值(update_block_size_for_backend);用户传了就照用。所以「16」是 CacheConfig 的默认,不保证是最终值,启动日志里的 Setting kv cache block size to ... 会说明改没改。

一块有多大,由注意力层的规格算出(AttentionSpec.unpadded_page_size_bytes):

每层每块字节 = KV 头数 × block_size × (K 的 head_size + V 的 head_size) × dtype 字节数

拿一个 32 层、8 个 KV 头、head_size 128、bf16 的模型算:每层每块 8 × 16 × 256 × 2 = 64 KiB,32 层合起来一块 2 MiB,折合每个 token 128 KiB。这是算式,不是实测;能分出多少块要看权重和激活占掉多少,待实测(C3-1)。

块池:元数据都在一个 Python 对象里

每个块的元数据是一个 KVCacheBlock:

vllm/v1/core/kv_cache_utils.py

@dataclass(slots=True)
class KVCacheBlock:
    block_id: int
    ref_cnt: int = 0
    # ...
    _block_hash: BlockHashWithGroupId | None = None
    # ...
    prev_free_block: "KVCacheBlock | None" = None
    next_free_block: "KVCacheBlock | None" = None
    is_null: bool = False

BlockPool 在启动时把所有块排成一条空闲队列,然后立刻弹出 0 号块当「空块」占位,永远不分配给请求:

vllm/v1/core/block_pool.py

        self.blocks: list[KVCacheBlock] = [
            KVCacheBlock(idx, pool=self) for idx in range(num_gpu_blocks)
        ]
        self.free_block_queue = FreeKVCacheBlockQueue(self.blocks)
        self.cached_block_hash_to_block: BlockHashToBlockMap = BlockHashToBlockMap()
        # ...
        self.null_block = self.free_block_queue.popleft()
        self.null_block.is_null = True

空闲队列 FreeKVCacheBlockQueue 不用 Python 的 deque,而是直接改块上的 prev_free_block / next_free_block 两个字段,做成带假头假尾的双向链表。理由写在注释里:前缀命中时要把队列中间的某个块摘出来,deque 做不到 O(1);改字段又不分配新 Python 对象。

块在三种状态之间走:

stateDiagram-v2
  direction LR
  FreeClean: 空闲,无哈希
  InUse: 使用中,ref_cnt 大于 0
  FreeCached: 空闲,带哈希,可被命中
  [*] --> FreeClean: 启动时按 block_id 入队
  FreeClean --> InUse: get_new_blocks 从队头取
  InUse --> InUse: touch:另一个请求命中,ref_cnt+1
  InUse --> FreeClean: ref_cnt 归零且无哈希:插到队头
  InUse --> FreeCached: ref_cnt 归零且有哈希:插到队尾
  FreeCached --> InUse: touch:前缀命中,从队列中间摘出
  FreeCached --> InUse: get_new_blocks 取到它:先清掉哈希(驱逐)

分配:从队头取,取到带哈希的就是驱逐

vllm/v1/core/block_pool.py

    def get_new_blocks(self, num_blocks: int) -> list[KVCacheBlock]:
        if num_blocks > self.get_num_free_blocks():
            raise ValueError(f"Cannot get {num_blocks} free blocks from the pool")
        ret: list[KVCacheBlock] = self.free_block_queue.popleft_n(num_blocks)
        # ...
        if self.enable_caching:
            for block in ret:
                self._maybe_evict_cached_block(block)
                assert block.ref_cnt == 0
                block.ref_cnt += 1

没有单独的「驱逐」步骤。空闲队列里既有干净的块,也有还挂着哈希、等着被命中的块;分配只管从队头取。取到的块如果还有哈希,_maybe_evict_cached_block 把它从哈希表里删掉,这就是一次驱逐。

前缀命中走另一条路:touch 把命中的块 ref_cnt 加一;原来是 0 的(还在空闲队列里),先从队列中间摘掉。

调度器为一个请求要块时调 KVCacheManager.allocate_slots。它算出这一步要多少新块(cdiv(需要槽位的 token 数, block_size) 减去已有的,见 allocate_new_blocks),空闲块不够就返回 None,由调度器决定抢占谁(scheduler.py,抢占见 抢占)。够的话先把命中的块接到请求的块列表上,再取新块,最后把已经满了的块登记进前缀缓存。对全注意力层来说,块只在后面追加,不会中途换号,块表只增不改(滑动窗口层会把窗口外的块换成空块)。

释放:倒序,干净的放队头,带哈希的放队尾

请求结束时,块按倒序交回:

vllm/v1/core/single_type_kv_cache_manager.py

        # Free blocks in reverse order so that the tail blocks are freed first.
        self.block_pool.free_blocks(reversed(self.pop_blocks_for_free(request_id)))

vllm/v1/core/block_pool.py

    def free_blocks(self, ordered_blocks: Iterable[KVCacheBlock]) -> None:
        blocks_to_evict_last = []
        blocks_to_evict_first = []
        # ...
        for block in ordered_blocks:
            # ...
            block.ref_cnt -= 1
            if block.ref_cnt == 0 and not block.is_null:
                if block.block_hash is None or not self.enable_caching:
                    # LIFO reuse of non-cached blocks for better GPU locality.
                    blocks_to_evict_first.append(block)
                else:
                    # FIFO reuse of cached blocks for LRU eviction behavior.
                    blocks_to_evict_last.append(block)
        self.free_block_queue.prepend_n(blocks_to_evict_first)
        self.free_block_queue.append_n(blocks_to_evict_last)

三条规则合起来,就是 vLLM 前缀缓存的驱逐顺序:

  1. 还被别的请求用着(ref_cnt > 0)的块不进队列,不会被驱逐。
  2. 没有哈希的块(请求末尾不满的那一块)进队头,下一次分配最先拿走它,不浪费可命中的缓存。
  3. 有哈希的块进队尾。越早释放的越靠前,越先被驱逐,这就是 LRU。同一个请求的块倒序进队,链尾的块排在链头前面:前缀里越靠后的块越先走,共享的开头留得最久。

还有一点:同一个哈希可能对应好几个块。两个请求同时算同一段前缀,各自得到一份块,BlockHashToBlockMap 都记下,不去重(注释 NOTE #1)。理由是块号一旦分出去就不能变,块表才能只追加。代价是并发时可能多占几份显存。

块表和 slot_mapping:kernel 看到的全部

调度器把每个请求的块号列表发给 worker。worker 维护一张二维的块表,形状 [max_num_reqs, max_num_blocks_per_req]、int32,每个请求一行:

vllm/v1/worker/block_table.py

        self.block_table = self._make_buffer(
            self.max_num_reqs, self.max_num_blocks_per_req, dtype=torch.int32
        )
        # ...
        self.slot_mapping = self._make_buffer(
            self.max_num_batched_tokens, dtype=torch.int64
        )

每一步前向前,一个 Triton kernel 根据每个新 token 的位置查块表,算出它的 KV 写到哪个槽位:

vllm/v1/worker/block_table.py

            slot_ids = block_numbers * KV_CACHE_BLOCK_SIZE + local_block_offsets

即 槽位 = 块表[请求, 位置 // block_size] × block_size + 位置 % block_size(不开上下文并行时)。

这两个张量放进每批的 CommonAttentionMetadata,传给各个注意力后端。以 FlashAttention 后端为例,接口就两处:

这个后端里每层的缓存张量注释为 [num_blocks, num_kv_heads, block_size, 2 * head_size]:块是最外一维,K 和 V 拼在最后一维。物理上各维怎么排由 KVCacheLayout 描述,逻辑形状统一是 [层, 块, 头, token, 内容]。kernel 内部怎么分块、怎么并行,不在本卷范围。

sequenceDiagram
  participant S as 调度器(Python)
  participant P as BlockPool
  participant W as worker(Python)
  participant K as 注意力 kernel(CUDA / Triton)
  S->>P: get_computed_blocks:查前缀
  P-->>S: 命中的块
  S->>P: allocate_slots:touch 命中块,get_new_blocks
  P-->>S: 新块号,或 None(要抢占)
  S->>W: 每个请求的新块号
  W->>W: append_row 进块表,算 slot_mapping
  W->>K: block_table_tensor、slot_mapping
  K->>K: 按 slot_mapping 写新 KV,按块表读历史 KV
  S->>P: 请求结束:free,倒序入队

写时复制只剩一个角落

老版本 vLLM 的分页注意力有「写时复制」:并行采样的几个序列共享前缀块,谁要往共享块里写就先复制一份。v1 的普通路径用不上它:前缀命中只命中满块,满块不会再被写,新 token 总写在请求自己的新块里。

v0.32.0 里还留一处写时复制:打开细粒度前缀命中时(混合 Mamba 模型的 align 模式,见 partial_hash_hits_enabled),命中可以停在一个块的中间,这时把共享的尾块换成一块私有副本再往里写。普通全注意力模型、默认配置下走不到这里。

对照:SGLang 按 token 分槽位

SGLang 默认不分块。page_size 在 CUDA 上默认是 1(_page_size_default),部分注意力后端会强制改大,比如 FlashMLA 改成 64、SM100 上的 FA4 改成 128(overrides.py)。

page_size 为 1 时,分配器就是一个空闲槽位号的张量,要几个切几个:

python/sglang/srt/mem_cache/allocator/token.py

    def alloc(self, need_size: int):
        # ...
        if need_size > len(self.free_pages):
            return None
        select_index = self.free_pages[:need_size]
        self.free_pages = self.free_pages[need_size:]
        return select_index

每个请求的「块表」是 ReqToTokenPool.req_to_token 的一行,形状 [请求槽数, max_context_len]、int32,第 i 列直接是第 i 个 token 的槽位号,不用再乘 block_size。每层的 K、V 是两个独立张量,默认形状 (槽位数 + page_size, 头数, head_dim)。

vLLM v0.32.0SGLang v0.5.22
分配单位块,默认 16 个 tokentoken 槽位(page_size 默认 1)
每请求映射块表一行:块号req_to_token 一行:槽位号
写 KV 的地址slot_mapping,由块表算出分配器直接给出的槽位号
空闲结构双向链表,兼做前缀缓存的 LRU槽位号张量;前缀缓存的驱逐在树上做
尾部浪费每个请求最多 15 个 token 的槽位无(page_size 为 1 时)
前缀复用块哈希表,见 前缀缓存基数树,见 RadixAttention

小结

  • vLLM 的块默认 16 个 token,后端可以改,用户指定了就不改。
  • 块的引用计数、哈希、空闲链表都在调度器进程的一个 Python 对象里;分配从队头取,取到带哈希的块就是驱逐。
  • 释放倒序进行,干净的块插队头、带哈希的块插队尾,空闲队列因此就是前缀缓存的 LRU。同一哈希可以有多份块,不去重,换来只追加的块表。
  • 注意力 kernel 的接口只有块表和 slot_mapping 两个张量。
  • SGLang 默认按 token 分槽位,没有块内的尾部浪费,每请求一行 token 到槽位的映射。每块能放多少、能撑多少并发,待实测(C3-1)。

💬 关联讨论

发起讨论
加载中...