量化在推理里省的是什么:
容量、带宽和算力
拿到一个量化模型,我们最先要搞清楚的通常是这几件事:它省了什么,什么场景下会变快、什么场景下不会,config.json 里该看哪几个字段,以及如何衡量量化精度。量化算法内部怎么做,这篇基本不涉及。
1为什么推理要量化
量化就是用更少的比特来存数和算数。推理时显存里放着权重和 KV cache,每一步 decode 都要把权重搬到 Tensor Core 上做矩阵乘,所以量化能省三种资源:显存容量、显存带宽和算力。以 BF16 换成 8 位为例,每个数的字节数减半,三种资源受益的场景各不相同:
2数字在显存里怎么存
符号 / 指数 / 尾数
浮点数由三部分组成:
- 符号位:管正负。
- 指数位:管范围,决定能表示多大、多小的数。
- 尾数位:管精度,决定同一个量级里能分得多细。
FP8 E4M3 只有 4 位指数、3 位尾数,范围和精度两头都很紧:
- 最大只能表示 448。
- 最小正规数是 2-6 ≈ 0.0156,最小次正规数是 2-9 ≈ 0.00195(次正规数即 subnormal,指数全 0 时不再补隐含的 1,让刻度继续往 0 靠)。
- 8 位一共 256 个编码,去掉 2 个 NaN,剩下 254 个有限值,其中正数 126 个。
位数从 16 降到 8 省下了容量和带宽,付出的代价是每个数只剩 3 位尾数,一共 254 个有限值。下一章看把高精度的数转成这些格式时会发生什么。
3从高精度到低精度,再回到高精度
直觉做法:直接舍入
从高精度到低精度,最先想到的办法是直接舍入。以 FP32 转 BF16 为例:BF16 的指数和 FP32 一样是 8 位,范围相同,只是尾数从 23 位砍到 7 位。所以 FP32 转 BF16 直接舍入就行,丢的只是精度,不会越界。下面拿 0.0137 走一遍。
再换一个数,3.14159 转成 BF16 是 3.140625,相对误差约 0.03%。BF16 有 7 位尾数,舍入的最大相对误差在 2-8 ≈ 0.39% 以内,对推理来说通常可以接受。
FP16 要小心一些:它只有 5 位指数,最大 65504,最小正规数约 6.1 × 10-5。权重一般落在这个范围里,可以直接转;但激活或中间结果偶尔会超出去,大的溢出成 inf,小的下溢。这常被认为是大模型训练更多用 BF16 的原因之一。
这个直觉放到 FP8 上为什么行不通
大模型里的权重和激活,数值大多集中在 0 附近,量级很小。下面用一组量级很小的示意数据来看:10 万个数,服从均值 0、标准差 0.002 的正态分布。直接转 BF16 的误差上面已经看过,问题出在直接转 FP8。
图里标红的次正规区,是 FP8 最小正规数 2-6 ≈ 0.0156 以下的那一段。到这里指数位已经是全 0,不再补隐含的 1,刻度没法继续按 2 倍区间往 0 加密,只能等间距地排下去,间距固定是 2-9 ≈ 0.00195。数越小,这个间距相对它自己就越大,所以越靠近 0 相对精度越差。
直接转的问题在于这组数挤在 ±0.0085 以内,用不到 FP8 大部分的刻度。先用一个比例尺把这组数整体放大,让最大的那个数正好落在 448,再转 FP8,就能用上整把尺子。这个比例尺就是 scale:先把高精度的数按这个比例映射到 FP8 的刻度上,用的时候再映射回去。这时自然会有两个问题:
- 具体怎么映射?
- 为什么用 FP8,不用同样是 8 位的 INT8?
具体怎么映射:量化后的数存的是什么
先用 scale 缩放,量化后的数就不再是原来那个数了。比如一组权重里绝对值最大的是 0.05,那么其中的 0.02 量化之后是 176(0.02 ÷ (0.05 ÷ 448) ≈ 179.2,再舍入到最近的刻度 176)。单独看 176 这个数没有意义,和真实值 0.02 差了近 9000 倍。
量化后的数记录的是这个数相对于这组里最大的那个数有多大。可以把 scale 理解成尺子上一个单位代表的真实长度,量化后的数是用这把尺子量出来的读数,两者相乘(x′ = 读数 × scale),才回到真实的单位。所以一组量化后的数由两部分组成:
- 一组读数,每个 8 位。
- 尺子本身 scale,一组只有一个,占的空间相比读数很小,所以容量和带宽上的节省基本不受影响。
量化的步骤:跟着 4 个数走一遍
假设一个张量里有这样 4 个数:12、7.25、−8.95、1.71。跟着它们走一遍:
- 找出绝对值最大的数(absmax):这里是 12。用它定出 scale = 12 ÷ 448 ≈ 0.0268。
- 每个数除以 scale,换到 FP8 的尺子上:12 → 448,7.25 → 270.7,−8.95 → −334.1,1.71 → 63.8。
- 舍入到最近的刻度,得到量化后的数:FP8 只能表示那些离散的刻度值,不在刻度上的数必须舍入到离它最近的那一个。
- 448 本身就是刻度,不变。
- 270.7 和 −334.1 所在的区段,相邻刻度相差 32,分别被舍入到 256 和 −320,偏离了一段明显的距离。
- 63.8 离刻度 64 很近,几乎不变。
- 用的时候乘回 scale,得到 x′:12、6.857、−8.571、1.714。和原值比,误差分别是 0%、5.4%、4.2%、0.25%。
下图把这四步画了出来,最后一步的虚线把原值投到输出尺上,前后差多少可以直接看出来。
误差来自舍入这一步:舍入时偏离刻度的距离乘回 scale,就是最终的偏差。刻度稀的区段(绝对值大的数)偏离得多,刻度密的区段偏离得少。
absmax ÷ 448 其实是把原值和 FP8 刻度映射起来的一种方法,也是最简单的一种:保证最大的数不越界,其余的数按比例落下去。还有一些更复杂的映射策略,目标都是让量化带来的误差尽量小,大致分两类:
- 只调映射本身。比如故意把 scale 取小一点,让少数最大的值被截断,换来大部分数的精度更高。scale 取多大,按误差最小去搜。
- 连权重的值也一起调,目标是让这一层输出的误差最小,而不只是这组数自己的误差最小,比如 GPTQ、AWQ(见第 6 章)。
对 FP8 来说,absmax 通常已经够用,因为它的相对精度处处差不多;复杂的映射策略在 INT4 这类刻度很少的格式上更重要。
为什么用 FP8,不用 INT8
INT8 量化的流程也是上面这几步,只是 scale = absmax ÷ 127,舍入到等距的整数刻度(−127…127)。两者都是 8 位、256 个编码,区别只在刻度怎么排。下图把两种刻度都换算回原始数值,画在同一条横轴上,最下面一行是一个真实权重矩阵里的数。
两种刻度的误差规律正好相反:
- INT8 的绝对误差处处一样,最大是 scale 的一半(absmax ÷ 254)。落在 0 附近的小数,这个误差相对它自己就很大,可能和数本身差不多,甚至直接变成 0。
- FP8 的绝对误差随数变大而变大,靠近 absmax 时比 INT8 还大,但相对误差大致保持在一个范围里(正规数区最大约 3.3% 到 6.3%)。
真实张量里的数大多挤在 0 附近,所以 FP8 更合适。按这个矩阵的全部元素统计,相对误差的中位数 INT8 是 7.2%,FP8 是 2.2%;INT8 有 9.1% 的数直接变成 0。
反过来,如果一组数跨度很窄、也没有离群值,INT8 的等距刻度(7 位幅值)反而比 FP8(3 位尾数)分得更细。所以 FP8 的优势在动态范围大;数据跨度窄的时候,它并不比 INT8 准。
4粒度:一个 scale 管多大一组
前面一直说「一组数配一个 scale」,这一组可以划得很粗,也可以划得很细。常见的有三种:
- per-tensor:整个张量共用一个 scale,最粗,也最省事。
- per-channel:矩阵的每一行(或每一列)共用一个 scale。激活按行、每个 token 一个 scale 时,常叫 per-token。
- per-block:把矩阵切成小块,每块一个 scale。
一个实际的例子是 DeepSeek-V4.1-Flash:按公开的 config 和参考代码,权重每 32×32 一块配一个 scale,激活每 32 个数配一个 scale。
粒度放细的意义在于隔离离群值,也就是一组数里个别特别大的数。scale 由一组里绝对值最大的数决定,组越大,越容易混进一个离群值,把其他数挤向 0 附近;切成小块以后,一个离群值只会拖累它所在的那一块。下图在三种粒度里各放了同一个离群值:
代价有两个:
- scale 变多,要多存一些。按 32×32 一块算,每 1024 个 FP8 权重多 1 个 scale:scale 用 FP32 存时多占约 0.4%,用 ue8m0(1 字节)存时约 0.1%。
- 矩阵乘要在中间多次乘 scale。per-tensor 或按行的 scale 可以等矩阵乘累加完再乘一次。沿求和方向切块后,每算完一块就要乘一次,kernel 也更复杂。
5量化谁:W / A / KV,以及 WxAy 记法
WxAy 这种记法说的是进入矩阵乘的两个输入(权重 W、激活 A)各是几位,和输出无关。下面两种方案的输出都是 BF16,层与层之间传的也都是 BF16。
- W8A8:先把激活在线量化成 FP8,然后做 FP8×FP8 的矩阵乘、FP32 累加,乘上 scale 后输出 BF16,到下一层入口再量化一次。
- W8A16 / W4A16:把权重变回 BF16,做 BF16×BF16 的矩阵乘,同样是 FP32 累加、输出 BF16。
两者的累加器都是 FP32,精度上的差别来自激活:W8A16 的误差只来自权重,W8A8 还多了一份激活的量化误差。
另外,W8A8 的激活要在推理时现场量化,这带来两点麻烦:
- 开销:每次都要先求 absmax、算 scale、再量化,这是额外的计算。所以收益要能抵过这部分开销,才能真正加速,否则可能反而更慢。不过容量上的优势(权重字节减半)是一定能拿到的。
- 难度:激活每次输入都不一样,没法像权重那样离线慢慢调。
三种方案各省什么
| 量化谁 | 省什么 | 哪边有效 |
|---|---|---|
| 只量权重(W8A16 / W4A16,weight-only) | 容量 + 带宽,不省算力 | decode(带宽型) |
| 权重 + 激活(W8A8) | 再加上 算力 | prefill(算力型)也有效 |
| KV cache | 容量 + attention 的带宽;attention 是否用 FP8 算看 kernel | 长上下文、高并发 |
6AWQ / GPTQ 是什么,和 absmax ÷ 448 的关系
前面「absmax ÷ 量程 + 就近舍入」这套做法叫 RTN(round-to-nearest)。8 位的刻度够多,RTN 通常够用;INT4 只有 16 个刻度,再用 RTN 误差就太大了。
AWQ 和 GPTQ 基本都是为 4 位权重(W4A16)设计的。它们没有引入新的数据格式,改的是每个权重最后取哪个值。产物形态和 RTN 一样,还是低比特权重加 scale;各家的打包格式、zero point 约定有差别,框架按 quant_method 选对应的 kernel,推理时做的都是 W4A16 的反量化加 GEMM。
| 方法 | 最小化什么 | 怎么做 |
|---|---|---|
| RTN | ‖W − W′‖(权重本身的误差) | absmax ÷ 量程,就近舍入 |
| AWQ(arXiv 2306.00978) | ‖WX − W′X‖(输出的误差,需要校准数据拿到 X) | 和大激活相乘的权重 channel 更重要:量化前放大它们,降低相对误差 |
| GPTQ(arXiv 2210.17323) | 逐列量化,每量化一列就调整剩余未量化的权重来补偿误差(用校准数据算二阶信息) |
- W:原权重
- W′:量化再反量化后的权重
- X:校准数据上的激活
对 infra 来说,这些都发生在离线阶段,推理时只会在 config 里看到一个 quant_method 字段。AWQ / GPTQ 的作用是让 4 位的时候精度别掉太多。
7读懂量化模型的 config
拿公开的 DeepSeek-V4.1-Flash 来看,量化相关的字段都在 config.json 的 quantization_config 里:
| 字段 | 含义 |
|---|---|
quant_method | 量化方案名,框架据此选 kernel |
activation_scheme | dynamic = 推理时现场算激活 scale;static = 校准好的固定 scale |
weight_block_size | 权重分块粒度,[32, 32] = 每 32×32 一个 scale |
scale_fmt = ue8m0 | scale 只有 8 位指数、无尾数 → 只能是 2 的整数次幂 |
expert_dtype = fp4 | routed expert(路由专家)权重用 FP4 E2M1 |
参考代码(公开 inference/model.py 的 linear())注释原文:
译:量化权重需要配量化激活,fp4 与 fp8 权重都配 fp8 激活,fp4 的混合精度由 kernel 处理。
按 linear() 的分派规则:
- 走 fp8 权重的线性层是 W8A8。
- 走 fp4 的 expert 是 W4A8。
具体哪些层保留 BF16,要去看 safetensors。
别的量化模型还会出现一些常见字段:fmt(如 e4m3)、bits / group_size(INT4 类),以及被排除在量化之外的模块(如 lm_head)。safetensors 里也会有对应的 scale 张量(如 weight_scale_inv)。
8如何衡量量化精度
量化省下来的资源,前提是输出没错。精度验证可以分三层,越往下成本越高、覆盖的范围也越大;上一层通过了,不代表下一层也没问题。
分布级里的 NLL(negative log-likelihood,负对数似然)在这个场景下和交叉熵是同一个量,perplexity = exp(NLL)。
另外,每一组推理配置都要测精度,包括高并发。不要只测了串行精度正常,就直接去测高并发下的性能。
汇总:拿到量化模型看哪些字段
把前面的内容按「看哪个字段、省哪种资源、哪边有收益」整理成一张表:
| 拿到量化模型,看哪个字段 | 它告诉你 | 对应省的资源 | 哪边有收益 |
|---|---|---|---|
权重位宽(quant_method / dtype) | 权重字节数 | 容量 + 带宽 | decode(带宽型) |
activation_scheme、是否量化激活(A8 / A16) | 是否走低精度 Tensor Core | 算力 | prefill(算力型) |
| KV cache dtype | KV 字节数 | 容量(+ attention 带宽) | 长上下文、高并发 |
weight_block_size、scale_fmt | scale 粒度与格式 | 精度 / kernel 路径 | 能否真的快、会不会掉点 |