KV cache 显存占用估算与 max_model_len 定位

在 **** 的 ****(8 卡,单卡 64GB HBM,卡间互联 392 GB/s)上用 vLLM 起大模型推理服务时,max_model_len 是最常被调大的参数之一。这个值受设备显存中 KV cache 可用容量的约束,而 KV cache 占用随序列长度与并发序列数线性增长,两项同时增大时增长最快。现场常见两类现象:显存显示仍有剩余但服务起不来,或服务能起但并发上不去。下面给出占用估算式、block 级别的碎片与浪费来源、触发场景,以及五步定位动作与可调的参数。

设备显存的三个分区:权重、激活与工作区、KV cache

在 vLLM 这类框架里,设备显存分为三部分:模型权重、激活与工作区、KV cache。框架不直接使用整卡显存,而是先按 gpu_memory_utilization 划定可用比例,该参数是 0 到 1 之间的小数,文档示例中出现过 gpu_memory_utilization=0.5 这类取值。划定之后,三部分按顺序扣减:

  1. 权重:模型参数,常驻,基本不随负载变化。每卡权重下限为 参数量 × 每参数字节数 ÷ tensor_parallel_size。
  2. 激活与工作区:前向过程的中间结果、算子工作区、图捕获预留;prefill 阶段的峰值最高。
  3. KV cache:扣除前两项之后的剩余预算,用于分配 KV 块。

KV cache 的容量由剩余预算决定,不是独立配置项。这也是常见的误判来源:卡上仍显示数十 GB 剩余,KV 池可用块数却可能为零。

分区 由什么决定 随什么变化
权重 参数量、精度、tensor_parallel_size 换模型才变
激活与工作区 单批 token 数、prefill 峰值、图捕获 随 --max-num-batched-tokens、负载波动
KV cache 层数、KV 头数、头维度、序列长度、并发 随 --max-model-len、并发、序列形态放大

KV cache 占用:可手算的估算式

占用可以手算。先定义符号:

单层 · 单序列 · K 或 V 之一 = H_kv × D × S × b
单层 · 单序列(K 与 V)     = 2 × H_kv × D × S × b
整个模型 · 单序列           = 2 × L × H_kv × D × S × b
N 条并发                    = 2 × L × H_kv × D × S × b × N
每卡(KV 头按 TP 切分)      = 2 × L × (H_kv / TP) × D × S × b × N

框架的实际分配流程与上式对应:先算每个 block 的字节数(每层的 K 与 V 各占 H_kv × D × B × b,乘上层数 L 与 K/V 两份即单块字节数),再用 KV 池可用字节除以单块字节数得到 num_blocks;块数乘以每块 token 数,即池子可容纳的总 token 数。

这个式子只有一个结论需要记住:占用对 S 与 N 均为线性。序列长度翻倍,占用近似翻倍;并发翻倍同理。两项同时增大时,乘积项增长最快。

量级上可以这样看(取一组常见规格作估算,非实测):L=32、H_kv=8、D=128、b=2、TP=1。单序列到 8192 token 时,2 × 32 × 8 × 128 × 8192 × 2 约为 1 GiB;要容纳 64 条并发,则约为 64 GiB。占用是两个线性项的乘积,因此「长上下文 + 高并发」会先把显存推到上限,而不是相加后再接近上限。

block 与 page:碎片与浪费来源

PagedAttention 把 KV cache 切成固定大小的 block(也称 page),每块存固定数量的 token。其定义是:一个 block 存「某个头上固定数量(BLOCK_SIZE)个 token 的 KV」。示例:block size 16、head size 128,一块装 16 × 128 = 2048 个元素。K 与 V 分开存储,形状大致为 [num_blocks, num_kv_heads, head_size/x, block_size, x] 与 [num_blocks, num_kv_heads, head_size, block_size]。对采用 MLA 的结构,KV 被压缩为潜向量,H_kv 与 D 的口径与 GQA 不同;估算时应按实际缓存的张量形状取这两个值,不能直接套用注意力头数。

--block-size 受 attention backend 约束,不能任意取值。约束以 %N 表示「N 的倍数」:有的后端要求 %16,有的要求 %32,有的允许 16 / 32 / 64 / 128 / 256 / 512 / 1024 这一取值集。取错值会使目标后端失格,框架回退到另一条 attention 实现;功能保留,但实际命中的实现与预期不同。vLLM 的 --block-size 默认值为 16;未显式指定时,框架按目标后端的能力选取一个兼容值,因此日志中实际生效的 block size 未必等于命令行惯用取值,核对时应以日志或后端报告为准。

碎片来自两处:

由此形成一个取舍:block 小,碎片小、前缀命中细,但块数多,块表与调度元数据开销上升,算子更碎;block 大,元数据省,但单条序列尾部的浪费按比例变大。长序列(S 远大于 B)时 B/(2S) 很小,block 取大影响有限;短序列多、长度参差时,block 越大越浪费。

前缀缓存的 hash 由三部分构成:父块 hash + 本块 token + 额外 hash。额外 hash 会带上 LoRA ID、多模态输入 hash,以及用于多租户隔离的 cache_salt。这些额外项每次变化时,公共前缀也拼不出相同的块 hash,前缀缓存的收益窗口随之关闭。开启 --enable-prefix-caching 不等于命中,需先确认前缀稳定且满块。

触发场景

