前四篇,我们一直在打地基:浮点数是怎么表示的,INT8、INT4 到底压缩了什么,scale 和 zero point 有什么用,per-tensor、per-channel、per-group 又有什么区别,以及 PTQ 和 QAT 分别适合什么场景。

从这一篇开始,我们不再只讲概念。

我们会拿一个真实的语言模型,把它从 FP16/BF16 权重压缩到 4bit,完整跑通:

  • 准备校准数据;
  • 收集每一层的输入激活;
  • 构造 Hessian;
  • 逐列量化并补偿误差;
  • 保存 GPTQ 模型;
  • 加载并执行推理;
  • 对比量化前后的显存、困惑度和生成速度;
  • 分析 group_sizedamp_percentdesc_act 等参数到底在控制什么。

这一篇的主角是 GPTQ

GPTQ 是最早把超大语言模型的 3bit、4bit 后训练量化真正做到可扩展、可部署的代表性方法之一。原论文展示了:在单张 A100 上,可以用数小时量化 175B 级别模型,并让 4bit 模型的困惑度非常接近 FP16 基线。(DOI)

但 GPTQ 真正值得学习的,不只是“它能把模型压到 4bit”。

更重要的是,它改变了我们看待量化误差的方式:

量化不应该只问“每个权重和原来差了多少”,而应该问“这些权重共同作用之后,模型的输出差了多少”。

这就是 GPTQ 的灵魂。


目录

一、先把 GPTQ 放到正确的位置上
二、GPTQ 到底要解决什么问题
三、为什么普通四舍五入在 4bit 下容易失效
四、从输出误差推导出 Hessian
五、最容易混淆的问题:到底是按行还是按列量化
六、GPTQ 的单步误差补偿公式
七、GPTQ 如何从 OBQ 变成可扩展算法
八、阻尼、Cholesky 与数值稳定性
九、两个“128”不是一回事
十、关键参数应该怎么理解
十一、用几十行 PyTorch 写一个教学版 GPTQ
十二、量化一个真实模型:当前正确的工具链
十三、准备高质量校准数据
十四、执行 Qwen2.5-0.5B 的 GPTQ INT4 量化
十五、加载量化模型并生成文本
十六、科学对比显存、困惑度与速度
十七、怎样设计一组有说服力的消融实验
十八、GPTQ、AWQ、bitsandbytes 怎么选
十九、十二个常见误区
二十、故障排查指南
二十一、把整篇浓缩成一张认知地图
二十二、小结


一、先把 GPTQ 放到正确的位置上

在深入公式之前,先建立三个坐标。

1.1 GPTQ 是后训练量化

GPTQ 属于 Post-Training Quantization,PTQ

它不需要重新训练整个模型,也不需要像 QAT 那样,在训练过程中模拟量化误差并反向传播。

它需要的是:

  • 一个已经训练好的高精度模型;
  • 一小批校准数据;
  • 一次离线量化过程。

量化结束后,得到一个新的低比特权重模型。

这里的 “one-shot” 不是说整个模型只做一次矩阵运算,也不是说完全不需要数据,而是说:

不进行常规的梯度训练或长时间微调,只通过一次离线校准和权重重建完成量化。

1.2 GPTQ 通常是权重量化

大多数 GPTQ 模型属于:

[
\text{W4A16}
]

其中:

  • W4:权重以 4bit 形式存储;
  • A16:激活通常仍以 FP16 或 BF16 计算和传递。

所以,所谓“GPTQ INT4 模型”,通常不是说整个网络从输入到输出都只做 INT4 运算。

更准确的描述是:

模型权重以压缩的 4bit 格式存储,推理 kernel 在计算时按块读取、解码或反量化,并与 FP16/BF16 激活完成矩阵乘法。

当前 Hugging Face 文档也将 GPTQ 描述为权重压缩方案:4bit 权重不会在显存中永久展开成一整份 FP16 权重,而是在融合 kernel 中按需恢复和计算。(Hugging Face)

这点非常重要。

因为它意味着 GPTQ 主要压缩的是参数显存和权重读取带宽,而不是:

  • KV Cache;
  • 中间激活;
  • attention score;
  • 所有临时 workspace;
  • 所有层的计算数据类型。

1.3 GPTQ 优化的是局部层重建目标

GPTQ 并不直接优化最终模型在 MMLU、数学推理、代码生成或对话任务上的分数。

它采用一个更容易计算的代理目标:

让每个线性层在校准数据上的输出,尽量接近量化前的输出。

也就是说,它进行的是逐层重建。

这是一个局部目标,但因为 Transformer 中绝大多数参数都位于线性层中,而且这些线性层的输出会继续传给后续模块,所以局部输出误差通常比单纯的权重误差更有意义。


二、GPTQ 到底要解决什么问题

设一个线性层的权重矩阵为:

[
W\in\mathbb{R}^{d_{\text{out}}\times d_{\text{in}}}
]

收集到的校准输入为:

[
X\in\mathbb{R}^{d_{\text{in}}\times N}
]

这里:

  • (d_{\text{in}}) 是输入维度;
  • (d_{\text{out}}) 是输出维度;
  • (N) 可以理解为所有校准样本中收集到的 token 数量。

采用这个矩阵方向时,原始层输出为:

[
Y=WX
]

量化后的权重记为:

[
\widehat W
]

量化层输出为:

[
\widehat Y=\widehat W X
]

GPTQ 要解决的问题是:

[
\min_{\widehat W\in\mathcal Q}
\left|WX-\widehat W X\right|_F^2
]

其中 (\mathcal Q) 表示量化网格,也就是 (\widehat W) 中每个权重只能取一组离散值。

例如,4bit 有 16 个整数编码状态。结合 scale 和 zero point 后,这 16 个状态会映射到 16 个离散浮点值。

GPTQ 论文使用的正是这种逐层输出重建目标。(arXiv)

需要提醒一下:PyTorch 的 torch.nn.Linear 通常写作:

output = input @ weight.T + bias

也就是输入放在左边、权重转置后放在右边。

本文为了让 Hessian 维度和论文表达更清晰,统一采用:

[
WX
]

两种写法只是矩阵排列约定不同,本质完全相同。


2.1 普通量化在优化什么

最普通的 round-to-nearest,简称 RTN,可以近似理解为在做:

[
\min_{\widehat W\in\mathcal Q}
\left|W-\widehat W\right|_F^2
]

它关心的是:

每个量化后的权重,离原权重有多远。

如果某个权重是 1.37,最近的量化刻度是 1.40,那么就把它变成 1.40。

如果某个权重是 0.62,最近刻度是 0.60,就把它变成 0.60。

每个权重独立处理。

这种做法简单、快速,而且在 8bit 时经常已经足够好。因为 8bit 有 256 个状态,刻度密,单个权重的舍入误差通常不大。

但到了 4bit,只有 16 个状态。

量化网格变得很稀疏,独立舍入产生的误差迅速放大。

2.2 GPTQ 在优化什么

GPTQ 不执着于:

[
\widehat W\approx W
]

它更关心:

[
\widehat W X\approx WX
]

这两件事并不等价。

完全可能出现以下情况:

[
\left|W-\widehat W_1\right|_F
<
\left|W-\widehat W_2\right|_F
]

但与此同时:

[
\left|WX-\widehat W_1X\right|_F

\left|WX-\widehat W_2X\right|_F
]

翻译成人话:

第二组量化权重在数值上离原权重更远,但在真实输入上产生的输出反而更接近原模型。

这听起来反直觉,却正是 GPTQ 能赢过普通四舍五入的原因。


三、为什么普通四舍五入在 4bit 下容易失效

3.1 所有权重并不是同等重要

考虑一个极端例子。

某个输入通道在校准数据里永远接近 0:

[
x_j\approx 0
]

那么与这个通道对应的权重 (w_j),即使量化误差很大,对输出的影响也可能很小:

[
\Delta y=\Delta w_jx_j\approx 0
]

另一个输入通道经常出现很大的激活:

[
|x_k|\gg 0
]

那么 (w_k) 只发生一点点误差,就可能明显改变输出:

[
\Delta y=\Delta w_kx_k
]

普通 RTN 只看 (\Delta w),看不到 (x)。

GPTQ 会利用校准输入,让量化误差具有“输入感知能力”。

3.2 不同输入通道之间还可能高度相关

假设两个输入通道满足:

[
x_2\approx 2x_1
]

某个输出神经元原本计算:

[
y=w_1x_1+w_2x_2
]

由于 (x_2\approx 2x_1),所以:

[
y\approx (w_1+2w_2)x_1
]

这时,(w_1) 和 (w_2) 不是各自独立地影响输出。

假设量化 (w_1) 时多了 0.2,那么可以适当减少 (w_2),让组合:

[
w_1+2w_2
]

尽量保持不变。

这种“一个权重多一点,另一个权重少一点”的补偿,普通 RTN 完全不会做。

GPTQ 则会利用输入相关性,计算最合适的补偿方向。


3.3 一个更接地气的比喻

假设你要把一排数字都变成整数,但要求最终总和尽量不变。

原数字是:

[
[1.4,\ 1.4,\ 1.4]
]

普通四舍五入得到:

[
[1,\ 1,\ 1]
]

原总和是 4.2,新总和是 3,误差是 1.2。

更聪明的做法是:

第一项从 1.4 变成 1,产生了 -0.4 的误差。

那就不要假装这个误差不存在,而是把它交给后面的数字。让第二项暂时从 1.4 调整到 1.8,再量化为 2。

现在前两项原本的和是 2.8,量化后是 3,只多了 0.2。

再把这 0.2 传给第三项处理。

核心思想不是“每个数字都最接近”,而是:

前面已经无法改变的误差,交给后面仍有自由度的数字去吸收。

GPTQ 的逐列处理,就是类似的逻辑。

