ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

YOLOv8换EfficientNetV2骨干:端侧部署延迟与int8量化双优化实践

YOLOv8换EfficientNetV2骨干:端侧部署延迟与int8量化双优化实践

把 YOLOv8 默认的 CSPDarknet53 主干换成 EfficientNetV2 的均质性网络架构,这件事我前后折腾了将近半年。起因很现实:一个工业外观检测项目要求在边缘设备上跑进 15ms 的单帧延迟,我拿 YOLOv8n 直接部署,精度达标了,但端侧延迟就是压不下去。后来逐算子分析才发现,Darknet 系主干里那些 CSP 分叉合并、C2f 的多路 concat,在 GPU 上很舒服,到了强 NPU 和 FPGA 上就成了各种 layout 转换的累赘。而 EfficientNetV2 这种从头到尾几乎只用 Fused-MBConv 和 MBConv 两种积木搭起来的均质网络,特征表达力强、预训练权重成熟,理论上很适合做检测主干的平替。这篇文章就是我在这半年里关于模型结构改造、训练调参、部署量化、性能对比的全部实践记录,主要写给被端侧部署精度和延迟双重夹击的检测方向工程师,也适合想理解"为什么换个主干会有这么大差别"的入门读者。

1. 为什么向 YOLOv8 默认主干动手:端侧延迟与 CSP 结构的账本

1.1 一个具体的部署场景

先把这个项目的背景说清楚。当时要检测的是金属工件表面的八类外观缺陷,包括划痕、凹坑、麻点、锈蚀、污渍、压伤、涂层不均和边缘破损。训练集一万一千多张,测试集两千八百张,检测目标普遍偏小,很多缺陷在 640 分辨率下只有三四十个像素。我第一版直接用了 YOLOv8n,配合 COCO 预训练权重,在数据集上 mAP50 到了 88 出头,对这个体量的任务来说完全够用。

问题出在部署环节。目标设备是一块端侧 NPU 开发板,供应商给的 SDK 声称支持大部分常见卷积算子,但实际跑起来,YOLOv8n 单帧推理要 16.8ms。客户要求 15ms 以内,差了两个毫秒,就是这么尴尬。

我把模型导出成 ONNX,用自带 profiler 逐层看了耗时分布,发现耗时的重灾区集中在 C2f 模块和 CSP 结构的 concat 层附近。NPU 对纯卷积链非常友好,但对"分叉→计算→再拼接"这种结构,每做一次 concat 就要做一次内存搬运,还要频繁切换算子上下文。YOLOv8 为了强化多尺度信息融合,把大量时间花在这种结构上,GPU 上不觉得,换到端侧就原形毕露了。

1.2 CSPDarknet 系结构在 GPU 与 NPU 上的不对称表现

CSPDarknet 家族的思路是:把特征图在通道维上拆成两部分,一部分进残差块做深度变换,另一部分直连,最后再 concat 起来。YOLOv8 的 C2f 更进一步,把多个 bottleneck 的输出全部 concat。这种设计对训练来说非常有效:梯度可以通过多条短路回传,深层信息不容易丢失,收敛速度也快。

但工程上有一个隐藏成本。每次 concat 之后,后续算子的输入通道顺序是"残差支路特征 + 直连支路特征",这种拼接后的特征图在不同硬件上的存储布局很不一样。GPU 的显存带宽充裕、CUDA 核心多,concat 几乎是零成本。端侧 NPU 就不同了,很多芯片的内存控制器要求通道方向连续存储,concat 往往要触发一次完整的特征图重排,算力没增加,延迟全花在搬数据上。

我统计过,在以 YOLOv8n 为例的整网里,backbone 部分贡献了大约六成以上的推理延迟,而其中跟 concat/重排相关的开销又占了 backbone 延迟的三成。也就是说,哪怕不动 neck 和 head,只换一个更加"直来直去"的主干,端侧延迟就有可能压进目标区间。

1.3 我对替换主干的预期目标

动手之前我给自己定了三条硬指标。第一,精度不能比原版 YOLOv8n 低,最好还能涨一点;第二,端侧延迟必须压进 15ms;第三,主干替换要尽量少改动 neck 和 head,降低后续维护成本。前两条好理解,第三条其实很重要——YOLOv8 的检测头和 PAN 颈部已经是一个非常成熟的组合,贸然全改,调试成本会成倍上升。

