4. 模型轻量化技术(下):模型剪枝与量化(PTQ/QAT)在BEV模型上的落地

上一讲我们聊了知识蒸馏和轻量化结构设计,今天咱们来啃两块硬骨头——模型剪枝量化。说实话,这两项技术才是真正把BEV模型从「能跑」变成「跑得动」的关键。

我最早接触剪枝是在一个双目深度估计项目上,当时模型死活压不到10MB以下。后来发现,剪枝不是简单的「砍掉一些权重」,而是有策略地「去掉冗余」。你想想看,BEV模型里那么多特征图,真的每个通道都重要吗?

4.1 模型剪枝:从结构化到非结构化

剪枝分两大类:非结构化剪枝结构化剪枝。前者把单个权重置零,后者直接干掉整个通道或卷积核。

核心观点:对于嵌入式部署,结构化剪枝才是王道。非结构化剪枝虽然压缩率高,但稀疏矩阵在NPU上基本跑不出加速效果。

4.1.1 非结构化剪枝

说白了就是「挑出不重要的权重,直接扔掉」。怎么判断重要性?最常见的是看权重的绝对值大小。

# 非结构化剪枝示例
import torch
import torch.nn.utils.prune as prune

# 对卷积层进行L1范数剪枝
prune.l1_unstructured(module.conv1, name='weight', amount=0.5)
# 50%的权重被置零,但参数数量没变

嗯,这里要注意:非结构化剪枝后,模型文件确实变小了(因为很多零可以压缩),但推理时计算量没减少。除非你的硬件支持稀疏矩阵运算——说实话,目前大部分嵌入式芯片都不支持。

避坑指南:我曾经在一个项目里花了三周做非结构化剪枝,压缩率做到80%,结果上板后推理速度一点没变。后来才意识到,NPU根本不认稀疏权重。所以,先搞清楚你的硬件支持什么。

4.1.2 结构化剪枝

这才是嵌入式工程师该关注的重点。结构化剪枝直接删除整个通道或卷积核,模型结构变了,参数量和计算量都实打实地减少。

常用的方法有:

  • 基于BN层缩放因子剪枝:训练时给BN层的gamma加L1正则,训练完把gamma小的通道剪掉
  • 基于梯度重要性剪枝:用梯度乘以权重作为重要性指标
  • 基于特征图相关性剪枝:计算通道间的冗余度,合并相似通道
# BN层剪枝的核心思路
def prune_by_bn_gamma(model, prune_ratio=0.3):
    # 收集所有BN层的gamma值
    gammas = []
    for module in model.modules():
        if isinstance(module, torch.nn.BatchNorm2d):
            gammas.extend(module.weight.data.abs().cpu().numpy())
    
    # 找到阈值
    threshold = np.percentile(gammas, prune_ratio * 100)
    
    # 剪掉gamma小于阈值的通道
    for module in model.modules():
        if isinstance(module, torch.nn.BatchNorm2d):
            mask = module.weight.data.abs() > threshold
            # 实际剪枝需要重建网络结构
            ...

个人经验:BEV模型的Backbone部分剪枝率可以到40%-50%,但Neck和Head部分建议控制在20%以内。因为BEV特征融合本身就需要足够的通道数来保持空间信息,剪太狠了检测精度掉得很快。

4.2 模型量化:PTQ vs QAT

量化,就是把FP32的模型变成INT8甚至INT4。为什么能这么做?因为神经网络对噪声有天然的鲁棒性。我习惯把量化比作「用低分辨率相机拍照」——虽然细节少了,但主体内容还在。

4.2.1 训练后量化(PTQ)

PTQ是最简单粗暴的方式:训练好的模型,直接拿校准集跑一遍,统计出每层激活值的min/max,然后做线性映射。

# PTQ示例(使用PyTorch)
import torch.quantization as quant

# 准备量化配置
model.qconfig = quant.get_default_qconfig('fbgemm')
# 或者用per_channel量化
# model.qconfig = quant.default_per_channel_qconfig

# 准备量化
model_prepared = quant.prepare(model, inplace=False)

