ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

图像训练显存不足怎么办?从数据加载到混合精度的完整优化策略

图像训练显存不足怎么办?从数据加载到混合精度的完整优化策略

今天这个 Day42 的话题,可能很多同学都遇到过——图像任务一路跑到中段,模型结构没问题、数据也清洗过了,结果第一道坎根本不是精度上不去,而是屏幕上冷不丁弹出一句"显存不足"。图像数据和显存之间的关系,比大多数人想象中要直接得多。训练过程中,显卡显存里住着的不只是模型参数,还有每一批图像本身、每一层卷积产生的中间特征图,以及各种临时缓冲。只要其中一环安排不当,给你再多显存也可能被悄无声息地浪费掉。这篇文章不是讲抽象理论,而是把图像从硬盘到显存、再从显存到训练引擎的完整过程拆开,结合我实际踩过的坑,说说显存是怎么被图像任务一步步吃满的,以及有哪些真正有用的控制手段。

1. 显存为什么会成为图像训练的真瓶颈:先说清空间被谁占了

1.1 显存里住着三拨"客人",优先级完全不同

很多人有个误解,以为显存主要装模型参数。对一个十几万参数的分类小网络来说,参数确实没多少,但图像任务一般不会这么简单。实际训练时,显存里通常住着三类东西。

第一类是可学习参数和优化器状态。模型权重、偏置,加上 Adam 之类的优化器还会额外保存一阶动量、二阶动量。这部分是"固定房客",无论你跑多少轮迭代,它的体积基本不变。第二类是前向传播过程中的中间激活值,也就是每一层卷积、池化、归一化之后产生的中间特征图。这部分是"临时房客",只在一次前向和反向的过程中存在,但它往往是显存消耗的大头。第三类是各类临时缓冲和数据副本,包括当前批次的输入图像、图像预处理时产生的中间变量、多卡通信时需要的通信缓冲区等等。

图像数据主要落在第三类,但它的影响力远不止第三类。因为图像一旦进入网络,第一步卷积就会把原始输入变成更大规模的中间特征图,这些特征图体积往往比原始图像还大不少。所以在排查显存问题时,不能只盯着输入图像本身,而要从整个数据流链条去看。

1.2 图像数据在训练循环里"进场"的瞬间

动手调显存之前,先想清楚一个关键动作:图像是在什么时候真正占用显存的。

最典型的一次进场,是数据加载器把处理好的一批图像从 CPU 内存拷贝到 GPU 显存。这一步之后,这批图像会以张量的形式参与前向计算。如果你用的是常规精度,一个 256×256 的 RGB 图像,转成浮点张量之后大约占 786KB,这个数看起来不大,但训练时往往是多张图同时存在。

另一次容易被忽略的进场,发生在图像预处理阶段。很多人会在 GPU 上做随机裁剪、翻转、颜色抖动,这些增强操作会额外生成中间张量。举个例子,你原本输入是 256×256,做一次随机裁剪并 resize 到 224×224,如果这个过程在 GPU 侧完成,那么原始图和裁剪结果会同时存在于显存里,显存峰值就直接翻倍。很多看起来"没干什么"的小操作,叠加起来就是几十 GB 级别。

还有一次进场与反向传播绑定。训练时为了计算梯度,框架需要保存前向过程中的中间激活值。这些都是图像数据通过卷积层后不断膨胀的产物。一个输入图像经过第一层卷积后,如果有 64 个输出通道、空间尺寸仍接近原图,产生的特征图就已经达到输入体积的几十倍。这才是"图像数据显存爆炸"的真正来源。

2. 一张图像从字节到显存张量的账本:怎么算才不被吓一跳

2.1 三个决定性变量:分辨率、通道数、字节精度

想对显存心里有数,必须会算账。显存占用有非常明确的计算公式:

[ 占用字节数 = 高 \times 宽 \times 通道数 \times 每元素字节数 ]

