1. 量化到底在解决什么问题:从一次显存不够用的深夜说起
量化这个词,在机器学习与深度学习的语境里,说的是把模型里的浮点数用更低比特的整数(或者更低精度的浮点)来表示,从而压缩模型体积、降低内存带宽压力、提升推理吞吐。它不是某种前沿玄学,而是每一个要把模型真正推到线上的人迟早会撞上的一道墙。我第一次被迫认真研究量化,是因为一个视觉检测模型在训练机器上跑得好好的,换到一台显存只有一半的推理机上,连加载都加载不进去——权重文件 480MB,加上中间激活和框架自身的开销,直接爆掉。当时脑子里第一反应是"那就换张小一点的卡",但项目预算就那么多,硬件换不了,只能从模型本身想办法。从那天起,量化的每一个参数、每一种校准策略、每一次精度掉点,都变成了我必须搞明白的东西。
这篇文章面向三类人:一是手上有模型、但被显存和延迟卡住脖子的工程同学;二是听过 INT8、INT4 这些词,但一直没搞清楚 scale、zero point、校准集到底在干什么的算法同学;三是准备做端侧部署、需要判断"我这个模型到底能不能压到 4 比特"的决策者。我会从数值格式讲起,一路讲到训练后量化、量化感知训练、大模型的权重量化与 KV Cache 量化,再到三条可以直接抄作业的实操路径和一堆踩过的坑。基础的概念我会用生活化的类比解释清楚,硬核的地方我也不打算含糊——毕竟量化这件事,糊弄不了。
1.1 一个典型现场:模型能训出来,就是压不进去
先把这个问题的"痛感"讲清楚。假设你手上有一个 1 亿参数左右的中等规模模型,训练时用的是 32 位浮点,权重文件就是 400MB 出头。听起来不大,但部署的时候账不是这么算的:权重占 400MB,推理时的激活值、中间张量、框架的运行时开销,加起来很容易再翻一倍;如果要做批处理,激活占用的增长是线性的;如果是序列模型,还要再加上缓存。一台只有 8GB 显存的设备,扣掉系统占用和框架驻留,真正能给你的可能只有 5GB 到 6GB。这时候模型不是"跑得慢",而是"根本进不去"。
还有一种更隐蔽的情况:模型能加载进去,但延迟不达标。很多人第一反应是算力不够,实际上在推理阶段,尤其是自回归生成这种一次只出一个 token 的场景,瓶颈往往不是算力而是内存带宽。每生成一个 token,都要把全部权重从显存里读一遍,读的速度取决于带宽,而不是算得多快。这时候你把权重从 16 位压到 4 位,理论上的读取时间直接降到四分之一——这才是量化在生成式模型上收益巨大的根本原因,它省的不是乘法次数,而是搬运字节数。
提示:判断你的场景该不该做量化,先看瓶颈在哪。如果是大批量、计算密集的卷积网络,量化带来的收益主要来自整数运算单元的高吞吐;如果是小批量、自回归、访存密集的场景,收益主要来自带宽节省。两者的优化目标和验收指标完全不同。
所以量化解决的核心问题是三个:显存装不下、带宽喂不饱、延迟压不住。这三个问题在工程上经常同时出现,而量化恰好是一根能同时撬动它们的杠杆。这也是为什么它从一个"可选的加速技巧",变成了今天模型部署流水线里几乎是标配的一环。
1.2 量化的本质:用更少的刻度尺去量同一根绳子
抛开所有术语,量化的本质是一句话:把连续的数值映射到有限的离散格点上。你可以想象一把尺子,原本刻了 40 亿个刻度(32 位浮点能表示的数值量级),现在你只保留 256 个刻度(8 位整数)。绳子还是那根绳子,但你只能用这 256 个刻度去近似它的长度。相邻两个刻度之间的距离,就是所谓的缩放因子(scale);而刻度从哪个位置开始算,就是所谓的零点(zero point)。
这个类比能解释量化里几乎所有的取舍。刻度越少,误差越大;刻度分布得越均匀,对分布不均匀的数据就越不友好。举一个具体的例子:如果某一层的权重绝大多数值都挤在 -0.1 到 0.1 之间,只有极少数离群值跑到了 ±2.0,那么你用"最大绝对值"去定标尺,绝大部分刻度就被那几个离群值浪费掉了,真正密集的区域只分到很少几个格点,误差自然大。这就是为什么后面要讲按通道量化、按分组量化、离群值处理——它们本质上都是在重新分配这把尺子的刻度密度。
量化误差和普通的数值误差有个关键区别:它是有偏的、系统性的。浮点数的舍入误差在不同的量级下相对误差基本恒定,而定点量化的绝对误差是恒定的,意味着小数值的相对误差会非常大。一层里那些接近零的权重,量化之后可能直接变成零或者误差百分之几十。单个权重的误差看起来无所谓,但一层有上百万个权重,误差在矩阵乘法的累加过程中会互相叠加,最终体现在输出上的就是精度掉点。理解这一点,你才能在后面看到"为什么某些层特别敏感"时不会觉得莫名其妙。
1.3 为什么偏偏是 8 比特:精度、硬件与工程成本的三角平衡
一个很自然的问题是:既然 4 比特比 8 比特更省,为什么不直接上 4 比特甚至 2 比特?答案藏在三个约束的交叉点上。
第一是精度约束。8 比特有 256 个格点,对于绝大多数卷积和全连接层的权重分布来说,量化误差还在可接受范围内,很多模型做完训练后量化甚至不需要微调就能保持指标。4 比特只有 16 个格点,误差直接翻了一个数量级,必须配合分组缩放、离群值保护、误差补偿等手段才能勉强可用,而且通常在生成式模型上才划算。2 比特目前基本停留在研究阶段,工程上很少有稳定可用的方案。
第二是硬件约束。8 位整数乘加在主流处理器上都有原生的指令支持和专用的计算单元,一条指令能完成多个 8 位乘加,吞吐是浮点的数倍。4 位整数在很多硬件上没有原生的乘加单元,实际执行时需要先反量化回 8 位或 16 位再算,省下的只有存储和带宽,算力上的收益会打折扣。这就是为什么同样是 4 比特,在不同硬件上的实测加速比差异很大——有的是真快,有的只是"省内存但不变快"。
第三是工程成本约束。8 比特量化在主流框架里的工具链相对成熟,算子覆盖率、校准流程、调试手段都比较完善;越往低位走,算子支持越残缺,出问题时你连日志都看不懂。我见过有团队为了追 4 比特的收益,花了两周时间排查一个算子回退问题,最后发现收益还不如把批处理调度改一改。
| 位宽 | 权重体积(相对 16 位) | 典型精度损失 | 硬件支持成熟度 | 适用场景 |
|---|---|---|---|---|
| 16 位浮点 | 100% | 基本无损 | 全面 | 训练、对精度要求极高的推理 |
| 8 位整数 | 50% | 通常小于 1% | 广泛 | 通用推理加速,首选方案 |
| 4 比特 | 25% | 1% 到 5%,需技巧 | 部分 | 大模型权重压缩、端侧部署 |
| 2 比特及以下 | 12.5% 以下 | 明显 | 稀缺 | 实验性质,谨慎使用 |
注意:上表的"精度损失"只是一个经验区间,具体到你的模型可能天差地别。一个对数值扰动敏感的模型,8 比特都可能掉好几个点;一个冗余度高的模型,4 比特也能稳住。永远用自己的验证集测,不要信别人的数字。
2. 数值格式与量化数学:把浮点映射到整数到底发生了什么
这一章是我认为最该先搞明白的部分。很多人上手就直接调框架的量化接口,结果精度掉了也不知道从哪儿查,因为他们跳过了"数值到底怎么变的"这一步。把这一层搞透,后面所有工具的参数你都能自己推出来。
2.1 FP32、FP16、BF16、TF32:先搞清楚你原本在用什么
在谈量化之前,得先把浮点格式理清楚,因为很多所谓的"量化"其实是降精度浮点,不是真正的定点量化。这三种东西经常被混为一谈。
32 位浮点由 1 位符号、8 位指数、23 位尾数组成。8 位指数给了它极大的动态范围,能表示从约 10 的负 38 次方到 10 的 38 次方量级的数;23 位尾数给了它大约 7 位有效十进制数字的精度。这是深度学习的默认格式,也是所有训练的基准。
16 位浮点把指数砍到 5 位、尾数砍到 10 位。尾数少了,精度降到约 3 到 4 位有效数字;指数少了,动态范围急剧收缩,最大值只有 65504,最小值正规数在 10 的负 5 次方量级。这意味着它非常容易上溢和下溢,训练时通常要配合损失缩放才能用。但在推理场景,只要数值范围可控,16 位浮点是很实用的选择,体积减半、精度损失很小、硬件支持全面。
BF16 走的是另一条路:保留 8 位指数(和 32 位浮点一样的动态范围),把尾数砍到 7 位。精度比 16 位浮点更差(大约 2 到 3 位有效数字),但完全不用担心溢出问题。这就是为什么它在训练中被大量采用——深度学习对精度没那么敏感,对稳定性极度敏感。顺便说一句,TF32 是另一种折中,保留 8 位指数、10 位尾数,主要用在矩阵乘法的输入上。
这里的关键认知是:降精度浮点(16 位、BF16)和定点量化(8 位整数)是完全不同的两件事。前者保留了指数的概念,误差是相对的;后者是均匀格点,误差是绝对的。前者通常不需要校准,直接把权重转过去就行;后者必须根据数据分布确定 scale 和 zero point,否则误差会失控。很多新手把这两件事混着说,结果在看量化文档时一头雾水。
2.2 对称量化与非对称量化:scale 和 zero point 是怎么算出来的
现在进入正题。量化的核心公式非常朴素:
q = clamp(round(x / s) + z, qmin, qmax) x_hat = (q - z) * s其中x是原始的浮点值,q是量化后的整数,s是缩放因子(scale),z是零点(zero point),x_hat是反量化回来的近似值。整个量化过程就是"除一下、取整、夹住",反量化就是"减一下、乘回来"。就这么简单。
对称量化把零点固定为 0,只用 scale 一个参数。它的好处是计算极简:反量化就是一次乘法,量化就是一次除法,硬件实现成本最低;负数区间的格点分布和正数完全对称,对于权重这种近似零均值分布的数据非常合适。代价是如果数据分布明显偏斜(比如经过 ReLU 之后的激活,全是非负数),对称量化会浪费掉一半的格点。对称量化的 scale 通常取max(|x|) / qmax,8 位有符号整数的 qmax 是 127。
非对称量化同时用 scale 和 zero point,把实际的数据区间[min, max]完整映射到整数区间[qmin, qmax]。scale 等于区间长度除以格点数,zero point 等于把min映射到 qmin 所需的偏移量。这样格点利用率最高,特别适合激活值这种分布偏斜、动态范围不对称的数据。代价是多了一个零点参数,反量化时多一次减法,并且在做卷积或矩阵乘法时,零点会引入额外的补偿项,硬件实现更复杂一些。
实操中的常见做法是:权重用对称量化(per-channel),激活用非对称量化(per-tensor 或 per-token)。这个组合在框架的默认配置里几乎随处可见,原因就是权重近似零均值、激活非负偏斜。如果你看到某个工具默认给你用对称量化处理激活,那它的精度大概率会差一些,值得检查一下。
import numpy as np def quantize_symmetric(x, bits=8): """对称量化:零点固定为 0,只计算 scale""" qmax = 2 ** (bits - 1) - 1 scale = np.abs(x).max() / qmax scale = np.where(scale == 0, 1e-8, scale) q = np.clip(np.round(x / scale), -qmax - 1, qmax) return q.astype(np.int8), scale.astype(np.float32) def quantize_asymmetric(x, bits=8): """非对称量化:同时计算 scale 和 zero point""" qmin, qmax = 0, 2 ** bits - 1 xmin, xmax = x.min(), x.max() scale = (xmax - xmin) / (qmax - qmin) scale = max(scale, 1e-8) zero_point = np.round(qmin - xmin / scale) zero_point = np.clip(zero_point, qmin, qmax) q = np.clip(np.round(x / scale) + zero_point, qmin, qmax) return q.astype(np.uint8), scale, zero_point def dequantize(q, scale, zero_point=0): return (q.astype(np.float32) - zero_point) * scale这段代码可以直接跑,拿一组你自己模型里的权重试试,对比一下两种方式的重建误差,你会对"对称 vs 非对称"有远超文字描述的直观感受。
2.3 量化粒度:per-tensor、per-channel、per-group 到底差在哪
粒度说的是"一套 scale 管多大范围的数据"。这是量化里对精度影响最大的超参数之一,比位宽的影响有时候还大。
per-tensor(每张量):整个张量共用一套 scale 和 zero point。实现最简单,硬件最友好,但被离群值坑得最惨。只要张量里有一个特别大的值,整张量的刻度分辨率就被拉低了。
per-channel(每通道):沿着输出通道维度,每个通道各有一套参数。对于卷积权重和全连接权重来说,每个输出通道对应一组独立的滤波器,统计特性差异很大,分开定标能显著降低误差。这是权重侧的事实标准,几乎所有量化方案都会用。
per-group(每分组):把一个通道内的权重再切成若干组(比如每组 128 个元素),每组一套参数。这是 4 比特量化的标配,因为 4 比特的格点实在太少,必须靠细粒度分组来救。代价是参数量增加:每 128 个权重就要额外存一个 scale,INT4 的理论压缩率是 4 倍,加上 scale 之后实际可能只有 3.5 倍左右。
| 粒度 | 参数开销 | 精度表现 | 硬件友好度 | 典型用途 |
|---|---|---|---|---|
| per-tensor | 极低 | 最差,易被离群值拖累 | 最好 | 早期的整网量化 |
| per-channel | 低 | 好 | 好 | 权重侧默认选择 |
| per-group(128) | 中 | 很好 | 一般 | 4 比特权重量化 |
| per-group(64 或更小) | 高 | 最好 | 较差 | 极低位宽、对精度极度敏感 |
有一个经常被忽略的点:激活的 per-channel 量化和权重的 per-channel 量化,维度方向是不同的。权重按输出通道分,激活在有些方案里按 token 分(即 per-token),这两者能不能在同一个矩阵乘法里高效融合,直接决定了你的量化模型跑起来是快还是慢。这也是为什么有些看起来"精度更好"的量化配置,实测反而更慢——反量化时要做广播,硬件不买账。
2.4 误差从哪来:舍入、截断与累积放大
量化误差主要来自三个地方,分开看每一处都微不足道,合起来就能让模型指标掉一大截。
第一是舍入误差。round操作把值推到最近的格点,单个值的误差上限是半个格距。这是不可避免的,位宽越低格距越大。
第二是截断误差。clamp操作把超出范围的值强行压到边界上。如果校准集没覆盖到推理时出现的极值,就会大量触发截断,误差会非常大且方向一致。这也是为什么校准集的代表性如此重要——它是量化误差的第一道防线。
第三是累积放大。这一条最容易被忽视。一层矩阵乘法里可能有上百万次乘加,每次都有微小误差,这些误差在求和时部分抵消、部分叠加。如果误差是零均值的随机噪声,叠加后大约按根号 N 增长;如果误差是有偏的(比如截断误差),就会线性累加。更麻烦的是,误差会跨层传播:前一层的输出偏差进入下一层,被下一层的权重放大,到最后可能完全偏离。这就是为什么有些模型单层量化看起来误差很小,整网量化却崩了。
我在实践中验证过一个经验规律:误差容忍度和层的位置强相关。靠近输入的层、靠近输出的层、以及那些承担"信息瓶颈"的层(比如降采样前的最后一层、注意力里的投影层),通常最敏感。一个很实用的做法是先把这些层排除在量化范围之外,看看精度能不能救回来,再决定要不要为它们单独做处理。
3. 训练后量化与量化感知训练:两条路线的选择逻辑
搞清楚数学之后,就面临路线选择:是直接把训好的模型量化(训练后量化,PTQ),还是在训练过程中模拟量化误差(量化感知训练,QAT)?这两条路的成本、效果、适用场景差别很大,选错了会浪费大量时间。
3.1 训练后量化:校准集怎么选、三种校准算法怎么挑
训练后量化的流程很简单:拿一批有代表性的数据跑一遍前向,统计每一层激活的分布,据此确定 scale 和 zero point,然后把权重量化掉,完事。整个过程通常几分钟到几十分钟,不需要反向传播,不需要标注(有些算法连标签都不需要,只要输入数据)。这是它最大的优势,也是它成为默认首选的原因。
校准集的选择是最容易被低估的环节。我的经验是:不需要很多样本,几百张到一两千张就够了,但分布必须匹配真实推理场景。我曾经在一个检测任务上翻过车:校准集用的是白天的图片,结果夜间场景下大量激活值超出校准范围被截断,精度直接掉了六个点。后来把校准集换成白天黑夜混合,问题消失。这件事让我养成了一个习惯——校准集从真实业务数据里抽样,而不是从训练集里随便抓一批。
校准算法方面,常见的有这么几种:
min-max:直接取统计区间的最大值和最小值。实现最简单,但极度敏感于离群值,一个异常的极值就能把整个 scale 拉偏。适合分布本身很干净的情况。
百分位截断:不取绝对极值,取比如 99.99% 分位点作为区间边界,超出部分截断。这个做法牺牲了极少数离群值的精度,换来了主分布区域的更高分辨率。实践中非常常用,尤其是激活值有长尾的情况。
KL 散度:把量化前后的分布做散度最小化,搜索最优的截断阈值。它的目标是让量化后的分布尽量接近原始分布,而不是最小化逐元素的误差。这个方法在分类网络的激活量化上表现很好,因为它保护的是"相对大小关系",而分类恰恰依赖这个。
MSE 最小化:直接枚举若干候选阈值,选反量化后重建误差最小的那个。计算量比 KL 大一些,但目标更直接。在权重侧用得比较多。
| 校准算法 | 抗离群值 | 计算成本 | 适合的数据 | 实测倾向 |
|---|---|---|---|---|
| min-max | 弱 | 极低 | 分布干净、范围稳定 | 容易掉点,慎用 |
| 百分位截断 | 强 | 低 | 长尾分布 | 稳定,推荐默认 |
| KL 散度 | 中 | 中 | 分类任务的激活 | 分类场景表现好 |
| MSE 最小化 | 中 | 中高 | 权重量化 | 权重量化常用 |
提示:不要迷信某一种校准算法。我做过对比,同一个模型换不同校准算法,精度差异可以到 1 到 2 个点,但哪个最好因模型而异。稳妥的做法是跑两到三种,用验证集指标说话,而不是用理论上的"更先进"来选。
3.2 量化感知训练:让模型提前适应"模糊的世界"
训练后量化的天花板在于:模型在训练时根本不知道量化这回事,它学出来的权重分布可能对量化极不友好。量化感知训练的思路就是把这个信息提前告诉模型——在训练的前向过程里插入"伪量化"节点,模拟量化的舍入和截断,让模型在训练时就适应这种数值模糊。
伪量化节点的实现很巧妙。前向时,它完完整整地做一遍量化再反量化,输出确实是量化后的值;反向时,问题来了——round函数的导数几乎处处为零,梯度传不回去。解决方案叫直通估计器(STE),意思是在反向传播时,假装量化操作不存在,直接把梯度原样传过去。这个近似在数学上并不严谨,但实践效果出奇地好。
import numpy as np class FakeQuantize: """伪量化节点:前向真实量化,反向梯度直通(STE)""" def __init__(self, bits=8, symmetric=True): self.bits = bits self.qmax = 2 ** (bits - 1) - 1 if symmetric else 2 ** bits - 1 def forward(self, x): scale = np.abs(x).max() / self.qmax if self.qmax else 1.0 scale = max(scale, 1e-8) self.scale = scale q = np.clip(np.round(x / scale), -self.qmax - 1, self.qmax) return q * scale def backward(self, grad_output): # 关键:直接透传梯度,忽略 round 和 clamp 的不可导性 return grad_outputQAT 的实际收益通常在 0.5 到 2 个点之间,看起来不多,但在那些 PTQ 无论怎么调都救不回来的模型上,它就是唯一的路。成本也很明确:需要完整的训练流程、需要超参调优(学习率通常要小得多,因为模型已经在收敛点附近)、需要几轮迭代。我一般建议先用 PTQ 打底,只有当 PTQ 的最优配置依然不达标时才上 QAT。
3.3 什么时候必须上 QAT:三个判断信号
不是所有项目都值得投入 QAT 的成本。我总结了三个信号,只要命中两个,基本就该考虑 QAT 了。
信号一:低位宽。如果你打算压到 4 比特甚至更低,PTQ 的成功率会明显下降。位宽越低,误差越大,模型自身的补偿能力越难覆盖,QAT 的相对收益就越高。
信号二:PTQ 的多种配置都到不了目标。如果你已经试过 per-channel、百分位校准、混合精度、敏感层保护,精度还是差那么一两个点,那就不要再在 PTQ 上磨了,这是模型本身对量化不友好的信号。
信号三:任务指标对小幅扰动极度敏感。比如密集预测类任务(分割、关键点)、回归类任务、或者那些指标曲线在某个区间非常陡峭的任务。分类任务对量化相对宽容,因为 argmax 对小幅扰动不敏感;但如果你输出的是连续值,误差会直接体现。
反过来说,如果你的模型是标准分类网络、用的 8 比特、PTQ 之后精度几乎没掉,那就完全没必要上 QAT。我见过不少团队为了"更稳"而走 QAT,结果多花了两周时间,收益只有零点几个点,性价比很低。
4. 大模型时代的量化实践:从 8 比特一路压到 4 比特
前面讲的都是相对传统的场景。这几年量化的重心明显转移到了大模型上,而大模型暴露出了传统方法没遇到过的新问题:激活值的离群现象极其严重,而且随着模型规模增大越来越严重。这一章讲的就是这些新问题和对应的解法。
4.1 权重好压、激活难压:大模型的离群值现象
先讲一个反直觉的现象。在传统卷积网络里,权重量化到 8 比特很轻松,激活量化到 8 比特也不算太难。但在大模型里,权重量化 8 比特依然轻松,激活量化 8 比特却经常直接崩掉。原因就是离群值:某些特定的特征通道,其数值幅度能达到其他通道的几十倍甚至上百倍,而且这个现象在不同输入之间是稳定的——不是偶然的极值,是模型结构性的特征。
这个问题在 per-tensor 激活量化下是致命的:整套 scale 被那几个巨大的通道绑架,其他 99% 的通道只能用极少的格点,误差惨不忍睹。解决思路有这么几条:
第一条,把激活也改成按 token 或按通道量化。每个 token 单独算一套 scale,离群值就只影响它自己所在的 token。这是最直接有效的办法,代价是反量化时需要按 token 广播,对硬件的融合算子支持要求更高。
第二条,把量化的难度从激活迁移到权重。有个很巧妙的思路是:既然激活里某些通道特别大,那我能不能在计算前先把这些通道"削平",同时把削掉的幅度乘到对应的权重通道上?这就是所谓的平滑技巧——数学上完全等价,因为线性变换可以左右移动缩放因子。经过平滑之后,激活的离群程度大幅降低,权重的量化难度略有上升但仍在可控范围,两边都变得好压了。这个思路在实践中效果非常显著,是很多 INT8 方案能在大模型上跑通的关键。
第三条,保护显著权重。有一类观察是:权重里有一小部分(大约 1% 左右)对输出影响极大,把这些权重用更高精度保留、其余压到 4 比特,整体精度会好很多。怎么找出"显著"的权重?可以用激活的统计量作为重要性指标,也可以用二阶信息估计每个权重的影响。这个思路是 4 比特权重量化的基础。
注意:离群值是数据相关的,不是权重相关的。同一个模型,换一批输入,离群通道的位置可能变。所以任何依赖"固定通道列表"的优化都要小心,最好用动态统计而不是硬编码。
4.2 4 比特权重量化:三种主流路线的差异
到了 4 比特,权重的量化也不再是简单的一次映射。主流方案大致可以分成三类,理解它们的差异能帮你在选工具时不迷茫。
第一类是基于误差补偿的逐层量化。思路是:量化一层,然后看这一层的输出误差,用剩余未量化的权重去补偿这个误差,一层一层往下修。数学上会用到近似的二阶信息(Hessian 的对角或近似),本质是在解一个"给定量化约束下最小化输出误差"的问题。这类方法的优点是精度好、校准成本可控(通常几百条样本就够),缺点是校准过程有一定的计算量,而且对实现细节比较敏感。
第二类是基于激活感知的缩放保护。核心观察是:不是所有权重同等重要,那些连接到大幅值激活通道的权重更关键。做法是给这些权重乘以一个保护系数,让它们在量化时能占据更精细的格点,或者干脆跳过量化。这类方法往往不需要反向传播,实现相对轻量,在某些任务上精度表现很好。
第三类是基于分组和混合精度的工程化方案。思路更朴素:不追求极致的算法,而是把分组做得足够细、把不同敏感度的层分配不同位宽,靠工程手段把平均误差压下来。这类方案的典型特征是有一套预定义的量化档位(比如不同的分组大小和混合位宽组合),用户根据精度和体积的权衡自己选。它的优势是部署链路成熟、各种硬件后端都能跑,劣势是最低档位的精度不如前两类。
| 路线 | 校准成本 | 精度表现 | 部署友好度 | 适合谁 |
|---|---|---|---|---|
| 误差补偿类 | 中,需要几百条样本 | 好 | 需要专用推理内核 | 追求精度的服务端部署 |
| 激活感知类 | 低到中 | 好 | 中等 | 通用场景,实现轻 |
| 分组混合精度 | 低 | 中到好 | 好 | 端侧、多后端兼容需求 |
这里我想强调一个经常被忽略的问题:4 比特量化的反量化开销。因为每 128 个权重就有一个 scale,实际计算时需要先把整数权重反量化成浮点再参与乘法(或者用专门的融合内核)。如果你的推理框架没有对应的融合实现,反量化这一步会吃掉大部分理论收益,甚至比 16 位还慢。选方案之前,先确认你的推理后端有没有针对该格式的优化内核,这比对比算法论文里的精度数字重要得多。
4.3 KV Cache 量化:长上下文场景下的显存账本
大模型推理的显存占用有两块:权重和 KV Cache。权重是固定的,KV Cache 随上下文长度和批大小线性增长。很多人只盯着权重量化,算完账发现显存还是不够,问题就出在 KV Cache 上。
先把账算清楚。假设一个模型有 32 层,每层 32 个注意力头,每个头的维度是 128。每个 token 在每一层需要缓存 K 和 V 两份,每份 32×128 = 4096 个元素,两层加起来 8192 个元素。乘上 32 层,每个 token 需要 262144 个元素。用 16 位浮点存,每个元素 2 字节,就是 0.5MB 每个 token。上下文 8192 个 token,就是 4GB。如果批大小是 8,直接变成 32GB——这还没算权重。
| 配置 | 每 token 元素数 | 上下文 8192 时(单条) | 批大小 8 时 |
|---|---|---|---|
| 16 位浮点 | 262144 | 约 4GB | 约 32GB |
| 8 位整数 | 262144 | 约 2GB | 约 16GB |
| 4 比特 | 262144 | 约 1GB | 约 8GB |
KV Cache 量化的难点在于:它的数值分布比权重更难预测,而且它是动态生成的,你没法提前用校准集统计。所以常见做法是用动态量化——每个 token 或每个头单独算 scale,边生成边量化。这会引入额外的计算开销和 kernel 启动开销,如果实现不好,省下的显存会被延迟吃回去。
另外一个必须注意的点是:KV Cache 量化对长上下文的影响是非线性的。短上下文时误差不明显,上下文一长,误差在注意力计算中累积,模型可能开始出现重复、跑题、遗忘前文的现象。这类问题很难通过困惑度指标发现,必须做实际的长文本任务评测。我踩过这个坑:困惑度只涨了 0.05,看起来完美,但实际做多轮问答时模型明显开始前言不搭后语。
5. 动手实操:三条可以直接复现的量化落地路径
理论讲完了,这一章是纯粹的实操。我按"从易到难、从通用到专用"的顺序给三条路径,你可以根据自己的情况挑一条走。
5.1 路径一:框架内的训练后量化完整流程
这是最通用的路径,适合卷积网络和中小规模的模型。整体分四步:准备模型、配置观察器、跑校准、转换并验证。
第一步,准备一个已经训练好、并且经过充分验证的模型。这里有个容易被跳过的细节:先把模型切换到推理模式。如果模型还带着训练模式下的行为(比如批归一化用当前批统计、随机失活还开着),校准出来的统计量全是错的,量化结果自然不可信。
第二步,配置观察器。核心是把模型里的算子替换成"带观察"的版本,让它在跑数据时记录激活的分布。配置项主要包括:权重量化用对称还是非对称、激活量化用对称还是非对称、粒度怎么选、用什么校准算法。我的默认起点是"权重 per-channel 对称、激活 per-tensor 非对称、百分位校准",然后根据精度表现微调。
第三步,跑校准。把校准数据喂进去,只做前向,不做反向。数据量几百条通常就够,但一定要覆盖真实场景的分布。跑完之后,观察器里就存下了每一层的统计量。
第四步,转换。把观察器替换成真正的量化算子,生成量化模型。转换完成后立刻做两件事:一是对比量化前后的输出,看最大误差和平均误差;二是跑验证集,看任务指标。两件事都要做,因为输出误差小不代表指标不掉,指标不掉也不代表没有局部区域的严重偏差。
# 训练后量化的流程骨架(伪代码,按你的框架调整具体接口) # 1. 准备:推理模式 + 冻结参数 model.eval() for p in model.parameters(): p.requires_grad = False # 2. 配置:权重 per-channel 对称,激活 per-tensor 非对称 config = { "weight": {"dtype": "int8", "scheme": "symmetric", "granularity": "per_channel"}, "activation": {"dtype": "int8", "scheme": "asymmetric", "granularity": "per_tensor"}, "calibration": {"algorithm": "percentile", "percentile": 99.99, "num_samples": 512}, } # 3. 校准:只前向,统计分布 prepared = prepare_model(model, config) with no_grad(): for batch in calibration_loader: # 512 条真实场景样本 prepared(batch["input"]) # 4. 转换 + 验证 quantized = convert_model(prepared) report = evaluate(quantized, val_loader) print("量化后指标:", report["metric"], "相对下降:", report["delta"])提示:转换之后一定要检查模型体积。如果体积没怎么变小,多半是有大量算子没有真正被量化(回退成了浮点)。这种情况指标看起来很漂亮,但加速效果为零,是最容易自欺欺人的失败模式。
5.2 路径二:走中间格式做量化与多后端部署
如果你的目标是跨平台部署(服务端、边缘设备、移动端都要跑),走中间表示格式会比在训练框架里直接量化更稳。流程是:导出模型到中间格式、用格式自带的量化工具做量化、在不同后端上分别验证。
这条路径的价值在于解耦。训练框架只管导出,量化由格式工具负责,推理由各后端负责,三方可以独立升级。代价是有些训练框架里的自定义算子导不出来,或者导出后语义发生偏移,需要额外处理。
实操上有几个坑值得提前知道。第一,导出前要固化输入形状,动态形状的量化支持往往不完整,容易出现某些维度没被正确量化的情况。第二,算子融合会改变图结构,融合后的算子可能需要不同的量化参数配置,建议在导出时就做好融合,而不是留给量化工具做。第三,量化节点的位置很关键,一个算子如果输入是量化张量、输出是浮点张量,就会产生一次反量化,这个开销在小算子上占比很高。做性能分析时专门看这类"量化-反量化"成对出现的节点,它们是最常见的性能杀手。
验证环节我建议做三层:数值层(逐层对比量化前后的输出,找出误差最大的层)、指标层(跑完整验证集看任务指标)、性能层(在目标硬件上实测延迟和内存,不要用理论值)。三层都过了才算成功,缺一层都可能在上线后翻车。
5.3 路径三:大模型权重量化与效果验证
大模型的量化流程和上面两条都不太一样,因为它的核心诉求是"能不能在有限显存里跑起来",而不是"能不能再快一点"。典型流程是:选定量化方案和位宽档位、准备校准数据、执行量化、验证生成质量和显存占用。
校准数据这块,大模型有个特殊性:用模型自己的训练数据分布效果最好,但很多时候拿不到。实践中常用的是从公开语料里抽一批通用文本,或者从业务场景的真实请求日志里抽。我的经验是,通用文本校准出来的模型在通用能力上表现稳定,但在特定领域(比如代码、专业问答)上可能退化明显,所以如果下游任务是垂直领域,校准集一定要掺入该领域的数据。
位宽档位的选择要结合硬件。如果是单卡显存刚好卡在临界点,那可能需要选最低档;如果显存还有余量,建议往上调一档,精度会明显好一些。这里有个反直觉的结论:从 4 比特的最低档调到中档,精度提升往往比从中档调到 8 比特更大,因为最低档为了压体积牺牲了太多分组粒度。
验证环节,我只信三样东西:困惑度(快速筛,看有没有崩)、任务指标(用你的真实下游任务评测,分类、抽取、生成各测一遍)、人工抽检(随机抽几十条输出人眼看,尤其是长文本和需要多步推理的场景)。困惑度涨 0.1 以内、任务指标下降 1 个点以内、人工抽检没发现明显退化,我才会认为这次量化是成功的。只看困惑度就上线的,早晚要出问题。
6. 常见问题与排查实录:那些文档里不会写的东西
这一章是我这些年踩坑攒下来的,每一条背后都至少有一个熬到凌晨的夜晚。
6.1 精度掉点怎么定位:从粗到细的三步排查法
量化后精度掉了,最忌讳的做法是"换个参数再试试"。盲目试参数效率极低,正确的做法是有层次地缩小范围。
第一步,确认是不是量化导致的。听起来很傻,但真的有人把数据处理管线的 bug 算到量化头上。做法是:把量化模型反量化回浮点(如果能的话),或者把量化开关全关掉,跑一遍验证集,看指标能不能回到基线。回不去,说明问题不在量化。
第二步,做逐层敏感度分析。思路很简单:一次只量化一层,其余保持浮点,看指标掉多少。把所有层的结果排个序,掉点最多的那几层就是敏感层。这个分析的成本是"层数 × 一次验证集推理",对于几十层的模型完全可以接受。拿到敏感层列表后,先试着把它们排除在量化范围外,看精度能不能救回来,同时看模型体积增加了多少。很多时候只需要保护三五层,体积增加不到 5%,精度就能回来大半。
第三步,针对敏感层做精细化处理。如果保护了敏感层还是不够,就往细里调:把这些层改成 per-channel 量化、换校准算法、缩小分组粒度、或者干脆保留 16 位。这一步是纯工程活,靠实验堆,但因为有第二步的结果做指引,范围是收敛的。
# 逐层敏感度分析的骨架 baseline = evaluate(model_fp32, val_loader)["metric"] results = [] for name, module in model_fp32.named_modules(): if not is_quantizable(module): continue # 只量化当前这一层 q_model = quantize_only(model_fp32, target=name, config=default_config) metric = evaluate(q_model, val_loader)["metric"] results.append((name, baseline - metric)) # 按掉点幅度排序,取前若干个作为敏感层 results.sort(key=lambda x: x[1], reverse=True) sensitive_layers = [name for name, drop in results[:10] if drop > 0.005] print("建议保护层:", sensitive_layers)注意:逐层分析的结论不能直接叠加使用。单层量化掉 0.5 个点,不代表十层叠加就掉 5 个点,误差的传播是非线性的,可能互相抵消也可能互相放大。正确的做法是拿到候选层之后,整体验证一遍保护方案。
6.2 量化后反而变慢:算子回退与反量化开销
这是最让人沮丧的一类问题:折腾半天,精度保住了,跑起来一测,比浮点还慢。原因通常有三个。
原因一,算子回退。量化模型里如果某个算子不支持量化版本,框架会自动把它回退成浮点实现,并在前后插入量化-反量化节点。这会导致数据在整数和浮点之间反复横跳,每次跳跃都是一次额外的转换。排查方法是把量化模型的图导出来看一眼,统计量化算子和浮点算子的数量比例。如果浮点算子占比很高,说明量化基本没生效。
原因二,反量化开销超过收益。尤其在 4 比特场景,反量化是一组整数转浮点的乘加操作,如果这部分没有被融合到矩阵乘法内核里,而是单独作为一个算子执行,开销会非常可观。判断方法是做算子级的性能分析,看反量化算子占总时间的比例。
原因三,内存对齐与访存模式变差。量化后的数据布局如果不满足硬件的对齐要求,访存效率会下降。比如 4 比特数据打包成字节时,如果张量的最后一维不是偶数,就需要补零,补零之后的访存模式可能不再是连续的。这类问题很难从代码上看出来,只能靠实测对比。
| 现象 | 可能原因 | 排查手段 | 处理方式 |
|---|---|---|---|
| 模型体积没变小 | 大量算子未量化 | 统计图中量化算子比例 | 检查算子支持列表,替换不支持算子 |
| 精度正常但速度没提升 | 反量化开销大 | 算子级性能分析 | 换用有融合内核的后端 |
| 某些输入下特别慢 | 动态形状触发重编译 | 固定形状后对比 | 固化输入形状,或用动态形状友好的后端 |
| 小批量慢、大批量正常 | 启动开销占比高 | 扫批大小画曲线 | 增大批大小,或减少算子数量 |
6.3 常见问题速查表
把上面这些以及一些零散的经验整理成表,出问题时可以直接对号入座。
| 问题 | 典型表现 | 根因 | 解决方向 |
|---|---|---|---|
| 量化后指标暴跌 | 掉 5 个点以上 | 校准集分布不匹配,或激活被离群值绑架 | 换校准集;激活改 per-token;加平滑处理 |
| 只有特定类别掉点 | 整体指标尚可但某类崩了 | 该类样本在校准集里缺失 | 校准集按类别分层抽样 |
| 首尾层问题 | 输出分布整体偏移 | 输入输出层对量化极敏感 | 首尾层保留高精度 |
| 长文本退化 | 短文本正常,长文本重复或跑题 | KV Cache 量化误差累积 | 提高 KV Cache 位宽;只量化部分层 |
| 量化模型加载失败 | 报算子不支持 | 推理后端缺少对应内核 | 降级量化方案;或换后端 |
| 多次量化结果不一致 | 同样配置精度波动 | 校准数据顺序或随机种子影响 | 固定种子;增大校准集 |
还有一个特别隐蔽的坑:量化模型在训练框架里测出来的延迟,和真实部署环境可能差好几倍。训练框架的算子实现是为通用性优化的,可能没有针对量化做专门的内核;而专用推理引擎会做大量的图优化和算子融合。所以性能一定要在目标环境测,别拿训练框架的数字做决策。
7. 一些踩坑之后才明白的经验
写到这里,我想分享几条不太适合放进教科书、但确实影响了我很多判断的经验。
第一,先确认瓶颈,再谈量化。我见过太多项目一上来就说"我们要做量化",问下去发现瓶颈其实是数据预处理或者调度。量化的收益高度依赖场景:访存密集的场景收益大,计算密集的大批量场景收益小,小模型上量化的相对开销可能比收益还高。花半天时间做一次性能剖析,比花两周做量化更值。
第二,精度验证要用业务指标,不要只看困惑度或者 Top-1。量化引入的误差是有结构的,它可能对整体指标影响很小,但对某些特定输入模式影响极大。我在一个抽取任务上遇到过:整体 F1 只掉了 0.3 个点,但实体边界识别错误率涨了一倍,因为边界处的数值恰好落在量化格点的边界上,微小的扰动就改变了 argmax 的结果。这种问题只有拆细了看才看得出来。
第三,量化参数的默认值只是起点,不是答案。框架给的默认配置是为了"大多数情况下不太差",但你的模型是独特的。per-tensor 还是 per-channel、对称还是非对称、百分位取多少,这些都需要用你自己验证集上的数据来决定。我现在的习惯是固定一个评测脚本,任何量化配置的改动都跑一遍,用数据说话。
第四,留一条退路。量化方案设计的时候,就要想好"如果精度不达标怎么办"。可行的退路包括:混合精度(敏感层保留高位宽)、部分量化(只量化某些模块)、或者干脆放弃量化改用其他优化手段(蒸馏、剪枝、换更小的模型)。我在项目里通常会准备两套配置——激进版和保守版,上线时根据实际表现切换,而不是把所有希望押在一个配置上。
第五,把量化的验证做成流水线。手工验证一次两次还行,做多了必然出错。我现在会把"量化 → 数值对比 → 指标评测 → 性能实测"串成一条脚本,每次改动一键跑完,输出一份对比报告。这件事看起来是额外投入,实际上省下了大量重复劳动和人为疏漏。
最后分享一个小技巧:如果你想快速判断一个模型"量化友好不友好",不用做完整的量化流程,只要统计一下每一层权重的分布形态和激活的最大值/中位数比值就够了。权重分布接近均匀、激活没有极端离群值的模型,量化起来通常很顺;反过来,权重里有明显的长尾、激活的极值和中位数差好几个数量级的模型,就要做好打硬仗的准备。这个预判花不了十分钟,但能帮你提前决定要不要投入 QAT 的成本,避免走到一半才发现方向不对。