☰
卷积神经网络内存膨胀:参数量、MACs与运行时内存的深度解析
2026/10/8 10:33:44 网站建设 项目流程

1. 一个让很多人困惑的现象

刚接触深度学习部署的朋友,经常会遇到一个让人挠头的问题:明明模型文件才几十兆,为什么一跑起来内存就飙到几个G?我最早做模型推理服务的时候就踩过这个坑,一个ResNet-50的权重文件也就98MB左右,结果服务一启动,内存直接吃掉1.5G,当时第一反应是"是不是哪里内存泄漏了",排查了半天才发现,问题根本不在代码,而在于我对卷积层的内存开销理解太浅。

这个现象其实非常普遍。你拿一个训练好的模型文件,看它的大小,觉得"就这么点东西,能占多少内存",但实际运行时,内存占用往往是文件大小的十几倍甚至几十倍。这中间的差距从哪来的?答案就藏在卷积运算的三笔账里:参数量、MACs(乘加运算次数)、以及运行时内存。这三者之间的关系,很多人是模糊的,甚至混为一谈。

这篇文章适合所有做模型部署、推理优化、边缘端落地的同学。不管你是刚入门的新手,还是已经做过几个项目但没深究过内存问题的工程师,我都会把这三笔账从头算清楚。你需要的基础知识只有一点:知道卷积大概是怎么回事,知道什么是FP32。其他的,我用人话给你讲明白。

2. 先把三笔账的概念理清楚

2.1 参数量到底算的是什么

参数量,说白了就是模型里所有需要学习的权重和偏置的总数。对于一个标准的二维卷积层,参数量的计算公式是:

参数量 = 卷积核宽 × 卷积核高 × 输入通道数 × 输出通道数 + 输出通道数(偏置)

举个例子,一个3×3的卷积层,输入通道64,输出通道128,那么参数量就是 3×3×64×128 + 128 = 73,856。这个数字乘以4(FP32每个参数占4字节),大概是295KB。你看,一个卷积层的参数量其实不大。

整个模型的参数量加起来,就是你在文件里看到的那个大小。比如ResNet-50大约2500万参数,乘以4字节,差不多100MB,跟文件大小对得上。所以参数量决定的是模型文件的大小,它跟运行时内存的关系是间接的。

2.2 MACs为什么才是计算量大头

MACs,全称是Multiply-Accumulate operations,也就是乘加运算次数。每一次卷积操作,本质上就是做一次乘法和一次加法。MACs衡量的是模型的计算量,也就是"这个模型跑一次要算多少次"。

还是那个3×3卷积,输入64通道,输出128通道,假设输入特征图是56×56,那么:

MACs = 3×3×64×128×56×56 ≈ 2.3亿次

这个数字就比参数量大得多了。参数量是7万多,MACs是2.3亿,差了三千倍。这就是为什么模型文件小但计算量大的原因——参数量决定存储,MACs决定计算。

很多人会把MACs和FLOPs搞混。FLOPs是浮点运算次数,一次MAC算两次FLOP(一次乘、一次加),所以FLOPs通常是MACs的两倍。但在实际工程中,大家更习惯用MACs,因为它更直接地反映了硬件要做多少次乘加。

2.3 运行时内存到底吃在哪里

运行时内存,这才是真正让内存飙升的元凶。它主要包括三部分:

  • 权重内存:所有参数加载到内存里,这部分跟参数量直接相关,但通常不是大头。
  • 激活内存:每一层的输出特征图都要存在内存里,因为反向传播或者某些推理框架需要保留中间结果。这部分跟特征图大小直接相关。
  • 工作内存:卷积运算过程中,硬件或框架需要额外的缓冲区来做im2col、矩阵乘法、临时存储等操作。

激活内存往往是最容易被忽略的。一个56×56×128的特征图,FP32下就是56×56×128×4 = 1.6MB。看起来不大,但一个ResNet有几十层,每层都存一份,加起来就是几十MB。如果是高分辨率输入,比如512×512,那特征图直接翻几十倍,激活内存轻松上G。

注意:推理时如果框架做了内存复用(比如TensorRT、ONNX Runtime的memory pool),激活内存可以大幅降低。但训练时因为要反向传播,几乎所有中间激活都得留着,内存占用会更高。

3. 参数量、MACs、内存三者的真实关系

3.1 为什么参数量小不代表内存小

