1. 项目概述:为什么我们需要亲手计算FLOPs和参数量?
在模型设计、论文复现或者面试准备时,我们经常会听到两个词:FLOPs和参数量。前者衡量模型的计算复杂度,后者衡量模型的空间占用和优化潜力。很多朋友习惯直接用现成的工具库(比如thop、ptflops)跑一下,得到一个数字就完事了。但说实话,如果你不理解这个数字是怎么来的,它对你的价值就大打折扣。当工具报错、结果异常,或者你需要为一个全新的模块估算复杂度时,就会束手无策。
这个内容,就是带你回到最根本的数学和代码层面,亲手推导和计算卷积神经网络(CNN)和视觉Transformer(ViT)这两大主流视觉架构的FLOPs与参数量。我们不止看公式,更会结合具体的PyTorch代码,从张量的维度出发,一步步拆解。你会发现,一旦掌握了核心原理,无论是面对ResNet、EfficientNet这样的CNN变体,还是Swin Transformer、DeiT等ViT改进型,你都能快速分析其计算开销,从而在模型选型、轻量化设计时做出更明智的决策。
2. 核心概念与计算基础拆解
在动手计算之前,我们必须统一对几个核心概念的理解。这就像木匠干活前要先认识尺子和锯子一样,概念清晰了,后面的计算才不会出错。
2.1 参数量:模型究竟记住了多少东西?
参数量,顾名思义,就是模型中所有需要学习的参数的总数。在深度学习中,这些参数通常是权重矩阵和偏置向量中的每一个标量值。
计算原则:对于模型中任何一个具有可学习参数的层,其参数量等于该层所有参数张量中元素的总和。更具体地说:
- 全连接层(Linear Layer):如果输入特征维度是
in_features,输出是out_features,并且使用偏置(bias=True),那么参数量 =in_features * out_features + out_features。不加偏置则减去后面的out_features。 - 卷积层(Conv Layer):这是重点。一个卷积核的参数量由其尺寸、输入通道数和输出通道数决定。假设卷积核尺寸为
(kernel_h, kernel_w),输入通道数为C_in,输出通道数为C_out,并且使用偏置。那么,一个卷积核的参数量是kernel_h * kernel_w * C_in。因为有C_out个这样的卷积核,所以总参数量 =kernel_h * kernel_w * C_in * C_out + C_out(加上偏置)。如果不加偏置,同样去掉C_out。
注意:参数量计算与输入图片的大小(
H, W)完全无关。它只由网络结构本身决定。所以,参数量是一个静态的、描述模型容量的指标。
2.2 FLOPs:完成一次前向传播需要多少“力气”?
FLOPs,即浮点运算次数,是衡量模型计算复杂度的关键指标。它特指完成一次前向传播(推理)所需要进行浮点数乘加运算的次数。通常,一次乘加运算(Multiply-ACCumulate, MAC)被视为一次浮点运算。但更严谨的学术文献中,有时会将一次乘法和一次加法计为两次单独的FLOP。为了与主流工具(如thop)保持一致,我们这里采用“一次乘加算作一次FLOP”的约定,这在业界通常称为“MACs”。当你看到论文中的FLOPs时,需要留意其具体定义。
计算原则:FLOPs的计算与具体的操作和输入数据尺寸强相关。
- 全连接层:对于输入向量
(in_features,)到输出向量(out_features,)的运算,可以看作是一个矩阵乘法(out_features, in_features) * (in_features,)。其FLOPs为in_features * out_features次乘法(每个连接一次)和同样数量的加法(累加),如果按一次乘加为1 FLOPs计,就是in_features * out_features。如果考虑偏置加法,还需要加上out_features次加法,但通常偏置加法的计算量很小,在估算时有时被忽略。 - 卷积层:这是计算的大头。其FLOPs由卷积操作在每个空间位置、每个输出通道上的计算量累加而来。一个广泛使用的公式是:
FLOPs = H_out * W_out * C_in * kernel_h * kernel_w * C_out其中(H_out, W_out)是输出特征图的空间尺寸。这个公式怎么来的?我们可以这样理解:对于输出特征图上的每一个点(共H_out * W_out * C_out个),都需要与一个(kernel_h, kernel_w, C_in)的输入块做内积(即逐元素相乘后求和),这个内积操作包含了kernel_h * kernel_w * C_in次乘法和同样次数的加法(减一),因此每个输出点对应kernel_h * kernel_w * C_in次乘加运算。
重要心得:FLOPs严重依赖于输入分辨率。这也是为什么比较模型复杂度时,必须指明输入图像尺寸(如224x224)。同样一个网络,处理320x320的图片比处理224x224的图片FLOPs要高出一倍多。
2.3 参数量 vs. FLOPs vs. 实际推理速度
这是一个常见的误区:参数量小的模型一定跑得快吗?不一定。
- 参数量主要影响模型大小(存储占用)和优化器状态内存(训练时),与计算速度没有直接关系。一个参数量大但计算模式规整(如大矩阵乘法)的模型,可能比参数量小但计算模式复杂(如大量分支、小卷积)的模型在GPU上跑得更快。
- FLOPs是理论计算量,是速度的一个主要影响因素,但非唯一因素。内存访问成本(Memory Access Cost, MAC)常常是瓶颈。例如,分组卷积(Group Convolution)或深度可分离卷积(Depthwise Separable Conv)通过降低FLOPs来提升效率,但其内存访问模式可能不如标准卷积高效,在特定硬件上可能无法达到FLOPs降低比例的加速效果。
- 实际推理速度由FLOPs、MAC、硬件计算单元利用率、算子优化程度、框架开销等共同决定。最可靠的方式仍然是在目标硬件和部署环境下进行实际测速。
我们的计算,是为了在理论层面建立直觉和进行初步筛选,避免设计出明显计算冗余的结构。
3. CNN核心层FLOPs与参数量计算实战
让我们以最经典的二维卷积层为例,用代码和公式彻底把它算明白。假设我们使用PyTorch框架。
3.1 标准卷积层计算详解
我们定义一个卷积层:nn.Conv2d(in_channels=3, out_channels=64, kernel_size=3, stride=1, padding=1)。输入一张(224, 224)的RGB图片。
第一步:确定输出尺寸
- 公式:
H_out = floor((H_in + 2*padding - kernel_size) / stride + 1) - 代入:
H_out = W_out = floor((224 + 2*1 - 3) / 1 + 1) = floor(224 /1) = 224。 - 所以输出特征图形状为
(64, 224, 224)。
第二步:计算参数量
- 权重形状:
(out_channels, in_channels, kernel_h, kernel_w) = (64, 3, 3, 3) - 参数量(含偏置):
64 * 3 * 3 * 3 + 64 = 1728 + 64 = 1792 - 可以看到,参数量与输入图片尺寸无关。
第三步:计算FLOPs
- 使用公式:
FLOPs = H_out * W_out * C_in * kernel_h * kernel_w * C_out - 代入:
FLOPs = 224 * 224 * 3 * 3 * 3 * 64 - 让我们分步计算:
- 每个输出点的计算量:
3 * 3 * 3 = 27次乘加。 - 输出点总数:
224 * 224 * 64 = 3,211,264个。 - 总FLOPs:
27 * 3,211,264 = 86,704,128。
- 每个输出点的计算量:
- 换算成常用单位:约86.7 MFLOPs(百万次浮点运算)。
import torch import torch.nn as nn # 定义卷积层和输入 conv = nn.Conv2d(in_channels=3, out_channels=64, kernel_size=3, stride=1, padding=1) input_tensor = torch.randn(1, 3, 224, 224) # (batch, channel, height, width) # 手动计算参数量 def manual_params(conv_layer): # 权重参数 weight_params = conv_layer.out_channels * conv_layer.in_channels * conv_layer.kernel_size[0] * conv_layer.kernel_size[1] # 偏置参数 bias_params = conv_layer.out_channels if conv_layer.bias is not None else 0 return weight_params + bias_params print(f"手动计算参数量:{manual_params(conv)}") print(f"PyTorch参数量:{sum(p.numel() for p in conv.parameters())}") # 应一致 # 手动计算FLOPs (一次前向) def manual_flops_conv2d(conv_layer, input_shape): batch, C_in, H_in, W_in = input_shape _, C_out, H_out, W_out = torch.randn(batch, conv_layer.out_channels, H_in, H_in).shape # 简单估算输出尺寸,严谨起见应用公式计算 # 严谨输出尺寸计算 H_out = (H_in + 2*conv_layer.padding[0] - conv_layer.dilation[0]*(conv_layer.kernel_size[0]-1) -1)//conv_layer.stride[0] + 1 W_out = (W_in + 2*conv_layer.padding[1] - conv_layer.dilation[1]*(conv_layer.kernel_size[1]-1) -1)//conv_layer.stride[1] + 1 flops_per_output = C_in * conv_layer.kernel_size[0] * conv_layer.kernel_size[1] total_flops = H_out * W_out * C_out * flops_per_output # 如果考虑偏置加法,加上 H_out * W_out * C_out if conv_layer.bias is not None: total_flops += H_out * W_out * C_out return total_flops print(f"手动估算FLOPs:{manual_flops_conv2d(conv, input_tensor.shape)}")3.2 深度可分离卷积计算与对比
深度可分离卷积是MobileNet等轻量级网络的核心,它显著降低了FLOPs和参数量。它分为两步:
- 深度卷积(Depthwise Conv):每个输入通道单独使用一个二维卷积核进行滤波。
groups=in_channels。 - 逐点卷积(Pointwise Conv):使用1x1卷积来组合深度卷积的输出通道。
我们对比一下标准卷积和深度可分离卷积。假设输入为(C_in, H, W),输出为(C_out, H, W),卷积核大小K,且stride=1, padding=1保持尺寸不变。
标准卷积:
- 参数量:
C_in * K * K * C_out + C_out - FLOPs:
H * W * C_in * K * K * C_out
深度可分离卷积:
- 深度卷积:
- 参数量:
C_in * K * K(每个输入通道一个KxK核) - FLOPs:
H * W * C_in * K * K
- 参数量:
- 逐点卷积(1x1 Conv):
- 参数量:
C_in * 1 * 1 * C_out + C_out = C_in * C_out + C_out - FLOPs:
H * W * C_in * C_out
- 参数量:
- 总计:
- 参数量:
C_in * K * K + C_in * C_out + C_out - FLOPs:
H * W * C_in * (K * K + C_out)
- 参数量:
对比与简化: 将总FLOPs与标准卷积的FLOPs相除,得到比例约为1/C_out + 1/(K*K)。当C_out较大(如256)且K=3时,这个比例大约为1/256 + 1/9 ≈ 0.12,FLOPs降低为原来的约1/8!参数量也有类似比例的下降。这就是深度可分离卷积威力巨大的原因。
3.3 池化层、激活层与归一化层的计算考量
这是一个容易混淆的点:像ReLU、MaxPooling、BatchNorm这样的层,它们有FLOPs吗?它们影响参数量吗?
- 池化层(MaxPool/AvgPool):通常不计入主要的FLOPs。虽然它们有比较或加法操作,但相对于卷积和全连接层的密集计算,其计算量可以忽略不计。它们没有可学习的参数,所以参数量为0。
- 激活层(ReLU, Sigmoid, GELU等):包含简单的元素级操作,计算量很小。在粗略估算时通常忽略其FLOPs。它们也没有参数。
- 归一化层(BatchNorm, LayerNorm):
- 训练阶段:BatchNorm需要计算批次的均值和方差,有额外的计算量。但在推理阶段,其参数(缩放因子gamma和偏移因子beta,以及运行统计的均值和方差)是固定的,它执行的是一个固定的仿射变换:
y = gamma * (x - running_mean) / sqrt(running_var + eps) + beta。这个变换包含减、除、乘、加,计算量不可忽略,尤其是通道数多的时候。 - FLOPs:对于形状为
(C, H, W)的特征图,BatchNorm在推理时的FLOPs约为C * H * W * 4(四次元素运算)。在严谨的模型分析中需要计入。 - 参数量:BatchNorm有
2*C个可学习参数(gamma和beta),以及2*C个运行时统计参数(均值和方差),但后者不算在通常的“可训练参数量”中。所以其可训练参数量是2*C。
- 训练阶段:BatchNorm需要计算批次的均值和方差,有额外的计算量。但在推理阶段,其参数(缩放因子gamma和偏移因子beta,以及运行统计的均值和方差)是固定的,它执行的是一个固定的仿射变换:
实操心得:在使用
thop等工具时,它们通常会计入BatchNorm的FLOPs。如果你手动计算一个包含BN的卷积块,记得加上这部分。一个常见的做法是,在模型部署时,将BN层与前一层的卷积或全连接层进行“融合”,将BN的参数吸收到卷积的权重和偏置中,这样既能保持数值等效,又能消除BN层的计算开销,提升推理速度。
4. ViT核心模块FLOPs与参数量计算实战
视觉Transformer的结构与CNN截然不同,其计算核心从卷积变成了自注意力(Self-Attention)和前馈网络(FFN)。理解它的计算开销是分析ViT及其变体(如Swin Transformer)的关键。
4.1 Patch Embedding层计算
ViT的第一步是将图像分割成固定大小的块(Patch),并将每个块展平、投影到一个嵌入向量。这通常通过一个卷积层来实现。
假设:
- 输入图像:
(3, 224, 224) - Patch大小:
P=16 - 嵌入维度:
D=768
计算过程:
- 图像被分割成
(224/16) * (224/16) = 14 * 14 = 196个Patch。 - 每个Patch是
(3, 16, 16)的张量,展平后长度为3*16*16=768。 - Patch Embedding层实质是一个输入为768,输出为768的线性投影(如果展平后长度等于D),或者更常见的是用一个卷积核大小=步长=P的卷积层:
nn.Conv2d(3, D, kernel_size=P, stride=P)。- 对于卷积实现:
conv = nn.Conv2d(3, 768, kernel_size=16, stride=16) - 参数量:
3 * 16 * 16 * 768 + 768 = 589,824 + 768 = 590,592 - FLOPs:输出特征图形状为
(768, 14, 14)。FLOPs = 14 * 14 * 3 * 16 * 16 * 768 = 14*14 * 768 * 768。注意,因为stride=kernel_size,每个输出位置对应输入的一个不重叠的块,计算量与线性层等价。
- 对于卷积实现:
4.2 自注意力机制计算分解
自注意力是Transformer的计算核心,也是复杂度最高的部分。对于一个包含N个令牌(Token,即Patch),每个令牌维度为D的序列,计算多头自注意力(MSA)。
单头自注意力计算:
- 生成Q, K, V:通过三个线性层,将输入
X (N, D)映射到Q, K, V (N, D_k),通常D_k = D / num_heads。- 参数量:
3 * D * D_k(忽略偏置) - FLOPs:
3 * N * D * D_k(矩阵乘法)
- 参数量:
- 计算注意力分数:
A = softmax(Q * K^T / sqrt(D_k)),形状为(N, N)。Q * K^T的FLOPs:N * N * D_k(每个元素是D_k维向量的内积)。- Softmax等操作计算量相对较小,可粗略估算或忽略。
- 加权求和:
Output = A * V,形状为(N, D_k)。- FLOPs:
N * N * D_k(每个输出元素是N个V向量的加权和)。
- FLOPs:
单头总FLOPs≈3 * N * D * D_k + 2 * N * N * D_k。 对于h个头,因为D = h * D_k,所以多头总FLOPs≈3 * N * D * D_k * h + 2 * N * N * D_k * h = 3 * N * D^2 + 2 * N^2 * D。
关键洞察:公式2 * N^2 * D揭示了自注意力的计算复杂度与序列长度N的平方成正比。这就是原生ViT处理高分辨率图像(N很大)时计算量爆炸的原因。Swin Transformer提出的窗口注意力(Window Attention)和移位窗口(Shifted Window),就是为了将全局的N限制在一个窗口大小M内,使复杂度从O(N^2)降为O(M^2),而M是固定值(如7)。
4.3 前馈网络与整体ViT块计算
一个标准的Transformer编码器块包含:层归一化(LN1)、多头自注意力(MSA)、残差连接、层归一化(LN2)、前馈网络(FFN)、残差连接。
前馈网络:通常是一个两层MLP,中间有一个扩展因子。
- 公式:
FFN(x) = Linear_up(GeLU(Linear(x)))。假设输入维度D,中间层维度扩展为D_ff = 4*D(常见设置)。 - 第一个线性层:
(N, D) -> (N, 4D), FLOPs ≈N * D * 4D = 4N D^2。 - 第二个线性层:
(N, 4D) -> (N, D), FLOPs ≈N * 4D * D = 4N D^2。 - FFN总FLOPs≈
8N D^2。
一个ViT块的FLOPs估算:
- MSA部分:
3N D^2 + 2N^2 D - FFN部分:
8N D^2 - 总计(忽略LN和残差加法的少量计算):
≈ 11N D^2 + 2N^2 D
参数量估算:
- MSA中的3个QKV投影矩阵:
3 * D * D - MSA后的输出投影矩阵:
D * D - FFN的第一个线性层:
D * 4D - FFN的第二个线性层:
4D * D - 总计(忽略偏置和LN参数):
≈ 12 D^2
以一个ViT-Base为例(D=768, N=197 (196 patches + 1 cls token), heads=12):
- 单块参数量 ≈
12 * 768^2 ≈ 7.08M。 - 单块FLOPs(对于输入197个token):
11N D^2 = 11 * 197 * 589,824 ≈ 1.28e92N^2 D = 2 * 38,809 * 768 ≈ 59.6e6- 总计 ≈1.34 GFLOPs。一个包含12个这样的块的ViT-Base,仅编码器的FLOPs就高达16 GFLOPs以上,这解释了为什么ViT需要更大的算力。
5. 常见问题、工具使用与避坑指南
理论懂了,一上手还是容易出错。这部分记录了我踩过的坑和总结的技巧。
5.1 手动计算与工具结果对不上?排查清单
当你用自己写的函数计算出的FLOPs和thop、ptflops等工具结果不一致时,可以按以下清单排查:
- FLOPs定义是否统一?这是最大的坑!确认工具使用的是“乘加算一次”还是“乘和加分开算两次”。
thop默认使用“乘加算一次”(即MACs)。有些论文报告的是“乘加分开”的FLOPs,数值会是前者的两倍。务必查看工具的文档。 - 输入尺寸是否正确?确保你手动计算时使用的输入张量形状(尤其是
batch_size)与传递给工具的形状完全一致。FLOPs与batch_size是线性关系。 - 是否包含了所有层?你的手动计算是否漏掉了某些层?例如:
- BatchNorm/LayerNorm:如前所述,推理时有计算量。
- 偏置加法:你的计算是否包含了卷积和线性层偏置的加法?这部分通常占比很小,但严格来说应该计入。
- 激活函数:
thop通常不计入ReLU等简单激活的FLOPs,但复杂的如GELU、Swish可能会被估算。 - 跳跃连接中的加法:残差连接中的张量加法操作。
- 卷积的细节参数:你的计算是否考虑了
dilation(空洞卷积)、groups(分组卷积)?对于空洞卷积,有效核尺寸会变大。对于分组卷积,计算量会除以groups。 - ViT中的Class Token和位置编码:Class Token是一个额外的可学习向量,会增加序列长度N(从
num_patches变为num_patches+1)。位置编码如果是可学习的,也会增加参数量。这些在你的计算中考虑了吗?
5.2 使用thop.profile的正确姿势与陷阱
thop是一个常用的PyTorch模型复杂度分析工具。基本用法很简单,但有些细节需要注意。
import torch import torchvision.models as models from thop import profile, clever_format model = models.resnet50() input = torch.randn(1, 3, 224, 224) flops, params = profile(model, inputs=(input, )) flops, params = clever_format([flops, params], "%.3f") print(f"FLOPs: {flops}, Params: {params}")常见陷阱:
- 模型模式:确保模型在
.eval()模式下进行profile。某些层(如Dropout、BatchNorm)在训练和评估模式下的计算图可能不同。 - 自定义层/操作:
thop通过注册钩子来统计已知模块(如nn.Conv2d,nn.Linear)的计算量。如果你有自定义的层(例如一个手工实现的注意力模块),thop可能无法识别,导致计算结果偏低。你需要为自定义层手动注册FLOPs计算函数。def my_attention_flops_counter_hook(module, input, output): # 根据你的实现计算FLOPs module.__flops__ += calculated_flops my_custom_layer.register_forward_hook(my_attention_flops_counter_hook) # 然后在profile前,还需要处理一下,比较麻烦。更推荐使用fvcore等更灵活的工具。 - 动态控制流:如果模型的前向传播包含条件判断(if-else)或循环,
thop可能只统计实际执行路径的计算量,但这依赖于输入。对于动态网络,需要谨慎分析。 - 内存访问成本未计入:再次强调,
thop只统计理论浮点运算次数,不反映内存带宽带来的实际延迟。
5.3 模型轻量化设计中的计算量权衡思路
当你需要设计一个高效的模型时,仅仅看FLOPs和参数量是不够的,但它们是重要的起点。以下是一些权衡思路:
FLOPs vs. MAC(内存访问成本):
- 1x1卷积是“便宜”的:它的FLOPs低(
H*W*C_in*C_out),并且具有良好的内存访问局部性,在GPU上非常高效。 - 分组卷积的权衡:虽然大幅降低FLOPs和参数量,但可能破坏数据连续性,增加MAC,在某些硬件上加速比不如理论。
- 激活函数与数据精度:使用ReLU6代替ReLU?使用半精度(FP16)或整型(INT8)量化?这能大幅降低内存占用和带宽压力,从而提升实际速度,但FLOPs统计上看不出来。
- 1x1卷积是“便宜”的:它的FLOPs低(
ViT中的计算优化:
- 序列长度是敌人:一切降低有效序列长度N的方法都能平方级地降低注意力计算量。这就是窗口注意力(Swin)、池化注意力(Pooling)、下采样(Downsampling)等技术的核心动机。
- 线性注意力近似:研究线性复杂度的注意力变体(如Linformer, Performer),用低秩分解或核函数近似原始注意力矩阵,将复杂度从O(N^2)降为O(N)。
- 蒸馏与剪枝:用一个大模型(教师)指导一个小模型(学生)训练,或者直接剪掉模型中不重要的权重/注意力头。
实践建议:在设计初期,用FLOPs和参数量进行快速筛选和迭代。但在最终决定前,一定要在目标硬件(手机CPU、GPU、NPU)上做端到端的基准测试。使用TensorRT、OpenVINO、MNN等部署框架进行优化和测速,因为框架优化和算子融合可能彻底改变性能表现。
计算FLOPs和参数量不是目的,而是手段。目的是为了建立对模型复杂度的直觉,在模型设计、论文阅读和性能调优时,能快速定位计算瓶颈,做出更优的工程决策。亲手推导一遍公式,再用代码验证一遍,这种理解远比单纯记住几个数字要深刻得多。下次当你看到一个新的SOTA模型时,试着先估算一下它的计算开销,你会发现自己对模型的理解又上了一个台阶。