但必须补充一点:真实 GPTQ 并不是简单地平均分配误差,更不是只维护总和。

它维护的是:

[
WX
]

误差应该分到哪些权重上、每个权重分多少,由输入相关性和逆 Hessian 决定。


四、从输出误差推导出 Hessian

这一节是整篇最核心的数学部分。

公式不难,关键是看懂每一步的意义。

令量化误差矩阵为:

[
E=W-\widehat W
]

那么输出误差是:

[
WX-\widehat W X=EX
]

GPTQ 的目标可以写成:

[
\mathcal L

|EX|_F^2
]

利用迹运算,可以得到:

[
\begin{aligned}
\mathcal L
&=\operatorname{Tr}\left(EXX^\top E^\top\right)
\end{aligned}
]

定义:

[
H=XX^\top
]

则:

[
\mathcal L

\operatorname{Tr}\left(EHE^\top\right)
]

如果严格求二阶导数,Hessian 是:

[
2XX^\top
]

很多讲解直接写:

[
H=XX^\top
]

是因为常数 2 不会改变最优补偿方向。只要整套公式保持一致,这个常数通常可以省略。


4.1 为什么这里会出现 Hessian

把权重矩阵的第 ® 行记为:

[
w_r
]

其量化误差行向量记为:

[
e_r=w_r-\widehat w_r
]

总误差可以分解为:

[
\mathcal L

\sum_{r=1}^{d_{\text{out}}}
e_r^\top H e_r
]

这说明两个重要事实。

第一,不同输出行之间可以独立优化。

每个输出神经元对应权重矩阵中的一行。总目标是所有行误差之和,不同的行之间没有交叉项。

第二,同一行中的不同输入维度不是独立的。

矩阵 (H) 的非对角元素描述了输入通道之间的相关性。

如果 (H) 是单位矩阵:

[
H=I
]

那么:

[
e_r^\top He_r

|e_r|_2^2
]

此时输出误差就退化成普通权重误差,独立地 round-to-nearest 基本合理。

但真实神经网络中的输入通道通常:

  • 尺度不同;
  • 方差不同;
  • 存在相关性;
  • 存在异常值;
  • 有些通道几乎不活跃;
  • 有些通道高度敏感。

因此 (H) 一般不会是单位矩阵。

GPTQ 的优势,恰恰来自对这个非平凡几何结构的利用。


4.2 它到底是不是“近似 Hessian”

这里有一个经常被模糊处理的细节。

对于局部层重建目标:

[
|WX-\widehat W X|_F^2
]

它本身就是一个关于权重误差的二次函数。

因此:

[
2XX^\top
]

是这个局部目标的精确 Hessian,不是通过有限差分猜出来的,也不是通过某种神经网络近似器估出来的。

那为什么 GPTQ 论文和大量资料仍然说它使用“近似二阶信息”?

因为真正关心的最终目标其实是:

  • 模型完整语言建模损失;
  • 最终任务准确率;
  • 对话质量;
  • 推理能力;
  • 整个网络量化后的端到端误差。

GPTQ 没有构造整个模型任务损失关于全部参数的完整 Hessian。

它采用的是每一层局部输出重建目标的 Hessian。

所以更准确的说法是:

对局部二次重建目标而言,(2XX^\top) 是精确 Hessian;对整个模型的真实任务损失而言,它是一种可计算、可扩展的二阶代理。


4.3 Hessian 对角线和非对角线分别代表什么

对于:

[
H=XX^\top
]

其第 (j) 个对角元素为:

[
H_{jj}=\sum_{n=1}{N}X_{jn}2
]

它反映第 (j) 个输入通道在校准数据中的能量。

通道激活越大、出现越频繁,通常 (H_{jj}) 越大。

非对角元素:

[
H_{jk}=\sum_{n=1}^{N}X_{jn}X_{kn}
]

则反映输入通道 (j) 和 (k) 的相关性。

如果两个通道经常一起增大、一起减小,(H_{jk}) 可能较大。

如果它们大体独立或正负抵消,(H_{jk}) 可能接近 0。

因此可以把 Hessian 理解为:

输入特征空间中的局部几何地图。

但还要再精确一步。

GPTQ 真正在补偿公式里大量使用的,不只是 (H),而是:

[
H^{-1}
]

(H) 描述“往某个方向移动有多贵”,而 (H^{-1}) 描述“为了满足一个固定量化约束,其他维度应该怎样协同移动”。


五、最容易混淆的问题:到底是按行还是按列量化

这是大量入门文章最容易讲错的地方。

有时你会看到:

GPTQ 对权重矩阵逐行量化。

另一些文章又会说:

GPTQ 一列一列地量化。

到底哪个对?

答案是:

优化问题按行分解,工程算法按列同步处理。

两句话都对,但描述的是不同层面。


5.1 为什么说按行独立

权重矩阵:

[
W\in\mathbb{R}^{d_{\text{out}}\times d_{\text{in}}}
]

每一行对应一个输出神经元。

前面已经推导出:

[
\mathcal L

\sum_r e_r^\top H e_r
]

因此每一行都可以作为一个独立的二次优化问题。

从数学问题分解的角度看:

GPTQ 独立处理每个输出行的量化误差。

5.2 为什么实际又是一列一列处理

虽然每一行的权重值不同,但所有行面对的是同一组输入 (X)。

因此它们共享同一个:

[
H=XX^\top
]

也就是说,第 1 行要处理输入维度 (q),第 2 行也可以同时处理输入维度 (q),第 3 行同样如此。

当所有输出行同时量化第 (q) 个输入维度时,矩阵层面上处理的正是:

[
W_{:,q}
]

也就是权重矩阵的第 (q) 列。

所以实际实现通常会:

  1. 选择一个输入维度 (q);
  2. 同时量化所有输出行在这个维度上的权重;
  3. 同时计算每一行产生的量化误差;
  4. 将这些误差补偿到尚未量化的输入维度。

因此,从程序执行和矩阵操作角度看,它是:

按列推进、跨行向量化。

GPTQ 论文正是利用了这一点:不再让每个输出行都采用各自不同的贪心量化顺序,而是让所有行共享同一个固定列顺序,从而把昂贵的 Hessian 更新次数从“每个权重一次”减少为“每个输入列一次”。(arXiv)


5.3 为什么列对应输入特征

权重矩阵的第 (q) 列:

[
W_{:,q}
]

控制第 (q) 个输入特征对所有输出神经元的贡献。

而 Hessian 的尺寸是:

[
d_{\text{in}}\times d_{\text{in}}
]

它描述的正是输入特征之间的关系。

因此 GPTQ 的顺序维度自然位于输入维度,也就是权重矩阵的列方向。

这也是为什么:

  • act-order 排序的是输入列;
  • Hessian 的对角线长度等于 in_features
  • group_size 通常也沿输入维度划分;
  • 逐列补偿发生在尚未量化的输入通道之间。

六、GPTQ 的单步误差补偿公式

现在考虑权重矩阵中的某一行,记为:

[
w\in\mathbb R^{d_{\text{in}}}
]

假设我们准备量化其中第 (q) 个权重。

原权重是:

[
w_q
]

量化后必须变为某个离散值:

[
Q(w_q)
]

于是这一维被强制改变了:

[
\Delta_q=Q(w_q)-w_q
]

这个变化已经不可避免。

问题变成:

在保证第 (q) 维必须移动 (\Delta_q) 的前提下,其他尚未量化的权重应该怎样移动,才能让输出误差最小?

设所有权重的调整向量为:

[
\delta
]

我们要求:

[
\delta_q=\Delta_q
]

并最小化:

[
\delta^\top H\delta
]

于是得到约束优化问题:

[
\begin{aligned}
\min_\delta\quad
&\delta^\top H\delta
\text{s.t.}\quad
&\delta_q=\Delta_q
\end{aligned}
]

通过拉格朗日乘子可以得到最优调整:

[
\delta^*

\frac{\Delta_q}{[H^{-1}]{qq}}
H^{-1}
{:,q}
]

代入:

[
\Delta_q=Q(w_q)-w_q
]

得到:

[
\delta^*

-\frac{w_q-Q(w_q)}
{[H^{-1}]{qq}}
H^{-1}
{:,q}
]

这就是 GPTQ/OBQ 误差补偿公式的核心形式。(arXiv)


6.1 这个公式应该怎么读

先看:

[
w_q-Q(w_q)
]

这是当前权重的量化残差。

再看:

[
[H^{-1}]_{qq}
]

它用于对当前误差进行归一化,也决定了把这一维固定到量化值后,最小可能增加多少目标误差。

最后看:

[
H^{-1}_{:,q}
]

这是逆 Hessian 的第 (q) 列。

它告诉我们:

当前维度被迫变化后,其他输入维度应该按什么比例一起变化。

如果某个元素:

[
[H^{-1}]_{jq}
]

很大,说明第 (j) 维是吸收第 (q) 维误差的重要方向。

如果接近 0,说明不需要让第 (j) 维承担太多补偿。


6.2 量化一个权重的代价

将最优补偿代回目标,可以得到量化第 (q) 个权重带来的最小误差增量:

[
\Delta\mathcal L_q

\frac{(w_q-Q(w_q))^2}
{[H^{-1}]_{qq}}
]

这条公式很有启发。

量化代价不只取决于:

[
(w_q-Q(w_q))^2
]

也就是原始舍入误差。

它还取决于:

[
[H^{-1}]_{qq}
]

也就是考虑其他权重可以共同补偿之后,这个误差到底有多难消化。

因此,“权重离量化刻度远”并不自动意味着“这个权重不能量化”。

如果其他方向很容易补偿,它仍然可能以较低代价被量化。


6.3 如果输入特征完全不相关会怎样

假设:

[
H=I
]

那么:

[
H^{-1}=I
]

于是:

