大模型量化从 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 个问题

  1. 量化到底压缩了什么?低比特整数、scale、zero-point 各自扮演什么角色?
  2. 为什么“量化误差不超过半个步长”不是无条件成立的?
  3. round 误差与 clip 误差有什么本质区别?
  4. INT4 到底是 16 个级别,还是 [-7, 7] 的 15 个级别?
  5. 对称量化与非对称量化的区别只是有没有 zero-point 吗?
  6. 为什么 min-max scale 往往不是误差最小的 scale?
  7. per-tensor、per-channel、per-group、per-token 分别沿哪个维度共享参数?
  8. group size 越小是否一定越好?它的真实存储开销怎么算?
  9. 为什么权重 MSE 很小,模型输出仍可能明显变差?
  10. W4A16、W8A8、weight-only、PTQ、QAT 这些词是什么关系?
  11. GPTQ、AWQ、SmoothQuant 各自利用了哪类信息?
  12. 为什么“模型文件缩小 4 倍”不等于“推理速度提升 4 倍”?

如果你只想快速建立主干,可以先读第 0、2、3、6、11、13 节;如果你准备自己实现或调参,建议从头顺序阅读,并实际运行第 9、10 节代码。


目录

    1. 先建立两幅图:量化是有限码本,也是有损通信
    1. 从浮点数到整数码:我们究竟在压缩什么
    1. 统一公式:均匀仿射量化
    1. 误差从哪里来:round、clip 与参数估计
    1. 对称量化与非对称量化
    1. scale 怎么选:min-max、截断与 MSE 搜索
    1. 量化粒度:per-tensor / per-channel / per-group / per-token
    1. 先把术语地图画清:W4A16、W8A8、PTQ、QAT
    1. 三次手算:把每个整数都算出来
    1. 从零实现一个可靠的 NumPy 量化器
    1. 五组实验:用数据验证直觉,也推翻过度简化的结论
    1. 从权重误差走向输出误差:为什么“重要性”比 MSE 更关键
    1. GPTQ、AWQ、SmoothQuant 与 GGUF:它们究竟解决什么
    1. 工程现实:文件大小、显存、带宽、算力与内核
    1. 常见误区与排错清单
    1. 自测题与动手练习
    1. 总结:把整篇压缩成一张认知地图
  • 附录 A:完整实验脚本、运行方法与校验项
  • 附录 B:从量化整数到整数矩阵乘法
  • 附录 C:均匀 INT 量化之外
  • 附录 D:量化质量评价方法
  • 参考资料

0. 先建立两幅图:量化是有限码本,也是有损通信

理解量化最容易卡住的地方,是一上来就掉进公式。先不写公式,我们建立两幅图。

0.1 第一幅图:连续尺子换成有限刻度

假设一支温度计可以显示任意小数:20.137°C20.138°C20.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

这条链路提醒我们,评价量化至少有三层:

  1. 表示层:单个数还原得准不准,例如绝对误差、MSE。
  2. 算子层:矩阵乘法的输出变了多少。
  3. 模型层:困惑度、准确率、生成质量和稳定性变了多少。

很多“看起来数学上误差很小,模型却掉点明显”的现象,正是因为这三层并不等价。本文前半段先把表示层讲透,后半段再把误差推到矩阵乘法与模型输出。


1. 从浮点数到整数码:我们究竟在压缩什么

1.1 量化后真正存储的,不只是整数

最常见的线性量化会存三类信息:

  • 低比特整数 q:权重的主体;
  • 缩放因子 s(scale):整数相邻一级对应多大的实数距离;
  • 零点 z(zero-point):哪个整数编码对应实数 0。

解码时,用 sz 把整数放回实数轴:

x^=s(q−z) \hat{x} = s(q-z) x^=s(qz)

所以“4 bit 模型”并不意味着每个参数严格只花 4 bit。它通常还要分摊 scale、zero-point、元数据、对齐与打包开销。更准确的说法是:权重主体采用 4 bit 编码

1.2 scale 是“尺子的刻度间距”

假设 s = 0.1z = 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(qz)