选 EfficientNetV2 也经历过比较。我试过 ResNet、RepVGG、GhostNet、MobileNetV4 这几个候选。ResNet 精度不错但结构太老、层数深,端侧访存同样不友好;RepVGG 在 GPU 上重参数化之后很快,但 NPU 对重参数化的支持参差不齐;MobileNetV4 精度差了口气;只有 EfficientNetV2 在 ImageNet 上精度高、结构规律性强、预训练权重又多,一眼看过去就是最合适的底子。至于标题里说的"均质性网络架构",当时我还只当它是一个学术形容词,后来在训练和量化阶段才真正体会到这个词的分量。

2. 均质性网络架构的拆解:Fused-MBConv、SE 与渐进式训练的协同

2.1 两种核心积木

EfficientNetV2 论文原文并没有用"均质性"这个说法,但从工程视角看,这个词比任何其他说法都更准确。整条主干从 stem 到最后的 stage,只用两类基础模块堆叠:Fused-MBConv 和带 Squeeze-Excitation(SE)的 MBConv。

Fused-MBConv 解决的是硬件效率问题。传统 MBConv 先做 1×1 升维、再做 3×3 深度可分离卷积,但在很多加速器上,深度可分离卷积的算力利用率很低,因为深度维的并行度太小,硬件流水线喂不满。EfficientNetV2 的思路很直接:在网络的早期阶段,把 1×1 升维和 3×3 深度卷积合并成一个普通的 3×3 卷积,这样整体计算量没省多少,但硬件执行效率大幅提升。后期特征图分辨率已经降下来了,深度可分离卷积的浪费就不那么明显,于是再换回带 SE 的 MBConv,用注意力机制去精调通道响应。

SE 模块本身也值得多说一句。它先对特征图做全局平均池化,压缩成每个通道一个标量,再过两个小全连接层学出每个通道的权重,最后把权重乘回原来的特征图。这个操作对分类任务提升显著,对检测任务同样有帮助——比如划痕和污渍在纹理上有差异,SE 能自动把响应集中到更关键的通道上,后续的检测头拿到的是已经被"重标定"过的特征。

2.2 均质堆积对算子融合和量化的影响

我们常说的"均质性"主要体现在三层。第一,模块种类少,全网络只有 Fused-MBConv 和 MBConv 两大族;第二,每个模块内部结构高度一致,都是"升维→卷积/深度卷积→SE→降维"的固定流水;第三,通道数随 stage 单调递增,没有分支合并、没有跨 stage 跳跃连接,计算图是一条非常规整的链。

这条链给部署带来两个意想不到的好处。第一个好处是算子融合简单。编译器做图优化时,可以把相邻的"卷积+BN+激活"融合成一个算子,因为每个模块边界都是规整的,遍历起来非常机械。CSPDarknet 那种结构,图优化器经常要在 concat 节点前停下来,导致融合率上不去。我实际测过,同样用一套图优化配置,EfficientNetV2 主干被融合的算子比例比 Darknet 主干高出将近两成。

第二个好处在 int8 量化上体现得更明显,后面第 5 节我会用数据说明。这里先提一句:均质结构里每个卷积层的输入分布形态都差不多,校准得到的量化参数彼此接近,整体量化误差就小。而 Darknet 里 concat 层的输入来自两条分布差异很大的支路,拼在一起后激活值范围被拉得很大,量化时低位宽根本覆盖不住。

2.3 预训练与渐进式训练为什么重要

EfficientNetV2 另一个容易被忽略的资产是它的预训练策略。论文提出的渐进式训练(progressive learning),是从小分辨率、强正则化开始,随着训练推进逐步提高分辨率、降低正则化强度。这种做法跟检测里常用的多尺度训练理念不谋而合,等于模型天然见过"从小图到大图"的多种尺度特征分布。

这一点在迁移到检测时非常重要。检测任务里目标尺度变化剧烈,尤其我这种缺陷检测数据,小目标占比高。一个经过渐进式训练的预训练主干,对尺度变化已经具备了一定的鲁棒性,比只在固定分辨率下训出来的主干更容易适配 640 或者更高的输入尺寸。

