
1. 为什么还要回头聊ShuffleNetV1如果你最近在翻轻量级骨干网络的老论文大概率会看到一堆新名字MobileNetV3、EfficientNet-Lite、GhostNet、MobileViT……相比之下ShuffleNetV1 显得有点上古。但我自己这两年做端侧部署项目时反而越来越频繁地把它重新捡起来用。原因很直接它的结构足够简单算子足够干净量化友好而且在很多中低端芯片上实测的延迟表现比一些纸面FLOPs更低的新模型还要稳。这篇文章不是论文翻译也不是把官方代码抄一遍。我想做的是把 ShuffleNetV1 拆到骨头缝里——它到底解决了什么问题、每个设计选择背后的动机是什么、用 PyTorch 复现时哪些地方容易写错、以及我在实际训练和部署中踩过的那些坑。适合已经能读懂 ResNet、MobileNet 基本结构想进一步理解轻量网络设计逻辑的读者也适合手头有端侧推理需求、正在做模型选型的工程师。先把结论摆在这ShuffleNetV1 的核心贡献就两个词——分组卷积Group Convolution和通道混洗Channel Shuffle。前者把计算量压下来后者把被分组割裂的通道信息重新打通。理解了这两点整个网络就没有秘密了。下面我会从设计动机开始一层层往下拆。2. 分组卷积省了算力却留下一个致命副作用2.1 从标准卷积的算力账算起要理解 ShuffleNet 为什么这么设计得先把卷积的算力账算清楚。假设输入特征图通道数为 $C_{in}$输出通道数为 $C_{out}$卷积核空间尺寸为 $K \times K$输出特征图的空间尺寸为 $H \times W$。标准卷积的乘加运算量MACs大致是$$ \text{MACs} H \times W \times C_{in} \times C_{out} \times K \times K $$这个式子里$C_{in} \times C_{out}$ 是增长最快的一项。当通道数从 256 涨到 512、再涨到 1024 时算力是平方级往上翻的。深度可分离卷积Depthwise Separable Convolution的思路是把标准卷积拆成 depthwise 和 pointwise 两步把 $C_{in} \times C_{out}$ 这一项拆掉算力能降到原来的 1/8 到 1/9。MobileNetV1 走的就是这条路。但 depthwise 卷积有个工程上的问题在很多推理框架和硬件上depthwise 算子的计算密度太低访存开销占比高实际加速比远达不到理论值。尤其是在早期的一些移动端 GPU 和 DSP 上depthwise 甚至比普通卷积还慢。这就是 ShuffleNet 选择另一条路的原因——它没有用 depthwise而是用分组卷积。2.2 分组卷积到底省在哪分组卷积不是新东西AlexNet 当年就是因为显存不够才把网络拆成两组。它的做法是把输入通道分成 $g$ 组每组独立做卷积输出也对应分成 $g$ 组。此时算力变成$$ \text{MACs}{group} H \times W \times \frac{C{in}}{g} \times \frac{C_{out}}{g} \times K \times K \times g \frac{1}{g} \times \text{MACs}_{standard} $$也就是说分组数 $g$ 直接把算力砍到原来的 $1/g$。相比 depthwise相当于 $g C_{in}$ 的极端情况分组卷积的 $g$ 是可以调的工程上更灵活计算密度也更高硬件更友好。但天下没有免费的午餐。分组卷积的副作用非常明显每组卷积只能看到自己那组输入通道组与组之间没有任何信息交流。如果整个网络从头到尾都用分组卷积那相当于把网络切成了 $g$ 个互不相干的子网络并行跑表达能力会严重受损。这个问题在堆叠多个分组卷积层之后会累积放大——第一层分组后信息就被隔离第二层、第三层继续隔离最后输出虽然通道数够但信息是碎片化的。2.3 一个生活化的类比你可以把分组卷积想象成一个公司把员工分成几个小组每组只负责自己那块业务组间不沟通。短期看效率高算力省了但时间一长各组信息不通整体协作就出问题。ShuffleNet 的解法不是取消分组而是在每组卷积之后强制做一次跨组轮岗让信息重新流动起来。这个轮岗操作就是 Channel Shuffle。3. Channel Shuffle一个零参数零算力的巧妙操作3.1 混洗的直观理解Channel Shuffle 的目标很朴素把分组卷积输出的、按组排列的通道打散重排让下一层卷积的每个组都能拿到来自上一层所有组的信息。举个具体例子。假设上一层分组卷积输出 6 个通道分成 3 组通道排列是组1: [a1, a2] 组2: [b1, b2] 组3: [c1, c2]如果直接送进下一层分组卷积还是 3 组那么下一层的组1只能看到[a1, a2]组2只能看到[b1, b2]信息依然隔离。Channel Shuffle 做的事情是把这个排列重新洗成组1: [a1, b1] 组2: [c1, a2] 组3: [b2, c2]这样下一层的每个组都能同时看到来自 a、b、c 三个原始组的信息。信息就打通了。3.2 用 reshape 和 transpose 实现Channel Shuffle 的实现非常优雅本质上就是一次 reshape transpose reshape。假设特征图形状是 $(N, C, H, W)$分组数为 $g$把通道维 reshape 成 $(N, g, C/g, H, W)$转置成 $(N, C/g, g, H, W)$再 reshape 回 $(N, C, H, W)$。整个过程没有任何可学习参数也没有乘加运算只有内存重排。这也是它比加一个 1x1 卷积做通道融合更划算的地方——1x1 卷积虽然也能融合通道但它有算力开销而 shuffle 是纯搬运。注意transpose 之后如果直接 reshape在 PyTorch 里必须保证内存连续contiguous否则 reshape 会报错或者产生意料之外的拷贝。这是复现时第一个容易踩的坑后面会详细讲。3.3 为什么是均匀分组 均匀混洗论文里 shuffle 的分组数和卷积的分组数是同一个 $g$。有人会问能不能用不同的分组数或者做更复杂的混洗模式理论上可以但实测下来收益不明显反而增加了实现复杂度和调参负担。均匀分组 均匀混洗的好处是每个输出组拿到的输入通道数完全相等信息分布最均匀且实现最简单。这也是工程上最容易被接受的设计——简单、可预测、好优化。4. ShuffleNet Unit 的三种形态与演进逻辑4.1 基本单元从 ResNet 瓶颈结构改造而来ShuffleNet 的基本单元Unit脱胎于 ResNet 的瓶颈结构Bottleneck但做了三处关键改造把第一个 1x1 卷积换成1x1 分组卷积 Channel Shuffle把标准的 3x3 卷积换成3x3 深度可分离卷积注意这里用了 depthwise因为 3x3 空间卷积用 depthwise 更省把最后的 1x1 卷积换成1x1 分组卷积但后面不再接 shuffle因为下一个 unit 的开头会 shuffle。整体结构是1x1 GConv → Shuffle → 3x3 DWConv → 1x1 GConv外面套一个残差连接。这个设计里1x1 卷积承担了主要的通道融合和升维降维任务用分组卷积来省算力3x3 卷积负责空间信息提取用 depthwise 来省算力。两者分工明确。4.2 步长为 2 的降采样单元当需要下采样时特征图空间尺寸减半、通道数翻倍ShuffleNet 用了两个小技巧在 3x3 depthwise 卷积上设置stride2在残差分支上不再用 1x1 卷积做投影而是用一个stride2的 3x3 平均池化然后 concat 到主分支输出上。为什么用 concat 而不是 add因为下采样时主分支和残差分支的输出通道数可能不一致add 要求形状完全对齐而 concat 更灵活。而且 concat 之后通道数翻倍正好符合下采样时通道扩张的需求。这个设计比 ResNet 的1x1 卷积投影 add更省算力。4.3 三种配置g1、g2、g3 的取舍论文给出了三档分组数配置对应不同规模的模型配置分组数 g输出通道各 stage特点ShuffleNet 1x1144, 288, 576相当于不用分组算力最大ShuffleNet 0.5x2120, 240, 480算力与精度平衡ShuffleNet 0.25x396, 192, 384最省算力精度最低这里有个反直觉的点分组数越大同样通道数下算力越省但精度下降也越明显。因为分组越多每组能看到的输入通道越少单层表达能力越弱。所以 g3 的配置虽然最轻但在复杂任务上往往力不从心。我自己的经验是g2 是大多数场景下的甜点g1 适合对精度要求高、算力相对宽裕的场景g3 只在极端受限时考虑。5. PyTorch 复现从零写出可训练的 ShuffleNetV15.1 环境与整体骨架我用的是 PyTorch 1.x 以上版本CUDA 可选。整个网络分成几个部分一个conv1普通卷积做初始下采样三个 stage 堆叠 ShuffleNet Unit最后全局池化 全连接分类。先定义两个基础模块channel_shuffle函数和ShuffleUnit类。import torch import torch.nn as nn import torch.nn.functional as F def channel_shuffle(x, groups): batchsize, num_channels, height, width x.size() channels_per_group num_channels // groups # reshape: (N, C, H, W) - (N, g, C/g, H, W) x x.view(batchsize, groups, channels_per_group, height, width) # transpose: (N, g, C/g, H, W) - (N, C/g, g, H, W) x torch.transpose(x, 1, 2).contiguous() # flatten back x x.view(batchsize, -1, height, width) return x这段代码看着简单但有两个细节必须注意。第一view之前要保证输入是连续的否则会报错第二transpose之后必须.contiguous()否则后面的view会失败。我见过不少复现版本在这里翻车训练时直接抛RuntimeError: view size is not compatible。5.2 ShuffleUnit 的完整实现下面是不带下采样的基本单元。注意分组卷积的写法nn.Conv2d(in_channels, out_channels, kernel_size, groupsgroups)其中in_channels和out_channels都必须能被groups整除。class ShuffleUnit(nn.Module): def __init__(self, in_channels, out_channels, groups2, downsampleFalse): super().__init__() self.downsample downsample self.groups groups mid_channels out_channels // 4 if downsample: # 主分支1x1 GConv - shuffle - 3x3 DWConv(stride2) - 1x1 GConv self.branch_main nn.Sequential( nn.Conv2d(in_channels, mid_channels, 1, groupsgroups, biasFalse), nn.BatchNorm2d(mid_channels), nn.ReLU(inplaceTrue), nn.Conv2d(mid_channels, mid_channels, 3, stride2, padding1, groupsmid_channels, biasFalse), nn.BatchNorm2d(mid_channels), nn.Conv2d(mid_channels, out_channels, 1, groupsgroups, biasFalse), nn.BatchNorm2d(out_channels), ) # 残差分支3x3 平均池化(stride2) self.branch_shortcut nn.AvgPool2d(3, stride2, padding1) else: self.branch_main nn.Sequential( nn.Conv2d(in_channels, mid_channels, 1, groupsgroups, biasFalse), nn.BatchNorm2d(mid_channels), nn.ReLU(inplaceTrue), nn.Conv2d(mid_channels, mid_channels, 3, stride1, padding1, groupsmid_channels, biasFalse), nn.BatchNorm2d(mid_channels), nn.Conv2d(mid_channels, out_channels, 1, groupsgroups, biasFalse), nn.BatchNorm2d(out_channels), ) def forward(self, x): if self.downsample: out self.branch_main(x) shortcut self.branch_shortcut(x) return F.relu(torch.cat([out, shortcut], dim1)) else: out self.branch_main(x) return F.relu(out x)这里有几个设计点值得说明。第一mid_channels out_channels // 4是瓶颈比例和 ResNet 的 1/4 一致目的是先降维再升维减少 3x3 卷积的算力。第二3x3 卷积的groupsmid_channels也就是 depthwise因为空间卷积用 depthwise 最省。第三下采样时残差分支用平均池化而不是 1x1 卷积省算力且效果不差。5.3 组装完整网络把 stage 拼起来。每个 stage 的第一个 unit 负责下采样后面的 unit 保持通道数不变。class ShuffleNetV1(nn.Module): def __init__(self, num_classes1000, groups2, width_mult1.0): super().__init__() self.groups groups # 根据 width_mult 调整通道数 def c(ch): return int(ch * width_mult) self.conv1 nn.Sequential( nn.Conv2d(3, c(24), 3, stride2, padding1, biasFalse), nn.BatchNorm2d(c(24)), nn.ReLU(inplaceTrue), nn.MaxPool2d(3, stride2, padding1), ) self.stage2 self._make_stage(c(24), c(240), 4, groups) self.stage3 self._make_stage(c(240), c(480), 8, groups) self.stage4 self._make_stage(c(480), c(960), 4, groups) self.global_pool nn.AdaptiveAvgPool2d(1) self.fc nn.Linear(c(960), num_classes) def _make_stage(self, in_ch, out_ch, num_units, groups): units [ShuffleUnit(in_ch, out_ch, groups, downsampleTrue)] for _ in range(num_units - 1): units.append(ShuffleUnit(out_ch, out_ch, groups, downsampleFalse)) return nn.Sequential(*units) def forward(self, x): x self.conv1(x) x self.stage2(x) x self.stage3(x) x self.stage4(x) x self.global_pool(x) x x.view(x.size(0), -1) return self.fc(x)注意 stage 的通道数配置stage2 输出 240stage3 输出 480stage4 输出 960。这是 1x 配置下的数值和论文表格略有出入论文里 1x 是 144/288/576因为论文的表格是按分组数不同分别列的。实际复现时通道数要和分组数匹配——out_channels必须能被groups整除否则分组卷积会报错。我一般会在_make_stage里加一个断言检查。5.4 训练时的几个关键设置网络搭好只是第一步训练配置同样重要。ShuffleNet 这类轻量网络有几个训练上的特点学习率不能太大。轻量网络参数少对学习率更敏感。我一般用 0.1 起步batch size 256配合 cosine 退火比 step 衰减更稳。权重衰减用 4e-5 左右比 ResNet 常用的 1e-4 小一些因为轻量网络更容易过拟合。数据增强要克制。MixUp、CutMix 这类强增强在轻量网络上有时反而拖慢收敛建议先用基础的随机裁剪 翻转跑通再逐步加。BN 的 momentum 可以调到 0.01默认 0.1 在轻量网络上可能让统计量更新太快影响小 batch 下的稳定性。6. 实测中那些论文不会告诉你的坑6.1 分组卷积的通道整除陷阱这是最常见的报错来源。分组卷积要求in_channels % groups 0且out_channels % groups 0。当你用width_mult缩放通道数时很容易出现int(240 * 0.5) 120120 能被 2 整除没问题但如果width_mult0.3int(240*0.3)7272 能被 2 和 3 整除但某些中间通道可能就不行了。我的做法是在缩放后强制对齐到 groups 的倍数def c(ch): v int(ch * width_mult) return v - (v % groups)这样能保证任何 width_mult 下都不会因为整除问题崩掉。6.2 transpose 后的 contiguous 问题前面提过channel_shuffle里 transpose 之后必须.contiguous()。但还有一个更隐蔽的坑如果你在forward里对 shuffle 的输出做了 inplace 操作比如relu(inplaceTrue)而 shuffle 内部又涉及 view可能会因为内存共享导致梯度计算出错。我的建议是shuffle 函数内部不要做任何 inplace 操作输出保持独立内存。实测下来加一个.contiguous()带来的开销很小但能避免一堆莫名其妙的梯度问题。6.3 下采样单元 concat 后的通道数下采样单元里主分支输出out_channels残差分支经过平均池化后通道数还是in_channels。concat 之后通道数是out_channels in_channels。如果你在 stage 的第一个 unit 里设置in_channels24, out_channels240那 concat 后是 264而不是 240。下一个 unit 的输入通道数必须按 264 来算否则会形状不匹配。这是很多人第一次复现时最容易忽略的地方。我的处理方式是在_make_stage里显式记录每个 unit 的实际输出通道数而不是想当然地用out_ch。6.4 推理部署时的算子融合训练完导出模型时BN 可以融合进前面的卷积这是标准操作。但 ShuffleNet 里有个特殊点分组卷积 BN 的融合要小心因为分组卷积的权重形状是(out_channels, in_channels/groups, K, K)融合时 BN 的参数要按输出通道广播逻辑和普通卷积一样但有些推理框架对分组卷积的融合支持不完善建议导出后做一次数值对齐验证。另外Channel Shuffle 在部署时通常会被编译成 reshape transpose 的组合某些框架对 transpose 的支持不好可能会退化成低效实现这时候可以考虑用自定义算子或者把 shuffle 合并到相邻的 reshape 里。7. 和 MobileNet 系列的横向对比与选型建议7.1 结构差异的本质ShuffleNetV1 和 MobileNetV1 都是轻量网络但路线不同。MobileNetV1 全程用 depthwise separable convolution结构非常规整ShuffleNetV1 用分组卷积 shuffle depthwise 的混合结构更花哨一些。从算力上看同等精度下 ShuffleNetV1 的 FLOPs 通常更低但实际延迟取决于硬件对分组卷积和 depthwise 的支持程度。维度ShuffleNetV1MobileNetV1核心算子分组卷积 shuffle depthwisedepthwise separable结构规整度较复杂非常规整理论 FLOPs更低略高硬件友好度依赖分组卷积支持depthwise 支持普遍较好量化友好度好好7.2 什么场景选 ShuffleNet我的经验是如果你的目标硬件对分组卷积有良好支持比如某些 NPU 和 DSP且算力预算非常紧张ShuffleNetV1 是很好的选择。如果目标硬件是通用 GPU 或对 depthwise 优化很好的移动 GPUMobileNet 系列可能更省心。另外ShuffleNetV1 的结构对量化比较友好因为它的算子类型少、没有奇怪的激活函数INT8 量化后的精度损失通常可控。7.3 后续版本的演进方向ShuffleNetV2 在 V1 的基础上提出了四条轻量网络设计准则核心思想是不要只看 FLOPs要看内存访问成本MAC。V2 去掉了 V1 里的一些低效设计比如 concat 后的通道分裂结构更规整。如果你现在从零开始做项目我其实更推荐直接用 V2 或 V2 的变体。但理解 V1 依然是理解整个 ShuffleNet 系列的必经之路因为 V2 的很多改进都是针对 V1 的痛点来的。8. 我个人的一些实操体会最后分享几个我在实际项目里总结的小经验都是文档里不会写的。第一训练 ShuffleNet 时前几个 epoch 的 loss 震荡比 ResNet 明显这是分组卷积和 shuffle 带来的信息流动特性导致的不用慌一般 5 个 epoch 之后就会稳定下来。如果一直震荡优先检查学习率和 BN 的 momentum。第二做消融实验时shuffle 操作本身带来的精度提升比想象中大。我试过把 shuffle 去掉只保留分组卷积在同等算力下精度掉了将近 3 个点。这说明通道信息交流对轻量网络至关重要分组省下来的算力不能以牺牲信息流动为代价。第三部署前一定要在目标硬件上实测延迟不要只看 FLOPs。我遇到过 FLOPs 更低的配置在实际芯片上反而更慢的情况原因是分组数太大导致算子启动开销占比过高。这种时候把 groups 从 3 降到 2FLOPs 略增但延迟反而下降。第四如果要做迁移学习冻结前面的 stage 时要注意 shuffle 的位置。因为 shuffle 是跨通道操作冻结某个 stage 时它的输出分布会变化后面 stage 的 BN 统计量需要重新校准。我的做法是冻结后先用小学习率跑一两个 epoch 让 BN 适应再正常训练。这些经验不一定适用于所有场景但至少能帮你少走一些弯路。ShuffleNetV1 虽然老但它把分组省算力 混洗通信息这个思路讲得很清楚理解了它再看后面那些轻量网络的新设计会顺畅很多。