分页注意力:块、块池与块表
主线位置:一个请求的一生里「调度 → 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 上:
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 号块当「空块」占位,永远不分配给请求:
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 取到它:先清掉哈希(驱逐)
分配:从队头取,取到带哈希的就是驱逐
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)))
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 前缀缓存的驱逐顺序:
- 还被别的请求用着(
ref_cnt > 0)的块不进队列,不会被驱逐。 - 没有哈希的块(请求末尾不满的那一块)进队头,下一次分配最先拿走它,不浪费可命中的缓存。
- 有哈希的块进队尾。越早释放的越靠前,越先被驱逐,这就是 LRU。同一个请求的块倒序进队,链尾的块排在链头前面:前缀里越靠后的块越先走,共享的开头留得最久。
还有一点:同一个哈希可能对应好几个块。两个请求同时算同一段前缀,各自得到一份块,BlockHashToBlockMap 都记下,不去重(注释 NOTE #1)。理由是块号一旦分出去就不能变,块表才能只追加。代价是并发时可能多占几份显存。
块表和 slot_mapping:kernel 看到的全部
调度器把每个请求的块号列表发给 worker。worker 维护一张二维的块表,形状 [max_num_reqs, max_num_blocks_per_req]、int32,每个请求一行:
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 写到哪个槽位:
slot_ids = block_numbers * KV_CACHE_BLOCK_SIZE + local_block_offsets
即 槽位 = 块表[请求, 位置 // block_size] × block_size + 位置 % block_size(不开上下文并行时)。
这两个张量放进每批的 CommonAttentionMetadata,传给各个注意力后端。以 FlashAttention 后端为例,接口就两处:
- 写:
reshape_and_cache_flash(key, value, key_cache, value_cache, slot_mapping, ...),把这一步新算的 K、V 按slot_mapping散写进缓存。 - 读:注意力 kernel 收
block_table=block_table和每个请求的长度,自己按块表跳着读历史 KV。
这个后端里每层的缓存张量注释为 [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.0 | SGLang v0.5.22 | |
|---|---|---|
| 分配单位 | 块,默认 16 个 token | token 槽位(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)。