1. 项目概述:量化算法到底在解决什么问题
量化这件事,说穿了就两步:先统计,再映射。统计是搞清楚模型权重和激活值“长什么样”,映射是找到一种高效方式把这些连续浮点数塞进低精度整数的格子里。大模型量化技术里,所有花哨的方法——GPTQ、AWQ、SmoothQuant——本质上都是在优化这两个环节中的某一个。所以第二章先讲基础量化算法,把统计与映射的每个细节抠清楚,后面看那些高级方案就会顺畅得多。
我当年第一次接触量化的时候,被各种术语绕得晕头转向,什么对称量化、非对称量化、零点、缩放因子、校准集,每个词单独看都懂,合在一起就不知道谁是谁。后来自己动手做了几次量化实验,才真正明白:这些东西就是一个编解码过程,编码器把float32的数值转成int8,解码器把int8按误差最小的方式还原回float32。中间决定精度损失多少的,就是统计环节怎么找范围、映射环节怎么定缩放。
这篇内容适合正在学习模型量化原理的开发者、准备落地端侧推理的工程师,以及被精度下降问题困扰的调参选手。我会用一次实际的小模型量化过程贯穿全文,把统计和映射这两个核心环节的每个决策点都拆开讲清楚,包括为什么这么做、不这么做会怎样、实际跑起来会遇到什么坑。这次实验用的是某开源的小型Transformer模型,参数量不大,但足够把量化流程完整走一遍。
2. 内容整体设计与思路拆解
2.1 为什么量化必须是“统计”和“映射”两步走
先说统计。统计的目标是回答一个问题:这组浮点数,它们的取值范围到底在哪里?最小是多少,最大是多少,分布集中在哪个区域,有没有特别离谱的离群值?这些信息决定了后续映射的“边界”。边界选大了,精度区间被浪费在很少出现的极端值上;边界选小了,超出范围的值全部被裁剪,信息直接丢失。所以统计环节看起来只是找max和min,实际上要做的远不止取个最大值那么简单。
再说映射。映射的目标是把统计得到的浮点范围,线性变换到整数范围。以int8为例,可表示的整数值是-128到127,总共256个格子。映射要做的事情就是找一个缩放因子scale,让浮点范围里的最大值对应整数范围里的最大值,最小值对应最小值,中间的每个小数的四舍五入到这个整数格子里的某一位。
这两步之所以必须分开,是因为它们解决的是两个完全不同的风险。统计环节面对的是“信息覆盖不全”的风险,映射环节面对的是“精度颗粒度不够”的风险。如果你的权重范围是-1.5到1.5,但大部分值集中在-0.1到0.1之间,那么按-1.5到1.5做映射,大多数数值会被映射到0附近的几个整数上,有效精度极低。反过来,如果强行把范围压缩到-0.1到0.1,那所有绝对值大于0.1的权重都会被裁剪成边界值,这些权重虽然是少数,但可能恰恰是决定模型行为的关键部分。统计和映射必须联合优化,这也是为什么后面会出现百分位截断、KL散度校准这些进阶统计方法的原因。
2.2 基础量化公式与实际直觉
量化过程的数学表达其实非常简洁。设原始浮点值为r,量化后的整数值为q,那么:
- 对称量化:q = round(r / scale) + zero_point
- 非对称量化:q = round(r / scale) + zero_point,其中zero_point不为零
反量化就是逆操作:r_hat = (q - zero_point) * scale。
这里的scale就是缩放因子,zero_point是零点偏移。大家刚接触时最容易困惑的就是zero_point:为什么有了scale还不够?因为浮点数范围不一定关于0对称。举个例子,神经网络经过激活函数后,输出值经常是0到6之间的正数,根本没有负数。如果强行用对称量化,让正数的最大范围去匹配整数的正负两半,那有一半的整数格子永远是闲置的,精度浪费了一半。这时引入zero_point,把0映射到某个非零整数上,就相当于把整个浮点区间平移了一下,让256个整数格子能完全覆盖实际用到的浮点区间。
理解了公式之后,很多东西就通了。后面我们遇到的各种量化方法,其实都是在解决同一个问题:怎么选择scale和zero_point,让量化后的误差最小。一种极端是让误差的最大值最小,另一种是让误差的期望最小,再一种是从信息论角度让分布差异最小。不同目标对应不同算法,没有绝对的好坏,取决于你的模型对误差的敏感程度。
2.3 训练后量化与量化感知训练的选择逻辑
基础量化算法通常是在训练后的模型上做,这就是Post-Training Quantization,PTQ。还有一个方案是Quantization-Aware Training,QAT,在训练过程中就模拟量化误差,让模型自己去适应低精度带来的扰动。这两个方案的选择逻辑很清晰:如果你的模型已经训好了,不想动训练流程,只想快速部署,那就走PTQ;如果你有训练资源,且模型对量化特别敏感,精度掉得很厉害,那就上QAT。
PTQ是我们这章的主角,因为它不需要改动训练流程,是最快见效的方案。QAT里那些模拟量化的技巧,很多也是基于PTQ的统计和映射原理衍生出来的。所以把基础量化算法吃透,两个方向都能快速上手。我个人的建议是:任何项目第一次做量化,都先尝试PTQ,用校准集统计激活分布,选择合适算法,看精度表现。只有PTQ实在救不回来的时候,再考虑QAT。
3. 统计环节的核心细节与实操要点
3.1 校准数据集:统计的“采样对象”
统计的对象不只是权重,还有激活值。权重是静态的,训练完就固定了,统计起来很直接,读一遍权重就能拿到min和max。但激活值是动态的,同一个模型输入不同数据,中间层的激活值分布完全不同。所以我们需要一个校准数据集,通过把一些有代表性的样本喂给模型,记录各层激活值的分布,然后基于这个分布去做量化决策。
校准数据集的选择有讲究。不能太少,否则统计出的分布不具有代表性;也不能太多,否则校准过程耗时太长。实际经验是两三百条典型样本就能覆盖绝大部分情况。关键是要保证校准数据的分布与真实部署数据接近。比如你的模型部署在图像识别任务上,校准集就应该用与真实业务场景相近的图像,不要用风格差异巨大的公开数据集,否则量化后的模型在真实数据上精度会莫名劣化。
校准过程中,模型的所有参数保持固定,只把中间激活值记录下来。这个记录过程通常放在推理模式下进行,确保Dropout、BatchNorm等训练时行为不会干扰统计结果。BatchNorm在推理模式下用滑动均值,在训练模式下用batch统计量,如果搞错了模式,统计出的分布会偏。
3.2 min/max统计、百分位截断与优化目标
最简单的统计方法就是直接取最小值到最大值作为映射范围。这个方法计算量最小,但往往会因为个别离群值而浪费大量量化精度。大模型里权重分布经常出现肥尾,少数权重绝对值特别大,但大量权重集中在零附近。如果按真正的max去量化,大部分值被压缩到很少的整数格子里,精度惨不忍睹。这就是很多人量化后精度大幅下降的第一个原因。
解决思路是引入百分位截断。做法是把激活值的分布从低到高排序,取0.1%分位和99.9%分位作为截断边界,把极端的离群点直接裁剪掉。裁剪意味着这部分信息被舍弃,但换来的是大多数值能有更细的量化颗粒度。关键问题就是截断点选在哪里,业界有几种不同策略:
- 直接按固定百分位截断,比如1%到99%,简单但武断。
- KL散度方法:计算不同截断阈值下的量化分布与原始分布的KL散度,选散度最小的阈值,相当于从信息论角度找最优截断点。
- MSE方法:直接最小化量化前后数值的均方误差,可以直接用网格搜索或梯度优化找到最佳截断点。
实际项目中,我自己用得最多的是先画出权重和激活值的分布直方图,目测分布形态,再试用KL或百分位方法交叉验证。模型量化的体验很大程度取决于你对分布形态的判断,而不是盲目套方法。
3.3 权重与激活的统计差异
权重和激活值的统计特征完全不同,必须分开对待。权重的分布是训练收敛后的静态结果,通常接近零中心的钟形分布,对称性较好。激活值的分布则跟输入数据强相关,经过ReLU或类似激活函数后往往是非负的偏态分布,极少数场景下激活值的动态范围会非常大,出现明显离群值。
这种差异直接影响量化方案选择。权重通常适合用对称量化,因为分布本来就近似关于零对称,引入zero_point反而增加推理时多一步减法操作。激活值则应该用非对称量化,动态范围偏在一侧,不用zero_point会在零点附近浪费太多整数量化台阶。一个经验法则是:卷积层或全连接层的权重用对称int8量化,激活值用非对称量化,这个组合在大量模型上都表现稳定。
3.4 校准过程的实操经验与注意事项
校准过程表面上简单,实际操作中会遇到很多细节问题。我总结几个特别注意的地方:
第一,校准必须覆盖模型的不同分支。如果模型有多个输入分支或注意力机制里有多个需要统计的张量,确保校准集能激活每一个分支,让统计结果覆盖到所有路径。只覆盖主路径会导致冷门分支的量化阈值严重偏离。
第二,动态形状输入对统计有影响。如果部署时输入序列长度变化范围大,校准集最好包含短、中、长不同长度的样本,避免统计结果被单一样长的分布带偏。
第三,校准数据过拟合现象是真实存在的。用校准集调整量化阈值时,理论上阈值会为了适配校准集而优化,但这些阈值对另一个数据集的泛化能力可能不理想。印象比较深的一次实验里,用训练集前几百张图做校准,量化后的模型在训练集上精度几乎无损,到了测试集上精度掉了一大截,后来换用单独的校准集才解决。
4. 映射环节的核心细节与实操要点
4.1 scale和zero_point的精确计算与舍入陷阱
确定了映射范围后,scale的计算是机械的。对称量化时:
scale = 2 * max_abs / 255
比如权重范围在-1.28到1.28之间,max_abs取1.28,那么scale = 2.56 / 255 ≈ 0.01004。一个float值,比如0.5,量化后的整数就是round(0.5 / 0.01004) ≈ round(49.8) = 50。反量化回来就是50 * 0.01004 = 0.502。量化误差大概是0.002。
非对称量化时:
scale = (r_max - r_min) / 255 zero_point = round(-r_min / scale) + q_min
这里有个细节容易算错:zero_point是浮点0映射到整数坐标的取值。如果量化范围是q_min到q_max(int8就是-128到127),那么浮点0应该落在整数坐标的某个位置,计算公式需要把q_min偏移进去。有些库实现时零点表示方式不同,转换时要格外小心。
更隐蔽的坑是浮点数舍入误差。r_min和r_max本身是浮点,除255后scale可能是一个除不尽的循环小数。在量化参数保存和加载过程中,如果做了额外的浮点运算,可能引入毫厘级的偏移。某些场景下这个偏移会在大量张量累加时被放大,最终影响模型输出。所以我建议scale和zero_point在量化时就用高精度存储,推理时再转换成低精度。
4.2 对称量化与非对称量化选型逻辑
对称量化的最大优势是推理时的计算可以简化。卷积或矩阵乘法的核心运算中,对称量化可以省去zero_point相关的修正项。具体来说,两个对称量化后的整数做乘加,结果可以直接作为浮点结果的缩放版本,不需要额外的偏移修正。这在高性能推理引擎里能省不少指令周期。
非对称量化的优势在于表达效率。当浮点数据的动态范围完全偏向一侧时(比如0到6的正激活值),对称量化把一半的整数范围浪费在负数一侧,有效精度会打折扣。非对称量化通过zero_point把0映射到合适的整数位置,让整个int8范围都服务于实际分布区间,精度通常比对称量化高不少。
选型的实务判断标准:如果数据的最大绝对值与均值的比例适中(比如3倍以内),对称量化问题不大。如果这个比例很大(激活值经ReLU或GELU后常见),就有必要考虑非对称方案。不同框架对两种模式的支持程度不同,某些硬件加速单元对非对称量化的支持不友好,这时即使非对称精度更好,也要权衡推理速度。
4.3 取整策略:四舍五入以外的选择
常规做法是round-to-nearest,四舍五入到最近的整数。这个方法的期望误差是零,但个体误差可能达到0.5个scale单位。对于模型参数来说,这种误差通常是可以接受的。但有一种情况例外:当权重值恰好落在两个整数的正中间时,round在不同语言中的行为可能不一致,Python的round是银行家舍入(遇五取偶),C的round是远离零的舍入,如果训练框架和推理框架混用,可能产生莫名其妙的偏差。
进阶思路是随机舍入。在量化感知训练里,有时会用概率性舍入:误差越小的方向,被选择的概率越大。期望值同样是原始值,但引入的随机性有助于训练过程把量化误差当作噪声来适应。对PTQ来说,随机舍入意义不大,因为它会引入不可复现的不确定性。
实际工程中还有一个不太起眼但很重要的点:overflow的检查。scale算出来后,理论上所有浮点值都在映射范围内,但如果统计环节给出的r_min或r_max恰好有误,或者某些输入在推理时超出了校准时的观察范围,量化后的整数值会超过int8边界。超界后乘加溢出,在C++里是未定义行为,在Python里可能毫无异常但数值已经错了。所以推理引擎里必须在量化时对输入做clamp,把越界值截到边界上,这一点很多自制量化工具都会漏掉。
4.4 逐层映射与per-channel量化
映射还有一个粒度问题:是每一层用一个全局的scale,还是每个通道单独一个scale。per-tensor量化简单,一层一个scale,存储和计算开销最小,但表达力不足。比如一个卷积层有64个输出通道,每个通道的权重范围可能差异较大,全局scale会被最大范围通道主导,其他通道的量化误差会被放大。per-channel量化给每个通道单独的scale和zero_point,明显更精确,但会增加存储和计算的复杂度。
实际部署中,权重通常用per-channel,激活值用per-tensor。原因有二:权重是固定不变的,为每个通道保存一个scale只增加极少的存储开销;激活值是动态计算的,如果在每个通道都做不同尺度的量化,计算复杂度会大幅提高。所以常见的工程配置是per-channel权重量化 + per-tensor激活量化,也被称为混合粒度量化。这种配置在大量模型上与全精度对比,精度损失通常在可接受范围。
映射环节最后一步是验证误差。量化完成之后,用同一份输入跑一遍量化模型和原始模型,对比各层输出差异和最终输出差异。正常的量化误差表现为数值上微小的扰动,最终模型输出可能在小数点后第三或第四位变化。如果误差在第一位有效数字就变化,往往说明某个层的量化参数不合理,需要回去检查统计范围和scale计算过程。
5. 实操过程与核心环节实现
5.1 搭建实验环境与模型准备
我做实验用的是Python 3.10配合PyTorch和相关量化库,模型选择了一个某开源小型Transformer模型,参数量大约在几千万级别,包含多个自注意力层和全连接层。选择这个模型的原因是它结构典型、训练速度快、量化效果容易观察,适合用来跑完整流程。
环境准备阶段需要安装的依赖包括必要的深度学习框架、量化库和数值计算库。值得注意的是量化库的版本兼容性,某些旧版本库对最新框架的支持不好,可能导致量化模型推理时出现莫名错误。建议在动手前先确认环境兼容性,用一段简单的量化示例跑通全流程。
模型准备阶段需要下载预训练权重。我这里使用了一个已经训练好的基础模型,因为PTQ不需要训练过程,直接用预训练权重做量化。如果你手头只有未训练或没完全收敛的模型,请先完成充分训练再量化,否则量化误差和训练不充分的误差会叠加在一起,很难排查问题。
5.2 校准数据集的构造与预处理
我构造校准集的方式是:从验证集中随机抽取200张不同类别的图像,做一个简单的预处理流程,包括缩放、归一化等。这200张图像被送到模型里做前向推理,过程中记录每个量化层输入激活值的分布。
构造校准集时有个容易忽视的点:校准集的样本数据量最好是推理批次大小的整数倍,避免最后一个批次样本不完整导致BatchNorm统计异常。虽然推理模式下BatchNorm不计算batch统计量,但某些框架在校准过程中还是会受到batch构建逻辑的影响。
预处理流程要跟实际部署时保持一致。如果你部署时用的是某种特定的图像预处理方式(比如中心裁剪加标准化),校准过程也必须用同样的方式。预处理不一致会直接导致输入分布偏移,统计出来的激活分布对真实场景没有参考价值。
5.3 统计环节的实施:直方图观察与直方图统计
加载模型后,我用钩子函数把中间层的激活值记录下来,然后绘制直方图。这一步非常直观,能帮助你快速判断激活的大致分布形态。观察结果:第一层注意力层的激活值在0到6之间,分布偏右;后面几层全连接层的激活值则分布在-2到2之间,近似对称。权重方面,各层基本是零中心的钟形分布,尾部略有肥尾特征。
根据观察结果确定统计方案:激活值用非对称量化,权重用对称量化。非对称量化需要额外的zero_point,但能更好地覆盖偏态分布;权重对称性较好,不需要zero_point。
然后执行实际统计:逐层计算权重和激活的min/max,对比各层数据分布范围。这里发现一个有意思的现象:某些注意力层的输出激活最大值显著超过其他层,如果不加处理,这些离群值会拉大scale,让其他所有值都挤在少数整数格里。我尝试用0.5%和99.5%百分位截断,效果立竿见影,量化后的模型精度比直接min/max统计高了不少。
5.4 映射环节的实施:逐层量化与反量化验证
统计阶段确定每层的截断范围后,进入映射阶段。我写了一个逐层量化的脚本:对每一层的权重,读取浮点值,计算scale和zero_point,然后做整数化处理;对每一层的激活值,同样处理。量化后的整数权重被保存下来,同时记录每个张量的scale和zero_point参数。
单层量化误差验证的方法是:把某一层的原始浮点权重取出来,与量化后反量化的权重对比,计算均方误差和最大绝对误差。这个验证很有价值,它能帮你快速找出哪些层是精度瓶颈。
跑完整模型流程后,对比量化模型和原始模型在同一批验证集上的输出差异。我这次实验的量化配置为权重用per-channel int8对称量化,激活用per-tensor int8非对称量化,结果量化后的模型在验证集上的准确率损失在0.3%以内,完全可接受。
5.5 不同映射方案的效果差异对比
为了说明映射决策的影响,我做了一组对照实验,分别在相同模型上应用三种方案:
- 方案一:权重和激活都用per-tensor对称量化。
- 方案二:权重用per-tensor对称,激活用per-tensor非对称。
- 方案三:权重用per-channel对称,激活用per-tensor非对称。
结果显示,方案三的准确率最高,方案一次之,方案二居中。方案的差异集中在极少数拥有较大动态范围的通道上,per-tensor对称量化把这些通道的范围强行拉伸到全局最大值,导致其他通道精度下降。这个实验结果验证了映射粒度和模式选择对量化精度的影响。
我还进一步尝试了per-channel非对称量化,但发现收益很小,且推理时的计算复杂度明显增加。这个对比也说明了为什么工程上常用per-channel权重+per-tensor激活的组合:它在精度和计算效率之间找到了较好的平衡点。
6. 常见问题与排查技巧实录
6.1 量化后精度大幅下降的排查顺序
精度大幅下降是最常见的问题,排查顺序通常是:
- 确认量化范围统计是否合理。先用简单方式检查min/max,如果有异常的大值,看是否是离群点导致的。
- 确认激活分布与校准集匹配。用真实部署数据抽样看与校准集分布是否相似,如果差异明显,重新构造校准集。
- 检查量化参数是否保存和加载正确。scale和zero_point在写入和读取过程中如果发生精度损失,会带来系统性偏移。
- 逐层对比量化模型与原始模型输出,定位问题出在哪一层。
一个经常被忽视的原因是:某些层包含的偏置项没有参与量化。偏置在全精度推理时数值相对较小,量化后如果被截断或精度丢失,可能对后续层产生较大影响。有些推理库用int32保存偏置,但如果你用的是自研量化实现,需要特别注意偏置的精度处理。
6.2 zero_point相关问题的定位技巧
zero_point相关的错误往往表现为输出整体偏大或偏小,而不是随机波动。排查技巧是:在量化模型的某一层输入一个已知的常数张量,比较量化前和量化后的输出。如果输出差了一个固定常量,几乎可以肯定是zero_point处理有误。
还有一类情况是zero_point与输出的数据类型不匹配。某些硬件上zero_point被强制视为无符号整数,当实际值为负时会发生回绕,导致所有值偏移128个单位。这个坑在跨平台部署时特别容易踩,解决方案是在保存量化参数时明确指定zero_point的数据类型,或者在加载时做一次强制转换检查。
6.3 数值溢出与饱和截断问题
数值溢出在量化推理中比预想中更容易出现。特别是当输入数据分布与校准集分布差异较大时,输入矩阵中的某些值会超出量化范围。推理引擎如果对这些值不做饱和截断,乘加运算会得到远超预期的结果。
实测中我遇到过一种典型的溢出场景:模型在长序列输入时的attention激活值比校准集短序列分布更极端,结果量化后的模型在长序列上的表现明显劣于短序列。解决办法是在校准集中加入长序列样本,重新做统计。如果部署场景无法完全覆盖,可以在输入量化之前加一个全局的clip操作。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 精度大幅下降 | 统计范围被离群值拉偏 | 画直方图观察分布,尝试百分位截断 |
| 输出整体偏移 | zero_point错误或类型回绕 | 用常量输入逐层测试,检查参数加载 |
| 长输入表现劣化 | 校准集与部署输入分布不匹配 | 校准集覆盖多种序列长度 |
| 个别层误差特别大 | 该层动态范围异常或权重通道差异大 | 逐层对比误差,切换per-channel |
| 量化模型输出为NaN | 溢出处未做饱和截断 | 检查各层是否对量化结果做clamp |
| 校准耗时过长 | 校准集规模太大或钩子函数效率低 | 缩小校准集,只统计关键层 |
| 数值一致性波动 | 随机舍入或推理引擎中非线性处理 | 改用确定性舍入,检查推理选项 |
| 激活分布统计不准 | BatchNorm模式或预处理与部署不一致 | 确保推理模式、统一预处理流程 |
6.5 排查过程中积累的实操心得
量化排查中最重要的是把问题分层拆解:先看权重量化的独立误差,再看激活量化的独立误差,最后看两者叠加后的误差。这样能快速定位问题所在,而不是对着整个模型发呆。
另一个心得体会是保存实验配置的重要性。我每次做量化实验都会记录:校准集大小、截断策略、量化粒度、scale的计算方式、取整策略。有时候模型表现得很差,回看配置就发现某一步设置错了。量化流程的每个环节都可能成为精度损失的来源,记录配置就是保留回溯的能力。
7. 实操总结与个人经验
做完这一系列实验,我的感受是:基础量化算法最难的地方不在数学公式,而在工程细节的取舍。统计环节需要你理解数据的分布特性,并选择合理的范围压缩策略;映射环节需要你权衡精度表达与推理效率。很多人量化效果差,不是公式写错,而是在统计阶段偷了懒,直接用min/max而没有分析分布形态,或者在映射阶段选了不当的粒度和模式。
最后分享一个实用的小技巧:量化完成后,不要只看最终模型的整体准确率,要同时关注每一层的重建误差。如果某一层的重建误差特别大,即使整体准确率看起来还行,这个层也可能在边界输入时成为不稳定因素。花一点时间逐层分析误差来源,往往能提前发现未来部署中的隐患。
如果你在量化实践中遇到了有意思的问题,或者对某个具体环节有不同的处理经验,欢迎在评论区交流。量化的世界很大,基础算法是起点,但每一步探索都可能带来新的发现。