vLLM KV Cache Layout 可视化

本文最后更新于 2026年10月12日

KV cache 是一整块 flat 的显存,token 的 KV 存在哪里由两层映射决定:block table 给出”逻辑块 → 物理块号”,物理块号到字节地址的换算由每一层各自完成。这篇文章希望把这个换算过程讲清楚,

术语

  • slot:一个 token 的 KV 占一个 slot。每个 slot 占的字节数称为行宽;
  • block:block_size 个连续 slot,是分配的最小单位;
  • page:一个 block 占的字节数,page = block_size × 行宽;
  • block table:每个请求一张表,记录逻辑块号到物理块号的映射。逻辑块号按 token 顺序编号(第 0~63 个 token 属于逻辑块 0,依此类推),物理块号是显存中的实际位置。两者分离是因为块按需分配、在显存中不保证连续,且不同请求可以共享前缀块。

剩下的问题只有一个:拿到物理块号 b 之后,如何算出字节地址。

核心:block ID 只是个编号

block table 只存块号,不存地址。每一组形状相同的层构成一个 view,由 KVCacheTensor(vllm/v1/kv_cache_interface.py:1421)描述:

1
2
3
4
5
6
7
@dataclass
class KVCacheTensor:
size: int # 底层分配的总字节数
layers: list[str] # 该 tensor 覆盖的层
layer_stride: int
block_stride: int
offset: int = 0 # layers[0] 的 block 0 的字节偏移

各参数的含义:offset 是这组层的起点;layer_stride 是相邻两层之间同一块号的地址间隔;block_stride 是同一层内相邻块号的地址间隔;行宽是同一块内相邻 token 的地址间隔。地址换算为:

1
地址 = offset + 层号 × layer_stride + 块号b × block_stride + 块内位置 × 行宽

layout 是什么

一层 cache 的逻辑 shape 是 5 维 [L, B, H, N, C](层、块、头、块内 token、每 token 字节)。显存是一维的,必须给这 5 个维度规定一个内存顺序,layout 就是这个顺序(vllm/v1/kv_cache_layout.py):

1
2
3
4
5
LBHNC = (0, 1, 2, 3, 4)  # layer-outer:每层占一段连续区域
LBNHC = (0, 1, 3, 2, 4)
BLHNC = (1, 0, 2, 3, 4) # block-outer:block 内打包所有层
BLNHC = (1, 0, 3, 2, 4)
BHLNC = (1, 2, 0, 3, 4)

有实质区别的只有两类(看第一个字母):

  • L 开头(layer-outer):第 0 层的所有块占一段连续区域,之后是第 1 层。同一层内相邻块紧挨着,因此 block_stride = page;相邻层之间隔着整层的所有块,因此 layer_stride = page × num_blocks;
  • B 开头(block-outer):块 0 内先排完所有层的 page,再排块 1。相邻层之间隔一个 page,layer_stride = page;同层相邻块之间隔着所有层的 page,block_stride = 一个块的总字节数。

单一 cache 时两种布局只是排布上的偏好,区别在下面这个页面里可以直接切换对比(也可以在新页面打开):

多种 cache 共存、page 大小不一时,layout 才有实质影响。

复杂一点:两种 cache 共用一张 block table

以 DeepSeek 的稀疏注意力(DSA,V3.2-Exp 引入)为例,每层注意力维护两份形状不同的 cache:

  • MLA cache:压缩后的 latent KV,每个 token 656 B(512 B FP8 latent + 16 B scale + 128 B BF16 RoPE);
  • Indexer cache:lightning indexer 为每个 token 存的索引 key,用于稀疏 top-k 选择,每个 token 132 B(128 B FP8 key + 4 B FP32 scale)。

两份 cache 与同一 token 序列逐 token 对齐,必须共用一张 block table——同一个 token 的两份数据块号相同。但 page 一大一小(block_size = 64 时 MLA 41984 B、indexer 8448 B),两种布局在显存里的排法完全不同。

