Decode 每生成一个 token 都要读完整历史 K/V,KV 因此成为与并发数、上下文长度一起线性增长的常驻开销。
vLLM V1 的做法是先按 gpu_memory_utilization 划出预算、扣掉权重与峰值激活,再把剩余空间切成
默认 64 token 的 block 按需发放。这一页从 commit e740bad 的源码公式出发,
算清 MHA/GQA/MLA、FP8 量化、分页与淘汰各自改变哪一项。
KV cache 的大小不神秘:f × L × Hkv × D × b 就是每 token 的字节数。f=2 表示 K 和 V 各存一份;MLA 只存一条 latent,f=1。GQA 改的是 Hkv,MLA 同时把 Hkv 压到 1、把 D 换成 kv_lora_rank + qk_rope_head_dim。
vLLM V1 不按 max_model_len 给每个请求预分配连续显存,而是维护一个固定大小的物理 block 池(默认 64 token/block),用 block table 做逻辑到物理的映射。相同前缀的 block 可以共享,引用计数归零后进入 free queue,按 LRU 被回收。
预算公式是 available = total × gpu_memory_utilization − 非 KV 峰值,再 num_blocks = available // bytes_per_block。默认 gpu_memory_utilization 是 0.92;启用 extensible KV cache 时 resolved 默认变成 1.0,因为容量改为按 profiling 后的实际空闲内存来定。
三个杠杆各有代价:GQA/MLA 的结构通常要在预训练或继续预训练阶段确定;分页解决碎片和共享,不减少总字节;FP8 把每元素 2 字节变 1 字节,但默认 scale=1.0 时精度风险要自己承担。
先把「为什么 decode 会被显存卡住」建立起来:算力决定一步多快,KV 决定能同时跑多少条、每条能有多长。
Prefill 把整段 prompt 并行算完,同时写入每个 token 的 K/V;decode 每步只生成一个 token,却要读完整历史 K/V。因此 KV cache 会在请求的整个生命周期里常驻,大小同时随层数、KV 头数、head 维度、精度、上下文长度、并发数线性增长。权重只付一次,KV 要按 token、按请求反复付。
拿 Llama-3-70B 算一笔:bf16 权重约 130 GiB(70B 参数 × 2 B / 230,派生见 LEDGER);一条 8K 请求的 KV 是 2.5 GiB(320 KiB/token × 8,192)。32 条并发就是 80 GiB,单这一项就超过一张 80 GiB 卡在 0.92 利用率下的全部预算。如果这个模型用 MHA(64 个 KV heads)而不是 GQA(8 个),每 token 会从 320 KiB 变成 2.5 MiB,同样一条 8K 请求要 20 GiB。
每 token 字节 = f × L × Hkv × D × b;整段请求再乘 T,整个 batch 再乘 N。改结构动 Hkv 和 f,改精度动 b,改上下文动 T,改并发动 N。分页不直接改这四个乘数,它改的是这些字节怎么摆放、能不能共享、碎片有多大。
如果按最朴素的方式给每个请求预留 max_model_len × max_num_seqs 的连续显存,大部分预留会空着:请求实际输出长度远小于上限,长度又参差不齐。空着的部分不能被别人用,外部碎片和内部碎片一起吃显存。这正是分页要解决的问题,也是 02 的起点。
它们不是替代关系,而是分别作用在公式的不同因子上;先看清各自动的是哪一项,才不会指望分页去解决总量问题。
GQA 把 query heads 分组,每组共享一个 K/V head;MQA 是所有 query heads 共享 1 个 K/V head。Llama-3-70B 把 KV heads 从 64 降到 8,每 token KV 直接 8× 变小。
MLA 把 K/V 压成每层一条 latent(DeepSeek-V3 是 kv_lora_rank 512 + qk_rope 64),推理只存这条向量,f=1、Hkv=1。相对 128 个 query heads × 128 维(qk_nope/v_head_dim)的标准 MHA,每 token 约 56.9× 更小。代价是结构通常要在预训练或继续预训练阶段确定,现成 checkpoint 不能直接 retrofit(改 KV 头数或换 MLA 结构需要重训/继续预训练)。
PagedAttention / vLLM 把 KV 池切成固定大小的 block,请求只占自己已经写过的块;block table 把连续逻辑位置翻译成可以不连续的物理块。相同前缀的块可以共享,写时复制(CoW)也可行。
代价是 block table 与哈希元数据,以及每条请求最后一个 block 最多浪费 63 个 token 的槽位。它不减少总字节,只减少「预留但没用」和「碎片导致不能用」的浪费。
kv_cache_dtype 支持 fp8(e4m3)、fp8_e5m2、int8_per_token_head、nvfp4 等(vllm/config/cache.py:48-66)。FP8/INT8 每元素 1 字节,每 token KV 约减半;nvfp4 是 4-bit,另有每 16 个值一个 fp8 scale 的开销,不能一律按 1 字节算(vllm/utils/torch_utils.py:562-564)。
代价是精度。文档明确:fp8 默认所有 scale 设为 1.0;要做 per-tensor 或 per-head 校准得走 llm-compressor;敏感层还可以用 --kv-cache-dtype-skip-layers 跳过。省下来的容量不是免费的。
vLLM V1 同时用了后两条:结构由模型决定,分配和精度由引擎配置决定。下面的公式会把这三条路线放到同一张账上。
每 token 公式只有五个因子;把模型结构、精度、上下文和并发代进去,后面的所有预算与并发数都是它的推论。
f:MHA/GQA/MQA 取 2(K 与 V),MLA 取 1(一条 latent);L:层数;Hkv:每卡 KV heads; D:head 维度;b:dtype 字节数;T:每条请求 token 数;N:并发请求数。
对应到 vLLM 的 KVCacheSpec,单个 block 的字节数是
num_heads × num_states × (head_size + head_size_v) × dtype_size
(vllm/v1/kv_cache_interface.py:582-590)。
MHA/GQA 的 head_size_v = head_size,所以一个 token 的 K 与 V 各占一份;MLA 的 head_size_v = 0
(vllm/v1/kv_cache_interface.py:740),
num_heads = 1,于是只剩一条 latent。整个 block 的字节数是该 group 内所有层 page 之和
(vllm/v1/core/kv_cache_planning.py:605-611)。
| 模型 | 结构 | L | Hkv | D | bf16 / token | fp8 / token | 128K bf16 | 同结构 MHA 对比 |
|---|---|---|---|---|---|---|---|---|
| Llama-2-7B | MHA | 32 | 32 | 128 | 512 KiB | 256 KiB | 64 GiB | 1× |
| Llama-3-8B | GQA | 32 | 8 | 128 | 128 KiB | 64 KiB | 16 GiB | 4× |
| Llama-3-70B | GQA | 80 | 8 | 128 | 320 KiB | 160 KiB | 40 GiB | 8× |
| DeepSeek-V3 | MLA | 61 | 1 | 576 | 68.625 KiB | 34.3125 KiB | 8.578 GiB | 56.9× |
「同结构 MHA 对比」= 把同一模型的 KV heads 换成 query heads 后的每 token 字节之比;DeepSeek-V3 的对照是 128 个 query heads × 128 维(qk_nope/v_head_dim)的 MHA 等价形态。128K 列只算 KV,不含权重与激活。
下面的模拟器只做 03 的算术。预设会改层数、KV heads、head 维度和示例预算;你也可以手动改任意一个因子,观察哪一项是主导项。
简化:预算只扣权重,不含激活、CUDA graph、block table 与量化 scale;并发按字节精确相除后向下取整;TP 分片不在这个模拟器里。MLA 预设按 f=1、Hkv=1、D=576 处理,并直接把 8×141 GiB / TP=8 折成单卡口径(141 GiB 显存、78.125 GiB 权重)。70B 128K 既可用预设按钮,也可把 T 滑块拉到 131,072。
参数组 A:Llama-3-70B,BF16,L=80,Hkv=8,D=128,f=2,T=8,192,GPU=160 GiB,util=0.92,权重=130 GiB。
参数组 B:DeepSeek-V3,BF16,L=61,Hkv=1,D=576,f=1,T=32,768,GPU=141 GiB(8×141 GiB 聚合中的单卡),util=0.92,权重=78.125 GiB(625/8,按 TP=8 均分估算)。
若把 8 卡聚合成一个池子、权重也只按总量 625 GiB 扣:budget = 1,128 × 0.92 − 625 = 412.76 GiB,concurrency = ⌊412.76 / 2.14453125⌋ = 192(192.47)。但 MLA 的 KV 在 TP rank 之间是副本、不能跨卡切分,192 只是聚合等效算术值,不是 8 卡部署的并发;真实单卡是 24。
参数组 C:Llama-3-70B,BF16,L=80,Hkv=8,D=128,f=2,T=131,072,GPU=160 GiB,util=0.92,权重=130 GiB。
三组都按 floor 取并发与 block 数;页面读数同时显示未取整值。JS 的 GiB 用 10243,KiB 用 1024,与手算一致。参数组 C 的并发为 0,读数会显示预算不足警告。
这一段把 03 的 per-token 公式接到引擎启动流程:先划预算,再除以每 block 字节数,得到物理 block 数。
requested = ceil(total_memory × gpu_memory_utilization)
(vllm/v1/worker/utils.py:749)。
默认 gpu_memory_utilization 是 0.92;
如果启用了 extensible KV cache,resolved_gpu_memory_utilization 在未显式设置时返回
1.0,因为容量改为按 profiling 后的实际空闲内存来定
(vllm/config/cache.py:491-497)。kv_cache_memory_bytes
一旦设置就直接覆盖这套预算(vllm/config/cache.py:381-388)。available = requested − non_kv_cache_memory − cudagraph_memory_estimate_applied
(vllm/v1/worker/gpu_worker.py:836-840)。
non_kv_cache_memory = total_consumed + transient_peak_headroom,只含权重、峰值激活与非 torch 分配
(vllm/utils/mem_utils.py:336);
CUDA graph 是单独的 cudagraph_memory_estimate_applied
(vllm/v1/worker/gpu_worker.py:803-804):未捕获 CUDA graph 时为 0,
是否计入由 VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS 控制,e740bad 中该开关默认开启
(vllm/envs.py:2339-2341)。page_size_bytes 求和
(vllm/v1/core/kv_cache_planning.py:592-620)。num_blocks = available // bytes_per_block
(vllm/v1/core/kv_cache_planning.py:696)。把 70B bf16 的例子代进去:160 × 0.92 = 147.2 GiB,扣掉 130 GiB 权重后剩 17.2 GiB(派生:160 × 0.92 − 130,LEDGER 有复算式)。每 block 是 20 MiB(320 KiB/token × 64),于是 880 个 block(含 1 个 null block,可用 879),合计 56,256 个可用 token 槽位。一条 8K 请求要 128 个 block;56,256 / 8,192 = 6.867,所以最多 6 条并发(按预算 17.2 / 2.5 也是 6)。这个例子只扣权重,便于和表 2 对账;真实 available 还要再扣非 KV 峰值,数字会更小。
allocate_new_blocks 用 cdiv(num_tokens, block_size) 算需要多少块,只补差额,不重建整张表
(vllm/v1/core/single_type_kv_cache_manager.py:406-411)。touch() 增加 ref_cnt,多个请求的 block table 可以指向同一物理块
(vllm/v1/core/block_pool.py:774-792)。prefix caching 默认开启
(vllm/config/cache.py:260)。ref_cnt 归零后进 free queue。带 hash 的 cached block 排到队尾(LRU),没有 hash 的块排到队头(LIFO,优先复用刚释放的块)
(vllm/v1/core/block_pool.py:826-837)。再次被分配时,_maybe_evict_cached_block 会清掉它的 hash 元数据
(vllm/v1/core/block_pool.py:751-772)。_preempt_request 释放它的全部 block,把
num_computed_tokens 重置为 0,放回 waiting 队列重算
(vllm/v1/core/sched/scheduler.py:946-1000、
vllm/v1/core/sched/scheduler.py:1899)。V1 这条路径是重算,不是 swap。还有两件容易忽略的账。第一,num_gpu_blocks 里包含一个永远保留的 null block(block 0),它不承载真实 KV;在默认 full-attention 路径下不分配给请求,可分配物理块从 1 开始(滑窗/RSWA 路径会用 null block 作占位符,本页未展开);get_usage() 会把它减掉
(vllm/v1/core/block_pool.py:164-165、
vllm/v1/core/block_pool.py:920)。
第二,TP 分片对 MHA/GQA 是把 Hkv 按 rank 切;MLA 的 num_kv_heads=1 且 max_tp_shards=1,
TP rank 之间是副本而不是分片(vllm/v1/kv_cache_interface.py:546-548、
vllm/model_executor/layers/attention/mla_attention.py:1446-1448)。所以 MLA 加 TP 不会让每卡 KV 按 TP 倍数缩小。
把 DeepSeek-V3 放到 8×141 GiB、TP=8 上,单卡权重按 625/8 = 78.125 GiB 估算,单卡预算 141 × 0.92 − 78.125 = 51.595 GiB;
32K 请求的 KV 是 2.14453125 GiB,单卡并发 ⌊51.595 / 2.14453125⌋ = 24。
1,128 × 0.92 − 625 = 412.76 GiB 与 192 只是把 8 卡聚合成一个池子的等效算术值,不是 8 卡部署并发。
CPU offload 是另一条路径:kv_offloading_size 设置后,vLLM 会把部分 block 下沉到 CPU,后端可选 native 或 lmcache
(vllm/config/cache.py:399-408)。命中时再读回 GPU。它扩大的是「可保留的 KV 总量」,
不是「GPU 上可同时计算的并发」推断(依据:cache.py:399-408 只定义 CPU offload buffer 与后端,未承诺 GPU 并发提升;搬运要付 PCIe 延迟,具体取决于硬件与命中率,源码未给出实测曲线)。
物理池只有固定大小的 block;请求看到的是连续逻辑位置,中间由 block table 翻译。共享前缀和 LRU 回收都发生在这层。
这张图里最值得盯的是 ref_cnt。请求 A 和 B 的逻辑块 0、1(逻辑编号从 0 起)指向同一组物理块(1、2),前缀只算一次、存一次;A 的私有物理块 3–8、B 的私有物理块 10–13 互不重叠。物理池的编号和逻辑位置无关,所以空闲块可以任意拼给新请求,不需要搬动已经写好的 KV。null block 0 在池外单独保留;默认 full-attention 路径下不进入 block table(滑窗路径的占位用法不在本图范围)。
当 A 结束,ref_cnt 从 2 变 1,物理块 1、2 仍然活着;当 B 也结束,它们归零,进入 free queue。带 hash 的块排到队尾,下次分配先从队头拿没有 hash 的块;只有 free queue 真的走到队尾,才会清掉 cached block 的 hash 并复用。这套顺序让「前缀缓存命中」和「LRU 淘汰」共用同一个队列,不需要额外的淘汰线程。
下面所有数都是公式代入,不是 benchmark;它们说明各因子改变时,每 token、每请求、可并发数分别怎么变。
表 1 已经给出每 token 的账。把「每 token」乘上上下文,就是每条请求的 KV;再用预算去除,就是可容纳的并发。表 2 用几组典型配置把这三步串起来,权重按「参数量 × dtype 字节」估算。
| 场景 | GPU GiB | util | 权重 GiB | 预算 GiB | 上下文 | KV / 请求 | 预算 / 请求 | 并发 |
|---|---|---|---|---|---|---|---|---|
| Llama-3-8B · bf16 KV | 80 | 0.92 | 15 | 58.6 | 8K | 1 GiB | 58.6 | 58 |
| Llama-3-70B · bf16 KV | 160 | 0.92 | 130 | 17.2 | 8K | 2.5 GiB | 6.88 | 6 |
| Llama-3-70B · fp8 KV,bf16 权重 | 160 | 0.92 | 130 | 17.2 | 8K | 1.25 GiB | 13.76 | 13 |
| Llama-3-70B · fp8 KV + fp8 权重 | 160 | 0.92 | 65.2 | 82 | 8K | 1.25 GiB | 65.6 | 65 |
| DeepSeek-V3 · bf16 KV 1,128 = 8×141 GiB,TP=8(聚合) | 1,128 | 0.92 | 625 | 412.76 | 32K | 2.14453125 GiB | 192.47 | 192 聚合等效 |
| DeepSeek-V3 · bf16 KV 单卡(TP=8 部署) | 141 | 0.92 | 78.125 | 51.595 | 32K | 2.14453125 GiB | 24.06 | 24 |
反过来看,这些数字也解释了为什么「把 block size 调大」不会提高并发:它改的是碎片和 kernel 效率,不改 per_token推断(依据:per_token 与 block_size 无关,见 03;可并发数由预算 / 单请求 KV 决定,cache.py:188-191 只说明 block_size 可配置,未承诺并发变化)。真正改变总量的是 f、L、Hkv、D、b 和 T;分页只是让这些字节能被更细粒度地借还。
公式和分页能解释容量,但解释不了精度、延迟和异构结构;下面几条是上线前必须另算的账。
vLLM 文档写明:kv_cache_dtype="fp8" 默认把 q/k/v 的 scale 全设为 1.0;要 per-tensor 或 per-attention-head 的校准,需要 llm-compressor 走一遍数据集。per-head 还只支持 Flash Attention 后端。敏感层可以用 --kv-cache-dtype-skip-layers 留在原生精度。代码里也提示,量化 KV 在没有合适 scale 时可能掉精度(vllm/config/cache.py:514-521)。
64 token/block 是 CacheConfig.DEFAULT_BLOCK_SIZE,可以通过 --block-size 覆盖(vllm/config/cache.py:188-191)。block 越大,block table 越短、kernel 越容易跑满,但每条请求尾部浪费的槽位也越多;block 越小,碎片越细,元数据和 kernel 调度开销越高推断(依据:block table 长度 = ⌈T / block_size⌉,尾块最多浪费 block_size−1 个槽位;源码只定义 block_size 可配,未给出 kernel 效率实测)。实际值还会被 attention backend 的 kernel block size 约束。
prefix caching 默认开启(vllm/config/cache.py:260),但它收益的前提是请求之间真的共享前缀。完全唯一的 prompt 只会多付哈希计算和元数据,不会多出可用 block推断(依据:block_pool.py:774-792 的命中要求 hash 相同,无共享前缀时没有 block 可复用;哈希与元数据开销由每次分配产生)。多轮对话、固定 system prompt、few-shot 模板是典型受益场景;一次性长文摘要不是。
没有空闲 block 时,调度器抢占最低优先级的 running 请求,释放全部 block,把 num_computed_tokens 置 0 并放回 waiting(vllm/v1/core/sched/scheduler.py:1899)。这不是把 KV swap 到 CPU 再换回来,而是把已经算过的 prefill/decode 工作扔掉重做。容量规划不足时,它表现为延迟尖刺和吞吐下降,而不是平滑降级推断(依据:scheduler.py:946-1000 的抢占会释放全部 block 并把 computed 置 0,恢复需要重新 prefill;源码与文档未给出延迟曲线)。
kv_offloading_size 把部分 block 放到 CPU,命中再读回(vllm/config/cache.py:399-408)。它适合「保留历史、偶尔复用」的场景,不适合「每条都在 GPU 上同时算」的场景:每次搬运都要走 CPU↔GPU 链路(通常是 PCIe),CPU 内存和带宽也是上限。它不会让 num_gpu_blocks 变大推断(依据:num_gpu_blocks 由 GPU profiling 后的 available 决定,gpu_worker.py:836-840;kv_offloading_size 是 CPU 侧 buffer 大小,cache.py:399-408)。
最后,03 的公式和 05 的模拟都做了简化:不含 block table、量化 scale、每条请求尾块的内部碎片、激活与 CUDA graph 峰值、hybrid KV groups(滑窗/Mamba)、DCP,也不含 TP 分片对 Hkv 的除法。真实容量以引擎 profiling 后填入的 num_gpu_blocks、kv_cache_size_tokens、kv_cache_max_concurrency 字段为准
(vllm/config/cache.py:338-354)。
另外,gpu_memory_utilization 是每实例限制,不是整卡全局上限;同一张卡跑多个 vLLM 实例时,各自按自己的比例申请,可能一起把卡吃满推断(依据:cache.py:216-227 明确该值是 per-instance limit,不做跨进程全局配额)。