这是最核心的认知误区。参数量小,只说明模型文件小,但运行时内存取决于同时活跃的张量数量。一个模型可能参数量只有几百万,但如果它的特征图很大,激活内存就会很高。

举个极端的例子:一个1×1卷积,输入通道1,输出通道1,参数量只有2个。但如果输入特征图是4096×4096,那么输出也是4096×4096,激活内存就是4096×4096×4 = 64MB。参数量2个,内存64MB,差距三万倍。

所以你在部署模型时,不能只看模型文件大小,必须看输入分辨率和特征图通道数。这两个才是决定运行时内存的关键。

3.2 MACs和内存的关系

MACs高,通常意味着计算量大,但不一定内存就高。比如深度可分离卷积,MACs比标准卷积低很多,但内存占用可能差不多,因为特征图大小没变。

反过来,有些操作MACs不高,但内存占用很高。比如concat操作,它本身不做乘加,但需要把两个特征图拼在一起,内存直接翻倍。再比如上采样,MACs几乎为零,但输出特征图是输入的4倍(2倍上采样),内存直接涨4倍。

所以MACs和内存是两个独立的维度,优化的时候要分开看。你不能说"我MACs降下来了,内存就一定降下来了",这是两码事。

3.3 一个具体的计算示例

我拿一个实际的卷积层来算一遍,让你有个直观感受。

假设输入特征图:224×224×64,卷积核3×3,输出通道128,stride=1,padding=1。

参数量:3×3×64×128 + 128 = 73,856,FP32下约288KB。

MACs:3×3×64×128×224×224 ≈ 37亿次。

激活内存:输出特征图224×224×128,FP32下224×224×128×4 = 25.7MB。如果框架需要保留输入和输出,那就是25.7 + 12.8 = 38.5MB。

你看,参数量288KB,激活内存38.5MB,差了130多倍。这就是为什么模型文件小但内存吃得多。

如果输入分辨率变成448×448,激活内存直接变成原来的4倍,154MB。如果通道数再翻倍,那就是308MB。一层就这么多,几十层加起来,内存不上G才怪。

4. 卷积内存膨胀的底层原理

4.1 im2col带来的内存放大

很多推理框架在实现卷积时,会用im2col(image to column)的方法。简单说,就是把卷积操作转换成矩阵乘法。具体做法是:把输入特征图中每个卷积窗口覆盖的区域拉成一列,形成一个矩阵,然后用这个矩阵跟卷积核矩阵做乘法。

这个方法的优点是可以用高度优化的矩阵乘法库(比如BLAS),计算效率高。但缺点是内存放大。一个3×3卷积,im2col之后,输入矩阵的行数变成原来的9倍(因为每个位置要拉出9个元素)。虽然列数减少了,但总体内存占用还是增加了。

具体来说,输入特征图224×224×64,im2col之后变成(224×224)×(3×3×64) = 50176×576的矩阵,FP32下就是50176×576×4 = 115MB。而原始输入只有224×224×64×4 = 12.8MB。放大了9倍。

这就是为什么很多框架在推理时内存飙升——im2col的临时缓冲区太大了。后来大家用Winograd、FFT等算法来减少计算量和内存,但im2col仍然是最通用的方法。

4.2 特征图的生命周期管理

推理框架通常会做内存复用,也就是一块内存用完了,后面的层可以接着用。但这个复用是有条件的:只有当两个张量的生命周期不重叠时,才能复用同一块内存。

在推理时,因为不需要反向传播,每一层的输入用完就可以释放,所以内存复用效率很高。但在训练时,每一层的输入都要留着给反向传播用,内存复用效率就很低。

这也是为什么同一个模型,训练时内存占用可能是推理时的3-5倍。很多人拿训练时的内存需求去估算推理时的内存,结果发现推理时内存小很多,就是这个原因。

4.3 框架层面的内存池机制

主流推理框架(TensorRT、ONNX Runtime、TVM等)都有自己的内存池机制。它们会在初始化时一次性申请一大块内存,然后自己管理分配和释放,避免频繁调用系统malloc/free。

这个机制的好处是减少内存碎片,提高分配效率。但缺点是内存占用是峰值决定的。也就是说,即使某一时刻内存用量很低,内存池也不会把内存还给系统,而是留着给后面的层用。所以你看到的内存占用,往往是整个推理过程中内存用量的峰值。

我实测过,一个MobileNetV2,模型文件14MB,推理时内存峰值大约300MB。其中权重只占14MB,剩下的全是激活内存和im2col缓冲区。如果用TensorRT优化,内存可以降到150MB左右,因为它做了层融合和内存复用。

