
自己搭过边缘端识别模型的朋友应该都有过这种体会同样的分类精度在 PC 上跑起来轻松愉快的模型一搬到手机或者嵌入式设备上内存和耗电立刻变成一道道紧箍咒。深度可分离卷积Depthwise Separable Convolution就是这几年移动端模型绕不开的核心算子MobileNet 系列全靠它撑场面Xception、EfficientNet 里也有它的影子。简单说它把一个普通的 3×3 卷积拆成“逐通道卷积 1×1 点卷积”两步把参数量和计算量压到原来的九分之一左右精度还能保持在一个能接受的范围内。这篇文章我会从数学原理、结构设计、PyTorch 实操到部署踩坑把深度可分离卷积这件事一次讲透。我最初接触这个概念的时候也觉得它不过是个“卷积的加速技巧”直到亲手在 MobileNet 基础上改结构、调训练又把它换回普通卷积做对比实验才真正明白为什么这个拆分能成立、在什么情况下它不划算、以及哪些细节会让它从“省力神器”变成“精度黑洞”。下面先从标准卷积为什么“重”说起。1. 标准卷积为什么这么“重”1.1 一次卷积同时做了两件事先别急着看深度可分离卷积把标准卷积这件事拆开看会更清楚。一个 3×3 卷积输入是 H×W×C_in 的特征图输出是 H×W×C_out每个输出通道都有一套独立的卷积核每个卷积核的尺寸是 3×3×C_in。它做的工作其实可以理解成两件事的叠加一方面在空间上聚合 3×3 邻域里的信息另一方面在通道维度上把 C_in 个通道的信息加权混合最终映射到 C_out 个输出通道。也就是说一个标准卷积把“空间特征提取”和“跨通道特征重组”绑在了同一个操作里。这种绑定在表达能力上很高效因为单次操作就能完成一次完整的特征变换但代价也很直接——每次输出一个空间位置的值都要对 C_in×3×3 个数做乘加运算如果输出通道是 C_out那这个位置的运算量就是 C_in×3×3×C_out。特征图整体转一遍计算量就是H × W × C_in × C_out × 3 × 3举一个具体例子输入 224×224×32输出 64 个通道标准 3×3 卷积一次要做的乘加次数大约是 224×224×32×64×9折算下来接近 9.27 亿次。这个数字在桌面显卡上不算什么但在手机芯片、树莓派、MCU 上就是很大的负担。1.2 参数量和内存访问也是问题除了 FLOPs参数量也不能忽视。同样上面这个例子标准卷积的卷积核参数量是 32×64×3×3约 1.8 万个。看起来不多但实际模型里往往有几十层叠加起来就非常可观。参数多不光是存储变大还有一个藏在后面的成本内存访问。推理的时候模型每一层都要把权重和中间特征图从内存搬到计算单元里参数越多、特征图越大内存读写就越频繁。移动端和边缘设备的内存带宽往往比算力更紧张很多情况下计算单元还在等数据这比多算几次乘法更影响延迟。我在跑一些轻量化网络时发现单纯比 FLOPs 下降多少并不准确因为实际推理时间很大程度受内存访问模式影响。深度可分离卷积把原来的操作拆成两步之后参数和 FLOPs 都显著下降但内存访问次数反而增加了因为中间要写出一份“临时特征图”。这也是为什么它在某些 GPU 上并没有想象中那么快——这个话题后面专门讲。回到设计思想上既然标准卷积把空间特征提取和跨通道重组绑在了一起那能不能把它们解耦让其中一个操作只做空间聚合另一个只做通道混合这就是深度可分离卷积最朴素的想法。2. 深度可分离卷积的结构拆解2.1 第一步Depthwise Convolution逐通道卷积深度可分离卷积的第一步是 Depthwise Convolution很多人也叫它 DW 卷积。它的做法很直接每个输入通道用自己独立的一个卷积核只在自己那个通道的二维平面上做卷积完全不跨通道。也就是说输入是 C_in 个 H×W 的平面输出还是 C_in 个平面只是每个平面被各自的 3×3 卷积核扫了一遍。这里要注意它不会改变通道数也不会把不同通道的信息融合到一起。如果输入是 32 个通道那这里就是用 32 个 3×3 的卷积核分别处理各自的通道。这样做的计算量是多少每个空间位置每个通道只需要做 3×39 次乘加一共 C_in 个通道所以单点计算量是 C_in×9。相比标准卷积单点需要 C_in×C_out×9 次乘加相当于只有原来的 1/C_out。Swift 收益是极其显著的。空间信息部分由 DW 卷积负责了那通道间的信息怎么办这就要靠第二步。2.2 第二步Pointwise Convolution1×1 点卷积Pointwise Convolution 实际上就是一个 1×1 卷积卷积核尺寸是 1×1×C_in输出 C_out 个通道。它不关心空间邻域只看重通道维度上的加权组合。可以理解成一个“通道 mixer”输入 32 通道输出 64 通道那么参数就是 32×64 个数每个输出通道都是输入通道的一个线性加权和。经过这样两步深度可分离卷积就完成了和标准卷积几乎一样的功能空间特征提取DW 负责 跨通道特征重组PW 负责。只不过原来是捆在一起一次做完现在拆成了两步、两个阶段。这里有一个很容易忽视的细节DW 卷积在前、PW 卷积在后这个顺序是有讲究的。如果反过来先做 1×1 卷积再做逐通道卷积效果上也能工作但计算量分配会变差。因为先做 PW 会把通道数扩展到 C_out再接 DW 时每个通道都要做一次空间卷积中间特征图更大运算量更高。MobileNet 系列一直维持 DW 在前、PW 在后的顺序确实是最划算的安排。2.3 参数和计算量到底省了多少直接上数学推导。设输入为 H×W×C_in输出为 H×W×C_out卷积核尺寸为 K×K通常 K3。标准卷积参数量C_in × C_out × K × K计算量H × W × C_in × C_out × K × K深度可分离卷积参数量C_in × K × K C_in × C_out计算量H × W × C_in × K × K H × W × C_in × C_out两者相除计算量比率约为1 / C_out 1 / (K×K)当 C_out64、K3 时这个比率大约是 1/64 1/9 ≈ 0.126也就是标准卷积的 12.6%。当 C_out256 时比率大约是 0.115基本就是 9 倍左右的缩减。用前面那个 224×224×32 → 64 的例子来算标准卷积计算量224×224×32×64×9 ≈ 9.27 亿 MACs深度可分离卷积计算量224×224×32×9 224×224×32×64 ≈ 1.17 亿 MACs1.17 亿和 9.27 亿差了正好约 8 倍。这就是 MobileNet 能轻松跑到几十毫秒的底气来源。不过这个比率公式也暴露了它的局限当 C_out 很小的时候比如某些下采样层输出只有 16 或 32 个通道节省的比例就有限。而且最后的 1/(K×K) 项是固定的即便 C_out 无限大计算量也不可能降到零DW 卷积本身那部分开销始终存在。3. PyTorch 里的实现方法与实操要点3.1 用 nn.Conv2d 的 groups 参数一步到位很多朋友刚开始会以为 PyTorch 里有什么专门的 DepthwiseSeparableConv 类其实没有。它就是 torch.nn.Conv2d 加一个参数groups。import torch import torch.nn as nn # 普通卷积 conv_normal nn.Conv2d(in_channels32, out_channels64, kernel_size3, padding1) # 深度可分离卷积 conv_depthwise nn.Conv2d(in_channels32, out_channels32, kernel_size3, padding1, groups32) conv_pointwise nn.Conv2d(in_channels32, out_channels64, kernel_size1)关键就在 groups32 这行。当 groups 等于输入通道数时每个卷积核只负责一个输入通道这就是 Depthwise Convolution。如果你对分组卷积的背景有了解会发现深度可分离卷积其实就是分组卷积的一个极端情况——每一组只有一个通道。一定要记住DW 卷积的 out_channels 必须等于 in_channels否则 groups 的数量没法整除而 PW 卷积就是一个标准 1×1 卷积不需要再设置 groups。3.2 一个可以直接用的卷积块实际项目中我通常会把这两个卷积连同 BN 和激活函数封装成一个模块方便替换网络里的标准卷积。这里直接给出一个在 ImageNet 分类任务上验证过的结构基本复刻 MobileNetV1 的 block 设计import torch import torch.nn as nn class DepthwiseSeparableConv(nn.Module): def __init__(self, in_channels, out_channels, kernel_size3, stride1, padding1): super().__init__() self.depthwise nn.Conv2d( in_channels, in_channels, kernel_sizekernel_size, stridestride, paddingpadding, groupsin_channels, biasFalse, ) self.pointwise nn.Conv2d( in_channels, out_channels, kernel_size1, biasFalse, ) self.bn1 nn.BatchNorm2d(in_channels) self.bn2 nn.BatchNorm2d(out_channels) self.relu1 nn.ReLU(inplaceTrue) self.relu2 nn.ReLU(inplaceTrue) def forward(self, x): x self.depthwise(x) x self.bn1(x) x self.relu1(x) x self.pointwise(x) x self.bn2(x) x self.relu2(x) return x这里有几个细节值得展开第一DW 卷积和 PW 卷积都设置了 biasFalse然后把 BatchNorm 放在卷积后面。这个在 PyTorch 里非常重要因为 BN 层本身就包含一个可学习的偏移项 bias如果卷积层再加 bias参数冗余不说训练时还容易引起不必要的抖动。BN 放在卷积之后、激活函数之前这是卷积网络的标准做法。第二MobileNetV1 用的是 ReLU 而不是 ReLU6。ReLU6 把输出截断到 6 以下在低精度推理时更容易保持数值稳定性这是后来在 MobileNet 中广泛使用的技巧。如果你的目标是手机端 INT8 量化部署建议把 ReLU 换成 ReLU6如果只是学术实验或者 GPU 训练用普通 ReLU 也没有太大区别。第三如果要实现下采样一般把 stride2 放在 DW 卷积上PW 卷积保持 stride1。因为 DW 卷积的参数非常少让它承担下采样的空间压缩工作计算上最省。同时要注意下采样时特征图分辨率减半如果后面需要做残差连接还要额外设计旁路否则维度不匹配。3.3 替换标准卷积时的几个大坑把网络里的标准卷积换成深度可分离卷积不是简单把 nn.Conv2d 改成两类就完事的。我遇到过很多次效果突然崩掉的情况绝大多数都是下面几个原因。通道数对齐问题是最常见的。DW 卷积输出通道数必须等于输入通道数所以如果你原本的网络结构是 32→64替换后需要先经过 DW32→32再经过 PW32→64。有人图省事直接把原来的卷积替换成 nn.Conv2d(32, 64, kernel_size3, groups32)这里 groups 根本不能整除程序直接报错。第二个坑是 padding 设置。DW 卷积是一个浅层操作感受野完全不同它对 padding 特别敏感。比如 5×5 的 DW 卷积padding 应该是 2如果沿用 3×3 卷积的 padding1输出特征图尺寸会变小而且空间信息被破坏得很厉害精度会莫名其妙地下降。第三个坑是 stride 与激活函数顺序。有些朋友在把标准卷积替换成深度可分离卷积时会把 stride 放在 pointwise 卷积上。从数学上行得通但实际效果很差因为 1×1 卷积没有空间聚合能力它做下采样就相当于直接丢像素信息损失比 3×3 卷积做 stride2 大得多。记得把 stride 留给 DW 部分。还有一个容易被忽视的点DW 卷积的可学习参数太少每个通道只有 9 个权重3×3这意味着它的表达能力非常有限。很多人在标准卷积替换后不加宽通道数结果模型容量不够精度掉得很快。MobileNet 后续版本为什么要引入宽度因子和通道扩展根本原因就在这里。4. 深度可分离卷积的变体与对比4.1 MobileNetV1 和 V2 有什么不一样MobileNetV1 把标准卷积块直接改成 DW PW每层之间连续堆叠结构上没有捷径连接。MobileNetV2 则做了两处关键修改一是引入了倒残差结构Inverted Residual二是去掉了 PW 后面的非线性激活。倒残差结构是这么设计的先用 1×1 卷积把通道数扩展 6 倍比如从 32 扩到 192再做 3×3 DW 卷积最后用 1×1 卷积把通道数压回 32。这和 ResNet 的残差结构正好相反ResNet 是先压缩通道再放大MobileNetV2 是先放大再压缩。为什么因为 DW 卷积本身表达能力弱需要先给它更丰富的通道信息让它有足够多的特征可以“挑选”。通道太少的话DW 卷积只能看到很少的信息后面的 PW 即使想混合也无米下锅。第二处修改更反直觉。一般卷积之后都要接激活函数但 MobileNetV2 在最后一个 PW 之后没有接 ReLU而是直接输出线性特征。论文里用实验说明ReLU 会把低维特征空间的信息大量破坏而最后一个 PW 的输出正好是低维的瓶颈层如果在这里强加 ReLU信息损失会很严重。这个操作叫做“线性瓶颈”Linear Bottleneck。这个细节给我留下特别深的印象因为大多数人只看结构图根本不会注意到这里没有激活。自己在复现时如果多画一个 ReLU精度直接掉一到两个点。4.2 和 ResNet 的 Bottleneck 有什么本质区别很多人问过我一个问题ResNet 的 bottleneck 也是用 1×1 卷积先降维、再 3×3 卷积、再 1×1 升维和深度可分离卷积看起来不是差不多吗确实很像但本质不同。ResNet bottleneck 的两个 1×1 卷积是为了控制 3×3 卷积的输入输出通道数从而降低计算量。它的 3×3 卷积仍然是标准卷积仍然同时混合空间信息和跨通道信息只是通道数变少了。深度可分离卷积则把 3×3 卷积彻底限制在单通道内跨通道的事全部交给 1×1 卷积。一个是“把标准卷积变小”一个是“把标准卷积拆开”角度完全不同。从信息流的角度理解更清晰ResNet bottleneck 压缩通道数的代价是丢失了一部分通道信息深度可分离卷积没有压缩通道只是把通道间的混合延后到 PW 阶段。前者改变的是“通道数量”后者改变的是“操作的分解方式”。4.3 与分组卷积的联系和边界分组卷积Grouped Convolution也很常见AlexNet 时代就出现过如今很多网络里也在用。它的做法是把输入通道分成若干组每组内部做标准卷积组与组之间不通信。当分组数等于 1 时就是标准卷积当分组数等于输入通道数时就是 DW 卷积。这样一看深度可分离卷积实际上是分组卷积在“分组数最大”时的特例外加一个 1×1 卷积来恢复通道间的通信能力。分组卷积的问题是分组数变大后组与组之间的信息完全隔离会严重影响表征能力。DW 卷积把分组推到极致然后特意补了一个 PW 卷积来解决这个问题这种补偿思路在 ShuffleNet 里也有体现——ShuffleNet 用通道混洗Channel Shuffle来让不同分组的信息流通而非使用 1×1 卷积。4.4 在现代轻量网络中的延续深度可分离卷积并没有止步于 MobileNet。EfficientNet 里的 MBConv 基本上就是 MobileNetV2 倒残差块的翻版只是加上了 Squeeze-and-Excitation 注意力机制。ConvNeXt 在向 Transformer 架构靠拢时也把 7×7 的大卷积核做成了 DW 卷积然后通过两个 1×1 卷积完成通道变换。为什么这些新模型都用 DW 卷积因为它提供了一种干净的、把空间操作和通道操作解耦的方式你可以自由调节这两部分的容量自由度比标准卷积高得多。我自己的经验是理解深度可分离卷积之后再看这些现代结构会轻松很多。所谓 Transformer 里的 MHSA、FFN其实也可以理解为一种“空间混合 通道混合”的交替结构和 DWPW 的哲学如出一辙。5. 训练与部署中的常见问题5.1 为什么换了深度可分离卷积之后精度反而更差这是最常遇到的问题。我用一张表格总结一下常见的精度下降原因典型原因表象解决方案通道数没有适当加宽模型参数量骤降但精度暴跌引入宽度因子或者手动加宽 1.5~2 倍下采样放在了 pointwise 上空间信息直接丢弃把 stride 转移到 depthwise 卷积Pointwise 之后也接了 ReLU低维特征被激活函数破坏参考 MobileNetV2瓶颈层不接激活Depthwise 没有加 padding 或者 padding 错误特征图尺寸不匹配按 kernel_size 计算 padding训练超参没调收敛慢、最终精度偏低适当调大学习率增加训练轮数还有一个很容易被忽略的原因DW 卷积的参数量和计算量分布极不均衡。每个通道的 3×3 卷积只有 10 个左右的参数但它每次推理要访问整张特征图的一个通道这在优化不充分的框架里会产生很大的内存开销。在 GPU 上如果 batch size 很小、通道数又不够多DW 卷积的计算密度太低GPU 的并行能力根本发挥不出来实际推理时间可能不比标准卷积快多少。5.2 激活函数怎么选、BN 放在哪关于激活函数和 BN 的放置我踩过一次很深的坑。最初我在复现 MobileNetV1 时习惯性地在 DW 卷积后面只加 BN不加激活又在 PW 卷积后面同时加 BN 和 ReLU。这样改完以后在 CIFAR-10 上精度掉了差不多 2 个百分点排查了很久才发现是激活放错了位置。官方结构是 DW → BN → ReLU → PW → BN → ReLU每个卷积之后都有激活。但到了 MobileNetV2最后一个 PW 后面的 ReLU 又要去掉。关键就在于需要在什么时候引入非线性通常在通道维度信息充足的地方引入在瓶颈低维处就要谨慎。如果你在做自己的变体我建议用实验来验证激活位置而不是一味照搬。BN 的位置也要注意。DW 卷积输出的每个通道独立做归一化如果 batch size 太小BN 的统计量会不稳定推理时容易出现训练和推理分布不一致的问题。在移动端部署时请把 BN 层和前面的卷积层融合掉这一步能显著减少推理延迟。PyTorch 里可以用 torch.jit 的 freeze 或者 onnx 转换工具自动完成融合。5.3 部署时的硬件陷阱深度可分离卷积在纸面上的 FLOPs 很漂亮但实际部署时会遇到一个现实问题硬件对 DW 卷积的优化往往不如标准卷积成熟。GPU 上标准卷积的矩阵乘法非常规整cuDNN 可以把它映射成高度优化的 GEMM 运算。DW 卷积每个通道只有很少的计算量很难用矩阵乘法高效表达反而更像一个逐元素的组合操作访存开销占比极大。我在 Jetson 系列设备上试过当通道数小于 64 时DWPW 的耗时甚至可能高于单个标准卷积因为计算量太小、调度开销反而成为主要矛盾。CPU 上的情况稍微好一些因为 MobileNet 系列本身是为 CPU 设计的很多推理引擎比如 TFLite、ONNX Runtime、TensorRT对 DW 卷积做了专门优化。但如果你的目标是 MCU 或者极低功耗芯片要特别注意核心库对 groups 大于 1 的卷积支持程度。有些芯片的 NPU 其实只对标准卷积做了深度优化DW 卷积是通过通用算子“模拟”出来的速度会非常感人。选型前最好先跑一份基准测试不要只看 FLOPs 的数字就做决定。5.4 训练调参的几条实用建议最后分享几条我在训练深度可分离卷积网络时积累的经验。学习率方面深度可分离卷积模块通常比标准卷积更难训练因为 DW 层的参数太少、梯度信号相对弱。我一般会在替换后把初始学习率调高一倍或者使用余弦退火让模型在后期有更多机会精细化调整。如果发现 loss 下降得特别慢先检查一下 DW 层的梯度是否正常再考虑加宽通道数。正则化方面深度可分离卷积模型的参数总量很小过拟合风险反而比大模型低但也不代表可以不加正则。我在 MobileNet 上常用的配置是 weight decay 设在 4e-5 到 1e-4 之间配合 label smoothing 使用效果比标准卷积网络上的默认配置要稳定一些。还有一个很实用的经验数据增强策略可以稍微激进一点。因为模型本身参数少、容量小更强的增强方式随机裁剪、颜色抖动、MixUp 等能帮助它学到更稳健的特征。我在 CIFAR-10 上把 MobileNetV2 的精度从 92% 提升到接近 95%最大的功臣不是结构改动而是数据增强和训练策略。最后再提醒一个细节如果要把深度可分离卷积网络导出到 ONNX 或者 TensorRT记得检查导出的模型里是否出现了两个相邻的 1×1 卷积。我曾经遇到过一次 DWPW 块被优化器错误融合的问题导致推理结果和 PyTorch 不一致最后发现是 BN 融合和通道重排的配合出了问题。导出前先在本地跑一遍输入输出对比这个动作别省。我自己在实际项目里用过很多次深度可分离卷积之后最大的体会是它不是一个“免费的午餐”而是一种结构性取舍。它把计算量和参数量降下来代价是引入了额外的访存开销、更弱的单层表达能力以及更复杂的工程优化空间。只有理解了这些取舍背后的原理在替换标准卷积时才知道该往哪个方向调遇到精度下降或者速度不达标的时候也不会一头雾水。如果你正在做移动端识别或者边缘推理的项目强烈建议先用一个小模型把 DWPW 这个组合完整跑通再逐步扩大规模。踩几次坑之后你会对这条卷积路线形成自己的手感。