负载形态 对 KV 占用的影响 先受阻的方面
超长上下文单请求(长文档问答、长代码库) S 极大,单条接近上限 单条放不下
长短交错混合负载 长请求按最长序列预占,短请求无法插入 并发被压低
高并发短请求 S 不大、N 大,乘以 N 后超预算 能启动但并发上不去
高前缀复用(固定系统提示 + 多变问题) 前缀未满块或 hash 混入易变项时复用率骤降 显存占用未产生收益
长输出生成 生成段计入 S,输出越长占用越大 解码阶段持续增长

现象与定位

同一组预算不足会以几种形态出现:

现象也可从服务指标确认:vLLM 暴露 vllm:gpu_cache_usage_perc、vllm:num_requests_running、vllm:num_requests_waiting;若 gpu_cache_usage_perc 长期贴近 1 且 num_requests_waiting 持续大于零,说明并发受 KV 池容量约束。

定位按五步走:

  1. 读日志中 KV cache 相关行:读出分配了多少块、每块多少字节、KV 池总量。块数为零或个位数,说明预算被权重/激活占用,问题不在上下文。不同版本的字段名与排版不固定,须按实际输出对照;maximum concurrency 一行是按池子与 max_model_len 反推的最大并发,可与目标并发直接对比。
  2. 反推预算去向:权重 ≈ 参数量 × b ÷ TP;用它和卡容量、KV 池容量对比,差额即激活与工作区。这一步决定是否需要减少权重占用。
  3. 用估算式算需求:代入 L、H_kv、D、b、TP,S 取 max_model_len,N 取目标并发,得到所需的 KV 字节数。
  4. 区分两种原因:需求大于预算时,未必是上下文过长。
    • 把 --max-model-len 减半再试,能起 → 单序列过长(S 侧);
    • 把并发降到 1 再试,能起 → 并发过高(N 侧);
    • 两者都试仍起不来 → 预算本身不足,需调 gpu_memory_utilization 或 KV 数据类型。
  5. 确认有无静默回退:对比实际并发与设定并发的落差;确认 attention backend 是否命中预期实现。显式用 --attention-backend 指定时,配置不兼容会直接报错并给出原因,不会静默替换;未报错时需主动确认实际命中了哪一条。
# 示意:一条用于定位的启动命令(参数名取自框架,取值按本机规格填)
vllm serve <model> \
  --tensor-parallel-size 8 \
  --max-model-len 32768 \
  --gpu-memory-utilization 0.9 \
  --block-size 16 \
  --kv-cache-dtype auto \
  --enable-prefix-caching \
  --max-num-seqs 64
# 示意:启动日志里与 KV 占用有关的那几行(字段名示意,非原文)
model weights ....................  <权重占用>
available KV cache memory ........  <KV 池可用字节>
GPU KV cache size ................  <可容纳 token 数>
maximum concurrency ..............  <按池子反推的最大并发>

第 4 步和第 5 步最容易被跳过。一个「调不上去」的现场常被归因为硬件能力不足,实际是 S 与 N 哪一项超预算未区分,或 attention backend 已回退到非预期实现。

处置:参数与代价

参数 作用 代价 / 前提
--max-model-len 调小 压小 S 上限 上下文窗口变小;最直接也最有效
--gpu-memory-utilization 调大 给 KV 池增加预算 须给激活/工作区留余量,顶到 1.0 会挤占 prefill 峰值
KV 数据类型转 fp8 b 从 2 降到 1,占用近似减半 命中的 attention backend 须支持该 KV dtype,否则失格
--tensor-parallel-size 调大 KV 头按 TP 切分,每卡占用约为原值的 1/TP 增加通信开销
--max-num-seqs 调小 压小 N,占用按比例缩小 峰值并发下降
--max-num-batched-tokens 调小 压平 prefill 峰值,为 KV 让出预算 单批吞吐可能下降
--enable-chunked-prefill 长 prefill 切块,长短请求可混批 调度更复杂,须确认后端支持
--enable-prefix-caching 公共前缀只计算一次 前缀须满块且 hash 稳定,否则命中率下降
--block-size 调整 在 backend 允许取值内权衡碎片与元数据 取值受约束(见上一节)
--swap-space 调大 部分 KV 换出到主机内存,先让服务跑起来 治标不治本,延迟明显上升

调整顺序与理由:先压 S,再考虑 KV 数据类型,再考虑 TP,最后才动 gpu_memory_utilization 与 --swap-space。S 是占用中增长最快、也最可控的一项;而调大 gpu_memory_utilization 只是把总预算推到设备边界,激活与工作区的峰值上升时仍会失败。--kv-cache-dtype 的取值除 auto 外还有 fp8(可细分为 fp8_e5m2 与 fp8_e4m3);转 fp8 后 K 与 V 的每个元素由 2 字节降为 1 字节,占用近似减半,同时引入量化误差。

小结

KV cache 的可用容量由 gpu_memory_utilization 扣除权重与激活后的剩余预算决定,占用对序列长度 S 与并发 N 均为线性,两项同时增大时增长最快。max_model_len 调不上去时先读启动日志里的 KV 池可用块数:块数接近零说明预算已被权重与激活占满,问题不在上下文;块数尚可时按两步区分,把 --max-model-len 减半能启动是受 S 约束,把 --max-num-seqs 降到 1 能启动是受 N 约束。这两步之后,才轮到 KV 数据类型、tensor_parallel_size 与预算比例的调整。