5. 实操:如何准确估算和优化内存

5.1 手算内存占用的方法

如果你想在部署前估算内存占用,可以按这个步骤来:

  1. 列出所有层:把模型的每一层列出来,包括卷积、BN、ReLU、池化等。
  2. 计算每层输出特征图大小:根据输入分辨率、卷积核大小、stride、padding,算出每层输出的宽高和通道数。
  3. 计算激活内存:每层输出特征图大小 × 4字节(FP32)。如果是推理,通常只需要保留当前层和下一层的输入,所以可以只算峰值。
  4. 计算im2col缓冲区:对于每个卷积层,输入特征图大小 × 卷积核面积 × 4字节。
  5. 加上权重内存:所有参数 × 4字节。
  6. 取峰值:把上面所有加起来,取整个推理过程中的最大值。

这个估算方法不是100%准确,因为框架的具体实现会有差异,但能给你一个数量级的判断。我一般会在这个基础上乘以1.5作为安全余量。

5.2 用工具实测内存占用

手算毕竟麻烦,实际项目中我更多用工具来测。常用的方法有:

  • PyTorch:用torch.cuda.max_memory_allocated()看GPU内存,用tracemalloc看CPU内存。
  • ONNX Runtime:开启profiling,会输出每个节点的内存占用。
  • TensorRT:用trtexec工具,加--dumpProfile参数,可以看到每层的内存和时间。
  • 系统工具:Linux下用/usr/bin/time -v看峰值内存,或者用ps、top实时监控。

我一般会先用工具测出峰值内存,然后跟手算结果对比,看看差距在哪里。如果差距很大,通常是某个层的im2col缓冲区特别大,或者框架没有做好内存复用。

5.3 降低内存的几种实用手段

如果你发现内存占用太高,可以尝试这几种方法:

降低输入分辨率:这是最直接有效的。输入从224降到160,激活内存直接降到原来的51%。精度可能会掉一点,但很多时候可以接受。

使用FP16或INT8:FP32换成FP16,内存直接减半。INT8再减半。现在很多硬件都支持FP16和INT8加速,精度损失可以通过量化感知训练来弥补。

层融合:把Conv+BN+ReLU融合成一个操作,减少中间特征图的存储。TensorRT和ONNX Runtime都支持这种优化。

内存复用:确保框架开启了内存复用。PyTorch的torch.no_grad()、ONNX Runtime的enable_mem_pattern、TensorRT的--memPoolSize都可以控制。

换算法:用Winograd代替im2col,可以减少计算量和内存。但Winograd对硬件有要求,不是所有平台都支持。

提示:优化内存时不要只盯着一个手段,通常是组合使用。比如先降分辨率,再上FP16,再做层融合,三管齐下,内存可以降到原来的1/4甚至更低。

6. 常见问题与排查技巧

6.1 为什么模型文件小但内存大

这个问题前面已经解释过了,核心原因是激活内存和im2col缓冲区。模型文件只包含参数量,而运行时内存还包括特征图、临时缓冲区等。一个经验法则是:运行时内存通常是模型文件的10-30倍。如果你的模型文件是100MB,那运行时内存1-3G是正常的。

6.2 内存泄漏还是正常占用

很多人看到内存高就怀疑是内存泄漏。区分方法很简单:看内存是否持续增长。如果内存稳定在一个峰值不再涨,那就是正常占用。如果内存一直涨,跑几次就OOM,那才是泄漏。

内存泄漏常见的原因有:没有用torch.no_grad()、没有释放中间变量、DataLoader的worker没关等。我踩过的一个坑是,在循环里不断创建新的tensor但没有释放,导致内存一直涨。后来改成复用tensor,问题就解决了。

6.3 不同框架的内存表现差异

同一个模型,在不同框架下内存占用可能差很多。我实测过一个模型:

框架内存峰值说明
PyTorch (eager)1.2GB默认不做优化,内存最高
PyTorch (JIT)800MB做了图优化,内存降低
ONNX Runtime600MB内存复用做得好
TensorRT400MB层融合+FP16,内存最低

这个差异主要来自框架的优化程度。TensorRT因为做了层融合和FP16,内存最低。ONNX Runtime次之。PyTorch eager模式最费内存,但最灵活。

6.4 常见问题速查表

