3. 模型剪枝技术

模型剪枝,说白了就是给模型「减肥」。VLM 动辄几十亿参数,部署到边缘设备上,显存和延迟都是大问题。我最早接触剪枝是在一个车载视觉项目上,模型太大,推理卡得没法用。后来靠剪枝硬是把模型体积砍掉 40%,速度翻了一倍。嗯,今天咱们就把剪枝这件事聊透。

3.1 非结构化剪枝:细粒度,但硬件不买账

非结构化剪枝,就是直接把不重要的单个权重置为零。粒度最细,理论上压缩率可以很高。

核心思想:对每个权重独立判断,小于阈值的直接干掉。

举个例子,一个 4×4 的权重矩阵,剪枝后可能变成这样:

原始权重:
[[ 0.12, -0.05,  0.98,  0.01],
 [ 0.00,  0.45, -0.02,  0.77],
 [ 0.33,  0.88, -0.01,  0.04],
 [ 0.67,  0.02,  0.11, -0.03]]

剪枝后(阈值 0.05):
[[ 0.12,  0.00,  0.98,  0.00],
 [ 0.00,  0.45,  0.00,  0.77],
 [ 0.33,  0.88,  0.00,  0.00],
 [ 0.67,  0.00,  0.11,  0.00]]

你看,很多权重变成了 0。但问题来了——这些 0 是散乱分布的,硬件没法做批量跳过。我在项目里试过,非结构化剪枝到 50% 以上,推理速度几乎没变,因为 GPU 还是得老老实实做稀疏矩阵乘法。

避坑指南:我曾经在 MobileNet 上做非结构化剪枝,压缩率做到 70%,结果推理延迟反而增加了 15%。原因就是稀疏度不够高,硬件加速器根本吃不到红利。

3.2 结构化剪枝:硬件友好,但更「暴力」

结构化剪枝,是按「块」来剪的。比如直接干掉整个通道、整个卷积核、甚至整个层。剪完之后模型结构还是规整的,硬件跑起来毫无压力。

剪枝粒度 典型操作 硬件友好度 压缩率上限
权重级 单个权重置零 低 高(理论)
通道级 删除整个通道 高 中
卷积核级 删除整个卷积核 高 中
层级 删除整层 最高 低

我个人习惯优先用通道级剪枝。为什么呢?因为 VLM 里的视觉编码器(比如 ViT)有很多注意力头,有些头其实学不到什么有用信息,直接砍掉不影响精度。

经验之谈:结构化剪枝的压缩率一般做到 20%-40% 比较安全。超过 50% 精度往往会掉得比较厉害,需要配合更精细的微调策略。

3.3 重要性评分方法:怎么判断哪些权重该剪?

剪枝的核心问题就一个:怎么知道哪个权重不重要? 我见过不少新手直接随机剪,结果模型直接崩了。下面说几种靠谱的方法。

3.3.1 L1/L2 范数评分

最简单也最常用。L1 范数就是值之和,L2 范数是平方和的平方根。值越小,说明这个权重对输出的贡献越小。

import torch
import torch.nn as nn

def l1_importance(conv_layer):
    """计算卷积核的 L1 重要性"""
    weight = conv_layer.weight.data  # shape: [out_channels, in_channels, k, k]
    importance = weight.abs().sum(dim=(1, 2, 3))  # 每个输出通道的 L1 范数
    return importance

def l2_importance(conv_layer):
    """计算卷积核的 L2 重要性"""
    weight = conv_layer.weight.data
    importance = (weight ** 2).sum(dim=(1, 2, 3)).sqrt()
    return importance

我在实际项目里发现,L1 和 L2 的排序结果其实差不多。但 L1 计算更快,所以我一般用 L1。

3.3.2 梯度重要性评分

光看权重大小不够,还得看它对 loss 的影响。梯度大的权重,说明 loss 对它敏感,剪掉会掉精度。

