部署过模型的朋友应该都有过这种感受:模型在GPU上跑得挺欢,一挪到CPU或者边缘设备上就卡成幻灯片,显存也动不动就爆。抱着这种痛苦找了半天资料,最后绕不开的一个词就是量化。这篇主要聊聊我整理的量化落地经验,聚焦INT8矩阵乘、PTQ校准、QAT,以及现在大模型LLM量化的那些特殊打法。内容不追求教科书式的全面,更偏实战:为什么这么做、参数怎么选、踩过哪些坑。
这篇文章适合正在做推理优化、端侧部署、或者想把手头大模型压缩到单卡/本地跑的人。不管你是刚接触量化的新手,还是已经用过TensorRT但没搞懂背后校准原理的进阶者,都能在这里找到一些参考价值。
1. 为什么偏偏是INT8:量化到底在量化什么
1.1 数值精度与硬件算力的账
量化这个词听起来玄,本质就是一句话:把模型里密密麻麻的浮点数,换成位数更少的整数。FP32是32位浮点,FP16是16位,INT8是8位整数。位数少了,数据体积变小,计算速度变快,这是一笔很直观的账。
但真正的核心逻辑不是"位数少就好"。先看算力:现代GPU和CPU都对低精度计算做了特殊加速。以NVIDIA GPU为例,Tensor Core对INT8的吞吐通常是FP32的好几倍,FP16也比FP32快一半到一倍。再看带宽:推理时模型权重要从显存/内存搬到计算单元,权重从4字节变成1字节,带宽占用直接降75%。很多推理场景根本卡在带宽上,而不是算力上。
所以选INT8而不是FP16,是因为INT8在精度可接受的前提下,同时赢了算力和带宽两场仗。FP16只赢算力,不赢带宽。FP64则是另一个极端,精度高但慢得离谱,除了科学计算几乎没人拿它来推理。
1.2 量化带来的收益到底有多大
我把一个ResNet34模型做过一次完整试验,FP32版本权重约83MB,转换成INT8之后变成21MB,降了正好75%。在CPU上用OpenVINO跑,延迟从FP32的42ms降到了16ms,大概快了2.6倍;在GPU上用TensorRT跑,FP32的延迟是18ms,INT8是7ms。而精度方面,ImageNet验证集top-1准确率从75.2%降到了74.7%,掉了0.5个点。对很多业务来说,这点损失完全可以接受。
这组数字说明了量化的价值,但也说明了一个容易被忽视的点:量化不是免费的,精度折损是必然存在的,关键是怎么把折损控制在可接受范围内。这就是接下来几节要谈的核心问题。
提示:如果你的模型量化后精度掉了超过1-2个点,先别急着怀疑模型结构,多半是校准没做好,或者某些敏感层不该被量化。后面会详细说。
2. INT8矩阵乘的计算原理与工程实现
2.1 线性量化公式与两个关键概念
INT8量化的数学基础是一套线性映射。浮点实数r和整数q的关系是:r = s × (q - z)。其中s是缩放因子(scale),z是零点(zero point)。反过来,把浮点转成整数就是q = round(r / s + z)。
这里面有两个关键概念必需区分。第一个是对称量化 vs 非对称量化。对称量化假设浮点分布关于0对称,z固定为0,公式简化为r = s × q;非对称量化则允许分布偏移,z可以非零。权重的分布通常接近0对称,所以权重一般用对称量化;激活值的分布可能整体偏正,用非对称量化更合适。
第二个是量化粒度。per-tensor是整个张量共用一个scale;per-channel是每个输出通道各有一个scale,主要用在权重上;per-group则更进一步,按group size分组各算各的scale,目前大模型量化常用group=128。粒度越细,量化误差越小,但计算也越复杂,硬件支持也是需要考虑的因素。
2.2 手写一个简化版INT8矩阵乘
INT8矩阵乘的经典实现思路是这样的:输入激活A和权重W先各自量化成INT8,然后做INT8乘法,累加结果放在INT32的累加器里,最后再乘上两个scale还原成浮点。下面这段代码展示了最核心的计算过程,我故意把细节写全,方便你看明白每一步在干什么。
import numpy as np def quantize_per_tensor(x, bits=8): """对称量化:把浮点张量映射到[-127, 127]区间""" x_min, x_max = x.min(), x.max() qmin, qmax = -127, 127 scale = (x_max - x_min) / (qmax - qmin) scale = max(scale, 1e-8) # 防止除零 q = np.clip(np.round(x / scale), qmin, qmax).astype(np.int8) return q, scale def int8_matmul(A_fp32, W_fp32): """简化版INT8矩阵乘:Y = A @ W""" # 第一步:分别量化输入和权重 A_q, scale_a = quantize_per_tensor(A_fp32) W_q, scale_w = quantize_per_tensor(W_fp32) # 第二步:INT8矩阵乘,结果累加到INT32 # 注意这里用int32接收,避免累加过程中溢出 acc = A_q.astype(np.int32) @ W_q.astype(np.int32) # 第三步:反量化回浮点 Y_fp32 = acc * scale_a * scale_w return Y_fp32, (A_q, W_q, scale_a, scale_w)为什么累加器必须是INT32?因为两个INT8数相乘最大是127×127=16129,这已经超出INT8的范围了。当向量维度是256时,累加和的理论上限是16129×256≈413万,这远远超过INT16的32767上限。所以累加器用INT32是行业标准,不是可选项。
2.3 工程上的两个关键优化点
上面的代码在数学上是正确的,但工程实现里还有两个关键优化,实际部署时一定要用。
第一个优化是scale融合(absorbing scale)。推理时先量化输入再乘权重再反量化的流程,多走了不少弯路。实际上可以把权重和scale预先融合:W_eff = W_q × scale_w。这样一来,矩阵乘的结果直接就是浮点scale_a倍,省掉了反量化那步。这在TensorRT、ONNX Runtime里都是默认优化。
第二个优化是激活值量化用per-token粒度。权重可以用per-channel,激活值在传统推理框架里通常整体共用一个scale(per-tensor)。但LLM推理时,per-tensor量化容易崩,原因后面专门说。现在很多推理引擎对激活值采用per-token粒度:每个token单独算一个scale。代码写起来更繁琐,但精度提升非常明显。
3. PTQ校准实战:量化不等于瞎截断
3.1 校准的本质是找最优阈值
训练后量化(Post-Training Quantization,PTQ)的核心环节是校准(calibration)。校准做的一件事:跑一批数据,统计各层激活值的分布,然后确定scale到底取多少。
最容易想到的方案是min-max,直接拿激活值的最大绝对值做scale。实测效果很差,原因在于:神经网络的激活值带有明显的长尾分布,大部分值集中在很小的区间,但个别特别大的"离群值"会把range拉得很大,导致小数部分都被量化成0,精度哗哗往下掉。
所以校准的本质是在找最优阈值:多大的绝对值范围算是"该保留的正常信号",超出范围的直接截断。截断虽然让少数极端值变成±127,但换来了主体分布更精细的表示,整体信息损失反而更小。
3.2 四种常用校准方法实测对比
我用ResNet34做过一组对比实验,校准集是ImageNet验证集里抽的256张图,量化目标是去掉最后一层外的所有卷积层,结果如下:
| 校准方法 | Top-1准确率 | 相对FP32下降 |
|---|---|---|
| FP32基线 | 75.2% | - |
| Min-Max | 73.1% | -2.1% |
| 百分位P99.9 | 74.5% | -0.7% |
| 均方误差MSE | 74.6% | -0.6% |
| 熵校准KL散度 | 74.7% | -0.5% |
百分位法和MSE法、KL法效果基本在一个档次。Min-Max确实不行。TensorRT默认用KL散度校准是有道理的:它通过不断尝试候选阈值,找到使量化前后分布差异最小的那个点。如果你手里的框架不支持KL校准,用百分位法也完全够用。
3.3 校准集怎么选才靠谱
校准集的选择比方法本身更容易被忽略,但影响往往更大。三个原则:
校准集要覆盖真实场景的分布。做分类模型就用验证集抽样;做OCR就用真实拍摄的文本图像;做LLM就用足够多样化的中英文语料。这里有个反面教材:我见过有人拿纯优雅的新闻稿做校准,部署后一遇到口语化提问,结果直接崩成乱码。
校准集规模不需要大,但一定要多样。经验值是100到500个样本就够了。Calibration的统计目标是分布刻画,不是模型训练,500个样本足以算出稳定的scale。相比数量,多样性才是稀缺资源。
校准数据要经过和推理完全一致的预处理。这一点特别容易出问题:训练时图像是Resize后CenterCrop,校准脚本里忘了做,算出来的分布全是错的。我的排查习惯是一旦量化后精度异常,先检查预处理pipeline是否和基准推理完全一致。
注意:如果某层激活值范围极广,没有明显集中区,这层可以考虑跳过不量化。很多推理框架支持按算子粒度配置量化策略,别硬着头皮全量量化。
4. QAT量化感知训练:让模型学会适应低精度
4.1 伪量化与直通估计器的核心机制
PTQ毕竟是在模型训练结束后"硬改"数值,对于小模型、关键任务或更低比特(INT4)场景,折损可能大到无法接受。这时就需要量化感知训练(Quantization-Aware Training,QAT)。
QAT的思路是在训练过程中加入伪量化节点(fake quant)。这个节点在forward时模拟量化的效果:先把值量化到INT8,再反量化回浮点,让网络亲身感受量化后的误差。backward时,由于round函数导数几乎处处为零,梯度传不过去,所以要用直通估计器(STE)技巧:让梯度直接绕过量化节点,就像它不存在一样。
伪量化层用PyTorch实现的话,核心代码就几行,我贴过很多次,再贴一次帮你把逻辑理顺:
import torch class FakeQuantize(torch.autograd.Function): @staticmethod def forward(ctx, x, scale, qmin=-127, qmax=127): # 量化后反量化,模拟量化误差 x_quant = torch.clamp(torch.round(x / scale), qmin, qmax) x_dequant = x_quant * scale return x_dequant @staticmethod def backward(ctx, grad_output): # 直通估计器:梯度原样通过 return grad_output, None # 使用时需要维护一个可更新的scale scale = torch.tensor(0.1, requires_grad=True) x_fake = FakeQuantize.apply(x, scale)4.2 QAT训练的几个实操坑
QAT不是简单地在训练脚本里加几行代码就行,有几个细节直接决定成败。
学习率必须调小。QAT本质是对已经收敛的模型做微调,学习率一般用原训练的1/10到1/100。我常用的做法是:先用1e-5跑两个epoch观察loss波动,稳定后再慢慢加。
BatchNorm折叠要处理好。推理时BatchNorm一般会被融合进前面的卷积层,但QAT训练时BN还在独立工作,会造成训练和推理行为不一致。PyTorch在量化接口里提供了fuse_model方法,QAT前务必先做融合。
QAT训练epoch数不需要太多。在很多数据集上,3到5个epoch就能让精度回升到接近FP32水平。训练太长反而可能过拟合到校准集上,这一点我实际吃过亏。
还有一点容易被忽略:QAT通常配合PTQ做初始化。先用PTQ流程确定初始scale,再开启QAT微调,而不是从FP32模型白手起家直接训。这样做收敛快得多,而且最终精度更高。
4.3 什么场景必须上QAT
并不是所有模型都需要QAT。我自己的决策流程是这样的:先跑PTQ,如果精度损失在1个点以内,就直接用;如果损失超过2个点,上QAT;如果QAT提升不明显,再回去检查是不是校准集有问题或者敏感层没保留FP32。通常QAT能挽回一半以上的精度损失。
最典型的必须上QAT的场景是小模型和极端低比特。MobileNetV3这类轻量网络本身冗余度低,PTQ经常掉3个点以上;INT4量化更是离不开QAT。大模型反而不太依赖QAT,这跟后面要说的LLM量化特性有关。
5. LLM量化的特殊打法:从GPTQ到GGUF
5.1 LLM量化与传统模型本质不同
LLM量化最近两年发展迅猛,但很多做传统CV部署的人一开始会把老经验直接搬过去,然后碰一鼻子灰。LLM量化最大的特殊性在于激活值存在显著的离群值:某些特征维度上,少数token的激活值比其他维度大好几个数量级。如果整层共用一个scale,主体信息会被彻底压扁,模型直接失去语言能力。
另一个特殊性是LLM推理是memory-bound,而不是compute-bound。解码阶段一次只生成一个token,权重绝大多数时间在等显存搬运,而不是在计算。这种情况下"权重量化、激活保FP16"的weight-only量化成为主流,因为带宽省了75%,而计算精度损失很低。
5.2 GPTQ、AWQ与SmoothQuant的基本思想
先看GPTQ(当时从OPTQ演化来的经典方法)。它的核心是用近似二阶信息来补偿量化误差:逐层找一个最优的补偿量,让量化后的权重乘以激活后,输出尽可能接近原始输出。实际操作中GPTQ有sensitivity分析和group size选择,group size越小越准但计算量越大。主流的做法是group=128,效果和速度比较平衡。
再看AWQ(Activation-aware Weight Quantization)。它的洞察很有意思:权重的重要性不是看权重本身,而是看它乘的激活值的大小。那些经常被大激活值激活的权重通道更重要,量化时应该给它们分配更多精度。AWQ不依赖复杂的重训练,纯PTQ流程,精度比GPTQ还稳。
SmoothQuant则是另一个思路:既然激活有离群值,权重没有,那把激活的难处"平滑"一部分给权重。在数学上可以找到一个对角缩放矩阵,把激活值变小、权重变大,整体矩阵乘结果不变。平滑之后,激活值就可以安全地用INT8量化了。这个思路在学术上非常优雅,但工程落地时因为要改模型结构,用得不如前两个广。
5.3 实际落地:GGUF的K量化与工具链选择
现在聊LLM量化,绕不开GGUF和llama.cpp生态。GGUF里常见Q4_K_M、Q5_K_M、Q8_0这样的名字,K代表K-quant方案。它的核心是分block处理:每个block内部,一部分权重用更高精度保存,一部分用低精度,再配合不同group size,在保持极端参数精度的同时尽量压缩整体体积。
我测试过一个7B模型,FP16版本大约14GB,Q4_K_M版本约4.8GB,Q8_0版本约7.8GB。在消费级显卡或者Mac上,Q4_K_M的指令跟随能力和原版差距很小,但速度要快很多。个人建议:显存充裕就上Q8_0,紧巴巴就用Q4_K_M,Q5_K_M属于比较折中的档位。
工具选择上,传统CV模型用ONNX Runtime、TensorRT或OpenVINO,各家的量化API都在快速演进,留意quantize_model接口的入参变化。LLM场景则通常绕不开llama.cpp和GPTQ-for-LLaMA这类工具。社区的模型仓库里大量存在预量化好的GGUF文件,下载即用,省去自己量化的痛苦。但务必看看量化档位和基座模型版本,不同基座参数量差异会导致GGUF文件体积差异巨大,下载前先核对参数量。
注意:LLM量化对"零样本过拟合"非常敏感,量化后的模型最好不要在评估基准上反复调校准集做优化,否则评测分数会虚高,一上真实场景就现原形。测评集与校准集必须严格隔离。
6. 常见问题与排查技巧实录
6.1 精度掉太多,优先检查这五件事
量化后精度大幅下降,我强烈建议按下面的优先级排查,别一上来就重训模型,那是最后手段。我自己每次都是按这个顺序逐个排除的。
校准集是不是有问题。不管是数量太少还是分布偏了,校准集永远是嫌疑最大的。换一批更有代表性的数据,经常能起到立竿见影的效果。
预处理是否一致。训练、校准、推理三段代码里的预处理必须完全一致,哪怕差一个像素的填充,分布都会偏。
有没有算子被重复量化。有些框架里算子融合不到位,明明权重已经被前任量化过,后任又来一次,误差翻倍。打开量化日志,检查每个算子的量化状态。
敏感层有没有被保护。BatchNorm层、第一层卷积/embedding层、最后的分类头,这几类通常不建议量化。现代框架一般都能按层配置"白名单",把敏感层保留FP32试试。
scale的粒度是否太粗。per-tensor不行就换per-channel,per-channel不行就换per-group。精度和计算量之间永远在找平衡。
6.2 实测排障案例:一次ResNet34的精度暴跌
有一次我把ResNet34量化到INT8,分类准确率从75%跌到61%,当时差点怀疑人生。排查过程是这样的:先看校准集,用的是标准ImageNet验证集,没问题;再查预处理,发现训练时用了RandAugment但校准脚本里直接读了原图,分布对不上。修正预处理后,准确率回到71%,还是差很多;然后看量化日志,发现第一层卷积被量化了,而它正好是处理RGB原始输入的层,数值范围极大。把第一层加入跳过白名单后,准确率恢复到74.6%。整个过程花了不到三个小时,但确实验证了"先查校准、再查预处理、再护敏感层"这套排查路径的实用价值。
6.3 工具选型速查表
我做了一张速查表,涵盖我常用的部署工具和适用场景,方便你对照着选型。
| 部署平台/场景 | 推荐工具 | 量化方案 | 备注 |
|---|---|---|---|
| NVIDIA GPU高性能推理 | TensorRT | PTQ+KL校准/QAT | 算子融合做得好,INT8加速明显 |
| 跨平台CPU推理 | ONNX Runtime | 动态/静态量化 | 生态全,文档全,易上手 |
| Intel CPU边缘部署 | OpenVINO | 静态量化 | 对Intel硬件优化极好 |
| 大模型本地部署 | llama.cpp(GGUF) | K-quant(Q4_K_M等) | 内存占用低,社区模型丰富 |
| 自定义训练/研究 | PyTorch | 伪量化+QAT | 灵活但有学习成本 |
这里多说一句:工具之间的量化效果差异没有想象中那么大,真正拉开差距的是你对校准和精度分析的理解。换工具不能解决校准集选错的问题。
6.4 量化工作的验收清单
量化工作有没有达标,不能只看一张图或一句生成结果。我的验收清单是:一,在验证集上跑完整精度对比,至少观察Top-1/Top-5(分类)、perplexity(LLM)等核心指标;二,用至少3种不同类型的输入做烟雾测试,防止量化引入偶发异常;三,对比CPU/GPU/端侧的真实延迟和峰值内存,确认收益确实到账;四,建立量化后模型的回归基线,以后每次改动都能对比,防止静默劣化。这四步做完,量化这件事才算真正闭环。
写在最后的实操心得
做量化这几年,我最大的感受是:量化不是一道把模型"压小"的算术题,而是一场寻找系统中敏感瓶颈的排查过程。每个模型、每个部署目标、每个硬件平台都有自己的脾气,没有一套参数能通吃所有场景,唯有多做实验、多保存基线、多记录每档配置的效果。
最后再分享一个小技巧:量产前一定要给量化流程加一个自动回滚机制。一旦精度指标跌过阈值,自动切回FP32版本,避免线上事故。这套保险机制救过我很多次。
量化是个越挖越深的领域,这篇算是把地基打了一遍:INT8矩阵乘的工程实现、校准的实战方法、QAT的关键细节、LLM量化的特殊思路、排障与验收经验。希望这些内容能帮你少走弯路,在动手部署时心里更有底。