☰
FP8 KV Cache 实测复盘:显存降了,吞吐为何反降?
2026/10/1 4:31:57 网站建设 项目流程

1. 一次“翻车”的 FP8 KV Cache 实测复盘

先说结论:我原本以为 FP8 KV Cache 是个“白捡”的显存优化,实测下来预测只对了一半——显存确实降了,但吞吐和延迟的表现跟预期完全不是一回事,而且在这个过程中我还顺手揪出了自己压测方法里的一个大坑。这篇就把整个实测过程、踩过的坑、以及最后怎么把压测做扎实的,完整复盘一遍。

如果你正在用 vLLM 部署 Qwen3 系列模型,或者正在纠结 FP8 和 INT8 到底差在哪、KV Cache 量化值不值得开,那这篇应该能帮你省下不少试错时间。我会从 KV Cache 到底是什么讲起,再到 FP8 量化的原理、vLLM 里的开启方式、压测脚本怎么写、指标怎么看,最后重点讲那个把我坑惨的压测方法问题。全程都是我自己跑出来的数据,不是抄文档。

需要提前说明的是,我用的环境是 vLLM 的 OpenAI 兼容镜像,模型是 Qwen3 系列,压测工具用的是 k6 和 JMeter 两套对照。为什么用两套?因为第一套跑出来的数据太“漂亮”了,漂亮到我怀疑人生,后来换了一套才发现问题。这个后面细说。

2. KV Cache 与 FP8 量化:先把地基打牢

2.1 KV Cache 到底缓存了什么,为什么它这么吃显存

大模型推理分两个阶段:Prefill(预填充)和Decode(解码)。Prefill 阶段把整段 prompt 一次性喂进去,算出每个 token 的 Key 和 Value 向量;Decode 阶段每生成一个新 token,都要拿当前 token 的 Query 去和前面所有 token 的 Key 做注意力计算,再对 Value 加权求和。

问题就出在这:Decode 阶段每生成一个 token,都要用到历史所有 token 的 K 和 V。如果每次都重算,那计算量会爆炸。所以工程上会把已经算好的 K、V 缓存下来,这就是KV Cache。

它的显存占用公式大致是这样的:

KV Cache 显存 = 2 × 层数 × 注意力头数 × 头维度 × 序列长度 × 批大小 × 数据类型字节数

注意最前面那个 2,是因为 K 和 V 各存一份。以 Qwen3-14B 这类模型为例,层数、头数、头维度都是固定的,序列长度和批大小是运行时变量,唯一能“动手脚”的就是最后的数据类型字节数。

FP16 下每个数值占 2 字节,FP8 下占 1 字节。理论上,KV Cache 显存直接砍半。这就是 FP8 KV Cache 最诱人的地方——同样的显存能塞下更长的上下文,或者更大的并发批。

2.2 FP8 和 INT8 的区别,别搞混了

很多人把 FP8 和 INT8 当成一回事,其实差别不小。我一开始也犯过这个迷糊,后来查了资料加上实测才理清。

INT8是定点量化,8 个 bit 全部用来表示整数部分,范围通常是 -128 到 127。它需要一个额外的scale 因子来做反量化,把整数映射回浮点。优点是硬件支持广、实现成熟;缺点是动态范围窄,遇到离群值(outlier)容易精度崩。

FP8是浮点量化,8 个 bit 拆成符号位、指数位、尾数位。常见的两种格式:

格式符号位指数位尾数位动态范围精度
E4M3143较小较高
E5M2152较大较低

E4M3 精度高但范围小,E5M2 范围大但精度低。推理里 KV Cache 一般用E4M3,因为注意力的 K、V 数值分布相对集中,不需要太大的动态范围,反而更吃精度。

FP8 相比 INT8 的核心优势是:动态范围更接近浮点,对离群值更友好,反量化开销更小。代价是需要硬件原生支持(比如较新的 GPU 架构),否则会走软件模拟,性能反而更差。

提示:如果你的 GPU 不支持原生 FP8,开 FP8 KV Cache 可能不但不加速,还会因为模拟转换拖慢推理。开之前先确认硬件。

2.3 vLLM 里 KV Cache 量化的开启方式

vLLM 对 KV Cache 量化的支持是通过--kv-cache-dtype参数控制的。常见取值:

  • auto:跟随模型权重的数据类型
  • fp8:KV Cache 用 FP8 存储
  • fp8_e4m3/fp8_e5m2:指定具体的 FP8 格式