[
H^{-1}_{:,q}
]

只有第 (q) 个位置为 1,其他位置全部为 0。

补偿公式退化为:

[
\delta_q=Q(w_q)-w_q
]

其他权重不发生变化。

这时 GPTQ 基本就退化成独立量化。

因此可以把 GPTQ 的收益理解为来自两部分:

  1. 识别不同输入通道的敏感度差异;
  2. 利用输入通道之间的相关性进行误差转移。

七、GPTQ 如何从 OBQ 变成可扩展算法

GPTQ 并不是从零开始发明整套二阶补偿框架。

它建立在 OBQ,也就是 Optimal Brain Quantization 的思想上。

OBQ 的策略非常直接:

  1. 在一行权重里,计算每个尚未量化权重的量化代价;
  2. 找出当前代价最小的权重;
  3. 将它量化;
  4. 更新其他所有权重进行补偿;
  5. 更新剩余变量对应的逆 Hessian;
  6. 重复直到整行量化完成。

这个方法很精细,但代价也很高。

因为每个输出行可能选择不同的权重量化顺序,所以每一行都要维护自己的剩余变量集合和逆 Hessian 更新。

对于大型语言模型,这是不可接受的。

GPTQ 做了三个关键改造。


7.1 改造一:放弃每行独立的贪心顺序

OBQ 会在每一步寻找当前“最便宜”的权重。

GPTQ 的实验发现,在大型、过参数化的线性层中:

严格贪心排序相比采用一个统一固定顺序,最终收益通常没有想象中那么大。

原因可以这样理解。

贪心策略确实会优先量化当前容易量化的权重。

但那些困难的权重会被拖到最后。

越到后面,可以用来补偿误差的未量化权重越少。

于是“前面每一步都很聪明”不一定能转化为显著更好的最终结果。

GPTQ 因而让所有输出行使用同一个列顺序。

这样,Hessian 的消元和更新只需要针对输入维度执行一次,而不是针对每一个权重重复执行。

论文给出的复杂度分析表明,这一变化能带来数量级上的加速,使二阶量化真正扩展到数十亿乃至千亿参数模型。(arXiv)


7.2 改造二:延迟批量更新

即使所有行共享列顺序,如果每量化一列,就立刻更新整个巨大权重矩阵,GPU 利用率仍然很差。

原因是这种操作:

  • 读取大量权重;
  • 每个权重只执行少量浮点运算;
  • 计算强度低;
  • 容易受到显存带宽限制。

GPTQ 采用了 lazy batch update,也就是延迟批量更新。

它会先拿一小块连续列,例如:

[
B=128
]

在块内部逐列完成量化和局部补偿。

等整个块处理完成后,再用一次矩阵运算,把累积误差统一传播到右侧尚未处理的所有列。

这样做并没有减少理论计算量,却把大量细碎的向量操作变成了更适合 GPU 的矩阵操作。

论文报告这种分块延迟更新对大型模型的实际量化速度至关重要。(arXiv)


7.3 改造三:用 Cholesky 重写逆 Hessian 更新

如果每删除一个已量化变量,都直接用高斯消元更新逆 Hessian,浮点误差会不断积累。

模型越大、层越宽,这个问题越明显。

严重时,原本应该正定的矩阵可能因为数值误差变得不再正定,导致:

  • 补偿方向异常;
  • 权重被大幅错误更新;
  • 某一层量化结果彻底崩坏;
  • 最终模型输出乱码或困惑度爆炸。

GPTQ 利用 Cholesky 分解预先计算后续需要的逆 Hessian信息。

Cholesky 分解适合对称正定矩阵,并且有成熟、高性能、数值稳定的 GPU 实现。

配合阻尼后,它能够显著提升大模型量化时的稳定性。(arXiv)


7.4 GPTQ 的整体伪代码

忽略工程细节,可以把 GPTQ 写成:

输入:
    权重矩阵 W
    校准输入 X
    量化器 Q
    量化块大小 B

1. 构造局部 Hessian:
       H = 2 X Xᵀ

2. 添加阻尼:
       H = H + λI

3. 计算逆 Hessian 所需的 Cholesky 信息

4. 以 B 列为一块遍历 W:

       对块中的每一列 q:

           a. 将当前列量化到离散网格:
                  W_hat[:, q] = Q(W[:, q])

           b. 计算归一化量化残差:
                  error = (W[:, q] - W_hat[:, q]) / h_q

           c. 用逆 Hessian 信息更新块内尚未量化列

       块处理完后:

           d. 将整个块累计的误差
              一次性传播到右侧剩余列

5. 保存量化权重、scale、zero point 和分组信息

真实实现还会处理:

  • 模型层识别;
  • Conv1D 权重转置;
  • attention 模块顺序;
  • CPU/GPU offload;
  • 多 GPU;
  • 量化权重打包;
  • kernel 格式;
  • act-order 排列与恢复;
  • 模块跳过;
  • MoE 专家层;
  • lm_head 是否量化。

但算法主干就是上面这些。


八、阻尼、Cholesky 与数值稳定性

8.1 为什么 Hessian 可能不可逆

校准 Hessian 为:

[
H=XX^\top
]

它可能是奇异或近似奇异的。

常见原因包括:

第一,校准 token 数量不足。

如果有效观测数量小于输入维度,(X) 的秩可能不足。

第二,输入通道高度相关。

如果某些通道几乎是其他通道的线性组合,Hessian 会病态。

第三,存在不活跃通道。

某些通道在校准数据中几乎始终为 0,其对应方向缺乏足够信息。

第四,浮点计算误差。

即使理论上可逆,有限精度运算也可能导致 Cholesky 失败。


8.2 阻尼在做什么

GPTQ 会在 Hessian 对角线上添加一个小量:

[
H_{\text{damped}}

H+\lambda I
]

常见定义是:

[
\lambda

\alpha\cdot
\operatorname{mean}(\operatorname{diag}(H))
]

其中 (\alpha) 就是配置中的 damp_percent

加入阻尼后,相当于告诉算法:

不要百分之百相信有限校准数据估计出的相关性,在所有方向上保留一点基础曲率。

它能:

  • 避免零特征值;
  • 改善条件数;
  • 减少逆矩阵中的极端值;
  • 防止补偿更新过于激进;
  • 提高 Cholesky 成功率。

8.3 论文中的 1% 和当前库的默认值为什么不同

原始 GPTQ 论文描述的是:

[
\alpha=0.01
]

也就是平均 Hessian 对角线的 1%。

但当前 Transformers 的 GPTQConfig 文档中,damp_percent 默认值和推荐值为:

0.1

也就是 10%。(Hugging Face)

这并不意味着论文错了,或者所有模型都必须改成 10%。

它说明的是:

阻尼不是一个不可改变的数学常数,而是实现、模型结构、校准规模和数值稳定性共同决定的超参数。

实践建议是:

  • 初次使用当前 GPTQModel 工具链,先使用库默认值;
  • 希望更贴近经典论文配置时,可测试 0.01;
  • 遇到 Cholesky 失败、NaN 或某层异常时,可以适当增大;
  • 量化精度异常时,不要只靠无限增大阻尼解决,应同时检查校准数据。

九、两个“128”不是一回事

阅读 GPTQ 代码时,经常会同时看到:

blocksize = 128
group_size = 128

它们碰巧都常用 128,但含义完全不同。

9.1 blocksize:算法更新块大小

GPTQ 的 lazy batch update 会一次处理一块连续列。

这个块有多少列,就是算法意义上的 blocksize

它主要影响:

  • 量化速度;
  • GPU 利用率;
  • 临时 buffer;
  • 全局误差更新频率。

它属于算法执行调度参数

9.2 group_size:量化参数共享范围

4bit 整数本身不能还原成原始权重尺度,还需要 scale,非对称量化通常还需要 zero point。

如果每个权重都保存独立 scale,元数据开销会非常大。

所以常见做法是让连续若干个权重共享一组量化参数。

例如:

group_size = 128

表示沿输入维度,每 128 个权重共享一组 scale 和 zero point。

它主要影响:

  • 量化误差;
  • checkpoint 大小;
  • scale/zero 元数据开销;
  • kernel 支持;
  • 推理效率。

它属于量化网格粒度参数

9.3 为什么它们经常都设为 128

不是因为两者在数学上必须相等。

而是因为 128 通常:

  • 足够大,适合 GPU 批量计算;
  • 又不会让量化 scale 的覆盖范围太大;
  • 方便向量化和权重打包;
  • 在精度、元数据和 kernel 效率之间较平衡。

所以一定要记住:

算法 blocksize 的 128,和量化 group_size 的 128,是两个完全不同的 128。


十、关键参数应该怎么理解

10.1 bits

最常见的是:

bits=4

理论上:

  • FP16 每个权重 16bit;
  • INT4 每个权重 4bit;
  • 纯权重部分的理论压缩率是 4 倍。

但真实 checkpoint 和显存不会严格缩小到四分之一,因为还要存储:

  • scale;
  • zero point;
  • 分组索引;
  • 未量化层;
  • embedding;
  • LayerNorm;
  • lm_head
  • 模型配置;
  • kernel workspace。

4bit 通常是 GPTQ 的甜点位。

3bit 能进一步压缩,但:

  • 网格更稀疏;
  • 对 group size 更敏感;
  • 对校准数据更敏感;
  • kernel 支持更有限;
  • 掉点风险明显增加。

2bit 则进入更极端的量化区间,通常不能简单理解为“把 bits 从 4 改成 2 就行”。

原论文确实探索了 2bit 和三值量化,但需要更细粒度分组和更谨慎的配置。(ar5iv)


10.2 group_size

典型配置:

group_size=128

更小的 group size,例如 32,意味着:

  • 每组权重的动态范围更相似;
  • scale 更贴合局部权重分布;
  • 量化误差一般更低;
  • 需要保存更多 scale 和 zero point;
  • 部分 kernel 的效率可能下降。