合起来就是:

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)+zround(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 可表示的实数范围

给定 szq_minq_max,最小和最大可表示实数分别是:

rmin⁡=s(qmin⁡−z) r_{\min}=s(q_{\min}-z) rmin=s(qminz)

rmax⁡=s(qmax⁡−z) r_{\max}=s(q_{\max}-z) rmax=s(qmaxz)

所有可表示值组成等间距集合:

C={s(k−z)∣k=qmin⁡,…,qmax⁡} \mathcal{C}=\{s(k-z)\mid k=q_{\min},\ldots,q_{\max}\} C={s(kz)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.750.63=0.12

由于没有发生 clip,0.12 < s/2 = 0.125。下一节会说明,这个“半步长上界”为什么必须附带前提。


3. 误差从哪里来:round、clip 与参数估计

量化误差常被一句话概括为“round 产生的误差”。这只说对了一部分。完整地看,至少有三类误差来源:

  1. 舍入误差:连续值落到最近网格点;
  2. 截断误差:值超出可表示范围,被夹到边界;
  3. 参数估计误差:校准数据不足,导致 scale / zero-point 不适合真实输入。

3.1 未发生截断时,误差不超过半个步长

先看最理想情况:x 位于可表示范围内,采用 round-to-nearest,并且网格间距为 s

在任意相邻网格点之间,最远的位置是二者中点,所以:

∣x−x^∣≤s2 |x-\hat{x}|\le \frac{s}{2} xx^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 xx^=∣5.01.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} xx^xclip(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) eU(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→01.5→22.5→2
  • stochastic rounding:按距离概率随机落到相邻格点。

NumPy 的 np.round/np.rint 与 PyTorch 的 torch.round 通常采用 ties-to-even,也就是俗称的“银行家舍入”。它能减少大量恰好半点样本总向同一方向偏移的问题。

不过,真实框架还可能因为:

  • 浮点表示误差;
  • CPU 与 GPU 指令差异;
  • 融合内核的实现;
  • 先加 zero-point 还是先做其他变换;

而在极少数边界值上产生 ±1 的整数差异。验证量化器时,不要只比较打印后的小数,最好同时比较 qsz 和使用的舍入约定。

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=2b11

令:

a=max⁡i∣xi∣ a=\max_i |x_i| a=imaxxi

最直接的 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⁡−rmin⁡qmax⁡−qmin⁡ s=\frac{r_{\max}-r_{\min}}{q_{\max}-q_{\min}} s=qmaxqminrmaxrmin

z=clip⁡(round⁡(qmin⁡−rmin⁡s),qmin⁡,qmax⁡) z=\operatorname{clip}\left( \operatorname{round}\left(q_{\min}-\frac{r_{\min}}{s}\right), q_{\min},q_{\max} \right) z=clip(round(qminsrmin),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_minr_max 不一定都恰好落在网格点上。端点误差不是实现出错,而是“整数 zero-point、固定级别数、同时覆盖区间”之间的离散约束。


5. scale 怎么选:min-max、截断与 MSE 搜索

公式本身并不难,真正决定效果的往往是参数选择。尤其是 scale,它决定了“刻度有多细”和“尺子有多长”。

5.1 min-max:最容易理解,也最容易被离群值绑架

最直接的做法是让量化范围覆盖观测到的全部数据。

对称量化:

s=max⁡∣x∣qmax⁡ s=\frac{\max |x|}{q_{\max}} s=qmaxmaxx

仿射量化:

s=rmax⁡−rmin⁡qmax⁡−qmin⁡ s=\frac{r_{\max}-r_{\min}}{q_{\max}-q_{\min}} s=qmaxqminrmaxrmin

它的优势很明确:观测范围内基本不会因为 scale 选择而额外截断。

问题也同样明确:只要出现极少数大值,整个网格就会被拉宽。假设 99.9% 的权重位于 [-0.1,0.1],只有一个值是 5.0。对称 INT4 窄范围下:

s=5/7≈0.7143 s=5/7\approx0.7143 s=5/70.7143

那么绝大多数 0.010.030.08 都会落到 0 附近。为了精确容纳一个极端值,主体分布几乎失去了分辨率。

5.2 主动截断:允许牺牲极少数大值,换取主体更细的网格

假设我们不再使用真实最大绝对值 a,而是选择阈值 α<a

s(α)=αqmax⁡ s(\alpha)=\frac{\alpha}{q_{\max}} s(α)=qmaxα

所有绝对值大于 α 的数都会被裁到边界。这样会增加离群值的截断误差,却会减小区间内部所有值的舍入误差。

量化问题于是变成一个权衡:

α∗=arg⁡min⁡α∑i(xi−x^i(α))2 \alpha^*=\arg\min_{\alpha} \sum_i\left(x_i-\hat{x}_i(\alpha)\right)^2 α=argαmini(xix^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(xix^i)2

但神经网络中,不同权重对输出的影响并不相同。一个绝对值很小的权重,如果它连接到幅度很大的输入通道,误差可能被放大;另一个较大的权重,如果对应输入几乎总为 0,误差影响反而很小。

因此可以考虑加权误差:

Lweighted=∑iwi(xi−x^i)2 L_{\text{weighted}}=\sum_i w_i(x_i-\hat{x}_i)^2 Lweighted=iwi(xix^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} WRO×I

其中 O = out_featuresI = 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=0axis=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} XRB×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/70.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/20.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=15050=310.333333

zero-point:

z=round⁡(0−0/s)=0 z=\operatorname{round}(0-0/s)=0 z=round(00/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/70.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/70.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
    )

这样做有三个好处:

  1. 同一套参数可以编码多个张量;
  2. 可以单独检查整数码是否正确;
  3. 能更贴近真实部署中“离线生成参数、运行时解码”的流程。

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 WW^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∥Δy22=E∥ΔWx22

令输入协方差:

Σ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∥ΔWx22=tr(ΔWΣxΔWT)

如果暂时假设各输入通道互不相关,Σ_x 近似为对角矩阵:

Σx≈diag⁡(σ12,…,σI2) \Sigma_x\approx\operatorname{diag}(\sigma_1^2,\ldots,\sigma_I^2) Σxdiag(σ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∥Δy22o,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 ΔL21ΔwTHΔw

普通 MSE 相当于粗略地用单位矩阵代替 H

H≈I H\approx I HI

而二阶量化方法试图保留 H 所描述的方向敏感性:某些方向上微小移动代价很大,另一些方向上可以容忍更多误差。

11.5 “误差补偿”是什么意思

假设某个权重必须从 w_i 改为量化值 q_i,产生不可避免的局部误差。我们可以轻微调整尚未量化的其他权重,使线性层输出尽可能保持不变。

这不是把误差凭空消除,而是:

把被迫发生的误差,沿模型不敏感的方向重新分配。

一个直观类比:你必须从总预算里砍掉 100 元。平均从每个项目砍一点未必最好;更合理的是根据每个项目的重要性与替代关系重新分配。

11.6 权重 MSE、层输出 MSE 与任务损失的层级

三个目标从易到难:

权重重构

min⁡∥W−W^∥F2 \min\|W-\hat W\|_F^2 minWW^F2

不需要校准数据,计算便宜,但忽略输入。

层输出重构

min⁡∥XWT−XW^T∥F2 \min\|XW^T-X\hat W^T\|_F^2 minXWTXW^TF2

利用一批校准激活 X,更接近实际算子行为。

端到端任务目标

min⁡Ltask(W^) \min\mathcal{L}_{task}(\hat W) minLtask(W^)

最接近真实需求,但计算昂贵、依赖数据,且优化更困难。

GPTQ、AWQ 等 PTQ 方法通常处在中间:它们使用有限校准数据和局部统计,尽量获得接近任务感知的收益,又避免完整重训练。

11.7 误差会跨层传播

量化某一层后,后续层接收到的输入也发生变化。局部误差经过:

  • 线性投影;
  • 激活函数;
  • 归一化;
  • 注意力 softmax;
  • 残差连接;
  • 多层重复;

可能被削弱、抵消或放大。

这解释了两个现象:

  1. 单层 MSE 很低,不保证端到端指标完全不变;
  2. 某些层远比其他层敏感,统一 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 通常指:

  1. 用 min-max、absmax 或简单 clipping 选参数;
  2. 每个权重独立舍入到最近网格;
  3. 不做跨权重误差补偿。

RTN 简单、快、容易实现。更复杂算法是否真的有价值,应该与配置一致的 RTN 基线比较,而不是只与 FP16 比较。

12.3 GPTQ:用近似二阶信息做顺序量化与补偿

GPTQ 是 one-shot 权重量化方法。它不是只看权重绝对值,而是用校准输入构造局部敏感性近似。

对一行权重 w,层输出重构目标可写成:

∥wX−w^X∥22 \|wX-\hat wX\|_2^2 wXw^X22

其曲率与输入统计 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)(S1x)

在未量化的精确数学里,这与原式完全等价。通过放大重要权重通道,再对对应激活做逆缩放,可以让这些权重在量化网格上获得更有利的相对分辨率,而不必真的把它们单独保留成 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=(XS1)(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=maxWj1αmaxXjα

直觉上:

  • α 大,更多压力从激活移向权重;
  • α 小,权重变化较小,激活保留更多动态范围。

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_0Q4_K_MQ5_K_MQ8_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 xx^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 仿射量化,求 sz

参考答案

s=(3−(−1))/15=4/15≈0.266667 s=(3-(-1))/15=4/15\approx0.266667 s=(3(1))/15=4/150.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=WxW_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.rinttorch.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 byte109.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 表示层

  1. 量化是用有限码本近似连续或高精度数值。
  2. 常见均匀仿射量化为:

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(qz)

  1. scale 决定网格间距,zero-point 决定整数轴平移。
  2. INT4 只说明 4 bit 编码,[-8,7][-7,7][0,15] 都可能是合法约定。

16.2 误差层

  1. 无 clip 且最近邻舍入时,标量误差不超过 s/2
  2. clip 后误差可以远大于 s/2
  3. “误差均匀随机、方差 s²/12”是有条件的近似,不是低比特量化定律。
  4. scale 选择是在舍入误差与截断误差之间平衡;min-max 不一定最优。

16.3 粒度层

  1. per-tensor、per-channel、per-group、per-token 的区别,是哪些元素共享参数。
  2. 粒度越细通常越能贴合局部分布,但 scale、zero-point、带宽和内核成本会上升。
  3. group_size=128 是常见折中,不是普适标准答案。
  4. 4 bit 权重还要真正打包;把小整数存进 int8 不会自动省到 4 bit。

16.4 模型层

  1. 线性层输出误差为:

Δy=ΔWx \Delta y=\Delta W x Δy=ΔWx

所以同样的权重误差,落在高能量激活通道上影响更大。

  1. GPTQ 用近似二阶信息顺序量化并补偿;AWQ 用激活统计识别并缩放重要通道;SmoothQuant 用等价变换在激活与权重之间迁移动范围。
  2. GGUF 是格式与生态,不是单一量化算法。
  3. 最终选择必须同时看质量、文件、峰值内存、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) xisx(qixzx)

wi≈sw(qiw−zw) w_i\approx s_w(q^w_i-z_w) wisw(qiwzw)

浮点点积:

y=∑i=1Kxiwi y=\sum_{i=1}^{K}x_iw_i y=i=1Kxiwi

代入量化表达:

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) ysxswi=1K(qixzx)(qiwzw)

把括号展开:

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] ysxsw[iqixqiwzwiqixzxiqiw+Kzxzw]

方括号里的量都可以用整数计算。它包含四部分:

  1. 整数码的普通点积;
  2. 权重 zero-point 乘激活整数和;
  3. 激活 zero-point 乘权重整数和;
  4. 两个 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] ysxsw[iqixqiwzxiqiw]

