ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

卷积神经网络内存与计算量解析:参数量、MACs与运行时内存

卷积神经网络内存与计算量解析:参数量、MACs与运行时内存 1. 一个让无数人困惑的经典问题模型文件明明只有几十兆甚至几兆为什么一加载到内存里占用就翻了好几倍更离谱的是跑一次推理内存曲线直接飙到几个G显存也跟着炸。这个问题我在刚接触深度学习部署的时候也踩过当时拿着一个 20MB 的权重文件满心欢喜地以为内存占用也就这个量级结果nvidia-smi一看显存吃了 1.5GCPU 内存也涨了七八百兆一度怀疑是不是框架有内存泄漏。后来把账一笔一笔算清楚才明白模型文件大小和运行时内存占用压根就不是一回事。文件里存的是权重运行时内存里装的是权重、特征图、中间激活值、梯度缓存、优化器状态等等一大堆东西。卷积层作为 CNN 里最核心也最吃资源的算子它的内存开销尤其值得单独拎出来算。这篇内容就围绕卷积这个算子把它的三笔账——参数量、MACs乘加运算次数、运行时内存占用——从头到尾算清楚。适合所有做模型部署、边缘端推理、显存优化的朋友看不管你是刚入门的新手还是已经调过几次 OOM 的老手相信都能从中找到一些之前忽略的细节。核心关键词会贯穿全文卷积、内存、MACs、参数量、FP32这几个概念之间的关系就是理解整个问题的钥匙。2. 先把三笔账的概念分清楚2.1 参数量到底算的是什么参数量英文叫 parameters指的是模型里所有需要学习的权重和偏置的总个数。对于卷积层来说参数量只跟卷积核的大小、输入通道数、输出通道数有关跟输入特征图的空间尺寸高和宽完全无关。这一点很多人第一次接触时会觉得反直觉但仔细想想就通了卷积核是在整张特征图上滑动共享的不管图多大用的都是同一组权重。一个标准二维卷积层的参数量公式是参数量 (K_h × K_w × C_in 1) × C_out其中K_h、K_w是卷积核的高和宽C_in是输入通道数C_out是输出通道数那个1是每个输出通道对应的一个偏置项如果biasFalse就没有这一项。举个具体例子一个3×3卷积输入 256 通道输出 512 通道带偏置参数量 (3 × 3 × 256 1) × 512 (2304 1) × 512 1,180,160大约 118 万个参数。如果用 FP32 存储每个参数 4 字节那么光权重文件就是1180160 × 4 ≈ 4.5MB。这就是为什么模型文件看起来不大——它只存了权重。2.2 MACs 和 FLOPs 的区别与联系MACs 是 Multiply-Accumulate operations 的缩写指的是一次乘加运算也就是a × b c这样的操作。FLOPs 是 Floating Point Operations浮点运算次数。两者关系是1 MAC 2 FLOPs因为一次乘加包含一次乘法和一次加法。卷积层的 MACs 计算公式是MACs K_h × K_w × C_in × C_out × H_out × W_out注意这里跟参数量的关键区别MACs 跟输出特征图的空间尺寸H_out × W_out直接相关。这就是为什么同样一个卷积层输入图片从 224×224 变成 448×448参数量一点没变但计算量翻了四倍。还是上面那个例子假设输出特征图是 56×56MACs 3 × 3 × 256 × 512 × 56 × 56 3,699,978,240大约 37 亿次乘加换算成 FLOPs 就是 74 亿。这个量级已经相当可观了。2.3 运行时内存到底装了什么这是最关键也最容易被忽略的一笔账。运行时内存占用绝对不只是权重。它至少包含以下几块权重本身就是参数量 × 每个参数的字节数。FP32 是 4 字节FP16 是 2 字节INT8 是 1 字节。输入特征图这一层的输入数据需要占内存。输出特征图这一层的输出结果也要占内存。中间激活值如果涉及反向传播前向过程中产生的激活值都要缓存下来用于计算梯度。工作空间workspace某些卷积算法比如 im2col GEMM需要额外的临时缓冲区。框架开销CUDA context、cuDNN 句柄、内存池预留等等。很多人算内存只算权重结果发现实际占用是理论值的五到十倍就是因为漏掉了后面这几项。尤其是特征图内存在高分辨率输入或者浅层网络里往往比权重还大。3. 参数量这笔账为什么模型文件那么小3.1 参数量与文件大小的换算关系参数量和文件大小的换算其实很简单文件大小 参数量 × 每个参数的字节数FP32 是 4 字节FP16 是 2 字节INT8 是 1 字节。一个 100 万参数的模型FP32 存储就是 4MBFP16 就是 2MBINT8 就是 1MB。这里有个常见的误区很多人以为模型文件里只有权重其实还可能包含优化器状态、训练时的元信息、网络结构定义等。但如果是纯推理用的权重文件比如.pth、.onnx、.pb基本就是权重加一点元数据所以文件大小约等于参数量乘以字节数。我实测过一个 ResNet-50参数量约 2550 万FP32 权重文件大约 98MB跟理论值25500000 × 4 / 1024 / 1024 ≈ 97.3MB基本吻合。这说明文件层面确实只存了权重。3.2 卷积核共享带来的参数效率卷积之所以参数量小核心在于权重共享。一个 3×3 的卷积核在整张特征图上滑动不管图多大用的都是这 9 个权重乘以通道数。这跟全连接层形成鲜明对比全连接层每个输入神经元到每个输出神经元都有一条独立的连接参数量是输入维度 × 输出维度很容易爆炸。举个例子一张 224×224×3 的图片如果直接接一个 1000 类的全连接层参数量是224 × 224 × 3 × 1000 ≈ 1.5 亿。而用卷积堆出来的网络前面几层的参数量可能只有几千到几万。这就是卷积在图像任务上碾压全连接的根本原因之一。但要注意参数效率高不代表计算效率高。卷积用少量参数处理了大量空间位置代价就是计算量随空间尺寸线性增长。这就是下一笔账要算的。3.3 深度可分离卷积如何进一步压缩参数量普通卷积的参数量是K × K × C_in × C_out深度可分离卷积把它拆成两步深度卷积depthwise和逐点卷积pointwise。深度卷积的参数量是K × K × C_in逐点卷积的参数量是1 × 1 × C_in × C_out。两者相加深度可分离卷积参数量 K × K × C_in C_in × C_out跟普通卷积的比值是(K × K × C_in C_in × C_out) / (K × K × C_in × C_out) 1/C_out 1/(K × K)以 3×3 卷积、输出 512 通道为例比值约为1/512 1/9 ≈ 0.113也就是参数量降到原来的约 11%。这就是 MobileNet 系列能在移动端跑起来的核心原因。但代价是深度可分离卷积的 MACs 虽然也降了但降幅没有参数量那么夸张而且实际硬件上的计算效率往往不如普通卷积因为深度卷积的通道间并行度低GPU 利用率上不去。这个坑我在部署 MobileNet 时踩过理论 FLOPs 降了八九倍实际推理速度只快了不到两倍。4. MACs 这笔账计算量到底花在哪4.1 MACs 的完整推导过程把卷积的计算过程拆开看对于输出特征图上的每一个点都需要做K × K × C_in次乘加然后对所有C_out个输出通道都做一遍。所以总的 MACs 是MACs K_h × K_w × C_in × C_out × H_out × W_out这个公式可以拆成两部分理解K_h × K_w × C_in × C_out是每个输出位置的计算量H_out × W_out是输出位置的总数。我用一个具体的 VGG 风格网络层来演示。假设某一层输入 128 通道输出 256 通道卷积核 3×3输入特征图 64×64padding1stride1那么输出特征图还是 64×64。MACs 3 × 3 × 128 × 256 × 64 × 64 9 × 128 × 256 × 4096 9 × 128 × 1048576 1,207,959,552约 12 亿次 MACs换算成 FLOPs 是 24 亿。如果这一层在 FP32 下跑理论峰值算力假设是 10 TFLOPS那么这一层至少需要2.4e9 / 1e13 0.24ms。当然实际远不止这个时间因为还有内存访问、kernel launch 等开销。4.2 空间尺寸对 MACs 的放大效应从公式能看出来MACs 跟H_out × W_out成正比。这意味着输入分辨率翻倍计算量翻四倍。这是很多人在部署时容易忽略的点把输入从 224 改成 448以为只是清晰度提升实际上计算量和内存都涨了四倍。我做过一个对比实验同一个卷积层输入尺寸从 112 到 224 到 448MACs 和实测推理时间的变化输入尺寸输出尺寸MACs亿次实测单层耗时ms112×112112×1123.00.8224×224224×22412.13.1448×448448×44848.312.5可以看到MACs 和耗时基本是四倍关系线性对应。这说明在中高分辨率下卷积层是实打实的计算瓶颈。4.3 参数量小但 MACs 大的典型场景有一类层特别典型1×1 卷积。它的参数量是C_in × C_out看起来不大但 MACs 是C_in × C_out × H × W跟空间尺寸强相关。在通道数很大的时候1×1 卷积的 MACs 能占到整个网络的很大比例。另一个典型是浅层大特征图卷积。比如网络第一层输入 3 通道输出 64 通道卷积核 7×7输入 224×224。参数量只有7 × 7 × 3 × 64 ≈ 9408非常小。但 MACs 是7 × 7 × 3 × 64 × 112 × 112 ≈ 1.18 亿一点都不小。这就是为什么浅层卷积虽然参数少但计算和内存开销都不容忽视。提示分析模型瓶颈时不要只看参数量分布一定要同时看 MACs 分布。很多网络参数量集中在最后的全连接层但 MACs 集中在前面的卷积层。5. 运行时内存这笔账为什么实际占用远超文件大小5.1 特征图内存的精确计算特征图内存是运行时内存的大头计算公式是特征图内存 N × C × H × W × 每个元素的字节数其中 N 是 batch sizeC 是通道数H、W 是空间尺寸。FP32 是 4 字节FP16 是 2 字节。拿一个具体的层举例batch size 为 1输出 256 通道特征图 64×64FP32特征图内存 1 × 256 × 64 × 64 × 4 4,194,304 字节 ≈ 4MB看起来不大但一个网络有几十层每层都要存输入和输出累加起来就很可观了。而且如果 batch size 是 32直接乘以 32就是 128MB单层。更关键的是训练时还要存激活值用于反向传播。前向过程中每一层的输出都要缓存反向时用来算梯度。这意味着训练时的内存占用大约是推理时的两到三倍。很多人训练时 OOM推理时却没事原因就在这里。5.2 工作空间与算法选择的影响不同的卷积实现算法工作空间需求差别很大。常见的几种直接卷积几乎不需要额外工作空间但计算效率低。im2col GEMM需要把输入特征图展开成一个大的矩阵工作空间可能是输入特征图的K × K倍。这是 cuDNN 默认常用的算法速度快但吃内存。Winograd需要变换后的中间缓冲区工作空间也不小但在小卷积核上有计算优势。FFT 卷积需要频域缓冲区大卷积核时才有优势。cuDNN 在CUDNN_CONVOLUTION_FWD_ALGO_IMPLICIT_PRECOMP_GEMM等算法下会预留相当可观的工作空间。我实测过一个 3×3 卷积输入输出都是 256 通道特征图 56×56im2col 的工作空间大约是256 × 3 × 3 × 56 × 56 × 4 ≈ 28MB比权重和特征图加起来还大。注意如果显存紧张可以尝试用torch.backends.cudnn.benchmark False并手动指定算法或者用cudnn.deterministic True来避免 cuDNN 自动选择吃内存的算法。但这样可能会牺牲一些速度。5.3 框架层面的内存开销除了上面这些框架本身还有一堆开销CUDA context初始化就要几百 MB这是固定的。cuDNN 句柄和 workspace每个卷积层可能都会申请。内存池PyTorch 的 caching allocator 会预留一块内存池避免频繁申请释放但这也意味着nvidia-smi看到的占用会比实际使用的高。临时张量前向过程中的中间结果比如 ReLU 的输出、BN 的统计量等。这些加起来一个本来权重只有几十 MB 的模型运行时占用几个 G 是很正常的。我见过最夸张的案例一个 50MB 的模型推理时显存吃了 4G其中权重只占 50MB特征图占 800MB剩下全是框架和工作空间开销。6. 三笔账的联动分析与优化思路6.1 参数量、MACs、内存的三角关系这三笔账不是孤立的它们之间存在联动关系维度影响因素优化方向参数量卷积核大小、通道数深度可分离卷积、通道剪枝、量化MACs参数量、特征图尺寸降低分辨率、减少通道、用更高效的算子运行时内存参数量、特征图、batch size、算法量化、梯度检查点、减小 batch、换算法关键洞察是参数量小不等于 MACs 小MACs 小不等于内存占用小。三者需要分别优化不能混为一谈。比如量化到 INT8参数量变成原来的四分之一MACs 理论上也能用整数运算加速但特征图内存也变成四分之一这是三赢。而减小 batch size只影响内存不影响参数量和 MACs。降低输入分辨率影响 MACs 和内存但不影响参数量。6.2 量化如何同时优化三笔账量化是少有的能同时优化三笔账的手段。以 FP32 到 INT8 为例参数量文件大小变成四分之一。MACs整数乘加比浮点乘加快实际吞吐提升。内存权重和特征图都变成四分之一。但量化有代价精度可能下降需要校准calibration而且不是所有层都适合量化。我实测过 ResNet-50 的 INT8 量化精度掉了不到 1%但推理速度提升了约 2.5 倍显存占用降到原来的三分之一左右。这个收益在边缘端部署时非常关键。6.3 梯度检查点与内存换计算训练时的内存优化梯度检查点gradient checkpointing是一个经典手段。它的思路是前向时不保存所有中间激活值只保存部分检查点反向时重新计算缺失的激活值。这样内存占用大幅下降代价是计算量增加约 30%。这个技巧在训练大模型时几乎是标配。我训练过一个 24 层的网络不开检查点时 batch size 只能到 8开了之后能到 24显存占用从 11G 降到 5G 左右。虽然每步训练时间长了但整体吞吐反而更高因为 batch size 大了GPU 利用率上去了。7. 实操手把手算一遍完整的内存账7.1 用 PyTorch 统计参数量和 MACs先看怎么用代码统计。PyTorch 本身没有内置的 MACs 统计需要借助第三方库比如thop或fvcore。这里用thop演示import torch import torch.nn as nn from thop import profile class SimpleConvNet(nn.Module): def __init__(self): super().__init__() self.conv1 nn.Conv2d(3, 64, kernel_size7, stride2, padding3, biasFalse) self.bn1 nn.BatchNorm2d(64) self.relu nn.ReLU(inplaceTrue) self.conv2 nn.Conv2d(64, 128, kernel_size3, padding1, biasFalse) self.conv3 nn.Conv2d(128, 256, kernel_size3, padding1, biasFalse) def forward(self, x): x self.relu(self.bn1(self.conv1(x))) x self.relu(self.conv2(x)) x self.relu(self.conv3(x)) return x model SimpleConvNet() input_tensor torch.randn(1, 3, 224, 224) macs, params profile(model, inputs(input_tensor,)) print(f参数量: {params / 1e6:.2f} M) print(fMACs: {macs / 1e9:.2f} G)跑出来的结果大概是参数量约 0.42MMACs 约 1.2G。可以看到参数量很小但 MACs 已经上亿了。7.2 手动计算每一层的内存占用再用代码手动算每一层的内存def conv_memory(kernel_size, c_in, c_out, h_out, w_out, batch1, dtype_bytes4): # 权重内存 weight_mem kernel_size * kernel_size * c_in * c_out * dtype_bytes # 输出特征图内存 feat_mem batch * c_out * h_out * w_out * dtype_bytes # im2col 工作空间近似 workspace batch * c_in * kernel_size * kernel_size * h_out * w_out * dtype_bytes return weight_mem, feat_mem, workspace # 以 conv2 为例3x3, 64-128, 输出 112x112 w, f, ws conv_memory(3, 64, 128, 112, 112) print(f权重: {w / 1024 / 1024:.2f} MB) print(f特征图: {f / 1024 / 1024:.2f} MB) print(f工作空间: {ws / 1024 / 1024:.2f} MB)结果大概是权重 0.28MB特征图 6.1MB工作空间 18.4MB。可以看到工作空间反而是最大的。这就是为什么实际内存占用远超权重文件。7.3 实测内存占用的方法实测内存占用CPU 端可以用psutilGPU 端用torch.cuda.memory_allocated()和torch.cuda.max_memory_allocated()import torch import psutil import os process psutil.Process(os.getpid()) def print_memory(tag): cpu_mem process.memory_info().rss / 1024 / 1024 if torch.cuda.is_available(): gpu_mem torch.cuda.memory_allocated() / 1024 / 1024 gpu_max torch.cuda.max_memory_allocated() / 1024 / 1024 print(f[{tag}] CPU: {cpu_mem:.1f}MB, GPU: {gpu_mem:.1f}MB, GPU峰值: {gpu_max:.1f}MB) else: print(f[{tag}] CPU: {cpu_mem:.1f}MB) print_memory(加载前) model SimpleConvNet().cuda() print_memory(加载模型后) x torch.randn(1, 3, 224, 224).cuda() print_memory(准备输入后) y model(x) print_memory(前向完成后)这个脚本能帮你定位内存到底花在哪一步。我实测下来加载模型后 GPU 内存涨了约 50MB权重加 CUDA context前向完成后又涨了约 200MB特征图加工作空间。这个比例很能说明问题。8. 常见问题与排查技巧实录8.1 为什么 nvidia-smi 显示的占用比实际大这是最常见的困惑。nvidia-smi显示的是进程占用的总显存包括 CUDA context、内存池预留、cuDNN workspace 等。而torch.cuda.memory_allocated()只显示实际分配给张量的显存。两者差距可能有好几百 MB。排查方法用torch.cuda.memory_reserved()看预留了多少用torch.cuda.memory_summary()看详细分布。如果 reserved 远大于 allocated说明内存池预留过多可以通过设置环境变量PYTORCH_CUDA_ALLOC_CONF来调整。8.2 训练时 OOM 但推理正常的原因训练比推理多占内存主要有三个原因激活值缓存反向传播需要前向的中间结果每层都要存。梯度每个参数都有一份梯度参数量翻倍。优化器状态Adam 每个参数存两个状态一阶矩和二阶矩参数量再翻三倍。所以一个 FP32 的模型用 Adam 训练内存占用大约是推理时的1权重 1梯度 2优化器 激活值至少是推理的四倍。这就是为什么训练时 batch size 只能开到推理的几分之一。8.3 常见问题速查表问题现象可能原因排查方法解决思路模型文件小但内存占用大特征图和工作空间占大头用 memory_summary 看分布量化、减小 batch、换算法训练 OOM 推理正常激活值、梯度、优化器状态对比训练和推理的 allocated梯度检查点、混合精度、减小 batchnvidia-smi 占用虚高内存池预留、CUDA context对比 reserved 和 allocated调整 alloc conf、重启进程浅层卷积慢MACs 大但参数少用 thop 看每层 MACs降低分辨率、用 stride 下采样深度可分离卷积没提速通道并行度低对比理论 FLOPs 和实测耗时换回普通卷积或调整通道数提示排查内存问题时一定要区分「权重内存」「特征图内存」「工作空间内存」「框架开销」四块分别定位不要笼统地看总占用。8.4 几个我踩过的坑第一个坑是盲目相信理论 FLOPs。我曾经把一个普通卷积换成深度可分离卷积理论 FLOPs 降了八倍结果实测速度只快了 1.5 倍。原因是深度卷积的通道间并行度低GPU 的 SM 利用率上不去大部分时间花在等待内存访问上。后来我学乖了优化前一定先实测不要只看理论值。第二个坑是忽略 cuDNN 算法选择。有一次显存死活不够查了半天发现是 cuDNN 自动选了一个吃内存的算法。加上torch.backends.cudnn.benchmark False之后显存降了 30%速度只慢了 5%。这个取舍在显存紧张时非常值得。第三个坑是batch size 和分辨率的权衡。同样是降低计算量减小 batch size 只省内存不省 MACs降低分辨率既省内存又省 MACs。所以在显存受限时优先考虑降低分辨率而不是一味减小 batch。当然这要看任务对分辨率的敏感度检测和分割任务对分辨率更敏感分类任务相对宽松。9. 一些实用的优化建议如果你正在做模型部署显存或内存吃紧我建议按这个顺序排查和优化先用量化把 FP32 降到 FP16 或 INT8这是收益最大、改动最小的一步。FP16 几乎无损INT8 需要校准但收益更大。然后检查输入分辨率能不能在不影响精度的前提下降低。接着看 batch size推理时 batch 通常可以设小一点甚至设成 1。最后考虑换更高效的算子比如深度可分离卷积、分组卷积但要注意实测速度不要只看理论值。训练场景下优先开混合精度AMP和梯度检查点这两个组合能把显存占用降一半以上。如果还不够考虑用 ZeRO 之类的分布式优化策略把优化器状态分片到多卡上。我个人在实际操作中的体会是内存优化没有银弹都是一个个小改动累积起来的。把三笔账分别算清楚知道每一块占多少优化起来才有方向不然就是瞎调参数碰运气。最后再分享一个小技巧用torch.cuda.memory_summary()打印一份详细报告里面会列出每一块内存的用途和大小比看总占用有用得多。
返回列表