更大的 group size,例如 256,意味着:

  • 元数据更少;
  • 权重打包更紧凑;
  • 一个 scale 要覆盖更宽的数值范围;
  • 极端权重更容易拉大量化步长;
  • 精度可能下降。

不存在“group size 越小一定越好”。

更准确的目标是:

在可接受的精度、模型大小和目标推理 kernel 之间找平衡。


10.3 对称和非对称量化

对称量化常写成:

[
q=
\operatorname{clip}
\left(
\operatorname{round}\frac{w}{s},
-q_{\max},
q_{\max}
\right)
]

反量化:

[
\widehat w=sq
]

非对称量化通常引入 zero point:

[
q=
\operatorname{clip}
\left(
\operatorname{round}\frac{w}{s}+z,
q_{\min},
q_{\max}
\right)
]

反量化:

[
\widehat w=s(q-z)
]

原始 GPTQ 论文实验主要采用逐行非对称 min-max 网格。

当前 GPTQModel 也支持非对称量化,但并非所有格式和 kernel 都完整支持。Hugging Face 文档特别提醒,某些非对称 GPTQModel 格式与旧 AutoGPTQ checkpoint 不兼容,Marlin 等 kernel 也可能有格式限制。(Hugging Face)

因此教学和兼容性优先时,常见配置是:

sym=True

追求更低量化误差,并确认目标 runtime 支持时,可以进一步测试:

sym=False

10.4 calibration data

GPTQ 需要用校准输入构造:

[
H=XX^\top
]

所以校准数据决定了算法“认为哪些输入方向重要”。

原论文使用了:

  • 128 个随机片段;
  • 每个片段 2048 token;
  • 数据来自 C4。

也就是说,论文里的“128 条数据”实际覆盖大约 26 万 token,并不是 128 个短句。(ar5iv)

因此不要迷信 n_samples=128 这个数字。

真正需要考虑的是:

  • 总 token 数;
  • 样本长度;
  • 语言分布;
  • 领域分布;
  • 对话模板;
  • 代码、数学或自然语言比例;
  • 是否覆盖实际输入风格。

如果你只拿 128 个二三十 token 的短句校准,总信息量可能远低于论文设置。


10.5 damp_percent

它控制:

[
H+\lambda I
]

中的阻尼强度。

经验上:

  • 太小:矩阵可能病态,补偿过激;
  • 太大:Hessian 被过度“抹平”,输入相关性利用不足;
  • 合适:数值稳定,同时保留有效二阶结构。

它不是用于直接控制模型压缩率的参数。


10.6 desc_act,也就是 act-order

desc_act=True 通常表示:

按输入激活大小从高到低重排列,再执行量化。

常见重要性指标来自 Hessian 对角线:

[
H_{jj}
]

因为它反映输入通道的激活能量。

需要修正一个常见说法:

不是简单地“先量化权重值最大的列”,而是:

按输入激活统计决定列顺序。

当前 GPTQConfigdesc_act 的描述也是“按激活大小递减的顺序量化列”。(Hugging Face)

它经常能改善精度,尤其是在:

  • 3bit;
  • 小模型;
  • 存在明显异常通道;
  • 部分难量化模型。

但它也可能需要保存或处理额外排列信息。

旧实现中,这可能给推理带来额外开销;静态分组和现代 group-aware activation reorder 能减少部分问题。

所以不能简单写成:

desc_act=True 永远更好

更合理的策略是:

  • 先用 False 建立稳定基线;
  • 精度不够时再测试 True
  • 同时在目标 runtime 上测真实吞吐。

10.7 true_sequential

一个 Transformer block 内通常包含多个线性层:

  • q_proj;
  • k_proj;
  • v_proj;
  • o_proj;
  • gate_proj;
  • up_proj;
  • down_proj。

如果先收集整个 block 的 FP16 输入,再分别量化所有子层,那么后面的子层看到的仍是原始高精度输入。

但真实量化模型中,后面的子层会接收到前面已量化子层产生的输出。

true_sequential=True 会更贴近真实执行顺序:

前一个子层量化后,使用它的实际量化输出作为后续子层的校准输入。

原始 GPTQ 仓库后来也加入了 true-sequential,并报告它能改善部分 LLaMA 模型的量化结果。(GitHub)


10.8 当前配置参数速查

参数 控制内容 常见起点 精度不够时
bits 权重位宽 4 不建议先降到 3
group_size scale/zero 共享范围 128 测试 64 或 32
damp_percent Hessian 阻尼 当前库默认 0.1 检查 0.01、0.05、0.1
desc_act 是否启用 act-order False 测试 True
sym 对称或非对称量化 True runtime 支持时测试 False
true_sequential block 内顺序量化 True 通常保持
batch_size 校准前向批量 1 显存足够再增大
model_seqlen 校准序列长度相关配置 与校准长度一致 根据实际场景调整

当前 Transformers 的 GPTQConfig 支持 2、3、4、8bit,并暴露了 group_sizedamp_percentdesc_actsymtrue_sequential 等参数。(GitHub)


十一、用几十行 PyTorch 写一个教学版 GPTQ

在直接调用库之前,我们先写一个极简版本。

这段代码不会处理:

  • 分组量化;
  • 权重打包;
  • GPU kernel;
  • 大模型 offload;
  • 分块延迟更新;
  • Cholesky 加速;
  • 复杂模型结构。

它的目标只有一个:

让你亲眼看到,GPTQ 可以让权重误差更大,但输出误差更小。

import torch


def quantize_rowwise_symmetric(
    W: torch.Tensor,
    bits: int = 4,
) -> torch.Tensor:
    """普通逐行对称 RTN 量化。"""
    qmax = 2 ** (bits - 1) - 1

    scale = (
        W.abs()
        .amax(dim=1, keepdim=True)
        .clamp_min(1e-8)
        / qmax
    )

    q = torch.clamp(
        torch.round(W / scale),
        -qmax,
        qmax,
    )

    return q * scale


def gptq_fixed_order(
    W: torch.Tensor,
    X: torch.Tensor,
    bits: int = 4,
    damp: float = 0.01,
    order: torch.Tensor | None = None,
) -> torch.Tensor:
    """
    教学版 GPTQ。

    参数:
        W: [d_out, d_in]
        X: [d_in, n_tokens]

    简化:
        1. 使用固定逐列顺序;
        2. 每个输出行使用一个固定对称 scale;
        3. 显式计算逆 Hessian;
        4. 显式执行 Schur complement;
        5. 不做真实 GPTQ 的分块和 Cholesky 优化。
    """
    d_in = W.shape[1]

    # 对局部重建目标,Hessian 为 2XX^T
    H = 2.0 * X @ X.T

    # 添加阻尼,避免矩阵病态
    lam = damp * torch.diag(H).mean()
    H = H + lam * torch.eye(
        d_in,
        dtype=H.dtype,
        device=H.device,
    )

    if order is None:
        order = torch.arange(d_in, device=W.device)

    inverse_order = torch.argsort(order)

    # 按指定列顺序排列
    W_work = W[:, order].clone()
    H_work = H[order][:, order]

    H_inv = torch.linalg.inv(H_work)

    qmax = 2 ** (bits - 1) - 1

    # 为便于观察,固定使用原始 W 计算出的逐行 scale
    scale = (
        W.abs()
        .amax(dim=1, keepdim=True)
        .clamp_min(1e-8)
        / qmax
    )

    Q_perm = torch.empty_like(W_work)

    for j in range(d_in):
        # 当前待量化列
        current = W_work[:, :1]

        # 对当前列执行 RTN
        q = torch.clamp(
            torch.round(current / scale),
            -qmax,
            qmax,
        ) * scale

        Q_perm[:, j : j + 1] = q

        # GPTQ 的归一化量化误差
        normalized_error = (
            current - q
        ) / H_inv[0, 0]

        # 将误差补偿到当前及后续列
        W_work = (
            W_work
            - normalized_error * H_inv[0:1, :]
        )

        if j < d_in - 1:
            # 删除已量化维度对应的逆 Hessian 行列
            # 这是论文中高斯消元更新的显式形式
            H_inv = (
                H_inv[1:, 1:]
                - H_inv[1:, 0:1]
                @ H_inv[0:1, 1:]
                / H_inv[0, 0]
            )

            W_work = W_work[:, 1:]

    # 恢复原始列顺序
    return Q_perm[:, inverse_order]

构造一组存在相关性的输入:

torch.manual_seed(7)

d_in = 8
d_out = 4
n_tokens = 512

base = torch.randn(1, n_tokens)

X = torch.randn(d_in, n_tokens) * 0.3

# 人为制造输入通道相关性
X[0] = base
X[1] = 0.95 * base + 0.05 * torch.randn(n_tokens)
X[2] = (
    0.7 * X[0]
    - 0.2 * X[3]
    + 0.1 * torch.randn(n_tokens)
)

W = torch.randn(d_out, d_in) * 1.5

W_rtn = quantize_rowwise_symmetric(W)
W_gptq = gptq_fixed_order(W, X)


def mse(A: torch.Tensor, B: torch.Tensor) -> float:
    return torch.mean((A - B) ** 2).item()


print(
    f"RTN  weight MSE : "
    f"{mse(W, W_rtn):.6f}"
)

print(
    f"GPTQ weight MSE : "
    f"{mse(W, W_gptq):.6f}"
)

print(
    f"RTN  output MSE : "
    f"{mse(W @ X, W_rtn @ X):.6f}"
)

print(
    f"GPTQ output MSE : "
    f"{mse(W @ X, W_gptq @ X):.6f}"
)

这组固定随机种子会得到:

RTN  weight MSE : 0.012213
GPTQ weight MSE : 0.014273

