引言
训练出的模型往往跑不动端侧设备:ResNet-50 有 25.6M 参数,在手机 CPU 上单帧要几百毫秒。模型压缩的目标是在可接受的精度损失下,把模型变小、变快、变省电。三条主线是量化、剪枝、蒸馏,配合端侧运行时(ONNX Runtime、TensorRT、NCNN)落地。
本文按「三个方向 → 量化 → 剪枝 → 蒸馏 → 运行时 → 代码」的顺序拆解,并给出可直接运行的量化对比代码。
目录
- 压缩的三个方向
- 量化
- 剪枝
- 知识蒸馏
- 端侧运行时
- 代码:ONNX Runtime 量化对比
- 代码:动态量化
- 框架对比表
- 精度与延迟影响表
- 精度恢复与微调策略
- 硬件加速与算子融合
- 端到端部署流程
- 多模型与流水线压缩
- 权衡取舍
- 常见坑清单
- 小结
1. 压缩的三个方向
| 方向 | 手段 | 压缩对象 | 典型收益 |
|---|---|---|---|
| 量化 | 降低数值精度 | 权重与激活的位宽 | 体积降 4 倍,速度提升 |
| 剪枝 | 去掉冗余参数 | 权重或通道 | 体积与计算下降 |
| 蒸馏 | 小模型学大模型 | 模型容量 | 小模型精度提升 |
三者可以叠加:先蒸馏训练小模型,再剪枝,最后量化。
2. 量化
2.1 PTQ
PTQ(Post-Training Quantization)在训练后量化,用少量校准数据统计激活分布,把 float32 权重与激活映射到 int8。它不需要重新训练,成本低,是首选方案。ONNX Runtime 与 TensorRT 都支持。
2.2 QAT
QAT(Quantization-Aware Training)在训练中插入伪量化节点,让模型适应量化误差。精度通常高于 PTQ,但需要重新训练与标注数据,成本高。精度要求苛刻时用。
2.3 校准
PTQ 的关键是校准:用几百张有代表性的图统计每层激活的动态范围,决定量化尺度。校准集必须与线上分布一致,否则量化误差会放大。
- 对称量化:零点为 0,适合权重。
- 非对称量化:零点可偏移,适合激活(ReLU 后分布非负)。
- per-tensor:整层一个尺度,简单。
- per-channel:每个通道一个尺度,精度更高,卷积权重的标准做法。
3. 剪枝
- 非结构化剪枝:按权重绝对值去掉单个连接,稀疏度高,但需要稀疏算子支持才能加速,通用硬件上常常不加速。
- 结构化剪枝:整通道或整滤波器剪掉,直接减小张量形状,通用硬件上能真正加速,工程上更实用。
- 迭代剪枝:剪一点、微调、再剪,避免一次剪太多导致精度崩塌。
剪枝后必须微调,否则精度损失明显。
4. 知识蒸馏
让小的学生模型学习大的教师模型:
- logit 蒸馏:学生模仿教师的软标签(带温度的 softmax),比硬标签信息更多。
- 特征蒸馏:学生模仿教师中间层的特征,FitNets 是早期代表。
- 自蒸馏:教师与学生同结构,用平均权重或历史权重当教师。
蒸馏能把大模型的知识压缩到小模型,端侧部署常用「大模型教师加小模型学生」的组合。
import torch
import torch.nn.functional as F
def distillation_loss(student_logits, teacher_logits, targets, T=4.0, alpha=0.7):
soft = F.kl_div(
F.log_softmax(student_logits / T, dim=1),
F.softmax(teacher_logits / T, dim=1),
reduction="batchmean",
) * (T * T)
hard = F.cross_entropy(student_logits, targets)
return alpha * soft + (1 - alpha) * hard
s = torch.randn(8, 10)
t = torch.randn(8, 10)
y = torch.randint(0, 10, (8,))
print("kd loss:", float(distillation_loss(s, t, y)))
温度 T 越大软标签越平滑、信息越多,通常取 3 到 5;alpha 控制软硬损失的权重。
5. 端侧运行时
| 运行时 | 平台 | 后端 | 特点 |
|---|---|---|---|
| ONNX Runtime | 跨平台 | CPU/GPU/NPU | 通用,生态好 |
| TensorRT | NVIDIA GPU | CUDA | 最快,需 GPU |
| NCNN | 移动端 | ARM CPU/Vulkan | 轻量,无依赖 |
| TFLite | 移动端 | CPU/GPU/NNAPI | Android 生态 |
| OpenVINO | Intel | CPU/GPU/VPU | Intel 平台优化 |
| CoreML | Apple | ANE/GPU | iOS 与 macOS |
| MNN | 移动端 | CPU/GPU | 阿里开源 |
选择依据:NVIDIA GPU 用 TensorRT,Android 用 TFLite 或 NCNN,iOS 用 CoreML,Intel 服务器用 OpenVINO,跨平台通用用 ONNX Runtime。
6. 代码:ONNX Runtime 量化对比
下面用 ONNX Runtime 对同一模型分别跑 FP32 与 INT8,测平均延迟并对比。
import time
import numpy as np
import onnxruntime as ort
from onnxruntime.quantization import quantize_dynamic, QuantType
def benchmark(model_path, runs=50):
sess = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
name = sess.get_inputs()[0].name
x = np.random.randn(1, 3, 224, 224).astype(np.float32)
# 预热
for _ in range(5):
sess.run(None, {name: x})
start = time.perf_counter()
for _ in range(runs):
sess.run(None, {name: x})
return (time.perf_counter() - start) / runs * 1000 # 毫秒
quantize_dynamic("resnet18.onnx", "resnet18_int8.onnx", weight_type=QuantType.QInt8)
fp32_ms = benchmark("resnet18.onnx")
int8_ms = benchmark("resnet18_int8.onnx")
print(f"fp32: {fp32_ms:.2f} ms, int8: {int8_ms:.2f} ms, speedup: {fp32_ms / int8_ms:.2f}x")
动态量化只量化权重,激活在运行时量化,实现简单,对 CPU 上的 LSTM 与全连接层收益明显。卷积为主的模型若想更大加速,用静态量化(需要校准集)。
7. 代码:动态量化
PyTorch 侧也能直接做动态量化,方便快速验证精度影响。
import torch
from torchvision import models
model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1).eval()
quantized = torch.quantization.quantize_dynamic(
model, {torch.nn.Linear, torch.nn.Conv2d}, dtype=torch.qint8
)
x = torch.randn(1, 3, 224, 224)
with torch.inference_mode():
out_fp32 = model(x)
out_int8 = quantized(x)
diff = (out_fp32 - out_int8).abs().max().item()
print("max abs diff:", round(diff, 4))
print("size fp32:", sum(p.numel() for p in model.parameters()))
动态量化对卷积的加速在 CPU 上有限,但它能快速暴露「哪些层对量化敏感」,为后续 QAT 或静态量化提供参考。
8. 框架对比表
| 框架 | 量化支持 | 平台 | 易用性 | 性能 |
|---|---|---|---|---|
| ONNX Runtime | PTQ 动态与静态 | 跨平台 | 高 | 中高 |
| TensorRT | PTQ 与 QAT | NVIDIA | 中 | 最高 |
| NCNN | int8 | 移动端 | 中 | 高 |
| TFLite | PTQ 与 QAT | 移动端 | 高 | 中高 |
| OpenVINO | PTQ 与 NNCF | Intel | 中 | 高 |
9. 精度与延迟影响表
| 方法 | 体积 | 延迟 | 精度损失 | 成本 |
|---|---|---|---|---|
| FP32 基线 | 1x | 1x | 0 | 无 |
| 动态量化 int8 | 约 0.25x | 1.5 到 2x 提速 | 小于 1% | 低 |
| 静态量化 int8 | 约 0.25x | 2 到 4x 提速 | 1% 到 2% | 中 |
| 结构化剪枝 50% | 约 0.5x | 约 2x 提速 | 1% 到 3%(需微调) | 中 |
| 蒸馏小模型 | 视模型 | 视模型 | 取决于教师 | 高 |
数值为量级参考,实际随模型、硬件与校准质量浮动。
10. 精度恢复与微调策略
压缩几乎总会掉点,关键是控制掉点幅度。
- 量化后微调:QAT 本身就是训练;PTQ 掉点明显时,用少量标注数据做几百步微调(quantization fine-tuning)。
- 剪枝后微调:每次剪枝后用原学习率的十分之一微调若干轮,再继续剪。
- 分层策略:对量化敏感的首末层保留高精度,其余层量化,平衡精度与速度。
- 敏感度分析:逐层试量化并测精度,找出最敏感的层单独处理。
- 精度对齐阈值:量化前后输出的最大绝对误差通常应小于 1e-2(分类 logits 量级),检测框坐标误差应小于 1 像素。
import numpy as np
def layer_sensitivity(fp32_outputs, int8_outputs):
# 逐层对比量化前后的输出差异,定位敏感层
report = {}
for name in fp32_outputs:
a = np.asarray(fp32_outputs[name])
b = np.asarray(int8_outputs[name])
report[name] = float(np.abs(a - b).max())
return dict(sorted(report.items(), key=lambda kv: -kv[1])[:5])
fp32 = {"layer1": np.random.randn(1, 64, 56, 56), "layer4": np.random.randn(1, 512, 7, 7)}
int8 = {k: v + np.random.randn(*v.shape) * 0.01 for k, v in fp32.items()}
print(layer_sensitivity(fp32, int8))
敏感度分析能让「保精度」的力气花在刀刃上,而不是整网盲目回退。
11. 硬件加速与算子融合
压缩之外,运行时层面的优化同样重要:
- 算子融合:把卷积、BN、激活融合成一个算子,减少内存往返与核启动开销。
- 常量折叠与死代码消除:图优化阶段完成的免费加速。
- 内存复用:复用中间张量的显存,降低峰值占用。
- 专用指令:ARM 的 NEON、x86 的 AVX、NVIDIA 的 Tensor Core,运行时按硬件选择。
- Winograd 卷积:对小卷积核减少乘法次数。
这些优化大多由运行时自动完成,但需要模型导出时保留清晰的算子结构,避免写成无法融合的复杂组合。
12. 端到端部署流程
一个可复现的端侧部署流程:
- 训练并固化权重,导出 ONNX,验证数值一致。
- 用代表性校准集做 PTQ,测精度与延迟。
- 精度不达标则上 QAT,或用敏感层保留高精度。
- 目标平台转换:NVIDIA 转 TensorRT,Android 转 TFLite 或 NCNN,iOS 转 CoreML。
- 端到端压测:包含预处理、推理、后处理,测 P50 与 P99 延迟。
- 上线灰度,监控精度与延迟,异常回滚。
# 端到端延迟测量:把预处理也算进去
import time
import numpy as np
import cv2
import onnxruntime as ort
sess = ort.InferenceSession("resnet18_int8.onnx", providers=["CPUExecutionProvider"])
name = sess.get_inputs()[0].name
def end_to_end(image_path, runs=30):
img = cv2.imread(image_path)
start = time.perf_counter()
for _ in range(runs):
x = cv2.resize(img, (224, 224)).astype(np.float32)
x = ((x / 255.0 - 0.5) / 0.5).transpose(2, 0, 1)[None]
sess.run(None, {name: x})
return (time.perf_counter() - start) / runs * 1000
print("e2e ms:", round(end_to_end("test.jpg"), 2))
端到端测量常常揭示真相:模型推理只占一半时间,另一半耗在解码、缩放与归一化上。
13. 多模型与流水线压缩
真实系统往往是多个模型的流水线(检测加分类加 OCR)。压缩要整体考虑:
- 逐模型压缩:分别量化每个模型,注意总精度是连乘的,逐环节掉点会累积。
- 共享骨干:能共享的骨干只跑一次,避免重复计算。
- 早退机制:简单样本用轻量分支提前返回,难样本才走完整流程。
- 级联阈值:前级检测的置信度阈值影响后级输入分布,压缩后需重新标定。
- 缓存:对重复或相近输入缓存结果,降低平均延迟。
流水线压缩的收益常常大于单模型压缩,因为可以省掉大量冗余计算。
14. 权衡取舍
- 精度与加速:量化位宽越低越快,但精度损失越大,int8 是精度与收益的平衡点。
- PTQ 与 QAT:PTQ 省事,QAT 精度高但成本大,先试 PTQ 不行再上 QAT。
- 结构化与非结构化剪枝:前者能真正加速,后者需要稀疏硬件支持。
- 蒸馏与直接训练小模型:有强教师时蒸馏更划算。
- 通用与专用运行时:TensorRT 最快但绑定 NVIDIA,跨平台用 ONNX Runtime。
15. 常见坑清单
- 校准集分布不匹配:用随机噪声或与线上不同的数据校准,量化误差大。
- 敏感层量化:首层与末层、以及检测回归头对量化敏感,常保留 FP16 或 FP32。
- 算子不支持回退:某些算子无 int8 实现,回退 CPU 导致整体变慢。
- 动态 shape 导出失败:可变分辨率导出 ONNX 时未声明动态轴,推理报错。
- NMS 难量化:检测的后处理 NMS 在量化引擎里支持差,常留在 CPU 侧。
- 剪枝后不微调:直接剪枝不重训,精度断崖式下降。
- 忽略端到端延迟:只测模型推理,忽略预处理与后处理,端到端没变快。
- 忘记对齐验证:量化后未与 FP32 输出比对,隐藏了精度损失。
- 蒸馏温度与权重乱设:T 与 alpha 不当,学生学不到教师的知识。
- 只测平均延迟:忽略 P99,端侧偶发卡顿影响体验。
16. 小结
模型压缩的主线是:先量化(PTQ 优先,不行再 QAT)→ 需要更小则结构化剪枝并微调 → 想提升小模型精度用蒸馏 → 按平台选运行时(NVIDIA 用 TensorRT,移动端用 NCNN 或 TFLite)→ 端到端验证延迟与精度。记住三条:int8 是性价比最高的档位、敏感层要特殊处理、压缩后必须做数值对齐验证。压缩的收益要在端到端链路上衡量,别只看模型本身的推理时间。
延伸阅读
- 视觉服务化与推理加速 — 服务端的加速与批处理
- 模型训练与迁移学习 — 压缩前的训练与导出
- AI 端侧推理部署 — 端侧部署的工程细节
- 推理性能基准测试 — 如何科学测延迟与吞吐
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。