另外 EfficientNetV2 的 ImageNet 精度确实能打:S 版本在 384 分辨率下 Top-1 到 82.6%,M 版本更高。相比之下,Darknet 系主干主要靠检测数据集本身的监督在学,没有预训练权重可用,特征表达力往往被低估。换主干之后,检测模型相当于"站在了 ImageNet 巨人的肩膀上",起点就比原版高出一截。

2.4 与 CSP 结构的分水岭

把两种结构并排看,差异非常直观。Darknet 是"宽而浅的整体 + 密集的支路互连";EfficientNetV2 是"窄而深的同构堆叠"。检测任务里,Darknet 的多路连接确实帮助梯度流通,但代价是结构复杂性带来的部署不确定性。EfficientNetV2 把复杂度转移到了数据增强和训练调度上,网络本身干净利落。

有人会担心,这种"一条道走到黑"的结构会不会丢失多尺度信息?我的答案是:多尺度融合本来就是 neck 的工作,YOLOv8 的 PAN 颈部就是干这个的。主干只需要在适当的位置输出 1/8、1/16、1/32 三个尺度的特征图,后面交给 FPN 去融合就够了。没必要让主干承担过多的特征聚合职责。这也是我后续整个改造方案的核心判断依据。

3. 替换主干的完整实现:从 YAML 到权重迁移的落地过程

3.1 搭建 EfficientNetV2 特征提取器

我用的是 Ultralytics 开源仓库 8.0.x 版本做基准。EfficientNetV2 的实现我参考了开源的 timm 库,但没有直接调用 timm 的整包模型,而是把主干部分单独提出来,按 YOLOv8 需要的三尺度输出重新组装。

EfficientNetV2-S 的配置大致是这样的:输入先过一个 stride=2 的 stem 卷积,通道数到 24,之后依次经过 24、48、64、128、160、256 通道的几个 stage,其中前三个 stage 用 Fused-MBConv,后面的 stage 用带 SE 的 MBConv。downsample 发生在特定 stage 的第一个 block 上,所以整条链在 stem 之后累计 stride 达到 8、16、32 的位置,正好对应检测需要的 P3、P4、P5 层。

核心模块的简化实现长这样:

import torch import torch.nn as nn class SqueezeExcite(nn.Module): def __init__(self, in_ch, se_ratio=0.25): super().__init__() reduced = max(1, int(in_ch * se_ratio)) self.fc = nn.Sequential( nn.Conv2d(in_ch, reduced, 1, bias=True), nn.SiLU(inplace=True), nn.Conv2d(reduced, in_ch, 1, bias=True), nn.Sigmoid(), ) def forward(self, x): # 全局池化得到每个通道的标量 y = x.mean(dim=[2, 3], keepdim=True) return x * self.fc(y) class FusedMBConv(nn.Module): """早期阶段使用的融合倒残差模块:普通 3x3 卷积替代 1x1 + Depthwise""" def __init__(self, in_ch, out_ch, stride, expand=4, use_se=False): super().__init__() mid_ch = in_ch * expand self.use_se = use_se if expand != 1: self.conv_expand = nn.Conv2d(in_ch, mid_ch, 3, stride, 1, bias=False) self.bn1 = nn.BatchNorm2d(mid_ch) else: self.conv_expand = nn.Identity() self.bn1 = nn.Identity() self.conv_proj = nn.Conv2d(mid_ch, out_ch, 1, 1, 0, bias=False) self.bn2 = nn.BatchNorm2d(out_ch) self.act = nn.SiLU(inplace=True) if use_se: self.se = SqueezeExcite(mid_ch) # stride=1 且通道不变时才启用残差连接 self.use_res = (stride == 1 and in_ch == out_ch) def forward(self, x): identity = x x = self.act(self.bn1(self.conv_expand(x))) if self.use_se: x = self.se(x) x = self.bn2(self.conv_proj(x)) if self.use_res: x = x + identity return x