每元素字节数和数据精度直接相关。常用精度对应关系如下:

数据类型每元素字节数典型用途
uint81 字节原始图像文件、RGB 编码
float16 / bfloat162 字节混合精度训练时使用
float324 字节常规训练时的输入张量
float648 字节极少用于图像训练,速度慢且占大

举个例子。一张 512×512 的 RGB 图像,原始文件用 uint8 表示时,占 512×512×3×1 ≈ 786KB。一旦转成 float32 张量,占 512×512×3×4 ≈ 3MB。如果图像是 2048×2048,转换后一张就是 48MB。这还只是输入图本身,没有算任何中间结果。

2.2 批次放大和激活放大:从"几个 MB"到"论 GB 算"

单张图账算清之后,下一步就是把批次放进去。假设你的 batch size 是 32,输入是 256×256 的 RGB 图,那么当前批次输入张量占:

32 × 256 × 256 × 3 × 4 ≈ 25MB

25MB 对现在动辄 12GB、24GB 的显存来说似乎无足轻重。问题出在激活值。假设网络第一层输出 64 个特征通道,尺寸近似保持 256×256,那么这一层特征图占 32×64×256×256×4 ≈ 536MB。如果是 1920×1080 的高分辨率输入,第一层特征图就可能是好几个 GB。

所以显存优化的核心矛盾从来不是"输入图把显存占满了",而是"输入图经过网络后被放大了几十上百倍"。我把这部分单独拿出来讲,是因为这是我见过最多人算错的地方。很多人只盯着输入尺寸和 batch size 调参,却忽略了模型的宽度和深度同样决定峰值显存。

3. 数据加载链路里最隐蔽的显存杀器:整批搬运、重复拷贝、峰值叠加

3.1 一次性把全部图像塞进显存的"土办法"为什么必翻车

有一种非常常见的做法,尤其是刚开始接触图像训练的同学特别喜欢用:把所有训练图像一次性读进内存,再一次性转成浮点张量,然后一股脑搬进显存。我见过不少简化代码是这么写的:先把高清图全部 resize 到目标尺寸,合并成一个巨型数组,然后直接把它放到计算设备上。

这个做法在小型玩具数据集上当然能跑,因为总共也就几十 MB。但一旦数据集上到几千甚至几万张图,这个"一次性张量"的体积会变成几十 GB。CPU 内存可能顶得住,显存绝对顶不住。更要命的是,这种做法完全没有分批次的概念,显存从一开始就处于接近满载的状态,后面网络参数和激活值根本没地方放,不报显存不足才奇怪。

有人可能会说,那我把它拆开分批放?这就是进步的方向。但拆开之后还有一个大坑:很多人会在 GPU 上做全部预处理,包括读取、缩放、归一化、增强。每一张原始图、缩放后的图、增强后的图都会同时排队在显存里。虽然操作重复多了,但显存峰值也极其夸张。

3.2 正确的流水线:CPU 端预处理、并行取数、预取缓冲

成熟的做法是构建一个三级流水线,让数据一刻不停地在 CPU 和 GPU 之间流动,而不是一次堆积。

第一级是磁盘读取和基础解码。这一步放在 CPU 侧完成。你只需要把图片路径传给加载队列,由几个独立的读取线程负责读取文件、解码成数组,完全不用 GPU 参与。

第二级是几何变换和数值预处理。调整尺寸、随机裁剪、翻转、归一化这些操作尽可能放在 CPU 上完成,这样显存里就不会存各种各样的大型中间结果。CPU 端处理完的最终产物是已经被规范到目标尺寸的浮点张量,体积已经比原始图小得多。

第三级才是搬运到显存。这一步每次只搬一个 batch 的数据,搬过去的同时继续准备下一个 batch。通过双缓冲甚至多缓冲的方式,让取数和计算并行起来,显存只会保留正在使用的那个 batch,不会出现"全量堆积"。

