先把一个最容易被忽略的问题摆在前面:你真的会手算一个卷积层的计算量吗?
我见过不少同学,模型跑起来了、精度也调上去了,但问他一层的FLOPs是多少,他第一反应是打开thop库敲一行代码。工具当然没问题,可一旦工具输出和理论对不上、或者你需要在两种结构之间做快速取舍时,不会手推公式就非常被动。这个题目看着像教科书内容,实际上它是模型设计、论文消融实验、端侧部署前评估成本的基本功。
这篇文章我会从标准卷积出发,把复杂度公式完整推一遍,再把分组卷积、深度可分离卷积、膨胀卷积、转置卷积这些常见变体的复杂度一个个算清楚,最后讲几个我在实际项目中踩过的、和复杂度计算有关的坑。内容不绕弯子,直接上推导和数字,你跟着算一遍,以后看任何模型结构都能在脑子里快速估出成本。
1. 复杂度公式从哪里来:标准卷积的完整推导
1.1 先把参数定义清楚:输入、核、输出的记号
很多推导看起来复杂,其实不是公式难,而是符号混乱。我先统一记号,后面所有推导都用这套。
假设输入特征图是 H_in × W_in × C_in,卷积核大小是 K × K,输出通道数是 C_out,padding 为 P,stride 为 S。那么输出特征图的高度和宽度分别是:
H_out = (H_in + 2P - K) / S + 1 W_out = (W_in + 2P - K) / S + 1
这是空间维度的尺寸变化公式,不管你后面用不用它算FLOPs,都得先把这个写对。因为卷积计算量的核心是“输出特征图每个位置都要做一次卷积”,所以输出尺寸直接决定了乘加次数。
这里有一个细节:实际工程中输入输出尺寸通常都能整除,但在推导公式时,最好带着向下取整的写法,避免边界情况翻车。严谨一点,上面公式应该写成:
H_out = floor((H_in + 2P - K) / S) + 1
1.2 一次乘加到底发生几次:从滑窗视角推FLOPs
现在从最朴素的角度开始。一个 K×K 的卷积核,在输入特征图的某个位置上,要覆盖 K×K×C_in 个输入值,这K×K×C_in个值分别和卷积核对应的权重相乘,然后再全部加起来,得到输出特征图一个通道上的一个像素值。
所以,输出特征图上的一个点,需要 K × K × C_in 次乘法,以及 K × K × C_in - 1 次加法。但在工程统计里,乘加操作(MAC,multiply-accumulate)通常被当成一次操作,几乎所有深度学习框架的计算量统计也默认一个MAC代表一次乘加组合。
那么一个输出通道、整张输出特征图需要多少次MAC?
H_out × W_out × K × K × C_in
如果输出有 C_out 个通道,标准卷积的总MAC数就是:
总MACs = C_out × H_out × W_out × K × K × C_in
这就是标准卷积的时间复杂度。如果换算成FLOPs,一个MAC对应两次浮点运算(一次乘法、一次加法),所以:
FLOPs = 2 × C_out × H_out × W_out × K × K × C_in
我一般习惯用MACs作为思考单位,因为大部分profile工具默认输出的也是MACs,比如thop输出的是MACs,ptflops虽然名字带FLOPs,但它统计的实际上是乘加次数。这一点特别容易搞混,后面我再细说。
1.3 光记公式不够,得知道每个符号为什么这么乘
公式本身不难背,但如果你只是背下来,换个变体就可能不会用了。我拆开说说每个因子的含义。
C_in × K × K 这个因子,表示一个卷积核在单个空间位置上覆盖了多少输入数据点。你可以把这个积想象成卷积核的“体积”:深度方向有C_in个通道,每个通道平面是K×K。
H_out × W_out 表示输出特征图上有多少个空间位置,每个位置都会完整执行一次上述的乘加操作。这个因子的存在意味着:分辨率越大,计算量增长越明显。事实上,输出分辨率通常和输入分辨率同量级,所以卷积的计算量是随输入尺寸平方级增长的。
C_out 表示我们有C_out个不同的卷积核,每个核都会在全部空间位置上扫一遍,各自产生一张输出通道图。因此它也是直接乘进去的。
把这三个层次搞清楚,你就掌握了“空间位置 × 核体积 × 核数量”这个通用框架。后面所有变体的复杂度计算,本质上都是在调整这三个因子中的一个或几个。
2. 把公式用到工程里:实例计算与成本盘算
2.1 一个具体例子手算一遍
光有公式太抽象,拿一个真实例子过一遍。假设输入是 224×224×3 的RGB图像,第一层卷积用 3×3 卷积核,stride=2,padding=1,输出通道数设为32。这个配置在常见分类网络的第一层里很典型。
首先算输出尺寸:
H_out = (224 + 2×1 - 3) / 2 + 1 = 112 W_out = (224 + 2×1 - 3) / 2 + 1 = 112
然后套公式:
MACs = 32 × 112 × 112 × 3 × 3 × 3 = 32 × 12544 × 27 = 10838016
约等于 1084万 次MAC,也就是 10.8 MMACs。换算成FLOPs再乘以2,大约是 21.7 MFLOPs。
这只是一个卷积层。一个ResNet50,输入224×224,常用的统计口径是约 4.1 GMACs,也就是41亿次乘加。你拿这个数字和单层对比,就能感受到深层网络的计算量大多堆在哪里:早期层分辨率高、通道数少,后期层分辨率低但通道数多,中间层往往是计算量最大的区域。
2.2 网络是多层的,复杂度要逐层累加
很多刚入门的人会犯一个错误:只算了卷积层,忘了把其他层算进去。一个完整模型的总计算量,是所有层的累加,包括卷积、全连接、池化、归一化等。
我做一个MobileNetV1的快速估算。它的核心是深度可分离卷积,这个后面专门讲,这里先看总账。在224×224输入下,MobileNetV1大约有 569M MACs。而一个标准卷积组成的VGG16大约是 15.5G MACs。两个模型精度差不多,计算量却差了将近30倍,这就是结构选型的价值。
所以你在做模型对比时,不要只比较精度指标,要把计算量、参数量放在同一张表里看。计算量决定推理延迟上限,参数量决定存储占用,这两个维度很多时候是独立的。一个模型可能参数很少但计算量很大,比如全连接层少的卷积网络;也可能参数很多但计算量不大,比如全连接层被替换成全局平均池化后的结构。
2.3 理论FLOPs和实际耗时的差距:Roofline视角
复杂度公式算出来的是理论计算量,但你在实际设备上测到的延迟,往往和理论FLOPs不成正比。这个现象我用Roofline模型解释过很多次,核心在于:硬件实际能达到的算力,取决于计算密度,也就是每次访存可以喂给计算单元多少有效数据。
举个例子,1×1卷积的计算密度很高,因为它的数据复用特别好,每个输入像素可以被C_out个输出通道复用,权重和特征图都适合缓存放得下,所以GPU上跑起来能接近理论峰值。但深度可分离卷积里的depthwise卷积,每个卷积核只作用于一个输入通道,数据复用极少,访存开销占比很高,哪怕FLOPs算下来很低,实际跑起来也不一定快。
这就是为什么你在移动端做模型加速时,只看FLOPs选模型是会被坑的。我吃过这个亏:选了一个FLOPs只有对手一半的模型,结果在某个特定芯片上跑得比对手还慢,因为访存模式太差,缓存命中率低。
3. 卷积家族改型后复杂度怎么变
3.1 分组卷积:分组数G如何影响FLOPs
分组卷积最早在AlexNet里出现,原因是当时两块GPU显存放不下整个模型,把通道拆成两组分别计算。后来它成了高效网络设计的重要工具。
分组卷积的思想是把输入通道分成G组,每组包含 C_in / G 个通道,输出通道也对应分成G组,每组包含 C_out / G 个通道。组与组之间不互通,只在组内做标准卷积。
对应的MACs公式:
MACs_group = G × (C_out / G) × H_out × W_out × (C_in / G) × K × K = C_out × H_out × W_out × (C_in / G) × K × K
看到没,分组数G直接除到了C_in上。和标准卷积相比,计算量变成了原来的 1/G。这意味着分组数越大,计算量越小,当G = C_in时,就退化成了depthwise卷积。
但分组卷积不是免费的午餐。组数增加了,每组能看到的输入通道就少了,特征交互能力下降。这直接影响了模型表达能力,ShuffleNet之所以要在分组卷积后加channel shuffle,就是在弥补组间信息不流通的问题。
3.2 深度可分离卷积:为什么MobileNet能省这么多
深度可分离卷积是MobileNet系列的基石,它把标准卷积拆成两步:depthwise卷积加pointwise卷积。
第一步,depthwise卷积:每个输入通道单独用一个K×K卷积核处理,输出通道数等于输入通道数,计算量为:
MACs_dw = C_in × H_out × W_out × K × K
注意这里没有C_out因子,因为没有跨通道融合。
第二步,pointwise卷积:用1×1卷积把通道数从C_in变换到C_out,计算量为:
MACs_pw = C_out × H_out × W_out × C_in
总和:
MACs_dsc = C_in × H_out × W_out × (K × K + C_out)
对比标准卷积:
MACs_std = C_out × H_out × W_out × C_in × K × K
两者比值:
MACs_dsc / MACs_std = 1 / C_out + 1 / (K × K)
拿常见的3×3卷积核、输出64通道来算,比值大约是 1/64 + 1/9 ≈ 0.126。也就是说,深度可分离卷积大约能把计算量降到标准卷积的八分之一左右。这就是MobileNetV1能在保持相近精度的前提下,把计算量做下来的核心原因。
不过这里要特别提醒:这个比值是在理想情况下成立的理论结论。实际部署时,depthwise卷积在GPU上的效率往往不如理论值,因为它的并行度和数据复用都远低于标准卷积。这也是为什么后来的MobileNetV2在结构上做了很多调整,不只是图省计算量,还要考虑硬件执行效率。
3.3 膨胀卷积:感受野扩大但计算量怎么变
膨胀卷积(dilated convolution)通过在不增加参数量的前提下,让卷积核覆盖更大的区域来扩大感受野。它的核心参数是膨胀率d,卷积核在输入上采样时,相邻权重之间间隔d-1个像素。
很多人误以为膨胀卷积的计算量会变大,因为感受野变大了。实际上,参与计算的权重数量没有变,仍然是 K × K 个,只不过它们对应的输入位置被拉远了。
MACs_dilated = C_out × H_out × W_out × K × K × C_in
和标准卷积一模一样。差异只在于输出特征图的尺寸可能会因为padding策略不同而改变,但单看每个点的计算量,公式形式不变。
这里有一个工程上的坑:膨胀卷积在GPU上不一定比标准卷积慢,但取决于实现方式。如果你直接按膨胀的方式读取输入,会产生非连续的内存访问,影响缓存效率。有些框架会用等效的稀疏卷积实现,实际效率差异很大。我在做语义分割模型时,把标准卷积换成膨胀卷积后,FLOPs没变,但实际推理延迟涨了不少,就是这个原因。
3.4 转置卷积:上采样时FLOPs怎么算
转置卷积(transposed convolution)常被叫成反卷积,用于上采样,比如语义分割的decoder部分。它的计算量计算比标准卷积稍微绕一点。
理解转置卷积的一个有效方式:它的前向传播等价于标准卷积的反向传播。如果你要在数学上严格推导,它做的事情是把输入通过补零、插值的方式扩展到更大尺寸,然后做一次标准卷积。
多数情况下,我们可以用下面的近似公式来估算:
MACs_transposed ≈ C_in × C_out × H_out × W_out × K × K
其中H_out和W_out是转置卷积的输出尺寸。和标准卷积的公式对比,差别只在H_out和W_out是变大而不是变小。
但注意,转置卷积的实际计算过程远比这个公式复杂。因为输入像素会被映射到输出图上的多个位置,重叠区域要做累加。实际工程中,cudnn会用col2im或类似的底层算法实现,内存开销比标准卷积大不少。如果你在显存受限的场景做上采样,转置卷积并不是最经济的选择,可以考虑用插值上采样加标准卷积替代。
3.5 1×1卷积:被低估的大计算量来源
1×1卷积看起来简单,就是K=K=1的标准卷积。它的公式退化为:
MACs_1x1 = C_out × H_out × W_out × C_in
这个公式极其常见,因为在很多网络里1×1卷积是通道变换的主力,比如ResNet的bottleneck结构,先1×1降维,再3×3,最后1×1升维。这些1×1卷积加起来,往往占总计算量的相当大比例。
举个例子,ResNet50的bottleneck第一层把256通道降到64通道,输入分辨率是56×56,那么这一层的MACs = 64 × 56 × 56 × 256 = 51485056,大约51M。一套流程下来,降维和升维两个1×1层加起来超过100M,而中间的3×3层因为通道数降到了64,反而只有约28M。所以1×1卷积是隐形的计算量大户,做模型压缩时千万别只盯着3×3卷积。
4. 其他常见层的复杂度与避坑清单
4.1 池化、全连接、激活函数的成本
池化层的计算量通常被忽略不计,但从严格角度,平均池化还需要做除法操作,最大池化则完全不需要乘法,只做比较运算。在FLOPs统计上,很多库直接跳过池化层,因为它的成本相对卷积和全连接来说太小了。
全连接层的计算量公式很直接:
MACs_fc = C_in × C_out
其中C_in是输入特征维数,C_out是输出维数。如果输入是展开的特征图,C_in等于所有空间位置和通道数的乘积。全连接层的问题是参数量和计算量都集中在矩阵乘法里,一旦特征维数很大,成本会非常夸张。这也是为什么现代网络普遍用全局平均池化代替最后的全连接层。
激活函数(ReLU、Sigmoid等)在传统的FLOPs统计里通常不单独计入,因为它们的计算量相对卷积来说非常小。但在一些特殊硬件上,激活函数可能触发额外的内存读写,实际成本并没有想象那么低。我做端侧部署时,遇到过一个模型因为GELU激活函数在某个低端芯片上没有硬件加速,导致推理延迟比理论预期高了一倍。
4.2 推导时最容易出错的三个细节
第一个坑是FLOPs和MACs的混淆。这两个术语在论文里经常被混用,有的论文写FLOPs实际指的是MACs,有的库输出FLOPs但按MACs统计。换算关系是FLOPs = 2 × MACs。我在对比论文数据时,一定会先确认对方用的哪个口径,否则数字直接比是没意义的。
第二个坑是忘记batch size。上面所有公式都是针对单张输入的。如果输入是batch为B的数据,总计算量要乘以B。thop这类工具默认batch=1,所以它输出的数字只是一个样本的成本。但在评估训练耗时或者GPU利用率时,必须乘上batch size。
第三个坑是偏置项。标准卷积通常会加偏置,每个输出位置多一次加法。在FLOPs统计里,这个加法有时被计入有时不计入。对于大卷积核来说,偏置的影响微乎其微,但1×1卷积输出位置很多,累加起来也有一定比例。目前绝大多数工具都忽略偏置,你在手算时保持一致即可,关键是在同一个体系内不要一会儿算一会儿不算。
4.3 复杂度公式在模型选型和压缩中的实际用途
公式不只是考试题,它是你做技术决策的工具。我举三个真实场景。
第一个场景是模型选型。你要在移动端跑一个图像分类任务,云端已经有一个精度96%的大模型,但它有200M FLOPs,手机跑不动。你有两个候选:一个80M FLOPs精度95%,另一个60M FLOPs精度94%。这时候你只需要把总FLOPs除以芯片的算力上限,就能估算出大致推理时间,再对比精度损失,做出取舍。这比把两个模型都部署到真机上测要快得多。
第二个场景是网络结构设计。你在搭一个分割网络,解码器的转置卷积导致FLOPs暴涨。用公式算一下发现是上采样分辨率太大导致的,于是你把转置卷积替换成插值上采样加3×3卷积,FLOPs立刻降了一个量级,精度基本不受影响。
第三个场景是模型压缩。你被要求把模型的FLOPs降低30%,用公式逐层扫描计算结果,发现某几个中间层的通道数可以减半。改完之后用公式重新算一遍,确认达标,再开始训练验证精度。这个流程在论文的实验章节里特别常见,你学会手推公式,就能独立复现这类分析。
最后再分享一个我在实际项目中经常用的验证方法:无论你用thop还是ptflops还是手算,结果出来之后,挑一层结构简单的,自己用公式手算一遍,两边对照。只要有一次对不上,就说明你对这个工具或者这个结构的理解有偏差,排查清楚再继续往下走。这个方法帮我抓出过不少问题,包括我自己代码里padding设置错了导致输出尺寸和预期不一致这种低级错误。工具好用,但验证工具的人始终得是你自己。