MBConv 的实现思路类似,只是把中间的 3×3 普通卷积换成 1×1 升维 + 3×3 depthwise + SE。一个 stage 就是多个 block 的串联,stage 内只有第一个 block 可能带 stride=2,其余 block 都是 stride=1 的残差块。我把这些 stage 封装成EFV2_FusedStage和EFV2_MBStage,内部按[out_ch, num_blocks, stride, expand]解析参数,这样在 YAML 里配置起来非常直观。

3.2 YAML 配置与通道对齐

在 Ultralytics 框架里,模型结构由 YAML 文件描述。我的做法是去掉原来的 CSPDarknet 主干段,替换成下面的结构(为了排版省略了部分细节):

# yolov8n_efv2s.yaml(缩略版) backbone: - [-1, 1, EFV2_Stem, [24, 3, 2]] # 1/2, 24 通道 - [-1, 1, EFV2_FusedStage, [24, 1, 1, 1]] # 1/2, 24 - [-1, 1, EFV2_FusedStage, [48, 2, 2, 4]] # 1/4, 48 - [-1, 1, EFV2_FusedStage, [64, 2, 2, 4]] # P3 1/8, 64 - [-1, 1, EFV2_MBStage, [128, 3, 2, 4]] # P4 1/16, 128 - [-1, 1, EFV2_MBStage, [160, 3, 1, 6]] # 1/16, 160 - [-1, 1, EFV2_MBStage, [256, 4, 2, 6]] # P5 1/32, 256 - [-1, 1, EFV2_MBStage, [256, 4, 1, 6]] # 可选尾块 head: - [-1, 1, Conv, [128, 1, 1]] - [-1, 1, nn.Upsample, [None, 2, 'nearest']] - [[-1, 4], 1, Concat, [1]] # P4 来自主干索引 4 - [-1, 1, C2f, [128, True]] # 后续 PAN 结构按原版 YAML 调整 from 索引即可

这里最关键也最容易出错的地方是通道对齐和索引对齐。原版 YOLOv8n 的 P3、P4、P5 通道数分别是 128、256、512,我换完主干之后变成了 64、128、256,neck 里 C2f 和后续 Conv 层的输入通道必须跟着改。另外 Ultralytics 里 Concat 的from字段引用的是网络层的绝对索引,我的 P4 在 backbone 里的索引是 4,P5 是 6,head 里拼接时必须写这些数字,写错一个索引模型也能跑,但训练出来精度一定不对,而且很难发现。

模块注册也有坑。8.0.x 版本的tasks.py在解析 YAML 时用globals()查找模块名,所以我必须把自定义的EFV2_Stem、EFV2_FusedStage、EFV2_MBStage这几个类显式导入到对应的命名空间里。每个小版本注册方式可能有差异,建议动手前先看一下源码里parse_model函数用的是哪一套查找逻辑。

3.3 预训练权重映射与严格加载

主干结构搭好之后,下一步是加载 ImageNet 预训练权重。这里我强烈建议:自定义模块的 key 命名尽量向 timm 的命名靠拢,能省掉一大半 key 映射的麻烦。timm 里 EfficientNetV2-S 的 stem 叫conv_stem、bn1,后续 stage 叫blocks.0.0、blocks.0.1这种格式,我的命名也保持一致,加载时只需要过滤掉分类头相关部分:

import timm from ultralytics import YOLO pretrained = timm.create_model('efficientnetv2_s', pretrained=True, num_classes=1000) # 过滤 classifier 相关 key ckpt = {k: v for k, v in pretrained.state_dict().items() if not k.startswith('classifier.')} model = YOLO('yolov8n_efv2s.yaml') model.model.load_state_dict(ckpt, strict=False)

加载之后一定要看一眼返回的 missing keys 和 unexpected keys 再继续。正常情况下 missing keys 应该只有检测头和 neck 相关的新层,unexpected keys 应该只有被我丢弃的 classifier 部分。如果 backbone 内部出现大量 key 对应不上,多半是 stage 编号或者 block 编号在实现时错位了,这种问题必须当场修正,否则后面训练一百个 epoch 都是在白费功夫。

3.4 验证前向与反向通路

