大模型量化从0到1(二):量化的数学本质——对称、非对称与 per-group 手撕
大模型量化从 0 到 1(二):看懂量化的数学本质——从 scale、zero-point 到 per-group 与误差传播
这是《大模型量化从 0 到 1》系列的第 2 篇。上一篇我们建立了“模型大小、速度、精度”之间的三角权衡;这一篇开始真正进入量化的数学内部。
本文不要求你学过信号处理,也不要求你熟悉 CUDA。你只需要会看数组、矩阵乘法和几行 Python。我们会从“把连续数值放进有限刻度”这个直觉出发,逐步推导均匀仿射量化、对称量化、非对称量化、截断、粒度、离群值与误差传播,再用可运行的 NumPy / PyTorch 代码逐项验证。
读完后,你不只会背
q = round(x / s) + z,而是能回答:这个公式为什么成立、什么时候会失效、scale 为什么不能只看最大值、group size 为什么影响精度与速度,以及 GPTQ、AWQ、SmoothQuant 分别在优化什么。
阅读目标:学完这篇,你应该能独立回答 12 个问题
- 量化到底压缩了什么?低比特整数、scale、zero-point 各自扮演什么角色?
- 为什么“量化误差不超过半个步长”不是无条件成立的?
- round 误差与 clip 误差有什么本质区别?
- INT4 到底是 16 个级别,还是
[-7, 7]的 15 个级别? - 对称量化与非对称量化的区别只是有没有 zero-point 吗?
- 为什么 min-max scale 往往不是误差最小的 scale?
- per-tensor、per-channel、per-group、per-token 分别沿哪个维度共享参数?
- group size 越小是否一定越好?它的真实存储开销怎么算?
- 为什么权重 MSE 很小,模型输出仍可能明显变差?
- W4A16、W8A8、weight-only、PTQ、QAT 这些词是什么关系?
- GPTQ、AWQ、SmoothQuant 各自利用了哪类信息?
- 为什么“模型文件缩小 4 倍”不等于“推理速度提升 4 倍”?
如果你只想快速建立主干,可以先读第 0、2、3、6、11、13 节;如果你准备自己实现或调参,建议从头顺序阅读,并实际运行第 9、10 节代码。
目录
-
- 先建立两幅图:量化是有限码本,也是有损通信
-
- 从浮点数到整数码:我们究竟在压缩什么
-
- 统一公式:均匀仿射量化
-
- 误差从哪里来:round、clip 与参数估计
-
- 对称量化与非对称量化
-
- scale 怎么选:min-max、截断与 MSE 搜索
-
- 量化粒度:per-tensor / per-channel / per-group / per-token
-
- 先把术语地图画清:W4A16、W8A8、PTQ、QAT
-
- 三次手算:把每个整数都算出来
-
- 从零实现一个可靠的 NumPy 量化器
-
- 五组实验:用数据验证直觉,也推翻过度简化的结论
-
- 从权重误差走向输出误差:为什么“重要性”比 MSE 更关键
-
- GPTQ、AWQ、SmoothQuant 与 GGUF:它们究竟解决什么
-
- 工程现实:文件大小、显存、带宽、算力与内核
-
- 常见误区与排错清单
-
- 自测题与动手练习
-
- 总结:把整篇压缩成一张认知地图
- 附录 A:完整实验脚本、运行方法与校验项
- 附录 B:从量化整数到整数矩阵乘法
- 附录 C:均匀 INT 量化之外
- 附录 D:量化质量评价方法
- 参考资料
0. 先建立两幅图:量化是有限码本,也是有损通信
理解量化最容易卡住的地方,是一上来就掉进公式。先不写公式,我们建立两幅图。
0.1 第一幅图:连续尺子换成有限刻度
假设一支温度计可以显示任意小数:20.137°C、20.138°C、20.1381°C。这是近似连续的表示。
现在你只有 8 个可用编号:
0, 1, 2, 3, 4, 5, 6, 7
但你仍要表示 [-1, 1] 里的温度变化。最自然的办法,是把区间切成 8 个刻度,再让每个真实值落到最近的刻度上。真实值是连续的,编号是有限的,所以很多不同的真实值必然会映射到同一个编号。
这就是量化最朴素的本质:
用一个有限集合中的元素,近似表示原本取值更丰富的数。
这个有限集合常被称为码本或量化网格。低比特量化不是把数“变小”,而是把可选的表示级别变少。
- 8 bit 最多提供
2^8 = 256个编码; - 4 bit 最多提供
2^4 = 16个编码; - 2 bit 最多只有
2^2 = 4个编码。
编码越少,两个相邻可表示值之间的间隔通常越大,近似误差也更难控制。但“比特越低就必然不可用”仍然是过度简化:最终是否可用,还取决于码本形状、分组方式、误差补偿、模型冗余和任务容忍度。3/4 bit 是当前常见实用区间,不代表 2 bit 在任何算法、任何模型上都绝对不可行。
0.2 第二幅图:量化是一条有损通信链路
把模型权重想成发送端要传输的信息:
原始浮点 x
↓ 编码 Q
低比特整数 q + 少量量化参数
↓ 存储 / 传输
↓ 解码 D
近似浮点 x_hat
发送前是 x,接收后是 x_hat。如果 x_hat != x,差值就是量化误差:
e=x^−x e = \hat{x} - x e=x^−x
这条链路提醒我们,评价量化至少有三层:
- 表示层:单个数还原得准不准,例如绝对误差、MSE。
- 算子层:矩阵乘法的输出变了多少。
- 模型层:困惑度、准确率、生成质量和稳定性变了多少。
很多“看起来数学上误差很小,模型却掉点明显”的现象,正是因为这三层并不等价。本文前半段先把表示层讲透,后半段再把误差推到矩阵乘法与模型输出。
1. 从浮点数到整数码:我们究竟在压缩什么
1.1 量化后真正存储的,不只是整数
最常见的线性量化会存三类信息:
- 低比特整数
q:权重的主体; - 缩放因子
s(scale):整数相邻一级对应多大的实数距离; - 零点
z(zero-point):哪个整数编码对应实数 0。
解码时,用 s 和 z 把整数放回实数轴:
x^=s(q−z) \hat{x} = s(q-z) x^=s(q−z)
所以“4 bit 模型”并不意味着每个参数严格只花 4 bit。它通常还要分摊 scale、zero-point、元数据、对齐与打包开销。更准确的说法是:权重主体采用 4 bit 编码。
1.2 scale 是“尺子的刻度间距”
假设 s = 0.1、z = 0,可表示值就是:
..., -0.3, -0.2, -0.1, 0, 0.1, 0.2, 0.3, ...
如果 s = 0.01,刻度更密,通常能表示得更细;但在整数范围固定时,能覆盖的实数范围也会缩小。
这就是 scale 的核心矛盾:
s大:覆盖范围广,但刻度粗;s小:刻度细,但更容易有数落在范围外,被截断。
因此,scale 绝不是“越小越好”。它在舍入误差与截断误差之间做平衡。
1.3 zero-point 是“把整数轴平移”
假设整数范围是 [0, 255]。如果 z = 128,那么:
q = 128 → x_hat = 0
q = 129 → x_hat = s
q = 127 → x_hat = -s
zero-point 让一个无符号整数区间也能表示负数,并能把更多编码放到数据真正出现的区间里。
把 z 设为整数还有一个重要好处:只要 z 没被范围裁掉,实数 0 就可以被精确表示。神经网络里 0 很特殊:padding、稀疏值、ReLU 输出和某些掩码都可能依赖“零仍然是零”。
1.4 量化、反量化与假量化
三个词经常混用,最好一次分清:
- 量化(quantize):把浮点
x编码成整数q。 - 反量化(dequantize):把
q解码成近似浮点x_hat。 - 假量化(fake quantization):执行“量化再反量化”,但输出仍保存在浮点张量里,用来模拟量化误差。
假量化不一定减少内存,也不等于真的调用低比特计算核。它的价值是:在普通浮点环境里观察量化影响,或者在量化感知训练中让网络提前适应误差。
2. 统一公式:均匀仿射量化
大多数入门资料分别讲对称量化和非对称量化,其实它们都能放进同一个公式。
设整数编码范围为:
q∈[qmin,qmax]∩Z q \in [q_{\min}, q_{\max}] \cap \mathbb{Z} q∈[qmin,qmax]∩Z
设 scale 为 s > 0,zero-point 为整数 z。量化函数写成:
q=Q(x)=clip(round(xs)+z, qmin, qmax) q = Q(x)=\operatorname{clip}\left(\operatorname{round}\left(\frac{x}{s}\right)+z,\ q_{\min},\ q_{\max}\right) q=Q(x)=clip(round(sx)+z, qmin, qmax)
反量化函数写成:
x^=D(q)=s(q−z) \hat{x}=D(q)=s(q-z) x^=D(q)=s(q−z)
合起来就是:
x^=s[clip(round(xs)+z,qmin,qmax)−z] \hat{x} =s\left[ \operatorname{clip}\left( \operatorname{round}\left(\frac{x}{s}\right)+z, q_{\min},q_{\max} \right)-z \right] x^=s[clip(round(sx)+z,qmin,qmax)−z]
这叫均匀仿射量化:
- “均匀”表示相邻可表示值的距离都等于
s; - “仿射”表示除了缩放,还有一个平移量
z。
2.1 round(x/s)+z 与 round(x/s+z) 是否等价
当 z 是整数,并且使用普通平移不变的舍入规则时,二者等价:
round(a)+z=round(a+z) \operatorname{round}(a)+z=\operatorname{round}(a+z) round(a)+z=round(a+z)
工程文档里两种写法都能见到。为了突出 zero-point 是整数轴上的平移,本文主要使用:
q=round(x/s)+z q=\operatorname{round}(x/s)+z q=round(x/s)+z
2.2 可表示的实数范围
给定 s、z、q_min、q_max,最小和最大可表示实数分别是:
rmin=s(qmin−z) r_{\min}=s(q_{\min}-z) rmin=s(qmin−z)
rmax=s(qmax−z) r_{\max}=s(q_{\max}-z) rmax=s(qmax−z)
所有可表示值组成等间距集合:
C={s(k−z)∣k=qmin,…,qmax} \mathcal{C}=\{s(k-z)\mid k=q_{\min},\ldots,q_{\max}\} C={s(k−z)∣k=qmin,…,qmax}
这就是量化码本。输入不在 [r_min, r_max] 内,就会被 clip 到边界编码。
2.3 “INT4”为什么可能对应不同整数范围
4 bit 一共有 16 种二进制编码,但映射方式不唯一。
约定 A:完整有符号二补码范围
q∈[−8,7] q\in[-8,7] q∈[−8,7]
共有 16 个整数,但正负不完全对称:负侧多一个 -8。
约定 B:对称窄范围
q∈[−7,7] q\in[-7,7] q∈[−7,7]
关于 0 对称,但只有 15 个有效级别,通常把 -8 留空。
约定 C:无符号范围
q∈[0,15] q\in[0,15] q∈[0,15]
再配一个 zero-point,例如 z=8,同样可以表示正负数。
因此,看到“INT4 有 16 级”与“对称 INT4 用 [-7,7]”并不矛盾:前者说的是比特编码容量,后者说的是某一种对称量化约定。不同格式、框架和内核可能选不同约定,比较结果时必须先确认 q_min/q_max。
2.4 一个最小例子
设:
s = 0.25
z = 0
q_min = -7
q_max = 7
x = 0.63
量化:
q=round(0.63/0.25)=round(2.52)=3 q=\operatorname{round}(0.63/0.25)=\operatorname{round}(2.52)=3 q=round(0.63/0.25)=round(2.52)=3
反量化:
x^=3×0.25=0.75 \hat{x}=3\times0.25=0.75 x^=3×0.25=0.75
误差:
e=0.75−0.63=0.12 e=0.75-0.63=0.12 e=0.75−0.63=0.12
由于没有发生 clip,0.12 < s/2 = 0.125。下一节会说明,这个“半步长上界”为什么必须附带前提。
3. 误差从哪里来:round、clip 与参数估计
量化误差常被一句话概括为“round 产生的误差”。这只说对了一部分。完整地看,至少有三类误差来源:
- 舍入误差:连续值落到最近网格点;
- 截断误差:值超出可表示范围,被夹到边界;
- 参数估计误差:校准数据不足,导致 scale / zero-point 不适合真实输入。
3.1 未发生截断时,误差不超过半个步长
先看最理想情况:x 位于可表示范围内,采用 round-to-nearest,并且网格间距为 s。
在任意相邻网格点之间,最远的位置是二者中点,所以:
∣x−x^∣≤s2 |x-\hat{x}|\le \frac{s}{2} ∣x−x^∣≤2s
这是非常重要的结论,但完整说法应当是:
在没有 clip、采用最近邻舍入、且使用均匀步长时,单个标量的绝对量化误差不超过
s/2。
一旦输入超出表示范围,这个上界就不成立。
3.2 发生截断时,误差可能远大于 s/2
假设最大可表示值为 1.0,步长 s=0.1,输入却是 x=5.0。量化后它只能落到 1.0:
∣x−x^∣=∣5.0−1.0∣=4.0 |x-\hat{x}|=|5.0-1.0|=4.0 ∣x−x^∣=∣5.0−1.0∣=4.0
显然远大于 s/2=0.05。
可以把总误差粗略拆成:
∣x−x^∣≤∣x−clip(x,rmin,rmax)∣+s2 |x-\hat{x}| \le |x-\operatorname{clip}(x,r_{\min},r_{\max})|+\frac{s}{2} ∣x−x^∣≤∣x−clip(x,rmin,rmax)∣+2s
第一项是超出范围造成的截断距离,第二项才是范围内部的舍入误差。
这条式子解释了 scale 的两难:
- scale 变小,
s/2变小,舍入更精细; - 但表示范围随之变窄,更多数值可能被 clip;
- scale 变大,截断减少,但正常值落到更粗的网格上。
真正优秀的量化参数,不是盲目让 s 最小,而是让这两类损失的总效果更小。
3.3 量化误差“均匀随机”只是近似模型,不是定律
经常有人写:
e∼U(−s/2,s/2) e\sim U(-s/2,s/2) e∼U(−s/2,s/2)
这在信号跨越很多量化格、分布足够平滑、舍入误差与信号近似不相关时,是一个有用的高分辨率近似。此时:
E[e]≈0,Var(e)≈s212 \mathbb{E}[e]\approx 0, \qquad \operatorname{Var}(e)\approx\frac{s^2}{12} E[e]≈0,Var(e)≈12s2
但在低比特大模型量化里,这些条件经常不满足:
- INT2 / INT3 / INT4 的网格很粗;
- 权重分布不是均匀分布;
- 大量值可能集中在 0 附近;
- 存在离群值与截断;
- 舍入误差会与权重、激活、通道结构相关。
所以更严谨的表述是:
[-s/2,s/2]是无截断时的误差范围;“均匀随机、均值为 0、方差为s²/12”是附带条件的近似模型。
这个区别很关键。后面 GPTQ 与 AWQ 的出发点之一,正是误差并非对所有权重、所有方向都同样重要。
3.4 舍入规则为什么会影响结果
常见舍入方式包括:
- round half away from zero:
0.5→1,-0.5→-1; - round half up:通常理解为向正无穷方向处理半点;
- round half to even:
0.5→0,1.5→2,2.5→2; - stochastic rounding:按距离概率随机落到相邻格点。
NumPy 的 np.round/np.rint 与 PyTorch 的 torch.round 通常采用 ties-to-even,也就是俗称的“银行家舍入”。它能减少大量恰好半点样本总向同一方向偏移的问题。
不过,真实框架还可能因为:
- 浮点表示误差;
- CPU 与 GPU 指令差异;
- 融合内核的实现;
- 先加 zero-point 还是先做其他变换;
而在极少数边界值上产生 ±1 的整数差异。验证量化器时,不要只比较打印后的小数,最好同时比较 q、s、z 和使用的舍入约定。
3.5 参数估计误差:激活量化尤其容易踩坑
权重在部署前是固定的,因此可以直接统计整块权重的最小值、最大值或直方图。激活却依赖输入:不同提示词、序列长度、语言、图像和 batch 都可能改变分布。
如果只用很少或偏科的校准样本估计激活范围,部署时可能出现:
- scale 太小:真实输入频繁溢出;
- scale 太大:大多数正常输入只使用少量整数级别;
- 某些通道没在校准集里暴露出离群值;
- 长序列与短序列的动态范围不同。
因此,对激活量化来说,“参数怎么估计”与“公式怎么写”同样重要。这也是静态量化、动态量化、per-token 量化和校准策略存在的原因。
4. 对称量化与非对称量化
对称与非对称不是两个完全不同的世界,而是统一仿射公式的两种参数约束。
4.1 对称量化:让实数网格围绕 0 对称
对称量化通常设:
z=0 z=0 z=0
并采用对称窄范围:
q∈[−qmax,qmax] q\in[-q_{\max},q_{\max}] q∈[−qmax,qmax]
对于 b bit:
qmax=2b−1−1 q_{\max}=2^{b-1}-1 qmax=2b−1−1
令:
a=maxi∣xi∣ a=\max_i |x_i| a=imax∣xi∣
最直接的 absmax scale 是:
s=aqmax s=\frac{a}{q_{\max}} s=qmaxa
量化与反量化:
q=clip(round(x/s),−qmax,qmax) q=\operatorname{clip}\left(\operatorname{round}(x/s),-q_{\max},q_{\max}\right) q=clip(round(x/s),−qmax,qmax)
x^=sq \hat{x}=sq x^=sq
对称量化的优点
- 公式简单,零点固定;
- 权重若大致以 0 为中心,码本利用合理;
- 内核更容易优化,某些计算路径能避免或吸收 zero-point 修正;
- scale 与打包元数据通常更简单。
对称量化的局限
如果数据只落在 [0, 5],对称码本仍要为负半轴预留空间,相当于浪费一部分级别。对于明显偏移或单边分布,步长会比合适的仿射量化更粗。
还要注意,“对称量化”不代表输入分布真的对称,只代表量化网格按对称约束构造。
4.2 非对称量化:用 zero-point 贴合实际区间
设观测实数范围为 [r_min, r_max],整数范围为 [q_min, q_max]。最常见的 min-max 仿射参数是:
s=rmax−rminqmax−qmin s=\frac{r_{\max}-r_{\min}}{q_{\max}-q_{\min}} s=qmax−qminrmax−rmin
z=clip(round(qmin−rmins),qmin,qmax) z=\operatorname{clip}\left( \operatorname{round}\left(q_{\min}-\frac{r_{\min}}{s}\right), q_{\min},q_{\max} \right) z=clip(round(qmin−srmin),qmin,qmax)
实际使用时,常先把 0 纳入实数范围:
rmin=min(xmin,0),rmax=max(xmax,0) r_{\min}=\min(x_{\min},0), \qquad r_{\max}=\max(x_{\max},0) rmin=min(xmin,0),rmax=max(xmax,0)
这样更容易保证实数 0 有对应的整数编码。
非对称量化的优点
- 能利用整个整数区间覆盖偏移分布;
- 对全正激活、偏置明显的数据通常更省级别;
- 在相同 bit 数下,可能得到更小的步长。
非对称量化的代价
- 需要存 zero-point;
- 矩阵乘法里要处理零点修正;
- 某些硬件或内核对特定对称格式支持更好;
- zero-point 的取整会让实际可表示端点与原始 min/max 存在轻微偏差。
“非对称更准、对称更快”只能作为初步直觉,不能当成跨平台定律。最终要看分布、格式和内核实现。
4.3 为什么权重常用对称、激活常见仿射,但不是铁律
许多训练完成的线性层权重以 0 为中心,正负都有,因此对称量化往往是自然选择。激活则可能受 ReLU、门控、归一化、残差叠加和 token 内容影响,分布更偏移或更动态,所以仿射、动态或 per-token 方案更常见。
但现代 Transformer 并不都使用 ReLU,LayerNorm/RMSNorm 后的张量也未必全正;某些权重格式会使用分组非对称量化;某些 W8A8 内核则偏好对称激活。正确做法是先看数据和部署后端,而不是机械套用“权重对称、激活非对称”。
4.4 端点为何不一定同时精确还原
在对称 absmax 量化里,定义 scale 的绝对最大值通常能落到边界编码,例如 +q_max 或 -q_max,因而可能精确还原。
在仿射量化里,zero-point 必须是整数。z 经过 round 后,实数网格会发生轻微平移,原始 r_min 和 r_max 不一定都恰好落在网格点上。端点误差不是实现出错,而是“整数 zero-point、固定级别数、同时覆盖区间”之间的离散约束。
5. scale 怎么选:min-max、截断与 MSE 搜索
公式本身并不难,真正决定效果的往往是参数选择。尤其是 scale,它决定了“刻度有多细”和“尺子有多长”。
5.1 min-max:最容易理解,也最容易被离群值绑架
最直接的做法是让量化范围覆盖观测到的全部数据。
对称量化:
s=max∣x∣qmax s=\frac{\max |x|}{q_{\max}} s=qmaxmax∣x∣
仿射量化:
s=rmax−rminqmax−qmin s=\frac{r_{\max}-r_{\min}}{q_{\max}-q_{\min}} s=qmax−qminrmax−rmin
它的优势很明确:观测范围内基本不会因为 scale 选择而额外截断。
问题也同样明确:只要出现极少数大值,整个网格就会被拉宽。假设 99.9% 的权重位于 [-0.1,0.1],只有一个值是 5.0。对称 INT4 窄范围下:
s=5/7≈0.7143 s=5/7\approx0.7143 s=5/7≈0.7143
那么绝大多数 0.01、0.03、0.08 都会落到 0 附近。为了精确容纳一个极端值,主体分布几乎失去了分辨率。
5.2 主动截断:允许牺牲极少数大值,换取主体更细的网格
假设我们不再使用真实最大绝对值 a,而是选择阈值 α<a:
s(α)=αqmax s(\alpha)=\frac{\alpha}{q_{\max}} s(α)=qmaxα
所有绝对值大于 α 的数都会被裁到边界。这样会增加离群值的截断误差,却会减小区间内部所有值的舍入误差。
量化问题于是变成一个权衡:
α∗=argminα∑i(xi−x^i(α))2 \alpha^*=\arg\min_{\alpha} \sum_i\left(x_i-\hat{x}_i(\alpha)\right)^2 α∗=argαmini∑(xi−x^i(α))2
这里的 α* 不一定等于 max|x|。在重尾分布中,一个略小的阈值常常能显著降低整体 MSE。
注意,“主动截断”不是随便删掉异常点。它是在一个明确的损失函数下,用少量边界误差换大量主体误差下降。
5.3 三类常见阈值策略
方案一:百分位阈值
例如取绝对值的 99.9% 分位数作为 α。优点是简单、速度快;缺点是百分位是经验超参数,不直接保证任务损失最小。
方案二:MSE 网格搜索
枚举若干候选阈值,执行假量化,选择重构 MSE 最小者:
候选 α1 → 量化 → MSE1
候选 α2 → 量化 → MSE2
...
取 MSE 最小的 α
它比纯 min-max 更直接地优化表示误差,适合离线权重量化。代价是需要多次扫描或计算。
方案三:基于直方图或分布距离的校准
激活量化常先收集直方图,再选择能较好保留分布的阈值。具体目标可以是 MSE、某种分布距离或框架定义的校准准则。
这里最重要的不是背某个算法名,而是理解:
校准器的任务,是从有限样本估计部署时合适的表示范围。
5.4 为什么“重构 MSE 最小”仍可能不是模型最优
普通 MSE 给所有元素相同权重:
LMSE=∑i(xi−x^i)2 L_{\text{MSE}}=\sum_i(x_i-\hat{x}_i)^2 LMSE=i∑(xi−x^i)2
但神经网络中,不同权重对输出的影响并不相同。一个绝对值很小的权重,如果它连接到幅度很大的输入通道,误差可能被放大;另一个较大的权重,如果对应输入几乎总为 0,误差影响反而很小。
因此可以考虑加权误差:
Lweighted=∑iwi(xi−x^i)2 L_{\text{weighted}}=\sum_i w_i(x_i-\hat{x}_i)^2 Lweighted=i∑wi(xi−x^i)2
其中 w_i 表示重要性。GPTQ 使用近似二阶信息处理误差传播,AWQ 用激活统计识别重要通道,它们都在回答同一件事:哪些误差更值得优先避免?
5.5 scale 的选择目标不止一种
你可能优化:
- 最大绝对误差;
- 平均绝对误差(MAE);
- 均方误差(MSE);
- 线性层输出误差;
- 任务损失或困惑度;
- 延迟、显存与精度的联合目标。
不同目标可能给出不同 scale。教程里常用 MSE,是因为它可微、易算、对大误差惩罚更强,但它不是宇宙唯一标准。
5.6 极端情况:全零张量、常数张量与极小范围
如果一组数全是 0,max|x|=0,直接计算 s=0/q_max 会得到 0,随后 x/s 发生除零。
工程实现必须规定退化情况,例如:
scale = max(absmax / qmax, eps)
对于全零组,任意正 scale 配 q=0 都能精确还原。对于近似常数但非零的数据,仿射参数也需要专门处理极小动态范围,避免数值不稳定。
这类边界条件看似不起眼,却是“课堂代码能跑”和“库代码可靠”之间的分界线。
6. 量化粒度:per-tensor / per-channel / per-group / per-token
同一套公式,多少个元素共享一组 (s,z),决定了量化粒度。
假设线性层权重矩阵:
W∈RO×I W\in\mathbb{R}^{O\times I} W∈RO×I
其中 O = out_features,I = in_features。
6.1 per-tensor:整块张量共用一把尺子
整个 W 只用一组参数:
W[所有行, 所有列] → 1 个 scale,可能再加 1 个 zero-point
优点:
- 元数据最少;
- 参数计算与内核实现简单;
- 对分布非常均匀的小张量可能够用。
缺点:
- 任意位置的离群值都能拉大全局 scale;
- 不同行、不同通道的动态范围差异被忽略;
- 低比特时精度往往不够。
6.2 per-channel:每个通道一把尺子
对线性层权重,常见做法是每个输出通道,也就是每一行,独立计算 scale:
W[0, :] → s0
W[1, :] → s1
...
W[O-1, :] → s(O-1)
为什么常按输出通道?因为矩阵乘法:
y=Wx y=Wx y=Wx
每一行对应一个输出特征。给每行独立 scale,既能适应行间动态范围,又比较容易在输出维度上广播。
但“per-channel 就一定沿行”也不是普遍真理。卷积核、转置权重、不同框架布局可能让 channel axis 不同。看到配置里的 axis=0、axis=1 时,必须结合真实张量形状理解。
6.3 per-group:一行再切成多个小组
对每一行沿输入维度分组:
W[row, 0:128] → 一组参数
W[row, 128:256] → 一组参数
W[row, 256:384] → 一组参数
...
若 group_size = G,每行大约有:
Ng=⌈IG⌉ N_g=\left\lceil\frac{I}{G}\right\rceil Ng=⌈GI⌉
个组,总组数约为:
O×⌈IG⌉ O\times\left\lceil\frac{I}{G}\right\rceil O×⌈GI⌉
per-group 的核心价值是缩小每把尺子负责的范围。一个离群值最多污染所在组,不再拖累整行或整层。
6.4 per-token:激活按 token 动态定标
对于形如:
X∈RB×T×H X\in\mathbb{R}^{B\times T\times H} X∈RB×T×H
的激活,可以让每个 token 的隐藏向量 X[b,t,:] 使用独立 scale。这通常叫 per-token 量化。
优点是能适应不同 token 的幅度变化;代价是 scale 必须运行时计算或传递,带来额外归约、元数据和内核复杂度。
还可能见到 per-batch、per-head、per-block 等方案。本质都一样:选择哪些元素共享量化参数。
6.5 粒度越细,表示误差为什么通常越小
采用同样的 absmax 规则时,把一个大集合拆成子集,每个子集的最大绝对值不会大于全局最大绝对值。因此各组 scale 通常不会更大,局部网格会更细。
但“粒度越细,模型效果一定严格单调提升”仍然不能保证,因为:
- 分组边界会改变误差结构;
- 量化算法可能同时做 clipping、重排或误差补偿;
- scale 本身也有有限精度;
- 内核、打包和累积顺序会变化;
- 更小组可能迫使使用较慢路径;
- 最终任务指标有测量噪声和非线性效应。
因此更稳妥的说法是:
细粒度通常提高表示能力,但会增加元数据与执行成本;端到端最优点依赖模型、算法和后端。
6.6 把存储开销算清楚
以 4096 × 4096 权重矩阵、INT4 主体、group_size=128、每组一个 FP16 scale 为例。
权重数:
4096×4096=16,777,216 4096\times4096=16,777,216 4096×4096=16,777,216
INT4 主体大小:
16,777,216×4 bit=67,108,864 bit=8 MiB 16,777,216\times4\text{ bit} =67,108,864\text{ bit} =8\text{ MiB} 16,777,216×4 bit=67,108,864 bit=8 MiB
组数:
4096×(4096/128)=131,072 4096\times(4096/128)=131,072 4096×(4096/128)=131,072
FP16 scale 大小:
131,072×2 byte=256 KiB 131,072\times2\text{ byte}=256\text{ KiB} 131,072×2 byte=256 KiB
仅计权重与 scale,总计约:
8 MiB + 0.25 MiB = 8.25 MiB
换算成每个权重的有效 bit 数:
4+16128=4.125 bit/weight 4+\frac{16}{128}=4.125\text{ bit/weight} 4+12816=4.125 bit/weight
如果每组还存 4 bit zero-point:
4+16128+4128=4.15625 bit/weight 4+\frac{16}{128}+\frac{4}{128}=4.15625\text{ bit/weight} 4+12816+1284=4.15625 bit/weight
实际文件还可能包含:
- 行对齐与 padding;
- 张量头信息;
- 索引与形状;
- 未量化层;
- embedding / lm_head 的特殊精度;
- tokenizer 与配置文件。
所以不能只用“参数量 × 4 bit”预测最终文件大小。
6.7 group size 的元数据账
只考虑每组一个 FP16 scale:
| group size | scale 开销 | INT4 + scale 的有效 bit/weight |
|---|---|---|
| 32 | 16/32 = 0.5 bit |
4.5 bit |
| 64 | 16/64 = 0.25 bit |
4.25 bit |
| 128 | 16/128 = 0.125 bit |
4.125 bit |
| 256 | 16/256 = 0.0625 bit |
4.0625 bit |
这张表解释了为什么 32、64、128、256 都有人用:它们处在不同的精度、元数据和内核效率位置。128 是常见选择,不是对所有模型与设备都不可更改的“物理常数”。
6.8 分组维度比 group size 数字本身更重要
同样写 group_size=128,沿输入维分组、沿输出维分组、按存储块分组,含义完全不同。实际排查时至少确认:
1. 原始权重逻辑形状是什么?
2. 文件里是否转置存储?
3. group 沿哪个 axis 切?
4. scale 的张量形状是什么?
5. 内核读取顺序与分组是否一致?
很多“自己实现结果对不上框架”的问题,不是公式错,而是 axis、转置或打包布局错。
7. 先把术语地图画清:W4A16、W8A8、PTQ、QAT
量化领域缩写很多。先把它们放在同一张地图上,后面的算法才不会混。
7.1 W4A16:权重 4 bit,激活通常保持 16 bit
W4A16 通常表示:
- 权重主体使用 4 bit;
- 激活采用 FP16 或 BF16;
- 矩阵乘法时由专用内核边读权重边解码,或先反量化再计算。
它常被称为 weight-only quantization。主要收益来自:
- 模型权重占用下降;
- 从显存读取的权重字节减少;
- 对内存带宽受限的自回归解码可能更有利。
但 W4A16 不代表所有中间值都真用 16 bit,也不保证底层一定执行“原生 INT4 乘法”。具体计算路径由内核决定。
7.2 W8A8:权重与激活都量化到 8 bit
W8A8 旨在让矩阵乘法两侧都低比特。典型整数路径可能是:
INT8 × INT8 → INT32 accumulate → rescale / cast
它不仅省权重带宽,也可能利用整数 Tensor Core 或其他低精度指令。但激活比权重更难处理,因为它随输入变化,并常有通道离群值。
7.3 A8、KV8、KV4:量化对象不止权重
还可能看到:
- 激活量化;
- KV cache 量化;
- embedding 或 lm_head 单独量化;
- optimizer state 量化;
- 梯度量化。
这些对象的分布、生命周期和性能瓶颈不同,不能用同一套结论生搬硬套。比如 KV cache 在长上下文推理中会持续增长,量化它的主要动机与只压缩静态权重并不相同。
7.4 PTQ:训练完成后再量化
Post-Training Quantization(训练后量化)不重新完整训练模型。它可能需要:
- 读取权重统计;
- 用少量校准数据收集激活;
- 搜索 scale / clipping;
- 做逐层重构或误差补偿。
“训练后”不等于“完全不优化”,也不等于“只做一次 min-max”。GPTQ、AWQ、SmoothQuant 都属于训练后量化范畴,但使用的信息和优化方式不同。
7.5 QAT:训练时就让模型看到量化误差
Quantization-Aware Training(量化感知训练)在前向中插入假量化,让模型在训练或微调时适应离散化误差。
round 和 clip 不可直接按普通方式求导,因此常用 straight-through estimator(STE)等近似处理梯度。
QAT 通常能在极低 bit 下获得更好精度,但成本更高,需要训练数据、算力和稳定的训练配方。
7.6 静态量化与动态量化
这两个词多用于激活参数何时确定。
- 静态量化:提前用校准集确定激活 scale,部署时复用;
- 动态量化:运行时根据当前输入计算 scale。
静态方案运行时开销低,但怕校准分布失配;动态方案适应性强,但要付出实时统计与额外内核成本。
7.7 校准不是训练,但校准数据仍然重要
校准通常不更新模型参数,却会影响:
- 激活范围;
- clipping 阈值;
- 通道重要性;
- 二阶统计近似;
- 层重构结果。
高质量校准集不一定要很大,但应覆盖真实部署输入。一个只含英文短句的校准集,未必适合中文长上下文、代码、数学或多模态请求。
8. 三次手算:把每个整数都算出来
公式看懂不等于真正掌握。下面用 INT4 手算三次,刻意暴露不同约定与误差来源。
8.1 手算一:对称 INT4 窄范围
给定:
x = [0.1, -0.5, 0.8, -0.3]
q ∈ [-7, 7]
z = 0
最大绝对值:
a=0.8 a=0.8 a=0.8
scale:
s=0.8/7≈0.1142857 s=0.8/7\approx0.1142857 s=0.8/7≈0.1142857
逐个量化:
0.1 / 0.1142857 ≈ 0.875 → round → 1
-0.5 / 0.1142857 ≈ -4.375 → round → -4
0.8 / 0.1142857 = 7 → round → 7
-0.3 / 0.1142857 ≈ -2.625 → round → -3
所以:
q = [1, -4, 7, -3]
反量化:
x_hat ≈ [0.11429, -0.45714, 0.8, -0.34286]
绝对误差:
[0.01429, 0.04286, 0, 0.04286]
这里最大误差小于:
s/2≈0.05714 s/2\approx0.05714 s/2≈0.05714
因为所有值都位于范围内,没有发生 clip。
8.2 手算二:无符号仿射 INT4
给定全正数据:
x = [0.0, 1.2, 3.4, 5.0]
q ∈ [0, 15]
实数范围已包含 0:
r_min = 0
r_max = 5
scale:
s=5−015−0=13≈0.333333 s=\frac{5-0}{15-0}=\frac{1}{3}\approx0.333333 s=15−05−0=31≈0.333333
zero-point:
z=round(0−0/s)=0 z=\operatorname{round}(0-0/s)=0 z=round(0−0/s)=0
量化:
0.0 / s → 0.0 → 0
1.2 / s → 3.6 → 4
3.4 / s → 10.2 → 10
5.0 / s → 15.0 → 15
反量化:
x_hat ≈ [0.0, 1.33333, 3.33333, 5.0]
如果改用对称 INT4 [-7,7] 覆盖 [−5,5],scale 为 5/7≈0.71429,网格明显更粗。对这组全正数据,仿射范围利用更充分。
不过,这并不自动推出“所有激活都应非对称”。实际内核、分布是否跨零、动态量化成本都要一起考虑。
8.3 手算三:一个离群值如何污染全局,又如何被分组隔离
给定:
x = [0.02, -0.03, 0.01, 0.04, 0.02, -0.01, 0.03, 2.0]
使用对称 INT4 [-7,7]。
per-tensor
全局最大绝对值是 2.0:
s=2/7≈0.285714 s=2/7\approx0.285714 s=2/7≈0.285714
前 7 个小值除以 scale 后,绝对值都小于 0.15,round 后全部变成 0;只有离群值编码为 7:
q_tensor = [0, 0, 0, 0, 0, 0, 0, 7]
per-group,group size = 4
第一组:
[0.02, -0.03, 0.01, 0.04]
scale:
s1=0.04/7≈0.005714 s_1=0.04/7\approx0.005714 s1=0.04/7≈0.005714
量化约为:
q1 = [4, -5, 2, 7]
这些小值被较好保留。
第二组:
[0.02, -0.01, 0.03, 2.0]
仍被 2.0 拉大 scale,小值大多归零。但污染只发生在第二组,没有扩散到第一组。
这就是 per-group 的真正作用:
它不一定消灭离群值问题,而是缩小离群值的影响半径。
9. 从零实现一个可靠的 NumPy 量化器
课堂版量化器往往只有五行,但一进入真实数据,就会遇到:全零组、不同整数范围、按轴广播、非整除分组、打包、NaN/Inf 等问题。本节先写最小版本,再逐步补齐。
9.1 最小对称量化器
import numpy as np
def symmetric_quantize_minimal(x, num_bits=4):
x = np.asarray(x, dtype=np.float64)
qmax = 2 ** (num_bits - 1) - 1 # INT4 -> 7
qmin = -qmax # INT4 -> -7
absmax = np.max(np.abs(x))
scale = max(absmax / qmax, 1e-12)
q = np.rint(x / scale)
q = np.clip(q, qmin, qmax).astype(np.int8)
x_hat = q.astype(np.float64) * scale
return q, scale, x_hat
x = np.array([0.1, -0.5, 0.8, -0.3])
q, scale, x_hat = symmetric_quantize_minimal(x)
print("q =", q)
print("scale =", scale)
print("x_hat =", x_hat)
print("abs err=", np.abs(x - x_hat))
输出:
q = [ 1 -4 7 -3]
scale = 0.1142857142857143
x_hat = [ 0.11428571 -0.45714286 0.8 -0.34285714]
abs err= [0.01428571 0.04285714 0. 0.04285714]
这段代码已经能验证公式,但仍有几个局限:
- 只支持对称窄范围;
- 只支持 per-tensor;
- 没有显式记录 qrange 和 zero-point;
- 不方便复用同一组参数编码新数据;
- 没有检查 NaN、Inf 和空数组。
下面把“参数选择”与“编码/解码”拆开。
9.2 用数据类保存量化参数
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class QParams:
scale: np.ndarray
zero_point: np.ndarray
qmin: int
qmax: int
axis: Optional[int]
scheme: str
num_bits: int
为什么不只返回一个 scale?因为一套量化结果的语义由多项共同决定:
bit 数 + qmin/qmax + scheme + axis + scale + zero-point
只保存低比特数组而丢失这些元数据,无法正确反量化。
9.3 先明确整数范围
def get_qrange(num_bits, *, signed=True, narrow_range=False):
if num_bits < 2 or num_bits > 16:
raise ValueError("num_bits must be in [2, 16]")
if signed:
qmin = -(1 << (num_bits - 1))
qmax = (1 << (num_bits - 1)) - 1
if narrow_range:
qmin += 1
else:
qmin = 0
qmax = (1 << num_bits) - 1
return qmin, qmax
测试:
print(get_qrange(4, signed=True, narrow_range=False))
# (-8, 7)
print(get_qrange(4, signed=True, narrow_range=True))
# (-7, 7)
print(get_qrange(4, signed=False))
# (0, 15)
把整数范围写成函数,而不是散落在代码里的魔法数字,可以避免大量格式错配。
9.4 参数选择:支持对称、仿射与 per-axis
下面代码的关键是 keepdims=True。它让 scale 保留可广播形状。
例如权重 W.shape = [O, I],若每一行独立量化,参数形状应是 [O, 1],这样 W / scale 会自动按行广播。
def _minmax_keepdims(x, axis):
if axis is None:
return np.min(x, keepdims=True), np.max(x, keepdims=True)
axis = axis % x.ndim
reduce_axes = tuple(i for i in range(x.ndim) if i != axis)
if not reduce_axes:
return x.copy(), x.copy()
return (
np.min(x, axis=reduce_axes, keepdims=True),
np.max(x, axis=reduce_axes, keepdims=True),
)
def choose_qparams(
x,
*,
num_bits=8,
scheme="symmetric",
axis=None,
eps=1e-12,
):
x = np.asarray(x, dtype=np.float64)
if x.size == 0:
raise ValueError("x must not be empty")
if not np.all(np.isfinite(x)):
raise ValueError("x contains NaN or infinity")
if scheme == "symmetric":
qmin, qmax = get_qrange(
num_bits, signed=True, narrow_range=True
)
x_min, x_max = _minmax_keepdims(x, axis)
absmax = np.maximum(np.abs(x_min), np.abs(x_max))
scale = np.maximum(absmax / qmax, eps)
zero_point = np.zeros_like(scale, dtype=np.int64)
elif scheme == "affine":
qmin, qmax = get_qrange(
num_bits, signed=False, narrow_range=False
)
x_min, x_max = _minmax_keepdims(x, axis)
rmin = np.minimum(x_min, 0.0)
rmax = np.maximum(x_max, 0.0)
scale = np.maximum((rmax - rmin) / (qmax - qmin), eps)
zero_point = np.rint(qmin - rmin / scale)
zero_point = np.clip(zero_point, qmin, qmax).astype(np.int64)
else:
raise ValueError("scheme must be 'symmetric' or 'affine'")
return QParams(
scale=scale,
zero_point=zero_point,
qmin=qmin,
qmax=qmax,
axis=axis,
scheme=scheme,
num_bits=num_bits,
)
9.5 编码与解码应当分开
def quantize(x, params):
x = np.asarray(x, dtype=np.float64)
q = np.rint(x / params.scale) + params.zero_point
q = np.clip(q, params.qmin, params.qmax)
if params.num_bits <= 8:
dtype = np.int8 if params.qmin < 0 else np.uint8
else:
dtype = np.int16 if params.qmin < 0 else np.uint16
return q.astype(dtype)
def dequantize(q, params):
return params.scale * (
q.astype(np.float64) - params.zero_point
)
这样做有三个好处:
- 同一套参数可以编码多个张量;
- 可以单独检查整数码是否正确;
- 能更贴近真实部署中“离线生成参数、运行时解码”的流程。
9.6 per-channel 示例
W = np.array([
[0.01, -0.02, 0.03, -0.04],
[1.00, -2.00, 3.00, -4.00],
])
# axis=0 表示保留第 0 维,每一行是一条 channel。
params = choose_qparams(
W,
num_bits=4,
scheme="symmetric",
axis=0,
)
q = quantize(W, params)
W_hat = dequantize(q, params)
print("scale shape:", params.scale.shape) # (2, 1)
print("scales:", params.scale.ravel())
print("q:\n", q)
print("W_hat:\n", W_hat)
两行动态范围相差 100 倍。如果用 per-tensor,第一行很容易被第二行拖累;per-channel 让它们各用一把尺子。
9.7 per-group:处理不能整除的输入维度
真实矩阵的 in_features 不一定能被 group size 整除。常见做法是补零到整组,计算完成后再切回原长度。
def per_group_symmetric_quantize_2d(
weight,
*,
group_size=128,
num_bits=4,
eps=1e-12,
):
weight = np.asarray(weight, dtype=np.float64)
if weight.ndim != 2:
raise ValueError(
"weight must have shape [out_features, in_features]"
)
qmin, qmax = get_qrange(
num_bits, signed=True, narrow_range=True
)
O, I = weight.shape
num_groups = (I + group_size - 1) // group_size
padded_I = num_groups * group_size
pad = padded_I - I
padded = np.pad(weight, ((0, 0), (0, pad)))
blocks = padded.reshape(O, num_groups, group_size)
absmax = np.max(np.abs(blocks), axis=-1)
scales = np.maximum(absmax / qmax, eps)
q_blocks = np.rint(blocks / scales[..., None])
q_blocks = np.clip(q_blocks, qmin, qmax).astype(np.int8)
deq_blocks = q_blocks.astype(np.float64) * scales[..., None]
q = q_blocks.reshape(O, padded_I)[:, :I]
weight_hat = deq_blocks.reshape(O, padded_I)[:, :I]
return q, scales, weight_hat
这里的 scale 形状为:
[out_features, num_groups]
反量化时用 scales[..., None] 扩展出组内维度。
9.8 4 bit 不等于 NumPy 的 int8:还要做打包
NumPy 没有通用 int4 dtype。即使 q 的值只在 [-7,7],如果存成 np.int8,每个值仍占 8 bit。
要真正把两个 INT4 装进一个字节,需要位打包:
一个 byte: [高 4 bit][低 4 bit]
q1 q0
示例:
def pack_int4_signed(q):
flat = np.asarray(q, dtype=np.int16).ravel()
if np.any(flat < -8) or np.any(flat > 7):
raise ValueError("signed INT4 values must be in [-8, 7]")
original_size = flat.size
if original_size % 2:
flat = np.pad(flat, (0, 1))
# & 0xF 取得四位二补码。
nibble = (flat & 0xF).astype(np.uint8)
packed = nibble[0::2] | (nibble[1::2] << 4)
return packed, original_size
def unpack_int4_signed(packed, original_size):
packed = np.asarray(packed, dtype=np.uint8).ravel()
nibble = np.empty(packed.size * 2, dtype=np.uint8)
nibble[0::2] = packed & 0x0F
nibble[1::2] = packed >> 4
# 四位二补码符号扩展:8~15 对应 -8~-1。
signed = np.where(
nibble >= 8,
nibble.astype(np.int16) - 16,
nibble,
).astype(np.int8)
return signed[:original_size]
测试:
q = np.array([-7, -1, 0, 1, 7], dtype=np.int8)
packed, n = pack_int4_signed(q)
restored = unpack_int4_signed(packed, n)
print(q) # [-7 -1 0 1 7]
print(packed) # [249 16 7]
print(restored) # [-7 -1 0 1 7]
5 个 INT4 理论上需要 20 bit。按整字节存储并补齐奇数项后,这里用了 3 byte,也就是 24 bit。
真实格式还会规定:
- 高低 nibble 顺序;
- 行内交错方式;
- tile 布局;
- scale 与 zero-point 排列;
- 对齐边界;
- 是否预重排以适配矩阵乘法内核。
所以“能把 INT4 打进 byte”只是第一步,格式兼容还需要严格遵循后端布局。
9.9 用 PyTorch 假量化交叉验证
import torch
x = torch.tensor([0.1, -0.5, 0.8, -0.3])
scale = 0.8 / 7
zero_point = 0
x_hat = torch.fake_quantize_per_tensor_affine(
x,
scale=scale,
zero_point=zero_point,
quant_min=-7,
quant_max=7,
)
print(x_hat)
# tensor([ 0.1143, -0.4571, 0.8000, -0.3429])
它与前面的 NumPy 手算一致。交叉验证时,请确保:
qmin/qmax 一致
round 规则一致
scale/zero-point 一致
是否包含 0 一致
axis 与布局一致
否则“框架结果不同”很可能只是配置定义不同,而不是谁算错了。
10. 五组实验:用数据验证直觉,也推翻过度简化的结论
以下实验都固定随机种子,输出可复现。完整脚本见文末配套文件 quantization_from_scratch.py。
10.1 实验一:bit 数下降,误差如何变化
构造 20,000 个正态分布样本,使用对称 min-max 量化:
rng = np.random.default_rng(7)
x = rng.normal(0.0, 0.2, size=20_000)
for bits in (8, 4, 3, 2):
_, x_hat, _ = fake_quantize(
x, num_bits=bits, scheme="symmetric"
)
print(bits, mse(x, x_hat), sqnr_db(x, x_hat))
本脚本输出:
| bit | MSE | 最大绝对误差 | SQNR |
|---|---|---|---|
| 8 | 0.00000346 | 0.003198 | 40.57 dB |
| 4 | 0.00112844 | 0.058020 | 15.43 dB |
| 3 | 0.00607565 | 0.135384 | 8.12 dB |
| 2 | 0.03457329 | 0.406107 | 0.57 dB |
结论不是“2 bit 永远不能用”,而是:在最朴素的均匀、per-tensor、min-max 条件下,低 bit 误差会快速恶化。 更先进的方法要靠更好的码本、粒度、缩放、重构或训练来弥补。
10.2 实验二:偏移分布上,对称与仿射的差距
生成 [0.5,6.0] 的均匀数据,比较 4 bit:
symmetric MSE = 0.06044849
affine MSE = 0.01359428
ratio sym/aff = 4.45x
这里仿射量化明显更好,因为它把编码集中到实际正区间。
但不要把 4.45x 当作普遍常数。它由数据分布、bit 数、范围估计和舍入方式共同决定。实验的意义是验证机制,而不是背倍率。
10.3 实验三:min-max 不一定是 MSE 最优
构造 50,000 个标准差为 0.05 的小值,再插入 10 个较大离群值。INT4 对称 min-max 的参数为:
alpha = 2.8000
scale = 0.400000
MSE = 0.00249624
对 clipping 阈值做网格搜索后:
alpha = 0.1845
scale = 0.026355
MSE = 0.00062960
整体 MSE 降到约四分之一。原因是搜索选择牺牲极少数离群点,换取 49,990 个主体值的更细网格。
这也提醒我们:只看最大值确定 scale,是一种“保证覆盖”的选择,不是“保证重构误差最小”的选择。
10.4 实验四:group size 的精度与元数据
构造 128 × 512 权重,并在部分行插入结构化离群值。结果:
| 方案 | 权重 MSE | 仅计 INT4 + FP16 scale 的有效 bit/weight |
|---|---|---|
| per-tensor | 0.00063156 | 约 4 bit,另有一组 scale |
| group 256 | 0.00008644 | 4.062 bit |
| group 128 | 0.00004626 | 4.125 bit |
| group 64 | 0.00002524 | 4.250 bit |
| group 32 | 0.00001491 | 4.500 bit |
在这个人工数据上,组越小,MSE 越低;同时 scale 开销持续增加。
真实选择还要加上延迟与内核支持。例如某设备可能对 group 128 有高度优化,而 group 32 需要更多 scale 读取或较差的 tile 对齐。此时 MSE 更低不一定换来更好的整体部署结果。
10.5 实验五:相同权重 MSE,输出误差可以差 2500 倍
这是本文最重要的实验之一。
构造线性层:
y=xWT y=xW^T y=xWT
让输入第 0 通道的幅度放大 50 倍。然后构造两份大小完全相同的权重扰动:
- 扰动 A 放在第 0 输入通道;
- 扰动 B 放在第 1 输入通道。
二者权重 MSE 完全相同:
weight MSE A = 0.000000781250
weight MSE B = 0.000000781250
但输出 MSE:
output MSE A = 0.03155205
output MSE B = 0.00001255
ratio A/B = 2513.2x
为什么接近 50² = 2500 倍?因为输出误差与输入能量平方相关。把同样大小的权重误差放在高能量输入通道上,会被大幅放大。
这组实验正式把我们带到下一节:量化不能只看 W 本身,还要看 W 如何与输入 x 相乘。
11. 从权重误差走向输出误差:为什么“重要性”比 MSE 更关键
前面我们一直比较:
∥W−W^∥F2 \|W-\hat W\|_F^2 ∥W−W^∥F2
它衡量权重本身的重构误差。但模型真正使用的是矩阵乘法输出,而不是孤立的权重数组。
11.1 一行公式看懂误差如何进入线性层
原始线性层:
y=Wx y=Wx y=Wx
量化权重:
W^=W+ΔW \hat W=W+\Delta W W^=W+ΔW
量化后的输出:
y^=W^x=(W+ΔW)x \hat y=\hat W x=(W+\Delta W)x y^=W^x=(W+ΔW)x
因此输出误差:
Δy=y^−y=ΔWx \Delta y=\hat y-y=\Delta W x Δy=y^−y=ΔWx
这条式子非常朴素,却是从普通量化走向 GPTQ、AWQ 等方法的桥梁。
11.2 输入协方差决定误差方向的重要性
考虑输出误差的期望平方:
E∥Δy∥22=E∥ΔWx∥22 \mathbb{E}\|\Delta y\|_2^2 =\mathbb{E}\|\Delta W x\|_2^2 E∥Δy∥22=E∥ΔWx∥22
令输入协方差:
Σx=E[xxT] \Sigma_x=\mathbb{E}[xx^T] Σx=E[xxT]
则:
E∥ΔWx∥22=tr(ΔWΣxΔWT) \mathbb{E}\|\Delta W x\|_2^2 =\operatorname{tr}(\Delta W\Sigma_x\Delta W^T) E∥ΔWx∥22=tr(ΔWΣxΔWT)
如果暂时假设各输入通道互不相关,Σ_x 近似为对角矩阵:
Σx≈diag(σ12,…,σI2) \Sigma_x\approx\operatorname{diag}(\sigma_1^2,\ldots,\sigma_I^2) Σx≈diag(σ12,…,σI2)
那么:
E∥Δy∥22≈∑o,iσi2(ΔWo,i)2 \mathbb{E}\|\Delta y\|_2^2 \approx\sum_{o,i}\sigma_i^2(\Delta W_{o,i})^2 E∥Δy∥22≈o,i∑σi2(ΔWo,i)2
这就是第 10.5 节实验的数学解释:
- 同样大小的
ΔW; - 放在输入方差大 50 倍的通道;
- 输出 MSE 大约会放大
50²=2500倍。
因此,普通权重 MSE 隐含地假设所有方向同等重要;输出感知目标会根据输入统计重新加权误差。
11.3 通道相关性也很重要
真实激活通道并不独立,Σ_x 往往有非零的非对角项。此时两个权重误差可能:
- 在输出上相互抵消;
- 在同一高能量方向叠加;
- 沿低方差方向几乎无影响;
- 沿高方差主成分被明显放大。
这说明量化不只是“每个元素就近取整”,还可以通过协调多个权重的误差,让整体输出更接近原模型。
11.4 为什么二阶信息会出现
设某层或某个局部目标的损失为 L(w)。在原始权重附近做二阶泰勒展开:
L(w+Δw)≈L(w)+gTΔw+12ΔwTHΔw L(w+\Delta w) \approx L(w) +g^T\Delta w +\frac{1}{2}\Delta w^T H\Delta w L(w+Δw)≈L(w)+gTΔw+21ΔwTHΔw
其中:
g是梯度;H是 Hessian 或其近似。
如果模型已训练到局部稳定点,或在局部重构问题里一阶项较小,就主要考虑:
ΔL≈12ΔwTHΔw \Delta L\approx\frac{1}{2}\Delta w^T H\Delta w ΔL≈21ΔwTHΔw
普通 MSE 相当于粗略地用单位矩阵代替 H:
H≈I H\approx I H≈I
而二阶量化方法试图保留 H 所描述的方向敏感性:某些方向上微小移动代价很大,另一些方向上可以容忍更多误差。
11.5 “误差补偿”是什么意思
假设某个权重必须从 w_i 改为量化值 q_i,产生不可避免的局部误差。我们可以轻微调整尚未量化的其他权重,使线性层输出尽可能保持不变。
这不是把误差凭空消除,而是:
把被迫发生的误差,沿模型不敏感的方向重新分配。
一个直观类比:你必须从总预算里砍掉 100 元。平均从每个项目砍一点未必最好;更合理的是根据每个项目的重要性与替代关系重新分配。
11.6 权重 MSE、层输出 MSE 与任务损失的层级
三个目标从易到难:
权重重构
min∥W−W^∥F2 \min\|W-\hat W\|_F^2 min∥W−W^∥F2
不需要校准数据,计算便宜,但忽略输入。
层输出重构
min∥XWT−XW^T∥F2 \min\|XW^T-X\hat W^T\|_F^2 min∥XWT−XW^T∥F2
利用一批校准激活 X,更接近实际算子行为。
端到端任务目标
minLtask(W^) \min\mathcal{L}_{task}(\hat W) minLtask(W^)
最接近真实需求,但计算昂贵、依赖数据,且优化更困难。
GPTQ、AWQ 等 PTQ 方法通常处在中间:它们使用有限校准数据和局部统计,尽量获得接近任务感知的收益,又避免完整重训练。
11.7 误差会跨层传播
量化某一层后,后续层接收到的输入也发生变化。局部误差经过:
- 线性投影;
- 激活函数;
- 归一化;
- 注意力 softmax;
- 残差连接;
- 多层重复;
可能被削弱、抵消或放大。
这解释了两个现象:
- 单层 MSE 很低,不保证端到端指标完全不变;
- 某些层远比其他层敏感,统一 bit 与统一 group size 未必最优。
因此,成熟评估必须最终回到完整模型,而不能只停在权重数组的 MSE。
12. GPTQ、AWQ、SmoothQuant 与 GGUF:它们究竟解决什么
学完前面的数学,现在可以不靠口号理解这些名字。
12.1 先放一张总表
| 名称 | 主要对象 | 使用的信息 | 核心思想 | 不应被误解为 |
|---|---|---|---|---|
| RTN | 权重 | 权重范围 | 直接 round-to-nearest | 所有 PTQ 的效果上限 |
| GPTQ | 低比特权重 | 校准激活形成的近似二阶信息 | 顺序量化并补偿剩余权重 | 单纯的离群值裁剪 |
| AWQ | 低比特权重 | 激活统计与通道显著性 | 等价缩放保护重要权重通道 | 简单把 1% 权重永久留成高精度 |
| SmoothQuant | 权重 + 激活,常见目标 W8A8 | 激活与权重通道幅度 | 把部分量化难度从激活迁移到权重 | 直接修改模型函数 |
| GGUF | 文件与张量容器生态 | 格式元数据、张量类型 | 存储模型及多种量化类型 | 单一量化算法 |
12.2 RTN:最重要的基线
Round-To-Nearest 通常指:
- 用 min-max、absmax 或简单 clipping 选参数;
- 每个权重独立舍入到最近网格;
- 不做跨权重误差补偿。
RTN 简单、快、容易实现。更复杂算法是否真的有价值,应该与配置一致的 RTN 基线比较,而不是只与 FP16 比较。
12.3 GPTQ:用近似二阶信息做顺序量化与补偿
GPTQ 是 one-shot 权重量化方法。它不是只看权重绝对值,而是用校准输入构造局部敏感性近似。
对一行权重 w,层输出重构目标可写成:
∥wX−w^X∥22 \|wX-\hat wX\|_2^2 ∥wX−w^X∥22
其曲率与输入统计 XX^T 相关。GPTQ 在量化某些权重后,利用近似逆 Hessian 信息更新尚未量化的权重,把误差向相对不敏感的方向转移。
概念流程:
收集少量校准激活
↓
估计局部二阶/协方差信息
↓
按列或按块顺序量化权重
↓
每量化一部分,就补偿剩余权重
↓
输出低比特权重与分组参数
为什么要“顺序”处理?因为一个权重被固定到离散值后,最优的剩余权重会改变。边量化边补偿,比所有权重互不相关地 round 更能控制层输出误差。
需要澄清:
- GPTQ 的核心不是“专门找离群值”;
- GPTQ 不是文件格式;
- “GPTQ 模型”通常还隐含 bit、group size、是否 desc_act、是否对称等配置;
- 实际速度取决于推理框架是否有匹配的打包与内核。
12.4 AWQ:重要性由激活揭示,再用等价缩放保护通道
AWQ 的观察是:并非所有权重对输出同等重要,而只看权重大小不足以识别重要性;激活幅度能提供关键线索。
如果某输入通道经常出现大激活,那么该通道对应权重列的量化误差更容易被放大。直接把少量重要权重留在高精度虽然可能有效,却会引入硬件不友好的混合精度稀疏路径。
AWQ 的关键做法是使用等价变换。对:
y=Wx y=Wx y=Wx
引入正对角缩放矩阵 S:
y=(WS)(S−1x) y=(WS)(S^{-1}x) y=(WS)(S−1x)
在未量化的精确数学里,这与原式完全等价。通过放大重要权重通道,再对对应激活做逆缩放,可以让这些权重在量化网格上获得更有利的相对分辨率,而不必真的把它们单独保留成 FP16。
AWQ 通常还会结合搜索选择缩放或 clipping 参数。其“activation-aware”含义是:用真实输入激活来判断权重误差的重要性。
12.5 SmoothQuant:把激活离群难度迁移给更容易量化的权重
低 bit 激活量化难,常见原因是某些固定通道出现系统性大值。SmoothQuant 利用线性层的等价变换:
若数据布局写成:
Y=XW Y=XW Y=XW
对每个输入通道选择正缩放 s_j:
Y=(XS−1)(SW) Y=(XS^{-1})(SW) Y=(XS−1)(SW)
其中 S=diag(s_1,\ldots,s_I)。
- 激活通道除以
s_j,离群值被“压平”; - 对应权重通道乘以
s_j,权重动态范围变大一些; - 变换前后浮点函数保持等价。
论文给出的典型平滑因子形式,用超参数 α 在激活与权重难度间平衡:
sj=max∣Xj∣αmax∣Wj∣1−α s_j= \frac{\max|X_j|^{\alpha}} {\max|W_j|^{1-\alpha}} sj=max∣Wj∣1−αmax∣Xj∣α
直觉上:
α大,更多压力从激活移向权重;α小,权重变化较小,激活保留更多动态范围。
SmoothQuant 的主要目标是让权重与激活都更适合 INT8,从而使用高效 W8A8 矩阵乘法。它不是把离群值删除,而是通过等价重参数化重新分配动态范围。
12.6 三者之间的共同主线
GPTQ、AWQ、SmoothQuant 表面不同,背后都在挑战“每个数独立、每个误差等价”的朴素假设:
- GPTQ:误差方向的重要性由二阶/协方差信息决定;
- AWQ:权重通道的重要性由激活统计揭示;
- SmoothQuant:权重与激活的动态范围可以通过等价变换重新分配。
所以,现代量化的核心不只是“减少 bit”,而是:
在模型功能、硬件格式与有限码本之间,重新安排误差和动态范围。
12.7 GGUF:先分清“容器”和“算法”
GGUF 是 llama.cpp / ggml 生态常用的模型文件格式与元数据容器。一个 GGUF 文件可以保存:
- 模型架构与超参数;
- tokenizer 相关元数据;
- 各个张量及其数据类型;
- F32、F16、BF16 或多种量化张量类型;
- 运行时需要的其他键值信息。
因此:
GGUF ≠ 某一个量化公式
Q4_0、Q4_K_M、Q5_K_M、Q8_0 等才是具体张量量化类型或混合方案的名字。某些方案会按块保存低比特值与缩放参数,某些“_M”配置还可能对不同张量采用不同类型组合。
比较“GPTQ vs GGUF”本身就不完全在同一层级:
- GPTQ 首先是一种量化算法;
- GGUF 首先是一种文件与运行生态;
- GPTQ 结果常保存为其他格式并由特定 GPU 内核运行;
- GGUF 内部可承载多种 llama.cpp 量化类型。
更合理的比较问题是:
某个模型 + 某种量化方法/类型 + 某个 bit/group 配置
在某个推理后端与硬件上,质量、显存和速度如何?
13. 工程现实:文件大小、显存、带宽、算力与内核
数学误差更小,不代表部署一定更快;文件更小,也不代表峰值显存同比下降。量化最终要经过系统栈。
13.1 先分清四个“大小”
模型文件大小
磁盘上的 checkpoint / GGUF / safetensors 大小,包含权重、量化参数、元数据和可能的 tokenizer。
权重驻留内存
模型加载后,权重在 RAM / VRAM 中实际占用。某些后端保持压缩格式,某些会转换或预打包成另一种布局。
运行时峰值内存
除权重外,还包括:
- KV cache;
- 激活与临时张量;
- attention / GEMM workspace;
- CUDA graph 或编译缓存;
- 内存池与碎片;
- 多卡通信缓冲区。
传输字节数
每次 token 生成时,从内存系统读取多少权重、scale、zero-point 和激活。它更直接影响带宽受限场景。
四者相关,但不相等。
13.2 为什么自回归解码常从 weight-only 量化受益
单 batch、逐 token 解码时,每一步都要读取大量模型权重,却只处理很少的新 token,算术强度可能较低。此时性能容易受显存带宽限制。
把 FP16 权重压到接近 4 bit,可以显著减少权重读取字节;如果反量化与矩阵乘法融合得好,吞吐可能提升。
但提升不会简单等于 16/4=4 倍,因为还存在:
- scale / zero-point 读取;
- 解码与位运算;
- 内核调度;
- 非量化算子;
- KV cache 访问;
- 内存对齐与缓存命中;
- CPU/GPU 协同与数据传输。
13.3 prefill 与 decode 要分开测
Prefill
一次处理整段输入,矩阵规模大,并行度高,更可能充分利用计算单元。某些低比特格式若缺少高效大矩阵内核,反量化开销可能抵消收益。
Decode
每次只新增少量 token,常更偏带宽受限,weight-only 压缩可能更有优势。
只报告一个“tokens/s”,却不说明 prompt 长度、输出长度和 batch,很难比较。
13.4 真低比特计算与边解码边浮点计算
常见路径至少有两类。
路径 A:反量化到浮点后计算
低比特权重 → 解码成 FP16/BF16 → 浮点 GEMM
如果完整反量化张量长期驻留显存,可能失去内存优势;如果只按 tile 在寄存器或共享内存中解码,再立即参与计算,仍能减少主存带宽。
路径 B:低比特/整数乘法与更高位累积
INT4/INT8 operands → 整数或低精度指令 → INT32/FP32 等累积 → 重标定
能否走这条路径,取决于硬件指令、数据布局、算子形状和内核实现。
“模型是 INT4”描述的是存储或操作数格式,不足以推出底层每一步都在做原生 INT4 算术。
13.5 为什么打包布局决定速度
矩阵乘法内核按 tile 读取数据。理想布局要让一个 warp / SIMD 向量连续读到需要的权重、scale,并尽量少做重排。
同样 4 bit 数据,以下差异都可能显著影响速度:
- 两个 nibble 的顺序;
- group 与 tile 是否对齐;
- scale 是否连续;
- 权重是否预转置;
- 是否按内核消费顺序 interleave;
- 每次载入后需要多少位操作;
- 是否能使用向量化 load。
因此,格式不是单纯的存储细节,而是算法与内核的接口。
13.6 量化参数本身也会成为带宽
以 group size 32 与 128 比较:前者每 32 个权重就要读取一个 scale,后者每 128 个读取一个。更细粒度虽然减少权重误差,却增加 scale 流量与索引频率。
当权重主体已压得很低时,元数据占比不再可以忽略。极低 bit 方案尤其需要联合设计:
码本 + scale 精度 + 分组 + 打包 + 内核
13.7 为什么某些层会保留高精度
模型中并非所有张量都值得或适合使用同一量化方式。常见考虑包括:
- 张量太小,量化收益不足以覆盖元数据;
- 某层对误差更敏感;
- embedding / output head 对词概率影响直接;
- 归一化、偏置等算子不属于主要权重瓶颈;
- 后端没有对应低比特内核;
- 混合专家模型的路由与专家权重有不同访问模式。
保留少量张量为 FP16/BF16,可能只增加很小体积,却显著改善质量或兼容性。
13.8 一套可信的性能测试至少要报告什么
建议记录:
模型与精确版本
量化算法/格式、bit、group size、是否对称
推理框架与版本
硬件型号、驱动、后端
CPU 线程数或 GPU offload 层数
batch / micro-batch
prompt token 数与生成 token 数
上下文长度、KV cache 精度
prefill tokens/s 与 decode tokens/s
首 token 延迟与峰值内存
是否 warm-up、重复次数与统计方式
缺少这些条件的单一速度数字,通常不能复现,也不适合横向比较。
13.9 一套可信的质量测试至少要看什么
- 困惑度或负对数似然;
- 与业务相关的任务集;
- 中文、英文、代码、数学等目标域;
- 短上下文与长上下文;
- 多轮稳定性;
- 生成中的重复、乱码、格式遵循和事实性;
- 多个随机种子或确定性解码对照。
量化误差常表现为概率分布的细微变化,单看几条“感觉还行”的回答容易漏掉退化。
13.10 工程选型的四步法
第一步:先定义硬约束
必须装进多少 RAM/VRAM?
目标首 token 延迟和 tokens/s 是多少?
运行在 CPU、GPU、NPU 还是混合设备?
第二步:选后端原生支持的候选格式
优先从已有高效内核的格式里选,而不是先定一个理论上漂亮但后端不支持的量化方案。
第三步:在质量预算内找最低成本
从较高精度候选开始向下测,例如 8 bit → 6 bit → 5 bit → 4 bit,直到质量触及业务底线。
第四步:固定条件做端到端基准
同时测:
文件大小、峰值内存、prefill、decode、质量指标
最后选的是系统 Pareto 点,不是某个孤立的最低 MSE。
14. 常见误区与排错清单
14.1 误区一:量化误差永远不超过 s/2
不对。只有在:
均匀量化 + 最近邻舍入 + 输入未被 clip
时,才有:
∣x−x^∣≤s/2 |x-\hat x|\le s/2 ∣x−x^∣≤s/2
发生截断后,误差可以远大于半个步长。
14.2 误区二:误差一定均匀随机、均值为 0
不对。它是特定条件下的近似噪声模型。低 bit、集中分布、离群值、结构化通道和确定性舍入都会让误差与信号相关。
如果误差真的只是独立白噪声,现代方法就不必费力建模通道重要性与二阶结构。
14.3 误区三:INT4 一定是 [-8,7],或者一定是 [-7,7]
都不对。“4 bit”只规定最多 16 个编码,具体 qrange 由格式定义:
[-8,7]、[-7,7]、[0,15]
都可能出现。必须连同 signed、narrow range、zero-point 一起看。
14.4 误区四:scale 越小越好
scale 小会减小范围内部的舍入步长,也会缩小可表示范围。过小会让大量值被 clip。
正确问题是:
在舍入误差与截断误差之间,哪个 scale 让目标损失最小?
14.5 误区五:min-max 不截断,所以最准确
min-max 的确尽量覆盖观测极值,却可能为了少数离群值牺牲主体分辨率。对于 MSE、层输出误差或任务损失,它未必最优。
14.6 误区六:非对称量化一定优于对称量化
非对称网格更灵活,但还要考虑:
- 数据是否偏移;
- zero-point 元数据;
- 零点修正成本;
- 内核支持;
- 参数估计稳定性。
对近似零中心权重,对称量化可能已经足够且更容易高效实现。
14.7 误区七:group size 越小,部署一定越好
更小 group 往往降低局部重构误差,却增加:
- scale / zero-point 数量;
- 元数据带宽;
- 反量化索引;
- 内核复杂度;
- 对齐与打包限制。
正确答案来自端到端基准,不来自单一 MSE。
14.8 误区八:group_size=128 是永远不需要改的标准答案
它是常见折中,不是普适定律。不同模型、bit 数、量化算法、GPU/CPU 内核可能更适合 32、64、128、256 或 per-channel。
14.9 误区九:权重 MSE 越小,模型质量一定越高
权重 MSE 忽略输入统计、层敏感性和误差传播。第 10.5 节已经构造出权重 MSE 完全相同、输出 MSE 相差约 2500 倍的例子。
权重 MSE适合调试量化器,却不能代替端到端评估。
14.10 误区十:离群值越大,越应该直接删除
某些离群通道对模型功能非常重要。直接裁剪可能降低整体数值 MSE,也可能破坏关键表示。
更稳妥的办法包括:
- 细粒度分组;
- 搜索 clipping 并验证输出;
- 混合精度隔离;
- activation-aware 缩放;
- 等价变换;
- 二阶误差补偿。
14.11 误区十一:GPTQ、AWQ、GGUF 是三种同类算法
不完全对:
- GPTQ、AWQ 是量化方法;
- GGUF 是文件与运行生态;
- GGUF 内有多种量化类型;
- 某个量化算法的结果也可能用不同格式保存。
比较前先统一层级。
14.12 误区十二:文件缩小 4 倍,速度也会提升 4 倍
速度受带宽、计算、解码、内核、batch、上下文和未量化算子共同影响。文件大小只是一个因素。
14.13 误区十三:数组里的值都在 [-7,7],就已经是 4 bit
如果它的 dtype 是 int8,仍然每个元素占 8 bit。真正 4 bit 存储需要打包,或使用后端定义的低比特块格式。
14.14 误区十四:所有层都应该使用同一配置
统一配置简单,但未必最优。常见混合策略会:
- 跳过敏感层;
- 给输出头更高 bit;
- 为不同模块设不同 group size;
- 对部分张量使用不同量化类型。
是否值得混合,要看增加的实现复杂度与质量收益。
14.15 误区十五:校准数据随便抽几条就行
如果量化方法依赖激活统计、二阶近似或 clipping 搜索,校准分布会影响结果。样本数量不是唯一问题,覆盖真实任务更重要。
14.16 误区十六:重复量化不会有额外损失
从 FP16/BF16 原始权重量化一次,与把已量化模型反量化后再量化,不是同一件事。后者会叠加先前丢失的信息和新网格误差,通常应尽量从高精度源重新生成目标格式。
14.17 排错一:反量化数值与框架对不上
按顺序检查:
□ qmin / qmax 是否一致?
□ signed 与 narrow range 是否一致?
□ zero-point 是否先 round 再 clip?
□ 是否把实数 0 纳入 min/max?
□ round 的半点规则是否一致?
□ scale 的 dtype 是否一致?
□ per-channel axis 是否正确?
□ 权重是否被转置?
□ group 边界是否一致?
□ 高低 nibble 顺序是否一致?
不要一开始就怀疑浮点误差;布局和约定更常见。
14.18 排错二:文件没有缩小到预期
检查:
□ 是否只是把数值限制到 INT4 范围,却仍以 int8/float 存储?
□ scale / zero-point 用了什么精度?
□ group size 多大?
□ 哪些层没有量化?
□ 文件是否同时保存原始权重?
□ 是否有 padding、分片和额外元数据?
□ 模型参数量是否包含共享权重或 MoE 非激活专家?
14.19 排错三:量化后反而更慢
可能原因:
- 后端没有匹配内核,先完整反量化再算;
- group size 不符合优化路径;
- batch / prefill 场景更偏计算受限;
- 小模型的解码开销占比过高;
- CPU 指令集或 GPU 架构不支持目标格式;
- 频繁的格式转换抵消了带宽收益;
- 部分算子回退到低效实现。
先用 profiler 确认时间花在哪里,不要只根据文件名判断执行路径。
14.20 排错四:MSE 看起来正常,生成质量却明显下降
检查:
□ 是否有少数敏感层异常?
□ 校准数据是否失配?
□ embedding / lm_head 是否过度量化?
□ 长上下文下激活或 KV 分布是否变化?
□ tokenizer、聊天模板是否一致?
□ 推理参数是否完全相同?
□ 是否发生 NaN、溢出或错误 scale 广播?
□ 是否只测了全局 MSE,漏掉层输出误差?
还要确认退化真来自量化,而不是模型转换、rope 配置、tokenizer 或采样参数不一致。
14.21 排错五:极少数值出现巨大误差
先区分:
- 正常 clip:输入确实超出量化范围;
- 参数错位:用了别组的 scale;
- zero-point 符号错误;
- int4 解包未做符号扩展;
- 溢出后在无符号整数中回绕;
- scale 下溢或被错误 cast。
打印一个小组的:
原始 x、scale、zero-point、q、x_hat、group id
逐项手算,通常比盯着全层统计更快找到 bug。
15. 自测题与动手练习
下面按“入门、进阶、工程”三层设计。建议先自己写,再展开答案。
15.1 入门题一:半步长上界
给定 s=0.2,输入没有发生 clip,采用最近邻舍入。最大绝对误差是多少?
最大绝对误差是:
s/2=0.1 s/2=0.1 s/2=0.1
半点如何归属会影响等号落在哪个网格点,但不会改变上界。
15.2 入门题二:INT4 级别数
为什么 [-7,7] 只有 15 个整数,却仍被称为 INT4?
因为底层编码仍使用 4 bit,理论容量为 16 个码。对称窄范围故意空出 -8,换取关于 0 对称的整数区间。bit 数描述编码宽度,不保证所有码都必须使用。
15.3 入门题三:手算仿射量化
给定:
x ∈ [-1, 3]
q ∈ [0, 15]
使用 min-max 仿射量化,求 s 与 z。
s=(3−(−1))/15=4/15≈0.266667 s=(3-(-1))/15=4/15\approx0.266667 s=(3−(−1))/15=4/15≈0.266667
z=round(0−(−1)/s)=round(3.75)=4 z=\operatorname{round}(0-(-1)/s) =\operatorname{round}(3.75)=4 z=round(0−(−1)/s)=round(3.75)=4
因此实数 0 对应整数 4。由于 zero-point 取整,重建端点与原始区间可能有轻微偏差。
15.4 进阶题一:推导输出误差
从 y=Wx 与 W_hat=W+ΔW 推导 Δy。
y^=(W+ΔW)x=Wx+ΔWx \hat y=(W+\Delta W)x=Wx+\Delta Wx y^=(W+ΔW)x=Wx+ΔWx
所以:
Δy=y^−y=ΔWx \Delta y=\hat y-y=\Delta Wx Δy=y^−y=ΔWx
15.5 进阶题二:为什么高激活通道更敏感
若两个通道互不相关,输入标准差分别为 σ1=10、σ2=1。同样大小的权重误差分别放在两个通道,输出 MSE 大约相差多少?
输出误差能量与方差 σ² 成正比,因此约相差:
102/12=100 10^2/1^2=100 102/12=100
倍。
15.6 进阶题三:为什么 clipping 可能降低 MSE
用一句话解释。
参考答案主动牺牲极少数离群值的边界误差,可以缩小 scale,让大量主体值的舍入误差下降;当后者收益更大时,总 MSE 会降低。
15.7 动手题一:画出误差随 clipping 阈值的曲线
修改配套脚本,让 α 从 0.05×absmax 扫到 1.0×absmax,记录 MSE,并用 Matplotlib 画:
横轴:alpha / absmax
纵轴:MSE
观察曲线是否在 1.0 之前达到最小值,并换三种分布比较:
- 高斯分布;
- 拉普拉斯分布;
- 高斯主体 + 少数离群值。
15.8 动手题二:比较权重 MSE 与输出 MSE 的最佳 group size
对同一层权重与校准激活,分别测试 group size:
32, 64, 128, 256, per-channel, per-tensor
同时记录:
weight MSE
layer-output MSE
effective bit/weight
运行时间
检查“权重 MSE 最优”的配置是否也是“输出 MSE 最优”。
15.9 动手题三:实现 per-token 激活量化
输入 X.shape=[batch, seq, hidden],让每个 [batch, token, :] 独立计算 scale。
提示:
absmax = np.max(np.abs(X), axis=-1, keepdims=True)
再比较 per-tensor 与 per-token 在不同 token 幅度下的误差。
15.10 动手题四:验证 PyTorch 与 NumPy 的 half-to-even
测试:
[-2.5, -1.5, -0.5, 0.5, 1.5, 2.5]
比较 np.rint、torch.round 与你自己实现的 half-away-from-zero。理解为什么边界测试可能差 1 个整数码。
15.11 工程题一:计算一个 7B 模型的理论权重大小
分别估算:
- FP32;
- FP16/BF16;
- 理想 INT8;
- 理想 INT4;
- INT4 + 每 128 权重一个 FP16 scale。
忽略所有额外张量与单位换算差异,7B 参数约为:
FP32:7e9 × 4 byte ≈ 28 GB
FP16:7e9 × 2 byte ≈ 14 GB
INT8:7e9 × 1 byte ≈ 7 GB
INT4:7e9 × 0.5 byte ≈ 3.5 GB
每 128 个权重一个 FP16 scale,额外:
7×109/128×2 byte≈109.4 MB 7\times10^9/128\times2\text{ byte} \approx109.4\text{ MB} 7×109/128×2 byte≈109.4 MB
所以约为 3.61 GB,还未计 zero-point、padding、未量化张量和元数据。若用 GiB 口径,数值会不同,应注明单位。
15.12 工程题二:设计一份量化对比表
至少包含:
格式 / 算法
bit 与 group size
文件大小
峰值 RAM/VRAM
prefill tokens/s
decode tokens/s
首 token 延迟
困惑度 / 任务指标
硬件与后端版本
不要只放“速度”和“肉眼质量”。
16. 总结:把整篇压缩成一张认知地图
这篇很长,但核心可以压成 16 条。
16.1 表示层
- 量化是用有限码本近似连续或高精度数值。
- 常见均匀仿射量化为:
q=clip(round(x/s)+z,qmin,qmax) q=\operatorname{clip}(\operatorname{round}(x/s)+z,q_{\min},q_{\max}) q=clip(round(x/s)+z,qmin,qmax)
x^=s(q−z) \hat x=s(q-z) x^=s(q−z)
- scale 决定网格间距,zero-point 决定整数轴平移。
- INT4 只说明 4 bit 编码,
[-8,7]、[-7,7]、[0,15]都可能是合法约定。
16.2 误差层
- 无 clip 且最近邻舍入时,标量误差不超过
s/2。 - clip 后误差可以远大于
s/2。 - “误差均匀随机、方差
s²/12”是有条件的近似,不是低比特量化定律。 - scale 选择是在舍入误差与截断误差之间平衡;min-max 不一定最优。
16.3 粒度层
- per-tensor、per-channel、per-group、per-token 的区别,是哪些元素共享参数。
- 粒度越细通常越能贴合局部分布,但 scale、zero-point、带宽和内核成本会上升。
group_size=128是常见折中,不是普适标准答案。- 4 bit 权重还要真正打包;把小整数存进 int8 不会自动省到 4 bit。
16.4 模型层
- 线性层输出误差为:
Δy=ΔWx \Delta y=\Delta W x Δy=ΔWx
所以同样的权重误差,落在高能量激活通道上影响更大。
- GPTQ 用近似二阶信息顺序量化并补偿;AWQ 用激活统计识别并缩放重要通道;SmoothQuant 用等价变换在激活与权重之间迁移动范围。
- GGUF 是格式与生态,不是单一量化算法。
- 最终选择必须同时看质量、文件、峰值内存、prefill、decode、内核与硬件,而不能只看 bit 或 MSE。
如果你能从这 16 条重新推导本文大部分内容,就已经真正建立了量化的数学骨架。
下一篇再进入具体算法时,可以始终追问四件事:
它量化谁?
它如何选码本与 scale?
它如何判断哪些误差重要?
它怎样映射到真实硬件内核?
绝大多数量化方法,都可以放回这四个问题中理解。
附录 A:完整实验脚本、运行方法与校验项
配套脚本文件:
quantization_from_scratch.py
依赖:
python -m pip install numpy
运行:
python quantization_from_scratch.py
脚本包含:
- 对称与仿射 min-max 参数计算;
- signed / unsigned / narrow-range qrange;
- per-tensor 与 per-axis 广播;
- 二维权重 per-group 量化;
- clipping 阈值 MSE 网格搜索;
- signed INT4 打包与解包;
- MSE、最大误差与 SQNR;
- bit 数、偏移分布、离群值、group size、输出重要性等可复现实验。
建议再加四个单元测试:
def test_zero_tensor():
x = np.zeros(32)
q, x_hat, _ = fake_quantize(
x, num_bits=4, scheme="symmetric"
)
assert np.all(q == 0)
assert np.allclose(x_hat, x)
def test_int4_pack_roundtrip():
q = np.arange(-8, 8, dtype=np.int8)
packed, n = pack_int4_signed(q)
restored = unpack_int4_signed(packed, n)
assert np.array_equal(restored, q)
def test_no_clip_half_step_bound():
rng = np.random.default_rng(123)
x = rng.uniform(-1, 1, size=10_000)
q, x_hat, p = fake_quantize(
x, num_bits=8, scheme="symmetric"
)
assert np.max(np.abs(x - x_hat)) <= p.scale.item() / 2 + 1e-12
def test_per_group_shape():
w = np.random.default_rng(0).normal(size=(3, 130))
q, scales, w_hat = per_group_symmetric_quantize_2d(
w, group_size=128, num_bits=4
)
assert q.shape == w.shape
assert w_hat.shape == w.shape
assert scales.shape == (3, 2)
第三个测试之所以成立,是因为参数范围由当前 x 的 absmax 确定,样本没有落在范围外。若你故意复用一套较小 scale 去量化更大输入,就应当预期 clip,不能继续断言半步长上界。
附录 B:从量化整数到整数矩阵乘法——zero-point 修正到底在算什么
前文主要讨论“一个浮点数如何编码和还原”。但真正的推理算子是点积与矩阵乘法。理解下面的展开式,就能看懂为什么对称量化更容易做高效整数内核,也能理解 bias 为什么常用更高位整数保存。
B.1 从一个点积开始
设长度为 K 的激活向量与权重向量分别为:
xi≈sx(qix−zx) x_i\approx s_x(q^x_i-z_x) xi≈sx(qix−zx)
wi≈sw(qiw−zw) w_i\approx s_w(q^w_i-z_w) wi≈sw(qiw−zw)
浮点点积:
y=∑i=1Kxiwi y=\sum_{i=1}^{K}x_iw_i y=i=1∑Kxiwi
代入量化表达:
y≈sxsw∑i=1K(qix−zx)(qiw−zw) y\approx s_xs_w \sum_{i=1}^{K}(q^x_i-z_x)(q^w_i-z_w) y≈sxswi=1∑K(qix−zx)(qiw−zw)
把括号展开:
y≈sxsw[∑iqixqiw−zw∑iqix−zx∑iqiw+Kzxzw] y\approx s_xs_w\left[ \sum_i q^x_iq^w_i -z_w\sum_iq^x_i -z_x\sum_iq^w_i +Kz_xz_w \right] y≈sxsw[i∑qixqiw−zwi∑qix−zxi∑qiw+Kzxzw]
方括号里的量都可以用整数计算。它包含四部分:
- 整数码的普通点积;
- 权重 zero-point 乘激活整数和;
- 激活 zero-point 乘权重整数和;
- 两个 zero-point 的常数补偿项。
zero-point 不是“解码时随手减一下”这么简单。进入矩阵乘法后,它会带来额外的行和、列和与常数修正。
B.2 为什么对称权重量化能简化公式
若权重对称量化:
zw=0 z_w=0 zw=0
展开式变为:
y≈sxsw[∑iqixqiw−zx∑iqiw] y\approx s_xs_w\left[ \sum_iq^x_iq^w_i-z_x\sum_iq^w_i \right] y≈sxsw[i∑qixqiw−zxi∑qiw]
与 z_w != 0 相比,少了:
−zw∑iqix+Kzxzw -z_w\sum_iq^x_i+Kz_xz_w −zwi∑qix+Kzxzw
如果激活也采用对称量化,即 z_x=0,则进一步简化为:
y≈sxsw∑iqixqiw y\approx s_xs_w\sum_iq^x_iq^w_i y≈sxswi∑qixqiw
这就是对称格式常受高性能内核欢迎的数学原因之一。实际内核还可能预计算权重整数和、把修正项折叠进 bias,或者利用特定指令隐藏部分成本,但零点结构依然会影响数据流。
B.3 per-channel 权重 scale 如何进入输出
对线性层第 o 个输出通道,权重通常有独立参数:
wo,i≈sw,o(qo,iw−zw,o) w_{o,i}\approx s_{w,o}(q^w_{o,i}-z_{w,o}) wo,i≈sw,o(qo,iw−zw,o)
若激活使用一组 s_x,z_x,那么第 o 个输出:
yo≈sxsw,o⋅acco y_o\approx s_xs_{w,o}\cdot \operatorname{acc}_o yo≈sxsw,o⋅acco
每个输出通道的反缩放系数不同,所以 s_w 是一个向量。矩阵乘法内核需要在输出维度应用对应 scale。
若激活是 per-token 动态量化,s_x 还会随 token 改变:
yt,o≈sx,tsw,o⋅acct,o y_{t,o}\approx s_{x,t}s_{w,o}\cdot\operatorname{acc}_{t,o} yt,o≈sx,tsw,o⋅acct,o
这形成一个外积式缩放:每个 token 一个激活 scale,每个输出通道一个权重 scale。高效实现会把它与后处理融合,避免单独启动大量小算子。
B.4 bias 为什么常映射到 INT32
原始线性层带偏置:
yo=∑ixiwo,i+bo y_o=\sum_i x_iw_{o,i}+b_o yo=i∑xiwo,i+bo
整数累加器对应的实数单位是:
sxsw,o s_xs_{w,o} sxsw,o
因此可把 bias 编码为:
qob=round(bosxsw,o) q^b_o=\operatorname{round}\left(\frac{b_o}{s_xs_{w,o}}\right) qob=round(sxsw,obo)
然后先在整数域加入累加器:
acco′=acco+qob \operatorname{acc}'_o=\operatorname{acc}_o+q^b_o acco′=acco+qob
最后整体乘 s_xs_{w,o}。
bias 的动态范围通常比单个 INT8/INT4 操作数大,而且要与许多乘积之和对齐,所以常使用 INT32 或浮点高精度保存,而不是强行压到与权重相同的 bit 数。
B.5 为什么累积位宽通常高于操作数位宽
一次 INT8 乘法的乘积最大量级约为:
127×127=16129 127\times127=16129 127×127=16129
点积会累加 K 项。即使每个操作数只有 8 bit,累积结果也远超 8 bit 可表示范围。INT4 也同理:操作数小,不代表总和小。
因此常见路径是:
低 bit 操作数 × 低 bit 操作数
↓
更高位累加器
↓
缩放、截断或转浮点
具体使用 INT32、FP32 或其他累积格式,由硬件和内核决定。设计时还要考虑最坏情况溢出、分块部分和、饱和行为以及大 K 的安全范围。
B.6 requantization:怎样把 INT32 输出交给下一层
如果下一层也希望接收量化激活,就不能一直保留 INT32 累加结果。设当前实数输出近似为:
y≈sxsw⋅acc y\approx s_xs_w\cdot\operatorname{acc} y≈sxsw⋅acc
下一层激活量化参数为 s_y,z_y,则需要:
qy=clip(round(sxswsyacc)+zy,qmin,qmax) q_y= \operatorname{clip}\left( \operatorname{round}\left( \frac{s_xs_w}{s_y}\operatorname{acc} \right)+z_y, q_{\min},q_{\max} \right) qy=clip(round(sysxswacc)+zy,qmin,qmax)
比例:
m=sxswsy m=\frac{s_xs_w}{s_y} m=sysxsw
常被称为重标定乘子。纯整数推理实现可能把 m 近似成“整数乘数 + 位移”,避免逐元素浮点乘法;其他实现会使用浮点后处理。无论哪种方式,都要再次面对 round 与 clip。
因此,量化网络并不是只在模型入口做一次编码。每层之间都可能发生:
整数累积 → 重标定 → 舍入 → 截断 → 下一层整数激活
误差会在层间持续注入和传播。
B.7 W4A16 为什么不直接套这套纯整数展开
W4A16 中,权重是低比特,激活仍为 FP16/BF16。常见内核会:
- 从显存读取打包的 INT4 权重;
- 按 tile 读取 scale/zero-point;
- 在寄存器或共享内存中解码为计算格式;
- 与 FP16/BF16 激活执行融合矩阵乘法。
此时收益主要来自权重带宽与缓存,而不一定来自“INT4 × INT4 的纯整数点积”。这也是为什么同样写 W4A16,不同框架的速度可能差很多:真正决定性能的是解码、布局和 GEMM 是否融合。
B.8 一个检查整数内核的最小方法
调试自定义整数点积时,先用极小向量,打印所有中间项:
qx = np.array([3, 5, 2], dtype=np.int32)
qw = np.array([7, 1, 4], dtype=np.int32)
zx = 2
zw = 3
sx = 0.1
sw = 0.2
K = len(qx)
acc = (
np.sum(qx * qw)
- zw * np.sum(qx)
- zx * np.sum(qw)
+ K * zx * zw
)
y_integer_path = sx * sw * acc
x_hat = sx * (qx - zx)
w_hat = sw * (qw - zw)
y_reference = np.sum(x_hat * w_hat)
assert np.allclose(y_integer_path, y_reference)
如果二者不一致,优先检查 zero-point 修正、整数溢出和 dtype,而不是先检查 scale 精度。
附录 C:均匀 INT 量化之外——非均匀码本、NF4 与向量量化
本文用最多篇幅讲均匀仿射量化,因为它公式统一、硬件友好,也是理解许多部署格式的基础。但“量化”远不止把实数放到等间距整数网格。
C.1 更一般的量化公式
设码本包含 M=2^b 个实数中心:
C={c0,c1,…,cM−1} \mathcal C=\{c_0,c_1,\ldots,c_{M-1}\} C={c0,c1,…,cM−1}
编码可以写成:
q=argminjd(x,cj) q=\arg\min_j d(x,c_j) q=argjmind(x,cj)
解码:
x^=cq \hat x=c_q x^=cq
其中 d 可以是绝对距离、平方距离或带权距离。
均匀仿射量化只是特殊情况:
cj=s(j−z) c_j=s(j-z) cj=s(j−z)
所有相邻中心间距相同。非均匀量化则允许:
cj+1−cj c_{j+1}-c_j cj+1−cj
随位置改变。
C.2 为什么非均匀码本可能更省误差
神经网络权重常大量集中在 0 附近,远离 0 的值较少。如果仍把 16 个级别均匀铺满整个范围,稀疏尾部与密集中心得到相同间距,未必高效。
非均匀码本可以:
- 在概率密度高的区域放更多中心;
- 在尾部放较少、较宽的区间;
- 让平均重构误差更贴合数据分布。
这类似城市里公交站的安排:人口密集区站点更密,郊区站点更疏。均匀距离容易设计,按人口密度设计则可能更方便大多数人。
C.3 k-means / Lloyd-Max 式码本
一种直接做法是把标量权重当作一维聚类数据:
- 初始化
M个中心; - 每个权重分配给最近中心;
- 用簇内均值更新中心;
- 反复迭代到收敛。
最终只存:
- 每个权重的中心索引;
- 每组或每层的码本中心。
优点是码本适应实际分布;缺点是:
- 需要查表;
- 中心本身有存储开销;
- 不规则值不一定适配整数乘法指令;
- 每层独有码本会增加元数据和内核复杂度。
因此,非均匀重构误差更低,不代表实际推理一定更快。
C.4 NF4 与普通 INT4 有什么不同
NF4(NormalFloat 4-bit)使用 16 个非均匀级别,设计目标是更贴合近似正态分布的权重。它在 QLoRA 中用于压缩冻结的基础模型权重,让训练只更新 LoRA 适配器,从而大幅降低微调显存。
理解 NF4 时要分清三件事:
- 它的索引宽度是 4 bit;
- 解码值不是简单的
s(q-z)等间距整数网格; - 它常在计算前解码到 BF16/FP16 等计算类型,不等同于某种原生 INT4 整数矩阵乘法。
所以“NF4”和“INT4”都写 4 bit,数值语义与适用场景却可能不同。
QLoRA 还提出 double quantization,也就是继续量化量化常数。它针对的是一个容易被忽略的问题:当 group 很小时,scale 等常数的总开销也会变得可观。再次量化这些常数,可以降低平均 bit/weight,但同时增加一层解码结构。
C.5 对数量化与幂次码本
若码本中心限制为 2 的幂:
C={0,±2k1,±2k2,…} \mathcal C=\{0,\pm2^{k_1},\pm2^{k_2},\ldots\} C={0,±2k1,±2k2,…}
乘法有机会转成移位与加法。它对跨多个数量级的数据可能有吸引力。
但神经网络权重并不总符合对数分布,零附近的精度、符号编码和硬件实际支持也要考虑。理论上“用移位代替乘法”不代表现代 GPU 上一定更快,因为 GPU 的高吞吐矩阵指令、内存布局和并行度可能比单个标量运算成本更重要。
C.6 二值、三值与极低 bit
二值权重可能使用:
{−1,+1} \{-1,+1\} {−1,+1}
三值权重可能使用:
{−1,0,+1} \{-1,0,+1\} {−1,0,+1}
它们能显著压缩表示,并可能把乘法简化为符号操作或条件累加。但直接把一个普通 FP16 大模型逐元素 round 成二值/三值,通常会产生巨大误差。
极低 bit 成功往往需要:
- 专门训练或继续预训练;
- 更合适的缩放与码本;
- 结构化误差补偿;
- 模型宽度与冗余;
- 与硬件共同设计。
因此,“朴素 INT2 很差”与“先进 2 bit / ternary 方法可能可用”可以同时成立。前者描述基线,后者描述经过专门优化的系统。
C.7 向量量化:一次编码多个权重
标量量化一次为一个数选择码本中心。向量量化则把一小段权重视为向量:
v∈Rd v\in\mathbb R^d v∈Rd
从向量码本中选择:
q=argminj∥v−cj∥22 q=\arg\min_j\|v-c_j\|_2^2 q=argjmin∥v−cj∥22
它能利用权重之间的相关性,一个索引同时代表多个数。潜在压缩率与重构能力更强,但码本搜索、查表、缓存和矩阵乘法融合更复杂。
产品量化(Product Quantization)还会把高维向量拆成多个子空间,各自使用较小码本,在码本容量与搜索成本间折中。
C.8 标量 bit 数与有效 bit 数不要混淆
一个方案说“4 bit 索引”,实际每个权重的平均成本还可能包括:
effective bits=index bits+scale overhead+zero-point overhead+codebook overhead+padding/metadata \text{effective bits} =\text{index bits} +\text{scale overhead} +\text{zero-point overhead} +\text{codebook overhead} +\text{padding/metadata} effective bits=index bits+scale overhead+zero-point overhead+codebook overhead+padding/metadata
对向量量化,码本开销分摊方式又不同。比较方案时,应尽量报告实际文件大小或有效 bit/weight,而不是只引用索引宽度。
C.9 什么时候优先考虑均匀量化
均匀量化通常在以下条件下更有吸引力:
- 后端有成熟 INT8/INT4 内核;
- 延迟与吞吐比极致重构误差更重要;
- 希望格式简单、易部署、易跨平台;
- 分组后局部分布已足够好量化;
- 需要纯整数或规则低比特计算。
C.10 什么时候非均匀码本值得考虑
- 权重分布与均匀网格明显不匹配;
- 主要目标是训练显存或模型存储;
- 后端已有高效查表/解码内核;
- 低 bit 下均匀量化掉点过大;
- 可以接受更复杂的格式与预处理。
最终仍要回到系统目标。码本更聪明只是一个维度,能否高效读、解码、乘加和累积同样重要。
C.11 为什么先学均匀仿射量化仍然最划算
掌握均匀仿射量化后,你已经拥有分析其他方案的统一语言:
码本有多少级?
中心是否等间距?
码本按 tensor、channel 还是 group 共享?
编码目标是权重 MSE、输出误差还是任务损失?
scale 与码本开销如何分摊?
计算时是整数乘法、查表,还是解码到浮点?
换一个新算法名,只要重新回答这六个问题,就不会再被“4 bit”“无损”“原生加速”等标签牵着走。
附录 D:如何评价量化质量——从标量误差到真实业务指标
量化实验最容易犯的错误,是找到一个好看的 MSE 就宣布成功。可靠评价应该像金字塔一样逐层上升:底层指标便宜、适合排错;越往上越接近真实目标,也越耗时。
业务体验与系统成本
↑
端到端任务、生成质量、困惑度
↑
层输出误差、logit 差异
↑
权重/激活重构误差与分布统计
每一层都不能完全替代上一层。底层指标能快速定位 bug,顶层指标决定方案是否真的可用。
D.1 MAE:平均每个元素偏了多少
MAE(x,x^)=1N∑i∣xi−x^i∣ \operatorname{MAE}(x,\hat x) =\frac{1}{N}\sum_i|x_i-\hat x_i| MAE(x,x^)=N1i∑∣xi−x^i∣
MAE 的单位与原数据相同,直观、对极端大误差没有 MSE 那么敏感。它适合回答:“平均每个权重偏了多远?”
局限是:大量小误差与少量大误差可能得到相似 MAE,而模型对二者的反应未必相同。
D.2 MSE 与 RMSE:更强调大误差
MSE(x,x^)=1N∑i(xi−x^i)2 \operatorname{MSE}(x,\hat x) =\frac{1}{N}\sum_i(x_i-\hat x_i)^2 MSE(x,x^)=N1i∑(xi−x^i)2
RMSE=MSE \operatorname{RMSE}=\sqrt{\operatorname{MSE}} RMSE=MSE
平方会放大大误差的影响,所以 MSE 常用于 clipping 搜索和层重构。RMSE 回到原数据单位,更容易解释。
但 MSE 与张量整体尺度有关。标准差为 100 的张量和标准差为 0.01 的张量,不能只看绝对 MSE 横向比较。
D.3 相对误差与归一化 MSE
一种常见归一化方式:
NMSE=∥x−x^∥22∥x∥22+ϵ \operatorname{NMSE} =\frac{\|x-\hat x\|_2^2}{\|x\|_2^2+\epsilon} NMSE=∥x∥22+ϵ∥x−x^∥22
它衡量误差能量占信号能量的比例,适合比较不同层或不同动态范围张量。
也可以逐元素计算相对误差:
ri=∣xi−x^i∣∣xi∣+ϵ r_i=\frac{|x_i-\hat x_i|}{|x_i|+\epsilon} ri=∣xi∣+ϵ∣xi−x^i∣
但当 x_i 接近 0 时,相对误差会非常大。对大量零中心权重,逐元素相对误差通常比 NMSE 更不稳定。
D.4 SQNR:用分贝看信号与量化噪声
SQNR=10log10E[x2]E[(x−x^)2] \operatorname{SQNR} =10\log_{10} \frac{\mathbb E[x^2]} {\mathbb E[(x-\hat x)^2]} SQNR=10log10E[(x−x^)2]E[x2]
SQNR 越高,表示量化噪声相对信号越小。它比裸 MSE 更方便跨尺度比较。
需要注意:这里把误差称为“噪声”只是评价语言,并不保证误差真的独立、白化或均匀随机。
D.5 最大绝对误差:专门抓灾难点
Emax=maxi∣xi−x^i∣ E_{\max}=\max_i|x_i-\hat x_i| Emax=imax∣xi−x^i∣
平均指标可能掩盖少量严重错误。最大误差适合检查:
- 是否有异常 clip;
- scale 是否错组;
- int4 符号扩展是否失败;
- zero-point 是否错位;
- 某个通道是否严重失真。
它对单个异常值极敏感,因此更适合作为安全检查,不适合单独决定整体方案。
D.6 余弦相似度:方向保持得怎么样
cos(x,x^)=xTx^∥x∥2∥x^∥2 \cos(x,\hat x) =\frac{x^T\hat x}{\|x\|_2\|\hat x\|_2} cos(x,x^)=∥x∥2∥x^∥2xTx^
余弦相似度接近 1,说明整体方向相近。它对统一缩放不敏感:即使 x_hat = 2x,余弦仍为 1。
这既是优点也是缺点。某些网络结构对方向更敏感,但数值尺度错误同样可能破坏残差、归一化或后续非线性。因此余弦相似度应与 NMSE、输出幅度一起看。
D.7 clip rate、零化率与码本利用率
除了重构误差,还应统计量化行为。
截断率
clip rate=#{q=qmin 或 q=qmax}N \text{clip rate} =\frac{\#\{q=q_{\min}\ \text{或}\ q=q_{\max}\}}{N} clip rate=N#{q=qmin 或 q=qmax}
边界编码中也可能包含本来就靠近端点的正常值,所以更精确的实现可直接统计量化前超出阈值的样本。
clip rate 过高通常表示范围太窄;完全为 0 也不代表最优,可能说明被离群值撑得过宽。
零化率
zero rate=#{x^=0}N \text{zero rate}=\frac{\#\{\hat x=0\}}{N} zero rate=N#{x^=0}
低 bit、粗 scale 会把大量小权重压成 0。适度零化未必有害,但突然暴增常提示主体分辨率丢失。
码本利用率
统计实际用了多少个整数码,以及每个码的频数。若 INT4 的 15 个有效级别只用了 3 个,说明 scale、分布或分组可能不合理。
还可以画每组直方图,观察是否大量堆在边界或 0。
D.8 分层统计比一个全局数字更有价值
全模型平均 MSE 可能被大层主导,也可能掩盖一个小而敏感的层。建议至少按以下层级保存统计:
模型 → block → module → tensor → channel/group
对每层记录:
- absmax 与分位数;
- scale 范围;
- MSE / NMSE / SQNR;
- clip rate;
- 零化率;
- 最大误差位置;
- group 中最差与中位统计。
排查质量下降时,按最差层排序往往比盯着全局均值有效得多。
D.9 层输出误差:比权重误差更接近模型行为
给定校准激活 X:
Y=XWT,Y^=XW^T Y=XW^T, \qquad \hat Y=X\hat W^T Y=XWT,Y^=XW^T
可计算:
OutputNMSE=∥Y−Y^∥F2∥Y∥F2+ϵ \operatorname{OutputNMSE} =\frac{\|Y-\hat Y\|_F^2}{\|Y\|_F^2+\epsilon} OutputNMSE=∥Y∥F2+ϵ∥Y−Y^∥F2
还可记录输出余弦相似度、每个 token 的误差、每个输出通道的误差。
层输出指标能捕捉激活重要性,却仍有局限:它通常使用原始层输入 X。真实量化模型运行到这一层时,输入本身已经带有前面层的误差。若要观察累计效应,需要逐层替换或直接运行完整量化模型。
D.10 logit 误差与分布差异
语言模型最终输出词表 logits。可以比较:
- logit MSE;
- top-k token 集合重合率;
- argmax 一致率;
- softmax 后分布的 KL divergence;
- 正确 token 的对数概率变化。
logit 差异比权重 MSE更接近生成行为,但也不要只看 argmax。两个模型当前 argmax 相同,次高概率与分布尾部可能已经变化,后续采样结果会逐步分叉。
计算 KL 时要明确方向:
DKL(P∥Q)≠DKL(Q∥P) D_{KL}(P\|Q)\ne D_{KL}(Q\|P) DKL(P∥Q)=DKL(Q∥P)
通常把高精度模型分布视为参考 P,量化模型为 Q,并注意数值稳定与零概率处理。
D.11 困惑度:语言建模质量的常用总体指标
对 token 序列,平均负对数似然为:
NLL=−1N∑tlogp(xt∣x<t) \operatorname{NLL} =-\frac{1}{N}\sum_t\log p(x_t|x_{<t}) NLL=−N1t∑logp(xt∣x<t)
困惑度:
PPL=exp(NLL) \operatorname{PPL}=\exp(\operatorname{NLL}) PPL=exp(NLL)
同一模型、同一 tokenizer、同一数据与同一滑窗设置下,PPL 越低通常越好。
量化评估中必须保持:
数据版本一致
分词器一致
BOS/EOS 处理一致
上下文窗口与 stride 一致
是否忽略 padding 一致
否则几个小数点的差异可能来自评估脚本,而不是量化。
PPL 是整体语言建模指标,不一定充分反映指令遵循、代码执行、数学推理或多轮对话。
D.12 下游任务与生成评价
根据实际用途选择任务:
- 选择题与知识问答;
- 数学、代码、工具调用;
- 长文总结与检索增强;
- JSON / schema 遵循;
- 多语言与目标领域;
- 安全与拒答稳定性。
生成任务还应关注:
- 重复与退化循环;
- 格式破坏;
- 长上下文后半段稳定性;
- 对温度、top-p 的敏感性;
- 多轮累积偏差。
尽量使用确定性解码做基础对照,再在实际采样配置下做多次运行。随机生成只看一条样例,结论很容易受运气影响。
D.13 质量指标必须与系统指标联表
最终表格不应只有“准确率”。建议一行代表一个量化配置,列同时包含:
有效 bit/weight
模型文件大小
加载后权重内存
峰值内存
首 token 延迟
prefill tokens/s
decode tokens/s
PPL / 任务分数
层输出 NMSE
这样才能看到真正的 Pareto 前沿:某方案质量最好但速度慢,另一个只损失极少质量却显著节省内存,第三个文件最小却因内核不匹配反而更慢。
D.14 校准集、开发集与测试集要分开
如果你用一批数据搜索 clipping、group 配置、跳过层和混合精度,再在同一批数据上报告结果,容易过拟合量化参数。
推荐:
- 校准集:估计 scale、激活统计、二阶信息;
- 开发集:选择 bit、group size 与策略;
- 测试集:只用于最终报告。
数据量可以不大,但角色应分开。还要准备分布外测试,检查量化是否只适合校准领域。
D.15 一套由快到慢的推荐评估流程
第一级:实现正确性
手算小数组
编码/解码 round-trip
qrange 与打包测试
无 clip 半步长上界
NaN/Inf/全零边界
第二级:张量与层级
MSE、NMSE、SQNR
clip rate、零化率、码本利用率
层输出 NMSE 与余弦
最差层与最差 group
第三级:完整模型
PPL、logit 分布
目标任务集
长上下文与多语言
确定性生成回归测试
第四级:系统与业务
峰值内存、延迟、吞吐
并发与稳定性
真实请求分布
成本与故障率
只有四级共同通过,量化方案才算真正完成。底层指标帮助你解释“为什么”,顶层指标回答“值不值得”。
参考资料
以下资料适合在读完本文后继续深入。论文结论应结合模型、数据、实现与硬件条件理解。
-
Benoit Jacob et al., Quantization and Training of Neural Networks for Efficient Integer-Arithmetic-Only Inference, 2017/2018. 经典均匀仿射量化与整数推理工作。
https://arxiv.org/abs/1712.05877 -
Tim Dettmers et al., LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale, 2022. 系统性激活离群特征与混合精度分解。
https://arxiv.org/abs/2208.07339 -
Zhewei Yao et al., ZeroQuant: Efficient and Affordable Post-Training Quantization for Large-Scale Transformers, 2022. 权重与激活的细粒度 PTQ 及系统后端。
https://arxiv.org/abs/2206.01861 -
Elias Frantar et al., GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers, 2022. 近似二阶信息、顺序量化与误差补偿。
https://arxiv.org/abs/2210.17323 -
Guangxuan Xiao et al., SmoothQuant: Accurate and Efficient Post-Training Quantization for Large Language Models, 2022. 通过等价变换平滑激活、迁移动范围。
https://arxiv.org/abs/2211.10438 -
Ji Lin et al., AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration, 2023. 用激活统计保护显著权重通道。
https://arxiv.org/abs/2306.00978 -
Tim Dettmers et al., SpQR: A Sparse-Quantized Representation for Near-Lossless LLM Weight Compression, 2023. 隔离高误差权重并使用稀疏高精度表示。
https://arxiv.org/abs/2306.03078 -
PyTorch Documentation, fake_quantize_per_tensor_affine / fake_quantize_per_channel_affine. 可用于核对仿射假量化公式与 per-channel 广播。
https://docs.pytorch.org/docs/stable/generated/torch.fake_quantize_per_tensor_affine.html
https://docs.pytorch.org/docs/stable/generated/torch.fake_quantize_per_channel_affine.html -
ggml-org/llama.cpp, quantize tool documentation. 用于理解 GGUF 转换、量化类型、importance matrix 与具体运行工具。
https://github.com/ggml-org/llama.cpp/blob/master/tools/quantize/README.md -
Tim Dettmers et al., QLoRA: Efficient Finetuning of Quantized LLMs, 2023. NF4、double quantization 与低显存参数高效微调。
https://arxiv.org/abs/2305.14314
一句话收尾:量化不是“把 FP16 强行四舍五入成 INT4”,而是在有限码本、误差结构、模型敏感性与硬件执行之间做联合设计。
更多推荐




所有评论(0)