1. 一个让很多人困惑的现象:权重才几兆,跑起来却吃掉几个G
先把场景摆出来。你训练好一个卷积网络,权重文件导出成 FP32 格式,一看大小,比如 3.5 MB、12 MB,甚至更小。你心想这玩意儿这么轻,部署到设备上肯定没压力。结果一跑起来,内存占用直接飙到几百兆甚至上 G,任务管理器或者nvidia-smi上的数字让你怀疑人生。
这个反差是真实存在的,而且非常普遍。我最早做移动端推理的时候也踩过这个坑:模型文件 5 MB,App 一加载,进程内存涨了 300 MB,当时第一反应是"是不是哪里内存泄漏了",查了半天才发现根本不是泄漏,是卷积这个操作本身在运行时需要的内存,和权重文件大小完全是两码事。
要理解这件事,得把卷积在运行时的内存开销拆成三笔账来算:
- 第一笔:权重本身占的内存,也就是你看到的那个文件大小,这部分通常最小;
- 第二笔:特征图(激活值)占的内存,这是真正的大头,往往比权重高出一到两个数量级;
- 第三笔:计算过程中的临时缓冲和中间结果,包括 im2col 展开、卷积累加缓冲、算子融合前的中间张量等。
很多人只算了第一笔账,所以才会觉得"模型这么小,凭什么吃这么多内存"。这篇就把这三笔账一笔一笔算清楚,顺带把 MACs、参数量、FP32 这几个概念之间的关系捋顺,让你以后看到任何一个卷积网络,都能大致估出它的运行时内存量级。
提示:本文讨论的是推理(inference)阶段的内存占用。训练阶段还要额外存梯度、优化器状态、前向激活缓存,内存需求通常是推理的数倍,那是另一个话题。
2. 第一笔账:权重内存,为什么它总是"看起来很小"
2.1 参数量和文件大小的换算关系
先把这个最基础的关系说清楚,因为后面所有估算都建立在它之上。
一个卷积层的参数量公式是:
参数量 = 卷积核高 × 卷积核宽 × 输入通道数 × 输出通道数 + 输出通道数(偏置)举个具体的例子。一个标准的 3×3 卷积,输入 256 通道,输出 256 通道:
参数量 = 3 × 3 × 256 × 256 + 256 = 589,824 + 256 ≈ 590K这大约 59 万个参数。如果用 FP32 存储,每个参数 4 字节:
590K × 4 Byte ≈ 2.36 MB就这一层,2.36 MB。一个典型的 ResNet-50 有大约 2550 万个参数,FP32 下:
25.5M × 4 Byte ≈ 102 MB所以 ResNet-50 的权重文件大概就是 100 MB 左右,这个数字大家应该都见过。而像 MobileNet 这种用深度可分离卷积的轻量网络,参数量能压到 400 万左右,FP32 下才 16 MB。
这里就能看出第一个关键点:权重内存 = 参数量 × 每参数字节数。FP32 是 4 字节,FP16 是 2 字节,INT8 是 1 字节。量化能把权重内存直接砍到 1/4 或 1/2,这也是为什么端侧部署特别爱用量化。
2.2 为什么权重往往不是内存瓶颈
把权重和后面的特征图一比,你就明白为什么它经常不是主角了。
还是 ResNet-50,权重 100 MB。但它第一层卷积的输入是一张 224×224×3 的图,经过 7×7 步长 2 的卷积后变成 112×112×64 的特征图。这个特征图如果按 FP32 存:
112 × 112 × 64 × 4 Byte ≈ 3.2 MB单看一层不大,但问题是网络是一层一层串起来的,中间的特征图在推理时是需要保留的(至少当前层和下一层要用)。而且越往后通道数越多,虽然空间尺寸在降,但通道数在涨。到了 stage 4,特征图可能是 14×14×2048:
14 × 14 × 2048 × 4 Byte ≈ 1.6 MB单张特征图看着都不大,但一个网络几十上百层,如果框架不做内存复用,把所有中间激活都留着,累加起来就非常可观了。更别说 batch size 一上去,直接乘以 batch。
2.3 一个容易被忽略的点:权重的内存布局
还有一个细节:权重在内存里不一定就是"参数量 × 4 字节"这么干净。很多推理框架为了计算效率,会把权重重新排布成更适合 SIMD 或张量核心的格式,比如把 OIHW 转成 NHWC 或者分块布局,这个过程中可能产生额外的对齐填充。对齐到 16 字节、32 字节边界,小通道数的层可能凭空多出不少。
我实测过一个通道数很小的网络,理论权重 8 MB,实际加载后框架报告占用了 11 MB 左右,多出来的就是布局转换和对齐的开销。这个比例在通道数是 3、8、16 这种小数字时会特别明显。
3. 第二笔账:特征图内存,真正的大头在这里
3.1 特征图内存的估算公式
特征图(也叫激活值、activation)的内存,是推理时内存占用的主要来源。它的估算公式很直接:
单层特征图内存 = 输出高 × 输出宽 × 输出通道数 × batch size × 每元素字节数注意这里有个 batch size,这是很多人估算时漏掉的。你本地测试用 batch=1,觉得内存还行,一上生产 batch=32,内存直接翻 32 倍,然后就开始 OOM。
拿一个具体的层算一下。假设某层输出是 56×56×256,batch=1,FP32:
56 × 56 × 256 × 1 × 4 Byte = 3,211,264 Byte ≈ 3.06 MB单层 3 MB。但一个网络里这种量级的层可能有十几二十个。如果框架不做任何复用,全留着:
3 MB × 20 层 ≈ 60 MB这还只是 batch=1。batch=16 就是接近 1 GB。这就是为什么"模型文件 100 MB,跑起来吃 1 GB 内存"完全说得通。
3.2 为什么特征图不能像权重那样"用完就扔"
有人会问:既然中间特征图用完就没用了,为什么不能立刻释放?
理论上可以,而且现代推理框架确实在做这件事,叫内存复用(memory reuse / memory pooling)。核心思路是:分析整个计算图,找出哪些张量的生命周期不重叠,让它们共享同一块内存。
但这里有个约束:残差连接(skip connection)会拉长张量的生命周期。ResNet 里那个 shortcut,意味着某一层的输出要一直留到后面和另一条分支相加,中间隔了好几层都不能释放。这就导致内存复用的效率下降,峰值内存被抬高。
我做过一个对比实验,同样深度的网络,带残差连接的版本峰值内存比不带的高出大约 30% 到 40%,原因就是残差把张量生命周期拉长了,复用池没法充分回收。
3.3 峰值内存才是决定你能不能跑起来的关键
这里要强调一个概念:决定 OOM 与否的不是平均内存,而是峰值内存。
推理过程中,内存占用是动态变化的。某些时刻,比如某个大特征图刚算出来、下一层还没开始消费它的时候,内存占用会达到峰值。框架的内存池通常会预留比峰值略多一点的空间,避免频繁向系统申请释放。
所以你在监控里看到的"内存占用",往往是峰值附近的值,而不是某一层的瞬时值。这也是为什么有时候你把 batch size 从 16 降到 15,内存没怎么变——因为峰值是由某个特定层决定的,降一点点 batch 没跨过那个台阶。
4. 第三笔账:临时缓冲与计算中间态,最隐蔽的开销
4.1 im2col:把卷积变成矩阵乘的代价
很多框架(尤其是早期版本和某些 CPU 实现)会把卷积转成矩阵乘法来加速,用的方法叫im2col。原理是把输入特征图里每个卷积窗口覆盖的区域拉成一列,拼成一个大矩阵,然后和展开后的卷积核做矩阵乘。
这个转换的代价是什么?内存膨胀。
假设输入是 H×W×C,卷积核 K×K,输出是 H'×W'×C'。im2col 之后的矩阵大小是:
(K × K × C) × (H' × W')对比原始输入 H×W×C,膨胀倍数大约是 K×K(考虑步长的话还要再乘)。一个 3×3 卷积,im2col 后输入数据膨胀 9 倍。如果输入特征图本身是 10 MB,展开后就是 90 MB。
这就是为什么有些框架在跑大卷积时会突然内存飙升——不是权重的问题,是 im2col 这个中间矩阵撑起来的。
4.2 现代框架怎么规避这个开销
好消息是,现在主流框架基本不用朴素的 im2col 了,改用隐式 GEMM(implicit GEMM),边算边取数据,不显式展开那个大矩阵。还有 Winograd 算法,通过变换减少乘法次数,但代价是需要额外的变换缓冲。
Winograd 的内存开销值得单独说一句。F(2×2, 3×3) 这种常见的 Winograd 配置,需要把输入 tile 做变换,变换后的数据比原始输入大,而且每个 tile 都要单独处理。在通道数多的时候,这个变换缓冲可能达到几十 MB。我见过一个案例,某框架开启 Winograd 后速度提升明显,但峰值内存涨了将近 50 MB,在内存紧张的设备上反而得不偿失。
4.3 算子融合能省下多少
算子融合(operator fusion)是省内存的一大利器。典型的是 Conv + BN + ReLU 融合,把三个算子合成一个,中间结果不落内存。
不融合的话,Conv 输出要写一块内存,BN 读它再写一块,ReLU 再读再写。融合之后,Conv 算完直接在寄存器或缓存里做 BN 和 ReLU,只写一次最终结果。省下的就是中间那两块特征图的内存。
对于大网络,算子融合能省下的内存相当可观。我实测过一个 50 层左右的网络,开启融合后峰值内存降了大约 25%。这个收益在移动端和边缘设备上尤其重要,因为那些地方内存本来就紧张。
5. 把三笔账合起来:一个完整的估算实例
5.1 选一个具体网络来算
光讲公式太干,咱们拿一个具体的网络从头到尾算一遍。就用一个简化版的类 VGG 网络,输入 224×224×3,FP32,batch=1。
网络结构简化如下:
| 层 | 操作 | 输出尺寸 |
|---|---|---|
| 1 | Conv 3×3, 64 | 224×224×64 |
| 2 | Conv 3×3, 64 | 224×224×64 |
| 3 | MaxPool 2×2 | 112×112×64 |
| 4 | Conv 3×3, 128 | 112×112×128 |
| 5 | Conv 3×3, 128 | 112×112×128 |
| 6 | MaxPool 2×2 | 56×56×128 |
| 7 | Conv 3×3, 256 | 56×56×256 |
| 8 | Conv 3×3, 256 | 56×56×256 |
| 9 | MaxPool 2×2 | 28×28×256 |
| 10 | FC 512 | 512 |
| 11 | FC 10 | 10 |
5.2 权重账
逐层算参数量:
- 层1:3×3×3×64 + 64 = 1,792
- 层2:3×3×64×64 + 64 = 36,928
- 层4:3×3×64×128 + 128 = 73,856
- 层5:3×3×128×128 + 128 = 147,584
- 层7:3×3×128×256 + 256 = 295,168
- 层8:3×3×256×256 + 256 = 590,080
- 层10:28×28×256×512 + 512 = 102,761,472(全连接是大头)
- 层11:512×10 + 10 = 5,130
加起来大约 1.04 亿参数,FP32 下约 396 MB。注意这里全连接层一个人就占了 98 MB 左右,这也是为什么后来的网络都用全局平均池化替代大全连接层。
5.3 特征图账
按峰值时刻算,假设框架不做复用(最坏情况),把所有中间激活都留着:
- 层1输出:224×224×64×4 ≈ 12.8 MB
- 层2输出:12.8 MB
- 层4输出:112×112×128×4 ≈ 6.4 MB
- 层5输出:6.4 MB
- 层7输出:56×56×256×4 ≈ 3.2 MB
- 层8输出:3.2 MB
累加约 45 MB。如果框架做了内存复用,实际峰值可能只有 15 MB 到 20 MB 左右,因为很多层生命周期不重叠。
5.4 临时缓冲账
假设框架用 im2col 处理层1,输入 224×224×3,K=3:
im2col 矩阵 = (3×3×3) × (224×224) = 27 × 50176 ≈ 1.35M 元素FP32 下约 5.4 MB。层4的 im2col:输入 112×112×64,K=3:
(3×3×64) × (112×112) = 576 × 12544 ≈ 7.2M 元素 ≈ 28.9 MB层7:输入 56×56×128,K=3:
(3×3×128) × (56×56) = 1152 × 3136 ≈ 3.6M 元素 ≈ 14.5 MB临时缓冲峰值大概在 30 MB 左右。
5.5 汇总
| 账目 | 内存量级 |
|---|---|
| 权重 | ~396 MB |
| 特征图(无复用) | ~45 MB |
| 特征图(有复用) | ~15-20 MB |
| 临时缓冲 | ~30 MB |
| 合计(有复用) | ~440-450 MB |
看到没,权重 396 MB 反而是大头,因为这里有个巨大的全连接层。但如果把全连接换成全局平均池化,权重降到 300 MB 以下,特征图和临时缓冲的占比就上来了。这就是为什么不同网络结构,三笔账的比例会差很多。
6. 参数量、MACs、内存:三个经常被混为一谈的指标
6.1 它们各自衡量什么
这三个概念经常被放在一起说,但衡量的东西完全不同:
- 参数量(Parameters):模型有多少个可学习的数,决定权重内存大小,也影响模型容量。
- MACs(Multiply-Accumulate Operations):乘加运算次数,衡量计算量,决定推理耗时。
- 内存(Memory):运行时占用的存储空间,由权重、特征图、临时缓冲共同决定。
一个常见的误解是"参数量小 = 内存小"。MobileNet 参数量只有 400 万,比 VGG 的 1.38 亿小得多,但 MobileNet 的 MACs 并不低,而且它的特征图内存因为深度可分离卷积的中间层设计,在某些配置下反而不小。
6.2 为什么深度可分离卷积能省参数但省不了太多内存
深度可分离卷积把标准卷积拆成 depthwise 和 pointwise 两步。参数量能降到原来的 1/8 到 1/9,这是它最大的优势。
但内存方面,depthwise 卷积的输出特征图和标准卷积一样大,pointwise 卷积(1×1)的输入输出通道数也没变。所以特征图内存并没有因为深度可分离而显著减少。省的主要是权重内存。
我做过一个对比:同样输出尺寸的网络,标准卷积版本权重 50 MB,深度可分离版本权重 7 MB,但两者峰值特征图内存都在 30 MB 左右,差别不大。所以如果你的瓶颈是特征图内存,换深度可分离卷积帮助有限。
6.3 一个实用的估算口诀
根据我的经验,可以记一个粗略的口诀:
- 权重内存 ≈ 参数量 × 4 字节(FP32)
- 特征图峰值内存 ≈ 最大那几层特征图之和 × 2(考虑复用不彻底)
- 临时缓冲 ≈ 最大 im2col 矩阵大小,通常是输入特征图的 3 到 9 倍
用这个口诀,你拿到任何一个网络,看一眼结构就能估出内存量级,误差通常在 30% 以内。这个精度对于判断"能不能部署到某个设备"已经够用了。
7. 实测中怎么定位内存到底花在哪了
7.1 用框架自带的内存分析工具
不同框架有不同的工具。PyTorch 可以用torch.cuda.memory_summary()看显存分配,或者用torch.profiler做逐算子分析。TensorFlow 有 memory profiler。ONNX Runtime 可以开 verbose 日志看每个节点的内存。
关键是要区分已分配内存(allocated)和保留内存(reserved)。框架的内存池会预留比实际需要更多的内存,reserved 通常比 allocated 大不少。你看到的"占用"往往是 reserved,实际用的没那么多。
7.2 逐层打印内存占用的土办法
如果框架工具不好用,可以自己插桩。在每层前后记录内存快照,打印差值。伪代码大概是这样:
import torch def hook_fn(module, input, output): if torch.cuda.is_available(): mem = torch.cuda.memory_allocated() / 1024 / 1024 print(f"{module.__class__.__name__}: {mem:.2f} MB") for name, layer in model.named_children(): layer.register_forward_hook(hook_fn)跑一遍前向,就能看到内存是在哪一层涨上去的。这个方法很土但非常有效,我早期排查内存问题基本都靠它。
7.3 几个常见的误判
排查内存问题时,有几个坑特别容易踩:
- 把 reserved 当成 allocated:看到占用 2 GB 就慌了,其实实际用的可能只有 800 MB,剩下的是内存池预留。
- 忽略框架自身的开销:CUDA context、cuDNN 句柄、框架运行时本身就要占几百 MB,这部分和模型无关。
- batch size 没对齐:测试用 1,生产用 32,估算时忘了乘。
- 输入尺寸变化:训练用 224,推理用 512,特征图内存按面积平方增长,直接翻 5 倍。
注意:定位内存问题时,一定要在真实的生产配置下测,包括 batch size、输入尺寸、精度模式。用测试配置测出来的数字没有参考价值。
8. 想省内存,从这三笔账分别下手
8.1 省权重内存:量化是首选
权重内存最容易省,因为它是静态的。FP32 转 FP16 直接减半,转 INT8 减到 1/4。而且量化对精度的影响通常可控,尤其是 INT8,配合校准(calibration)在很多视觉任务上精度损失不到 1%。
我实测过一个分类网络,FP32 权重 90 MB,INT8 量化后 23 MB,Top-1 精度只掉了 0.4%。这个收益在端侧部署时非常划算。
8.2 省特征图内存:控制 batch 和输入尺寸
特征图内存和 batch size、输入分辨率都是线性或平方关系。降 batch 是最直接的,但会影响吞吐。降输入分辨率更狠,面积直接平方下降,但会影响精度。
另一个办法是梯度检查点(gradient checkpointing),不过那是训练阶段的技术,推理用不上。推理阶段能做的就是让框架充分做内存复用,选支持内存池的推理引擎。
8.3 省临时缓冲:选对算子和精度
临时缓冲主要靠框架优化。选支持隐式 GEMM 的框架,避免朴素 im2col。Winograd 要权衡,速度快但内存可能涨。算子融合一定要开,Conv+BN+ReLU 融合是标配。
精度方面,FP16 不仅省权重,临时缓冲也减半。在支持 FP16 的硬件上,开 FP16 推理通常能省 30% 到 40% 的总内存。
8.4 一个综合优化案例
我之前优化过一个部署在边缘设备上的检测网络,原始配置 FP32、batch=8、输入 640×640,峰值内存 1.8 GB,设备只有 2 GB 可用,跑起来很勉强。
优化步骤:
- 权重 INT8 量化:权重从 120 MB 降到 30 MB
- 开启 FP16 推理:特征图和临时缓冲减半
- batch 降到 4:特征图内存再减半
- 开启算子融合:省下约 20% 中间内存
最终峰值内存降到 620 MB 左右,设备跑得很轻松,精度掉了不到 1 个点。这个案例说明三笔账要一起算,单省一笔效果有限。
9. 几个我踩过的坑和对应的经验
9.1 别信"模型小就能跑在低端设备上"
这是我最早踩的坑。看到一个模型文件才 10 MB,就觉得随便什么设备都能跑。结果部署上去发现内存不够。后来才明白,模型文件大小只反映权重,运行时内存的大头是特征图和临时缓冲,跟文件大小关系不大。
现在的习惯是:拿到模型先算三笔账,估出峰值内存,再决定部署到哪。这个习惯帮我避免了好几次返工。
9.2 内存池的 reserved 会骗人
有一次监控显示进程占用 3 GB,我以为出问题了,查了半天。后来发现是框架的内存池预留了大量空间,实际 allocated 只有 900 MB。内存池的设计是"宁可多留,不愿频繁申请",所以 reserved 偏高是正常的。
判断是否真的内存不足,要看 allocated 和系统可用内存,而不是看 reserved。
9.3 输入尺寸的平方效应最容易被低估
把输入从 224 改成 448,很多人以为内存翻倍,其实是翻 4 倍(面积平方)。如果网络里有全连接层,输入尺寸变化还会导致全连接层参数量剧变,权重内存也跟着涨。这个坑我在做超分辨率相关项目时踩过,输入尺寸一调,内存直接爆了。
9.4 残差连接对内存复用的影响
前面提过,残差连接会拉长张量生命周期,降低内存复用效率。如果你的网络残差特别多(比如 DenseNet 那种密集连接),峰值内存会比同等深度的普通网络高不少。设计网络时如果内存紧张,要控制残差分支的数量和跨度。
10. 写在最后的一点个人体会
把卷积的三笔账算清楚之后,我看待模型内存问题的心态完全变了。以前是"怎么又爆内存了",现在是"先估一下峰值,看看是哪笔账超了"。这个转变让我在选型、部署、优化时都有了明确的抓手,而不是盲目试错。
参数量、MACs、内存这三个指标,各有各的用途,千万别混着用。参数量看模型容量和权重内存,MACs 看计算量和耗时,内存看能不能跑起来。三个都要看,但看的时候要清楚各自衡量的是什么。
最后分享一个我常用的快速估算流程:拿到模型,先看参数量估权重内存,再看最大几层特征图估激活内存,最后看有没有大卷积估临时缓冲,三个加起来乘个 1.3 的安全系数,基本就是峰值内存的量级。这个方法不精确,但足够快,能在几分钟内判断一个模型能不能部署到目标设备上。