权重加载完先别急着开训。我会跑三个快速检查:第一,随机输入一张 640×640 的图,确认三个尺度的输出形状是 80×80、40×40、20×20;第二,用一张标注好的测试图做一次前向推理,看 loss 是否能正常计算,数值不能是 NaN;第三,反向传播一次,确认梯度能走到主干最底层的 stem 卷积。前两个检查好理解,第三个检查容易漏,但很重要——之前有同事自定义主干时把某个 block 的requires_grad误设成了 False,训练时 loss 一直降不下来,查了一整天。

这三个检查都通过,模型结构和数据通路就算通了。下一步才是真正烧时间的训练调参阶段。

4. 让新主干真正收敛:BN 冻结、渐进式分辨率与正则化策略

4.1 前 3 个 epoch 冻结 BN 的原因

这是我最想强调的一个经验。预训练主干的 BatchNorm 是带着 ImageNet 数据的分布统计量来的,而检测训练的输入经过 mosaic、随机仿射、颜色扰动之后,分布跟 ImageNet 差异巨大。如果第一个 epoch 就放开 BN 跟着学,running mean 和 running variance 会被剧烈的增强分布拉扯,出现明显的抖动,后面要花很多个 epoch 才能找回稳定状态。

我第一版训练时没有做任何处理,前二十个 epoch 的 loss 曲线像心电图,mAP 一直到六七十个 epoch 才勉强超过原版。后来参考了迁移学习里常用的 FrozeBN 技巧:训练的前 3 个 epoch 把主干里的 BatchNorm 全部冻结,既不让它们更新统计量,也不更新 scale 和 shift,让网络先用预训练的统计量"站稳脚跟",第 4 个 epoch 再全部解冻。

def freeze_bn(model): for m in model.modules(): if isinstance(m, torch.nn.BatchNorm2d): m.eval() # 使用 running stats m.weight.requires_grad = False m.bias.requires_grad = False for epoch in range(total_epochs): model.train() if epoch < 3: freeze_bn(model) train_one_epoch(...)

注意一个细节:PyTorch 的model.train()会把所有 BatchNorm 切回训练模式。所以冻结操作必须在每个 epoch 调完train()之后再做,不能只在训练开始前做一次。

4.2 渐进式分辨率在检测训练中的落地

EfficientNetV2 的渐进式训练思路完全可以移植到检测任务里。我的做法是给训练配置一个分辨率计划表:前 0 到 15 个 epoch 用 480,16 到 30 个 epoch 用 544,31 到 45 个 epoch 用 608,之后固定 640。同时,随着分辨率提升,我把数据增强强度逐步回调:mosaic 概率从 1.0 降到 0.75,颜色抖动的幅度缩小,让模型在训练后期能看到更接近真实部署分布的图像。

这个方案的效果非常明显,最终 mAP50-95 比固定 640 训练高出了 0.9 个点。原因也不难理解:小分辨率下训练,模型被迫从大目标的整体轮廓里学习类别信息;分辨率逐步提高后,小目标的细节逐渐进入训练视野,模型相当于经历了一次从宏观到微观的渐进过程,学到的特征更鲁棒。

4.3 优化器、EMA 与损失权重的调整

优化器我做了对比实验。官方的 SGD 配置在换了主干之后表现不太行,前 50 个 epoch 掉点明显,换成 AdamW、初始学习率 1e-3、权重衰减 0.01 之后训练才稳定下来。我的解释是:EfficientNetV2 预训练参数已经处在一个很优质的局部区域,SGD 动量大、更新步长相对激进,容易把这个区域"冲"过去;AdamW 逐参数调整学习率,在迁移微调场景下更温和。

EMA 保持默认的 0.9999 衰减,没动。损失权重方面,YOLOv8 默认是 box=7.5、cls=0.5、dfl=1.5,我发现换主干后在缺陷数据集上分类歧义变大了(很多缺陷类别形似),所以把 cls 权重从 0.5 提到 0.8,box 和 dfl 保持默认。这个调整让 mAP50 涨了 0.4 个点,代价是少数难分类别的 F1 稍微下降了一点,整体还是划算的。

4.4 结构级深度优化:删尾块与通道裁剪

"深度优化"不能只停留在训练策略层面,结构本身也要动手。我发现最后那个 stride=1 的尾块 stage(256 通道、4 个 block)主要是为了 ImageNet 分类准备的,对检测的 P5 特征没什么贡献。把它整个删掉之后,主干参数量少了约 20%,端侧延迟降低 1.8ms,mAP50-95 只掉了 0.3 个点。如果你对延迟极度敏感,这个尾块可以直接不要。

