KV Cache 显存管理
推理系统 · vLLM V1

KV Cache 显存管理:把每个 token 的账算到 block

Decode 每生成一个 token 都要读完整历史 K/V,KV 因此成为与并发数、上下文长度一起线性增长的常驻开销。 vLLM V1 的做法是先按 gpu_memory_utilization 划出预算、扣掉权重与峰值激活,再把剩余空间切成 默认 64 token 的 block 按需发放。这一页从 commit e740bad 的源码公式出发, 算清 MHA/GQA/MLA、FP8 量化、分页与淘汰各自改变哪一项。

vLLM V1 · commit e740bad 源码 ↗ 文档 ↗
TL;DR · 30 秒抓住核心

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 时精度风险要自己承担。

64
默认 block size(token/block)
0.92
gpu_memory_utilization 经典默认值
320 KiB
Llama-3-70B GQA · bf16 每 token KV
01

权重是一次性的,KV 是每个 token、每个并发都要再付一遍

先把「为什么 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 的起点。

02

三条路线:改结构、改分配、改精度

它们不是替代关系,而是分别作用在公式的不同因子上;先看清各自动的是哪一项,才不会指望分页去解决总量问题。

改结构

MHA → GQA / MQA → MLA

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 结构需要重训/继续预训练)。

改分配

连续缓存 → 分页 block

PagedAttention / vLLM 把 KV 池切成固定大小的 block,请求只占自己已经写过的块;block table 把连续逻辑位置翻译成可以不连续的物理块。相同前缀的块可以共享,写时复制(CoW)也可行。

代价是 block table 与哈希元数据,以及每条请求最后一个 block 最多浪费 63 个 token 的槽位。它不减少总字节,只减少「预留但没用」和「碎片导致不能用」的浪费。

改精度

bf16 → FP8 / INT8

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 同时用了后两条:结构由模型决定,分配和精度由引擎配置决定。下面的公式会把这三条路线放到同一张账上。

03

先把账算对:每 token 的 KV 字节数

每 token 公式只有五个因子;把模型结构、精度、上下文和并发代进去,后面的所有预算与并发数都是它的推论。

KV_bytes = f × L × Hkv × D × b × T × N

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)。

per_token = f × L × Hkv × D × b
page_bytes = per_token × block_size
表 1 · 按 per_token = f × L × Hkv × D × b 代入公开 config.json 计算(非实测)。Llama-2-7B:32 层 / 32 KV heads / 128;Llama-3-8B:32 / 8 / 128;Llama-3-70B:80 / 8 / 128;DeepSeek-V3:61 层 / kv_lora_rank 512 / qk_rope 64。
模型结构LHkvDbf16 / tokenfp8 / token128K bf16同结构 MHA 对比
Llama-2-7BMHA3232128512 KiB256 KiB64 GiB1×
Llama-3-8BGQA328128128 KiB64 KiB16 GiB4×
Llama-3-70BGQA808128320 KiB160 KiB40 GiB8×
DeepSeek-V3MLA61157668.625 KiB34.3125 KiB8.578 GiB56.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 维度和示例预算;你也可以手动改任意一个因子,观察哪一项是主导项。

MHA/GQA/MQA · f=2(K+V)
每 token
320 KiB
单请求 T
2.5 GiB
KV 预算
17.2 GiB
并发(floor)
66.88 ↓
block 数(floor)
880880.64 ↓
当前算术
每 token = f × L × H × D × b = 2 × 80 × 8 × 128 × 2 = 327,680 B = 320 KiB
单请求 = 327,680 B × 8,192 = 2,684,354,560 B = 2.5 GiB
KV 预算 = 160 GiB × 0.92 − 130 GiB = 17.2 GiB
并发 = ⌊17.2 GiB / 2.5 GiB⌋ = 6(未取整 6.88)
block = ⌊17.2 GiB / 20 MiB⌋ = 880(未取整 880.64);每请求 128 block
num_gpu_blocks = 880(含 1 个 null block);可用 879,可用槽位 879 × 64 = 56,256

简化:预算只扣权重,不含激活、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。

手算三组:与上面的 JS 输出逐位一致点开核对

