模型调参、发论文、横向对比算力开销,只要你碰深度学习,就绕不开 FLOPs 这个词。我最早开始算 FLOPs 时,是抱着“跑个脚本看个数”的心态去的,结果连续两周被各种报错按在地上摩擦:同样的模型,thop 说 2.4G,pytorch profiler 说 3.1G,换了另一个工具又变成 1.17G,三个数字谁都不敢信。后来踩的坑多了,才慢慢把“计算 FLOPs 报错”这件事背后的原理、工具差异和排查套路摸清楚。这次就顺手把这段经历整理成一份报错集锦,按实际场景把高频问题、根因和解决方法列出来,给被 FLOPs 折磨过的人一份可以直接抄的排查手册。适合刚入门想评估模型复杂度的同学,也适合准备横向对比模型论文指标的老手。
1. 先搞清楚:FLOPs 到底是怎么被算出来的
很多报错其实不是“工具坏了”,而是我们对统计原理的理解有偏差。先把这个底层逻辑讲清楚,后面再聊报错,你才知道怎么对症下药。
1.1 一次损失惨重的经历:数错 FLOPs 比不算还坑
有一次做模拟项目 X 的版本选型,需要对比两个结构非常相似但细节不同的图像模型。当时的操作很简单:把模型和输入扔给某个工具,看一眼输出,然后把数字填进了对外文档。结果模型 A 在文档里写着 3.2G FLOPs,模型 B 写着 1.8G FLOPs,最终汇报时被评审追问:为什么模型 A 的输入输出shape几乎和模型 B 一样,FLOPs 却差了快一倍?
我当场愣了一下,回去逐个算子排查才发现问题:模型 A 的 forward 里有一个在推理阶段根本没有走到的分支,而统计工具默认把模型的所有参数层都遍历了一遍,无论这个分支实际运不运行,它都按“存在”来计数。更离谱的是,模型的 attention 部分里用了一个自定义的窗口聚合算子,工具根本没识别出它的真实计算量,只统计了旁边的卷积层。
这次教训让我意识到,统计 FLOPs 这件事,比大多数人想象的要细致得多。工具给出来的数字是一个“理论参考值”,它基于模型参数的静态遍历和输入 shape 的推导,并不代表真实运行时的浮点运算量。之后我再也不盲信任何一行数字,每次都要自己把关键层拉出来核对一遍。
1.2 主流统计工具的原理与差异
现在常用的 FLOPs 统计方式大致分三类,每一类的统计口径和报错方式都不一样,这是报错集锦里最容易被忽略的背景知识。
第一类是 pytorch 自带的 torch.profiler。它的原理是把模型真实跑一遍,在算子执行层面记录 CPU/CUDA 上的耗时和 flops。优势是不用额外装库,而且是在真实运行时统计,能准确反映“这次实际执行”的计算量。缺点也很明显:它只会统计真正跑到的分支,如果模型里有 if 条件分支、训练阶段才走的路径,它统统看不到。另外,torch.profiler 的 flops 字段对很多底层算子支持不全,某些自定义 kernel 可能根本没有对应的 flops 统计。
第二类是 thop,也是最早流行的一批工具。thop 的做法是遍历模型所有包含参数的层,通过注册 forward hook 来截获每一层的输入输出 shape,再用内置的“层类型 -> 计数函数”映射表去计算对应计算量。这种静态遍历方式的好处是稳定、快速,缺点是映射表不完善的层就会直接抛异常或者统计为 0。这就是很多新人第一次用 thop 就撞上 NotImplementedError 的根本原因。
第三类是 ptflops,它的思路和 thop 类似,但实现上更像是在“构造一个 dummy 计算图”,然后通过钩子去记录每个模块的 MACs。它对标准卷积、全连接、归一化等常见层支持得不错,输出格式也比 thop 漂亮,但对嵌套输入和一些奇怪的容器模块处理起来同样僵硬。
另外还有一个更手动的思路:自己按照公式算卷积、全连接的计算量。这种方法最慢但最可控,适合排查工具计算结果是否合理时用来做交叉验证。
我把常用工具的特征整理成了一张表:
| 工具 | 统计口径 | 是否依赖真实运行 | 多输入支持 | 常见报错 |
|---|---|---|---|---|
| torch.profiler | 实际运行时算子 | 是 | 较好 | flops 字段缺失、算子不支持 |
| thop | 静态遍历带参数层 | 否 | 较差 | NotImplementedError、tuple输入报错 |
| ptflops | 静态构造计算图 | 否 | 一般 | 维度推导失败、None返回值报错 |
| 手算 | 公式推导 | 否 | 完全可控 | 容易漏层、算错尺度 |
1.3 FLOPs 和 MACs 到底怎么换算
还有一个经常把数字搞到对不上的原因:MACs 和 FLOPs 的换算关系。一个 MAC(Multiply-Accumulate,乘加运算)包含一次乘法和一次加法,所以理论上 1 MAC = 2 FLOPs。但很多人不知道,thop 的 profile 函数返回的第一个值默认是 MACs 而不是 FLOPs,你要是没看文档,直接把数字当成 FLOPs 去跟论文对比,分母上就凭空少乘了一个 2,量级直接翻倍。
我自己习惯先看一眼返回值的命名,再决定要不要乘 2。通常的规则是:工具返回macs,那么 FLOPs = macs × 2;工具返回flops,那就直接用。千万别看着数字相似就忽略单位,在脑科学、推荐系统这种对算力比较敏感的领域,这一个小数点能差出十万八千里。
2. 高频报错逐个拆解:一上来就能复现的那种
现在进入正题。下面这几个报错,是群里伙伴问得最多、也是我自己在项目里反复撞过的类型。每一条我都按照“报错信息 -> 出错原因 -> 解决路径”来整理,方便你直接对照排查。
2.1 经典报错:thop 说你没实现这个层的 flops
第一次用 thop 统计某个带 HAT 模块的模型时,直接看到:
NotImplementedError: If you want to count the model flops, please implement the flops of the module: <class 'torch.nn.modules.conv.ConvTranspose2d'>这个错误的意思是:thop 的层类型映射表里没有 ConvTranspose2d 的计数逻辑。其实不止 ConvTranspose2d,常见的还有某些池化、特殊激活、上采样、自定义注意力机制里的新层。
解决方案是手动注册计数字典。thop 提供了一种注册方式,我当时的做法是写一个独立文件来注册缺失算子:
from thop import clever_register import torch.nn as nn def count_convtranspose2d(self, input, output): # 反卷积的乘法次数:输出通道 * 输出宽高 * 输入通道 * 卷积核宽 * 卷积核高 / groups input = input[0] batch, in_c, in_h, in_w = input.shape out_c, _, kh, kw = self.weight.shape out_h = output.shape[-2] out_w = output.shape[-1] kernel_ops = in_c * kh * kw // self.groups macs = batch * out_c * out_h * out_w * kernel_ops flops = 2 * macs self.macs = macs self.flops = flops clever_register(nn.ConvTranspose2d, count_convtranspose2d)注册完再跑同一段代码,报错就消失了。关键点是:注册函数里必须有self.macs和self.flops这两个属性挂到层对象上,thop 在汇总阶段会去读这两个字段。如果你手滑写成了别的名字,统计结果不会报错,但会悄悄变成 0,这种“静默错误”比报错更坑人。
2.2 处理不了 list/tuple 嵌套输入:直接 AttributeError
第二个高频报错是输入类型问题。比如模型的 forward 定义是def forward(self, x, y):并且 x 本身又是一个list,这时候直接套用 thop 的 profile 经常会遇到:
AttributeError: 'tuple' object has no attribute 'size'这个报错的原因在于 thop 内部会自动把inputs包装成 tuple,然后传给 hook,它在里面尝试对input[0]调用.size()。当input[0]本身还是 list 或 tuple 时,解析就崩了。
我对付这个问题的思路是“隔离输入”。具体来说,给模型包一个壳子,让壳子接收一个普通的 tensor,然后在壳子 forward 里拆包成原模型需要的格式:
import torch from thop import profile class FixedWrapper(torch.nn.Module): def __init__(self, model): super().__init__() self.model = model def forward(self, x): # 假设原模型需要 list[int],这里模拟拆包 patch = x.unflatten(0, (1, -1)) return self.model([patch]) model = FixedWrapper(raw_model) input_tensor = torch.randn(1, 112, 112) flops, params = profile(model, inputs=(input_tensor,))这种“包一层”的方法能绕开大部分嵌套输入解析问题,而且对模型本身没有任何修改。另一个更稳妥但不那么常用的方法是直接看 forward 代码,手动给模型每个层写计数逻辑,不过工作量大,非必要不建议。
2.3 维度对不上:expected input to have 3 channels
这种报错一般出现在你手动构造 dummy input 时搞错了输入通道:
RuntimeError: Given groups=1, weight of size [64, 3, 7, 7], expected input[1, 4, 224, 224] to have 3 channels, but got 4 channels instead我见过太多人把(224, 224, 3)这种 HWC 的习惯带进来,下意识写成了torch.randn(1, 224, 224, 3)。PyTorch 默认是 NCHW,通道在第二维。另一个相似场景是 RGB 图像模型,结果你给了一个带背景通道的 4 通道输入,这就不是报错与否的问题,而是统计结果会直接反映一个并不存在的输入。
最保险的做法,是先用next(model.parameters()).shape看一眼第一个卷积层的输入通道数,再构造 dummy input。比如模型第一个卷积是nn.Conv2d(3, 64, 7),那就用torch.randn(1, 3, 224, 224),分辨率大小其实不影响统计,最重要的是通道数和模型定义一致。
2.4 设备不一致:CUDA 和 CPU 混用
这种情况更容易出现在刚接触 GPU 训练的同学身上。模型已经通过.cuda()放到 GPU 上了,但构造输入的时候忘了同样迁移,跑 ptflops 或 torch.profiler 时报:
RuntimeError: Input type (torch.cuda.FloatTensor) and weight type (torch.FloatTensor) should be the same解决思路非常朴素:输入、模型放同一设备。如果你用 profile 工具时传的模型本身就是model.cuda(),那么 dummy input 也要cuda():
input_tensor = torch.randn(1, 3, 224, 224).cuda()但注意,thop 这类静态工具对设备没那么多要求,因为很多计数逻辑只做 shape 推导,不需要真的跑到 GPU。可 ptflops 在构造 dummy graph 时,某些算子确实会触发真实张量运算,所以设备一致性就成了硬要求。这也是为什么我更喜欢 thop 的原因之一——报错更少,迁移更平滑。
3. 复杂模型里的隐形地雷
常规 CNN 模型统计起来相对顺利,真正常踩坑的是那些结构灵活、前后依赖强的模型,比如多分支结构、动态 shape 的 Transformer、带自定义算子的注意力模块。这一章把常见的“隐性错误”拆开看。
3.1 多分支结构被重复计数:结果虚高
有一次我要给一个双塔结构的对比模型写算力报告,模型结构大致是:一个共享编码器,输入两路不同的特征,最后拼接送到输出层。我用 thop 统计完,发现总 FLOPs 比理论预期高了快一倍。排查了很久,发现是共享编码器在两个分支里各被调用了一次,hook 机制把同一个模块的计算量累加了两遍。
这就涉及一个关键认知:thop 是基于“模块被 forward 调用次数”来累计的,计的是动态执行次数,而不是模型定义里的唯一层数。模型只有在训练和推理中都保持单分支结构时,hook 直接累加才准确。对于孪生网络、对比学习、多尺度推理这类结构,你不能直接把工具输出的总和当成“一次推理成本”。
我当时的处理方法是:把共享编码器单独抽出来统计一次,再统计除共享部分之外的其他模块,最后手动求和。或者在包装模型时,把共享编码器的输出通过torch.no_grad()缓存下来,避免第二次前向重复触发 hook。如果你只想得到一个参考值,直接在最后结果上除以分支数也勉强能看,但严谨的项目不建议这么做。
3.2 动态 shape 输入:换尺寸后数字就变了
还有人问过一个问题:为什么我换了一版输入分辨率,FLOPs 就差别这么大?这其实不是报错,是一种容易误导人的“正常现象”。因为 FLOPs 本质上是输入 shape 的函数,尤其是 Transformer 里的自注意力,它的计算量随序列长度平方增长,输入尺寸稍微差一点,最终数字可能差出一个量级。
用一个简单公式说明:一个形状为(B, N, C)的序列经过自注意力模块,QKV 投影的计算量约为3 * B * N * C^2,注意力的矩阵乘则是2 * B * N^2 * C。这里的 N 一旦翻倍,第二项的 N² 就直接放大 4 倍。所以你在线对比两个模型时,如果输入尺寸设置不一样,数字根本没有可比性。
我的建议是:在准备横向对比前,先约定一个标准输入尺寸,比如(1, 3, 224, 224),所有模型都按这个口径统计。论文里写 FLOPs 时也一定要标注分辨率,否则数据没有任何参考意义。虽然这个点不算报错,但它是很多“数字对不上”纠纷的根源。
3.3 view、reshape、切片到底算不算 FLOPs
很多人在手工核对模型计算量时还会困惑:类似view、reshape、permute这类算子,工具到底有没有把他们计入 FLOPs?
答案取决于统计口径。thop 的映射表对这类“内存重排型”算子基本不计入 MACs,因为它不包含乘加运算。而 torch.profiler 在真实执行时,偶尔会把某些 copy 或者 index_select 也统计成有计算量的算子,导致它给出的整体数字比 thop 高。这种差异并不是哪个工具错了,而是它们衡量的是不同层面的开销。理论 FLOPs 通常默认指的是乘加类运算,所以你在对外文档里引用理论 FLOPs 时,建议参考 thop/ptflops 这类静态工具的口径。你完全可以用 torch.profiler 去看实际耗时,但不建议把它的 flops 字段直接当论文指标用。
4. 自己动手写一个统一封装工具
这一节讲的是我在项目里最终落地的一套方案。与其每次被不同工具的报错折磨,不如抽时间把“计算 FLOPs”这个需求封装成一个函数,后续任何模型都能一键跑,遇到未知算子再往注册表里补一笔记录。
4.1 为什么我没有直接用现成 API
直接调 thop 的 profile 其实很简单,但真实需求往往不止“跑一个数字”。我需要的是:
- 能处理单输入、多输入、list 嵌套输入
- 缺失算子的报错能很快定位到是哪个模块
- 每一层都能输出独立 MACs,方便核对
- 遇到重复计数的多分支结构,能快速过滤
这些需求用现成 API 硬套,每一类都要重新写一遍适配代码。干脆把适配逻辑收拢到一个文件里,以后新增模型就只写一行调用。
4.2 核心实现思路
我最终选择以 thop 为底座,在上面做了一层封装。原因是它速度快、可控性强,而且注册机制足够清晰。核心函数大概是这样的:
import torch from thop import profile, clever_register class FlopsEvaluator: def __init__(self, model, input_creator, skip_reuse=False): self.model = model self.input_creator = input_creator # 接收模型返回的输入 self.skip_reuse = skip_reuse def evaluate(self): model = self.model if hasattr(model, "cuda"): try: model = model.cuda() except Exception: pass inputs = self.input_creator(model) flops_total, params_total = profile(model, inputs=(inputs,)) summary = self._flops_summary() return { "flops": flops_total, "macs": flops_total, "params": params_total, "summary": summary, } def _flops_summary(self): # 从 thop 内部缓存中拿逐层明细,这里按自己的格式整理 result = {} for module_name, module in self.model.named_modules(): macs = getattr(module, "macs", 0) if macs > 0: result[module_name] = macs return result这套封装帮我解决了很多实际问题。比如input_creator可以写成可插拔的函数,不同模型传入不同构造逻辑,兼容多输入或嵌套输入。逐层明细输出到 dict 后,可以直接 check 哪一层为 0,快速定位未识别算子。
4.3 实测过程:从报错到跑通的完整记录
我用一个自制的图像分割模型来做实测,结构里包含普通卷积、转置卷积、残差连接和一个自定义的局部注意力层。
第一轮跑 thop,报错是自定义注意力层的NotImplementedError,按 2.1 的方法注册了计数函数;第二轮跑,不报错了,但我发现局部注意力层的macs字段是 0。我检查了计数函数,发现是输入里包含了 tuple,而我的函数只取input[0],修正后统计正常;第三轮,模型里有双分支结构,共享编码器被调用了两次,我又在封装里加了skip_reuse=True选项,统计时对已经出现过的共享参数模块进行计数抑制。
最终结果如下:
| 模块 | 统计值(MACs) | 备注 |
|---|---|---|
| 下采样卷积组 | 1.42G | 正常 |
| 重构反卷积组 | 0.78G | 注册后成功 |
| 局部注意力层 | 0.35G | 修复 tuple 输入后成功 |
| 分类头 | 0.02G | 正常 |
整体 MACs 约为 2.57G,对应 FLOPs 约 5.14G。我把这个数字和手算核对了一遍,差距在 3% 以内,基本可以放心使用了。
4.4 一个小技巧:把“0 值层”当警报器
在封装工具迭代过程中,我还养成了一个习惯:如果某个模块的 macs 为 0,而这个模块又不是 BN 或 ReLU,那我就会怀疑是不是漏统计了。这相当于把工具当成一个“报警器”,让那些偷偷不生效的模块现出原形。实践中,漏统计通常发生在自定义 Attention、GroupNorm、各种 Pooling 层上,一旦出现 0,第一反应是去查 thop 的注册表有没有覆盖这个层。
5. 常见问题速查表与避坑要点
把前面所有真实遇到的问题汇总成一张速查表,以后遇到类似报错直接翻表查,省得再翻历史记录。
| 报错或异常 | 根因 | 快速解决 |
|---|---|---|
| NotImplementedError | thop 映射表缺自定义层 | 用 clever_register 注册缺失算子 |
| AttributeError: 'tuple' object... | forward 接收了 list/tuple 嵌套输入 | 包一层 Wrapper,把复杂输入拆成 tensor |
| RuntimeError: expected input to have 3 channels | dummy input 通道数和模型不匹配 | 从模型首个卷积层读取输入通道构造输入 |
| CUDA/CPU 混用报错 | 模型和输入不在同一设备 | 统一调.cuda() |
| 统计结果偏大接近 2 倍 | 多分支结构或共享模块被重复计数 | 抽取共享模块单独统计,或除以分支数 |
| flops 字段为 0 | 层类型没有被注册 | 查看对应层的计数函数,确认输入解析正确 |
| FLOPs 数值和论文差一个量级 | MACs 与 FLOPs 混用、分辨率口径不同 | 统一换算 ×2,并严格固定输入尺寸 |
| torch.profiler 无 flops 列 | 算子或版本不支持 flops 字段 | 升级 torch 版本,或改用 thop 统计理论值 |
除了这张表,还有几条我觉得特别重要的原则:
- 使用 thop 时,输入张量的
requires_grad不会影响统计,但如果你用的是torch.no_grad()包住整个统计过程,某些 hook 的中间缓存行为会不一致,建议默认不额外包。 - 统计结果最好每次都在同一个 batch_size 下做。不同 batch 下普通卷积的 flops 是线性增长,但对比学习这种结构可能因为矩阵乘法形状不同产生非线性变化。
- 注册自定义算子时,优先参考 PyTorch 官方算子的实现方式,重点看它对 padding、stride、groups 这些参数的处理,避免手写的公式和实际参数不一致。
- 最后也是最重要的一点:任何统计工具输出前,都要先看一眼代码里有没有对
macs和flops这两个字段做混淆。很多第三方库更新后字段名会变,你写的计数函数最好自己定义字段,别依赖工具的默认行为。
我个人在实际操作中的体会是:FLOPs 统计这项技能,想真正做好,不能只靠“拿工具跑一下”,而是要理解每一类算子到底做了多少乘法、多少加法。报错并不是坏事,每次报错都等于工具在提醒你:这里有一个你以为懂了但实际没搞透的细节。把报错集锦里的每一条都走一遍,你对模型的计算图理解会明显上一个台阶。后来我偶尔还会主动用一段旧版模型验证新工具的行为,把两种结果放一起比对,花不了几分钟,但能避免很多“当时觉得没问题、后来答辩被问懵”的尴尬时刻。这大概就是踩坑之后最大的收获吧。