另一个优化是通道裁剪。均质结构裁剪起来很方便:Fused-MBConv 的主卷积按 L1 范数对输出通道排序,剪掉影响最小的 15%~20% 中间通道,再微调十来个 epoch。我试过把 stage5 和 stage6 的通道从 160/256 裁到 128/192,参数量再降 12%,量化后精度损失不到 0.3 个点。这类裁剪在 CSPDarknet 上就很难做,因为支路合并导致通道重要性高度耦合,剪一个通道经常连带影响一大片。

5. 性能解析:精度、延迟与参数量在真实数据上的平衡

5.1 实验环境与数据集

所有对比实验都在同一台机器上完成:单张 24GB 显存的 GPU,PyTorch 2.0 以上,CUDA 版本一致,AMP 混合精度打开。数据集就是前面说的八类工件表面缺陷,训练集 11200 张、测试集 2800 张,输入分辨率统一 640,训练 100 个 epoch,用相同的 EMA 参数和评估脚本。端侧延迟在目标 NPU 开发板上用官方 SDK 的 INT8 模型测,取 50 次推理的平均值。

需要提前声明:下面所有数字都是我自己环境和数据集上测出来的,换硬件、换数据、换超参都会变,千万别当通用基准常量用。但趋势是有代表性的——均质主干在端侧的优势、在原版 Darknet 在纯 GPU 吞吐上的优势,这两点在多个项目里都复现过。

5.2 结果对比表

模型版本主干参数量(M)GFLOPsmAP50mAP50-95GPU FPS端侧延迟(ms)
YOLOv8nCSPDarknet538.78.788.462.121816.8
YOLOv8n + EFV2-SEfficientNetV2-S23.627.390.164.914212.5
YOLOv8s + EFV2-SEfficientNetV2-S32.844.891.367.611210.3
YOLOv8n + EFV2-MEfficientNetV2-M49.562.690.666.29614.1

看这张表,第一感觉是 GPU 上的 FPS 确实下降了,从 218 掉到 142,掉幅超过三成。原因不难解释:EfficientNetV2 后期大量使用 depthwise 卷积,这种算子在同算力 GPU 上的利用率远不如普通卷积。如果你只在云端 GPU 上跑推理,这个方案并不划算。

但端侧延迟这个指标很能说明问题:YOLOv8n 原版 16.8ms,换了 EFV2-S 之后直接掉到 12.5ms,反而通过了 15ms 的硬门槛。在 YOLOv8s 加 EFV2-S 的配置下,端侧延迟进一步到 10.3ms,精度还更高。这说明端侧 NPU 对均质结构的亲和力是真实存在的。

5.3 精度收益来自哪里

换主干后 mAP50-95 比原版 YOLOv8n 涨了 2.8 个点,这部分收益拆开看有三个来源。第一是预训练特征质量:ImageNet 82.6% Top-1 的骨架比 Darknet 自带随机初始化特征强很多,尤其在数据量只有一万出头的场景下,预训练特征的泛化优势被放大。第二是 SE 注意力:置信度最高的两个类别(划痕和凹坑)单看 AP 分别提升了 3.1 和 2.6 个点,这两类共同点是纹理局部性很强,SE 对通道重标定的收益特别明显。第三是渐进式分辨率训练带来的尺度鲁棒性,小目标类别(麻点、锈蚀)的 AP 提升都超过了 3 个点。

5.4 int8 量化下的均质结构优势

量化是端侧部署绕不开的一步。我用每通道量化的 naive 方案,只对权重和激活做 int8 校准,不做任何量化感知训练,对比原版和替换主干的精度损失:

模型版本FP32 mAP50-95INT8 mAP50-95损失
YOLOv8n62.160.21.9
YOLOv8n + EFV2-S64.964.20.7
YOLOv8s + EFV2-S67.666.80.8

差异非常显著。Darknet 主体的激活分布里有大量 concat 带来的"双峰"特征,校准集很难用一个缩放系数同时覆盖两个峰值;EfficientNetV2 从头到尾都是一条单调的卷积链,每个卷积层的激活分布都相对集中,量化误差自然小。我在不止一个模型上验证过这个结论:卷积链越规整,低比特量化损失越小。这就是均质性在部署环节最实在的价值。

