正在获取所在地区…
正在读取天气
音乐
暂未播放
KV Cache 的页粒度:128 token 一块的账,以及五个推理框架为什么选了五个不同答案
一个被反复看到、却很少被追问的数字
读推理框架源码时,几乎每个引擎里都会遇到一个「block size」或「page size」,而且数值惊人地分散:
| 框架 | 默认 KV 页大小 | 源码位置与备注 |
|---|---|---|
| vLLM | 16(CUDA / ROCm),取值集合 Literal[1, 8, 16, 32, 64, 128] | vllm/config.py 的 CacheConfig.block_size;平台层兜底,见 platforms/cuda.py |
| SGLang | 1(并会按注意力后端自动改写为 16/32/64/128) | arg_groups/overrides.py 的 _page_size_default 与 _mla_backend_page_constraints |
| TensorRT-LLM | 常见 32 或 64(可配置 16–128) | KVCacheManager 构造参数 tokensPerBlock |
| llama.cpp | 不分页,统一 KV 缓冲按 cell 与 seq 切分 | llama-kv-cache.cpp 中的 k / v 张量与 cells |
| DeepInfer | 固定 128,且不是调优旋钮 | ModelMetadata.resolved_tokens_per_block 恒返回 128 |
如果只是「各家口味不同」,这张表没什么价值。但把这几个数字放回各自的引擎里,会发现它们不是同一种选择:
- vLLM 的 16 是一个在「块表长度、内核循环次数、内部碎片」之间调出来的折中,而且它还在模型级做了特化:遇到 MLA 模型并使用 FlashMLA 后端时,平台层会强制把块大小改成 64,并在日志里写「Forcing kv cache block size to 64 for FlashMLA backend」。
- SGLang 的 1 是把「可复用粒度」和「物理分配粒度」拆开之后的产物,页大小不再是一个静态常量,而是由后端约束反向推导出来的。
- TensorRT-LLM 的 64 来自两个硬约束:块内前缀哈希的「整块才可复用」,以及
blockSize = numKvHeads * sizePerHead * tokensPerBlock这个内核基底地址的乘法。 - llama.cpp 干脆放弃分页,用 seq 与 cell 的另一套机制解决同一个问题。
- DeepInfer 的 128 则是被模型侧的压缩 KV 布局和内核访存宽度钉死的,设计者明确把它定义为「模型布局的函数」。
这一篇就回答一个问题:给 KV 缓存切块时,块大小到底在跟哪些东西做交易? 先把交易的四本账(地址记账、内部碎片、前缀命中、内核访存)拆开推导,再逐框架对账,最后给出一个可以直接手算的估算公式和一组工程结论。
源码基线:vLLM 0.9 系列的 CacheConfig 与 platforms/cuda.py;SGLang 0.5.22 的 arg_groups/overrides.py 与 mem_cache/memory_pool.py;TensorRT-LLM 的 blockKey.h 与 kvCacheManager.h/.cpp;llama.cpp 的 src/llama-kv-cache.cpp;DeepInfer 主线的 model_metadata_utils.py、config.py 与 csrc/engine/scheduler.hpp。
第一本账:块表是一次乘法放大
分页的核心动作是「逻辑位置 → 物理块号 + 块内偏移」:
slot=block_table[⌊Bi⌋]×B+(imodB)其中 i 是这条序列内的逻辑 token 位置,B 是每块 token 数,block_table 是这条序列的物理块号列表。块表长度为 ⌈L/B⌉,L 是序列长度。
块表本身也要显存,而且它被读的次数远多于它本身的字节数——每一次注意力内核访问都要查一次表。对 K 条序列、总长度 N=∑Li 的情形,块表里的整数个数是:
Ntable=i=1∑K⌈BLi⌉≈BN+K这个式子有两个分量,第二个经常被忽略:
- N/B 是「平均摊到每个 token 上」的部分,块越大越少;
- K 是「每条序列至少占一个块号」的部分——即使整条序列只有一个 token,它也要占一个块号。
vLLM 与 SGLang 的 paged 模式里,这个结构就是 block_tables,形状是 [max_num_seqs, max_num_blocks_per_seq],并且每一行都按最长序列的块数补齐(为了拼成规整张量交给内核)。补齐带来的额外项是:
B 越大,每行需要的列数越少,padding 也越少——这是块表账里唯一对大块有利的一面。
再看内核侧。经典 PagedAttention 解码内核以「块」为单位循环:
1// 伪代码:vLLM 经典 paged_attention 的内核主干2for (int block_idx = 0; block_idx < num_blocks; ++block_idx) {3 int physical_block = block_table[block_idx];4 // 取第 physical_block 块内 start_token .. end_token 的 K5 // 与 query 做点积,更新 running max / running sum / 累加输出6 ...7}num_blocks = ceil(seq_len / block_size)。循环体的开销除了计算,还有「查表 + 计算块基址 + 装载 key 到寄存器」的固定成本。块越小,循环次数越多,固定成本占比越高;块越大,每次循环处理更多 token,寄存器占用与展开压力上升。这也是 vLLM Attention Backend 层要针对块大小做特化的原因——可参考 vLLM 源码完全拆解(六):Attention Backend。
第一本账的结论可以直接写出来:
- 块表项数与块大小近似成反比,但永远有一个 K 的下界;
- 块表 padding 项由最长序列与并发数共同决定,块越大越省;
- 块越小,块表越长、内核固定循环开销越大。
第二本账:内部碎片是页内的「半截」
一块有 B 个槽位。除最后一块外,块内是满的;但每次序列长度增长,都会在最后一块里留下从 0 到 B−1 个空位,均匀分布时期望空位是 (B−1)/2。所以,每条存活序列平均贡献 (B−1)/2 个空槽位。
注意这个量与序列长度无关,只与块大小有关。全部序列的内部碎片是:
Winner=K⋅2B−1把它换算成字节,还要乘上单 token 的 KV 字节数 c。取一组典型 decode 混合负载:K=128 条并发请求、平均长度 2048 token,并以稠密 GQA(8 个 KV 头 × 128 维 × FP16)的 c=4096 B/token 计算:
| 块大小 B | 每条序列平均浪费 | 总浪费(MB) |
|---|---|---|
| 1 | 0 | 0 |
| 8 | 3.5 | 1.84 |
| 16 | 7.5 | 3.93 |
| 32 | 15.5 | 8.13 |
| 64 | 31.5 | 16.52 |
| 128 | 63.5 | 33.29 |
再取一组短请求负载:K=256、平均长度 64 token:
| 块大小 B | 每条序列平均浪费 | 总浪费(MB) |
|---|---|---|
| 1 | 0 | 0 |
| 8 | 3.5 | 3.67 |
| 16 | 7.5 | 7.86 |
| 32 | 15.5 | 16.25 |
| 64 | 31.5 | 33.03 |
| 128 | 63.5 | 66.58 |
两组数放在一起,关键不在绝对值,而在这一项只随 K 放大、不随 N 摊薄。它每多一条并发,就多付出 (B−1)/2 个 token 的空位。这也是为什么 vLLM 论文里说「块太大时内部碎片增加、共享概率下降」。
还有一层更隐蔽的浪费:已分配的块无法被其他序列借走。哪怕某条序列最后一块只用了 3 个槽位,剩下的 B−3 个槽位在这条序列存活期间也不能借给别人,这构成了「外部碎片」,在并发高、长度参差时尤其明显。
第二本账的结论:内部碎片与块大小近似线性,且按序列数而非 token 数放大;短请求密集型负载对块大小最敏感。
第三本账:前缀命中是随 B 起伏的锯齿
分页 KV 之所以值得多花这些碎片,是因为它带来了前缀复用。但这个复用的粒度恰好是 B:两条请求共享一段长度为 P 的公共前缀,可复用的 token 数是:
Preuse=⌊BP⌋B共享前缀越长,B 的影响越小;共享前缀越短,B 的影响越大。
最常见的共享前缀是系统提示词。假设 system prompt 长度 P=1200 token:
| B | 可复用 token | 浪费 token | 复用率 |
|---|---|---|---|
| 16 | 1200 | 0 | 100% |
| 32 | 1184 | 16 | 98.7% |
| 64 | 1152 | 48 | 96.0% |
| 128 | 1152 | 48 | 96.0% |
1200 是刻意选的「对齐得好」的情况。换一组更常见的分布:设前缀长度在 [64,256] 区间均匀分布,则平均复用率为:
| B | 平均复用率(P∈[64,256]) |
|---|---|
| 1 | 100% |
| 8 | 97.5% |
| 16 | 94.7% |
| 32 | 89.3% |
| 64 | 79.3% |
| 128 | 46.6% |
B=128 时,短前缀的复用平均要打掉一半多。对一个「每条请求都带几百 token 系统提示」的服务,这等价于 prefix caching 的整体命中率下降一半。
这还只是块粒度带来的损失。工程上还有两个叠加因素:
其一,前缀缓存只缓存「完整块」。 vLLM 的链式块哈希只对完整块建键,最后一块不满时不入缓存;TensorRT-LLM 的 analyzePrefixReuse 默认路径里 enablePartialReuse=false、copyOnPartialReuse=false,chopVectorIntoBlocks 也只按整块切键,所以只有完整块参与复用。BlockKey 本身倒是支持部分匹配(numMatchingTokens 返回两个键的最长公共前缀),但含多模态 extraKeys 时 supportsPartialMatching() 返回 false,像素哈希不能截断。SGLang 的 RadixCache 同样只对已经入树的完整 token 路径做复用,块边界之外的部分要重算。
其二,多轮对话的前缀是「每轮增长式」的。 第 n 轮的历史每轮可能只多几十 token。如果 B=64,这几十 token 常常凑不成一个完整块,命中就一直停在上一轮的块边界上,锯齿会更明显。
第三本账的结论:前缀命中率随 B 增长呈锯齿下降,拐点出现在共享前缀长度 ≲2B 附近;共享前缀越短,块越小越划算。 此外还有一个反方向的结论:块大小不一致就无法复用——P/D 分离两侧、Prefill 与 Decode 两套实例只要页格式不同,KV 就必须重排。TensorRT-LLM 在分离开的 tokens_per_block 不一致时会打警告,SGLang 则把「按后端自动选页大小」放在启动参数解析的后处理阶段,都是在守这条边界。
第四本账:内核访存与地址上限
前三本账是控制面(调度、显存账本)的事,第四本账是数据面(GPU 内核)的事,而且它是唯一会给出硬约束的那本。
先看一个具体的数字。以 GQA 的 128 维头、8 个 KV 头、FP16 存储为例,一条 token 记录是:
8×128×2 字节(K)+8×128×2 字节(V)=4096 字节一块 B 个 token 的记录字节数是 B×4096:
| B | 单块字节数 |
|---|---|
| 1 | 4 KiB |
| 8 | 32 KiB |
| 16 | 64 KiB |
| 32 | 128 KiB |
| 64 | 256 KiB |
| 128 | 512 KiB |
内核层面,分块访问 KV 与分块计算注意力是同一件事的两面:FlashAttention 把「对 K 的分块遍历」称为 K 方向的 tile,把 query 方向称为 Q 方向的 tile。KV 的页大小直接决定 load 阶段能搬多宽的一段连续数据:
- 页小、内核 tile 大(例如 128 token 的 tile 配 B=16 的页),一次 tile 需要拼 8 个物理块,地址不连续,要靠
block_table逐块查、逐块搬,寄存器里的循环次数增加; - 页大、内核 tile 小(例如 B=128、tile 64),一个 tile 落在页内,访问连续,但页尾有浪费。
DeepSeek 的 FlashMLA 系内核是一个极端例子:它的 KV_LEN 方向 tile 是 64,页大小也要求 64。这不是建议,而是内核实现里写死的:页大小与 tile 不同时,内核需要额外的 gather 路径,而 FlashMLA 没做。SGLang 的 _mla_backend_page_constraints 里因此有一行直白的日志:「FlashMLA only supports a page_size of 64, change page_size to 64」。vLLM 在 CUDA 平台层也对 MLA 模型做了同样的强制:
1# vllm/platforms/cuda.py(节选)2if cache_config and cache_config.block_size is None:3 cache_config.block_size = 164
5if model_config is not None and model_config.use_mla:6 use_flashmla = (envs.VLLM_ATTENTION_BACKEND is None7 or envs.VLLM_ATTENTION_BACKEND == "FLASHMLA")8 if use_flashmla and is_flashmla_supported()[0] \9 and cache_config.block_size != 64:10 cache_config.block_size = 64再往上一层,是 CUDA 内核的地址参数宽度。分页 KV 内核的常见输入里有一个 max_num_blocks_per_seq,类型常是 int32。块表过长时,某些路径会在块索引计算上溢出或被钳制。更本质的限制是 32 位块号 × 块内偏移:当显存为 HBM3(H100/H200 系列)时,显存总量可到 192 GB 甚至 288 GB,而 32 位字节偏移只能覆盖 4 GiB。解决方案只有两类:用 int64 偏移(内核代价),或者把每块做得够大,让「块号 × 块字节」的中间量不溢出。TensorRT-LLM 在 kvCacheManager.h 里同时维护两个量:
1SizeType32 tokensPerBlock;2SizeType32 blockSize; // = numKvHeads * sizePerHead * tokensPerBlockblockSize 就是「单块内一条 token 记录 × 块内 token 数」,是内核直接用来算基址的量。块越大,偏移粒度越粗,int32 块索引能覆盖的显存范围越大。
最后是对齐约束。DeepInfer 的 ModelMetadata 里有两个相关属性:
1@property2def resolved_tokens_per_block(self) -> int:3 return 1284
5@property6def kvcache_align_to(self) -> int:7 """MXFP8 / sparse FP4 cache requires 128-byte block alignment."""8 return 128第一个恒为 128;第二个是 128 字节的块基址对齐。它们共同决定了一个「块 = 128 token」的布局:块内每条压缩 KV 记录的长度由模型配置决定,乘 128 之后的字节数恰好满足 128 字节对齐,同时让内核的 block_size * token_idx 这一乘法不会越界。这个 128 不是挑出来的,是被模型侧压缩 KV 的记录长度与内核寻址方式共同反推出来的。
第四本账的结论:内核的 tile 宽度、页格式一致性、地址宽度与对齐,都会给块大小上硬约束;这些约束往往比前三本账的收益更强势。
分页本身长什么样:三张官方示意图
在逐框架对账前,先把分页的数据结构摆出来。PagedAttention 论文(UC Berkeley,2023 年发表于 SOSP 2023)给出了最清晰的图示。
第一张是逻辑块与物理块表的翻译过程:序列的逻辑块按顺序排列,块表把每个逻辑块号映射到物理块号,物理块在显存里可以任意分散。

