在模型量化这条路上,我一直觉得最难的不是理解“什么是INT8”,而是把训练框架、量化算法和部署工具链揉在一起跑通。Horizon平台的QAT(量化感知训练)方案里,fx_mode.py就是这样一个关键入口文件,它负责把PyTorch的浮点模型转换成带伪量化节点的QAT模型,再导出给工具链做后续编译。这篇文章我从源码工作流程出发,把fx_mode.py的完整运行逻辑、关键参数和踩坑经验整理出来,适合正在调模型量化精度、或者第一次接触Horizon QAT流程的工程师参考。
1. fx_mode.py在整个QAT流程里的定位与设计思路
1.1 一条完整的QAT流水线长什么样
先把我理解的完整流程画个脑图式的描述,因为我平时调模型,习惯先看全局再看局部。Horizon的量化部署流程大致分四个阶段:浮点模型准备、QAT模型构建、量化感知训练、模型导出与编译。fx_mode.py负责的是第二阶段的核心操作。
第一阶段我们拿到一个训练好的浮点模型,通常是PyTorch的nn.Module,跑通了前向推理,精度也符合预期。第二阶段要把这个模型“改造”成可以做量化感知训练的模型,具体来说就是插入伪量化节点(FakeQuantize),让模型在前向和反向过程中模拟量化误差。fx_mode.py就是干这件事的。
第三阶段是在改造后的模型上继续精调,通常用很小的学习率训练几个epoch,让模型权重和激活值适应量化带来的扰动。第四阶段把训练好的QAT模型导出成工具链要求的格式,Horizon这边一般会转成量化后的ONNX,然后交给编译工具生成BPU可执行的指令。
fx_mode.py放在第二阶段,它的核心输出就是一个配置好量化参数的QAT模型,以及后续导出需要的一些元信息。文件名叫fx_mode,说明它走的是PyTorch FX这套图模式量化的技术路线,用符号执行把模型变成一张静态图,再在图上插入量化相关节点。
1.2 为什么Horizon选择FX模式而不是其他方案
用PyTorch做量化,方案其实不少。最老的Eager Mode量化是把量化逻辑写在每个模块内部,改动侵入性强,而且对不支持的算子很头疼。FX模式的核心优势是借用torch.fx的Symbolic Trace,把模型“翻译”成一张GraphModule,量化的插入、融合、转换都在这张图上完成,可以批量处理,也能做图级优化。
还有一条路线是TorchScript,但TorchScript对动态控制流的支持虽然不错,做量化插桩时灵活性不如FX。Horizon的模型编译工具链其实自己有一层图优化能力,但源头上需要一个结构清晰、算子边界明确的量化模型,FX模式可以很好地满足这个前提。
具体到fx_mode.py,它的设计思路很明显:不要用户手改模型,不要逐个模块去包一层QuantWrapper,而是给定一个浮点模型和一套量化配置,自动完成trace、插桩、融合、校验的一整套动作。
2. 工作流程分步拆解:从浮点模型到QAT模型
2.1 第一步:Symbolic Trace与模型结构解析
fx_mode.py启动后,第一个动作是对传入的模型做torch.fx.symbolic_trace。这一步的目标是把PyTorch的动态执行逻辑转换成一张静态的计算图。我一开始用这玩意时有个误区,以为它就是跑一遍前向然后记录算子,实际上它是用一个Proxy对象把输入包起来,然后模拟执行整个模型,把每个算子调用都记录成图中的节点。
这个机制要求模型本身能被“符号执行”,也就是说模型里不能有依赖真实数据值才能走通的控制流,比如if tensor.sum() > 0这种就没法trace,因为Proxy对象不支持比较后再决定分支。如果一个模型trace失败,报错信息一般是symbolic_trace returned non-GraphModule或者直接抛异常,这时最先要检查的就是模型里有没有这种数据依赖的分支。
trace成功后会得到一个torch.fx.GraphModule,它的graph属性里保存了所有节点信息。节点类型包括placeholder(输入)、call_module(子模块调用)、call_function(函数调用)、call_method(方法调用)和output。fx_mode.py接下来就是遍历这些节点,逐一判断每个节点对应的算子支不支持量化,然后把不支持的算子标注出来。
2.2 第二步:量化配置匹配与伪量化节点插入
图分析完成后,真正核心的动作是插入伪量化节点。伪量化节点的作用是在前向时把浮点张量量化再反量化回来,模拟INT8计算带来的精度损失,同时在反向时传递近似的梯度。这样训练出来的模型,权重分布已经“适应”了量化误差。
fx_mode.py里插入节点的逻辑依赖一套量化配置模板。对卷积、线性这类算子,插入的是权重和激活的伪量化节点;对ReLU这类激活函数,通常不单独插入,而是融合到前一个算子里面去。融合是FX量化里一个关键优化,它把Conv2d + ReLU变成Conv2d + ReLU的融合模式,实际推理时激活不做单独的量化往返,降低误差和计算量。
判断一个算子是否插入量化节点,底层有一个backend_config字典,里面记录了Horizon平台BPU各算子的量化支持情况。如果是第一次接触这个文件,碰到自己模型里有工具链不支持的算子,比如某些自定义的torch.where组合,fx_mode.py会抛出一个包含算子类型的告警,同时建议将对应部分放进QuantStub/DeQuantStub范围外,或者用torch.fx的subgraph重写机制换掉。
2.3 第三步:QAT模型校验与导出信息收集
模型完成伪量化节点插入后,fx_mode.py还会做一次校验。这个校验我理解是分两层:一层是结构层的,确认每个伪量化节点的输入输出张量维度在对齐后没有缺失;另一层是语义层的,它会在一个虚拟的输入上执行一遍前向,确认图形化后的模型能跑通,输出的shape和原始模型完全一致。
校验通过后,fx_mode.py会把模型实例和量化参数打包返回。量化参数包括每个量化层的scale和zero_point的初始值,这些初始值不是训练出来的,而是通过校准得到的。校准的细节在下一章节讲,这里只提一点:fx_mode.py返回的模型,量化参数是“待校准”状态,如果你直接拿去训练,效果会很差,必须先跑校准或者直接在训练过程中让模型自己适配。
导出信息收集这部分容易被忽略,但其实很关键。Horizon工具链后续需要知道模型的输入名、输出名、每个算子的量化阈值范围、每个张量的数据布局(比如NHWC还是NCHW),这些信息有一部分是fx_mode.py在构建QAT模型时就已经绑定到节点属性里的。
3. 实操要点:跑通fx_mode.py的关键配置与参数
3.1 环境依赖与文件入口
先说说怎么跑起来这个文件。我用的环境是PyTorch 1.10以上的版本,因为torch.fx在那时候已经比较稳定。Horizon QAT工具包安装后会提供一个名为horizon_quant的Python包,fx_mode.py就在这个包内部,一般不需要用户直接调用,而是通过一个更高层的API入口触发,比如prepare_qat_fx。
调用方式大概是:
import torch from horizon_quant import prepare_qat_fx model = MyModel() model.load_state_dict(torch.load("float_model.pth")) model.eval() qat_model = prepare_qat_fx( model=model, qconfig_dict={ "": torch.quantization.get_default_qat_qconfig("fbgemm"), }, example_inputs=torch.randn(1, 3, 224, 224), )这段代码会在fx_mode.py内部完成trace、插桩、返回一个可以训练的QAT模型。注意example_inputs的shape必须和真实推理时一致,因为它决定了placeholder节点的shape,后续插入伪量化节点时也依赖这个shape做scale初始化。
3.2 模型结构上的一些硬性要求
fx_mode.py对输入模型的结构有要求,这些要求一开始看文档时没有太在意,直到踩了坑才老实遵守。
第一,模型顶层必须是nn.Module,不能是nn.Sequential的裸实例,最好也别在forward里做太多tensor切片和拼接。倒不是说FX不支持这些,而是你让fx_mode.py分析时,它会尝试把切片、拼接也纳入量化图,但这些算子在BPU上可能只支持部分数据布局,很容易在后续导出时报错。
第二,模型中不要包含dropout这类训练态和推理态行为不一致的模块,QAT训练时虽然有dropout,但如果fx_mode.py在trace阶段把dropout记录成普通节点,后面校准和导出时会有隐患。建议训练和推理前临时切换成eval模式。
第三,激活函数尽量用torch.nn.ReLU这类模块形式,而不要用torch.relu这种函数形式。因为模块形式可以被FX识别成call_module节点,在算子融合阶段可以被当成一个独立的子图节点处理,函数形式虽然也能trace,但融合逻辑处理起来的灵活度差很多。
3.3 量化配置参数对照与推荐值
fx_mode.py的量化行为很大程度上由qconfig_dict控制。很多工程师第一次拿到这个文件,不知道这里面的参数应该怎么设置,我整理了一份自己反复调整过的对照表,基于常见的图像分类模型和检测模型,给一个起跳点。
| 参数项 | 推荐值 | 说明 |
|---|---|---|
activationobserver | MovingAverageMinMaxObserver | 记录激活值的动态范围,采用滑动平均策略,对连续帧输入比较友好 |
weightobserver | MovingAveragePerChannelMinMaxObserver | 权重量化按通道粒度做,能显著降低通道间数值范围差异导致的误差 |
quant_min/quant_max | 0/255 | 对应无符号INT8激活量化,若模型激活存在负值则改成-128/127 |
dtype | torch.qint8或torch.quint8 | 取决于算子类型,卷积通常使用qint8 |
qscheme | torch.per_tensor_affine或torch.per_channel_symmetric | 激活一般用per-tensor,权重建议per-channel |
reduce_range | False | 通常不需要收窄量化范围 |
这些参数不是拍脑袋定的。以权重量化为例,如果不做per-channel,只用per-tensor,那么同一个卷积核里不同输出通道的权重值范围差别一大,量化步长就被拉大,整体精度掉得很快。我第一次用默认的per-tensor配置去量化一个检测模型,掉点了接近三个点,后来改成per-channel才拉回来。
激活值的observer也很讲究。模型在训练前段激活值范围波动很大,如果用固定的MinMaxObserver,很容易被离群点带偏,滑动平均策略会把这些噪声平滑掉。训练后期为了获得更精确的scale,也可以切换到HistogramObserver,按百分位数截断离群值,不过这个u要配合校准数据集来用。
4. 踩坑实录:fx_mode.py使用中的高频问题与排查方法
4.1 冷启动时量化误差偏大
跑完校准和训练之后,模型的精度还是比浮点低不少,这是最常见的问题。我的排查思路分三步。
先看校准数据量是不是太少。校准数据集应该覆盖真实推理时的数据分布,少于500张图基本不可靠。fx_mode.py里如果只喂了100张图去统计激活值范围,scale经常被低估,导致量化后数值溢出,推理精度崩坏。我一般用验证集里随机采样的1000张图做校准,如果验证集本身不够,就用训练集里分层抽样补齐。
再看权重量化粒度,确认是否用了per-channel。如果用的是per-tensor,建议立刻改掉。这个在qconfig_dict里可以直接指定,不需要改fx_mode.py源码。
最后看模型里有没有对量化极不友好的层,比如某些深层特征图的数值范围横跨好几个数量级,导致无论怎么调observer,量化误差都很大。这种时候就需要做敏感层分析,把每个层单独保留浮点精度,其余层强制量化,跑一遍测试集看哪几层对量化最敏感,然后针对性地在fx_mode.py返回的模型中做混合精度配置。
4.2 敏感层定位与混合精度策略
敏感层分析我用的方法很简单,但很有效。先用fx_mode.py导出一个全量化的QAT模型,然后写一个遍历脚本,对每个量化层做“关掉量化”操作,即保留该层浮点权重和浮点激活,其余层保持量化,测试精度变化。精度掉得最多的层就是敏感层。
实际操作时,如果敏感层数量不多,比如只有两三个,我会直接把这几个层从量化名单里摘除,用浮点计算,效果很理想。如果敏感层很多,说明模型本身的结构和量化方案不匹配,这时候不能只靠混合精度救,建议考虑重新设计模型,把那些数值范围异常大的层拆成多个小层,或者引入额外的归一化。
Horizon平台在导出混合精度模型时,允许每个算子设置独立的数据类型,fx_mode.py初始会返回一个全INT8配置的模板,用户可以在这份模板上改写,把特定节点从qint8改成float32。
4.3 BatchNorm融合导致的精度波动
还有一个很多人都会踩的坑,就是BatchNorm在量化模型里的处理方式。在浮点模型里,BatchNorm是一个独立的层,但在量化推理时,BatchNorm必须融合到前面的卷积层里,否则量化后会多一次额外的张量缩放,精度和性能都会受影响。
fx_mode.py在trace阶段会自动识别卷积后接BatchNorm的结构,并执行融合操作。这个融合需要在模型处于eval模式下进行,因为在训练模式下BatchNorm使用batch内的统计量,融合后就变成了推理模式下使用的running_mean和running_var。如果在调用prepare_qat_fx时模型还停留在train()状态,融合就可能失败,或者用一个错误的统计量去融合,导致量化模型精度异常。
融合完成后还有一个细节:BatchNorm的running_var和running_mean会被冻结在融合后的卷积权重里,所以精确的统计量就显得格外重要。如果模型是从一个半途保存的checkpoint加载的,running_mean和running_var可能还没有完全收敛,量化训练前建议先正常训练几个epoch,让BN统计量稳定下来再做QAT。
另外一个常见的问题出现在多卡训练或大Batch训练时。BatchNorm的统计量在不同卡上各自计算,最后同步时统计量会被拉平均,这在浮点训练中问题不大,但在QAT里,统计量直接参与量化阈值计算,统计不稳就会引起量化误差。我的经验是,QAT阶段要么不切换BN到同步模式,要么把BN层全部换成nn.Identity并使用前置的GroupNorm,不过这是比较大的改动,建议在模型设计阶段就考虑好。
4.4 自定义算子报错与图改写
如果用到了一个不在backend_config字典里的自定义算子,fx_mode.py会报类似“Unsupported operator”的异常。我第一次遇到时很懵,明明模型在GPU上跑得很好,怎么到了量化阶段就说不支持。
解决办法有两类。一类是把自定义算子的计算逻辑拆解成几个基础算子,比如把自定义的fancy_pooling改写成avg_pool2d配合乘加操作。另一类是用torch.fx提供的GraphModule改写接口,在图中把自定义算子替换成一个等价的基础算子子图。
如果替换不了,还有一个更省事的做法:把自定义算子的输入和输出分别用QuantStub和DeQuantStub包起来,让它在浮点精度下计算,不进量化图。这样做的代价是该算子前后需要各做一次量化和反量化,会有一点额外开销,但在模型主体结构合理、只有少数几个特殊层的情况下完全可接受。
5. 用fx_mode.py时我常用的辅助工具与调参技巧
5.1 可视化量化配置的调试脚本
fx_mode.py输出的是一个GraphModule,它的内部结构可以通过qat_model.graph.print_tabular()打印出来。我把每个量化节点的信息,包括scale、zero_point、量化范围和输入输出节点名,整理成一个表格,方便检查哪个层的量化配置异常。
for node in qat_model.graph.nodes: if node.op == "call_module": mod = qat_model.get_submodule(node.target) if hasattr(mod, "scale"): print(f"Node: {node.name}, scale: {mod.scale}, zero_point: {mod.zero_point}")这个脚本很实用,尤其在排查“为什么这个层量化后数值全是0”这种问题时,能直接定位到scale是否被初始化成了极小值。
5.2 观察激活值分布的技巧
调量化模型时,激活值的分布直接决定了量化阈值的合理性。我在fx_mode.py插入伪量化节点前后,分别统计了每层激活值的min、max、均值和中位数,做成分布图,这样能一眼看出哪些层的数值范围是病态的。
一个典型例子是某些模型的倒数第二层是全连接层前的一层特征图,数值集中在负数区域,如果激活observer配置的是无符号量化,那负半轴的信息全部被截断,精度不可能不掉。这个问题的解决办法是把该层的激活量化类型改成有符号(qint8),或者在网络设计阶段在特征图后面接一个clamp操作,让数值归到非负区间。
5.3 批量跑实验时参数自动搜索
QAT调参里,observer的类型、量化粒度、校准数据量、训练轮数,这些参数互相影响。手动调参效率太低,我写了一个简单的参数搜索脚本,将fx_mode.py包装成一个函数,支持输入不同qconfig_dict组合,自动跑校准和验证,输出精度对比矩阵。
configs = [ {"activation": "moving_avg", "weight": "per_channel"}, {"activation": "histogram", "weight": "per_channel"}, {"activation": "moving_avg", "weight": "per_tensor"}, ] for cfg in configs: qat_model = run_fx_qat(cfg) acc = evaluate(qat_model) print(f"{cfg['activation']} + {cfg['weight']} => {acc:.4f}")这种自动搜索方式帮我省了大量时间,也让我对每类模型的量化敏感度有了更直观的感觉。如果团队有条件,还可以把这套东西接上超参搜索平台,跑一次完整的参数扫描。
6. 再补充两个容易忽略的细节
6.1 输入数据的预处理与量化边界
在fx_mode.py里跑QAT之前,记得确认模型输入数据的预处理是固定的。Normalization的均值、方差在训练和量化时不能换,哪怕换了0.01的差值,对量化阈值都会产生影响。有些检测模型在预处理里包含一个自定义的letterbox操作,这个操作如果处理的是动态shape输入,就可能在trace阶段报错;解决办法是固定输入分辨率,或者把letterbox挪到模型外面。
6.2 量化训练的学习率设置
QAT不是重新训练,只是精调。学习率通常是浮点训练最后阶段的十分之一到二十分之一,比如浮点模型最后用了1e-4,那QAT我一般用1e-5到5e-6。学习率太大会让权重分布发生较大改动,破坏之前训练好的特征表达,反而导致精度下降。
还有一个经验值:QAT的epoch数不用多,2到3个epoch足矣,除非模型原本的精度就比较差,需要更长的时间把浮点权重“调到量化友好的位置”。训练过程中可以观察伪量化节点的scale变化,如果稳定迭代几百步后scale还在剧烈波动,说明学习率还是太大,需要再调低。
在我实际跑过的几个模型里,只要数据分布没有明显问题、敏感层做了合理的混合精度支持、BN统计量是稳定的,量化后的精度通常能控制在浮点模型的0.5%以内。fx_mode.py并不是一个黑盒工具,把它背后每个节点的意义搞清楚,排查问题时就能少走很多弯路。