6. 踩坑与排查链路:五个翻车现场及根因定位过程

6.1 权重加载时的 missing/unexpected keys

现象:加载预训练权重后打印出来一堆 missing keys,仔细一看连conv_stem都在 missing 列表里,同时出现一堆以stage_1开头的 unexpected keys。根因是我自己封装模块时的命名和 timm 不一致,timm 里blocks.0.0.conv_expand对应我的stage_1.blocks.0.conv_expand,全网几十个 key 全部错位。

排查链路也记录一下:先打印 state dict 里双方 key 的集合,做差集,发现明明是同样的卷积层却因为前缀不同对不上。解决方法是把自定义模块的命名对齐 timm,或者写一个完整的 key 映射表。这个坑最值得警惕的是:如果加载用了strict=True,程序会直接报错,反而容易发现;很多教程为了省事让新手用strict=False,这会把问题掩盖到训练阶段才暴露,届时就只剩下一堆看不懂的异常曲线。

6.2 BN 统计量漂移导致的前期震荡

现象:前二十个 epoch 的 loss 曲线持续震荡,验证 mAP 长期不动。这个是 4.1 节已提到的 BN 冻结问题。我当时排查的顺序是:先排除数据问题,固定随机种子重跑,还是震荡;再看梯度,发现主干第一层卷积的梯度范数在不停跳;最后把主干里每个 BN 的 running mean 打出来对比,发现前两个 epoch 结束时已经漂移得面目全非。

结论就是 BN 统计量被 mosaic 增强拉飞了。修复方式就是前 3 个 epoch 冻结 BN,同时把 mosaic 启用概率从 1.0 降到 0.5 再逐步恢复。我后来把这个经验固化成模板:只要换 ImageNet 预训练主干,前 3 个 epoch 必冻结 BN,没有例外。

6.3 分辨率提升反而掉点的诡异曲线

现象:按渐进式分辨率计划训练,到第 31 个 epoch 把分辨率从 544 提到 608 之后,mAP50-95 反而连续 5 个 epoch 下滑,之后也没能补齐。这个现象非常反直觉,当时差点让我放弃整个渐进式方案。

完整排查链路:先是用 TensorBoard 把 loss 分解看,发现分类损失和回归损失都异常波动;再把变量逐个隔离——固定分辨率训练一组,固定 mosaic 尺度训练一组,最后确认问题出在 mosaic 的随机尺度范围没有跟着分辨率调整。我用的数据加载器里 mosaic 会把四张图缩放后拼成一张,缩放范围写死成了 0.5 到 1.5 倍。当输入分辨率提升到 608 时,小目标经过 0.5 倍缩放后几乎消失,模型反而被大量的"退化目标"干扰。

修复方式是让 mosaic 的缩放上限随分辨率提高而收敛,同时在前三分之一训练轮次里把 mosaic 概率降到 0.5。修完之后曲线立刻恢复,整个渐进式方案最终净赚 0.9 个点。

6.4 ONNX 导出时 SE 模块的 reshape 报错

现象:导出 ONNX 时报错,定位到 SqueezeExcite 模块,提示 reshape 相关的 shape 推导失败。根因是我在写 SE 的时候用x.view(b, c, 1, 1)之类的方式处理全局池化后的张量,动态 batch 下 view 的形状推导经常在 ONNX 图优化阶段出问题。

修法有两种。第一种,把 SE 的池化和展平写成完全静态的算子组合,用torch.flatten(x, 2).mean(2)或者adaptive_avg_pool2d(output_size=1),这两种算子在 ONNX 里都有稳定的映射。第二种,导出时固定 batch 为 1,很多部署场景其实只需要固定 batch 的模型,--batch 1一加上去问题立刻消失。我建议两个都做:开发验证用动态 batch,最终导出固定 batch。

6.5 显存暴涨与 EMA/AMP 的隐性成本

