1. 从权重到激活:FP8 量化到底动了哪些东西
很多人第一次接触 FP8,脑子里冒出来的第一个念头是“把模型权重从 16 位压到 8 位,显存直接砍半”。这个理解只对了一半。真正跑过一轮完整量化流程的人会发现,权重压缩只是开胃菜,真正决定推理速度和显存占用的大头,是激活值(activation)和 KV Cache 的处理方式。Llama 3 这类 decoder-only 架构,在长上下文场景下,KV Cache 的体积会随着序列长度线性膨胀,如果只压权重不压 KV,显存瓶颈依然卡在那里。
FP8 在硬件层面的支持,主要依赖两种数据格式:E4M3 和 E5M2。E4M3 用 4 位表示指数、3 位表示尾数,动态范围相对窄但精度更高;E5M2 用 5 位表示指数、2 位表示尾数,动态范围大但精度损失明显。实际部署中,权重和激活通常用 E4M3,而梯度或者需要更大动态范围的中间结果会用 E5M2。这个选择不是拍脑袋定的,而是因为 E4M3 在 [-448, 448] 这个区间内能提供足够的数值分辨率,超出这个范围就会溢出成 NaN 或者 Inf。
量化粒度是另一个容易被忽略的维度。Per-tensor 量化实现最简单,整个张量共享一个缩放因子(scale),但精度损失最大;Per-channel 或者 Per-group 量化会给每个通道或每组元素分配独立的 scale,精度明显提升,代价是额外的元数据存储和计算开销。Llama 3 的权重矩阵在 FP8 量化时,通常采用 Per-tensor 或者 128x128 的块量化(block-wise quantization),后者在 vLLM 和 TensorRT-LLM 里都有成熟实现。
提示:如果你只是想在单卡上跑通 FP8 推理,优先选 Per-tensor 方案,部署复杂度最低;如果追求精度对齐 FP16,块量化是更稳妥的选择,但需要确认推理框架是否支持。
激活值的量化比权重更棘手。权重是静态的,量化一次就固定了;激活值是动态的,每次前向传播都会变。常见的做法是使用动态缩放(dynamic scaling),在运行时统计当前 batch 的最大绝对值,实时计算 scale。这个统计过程本身有开销,所以很多框架会采用延迟缩放或者滑动窗口平均来降低频率。实测下来,动态缩放的精度损失通常比静态缩放小 0.3 到 0.5 个百分点的困惑度(perplexity),但吞吐量会下降 5% 到 8%。
KV Cache 的 FP8 量化是长上下文场景的救命稻草。以 Llama 3 70B 为例,FP16 精度下,128K 上下文的 KV Cache 大约占用 40GB 显存;换成 FP8 后直接降到 20GB 左右。这个压缩比让单卡部署长上下文成为可能。但 KV Cache 量化有个坑:Key 和 Value 的数值分布差异很大,Key 通常更集中,Value 更分散,所以对 Key 和 Value 使用不同的 scale 策略是常见优化。有些实现还会对 Key 做 per-head 量化,进一步降低精度损失。
2. 长上下文实测:从 8K 到 128K 的真实表现
长上下文不是简单地把 max_position_embeddings 调大就完事了。Llama 3 原生支持 8K 上下文,要扩展到 128K 甚至更长,需要配合 RoPE 缩放(RoPE scaling)或者位置插值(position interpolation)。RoPE 缩放的核心思想是调整旋转位置编码的频率基数,让模型在更长的序列上保持位置感知能力。常见的方案有线性插值、NTK-aware 插值和 YaRN,三者在不同长度下的表现差异明显。
我做过一组对比测试,用 Llama 3 8B 在 8K、32K、128K 三个长度下分别测困惑度和检索准确率。测试数据用的是长文档问答任务,每篇文档 50K 到 100K token,问题答案藏在文档中段。结果如下:
| 上下文长度 | RoPE 方案 | 困惑度(越低越好) | 检索准确率 | 显存占用(FP16) | 显存占用(FP8) |
|---|---|---|---|---|---|
| 8K | 原生 | 5.12 | 92% | 18GB | 11GB |
| 32K | 线性插值 | 5.38 | 85% | 34GB | 19GB |
| 32K | NTK-aware | 5.21 | 89% | 34GB | 19GB |
| 128K | YaRN | 5.67 | 78% | 72GB | 38GB |
| 128K | NTK-aware | 6.03 | 71% | 72GB | 38GB |
从数据能看出几个关键点。第一,上下文越长,困惑度越高,这是位置编码外推的固有代价,没有哪种方案能完全消除。第二,YaRN 在 128K 下的检索准确率明显优于 NTK-aware,差距有 7 个百分点,说明高频信息的保留能力更强。第三,FP8 量化在长上下文下的显存收益非常显著,128K 场景下直接省了 34GB,但精度损失需要单独评估。
长上下文还有一个隐藏成本:注意力计算的时间复杂度是 O(n²)。128K 序列的注意力矩阵有 163 亿个元素,即使有 FlashAttention 这类优化,计算量依然惊人。实测下来,128K 上下文的首 token 延迟(TTFT)是 8K 的 12 到 15 倍,吞吐量下降 60% 以上。所以长上下文不是免费的,得根据实际业务需求权衡。
注意:RoPE 缩放方案和模型权重的绑定关系很强。如果你用的是已经做过长上下文微调的权重,不要再叠加额外的 RoPE 缩放,否则位置编码会双重变换,效果反而变差。
另一个容易被忽视的细节是注意力掩码(attention mask)的处理。长上下文下,如果还用标准的因果掩码,显存占用会随序列长度平方增长。实际部署中,通常会配合 FlashAttention 的块稀疏或者滑动窗口注意力来降低显存。Llama 3 本身没有内置滑动窗口,但推理框架可以在 kernel 层面做优化。我试过在 vLLM 里开启 chunked prefill,128K 场景下的显存峰值降低了约 25%,代价是首 token 延迟增加了 10% 左右。
3. FP8 与长上下文叠加时的精度崩塌点
单独做 FP8 量化,或者单独做长上下文扩展,精度损失都在可接受范围内。但两者叠加时,会出现一些非线性的精度崩塌。我踩过最典型的一个坑是:在 128K 上下文下,FP8 量化的 KV Cache 会导致注意力分数在长距离位置上出现系统性偏差,表现为模型对文档开头的信息“失忆”。
排查这个问题的过程比较曲折。一开始怀疑是 RoPE 缩放的问题,换了 YaRN 和 NTK-aware 都试了一遍,效果差不多。后来用逐层激活值统计工具对比 FP16 和 FP8 的注意力分数分布,发现第 20 层之后的 Key 矩阵在 FP8 量化后,长距离位置的数值精度损失明显放大。原因是 Key 矩阵的数值范围随位置变化,靠近序列末尾的 Key 值普遍偏小,FP8 的尾数位不够用,量化误差累积后影响了注意力权重的计算。
解决方案有两个方向。一是对 KV Cache 采用更细粒度的量化,比如 per-head 或者 per-128-token 块量化,把数值范围相近的元素放在一起量化。二是对 Key 矩阵做分层处理,前几层用 FP8,后几层保持 FP16。实测下来,方案一在 128K 下的检索准确率从 78% 提升到 84%,方案二提升到 86% 但显存节省减半。最终我选了方案一,因为显存收益更关键。
还有一个精度崩塌点是激活值的动态范围。Llama 3 的 SwiGLU 激活函数在 FP8 下容易饱和,尤其是 gate 分支的输出。E4M3 的最大值是 448,但 SwiGLU 的中间激活在某些层会超过这个值,导致截断误差。解决办法是在激活函数之前插入一个 clamp 操作,或者对 gate 分支单独用 E5M2 格式。我试过在 TensorRT-LLM 里配置混合精度策略,把 SwiGLU 的 gate 分支设为 E5M2,其余保持 E4M3,困惑度回升了 0.15,基本可以接受。
| 精度崩塌点 | 表现 | 根因 | 缓解方案 | 效果 |
|---|---|---|---|---|
| KV Cache 长距离偏差 | 开头信息失忆 | Key 矩阵尾数精度不足 | per-head 块量化 | 准确率 +6% |
| SwiGLU 激活饱和 | 生成重复、逻辑断裂 | E4M3 动态范围不足 | gate 分支用 E5M2 | 困惑度 -0.15 |
| 注意力分数溢出 | 输出 NaN | scale 计算偏差 | 动态缩放 + 滑动窗口 | 消除 NaN |
| 层间误差累积 | 深层输出漂移 | 逐层量化误差叠加 | 后几层保持 FP16 | 困惑度 -0.3 |
这些精度问题不是理论推演,是我在实际部署中一个个撞出来的。每次撞墙都得回退到 FP16 做 baseline 对比,逐层排查,耗时但值得。毕竟长上下文场景下,模型“失忆”比生成质量下降更致命,用户直接就能感知到。
4. 推理框架选型:vLLM、TensorRT-LLM 与 SGLang 的 FP8 支持对比
FP8 量化不是模型层面的孤立操作,推理框架的支持程度直接决定落地难度。我先后在 vLLM、TensorRT-LLM 和 SGLang 上部署过同一套 Llama 3 8B 的 FP8 权重,踩的坑各不相同。
vLLM 的 FP8 支持从 0.4 版本开始成熟,配置最简单,只需要在启动参数里加--quantization fp8,框架会自动处理权重加载和 KV Cache 量化。优点是生态好、文档全、社区活跃,遇到问题容易找到答案。缺点是 KV Cache 的 FP8 量化粒度固定为 per-tensor,没法自定义,长上下文下的精度损失比预期大。另外 vLLM 的 chunked prefill 和 FP8 同时开启时,偶尔会出现显存碎片,需要调--gpu-memory-utilization来规避。
TensorRT-LLM 的 FP8 支持最彻底,从权重到激活到 KV Cache 都能细粒度控制,还支持混合精度策略。性能也是三者里最好的,128K 上下文下的吞吐量比 vLLM 高 30% 左右。但代价是编译流程复杂,需要先把模型转成 TensorRT engine,量化策略在编译时固定,改一个参数就得重新编译。我试过调 KV Cache 的量化粒度,从 per-tensor 改成 per-head,编译花了 40 分钟,迭代成本很高。
SGLang 的定位介于两者之间,FP8 支持通过--quantization fp8开启,KV Cache 量化支持 per-head 粒度,配置比 TensorRT-LLM 简单,性能比 vLLM 略好。SGLang 的 RadixAttention 在长上下文场景下有优势,前缀共享做得好,多轮对话的缓存命中率高。但生态相对小,遇到冷门问题得自己读源码。
| 框架 | FP8 权重 | FP8 激活 | KV Cache 量化粒度 | 长上下文优化 | 部署复杂度 | 128K 吞吐量(相对值) |
|---|---|---|---|---|---|---|
| vLLM | 支持 | 支持 | per-tensor | chunked prefill | 低 | 1.0 |
| TensorRT-LLM | 支持 | 支持 | per-head 可配 | 自定义 kernel | 高 | 1.3 |
| SGLang | 支持 | 支持 | per-head | RadixAttention | 中 | 1.15 |
选型建议很直接:如果你追求快速验证和迭代,vLLM 是首选;如果追求极致性能和细粒度控制,且团队有编译部署经验,TensorRT-LLM 更合适;如果场景是多轮对话或者 RAG,SGLang 的缓存机制能省不少算力。
提示:不管用哪个框架,FP8 量化后的模型都要做一轮精度回归测试。我通常会用 200 到 500 条领域内的评测样本,对比 FP16 和 FP8 的输出差异,重点看长距离依赖和数值敏感任务。回归测试通过后再上生产,能避免很多线上事故。
还有一个实际部署中的细节:FP8 权重的加载速度比 FP16 快,因为文件体积小了一半。但首次推理的预热时间可能更长,因为框架需要做量化参数的校准和 kernel 的 JIT 编译。我实测过,vLLM 加载 FP8 权重的冷启动时间比 FP16 多 15 到 20 秒,但后续推理的吞吐量提升能很快把这部分时间赚回来。
5. 长上下文场景下的显存账本与 batch size 权衡
长上下文部署最现实的问题不是精度,是显存不够。128K 上下文下,即使做了 FP8 量化,KV Cache 依然是大头。以 Llama 3 70B 为例,FP8 量化后权重占 35GB 左右,128K 的 KV Cache 占 20GB,加上激活值和框架开销,单卡 80GB 显存勉强能跑 batch size 1。如果想提高并发,必须做张量并行或者降低上下文长度。
我算过一笔细账。Llama 3 70B 有 80 层,每层 8 个 KV head,head dimension 128。FP16 精度下,每个 token 的 KV Cache 大小是 2 × 80 × 8 × 128 × 2 字节 = 327KB。128K token 就是 40GB。FP8 量化后减半到 20GB。如果换成 GQA 的 8 个 KV head(Llama 3 70B 本身就是 GQA),这个数字已经算过了。如果是 MHA 的 64 个 KV head,FP16 下 128K 的 KV Cache 会达到 320GB,根本没法单机部署。
batch size 的权衡更微妙。batch size 从 1 增加到 2,KV Cache 翻倍,但权重的显存占用不变,所以总显存不是简单翻倍。以 80GB 卡为例,FP8 权重 35GB,128K 上下文 batch size 1 的 KV Cache 20GB,总占用 55GB,还剩 25GB 余量。batch size 2 时 KV Cache 40GB,总占用 75GB,勉强能跑但没余量给激活值。所以实际部署中,128K 上下文通常只能跑 batch size 1 到 2,想提高并发得靠多卡或者请求队列。
| 模型规模 | 精度 | 权重显存 | 128K KV Cache(batch 1) | 总显存 | 80GB 卡可跑 batch |
|---|---|---|---|---|---|
| 8B | FP16 | 16GB | 4GB | 20GB | 3-4 |
| 8B | FP8 | 8GB | 2GB | 10GB | 6-8 |
| 70B | FP16 | 140GB | 40GB | 180GB | 需多卡 |
| 70B | FP8 | 70GB | 20GB | 90GB | 单卡 batch 1 |
| 70B | FP8 + 4bit 权重 | 35GB | 20GB | 55GB | 单卡 batch 1-2 |
从表里能看出,FP8 对 8B 模型的收益是让 batch size 翻倍,对 70B 模型是让单卡部署成为可能。如果进一步把权重压到 4bit,70B 模型能在单卡上跑 batch size 2,但 4bit 权重的精度损失比 FP8 大,需要评估业务容忍度。
还有一个省显存的技巧是 PagedAttention,vLLM 和 SGLang 都支持。它把 KV Cache 分成固定大小的块,按需分配,避免预分配造成的浪费。实测下来,PagedAttention 能让 128K 场景的显存利用率提升 20% 到 30%,尤其是变长请求混合的场景。但 PagedAttention 和 FP8 KV Cache 同时开启时,块大小的选择会影响量化效果,块太小会导致 scale 计算不稳定,块太大又失去灵活性。我一般设 16 或 32 个 token 一块,平衡精度和显存。
6. 实测中的意外发现与调优经验
跑完这一轮 FP8 加长上下文的实测,有几个发现和预期不太一样,值得单独拎出来说。
第一个意外是 FP8 量化对生成速度的提升没有想象中大。理论上 FP8 的矩阵乘法吞吐量是 FP16 的两倍,但实际推理中,瓶颈往往在注意力计算和 KV Cache 的读写上,矩阵乘法的占比只有 40% 到 60%。所以 FP8 带来的端到端加速通常只有 20% 到 35%,而不是翻倍。真正让吞吐量翻倍的是 batch size 的提升,而 FP8 通过省显存间接实现了这一点。
第二个意外是长上下文下的输出质量对温度参数更敏感。FP16 精度下,温度 0.7 和 0.8 的输出差异不大;FP8 量化后,同样的温度差异会导致生成风格明显不同,温度稍高就容易出现重复或者逻辑跳跃。我的经验是把温度调低 0.1 到 0.2,配合 top-p 0.9 左右,能在保持多样性的同时减少量化带来的噪声放大。
第三个意外是 RoPE 缩放方案和 FP8 量化的交互。YaRN 在 FP16 下表现最好,但和 FP8 叠加时,由于 YaRN 对高频部分的处理更激进,量化误差在高频维度上被放大,导致长距离检索准确率反而略低于 NTK-aware。这个发现和直觉相反,我反复验证了三次才确认。所以如果你的部署方案是 FP8 加长上下文,RoPE 方案的选择需要重新评估,不能直接沿用 FP16 下的最优解。
调优过程中还总结了几条实操经验。第一,FP8 量化后的模型一定要做 warm-up,让动态缩放的统计量稳定下来,通常跑 10 到 20 条长样本后,输出质量会明显改善。第二,KV Cache 的量化 scale 不要用全局最大值,用滑动窗口的 99.9 分位数更稳,能避免个别异常值把 scale 拉偏。第三,如果业务对首 token 延迟敏感,可以关闭 chunked prefill,用完整的 prefill 换更低的 TTFT,代价是显存峰值升高。
注意:FP8 量化不是一劳永逸的。模型微调、数据分布变化、甚至推理框架版本升级,都可能让原本稳定的量化参数失效。建议把量化配置和精度回归测试纳入 CI 流程,每次变更都跑一遍,避免线上翻车。
最后分享一个排查精度问题的笨办法但很有效:逐层对比 FP16 和 FP8 的激活值分布,用直方图看哪些层的分布偏移最大。通常偏移最大的那几层就是精度崩塌的源头,针对性地把这些层保持 FP16,其余用 FP8,能在精度和显存之间找到不错的平衡点。我用这个方法在 70B 模型上定位到了第 18、42、67 层是敏感层,单独保留 FP16 后,困惑度从 5.89 降到了 5.54,显存只多了 3GB。