1. 为什么需要量化训练
1.1 浮点模型的部署困境
先聊一个很多工程师都撞上过的场景:模型在服务器上各种指标都好,F1、mAP、AUC样样漂亮,一部署到边缘设备或推理卡上,精度掉得让人怀疑人生,轻则掉三五个点,重则直接崩。你翻代码、查优化、看算子实现,折腾几天,最后大概率被一句话点醒:你没做量化训练,或者更准确地说,你没做对量化训练。
Horizon QAT这套方法论,就是专门来解决"模型精度在低精度推理环境下掉点严重"这个问题的。它不是什么神秘的黑科技,核心就一句话:在训练阶段就把量化带来的误差模拟进去,让网络权重和激活在梯度更新过程中主动适配量化噪声,而不是等到训练完、模型上线前再拿一批数据去"校准"。
如果你的业务是边缘推理、DSP/NPU/GPU上的INT8加速,或者任何一个受内存带宽、功耗约束的部署场景,QAT基本是绕不开的关键环节。我见过很多团队在PTQ(训练后量化)上死磕精度,折腾各种数据增强、混合量化、逐层网格搜索,最后精度还是差一点,换到QAT之后,同样结构、同样推理引擎,精度直接回来了。这篇文章就把Horizon QAT的完整做法摊开讲,从原理到配置,从训练到导出,包括我踩过的坑和排查思路,希望能帮你少走点弯路。
1.2 FQAT与PTQ的适用边界
先厘清一个概念:不是所有情况都需要QAT。训练后量化(PTQ)的优势是快,不需要重新训练,只需要一小组校准数据,统计每层的激活分布,确定scale和zero_point就能导出INT8模型。对于精度余量比较大的网络,比如一些大感受野的检测模型或分类模型,PTQ完全够用,效果也还能接受。
但PTQ有几个硬伤:第一,它用前向推理统计到的分布来近似量化误差,对分布比较敏感的层无能为力;第二,激活值的离群点对PTQ影响极大,一个异常大的激活能直接把scale拉偏;第三,很多结构化剪枝或轻量化网络本身余量就小,量化误差叠加后精度损失不可控。这时候就能体现QAT的实用价值,它不需要猜哪些层容易敏感,而是在训练过程中让整个网络自适应去补偿量化带来的分布偏移。
我个人的选型经验是:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 网络精度余量大(如大模型) | PTQ | 快,校准10分钟搞定 |
| 精度余量小、结构紧凑的网络 | QAT | 需要训练期补偿 |
| 极端低比特(如4bit权重) | QAT | PTQ基本稳不住 |
| 有充足训练资源和数据 | QAT | 效果上限更高 |
很多团队在PTQ上耗了几个星期,不如直接上QAT来得干净。当然,QAT也有它自己的坑,而且坑还不少,下面会逐个说。
2. 量化训练的核心机理
2.1 伪量化节点究竟做了什么
QAT的基础是伪量化,也叫模拟量化。它的思路很直接:在网络前向计算时,把浮点的权重和激活先量化成低比特整数,再反量化回浮点,用这个"有损"的数值参与后续计算。注意,训练过程中计算本身还是浮点运算,只是数值已经被量化-反量化过程"污染"过,梯度基于这些污染后的值反向传播。这样学到的权重,天然就适应了量化噪声。
一个标准的对称量化-反量化操作可以写成:
# 对称量化伪代码(简化版) def fake_quantize(x, scale): # 缩放 x_scaled = x / scale # 取整(四舍五入/截断) x_round = torch.round(x_scaled) # 截断到目标位宽范围 x_clamped = torch.clamp(x_round, -128, 127) # INT8 # 反缩放回浮点 return x_clamped * scale实际框架(PyTorch、TF、以及Horizon这类内部工具链)会在此基础上加很多细节:比如非对称量化的zero_point、分通道量化、直方图初始化scale、以及训练过程中scale的更新方式。但理解上面这几行就足够抓住灵魂了:量化误差是前向传播的一部分,网络会学着适应它。
2.2 STE直通估计器的工作原理
这里有个关键问题:round操作对x的导数几乎处处为0,梯度传不回去。如果不能解决这个问题,训练根本跑不起来。所以业界普遍用一个近似梯度的方法,叫STE(Straight Through Estimator,直通估计器)。
STE的处理方式非常粗暴而又优雅:前向走量化函数的完整计算图,但在计算梯度时,直接把量化函数当作恒等映射,让梯度无损穿透过去。
# STE近似梯度示意 class FakeQuantize(torch.autograd.Function): @staticmethod def forward(ctx, x, scale): # 前向:量化-反量化 return quantize_and_dequantize(x, scale) @staticmethod def backward(ctx, grad_output): # 反向:梯度直接穿透 return grad_output, None也就是说,虽然前向计算时数值被"污染"了,但反向传播时梯度假装污染不存在,直接穿过去更新权重。这里面有一个很微妙而重要的点:正是因为梯度没被量化噪声修正,权重在高位宽空间里正常更新,模型才能逐步收敛出一个对量化噪声鲁棒的参数状态。
如果梯度也被模拟量化,训练会很爆炸;如果前向不量化,训练出来的模型部署后又掉精度。STE就是平衡这两者的关键。
2.3 量化参数scale是如何训练出来的
前文提到scale可以是固定的,比如用校准数据统计出来的分位数或最大绝对值。但在QAT里,scale往往是可学习的参数。原因不复杂:固定scale训练,模型确实能适应分布,但如果scale本身也微调,就能进一步压缩量化误差。
以对称量化为例,量化scale初始值一般用输入数据的绝对值最大值除以127(或者用百分位数),后续可以在反传时更新:
# scale梯度更新示意(以最大绝对值估计为例的简化) # 实际推导参考LSQ等论文 scale = scale - lr * grad_scale学习scale时一般会配合梯度缩放的技巧,比如让scale的学习率远小于权重的学习率,避免scale震荡导致数值爆炸。我这边的经验是scale学习率设置为权重学习率的1/10到1/100之间。如果训练中loss出现周期性波动,很可能是scale学习率偏高了。
3. 实操配置与训练流程
3.1 环境与依赖组织
Horizon QAT通常是配合自家推理工具链使用的,但底层的训练逻辑并不绑定硬件,凡是支持了伪量化算子的深度学习框架都能跑。我这里基于PyTorch举例,因为它的算子改写和自定义autograd函数最灵活,排查问题也方便。
环境组装的几个要点:
- PyTorch官方自带了
torch.quantization.FakeQuantize模块,覆盖了基础功能,但它是面向CPU部署场景的,想深度定制需要自己写扩展。 - 如果想深入学习实现细节,建议用原生PyTorch手动插入伪量化节点,而不是直接用现成的
QuantStub/DeQuantStub。手写一遍之后,你对scale更新、通道粒度这些细节理解会深很多。 - 我自己维护了一个版本,是基于社区开源量化训练框架二次封装的,把伪量化节点抽象成了独立模块,方便做模型级替换。
3.2 插入伪量化节点的具体方法
QAT的第一个实操问题:在哪里插伪量化节点?基本遵循以下原则:
- 权重:每个卷积/全连接层的权重,在卷积计算前做一次伪量化。
- 激活:每个激活输出之后、下一层输入之前做一次伪量化。
- 残差连接:如果两个分支汇合,每个分支的分支点都要插,混合方式需要按推理引擎融合规则决定。
以一个ResNet风格的Block为例:
import torch import torch.nn as nn import torch.nn.functional as F class QuantConv2d(nn.Module): def __init__(self, in_channels, out_channels, kernel_size, stride=1, padding=0): super().__init__() self.conv = nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding) self.weight_fake_quant = FakeQuantize() self.act_fake_quant = FakeQuantize() def forward(self, x): # 权重先伪量化,再做卷积 w_q = self.weight_fake_quant(self.conv.weight) out = F.conv2d(x, w_q, self.conv.bias, self.conv.stride, self.conv.padding) return self.act_fake_quant(out)这里要注意几个关键点:
偏置不量化。卷积层的bias通常保留浮点精度,因为反量化后偏置可以以浮点形式在推理引擎中融合,量化它收益极小,反而增加误差。
BN层要特殊处理。在QAT中最常见的坑之一就是BN层的统计量(running_mean和running_var)。伪量化激活是在BN之后还是之前?这个问题直接影响精度。业界主流的做法是:BN的参数折叠进前一层卷积之后再量化。原因是推理引擎通常会把BN融合进卷积权重,如果你在BN后插伪量化,导出的模型和训练时的计算图不一致。所以很多框架都是在卷积+BN融合后,再对融合后的权重做伪量化,激活伪量化插在融合之后的输出上。如果直接用原生PyTorch做QAT,建议把BN层改成在训练时用批量统计而不是running统计,或者干脆在QAT之前把BN融合掉。
3.3 关键超参与训练策略
QAT不是简单地把原网络加几个节点重新训练,它的超参设置和普通训练差距很大。我提供一个可落地的配置基准,基于Common Batch Size 64到128之间:
- 训练轮次:QAT微调阶段,通常不需要从头训练。从预训练浮点模型出发,微调10到20个epoch就足够。这个数字比很多人想象中少得多。因为QAT的底座是一个已经收敛的模型,你只是让它适应量化噪声。跑太多epoch反而有风险,后面会讲。
- 学习率:QAT微调的学习率一般设置为基础训练学习率的0.01到0.1倍。我用过5e-5到1e-4这个区间,效果比较稳。太大会把已经收敛的权重破坏掉,太小则无法有效补偿量化误差。
- 优化器:AdamW是我常用的选择。权重衰减一般保持和普通训练一致,或者适当降低一些。
- Batch Size:QAT对Batch Size相对敏感,因为伪量化节点用到的scale需要反映分布的稳定性。如果batch size太小,统计到的scale抖动大,训练曲线会直立跳下跳上。
- 学习率调度:通常用Cosine退火或者阶段式下降。我个人偏好Cosine退火,配合微调轮次短的特点,收敛很平滑。
- 预热:如果是从浮点预训练直接切换QAT,建议前2-3个epoch用一个较小的学习率做预热,让BN和量化scale适应新权重分布,再进入正式微调。
还有一个容易被忽略的点:图像预处理细节。QAT训练时,数据增强如果和推理部署时不一致,会导致输入分布偏移,量化scale统计不准。我见过一个案例,训练时用了随机颜色抖动,部署时没有,结果第一层激活的量化噪声大到离谱。建议QAT微调阶段弱化甚至关闭掉对颜色分布影响大的增强项,例如色彩抖动类增强,保留几何类增强即可。
3.4 评估与导出流程
QAT训练完成后,不要直接拿训练时的准确率作为部署预期。你有两条路可以走:
离线评估:把训练好的模型导出为浮点权重,然后用推理工具链自带的量化工具做校准和量化,注意此时在校准过程中要把fake_quant的观测值替换成实际推理时用的量化算子。理想情况下,这个校准结果和QAT训练时的伪量化模拟基本一致。
仿真评估:在训练框架里把伪量化节点的量化参数固定,以模拟推理的方式跑一遍完整验证集,得到仿真精度,然后和最终部署后在推理引擎里实测的精度做对比。两者之间若有明显差异,问题大概率出在算子对齐或图优化上,此时应该优先检查卷积算子、残差融合、BN折叠这些环节。
导出时特别注意几个细节:
- 折叠BN:导出前一定要把BN参数融合进卷积,否则部署精度和训练仿真精度会有系统性偏差。
- 固定scale:导出前把scale固定成整数或定点格式,不要让它随推理继续更新。
- 清理伪量化节点:导出给推理引擎的模型里,伪量化节点必须移除,否则多一次量化-反量化操作,在部分硬件上会引起精度损耗或性能下降。
4. 避坑指南与问题排查实录
4.1 精度骤降的排查思路
QAT训练完,精度不升反降,甚至比PTQ还差。这是最打击人的场景。我把它总结成四步排查法:
第一步,查仿真评估的输入一致性。先确认你的验证集图像预处理(缩放、归一化、通道顺序)和推理链完全一致。这个看似基础,却是出错率最高的。尤其当你换了推理框架、或者在新环境里复现老代码时,输入管道的差异是最容易潜伏的。
第二步,查scale质量和量化位宽。打印各层scale的典型值,看是否有特别异常大或异常小的。scale异常大通常意味着权重或激活的分布中有稀疏极大值;scale异常小则可能说明量化位宽没生效,步长过细,而有效动态范围没有利用。
第三步,查权重分布。训练早停,直接把权重分布画出来,和预训练浮点模型的权重分布做对比。如果QAT模型权重分布出现了不该出现的长尾,或者收缩得特别明显,说明训练策略有问题,可能是学习率太大,也可能是scale更新方式不对。
第四步,查算子覆盖。看看你用了哪些结构,是否有推理引擎不支持的算子在QAT时被忽略了。有些框架对不支持的算子在量化时会自动跳过,但这个自动跳过可能导致某些敏感层仍然使用浮点,导出时却被硬量化了,从而掉精度。这时候你要人工指定对这些层做特殊处理,或者回到浮点部署。
4.2 BN层的统计值陷阱
这个问题值得单独拿出来说。QAT训练时,即使我前面提醒过,还是会有不少人在BN处理上翻车。
具体现象是:训练时loss正常收敛,验证精度看起来也没问题,但一导出到推理引擎,精度突然掉了一两个点。排查下来,问题在于BN层的running统计量和当前mini-batch统计量的偏差。
BN在训练时用的是batch内统计;在推理时用的是累计的running统计。QAT训练中,如果你的伪量化激活插在了BN之后,而导出时把BN折叠了,那么scale对应的是BN之后的激活分布,但实际推理时卷积输出的分布和BN后的分布有系统性差异。
我的经验做法是:
- 在QAT微调的最后一个阶段(比如最后的5个epoch),把BN冻结,强制使用running统计,让网络调整到和相关推荐一致的统计模式。
- 这一步只需要很小的学习率,甚至0学习率去做forward统计的稳定化都行。
def freeze_bn(model): for m in model.modules(): if isinstance(m, nn.BatchNorm2d): m.eval() # 强制使用running统计这个方法治好了很多莫名其妙的导出掉点问题。
4.3 训练轮次与过拟合的微妙平衡
前面说过QAT微调轮次通常很短,但很多人还是会忍不住把它当普通训练跑,动辄几十上百个epoch。QAT跑太久会有什么后果?
首先是过拟合量化噪声。你让权重过度适应了伪量化节点引入的特定噪声模式。当模型部署到真实硬件,实际量化实现和训练时的伪量化实现有细微差异时,这些差异就会被放大,表现为部署精度比仿真精度低很多。其次是训练分布和真实分布的偏移。数据增强里细小的随机性会让网络越来越依赖这种随机性来"维持精度",一旦在部署时输入是固定的pipeline,精度就会掉。
一般来说,我个人的建议是:
- 从预训练模型开始,QAT微调10-20个epoch,观察在验证集上的精度变化曲线。
- 一旦精度进入平台期(不再上升且有小幅波动),立刻停止训练,不要等。
- 如果预训练模型和QAT训练是同一套数据,5-10个epoch可能就够了。只有预训练模型在别的数据集上、目标域有明显分布偏移时,才需要适当延长。
4.4 多卡训练和同步BN的问题
QAT训练如果用了多张卡,BN的统计方式会带来额外的不确定因素。标准BN里,统计量是按卡各自算的,卡间runnin统计不一致,会让量化scale在不同卡上产生差异,最后汇总到overall模型时干扰更大。
解决方案有几个:
- 使用同步BN(SyncBN),统计所有卡上的均值和方差,再计算归一化。
- 如果无法用SyncBN,可以把batch size调大,削弱单卡统计抖动。
- 最稳妥的做法,QAT这种小规模微调,用单卡跑反而更可控。数据量不大,训练轮次也短,单卡训练时间完全可以接受,却省掉大量同步和多卡调试成本。
我在实际测试中发现,小batch size的单卡QAT往往比大batch size的多卡QAT更容易达到好的部署精度,核心原因就是BN统计更稳定、scale更新也更平滑。
4.5 硬件推理引擎和训练框架的scale对齐问题
最后一个非常重要但容易被忽略的环节:训练框架里的scale是浮点小数,推理引擎里通常会把scale表示为定点数,常见形式是scale_fp * 2^fixed_point_shift这种变形,需要对齐。
如果不对齐,你训练时学到的scale没有精准传递给推理引擎,精度损失就白做了。检查方法有两种:
- 打印训练框架导出的scale列表,对推理引擎导出的模型做逐层对照,确认每一层scale的相对误差是否在设计范围内。
- 在推理引擎里逐层打印特征图,和训练框架仿真输出的特征图做相似度对比,相似度低于阈值(比如余弦相似度低于0.99)的层需要排查。
这块工作虽然有点枯燥,但却是QAT项目成功与否的最后一道关卡。很多团队把QAT做好了却栽在最后的scale对齐上,真的可惜。
5. 实操经验总结
Horizon QAT这套方法,在我经历过的多个部署项目里基本是"稳定救场"的角色。比起花几周时间在PTQ校准上反复试探,直接用QAT微调,精度舒心得多。但也别把它当银弹,它引入的额外训练成本、超参调优、以及和推理引擎的对齐工作,都需要你提前规划。
我个人在实际操作中体会最深的一点是:QAT训练过程中要始终记得,所有环节(数据处理、量化位置、BN处理、scale对齐)的最终目标都是"训练时的模拟计算图"和"部署时的真实推理图"足够接近。如果你始终用这个标准去审视每一步操作,很多坑其实是能提前避开的。
最后再分享一个小技巧:如果你有条件,在做QAT之前,先用一小部分数据快速做一次PTQ,看看精度损失的主要来源层是哪些,然后用QAT训练时对这些敏感层单独调整量化参数(比如加宽量化位宽、调整scale初始化范围)。这种"局部重点照顾"的思路,在资源紧张或者模型特别大时非常管用。量化训练的世界里,细节决定成败,多做一层对齐检查、多看一眼分布图,往往比盲目调参有效得多。