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 这类取值。划定之后,三部分按顺序扣减:
- 权重:模型参数,常驻,基本不随负载变化。每卡权重下限为 参数量 × 每参数字节数 ÷
tensor_parallel_size。 - 激活与工作区:前向过程的中间结果、算子工作区、图捕获预留;prefill 阶段的峰值最高。
- KV cache:扣除前两项之后的剩余预算,用于分配 KV 块。
KV cache 的容量由剩余预算决定,不是独立配置项。这也是常见的误判来源:卡上仍显示数十 GB 剩余,KV 池可用块数却可能为零。
| 分区 | 由什么决定 | 随什么变化 |
|---|---|---|
| 权重 | 参数量、精度、tensor_parallel_size |
换模型才变 |
| 激活与工作区 | 单批 token 数、prefill 峰值、图捕获 | 随 --max-num-batched-tokens、负载波动 |
| KV cache | 层数、KV 头数、头维度、序列长度、并发 | 随 --max-model-len、并发、序列形态放大 |
KV cache 占用:可手算的估算式
占用可以手算。先定义符号:
L:注意力层数;H_kv:每层的 KV 头数(GQA/MQA 下远小于 query 头数);D:单头维度;S:单条序列的 token 数(含提示与生成,受max_model_len约束);b:KV 每个元素的字节数(fp16/bf16 为 2,fp8 为 1);N:同时驻留的序列数(并发);TP:tensor_parallel_size。
单层 · 单序列 · 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 size 整数倍时,最后一个块只用到前几个位置,占用却按整块计。平均浪费半个块,浪费比例量级约为
B / (2S)(B为 block size,S为序列长度)。 - 前缀缓存的满块边界:前缀缓存只缓存满块。示例:两条请求共享 14 个 token、block size 为 4 时,只有前 8 个 token(两个满块)命中,第三块只对上 2/4,整块作废。block 越大,前缀命中粒度越粗,尾部越容易对不齐。
由此形成一个取舍: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,输出越长占用越大 |
解码阶段持续增长 |
现象与定位
同一组预算不足会以几种形态出现:
- 显存显示仍有剩余,服务起不来,日志里 KV 相关那一行显示可用块数为零或极小;
- 能启动,但
max_model_len超过某个值即报错退出; - 服务启动、单条可跑,并发上不去,排队队列持续积压;
- 最大并发被静默压低:没有任何报错,但实际同时运行的序列数远低于期望。
现象也可从服务指标确认:vLLM 暴露 vllm:gpu_cache_usage_perc、vllm:num_requests_running、vllm:num_requests_waiting;若 gpu_cache_usage_perc 长期贴近 1 且 num_requests_waiting 持续大于零,说明并发受 KV 池容量约束。
定位按五步走:
- 读日志中 KV cache 相关行:读出分配了多少块、每块多少字节、KV 池总量。块数为零或个位数,说明预算被权重/激活占用,问题不在上下文。不同版本的字段名与排版不固定,须按实际输出对照;
maximum concurrency一行是按池子与max_model_len反推的最大并发,可与目标并发直接对比。 - 反推预算去向:权重 ≈ 参数量 ×
b÷TP;用它和卡容量、KV 池容量对比,差额即激活与工作区。这一步决定是否需要减少权重占用。 - 用估算式算需求:代入
L、H_kv、D、b、TP,S取max_model_len,N取目标并发,得到所需的 KV 字节数。 - 区分两种原因:需求大于预算时,未必是上下文过长。
- 把
--max-model-len减半再试,能起 → 单序列过长(S侧); - 把并发降到 1 再试,能起 → 并发过高(
N侧); - 两者都试仍起不来 → 预算本身不足,需调
gpu_memory_utilization或 KV 数据类型。
- 把
- 确认有无静默回退:对比实际并发与设定并发的落差;确认 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 与预算比例的调整。