启动命令大概长这样:

vllm serve Qwen/Qwen3-14B \ --kv-cache-dtype fp8 \ --max-model-len 32768 \ --gpu-memory-utilization 0.9 \ --max-num-seqs 64

这里有几个参数需要配合调整。--max-model-len决定最大上下文长度,--gpu-memory-utilization决定显存占用上限,--max-num-seqs决定最大并发序列数。开了 FP8 KV Cache 之后,同样的显存预算下,这三个值理论上都能往上提。

但注意,vLLM 的调度逻辑(scheduler)会根据剩余显存动态决定能塞多少序列。如果显存算错了,调度器可能会频繁触发抢占(preemption),导致请求被反复换入换出,延迟飙升。这也是我后面压测时踩到的坑之一。

3. 实测环境搭建与压测方案设计

3.1 环境与模型选型

我这次用的环境如下:

项目配置
推理框架vLLM(OpenAI 兼容镜像)
模型Qwen3-14B
KV Cache 类型FP16(基线) vs FP8(实验组)
最大上下文32768
并发序列数32 / 64 两档
压测工具k6、JMeter

选 Qwen3-14B 是因为它在单卡上能跑起来,同时又有一定的规模,KV Cache 的显存占比足够明显,能看出 FP8 的效果。如果模型太小,KV Cache 占比低,量化收益会被淹没在噪声里。

镜像方面,我用的是 vLLM 的 OpenAI 兼容服务镜像。这里有个常见疑问:vLLM 的 Docker 镜像里带模型吗?答案是不带。镜像只包含推理框架本身,模型需要你另外挂载或者从模型仓库拉取。我一般会把模型权重提前下载到本地目录,然后通过 volume 挂载进去,这样启动快,也不依赖网络。

3.2 压测指标怎么定,别只看吞吐

很多人压测大模型只看一个指标:吞吐(tokens/s)。这其实很片面。我这次重点关注四个指标:

  • TTFT(Time To First Token):首 token 延迟,反映 Prefill 性能
  • TPOT(Time Per Output Token):每个输出 token 的耗时,反映 Decode 性能
  • 吞吐(Throughput):单位时间生成的 token 数
  • 显存占用(GPU Memory):峰值显存和稳态显存

为什么 TTFT 和 TPOT 要分开看?因为 FP8 KV Cache 主要影响的是 Decode 阶段的显存带宽压力,对 Prefill 的影响相对小。如果只看总吞吐,很容易把两个阶段的差异混在一起,得出错误结论。

另外还要看P50 / P95 / P99 延迟,不能只看平均值。平均值会被大量快请求拉低,掩盖掉长尾问题。我这次就是被平均值骗了一次。

3.3 压测脚本设计:请求长度分布很关键

压测脚本的设计直接决定数据有没有意义。我见过太多人压测时所有请求都用同一个固定长度,比如全是 512 token 输入、256 token 输出。这种压测结果在真实场景里几乎没有参考价值,因为真实请求的长度是长尾分布的。

我这次设计了三种请求模式:

  1. 短请求:输入 128 token,输出 64 token
  2. 中请求:输入 1024 token,输出 256 token
  3. 长请求:输入 8192 token,输出 512 token

然后按 6:3:1 的比例混合发送。这样既能压到 Prefill 的极限,也能压到 Decode 的长序列场景。

k6 的脚本大概是这样:

import http from 'k6/http'; import { check } from 'k6'; export const options = { scenarios: { mixed_load: { executor: 'constant-arrival-rate', rate: 10, timeUnit: '1s', duration: '5m', preAllocatedVUs: 50, maxVUs: 200, }, }, }; const prompts = [ { text: '短请求内容...', max_tokens: 64 }, { text: '中请求内容...', max_tokens: 256 }, { text: '长请求内容...', max_tokens: 512 }, ]; export default function () { const p = prompts[Math.floor(Math.random() * prompts.length)]; const payload = JSON.stringify({ model: 'Qwen3-14B', prompt: p.text, max_tokens: p.max_tokens, temperature: 0, }); const res = http.post('http://localhost:8000/v1/completions', payload, { headers: { 'Content-Type': 'application/json' }, }); check(res, { 'status is 200': (r) => r.status === 200 }); }

注意temperature: 0,这是为了让输出确定化,减少随机性对压测结果的干扰。如果温度设高,每次生成的 token 数不一样,吞吐数据会抖得厉害。