z_w != 0 相比,少了:

−zw∑iqix+Kzxzw -z_w\sum_iq^x_i+Kz_xz_w zwiqix+Kzxzw

如果激活也采用对称量化,即 z_x=0,则进一步简化为:

y≈sxsw∑iqixqiw y\approx s_xs_w\sum_iq^x_iq^w_i ysxswiqixqiw

这就是对称格式常受高性能内核欢迎的数学原因之一。实际内核还可能预计算权重整数和、把修正项折叠进 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,isw,o(qo,iwzw,o)

若激活使用一组 s_x,z_x,那么第 o 个输出:

yo≈sxsw,o⋅acc⁡o y_o\approx s_xs_{w,o}\cdot \operatorname{acc}_o yosxsw,oacco

每个输出通道的反缩放系数不同,所以 s_w 是一个向量。矩阵乘法内核需要在输出维度应用对应 scale。

若激活是 per-token 动态量化,s_x 还会随 token 改变:

yt,o≈sx,tsw,o⋅acc⁡t,o y_{t,o}\approx s_{x,t}s_{w,o}\cdot\operatorname{acc}_{t,o} yt,osx,tsw,oacct,o

这形成一个外积式缩放:每个 token 一个激活 scale,每个输出通道一个权重 scale。高效实现会把它与后处理融合,避免单独启动大量小算子。

