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 拆成符号位、指数位、尾数位。常见的两种格式:
| 格式 | 符号位 | 指数位 | 尾数位 | 动态范围 | 精度 |
|---|---|---|---|---|---|
| E4M3 | 1 | 4 | 3 | 较小 | 较高 |
| E5M2 | 1 | 5 | 2 | 较大 | 较低 |
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 输出。这种压测结果在真实场景里几乎没有参考价值,因为真实请求的长度是长尾分布的。
我这次设计了三种请求模式:
- 短请求:输入 128 token,输出 64 token
- 中请求:输入 1024 token,输出 256 token
- 长请求:输入 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 Cache | 68.2 GB | 64.5 GB | 32 |
| FP8 KV Cache | 52.7 GB | 49.8 GB | 64 |
显存确实降了,峰值从 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 P50 | TPOT P50 |
|---|---|---|---|
| FP16 KV Cache | 1842 | 320ms | 28ms |
| FP8 KV Cache | 1697 | 298ms | 34ms |
吞吐不升反降,从 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 Cache | 142 | 89ms | 并发 16 时 OOM |
| FP8 KV Cache | 168 | 71ms | 并发 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 开了没效果甚至变慢
这是最常见的问题。排查顺序:
- 确认硬件支持原生 FP8。如果不支持,vLLM 会走软件模拟,性能必然下降。
- 确认模型规模。小模型 KV Cache 占比低,FP8 收益被稀释。
- 确认并发水平。低并发下显存带宽不是瓶颈,FP8 反量化开销反而拖后腿。
- 确认
--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 的换入换出情况。如果抢占次数很高,说明并发设得太激进,这时候即使吞吐数字好看,实际服务质量也是不稳定的。这个指标比单纯的吞吐更能反映系统的真实健康度。