模型压缩与端侧部署

本文系统讲解视觉模型压缩与端侧部署,回答 PTQ 与 QAT 怎么选、剪枝后要不要重训、蒸馏怎么落地、ONNX Runtime 与 TensorRT 与 NCNN 各自适合什么设备等实战问题。覆盖 int8 量化与校准、结构化与非结构化剪枝、logit 与特征蒸馏、主流端侧运行时,并给出 ONNX Runtime 量化对比与动态量化两段可运行代码,附框架对比表、精度与延迟影响表、权衡与常见坑。

引言

训练出的模型往往跑不动端侧设备:ResNet-50 有 25.6M 参数,在手机 CPU 上单帧要几百毫秒。模型压缩的目标是在可接受的精度损失下,把模型变小、变快、变省电。三条主线是量化、剪枝、蒸馏,配合端侧运行时(ONNX Runtime、TensorRT、NCNN)落地。

本文按「三个方向 → 量化 → 剪枝 → 蒸馏 → 运行时 → 代码」的顺序拆解,并给出可直接运行的量化对比代码。

目录

  1. 压缩的三个方向
  2. 量化
  3. 剪枝
  4. 知识蒸馏
  5. 端侧运行时
  6. 代码:ONNX Runtime 量化对比
  7. 代码:动态量化
  8. 框架对比表
  9. 精度与延迟影响表
  10. 精度恢复与微调策略
  11. 硬件加速与算子融合
  12. 端到端部署流程
  13. 多模型与流水线压缩
  14. 权衡取舍
  15. 常见坑清单
  16. 小结

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通用,生态好
TensorRTNVIDIA GPUCUDA最快,需 GPU
NCNN移动端ARM CPU/Vulkan轻量,无依赖
TFLite移动端CPU/GPU/NNAPIAndroid 生态
OpenVINOIntelCPU/GPU/VPUIntel 平台优化
CoreMLAppleANE/GPUiOS 与 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 RuntimePTQ 动态与静态跨平台高中高
TensorRTPTQ 与 QATNVIDIA中最高
NCNNint8移动端中高
TFLitePTQ 与 QAT移动端高中高
OpenVINOPTQ 与 NNCFIntel中高

9. 精度与延迟影响表

方法体积延迟精度损失成本
FP32 基线1x1x0无
动态量化 int8约 0.25x1.5 到 2x 提速小于 1%低
静态量化 int8约 0.25x2 到 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. 端到端部署流程

一个可复现的端侧部署流程:

  1. 训练并固化权重,导出 ONNX,验证数值一致。
  2. 用代表性校准集做 PTQ,测精度与延迟。
  3. 精度不达标则上 QAT,或用敏感层保留高精度。
  4. 目标平台转换:NVIDIA 转 TensorRT,Android 转 TFLite 或 NCNN,iOS 转 CoreML。
  5. 端到端压测:包含预处理、推理、后处理,测 P50 与 P99 延迟。
  6. 上线灰度,监控精度与延迟,异常回滚。
# 端到端延迟测量:把预处理也算进去
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 是性价比最高的档位、敏感层要特殊处理、压缩后必须做数值对齐验证。压缩的收益要在端到端链路上衡量,别只看模型本身的推理时间。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「计算机视觉」更多文章

  1. 检测与分割的评估指标
  2. 多模态视觉语言模型
  3. 3D 视觉:点云与深度估计