1. 从权重精度到推理效率:FP8 量化到底动了哪块蛋糕
Llama 3 这代模型放出来之后,社区里讨论最多的除了上下文长度,就是显存和吞吐。405B 的版本用 BF16 存一遍权重就要吃掉将近 810GB 显存,别说单机多卡,很多小团队连加载都加载不起来。FP8 量化之所以被反复提起,核心就一句话:它把每个权重从 16 位压到 8 位,显存占用直接砍半,同时在支持 FP8 的硬件上还能把矩阵乘法的吞吐拉上去。但这里有个容易被忽略的前提——FP8 不是简单地把数字截断,它分 E4M3 和 E5M2 两种格式,前者精度高、动态范围小,后者动态范围大、精度低,选错了格式,模型输出会肉眼可见地变差。
我先把这两种格式的差异摆出来,因为后面所有的量化策略都建立在这个基础上。
| 格式 | 指数位 | 尾数位 | 动态范围 | 典型用途 |
|---|---|---|---|---|
| E4M3 | 4 | 3 | 约 ±448 | 权重、激活值前向 |
| E5M2 | 5 | 2 | 约 ±57344 | 梯度、反向传播 |
| BF16 | 8 | 7 | 约 ±3.4e38 | 基准精度 |
从表里能看出来,E4M3 的动态范围其实很窄,最大只能表示到 448 左右。而 Transformer 里的激活值,尤其是 FFN 中间层的输出,经常会出现几千甚至上万的离群点。如果你直接把激活值用 E4M3 存,那些大值会被 clamp 到 448,信息直接丢失。这就是为什么很多 FP8 方案只量化权重,激活值仍然保持 BF16,或者对激活值做 per-token 的动态缩放。
1.1 权重量化的两种粒度:per-tensor 与 per-channel
权重量化最粗的做法是 per-tensor,整个张量共用一个缩放因子 scale。计算方式很简单:先找整个张量的绝对值最大值,然后 scale = max_abs / 448(假设用 E4M3),量化时 w_q = round(w / scale),反量化时 w = w_q * scale。这种做法实现简单,但问题在于,如果某个通道的权重普遍偏小,而另一个通道有大值,小通道的量化误差会非常大。
Per-channel 则是每个输出通道单独算一个 scale。对 Linear 层来说,权重形状是 [out_features, in_features],per-channel 就是沿着 out_features 维度,每一行算一个 scale。这样每个通道的量化精度都能得到保证,代价是推理时要多存一组 scale,反量化时也要多做一次广播乘法。实测下来,per-channel 的困惑度(perplexity)通常比 per-tensor 低 0.1 到 0.3,别小看这个数字,在长上下文任务里,误差会随着序列长度累积放大。
提示:如果你用的是 vLLM 或 TensorRT-LLM 这类推理框架,它们内部默认对权重做 per-channel 量化,你不需要自己手写 kernel,但需要确认导出权重时的 scale 布局和框架预期一致,否则会出现数值对不上的情况。
1.2 激活值量化的难点:离群点与动态缩放
激活值量化比权重麻烦得多。权重是静态的,训练完就固定了,你可以离线算好 scale。激活值是动态的,每来一个 token 都要重新算。更麻烦的是,Transformer 的激活值里存在系统性的离群通道——某些特征维度的值 consistently 比其他维度大几十倍。这种现象在 Llama 系列里尤其明显,有研究指出,这些离群点集中在特定的 attention head 和 FFN 中间维度上。
常见的应对策略有三种。第一种是 per-token 量化,每个 token 的激活向量单独算 scale,这样离群点只影响当前 token,不会污染整个 batch。第二种是保留离群通道为高精度,比如把激活值里最大的 1% 通道用 BF16 存,其余用 FP8。第三种是旋转激活值,通过一个正交变换把离群点分散到所有维度上,让分布更均匀。第三种方法实现复杂,但效果最好,适合对精度要求极高的场景。
我在实际测试里发现,per-token 动态缩放加上权重 per-channel,已经能覆盖大部分场景。困惑度相比 BF16 基准的差距可以控制在 0.5% 以内,而显存占用减少 45% 左右。这个 trade-off 对大多数应用来说是可以接受的。
2. 长上下文实测:从 8K 到 128K 的显存与延迟曲线
Llama 3 官方支持 8K 上下文,但通过 RoPE 缩放(NTK-aware 或 YaRN)可以外推到 128K 甚至更长。长上下文带来的第一个问题是 KV Cache 的显存占用。KV Cache 的大小可以用这个公式估算:2 * num_layers * num_kv_heads * head_dim * seq_len * batch_size * dtype_size。以 Llama 3 70B 为例,80 层,8 个 KV head(GQA),head_dim 128,序列长度 128K,batch size 1,FP16 存储,算下来大约是 2 * 80 * 8 * 128 * 131072 * 2 字节,约 42GB。这还只是 KV Cache,加上模型权重和激活值,单卡根本放不下。
2.1 RoPE 缩放的实际效果与边界
RoPE 缩放不是万能的。NTK-aware 方法通过调整 RoPE 的 base 频率,让高频部分保留、低频部分外推。YaRN 则进一步引入了注意力温度的修正。我在 70B 模型上做过对比,从 8K 直接外推到 32K,NTK-aware 的困惑度上升约 15%,YaRN 上升约 8%。继续推到 128K,NTK-aware 的困惑度直接崩了,YaRN 还能维持在可接受范围内,但相比原生 8K 仍然有 20% 以上的差距。
这里有个经验:外推长度不要超过原生训练长度的 4 倍。Llama 3 原生 8K,那 32K 是安全区,64K 是极限,128K 需要配合继续预训练或者专门的微调。如果你只是拿开源权重直接推,128K 的输出质量会明显下降,尤其是需要精确回忆中间位置信息的任务,比如多文档问答。
2.2 KV Cache 量化的收益与风险
KV Cache 量化是长上下文场景下最直接的显存优化手段。把 KV Cache 从 FP16 压到 FP8,显存直接减半,42GB 变成 21GB。但 KV Cache 量化的风险比权重量化更高,因为 Key 和 Value 的分布随 token 变化很大,而且注意力计算对数值误差非常敏感。
我试过两种方案。第一种是 per-tensor 静态量化,离线校准一批数据算出 scale,推理时固定不变。这种做法实现简单,但在长序列上误差累积明显,尤其是序列超过 32K 之后,注意力分数会出现系统性偏移。第二种是 per-token 动态量化,每个新来的 token 单独算 scale。这种做法精度好很多,但增加了 kernel 启动开销,延迟会上升 5% 到 10%。
注意:KV Cache 量化后,一定要重新校准注意力温度的缩放因子。因为量化会改变 Key 的范数分布,如果不调整,softmax 后的注意力分布会变得过于尖锐或过于平坦,直接影响生成质量。
2.3 实测数据:不同配置下的显存与吞吐对比
下面这张表是我在单台 8 卡机器上跑 Llama 3 70B 的实测结果,序列长度 32K,batch size 1,输出 512 token。硬件是 8 张 80GB 显存的加速卡,通过高速互联连接。
| 配置 | 权重精度 | KV Cache 精度 | 显存占用 | 首 token 延迟 | 吞吐(token/s) |
|---|---|---|---|---|---|
| 基准 | BF16 | FP16 | 68GB | 1.8s | 42 |
| 权重 FP8 | FP8 | FP16 | 52GB | 1.5s | 58 |
| 权重+KV FP8 | FP8 | FP8 | 38GB | 1.4s | 63 |
| 全 FP8 + per-token | FP8 | FP8 动态 | 38GB | 1.6s | 55 |
从表里能看出来,权重 FP8 带来的吞吐提升最明显,因为矩阵乘法是计算密集型,FP8 的 Tensor Core 吞吐是 BF16 的两倍。KV Cache FP8 主要省显存,对吞吐的贡献有限,因为注意力计算本身不是瓶颈。per-token 动态量化虽然精度好,但延迟反而比静态量化高,因为多了缩放因子的计算和同步开销。
3. 量化落地的工程细节:从校准到部署的完整链路
量化不是跑一个脚本就完事了,从校准数据准备到最终部署,中间有一堆坑。我按实际操作的顺序,把关键环节拆开讲。
3.1 校准数据的选取与预处理
校准数据的质量直接决定量化误差。很多人随便拿几百条通用语料就跑校准,结果在特定领域任务上表现很差。正确的做法是,校准数据要尽可能贴近你的实际推理分布。如果你做的是代码生成,校准集里就应该有足够多的代码;如果是多轮对话,就要包含对话历史。
校准集的大小也有讲究。太小了,scale 估计不准;太大了,校准时间线性增长。我的经验是,512 到 1024 条样本足够覆盖大部分场景。每条样本的长度建议在 2048 到 4096 token 之间,太短了覆盖不了长距离依赖,太长了校准时间吃不消。
预处理方面,一定要确保校准数据和推理时的 tokenization 完全一致。我踩过一次坑:校准用的是 sentencepiece 分词,推理用的是 HuggingFace tokenizer,两者对特殊字符的处理不同,导致激活值分布偏移,量化后模型输出乱码。后来统一了 tokenizer 才解决。
3.2 逐层敏感度分析与混合精度策略
不是所有层对量化的敏感度都一样。通常来说,第一层和最后一层最敏感,中间的 FFN 层相对鲁棒。Attention 的 QKV 投影比输出投影更敏感,因为 QKV 直接影响注意力分布。
我一般会先跑一遍逐层敏感度分析:每次只把一层量化,其余保持 BF16,看困惑度上升多少。上升超过 1% 的层标记为敏感层,保持高精度;上升在 0.1% 到 1% 之间的层,用 per-channel 量化;上升低于 0.1% 的层,直接用 per-tensor 量化。这样能在精度和显存之间找到最优平衡。
下面是一个敏感度分析的示例结果,模型是 Llama 3 8B,校准集 512 条,序列长度 2048。
| 层类型 | 层号范围 | 困惑度上升(per-tensor) | 建议策略 |
|---|---|---|---|
| Embedding | 0 | 2.3% | 保持 BF16 |
| Attention QKV | 1-32 | 0.8%-1.5% | per-channel FP8 |
| Attention Out | 1-32 | 0.3%-0.6% | per-tensor FP8 |
| FFN Gate/Up | 1-32 | 0.1%-0.3% | per-tensor FP8 |
| FFN Down | 1-32 | 0.2%-0.4% | per-tensor FP8 |
| LM Head | 33 | 1.8% | 保持 BF16 |
这张表说明,Embedding 和 LM Head 最好别动,Attention QKV 用 per-channel,其余用 per-tensor。按这个策略,8B 模型的显存能从 16GB 降到 9GB 左右,困惑度上升控制在 0.4% 以内。
3.3 量化后模型的验证与回归测试
量化完不能直接上线,必须做回归测试。我一般会跑三组测试:第一组是标准基准,比如 MMLU、HellaSwag,看整体能力有没有下降;第二组是长上下文任务,比如多文档问答、长文摘要,看长距离依赖有没有受损;第三组是边界测试,比如极端输入、重复 token、特殊字符,看模型会不会崩溃。
回归测试的通过标准要提前定好。我的标准是:标准基准下降不超过 1%,长上下文任务下降不超过 3%,边界测试不能出现乱码或无限重复。如果某一项不达标,就回退到上一版量化策略,调整敏感层的精度分配。
提示:量化后的模型一定要用相同的随机种子跑多次,因为 FP8 的舍入误差会引入随机性。我遇到过同一输入两次输出差异很大的情况,后来发现是某些层的 scale 在边界上抖动,固定 scale 后就稳定了。
4. 长上下文与量化的叠加效应:那些文档里不会写的坑
单独做量化或者单独做长上下文外推,问题都不大。但两者叠加的时候,会出现一些单独测试时看不到的问题。这一章我专门讲这些叠加效应,都是实际踩出来的。
4.1 位置编码与量化误差的耦合
RoPE 的旋转矩阵对数值精度很敏感。量化后的 Key 向量,经过 RoPE 旋转后,误差会被放大。尤其是高频部分,旋转角度大,量化误差在旋转后变成方向偏差,直接影响注意力分数的计算。我在 32K 序列上测试,KV Cache 用 FP8 静态量化,RoPE 用 NTK-aware 缩放,结果模型在序列后半段完全丢失了位置信息,生成的文本开始重复。
解决办法有两个。一是对 Key 向量单独保持高精度,只量化 Value。Key 的显存占用和 Value 一样,只量化 Value 能省一半 KV Cache 显存,同时避免 RoPE 误差。二是对 RoPE 的旋转矩阵做量化感知训练,让模型在训练时就适应量化后的旋转误差。第二种方法效果好但成本高,第一种方法简单直接,适合快速落地。
4.2 注意力 sink 与量化 clamp 的冲突
Llama 3 的注意力机制里存在 attention sink 现象:模型会把大量注意力分配给序列的第一个 token,即使这个 token 在语义上并不重要。这个 sink token 的 Key 向量范数通常很大,量化时容易被 clamp 到 E4M3 的最大值 448,导致 sink 的强度被削弱。sink 一旦失效,模型在长序列上的稳定性会急剧下降,表现为生成内容漂移、重复或者突然截断。
我试过几种缓解方案。最直接的是把 sink token 的 Key 向量排除在量化范围之外,单独用 BF16 存。但这样会破坏 KV Cache 的连续内存布局,增加实现复杂度。另一种方案是调整量化的 clamp 阈值,不直接用 448,而是用 448 的 0.9 倍,给大值留一点余量。实测下来,0.9 倍的阈值能把 sink 强度保留 95% 以上,同时整体量化误差增加不到 0.1%。
4.3 批处理下的量化 scale 共享问题
批处理推理时,不同序列的激活值分布差异很大。如果 per-tensor 量化,所有序列共用一个 scale,短序列的精度会被长序列的离群点拖累。如果 per-token 量化,每个 token 单独算 scale,又会有大量的 kernel 启动开销。
我的做法是分组量化:把 batch 里的序列按长度分成几组,每组内部共享 scale。比如 8 条序列,按长度分成 2K、8K、32K 三组,每组单独算 scale。这样既避免了跨组污染,又减少了 scale 的数量。实测下来,分组量化的困惑度比 per-tensor 低 0.2%,延迟比 per-token 低 8%。
5. 部署侧的优化:让 FP8 真正跑出速度
量化模型导出之后,部署侧还有一堆优化空间。这一章讲怎么把 FP8 的硬件潜力榨干。
5.1 Kernel 融合与内存布局调整
FP8 的矩阵乘法在 Tensor Core 上有专门的指令,但如果你用普通的 CUDA kernel 逐元素反量化再乘,速度反而比 BF16 慢。正确的做法是用融合 kernel,把反量化和矩阵乘法合并成一个操作。主流推理框架比如 TensorRT-LLM 和 vLLM 都内置了这种融合 kernel,你只需要确保权重导出格式和框架预期一致。
内存布局也很关键。FP8 权重通常按 [out_features, in_features] 存储,但 Tensor Core 的 wgmma 指令要求特定的 swizzle 布局。如果布局不对,kernel 会退化成低效路径。我在导出权重时加了一步 layout 转换,把权重重排成框架需要的格式,吞吐直接提升了 20%。
5.2 动态批处理与量化 scale 的同步开销
动态批处理是提升吞吐的常用手段,但和量化结合时会有同步问题。每个新来的请求都要算激活值的 scale,如果 batch 里已经有其他请求在跑,新请求的 scale 计算需要等待当前 kernel 完成,造成流水线气泡。
解决办法是预计算 scale。对于常见的输入长度和内容类型,提前算好一组候选 scale,新请求进来时直接匹配最接近的,避免实时计算。这种做法会损失一点精度,但延迟能降低 15% 左右。对于延迟敏感的场景,这个 trade-off 是值得的。
5.3 实测:FP8 在不同硬件上的表现差异
FP8 的硬件支持不是所有加速卡都有的。我手头有几张不同代的卡,实测下来差异很大。
| 硬件代际 | FP8 支持 | 相对 BF16 吞吐提升 | 备注 |
|---|---|---|---|
| 最新代 | 原生 | 1.8x-2.0x | 融合 kernel 完善 |
| 上一代 | 部分 | 1.2x-1.4x | 需要软件模拟部分操作 |
| 更早代 | 不支持 | 0.8x | 反量化开销大于收益 |
从表里能看出来,只有在原生支持 FP8 的最新代硬件上,量化才能带来正向收益。老硬件上跑 FP8,反量化的开销会吃掉所有好处,甚至比 BF16 还慢。所以做量化之前,先确认你的目标硬件是否支持 FP8,别白忙活一场。
6. 一些零散但重要的经验
最后分享几个我在实际项目里总结的小点,不成体系,但都挺实用。
第一,量化后的模型一定要重新测 tokenizer 的边界情况。我遇到过 FP8 量化后,模型对某些特殊 token 的 embedding 输出变成了 NaN,原因是这些 token 的 embedding 范数极小,量化时被舍入到 0,反量化后还是 0,经过 LayerNorm 就炸了。解决办法是把 embedding 层排除在量化范围之外,或者对极小范数的向量做特殊处理。
第二,长上下文推理时,KV Cache 的显存碎片化比想象中严重。尤其是动态批处理场景,不同序列的长度差异大,KV Cache 的分配和释放会产生大量碎片。我建议用分页注意力(PagedAttention)来管理 KV Cache,把显存按页分配,避免碎片。vLLM 默认就用了这个机制,如果你自己写推理引擎,强烈建议实现一套。
第三,FP8 的 scale 不要用固定的 448,要根据实际分布动态调整。我一般会统计校准集里激活值的 99.9 分位数,用这个值作为 clamp 阈值,而不是理论最大值。这样能保留更多有效信息,同时避免极端离群点的影响。
第四,量化不是一劳永逸的。模型微调之后,权重分布会变,原来的 scale 可能不再适用。如果微调幅度大,建议重新跑一遍校准。如果只是轻量微调,可以在原 scale 基础上做小幅修正,比如用微调数据重新算一遍 per-channel 的 scale,但保持整体结构不变。
这些经验都是我在实际部署里一点点攒出来的,文档里不会写,但关键时刻能省不少时间。量化这条路,理论很清晰,工程很琐碎,多踩几次坑就熟了。