RTN  output MSE : 0.033483
GPTQ output MSE : 0.010977

注意这个结果。

GPTQ 的权重 MSE:

0.014273

反而比 RTN 的:

0.012213

更大。

也就是说,从“权重是否接近”来看,GPTQ 甚至更差。

但输出 MSE 从:

0.033483

降到了:

0.010977

这就是整篇文章最重要的实验:

GPTQ 愿意牺牲权重空间里的距离,换取函数输出空间里的接近。

它不是在努力把每个权重都复刻得最像,而是在努力保住这一层真正执行的计算。


十二、量化一个真实模型:当前正确的工具链

原稿使用的是:

auto-gptq

但这已经不适合作为新教程的主线。

AutoGPTQ 仓库已在 2025 年归档,维护者建议迁移到 GPTQModel;当前 Hugging Face Transformers 也明确写明,不再支持 AutoGPTQ,应安装 GPT-QModel。(GitHub)

本文采用:

Transformers
    +
GPTQModel
    +
GPTQConfig

GPTQModel 是当前 Transformers 中活跃维护的 GPTQ 后端,并扩展了模型、平台和硬件支持。(Hugging Face)


12.1 教程使用的模型

我们选:

Qwen/Qwen2.5-0.5B-Instruct

原因很简单:

  • 是真实的指令模型;
  • 参数量较小;
  • 普通消费级 GPU 更容易完成量化;
  • 支持中文;
  • 方便快速验证完整流程。

0.5B 适合教学,但要注意:

小模型的固定开销占比更大,量化后总显存不会严格缩小到 FP16 的四分之一;同时,小模型有时也比大模型更容易受到量化扰动。

完成流程后,把模型 ID 换成 1.5B、3B、7B 或你的微调模型即可。


12.2 环境准备

建议创建独立环境:

python -m venv .venv
source .venv/bin/activate

Windows PowerShell:

.venv\Scripts\Activate.ps1

先按照自己的 CUDA 版本安装对应的 PyTorch。

随后安装:

python -m pip install -U \
  transformers \
  accelerate \
  optimum \
  datasets \
  safetensors \
  sentencepiece

安装 GPTQModel:

python -m pip install -U \
  gptqmodel \
  --no-build-isolation

这也是当前 Hugging Face GPTQ 文档给出的主线安装方式。(Hugging Face)

检查环境:

import importlib.metadata
import torch
import transformers

print("torch:", torch.__version__)
print("transformers:", transformers.__version__)
print(
    "gptqmodel:",
    importlib.metadata.version("gptqmodel"),
)

print("CUDA available:", torch.cuda.is_available())

if torch.cuda.is_available():
    print(
        "GPU:",
        torch.cuda.get_device_name(0),
    )

本文后续示例默认:

  • 有一张 NVIDIA CUDA GPU;
  • PyTorch 能正常识别 CUDA;
  • 模型和数据能够从 Hugging Face 下载;
  • CPU 内存能够容纳模型加载和保存过程。

GPTQModel 当前也覆盖更多 CPU、AMD、Apple Silicon 和其他硬件路径,但不同平台的 kernel、安装方式和性能表现不完全相同。本文不展开跨平台细节。(GitHub)


十三、准备高质量校准数据

13.1 教程数据和生产数据要分开看

为了让代码开箱即用,我们可以从 WikiText 中准备一批文本。

但必须明确:

WikiText 只是验证流程的通用数据,不是中文对话模型的理想生产校准数据。

如果你的模型实际服务于:

  • 中文客服;
  • 法律问答;
  • 医疗文本;
  • Python 代码;
  • 数学证明;
  • 多轮角色扮演;
  • SQL 生成;

那么校准数据应该尽量覆盖对应分布。


13.2 构造固定长度的校准片段

原稿按“文本行”取样,会产生一个问题:

有些文本行很短,最终 128 条样本可能只有很少的 token。

更稳妥的方式是:

  1. 把非空文本拼成 token 流;
  2. 按固定长度切块;
  3. 打乱;
  4. 选出指定数量。
import random

from datasets import load_dataset


def build_calibration_texts(
    tokenizer,
    n_samples: int = 128,
    seq_len: int = 512,
    seed: int = 42,
) -> list[str]:
    """
    从 WikiText 构造固定 token 长度的校准文本。

    注意:
        这适合验证流程。
        生产环境应替换为目标领域数据。
    """
    dataset = load_dataset(
        "wikitext",
        "wikitext-2-raw-v1",
        split="train",
    )

    paragraphs = []

    for item in dataset:
        text = item["text"].strip()

        if text:
            paragraphs.append(text)

    merged_text = "\n\n".join(paragraphs)

    token_ids = tokenizer.encode(
        merged_text,
        add_special_tokens=False,
    )

    chunks = []

    for start in range(
        0,
        len(token_ids) - seq_len + 1,
        seq_len,
    ):
        chunk_ids = token_ids[
            start : start + seq_len
        ]

        chunks.append(chunk_ids)

    rng = random.Random(seed)
    rng.shuffle(chunks)

    selected = chunks[:n_samples]

    if len(selected) < n_samples:
        raise RuntimeError(
            f"校准片段不足:需要 {n_samples},"
            f"实际只有 {len(selected)}"
        )

    texts = [
        tokenizer.decode(
            ids,
            skip_special_tokens=True,
        )
        for ids in selected
    ]

    return texts

13.3 中文对话数据应该怎样处理

假设你有 JSONL:

{"messages":[{"role":"user","content":"什么是模型量化?"},{"role":"assistant","content":"模型量化是……"}]}
{"messages":[{"role":"user","content":"写一个快速排序。"},{"role":"assistant","content":"下面是 Python 实现……"}]}

最好先用模型自己的 chat template 渲染:

import json


def load_chat_calibration_texts(
    jsonl_path: str,
    tokenizer,
    limit: int = 128,
) -> list[str]:
    texts = []

    with open(
        jsonl_path,
        "r",
        encoding="utf-8",
    ) as f:
        for line in f:
            item = json.loads(line)

            messages = item["messages"]

            rendered = tokenizer.apply_chat_template(
                messages,
                tokenize=False,
                add_generation_prompt=False,
            )

            texts.append(rendered)

            if len(texts) >= limit:
                break

    if len(texts) < limit:
        raise RuntimeError(
            f"校准数据不足:"
            f"需要 {limit} 条,"
            f"实际 {len(texts)} 条"
        )

    return texts

这样 Hessian 看到的输入形式,会更接近真实部署时的:

  • system token;
  • user token;
  • assistant token;
  • 对话分隔符;
  • 特殊模板结构。

13.4 校准数据选择的五条原则

第一,校准集和评测集必须分开。

否则你无法判断模型是在整体上保住了能力,还是只在校准文本上表现好。

第二,总 token 数比样本条数更重要。

128 个 2048-token 片段和 128 个 30-token 短句不是同一个量级。

第三,长度分布要接近部署输入。

只用短文本校准,不一定能覆盖长上下文时的隐藏状态分布。

第四,语言与领域要匹配。

中文模型只拿英文百科校准,未必立即崩坏,但通常不是最佳选择。

第五,不要只放“干净答案”。

如果线上输入包含错别字、代码块、表格、长指令、多轮对话,校准数据也应适当覆盖。


十四、执行 Qwen2.5-0.5B 的 GPTQ INT4 量化

下面给出完整脚本。

保存为:

quantize_qwen_gptq.py
from pathlib import Path

from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    GPTQConfig,
)


MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct"
OUTPUT_DIR = Path(
    "./qwen2.5-0.5b-instruct-gptq-int4"
)

N_SAMPLES = 128
SEQ_LEN = 512


def main() -> None:
    tokenizer = AutoTokenizer.from_pretrained(
        MODEL_ID,
        use_fast=True,
    )

    if tokenizer.pad_token_id is None:
        tokenizer.pad_token = tokenizer.eos_token

    calibration_texts = build_calibration_texts(
        tokenizer=tokenizer,
        n_samples=N_SAMPLES,
        seq_len=SEQ_LEN,
        seed=42,
    )

    print(
        f"已准备 {len(calibration_texts)} "
        f"条校准片段"
    )

    gptq_config = GPTQConfig(
        bits=4,

        # 每 128 个权重共享一组量化参数
        group_size=128,

        # 当前 GPTQConfig 的常见默认值。
        # 原始论文使用过 0.01。
        damp_percent=0.1,

        # 先关闭经典 act-order,
        # 建立容易复现的基线
        desc_act=False,

        # 对称量化兼容性通常更广
        sym=True,

        # block 内使用真实顺序量化
        true_sequential=True,

        # 为了更接近经典 GPTQ 基线,
        # 显式关闭当前扩展的 GAR。
        # 生产测试时可删除这一行,
        # 使用当前库默认策略。
        act_group_aware=False,

        dataset=calibration_texts,
        tokenizer=tokenizer,

        model_seqlen=SEQ_LEN,
        batch_size=1,
        pad_token_id=tokenizer.pad_token_id,
    )

    print("开始加载并量化模型……")

    quantized_model = (
        AutoModelForCausalLM.from_pretrained(
            MODEL_ID,
            device_map="auto",
            quantization_config=gptq_config,
            low_cpu_mem_usage=True,
        )
    )

    OUTPUT_DIR.mkdir(
        parents=True,
        exist_ok=True,
    )

    # 使用 device_map 量化后,
    # 保存前可按当前 HF 文档建议移回 CPU。
    quantized_model.to("cpu")

    quantized_model.save_pretrained(
        OUTPUT_DIR
    )

    tokenizer.save_pretrained(
        OUTPUT_DIR
    )

    print("量化完成")
    print(f"模型已保存到:{OUTPUT_DIR}")


if __name__ == "__main__":
    main()

运行:

python quantize_qwen_gptq.py

在当前 Transformers 集成中,核心入口是:

GPTQConfig(...)

然后将它传给:

AutoModelForCausalLM.from_pretrained(
    ...,
    quantization_config=gptq_config,
)

这与当前 Hugging Face 官方 GPTQ 流程一致。(Hugging Face)


14.1 这段代码背后发生了什么

虽然我们只写了几十行,但库内部大致做了以下事情:

  1. 加载模型结构和高精度权重;
  2. 找到 Transformer block;
  3. 将校准文本 tokenize;
  4. 前向执行模型;
  5. 捕获各线性层输入;
  6. 累积每层的 (XX^\top);
  7. 添加阻尼;
  8. 计算 Cholesky/逆 Hessian 信息;
  9. 对目标线性层执行 GPTQ;
  10. 生成量化整数权重;
  11. 保存 scale、zero point、分组映射;
  12. 将低比特权重打包成 runtime 支持的格式;
  13. 把量化配置写入模型配置文件。

14.2 为什么量化时仍需要不少内存

有人会问:

最终模型只有 4bit,为什么量化过程还会显存不足?

因为量化过程需要同时处理:

  • 原始高精度权重;
  • 校准激活;
  • Hessian;
  • 逆 Hessian或 Cholesky 因子;
  • 临时误差矩阵;
  • 量化输出;
  • 层级缓存。

假设某层输入维度是 4096。

一个 FP32 Hessian 的尺寸为:

[
4096\times4096\times4\text{ bytes}
]

约为:

[
64\text{ MiB}
]

输入维度是 8192 时:

[
8192\times8192\times4
]

约为:

[
256\text{ MiB}
]

这还只是一份矩阵,不包括其他 buffer。

所以:

最终模型省显存,不等于离线量化过程不吃显存。


十五、加载量化模型并生成文本

保存完成后,不需要再次提供校准数据。

import torch

from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
)


MODEL_DIR = (
    "./qwen2.5-0.5b-instruct-gptq-int4"
)

tokenizer = AutoTokenizer.from_pretrained(
    MODEL_DIR
)

model = AutoModelForCausalLM.from_pretrained(
    MODEL_DIR,
    device_map="auto",
)

model.eval()

messages = [
    {
        "role": "user",
        "content": (
            "请用通俗但准确的语言解释:"
            "为什么 GPTQ 量化需要 Hessian?"
        ),
    }
]

text = tokenizer.apply_chat_template(
    messages,
    tokenize=False,
    add_generation_prompt=True,
)

inputs = tokenizer(
    text,
    return_tensors="pt",
)

input_device = (
    model
    .get_input_embeddings()
    .weight
    .device
)

inputs = {
    key: value.to(input_device)
    for key, value in inputs.items()
}

with torch.inference_mode():
    outputs = model.generate(
        **inputs,
        max_new_tokens=256,
        do_sample=False,
        pad_token_id=tokenizer.eos_token_id,
    )

new_tokens = outputs[
    0,
    inputs["input_ids"].shape[1] :,
]

response = tokenizer.decode(
    new_tokens,
    skip_special_tokens=True,
)

print(response)

这里最好使用:

do_sample=False

因为我们正在做量化前后对比。

如果开启随机采样,即使两个模型完全相同,也可能因为随机性生成不同内容,干扰判断。


十六、科学对比显存、困惑度与速度

只看一个回答“好像还挺通顺”,远远不够。

一个完整的量化评估至少应包含四类指标:

  1. 模型存储和显存;
  2. 语言建模损失或困惑度;
  3. 下游任务能力;
  4. 推理吞吐与延迟。

16.1 不要再写“INT4 显存一定是 FP16 的四分之一”

理论上,如果所有参数都从 16bit 变成 4bit:

[
\frac{4}{16}

\frac14
]

但真实总显存近似为:

[
M_{\text{total}}

M_{\text{weights}}
+
M_{\text{KV}}
+
M_{\text{activations}}
+
M_{\text{workspace}}
+
M_{\text{runtime}}
]

GPTQ 主要降低的是:

[
M_{\text{weights}}
]

不会自动把其他部分都缩小四倍。

此外,量化权重还需要:

  • scales;
  • zero points;
  • g_idx
  • 打包元数据;
  • 未量化模块。

所以更严谨的写法是:

GPTQ 可以让权重存储接近 4 倍压缩,但端到端显存下降比例取决于非量化权重、KV Cache、激活和 runtime 开销。

对于短上下文、大模型、单 batch 的自回归解码,权重显存占比通常较高,GPTQ 的收益明显。

对于超长上下文,KV Cache 占比会快速上升,此时仅压缩权重的边际收益会下降。


16.2 查看模型内存占用

Transformers 模型通常提供:

footprint = model.get_memory_footprint()

print(
    f"Model footprint: "
    f"{footprint / 1024**2:.1f} MiB"
)

GPU 当前分配:

if torch.cuda.is_available():
    torch.cuda.synchronize()

    allocated = (
        torch.cuda.memory_allocated()
        / 1024**2
    )

    reserved = (
        torch.cuda.memory_reserved()
        / 1024**2
    )

    print(
        f"CUDA allocated: "
        f"{allocated:.1f} MiB"
    )

    print(
        f"CUDA reserved: "
        f"{reserved:.1f} MiB"
    )

注意:

  • memory_allocated 是当前被张量占用的显存;
  • memory_reserved 是 PyTorch allocator 保留的显存;
  • nvidia-smi 看到的还可能包含 CUDA context 和其他进程;
  • 不同指标不能直接混为一谈。

为了准确比较 FP16 和 GPTQ,最好分别启动两个独立 Python 进程。

否则前一个模型留下的 allocator 缓存可能污染第二个结果。


16.3 用滑动窗口计算困惑度

困惑度定义为:

[
\operatorname{PPL}

\exp(\text{average negative log-likelihood})
]

PPL 越低,表示模型对真实文本越不“意外”。

下面给出一个滑动窗口版本。

import math

import torch


@torch.inference_mode()
def sliding_window_ppl(
    model,
    tokenizer,
    text: str,
    max_length: int = 512,
    stride: int = 256,
) -> float:
    model.eval()

    encoded = tokenizer(
        text,
        return_tensors="pt",
        add_special_tokens=False,
    )

    all_ids = encoded["input_ids"]

    input_device = (
        model
        .get_input_embeddings()
        .weight
        .device
    )

    total_nll = 0.0
    total_loss_tokens = 0
    previous_end = 0

    sequence_length = all_ids.shape[1]

    for begin in range(
        0,
        sequence_length,
        stride,
    ):
        end = min(
            begin + max_length,
            sequence_length,
        )

        target_length = end - previous_end

        input_ids = all_ids[
            :,
            begin:end,
        ].to(input_device)

        labels = input_ids.clone()

        # 重叠窗口中,只计算新出现 token 的损失
        if target_length < labels.shape[1]:
            labels[
                :,
                :-target_length,
            ] = -100

        outputs = model(
            input_ids=input_ids,
            labels=labels,
        )

        # Causal LM 内部会做 shift,
        # 因此统计 labels[:, 1:] 中的有效位置
        valid_tokens = (
            labels[:, 1:] != -100
        ).sum().item()

        total_nll += (
            outputs.loss.float().item()
            * valid_tokens
        )

        total_loss_tokens += valid_tokens
        previous_end = end

        if end == sequence_length:
            break

    if total_loss_tokens == 0:
        raise ValueError(
            "用于计算 PPL 的有效 token 数为 0"
        )

    return math.exp(
        total_nll / total_loss_tokens
    )

评测文本必须来自未参与校准的数据。

例如:

from datasets import load_dataset


test_dataset = load_dataset(
    "wikitext",
    "wikitext-2-raw-v1",
    split="test",
)

test_text = "\n\n".join(
    item["text"].strip()
    for item in test_dataset
    if item["text"].strip()
)

ppl = sliding_window_ppl(
    model,
    tokenizer,
    test_text,
    max_length=512,
    stride=256,
)

print(f"PPL: {ppl:.4f}")

16.4 为什么原稿里重复一句话算 PPL 不够可靠

原稿使用类似:

test_text = "模型量化是一种……" * 5

这种测试可以用于检查代码是否跑通,但不适合做严肃结论。

因为重复文本会产生异常简单的统计模式。

模型看到同样的片段反复出现后,预测难度会下降,PPL 不能代表真实语料表现。

更合理的是:

  • 使用数万 token 的保留集;
  • 覆盖多个文档;
  • 校准集和测试集分离;
  • FP16 与 INT4 使用完全相同的文本;
  • 固定 tokenizer 和窗口策略。

16.5 测量端到端生成速度

import time

import torch


@torch.inference_mode()
def benchmark_generation(
    model,
    tokenizer,
    prompt: str,
    max_new_tokens: int = 128,
    warmup_runs: int = 2,
    test_runs: int = 5,
) -> dict:
    messages = [
        {
            "role": "user",
            "content": prompt,
        }
    ]

    text = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True,
    )

    inputs = tokenizer(
        text,
        return_tensors="pt",
    )

    input_device = (
        model
        .get_input_embeddings()
        .weight
        .device
    )

    inputs = {
        key: value.to(input_device)
        for key, value in inputs.items()
    }

    generation_kwargs = dict(
        **inputs,
        max_new_tokens=max_new_tokens,
        do_sample=False,
        pad_token_id=tokenizer.eos_token_id,
    )

    for _ in range(warmup_runs):
        model.generate(**generation_kwargs)

    if torch.cuda.is_available():
        torch.cuda.synchronize()
        torch.cuda.reset_peak_memory_stats()

    total_time = 0.0
    total_new_tokens = 0

    for _ in range(test_runs):
        if torch.cuda.is_available():
            torch.cuda.synchronize()

        start = time.perf_counter()

        outputs = model.generate(
            **generation_kwargs
        )

        if torch.cuda.is_available():
            torch.cuda.synchronize()

        elapsed = time.perf_counter() - start

        new_tokens = (
            outputs.shape[1]
            - inputs["input_ids"].shape[1]
        )

        total_time += elapsed
        total_new_tokens += new_tokens

    result = {
        "seconds": total_time,
        "new_tokens": total_new_tokens,
        "tokens_per_second": (
            total_new_tokens / total_time
        ),
    }

    if torch.cuda.is_available():
        result["peak_vram_mib"] = (
            torch.cuda.max_memory_allocated()
            / 1024**2
        )

    return result

