你盯着日志看模型权重一点一点加载完,长出一口气,觉得一张 24 GiB 卡的活终于干完了。然后你顺手把上下文开到了 32K,并发调成四路,GPU 直接给你变红屏。这种场景我不止一次在本地部署的群里看到,提问人往往会补一句:“权重好不容易装进 24 GiB 了,四路 32K 上下文还装得下吗?”答案是:大概率装不下,而且问题不一定出在权重上。
这个问题不仅折磨跑 LLM 的人,下载 dinov3、sam3、yolov8 这类视觉大模型权重时同样会遇到——权重文件下得下来,未必上得了卡。真正的显存杀手,是运行时才产生的 KV Cache。这篇文章就把这笔账完整算一遍,包括公式、实测量级、以及 24 GiB 卡上还剩下哪些腾挪手段。
1. 先把账本摊开:24 GiB 里挤着的可不只是权重
1.1 显存预算的四个去处
很多人以为显存占用就等于模型权重大小,这是第一个误区。一个正在跑推理的进程,显存里至少住着四类东西。
第一是模型权重,这个最直观。权重大小 = 参数量 × 精度字节数。比如 13B 参数,FP16 精度下就是 13×2=26GB 十进制,换算成二进制 GiB 约 24.2GiB,正好对得上标题里的“24 GiB”。BF16 和 FP16 一样,FP8 减半,INT4 再减一半左右。
第二是 KV Cache,这是长上下文的隐形吞噬者。每个 token 在注意力计算中产生的 Key 和 Value 都要缓存下来,供后续 token 查询,而且只增不减,直到这条序列结束。
第三是中间激活值。前向计算时每一层会产出大量临时张量,短文本时只有几百 MB,一旦 prefill 一个几万 token 的超长 prompt,峰值能冲到几个 GB。FlashAttention 和激活重计算能压一部分,但框架为了保证吞吐通常会留出更多缓冲。
第四是运行时开销。CUDA context 默认要吃几百 MB,PyTorch 的缓存分配器会预留碎片空间,驱动和桌面环境也要占一部分。很多人在 24 GiB 卡上把所有能设的参数都填满,结果一启动就 OOM,就是没给这部分留活路。
1.2 为什么不能只盯着权重这一个数字
权重是“固定房租”,KV Cache 是“按人头按天数算的水电”。房租再便宜,水电费也能把你耗破产。推理框架启动时通常会按gpu_memory_utilization参数划走一块显存,比如 vLLM 默认 0.9,也就是 24 GiB 卡先预留约 21.6GiB 给模型和 KV Cache,剩下 2.4GiB 是框架和 CUDA context 的保底空间。
我实际部署时习惯把gpu_memory_utilization调到 0.92 左右,但不会超过 0.95。因为 I/O 队列、显存换页、驱动自身的临时分配都需要余量。换句话说,真正能自由支配的预算往往只有 21~22GiB。
这么一算,如果权重本身已经 24GiB,那你连权重都塞不满,更别提 KV Cache。所以“权重装进 24 GiB”这个说法,通常意味着权重本身经过了一定压缩,或者你用的模型权重本来就在 14~16GiB 级别,剩了七八个 GiB 给长上下文。这篇文章后面所有推演,都以“权重已经成功装入 24 GiB 卡”为前提,看 KV Cache 到底还能不能挤进去。
2. 单路 32K 已不轻松:KV Cache 的体积公式与实测量级
2.1 KV Cache 是怎么一块砖一块砖盖起来的
先解释一个最小但关键的概念:在多头注意力里,每个输入位置会算出 Query、Key、Value 三组向量。生成下一个 token 时,新的 Query 要和之前所有 token 的 Key 做点积,再对 Value 加权求和。所以历史位置的 Key 和 Value 必须留着,不能算完就扔。这就是 KV Cache 存在的根本原因。
KV Cache 的体积公式非常规整:
KV Cache 字节数 = 2 × 层数 × KV 头数 × 每头维度 × 序列长度 × 精度字节数
这里乘 2 是因为 Key 和 Value 各一套。精度字节数在 FP16/BF16 下是 2,FP8/INT8 下是 1。层数就是模型配置里的num_hidden_layers,KV 头数则是num_key_value_heads。
这里有个架构差异必须点出来。老式模型像 LLaMA-2 全系都是 MHA,KV 头和注意力头一样多,32 个头就存 32 份 KV。后来的 Llama-3、Qwen2.5 这类模型大多转向 GQA,KV 头只有 4 个或 8 个,KV Cache 直接缩到 MHA 的四分之一到八分之一。这也是为什么同一个 32K 上下文,不同模型显存表现天差地别。
2.2 五种主流模型架构的逐项测算
直接套公式算几组真实数据,比背结论更有用。下表按 FP16/BF16 精度计算,单位全部取 GiB(2 的 30 次方)。
| 模型代表 | 层数 | KV 头数 | 每头维度 | 每 Token KV | 1K 上下文 | 单路 32K | 四路 32K |
|---|---|---|---|---|---|---|---|
| LLaMA-2-7B (MHA) | 32 | 32 | 128 | 512KiB | 0.5 | 16 | 64 |
| LLaMA-2-13B (MHA) | 40 | 40 | 128 | 800KiB | 0.78 | 25 | 100 |
| Llama-3.1-8B (GQA) | 32 | 8 | 128 | 128KiB | 0.125 | 4 | 16 |
| Qwen2.5-7B (GQA) | 28 | 4 | 128 | 56KiB | 0.055 | 1.8 | 7 |
| Qwen2.5-14B (GQA) | 48 | 8 | 128 | 192KiB | 0.19 | 6 | 24 |
| Qwen2.5-32B (GQA) | 64 | 8 | 128 | 256KiB | 0.25 | 8 | 32 |
这表拿出来很直观。单路 32K 这一个条件,就已经把老式 MHA 模型逼到了墙角:LLaMA-2-13B 单路 32K 的 KV Cache 是 25GiB,比整张 24 GiB 卡还大。你就算把权重换成 4bit 量化、腾出 20 个 G,单路都跑不起来。
而 GQA 模型就从容很多。Llama-3.1-8B 单路 32K 只要 4GiB,Qwen2.5-14B 是 6GiB。这也是为什么我这两年给人推荐本地模型,一律先看是不是 GQA,老 MHA 架构在同代能力下已经没有任何长上下文性价比。
2.3 容易被忽略的“输出也占额度”
另一个常见的认知偏差,是以为“32K 上下文”只算输入。实际上 KV Cache 按实际序列长度累计,prompt 里的每个输入 token 和生成出的每个输出 token 都要缓存。
假设一个请求输入 28K,生成 4K,那 KV Cache 按 32K 长度计算,一视同仁。如果你输入 2K、输出 30K,那实际 KV 量接近满窗口,和“32K 全用于输入”的代价几乎一样。 所以评估能不能跑,要看你业务里 prompt 和 generation 的分布,而不是窗口上限。很多客服场景 prompt 固定 1.5K、输出 2K,哪怕窗口开 32K,实际 KV 只有 3.5K 的量,远够不着天花板。
3. 四路并发:权重共用一份,KV Cache 却要一人一桌
3.1 连续批处理的显存共享逻辑
四路并发在推理框架里就是 batch size 为 4,四个不同请求同时走一遍前向。这里权重是共享的,加载一份,所有人都用,这也是并发吞吐能上去的根本原因。
但 KV Cache 不行。每个请求的序列内容不同,KV 缓存自然各算各的。用自助餐厅打比方:菜是公共的,谁都能夹;但人的胃容量是个人的,吃了多少就得消化多少。权重是一个锅,KV Cache 是四个人的碗。总显存需求基本是:
总显存 ≈ 权重 + Σ(每个请求的实际序列长度 × 每 token KV) + 中间激活 + 运行时
注意这个 Σ 不是取最大值,而是把所有并发请求的 KV 加起来。四路 32K,就接近四份单路 32K 的 KV。
3.2 最坏情况推演:四路满载 32K 要吞多少显存
现在回到标题的核心:权重 24GiB、四路 32K,24 GiB 卡到底行不行。我列三种典型情况。
第一种,LLaMA-2-13B FP16,权重正好约 24GiB。四路 32K 的 KV 是 100GiB。加上权重和激活,总需求超过 124GiB。这已经不是“能不能挤”,而是物理上差了四倍。想都不用想。
第二种,Llama-3.1-8B BF16,权重约 15GiB。四路 32K 的 KV 是 16GiB。加起来 31GiB,超了。但如果把 KV Cache 精度降到 FP8,KV 变 8GiB,总需求 23GiB 左右,勉强能塞进一台余量充足的机器。
第三种,Qwen2.5-14B,如果权重用 FP8 压缩到约 14GiB,KV 保持 BF16 是 24GiB,总需求 38GiB,超。KV 也降 FP8,KV 变 12GiB,总需求 26GiB,还是超一点。这时候就得把四路降成三路,或者换成 Qwen2.5-7B。
从这三组数据能看出一个趋势:目标“四路满载 32K”在 24 GiB 卡上,只有 8B 级 GQA 模型配合 KV 量化才可能实现。权重本身到了 24GiB 的 13B 老 MHA,连单路都没戏。
3.3 平均序列长度才是日常救星
上面算的是最坏情况:四路全部满载 32K。真实业务里这种极端场景极少。绝大多数时候,用户发过来的 prompt 几百到几千 token,输出几百到一千 token,平均序列长度可能只有 3~5K。
拿 Qwen2.5-14B 举例。KV 每 token 192KiB,四路平均 4K 的实际序列长度,KV 总量是 3GiB。哪怕权重用 BF16 占 27GiB 放不下,换成 FP8 的 14GiB 权重+3GiB KV,总共 17GiB,四路 32K 窗口敞着跑也毫无压力。
这就是“窗口开 32K”和“每路都跑满 32K”的本质区别。很多卡得刚刚好的部署,靠的不是极限优化,而是请求根本没到窗口上限。规划显存时先看统计分布,不要被一个理论峰值吓死;但压测时必须按峰值测,这也是后面要说的经验。
4. 把 KV Cache 挤进剩余显存的几张实战牌
4.1 引擎选型:PagedAttention 如何减少浪费
传统推理框架给每个请求预分配一整块连续的 KV 空间,哪怕当前只写了几百 token,也按最大长度预留。长上下文场景下,这种预留浪费非常严重。vLLM 的 PagedAttention 把 KV Cache 切成固定大小的页,按需分配,类似操作系统的虚拟内存。四路请求各用各的页,碎片的概率大幅下降。
实际使用时重点关注三个参数。gpu_memory_utilization决定给模型和 KV Cache 的总预算;max_num_seqs是同时处理的序列数上限;max_num_batched_tokens控制一次 prefill 最多吃多少 token,调小一点能压低长 prompt 带来的激活峰值。一个可以参考的启动参数:
python -m vllm.entrypoints.openai.api_server \ --model Qwen/Qwen2.5-7B-Instruct \ --gpu-memory-utilization 0.92 \ --max-model-len 32768 \ --kv-cache-dtype fp8 \ --max-num-batched-tokens 8192 \ --max-num-seqs 4 \ --enable-prefix-caching这套配置下 Qwen2.5-7B 的权重约 14.2GiB,四路 32K 的 KV 在 FP8 下约 3.5GiB,总占用约 18GiB,剩余的给激活和运行时,跑起来比较稳。
4.2 KV Cache 精度降级:从 2 字节压到 1 字节
KV Cache 也用 FP8 或 INT8,是最直接、见效最快的一招。同样一组模型,KV 精度从 FP16 降到 FP8,理论上 KV 显存直接减半。
还是用上面的表。Llama-3.1-8B 四路 32K 从 16GiB 变成 8GiB,Qwen2.5-14B 从 24GiB 变成 12GiB。对于目的是“把四路塞进 24GiB 卡”的场景,这 12GiB 的差距往往就是生死线。
代价也客观存在。我做 RAG 场景压测时,FP8 KV 在绝大多数任务上困惑度变化可以忽略,但某些对数字敏感、需要逐位比对的生成任务上,可能偶尔出现个别 token 偏差。如果你的应用容忍度低,建议先在验证集上跑一轮对比再决定开不开。vLLM 里用--kv-cache-dtype fp8开启,llama.cpp 则通过--cache-type-k q8_0 --cache-type-v q8_0控制。
4.3 前缀缓存:多路相似请求的共享大招
如果四路请求共享相同前缀,比如同一个 system prompt、同一份 RAG 参考文档,框架可以缓存这只算一次,后续请求直接复用。这省下的不是一点点。
举个例子:四个请求都询问同一篇 10K token 文档的不同问题,前缀完全相同,长度 10K。对 Qwen2.5-14B 来说,每 token KV 是 192KiB,10K 前缀的 KV 是 1.8GiB。没有前缀缓存,四路各算各的,仅前缀部分就消耗 7.2GiB;开启缓存后,这份 1.8GiB 只占一次,四路额外只为各自新增的几个百 token 买单。RAG 场景下这个收益极其可观,强烈建议部署时打开--enable-prefix-caching。
4.4 权重侧腾挪:最后几 GB 的抠法
KV 优化的空间榨干后,最后看一眼权重。如果权重正好卡在 24GiB 这条线上,比如 13B 模型 FP16,那降到 FP8 可以腾出 12GiB 给 KV;再往下 GGUF Q6/Q4 还能更省,但质量损失得自己权衡。
不过我的建议是:如果是老 MHA 架构模型,不要在量化上死磕太多,换个 GQA 模型架构省下的 KV Cache 远大于压缩权重省下的那几 GB。架构选对了,后面每一步都轻松。
5. 24 GiB 卡跑四路 32K 的最终判断与踩坑清单
5.1 一张决策表看懂“可跑/不可跑”
把前面的推演汇总成一张决策表,组合条件是“权重装入 24GiB 卡,目标四路并发且单路满载 32K”。这里的预算按 22GiB 可自由支配计算,预留 2GiB 给运行时。
| 方案 | 权重占用 | KV(BF16) | KV(FP8) | 总需求(BF16 KV) | 总需求(FP8 KV) | 四路 32K |
|---|---|---|---|---|---|---|
| LLaMA-2-13B FP16 | 24 GiB | 100 GiB | 50 GiB | 124 GiB | 74 GiB | 不可跑 |
| Llama-3.1-8B BF16 | 15 GiB | 16 GiB | 8 GiB | 31 GiB | 23 GiB | FP8 KV 可跑 |
| Qwen2.5-7B BF16 | 14 GiB | 7 GiB | 3.5 GiB | 21 GiB | 17.5 GiB | 可跑 |
| Qwen2.5-14B FP8 | 14 GiB | 24 GiB | 12 GiB | 38 GiB | 26 GiB | 不可跑,三路可 |
| Qwen2.5-32B INT4 | 19 GiB | 32 GiB | 16 GiB | 51 GiB | 35 GiB | 不可跑,两路都紧张 |
这张表再次说明那个核心结论:24 GiB 卡上四路满载 32K 的现实边界,基本划在 7B~8B 级 GQA 模型。想上 14B 级别,就得把并发降到三路或以下,或者接受更短的输出长度。
5.2 实测中容易翻车的四个细节
第一,别忘记 CUDA context 和桌面预留。有一回我自信满满配了gpu_memory_utilization 0.95,结果 vLLM 启动就崩,日志提示 CUDA out of memory。后来才发现是桌面环境占了几百 MB,加上框架缓存,把最后的余量吃没了。纯命令行部署不带桌面环境,才能把利用率往上顶。
第二,长 prompt 的首 token 延迟极高。窗口 32K 不代表你要把 32K 一次喂进去,max_num_batched_tokens设太大,prefill 阶段激活值直接暴涨。我压测时遇到过 prefill 一个 32K 的请求,瞬间吃掉 6GiB 中间激活的情况。安全做法是把max_num_batched_tokens压到 8192 或 4096,让框架分批处理长 prompt。
第三,KV 量化后算子兼容性要验证。FP8 KV 在部分老算子路径上可能触发回退,速度反而变慢。启动后看一眼日志里的 kernel 选择,或者用两轮相同请求对比首 token 延迟和每秒生成 token 数,别光看显存变少了就以为万事大吉。
第四,日志里的 KV Cache 利用率要常看。vLLM 启动后会打印KV Cache Size和Maximum CPU/GPU utilization等信息,加上nvidia-smi实时观察实际占用。我发现很多 OOM 不是算错了预算,而是请求峰值比统计平均高出一截,预留空间不够直接被顶爆。压测时把并发拉满、把单路长度跑到接近窗口上限,比什么理论估算都准。
5.3 我自己的部署惯例
这套问题我处理过很多次,最后的操作路径基本固定:先看模型架构是不是 GQA,再按公式算每 token KV,接着统计业务的平均和 P95 序列长度,最后压测时把单路长度顶到窗口上限、并发调到目标值,全程盯nvidia-smi和日志中的 KV 利用率。
如果发现权重恰好卡在整卡边缘,我会优先换 KV 精度而不是继续量化权重。KV 减半立竿见影,而权重量化省下的空间经常一两个 G,还要担质量风险。如果换了 KV 精度还差一口气,就把并发从四路降到三路,同时把max_num_seqs写死,防止框架自行放大并发把显存吃掉。实测下来,这种“峰值算一遍、余量留两成”的做法,比任何花式优化都稳。