ARTICLE DETAIL

资讯详情

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

参数量、MACs与内存占用:卷积模型推理内存预算实战指南

参数量、MACs与内存占用:卷积模型推理内存预算实战指南 1. 一个让很多人困惑的现象权重文件才几十兆跑起来却吃掉几个G先把场景摆出来。你从某个开源仓库下载了一个图像分类模型权重文件model.pth或者model.onnx一看才 40MB 出头心里盘算着这点东西随便一台 8G 内存的机器都能跑吧。结果一加载、一推理任务管理器里的内存曲线直接窜到 2G、3G甚至更高。如果是在浏览器里跑标签页直接崩如果是在嵌入式板子上跑进程被 OOM Killer 干掉。这个落差感非常强因为直觉告诉我们文件多大内存就占多大。但计算机里没有这么朴素的等价关系。权重文件只是模型的一个快照它记录的是训练完之后每个参数的数值而模型真正跑起来需要的不只是这些数值本身还有中间层的激活值、卷积运算的临时缓冲区、框架自身的运行时开销、内存分配器的碎片等等。我见过太多人在做模型部署时只盯着参数量算内存结果上线就翻车。也见过有人把 FP32 权重转成 FP16文件小了一半就以为内存问题解决了实际上推理峰值内存几乎没怎么降。问题的根子在于推理内存 ≠ 权重文件大小这两者之间差着好几笔账。这篇内容就是要把这几笔账一笔一笔算清楚。核心围绕三个概念展开参数量Params、乘加运算量MACs、内存占用Memory Footprint。这三个东西经常被混为一谈但它们描述的是完全不同的维度。搞清楚它们的区别和联系你才能准确预估一个卷积模型到底需要多少内存、多少算力才能知道优化该往哪个方向使劲。适合谁看如果你正在做模型部署、端侧推理、模型压缩或者只是单纯好奇为什么我的模型这么吃内存这篇内容会给你一套可以自己动手算的方法论。不需要你有很深的体系结构背景只要会基本的乘法和单位换算就行。2. 参数量、MACs、内存占用三个被混为一谈的指标2.1 参数量到底在数什么参数量英文 Params指的是模型里所有需要学习的权重和偏置的总个数。对于一个标准的二维卷积层它的参数量计算公式是Params C_out × (C_in × K_h × K_w 1)其中C_in是输入通道数C_out是输出通道数K_h和K_w是卷积核的高和宽那个1是偏置项如果biasFalse就没有这一项。举个例子一个3×3卷积输入 256 通道输出 256 通道带偏置Params 256 × (256 × 3 × 3 1) 256 × 2305 589,824差不多 59 万个参数。如果每个参数用 FP32 存储也就是 4 字节那这一层光权重就占589824 × 4 ≈ 2.36MB。注意这里的关键点参数量只跟层的结构有关跟输入图片多大完全无关。你输入 224×224 的图还是 512×512 的图这一层的参数量都是 59 万。这是很多人第一个认知误区——以为图片大了参数就多。不是的参数是学出来的图片大小影响的是另一笔账。2.2 MACs 才是真正的工作量MACs全称 Multiply-Accumulate operations乘加运算次数。每一次乘一个数再加到累加器上算一次 MAC。卷积层的 MACs 计算公式是MACs C_out × C_in × K_h × K_w × H_out × W_out还是刚才那个例子假设输入特征图是 56×56输出也是 56×56padding 保持尺寸MACs 256 × 256 × 3 × 3 × 56 × 56 ≈ 1.85 × 10^918.5 亿次乘加。这个数字和参数量差了四个数量级。原因很简单同一个卷积核会在整张特征图上滑动复用。权重只有 59 万个但每个权重被用了56×56 3136次。这就是卷积的精髓也是它省参数的原因——参数共享。全连接层就没有这个待遇全连接层的 MACs 和参数量基本是同一个量级都是输入维度 × 输出维度。所以当你评估一个模型的计算量时看参数量是没用的要看 MACs。一个模型可能只有 5M 参数但 MACs 高达几十 G因为它在高分辨率特征图上做了大量卷积。2.3 内存占用是第三本账和前两个都不是一回事内存占用指的是模型在运行过程中实际占用的 RAM 或显存。它至少包含以下几块内存组成说明是否与输入尺寸相关权重内存所有参数按精度存储否激活内存每层输出特征图是强相关临时缓冲区卷积算法的工作区是部分相关框架运行时解释器、调度、元数据否但有固定开销分配器碎片内存对齐、缓存池间接相关权重内存好算参数量 × 每参数字节数。FP32 是 4 字节FP16 是 2 字节INT8 是 1 字节。激活内存才是大头而且它和输入尺寸强相关。一个H×W×C的 FP32 特征图占H×W×C×4字节。如果网络中间某层输出是256×256×256那就是256×256×256×4 64MB一层就 64MB。如果同时要保留多个层的激活比如训练时的反向传播或者某些推理框架的图优化策略这个数字会成倍增长。临时缓冲区经常被忽略。不同的卷积实现算法im2col、Winograd、FFT、直接卷积需要的工作区大小差别很大。im2col 会把输入展开成一个巨大的矩阵虽然计算规整了但内存膨胀可能达到K_h × K_w倍。Winograd 能减少乘法次数但变换矩阵也要占内存。2.4 三者的关系用一个表格说清楚指标单位描述什么受什么影响典型用途参数量个数模型容量层结构、通道数、核大小评估模型大小、存储需求MACs次数计算工作量参数量、特征图尺寸评估算力需求、推理延迟内存占用字节运行时资源参数量、激活尺寸、精度、框架评估部署可行性一句话总结参数量决定模型多大MACs 决定模型多累内存占用决定模型多占地方。三者相关但不等价优化的时候要分开看。3. 权重内存这笔账为什么 FP32 转 FP16 没你想的那么省3.1 权重内存的精确计算权重内存的计算非常直接权重内存 参数量 × 每个参数的字节数FP32 是 4 字节FP16 是 2 字节BF16 是 2 字节INT8 是 1 字节INT4 是 0.5 字节。拿一个经典的 ResNet-50 举例它的参数量大约是 25.6M。那么FP3225.6M × 4 102.4MBFP1625.6M × 2 51.2MBINT825.6M × 1 25.6MB看起来 FP32 转 FP16 直接省了一半很美好对吧但这里有个陷阱权重内存只是总内存的一部分而且往往不是最大的那部分。假设你的模型权重占 100MB但激活内存占了 1.5GB那你把权重从 FP32 降到 FP16总内存从 1.6GB 降到 1.55GB只降了 3%。这就是为什么很多人做了权重量化发现内存问题几乎没改善——你优化错了地方。3.2 量化省的是权重激活才是大头在推理场景下尤其是高分辨率输入的场景激活内存往往远超权重内存。原因在于权重是固定的只存一份激活是每层都要产生的而且尺寸可能很大如果框架不做内存复用memory reuse所有中间激活同时存在内存会爆炸我做过一个实测一个轻量级分割网络权重只有 8MBINT8但输入 1024×1024 时峰值内存到了 1.2GB。权重占比不到 1%。这种情况下你去优化权重精度完全是杯水车薪。正确的做法是先算清楚权重内存和激活内存各占多少再决定优化方向。如果权重占大头比如全连接层很多的模型量化权重有效如果激活占大头比如高分辨率卷积网络要做的是激活复用、算子融合、降低中间精度。3.3 一个容易忽略的点优化器状态和梯度上面说的都是推理场景。如果你在训练内存账还要复杂得多。训练时除了权重和激活还要存梯度和权重同尺寸FP32 下又是 100MB优化器状态Adam 要存一阶矩和二阶矩又是权重的 2 倍前向激活反向传播需要通常要保留所有层所以训练一个 25.6M 参数的模型FP32 下光权重梯度优化器状态就是25.6M × 4 × 4 409.6MB再加上激活轻松上几个 G。这也是为什么大模型训练这么吃显存。提示推理和训练的内存模型完全不同。做部署优化时不要拿训练时的内存数据来估算推理内存两者差着数量级。4. 激活内存这笔账输入尺寸一变大内存就失控4.1 激活内存的计算方法激活内存指的是前向传播过程中每一层输出的特征图所占的内存。对于单个特征图单层激活内存 H × W × C × 精度字节数其中 H、W 是特征图的高和宽C 是通道数。关键在于H 和 W 会随着网络深度逐渐减小但 C 会逐渐增大。以 ResNet 为例阶段特征图尺寸通道数FP32 单层内存conv1112×112643.2MBlayer156×562563.2MBlayer228×285121.6MBlayer314×1410240.8MBlayer47×720480.4MB单看一层都不大但问题是网络有很多层而且如果框架不做复用所有层的激活都要同时存在。ResNet-50 有 50 多个卷积层累加起来激活内存就很可观了。更要命的是输入尺寸翻倍激活内存翻四倍。因为 H 和 W 都翻倍乘积是四倍。输入从 224 变成 448激活内存直接 ×4。这就是为什么高分辨率推理特别吃内存。4.2 内存复用框架帮你省了多少现代推理框架TensorRT、ONNX Runtime、TVM、NCNN 等都会做内存复用优化。核心思想是如果两个张量的生命周期不重叠它们可以共用同一块内存。比如第 1 层的输出被第 2 层消费完之后第 1 层的输出内存就可以回收给第 3 层用。通过分析计算图的生命周期框架可以把峰值内存压到同时活跃的张量总和而不是所有张量总和。这个优化效果非常显著。我实测过一个模型不做复用时峰值激活内存 800MB做了复用之后降到 180MB降了 4 倍多。所以选一个会做内存复用的推理框架比你自己手动优化权重精度有用得多。但内存复用也有代价它需要框架在加载时做图分析有一定的启动开销而且某些动态形状的场景下复用效果会打折扣。如果你的输入尺寸是变化的框架可能要为最坏情况预留内存。4.3 算子融合对激活内存的影响算子融合是另一个省内存的大招。典型的融合模式是 Conv BN ReLU 融合成一个算子。融合之后不需要单独存 BN 的中间结果不需要单独存 ReLU 的输入输出减少了内存读写次数也减少了峰值内存更激进的融合比如 Conv Add ReLU残差块可以把整个残差块融合成一个算子中间激活完全不落地。我见过一个案例一个包含大量残差连接的模型不做融合时峰值内存 2.1GB做了 Conv-BN-ReLU 融合 残差融合之后降到 1.3GB。融合不仅省内存还提速因为减少了 kernel launch 和内存带宽压力。注意算子融合通常需要推理框架支持而且对模型结构有要求。如果你的模型有复杂的控制流或者动态形状融合可能会失败。部署前一定要用框架的 profiler 看一下实际融合了哪些算子。5. 临时缓冲区和框架开销那些看不见的内存黑洞5.1 卷积算法的工作区不同的卷积实现算法内存开销差别巨大。常见的几种直接卷积最朴素不需要额外工作区但计算效率低。内存开销最小。im2col GEMM把输入展开成矩阵然后用矩阵乘法。展开后的矩阵大小是C_in × K_h × K_w × H_out × W_out相比原始输入膨胀了K_h × K_w倍。一个 3×3 卷积输入膨胀 9 倍。这是很多框架的默认选择因为 GEMM 优化得很成熟。Winograd通过变换减少乘法次数但需要存储变换后的输入和输出。工作区大小和 tile 尺寸有关通常比 im2col 小但比直接卷积大。FFT大卷积核时有效但需要存储频域数据工作区也很大。我实测过一个 3×3 卷积输入 256×256×256用 im2col 时临时缓冲区峰值到了 600MB 以上而用直接卷积只有几十 MB。所以如果你的内存很紧张可以尝试强制框架使用内存友好的卷积算法代价是可能慢一点。5.2 框架自身的固定开销不管你跑什么模型框架本身都要占一块内存。这部分经常被忽略但在小模型上占比可能很高。Python 解释器如果你用 PyTorch 的 Python API光解释器加 import torch 就要 200-400MBCUDA 上下文如果用 GPUCUDA context 本身就要 300-500MB内存分配器缓存PyTorch 的 caching allocator 会缓存已分配的内存不立即归还给系统图优化和元数据计算图、算子注册表、常量折叠等我做过对比同一个模型用 PyTorch Python 跑占 1.8GB用 ONNX Runtime C 跑占 600MB用 NCNN 跑占 200MB。模型没变框架换了内存差了一个数量级。所以如果你在资源受限的环境部署不要用训练框架做推理。把模型导出成 ONNX用专门的推理引擎跑内存和速度都会有质的提升。5.3 内存分配器碎片为什么释放了内存还是不够内存碎片是个隐蔽的问题。假设你依次申请了 100MB、200MB、100MB 三块内存然后释放了中间的 200MB。现在系统有 200MB 空闲但它是碎片化的如果你要申请一块 250MB 的连续内存就会失败。深度学习框架通常有自己的内存池来缓解这个问题。PyTorch 的 caching allocator 会把释放的内存块缓存起来下次申请同样大小的块时直接复用。但如果你的张量尺寸变化很大缓存命中率低碎片就会累积。一个实用的技巧如果你的推理服务是长驻的尽量固定输入尺寸这样内存池能高效复用。如果输入尺寸变化频繁考虑定期重启进程或者用支持动态内存整理的框架。6. 动手算一遍给一个真实卷积网络做内存预算6.1 选定模型和输入规格我们拿一个简化版的 VGG 风格网络来算。假设网络结构如下conv1: 3→64, 3×3, 输入 224×224conv2: 64→128, 3×3, stride 2conv3: 128→256, 3×3, stride 2conv4: 256→512, 3×3, stride 2fc: 512×7×7 → 1000输入是 1×3×224×224 的 FP32 图片。6.2 逐层计算参数量和激活内存conv1Params 64 × (3×3×31) 64 × 28 1792激活224×224×64×4 12.8MBconv2Params 128 × (64×3×31) 128 × 577 73856输出 112×112激活112×112×128×4 6.4MBconv3Params 256 × (128×3×31) 256 × 1153 295168输出 56×56激活56×56×256×4 3.2MBconv4Params 512 × (256×3×31) 512 × 2305 1180160输出 28×28激活28×28×512×4 1.6MBfcParams 1000 × (512×7×71) 1000 × 25089 25,089,000激活1000×4 4KB总参数量约 25.5MFP32 权重内存约 102MB。激活内存如果全部同时存在12.8 6.4 3.2 1.6 0.004 ≈ 24MB。看起来不大。但注意这是单张图片、单次前向的激活。如果 batch size 是 32激活内存 ×32 768MB。如果输入分辨率翻倍到 448激活内存 ×4 3GBbatch 32 时。6.3 加上临时缓冲区和框架开销假设用 im2col 做卷积conv1 的 im2col 矩阵大小是3×3×3 × 224×224 27 × 50176 ≈ 1.35M个元素FP32 下 5.4MB。conv2 的 im2col 矩阵64×3×3 × 112×112 576 × 12544 ≈ 7.2M元素28.9MB。conv3 是128×3×3 × 56×56 1152 × 3136 ≈ 3.6M14.5MB。conv4 是256×3×3 × 28×28 2304 × 784 ≈ 1.8M7.2MB。临时缓冲区峰值约 29MBconv2 时。框架开销如果用 PyTorch Python加 400MB如果用 ONNX Runtime加 100MB如果用 NCNN加 30MB。6.4 汇总一个完整的内存预算表项目单张 FP32batch 32 FP32batch 32 FP16权重102MB102MB51MB激活24MB768MB384MB临时缓冲区29MB29MB15MB框架开销400MB400MB400MB合计555MB1299MB850MB从这个表能看出几个关键结论batch size 对激活内存影响巨大batch 从 1 到 32激活内存涨了 32 倍框架开销在小 batch 时占比很高batch 1 时占了 72%FP16 主要省的是权重和激活框架开销不变所以整体节省比例有限提示这个预算是理论值实际运行时会因为内存对齐、分配器策略、框架实现细节有所偏差。建议用框架自带的 profiler 实测比如 PyTorch 的torch.cuda.max_memory_allocated()或者 ONNX Runtime 的 profiling 功能。7. 优化内存的几条实战路径和踩坑记录7.1 降低精度不是所有层都适合量化FP32 转 FP16 是最简单的优化大部分框架一行代码就能搞定。但有几个坑坑一某些算子不支持 FP16。比如某些版本的 LayerNorm、Softmax 在 FP16 下会溢出。你需要用混合精度把这些层保持 FP32。坑二FP16 的数值范围小。FP16 的最大值是 65504如果你的激活值超过这个数就会变成 inf。训练时用 loss scaling 解决推理时要检查激活值范围。坑三INT8 量化需要校准。不是简单地把 FP32 截断成 INT8而是要用校准数据集统计激活值的分布确定量化参数。校准集选得不好精度掉得厉害。我踩过的一个坑把一个检测模型做 INT8 量化mAP 掉了 8 个点。后来发现是校准集只用了白天场景没有夜间样本导致夜间激活值分布偏移。校准集一定要覆盖真实场景的分布。7.2 算子融合框架帮你做但你要会看算子融合通常是推理框架自动做的但你需要知道它有没有生效。以 TensorRT 为例可以用trtexec --verbose看融合了哪些层。如果发现 Conv-BN-ReLU 没有融合可能是模型导出时 BN 没有正确折叠。一个常见问题训练时用了nn.BatchNorm2d导出 ONNX 时没有做 BN folding。这样推理时 BN 还是一个独立算子不仅多占内存还慢。正确做法是在导出前调用torch.nn.utils.fuse_conv_bn_eval或者用框架的优化工具做折叠。7.3 内存复用固定输入尺寸是关键前面说过内存复用依赖框架分析张量生命周期。如果输入尺寸动态变化框架可能要为最坏情况预留内存复用效果大打折扣。我的经验是如果业务允许尽量固定输入尺寸。比如图像分类统一 resize 到 224×224目标检测统一到 640×640。这样框架能做出最优的内存规划。如果必须支持动态尺寸可以考虑按尺寸分桶每个桶用独立的推理会话。这样每个会话内部还是固定尺寸复用效果好。7.4 选对推理框架省下的内存比优化模型还多这是我踩过最大的坑。早期做部署直接用 PyTorch 的model.eval()加torch.no_grad()就上线了结果内存占用高得离谱。后来换成 ONNX Runtime同样的模型内存降了 60%速度还快了 2 倍。不同框架的内存效率对比同一模型同一输入框架内存占用相对开销PyTorch Python1.8GB基准ONNX Runtime600MB33%TensorRT450MB25%NCNN200MB11%TFLite250MB14%所以部署第一步不是优化模型而是选对框架。把模型导出成 ONNX然后用目标平台最优的推理引擎跑这是性价比最高的优化。7.5 一个真实的排查案例内存缓慢增长最后分享一个我遇到过的诡异问题。一个推理服务跑了一段时间后内存缓慢增长从 800MB 涨到 2GB然后被 OOM 杀掉。重启后又能跑一段时间。排查过程先用tracemalloc看 Python 层的内存分配没发现异常用psutil看进程 RSS确实在涨怀疑是框架的内存池问题检查 PyTorch 的 caching allocator发现缓存块数量在增加最终定位到输入尺寸偶尔会变化某些请求的图片尺寸和默认值不同导致内存池缓存了多种尺寸的块碎片累积解决方案在预处理阶段强制 resize 到固定尺寸问题消失。这个坑让我深刻理解到内存问题往往不在模型本身而在工程细节。8. 把三笔账算清楚之后优化方向自然就清晰了回到最开始的问题为什么模型文件很小运行起来却吃内存因为文件大小只反映了权重内存而运行时内存还包括激活内存、临时缓冲区、框架开销。在 batch size 大、输入分辨率高、框架开销重的场景下权重内存可能只占总内存的 5% 不到。把三笔账算清楚之后优化路径就很明确了如果权重占大头做权重量化FP16/INT8或者用更紧凑的模型结构深度可分离卷积、MobileNet 系列如果激活占大头降低 batch size、降低输入分辨率、启用内存复用、做算子融合如果框架开销占大头换轻量级推理框架用 C 而不是 Python避免用训练框架做推理我个人在实际操作中的体会是先测量再优化。不要凭直觉猜哪里占内存用 profiler 把每一块的内存占用打出来数据会告诉你答案。我见过太多人一上来就做权重量化结果发现激活才是瓶颈白忙一场。最后再分享一个小技巧如果你用的是 PyTorch可以用torch.cuda.memory_summary()看显存的详细分配情况包括已分配、已缓存、碎片等。如果是 CPU 推理用memory_profiler逐行看内存增长。这些工具能帮你快速定位问题比盲目试错高效得多。
返回列表