做推理部署的时候,最烦的一件事就是模型训练得好好的,一上生产环境就卡成幻灯片,或者GPU显存直接拉满,根本没法在同一张卡上多跑几个实例。模型优化这个事儿,听起来像是个锦上添花的调优工作,但真正落地过的人都知道,它往往是决定一个模型能不能从实验环境走进生产环境的关键,甚至比调几个点的精度还要重要。
这个"Model-Optimizer"项目,核心就是做模型推理侧的优化压缩。它的思路不是去碰训练逻辑,而是聚焦在推理管线本身,通过层融合、算子替换、量化、剪枝组合拳,把模型的体积、延迟、显存占用一起压下去。我把这套东西从一个PyTorch模型跑通到ONNX Runtime和TensorRT的完整过程捋了一遍,踩了不少坑,也总结了一些实测可复现的经验,分享一下。
1. 模型优化到底在优化什么:一张卡上能塞下多少个实例
先聊一个我个人的体会。很多人在优化模型的时候,第一反应是盯着"延迟"不放,恨不得把单次推理压到几毫秒。但真正到生产环境你会发现,大多数场景下更值钱的是"吞吐",也就是一张卡上能并行跑多少个推理实例、单位时间能处理多少请求。
这个观点决定了整个Model-Optimizer项目的设计方向。我早期犯过一个错误:一门心思追求单次推理最短延迟,结果模型被量化得面目全非,精度掉了不少,但吞吐能力并没有显著提升。后来把优化目标改成"在可接受的精度损失范围内,最大化一张卡上的并发实例数",整个优化思路一下子就清晰了。
具体到指标上,我把优化目标拆成三个维度:
- 模型体积:直接影响冷启动速度和存储成本,同一个模型从500MB压到150MB,分发和加载都要快很多。
- 单次推理延迟:这个是用户能直接感知到的东西,尤其在线推理场景,P99延迟比平均延迟更有参考意义。
- 显存占用峰值:这个最容易被忽略,但恰恰决定了你能在同一张卡上塞多少个实例。24GB的卡,模型跑一个实例要吃掉8GB显存,那就只能同时跑3个,如果优化到5GB,就能跑4个,等于白赚了33%的吞吐。
Model-Optimizer的整个管线设计,就是围绕这三个指标来做权衡的,任何一个优化手段都不是无脑上,而是先跑benchmark,看它到底影响了哪个指标、牺牲了多少精度,再决定要不要在最终模型里保留这个优化项。
我最终搭起来的优化管线整体分成四层,从低风险到高风险逐步推进:
- 图优化层:做算子融合、常量折叠、死节点消除,这都是无损优化,模型精度不会有任何变化,是优化的第一步。
- 精度无损压缩层:做权重共享、稀疏化编码,理论上也是无损的,但对推理框架的支持要求比较高,收益相对有限。
- 量化层:用INT8替换FP16/FP32,这是收益最大、风险也比较可控的一步,需要校准数据集,量化后精度损失通常在1%以内。
- 结构化剪枝层:这是最后一步,也是最激进的一步,需要微调来恢复精度,如果前面的优化已经达标,这一步可以跳过。
每一层都有独立的验证开关,可以单独开启或关闭,方便在给不同项目做适配的时候,快速比较"哪几层组合对这个模型最有效"。
2. 层融合与算子替换是怎么做到"白赚"性能的
层融合是整个优化管线里我最先做的一步,因为它完全不改变模型数学语义,纯粹的工程优化,做成了就是无风险提速。
以Transformer结构里的LayerNorm为例。LayerNorm在PyTorch里通常会被拆成mean、pow、sqrt、div、add等一系列细小算子,每个算子之间都要做张量读写。这种拆法对训练框架友好,因为要自动求导,但对推理框架来说就是灾难——每个算子都要启动一遍kernel,都要把中间结果写回显存再读出来,白白浪费了大量带宽。
层融合要做的事情,就是把这一连串细碎算子合并成一个或两个大kernel。在操作上,我主要通过ONNX Runtime的优化pass来实现。
import onnx from onnxruntime.transformers.onnx_model import OnnxModel # 加载原始ONNX模型 model = onnx.load_model("model_fp32.onnx") # 查看模型里的基础算子统计 from onnxruntime.transformers import model_info op_list = model_info.get_operator_info(model) for op in op_list: print(op)跑一遍get_operator_info就能很直观地看出这个模型里算子分布情况。我做医疗影像模型的时候,发现整个模型里有将近800个Transpose节点,这些人畜无害的算子看起来不占什么计算量,但在GPU上,Transpose会把内存访问从连续变成跳跃,性能影响非常可观。
再往后做就是更激进的算子替换了。其中一个收益最大的是把MatMul + Add替换成Gemm,因为Gemm可以直接吸收掉bias的预计算,减少一个kernel的调用。另一个是注意力头里的MatMul,如果输入维度匹配,可以尝试用Conv2D1x1来做替换,在某些硬件上1x1卷积的调度效率比矩阵乘法要高。
融合方式上我自己最常用的是直接在ONNX层面修改图结构,好处是ONNX的GraphProto语义清晰,而且转回其他格式的兼容性好。核心实现逻辑是遍历所有节点,找到满足"连续两个可融合算子"的模式,然后把第二个算子的数学操作折叠到第一个算子里。
def fuse_reshape_transpose_pattern(graph): """将一个常见模式: Transpose -> Reshape -> Flatten 合并为 Squeeze(如果条件满足)""" nodes = list(graph.node) patterns = [] for i, node in enumerate(nodes): if node.op_type == "Transpose" and i + 1 < len(nodes): next_node = nodes[i + 1] if next_node.op_type == "Reshape": # 进一步检查接下来的算子 if i + 2 < len(nodes) and nodes[i + 2].op_type == "Flatten": patterns.append((i, i + 1, i + 2)) return patterns这个只是简单展示了模式匹配的思路,实际上做得更细的话,要对每个候选融合做shape推导,确保融合后张量语义没有变化。
我在这里给一个重要的建议:做融合或者算子替换时,每一层都要用一个真实的输入跑一遍前后的模型输出,做数值比对,误差要控制在1e-5以下才算通过。因为你改的是一个子图的拓扑结构,任何一个小小的维度理解偏差都可能导致某个特定shape下计算结果错位,而这种错误在测试集上可能恰好不会暴露出来。
层融合跑完之后,我拿一个BERT分类模型实测过,FP32下推理延迟从12ms降到了8ms,收益大概有33%,而且精度完全不变。这就是白赚,不做白不做。
3. INT8量化:校准数据的质量决定了精度的损失幅度
量化是整个Model-Optimizer里收益最大也是最容易翻车的一环。所谓INT8量化,本质上是把一个浮点数张量表示成低精度整数加缩放因子的形式。32位浮点变成8位整数,信息量直接打了四折,但换来了更小的显存占用和更快的计算速度。
量化的核心是缩放因子的计算,而缩放因子的计算依赖校准数据集。校准数据集就是从真实训练数据里抽取的一小部分样本,在模型上跑一遍前向推理,统计每一层激活值的分布范围,然后为每个tensor确定合适的scale和zero_point。
最开始量化一个语义分割模型的时候,我用了一个很蠢的做法:直接随机找了几十张图做校准,结果量化后mIoU直接从0.72掉到了0.51,直接崩了。后来排查发现,随机抽的这批图大多是灰度占比很高的图片,激活值分布和真实数据差异太大,scale完全算歪了。
正确的做法是,校准数据集必须在类别分布、清晰度、亮度等方面都尽量贴近真实业务数据的分布,且每类关键特征都要有覆盖。比如做医学影像的话,要有足够的正样本(病灶区域)参与校准,不然量化会把细小的病变特征当成噪声丢掉。
校准过程中模型需要跑前向推理来观察激活值范围,这我在CPU上有GPU时的操作略有不同:
import torch from torch.quantization import prepare, observe, convert from torch.quantization.observer import MinMaxObserver, HistogramObserver # 使用直方图校准方式 model.eval() model.qconfig = torch.quantization.get_default_qconfig('fbgemm') prepare(model, inplace=True, observer=HistogramObserver) # 喂入校准数据 with torch.no_grad(): for batch in calibration_dataloader: model(batch) # 转换到量化模型 convert(model, inplace=True) torch.save(model.state_dict(), "quantized_model.pth")这里有几个细节值得注意。qconfig的选择直接决定了校准用的observer策略,MinMaxObserver简单粗暴,但容易受离群值影响;HistogramObserver更准确,但校准时间更长。在有GPU的情况下,我倾向于用HistogramObserver,精度更稳,校准时间多几十分钟完全值得。
另一个更关键的问题是对敏感层要单独豁免量化。我给BERT模型做量化的时候发现,LayerNorm和GELU激活函数对应的层一旦量化,精度损失明显变大。因为这些操作的输出分布通常不是均匀的,用固定的INT8精度表示会损失大量信息。解决办法是,在配置量化策略的时候,把这些层单独排除:
def skip_quantize_layers(model, skip_names): for name, module in model.named_modules(): if any(skip in name for skip in skip_names): # 保持浮点精度 module.qconfig = None这样做了之后,量化模型的F1从0.89降到0.86,只损失了约3%,而如果全部层都量化,F1直接掉到0.71,完全不可用。
量化后的实际收益体现在显存和推理速度上。我把一个BERT-base分类模型从FP16量化到INT8后,显存占用从2.1GB降到720MB,延迟从14ms降到6ms,而且精度只损失了不到一个百分点。在24GB的推理卡上,这个区别就是从能跑11个并发实例到能跑33个并发实例。
量化不在精度上翻车的方法,本质上就是"校准数据要和业务数据对齐"这一件事。几乎所有失败的量化项目,追根溯源都是校准数据出了问题,要么量太少,要么分布太偏。
4. 结构化剪枝与稀疏化:该放弃的时候得舍得放弃
剪枝是比量化更激进的手段,它是直接砍掉模型里不重要的结构,比如某个卷积通道、某个注意力头,让模型本身变小变快。
我最早做剪枝的时候也掉进过一个误区,以为剪枝的目标是"尽量靠近原始精度",结果剪枝力度一上来,模型精度断崖式下跌,微调了很长时间都回不来。后来想明白一件事:剪枝和量化的核心区别在于,量化处理的是"精度冗余",比如FP32转INT8,你丢掉的大多是那些无意义的尾数位;而剪枝处理的是"结构冗余",比如某个通道的输出几乎全部接近零,对下一层根本没有产生有意义的信息。
在做剪枝前,先花点时间分析一下模型里到底有没有结构冗余,这一点很关键。有些模型本身设计得就很紧凑,比如某些手工设计的轻量化网络,这时候剪枝的空间很小,硬剪的结果就是精度受损。而像BERT这类大规模Transformer,每个注意力头之间存在明显的冗余,剪掉一两个头对整体性能影响很小。
剪枝的实际操作上,有两种路线对比可以考虑:
- 非结构化剪枝:把权重矩阵中接近零的参数置零,模型稀疏率高,但矩阵变得不规则,GPU对这种稀疏矩阵的加速支持很有限,实际上对推理速度提升不大,更多是压缩体积。
- 结构化剪枝:按通道、按行、按注意力头整块删掉,权重矩阵保持规则形状,CPU上可以直接受益,GPU上的kernel也能正确加速。
我推荐用结构化剪枝。它能真正转化成推理性能的提升,而不是纸面上的参数变少。
具体实现上,我用的是Layer-wise剪枝——一次对一层的通道进行裁剪,然后重新校准。这样可以避免全局剪枝带来的层间影响难以定位的问题,并且每剪一层就做一次验证,及时发现问题。
import torch import torch.nn as nn def prune_channels_by_norm(module, prune_ratio=0.3): """ 按L2范数对卷积层通道进行结构化剪枝 prune_ratio: 剪枝比例,这里的含义是保留比例而非删除比例 """ with torch.no_grad(): weight = module.weight.data # [out_ch, in_ch, kh, kw] # 计算每个输出通道的L2范数 norm = torch.norm(weight.view(weight.size(0), -1), dim=1) keep_num = int(norm.size(0) * (1 - prune_ratio)) # 按范数排序,保留范数大的通道 _, indices = torch.topk(norm, keep_num) mask = torch.zeros_like(norm, dtype=torch.bool) mask[indices] = True module.weight.data = weight[mask] if module.bias is not None: module.bias.data = module.bias.data[mask]这里的关键在于torch.topk的选择,保留范数大的通道,意味着"信息量大的结构被保留,信息量小的被舍弃",如果两个通道之间的范数差异很小,说明冗余确实存在,剪掉一个影响不大;但如果某一层通道的范数都很大且差异小,说明这层没有冗余,剪枝应该跳过这一层。
对于包含残差连接的模型,你在处理时要格外小心,因为残差连接要求输入通道数和输出通道数匹配,你剪了一条路径上的通道,另一条路径必须同步剪掉,否则shape对不上。更稳妥的做法是先获取每个层的输入输出shape,在剪切时做链条式同步更新。
# 裁剪包含残差依赖的模块时需要同步 def prune_residual_safe(model, layer_name, prune_ratio): layer = dict(model.named_modules())[layer_name] # 找到与该层直接相连的残差连接模块 residual_branch = find_residual_branch(model, layer_name) # 两边保持一致的通道索引 _, shared_indices = torch.topk(torch.norm(layer.weight.view(layer.weight.size(0), -1), dim=1), int(layer.weight.size(0) * (1 - prune_ratio))) apply_channel_mask(layer, shared_indices) if residual_branch is not None: apply_channel_mask(residual_branch, shared_indices)剪完之后,必须做短时间的微调,让剩余结构重新适应数据分布。微调的epoch不用太多,一般跑3到5个epoch就够了,学习率大约在原始训练时的1/10,软标签蒸馏的方式效果比较好,用原始模型当作teacher,让剪枝后的学生模型去拟合teacher的输出分布,能最大程度保持原来模型学到的知识。
在一组视觉模型上的实测数据是,15%的通道剪枝后精度基本无感,30%通道剪枝后精度下降约1.2%,但模型体积缩小了38%,推理延迟降低了约25%,这个数字说明剪枝的空间是实打实存在的,搭配量化后效果还可以进一步叠加。
5. 优化链路的效率评估:从单个加速点到系统级收益核算
任何时候都不要相信"性能有提升"这种模糊的说法,我在Model-Optimizer里加了一个标准化的benchmark环节,每一层优化做完之后,必须跑同样的测试数据、同样的硬件环境,记录性能变化和精度变化,作为是否保留这项优化的依据。
先看单独做融合和量化的情况。我这边的实测是在NVIDIA T4 GPU上,FP32基线BERT-base的单次推理延迟是13.8ms,显存占用2.3GB;做完层融合和算子替换之后,延迟降到9.2ms,显存占用降了约300MB,精读无损失;再加上INT8量化之后,延迟进一步降到5.4ms,显存占用只剩740MB,精度下降了不到1%。这套数字看起来很直观,但对实际生产决策更有参考价值的指标是"性价比":同样的显存预算下能承载的并发推理数量,从前者的约10个实例,提高到后者的32个左右,吞吐提升了2倍以上。
在一些对精度要求比较严格的项目里,通常可以做一个弹性的取舍,例如:直接INT8量化后精度降了3%,但如果只量化部分卷积层,不做残差层量化,精度只降1.2%,延迟优化仍然能保留70%的收益。这类"局部量化"策略,往往是快又稳的方案。
然后测一下加入剪枝之后的情况。对一个ResNet-50模型,做了20%结构化剪枝再配合量化,最终模型体积从98MB压到23MB,延迟从5.6ms降到2.7ms,而Top-1精度只从76.3%掉到了74.1%。这个2.2%的精度换来了几乎一半的延迟降幅,在实时性要求高的场景里值得一做,但离线的离线精度评测项目里,我可能会更倾向于保留精度、不做剪枝。
精度和性能的权衡必须拿到benchmark数据之后做决定,不要拍脑袋。
最后的评估环节还应该加一道端到端的验证:不只是看单次推理延迟,还要测多个并发请求同时到达时的排队延迟和超时概率。量化模型在并发升高时,稳定性通常比FP32模型更好,因为显存释放和kernel调度更轻量,不容易出现显存溢出导致的报错。
6. 上线前必须补完的几个坑:BatchNorm折叠、动态shape、异构后端兼容
走完整条优化链路之后,前期的性能数据可能很漂亮,但真要部署到生产环境,有些累赘一开始很难注意到,它们恰恰是线上出问题的根源。这里集中说一下我在Model-Optimizer实践中反复踩过的坑。
第一个是BatchNorm折叠的问题。在推理模式下,BatchNorm的均值和方差已经固定,它的归一化操作可以完全折叠进前一层的Conv或Linear的权重里。但如果你加载的模型是在训练模式下保存的,或者某些导出的ONNX模型里BatchNorm还是独立节点,那么在ONNX Runtime里它就无法被融合优化,性能会受到明显影响。解决办法是转模型前先在PyTorch里调一次model.eval(),然后最好再显式调用一次torch.jit.trace来固化模型结构。
model.eval() example_input = torch.randn(1, 3, 224, 224) traced_model = torch.jit.trace(model, example_input) traced_model = torch.jit.freeze(traced_model) # 冻结所有可折叠参数这一套做下来,基本能把BatchNorm从模型图里彻底消灭。
第二个是动态shape的处理。很多后端(尤其是TensorRT的早期版本)对动态输入支持不那么顺滑,如果推理服务的输入长度不固定,选择模型导出时机身定义最大/最小shape范围,让优化后的模型在这个区间内做kernel自动选择。如果你用的是ONNX Runtime,动态shape通常靠session_options.add_free_dimension_override_by_name来指定具体维度,或者直接在导出时把维度固定成实际部署时的最常见shape。千万记得,动态shape会导致大多数图优化pass无法生效,因为很多融合是建立在shape确定的前提下的,如果你的服务允许,上线前尽量统一输入尺寸。
第三个是后端兼容性差异的问题。同样一个量化后的ONNX模型,ONNX Runtime CPU上可能跑得不错,到了TensorRT里对算子支持范围要求更高,有些INT8算子它的实现并不完全对齐,精度会略有差异。我的建议是:同一个模型,优化链路和推理后端是一体的,不能换后端而不重新做benchmark。最稳的做法是分别做一遍onnxruntime + quantization和tensorrt + quantization两套cache,然后用同一份测试数据跑结果对比,选择精度和性能综合更符合预期的那套上线,给另一套留作灰度回退。
第四点是推理服务里的显存复用。量化模型通常配合多实例并发部署,这时候要注意给每个实例设置独立的CUDA context和显存池,避免多个实例竞争同一块显存造成OOM或性能抖动。ONNX Runtime里可以设置arena_extend_strategy来调整内存池扩展策略,改成kSameAsRequested之后显存碎片化情况会显著好转。
sess_options = ort.SessionOptions() sess_options.enable_cpu_mem_arena = False sess_options.enable_mem_pattern = False sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL # 内存扩展策略调整 sess_options.add_session_config_entry("session.use_arena_strategy", "gpu:0")这个小配置我在多个项目里试过,对于长期运行的服务进程,显存最终稳定占用能降低约10%~15%,虽然数字不大,但积少成多。
7. 一个完整的优化流程Demo:从PyTorch模型到生产级ONNX
前面拆了那么多理论和单步实验,这一节把整个Model-Optimizer跑通的流程串一遍,用一个实际的分类模型举例,算是一份可以直接对照操作的checklist。
7.1 导出和预处理
无论用什么框架训练,统一先转成ONNX。PyTorch这边要注意的就是前面说的,先eval,再用实际输入做一次trace,以确保最稳的图结构。
import torch import onnx from onnxruntime.transformers import optimizer as transformer_optimizer # 假设已有训练好的模型 model = get_trained_model() model.eval() dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, "model_raw.onnx", opset_version=17, input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch_size"}, "output": {0: "batch_size"}} ) # 检查模型完整性 onnx_model = onnx.load("model_raw.onnx") onnx.checker.check_model(onnx_model) print("ONNX export passed")这里补充一下,如果模型之后要转TensorRT,dynamic_axes的设置一定要提前规划好。可以给batch维度留动态,但通道和尺寸维度尽量固定,否则TensorRT的engine构建时间会明显变长,而且优化效果也会打折。
7.2 图优化和量化一体化执行
ONNX Runtime官方提供了一整套针对Transformer模型的优化工具,包括层融合、Attention融合、LayerNorm融合等。如果是CNN模型,可以用更通用的onnxruntime.transformers里的优化器,或者更通用的图优化pass。
from onnxruntime.transformers import optimizer as transformer_optimizer optimized_model = transformer_optimizer.optimize_model( "model_raw.onnx", model_type="bert", # 按实际模型类型选择,CNN模型可用"vit"或走其他路径 num_heads=12, hidden_size=768, opt_level=1 ) optimized_model.save_model_to_file("model_optimized.onnx")这里opt_level=1表示开启基础优化,适合模型结构还处于调试阶段的场景。等验证没问题后,以及要上线的时候,可以调成opt_level=2甚至更高,会做更激进的去除冗余计算的操作。
量化部分的落地我走的是onnxruntime.quantization里提供的接口,要先准备一个校准数据集(和训练数据分布一致的dataloader),然后直接调quantize_static生成INT8模型。
from onnxruntime.quantization import quantize_static, CalibrationDataReader, QuantFormat import numpy as np class MyDataReader(CalibrationDataReader): def __init__(self, calibration_data): self.calibration_data = calibration_data self.iterator = iter(calibration_data) def get_next(self): try: return {"input": next(self.iterator)} except StopIteration: return None # 校准数据是预处理好的numpy数组集合 calib_data = [np.random.randn(1, 3, 224, 224).astype(np.float32) for _ in range(100)] data_reader = MyDataReader(calib_data) quantize_static( "model_optimized.onnx", "model_quantized.onnx", data_reader, quant_format=QuantFormat.QDQ, # QDQ格式兼容更多后端 per_channel=True, weight_type="QUInt8" )这里QuantFormat.QDQ是比QOperator更推荐的选择,因为QDQ格式保留了更多的图语义信息,在后端转换为TensorRT时兼容性更好,不容易出现算子不支持的报错。
7.3 性能与精度验证
跑完优化链之后,不要急着部署,花点时间做一次完整验证。用同一份测试集分别跑原始模型、图优化模型、量化模型、剪枝模型,记录三个指标:Top-1精度、P99延迟、显存峰值。
我用表格记录关键验证数据,方便在不同模型间横向比较:
| 模型版本 | 模型体积 | P99延迟 | 显存峰值 | 精度(相对基线) |
|---|---|---|---|---|
| FP32基线 | 420MB | 15.2ms | 2.4GB | 100% |
| 图优化+算子替换 | 410MB | 10.1ms | 2.1GB | 100% |
| INT8量化 | 108MB | 5.6ms | 0.8GB | 98.7% |
| 量化+20%剪枝 | 81MB | 4.2ms | 0.6GB | 96.9% |
到这一步,选择哪个版本上线就一目了然了。对精度要求极高的场景,可能选INT8量化就够了,对延迟要求极端苛刻且能接受精度小幅牺牲的场景,才考虑上剪枝。
7.4 部署到推理服务的最后一步
部署的时候,加载优化后的ONNX模型时,有两个配置值得单独提一下。第一个是session_options.intra_op_num_threads,如果服务端是纯CPU推理,这个参数要按物理核数来设置,不是线程数越大越好,设太大反而会让线程上下文切换开销抵消掉优化收益。第二个是session_options.graph_optimization_level,ONNX Runtime默认就开启了大部分优化,但如果你用的是自己手动调过图的模型,建议设成ORT_DISABLE_ALL,避免Runtime做一些你没预期的变更打乱部署调试。
import onnxruntime as ort so = ort.SessionOptions() so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL so.intra_op_num_threads = 8 so.inter_op_num_threads = 2 sess = ort.InferenceSession("model_quantized.onnx", so, providers=["CUDAExecutionProvider", "CPUExecutionProvider"])providers的顺序代表着优先级,GPU优先,GPU不可用时退回CPU。这个配置上线前一定要检查清楚,环境变量设错会导致GPU没有生效但程序还不报错,默认走了CPU,延迟数据一片混乱。
8. 实战后的复盘
整套Model-Optimizer做完之后,我的一个整体感受是:模型优化不是一个线性操作,而是一套组合拳,每一步与每一步之间都存在耦合关系。先做图优化和层融合,量化效果往往更好,因为融合后的子图更容易做精度对齐;先量化再做剪枝,剪枝过程中微调的收敛速度会更快,因为参数量变小了,反向传播更快。反过来,如果顺序反了,先剪枝再量化,有些跳跃的shape变化会导致校准数据失效,量化精度损失会明显加大。
还有一点要给准备做模型优化的朋友一些提醒:这是一个强烈的实验驱动的过程,合不合适,测了才知道,而且测试结果会随着模型结构、业务数据分布、推理硬件、并发模型发生变化。同一种优化配置,在一个项目里很成功,换一个模型很可能适得其反。
做过几个项目之后,我现在更倾向于把Model-Optimizer做成一个持续集成式的流程,模型训练完后自动进入优化管线,跑完benchmark自动生成报告,让模型产出的同学自己决定是否接受精度损失来换取性能提升。以前人工评估每个优化组合要花两三天,现在优化流程跑一遍大约半小时,剩下时间都花在解读报告和做决策上。
最后想补充一个让我印象很深的小发现,在做某一个BERT模型量化的时候,我以为INT8量化后精度会掉得很厉害,结果跑出来几乎和FP32持平。后来去翻了训练日志,发现这个模型的权重分布本来就集中在小范围内,量化误差天然极小。这算是模型优化的一个隐藏福利:有些模型天生就适合压缩,选对了压缩方式甚至会带来意想不到的泛化提升,因为量化本身就带有一点正则化的效果。当然这个现象不保证每个模型都出现,但确实说明模型优化这件事,值得在项目里多花些时间试一遍,它带来的收益可能比调训练参数还来得直接。