
上周有位同学在群里问我一个模型文件才 5MB 左右用的还是轻量级网络为什么推理的时候进程直接吞掉了 1.2GB 内存我第一反应是反问了一句你是不是用了 batch 比较大或者开了半精度没生效他说都没有。后来我让他把监控脚本跑起来把峰值内存和常驻内存分开看了才发现真正的问题根本不在“模型本身”而在他对内存的账目算法从一开始就算错了。这个问题其实特别典型。很多人看到“模型文件很小”就默认“运行时占用也小”但文件大小衡量的是静态参数存储运行时内存衡量的是动态计算空间中间隔着一整条卷积执行链路以及训练/推理两套完全不同的记账规则。这篇文章我就把卷积的“三笔账”完整算一遍看完你就明白为什么文件小、内存大这件事一点都不玄学。1. 先别急着骂框架文件大小和运行内存本来就是两套账1.1 你看到的文件大小只是权重的“三维码”模型文件本质上是权重参数的序列化存储也就是把网络的每个卷积核、每个偏置、每个 BatchNorm 的均值和方差按照顺序写成二进制。它的大小只取决于三件事参数量、每个参数的字节数、文件有没有做压缩。参数量的算法很简单一个卷积层的参数量是输出通道数 × 输入通道数 × 卷积核高 × 卷积核宽 偏置数以最常见的 3×3 卷积为例如果输入是 256 通道输出是 512 通道3 × 3 × 256 × 512 1,179,648 个权重再加上 512 个偏置约 118 万个参数。按 FP32 计算每个参数 4 字节就是约 4.7MB。这就是这一层在磁盘上的“身价”。但注意这只是“存储账”。文件里的参数就像仓库里的货物货物摆得再整齐也不代表你店里不需要柜台和货架。运行时内存管的是“营业账”。1.2 运行时内存的记账规则完全不同推理时进程要做的事远不止“读一遍权重”这么简单。它要把权重从磁盘加载到内存为每一层的输入、输出分配存储空间为卷积算子准备中间工作区为框架本身维护计算图、缓存分配器、线程池等训练时还要再加几笔梯度、优化器状态、前向激活值的缓存。所以模型文件是“静态账本”运行内存是“动态流水”两套账目天然不对等。任何人跟你说“文件小所以内存必然小”都是在拿静态账估算动态开销。2. 第一笔账权重参数——文件里的常驻人口但往往不是大头2.1 权重的字节数要怎么精确计算权重内存的第一步估算公式很简单权重内存 参数量 × 每个参数的字节数不同精度的字节数如下精度字节数典型场景FP324 字节默认训练精度FP162 字节混合精度训练 / 半精度推理BF162 字节大模型训练常用INT81 字节量化推理INT40.5 字节极端压缩部署拿 ResNet-50 举例它的参数量大约 2560 万。FP32 下就是 2560 万 × 4 字节 ≈ 102MB模型文件差不多这个大小。但如果你用 MobileNetV3-Small参数约 250 万FP32 下才 10MB量化到 INT8 后只有 2.5MB。很多人一看“2.5MB 的文件”就觉得运行内存应该也在 2.5MB 上下。这就是没分清楚这 2.5MB 只是“货物重量”你还得给货物留出“装卸空间”。2.2 为什么权重通常不是内存瓶颈在绝大多数卷积网络里权重内存只占总运行内存的一小部分。原因有两个。第一卷积网络是典型的“权重少、激活多”结构。参数量集中在深层但每一层的输入输出特征图都要占空间尤其浅层分辨率高的时候一张特征图的字节数可能超过整层权重。第二推理框架通常不会把权重重复拷贝。PyTorch、ONNX Runtime 这类框架加载模型后权重在内存里只有一份后续算子只是引用它。训练时情况就不同了。梯度与参数同尺寸又加一份如果用了 Adam 优化器状态又会翻倍。权重从“静态小头”变成“动态大头”的一部分这个问题放到第三节专门说。我算过一笔实测账ResNet-50 在 FP32 下跑 batch1 的推理权重 102MB但 PyTorch 进程的常驻内存经常爬到 1.5GB 以上。权重只贡献了不到 7%剩下全是特征图、临时 buffer、CUDA context 和缓存分配器的事。提示判断权重是不是内存瓶颈最简单的办法是用 torch.save 出来的文件大小和nvidia-smi观察到的进程显存做一个对比。如果差距超过 5 倍那瓶颈一定在别处。3. 第二笔账特征图——真正吃内存的主力也是最容易被忽略的3.1 一张特征图的字节数怎么算特征图就是卷积层的输入和输出张量它的形状是batch_size × 通道数 × 高度 × 宽度字节数等于所有元素数量乘以每个元素字节数。还是用 3×3、输入 256 通道、输出 512 通道的例子。假设输入是 14×14 的空间分辨率batch1输入特征图1 × 256 × 14 × 14 × 4 字节 ≈ 200KB 输出特征图1 × 512 × 14 × 14 × 4 字节 ≈ 400KB看起来不大把分辨率换到 224×224输出通道还是 512得用 1 × 512 × 224 × 224 × 4 字节 ≈ 102MB。这还只是单层。一个完整的网络往往有三四十层甚至上百层如果每一层的前向输出都要留住算算就知道为什么内存爆了。3.2 卷积层为什么对内存格外“贪婪”卷积和全连接不一样。全连接层的输出是 1D 向量特征图只有 batch × 输出维度。卷积输出的却是 4D 张量空间维度和通道维度同时存在。卷积的每一步计算只涉及局部窗口但中间结果必须完整存下来供下一层消费或者供反向传播使用。这里有两个特性会成倍放大内存消耗。第一个是“分辨率惩罚”。浅层卷积虽然通道少但分辨率高。比如 ResNet 的第一层输出 112×112×64一张图就是 1×64×112×112×4 ≈ 3MB且这一层权重只有 9KB 左右。权重的 300 多倍。第二个是“多 batch 惩罚”。很多人跑训练时为了稳定梯度把 batch 调到 32 或者 64。batch 一涨所有层的特征图同步翻倍。输入分辨率 224、batch32 的 ResNet-50前向特征图峰值动辄 1GB 以上。对比一下场景权重内存特征图内存估算ResNet-50 推理 batch1102MB峰值约 200-400MBResNet-50 训练 batch32102MB × 2含梯度峰值约 1-2GBMobileNetV3 推理 batch110MB峰值约 20-50MB3.3 训练时特征图还要“双份记账”这就涉及第三笔账的核心逻辑训练要反向传播所以前向的每一层输出都不能丢弃必须留着算梯度时用。也就是说训练时的特征图内存不是“峰值那一层”而是“所有层的输出之和”。以前向一个有 50 个卷积层的网络为例哪怕每层输出平均只有 20MB50 层就是 1GB全都要驻留内存。这也是为什么很多框架会提供“激活检查点”或者说 gradient checkpointing 的选项。它的思路很简单不保存全部前向输出只每隔几层存一张反向传播时再重新算被丢弃的中间结果。这就是典型的时间换空间。我用实际训练跑过对比ResNet-50、batch64、FP32不开检查点时显存峰值约 2.8GB开检查点后峰值降到 1.6GB但训练时间增加了大约 15%。如果显存不够但时间还算充裕这个方案非常实用。提示判断一张特征图的“身价”永远要把 batch 和分辨率放在前面。通道数增加只是线性增长分辨率翻倍是 4 倍增长batch 翻倍也是 4 倍增长。4. 第三笔账梯度、优化器状态和框架缓存——训练时最深的坑4.1 梯度参数的“第二份拷贝”训练时每个参数都要算梯度梯度张量与参数张量同形状、同精度。PyTorch 默认会对每个 requires_gradTrue 的叶子节点维护 grad。也就是说你的权重刚加载完占了 100MB梯度自动又占 100MB。这是跑不掉的。有些粗心的人在写训练循环时每轮都调用loss.backward()却忘记在下一轮之前optimizer.zero_grad()于是梯度跨 batch 累加内存不涨只是梯度数值错了。这虽然不直接导致内存暴涨但如果计算图没有及时释放会出现更严重的“计算图泄漏”——每轮迭代都把前面的图结构挂在内存里。我排查过很多次这类问题最后都指向同一个原因loss.backward()之后没有及时释放中间变量。4.2 Adam 优化器存储直接翻好几倍Adam 优化器会为每个参数维护两段额外状态一阶动量 m 和二阶动量 v。两者与参数同形状、同精度。FP32 训练时一个参数至少占三份存储参数本身、梯度、Adam 的 m 和 v。其中 Adam 又占两份。所以一笔账就出来了项目每参数字节数ResNet-50 总量参数4102MB梯度4102MBAdam 一阶动量4102MBAdam 二阶动量4102MB合计16约 409MB这还没算特征图和框架缓存单是权重相关就比模型文件大了 4 倍。如果换成 SGD momentum优化器状态只有一阶动量每参数 4 字节省掉一份。如果再用 Adafactor 这类内存友好型优化器动量状态还能进一步压缩。所以训练大型模型时优化器选型不只是收敛速度问题也是内存问题。4.3 框架的缓存分配器内存“只借不还”这是很多人最不能理解的一块。明明我每轮迭代都del了中间 tensor为什么nvidia-smi看着显存一直不下滑因为 PyTorch 的显存分配器是缓存式的。为了减少频繁向 CUDA runtime 请求内存的开销它会把你释放的显存块留在自己的缓存池里。下次再申请同尺寸张量直接复用缓存块而不是真正还给驱动。CPU 内存也有类似的机制。进程的常驻内存 RSS 可能会保持在高位即使你释放了 Python 对象。这是分配策略不是内存泄漏。判断是不是真的泄漏要看长时间训练时内存是否无上限上涨。检查内存是否真泄漏我常用的方法import torch # 训练循环中打印最大已分配显存 print(torch.cuda.max_memory_allocated() / 1024**2, MB)max_memory_allocated看已分配且被使用过的显存nvidia-smi看进程持有显存。前者更能反映你的代码真正用了多少后者包含缓存。5. 卷积实现层面的“隐性收费”im2col 与算子工作区5.1 im2col用空间换时间的经典做法卷积的直接实现是一个四重循环遍历输出位置、遍历输出通道、遍历输入通道、遍历核位置。这个写法内存最省但速度慢得没法用。为了提速几乎所有主流框架都会选择把卷积“变形”成矩阵乘法再调用高度优化的 GEMM 库。这个变形过程就叫 im2col。im2col 做的事很简单把每个卷积窗口内的输入数据复制到矩阵的一列。比如输入特征图是 14×14×256核是 3×3stride1padding1输出还是 14×14 的空间尺寸。im2col 之后得到一个二维矩阵行数等于输出像素数 196列数等于 3×3×2562304。这个矩阵的字节数大约是196 × 2304 × 4 字节 ≈ 1.8MB而原始的输入特征图只有 200KB。也就是说im2col 单层就把输入空间临时膨胀了 9 倍。3×3 核就是 9 倍5×5 核就是 25 倍。整个网络层层叠加下来临时工作区很容易盖过权重本身好几倍。如果你截一张nvidia-smi看 GPU 显存这些“看不见的工作区”会隐藏在算子实现里但它们确实被分配、被占用。5.2 Winograd、FFT 和框架工作区是另一个隐藏收费项除了 im2col还有 Winograd、FFT 等快速卷积算法。它们各有各的临时缓冲区需求。cuDNN 会为卷积算子分配一个 workspace 缓冲区上限由框架控制。PyTorch 中可以通过环境变量控制export CUDNN_WORKSPACE_LIMIT_MB512这个值限制 cuDNN 自动选择算法时能使用的最大工作区。调低一点可以省内存但可能会牺牲一点速度。TensorRT 部署也有类似参数。工作区的大小并不直观。有些同学以为 workspace 是固定几 MB实际在深层次网络中工作区可能随分辨率、通道数呈非线性增长。我在一个语义分割任务里就见过 workspace 接近 500MB 的情况裁掉这段后显存峰值下降了近 20%。提示排查显存高涨时记得区分“显存真正用在算特征图”和“显存暂时被算子工作区占着”。用 PyTorch 的 torch.profiler 导出内存事件就能看到每个算子消耗的分配的字节数。6. 省钱实操我平时怎么把这套账压下来6.1 搭建一个两层半的内存估算脚本在动任何优化手法前先用脚本把账算清楚。输入网络的 input_shape、batch、精度然后逐层统计权重参数量、输出特征图形状累加得到估算值。以我的习惯脚本长这样def estimate_memory(input_shape, batch, layers): total_activation 0 total_weight 0 x [batch] list(input_shape) for name, layer in layers: weight_count layer.weight.numel() total_weight weight_count * 4 # FP32 out_shape layer.get_output_shape(x) act_bytes batch * out_shape[0] * out_shape[1] * out_shape[2] * 4 total_activation act_bytes x out_shape print(fWeight: {total_weight / 1024**2:.1f} MB) print(fActivation (sum): {total_activation / 1024**2:.1f} MB)跑一遍就能把“哪笔账占大头”量化出来再针对大头做优化。6.2 推理侧的降内存清单精度换成 FP16 或者 INT8。权重减半到八分之一特征图同步收缩。这是性价比最高的操作。调小 batch。推理场景很多任务可以 batch1。batch 是乘在整个网络前面的系数减半直接省一半特征图。控制输入分辨率。很多分类/检测任务不需要 224×224降到 192 或者 160分辨率的影响是平方级的。关闭不必要的 grad 计算。用with torch.no_grad():包裹推理逻辑避免框架为叶子节点构建计算图。尽可能用 ONNX Runtime / TensorRT 这类推理引擎。它们会做算子融合、内存池复用、工作区精简实测同样模型能比 PyTorch 推理少 20%-40% 峰值内存。6.3 训练侧真正有用的几个手段训练侧的核心矛盾是所有前向输出都要存下来所有梯度都要算出来优化器状态也得养着。能压缩的只有这几块。第一混合精度 AMP。前向用 FP16 跑卷积计算权重保留 FP32 主副本。激活值从 4 字节降到 2 字节前向激活内存直接减半。主流 GPU 有 Tensor Core速度往往还更快。第二梯度累积。显存不够但想要大 batch 时用几个小 batch 累积梯度再更新参数。注意loss.backward()后要optimizer.zero_grad(set_to_noneTrue)这个写法比默认的.zero_grad()内存更干净。第三激活检查点。每隔几层保存特征图反向时重新计算。代码改动极少就一行调用效果立竿见影。代价是训练时间变长。第四选内存友好的优化器。不是所有任务都非 Adam 不可。SGD momentum 能省掉二阶动量那份Adafactor 能进一步压缩状态。在模型大到卡装不下时优化器换一下比换架构还管用。第五及时释放大对象。在训练循环里用完的大tensor用del释放必要时torch.cuda.empty_cache()回收缓存。这话听起来像废话但不这么写的真实项目我见太多了。6.4 实在不够时再想的方案如果上面五条都做完了还是卡那就得从架构层面动刀了。深度可分离卷积是专门为省内存和算力设计的比如 MobileNet 和 EfficientNet-Lite 系列。它们把标准 3×3 卷积拆成 depthwise pointwise 两步参数量和中间特征图都明显下降。模型蒸馏也是一个方向拿大模型当老师把知识迁移到一个更小的学生网络里学生网络跑起来内存占用自然小。做云端部署时我就经常用蒸馏后的学生模型替换原模型精度损失不大内存省得很明显。还有一个比较偏门的技巧CPU 推理时可以按层懒加载权重即只在需要该层时才把权重读入内存。PyTorch 的torch.load(map_locationmeta)配合自定义 hook 做到这一点。理论上所有层权重都不需要同时驻留内存代价是 I/O 开销。这个解法在磁盘性能好的场景很实用。另外很多人在跑 Stable Diffusion 或者其他生成模型时遇到“爆内存”本质上也是同一套账。模型文件可能只有 2GB但 UNet 的隐特征图、文本编码器的 K/V 缓存、采样循环里保留的中间结果统统要占空间。ComfyUI 里爆显存大都是因为特征图积累和缓存没清理而不是模型文件本身有多“重”。7. 最后分享一个小习惯先记账再优化我在处理所有内存问题时都遵循一个原则先量化再动手不要凭感觉猜。实际跑一个模型之前先花十分钟写个估算脚本把权重账、特征图账、梯度账、优化器账分别铺开。哪一笔超预期就顺着监控数据去找原因。没有数据支撑的“我觉得是这里的问题”最后基本都会浪费半天时间。这套思路不只适用于卷积网络Transformer、扩散模型、多模态模型也都是同一套记账规则只是每笔账的权重占比不同。搞清楚了账本结构换任何模型你都能快速定位内存瓶颈在哪。如果非要用一句话总结就是模型文件小只代表权重小不代表运行时开销小真正决定内存峰值的是特征图、梯度和框架缓存。把这三笔账分别算一遍内存优化就成功了大半。