现象:换完主干之后,batch size 从 32 降到 16 依然 OOM。查了模型参数量,明明比同规模 Darknet 没有大多少,显存为什么反而吃紧?后来把显存占用逐项拆开才明白:EMA 保存了一份 fp32 全模型副本,AMP 的 GradScaler 又维持了一份 fp32 master weight,优化器本身还有两份 fp32 状态,再加上前向激活和梯度,训练期间的显存峰值远高于模型文件大小给人的直觉。

解决路径是梯度累积。batch 设成 8,累积 4 步再更新,等效 batch 还是 32,显存峰值砍掉一半多。如果还差,可以把 EMA 改成每 N 个 epoch 才同步一次,牺牲少量精度样板换取显存。这个问题的排查要点是:换主干后一定要重新测一次峰值显存,别拿旧配置想当然地继续跑。

6.6 排查方法论总结

这半年踩坑踩下来,我形成了一条固定的排查顺序:先查 state dict 对不对,再查曲线形态,最后才怀疑结构本身。五个问题里有三个是键值/通道/预处理层面的低级错位,真正属于网络结构缺陷的基本没有。这其实印证了一个判断:换主干这类迁移改造,八成以上的失败都不是因为新主干不够好,而是配套的适配工作没有做到位。

7. 我的选型建议与后续扩展

7.1 这种替换适合什么场景

如果你的部署目标是端侧 NPU、FPGA 这类对内存布局敏感的硬件,同时又想做 int8 量化,EfficientNetV2 换主干是值得认真考虑的方案。均质结构在低比特量化下的损失明显更小,端侧延迟也压得下来,我用一张表总结一下:

场景建议理由
端侧 NPU/FPGA + int8换 EFV2-S 或 EFV2-M量化损失小,延迟低
功耗敏感移动端换 EFV2-S + 删尾块 + 裁剪参数少,结构规整
小目标占比高换 EFV2-M + 提高输入分辨率渐进式训练对尺度更友好
纯 GPU 高吞吐云端保持原版 DarknetGPU 上算子利用率更高
数据量很少(< 3k)慎换预训练特征优势尚存但过拟合风险高
已有稳定系统不建议为换而换迁移验证成本远大于收益

7.2 不建议替换的场景

老实说,不是所有项目都适合这个方案。如果你的推理环境就是纯 GPU,机器不缺算力,那原版 Darknet 的 GPU 吞吐优势无可争辩,换了反而亏。如果数据集规模很小,新主干强大的表达能力容易变成过拟合的催化剂,这时更应该用轻量增强+原版主干。还有一种情况是团队没人能长期维护自定义主干代码,那么任何结构改造都要慎重——代码是开源的,但出了问题能排查的人是自己。

7.3 可以继续深入的三个方向

第一,知识蒸馏。我用一个更大更强的检测模型当 teacher,只对同 stride 位置的中间特征做蒸馏,学生用 EFV2-S 主干,最终在保持端侧延迟不变的前提下又涨了 0.6 个点。均质结构的对应关系清清楚楚,teacher 的哪一层对应学生的哪一层一目了然,比 Darknet 那种异构结构好对齐得多。

第二,混合主干。我在 EFV2 的 P3 输出之后接了一段 C2f 结构做颈部特征融合,相当于"均质主干 + 异构颈部",在中等规模数据集上比纯 EFV2 配置高了 0.8 个点。这个方向说明均质和异构不是对立的,关键是找到各自适合的位置。

第三,把 PAN 颈部换成加权特征融合结构。EfficientDet 的思路是给不同尺度的特征加可学习权重再做融合,这种带权重的融合方式和均质主干搭配得非常好,训练曲线比 PAN 更平滑。我还没有在项目里完整跑完这个方案,但对比实验已经显示出正向趋势。

最后再分享一点个人体会。换主干这件事,技术难度其实不大,真正的门槛在于"配套改造的完整度"——要不要冻结 BN、渐进式分辨率怎么设、mosaic 尺度怎么配合、量化校准用什么参数,每一个环节都是单独的小坑,但合在一起就决定了最终能不能达到预期。均质性网络架构的好处,绝不只是精度和速度这两个数字,而是在编译器、量化器、剪枝工具每一层都变得更"好相处"。如果你正被端侧部署折磨,我建议不要只盯着原版模型调参,认真考虑一下从主干层面做一次结构性的更换,值得。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进