参数组 A:Llama-3-70B,BF16,L=80,Hkv=8,D=128,f=2,T=8,192,GPU=160 GiB,util=0.92,权重=130 GiB。

per_token = 2 × 80 × 8 × 128 × 2 = 327,680 B = 320 KiB
per_request = 327,680 × 8,192 = 2,684,354,560 B = 2.5 GiB
budget = 160 × 0.92 − 130 = 17.2 GiB
concurrency = ⌊17.2 / 2.5⌋ = 6(未取整 6.88)
block_bytes = 327,680 × 64 = 20,971,520 B = 20 MiB
blocks = ⌊17.2 GiB / 20 MiB⌋ = 880(未取整 880.64)
blocks_per_request = ⌈8,192 / 64⌉ = 128
可用 block = 880 − 1 = 879;可用槽位 = 879 × 64 = 56,256

参数组 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 均分估算)。

per_token = 1 × 61 × 1 × 576 × 2 = 70,272 B = 68.625 KiB
per_request = 70,272 × 32,768 = 2,302,672,896 B = 2.14453125 GiB
budget = 141 × 0.92 − 78.125 = 51.595 GiB
concurrency = ⌊51.595 / 2.14453125⌋ = 24(未取整 24.06)
block_bytes = 70,272 × 64 = 4,497,408 B = 4.2890625 MiB
blocks = ⌊51.595 GiB / 4.2890625 MiB⌋ = 12,318(未取整 12,318.14)
blocks_per_request = ⌈32,768 / 64⌉ = 512
可用 block = 12,318 − 1 = 12,317;可用槽位 = 12,317 × 64 = 788,288

若把 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。

per_token = 2 × 80 × 8 × 128 × 2 = 327,680 B = 320 KiB
per_request = 327,680 × 131,072 = 42,949,672,960 B = 40 GiB
budget = 160 × 0.92 − 130 = 17.2 GiB
concurrency = ⌊17.2 / 40⌋ = 0(未取整 0.43)→ 读数提示「预算不足一个请求的 KV。」
block_bytes = 327,680 × 64 = 20,971,520 B = 20 MiB
blocks = ⌊17.2 GiB / 20 MiB⌋ = 880(未取整 880.64)
blocks_per_request = ⌈131,072 / 64⌉ = 2,048
可用 block = 880 − 1 = 879;可用槽位 = 879 × 64 = 56,256

三组都按 floor 取并发与 block 数;页面读数同时显示未取整值。JS 的 GiB 用 10243,KiB 用 1024,与手算一致。参数组 C 的并发为 0,读数会显示预算不足警告。

04

vLLM 的预算表:从 GPU 总量到 num_gpu_blocks

这一段把 03 的 per-token 公式接到引擎启动流程:先划预算,再除以每 block 字节数,得到物理 block 数。

启动时

四步把显存变成 block 数

  1. 划请求预算: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)。
  2. 扣非 KV 峰值:跑一次 profile,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)。
  3. 算每 block 字节:对最大 KV group 内所有层的 page_size_bytes 求和 (vllm/v1/core/kv_cache_planning.py:592-620)。
  4. 得到 block 数: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 峰值,数字会更小。

运行时:借、共享、还、抢

还有两件容易忽略的账。第一,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 延迟,具体取决于硬件与命中率,源码未给出实测曲线)。

05

一块显存怎么被切成 block,再被请求借走

物理池只有固定大小的 block;请求看到的是连续逻辑位置,中间由 block table 翻译。共享前缀和 LRU 回收都发生在这层。

