模型优化这件事,很多人第一反应是"调参"——学习率、batch size、权重衰减,翻来覆去地试。但真正在生产环境里跑过推理服务的人都知道,模型能不能上线,往往不取决于你训练得多好,而取决于它在目标硬件上跑得多快、占多少显存、精度掉没掉。Model-Optimizer 这个方向之所以值得单独拿出来聊,就是因为它解决的正是"训练完之后怎么办"这一段最容易被忽视、却最影响落地效果的问题。
我自己在几个实际项目里做过量化、剪枝、算子融合这些优化工作,踩过的坑不算少。有些是工具链本身的限制,有些是对精度损失估计不足,还有些纯粹是没搞清楚优化目标和部署目标之间的对应关系。这篇文章我会把 Model-Optimizer 涉及的核心技术点拆开讲,包括它到底优化什么、每种优化手段的底层逻辑、实际操作中怎么选型、以及那些文档里不会写的经验教训。不管你是刚接触模型部署的新手,还是已经做过几轮优化的老手,应该都能从中找到对自己有用的部分。
1. Model-Optimizer 到底在优化什么
1.1 从训练完成到上线服务之间的空白地带
一个模型训练完了,loss 曲线很漂亮,验证集指标也达标,然后呢?直接拿去做推理服务,大概率会遇到几个问题:延迟太高、显存不够、吞吐上不去。训练框架关心的是梯度能不能正确回传、参数能不能收敛,它并不关心推理时算子怎么调度、内存怎么复用。这两者之间的 gap,就是 Model-Optimizer 要填的。
具体来说,Model-Optimizer 的工作范围通常包括几个层面。最上层是计算图层面的优化,比如算子融合、常量折叠、死代码消除。中间层是数值精度层面的优化,比如量化、混合精度。最底层是内存和调度层面的优化,比如内存池化、kernel 自动调优。这三个层面不是孤立的,很多时候需要配合使用才能达到最佳效果。
我见过不少团队的做法是:训练用一套代码,部署用另一套代码,中间靠一个转换脚本硬接。这种做法在模型结构简单的时候还能凑合,一旦遇到动态控制流、自定义算子、或者复杂的后处理逻辑,转换脚本就会变得极其脆弱。Model-Optimizer 的思路是把这个转换过程系统化、可配置化,让你能针对不同的部署目标做不同的优化组合。
1.2 优化目标之间的三角权衡
做模型优化,本质上是在三个维度之间找平衡:精度、速度、资源占用。这三者构成一个不可能三角——你不可能同时让三者都达到最优。
举个具体的例子。假设你有一个 BERT-base 模型,原始 FP32 精度下在某个 GPU 上推理延迟是 20ms,显存占用 400MB。如果你做 INT8 量化,延迟可能降到 8ms,显存降到 120MB,但精度可能会掉 0.5 到 2 个百分点。如果你做结构化剪枝,把注意力头数减少一半,延迟可能降到 12ms,显存降到 250MB,精度掉得更多,可能 3 到 5 个百分点。如果你什么都不做,精度最高,但延迟和显存都下不来。
所以 Model-Optimizer 的核心价值不是"让模型变快",而是"让你能可控地、可预测地在三角之间做取舍"。可控意味着你知道每种优化手段会带来多少精度损失、多少速度提升;可预测意味着你可以在优化之前就估算出大致的效果,而不是优化完了才发现精度崩了。
提示:在做任何优化之前,先明确你的瓶颈到底在哪里。是延迟敏感还是吞吐敏感?是显存受限还是算力受限?不同的瓶颈对应完全不同的优化策略。延迟敏感的场景,量化通常比剪枝更有效;吞吐敏感的场景,算子融合和 batch 调度更关键。
1.3 为什么不能只靠"换更快的硬件"来解决
有人可能会说,模型慢就换更好的 GPU 呗,费那劲优化干嘛。这话在个人项目里可能成立,但在生产环境里往往行不通。原因有几个:成本、供应链、以及边缘部署的硬约束。
成本方面,高端 GPU 的价格可能是中端的好几倍,但性能提升未必有那么多。对于大规模部署来说,单卡成本乘以卡的数量,差距会非常惊人。供应链方面,特定型号的硬件不一定随时能买到,优化过的模型可以在更多型号上运行,降低对特定硬件的依赖。边缘部署就更不用说了,手机、嵌入式设备、IoT 终端,算力和内存都是硬约束,不优化根本跑不起来。
而且从工程角度看,优化模型带来的收益是"乘法效应"。你优化一次,所有部署实例都受益。换硬件带来的收益是"加法效应",你得把每个实例都换掉才能看到效果。在大规模场景下,前者的投入产出比通常更高。
2. 量化:最常用但也最容易翻车的优化手段
2.1 从 FP32 到 INT8 到底发生了什么
量化的本质是用更少的比特位来表示数值。FP32 用 32 位表示一个浮点数,INT8 用 8 位表示一个整数。从信息量上看,INT8 能表示的数值范围从 FP32 的约 3.4e38 降到了 127,精度从约 7 位有效数字降到了整数精度。但神经网络有个特点:它对数值的精确度其实没那么敏感,对数值的相对大小关系更敏感。这就是量化能 work 的根本原因。
具体做量化的时候,核心是找到一个映射关系,把 FP32 的数值范围映射到 INT8 的 [-128, 127] 区间。最常见的是线性量化:
scale = (max_fp32 - min_fp32) / (max_int8 - min_int8) zero_point = round(-min_fp32 / scale) + min_int8 quantized = round(fp32_value / scale) + zero_point这里的scale和zero_point就是量化的关键参数。scale决定了量化的粒度,zero_point决定了零点对齐方式。对于对称量化,zero_point固定为 0,适合权重这种分布比较对称的数据;对于非对称量化,zero_point可以调整,适合激活值这种分布可能偏移的数据。
实际做的时候,权重的量化通常用对称量化,因为权重分布一般以 0 为中心。激活值的量化用非对称量化更合适,因为 ReLU 之后的激活值都是非负的,分布明显偏移。这个细节很多教程不会讲,但实际做的时候如果搞反了,精度损失会明显增大。
2.2 训练后量化与量化感知训练的选择逻辑
量化分两条路线:训练后量化(PTQ)和量化感知训练(QAT)。PTQ 是拿训练好的 FP32 模型直接量化,不需要重新训练;QAT 是在训练过程中模拟量化误差,让模型学会适应量化。
PTQ 的优点是快,通常几分钟到几小时就能完成,不需要标注数据,不需要重新训练。缺点是精度损失可能比较大,尤其是对于小模型或者对量化敏感的模型。QAT 的优点是精度保持得好,通常能做到和 FP32 几乎无差别。缺点是需要重新训练,需要标注数据,训练时间可能和原始训练差不多。
怎么选?我的经验是:先试 PTQ,如果精度损失在可接受范围内(比如掉点不超过 1%),就直接用 PTQ。如果 PTQ 掉点太多,再考虑 QAT。但 QAT 也不是万能的,有些模型结构本身就不适合量化,比如包含大量小数值运算的模型,QAT 也救不回来。
还有一个折中方案叫部分量化,只量化对精度不敏感的部分,比如只量化权重不量化激活值,或者只量化某些层。这种方案在 Model-Optimizer 里通常可以通过配置来实现,灵活性比全量化或全不量化高很多。
2.3 量化实操中的精度校准技巧
做 PTQ 的时候,最关键的一步是校准(calibration)。校准的目的是用一批代表性数据跑一遍模型,统计每层激活值的分布,从而确定量化的scale和zero_point。校准数据的质量和数量直接影响量化精度。
我踩过的一个坑是:用训练集的一小部分做校准,结果量化后模型在测试集上掉点严重。后来发现是因为训练集和测试集的分布有差异,校准数据没有覆盖到测试集里的某些模式。换成从测试集里抽一部分做校准后,精度明显改善。这个经验告诉我,校准数据一定要有代表性,最好能覆盖实际部署时可能遇到的各种输入。
校准的样本数量也有讲究。太少(比如几十个)统计不充分,太多(比如几万个)浪费时间且收益递减。我的经验是 500 到 1000 个样本通常就够了,但具体要看模型的复杂度和输入数据的多样性。对于输入变化很大的模型(比如目标检测),可能需要更多样本。
校准算法本身也有选择。最简单的是MinMax,直接取最大最小值;好一点的是Moving Average MinMax,用滑动平均平滑异常值;更好的是Entropy或Percentile,用信息熵或百分位来截断异常值。Model-Optimizer 通常支持多种校准算法,实际用的时候建议都试一下,选精度最好的那个。
| 校准算法 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| MinMax | 分布均匀的数据 | 简单快速 | 对异常值敏感 |
| Moving Average MinMax | 有噪声的数据 | 平滑异常值 | 需要调窗口大小 |
| Entropy | 分布复杂的数据 | 精度保持好 | 计算稍慢 |
| Percentile | 有长尾的数据 | 鲁棒性强 | 需要调百分位 |
3. 剪枝与稀疏化:让模型"瘦"下来的正确姿势
3.1 结构化剪枝与非结构化剪枝的本质区别
剪枝的核心思想是去掉模型中不重要的部分。但"不重要"怎么定义、怎么去掉,就有很多讲究了。最粗的分类是结构化剪枝和非结构化剪枝。
非结构化剪枝是把权重矩阵里的一些元素置零,不改变矩阵的形状。比如一个 1000x1000 的权重矩阵,剪掉 90% 的元素变成稀疏矩阵,但矩阵还是 1000x1000。这种剪枝的优点是精度损失小,因为可以精细地选择哪些元素不重要。缺点是实际加速效果有限,因为通用硬件对稀疏矩阵的支持不好,除非稀疏度非常高(比如 95% 以上),否则计算时间不会明显减少。
结构化剪枝是直接去掉整个通道、整个注意力头、整个层。比如把某个卷积层的输出通道从 256 减到 128,或者把 Transformer 的注意力头从 12 个减到 6 个。这种剪枝的优点是实际加速效果明显,因为矩阵形状真的变小了。缺点是精度损失可能更大,因为去掉的是整个结构单元,粒度更粗。
Model-Optimizer 通常两种都支持,但实际部署时我更推荐结构化剪枝。原因很简单:非结构化剪枝的加速效果太依赖硬件和推理引擎的支持,通用性差。结构化剪枝虽然精度损失大一点,但加速效果是实打实的,不依赖特殊硬件。
3.2 重要性评分:怎么判断哪些部分该剪
剪枝的关键是找到"不重要"的部分。怎么定义重要性?常见的方法有几种:
基于权重幅值:权重绝对值小的认为不重要。这是最简单的方法,计算成本低,但效果一般。因为权重幅值小不一定代表贡献小,有些小权重可能对最终输出影响很大。
基于梯度:梯度小的认为不重要。梯度反映了参数对 loss 的影响程度,比单纯看幅值更准确。但需要计算梯度,成本更高。
基于激活值:激活值小的认为不重要。这个方法直接看前向传播的贡献,通常比看权重更准确。但需要跑数据统计激活值分布。
基于二阶信息:用 Hessian 矩阵或 Fisher 信息矩阵来评估重要性。这是最准确的方法,但计算成本极高,通常只用于小模型或研究场景。
实际用的时候,我一般先用基于权重幅值的方法快速筛一遍,再用基于激活值的方法精调。二阶方法除非精度要求极高,否则不太实用。Model-Optimizer 通常会提供多种重要性评分方法,可以配置选择。
还有一个经验是:逐层剪枝比全局剪枝更稳。全局剪枝是设定一个总稀疏度,然后所有层一起剪。逐层剪枝是每层单独设定稀疏度。全局剪枝的问题是不同的层对稀疏度的敏感度不同,统一剪可能导致某些层被过度剪枝。逐层剪枝虽然麻烦一点,但精度控制更精细。
3.3 剪枝后的微调策略与恢复训练
剪枝之后通常需要微调来恢复精度。微调的策略有几个关键点:
学习率要小。剪枝后的模型已经接近一个局部最优,学习率太大会破坏已有的知识。通常用原始训练学习率的 1/10 到 1/100。
训练轮数不用太多。微调不是重新训练,通常几个 epoch 就够了。太多轮反而可能过拟合。
数据要覆盖全面。微调数据要能代表实际部署时的输入分布,否则微调后的模型可能在实际场景中表现不佳。
可以逐步剪枝。不要一次性剪到目标稀疏度,而是分多次剪,每次剪一点然后微调。这种方法叫迭代剪枝,精度保持通常比一次性剪枝好很多。比如目标稀疏度是 80%,可以分 4 次,每次剪 20%,每次剪完微调几个 epoch。
我做过一个对比实验:一次性剪枝到 80% 稀疏度,精度掉了 5 个百分点;迭代剪枝分 4 次,每次剪 20%,精度只掉了 1.5 个百分点。虽然迭代剪枝总时间更长,但精度优势明显。
4. 算子融合与计算图优化:不损失精度的加速
4.1 算子融合为什么能加速
算子融合是把多个连续的小算子合并成一个大的算子。比如Conv + BatchNorm + ReLU这三个算子,在推理时可以融合成一个算子。融合之后,中间结果不需要写回内存再读出来,减少了内存访问次数,也减少了 kernel 启动的开销。
为什么内存访问这么重要?因为现代 GPU 的算力增长速度远快于内存带宽增长速度,很多算子其实是内存带宽受限而不是算力受限。也就是说,GPU 的计算单元在等数据从内存搬过来,而不是在忙着计算。算子融合减少了内存访问,直接缓解了这个瓶颈。
以Conv + BatchNorm + ReLU为例,不融合的时候需要三次内存读写:Conv 写输出、BatchNorm 读输入写输出、ReLU 读输入写输出。融合之后只需要一次:Conv 计算完直接做 BatchNorm 和 ReLU,然后写一次输出。内存访问量减少了约 2/3,加速效果非常明显。
而且 BatchNorm 在推理时其实是一个线性变换,可以完全折叠进 Conv 的权重里。具体来说,BatchNorm 的公式是y = gamma * (x - mean) / sqrt(var + eps) + beta,可以重写成y = scale * x + shift的形式,其中scale = gamma / sqrt(var + eps),shift = beta - gamma * mean / sqrt(var + eps)。然后 Conv 的权重W和偏置b可以更新为W' = W * scale,b' = (b - mean) * scale + shift。这样 BatchNorm 就完全消失了,只剩下 Conv 和 ReLU。
4.2 计算图层面的死代码消除与常量折叠
除了算子融合,计算图层面还有很多优化可以做。
死代码消除是去掉对最终输出没有贡献的节点。比如训练时用的辅助分支、调试用的打印节点、被条件分支永远走不到的分支。这些节点在推理时是多余的,去掉它们能减少计算量。
常量折叠是在编译期计算那些输入固定的节点。比如某个节点的输入全是常量,那它的输出也是常量,可以直接算好存起来,不用在运行时再算一遍。
公共子表达式消除是找出重复计算的表达式,只算一次然后复用。比如两个分支都用了同一个中间结果,可以只算一次。
内存复用是让不同的张量共享同一块内存。因为很多张量的生命周期不重叠,可以复用内存减少峰值占用。
这些优化在 Model-Optimizer 里通常是自动做的,但你需要知道它们的存在,才能在优化效果不理想时判断是哪里出了问题。比如如果发现某个算子的耗时异常高,可能是它没有被融合;如果发现显存占用比预期高,可能是内存复用没有生效。
4.3 针对不同推理引擎的图优化差异
不同的推理引擎对计算图的理解和优化能力不同。TensorRT 对 NVIDIA GPU 的优化最深入,支持大量的算子融合和 kernel 自动调优。ONNX Runtime 的跨平台性好,支持多种硬件后端,但优化深度可能不如 TensorRT。OpenVINO 针对 Intel 硬件优化,在 CPU 上表现很好。TFLite 针对移动端优化,在 ARM 芯片上效率高。
Model-Optimizer 的价值在于它提供了一个统一的优化层,让你可以用同一套配置生成针对不同引擎的优化模型。但要注意,不同引擎支持的算子集不同,有些优化在某个引擎上能做,在另一个引擎上做不了。比如 TensorRT 支持 INT8 量化,但某些自定义算子可能不支持;ONNX Runtime 支持更广泛的算子,但量化支持可能不如 TensorRT 成熟。
实际选型的时候,我的建议是:先确定部署硬件,再选推理引擎,最后做优化。不要反过来,先做优化再找引擎,那样很可能白忙一场。
5. 优化效果的评估与验证方法
5.1 精度评估不能只看一个指标
优化之后怎么判断模型还能不能用?很多人只看一个 top-1 accuracy 或者 mAP,这其实不够。不同的优化手段对不同指标的影响不同,只看一个指标可能漏掉问题。
比如量化对分类模型的 top-1 accuracy 影响可能不大,但对 top-5 accuracy 或者某些类别的召回率影响可能很大。剪枝对整体 mAP 影响可能不大,但对小目标的检测精度影响可能很大。所以评估的时候要看多个指标,最好能分类别、分场景地看。
还有一个重要的评估维度是一致性。优化后的模型和原始模型的输出应该尽可能一致。可以计算两个模型输出的 KL 散度或者余弦相似度,如果差异太大,说明优化引入了不可忽略的偏差。
我通常的做法是:准备一个评估集,同时跑原始模型和优化模型,对比多个指标,并且画出误差分布图。如果误差分布是均匀的,说明优化比较稳定;如果某些样本误差特别大,说明优化在这些样本上失效了,需要针对性分析。
5.2 性能测试的常见陷阱
性能测试看起来简单,跑一下计时就行了,但实际上有很多陷阱。
预热不够。GPU 在刚启动时频率可能没跑满,第一次推理通常比后续慢。所以测试前要预热几次,等频率稳定了再测。
同步问题。GPU 操作是异步的,如果你在 kernel 还没执行完就计时结束,测出来的时间会偏小。需要确保计时前所有操作都完成了。
批大小影响。不同的 batch size 下,优化效果可能完全不同。量化在小 batch 下加速明显,在大 batch 下可能不明显。剪枝在大 batch 下加速明显,在小 batch 下可能被其他开销掩盖。所以测试时要覆盖实际部署时可能用到的 batch size。
内存碎片。长时间运行后内存可能碎片化,导致性能下降。测试时要模拟长时间运行的情况。
热节流。GPU 长时间高负载运行会发热降频,性能会下降。测试时要监控温度,确保没有热节流。
这些陷阱我都踩过,尤其是预热和同步问题,刚开始做性能测试的时候经常测出"虚高"的性能数据,实际部署时才发现达不到。
5.3 端到端延迟与吞吐的权衡
最后说一下延迟和吞吐的权衡。延迟是单个请求的处理时间,吞吐是单位时间内能处理的请求数。这两个指标通常是矛盾的:增大 batch size 能提高吞吐,但会增加延迟;减小 batch size 能降低延迟,但会降低吞吐。
怎么选取决于你的应用场景。如果是实时交互场景(比如语音助手),延迟优先,batch size 要小。如果是离线批处理场景(比如视频分析),吞吐优先,batch size 可以大。如果是混合场景,可能需要动态 batch 或者多实例部署。
Model-Optimizer 通常不直接控制 batch size,但它会影响不同 batch size 下的性能表现。比如量化后的模型在小 batch 下延迟优势明显,在大 batch 下可能被计算密度掩盖。所以做优化的时候要结合实际的 batch size 来评估效果。
| 场景类型 | 优先指标 | 推荐 batch size | 优化重点 |
|---|---|---|---|
| 实时交互 | 延迟 | 1-4 | 量化、算子融合 |
| 在线服务 | 平衡 | 8-32 | 量化、剪枝 |
| 离线批处理 | 吞吐 | 64+ | 剪枝、算子融合 |
| 边缘部署 | 资源占用 | 1-2 | 量化、剪枝 |
6. 实际项目中的优化决策链路
6.1 从需求到方案的推导过程
实际做优化的时候,不能上来就选工具、调参数,而是要先理清需求。我通常按这个链路来推导:
第一步,明确部署目标。是云端 GPU 还是边缘设备?是 CPU 还是专用加速器?不同的目标对应完全不同的优化策略。
第二步,明确性能约束。延迟上限是多少?吞吐下限是多少?显存上限是多少?这些约束决定了优化的空间有多大。
第三步,明确精度底线。精度最多能掉多少?哪些指标不能掉?这决定了优化的激进程度。
第四步,分析瓶颈。用 profiling 工具找出模型的时间主要花在哪里,是某个算子特别慢,还是内存带宽受限,还是 kernel 启动开销大。不同瓶颈对应不同的优化手段。
第五步,选择优化组合。根据前面的分析,选择合适的优化手段组合。通常先做算子融合(无损),再做量化(有损但可控),最后考虑剪枝(有损且需要微调)。
第六步,迭代验证。每做一步优化都验证精度和性能,确保没有偏离目标。如果某一步效果不好,回退重新选择。
这个链路看起来简单,但实际做的时候每一步都可能遇到意外。比如 profiling 发现瓶颈在某个自定义算子上,但这个算子不支持量化,那就只能想别的办法。或者量化后发现精度掉太多,QAT 又没时间做,那就只能降低量化范围。
6.2 一个真实案例的完整优化过程
我拿一个实际做过的项目来举例。场景是:一个目标检测模型要部署到边缘设备上,设备算力有限,要求延迟不超过 50ms,精度 mAP 掉点不超过 2 个点。
原始模型是 YOLO 系列的某个变体,FP32 精度下在服务器 GPU 上延迟 30ms,但在目标边缘设备上延迟超过 200ms,完全达不到要求。
第一步,分析瓶颈。用 profiling 工具发现,边缘设备上耗时最多的是卷积层,尤其是前几层的大卷积。这些卷积的计算量大,而且边缘设备的算力有限。
第二步,尝试量化。先做 PTQ,用 500 张代表性图片做校准。量化后延迟降到了 80ms,但 mAP 掉了 3 个点,超过了 2 个点的底线。
第三步,调整量化策略。把敏感层(主要是检测头附近的层)排除在量化范围外,只量化主干网络。延迟降到 95ms,mAP 只掉了 1.2 个点,达标了。但延迟还是超过 50ms。
第四步,加入剪枝。对主干网络做结构化剪枝,剪掉 30% 的通道。剪枝后微调 10 个 epoch。延迟降到 60ms,mAP 又掉了 0.8 个点,总共掉了 2 个点,刚好在底线。
第五步,算子融合和内存优化。用推理引擎的图优化功能做算子融合,同时优化内存分配。延迟降到 45ms,精度不变。
最终方案:部分量化 + 结构化剪枝 + 算子融合,延迟 45ms,mAP 掉 2 个点,满足要求。
这个案例里有几个关键决策点:为什么先量化后剪枝?因为量化是无损的(相对剪枝而言),先做无损优化再做有损优化,可以最大化精度保持。为什么部分量化而不是全量化?因为全量化掉点太多。为什么剪枝只剪主干?因为检测头对精度更敏感。
6.3 优化失败时的回退与排查思路
优化不可能一次成功,失败的时候怎么排查?我总结了一个排查顺序:
先查数据。校准数据有没有代表性?评估数据有没有问题?数据问题是最容易被忽视但也最容易修复的。
再查配置。量化配置对不对?剪枝配置对不对?有没有配置项写错了?这个听起来很蠢,但实际中经常发生。
然后查工具链。推理引擎版本对不对?算子支持不支持?有没有已知的 bug?有时候换个版本就好了。
最后查模型本身。模型结构是不是不适合这种优化?有没有特殊的算子或者结构导致优化失效?
如果所有都查过了还是不行,那就回退到上一个能工作的版本,换一种优化手段重新试。不要在一个方向上死磕,有时候换条路反而更快。
注意:优化是一个迭代过程,不要期望一次就达到最优。每次只改一个变量,这样才能准确判断每个优化的效果。同时改多个变量,出了问题都不知道是哪个引起的。
7. 工具链选型与工程化落地
7.1 主流优化工具的能力边界对比
Model-Optimizer 不是一个具体的工具,而是一类工具的总称。实际用的时候要选具体的工具。主流的几个:
TensorRT:NVIDIA 的推理优化库,对 NVIDIA GPU 支持最好,算子融合和量化能力都很强。缺点是只支持 NVIDIA 硬件,跨平台性差。
ONNX Runtime:微软的推理引擎,跨平台性好,支持多种硬件后端。量化工具链比较成熟,但优化深度可能不如 TensorRT。
OpenVINO:Intel 的推理工具包,对 Intel CPU 和集成显卡优化很好。支持量化、剪枝、算子融合,工具链完整。
TFLite:Google 的移动端推理框架,对 ARM 芯片优化好。支持量化,但剪枝和算子融合能力相对弱一些。
TVM:开源的深度学习编译器,支持多种硬件后端,自动调优能力强。但学习曲线陡峭,工程化落地成本高。
选哪个取决于你的部署目标。NVIDIA GPU 就选 TensorRT,Intel CPU 就选 OpenVINO,移动端就选 TFLite,需要跨平台就选 ONNX Runtime。不要为了用某个工具而用某个工具,要从部署目标出发。
7.2 优化流程的自动化与可复现性
优化流程最好能自动化,原因有两个:一是手动做容易出错,二是优化需要迭代,手动做效率太低。
自动化的关键是配置化。把优化参数(量化位宽、校准算法、剪枝稀疏度、微调轮数等)都写成配置文件,然后用脚本自动跑。这样每次调整参数只需要改配置,不需要改代码。
可复现性也很重要。同样的配置应该能产出同样的结果。这要求固定随机种子、固定数据顺序、固定工具版本。我见过因为工具版本不同导致优化结果差异很大的情况,所以版本管理一定要做好。
还有一个经验是:保存中间结果。每一步优化的输出都保存下来,这样如果后面出了问题,可以回退到中间步骤,不用从头再来。同时中间结果也可以用来做对比分析,看看每一步优化到底带来了多少收益。
7.3 优化后的模型版本管理与回滚
优化后的模型需要版本管理。每个版本要记录:原始模型版本、优化配置、优化后的精度指标、性能指标、优化日期、负责人。这样出了问题可以追溯,也可以对比不同版本的效果。
回滚机制也要有。如果优化后的模型上线后发现问题,要能快速回滚到上一个稳定版本。这要求部署系统支持多版本共存和快速切换。
我自己的做法是:每个优化版本都打标签,标签里包含优化配置的哈希值。部署的时候指定标签,回滚的时候切换到上一个标签。同时保留原始 FP32 模型作为最后的兜底,万一所有优化版本都有问题,还能回到原始模型。
8. 那些文档里不会写的经验教训
8.1 量化不是越激进越好
刚开始做量化的时候,我总想着把位宽压得越低越好,INT8 不够就试 INT4,INT4 不够就试二值化。结果发现,位宽越低,精度损失越大,而且不是线性的。从 FP32 到 INT8,精度可能只掉 0.5 个点;从 INT8 到 INT4,精度可能掉 5 个点;从 INT4 到二值化,精度可能直接崩掉。
而且低位宽对硬件的要求更高。不是所有硬件都支持 INT4,支持 INT4 的硬件也不一定支持所有算子。实际部署的时候,INT8 通常是性价比最高的选择,INT4 只在特定场景下有意义。
还有一个反直觉的发现:量化后的模型不一定比 FP32 快。如果硬件不支持 INT8 加速,或者推理引擎没有针对 INT8 优化,量化后的模型可能因为要插入反量化操作而变得更慢。所以量化之前一定要确认目标硬件和推理引擎支持 INT8 加速。
8.2 剪枝后的模型可能"看起来"更慢
剪枝之后模型参数变少了,理论上应该更快。但实际测试的时候,有时候剪枝后的模型反而更慢。原因有几个:
一是形状不规整。剪枝后的矩阵形状可能不是硬件友好的(比如不是 8 的倍数),导致计算效率下降。
二是并行度降低。剪枝减少了计算量,但也减少了并行度,GPU 可能跑不满。
三是内存访问模式改变。剪枝后的权重矩阵可能变得稀疏,内存访问不再连续,缓存命中率下降。
所以剪枝之后一定要实际测试性能,不能只看参数量。如果剪枝后性能没有提升甚至下降,可能需要调整剪枝策略,比如保持形状规整、控制剪枝粒度。
8.3 优化效果的"天花板"在哪里
每个模型都有优化的天花板。到了某个点之后,再优化精度就会崩,或者性能提升微乎其微。识别天花板很重要,可以避免浪费时间。
怎么判断到了天花板?我的经验是:如果连续几次优化尝试(比如调整量化配置、调整剪枝稀疏度)带来的性能提升都小于 5%,而精度损失都大于 0.5%,那大概率是到天花板了。这时候应该考虑换模型结构,而不是继续优化当前模型。
换模型结构有时候比优化更有效。比如把一个大的模型换成专门为移动端设计的小模型,可能直接就能满足要求,不需要复杂的优化。当然,换模型需要重新训练,成本更高,但如果优化已经走到死胡同,换模型可能是唯一的出路。
8.4 和训练团队的协作要点
做模型优化的人通常不是训练模型的人,这就需要和训练团队协作。协作的时候有几个要点:
尽早介入。不要等模型训练完了才开始考虑优化,最好在模型设计阶段就考虑部署约束。比如如果知道要部署到边缘设备,训练的时候就可以用一些对量化友好的结构。
明确精度底线。训练团队通常希望精度越高越好,但部署团队需要知道精度能掉多少。这个底线要提前对齐,避免优化完了才发现精度不达标。
共享评估数据。优化团队和训练团队应该用同一套评估数据,这样指标才可比。如果各用各的,很容易出现"训练团队说精度 95%,优化团队说精度 90%"的扯皮情况。
建立反馈循环。优化过程中发现的问题(比如某些层对量化特别敏感)应该反馈给训练团队,训练团队可以在下一版模型中针对性改进。
9. 面向未来的优化趋势
9.1 自动化优化与神经架构搜索的结合
现在的一个趋势是把模型优化和神经架构搜索(NAS)结合起来。传统做法是先设计模型再优化,NAS 的做法是在搜索模型的时候就考虑部署约束,直接搜出对部署友好的模型。
这种做法的好处是优化空间更大。传统优化只能在给定模型结构上做文章,NAS 可以改变模型结构本身。比如搜索出更适合量化的结构、更适合剪枝的结构。
但 NAS 的成本很高,需要大量的计算资源。对于大多数团队来说,可能还是先用传统优化手段,等有资源了再考虑 NAS。
9.2 硬件感知优化的发展方向
另一个趋势是硬件感知优化。不同的硬件对算子、数据布局、量化方式的支持不同,通用的优化策略可能不是最优的。硬件感知优化是根据目标硬件的特性来定制优化策略。
比如某些硬件对特定尺寸的卷积有加速,优化的时候就可以把卷积尺寸调整成硬件友好的尺寸。某些硬件对特定数据布局有优化,优化的时候就可以调整数据布局。
Model-Optimizer 这类工具的未来方向之一,就是更好地支持硬件感知优化,让用户能针对特定硬件做深度优化。
9.3 大模型时代的优化新挑战
大模型(LLM、大视觉模型)给优化带来了新挑战。大模型的参数量巨大,量化到 INT8 可能还是很大,需要更激进的量化(比如 INT4 甚至更低)。大模型的计算量大,对算力和内存带宽的要求都很高。大模型的推理通常是自回归的,每一步只生成一个 token,batch 效率低。
针对大模型的优化手段也在发展,比如 KV Cache 优化、Paged Attention、投机采样等。这些手段和传统的模型优化有交集但不完全相同。Model-Optimizer 这类工具也在逐步支持大模型的优化需求。
我在实际做 LLM 量化的时候发现,传统的 PTQ 方法对大模型效果不好,因为大模型的激活值分布更复杂,异常值更多。需要更精细的量化方法,比如 GPTQ、AWQ 这些专门为大模型设计的量化算法。这些算法的核心思想是对不同的权重用不同的量化策略,对重要的权重保留更高精度。
10. 写在最后:一些个人体会
做模型优化这几年,最大的体会是:优化不是目的,落地才是。不要为了优化而优化,不要追求极致的压缩率或者极致的速度,而是要在满足业务需求的前提下找到最合适的方案。
另一个体会是:没有银弹。每种优化手段都有适用场景和局限性,没有一种方法能解决所有问题。实际做的时候往往是多种手段组合使用,而且需要根据具体情况调整。
还有一个体会是:测试比优化更重要。优化做得好不好,最终要靠测试来验证。建立完善的测试体系,包括精度测试、性能测试、稳定性测试,比掌握多少优化技巧都重要。
最后,保持学习。硬件在变,推理引擎在变,模型结构在变,优化手段也在变。今天有效的方法明天可能就过时了。保持对新技术的好奇心,多动手实践,才能跟上这个领域的发展。