问题可能原因解决方法
内存占用是模型文件的20倍激活内存+im2col缓冲区降低分辨率、用FP16、层融合
内存持续增长不释放内存泄漏检查no_grad、释放中间变量
推理时内存比训练时小很多训练要保留激活做反向传播正常现象,推理时内存复用效率高
换了框架内存变化很大框架优化程度不同选优化好的框架,如TensorRT
某层内存特别大im2col缓冲区大换Winograd或FFT算法

6.5 几个容易踩的坑

坑一:只看模型文件大小估算内存。这是最常见的错误。模型文件只是参数量,运行时内存还包括激活和缓冲区。我一般会按模型文件的15-20倍来估算。

坑二:忽略输入分辨率的影响。输入分辨率翻倍,激活内存翻4倍。很多人调模型时只关注精度,忽略了分辨率对内存的影响。

坑三:以为FP16只是加速。FP16不仅加速,还能直接减半内存。如果你的硬件支持FP16,强烈建议用。

坑四:不做内存复用。有些框架默认不开内存复用,需要手动开启。比如ONNX Runtime的enable_mem_pattern,TensorRT的--memPoolSize。

坑五:忽略batch size的影响。batch size翻倍,激活内存也翻倍。推理时如果不需要batch,就设成1。

7. 一个完整的计算实例

我拿一个实际的模型来算一遍,让你有个完整的感受。假设我们有一个简单的CNN,输入224×224×3,结构如下:

  • Conv1: 3×3, 3→64, stride=1, padding=1
  • Conv2: 3×3, 64→128, stride=1, padding=1
  • Conv3: 3×3, 128→256, stride=1, padding=1
  • FC: 256×7×7→1000

参数量:

  • Conv1: 3×3×3×64+64 = 1,792
  • Conv2: 3×3×64×128+128 = 73,856
  • Conv3: 3×3×128×256+256 = 295,168
  • FC: 256×7×7×1000+1000 = 12,545,000
  • 总计:约12.9M参数,FP32下约51.6MB

MACs:

  • Conv1: 3×3×3×64×224×224 ≈ 8.7亿
  • Conv2: 3×3×64×128×224×224 ≈ 37亿
  • Conv3: 3×3×128×256×224×224 ≈ 148亿
  • FC: 256×7×7×1000 ≈ 12.5亿
  • 总计:约206亿MACs

激活内存(假设只保留当前层输出):

  • Conv1输出:224×224×64×4 = 12.8MB
  • Conv2输出:224×224×128×4 = 25.7MB
  • Conv3输出:224×224×256×4 = 51.4MB
  • 峰值:51.4MB

im2col缓冲区(以Conv3为例):

  • 输入224×224×128,im2col后224×224×3×3×128×4 = 231MB

你看,im2col缓冲区231MB,比激活内存51.4MB大得多。这就是为什么实际运行时内存会飙升到几百MB甚至上G。

如果换成FP16,所有数字减半。如果输入分辨率降到112×112,所有数字降到1/4。如果做层融合,im2col缓冲区可以省掉。组合使用,内存可以从几百MB降到几十MB。

8. 我个人的一些经验体会

做了这么多年的模型部署,我最大的体会是:不要用模型文件大小来估算内存。这个习惯害了很多人。我现在拿到一个新模型,第一件事是看输入分辨率和特征图通道数,这两个决定了内存的下限。然后看框架的优化程度,这决定了内存的上限。

另一个体会是,内存优化没有银弹。降分辨率、FP16、层融合、内存复用,每个手段只能解决一部分问题,必须组合使用。我一般会先做层融合和内存复用,这两个不需要改模型,效果也最明显。然后再考虑降分辨率或量化,这两个会影响精度,需要权衡。

最后说一个容易被忽略的点:batch size对内存的影响是线性的。很多人推理时习惯用batch size=8或16,觉得能提高吞吐。但如果内存不够,batch size=1反而更稳。我一般会先测batch size=1的内存,然后根据余量决定能开到多大。

还有一个坑是,不同硬件的内存表现不一样。同样的模型,在服务器CPU上跑和在边缘设备上跑,内存占用可能差很多。边缘设备通常内存更紧张,优化要更激进。我一般会在目标硬件上实测,而不是在开发机上估算。

如果你也在做模型部署,建议你养成一个习惯:每次拿到新模型,先算一遍参数量、MACs和激活内存,心里有个数。然后用工具实测,对比一下。时间长了,你就能凭经验判断一个模型大概吃多少内存,这对选型和优化非常有帮助。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询