实操中我还会开一个"预取缓冲":当前 batch 正在 GPU 上训练时,下一个 batch 已经在 CPU 侧完成全部预处理,甚至已经有一部分连续搬运到显存里。这种方式把等待时间藏进了计算时间里,训练速度没有明显下降,显存峰值却稳定在可控范围内。

4. 控制输入尺寸和精度:在数据层面给显存"做手术"

4.1 分辨率不是越大越好:中心裁剪和随机裁剪的取舍

图像分辨率决定了一切中间特征图的空间尺寸。同样的 batch size,输入从 512×512 降到 224×224,整体浮点计算量大约降到原来的五分之一,显存占用也会同步下降不少。

很多任务并不需要全尺寸分辨率。以目标检测为例,如果原始图像是 1024×1024,你完全可以做随机裁剪,得到一个 512×512 的区域作为训练样本。这样既做了数据增强,增加了样本多样性,又显著降低了显存压力。不要在预处理阶段把原始图无脑 resize 成一个高分辨率版本,那会用大量显存为无效区域买单。

另一种常用手段是"多尺度训练",但这里要控制好上下限。如果数据集本身尺寸差异大,我会设定一个最大边,超过这个边就按比例缩到目标范围;小于目标范围再考虑随机 padding 或裁剪。核心原则是:能让网络看到的信息尽量保留,但绝不把整幅图像未压缩内容全部搬进显存。

4.2 混合精度:显存减半,但不等于可以无脑用

混合精度是图像训练里最直接的显存减负手段。把输入张量和大部分中间激活值从 float32 换成 float16 或 bfloat16,数据存储体积直接减半。以前一个 536MB 的特征图,切到半精度后变成 268MB。这对于动不动就爆显存的任务来说,几乎是立刻见效。

但必须明确两个边界。第一个是数值范围。float16 的指数位有限,遇到特别大或特别小的梯度值时容易溢出。所以混合精度的标准做法不是"全部换掉",而是"主权重保持 float32,前向和激活用半精度,梯度用半精度计算后转回 float32 再更新"。第二个是显存释放速度。混合精度减少的是数据体积,但如果你代码里仍然同时保存了 float32 副本和 float16 副本,显存不仅不会减少,反而可能会增加。真正有效的做法是让输入数据在进入网络前完成精度转换,并且不要在后续逻辑里随意保留冗余精度。

在我的实际操作里,混合精度对大多数视觉任务都能带来接近两倍的显存减负,同时训练收敛速度通常不会变慢。但它不是"免死金牌",批量大小依然需要根据任务做合理设置,不能把 batch 翻四倍。

5. 批量大小想保住,显存却不允许:梯度累积和激活检查点

5.1 梯度累积:用小步长模拟大批次的另类解法

有时候模型精度要求 batch size 必须比较大,比如 64、128 甚至更大。但显存只允许一次放进 16 或者 32,该怎么办?梯度累积是比较成熟的方案。

思路并不复杂:把一个大批次拆成多个小批次,依次完成前向和反向;每个小批次并不立刻更新参数,而是把梯度累积起来,等累积够一定数量后,再统一做一次参数更新。从优化器角度看,效果等同于用了更大 batch;从显存角度看,每次只处理一个小批次,峰值显存没有暴涨。

这里有几个值得记录的细节。第一,梯度累积不等于完全没有副作用:如果你的模型用了批归一化(Batch Normalization),小批次内的统计量和大批次还是不同的,训练效果会有细微差异。第二,累积步数不要设置得过于极端。比如本来要 batch 128,显存只允许 8,累积 16 步,这种极端拆分很容易让归一化层变得不稳定。第三,如果你开了混合精度,梯度累积时需要注意梯度缩放,否则小梯度在累积中可能被舍入误差吃掉。

我实际用的做法是,先计算目标 batch size 和显存可容纳 batch size 的比值,再设置累积步数;同时把学习率微调一下,因为有效批次变大之后,更新频率下降,学习率可以适当提高。

