做模型优化之前我一直以为“优化”就是调参、换 loss、加正则这些训练阶段的动作。直到有一次模型在训练集上漂亮得不行到了部署端却因为体积和延迟被业务方直接打回我才意识到训练和部署之间隔着的不是代码是两套完全不同的评价体系。后来我花了大半年时间搭了一个叫 Model-Optimizer 的小工具专门解决“拿到一个训练好的权重之后怎么把它压到能上线”的问题。这篇文章就把我踩过的坑、验证过的方法、以及工程化的取舍原原本本写出来。1. Model-Optimizer 只处理“拿到权重之后”的事定位与边界1.1 训练阶段优化和部署阶段优化明明是两个问题很多人会把模型优化理解成一件事其实它是两个完全不同的阶段。训练阶段的优化目标是让 loss 降得更低、指标刷得更高手段包括改网络结构、调学习率、做数据增强、加正则项而部署阶段的优化目标是在尽量不损失精度的前提下把推理速度提上去、显存压下来、体积缩小。这两个目标很多时候是互相拉扯的训练时你觉得某个模块加了能涨两个点部署时却发现这个模块让推理时间翻倍。Model-Optimizer 的定位非常明确它不碰训练过程只接收已经训练好的权重文件然后输出一个同样可用、但更小更快的模型。这样做的原因很实际——我接触过的很多项目里训练代码和部署代码根本不在一个团队手里你拿不到完整的数据管道更不可能让人家为了部署重训一遍模型。所以工具必须设计成对训练脚本零依赖给我一个 checkpoint我就能干活。1.2 我只碰权重、不碰训练脚本的设计理念这个设计理念最初被同事吐槽过说你光有权重没有训练细节怎么做量化、怎么做蒸馏但事实证明这种“黑盒优化”反而逼出了工具的通用性。权重文件里能读到的信息其实够用网络结构、每一层的参数分布、BN 层的均值和方差这些都是优化的关键依据。更重要的是只面向权重可以让优化流程标准化。Model-Optimizer 的输入是一份模型文件加一份小规模校准数据集输出是优化后的模型和一份优化报告。我不需要知道你的模型是在 PyTorch 还是 Paddle 里训的也不需要关心你用了什么花哨的 loss。这种黑盒思路最大的好处是边界清晰不会陷入“帮你炼丹”的泥潭也方便后来接进自动化的模型发布流水线。1.3 量化、剪枝、蒸馏、融合四把剪刀的分工模型压缩领域常用手段就那几样Model-Optimizer 把它们组织成了一条流水线。量化是把 Float32 权重变成 Int8 甚至更低精度直接砍掉显存占用和访存带宽剪枝是删除不重要的通道或结构让计算量直接降下来蒸馏是用一个大模型当老师教一个小模型把精度追回来算子融合则是把多个相邻操作合并成一个比如把 Conv 后面的 BatchNorm 融进前面的卷积计算里。四者分工不同但很多人忽略了它们的先后顺序。量化处理的是数值精度剪枝处理的是结构大小蒸馏处理的是精度补偿融合处理的是计算图冗余。如果顺序不对会出现量化把剪枝后的模型打崩、或者融合后又重新剪出无效结构这种尴尬情况。Model-Optimizer 里我固定了“先融合、再剪枝、然后量化、最后蒸馏补精度”的顺序实测下来最稳。2. 压缩管线从零搭建四类操作怎么串起来不打架2.1 量化校准先从真实部署场景里拿 200 张图量化这块最容易犯的错误是随手拿训练集的几百张图做校准。训练集里的数据分布和部署现场的真实输入往往差得很远尤其在光照、噪声、拍摄角度这些维度上。我见过一个分类模型用训练集校准做 Int8 量化精度损失不到 0.3%换成现场采集的影像做校准同一套量化参数直接掉了 2 个百分点。Model-Optimizer 里的量化流程分三步。第一步是准备校准数据集我建议至少取 200 张能代表线上分布的样本不需要标注但要尽量覆盖边界情况第二步是收集每一层激活值的统计信息分别记录 Min、Max还有直方图分布第三步才是根据这些统计信息计算量化参数。下面这段代码是 PTQ 校准的一个核心片段思路是用 DataLoader 跑若干 batch收集激活分布import torch from torch.quantization import observer def collect_activation_stats(model, calib_loader, num_batches20): model.eval() stats {} def hook_fn(module, input, output): if isinstance(output, torch.Tensor): # 记录激活张量的全局极值后续可以用直方图替代 stats[module] (output.min().item(), output.max().item()) hooks [] for name, module in model.named_modules(): if isinstance(module, torch.nn.ReLU) or isinstance(module, torch.nn.Conv2d): hooks.append(module.register_forward_hook(hook_fn)) with torch.no_grad(): for i, data in enumerate(calib_loader): if i num_batches: break model(data) for h in hooks: h.remove() return stats收集到分布之后还要注意一个细节per-tensor和per-channel的量化粒度差异很大。像卷积权重用per-channel量化精度通常比per-tensor高一截代价是实现复杂度稍微增加。而激活值因为逐层计算的原因通常走per-tensor加asymmetric非对称量化就够用。2.2 敏感层定位量化后精度崩了多数不是全局问题很多人在做完整模型量化之后发现精度崩了第一反应是怀疑量化算法本身不行或者急忙换 QAT 重训。但实际上绝大多数情况是模型里有少数“量化敏感层”只要这几层保持较高精度整个模型的精度就还在可控范围内。敏感层通常出现在输出层附近或者某些 BN 层尺度异常卷积后面。Model-Optimizer 里我实现了一个自动敏感层定位工具先把所有层都量化成 Int8然后逐层或逐块替换回 FP16/FP32重新跑一遍验证集观察精度回升幅度。精度回升最多的那几层就是需要“特殊照顾”的敏感层。“逐层验证”听起来简单实际跑起来很花时间。所以我在工程上做了一层优化先按模块分组比如把 Backbone 的一组残差块作为一个整体先按块级别做敏感性扫描锁定敏感块之后再进到块内部做细粒度定位。这样扫描轮数从几十轮降到了个位数而且定位精度足够用。2.3 结构化剪枝按通道剪而不是按稀疏点剪剪枝分为非结构化剪枝和结构化剪枝两者的区别要掰开揉碎讲清楚。非结构化剪枝是把权重矩阵里绝对值接近零的元素直接置零形成稀疏矩阵这种方式压缩率很高但绝大多数推理框架和硬件并不买账因为稀疏矩阵的存储和计算需要专门支持GPU 上的加速收益往往要到很高的稀疏度才显现。结构化剪枝不一样它直接剪掉整个卷积核或整个通道比如一个 64 通道的卷积层剪成 48 通道输出张量本身就变小了下一层的输入也变小这种剪枝在任何框架里都能直接变现。所以我坚持让 Model-Optimizer 只做结构化剪枝。判断一个通道是否重要常见做法是看它对应卷积核的 L2 范数。范数小说明这个通道学到的特征能量低删掉它影响相对较小。但这里有个容易被忽略的坑如果前一层的后面接了 BN通道的重要性必须结合 BN 的 scale 一起看不然你会把一些范数小但 scale 很大的通道误删掉。剪完之后还有一个“收尾动作”把剪掉的通道从后续所有依赖层里同步移除。动手改过模型的同学都懂这个传播过程最烦人因为在 ResNet 这类带 shortcut 的结构里通道数变了会影响相加操作的对齐。Model-Optimizer 里我用 PyTorch FX 把模型转换成 Graph 之后再剪同步更新所有相关节点大大减少了手工改模型的痛苦。下面给你看一段用 FX 做结构化剪枝的示意代码关键点在于利用module.named_modules找到 Conv2d然后用torch.nn.utils.prune做重参数化import torch.nn.utils.prune as prune def structured_prune_conv(conv_module, amount0.3): # 按输出通道计算 L2 范数作为重要度分数 l2_norm conv_module.weight.detach().abs().pow(2).sum(dim(1, 2, 3)) k int(conv_module.out_channels * amount) prune.ln_structured(conv_module, nameweight, amountamount, n2, dim0) # 返回被保留的通道索引供后续层传播 keep_idx torch.where(l2_norm l2_norm.topk(conv_module.out_channels - k).values.min())[0] return keep_idx.tolist()2.4 蒸馏软标签温度怎么定直接看实验曲线蒸馏在 Model-Optimizer 里的作用是给剪枝和量化后的模型做“精度回血”。它的思路是拿原始高精度模型当老师让压缩后的小模型同时学习真实标签和老师模型输出的软标签。软标签的好处在于它携带了类别之间的相似度信息比如一张图在模型眼里更像“猫”还是更像“狗”这些信息对提升小模型的泛化能力很有帮助。蒸馏的关键参数是温度 T。温度越高软标签的分布越平滑类别间的精细差异被放大温度太低软标签退化成接近 one-hot蒸馏的作用就淡了。我做过一组对比同一个剪枝后模型T1 时精度最多恢复 0.8%T3 时恢复 2.1%T7 时反而掉到只恢复 1.2%。这说明温度太高会把软标签变成噪声。我的建议是先用 T3 起步观察验证集损失的变化再在 2~6 之间做一轮小范围搜索别迷信某个固定值。蒸馏的训练方式也影响结果。student 模型不要从头训很多工具默认从随机初始化开始蒸馏这是错误示范。Model-Optimizer 的做法是先加载剪枝量化后的权重当成初始权重再去跟着 teacher 蒸馏。这样做既保留了大模型原有的知识又能把失真修正回来。2.5 算子融合先融合再校准还是先校准再融合算子融合的经典案例是 ConvBN。训练时 BN 对卷积输出做归一化但推理时 BN 本质上是一组线性变换完全可以折叠到卷积的 weight 和 bias 里。融合之后少了一次张量读写对推理速度的提升非常直接尤其在小模型上更加明显。关于融合和校准的顺序我专门做过 AB 对比。如果先做 Int8 校准再做融合那么校准统计到的激活分布里混合了融合前的中间状态等真正融合后计算路径变了统计信息就不完全匹配如果先融合再校准统计的就是部署时真实会走的计算路径量化精度更稳。所以 Model-Optimizer 的管线顺序是“先融合后校准最后量化”。融合动作本身也要小心 weight 的数值范围变化。ConvBN 融合后卷积的 weight 数值会变大一些因为要乘上 BN 的 scale。这个在 Float32 下完全没问题但如果你在融合之后做 Int8 量化超大绝对值的权重会挤压有效量化区间反而带来精度损失。处理办法是把融合放在张量极值统计之前让量化参数对融合后的真实分布负责。3. 实测记录体积、延迟、精度之间的真实拉扯3.1 一个检测模型的压缩全程37MB 压到 9.8MB项目里有一个基于 MobileNet 骨干的目标检测模型训练后权重 37MB在边缘设备上的推理延迟是 46ms显存占用大概 280MB。业务方的要求是模型小于 15MB延迟低于 25ms显存尽量别超过 120MB同时 mAP 下降控制在 0.03 以内。我把这个模型丢进 Model-Optimizer 走了一遍全流程结果如下表阶段模型体积推理延迟显存占用mAP原始模型37MB46ms280MB0.312算子融合后36MB39ms245MB0.311结构化剪枝 30% 后21MB28ms165MB0.298Int8 量化后9.8MB21ms97MB0.287知识蒸馏回血后9.8MB21ms97MB0.301最直观的感受是剪枝把体量真正降下来量化把最后一段性能挖出来蒸馏把精度损失补回来三者各干各的活。很多人以为量化是把 37MB 变成个位数兆的主角但这组数据说明真正的大头是剪枝。融合在这里贡献不大但它保证了后续量化时模型的数值路径是最简的属于看不见的助攻。3.2 量化精度崩盘的完整排查链路另外有一次没那么顺利。一个文本分类模型全模型做 Int8 量化之后准确率从 91.2% 直接掉到 82.7%。这个跌幅明显不正常光调量化参数已经救不回来。我决定用 Model-Optimizer 的敏感层扫描功能做一次系统排查整个过程可以作为“量化崩盘排查”的参考模板。第一步先给每个 Transformer 层单独做量化模拟其他层保持 Float32跑一遍验证集。结果显示前两层几乎不掉点中段有 0.5% 左右的损失但最后一层的全连接层单独量化后掉了 4.8%。第二步把最后一层单独改回 FP16做混合精度量化准确率恢复到了 90.5%。第三步再去看前一层的输出分布发现最后一层的输入张量里存在明显的离群点最大值是第三四分位数的 40 倍左右这正是经典的量化敏感场景。最后我给的方案是把最后一层和它前面的 GELU 层一起保留 FP16同时给 GELU 输出按绝对值截断把极端离群点削掉一部分。经过这两步准确率恢复到了 91.0%模型体积比全 Int8 多了不到 0.5%完全可以接受。这个案例说明Int8 量化不是一刀切真正的功力体现在“哪些层该让一步”的判断上。Model-Optimizer 之所以把敏感层定位做成内置能力就是因为在真实项目里这种问题根本没法靠猜。3.3 性能测量口径FPS、P99 延迟和内存峰值都要看模型优化做完性能验收又是一道坎。我在 Model-Optimizer 的测试工具里固定了三个指标平均延迟、P99 延迟和内存峰值。只看平均延迟会骗人因为很多设备上模型首次推理会触发显存分配和算子编译平均延迟会被少部分慢请求拉低或者掩盖抖动。标准做法是先跑 50 轮 warmup让框架完成预热然后再记录后面 200 轮的数据。warmup 这一步很多人忽略但它的影响不是一般的大。同一个优化后的模型不 warmup 时测出来的延迟可能比 warmup 后高一倍尤其在使用 TensorRT 或 GPU 图模式部署时。测试环境也要固定。CPU 需要锁核数、关超线程GPU 需要固定频率不然对比结果没有意义。Model-Optimizer 里我写了一个环境检测脚本把 CPU 型号、GPU 型号、驱动版本、PyTorch 版本和实测算子 backend 全部记录到报告里方便复盘时对齐环境。4. 工具工程化最容易低估的三个决策4.1 为什么中间表达选了 FX Graph 和 ONNX写 Model-Optimizer 之前我一直在用逐模块替换的方式做模型修改比如手动遍历named_modules()替换 Conv2d。这种办法在简单模型上能跑但遇到多分支结构和动态控制流就头大。后来我切到 PyTorch 的 FX把 Python 模型转成一张图所有算子变成图上的节点做融合和剪枝就不再是改层级结构而是改图结构。选用 ONNX 作为导出端的中间表达是为了让优化结果不被 PyTorch 运行时绑死。ONNX 本身是一个计算图描述格式很多推理引擎都能加载它TensorRT、ONNX Runtime、OpenVINO 都有对应的解析入口。所以 Model-Optimizer 的输出不只是一个 PyTorch 模型文件还有一个配套的 ONNX 文件和部署 JSON后者描述了优化过的算子、保留精度的层、以及剪枝索引。选型时也要注意 FX 的限制。它对动态控制流支持不好遇到 Python 层的if或者动态 shape 会抛异常。但只要模型不涉及这些FX 的性价比非常高。如果你手里的模型结构很特殊FX 走不通就只能在 ONNX 层面做 graph transform那是另一套工作量。4.2 可配置、可复现、报错不甩锅工具化之后最重要的不是功能多而是可配置和可复现。我给 Model-Optimizer 设计了一个 YAML 配置文件所有优化选项都在里面包括剪枝比例、量化方式、温度参数、保留敏感层的名单。这样不同模型、不同硬件只需要切换配置不需要改代码。经常有人问为什么不用参数列表直接传因为优化一个模型往往要跑十几个实验不同实验之间的参数差异通常只有一两项配置文件可以整体留存方便比较。我在配置里还加了seed字段固定随机种子后剪枝时通道选择、蒸馏时的数据打乱都能复现。这看起来是个小事实际做实验时帮了大忙。报错处理我也花了心思。Model-Optimizer 在跑优化流程时分为多个阶段任何阶段失败都会在报告里标明失败发生在哪个模块、输入什么配置、权重版本是什么而不会给一个笼统的 stack trace。只有让错误信息精确到“哪一步错了”工具才真正适合团队协作而不是只自己一个人用得顺手。4.3 后续想做的自动压缩方案搜索和硬件适配Model-Optimizer 目前的一个明显短板是压缩方案的取舍得靠人工经验判断。比如某个模型是剪 20% 再量化还是直接量化不剪枝不同模型结论不一样。人工试一遍确实能出结果但遇到新模型、新硬件效率很低。所以下一步我想在工具里加入一个自动搜索模块给定一个目标延迟或目标体积自动尝试剪枝比例、量化粒度、敏感层保留策略的不同组合然后用一组评测指标打分找出当前硬件上最合适的压缩配置。这个思路跟超参搜索类似只不过搜索空间是压缩策略。硬件适配也是必须做的。同样一个 Int8 模型在 GPU 上走 TensorRT和在一台只支持 Int8 的 FPGA/NPU 设备上能利用的算力完全不同。Model-Optimizer 应当在配置里感知部署硬件的能力比如是否支持混合精度算子、是否对某些算子做了特殊加速再决定优化策略。这一步短期不一定能覆盖很多设备但至少要在工具里预留好接口。4.4 跑完这些优化流程后我更愿意强调的几件事用 Model-Optimizer 做了大半年优化我最大的感受是模型压缩不是纯粹的数学题也不是纯粹的工程题而是两者的黏合。算法上要为每层参数的分布做分析工程上又要为不同推理引擎留好后路。只重理论的人容易把压缩方案设计得“好看但跑不快”只重工程的人又容易陷入用上线测方案的死循环里。最好的状态是先有一点量化感知、有一点图优化意识再用工具把重复劳动省下来。另一个体会是永远保留一条“回滚路径”。优化前的原始模型一定要留好优化后的模型也要记录每个阶段的产物包括融合后版本、剪枝后版本、量化后版本。这样上线后如果发现某个指标异常可以一步步缩小问题范围。Model-Optimizer 的所有中间产物都会带上阶段标记和 Git 哈希确保任何时候都能回溯到对应代码版本。对我个人来说这项目最大的收获不是模型变小了、延迟变快了而是让我接受了一个道理好的工具不是替你做决定而是把决定背后的代价展示得足够清楚。减掉哪些通道、量化哪些层、保留多少精度这些选择从来不是白拿的每一次体积下降都对应着一部分精度妥协。工具的价值就是把这种妥协从“看不见的黑盒”变成“看得见的报告”。这个思路以后做任何涉及资源取舍的工具我都觉得适用。