① 显存预算(8B 示例) 权重 15 GiB KV pool 56.6 GiB = 73.6 − 15 − 2 free 6.4 requested = 73.6 GiB 总量 80 GiB 权重 15 GiB(参数量 × 2 B,约值) 非 KV 峰值 2 GiB(示意值,不是实测) KV pool:31 × 64 = 1,984 个 token 槽位(另有 null block) free:未纳入本次预算的 6.4 GiB ② 物理 block 池 · 每块 64 token 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 共享前缀 Req A 私有 Req B 私有 cached(LRU) free null block(池外) 0 null block 池外 · 不分配 ③ block table:逻辑块 → 物理块 Req A 1 2 3 4 5 6 7 8 Req B 1 2 10 11 12 13 逻辑位置连续,物理块可以不连续;物理块 1、2 被两个请求同时指向,ref_cnt = 2。 allocate cdiv(T, 64),只补差额 share / free ref_cnt++;归零进 free queue exhausted 抢占 → computed=0 重算 block 0 是 null block,池外保留、不参与分配(vllm/v1/core/block_pool.py:164)。 图中 31 个可分配物理块 × 64 = 1,984 个槽位仅为放大示意(8B 场景实际约 7,245 个 8 MiB block)。
图 1:按 vLLM V1 的预算公式与 BlockPool 行为重绘。覆盖:显存预算拆分、物理 block 池、block table 的逻辑→物理映射、共享前缀与 ref_cnt、free queue 与抢占。 未覆盖:hybrid KV groups(滑窗/Mamba)、DCP、量化 scale 的实际存储、CUDA graph 实测;图中 2 GiB 非 KV 峰值为示意值。

这张图里最值得盯的是 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 淘汰」共用同一个队列,不需要额外的淘汰线程。

06

四本账:结构省下的和精度省下的

下面所有数都是公式代入,不是 benchmark;它们说明各因子改变时,每 token、每请求、可并发数分别怎么变。

表 1 已经给出每 token 的账。把「每 token」乘上上下文,就是每条请求的 KV;再用预算去除,就是可容纳的并发。表 2 用几组典型配置把这三步串起来,权重按「参数量 × dtype 字节」估算。

表 2 · 预算 = GPU GiB × util − 权重 GiB;并发 = ⌊预算 / 单请求 KV⌋。「预算 / 请求」列是未取整的比值,并发列是 floor。权重为约值:70B bf16 ≈ 130 GiB,70B fp8 ≈ 65.2 GiB,8B bf16 ≈ 15 GiB,DeepSeek-V3 fp8 ≈ 625 GiB。DeepSeek-V3 的 1,128 GiB = 8×141 GiB(TP=8)是聚合显存;MLA 的 KV 在 TP rank 间是副本、不能跨卡切分,所以 192 只是假定 KV 可跨卡切分的聚合等效算术值(聚合预算 ÷ 单请求 KV),不是 8 卡部署并发。单卡预算 = 141×0.92 − 625/8 = 51.595 GiB,单卡并发 24。
场景GPU GiButil权重 GiB预算 GiB上下文KV / 请求预算 / 请求并发
Llama-3-8B · bf16 KV800.921558.68K1 GiB58.658
Llama-3-70B · bf16 KV1600.9213017.28K2.5 GiB6.886
Llama-3-70B · fp8 KV,bf16 权重1600.9213017.28K1.25 GiB13.7613
Llama-3-70B · fp8 KV + fp8 权重1600.9265.2828K1.25 GiB65.665
DeepSeek-V3 · bf16 KV
1,128 = 8×141 GiB,TP=8(聚合)
1,1280.92625412.7632K2.14453125 GiB192.47192
聚合等效
DeepSeek-V3 · bf16 KV
单卡(TP=8 部署)
1410.9278.12551.59532K2.14453125 GiB24.0624

反过来看,这些数字也解释了为什么「把 block size 调大」不会提高并发:它改的是碎片和 kernel 效率,不改 per_token推断(依据:per_token 与 block_size 无关,见 03;可并发数由预算 / 单请求 KV 决定,cache.py:188-191 只说明 block_size 可配置,未承诺并发变化)。真正改变总量的是 f、L、Hkv、D、b 和 T;分页只是让这些字节能被更细粒度地借还。

07

这套账算不到的地方

公式和分页能解释容量,但解释不了精度、延迟和异构结构;下面几条是上线前必须另算的账。

边界一

量化不是无损,默认 scale 也不是校准过的

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)。

边界二

block size 64 是默认,不是所有后端的最优

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 模板是典型受益场景;一次性长文摘要不是。

边界四

显存不够时,V1 的默认动作是抢占重算

没有空闲 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;源码与文档未给出延迟曲线)。

边界五

offload 扩的是容量,不是 GPU 并发

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,不做跨进程全局配额)。