layer-outer 下,每种 cache 的每层各占一段连续区域,段内是该层的所有 block:

1
2
3
4
MLA L0:  [ blk 0 | blk 1 | ... | blk N ]     (每 block 41984 B)
MLA L1: [ blk 0 | blk 1 | ... | blk N ]
IDX L0: [ blk 0 | blk 1 | ... | blk N ] (每 block 8448 B)
IDX L1: [ blk 0 | blk 1 | ... | blk N ]

这样也能寻址——每种 cache 一个 view,各用各的 stride——但同一个 block 的两份数据在显存里隔着几百 KB,跨层读取、按块做 KV 传输都不连续。

block-outer 下,一个 block 内的所有层挨在一起,逐 block 重复:

1
2
3
blk 0:  [ MLA L0 | MLA L1 | IDX L0 | IDX L1 ]
blk 1: [ MLA L0 | MLA L1 | IDX L0 | IDX L1 ]
...

每个 block 的内部结构相同,所有层对齐到同一个块号网格,block table 可以共用,按块搬运也是一段连续内存。

具体例子

假设 2 层,block_size = 64,BLHNC 布局:

  • MLA 每个 page:64 × 656 = 41984 B
  • indexer 每个 page:64 × 132 = 8448 B
  • 一个物理 block 的大小:2×41984 + 2×8448 = 100864 B

一个物理 block 内的排布:

1
2
blk b: [  MLA L0  |  MLA L1  |  IDX L0  |  IDX L1  ]
0 41984 83968 92416 100864

两个 view 的参数:

offset layer_stride block_stride 行宽
MLA 0 41984 100864 656
indexer 83968 8448 100864 132

block-outer 下所有 view 共享同一个 block_stride(一个 block 的总字节数),只是 offset 不同:每个 block 内各层的 page 依次排开,同样的结构在每个 block 里重复。换成 layer-outer 则每层占一段连续区域,block_stride 等于该层自己的 page 大小。

查一个 token:slot_mapping 是什么

假设一个请求的第 130 个 token 要写入 cache。调度器算出它与层无关的位置编号:

  1. 逻辑块号 = 130 // 64 = 2,查 block table 得 block_table[2] = 5
  2. 块内位置 = 130 % 64 = 2
  3. slot_mapping = 块号b × block_size + 块内位置 = 5 × 64 + 2 = 322

slot_mapping 不是地址,只是把”哪个物理块、块内第几个位置”合成一个整数。调度器为每个 token 算一次,MLA 和 indexer 共用同一个值;换算成字节地址是写 cache 的 kernel 在每层各自完成的。同样在第 1 层,两个 view 各自代入公式:

  • MLA L1:0 + 1×41984 + 5×100864 + 2×656
  • IDX L1:83968 + 1×8448 + 5×100864 + 2×132

同一个 slot_mapping,换算出两个不同的物理地址。

kernel 那一侧

每个 kernel 只拿到自己那一层的 view,形状大致是 [num_blocks, 1, block_size, 行宽],stride(0) = block_stride。offset 和层号的偏移已经包含在 view 的起始地址里,kernel 不知道 block 内还有其他层。拿到 slot_mapping 后先拆回块号和块内位置,再乘本层的 stride:

1
2
3
block_idx    = slot_idx / block_size;   // 拆回 b
block_offset = slot_idx % block_size; // 块内位置
dst = cache + block_idx * block_stride + block_offset * page_stride;

slot_mapping = 322 在两层里都拆出 b = 5、slot = 2,但 cache 起点、block_stride、page_stride 各用各的,于是落到不同的物理地址。

交互演示:两种 cache 混排

下面这个页面演示双 cache 的情形:切换 BLHNC / LBNHC 对比内存排布的差异,点选 token 和层可以看到 slot_mapping 的拆分和地址公式的每一项(可以在新页面打开):