我先把这次实测的结论放在前面:FP8 KV Cache确实能砍掉一半的KV缓存占用,但对吞吐量的提升远没有宣传中那么性感,而且长上下文场景下精度损耗会毫不客气地浮现出来。至于压测环节,这次顺手找到了好几个常见的基准测试陷阱,任何一个都会让你得出完全错误的结果。
先说下背景。我在做长文场景的推理优化,手头有8卡A100 80G的集群,主要跑Llama-3-8B-Instruct这类模型。长上下文推理最头疼的问题就是KV Cache把显存吃掉大半,导致并发上不去、batch拉不高。看到vLLM支持--kv-cache-dtype fp8之后,我本能地觉得这是个白捡的优化,于是写了一套对比方案准备验证。在真正跑数据之前,我做了四个预测:
- KV Cache显存占用量减半;
- 同样的显存预算下能承载更长的上下文或更大的并发;
- 量化精度损失对回答质量几乎无影响;
- 解码吞吐量会有可观提升。
实测下来,预测一和预测二基本命中,预测四只在长上下文场景下成立,预测三则在某些任务上彻底打脸。更意外的是,做对比时我发现之前的压测脚本本身有几个隐蔽问题,差点让两组数据没法解释。这篇文章把整个测试过程、数据结论和压测方法上的坑都整理出来,给正准备做这件事的你一个参考。
1. 为什么要碰FP8 KV Cache
1.1 KV Cache是个什么角色,为什么它这么吃显存
KV Cache是自回归生成过程中,为了省掉重复计算而缓存下来的每层Key矩阵和Value矩阵。每生成一个新token,模型都要把当前token的K、V追加到缓存里,同时所有之前的token对应的K、V也要重新参与注意力计算。所以KV Cache的大小跟序列长度严格成正比,而且是线性增长。
以Llama-3-8B为例,32层、8个KV头、每个头128维,FP16精度下每新增一个token的KV占用是:
2(K和V各一份)× 32层 × 8个KV头 × 128维 × 2字节 = 131072字节 = 128KB
单看128KB好像不多,但上下文长度一上来就不一样了。8K上下文时,单个序列的KV Cache是1GB,32K时是4GB。8B模型的FP16权重本身也就16GB左右,跑32K上下文时KV Cache直接占掉25%的显存预算。再乘上并发数,这个数字很容易把整张卡吃穿。对比之下,如果你把KV Cache从FP16压到FP8,单token占用降到64KB,32K上下文只需要2GB,同样的显存预算就能多跑一倍的并发序列或翻倍的上下文长度。
1.2 FP8量化到底在压什么,压完损失在哪
这里要澄清一个点:FP8 KV Cache没有对模型权重做任何改动,它只动缓存。vLLM在生成过程中把K、V矩阵以FP8 E4M3格式写入KV缓存池,读取时再反量化回注意力计算所需的高精度。E4M3这种格式用一个符号位、四个指数位、三个尾数位表示数值,动态范围大致能覆盖正常训练和推理中的数值分布,代价是尾数精度很粗。
粗到什么程度?3个尾数位意味着尾数方向上只有8个步进档位,落在某个档位区间内的数值,最多可能引入约6%到12%的相对误差。模型权重如果被量化到这个精度,很多模型直接崩;但KV Cache里的K、V本质上只是中间激活,数值分布相对集中,而且误差不一定会线性传导到最终输出上,所以业界才敢尝试。不过“敢尝试”和“能放心用”是两回事,这也是我坚持做精度对比的原因。
1.3 这次实测要回答的几个问题
我给自己定的验证目标很简单,就三个问题:
- FP8 KV Cache在真实服务负载下,能让KV显存实际占用量下降多少;
- 在显存不再吃紧之后,吞吐量和延迟是否有可测提升;
- 量化的数值误差在短上下文和长上下文任务里,到底会不会影响回答质量。
前两个问题靠压测回答,第三个问题得用一组内容质量对比来打分。这里多说一句,如果你只是跑一下官方脚本看吞吐量数字,很容易忽略精度问题,因为很多基准负载根本不检查生成内容的正确性。
2. 测试环境与压测方法设计
2.1 软硬件配置和模型选择
这次测试我没有动用8卡,单张A100 80G就够说明问题,主要是为了排除张量并行的干扰。详细环境如下:
| 项目 | 配置 |
|---|---|
| GPU | NVIDIA A100 80G(PCIe版,显存带宽约2TB/s) |
| CPU | 64核,内存256GB |
| 推理引擎 | vLLM 0.7.x |
| 模型 | Llama-3-8B-Instruct,FP16权重 |
| 基准脚本 | vLLM自带的benchmark_serving.py |
| 监控工具 | nvidia-smi、vLLM的/metrics接口 |
选Llama-3-8B的原因很简单:它是我实际生产中最常用的模型档位,权重体积适中(FP16约16GB),KV Cache占比刚好处于“有感知但不完全爆掉”的区间,特别适合观察量化效果。如果你用的模型是7B/13B甚至更大的MoE,结论的数值会变,但趋势是类似的。
2.2 对照组设计与负载场景划分
为了做公平对比,我固定了所有能固定的参数:
- 启动参数里
gpu-memory-utilization统一设0.9,max-model-len统一设32768; - 压测时请求速率、并发数、输入长度、输出长度两组完全一致;
- 只切换
--kv-cache-dtype,即auto和fp8对照; - 每组测试跑三轮,取中位数作为最终结果,避免单轮抖动。
负载场景我拆成四档,分别覆盖短上下文和高前缀命中场景:
| 场景 | 输入长度 | 输出长度 | 说明 |
|---|---|---|---|
| S1 | 512 | 512 | 短对话 |
| S2 | 4096 | 1024 | 中长文档摘要 |
| S3 | 8192 | 2048 | 长文档抽取 |
| S4 | 16384 | 2048 | 超长上下文问答 |
每个场景用benchmark_serving.py压500个请求,请求速率从1逐步升到16,观察从从容到过载的变化曲线。
2.3 指标约定:别被平均数和瞬时值忽悠
这次压测我不只看整轮的平均吞吐量和平均延迟,而是拆成了四个维度:
- TTFT(Time To First Token):从请求发出到第一个token返回的时间,反映预填充阶段的性能;
- TPOT(Time Per Output Token):每生成一个token的耗时,反映解码阶段单步性能;
- 端到端时延:整个请求从发起到流式结束的总时间;
- 有效并发下的总吞吐量:按token数统计的每秒生成速率。
同时记录错误请求数。基准脚本默认输出这些指标,但默认的汇总会给你一个“平均”,后面会说为什么平均数会骗人。
3. 四句预测,到底对了几句
3.1 预测一:KV Cache显存占用量减半——完全命中
KV Cache的显存占用确实是按量化精度成比例缩水的。FP16单token是128KB,FP8是64KB。对单序列32K上下文来说,KV池里的实际数据从4GB降到2GB。
有个细节容易被忽略:vLLM启动时会预先划走一整块KV Cache池,不是按需一个个分配。所以你在nvidia-smi里看进程显存占用,FP16和FP8可能差不多,因为池的总大小由gpu-memory-utilization决定,两块配置都划了差不多大小的池。真正变的是池里能放的条目数:FP16的池可以容纳327680个token,FP8后变成655360个。
想看真实占用,不要只看nvidia-smi,看vLLM的KV Cache使用率指标更准。在/metrics接口里能查到实际使用的KV块数和整池容量的比值。我们这里测试是,FP16配置下8K并发负载的KV使用率大约在65%到70%,换成FP8后同样负载直接降到35%上下,这就是实打实的节省。
3.2 预测二:同样显存能承载更长的上下文或更大的并发——命中但有前提
这个预测的核心逻辑是:KV Cache占用变少,单个序列能跑更长的上下文,或者同一上下文长度下能容纳更多并发序列。实测确实如此。FP16配置下,单卡max-model-len设为32768,并发稍微拉高就可能因为KV不足报错;FP8配置下同样的并发在64K上下文范围内依然稳得住。
但有个前提条件很容易被忽略:KV Cache不再是瓶颈之后,并发上限会被max-num-seqs这个参数卡住。vLLM最多同时处理max-num-seqs个序列,默认值通常是256。如果你原本的并发压力在128以内,切FP8后会感觉“显存突然多了”,但不会带来成倍的并发提升,因为调度器根本没放那么多序列进来。换句话说,FP8是在搬走你眼前的石头,但门框本身还有限宽。如果想享受并发红利,别忘了同步调大max-num-seqs。
3.3 预测三:输出质量几乎无损——打脸现场
这部分是我最想详细说的,因为只看跑分的话完全看不出来。普通代码生成、翻译、摘要类任务,FP16和FP8的输出没太大差异,Token级别的差异率约在1%到3%之间,属于人类难以感知的范围。
但换成长上下文多跳推理和需要精确引用原文的任务后,问题就冒出来了。我设计了一组“证据定位”测试:在8K到16K的文档中安插具体数字和专有名词,然后让模型回答“文档里出现的第三组实验数据对应的值是多少”。FP16能准确引用原文内容,FP8则出现了两种典型错误:
- 把相近的数值混淆,例如把0.73答成0.83;
- 省略细节,直接给出一段似是而非的概括性回答。
我初步归因于两个因素:一是长上下文中注意力分布趋于稀疏,大量低概率位置在量化后就丢失了细微差别;二是序列越长,量化误差在多层传递中不断累积,最终在关键token上体现出来。
更明显的差异出现在长文本复制生成上。让模型连续复述一段原文,FP8在复述超过一定长度后开始出现个别词替换,这在你做检索增强生成(RAG)时是很危险的。我做RAG场景验证时,FP8版本的引用文本出现了轻微的拼接错误。所以预测三在短上下文基本成立,长上下文则部分失效。
3.4 预测四:吞吐量能明显提升——一半对一半错
这个预测的偏差主要来自我对性能瓶颈的误判。解码阶段生成一个token,需要把全部模型权重从显存读一遍,同时还要读取当前上下文对应的KV Cache。Llama-3-8B的FP16权重有16GB,A100显存带宽约2TB/s,理论上每生成一个token至少要花16GB / 2TB/s = 8ms。KV Cache读取量要看上下文长度,FP16下32K上下文是4GB,额外增加2ms,FP8下变成2GB,只增加1ms。
所以两者的理论差异不是“吞吐量翻倍”,而是从(16+4)GB / 2TB/s变成(16+2)GB / 2TB/s,只缩减了大约11%。上下文越短,权重读取占大头,FP8的优势越不明显。下面这张表是我按纯显存带宽估算的理论解码耗时对比:
| 上下文长度 | FP16 KV Cache读取量 | FP8 KV Cache读取量 | 理论单token耗时(FP16) | 理论单token耗时(FP8) | 理论提升 |
|---|---|---|---|---|---|
| 8K | 1GB | 0.5GB | 8.5ms | 8.25ms | 3%左右 |
| 32K | 4GB | 2GB | 10ms | 9ms | 10%左右 |
| 64K | 8GB | 4GB | 12ms | 10ms | 17%左右 |
实测结果和这个估算大体吻合。短上下文的吞吐量提升只有2%到5%,几乎可以忽略;32K上下文时提升约10%到12%;在64K以上、同时KV Cache读取占比足够高的场景,提升才接近理论水平。预填充阶段基本没有收益,因为预填充是计算密集型的GEMM,瓶颈不在显存带宽。如果你的线上负载平均输入长度不超过2K,那么FP8 KV Cache对你最大的价值只是省显存,不会带来可感知的加速。
4. 压测方法上的五个坑,这次全踩了一遍
4.1 没预热就跑,第一批数据是脏数据
vLLM在启动后第一次收到请求时,要做CUDA图捕获、kernel编译、内存布局初始化,这部分耗时远高于正常工作负载。如果你启动服务后立刻开始压测,前几个请求的TTFT会异常高,直接把均值拉出一个假尾巴。
我这次第一轮数据就是这样,FP16组前10个请求的TTFT普遍在2秒以上,后面才稳定到300毫秒左右。如果不截断预热数据,所有延迟指标都会被高估。正确做法是先发几十个请求作为预热,并且明确丢弃这些数据。更稳的做法是单独跑一个“空载延迟”测试,确认服务稳定后再进入正式压测。
4.2 用平均值做总结,长尾延迟全被吞了
基准脚本默认输出平均TTFT和平均TPOT。但LLM推理的延迟分布并不对称,尤其是高并发下,一部分请求会排队等调度,尾部延迟会比中位数高一倍以上。平均值最大的问题是被少数慢请求拉高,同时掩盖了绝大多数请求的真实体验。
这次的对比里,FP16和FP8的平均TTFT差距只有几十毫秒,看起来不显著;但看P95和P99时,差距明显拉大。FP8因为KV Cache更充裕,并发搁浅的情况更少,尾部延迟明显更平滑。如果只看平均值,我可能会得出“FP8对延迟没帮助”的错误结论。建议所有压测脚本都明确统计P50、P90、P95、P99,再结合TPOT分位值一块分析。
4.3 请求长度分布不匹配,TTFT被带偏
TTFT本质上是预填充阶段对输入序列做一遍完整前向计算所需的时间,它和输入长度几乎线性相关。如果你不清洗长度分布,让压测混入一批超长输入,TTFT的均值会被超长输入拉高,而这个“变慢”可能和KV Cache完全无关。
另外,vLLM的benchmark_serving.py默认按负指数分布随机生成输入输出长度,这模拟一般Chat负载是可以的,但你要对比“固定场景下的容量测试”时就不能用随机分布。我后来固定了--input-len和--output-len,每组跑两轮,一轮固定长度,一轮混合长度,分开统计。千万不要让两组测试的长度分布不一致,否则你的对比没有任何意义。
4.4 客户端成了服务器瓶颈
一开始我用并发数16压测,怎么调服务端参数,吞吐量都上不去。后来发现瓶颈根本不在GPU,在客户端。benchmark_serving.py用的是异步HTTP客户端,单进程单脚本,在请求输出很长、并发较高时,客户端的解析和调度本身就会消耗大量CPU,反而限制了能发出的请求速率。
检查方法很简单:压测时另开一个终端看客户端进程的CPU占用。如果已经稳定打满,说明你在测客户端的上限,不是服务器的。对策是换多机压测客户端,或者至少把压测进程绑定到独立物理核上,并且用--max-concurrency控制客户端侧的并发不超过能力上限。
4.5 只数成功请求,没数错误率
还有比延迟更隐蔽的坑:错误率。高并发下部分请求可能因为超时、服务端过载、连接中断而失败。基准脚本会打印失败数,但如果你只看成功的平均值,整个结果都是自欺欺人。
FP16组在高请求速率下出现过少量HTTP 503,直接拉低了有效吞吐量。FP8组因为KV缓存更充裕,错误率明显低。说明FP8对稳定性的收益是真实存在的,但如果你没有统计错误率,只能看到“FP8吞吐更高”,解释不了原因。所有压测都必须把错误率单列出来,作为核心指标之一。
5. 实操建议:FP8 KV Cache到底该怎么用
5.1 什么场景值得开FP8 KV Cache
结合这次数据和实践经验,我给你的直接建议是:
- 如果是短对话、短上下文为主的Chat服务,输入长度普遍在2K以下,FP8 KV Cache的加速效果很微弱,但显存确实减半,可以用来提升并发或延长max-model-len;
- 如果是RAG、长文档、代码仓库分析这类长上下文场景,无论从显存还是从吞吐角度都建议开,但必须做好精度验证;
- 如果模型输出需要进行精确数字引用、多跳推理或长文本复述,建议保留FP16作为兜底方案,或者对敏感任务做二次校验;
- 如果硬件是Hopper架构(H100等),FP8的硬件支持更完整,收益会更明显;Ampere架构也能跑,但部分操作要靠软件模拟,收益略打折扣。
5.2 具体启动参数和压测命令参考
这是我实际用过的可复现配置。对照组FP16:
python -m vllm.entrypoints.openai.api_server \ --model /data/models/Llama-3-8B-Instruct \ --max-model-len 32768 \ --gpu-memory-utilization 0.9 \ --max-num-seqs 128 \ --port 8000FP8实验组:
python -m vllm.entrypoints.openai.api_server \ --model /data/models/Llama-3-8B-Instruct \ --kv-cache-dtype fp8 \ --max-model-len 32768 \ --gpu-memory-utilization 0.9 \ --max-num-seqs 128 \ --port 8001压测命令:
python benchmarks/benchmark_serving.py \ --backend vllm \ --model /data/models/Llama-3-8B-Instruct \ --tokenizer /data/models/Llama-3-8B-Instruct \ --base-url http://127.0.0.1:8001/v1 \ --request-rate 8 \ --num-prompts 600 \ --max-concurrency 32 \ --input-len 4096 \ --output-len 1024 \ --seed 42 \ --save-result \ --output-dir ./results注意--seed一定要固定。如果不固定,两组测试的请求内容完全不同,长度分布也会有偏差,对比就不公平了。另外,对比时尽量让两组请求从同一份数据文件读取,或者干脆用同一个seed生成。
5.3 精度验证怎么做才靠谱
精度验证不要只靠肉眼。我推荐至少三层检查:
- 第一层,固定相同模型、相同prompt、相同采样参数(seed固定、temperature固定),分别跑FP16和FP8,对比生成文本的Token级差异率。最容易出问题的采样参数是temperature调高后,量化误差会跟采样随机性混在一起。建议先试temperature=0的确定性解码;
- 第二层,用带答案的评测集跑分数对比。抽取100到200条真实业务数据,分别用两种配置跑完,再按你自己的评测标准打分。这一步能暴露长上下文推理问题;
- 第三层,针对具体任务构造压力样本,比如“从长文档里找出第三组实验数据”“复述第几段开头的原文”“多跳推理链条”。我这次的问题正是在这种样本上暴露的。
如果嫌麻烦,至少做第一层和第三层。只跑跑分不看内容,等于确认量化后模型“没有当场崩溃”,而不是“结果仍然正确”。
6. 实测中的问题排查速查表
顺手整理一张表,把这次遇到的典型问题、判断方法和对应解法放进来,你真正操作时可以直接对照。
| 现象 | 可能原因 | 判断方法 | 解法 |
|---|---|---|---|
| FP8配置下吞吐量没有提升 | 上下文中位数太短,权重读取占主导 | 看场景平均输入长度,小于4K基本正常 | 无需处理,这是正常现象 |
| 压测客户端CPU跑满 | 客户端成为瓶颈 | 另开终端看压测进程CPU占用 | 换更强压测机,或降低客户端并发 |
| KV Cache显存看起来没减少 | vLLM预分配了整块缓存池 | 检查vLLM的KV使用率指标而非nvidia-smi | 关注KV池利用率,进程显存不会立刻变化 |
| 前几个请求TTFT异常高 | 缺少预热 | 观察前10个请求延迟明显偏高 | 正式压测前先发几十个请求并丢弃数据 |
| FP8在长上下文输出错漏数字 | 量化误差累积 | 对比FP16和FP8在长文档问答中的输出 | 敏感任务保留FP16,或加校验环节 |
| 高并发下错误率上升 | KV不足或过载 | 检查HTTP错误数和vLLM日志 | 调大max-num-seqs,或降低请求速率 |
| 平均延迟相近但P99差异大 | 平均值掩盖长尾 | 统计分位值 | 以后统一看P50/P90/P95/P99 |
另外补充几个实际操作中的小提示:
- 用
nvidia-smi dmon边压测边看GPU利用率,如果GPU利用率长期不到80%且显存没满,通常说明瓶颈在调度或客户端; - 在压测机上固定CPU频率,否则CPU频率抖动会直接影响客户端吞吐,造成误差;
- 换精度配置后一定要重启服务再测,不要在同一个服务进程里热切换,即使vLLM支持你也不能保证状态完全干净;
- 把每组测试的结果文件保存好,用脚本统一汇总,别靠人眼从终端日志里抄数字。
这次实测给我的最大感受是,FP8 KV Cache是个值得开的功能,但你要清楚它解决的是显存问题,其次才是吞吐问题。想要真正吃到长上下文红利,它确实有用;如果只是跑短对话,它的价值非常有限。压测方面,我建议每人至少犯错一次才知道平均数到底骗了你多少。上面这些坑,你提前规避掉,能省下大量的无效排查时间。