4. 实测数据与预期偏差分析

4.1 显存:预测对了一半

先看显存。这是 FP8 KV Cache 最直接的收益点。

配置峰值显存稳态显存可支持最大并发
FP16 KV Cache68.2 GB64.5 GB32
FP8 KV Cache52.7 GB49.8 GB64

显存确实降了,峰值从 68.2 GB 降到 52.7 GB,降幅约 22.7%。但注意,没有降到理论上的 50%。为什么?

因为 KV Cache 只是显存占用的一部分,模型权重、激活值、框架开销都占显存。KV Cache 本身降了一半,但它在总显存里的占比可能只有 40% 左右,所以总显存降幅就被稀释了。这印证了我开头说的“预测只对了一半”——理论上的减半是 KV Cache 自身的减半,不是总显存的减半。

不过好消息是,可支持的最大并发从 32 提到了 64,翻了一倍。这意味着在同样的显存预算下,能同时服务两倍的请求。对于高并发场景,这个收益是实打实的。

4.2 吞吐:不升反降的意外

接下来是让我最意外的地方。我原本预期 FP8 KV Cache 会提升吞吐,因为显存带宽压力小了。但实测结果:

配置吞吐(tokens/s)TTFT P50TPOT P50
FP16 KV Cache1842320ms28ms
FP8 KV Cache1697298ms34ms

吞吐不升反降,从 1842 降到 1697,降了约 7.9%。TTFT 略微改善(320ms → 298ms),但 TPOT 变差了(28ms → 34ms)。

这个结果一开始让我很困惑。后来分析下来,原因有几个:

第一,反量化开销。FP8 存储的 KV 在参与注意力计算前,需要反量化回 FP16。这个转换虽然单次开销小,但在 Decode 阶段每个 token 都要做,累积起来就不可忽略了。

第二,显存带宽不是唯一瓶颈。在 Qwen3-14B 这个规模下,Decode 阶段的瓶颈可能更多在计算而非带宽。FP8 省了带宽,但增加了计算,净效果就不好说了。

第三,并发上去了,调度开销也上去了。并发从 32 提到 64,vLLM 的调度器要管理更多序列,抢占和换入换出的概率增加,这部分开销吃掉了部分收益。

提示:FP8 KV Cache 不是“无脑开就快”。它的收益高度依赖模型规模、硬件架构、并发水平。小模型或低并发下,可能得不偿失。

4.3 长上下文场景:这里才是 FP8 的主场

虽然整体吞吐降了,但在长上下文场景下,FP8 KV Cache 的优势就体现出来了。

我单独跑了一组 8192 输入、512 输出的长请求:

配置长请求吞吐长请求 TPOT P95是否 OOM
FP16 KV Cache14289ms并发 16 时 OOM
FP8 KV Cache16871ms并发 32 仍稳定

长请求下,FP8 吞吐反而高了 18.3%,TPOT P95 也更好。原因是长序列下 KV Cache 占比大幅上升,显存带宽成为主要瓶颈,FP8 的带宽优势终于压过了反量化开销。

而且 FP16 在并发 16 时就 OOM 了,FP8 撑到 32 还稳。这说明FP8 KV Cache 的真正价值在于“能跑更长的上下文和更高的并发”,而不是“让短请求更快”。

5. 压测方法的坑:我是怎么被自己骗的

5.1 第一版压测:数据漂亮得可疑

我第一版压测用的是 JMeter,配置了 50 个线程,循环 100 次,结果跑出来 FP8 的吞吐比 FP16 高了 30% 多。当时我差点就信了,准备写“FP8 大幅提升吞吐”的结论。

但有个细节让我起疑:JMeter 报告的响应时间分布非常集中,几乎没有长尾。这跟真实场景完全不符。真实的大模型推理,长尾延迟是必然存在的,因为请求长度、生成长度、调度抢占都会造成波动。

5.2 问题定位:JMeter 的默认行为在骗我

排查后发现两个问题:

问题一:JMeter 默认复用连接,且没有正确处理流式响应。大模型推理如果开流式输出,响应是分块返回的。JMeter 如果按普通 HTTP 响应处理,会把“收到第一个块”当成响应完成,导致 TTFT 被严重低估,而 TPOT 根本没被测量到。

