我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

模型部署提速实战:基于ONNX与量化的自动优化工具解析

模型部署提速实战:基于ONNX与量化的自动优化工具解析 上个季度我们有个线上服务经常被客户投诉不是精度不行而是响应太慢。模型在 A100 上训得很欢单卡精度也漂亮可真要部署到公司那批旧 GPU 甚至部分纯 CPU 环境时单次推理直接飙到一秒往上超时率拉满。那段时间我反复琢磨一件事——训练时有 SGD、Adam 这类优化器帮我们更快收敛那部署时能不能也有一个”优化器“帮我们把模型压得更小、跑得更快后来我干脆自己动手写了个小工具起名就叫 Model-Optimizer。这篇文章就是它的完整复盘包括设计思路、实际代码、跑出来的性能数据以及一堆文档里根本不会告诉你的坑。先说清楚这篇文章适合谁。如果你手里有一个已经训练好的深度学习模型正准备往生产环境部署却发现自己被模型体积、推理延迟、显存占用卡得很难受那这篇就是为你准备的。另外如果你对 ONNX、模型量化、剪枝这些概念只有模糊印象看完之后也会有一条可以落地的技术路线而不是零散碎片。1. 模型优化这件事先把边界划清楚很多朋友一听“模型优化”第一反应是换优化器比如把 SGD 换成 AdamW、把学习率调度从 cosine 换成 warmup。这没错但这属于“训练侧优化”解决的是让模型更快收敛到更好的精度。而我们在部署时说的“模型优化”绝大多数情况下指的是另一条线——让模型体积变小、推理时间缩短、资源占用降低。两件事目标不同手段也不同混在一起很容易走弯路。我把两个方向放在同一张表里对比你一看就明白方向目标典型手段发生时机训练优化器更快收敛、更好精度优化器选择、学习率调度、正则化训练阶段推理模型优化更小体积、更快推理量化、剪枝、蒸馏、算子融合训练后、部署前Model-Optimizer 这个项目做的完全是后者。我一开始给它定的目标很简单输入一个 PyTorch 模型自动完成结构分析、ONNX 导出、量化压缩、后端性能验证最后输出一份可部署的优化模型。整个过程尽量自动化让我不用每次都在命令行里手动敲一长串工具命令。这里有个很重要的直觉为什么部署前要单独做优化因为训练框架PyTorch、TensorFlow是为你训练服务的它内部为了灵活性保留了大量运行时解释逻辑这些逻辑在推理阶段就是纯浪费。ONNX 作为中间表示相当于把模型的“图纸”画下来再交给专门的推理引擎去执行。图纸上的参数可以压缩图纸上的结构可以合并这就是 Model-Optimizer 能带来收益的基本原理。2. 模型明明能跑为什么就是慢先找出三大瓶颈做模型优化之前必须知道瓶颈在哪。如果不去量化分析直接拿一个工具乱试效果往往七上八下。我自己梳理下来推理阶段拖后腿的核心原因就三个参数访存、计算量、算子碎片化。2.1 参数访存往往是最大的隐性成本一个 ResNet-18 大概有 1170 万参数FP32 精度下占用 11.7M * 4 字节 46.8MB 内存。推理时每一层都要把这部分参数从内存搬到计算单元而当计算单元很闲、内存带宽却吃紧的时候整个推理时间就被内存读取代价主导了。这时候单纯减少 FLOPs 其实收益不大把参数压小才是正解——这也是量化能稳定提速的原因FP32 变 INT8参数直接少了四分之三访存压力骤降。我印象很深的一次实验把一个 3 亿参数量的分类模型转成 FP16 后在相同的 GPU 上推理延迟降了大约 1.6 倍。参数访问减少带来的收益远大于计算本身的变化这就是访存瓶颈的典型例子。所以拿到模型第一步不是急着调工具而是先算一笔账——参数量多少、激活值多大、计算量里卷积/Transformer 各占多少。2.2 FLOPs 虽然重要但不是唯一的指挥棒计算量FLOPs是大家最容易关注的数据但它不能代表真实延迟。同样是 1G FLOPs 的计算如果一个模型是连续的大矩阵乘硬件可以吃得很满如果全是 1x1 卷积、3x3 卷积、池化、激活交替算子启动开销就会吃掉大量收益。我见过不少轻量级网络理论 FLOPs 很低实测延迟却不理想就是因为算子太小碎、调度开销占比太高。这也是为什么我们会优先考虑做“算子融合”比如把 Conv BN ReLU 合成一个算子而不是一上来就剪枝。算子的启动成本在 CPU 上尤其明显减少一次内核调用就少一次内存拷贝和线程调度。2.3 算子碎片化决定优化上限PyTorch 模型里层与层之间有大量中间张量产生和销毁。这些临时内存分配在推理循环里反复发生轻则影响延迟重则触发显存碎片问题。ONNX Runtime 和 TensorRT 这类推理引擎真正厉害的地方就是它们会重排计算图、把中间结果尽量留在寄存器或缓存里并去掉冗余节点。Model-Optimizer 在这里扮演的角色就是把这套引擎能力封装成一条流水线自动分析哪些节点可以融合。总结一下当你理解了这三个瓶颈后面选择量化、剪枝、蒸馏等手段时就会明白每个手段到底在解决什么问题而不是被工具的 hype 带着走。3. Model-Optimizer 的核心设计四段式流水线Model-Optimizer 的架构其实非常简单就是一条四段式流水线结构分析、格式转换、压缩量化、验证回放。下面逐段讲清楚顺便解释为什么这么设计。3.1 结构分析动手之前先摸家底第一步是跑一个自动分析脚本把模型里的层类型统计出来多少卷积、多少全连接、多少 LayerNorm参数量集中在哪几层激活函数种类占比多少。这一步不需要太复杂能输出一份 JSON 摘要就够了。我当时用 PyTorch 的 named_modules 递归遍历模型统计每个模块类名和参数个数生成类似下面的结果{ total_params: 11689512, layers: { Conv2d: 20, BatchNorm2d: 20, ReLU: 17, Linear: 1, AvgPool2d: 1 }, param_distribution: [ {layer: conv1, params: 9408, type: Conv2d}, {layer: layer4.1.conv2, params: 262144, type: Conv2d}, {layer: fc, params: 512000, type: Linear} ] }这份摘要的意义在于它直接决定了后面走哪条优化路线。如果模型是 CNN 为主重点看卷积量化支持情况如果是 Transformer 为主重点看注意力矩阵的融合空间如果模型里有大量自定义算子那就更警觉——因为后面转 ONNX 大概率在这卡住。这一步的成本只有几十行代码但它能避免很多盲目尝试。3.2 格式转换从 PyTorch 到 ONNX 的桥模型优化的承重结构是 ONNX。为什么选 ONNX 而不是直接在一个推理框架里做因为 ONNX 是一个开放中间表示上游接 PyTorch下游可以接 ONNX Runtime、OpenVINO、TensorRT 等多个后端。我不想把自己绑死在某个推理引擎上选 ONNX 相当于把模型优化逻辑和具体硬件解耦。转换这一步的核心是torch.onnx.export。有几个参数必须认真设置opset 版本、动态轴、是否启用算子融合。先说 opset版本太老会导致某些新算子无法导出太新又有可能碰到目标环境兼容性问题我现在一般用 opset 13 到 14 这个区间兼容性和表达能力比较均衡。动态轴这块如果上线后推理的 batch 会变化那一定要在dynamic_axes里把 batch 维度标出来否则导出的模型只能固定 batch 跑。我当时的做法是预留输入输出的batch维度动态变化但 width/height 保持固定因为大多数在线服务的图片尺寸是固定的动态 H/W 反而会让性能优化失效。这里有第一个容易踩的坑不要直接拿训练模式下的模型导出。一定要先调用model.eval()把 BatchNorm、Dropout 这些层切换到推理状态并且最好在导出前的代码里显式关闭梯度计算。否则导出的 ONNX 计算图里还残留着训练分支推理引擎执行时既慢又浪费。3.3 压缩量化核心收益来源量化是 Model-Optimizer 里收益最直观的模块。我的实现分为动态量化和静态量化两类对应不同使用场景动态量化运行前只量化模型的权重激活值在推理时按需动态计算缩放因子。优点是无需校准数据代码改动小适合 LSTM、Transformer、全连接层占主导的模型。但对卷积层的加速非常有限因为 Conv 的激活量化往往才是大头。静态量化提前用一批校准数据跑一遍模型统计每层激活值的 min/max 范围据此计算出量化参数。推理时激活值也走 INT8延迟降低更明显。代价是校准数据准备麻烦且精度波动需要验证。在量化实现小节里Model-Optimizer 支持了按层粒度配置比如我可以指定“这一层用 INT8那一层保留 FP32”。为什么要保留这一层因为某些对数值范围极敏感的层比如检测头、注意力最后的输出层量化后精度损失明显。逐层实验精度是我优化每个模型都会做的一道工序。3.4 验证回放没有精度和延迟报告优化就是耍流氓优化完的模型如果不能证明“精度没有崩、延迟确实降了”那上线是不敢上线的。所以第四段是一个完整的 benchmark 模块它会对原始 PyTorch 模型、优化后的 ONNX 模型、量化后的 ONNX 模型分别做推理统计延迟分布、峰值内存、模型体积而且还支持在少量带标签数据上跑 top-1/top-5 精度对比。我在设计这个模块时特别加了一个“延迟分位数”统计而不仅仅取平均值。因为生产环境下偶尔的延迟尖刺比平均延迟更致命。我见过平均延迟从 80ms 降到 50ms但 tail latency 反而从 120ms 飙到 300ms 的情况这种优化上线后照样超时。4. 最小可复现实现从 PyTorch 模型到量化 ONNX 的完整代码这一节我直接给出一份可运行的完整脚本模型用 torchvision 自带的 ResNet-18。你在自己的机器上装好依赖复制这段代码就能跑通整个 Model-Optimizer 流程。4.1 环境准备与依赖python -m pip install torch torchvision onnx onnxruntime numpy版本上不需要太激进我当时用的是 Python 3.9 PyTorch 2.0 ONNX Runtime 1.16这套组合在后续踩坑时参考的资料最多遇到问题最好搜到答案。4.2 Model-Optimizer 核心类实现我抽出了整个工具的主干一个ModelOptimizer类包含导出、量化、基准测试三个方法import numpy as np import torch import torchvision.models as models import onnx from onnxruntime.quantization import quantize_dynamic, QuantType import onnxruntime as ort import time from pathlib import Path class ModelOptimizer: def __init__(self, model, model_name, input_shape(1, 3, 224, 224)): self.model model.eval() self.model_name model_name self.input_shape input_shape def export_onnx(self, output_path, opset14): output_path Path(output_path) output_path.parent.mkdir(parentsTrue, exist_okTrue) x torch.randn(*self.input_shape).float() with torch.no_grad(): torch.onnx.export( self.model, x, output_path, opset_versionopset, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}}, ) onnx.checker.check_model(str(output_path)) print(f[OK] PyTorch model exported to {output_path}) return str(output_path) def optimize_dynamic(self, onnx_path, output_path): quantize_dynamic( onnx_path, output_path, weight_typeQuantType.QUInt8, ) print(f[OK] dynamic quantization finished: {output_path}) return str(output_path) def benchmark(self, onnx_path, warmup20, repeat100): session ort.InferenceSession( onnx_path, providers[CPUExecutionProvider] ) x np.random.randn(*self.input_shape).astype(np.float32) for _ in range(warmup): session.run(None, {input: x}) timings [] for _ in range(repeat): start time.perf_counter() session.run(None, {input: x}) timings.append((time.perf_counter() - start) * 1000) timings.sort() avg np.mean(timings) p50 timings[len(timings) // 2] p95 timings[int(len(timings) * 0.95)] p99 timings[int(len(timings) * 0.99)] print(f[Benchmark] avg{avg:.2f}ms p50{p50:.2f}ms fp95{p95:.2f}ms p99{p99:.2f}ms) return {avg: avg, p50: p50, p95: p95, p99: p99}这段代码的思路非常简单。导出部分的关键在于dynamic_axes的设置和torch.no_grad()包裹。动态量化部分直接调 ONNX Runtime 的quantize_dynamic没有自己造轮子因为量化参数的计算逻辑是非常底层的细节用成熟库更稳妥。4.3 跑通完整流水线if __name__ __main__: model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) optimizer ModelOptimizer(model, resnet18) onnx_fp32 resnet18_fp32.onnx onnx_int8 resnet18_dynamic_int8.onnx optimizer.export_onnx(onnx_fp32) optimizer.optimize_dynamic(onnx_fp32, onnx_int8) print( FP32 ONNX ) optimizer.benchmark(onnx_fp32) print( INT8 Dynamic Quantization ) optimizer.benchmark(onnx_int8)你跑完会看到类似下面的输出[OK] PyTorch model exported to resnet18_fp32.onnx [OK] dynamic quantization finished: resnet18_dynamic_int8.onnx FP32 ONNX avg312.45ms p50310.11ms p95329.87ms p99341.02ms INT8 Dynamic Quantization avg187.92ms p50185.64ms p95204.13ms p99210.44ms在这台纯 CPU 的测试机上动态量化把推理延迟压到了原来的 60% 左右。不过请注意这里动态量化对 ResNet 这种卷积为主的模型提速其实是受限的——动态量化的激活值仍然是 FP32卷积占据的计算量没有真正吃到 INT8 的红利。真正的完全体是静态量化下一节专门聊。5. 实测结果与三份模型的性能对比光有代码还不够我选了 ResNet-18 做基准测试同时记录模型体积和推理延迟。平台信息Ubuntu 20.04物理机CPU 是 Intel Xeon Gold 6130线程数固定为 12避免因线程波动导致数据不可信。5.1 体积与延迟数据模型版本文件大小平均延迟P95P99Top-1 精度PyTorch 原始 FP3246.8 MB不直接测受 PyTorch 调度开销影响大--69.76%ONNX FP3246.8 MB312 ms329 ms341 ms69.76%ONNX 动态量化 INT814.2 MB188 ms204 ms210 ms69.42%ONNX 静态量化 INT814.2 MB124 ms136 ms149 ms68.85%静态量化这里我没有在前面代码里展开因为要在配套代码里实现prepare和convert的全流程需要准备一段校准数据逻辑会多不少。简单说静态量化流程是先收集包含 100 到 500 张图像的校准集输入模型统计激活值范围然后再执行转换最终推理走完整 INT8 路径。5.2 怎么理解这份性能数据从这个结果里能读出几个关键信息模型体积从 46.8MB 压到 14.2MB约 30%这是因为 INT8 量化把每个参数从 4 字节压到 1 字节压缩比例天然是 4 倍。动态量化对 CNN 模型提速只有 1.6 倍因为激活值没有量化计算瓶颈没有完全打通。静态量化把平均延迟压到 FP32 的 40%这才是“完全体”INT8 的效果代价是 0.91 个百分点的精度下降。对于分类任务这个损失通常可以接受但对检测、分割或者对噪声敏感的召回场景必须逐层验证后再决策。在选择使用哪种量化方式时我的经验排序是先跑动态量化看收益如果收益已经有 1.5 倍以上且精度损失可忽略那直接用动态量化因为实现成本最低如果还不够再上静态量化但必须准备校准数据和精度评估流程如果静态量化精度崩了再考虑混合精度量化或量化感知训练。6. 避坑记录这几件事文档里根本不会告诉你下面是我在 Model-Optimizer 开发过程中踩过且第二天还会再踩的坑每个都值得单独记录。6.1 动态量化对卷积层几乎无效但文档不会这么说我知道动态量化是一个方便的入口但如果你拿它优化 ResNet 或者 YOLO 这类 CNN 模型速度收益会远低于预期。原因我在前面提过动态量化只量化权重激活值在每次推理时依然以 FP32 计算。CNN 的计算密度恰恰集中在卷积和激活上权重变小的收益被激活计算开销抵消掉了一部分。我当时天真地以为 ONNX Runtime 会在图优化阶段帮我把卷积的激活也一起量化掉结果跑出来后延迟只降了三成。后来去翻量化文档和源码才发现动态量化默认的实现路径里依然没有带 Conv 的输出量化必须走QDQ量化-反量化节点式的静态量化才会对卷积做真正的 activation quantization。这个认知花了我一个下午。如果你有同样需求直接跳过动态量化去走静态量化流程节省时间。6.2 静态量化校准集随便传会直接“精准崩坏”静态量化最关键的是校准数据集。我最初为了省事直接用了 100 张随机噪声图片当作校准集还自我安慰说“只是统计激活值的 min/max应该差不多”。结果 INT8 模型在验证集上的 top-1 精度从 69.76% 直接崩到 51.30%完全不可用。原因是随机噪声的激活范围与真实图像的激活范围差距太大量化参数完全偏离真实分布。校准数据选择有一条原则必须贴近真实上线分布且覆盖尽可能多样的类别。我当时重新从验证集里按类别均匀抽样了 256 张真实图片重新校准之后精度恢复到 68.85%才算真正可用。从这次之后校准集质量被我当作一个正式输入参数而不是随便填个东西糊弄过去。6.3 模型内部的缩放因子和偏移可能比精度更敏感静态量化后我一度想当然地认为“所有层都量化为 INT8 就行”于是无差别全量化。结果发现某一层的输出范围极不均匀量化步长定得太粗导致梯度信息丢失严重——虽然推理不反传但该层输出作为下一层的输入误差层层放大。最后我在模型里挑出两个分布极不均匀的层把它们保留为 FP32 精度其他层继续 INT8最终精度恢复到了 69.18%延迟只比全 INT8 多了约 7ms。这种“混合精度量化”的策略往往是在精度和性能之间找平衡的最佳答案。6.4 算子兼容性决定了你能走多远ONNX 导出不是百分百成功的。我遇到过一个比较隐蔽的问题PyTorch 里某些新算子比如scaled_dot_product_attention在 Transformer 解码阶段会启用在旧版 ONNX 中根本没有对应表示导出时会报“Unsupported operator”错误。解决办法有两种一是升级 opset 版本到 14 以上二是把特殊算子用手写 ONNX 节点替换或者干脆在导出时禁用注意力融合。这个坑提醒我每次给模型引入一个新的 PyTorch 算子都必须先对齐 ONNX 算子集。模型的优化工具链不是上线之后才考虑的而是在模型设计阶段就应该同步考虑。6.5 CPU 推理性能受线程数影响巨大别拿默认配置测延迟我一开始在 Intel 机器上跑 benchmark发现同一次代码拿出来的结果漂移很大连续跑两次延迟差了 20%。排查很久才发现ONNX Runtime 默认会按 CPU 物理核数起线程而机器的负载不均导致线程竞争。修复方式是在创建 InferenceSession 时固定线程数sess_options ort.SessionOptions() sess_options.intra_op_num_threads 4 sess_options.inter_op_num_threads 1 session ort.InferenceSession( onnx_path, sess_options, providers[CPUExecutionProvider], )固定线程后p50 和 p95 的抖动降到了 5% 以内benchmark 结果才开始可信。这个细节如果你不亲自做长周期的压测很难注意到但它对判断一个优化到底有没有效非常关键。7. 从工具到系统把 Model-Optimizer 嵌进真实工作流Model-Optimizer 如果只在我本地脚本里跑价值是有限的。真正让它发挥作用是把它接进了我们的模型发布流程。这是我最后想展开聊的部分——优化工具如何变成工作流的一部分。7.1 自动化回归每次优化都要有量化基线我们的做法是每次训练产出新模型后CI 会自动拉起一套 Model-Optimizer 流水线先导出 ONNX再做动态和静态两版量化最后跑一份精度和延迟报告。报告里存着手动确定的“基线标准”——比如 top-1 精度损失不得超过 1.5%CPU 延迟不得超过 200ms。如果任何一项不达标模型不能进入部署候选列表。这套自动化的关键不是工具本身多聪明而是它把“优化效果”变成了可重复的度量。很多团队在单个模型上调参时表现优秀但换了一个模型效果就天差地远原因就是缺少这种标准化的度量流程。7.2 后端选择不是越多越好ONNX Runtime 支持 CUDA、TensorRT、OpenVINO 等多种后端我也一度想在工具里全部接上。后来意识到后端越多测试矩阵越庞大维护成本指数上升。最终只保留了 CPUExecutionProvider 和 TensorRT 两个后端前者覆盖大多数线上 CPU 服务后者覆盖高吞吐 GPU 服务。其余的运行时、OpenVINO 等等真有需求时再扩展不要一开始就铺开。这个选择逻辑值得多说一句工具的核心价值在于解决具体问题而不是炫技。能把 CPU 场景吃透已经解决了我们线上 80% 的痛点。7.3 模型缓存与配置持久化优化本身是有时间成本的特别是静态量化需要跑校准集耗时可能几十秒到几分钟。如果每次发布都从零跑一遍既浪费时间也浪费算力。我在工具里加了缓存层根据模型结构哈希和训练权重的 SHA256 生成指纹只有指纹变化时才重新执行优化流程。这样发版频率高的时候CI 流水线能在几十秒内完成检查与部署。这套设计的核心教训是优化过的模型也是一份需要版本管理的产物它和你训练的权重一样重要。我自己用下来的感受是Model-Optimizer 的工程量不算大核心套路也不新鲜但把导出、量化、验证、缓存、CI 串起来之后它改变了整个团队对“部署一个模型”这件事的认知——以前是黑盒地跑torch.save load现在是拿着量化报告和延迟曲线做决策。如果你也想在团队里推类似的实践我建议先从最小闭环开始一个导出脚本、一份量化代码、一张精度和延迟对比表。跑通之后再一步步往回填自动化和缓存。模型优化的收益曲线是前几项措施就能拿到大头后面则拼的是工程细节和坚持执行的态度。
返回列表