拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

模型优化实战:从FP32到INT8,边缘部署提速四倍

模型优化实战:从FP32到INT8,边缘部署提速四倍 模型优化这件事我这两年没少折腾。最近一条业务链路里训练好的检测模型在GPU上跑得还行但一到边缘设备上帧率直接跌到个位数客户反馈“卡到没法用”。前前后后调了两周把 Model-Optimizer 这套思路完整走了一遍——导出、图优化、INT8量化、推理引擎调优最终帧率翻了快四倍显存占用降了六成精度只掉了不到1个点。这篇文章就是把这条链路上的做法、选型逻辑和踩坑记录整理出来写给正在被“模型太大、推理太慢、上线太贵”折磨的同学参考。不管你是刚接触部署的新手还是已经在做线上优化的老手里面提到的几个排查思路应该都能帮上忙。1. 先搞清楚Model-Optimizer 到底在优化什么1.1 三个指标一次说清很多人一提到模型优化第一反应就是“把模型变小”这其实是个挺片面的理解。大小只是表象我们真正要优化的是在精度不掉点太多的前提下把延迟、吞吐、显存/内存占用这三项拉到一个能接受的范围。用一个不太严谨但好记的说法速度代表用户体验吞吐代表服务能力显存代表运行成本精度则是这条优化路径上不能突破的底线。以我这次边缘部署为例原始FP32模型在设备上单帧推理耗时280ms这已经明显超出实时性要求了。而优化之后单帧耗时降到75ms左右吞吐从3.5 FPS提到13 FPS。这里的收益不是来自某一个操作而是整条Pipeline各环节的累积ONNX图优化带来约15%的收益FP16转INT8量化带来约55%的收益推理引擎的线程与内存配置又额外补了一点。所以我的经验是不要把 Model-Optimizer 想成一个命令、一个脚本它是一条流水线每个环节都在往最终指标里贡献一点点。1.2 训练态、推理态、压缩态别拿一套思路硬套优化目标不同手段也完全不同。最常见的情况是大家只关注推理态也就是模型上线之后的加速。但实际做的时候你会发现训练态的优化同样会影响最终部署质量比如在训练时就加入量化感知的训练策略QAT后面做INT8量化时精度损失会小很多。我习惯把优化分成三个形态去理解。训练态优化关注的是训练吞吐、显存占用和收敛速度常见手段包括混合精度训练、梯度累积、分布式数据并行。推理态优化关注的是单次前向推理的延迟和吞吐常见手段包括图优化、算子融合、低精度推理、推理引擎选型。压缩态优化关注的是模型本身的体积和结构常见手段包括剪枝、蒸馏、矩阵分解。这三个形态不是互斥的生产环境里往往要组合使用。比如我的边缘项目就同时用了推理态和压缩态的手段训练态只是做了Precision的调整没有做显存优化。这里有个容易忽略的问题不要只看理论FLOPs或参数量来判断模型快不快。我见过一个参数量很小的模型因为网络结构里全是动态分支和reshape操作实际推理延迟反而比一个参数量大两倍的模型还高。原因是推理引擎对动态shape的优化非常有限一旦遇到结构不规则的图很多fusion pass都会失效。所以优化之前最好先用profile工具跑一遍真实数据看看时间到底花在哪里而不是凭感觉去压参数量。2. 主流优化手段量化、剪枝、蒸馏怎么选2.1 量化最常用也最见效如果要我在所有优化手段里选一个“性价比之王”一定是量化。它的核心逻辑很简单把FP32浮点数表示的权重和激活值映射到更低的精度表示上比如FP16、INT8甚至INT4。比如一个权重值是0.8732在INT8量化后可能就变成一个近似整数128推理时用整数乘法代替浮点乘法。硬件对整数运算的吞吐通常远超浮点运算尤其是GPU的Tensor Core和很多边缘NPU对INT8都有专门加速单元。量化主要分两种PTQ训练后量化和QAT量化感知训练。PTQ的做法是拿一批代表性数据喂给模型统计各层激活值的分布据此算出量化参数scale和zero-point。优点是快几乎不用改训练逻辑缺点是在极小模型、特别敏感的任务上掉点可能比较明显。QAT则是在训练过程中就“模拟”量化误差让模型自己适应低精度表达精度通常更好但要改训练流程还要重新训一轮成本高不少。我的经验是90%的场景下先用PTQ如果精度掉得不多就不要再折腾QAT了。如果PTQ真的掉点超过2%而业务无法接受再考虑QAT或者混合精度的方案。还有个容易被忽略的细节是校准数据的选择。校准数据不是随便拿一批就行它要尽量覆盖模型在真实场景中会遇到的分布。用我的检测模型举例如果业务场景里有白天和夜间的图像校准集里就必须两个时段都有否则夜间图像在量化后精度会明显劣化。我踩过一次这个坑只拿了白天数据做校准上线后夜间场景误检率飙了三倍。2.2 剪枝和蒸馏什么时候值得上剪枝和蒸馏比量化复杂但属于“模型结构层面的瘦身”。剪枝分结构化剪枝和非结构化剪枝。非结构化剪枝是把权重矩阵里接近0的值置为0产生稀疏矩阵理论上参数和计算量都下降了但实际硬件如果不支持稀疏加速收益基本为零。结构化剪枝则按channel或filter维度整体删掉和硬件衔接得好效果直接但需要重新微调恢复精度。蒸馏是我个人很喜欢但很少第一个使用的手段。它的思路很简单用一个强大的大模型Teacher教一个小的模型Student输出Student不光学硬标签还要拟合Teacher的软输出。蒸馏在NLP里的效果尤其明显比如BERT蒸馏出TinyBERT参数少了一个量级精度只掉几个点。我过去在一个人脸识别项目里用蒸馏把特征提取网络的耗时压了一半精度还保持在可接受范围。但真到了选型的时候我的建议是遵循“从简到繁”的顺序。第一步先做ONNX导出和图优化这一步几乎是零成本的第二步上FP16或INT8量化收益很大成本很低这两步做完如果精度不达标或者性能还差一截再考虑剪枝、蒸馏甚至重设计网络结构。一上来就搞蒸馏可能费半天劲最后发现瓶颈根本不在模型结构而在数据预处理或推理引擎配置上。3. 实操一条可复现的模型优化Pipeline3.1 手动环境清单先把底子打好正式开始之前先把环境列清楚。我这次的目标平台是带TensorRT的Jetson设备开发机上装的是CUDA 11.8Python 3.9。以下是我用来跑完整条优化链路的组件列表版本号都标注好了方便你直接照参考。组件版本用途PyTorch1.13.1cu117训练与导出onnx1.14.0模型读取与结构检查onnxoptimizer0.3.12图优化passesonnxruntime-gpu1.16.0推理引擎与量化工具TensorRT8.6.1最终推理引擎Jetson上numpy / opencv-python最新稳定版数据预处理与校准集读取注意onnxruntime的GPU版本和CUDA版本必须严格对应否则装完import就会报DLL加载错误。这个坑是社区里被问得最多的一个。如果对版本对应关系拿不准建议直接看onnxruntime官方发布说明里的CUDA兼容表。3.2 第一步先做ONNX导出与图优化不管是PyTorch还是TensorFlow训练出来的模型在做量化或上推理引擎之前建议先统一导出成ONNX格式。ONNX的作用相当于中间语言后续可以自由选择ONNX Runtime、TensorRT或OpenVINO做推理加速。PyTorch端导出我的检测模型时需要明确指定opset版本和动态轴。代码大概是这样的import torch model.eval() dummy_input torch.randn(1, 3, 640, 640).cuda() torch.onnx.export( model, dummy_input, model.onnx, opset_version17, do_constant_foldingTrue, input_names[input], output_names[output], dynamic_axes{ input: {0: batch_size}, output: {0: batch_size}, } )这个示例里的 dynamic_axes 指向batch维度是为了后续推理时可以灵活调整batch大小。如果模型本身对输入尺寸有固定要求就不要把H、W设为动态否则后续图优化时很多pass会因为动态shape而失效。拿到ONNX后第一步是跑一遍图优化。onnxoptimizer提供很多pass比较常用的有去掉无意义的Identity节点、把Conv和BN融合、把常量折叠成Initializer。代码如下import onnx from onnxoptimizer import optimize model onnx.load(model.onnx) passes [fuse_bn_into_conv, eliminate_deadend, extract_constant_to_initializer, eliminate_identity] optimized_model optimize(model, passes) onnx.save(optimized_model, model_opt.onnx)这段操作几乎零风险不会改变数值结果只会让图更干净。拿我手上的模型来说优化前ONNX大小78MB优化后72MB推理延迟降了大约13%。图优化的逻辑很像写代码时做Dead Code Eliminination和常量折叠本质是让编译器拿到更规整的中间表示。3.3 第二步INT8静态量化实操这是整个Pipeline里收益最大也是坑最多的一步。我用的是onnxruntime的静态量化接口。静态量化的意思是在转换之前就要准备好校准数据用校准数据统计出每层激活值的分布从而确定scale和zero-point。相比动态量化每次推理时在线统计静态量化因为提前算好了参数推理时开销更小更适合生产环境。校准数据集的构建非常关键。我的建议是取200到500张真实业务数据做相同的预处理后封装成一个dataloader。下面是一个简单的示例import cv2 import numpy as np import torch class CalibDataLoader: def __init__(self, image_paths, batch_size8): self.image_paths image_paths self.batch_size batch_size def __len__(self): return int(np.ceil(len(self.image_paths) / self.batch_size)) def __iter__(self): for i in range(0, len(self.image_paths), self.batch_size): batch_paths self.image_paths[i:i self.batch_size] batch [] for path in batch_paths: img cv2.imread(path) img cv2.resize(img, (640, 640)) img img[:, :, ::-1] # BGR - RGB img img.astype(np.float32) / 255.0 batch.append(img) yield np.stack(batch).astype(np.float32)然后调用quantize_static接口from onnxruntime.quantization import quantize_static, QuantType, CalibrationMethod calib_dataloader CalibDataLoader(calib_image_paths, batch_size8) quantize_static( model_inputmodel_opt.onnx, model_outputmodel_int8.onnx, calibration_data_readercalib_dataloader, quant_formatQuantType.QOperator, per_channelTrue, weight_typeQuantType.QInt8, activation_typeQuantType.QInt8, calibrate_methodCalibrationMethod.MinMax, extra_options{ActivationSymmetric: True} )这里有几个参数值得展开说说。per_channelTrue按输出channel分别计算权重的scale相比per-tensor精度通常更好代价是略微增加计算和存储。卷积层的权重在per-channel下量化误差会小很多。MinMax校准法直接用激活值的min/max作为量化范围简单但容易受离群点影响。更稳妥的是Percentile或KL散度校准比如CalibrationMethod.KLD。如果你的数据里有极端亮暗分布建议优先试KLD。ActivationSymmetricTrue激活值用对称量化这样zero-point固定为0推理实现更简单、性能更好但如果激活值分布明显偏向一侧会有一定精度损失。量化完的模型我建议不要直接上线先做一轮全量评测集的精度对比。如果相对FP32掉点在可接受范围内我一般设1%为阈值就继续如果掉点超了就需要检查校准集是否合理或者对敏感层做混合精度。3.4 第三步跑通基准用数据说话优化做没做好不能凭感觉必须用统一的Benchmark流程来验证。我常用的方法是固定输入尺寸、固定推理线程数、多次运行取平均值或P99值。以下是优化前后的对比表是我那次部署的真实数据仅供参考配置单帧耗时(ms)显存占用(MB)相对精度原始FP32 ONNX280830100%FP16 ONNX Runtime16052099.7%INT8量化 ONNX Runtime7836099.2%INT8 TensorRT(最终上线)7234099.1%Benchmark时有个很容易被忽略的细节warmup。推理引擎在处理前几十次调用时会做内存分配、线程池初始化、算子选择等准备动作如果直接计时会把很多初始化开销算进去导致数据虚高。正确的做法是先用虚拟输入跑50次左右的warmup再进入正式计时循环取多个batch的平均值或P99。我还会把输入数据的范围控制在和真实数据一致避免因为输入全零或随机噪声导致某些算子走了快速路径。4. 常见问题与排查我踩过的坑4.1 精度掉了3%以上先别怪量化INT8量化之后精度掉点是最常见的反馈。但有一个很低级的坑模型导出时的输出格式变了导致测试代码拿到的结果和原模型对不上。比如原模型输出的是(x1, y1, x2, y2, score, class)的二维数组导出ONNX时如果不小心多套了一层Softmax或Sigmoid测试端再跟着处理一次精度自然掉得离谱。所以我排查精度问题的第一件事不是怀疑量化而是对比FP32 ONNX的输出格式和原始PyTorch模型是不是完全一致。另一个定位掉点的方法是“分层排查”。onnxruntime的量化接口支持把模型的某些层排除在量化之外。我的做法是先全模型INT8量化如果精度不达标就保留前10%的层为FP16跑一轮看看如果还不行再逐步放开更多层。这样能快速定位到最敏感的层一般是检测头、分类头等输出层或者是含有大数值动态范围的特征提取层。如果定位到某些层就是掉点根源可选的方案有几个一是对这些层做混合精度二是把校准方法从MinMax换成KLD三是干脆上QAT做几轮微调。按成本排序我是建议用过采样、调校准法覆盖前两步之后再考虑QAT。4.2 算子不支持的那一堆报错换了推理引擎之后遇见最多的是“Unsupported Operator”或者“Unsupported ONNX Opset”之类的报错。ONNX是一个中间层不同推理引擎对算子的支持范围并不完全相同。比如TensorRT对某些自定义ROI算子就支持得不好OpenVINO早期对Transformer结构里的一些动态算子支持也有限。我的排查思路基本是三步走。第一步去官方文档确认当前引擎版本支持的算子列表确认报错的算子在不在里面。第二步查看ONNX导出时的opset版本如果选得太新引擎还没跟上就会报兼容错误这时把opset_version往回调一两个版本。第三步如果某个节点确实不被支持就要考虑改图把不支持的一个算子拆成几个支持的算子或者反向操作合并相邻节点让结构更规整。这里推荐用netron查看ONNX图结构可视化之后能直观看到问题节点到底在网络的哪一层、连接着哪些张量。这个环节非常考验细节。我有个项目因为用了PyTorch 2.0里新增的一个fusion算子导出ONNX后TensorRT报错排查了半天最后靠把torch.onnx.export里的opset从17改到16就解决了。所以拿到一套环境之后建议第一时间把版本组合固定下来写进项目文档别让队友或未来的自己踩同一个坑。4.3 动态shape是性能杀手很多人的模型在导出时就不幸地把所有维度都设成了动态。理论上动态shape很灵活但推理引擎面对动态shape时非常痛苦很多算子融合无法进行图优化会失效甚至每次推理的耗时都不稳定。我的建议是能用固定shape绝不用动态shape尤其是部署场景。如果确实需要多尺寸输入比较好的做法是固定几个档位比如640x640、960x960通过预处理把输入缩放到最接近的档位而不是让模型随便接受任意尺寸。Batch size问题也是一样。在线服务场景我推荐固定batch1多用并发实例的水平扩展来提升吞吐而不是在一个实例里调大batch。调大batch的代价是显存占用大幅上升延迟也会相应增加对很多场景来说性价比不高。只有在离线批量推理的场景比如一次要处理几千张图片才考虑加大batch并配合TensorRT的NMS优化。5. 收尾关于 Model-Optimizer 的个人体会踩过几次坑之后我对优化这件事的态度发生了挺大变化。以前总想着一步到位找到一个“神级工具”把所有问题解决。现在我的习惯是先把整条链路完整跑通用Benchmark量化每一段的收益再决定下一步往哪投入。优先级上我永远是先图优化和量化再考虑剪枝蒸馏。原因很简单前两者成本低、收益大、风险小后两者要动模型结构得重新训练工作量和不确定性都大得多。最后再分享一个小技巧。保存优化后的模型时一定要把原始模型、导出脚本、优化参数、校准集路径这些信息一起打包归档。我吃过一次大亏三个月后模型v2上线发现业务指标不对怎么都复现不出当初的精度和性能最后排查下来是因为当初跑量化的校准集没有保存重新采样的数据分布和原来不一样了。这类“生产环境里的元数据问题”比模型本身的问题难查得多。把环境和数据当作代码一样管理才是 Model-Optimizer 这条流水线长期稳定运转的真正保障。
返回列表