B.4 bias 为什么常映射到 INT32

原始线性层带偏置:

yo=∑ixiwo,i+bo y_o=\sum_i x_iw_{o,i}+b_o yo=ixiwo,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)

然后先在整数域加入累加器:

acc⁡o′=acc⁡o+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} ysxswacc

下一层激活量化参数为 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。常见内核会:

  1. 从显存读取打包的 INT4 权重;
  2. 按 tile 读取 scale/zero-point;
  3. 在寄存器或共享内存中解码为计算格式;
  4. 与 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,,cM1}

编码可以写成:

q=arg⁡min⁡jd(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(jz)

所有相邻中心间距相同。非均匀量化则允许:

cj+1−cj c_{j+1}-c_j cj+1cj

随位置改变。

C.2 为什么非均匀码本可能更省误差

神经网络权重常大量集中在 0 附近,远离 0 的值较少。如果仍把 16 个级别均匀铺满整个范围,稀疏尾部与密集中心得到相同间距,未必高效。

非均匀码本可以:

  • 在概率密度高的区域放更多中心;
  • 在尾部放较少、较宽的区间;
  • 让平均重构误差更贴合数据分布。

这类似城市里公交站的安排:人口密集区站点更密,郊区站点更疏。均匀距离容易设计,按人口密度设计则可能更方便大多数人。

C.3 k-means / Lloyd-Max 式码本

一种直接做法是把标量权重当作一维聚类数据:

  1. 初始化 M 个中心;
  2. 每个权重分配给最近中心;
  3. 用簇内均值更新中心;
  4. 反复迭代到收敛。

最终只存:

  • 每个权重的中心索引;
  • 每组或每层的码本中心。

优点是码本适应实际分布;缺点是:

  • 需要查表;
  • 中心本身有存储开销;
  • 不规则值不一定适配整数乘法指令;
  • 每层独有码本会增加元数据和内核复杂度。

因此,非均匀重构误差更低,不代表实际推理一定更快。

C.4 NF4 与普通 INT4 有什么不同

NF4(NormalFloat 4-bit)使用 16 个非均匀级别,设计目标是更贴合近似正态分布的权重。它在 QLoRA 中用于压缩冻结的基础模型权重,让训练只更新 LoRA 适配器,从而大幅降低微调显存。

理解 NF4 时要分清三件事:

  1. 它的索引宽度是 4 bit;
  2. 解码值不是简单的 s(q-z) 等间距整数网格;
  3. 它常在计算前解码到 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 vRd

从向量码本中选择:

q=arg⁡min⁡j∥v−cj∥22 q=\arg\min_j\|v-c_j\|_2^2 q=argjminvcj22

它能利用权重之间的相关性,一个索引同时代表多个数。潜在压缩率与重构能力更强,但码本搜索、查表、缓存和矩阵乘法融合更复杂。

产品量化(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^)=N1ixix^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(xix^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=x22+ϵxx^22

它衡量误差能量占信号能量的比例,适合比较不同层或不同动态范围张量。

也可以逐元素计算相对误差:

ri=∣xi−x^i∣∣xi∣+ϵ r_i=\frac{|x_i-\hat x_i|}{|x_i|+\epsilon} ri=xi+ϵxix^i

但当 x_i 接近 0 时,相对误差会非常大。对大量零中心权重,逐元素相对误差通常比 NMSE 更不稳定。

D.4 SQNR:用分贝看信号与量化噪声

SQNR⁡=10log⁡10E[x2]E[(x−x^)2] \operatorname{SQNR} =10\log_{10} \frac{\mathbb E[x^2]} {\mathbb E[(x-\hat x)^2]} SQNR=10log10E[(xx^)2]E[x2]

SQNR 越高,表示量化噪声相对信号越小。它比裸 MSE 更方便跨尺度比较。

需要注意:这里把误差称为“噪声”只是评价语言,并不保证误差真的独立、白化或均匀随机。

D.5 最大绝对误差:专门抓灾难点

Emax⁡=max⁡i∣xi−x^i∣ E_{\max}=\max_i|x_i-\hat x_i| Emax=imaxxix^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^)=x2x^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=YF2+ϵYY^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(PQ)=DKL(QP)

通常把高精度模型分布视为参考 P,量化模型为 Q,并注意数值稳定与零概率处理。

D.11 困惑度:语言建模质量的常用总体指标

对 token 序列,平均负对数似然为:

NLL⁡=−1N∑tlog⁡p(xt∣x<t) \operatorname{NLL} =-\frac{1}{N}\sum_t\log p(x_t|x_{<t}) NLL=N1tlogp(xtx<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 分布
目标任务集
长上下文与多语言
确定性生成回归测试

第四级:系统与业务

峰值内存、延迟、吞吐
并发与稳定性
真实请求分布
成本与故障率

只有四级共同通过,量化方案才算真正完成。底层指标帮助你解释“为什么”,顶层指标回答“值不值得”。

参考资料

以下资料适合在读完本文后继续深入。论文结论应结合模型、数据、实现与硬件条件理解。

  1. Benoit Jacob et al., Quantization and Training of Neural Networks for Efficient Integer-Arithmetic-Only Inference, 2017/2018. 经典均匀仿射量化与整数推理工作。
    https://arxiv.org/abs/1712.05877

  2. Tim Dettmers et al., LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale, 2022. 系统性激活离群特征与混合精度分解。
    https://arxiv.org/abs/2208.07339

  3. Zhewei Yao et al., ZeroQuant: Efficient and Affordable Post-Training Quantization for Large-Scale Transformers, 2022. 权重与激活的细粒度 PTQ 及系统后端。
    https://arxiv.org/abs/2206.01861

  4. Elias Frantar et al., GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers, 2022. 近似二阶信息、顺序量化与误差补偿。
    https://arxiv.org/abs/2210.17323

  5. Guangxuan Xiao et al., SmoothQuant: Accurate and Efficient Post-Training Quantization for Large Language Models, 2022. 通过等价变换平滑激活、迁移动范围。
    https://arxiv.org/abs/2211.10438

  6. Ji Lin et al., AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration, 2023. 用激活统计保护显著权重通道。
    https://arxiv.org/abs/2306.00978

  7. Tim Dettmers et al., SpQR: A Sparse-Quantized Representation for Near-Lossless LLM Weight Compression, 2023. 隔离高误差权重并使用稀疏高精度表示。
    https://arxiv.org/abs/2306.03078

  8. 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

  9. ggml-org/llama.cpp, quantize tool documentation. 用于理解 GGUF 转换、量化类型、importance matrix 与具体运行工具。
    https://github.com/ggml-org/llama.cpp/blob/master/tools/quantize/README.md

  10. Tim Dettmers et al., QLoRA: Efficient Finetuning of Quantized LLMs, 2023. NF4、double quantization 与低显存参数高效微调。
    https://arxiv.org/abs/2305.14314


一句话收尾:量化不是“把 FP16 强行四舍五入成 INT4”,而是在有限码本、误差结构、模型敏感性与硬件执行之间做联合设计。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