5.2 激活检查点:省掉中间变量,用时间换空间

如果梯度累积也救不了你,还有一个杀手锏:激活检查点,也有人叫它重计算机制。原理非常直接:正常情况下,训练需要把每个中间特征图都保存下来,这样反向传播时才能算梯度。激活检查点只保存部分关键特征图,其余中间特征在反向传播需要时重新计算一遍。

这相当于把"保存空间"改变成了"消耗时间"。显存占用可以从原本的每层都存,压缩到每隔若干层存一份。代价是反向阶段需要多一次前向计算,训练总时间会变长。对于高分辨率图像、大模型场景,这种时间换空间的策略经常是最后能跑通训练的保证。

实际使用时有几个原则。第一,检查点不是层层都省,而是每隔一定层数保留一份完整特征图,过于激进省掉所有中间层会让反向重计算量暴涨。第二,重计算部分的激活最好也用半精度存储,进一步压体积。第三,检查点机制和梯度累积可以叠加使用,二者并不冲突,配合得当可以解决绝大多数显存不足的问题。

6. 我实际踩过的显存坑和现在还保留的检查习惯

6.1 "明明显存显示够用,却还是溢出"是怎么回事

这是我踩过最诡异的一个坑。监控工具显示显存占用只有一半,但训练一启动就报错。后来才发现,原因通常有三个。

第一个原因是"显存碎片化"。GPU 显存分配和硬盘类似,反复申请和释放大块显存后,会产生大量无法利用的小碎片。即使总剩余空间够大,也未必能找出一整块连续空间给大张量。出现这种情况,最简单的缓解办法是减少动态分配次数,把重复用到的张量尽量放到底层缓冲池里复用。

第二个原因是"临时缓冲叠加"。比如你在 GPU 上进行图像归一化,代码里同时存在原始 uint8 张量、中间 float32 张量、归一化结果张量。三者同时在场,峰值自然比单个张量大好几倍。我后来习惯用"峰值计数器":在代码关键节点输出当前显存峰值,对照代码逻辑逐段定位是哪些临时变量导致的暴涨。

第三个原因是"验证阶段也占显存"。训练过程中每跑完一个 epoch 就要做验证,但很多人忘记在验证阶段停止梯度计算。没有关闭梯度的情况下,验证过程也会保存大量中间激活值,和训练过程的显存需求叠加,直接突破上限。验证阶段一定要显式关闭梯度计算,看着不起眼,但往往能省出一大块空间。

6.2 我现在保留的一套显存预检习惯

踩过不少坑之后,我形成了一套相对固定的显存预检流程,现在分享在这里。

第一步,写脚本模拟单张图的前向过程,打印每一层特征图的规模,估算一个 batch 的峰值。这一步能提前判断当前网络结构是否适合目标分辨率,而不是等到训练中途才发现爆显存。

第二步,用最小 batch size 跑一轮完整训练,观察显存峰值的波动。如果峰值和理论计算值差距太大,优先检查临时变量、是否关闭了梯度、是否有隐藏的数据副本。

第三步,按需求逐步增加 batch size,每次增加后观察显存峰值变化。如果增长接近线性,说明数据加载链路基本正常;如果出现非线性的突然跳变,大概率是某些中间变量被批量放大。

这三步做下来,大部分显存问题都能在正式训练之前被暴露。即使临时发现问题,也能快速判断是该降分辨率、减 batch、开混合精度,还是该采用梯度累积或激活检查点,而不是继续盲目换卡。

图像数据和显存的关系,说到底是一场"空间管理"的博弈。模型本身能吃掉多少空间,数据预处理又会额外叠加多少空间,中间的每一环都有优化余地。只要你把账算清楚、把流水线理顺、把精度和分辨率合理控制住,绝大多数显存危机其实都可以在软件层面解决,未必非要花大价钱升级硬件。这是我做了大量图像训练实验之后最深的体会。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进