调用:

result = benchmark_generation(
    model=model,
    tokenizer=tokenizer,
    prompt=(
        "解释 GPTQ、AWQ 和普通四舍五入量化"
        "之间的区别。"
    ),
    max_new_tokens=128,
)

print(result)

这个测试测的是:

包含 prompt prefill 和 token decode 的端到端吞吐。

它不是纯 decode tokens/s,也不是首 token 延迟。

如果要精确区分:

  • TTFT,Time To First Token;
  • ITL,Inter-Token Latency;
  • prefill throughput;
  • decode throughput;

需要使用更专业的 serving benchmark 或 profiler。


16.6 GPTQ 不保证在所有硬件上都更快

量化模型通常读取的权重更少,因此自回归 decode 这种容易受显存带宽限制的场景,经常能受益。

但最终速度仍取决于:

  • GPU 架构;
  • kernel;
  • batch size;
  • prompt 长度;
  • 生成长度;
  • 是否发生 CPU offload;
  • 模型大小;
  • act-order 排列;
  • 权重格式;
  • 是否使用 Marlin、ExLlama、Triton 等优化后端。

对于非常小的模型:

  • FP16 权重本来就不大;
  • dequantization 和 kernel 调度开销可能占比较高;
  • INT4 不一定明显更快;
  • 某些场景甚至可能更慢。

因此不要写:

GPTQ 量化后推理一定更快。

更准确的表述是:

GPTQ 降低了权重存储和权重读取带宽;在有合适融合 kernel、且推理受内存带宽限制时,通常能带来速度收益。

原论文展示了特定 GPU 和定制 kernel 下的显著加速;当前 Transformers 也支持使用 Marlin 等 4bit GPTQ 推理 kernel,但实际收益需要在目标硬件上测量。(DOI)


16.7 一张正确的结果表应该长这样

不要提前写死示例数字。

运行完成后,把真实结果填入:

指标 FP16/BF16 GPTQ INT4 变化
模型 footprint
CUDA allocated
峰值显存
保留集 PPL
生成 tokens/s
下游任务分数
checkpoint 大小

只有同时看到:

  • 显存明显下降;
  • PPL 增幅可接受;
  • 下游任务没有异常退化;
  • 推理速度符合部署目标;

才能说这次量化成功。


16.8 论文结果能说明什么

原始 GPTQ 论文在 WikiText2 上报告:

模型 FP16 RTN 4bit GPTQ 4bit
OPT-175B PPL 8.34 10.54 8.37

也就是说,在论文设置下:

  • 普通 4bit RTN 从 8.34 恶化到 10.54;
  • GPTQ 4bit 只从 8.34 变为 8.37。

这组结果非常直观地展示了二阶补偿相对独立舍入的价值。(ar5iv)

但不能把这个结果直接写成:

所有模型做 GPTQ 4bit 都只掉 0.03 PPL。

模型、数据、量化器、分组、kernel 和评测集变化后,结果都会变化。


十七、怎样设计一组有说服力的消融实验

如果只是跑一次:

4bit + group_size 128

你只能知道这个配置能不能用,无法知道哪个因素最重要。

建议至少设计下面五组实验。

17.1 基线组

bits=4
group_size=128
desc_act=False
sym=True
damp_percent=0.1

这是当前教程基线。

17.2 更小分组

bits=4
group_size=32
desc_act=False

观察:

  • PPL 是否改善;
  • checkpoint 增加多少;
  • 推理速度是否变化。

17.3 开启 act-order

bits=4
group_size=128
desc_act=True

观察精度改善是否值得潜在的 runtime 复杂度。

17.4 更匹配的校准数据

量化配置完全相同,只把:

英文 WikiText 校准

替换为:

中文目标领域校准

这是非常有价值的一组实验。

它能直接验证“校准数据分布是否重要”。

17.5 校准规模实验

例如:

32 × 512 token
64 × 512 token
128 × 512 token
128 × 1024 token

不要只比较样本数,同时记录总 token 数。


17.6 实验时一次只改一个变量

错误做法:

实验 A:
g128、无 act-order、英文数据、0.1 阻尼

实验 B:
g32、有 act-order、中文数据、0.01 阻尼

即使实验 B 更好,你也不知道是哪个因素造成的。

正确做法是每次只改变一项:

实验 group act-order 校准数据 阻尼
A 128 通用 0.1
B 32 通用 0.1
C 128 通用 0.1
D 128 目标领域 0.1
E 128 通用 0.01

这样才能形成可解释结论。


十八、GPTQ、AWQ、bitsandbytes 怎么选

这三者经常被放在同一张表里,但它们的定位并不完全相同。

方法 核心思想 外部校准数据 二阶信息 常见目标 典型产物
RTN 每个权重独立舍入 快速基线 简单低比特权重
GPTQ 局部输出重建、逐列补偿 高质量离线 W4 预量化 checkpoint
AWQ 用激活统计识别并保护重要通道 高质量硬件友好 W4 预量化 checkpoint
bitsandbytes 4bit 分块 FP4/NF4、加载时替换线性层 通常不需任务校准集 快速加载、QLoRA、实验 加载时量化模型

18.1 GPTQ 与 RTN

RTN:

  • 权重之间互不沟通;
  • 不使用真实输入;
  • 不补偿前面产生的误差;
  • 非常快。

GPTQ:

  • 使用校准激活;
  • 构造局部 Hessian;
  • 逐列量化;
  • 把当前误差传播给后续权重;
  • 离线量化更慢;
  • 4bit 下通常明显优于简单 RTN。

两者真正的差异,不只是“GPTQ 算得更多”。

而是优化目标发生了变化:

RTN:权重接近
GPTQ:输出接近

18.2 GPTQ 与 AWQ

AWQ 的出发点是:

不同权重通道的重要性不同,而激活分布比单看权重幅值更能识别重要通道。

AWQ 不像 GPTQ 那样,对每一列执行逆 Hessian 误差补偿。

它通过等价缩放:

  • 放大需要保护的权重通道;
  • 对输入激活做反向缩放;
  • 保持原始函数等价;
  • 让重要权重在分组量化网格中获得更好的相对精度。

AWQ 论文强调,它不依赖反向传播或逐层重建,而是通过离线激活统计选择缩放策略。(arXiv)

可以把二者概括为:

GPTQ:
误差已经产生后,
计算怎样补偿最划算。

AWQ:
量化之前先调整尺度,
尽量保护重要通道。

谁一定更好?

没有统一答案。

真实结果取决于:

  • 模型结构;
  • 位宽;
  • 校准集;
  • 目标任务;
  • 推理框架;
  • kernel;
  • 是否量化 MoE;
  • 是否涉及多模态模块。

算法精度只是一部分,部署兼容性同样重要。


18.3 GPTQ 与 bitsandbytes

bitsandbytes 的最大优势是简单。

BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
)

然后在加载模型时量化即可。

它还与 QLoRA 紧密结合,可以:

  • 用 4bit 基座降低训练显存;
  • 保留低秩适配器参数进行训练;
  • 支持 NF4;
  • 支持 double quantization。

当前 Hugging Face 文档也主要围绕 FP4、NF4、计算 dtype 和嵌套量化介绍 bitsandbytes 4bit。(Hugging Face)

GPTQ 更像是:

提前花时间,把模型做成一个面向低比特推理的静态 checkpoint。

bitsandbytes 更像是:

在加载模型时,快速把线性层替换成低比特实现,尤其适合实验和参数高效微调。

所以:

  • 想快速试模型、做 QLoRA:优先考虑 bitsandbytes;
  • 想提前量化、保存、分发并部署:GPTQ 或 AWQ 更自然;
  • 追求线上吞吐:先确认目标 serving 框架和 kernel 支持什么格式;
  • 追求最高精度:必须在自己的任务上实测,不能只看方法名称。

18.4 选型时先问五个问题

第一,模型要跑在哪里?

  • NVIDIA GPU;
  • AMD GPU;
  • CPU;
  • Apple Silicon;
  • 移动端。

第二,目标框架支持什么?

  • Transformers;
  • vLLM;
  • SGLang;
  • TensorRT-LLM;
  • 自定义 runtime。

第三,是推理还是微调?

  • 纯推理;
  • LoRA;
  • 全参数训练;
  • 在线服务。

第四,主要瓶颈是什么?

  • 模型放不进显存;
  • decode 太慢;
  • prefill 太慢;
  • KV Cache 太大;
  • batch 吞吐不足。

第五,能接受多少离线处理成本?

GPTQ 量化可能需要较长时间,但这是一次性成本。


十九、十二个常见误区

误区一:GPTQ 的 Hessian 是整个大模型训练损失的完整 Hessian

不是。

它是每个局部线性层输出重建目标的 Hessian。

对于这个局部二次目标,它是精确的;对于整网任务损失,它是代理。


误区二:GPTQ 按列处理,所以每一列互相独立

恰恰相反。

如果列互相独立,就不需要 Hessian 补偿了。

GPTQ 逐列处理,是因为列之间存在耦合,而且后续尚未量化列可以吸收前面列的误差。


误区三:GPTQ 到底按行还是按列,两个说法必有一个错

数学目标按输出行分解。

实现让所有行共享同一输入列顺序,因此矩阵层面按列向量化执行。

