1. 为什么模型部署绕不开量化这道坎做过模型部署的人都有一个共同体会训练时用FP32跑得好好的模型一上生产环境就各种水土不服。显存不够、延迟超标、吞吐量上不去尤其是想把模型塞进边缘设备或者用单卡撑起更高的并发时FP32的权重和激活值就像搬家时舍不得扔的旧家具占地方还拖慢速度。量化就是解决这个矛盾的核心手段而INT8矩阵乘、校准、QAT和LLM量化这几件事基本覆盖了从传统CNN到如今大模型部署的完整量化链路。先把概念说清楚。量化本质上是把高精度浮点数FP32/FP16映射到低比特整数INT8/INT4的过程用更少的比特位表示同样的数值范围。FP32有32位能表示大约7位有效十进制数字动态范围极大FP16砍到16位精度和范围都缩水但通常够用INT8只有8位能表示256个离散值范围是-128到127。算力需求上FP32的矩阵乘在GPU上通常走CUDA Core的FP32流水线FP16可以走Tensor Core获得数倍加速INT8则能进一步利用Tensor Core的整数运算单元理论吞吐是FP16的两倍、FP32的四倍左右。这不是线性关系因为实际瓶颈往往在内存带宽和调度开销上但方向是明确的比特数越低单位算力能处理的参数量越大。那为什么不能直接把FP32权重四舍五入成INT8就完事因为神经网络对数值分布极其敏感。权重和激活值的分布往往是不对称的、有长尾的简单截断会丢失大量信息导致精度断崖式下跌。所以量化必须配套一套“映射策略”和“校准机制”让INT8的256个格子尽可能均匀地覆盖真实数据的分布。这就是校准Calibration存在的意义也是QATQuantization-Aware Training和PTQPost-Training Quantization分道扬镳的地方。这篇文章面向的是已经能把模型跑起来、但被部署效率和资源占用卡住的工程师也适合想系统理解量化原理、不想只调包的同学。我会从INT8矩阵乘的底层逻辑讲起把校准的数学直觉、QAT的训练技巧、LLM量化的特殊挑战串成一条线中间穿插可以直接抄的代码和踩过的坑。读完你至少能判断我的模型该用PTQ还是QAT校准集怎么选LLM量化为什么不能照搬CNN那套。2. INT8矩阵乘到底快在哪里2.1 从浮点乘加到整数乘加的映射矩阵乘是深度学习里最核心的算子全连接层、卷积层展开后都是矩阵乘。FP32的矩阵乘做的是浮点乘加每个乘加单元要处理指数对齐、尾数运算硬件面积大、功耗高。INT8矩阵乘做的是整数乘加两个8位整数相乘得到16位中间结果再累加到32位累加器里。这个过程的硬件实现简单得多同样面积的芯片能塞进更多的乘加单元这就是吞吐量提升的物理基础。但这里有个关键点INT8矩阵乘不是简单地把FP32值截断成整数。它需要一套仿射映射把浮点区间映射到整数区间。公式是这样的real_value scale * (quantized_value - zero_point)其中scale是浮点缩放因子zero_point是整数零点偏移。对于对称量化zero_point为0映射变成real scale * q。对于非对称量化zero_point不为0能更好地处理ReLU之后全正数的激活值分布。scale的计算方式是scale (max_val - min_val) / (q_max - q_min)对于INT8q_max127q_min-128所以分母是255。这个scale决定了量化的粒度scale越小能表示的浮点范围越窄但精度越高scale越大范围越宽但相邻整数代表的浮点间隔越大。2.2 为什么INT8矩阵乘能保持精度很多人担心8位表示会丢太多信息但实际部署中INT8量化后的模型精度损失通常能控制在1%以内甚至无损。原因有三层。第一神经网络本身有冗余权重和激活值并不是均匀分布在FP32的整个动态范围里大量数值集中在零附近INT8的256个格子如果校准得当足够覆盖有效信息。第二矩阵乘的累加是在32位整数上做的中间不会溢出只有最终输出才重新量化回INT8或反量化回FP32累加过程保留了精度。第三校准过程会统计激活值的真实分布用KL散度或者最小化量化误差的方式选择最优的截断阈值而不是简单取最大最小值。我实测过一个ResNet-50的INT8量化ImageNet top-1精度从76.1%掉到75.8%几乎可以忽略。但同样的方法用在某些轻量级网络上比如MobileNet系列精度掉得就明显一些因为深度可分离卷积的激活值分布更尖锐对量化更敏感。这说明量化不是万能药模型结构本身会影响量化友好度。2.3 实操用ONNX Runtime做INT8量化ONNX Runtime提供了现成的量化工具适合快速验证PTQ效果。下面是一个完整的流程假设你已经有一个FP32的ONNX模型。import onnx from onnxruntime.quantization import quantize_dynamic, quantize_static, QuantType, CalibrationDataReader import numpy as np # 动态量化不需要校准数据权重转INT8激活值运行时动态量化 quantize_dynamic( model_inputmodel_fp32.onnx, model_outputmodel_int8_dynamic.onnx, weight_typeQuantType.QInt8 ) # 静态量化需要校准数据权重和激活值都提前确定量化参数 class MyCalibrationReader(CalibrationDataReader): def __init__(self, calibration_data): self.data calibration_data self.index 0 def get_next(self): if self.index len(self.data): return None batch self.data[self.index] self.index 1 return {input: batch} def rewind(self): self.index 0 calib_data [np.random.randn(1, 3, 224, 224).astype(np.float32) for _ in range(100)] reader MyCalibrationReader(calib_data) quantize_static( model_inputmodel_fp32.onnx, model_outputmodel_int8_static.onnx, calibration_data_readerreader, quant_formatQuantType.QInt8, per_channelTrue, reduce_rangeFalse )动态量化适合LSTM、Transformer这类激活值动态范围变化大的模型部署简单但推理时仍有动态量化开销。静态量化适合CNN推理速度更快但需要代表性校准集。per_channelTrue表示每个通道单独计算scale比per_tensor精度更好但模型体积略大。reduce_range在早期硬件上用于避免溢出现在大多数情况可以关掉。注意校准集不要用训练集的全量数据100到500个样本足够但要覆盖真实部署时的输入分布。我见过有人用纯白噪声校准结果量化后模型在真实图片上精度崩了因为白噪声的激活分布和自然图像完全不同。3. 校准量化精度的守门人3.1 校准在解决什么问题校准的核心任务是确定激活值的截断范围。理论上激活值的动态范围可以是负无穷到正无穷但实际分布通常集中在某个区间。如果直接用全局最大最小值少数极端值会把scale拉得很大导致大多数正常值被压缩到很少的整数格子里量化误差剧增。校准就是找一个阈值T把超过T的值截断用[-T, T]或者[min, max]来计算scale让有效数值获得更细的量化粒度。这个过程有点像给照片调色阶。原图可能有一两个过曝的高光点如果按最高光来拉伸整个直方图暗部细节就全糊了。校准就是手动设定黑白场牺牲极少数极端像素换取整体对比度和细节。3.2 主流校准算法对比校准方法核心思想适用场景优缺点Min-Max直接取全局最小最大值分布均匀、无长尾简单快速但对离群值敏感KL散度最小化量化前后分布差异CNN激活值精度好计算量中等百分位截断取99.9%分位数作为阈值有长尾分布需要调百分位参数MSE最小化最小化量化误差平方和通用精度稳定计算稍慢移动平均滑动窗口统计动态输入适合在线校准KL散度校准是TensorRT和ONNX Runtime的默认推荐它把量化前后的激活值直方图做KL散度选择使散度最小的截断阈值。具体做法是把激活值分成2048个bins然后从高到低逐步截断每次计算量化后分布和原分布的KL散度取最小值对应的阈值。这个过程对每个激活层独立进行所以校准时间跟模型层数和校准样本数成正比。百分位截断更直接比如取99.9%分位数意味着只截断0.1%的极端值。这个参数需要根据模型调整太激进会丢信息太保守等于没截断。我一般先用99.99%跑一版看精度掉多少再逐步收紧。3.3 校准集的选择与预处理校准集的质量直接决定量化精度。几个原则第一校准集必须来自真实数据分布不能用随机噪声或者训练集的子集代替因为训练集经过增强后分布可能偏移。第二样本数量不用多100到500个足够但类别要均衡如果做分类任务每个类别至少要有几个样本。第三预处理要和推理时完全一致包括归一化、resize、通道顺序任何不一致都会导致激活分布偏移。我踩过一个坑校准的时候用了BGR通道顺序推理时用的是RGB结果量化后模型精度掉了5个点。排查了半天才发现是通道顺序问题。所以校准脚本最好直接复用推理的前处理代码不要重新写一套。# 校准数据预处理示例确保和推理一致 def preprocess(image_path): img cv2.imread(image_path) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 统一转RGB img cv2.resize(img, (224, 224)) img img.astype(np.float32) / 255.0 mean np.array([0.485, 0.456, 0.406]) std np.array([0.229, 0.224, 0.225]) img (img - mean) / std img np.transpose(img, (2, 0, 1)) return np.expand_dims(img, axis0).astype(np.float32)提示如果模型有多个输入校准reader要返回包含所有输入的字典key要和ONNX模型的输入名一致。漏掉任何一个输入都会导致校准失败或者量化参数错误。4. QAT把量化误差写进训练目标4.1 PTQ的天花板与QAT的破局思路PTQ训练后量化最大的优势是简单不需要重新训练拿现成模型跑一遍校准就能用。但它的天花板也很明显量化误差是在模型训练完之后才引入的模型没有机会去适应这种误差。对于量化友好的模型PTQ足够对于轻量级网络或者激活值分布复杂的模型PTQ精度掉得多这时候就需要QAT。QAT的核心思想是在训练前向传播时模拟量化误差让模型在训练过程中学会补偿这种误差。具体做法是在前向传播中插入伪量化节点Fake Quantization把权重和激活值按照量化的方式截断和舍入但反向传播时梯度仍然按浮点计算用直通估计器Straight-Through Estimator, STE把梯度直接传过去。这样模型看到的输入是量化后的但更新的是浮点权重训练完成后权重再真正量化成INT8。打个比方PTQ像是考试前没复习直接上考场能考多少算多少QAT像是提前做了几套模拟题知道题型和陷阱在哪正式考试时发挥更稳。4.2 伪量化节点的实现细节伪量化的前向过程分四步计算scale和zero_point、量化、反量化、输出。以对称量化为例import torch import torch.nn as nn class FakeQuantize(nn.Module): def __init__(self, bits8, symmetricTrue): super().__init__() self.bits bits self.symmetric symmetric self.qmin -(2 ** (bits - 1)) self.qmax 2 ** (bits - 1) - 1 self.scale nn.Parameter(torch.tensor(1.0)) self.zero_point 0 def forward(self, x): if self.training: # 训练时用移动平均更新scale max_val x.abs().max() self.scale.data 0.9 * self.scale.data 0.1 * (max_val / self.qmax) # 伪量化 x_q torch.clamp(torch.round(x / self.scale), self.qmin, self.qmax) x_dq x_q * self.scale return x_dq这里的关键是STEtorch.round的梯度几乎处处为零直接反向传播会导致梯度消失。STE的做法是在反向传播时把round的梯度当作1让梯度直接穿过量化操作。PyTorch的torch.round本身不提供STE需要自定义autograd函数或者在量化感知训练框架里用现成的实现。4.3 QAT训练策略与调参经验QAT不是从头训练而是在预训练模型基础上微调。学习率要设得很小通常是原始训练学习率的1/100到1/10因为模型已经收敛只需要微调适应量化误差。训练轮数也不用多5到20个epoch通常足够。如果学习率太大模型会震荡甚至发散如果太小量化误差补偿不充分。我一般分三个阶段第一阶段冻结量化参数只训练权重让模型先适应量化后的前向第二阶段解冻量化参数让scale也参与学习第三阶段用很小的学习率做精调。这个流程比一上来就全解冻稳定得多。另一个经验是BN层的处理。QAT时BN层的统计量要重新校准因为量化后的激活值分布变了。如果直接用预训练模型的BN统计量精度会受影响。PyTorch的量化感知训练会在训练过程中自动更新BN的running mean和var但要注意在QAT开始前把BN的momentum调小让统计量更新更平滑。注意QAT训练时要用和推理一致的量化配置包括对称/非对称、per_tensor/per_channel、比特数。训练时用INT8推理时用INT4量化参数对不上精度肯定崩。5. LLM量化当模型大到装不下5.1 LLM量化的特殊挑战把CNN那套量化方法直接搬到LLM上大概率会翻车。原因有几个。第一LLM的激活值分布和CNN完全不同Transformer里的激活值存在大量离群值outliers少数通道的数值可能是其他通道的几十倍甚至上百倍。如果按全局最大最小值校准这些离群值会把scale拉得极大导致正常值全部被压到零附近。第二LLM的权重分布也有特点某些层的权重方差很大per_tensor量化效果差。第三LLM推理是自回归的每一步的激活值都依赖前一步的输出误差会累积。离群值问题是LLM量化的核心难点。有研究发现Transformer的某些注意力头和FFN通道会产生极大的激活值这些离群值对模型性能至关重要不能简单截断。解决方案有几种一是per_channel量化每个通道单独算scale把离群值隔离在少数通道里二是混合精度对离群通道保持FP16其他通道用INT8三是旋转矩阵方法通过正交变换把离群值分散到所有通道降低量化难度。5.2 GPTQ与AWQ两种主流LLM量化方案GPTQGenerative Pre-trained Transformer Quantization是一种基于二阶信息的PTQ方法。它的核心思想是逐层量化每量化一列权重就用校准数据调整剩余未量化的权重补偿量化误差。这个过程用到了Hessian矩阵的近似计算量比简单PTQ大但精度好很多。GPTQ支持INT4甚至INT3在7B到70B模型上都能把精度损失控制在可接受范围。AWQActivation-aware Weight Quantization则从激活值出发发现只有少量权重通道对激活值影响大这些通道应该保持高精度其他通道可以大胆量化。AWQ不依赖反向传播校准速度快而且对指令微调模型效果很好。实际部署中AWQ的推理速度通常比GPTQ快因为它的量化配置对硬件更友好。方案核心思想校准速度精度硬件友好度GPTQ逐层二阶误差补偿中等高中等AWQ激活感知的通道保护快高高SmoothQuant激活值平滑迁移到权重快中高高LLM.int8()离群值FP16其他INT8快高中等SmoothQuant的思路又不一样它把激活值的量化难度迁移到权重上。具体做法是对每个通道乘一个平滑因子让激活值的动态范围变小权重的动态范围变大因为权重比激活值更容易量化。这个变换是数学等价的不影响模型输出。5.3 实操用GPTQ量化一个7B模型下面是一个简化的GPTQ量化流程基于Hugging Face的transformers和auto-gptq库。from transformers import AutoModelForCausalLM, AutoTokenizer from auto_gptq import AutoGPTQForCausalLM, BaseQuantizeConfig import torch model_name meta-llama/Llama-2-7b-hf tokenizer AutoTokenizer.from_pretrained(model_name) # 量化配置4比特group_size128对称量化 quantize_config BaseQuantizeConfig( bits4, group_size128, desc_actFalse, symTrue ) # 加载模型并量化 model AutoGPTQForCausalLM.from_pretrained( model_name, quantize_configquantize_config, device_mapauto ) # 校准数据用真实文本不要用随机token calib_texts [ The quick brown fox jumps over the lazy dog., Machine learning is a subset of artificial intelligence., # ... 更多文本建议128到256条 ] calib_tokens [tokenizer(t, return_tensorspt).input_ids for t in calib_texts] model.quantize(calib_tokens) model.save_quantized(llama-2-7b-gptq-4bit)group_size128表示每128个权重共享一个scale比per_tensor精度好比per_channel存储开销小。desc_actFalse表示不按激活值大小重排权重开启后精度略好但推理速度慢。symTrue是对称量化LLM权重通常用对称量化就够了。校准数据一定要用真实文本而且最好覆盖模型实际会遇到的领域。用随机token校准出来的量化参数没有意义因为随机token的激活分布和真实文本差异巨大。我一般从训练集或者业务数据里抽200条左右每条长度控制在512到1024个token。提示量化7B模型大概需要15到30GB显存具体取决于group_size和校准数据量。如果显存不够可以先用device_mapcpu加载量化时再逐层搬到GPU。量化完成后模型体积会从FP16的13GB左右降到INT4的3.5GB左右单张消费级显卡就能跑。6. 常见问题与排查技巧实录6.1 量化后精度暴跌怎么排查精度暴跌是最常见的问题排查思路要系统化。第一步确认校准集和推理前处理是否一致通道顺序、归一化参数、resize方式都要对齐。第二步检查量化配置per_tensor改成per_channel试试对称改非对称试试INT8改INT4通常会更差但能帮助定位问题。第三步逐层分析用工具把每层的量化误差打出来看是哪一层掉得最厉害。第四步如果模型有BN层确认BN统计量是否在量化后重新校准过。我遇到过一次精度从75%掉到60%的情况最后发现是校准集里混入了未归一化的数据导致激活值分布完全错误。所以校准数据的预处理一定要和推理严格一致最好直接调用推理的预处理函数。6.2 量化模型推理速度反而变慢量化理论上应该更快但实际可能变慢原因通常有几个。第一硬件不支持INT8加速比如某些老GPU的INT8吞吐和FP32一样甚至因为额外的量化/反量化操作更慢。第二量化粒度太细per_channel量化虽然精度好但推理时每个通道都要单独计算调度开销大。第三模型太小量化带来的收益抵不过量化/反量化的开销通常参数量小于1M的模型量化收益不明显。判断方法很简单用profiler看各算子的耗时如果量化/反量化算子占比超过20%说明量化粒度太细或者硬件不支持。这时候可以试试per_tensor量化或者换用支持INT8的推理引擎。6.3 常见问题速查表问题现象可能原因排查方法解决方案精度掉超过5%校准集分布不对对比校准和推理前处理统一预处理增加校准样本精度掉1-3%量化粒度太粗检查per_tensor/per_channel改per_channel调整校准算法推理变慢硬件不支持INT8查硬件规格和profiler换硬件或改回FP16输出全零或NaNscale计算溢出检查激活值范围用reduce_range或调大scale某些层误差大离群值影响逐层误差分析该层保持FP16或混合精度QAT不收敛学习率太大观察loss曲线降低学习率分阶段训练6.4 几个容易被忽略的细节量化不是一次性的工作模型更新后要重新校准。我见过有人用半年前的校准数据量化新模型精度掉得莫名其妙。校准数据要跟着模型迭代走至少每个大版本更新一次。ONNX量化时要注意opset版本不同版本对量化算子的支持不一样。opset 13以上对QDQ格式支持更好opset 10到12可能需要用QOperator格式。如果量化后模型加载失败先检查opset版本。LLM量化时embedding层和最后的lm_head通常不量化因为这两层对精度影响大且参数量占比小。量化配置里要显式排除这两层否则精度会明显下降。注意量化后的模型不要再用FP32的优化器去微调因为量化参数和浮点权重的更新规则不兼容。如果量化后还需要微调用QAT或者LoRA这类适配量化模型的方法。7. 从PTQ到QAT再到LLM量化的选型逻辑实际项目中怎么选量化方案我一般按这个逻辑走。先看模型大小和部署硬件如果模型小于10M参数、部署在支持INT8的移动端或边缘设备优先PTQ加per_channel量化一天内能搞定。如果PTQ精度掉超过3%再上QAT多花两三天训练但精度能拉回来。如果模型是LLM参数量在7B以上直接上GPTQ或AWQ不要尝试传统PTQ离群值问题会让你怀疑人生。硬件方面NVIDIA的Tensor Core从Turing架构开始支持INT8Ampere之后INT4也有硬件加速。如果部署在CPU上ONNX Runtime和OpenVINO对INT8的支持都不错但要注意CPU的VNNI指令集没有VNNI的话INT8加速有限。移动端高通和联发科的NPU对INT8支持很好但量化配置要按厂商的工具链来不能直接用ONNX的量化结果。还有一个经验量化不是越激进越好。INT8通常是无损或近无损的INT4在LLM上可用但在CNN上风险大INT2和INT1基本只适合研究。我见过为了省显存硬上INT4结果精度崩了最后回退到INT8反而整体吞吐更高因为INT4的反量化开销和精度补偿成本抵消了存储收益。最后分享一个校准集构建的小技巧从业务数据里分层抽样按类别或长度分桶每个桶抽等量样本。这样校准集能覆盖各种输入模式比随机抽样稳定得多。我一般抽256条分8个桶每桶32条实测比随机抽500条效果还好。