(图源:PagedAttention 图 5:Block table translation in vLLM)
第二张是两条请求同时驻留时的物理块分布:每条请求自己的块表指向各自的物理块,物理块在池里交错分布,块号的连续性只存在于块表里,不存在于显存里。

(图源:PagedAttention 图 6:Storing the KV cache of two requests at the same time)
第三张是共享前缀的例子:服务提供方为一组预定义共享前缀预留物理块,带同样前缀的新请求只需把自己的逻辑前缀块映射到这些物理块上,最后一个块标为 copy-on-write,prefill 只算用户自己的那一段。

(图源:PagedAttention 图 9:Shared prompt example)
这三张图合起来说明一个事实:块表是「逻辑连续」与「物理分散」之间唯一的桥,而桥的粒度(B)同时决定了桥的宽度(块表长度)和每块浪费的尾部空间。系统总览图则把注意力内核、块分配器与块表放在同一层,可以看到块表是内核的输入而非调度器私有的数据结构:

(图源:PagedAttention 图 4:vLLM system overview)
论文自己的实验:块大小到底影响多大
PagedAttention 论文专门做了块大小的消融(§5.3 Impact of Block Size)。论文的定性结论是双向的:
- 块太小,GPU 读和处理 KV 的并行度不够;
- 块太大,内部碎片增加,共享概率下降。
在 ShareGPT 和 Alpaca 两条 trace、固定请求率下测端到端延迟:

(图源:PagedAttention 图 15(a):Ablation experiments,不同块大小的端到端延迟)
论文报告的现象:
- ShareGPT trace(长对话、长序列)上,16 到 128 之间的块大小表现最好——序列够长,大块几乎不产生碎片,反而省下内核循环与块表;
- Alpaca trace(短指令)上,16 和 32 表现良好,更大的块显著劣化,因为很多序列比一个块还短;
- 最后的工程取舍是 16:「大到足以有效利用 GPU,小到在多数负载下避免显著内部碎片」。
这张图与第二节的结论完全对应:短请求负载下,B 的上限由「平均序列长度」直接卡死;长请求负载下,B 的上限由内核访存决定。 还有一个常被忽略的细节:论文测了块大小对 CPU-GPU 交换(swapping)与重计算(recomputation)的影响——块越小,交换时会产生大量小块传输,有效 PCIe 带宽下降;重计算成本则与块大小无关。所以「块小」并不是无代价的,只是代价从碎片转移到了交换路径上。
这块大小的账里还有一个容易漏掉的成本项:交换与重计算。论文的消融测了两种回收策略在不同块大小下的开销:块越小,交换时会产生大量小块传输,有效 PCIe 带宽被传输次数吃掉;重计算成本则与块大小无关,因为重计算不使用 KV 块。论文的结论是,重计算的开销始终不高于交换延迟的 20%,在中等块大小(16–64)区间两者端到端表现相当。换成一个可以直接算的例子:回收 1 GiB KV,若 B=128、单 token 4 KiB,每块 512 KiB,需要 2048 次传输;B=16 时每块 64 KiB,需要 16384 次;B=8 时需要 32768 次。每次 DMA 都有固定的提交与握手成本,传输次数从两千涨到三万,带宽利用率会被拉低——这就是「小块省显存、却把成本推给交换路径」的具体形态。
PagedAttention 论文附录还给了内存分配的实测图:不同 batch size 下 KV 缓存实际占用的比例,说明「预留连续空间」与「按页分配」之间的差别有多大:

(图源:PagedAttention 附录 A.2 的内存分配分析)
五个框架的五个答案
四本账拆完,可以逐个看框架的实际选择了。
vLLM:16 是「通用」的折中,但模型会改写它
vLLM 的 block_size 取值集合是 Literal[1, 8, 16, 32, 64, 128],但 CacheConfig 里没有静态默认值,注释写得很明确:
1block_size: SkipValidation[BlockSize] = None # type: ignore2"""Size of a contiguous cache block in number of tokens. ...3On CUDA devices, only block sizes up to 32 are supported. ...4This config has no static default. If left unspecified by the user, it will5be set in `Platform.check_and_update_config()` based on the current6platform."""真正的默认值在平台层:
1if cache_config and cache_config.block_size is None:2 cache_config.block_size = 1616 大致处在三条曲线的拐点附近:在长请求负载下浪费小(K=128 时约 3.9 MB),4×4096 B = 64 KiB 的单块尺寸对 L2 与共享内存都友好,而且对前缀复用足够细。调大块的收益是块表变短、内核循环变少;调小(8)的收益是前缀命中更好。vLLM 让用户自己调的理由正是它的注意力后端可以自由更换,没有一个「所有后端都满意」的值。
但模型侧的特化是硬性的:MLA 模型 + FlashMLA 后端会强制 64。原因就是第四本账说的 tile 宽度——FlashMLA 的页内布局按 64 设计。
vLLM 的块表形状是 [max_num_seqs, max_num_blocks_per_seq],即使某条序列只有 3 个块,它那一行也有 max_num_blocks_per_seq 个 int32。这个 padding 与 B 无关、由最长序列决定;调大 B 会让这一行变短,是块表账里唯一有直接收益的旋钮。
SGLang:默认 1,把两种粒度拆开
SGLang 的默认 page_size = 1 初看反直觉:按第一本账,B=1 意味着块表长度等于序列长度。SGLang 怎么处理?
它把「分配与复用粒度」和「物理页粒度」拆成了两层。 默认模式下,ReqToTokenPool 是一张按请求分配行的 token 映射表:
1# python/sglang/srt/mem_cache/memory_pool.py(节选)2self._alloc_size = size + 1 # +1 是一个 padding 行,供 CUDA Graph 虚拟批写入3self.req_to_token = torch.zeros(4 (self._alloc_size, max_context_len), dtype=torch.int32, device=device5)也就是说,SGLang 默认把「这条请求的第 i 个 token 落在哪个物理槽」记录成逐 token 的 int32 数组,前缀复用也按 token 命中。这正是「块表 K 项下界」的另一面:表宽等于 max_context_len,代价是显存,换来的是任何 token 长度的前缀都能精确复用。它的好处在 KV 内存池与 RadixCache 的所有权闭环 里有完整拆解。
当页大小 >1 时(例如长上下文 MLA、CUDA Graph 对齐、ROCm 的 vectorized 布局),SGLang 改用 block tables,page_size 随之变成块表与物理池共同的粒度。
SGLang 的 page_size 还带一组后端驱动的自动调整,集中在 arg_groups/overrides.py:
| 注意力后端 | 支持的 page_size | 越界时自动调整到 |
|---|---|---|
| FlashMLA | 仅 64 | 64 |
| TRT-LLM MLA | 32 / 64 | 64 |
| tokenspeed_mla | 32 / 64 | 64 |
| CuteDSL MLA | 32 / 64 | 64 |
| TRT-LLM MHA | 16 / 32 / 64 / 128 | 64 |
| HPC-Ops | 仅 64 | 64 |
| FA4(非 MLA) | 128 | 128 |
_page_size_default 的规则是:非 MUSA 平台默认返回 1,只有 ROCm AITER 的 vectorized_5d KV 布局默认 64;后端约束随后再把不合法的值改写。所以,SGLang 把「页大小」定义为「后端决定的量」而不是「通用调优参数」——默认给 1,后端来强制。这与 vLLM 把块大小暴露给用户的哲学恰好相反。
TensorRT-LLM:64 来自页级哈希与地址乘法
TensorRT-LLM 的 KV 池有两级(主池 + 次级池),tokensPerBlock 是构造参数,常见默认值是 32 或 64,可配置为 16–128。它的约束来自两处。
第一处,前缀复用是页级的。 BlockKey 把一个块内的 token 序列作为键,并且把影响内容一致性的所有信息都放进键里:
1// cpp/include/tensorrt_llm/batch_manager/blockKey.h(节选)2struct BlockKey3{4 bool usesExtraIds = false;5 std::optional<LoraTaskIdType> loraTaskId = std::nullopt;6 VecUniqueTokens uniqueTokens;7 std::vector<MmKey> extraKeys; // 多模态内容的哈希与块内偏移8 std::optional<std::string> cacheSalt = std::nullopt; // 缓存盐,不同盐的块不会匹配9};numMatchingTokens 只有在 loraTaskId、extraKeys、cacheSalt 完全一致时才比较 token 内容。多模态块(extraKeys 非空)不支持部分截断匹配,因为像素哈希不能有意义地截短。于是「块的完整性」成了前缀复用的前提:kvCacheManager.cpp 先用 chopVectorIntoBlocks 把 token 按 mTokensPerBlock 切块,只有完整块才进入复用树。
第二处,块字节数决定地址乘法。 blockSize = numKvHeads * sizePerHead * tokensPerBlock。以 64 个 KV 头 × 128 维 × 2 字节计,单 token 记录就是 16 KiB,B=64 时单块 1 MiB——这已是内核一次搬运的上限附近。块再大,单个 CTA 无法完整承载;块再小,块表与前缀树节点数成倍膨胀。
TensorRT-LLM 的 paged 布局有两种:默认 {numBlocks, numLayers, kvFactor, blockSize},可选 {numLayers, numBlocks, kvFactor, blockSize}(代码注释里明确写了两种)。页大小直接决定这两种布局下的 stride 乘法,也决定了跨层遍历时相邻页的内存距离。站内 TensorRT-LLM 源码完全拆解(四):KVCacheManager 与块管理 已拆过两级池与复用树账本,这里补的是页大小对这张账的影响:tokensPerBlock 越大,前缀树节点越少(树高变矮),但每命中一次浪费的尾部 token 越多。
llama.cpp:不做页,用 seq 切同一份缓冲
llama.cpp 没有分页 KV。llama-kv-cache.cpp 里,KV 是按层组织的两个三维张量:
1ggml_tensor * k = has_k ? ggml_new_tensor_3d(ctx, type_k, n_embd_k_gqa, kv_size, n_stream) : nullptr;2ggml_tensor * v = has_v ? ggml_new_tensor_3d(ctx, type_v, n_embd_v_gqa, kv_size, n_stream) : nullptr;kv_size 是一个固定的 cell 数,每条序列通过 seq_id 声明自己占用哪些 cell,多个 seq_id 可以共享同一个 cell(投机解码、并行请求都这么用)。日志里的汇总行写得很清楚:
1size = 1234.56 MiB (65536 cells, 32 layers, 8/8 seqs), K (...): ..., V (...): ...这种设计下,「块大小」这个旋钮根本不存在,取而代之的是一组逐 seq 的「cell 偏移表」(cells 向量与 seq_meta)。优点是没有内部碎片(只按 cell 分配);缺点是:
- 无法做「页级共享」——前缀共享只在 cell 粒度上,而且必须在同一份 KV 缓冲内完成;
- 无法把 KV 池扩展到分页池之外(比如 P/D 分离,需要额外机制);
- 序列数一多,因果注意力在图里就要按 seq 的 cell 范围做 gather。
站内 llama.cpp 源码完全拆解(四):llama_context 与记忆系统 拆过 llama_kv_cache 的 cell 与 seq 关系;llama.cpp 源码完全拆解(九):投机解码 展示了它用 seq_id 在同一份缓存里同时放草稿与目标序列的做法。两篇合起来解释一个事实:一个单机、单模型、本地推理框架不需要「跨请求的块级前缀缓存」时,分页就是纯开销——llama.cpp 省掉了全部页管理代码,代价是失去了服务化场景下的前缀复用。
DeepInfer:128 是模型布局的函数
DeepInfer 的 ModelMetadata.resolved_tokens_per_block 恒为 128,不是配置项。EngineArgs.create_engine_configs() 里用户给的 tokens_per_block 默认值就是 -1,只做一次校验:
1# deepinfer/engine/arg_utils.py + model_metadata_utils.py(节选)2resolved_tokens_per_block = model_metadata.resolved_tokens_per_block # 恒为 1283if self.tokens_per_block != -1:4 assert resolved_tokens_per_block == self.tokens_per_block, (5 f"{resolved_tokens_per_block=} {self.tokens_per_block=}"6 )为什么必须是 128?三个理由合起来:
- 压缩 KV 的记录长度与对齐。 DeepInfer 的主 KV 缓存是分层压缩的(
kv_compress_ratio允许取 0/1/2,代码注释写明 C4 和 C128 已移除),FP4 主压缩 KV 每 16 个值共用 scale、MXFP8 主 KV 和 SWA 每 32 个值共用 scale。kvcache_align_to = 128就是块基址必须满足 128 字节对齐的要求。 - 索引器与候选块的粒度。 稀疏注意力层的候选块大小是 8 或 16,块级筛选的
block_size要与 KV 页大小整除对齐。可参考 候选块 topk 管线。 - CUDA Graph 的静态形状。 DeepInfer 的主力 decode 路径全程在图里(见 CUDA Graph 捕获与重放),KV 块表与
slot_mapping是图输入。块大小固定,图里所有地址计算就能固定,图外只回填张量内容。
站内 启动时的显存 profiling 写过一句结论:「block size 是模型布局的函数,不是性能调优旋钮」;KV Cache 的写入与取用 则用 block_size = 64 加压缩比 4 的数值走查过压缩块与 SWA 段的装载计划。两者合起来就是 DeepInfer 选择 128 的完整逻辑:这个引擎只服务一个模型,块大小可以随模型一起特化;特化之后,调优的余地被换成「契约的确定性」。
不过,DeepInfer 的页大小不等于「所有块都一样大」。同一个引擎还有两套与之对齐的粒度:
- SWA 状态槽:滑动窗口注意力的状态池按序列数分配,槽数是
max_num_seqs,与 KV 块完全独立(见 KV 内存与 SWA 有界重放)。 - 候选块:稀疏索引器按 8 或 16 token 一块打分,这是页内的二级粒度,不复用 KV 页的编号空间。
也就是说,DeepInfer 实际上把一个维度拆成三层:128 token 的物理页、8/16 token 的筛选块、以及按 seq 计的状态槽。每一层服务于一种不同的访问模式。
前缀复用的实现也绑在页上
块大小不仅决定「能复用多少」,还决定「复用路径长什么样」。三家引擎的差异恰好落在页粒度上:
vLLM 的块级前缀哈希以块为单位计算链式哈希:块 0 的哈希只依赖块 0 的 token,块 1 的哈希依赖块 0 的哈希与块 1 的 token,依此类推。这样「前几块相同」就能等价为「前几块哈希相同」,复用粒度是块。块的完整性要求与 TensorRT-LLM 一致。站内 vLLM 系列的调度器与块管理两篇已拆过这条路径。
TensorRT-LLM 除了完整块匹配,还支持部分匹配:BlockKey::numMatchingTokens 返回两个键的最长公共前缀 token 数,supportsPartialMatching() 在多模态键上返回 false。这意味着即使两条请求只在块中间分叉,也能复用分叉前的 token;代价是多模态块无法参与部分匹配,因为像素哈希不能截断。
DeepInfer 走的是第三条路。生产系统把 KV 块缓存与 SWA 状态槽放在 3FS 上,并以「命中即重算」的方式处理部分命中——块缓存粒度与重算成本直接挂钩,可参考 生产系统的 KV Cache 存储。在调度器一侧,_schedule_prefill 里的 prefix_caching_hit 只对「已有 block_table 的序列」(即分叉出的子序列)和「空块表的全新序列」分流处理,而不是像 vLLM 那样对每条新请求做链式哈希查找:
1// csrc/engine/scheduler.hpp(节选)2bool prefix_caching_hit = false;3if (seq->cache.block_table.empty()) {4 prefix_caching_hit = true; // 全新请求5 ...6} else if (seq->schedule.branch_id.compare(0, 1, "_") == 0) {7 prefix_caching_hit = true; // 分叉序列,复用父请求已算的前缀8 ...9}这是「128 一整块 + 显式分叉」的设计:复用不是靠查找,而是靠调度器显式 fork 出一条共享父请求块的子序列。页大、块表短,分叉时 pin 住的块数少,回滚也简单;这也解释了为什么 DeepInfer 不需要像 vLLM 那样把「块哈希链」做成通用数据结构。
一张估算表:把四本账合起来
四本账单独看都是单调的,合起来就是一个优化问题。把它写成可以手算的形式。
设:并发序列数 K、平均序列长度 Lˉ、总 token 数 N=KLˉ;单 token KV 字节数 c;共享前缀的平均长度 P;前缀复用的事件数 H(并发请求中带前缀的条数)。
页内浪费(token 计):
Winner=K⋅2B−1块表开销(int32 项计,按最长序列补齐):
Wtable=K⋅⌈BLmax⌉前缀未命中(token 计,每次前缀命中事件):
Wprefix=PmodB三者折算成字节并求和:
W(B)=c⋅K⋅2B−1+4⋅K⋅⌈BLmax⌉+c⋅(PmodB)⋅H代入一组典型参数:K=128、Lmax=8192、c=4096 B、P=1200、H=128:
| B | 页内浪费(MB) | 块表(KB) | 前缀浪费(MB) | 合计(MB) |
|---|---|---|---|---|
| 8 | 1.84 | 512 | 0 | 2.35 |
| 16 | 3.93 | 256 | 0 | 4.18 |
| 32 | 8.13 | 128 | 8.39 | 16.64 |
| 64 | 16.52 | 64 | 25.17 | 41.74 |
| 128 | 33.29 | 32 | 25.17 | 58.49 |
这组数给了一个反直觉的结论:在这个参数下,块越小总浪费越小,最优接近 B=8。当 Lmax 远大于 B 时块表项会增长,但在这个例子里块表只有几百 KB——与页内浪费和前缀浪费相比几乎可以忽略。注意这张表没有算内核循环开销,那是时间账而不是显存账,它随 B 减小而增大。
换成另一组参数——短请求 + 长共享前缀:K=256、L=64、Lmax=256、P=4096、H=256:
| B | 页内浪费(MB) | 块表(KB) | 前缀浪费(MB) | 合计(MB) |
|---|---|---|---|---|
| 8 | 3.67 | 32 | 0 | 3.7 |
| 16 | 7.86 | 16 | 0 | 7.88 |
| 32 | 16.25 | 8 | 0 | 16.26 |
| 64 | 33.03 | 4 | 0 | 33.03 |
| 128 | 66.58 | 2 | 0 | 66.59 |
这里块大小越小越好,前缀长度 4096 恰好被所有候选块大小整除,前缀浪费为 0。如果 P=3000(不整除),前缀项会立刻把差距拉开:B=128 时每次命中浪费 3000 − 2944 = 56 token,按 H=256 乘起来就是十几 MB 的额外浪费。
把两组对比放在一起,就能得到选 B 的经验法则:
- 先看平均序列长度:Lˉ≲4B 时,页内浪费是主要矛盾,应取小块;
- 再看最长序列:Lmax≫B 时,块表长度 Lmax/B 的绝对值大(vLLM 与 SGLang 的块表补齐到
max_num_blocks_per_seq),块越大越省; - 看前缀:系统提示词长度 P,取 B 使 PmodB 接近 0;若前缀长度变化大,优先减小 B;
- 最后看内核:后端的 tile 宽度与页格式会给出硬约束(FlashMLA = 64、HPC-Ops = 64、FA4 = 128、TRT-LLM MHA 支持 16/32/64/128),在此约束内选。
工程结论:块大小是「模型的函数」,不是「性能旋钮」
把这五个框架的选择抽象一下,可以归纳出一个分层判断:
- 通用服务框架(vLLM、SGLang、TensorRT-LLM)的块大小是负载与后端的函数。vLLM 把它暴露给用户,SGLang 交给后端自动判定,TensorRT-LLM 把它固化在 C++ 层但保留参数。它们之所以能自由选择,是因为它们的模型是「任意模型」——块布局不需要与某个特定模型的 KV 格式耦合。
- 单模型特化引擎(DeepInfer)的块大小是模型的函数。它的模型 KV 是压缩的,块内记录长度由压缩比与头数决定,所以
resolved_tokens_per_block写死 128,用户只能校验而不能改。 - 非服务化推理(llama.cpp)的块大小不存在——它用 seq 切分替代分页,以换取零页管理的简洁。
对工程实践的启示:
第一,块大小改变时,要同时检查的三件事:页内碎片(只看平均长度)、块表 padding(由最长长度决定)、内核 tile 对齐(由后端决定)。三者常常冲突,因此实践中很少有一个「全局最优」。
第二,跨模块的块大小不一致比块大小本身更危险。P/D 分离两侧、Prefill 与 Decode 两套实例、索引器的候选块与 KV 页——只要其中一个改了,另一个的地址计算就会错。TensorRT-LLM 对 P/D 侧 tokens_per_block 不一致打警告、DeepInfer 对候选块大小断言、SGLang 按后端自动改写页大小,都是同一类防御。
第三,块大小对前缀缓存的影响要按「命中事件」计。一次前缀复用的浪费是 PmodB,它对「每条请求带一次前缀」的成本是 H 倍。所以高并发短前缀服务的块大小,不能只看显存账,还要乘上复用频率。
第四,显存账与时间账要分开算。上面两张表都只是显存账:B 小时碎片最小,但内核循环、块表寻址、交换路径的传输次数都随 B 减小而增加。vLLM 选 16 而不是 8,本质上是把「显存最优」让给了「时间可接受」。
块大小之外:一个块里到底装了什么
前面四本账都假设「块里装的是 token 的 K/V」,但真实引擎里块的内部布局并不只是「B 个 token 的顺序拼接」。这一点直接决定了「同样的 B 在不同框架里的单块字节数可以差一个半数量级」。
按「一条块内记录」的维度看,主流布局大致有三类:
其一,按 token 内联所有头。 vLLM 的页布局是 [num_blocks, block_size, num_kv_heads, head_size],每个块内每条 token 记录包含全部 KV 头。块内 stride 由 num_kv_heads * head_size 决定。GQA 的 8 头 × 128 维就是 4 KiB/token,B=16 时 64 KiB/块。
其二,按层切两层布局。 TensorRT-LLM 的 paged 池有两种排布:默认 {numBlocks, numLayers, kvFactor, blockSize} 以及可选 {numLayers, numBlocks, kvFactor, blockSize}。前者的块按「块号优先」遍历,跨层访问同一块的相邻层要在层维 stride 上跳;后者按「层优先」,同一层的连续块在内存里相邻。两种布局下的内核地址算术完全不同,页大小改变会同时改变两种布局的 stride 乘积——这也是为什么 tokensPerBlock 被固化在 KVCacheManager 构造参数里,而不是一个随时可调的运行时变量。
其三,压缩 latent 记录。 MLA 类模型把 K/V 合并成一个 latent,DeepInfer 进一步把它做成 FP4/MXFP8 存储,FP4 每 16 个值共用 scale、MXFP8 与 SWA 每 32 个值共用 scale(见 derive_main_kv_cache_num_per_channels)。一条 latent 记录的长度远小于 GQA 的 4 KiB,所以同样 128 字节对齐下,128 token 的块只占 111 KiB,而 GQA 的 16 token 块占 64 KiB。
三类布局对「块大小该取多少」的要求并不相同:
- 内联法关心的是「单 token 记录 × B 是否超过 CTA 一次能搬进共享内存的上限」;
- 分层法关心的是「stride 乘法是否溢出、相邻页是否落在同一页粒度里」;
- latent 法关心的是「scale 组长度与对齐要求」。
但三者有一个共同的量:块内每条记录的字节数。它才是内核真正关心的「搬运宽度」,而 B 只是把宽度乘上去的系数。一个简单的判断方法是:先算 record_bytes * B,如果它超过几十 KiB 的共享内存预算,就必须减小 B 或改用更紧凑的记录;如果远小于预算,就可以考虑增大 B 以换取更短的块表。这是比「看别人用多少」更可靠的起点。
一个真实的配置陷阱:页大小不在一个地方决定
把三个开源框架的页大小决定路径并排放,会发现一个共同的陷阱:用户以为自己在启动参数里设了一个值,但真正的值在平台层或后端层被改写了。
vLLM 的路径是「启动参数 → CacheConfig(可能为 None)→ Platform.check_and_update_config() → 可能被 MLA 特化改写」。所以查页大小时,日志里的 Using ... block size 才是运行时真实值,配置文件里的 --block-size 不一定等于它。
SGLang 的路径是「--page-size(可能为 None)→ _page_size_default → _mla_backend_page_constraints」。它会打一条 WARNING 说明把页大小从多少改成了多少;如果运维只看启动命令、不看日志,就会以为自己是 1 或 64,而实际值是后端约束后的结果。
TensorRT-LLM 的路径是「KVCacheManager 构造时传入 tokensPerBlock」,但 P/D 分离两侧由不同的 executor 构造,两侧不一致时只在日志里打警告。真正危险的场合是「一侧改了参数、另一侧忘了改」:数据面能传,控制面能对,但块的 stride 不同,KV 会在重排阶段被错误解释。
所以工程上应该把页大小当成跨进程契约来管理:启动时打印一次真实值,P/D 两侧比对,任何一侧不匹配就拒绝启动而不是警告。DeepInfer 的 assert 写法正是这条原则的极端版本——它不允许两侧出现不一致,因为只有一个模型、一个值。
一个跨框架的对照:同一份模型、不同页大小会发生什么
拿 MLA 模型做一次思想实验。MLA 把 K 与 V 压缩成一个 latent 向量(见站内 MLA 完全拆解),单 token 记录只有 latent_dim 个元素,通常远小于 GQA 的 2×heads×head_dim。这份模型在不同框架里会落到不同的页大小:
- vLLM + FlashMLA:强制 64,每块 64 条 latent 记录;
- SGLang + FlashMLA:同样强制 64;
- SGLang 默认(MHA 后端、非 MLA 场景):1;
- TensorRT-LLM:32 或 64;
- DeepInfer:128,但记录是压缩过的 FP4/MXFP8,单块约 111 KiB(128 × 890 B)。
单块字节数从 4 KiB 到 111 KiB,跨度一个半数量级。差异的根源不是「谁调得更好」,而是每个框架在同一页粒度下塞进去的记录长度不同:记录越短,块可以越大而不超寄存器上限;记录越长,块就必须越小。这也解释了为什么 MLA 的页大小普遍是 64 而 DeepInfer 是 128——DeepInfer 的记录更短,所以能在同样对齐约束下装更多 token。
小结
KV 缓存的页粒度不是一个「调参问题」,而是四个量的共同函数:
- 块表项数 ≈N/B+K,块越大越省,但永远有一个 K 的下界;
- 页内碎片 =K(B−1)/2 个槽位,块越小越省,按序列数而非 token 数放大;
- 前缀命中 ≈⌊P/B⌋B,呈锯齿,块越小越接近满命中;
- 内核访存与地址宽度给出硬约束,通常比前三项更强势。
五个框架的回答差异,恰好对应它们对这四个量赋予的权重:vLLM 用 16 折中通用性,并在 MLA/FlashMLA 路径上强制 64;SGLang 用 1 把粒度拆开、再交给后端自动调整;TensorRT-LLM 用 64 满足地址乘法与页级哈希;llama.cpp 用 seq 切分绕开页;DeepInfer 用 128 把块变成模型布局的一部分。理解这些选择的分歧,比记住任何一个数字都更有用——因为在你自己的服务里,这四个权重由你的负载决定,而写死的默认值往往只是别人的权重。
参考资料
- Efficient Memory Management for Large Language Model Serving with PagedAttention(vLLM,SOSP 2023)
- vLLM
vllm/config.py(CacheConfig.block_size与BlockSize) - vLLM
vllm/platforms/cuda.py(CUDA 平台默认块大小与 FlashMLA 强制 64) - SGLang
arg_groups/overrides.py(page_size 默认值与后端约束) - SGLang
mem_cache/memory_pool.py(ReqToTokenPool与 paged 池) - TensorRT-LLM
blockKey.h - TensorRT-LLM
kvCacheManager.h - llama.cpp
src/llama-kv-cache.cpp - DeepInfer
model_metadata_utils.py(resolved_tokens_per_block、kvcache_align_to) - DeepInfer
engine/arg_utils.py(EngineArgs 与 CacheConfig) - DeepInfer
csrc/engine/scheduler.hpp(prefix_caching_hit与分叉序列) - SGLang 论文:SGLang: Efficient Execution of Structured Language Model Programs
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
分享你的想法,与大家交流讨论
正在获取所在地区…
正在读取天气
音乐
暂未播放