两者描述的是不同层次。


误区四:4bit 模型所有计算都是 INT4

通常不是。

常见 GPTQ 是 W4A16:

  • 权重低比特存储;
  • 激活保持 FP16/BF16;
  • kernel 融合反量化和乘法;
  • 累加精度由 backend 决定。

误区五:4bit 模型总显存一定是 FP16 的四分之一

只有纯权重部分接近四倍压缩。

KV Cache、激活、未量化层、scale、zero point 和 workspace 不会全部同步缩小。


误区六:量化过程慢,所以推理也慢

量化和推理是两个阶段。

GPTQ 离线量化要收集 Hessian、做分解和误差补偿,确实较慢。

推理速度则主要由低比特 kernel、硬件和 workload 决定。


误区七:量化后一定比 FP16 快

不一定。

如果:

  • kernel 不合适;
  • 模型很小;
  • 存在 CPU offload;
  • batch 设置不匹配;
  • dequantization 开销占比高;

INT4 可能不比 FP16 快。


误区八:校准数据只要有 128 条就行

不对。

应该关注:

  • 每条多少 token;
  • 总 token 数;
  • 领域是否匹配;
  • 是否使用正确 chat template;
  • 是否覆盖真实输入长度。

误区九:group_size 越小越好

小分组通常提高精度,但会增加:

  • scale/zero 元数据;
  • checkpoint 大小;
  • kernel 复杂度;
  • 读取开销。

最后应根据端到端指标选择。


误区十:desc_act 能提精度,所以必须开

它通常有机会改善精度,但也可能改变:

  • 权重排列;
  • 分组行为;
  • checkpoint 格式;
  • 推理 kernel 路径;
  • 实际吞吐。

应同时测质量和速度。


误区十一:PPL 上升一点就代表量化失败

量化本来就是有损压缩。

应该看:

  • 相对增幅;
  • 是否在多个数据集上一致;
  • 下游任务是否退化;
  • 真实业务样本是否异常。

例如从 8.40 变成 8.48,和从 8.40 变成 20,显然不是一个级别。


误区十二:PPL 没变化就代表所有能力都保住了

也不对。

PPL 是语言建模指标,不能完整覆盖:

  • 数学推理;
  • 代码正确率;
  • 指令遵循;
  • 长上下文;
  • 工具调用;
  • 结构化输出;
  • 多语言能力;
  • 安全对齐。

一个模型可能整体 PPL 变化很小,但某类敏感任务明显退化。


二十、故障排查指南

20.1 量化时 CUDA OOM

优先检查:

校准 seq_len 是否过大
batch_size 是否大于 1
是否有其他 GPU 进程
模型是否全部常驻 GPU
CPU 内存是否足够

可以尝试:

batch_size=1

降低:

SEQ_LEN = 256

或者设置:

device_map="auto"

大模型还可以通过 max_memory 控制 CPU/GPU 分配。当前 Hugging Face GPTQ 文档也提供了这种 offload 配置。(Hugging Face)

但降低序列长度可能影响校准质量。

所以它首先是排障手段,不一定是最终生产配置。


20.2 Cholesky 失败或出现非正定错误

可能原因:

  • 校准 token 太少;
  • 大量输入通道无激活;
  • Hessian 条件数太差;
  • 阻尼过小;
  • 模型 dtype 或 kernel 存在兼容问题。

尝试:

  1. 增加校准数据;
  2. 增大 damp_percent
  3. 升级 transformersgptqmodel
  4. 检查校准输入是否为空;
  5. 检查 tokenizer 是否返回有效 token;
  6. 先用较小模型验证环境。

20.3 量化结果 PPL 爆炸

优先排查:

  • 用错 tokenizer;
  • 校准文本为空或过短;
  • 输入模板与模型不匹配;
  • 位宽过低;
  • group size 过大;
  • 某些模型模块未被正确识别;
  • 量化了不应量化的输出层;
  • 保存或加载格式不匹配;
  • 校准数据分布严重偏离。

可以先退回保守配置:

bits=4
group_size=128
desc_act=False
sym=True
true_sequential=True

然后逐项测试。


20.4 模型能加载,但生成乱码

检查特殊 token:

print(tokenizer.eos_token_id)
print(tokenizer.pad_token_id)

确认使用:

tokenizer.apply_chat_template(...)

而不是直接把裸 prompt 喂给指令模型。

同时检查:

  • 模型路径是否完整;
  • tokenizer 是否与量化模型一起保存;
  • 是否加载了错误的基座 tokenizer;
  • 输入是否放到正确设备;
  • 是否错误截断了 prompt;
  • generation_config.json 是否异常。

20.5 INT4 显存下降很少

可能原因:

  • 模型太小;
  • embedding 和 lm_head 未量化;
  • 当前上下文很长,KV Cache 占比高;
  • runtime 预留了较多 workspace;
  • memory_reserved 被误当成真实模型权重;
  • 同一进程中曾加载 FP16 模型;
  • CPU/GPU 多设备映射造成统计混乱。

先分别在独立进程测量:

model.get_memory_footprint()

和:

torch.cuda.memory_allocated()

20.6 INT4 推理比 FP16 慢

优先检查:

  • 是否使用了优化 kernel;
  • 是否发生 CPU offload;
  • 模型是否过小;
  • batch 是否过大或过小;
  • checkpoint 格式是否匹配 runtime;
  • desc_act 是否触发额外排列;
  • GPU 架构是否适合当前 kernel;
  • 是否把第一次 JIT 编译计入 benchmark;
  • 是否做了 warmup;
  • 是否在计时前调用 torch.cuda.synchronize()

Marlin 之类的 kernel 只负责量化模型推理,不负责离线量化,并且有明确的位宽和硬件约束。(Hugging Face)


20.7 旧 AutoGPTQ 模型无法用新工具加载

不要默认所有名为 “GPTQ” 的 checkpoint 都完全兼容。

可能存在差异:

  • legacy GPTQ 格式;
  • GPTQ v2;
  • 对称或非对称;
  • static group;
  • act-order;
  • Marlin 专用格式;
  • ExLlama 格式;
  • zero point 打包方式。

当前 GPTQModel 已经明显偏离早期 AutoGPTQ 实现,部分新格式并不与旧 checkpoint 双向兼容。(Hugging Face)

迁移时应检查模型目录中的:

config.json
quantize_config.json
quantization_config

不要只看文件名里是否包含 gptq


二十一、把整篇浓缩成一张认知地图

可以把 GPTQ 分成五层理解。

第一层:优化目标

普通量化:

[
\min|W-\widehat W|^2
]

GPTQ:

[
\min|WX-\widehat W X|^2
]

从权重对齐,转向输出对齐。

第二层:数据统计

[
H=2XX^\top
]

Hessian 编码:

  • 输入通道能量;
  • 输入通道相关性;
  • 局部误差曲率。

第三层:单步补偿

量化当前权重后:

[
\delta^*

-\frac{w_q-Q(w_q)}
{[H^{-1}]{qq}}
H^{-1}
{:,q}
]

用剩余未量化权重吸收当前误差。

第四层:可扩展工程

GPTQ 相对 OBQ 的关键改造:

  • 所有输出行共享列顺序;
  • 按列向量化;
  • 分块延迟更新;
  • Cholesky 重写;
  • 阻尼稳定;
  • 逐层、逐 block 执行。

第五层:真实部署

最终 checkpoint 不只包含 4bit 整数,还包含:

  • packed weights;
  • scales;
  • zero points;
  • group mapping;
  • quantization config;
  • kernel 格式信息。

推理时通常是:

压缩权重读取
    ↓
融合解码/反量化
    ↓
与 FP16/BF16 激活计算
    ↓
高精度累加或指定精度累加

二十二、小结

这一篇,我们从直觉、数学、工程和实战四个层面完整走了一遍 GPTQ。

最值得记住的是下面十点。

第一,GPTQ 的根本目标不是让每个量化权重都尽量接近原值,而是让量化层的输出尽量接近原输出。

它优化的是:

[
|WX-\widehat W X|_F^2
]

而不是简单的:

[
|W-\widehat W|_F^2
]

第二,校准输入决定了量化误差应该怎样衡量。

同样大小的权重误差,落在高激活通道和低激活通道上,影响完全不同。

第三,Hessian 来自输入相关性。

对于局部重建目标:

[
H=2XX^\top
]

它描述输入通道的能量和相关结构。

第四,局部目标里的 Hessian 是精确的;所谓“近似二阶信息”,是相对于整网真实任务损失而言。

第五,GPTQ 的数学问题按权重行分解,但工程上让所有行共享列顺序,因此通常表现为逐列处理。

这不是矛盾。

第六,GPTQ 的核心操作是逐列量化和误差补偿。

当前列被固定到量化网格后,利用逆 Hessian 计算后续权重应该如何调整。

第七,GPTQ 能扩展到大模型,不只是因为用了二阶信息,更因为它做了固定顺序、延迟批量更新和 Cholesky 重写。

第八,group_size 和算法 blocksize 不是同一个参数。

前者控制 scale 共享粒度,后者控制误差更新的计算分块。

第九,4bit 不等于整网 INT4,也不代表总显存一定缩小到四分之一。

大多数 GPTQ 是 W4A16,KV Cache 和激活不会自动压缩。

第十,当前新项目应优先使用 GPTQModel,而不是继续围绕已归档的 AutoGPTQ 编写主线教程。

GPTQ 真正带来的认知升级是:

量化不是把每个数字单独变短,而是在有限的离散表示能力下,尽量保住模型原本实现的函数。

普通四舍五入只看一个权重。

GPTQ 看的是一个权重被改变之后,整个输入特征空间会发生什么。

这就是二阶信息的价值,也是高质量低比特量化与简单数值压缩之间的分界线。

Logo

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

更多推荐