# 校准:用少量数据跑一遍
with torch.no_grad():
    for data in calib_loader:
        model_prepared(data)

# 转换量化模型
model_quantized = quant.convert(model_prepared, inplace=False)

PTQ的优点是快,缺点也很明显——精度损失不可控。特别是BEV模型中的一些敏感层,比如Transformer的QKV投影,量化后误差会放大。

我曾经踩过的坑:有一次做BEV模型的PTQ,校准集用了500张图片,结果量化后mAP掉了8个点。后来发现是校准集分布和实际场景不匹配。建议校准集至少覆盖所有典型场景,而且数量不要少于1000张。

4.2.2 量化感知训练(QAT)

QAT的思路是:在训练过程中模拟量化误差,让模型学会适应低精度。说白了就是「在训练时就告诉模型:你以后要过苦日子了,提前适应一下」。

# QAT示例
model.qconfig = quant.get_default_qat_qconfig('fbgemm')
model_prepared = quant.prepare_qat(model, inplace=False)

# 正常训练,但学习率要调小
optimizer = torch.optim.SGD(model_prepared.parameters(), lr=1e-4)
for epoch in range(num_epochs):
    for data, target in train_loader:
        output = model_prepared(data)
        loss = criterion(output, target)
        loss.backward()
        optimizer.step()

# 最后转换为量化模型
model_quantized = quant.convert(model_prepared, inplace=False)

QAT的精度通常比PTQ高1-3个点,但训练时间翻倍。而且要注意:QAT训练时学习率要调小,一般用原始训练学习率的1/10。

4.2.3 BEV模型量化的特殊挑战

BEV模型和普通分类模型不一样,有几个特别容易「翻车」的地方:

模块 量化难点 我的建议
View Transformer 特征映射涉及大量插值操作,量化误差累积 保留FP16,或者用per-tensor量化
BEV Encoder 多尺度特征融合,不同尺度的激活值范围差异大 使用per-channel量化,或者分尺度单独校准
Detection Head 回归分支对精度敏感 建议保留FP32,或者用混合精度(INT8+FP16)

核心经验:BEV模型量化时,不要一股脑全量化。我习惯的做法是:Backbone用INT8,Neck用INT8+per-channel,Head的回归分支保留FP16。这样精度损失可以控制在1%以内,推理速度提升3-4倍。

4.3 剪枝+量化联合优化

剪枝和量化不是孤立的,它们可以叠加使用。但顺序很重要:先剪枝,后量化

为什么?因为剪枝后的模型结构变了,量化需要重新校准。如果先量化再剪枝,剪枝会破坏量化参数的统计分布。

# 联合优化流程
1. 训练原始模型(FP32)
2. 结构化剪枝(比如剪掉30%的通道)
3. 微调剪枝后的模型(少量epoch)
4. PTQ或QAT量化
5. 最终部署

我做过一个实验:一个BEV模型,剪枝40%后mAP掉了2个点,再量化到INT8又掉了1个点,总共掉3个点。但模型大小从120MB降到了15MB,推理速度从30ms降到了8ms。这个trade-off,我觉得值。

一个小技巧:剪枝和量化之间一定要做微调。哪怕只微调10个epoch,也能把精度拉回来1-2个点。我习惯用原始学习率的1/100做微调,效果最好。

4.4 实战建议与避坑指南

最后,总结几条我踩过坑后得出的经验:

  1. 先分析,再动手:用profiling工具看看模型哪层最耗时,哪层参数最多。剪枝和量化都要有的放矢。
  2. 逐层验证:不要一次性全量化。先量化一层,跑一下精度,没问题再量化下一层。这样出了问题好定位。
  3. 保留一个FP32的baseline:每次优化后都和baseline对比,确保精度损失在可接受范围内。
  4. 硬件适配:不同芯片对量化的支持不一样。高通、地平线、黑芝麻的量化工具链各有差异,一定要用芯片厂商提供的工具做最终验证。

嗯,模型轻量化这部分就讲到这里。下一讲我们会聊聊BEV模型在嵌入式平台上的部署实战,包括ONNX导出、TensorRT优化、以及多线程流水线设计。到时候见。