1. 量化到底在压缩什么:一次把收益算明白
做模型部署的人应该都有过这种经历:训练好的模型明明在离线评测里表现不错,一上生产环境就被“内存超限”打回来。一个标准的 FP32 模型,光权重就占 4 字节/参数,几千万参数就是几百 MB,再加上激活值、梯度、中间缓存,设备端往往连模型都装不下。于是大家开始聊模型压缩,而压缩的手段无非三类:剪枝、蒸馏、量化。剪枝是把不重要的连接或通道去掉,蒸馏是让小模型模仿大模型,量化则是从“数值表示”层面下手——把连续浮点用更少的比特位来近似。这三者不冲突,甚至可以叠加,但真要说工程落地性价比,量化一定排在前面。
我为什么强调性价比?因为量化不改变模型结构,不需要重新训练整个任务,大部分场景下甚至不需要动训练代码。你只需要把 FP32 的权重和激活值,按一定规则映射到 INT8 的整数范围,推理时用整数运算替代浮点运算。存储从 4 字节变 1 字节,参数内存直接降到原来的四分之一;计算上,整数乘加指令在多数芯片上比浮点指令吞吐更高,还更省电。一个典型的检测模型,在端侧设备上做完 INT8 量化后,推理延迟常常能压到原来的 60% 甚至更低,模型体积也从几百 MB 降到几十 MB。
适合谁来参考这篇内容?如果你是刚接触模型压缩的算法工程师,想搞明白量化到底怎么选、怎么做;或者你已经在做部署,遇到“量化后精度掉点”“推理没提速”这类问题,都可以照着下面的思路排查一遍。我会把原理讲得尽量直白,也会把实操里那些文档不会写清楚的坑直接摆出来。
1.1 FP32 模型“重”在哪
神经网络的计算,本质上就是大量矩阵乘法。训练阶段我们用 FP32 甚至 FP16 的精度来保证数值稳定,因为反向传播对误差很敏感,梯度稍微不准,参数更新就可能跑偏。但推理阶段不一样,我们只需要前向计算。前向计算对数值精度的容忍度比训练高得多,原因在于神经网络的权重和激活值通常都落在一个比较集中的分布区间里,不是均匀铺满整个浮点范围。
举个例子,某两层卷积网络的权重分布,绝大多数值集中在 -0.1 到 0.1 之间,真正的大值很少。FP32 用 32 位去表示这么小的范围,其实有大量比特位在“描述空气”。量化做的事,就是用一小段整数刻度去覆盖这个真实的有效范围,把那些用不上的精度丢掉。
这样做能省多少?单个参数从 4 字节变 1 字节是确定的,模型体积直接打到 25%。但更关键的是内存带宽,在线服务的吞吐瓶颈很多时候不在计算而在访存,参数变小后,加载权重的时间缩短,缓存命中率提升,推理请求的吞吐量也跟着上去。这一点在做大批量并发服务时感受尤其明显。
1.2 INT8 推理为何能加速
量化带来的加速主要有三个来源:一是内存带宽压力降低,二是计算指令更高效,三是缓存更友好。很多人在讨论量化加速时只盯着“整数计算比浮点快”,其实不全对。在部分硬件上,FP32 和 INT8 的单次运算延迟差距没那么夸张,真正的差距体现在并行度上,一个时钟周期能塞进去的整数运算往往远多于浮点运算。也就是说,单位时间能处理的矩阵元素更多。
另外,量化后的激活值也变成了 INT8,中间特征图占用的内存大幅缩小。对卷积这类计算密集算子来说,特征的搬运成本有时候比计算本身还高。激活从 4 字节变 1 字节,意味着片上缓存能装下更多中间结果,数据复用更好,内存访问次数降低,速度自然就上来了。这也是为什么很多量化方案会强调“激活量化”而不只是“权重量化”。
如果你部署的目标是专用的 AI 加速芯片,INT8 往往还是硬件的“主赛道”,不少芯片甚至只对 INT8 做了高吞吐的定制单元,FP32 反而要靠通用核来跑。这种情况下,量化与否直接决定了你的模型能不能跑在硬件最强的算力上。
1.3 量化收益的直观对比
拿一个虚构的典型项目来说:一个 50 MB 的 FP32 图像分类模型,部署在某嵌入式设备上,推理延迟约 80 ms,内存占用约 180 MB。做 INT8 权重量化加激活量化后,模型文件约 13 MB,推理延迟约 45 ms,内存占用约 70 MB。精度从 92.1% 掉到 91.6%,属于可接受范围。
| 项目 | FP32 基线 | INT8 量化后 | 变化 |
|---|---|---|---|
| 模型体积 | 50 MB | 约 13 MB | 减少约 74% |
| 单次推理延迟 | 80 ms | 约 45 ms | 缩短约 44% |
| 峰值内存 | 180 MB | 约 70 MB | 减少约 61% |
| 准确率 | 92.1% | 91.6% | 下降 0.5% |
当然,这只是理想情况。如果你的模型本身很小、算子碎片化严重、部署环境对 INT8 支持又不好,收益就没这么大,甚至可能出现“理论省内存、实际没提速”的情况。这个我放到后面“实操问题”章节详细说。
2. 方案选型:先跑通,再往细里抠
量化不是只有一个招数。按“是否需要训练”来划分,常见的有两大类:训练后量化(PTQ,Post-Training Quantization)和量化感知训练(QAT,Quantization-Aware Training)。这两条路的成本、精度和适用场景差别很大,选错会走很多弯路。
2.1 训练后量化:最快的“无痛瘦身”
PTQ 的思路很粗暴:模型训练好之后,喂一小批校准数据,统计每一层激活值的数值范围,然后据此算出缩放系数,把 FP32 权重和激活直接转成 INT8。整个过程不需要反向传播,也不需要重新训练,通常几十分钟就能搞定。
为什么可以这么省事?因为模型的权重已经收敛了,权重的分布是固定的,我们只需要找到合适的映射关系。唯一的麻烦是激活值,它依赖于输入数据,所以必须有校准数据去“预演”前向过程,观察激活的取值范围。校准集选得好不好,直接影响量化精度。
PTQ 适合两类场景:一类是模型比较大,重训成本太高,比如语言模型或者多模态模型,重新训练一轮可能要几周;另一类是项目周期紧,需要快速验证量化方案的效果。我见过不少团队会把 PTQ 作为第一优先级,先看掉点是否可接受,再决定要不要上更重的方案。
PTQ 的局限也很明显:它完全依赖训练好的权重分布,如果前后推理时的输入分布和校准集差太多,量化误差会被放大。另外,当模型对数值误差特别敏感时,PTQ 往往压不住精度,这时候只能靠 QAT。
2.2 量化感知训练:精度不足时的第二张牌
QAT 的核心思路,是在训练过程中就“模拟”量化的效果。具体做法是在网络里插入伪量化节点,前向传播时把权重和激活先量化再反量化,让浮点模型暂时背上一层“噪声”,反向传播时再用直通估计器让梯度绕过量化的不可导部分。
听起来复杂,其实可以打个比方:你训练时让模型提前适应“被人为降低精度”后的状态,这样真正部署到 INT8 环境时,模型不会因为数值变化而措手不及。精度通常能比 PTQ 高很多,尤其在检测、分割这类对细节敏感的任务上。
代价也很直接:你得准备训练数据和完整的训练流程,训练时间变长,调参难度增加。伪量化节点会占用额外显存,训练中断恢复也可能遇到状态不一致的问题。更麻烦的是,QAT 对训练超参数比较敏感,学习率策略稍微不对,精度反而比 PTQ 还差。
2.3 两者怎么选:一张表讲清楚
| 对比维度 | PTQ | QAT |
|---|---|---|
| 是否需要训练数据 | 只需要少量校准数据 | 需要完整训练数据与训练流程 |
| 实施周期 | 几十分钟到几小时 | 几天到几周 |
| 精度表现 | 大模型通常可接受,小模型波动大 | 大多比 PTQ 好,尤其对敏感层 |
| 工程复杂度 | 低,基本不动训练代码 | 高,需要改动训练逻辑 |
| 适用场景 | 快速验证、大模型、迭代频繁 | 精度要求高、小模型、敏感任务 |
我的建议是:不差时间的话,先做 PTQ,量化后测一轮精度。如果掉点在可接受范围内,直接上线;如果掉点明显,再针对性做混合量化或 QAT。跳过 PTQ 直接上 QAT 属于“用力过猛”,很多情况下浪费人力不说,收益还不一定对等。
3. 绕不开的数学:scale、zero point 与三种量化细则
理解了方案,还得把量化本身的数学搞明白。很多人看到公式就头疼,但量化的公式真的不多,核心就两个:量化公式、反量化公式。搞懂它们,你就能看懂所有量化配置的含义。
3.1 量化与反量化公式
设原始浮点值为 r,量化后的整数为 q,缩放系数为 S,零点为 Z。那么:
q = round(r / S) + Z r ≈ (q - Z) * SS 的作用是把浮点范围映射到整数范围。举例来说,某层激活值的范围是 [0.0, 6.0],要量化为无符号 INT8,整数范围是 [0, 255]。那么 S = (6.0 - 0.0) / (255 - 0) ≈ 0.0235,Z = 0。浮点值 3.0 量化后是 round(3.0 / 0.0235) ≈ 128,反量化回来是 128 * 0.0235 ≈ 3.008,误差不到 0.3%。
这里有一个新手常犯的误解:S 不是随便取的,它是由浮点范围与整数范围共同决定的。浮点范围取多大,直接决定了量化的分辨率。范围取得太大,小的浮点值容易被“淹没”在量化噪声里;范围取得太小,超出范围的值会被截断,同样带来误差。找到那个能平衡“截断误差”和“舍入误差”的临界范围,就是量化校准的核心工作。
3.2 对称 vs 非对称量化
对称量化把浮点范围按原点对称扩展,即范围取 [-max(|r_min|, |r_max|), max(|r_min|, |r_max|)],Z 固定为 0。这样实现最简单,反量化时不需要考虑零点偏移,推理引擎在很多硬件上可以直接用更精简的指令去计算。
非对称量化允许浮点范围整体偏移,Z 可以不为 0,能更精细地覆盖非对称分布。激活值经过 ReLU 之后通常都是非负的,最小值是 0,用非对称量化可以把整数刻度全部用在有效范围上,分辨率更高。
| 对比维度 | 对称量化 | 非对称量化 |
|---|---|---|
| 零点 | 固定为 0 | 根据范围计算,可非 0 |
| 实现复杂度 | 低,硬件友好 | 稍高,多一次减法 |
| 适用范围 | 权重(分布接近对称) | 激活(ReLU 后非负分布) |
| 精度表现 | 对权重好 | 对激活好 |
实操里的常见做法是:权重用对称量化,激活用非对称量化。原因很简单,训练好的网络权重正负分布往往比较对称,强行非对称化收益不大;而激活值几乎都是非负的,用对称量化等于白白浪费一半整数刻度,非对称量化更划算。
3.3 逐张量 vs 逐通道 vs 逐组
除了对称与非对称,量化粒度也影响精度。最简单的是 per-tensor,整层所有数据共用一个 S 和 Z。实现最简单,计算开销最小,但一旦该层某个通道的值域特别大,整个层的量化分辨率都会被拉低。
进阶一点是 per-channel,每一个输出通道单独算 S 和 Z。卷积的每个卷积核都可以有自己独立的缩放系数,相当于把“量程”切得更细,精度明显更好。代价是缩放系数变多,部分硬件上实现起来要处理额外数据布局。
再细还有 per-group,把通道再分组,每个组单独量化。这在一些大规模语言模型的权重量化里很常见,因为逐通道对极端离群值依然不够精细。实际选型时,我建议先看硬件支持:如果推理引擎支持逐通道,优先用逐通道;如果不支持,再考虑用更大的校准手段去弥补逐张量的精度损失。
4. 手把手实操:把一个浮点模型压到 INT8
前面讲的是理论,这一章进入真正动手的环节。我会用一套通用的流程来讲,具体接口每个框架不一样,但思路是通用的。整个过程可以拆成四步:准备校准集、校准统计、参数转换、精度回归。
4.1 准备校准集:别在这步省时间
很多人量化翻车,第一步就栽在校准集上。校准集的作用是统计激活值的分布范围,所以它必须能代表线上真实输入。常见误区有三种:只用训练集前几百张图、只挑简单的样本、类别不平衡。
我的建议是,校准集样本数量在 500 到 2000 条之间比较合适,太少统计不准,太多边际收益递减。取样时要覆盖所有类别的典型情况,最好加上线上真实采集的数据。如果业务场景是动态变化的,比如光照、环境噪声差异很大,校准集里要尽量包含这些变化。
这里有个小技巧:如果条件允许,把线上真实请求的输入采样一轮作为校准集。训练集是离线数据,分布和线上往往有偏差,用线上数据校准能让量化模型更贴近实际情况。
4.2 校准与转换的完整流程
下面这段是典型的 PTQ 校准流程伪代码:
# 伪代码:训练后量化校准流程(接口以实际框架为准) def ptq_calibrate(model, calib_loader, quant_config): model.eval() # 1. 注册钩子,记录每个量化算子的输入/输出激活分布 hooks = register_activation_hooks(model, quant_config) # 2. 前向跑一遍校准集,只统计分布,不更新权重 with no_grad(): for batch in calib_loader: _ = model(batch) # 3. 汇总每层激活的 min/max 或直方图 ranges = collect_activation_ranges(hooks) # 4. 根据配置计算 scale 和 zero_point scales, zero_points = compute_quant_params(ranges, quant_config) # 5. 把参数写入量化模型中 apply_quant_params(model, scales, zero_points) return model核心就是“观察分布、确定范围、计算参数”三步。其中第四步的计算方法有多种:最简单的是 min/max,直接用观察到的极值作为范围;更稳一点的是 percentile,把极端的 0.01% 离群点忽略掉;还有基于 KL 散度或 MSE 的算法,会遍历多种截断位置,找让原始分布和量化后分布最接近的那个点。
我个人的习惯是:先用 min/max 跑一版,精度可接受就先用着,毕竟简单可靠;如果掉点明显,改成 percentile 或 MSE 再试。不要一上来就上复杂算法,否则出问题时很难判断是校准算法的问题,还是模型本身的问题。
4.3 精度回归与性能基线
量化完成后,第一件事不是测速度,而是测精度。用和浮点模型完全相同的评测集,跑一遍量化模型的准确率。如果掉点在预定的阈值内(比如 1% 以内),才能进入性能测试环节。
性能测试要注意对照条件一致:同一台设备、同一个输入尺寸、同一套推理框架配置。分别记录浮点模型和量化模型的内存占用、单次推理延迟、吞吐量。我建议多跑几轮取平均值,第一轮预热的数据不能要,因为缓存和指令页还未就绪,结果会偏高。
另一个容易忽略的点是:模型体感变快不等于吞吐变快。有的模型单请求延迟下降了,但服务器并发处理时,量化的缓存优势会被掩盖。所以性能评估应该以端到端压测为准,而不是只看单次推理。
4.4 端侧部署必须注意的算子融合
如果你的目标是移动端或嵌入式设备,还有一个额外重点:算子融合。量化后的 INT8 算子如果还是逐个调用通用算子库,每次运算都可能伴随数据格式转换,反而比浮点更慢。常见做法是把量化反量化操作融合到上下算子中,或者把连续的卷积、批量归一化、激活函数融合成一个算子。
一个典型的融合链是“卷积 + 批量归一化 + ReLU”合成一个算子。批量归一化在训练时是一层独立计算,部署时它的参数可以直接折进卷积权重里,ReLU 则可以被量化节点吸收。融合之后,数据在寄存器里的流转次数减少,INT8 的优势才能真正发挥出来。
所以部署前一定要确认推理引擎对目标算子的融合支持情况。有些算子(比如某些动态形状的算子)不支持 INT8,引擎会把它们保留在 FP32,这种情况会出现“模型变小了,但推理没快多少”的现象,因为计算热点根本没走到 INT8 上去。
5. 实战问题库:掉点、变慢、没效果
这部分是我最想写的。量化方案原理不难,难的是在生产环境里遇到各种莫名其妙的问题时,能有一条清晰的排查思路。下面几个问题是我在实际项目中反复遇到的,每个都附上排查路径。
5.1 精度掉了,先别急着上 QAT
遇到量化后准确率明显下降,第一反应不应该是“我要用 QAT”,而是先定位“哪一层在掉点”。做法是逐层量化测试:只量化第一层,其他层保持浮点,跑一遍精度;再只量化第二层,跑一遍精度。这样扫描一轮,通常能找到那么一两层是“精度黑洞”。
这一两层往往就是敏感层,它们的激活值分布可能极其不均匀,或者包含大量离群值。针对敏感层,有两个选择:一是对该层不做量化,保留 FP32 精度,这就是混合量化;二是改用 per-channel 或更细致的量化粒度。这两种方案都比全模型 QAT 成本低得多。
我见过一个项目,模型量化后准确率掉了 8 个点,逐层测试后发现是某一个特定层的激活值有几个极端大的离群点,把量化范围整体拉大,导致大量正常值被压缩到低分辨率区。后来把校准算法从 min/max 换成 percentile,忽略掉那 0.01% 的离群点,掉点直接回到 1.5% 以内。所以掉点时,先问校准算法,再问量化粒度,最后才考虑训练方案。
5.2 校准集“看起来正常”却掉点
还有一种情况更隐蔽:校准集数量、类别都平衡,跑出来的激活分布也合理,但量化模型在真实数据上就是明显掉点。后来排查发现,是校准集和线上数据的分布有偏差。
具体来说,某个语音唤醒项目,训练数据都是清晰室内环境,线上却经常有嘈杂背景声。校准集里没有包含这些噪声特征,激活分布统计自然偏向“安静场景”。模型量化后对噪声输入特别脆弱。
解决办法有两个层面:最直接的是在部署环节采集线上真实样本,补充进校准集;另一个是把特征分布做一下预处理,比如做标准化、限制动态范围,让模型对输入变化的敏感度降低。这类问题很难从单次评估里发现,我建议量化评估时除了整体准确率,还要按类别、按场景切分统计,看掉点是否集中在某个子集上。
5.3 量化后推理不加速,反而变慢
这大概是让人最沮丧的问题:模型文件小了,精度也没怎么掉,但推理延迟一点没降,有时还变慢。常见原因有以下几种。
第一种,计算热点不在被量化的算子上。比如一个网络里有一个大矩阵乘法,但它没走量化算子,而是走了 FP32 的通用实现;真正占时间的小算子反而被量化了,整体收益自然不明显。排查方法是看性能 profile,识别热点算子。
第二种,算子碎片化导致的数据转换开销。如果量化算子和非量化算子交替出现,引擎在每次边界都要做 FP32 到 INT8 的格式转换,这个转换本身有成本。层数越多,转换开销越明显,甚至覆盖掉量化省下的计算时间。解法是算子融合,前面提到过。
第三种,硬件指令集不支持。部分 CPU 虽然能跑 INT8 指令,但默认调度没开;或者推理引擎没开启对应的指令优化。检查一下部署环境的低精度指令开关,有时候一个配置没打开,量化就白做了。
我把精度和性能问题的排查要点整理成一张速查表:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 精度掉点明显 | 校准算法不合理 | 改用 percentile 或基于分布的校准 |
| 精度掉点集中某类 | 校准集偏差 | 补充线上真实样本,按类别统计 |
| 敏感层导致掉点 | 个别层对误差敏感 | 逐层测试,敏感层混合量化 |
| 激活值有极端离群点 | 范围被拉大 | 截断离群值、放宽范围 |
| 推理不加速 | 热点算子未量化 | 性能 profile 定位热点 |
| 推理变慢 | 算子碎片化 | 做算子融合,减少格式转换 |
| 推理不变 | 引擎配置未开低精度 | 检查低精度指令开关 |
5.4 我踩过的一些坑
再分享几个日常操作层面的经验,都是常规文档不会写的。
第一个,校准集批次大小要固定。同样的数据,用批次 32 和批次 1 统计出来的激活分布是有差别的,因为批量归一化层在统计模式下的行为会受输入批次的统计特征影响。校准和部署时必须用相同的批次大小,否则可能引入偏差。
第二个,千万别把量化模型再次放到训练模式里。量化模型里的伪量化节点,在训练和推理模式下行为完全不同。有些人直接在训练脚本里顺手把量化模型又喂了一轮迭代,结果精度莫名其妙地下降,还以为量化方案有问题。
第三个,多版本模型管理要留痕。量化是一套独立的配置组合:校准集、校准算法、量化粒度、混合量化策略,每一组都可能产生一颗不同的量化模型。我在项目里吃过亏,几周后想复现一个结果,却发现已经不记得当时用的是哪套校准集了。建议每次量化都记录下配置参数和对应的精度、性能数据,这比记代码还重要。
6. 量化往后的扩展思路
量化做完之后,还有几个方向可以继续挖。一个是“量化+剪枝”的组合,先剪掉冗余通道,再量化,两者叠加的内存收益经常能到 10 倍以上。另一个是“量化+蒸馏”,用一个高精度大模型做教师,蒸馏一个量化学生模型,在端侧场景很常见。
我个人的看法是,量化不是一个“做完一次就结束”的工作。当业务数据变化时,校准集的分布也要跟着更新;硬件平台升级时,量化参数可能需要重新调优。把它当成一个持续维护的工程流程,比当成一个一次性任务,踩坑的概率会小很多。
最后再分享一个小习惯:我每次做量化都会先建立一份“精度基线文档”,把浮点模型的各场景准确率、延迟、内存逐项记下来。这样后续不管改了校准集还是换了部署环境,都能快速看出差异出现在哪一环。这个习惯救过我不少次。