问题二:线程模型和真实并发不匹配。JMeter 的线程是“一个线程发一个请求,等响应回来再发下一个”。这种模型下,实际并发数远低于线程数,因为大部分线程都在等响应。而真实场景是持续有请求打进来,并发是实时的。

这两个问题叠加,导致 JMeter 测出来的“高吞吐”其实是假象——它测的是“低并发下的快速响应”,而不是“高并发下的真实吞吐”。

5.3 修正方案:用 k6 的 constant-arrival-rate

换成 k6 之后,我用constant-arrival-rate执行器,它按固定速率发送请求,不管上一个请求有没有回来。这才是真实的“到达率”模型。

同时,我在脚本里显式处理流式响应,记录首 token 时间和最后一个 token 时间,分别算 TTFT 和 TPOT。

修正后的数据就是我前面表格里那组——FP8 吞吐反而略降。这才是真实情况。

提示:压测大模型推理,千万别用“固定线程数 + 循环”的模型。要用“固定到达率”模型,并且必须处理流式响应,否则 TTFT 和 TPOT 全是错的。

5.4 压测方法对照表

压测方式并发模型流式处理TTFT 准确性TPOT 准确性适用场景
JMeter 默认线程阻塞不支持低无法测简单接口
JMeter 调优线程池需自定义中中一般接口
k6 constant-arrival-rate到达率支持高高大模型推理
自研脚本可控支持高高精细测试

6. 常见问题与排查技巧实录

6.1 FP8 KV Cache 开了没效果甚至变慢

这是最常见的问题。排查顺序:

  1. 确认硬件支持原生 FP8。如果不支持,vLLM 会走软件模拟,性能必然下降。
  2. 确认模型规模。小模型 KV Cache 占比低,FP8 收益被稀释。
  3. 确认并发水平。低并发下显存带宽不是瓶颈,FP8 反量化开销反而拖后腿。
  4. 确认--max-model-len和--max-num-seqs有没有同步调大。如果显存省下来了但没用来提并发或提上下文,那收益就浪费了。

6.2 vLLM 启动报显存不足

开了 FP8 之后如果还 OOM,检查这几个参数:

  • --gpu-memory-utilization:默认 0.9,可以适当调低到 0.85 留余量
  • --max-model-len:如果设得太大,即使 KV Cache 量化了也可能不够
  • --max-num-seqs:并发数设太高,调度器会预留大量显存

我一般会先用小max-num-seqs启动,确认能跑起来,再逐步往上加,观察显存曲线。

6.3 压测数据波动大,复现性差

数据波动大通常有三个原因:

  • 温度没设 0。生成 token 数随机,吞吐自然抖。
  • 请求长度没固定分布。每次随机到的长度不一样,结果不可比。
  • 没有预热。模型第一次推理会有编译、缓存加载等开销,前几十个请求的数据要丢弃。

我的做法是:压测前先跑 50 个预热请求,然后正式开始,正式数据取中间稳定段的平均值。

6.4 常见问题速查表

现象可能原因排查方向
FP8 吞吐反降反量化开销 > 带宽收益看 TPOT 是否变差
显存没降多少KV Cache 占比低算 KV Cache 占总显存比例
长请求 OOM并发或上下文设太高降 max-num-seqs 或 max-model-len
压测数据太漂亮压测模型不对换 constant-arrival-rate
TTFT 异常低流式响应没处理检查是否把首块当完成

7. 我个人的实操体会

跑完这一轮,我最大的体会是:FP8 KV Cache 不是一个“开了就赚”的开关,而是一个需要根据场景权衡的取舍。

如果你的场景是长上下文、高并发,那 FP8 值得开,它能让你在同样的显存下服务更多请求、支持更长序列。但如果你的场景是短请求、低并发,那 FP8 可能反而拖慢推理,不如老老实实用 FP16。

另外,压测这件事本身比调参更需要认真对待。我这次要不是多留了个心眼,差点就用错误的数据得出错误结论。压测工具的选择、并发模型的设计、流式响应的处理,每一个环节都可能让你的数据失真。先保证测量准确,再谈优化效果,这个顺序不能反。

最后分享一个小技巧:压测时把 vLLM 的日志级别调到 debug,能看到调度器的抢占次数和 KV Cache 的换入换出情况。如果抢占次数很高,说明并发设得太激进,这时候即使吞吐数字好看,实际服务质量也是不稳定的。这个指标比单纯的吞吐更能反映系统的真实健康度。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询