
1. 从文件小到占用高先搞清楚两个完全不同的概念很多人第一次跑卷积神经网络时都会有一个困惑模型文件明明只有几十MB甚至几MB怎么一运行起来内存占用就轻松突破几个GB这个疑惑太常见了以至于我在社区里每隔几天就能看到类似的问题帖。先说结论模型文件大小看的是存储状态运行时内存看的是计算状态两者根本不是一回事。模型文件比如PyTorch的.pt、TensorFlow的.h5或ONNX的.onnx是权重参数的序列化存档它就像一本压缩过的菜谱只记录做菜需要什么而运行时内存是程序真正把食材、锅碗瓢盆全部铺开在案板上操作的空间包括参数、梯度、中间特征图、优化器状态、框架运行时开销等等。菜谱再薄真做起菜来案板也得铺满。让我用一组数字把账算明白。假设你有一个标准的卷积神经网络比如ResNet-18参数量大约1100万。以32位浮点数float32存储每个参数占4字节那么模型文件的理论大小就是1100万 × 4字节 ≈ 44MB这个44MB就是你在磁盘上看到的主模型文件大小。但当你把模型加载到GPU或CPU上运行时仅仅是把这些参数复制进显存或内存就已经占掉了至少44MB。问题是这只是开胃菜真正的内存大头根本不在参数本身。如果你开启反向传播训练模型每一步都要为每个参数保存梯度梯度同样是float32又是44MB。如果再用上Adam优化器Adam会为每个参数额外保存一阶动量momentum和二阶动量variance每个都是float32也就是每个参数要多占8字节。算一下44MB参数 44MB梯度 88MBAdam状态 176MB这还只是纯参数相关的开销实际运行时内存占用轻松超出这个数字好几倍。所以当你看到模型文件只有44MB但进程内存却占了2GB甚至更多时别惊讶这在深度学习中完全是正常现象。接下来我把卷积这三笔账一笔一笔拆开算清楚参数账、特征图账、运行时账。算完之后你就明白内存究竟被谁吃掉了以及哪些地方可以省哪些地方省不了。 ## 2. 第一笔账卷积层的参数量——决定文件大小的地基2.1 卷积核参数的数学公式要算清楚内存第一步先算清楚参数。卷积层的参数由卷积核权重和偏置组成。假设输入特征图的通道数是 C_in输出特征图的通道数是 C_out卷积核的空间尺寸是 K×K那么这一层的权重参数量是权重参数 C_in × C_out × K × K偏置参数 C_out如果启用偏置的话举个例子。一个标准的3×3卷积输入通道64输出通道128那么权重参数就是64 × 128 × 3 × 3 73728 个参数如果以float32存储就是 73728 × 4 294912 字节约0.28MB。单看一层很少但神经网络动辄几十上百层数量乘起来就非常可观了。我遇到过不少刚入门的朋友以为卷积核的参数量只看卷积核大小觉得3×3总共就9个参数。这就是误解的来源。3×3只是单个卷积核的空间尺寸而每个卷积核是作用在所有输入通道上的所以真正的参数必须乘上输入通道数。这就好比你打扫房间不是只擦一个面而是要把房间里的每一个平面都擦一遍——通道数越多擦的次数越多。2.2 用ResNet-18把总账算出来光看单层没感觉我直接拿ResNet-18算总账。ResNet-18的骨干结构大致是第一层7×7卷积输入3通道输出64通道后续四个stage每个stage包含若干BasicBlock每个BasicBlock由两个3×3卷积组成逐层累加权重参数最终ResNet-18的总参数量约为11.18M百万以float32存储就是11.18M × 4字节 ≈ 44.7MB这就是你在磁盘上看到的模型文件大小的主要来源。如果模型文件比这个大不少往往是因为保存了优化器状态就是前面说的Adam那两个动量或者是用了float64精度或者保存了不必要的计算图结构。这里补充一个经验之谈用PyTorch保存模型时model.state_dict()只存权重和偏置文件最干净而直接torch.save(model)会连模型结构定义、类信息等一起存进去文件会偏大且容易踩版本兼容的坑。线上部署我基本都是只存state_dict加载时再实例化模型结构。2.3 为什么算参数账对于内存问题只是起点参数账解决的是模型文件为什么这么大但它远远不能解释运行时的高内存占用因为参数在推理时是静态的加载一次就一直躺在内存里不会再变。真正让内存飙起来的是下面这笔账——中间特征图。可以说90%的模型文件小但内存高的困惑根源都在特征图上而不是参数上。# 以PyTorch为例计算模型参数量占用的内存 import torchvision.models as models model models.resnet18(pretrainedFalse) param_count sum(p.numel() for p in model.parameters()) param_memory_mb param_count * 4 / (1024 ** 2) print(fResNet-18参数量: {param_count / 1e6:.2f}M) print(ffloat32参数占用内存: {param_memory_mb:.2f}MB)这段代码跑出来就是44MB左右但如果你此时看进程的内存占用会发现已经不止这个数了。因为PyTorch框架本身、CUDA上下文如果在GPU上、cuDNN算法缓存都会额外占内存。这部分属于运行时账我放到第四部分详说。3. 第二笔账中间特征图——真正的内存吞金兽3.1 特征图内存的计算方式这是整个问题最关键的部分。卷积层在计算时输入和输出的不是单个数值而是一整张特征图Feature Map。假设输入特征图的尺寸是 H×W通道数是 C那么这一张特征图在内存中占用的空间就是特征图内存 C × H × W × 字节数你以为这就完了并没有。卷积神经网络是有深度的每一层都会产出一张新的特征图网络有多深就得同时保存多少张特征图。虽然在推理时理论上可以一层算完扔一层但实际的主流深度学习框架为了保证反向传播能计算梯度会在前向传播时把每一层的输入特征图缓存下来。这意味着训练时的内存占用基本上是所有层特征图之和而不是单层特征图。我见过一个形象的类比卷积网络的前向传播就像流水线作业每道工序加工完的半成品都得放在传送带上等着不能扔因为后面质检反向传播还要回头检查每一道工序。传送带上同时摆着的所有半成品就是特征图占用的内存。3.2 用实际数字感受特征图的恐怖空谈公式没有感觉我直接构造一个典型案例。假设输入图像是224×224 的RGB三通道图片经过ResNet-18的第一个卷积层7×7输出64通道后特征图尺寸变成 112×112×64。看看这一层特征图的内存112 × 112 × 64 × 4字节 3211264字节 ≈ 3.06MB而输入图片本身只有224 × 224 × 3 × 4字节 602112字节 ≈ 0.57MB注意这才第一层特征图大小已经变成输入的5倍多。ResNet-18一共有大约20个卷积层虽然随着网络加深特征图的空间尺寸会不断缩小池化和步长卷积但通道数在不断增加整体内存占用曲线通常是先升后降的。粗算一下ResNet-18训练时各层特征图累加起来大概需要500MB到1GB的内存具体取决于batch size。如果我们把batch size从1调到32会怎么样上面的数字直接乘以32。也就是说光特征图就可能吃掉16GB到32GB。这还没算参数、梯度和优化器状态呢。我给大家总结一个非常实用的经验公式训练时每张输入图片占用的内存大约是参数内存的10到50倍。这是个量级估计不同网络结构差别很大但方向不会错——特征图永远是监督学习训练时的内存大头。3.3 为什么推理时特征图也不省心有朋友会问我只做推理不反向传播岂不是可以把中间特征图扔掉理论上是这样的。推理模式下PyTorch的torch.no_grad()就是告诉框架不用保存中间结果每层计算完就可以释放内存。但问题在于卷积计算本身需要临时缓冲区。cuDNN、oneDNNMKL-DNN这类底层加速库在实现卷积时不是一蹴而就的而是把卷积拆成多个步骤中间会分配临时内存来存放拆分后的数据块。这些临时缓冲区的分配和释放在内存占用曲线上的表现就是波峰。我实测过一个典型的例子跑MobileNetV3的推理模型文件只有约25MBfloat32但进程实际常驻内存RSS能达到600MB以上。多出来的这500多MB里一部分是框架加载的共享库另一部分就是推理库的算法工作区。所以哪怕纯推理也别指望内存占用接近模型文件大小中间特征图依然在生产数据只是生命周期变短了。3.4 特征图内存的实际优化方向既然特征图是大头那降低特征图占用的方向就很明确减小batch size这是最直接的手段batch从32降到16内存直接减半。代价是训练时梯度噪声变大可能需要配合调整学习率。降低输入分辨率把224×224降到160×160特征图的H×W缩小近一半内存显著下降。这在很多实际场景中精度损失并不大。使用混合精度训练AMP让特征图以float16存储和计算内存直接减半。NVIDIA的Tensor Core对FP16的支持很成熟我现在日常训练基本都开AMP内存占用和训练速度双双受益。启用梯度检查点Gradient Checkpointing不保存所有中间特征图只保存一小部分反向传播时重新计算需要的特征图。这是用计算换内存的经典策略能省70%左右的特征图内存代价是训练时间增加30%左右。4. 第三笔账运行时框架开销——被忽略的隐性内存消耗4.1 框架运行时和底库占用很多初学者只盯住参数和特征图却完全忽略了深度学习框架本身是个庞然大物。当你import torch或import tensorflow的一瞬间框架会加载大量动态库、初始化计算图、建立线程池这些都要占内存。我记得自己刚转深度学习那会儿写了个Hello World级别的神经网络连数据都没加载import torch之后进程内存就已经吃了500MB。当时我一度怀疑是自己环境出问题了后来才知道这太正常了。PyTorch的libtorch核心库、MKL数学库、OpenMP线程池、CUDA运行时GPU场景每一项都不是省油的灯。在GPU上跑的话还有一个容易被忽略的大头CUDA上下文CUDA Context。只要你的PyTorch代码第一次调用CUDA算子驱动就会为当前进程分配一整块显存作为CUDA上下文这个上下文在显存里通常要占300MB到500MB。哪怕你的模型只需要几十MB显存这个上下文也躲不掉。当进程的CPU内存host memory与设备显存device memory进行数据拷贝时还会分配额外的页锁定内存pinned memory用于加速传输。4.2 线程池和内存分配器的猫腻为什么进程内存占用看起来比实际计算所需大那么多这里面的猫腻在于内存分配策略。PyTorch和TensorFlow都内置了自定义的内存分配器如PyTorch的CachingAllocator设计逻辑是内存分配慢、释放也慢所以我要缓存起来重复利用。也就是说FP16时代PyTorch一旦从操作系统申请了显存或内存即使某层算完释放了分配器也会把这块内存留在自己的缓存池里不会立刻还给操作系统。下次再有计算需要内存时直接从缓存池拿速度飞快。这就解释了为什么你观察到内存在某个时刻达到峰值后即便后续计算量小了内存占用也不会明显下降。它并不是泄漏只是缓存策略。很多时候等到进程结束内存才真正归还系统这是正常的。CPU端同理。PyTorch的CPU后端依赖oneDNNMKL-DNN而oneDNN在初始化时会为每个线程分配独立的缓冲区和工作区。线程数由OMP_NUM_THREADS或torch.set_num_threads()决定默认情况下会按CPU核数创建线程每个线程的缓冲区虽然不大但乘上几十个核总开销就不小了。4.3 实测一份运行时开销拆分我把自己实测过的某个小型卷积模型VGG-like参数量约5M文件大小20MB的CPU推理内存占用拆个表让大家直观感受各部分比例内存构成大致占用说明模型参数20MB就是模型文件里的float32权重单图特征图30–80MB取决于输入尺寸这里按224×224估算框架库与线程200–400MBPyTorch核心库、MKL、OpenMP线程区推理算法临时工作区50–150MBcuDNN/oneDNN卷积展开的中间缓冲分配器缓存100–300MB框架自持缓存不还给操作系统看到没模型参数占比可能不到10%剩下全被特征图、运行时和缓存吃掉了。这就是模型文件很小运行为什么还吃内存的最完整的答案。5. 从账面数字到实际优化四个立竿见影的省钱手段5.1 手段一混精度推理与量化先说最务实的一条路。如果我想压运行时的内存占用第一个考虑的永远是降低数据精度。用float16代替float32内存减半。用int8量化内存减到四分之一而且CPU上配合AVX512指令集推理速度反而更快。用bfloat16在CPU上也有奇效动态范围与float32接近精度损失更小。我在实际项目里把模型从float32换成int8后内存占用直接砍到接近四分之一精度只掉了0.3%左右。对于部署场景来说这个性价比极高。PyTorch里做量化推理有现成的API比如torch.quantization.quantize_dynamic十几行代码就能把模型转成int8不用重新训练非常推荐先去试这个。5.2 手段二调整线程数与内存分配策略如果只是想把进程常驻内存压下来最容易被忽视的动作就是限制线程数。import torch # 限制PyTorch使用的线程数 torch.set_num_threads(4)有时候默认创建的线程数太多了每个线程的工作区加一起白白占掉几百MB内存。限制到4个或8个线程内存能降不少推理速度影响却在多数任务里可以接受。尤其当你跑多个模型实例做服务端并发推理时每个进程都默认开几十个线程那就是灾难必须手动限制。另一个实用技巧是设置环境变量PYTORCH_CUDA_ALLOC_CONF在GPU场景下调整显存分配策略。比如export PYTORCH_CUDA_ALLOC_CONFmax_split_size_mb:128这个参数控制显存块的拆分粒度调小之后显存碎片率会降低含义是让显存占用更紧凑同时批处理大小变化时更不容易触发重新分配。CPU端也有类似的环境变量可以限制MKL的缓冲区数量。5.3 手段三用小工具看清楚钱到底花在哪要省钱先得知道钱花在哪。我用过好几种内存分析工具比较推荐的是tracemalloc和py-spy。tracemallocPython标准库可以精确追踪Python对象的内存分配定位到具体哪一行代码申请了大内存。py-spy一个第三方工具可以采样运行中的Python进程的调用栈看到实时内存暴涨发生在哪个函数。用它来排查推理内存峰值非常管用不用改代码直接py-spy dump --pid 进程ID就能看到。我自己排查线上推理服务内存问题时惯用套路是先用py-spy看调用栈定位到具体模型推理函数再用tracemalloc配合nvidia-smi如果是GPU看显存和主机内存的变化曲线基本能在半小时内锁定内存问题出在特征图保留还是框架缓存。5.4 手段四在线推理服务中的冷启动与复用再说一个工程化的经验。模型部署成在线服务时内存占用还和生命周期管理相关。我在生产环境遇到过这样的问题模型单次推理内存不高但并发高了以后内存持续增长。排查到最后发现每个请求重建了输入张量、中间特征图并且框架的缓存分配器在高并发下不断分配新块缓存池越来越大。解决办法有几个方向用请求级张量复用提前分配好固定大小的输入输出缓冲区避免每请求都走系统分配。开启并发的共享模型实例不要为每个请求新建模型对象。用模型推理服务框架如Triton Inference Server它对显存和内存的池化、并发调度已经做过深度调优比自己手写的服务稳定得多。上面这些手段单拎出来每一个都能省下不少内存但真正干活的时候要注意它们之间的组合效果。下面我专门把模型文件小但内存高这个问题上升到设计层面讲一下。6. 从设计层面看懂你的模型该不该省6.1 训练态和推理态的内存结构完全不同很多读者其实还有一层困惑模型文件小但内存高这到底是好还是坏是不是我模型写错了答案是这里没有对错只有训练态和推理态的区别。训练态内存 参数 梯度 优化器状态 所有层特征图 临时缓冲区 框架运行时推理态内存 参数 特征图生命周期短 临时缓冲区 框架运行时训练态内存比推理态内存高出三五倍甚至十倍完全合理。所以你在讨论模型小但占用高时先要明确自己是在训练还是在推理。两者省内存的策略完全不同如果在训练优先砍特征图用梯度检查点、减batch size、降分辨率。优先用AMP混合精度梯度也顺便变小一半。优化器优先用AdamW的8-bit版本bitsandbytes库省下那两份动量内存。不要全量保存训练日志和checkpoint频繁的序列化也会让内存峰值升高。如果在推理优先做量化int8/float16。限制线程数关闭不需要的debug模式和梯度记录。用torch.no_grad()包裹推理代码。确保输入张量是连续的避免非连续内存引发额外拷贝。6.2 用理论算代替试了才知道我注意到很多初学者一遇到内存问题第一反应是去调参、换模型、试各种骚操作但很少先冷静下来把账算清楚。实际上绝大多数内存问题都可以靠一张纸和一支笔提前算出来。步骤很简单统计模型的参数量和每一层特征图的尺寸。按float32精度算出参数量内存和特征图内存。加上一个框架运行时系数CPU推理乘3到5倍GPU训练乘5到10倍。你就得到了一个理论内存范围再去对比nvidia-smi或ps看到的实际占用。如果实际占用和理论值差很远那才是真正产生内存泄漏之类问题的信号。如果两者吻合那恭喜你你的内存占用是健康的只是你之前高估了模型文件小内存小这个等式罢了。6.3 这种差距背后的工程思维转变这篇文章写到这儿我想表达的其实已经超越了卷积内存怎么算这个具体技术点。更重要的是一个人从看文件大小判断资源消耗转变为看特征图、看运行时开销来分析资源消耗这是从应用使用者到工程优化者的思维升级。我做过的实际项目里靠这种思维转变省下的成本非常可观。有一个边缘设备端的项目最开始同事认为模型不大直接跑没问题结果一测内存超了设备上限。后来我们按上面的算法梳理了一遍发现输入分辨率可以从1920×1080降到1280×720特征图内存立刻降为原来的不到三分之一再叠加int8量化最终整个推理服务缩到了不足原来的五分之一内存。整个优化过程没有一个魔法操作全是在账面数字上做减法。这种工程经验远比背几个API要值钱。希望看完这篇文章你以后遇到类似问题第一反应是去算账而不是去瞎试。最后再分享一个我平时用得比较顺手的检查点每写一个模型或改一次结构都写个几行的小脚本把参数量、理论内存、实际内存打出来攒成一个基准表。长期积累下来你对什么结构大概吃什么内存会有非常敏锐的手感。这个习惯我用了好几年受益很多。