def gradient_importance(model, loss_fn, dataloader):
    """基于梯度的剪枝重要性评分"""
    model.train()
    importance_dict = {}
    
    for name, param in model.named_parameters():
        if param.grad is not None:
            # 梯度 × 权重的值
            importance = (param.grad.abs() * param.abs()).sum().item()
            importance_dict[name] = importance
    
    return importance_dict
关键点:梯度重要性评分比纯范数更准,但需要跑一次前向+反向,计算成本高一些。我通常先用 L1 粗筛,再用梯度精筛。

3.3.3 三种方法对比

方法 计算成本 准确度 适用场景
L1 范数 低 中 快速粗筛、大模型
L2 范数 低 中 与 L1 互补验证
梯度评分 高 高 精细剪枝、小模型

3.4 剪枝流程:从评估到执行

下面这张图是我在项目中总结的剪枝流程,你可以直接照着做。

模型剪枝标准流程 预训练模型 重要性评估 执行剪枝 微调恢复 精度不达标?回退重剪 流程说明: 1. 先加载预训练模型,不要从头训练 2. 用 L1/梯度方法评估每个通道的重要性 3. 按比例剪掉最不重要的通道,然后微调恢复精度

3.5 剪枝后的微调策略

剪完枝,模型精度一定会掉。怎么恢复?我试过三种策略,效果差别很大。

3.5.1 一次性剪枝 + 全量微调

最直接的做法:一次剪到位,然后用全部训练数据微调几个 epoch。适合小模型,大模型这么搞容易过拟合。

3.5.2 迭代剪枝 + 局部微调

每次只剪 10%-20%,然后微调几个 step,再继续剪。我比较推荐这种方式,精度掉得少,收敛也快。

def iterative_pruning(model, dataloader, target_sparsity=0.5, steps=5):
    """迭代剪枝:每次剪一点,微调一下"""
    current_sparsity = 0.0
    step_sparsity = target_sparsity / steps
    
    for step in range(steps):
        current_sparsity += step_sparsity
        
        # 1. 评估重要性
        importance = evaluate_importance(model, dataloader)
        
        # 2. 剪枝
        prune_model(model, importance, current_sparsity)
        
        # 3. 微调几个 step
        finetune(model, dataloader, num_steps=100)
        
        print(f"Step {step+1}: sparsity={current_sparsity:.2f}")
    
    return model

3.5.3 知识蒸馏辅助微调

如果剪枝后精度掉得厉害(比如掉了 5% 以上),我会用原始大模型做 teacher,剪枝后的模型做 student,做蒸馏微调。效果比单纯微调好很多。

我的习惯:剪枝比例在 30% 以内时,用迭代剪枝+局部微调就够了。超过 30%,我会上蒸馏。曾经有个项目剪了 50% 的通道,靠蒸馏把精度从 82% 拉回到 85.5%,只比原始模型低 0.3%。

3.6 实战避坑清单

  • 不要一次性剪太多:我见过有人一次剪 70%,模型直接变成随机猜测。建议每次不超过 20%。
  • 注意 BatchNorm 层:剪掉通道后,对应的 BN 参数也要一起删掉,否则推理会报错。
  • 验证集上做决策:重要性评分用训练集算,但剪枝比例的选择要在验证集上试。
  • VLM 的特殊性:视觉编码器和文本编码器的剪枝敏感度不同。我一般对视觉部分剪得多一些(30%),文本部分只剪 10%-15%。
曾经踩过的坑:我在剪枝后直接跑推理,发现显存没降多少。后来排查发现,PyTorch 的稀疏张量在 GPU 上并不会自动节省显存。结构化剪枝才能真正降显存,非结构化剪枝需要配合专门的稀疏推理库(比如 NVIDIA 的 cuSPARSE)才有收益。

好了,剪枝这部分就聊到这儿。记住一个原则:剪枝不是目的,部署才是。选对方法,控制好比例,你的 VLM 就能在边缘设备上跑得又快又准。