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 | |
各参数的含义:offset
是这组层的起点;layer_stride
是相邻两层之间同一块号的地址间隔;block_stride
是同一层内相邻块号的地址间隔;行宽是同一块内相邻 token
的地址间隔。地址换算为:
1 | |
layout 是什么
一层 cache 的逻辑 shape 是 5 维
[L, B, H, N, C](层、块、头、块内 token、每 token
字节)。显存是一维的,必须给这 5 个维度规定一个内存顺序,layout
就是这个顺序(vllm/v1/kv_cache_layout.py):
1 | |
有实质区别的只有两类(看第一个字母):
- 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 | |
这样也能寻址——每种 cache 一个 view,各用各的 stride——但同一个 block 的两份数据在显存里隔着几百 KB,跨层读取、按块做 KV 传输都不连续。
block-outer 下,一个 block 内的所有层挨在一起,逐 block 重复:
1 | |
每个 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 | |
两个 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。调度器算出它与层无关的位置编号:
- 逻辑块号 =
130 // 64 = 2,查 block table 得block_table[2] = 5 - 块内位置 =
130 % 64 = 2 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 | |
slot_mapping = 322 在两层里都拆出
b = 5、slot = 2,但 cache
起点、block_stride、page_stride
各用各的,于是落到不同的物理地址。
交互演示:两种 cache 混排
下面这个页面演示双 cache 的情形:切换 BLHNC / LBNHC
对比内存排布的差异,点选 token 和层可以看到 slot_mapping
的拆分和地址公式的每一项(可以在新页面打开):