极市导读
随着深度学习快速发展,同时伴随着模型参数的爆炸式增长,对显卡的显存容量提出了越来越高的要求,如何在单卡小容量显卡上面训练模型是一直以来大家关心的问题。本文结合 MMCV 开源库对一些常用的节省显存策略进行了简要分析。 >>加入极市CV技术交流群,走在计算机视觉的最前沿
本文涉及到的 PyTorch 节省显存的策略包括:
混合精度训练全称为 Automatic Mixed Precision,简称为 AMP,也就是我们常说的 FP16。在前系列解读中已经详细分析了 AMP 原理、源码实现以及 MMCV 中如何一行代码使用 AMP,具体链接见:
OpenMMLab:PyTorch 源码解读之 torch.cuda.amp: 自动混合精度详解
https://zhuanlan.zhihu.com/p/348554267
OpenMMLab:OpenMMLab 中混合精度训练 AMP 的正确打开方式
https://zhuanlan.zhihu.com/p/375224982
由于前面两篇文章已经分析的非常详细了,本文只简要描述原理和具体说明用法。
考虑到训练过程中梯度幅值大部分是非常小的,故训练默认是 FP32 格式,如果能直接以 FP16 格式精度进行训练,理论上可以减少一半的内存,达到加速训练和采用更大 batch size 的目的,但是直接以 FP16 训练会出现溢出问题,导致 NAN 或者参数更新失败问题,而 AMP 的出现就是为了解决这个问题,其核心思想是 混合精度训练+动态损失放大:
在 MMCV 中使用 AMP 分成两种情况:
以 MMDectection 为例,用法非常简单,只需要在配置中设置:
fp16 = dict(loss_scale=512.) # 表示静态 scale
# 表示动态 scale
fp16 = dict(loss_scale='dynamic')
# 通过字典形式灵活开启动态 scale
fp16 = dict(loss_scale=dict(init_scale=512.,mode='dynamic'))
三种不同设置在大部分模型上性能都非常接近,如果不想设置 loss_scale,则可以简单的采用 loss_scale='dynamic'
直接调用 MMCV 中的 AMP,这通常意味着用户可能在其他库或者自己写的代码库中支持 AMP 功能。需要特别强调的是 PyTorch 官方仅仅在 1.6 版本及其之后版本中开始支持 AMP,而 MMCV 中的 AMP 支持 1.3 及其之后版本。如果你想在 1.3 或者 1.5 中使用 AMP,那么使用 MMCV 是个非常不错的选择。
使用 MMCV 的 AMP 功能,只需要遵循以下几个步骤即可:
# 1 作用到 forward 函数中
class ExampleModule(nn.Module):
@auto_fp16()
def forward(self, x, y):
return x, y
# 2 如果开启 AMP,则需要加入开启标志
model.fp16_enabled = True
# 3 配置 Fp16OptimizerHook
optimizer_config = Fp16OptimizerHook(
**cfg.optimizer_config, **fp16_cfg, distributed=distributed)
# 4 传递给 runner
runner.register_training_hooks(cfg.lr_config, optimizer_config,
cfg.checkpoint_config, cfg.log_config,
cfg.get('momentum_config', None))
# 5 可选
class ExampleModule(nn.Module):
@auto_fp16()
def forward(self, x, y):
features=self._forward(x, y)
loss=self._loss(features,labels)
return loss
def _forward(self, x, y):
pass
@force_fp32(apply_to=('features',))
def _loss(features,labels) :
pass
注意 force_fp32 要生效,依然需要 fp16_enabled 为 True 才生效。
大 Batch 训练通常也称为梯度累加策略,通常 PyTorch 一次迭代训练流程为:
y_pred = model(xx)
loss = loss_fn(y_pred, y)
loss.backward()
optimizer.step()
optimizer.zero_grad()
而梯度累加策略下常见的一次迭代训练流程为:
y_pred = model(xx)
loss = loss_fn(y_pred, y)
loss = loss / cumulative_iters
loss.backward()
if current_iter % cumulative_iters==0
optimizer.step()
optimizer.zero_grad()
其核心思想就是对前几次梯度进行累加,然后再统一进行参数更新,从而变相实现大 batch size 功能。需要注意的是如果模型中包括 BN 等考虑 batch 信息的层,那么性能可能会有轻微的差距。
细节可以参考:
https://github.com/open-mmlab/mmcv/pull/1221
在 MMCV 中已经实现了梯度累加功能,其核心代码位于 mmcv/runner/hooks/optimizer.py
GradientCumulativeOptimizerHook 中,和 AMP 实现一样是采用 Hook 实现的。使用方法和 AMP 类似,只需要将第一节中的 Fp16OptimizerHook 替换为 GradientCumulativeOptimizerHook 或者 GradientCumulativeFp16OptimizerHook 即可。其核心实现如下所示:
@HOOKS.register_module()
class GradientCumulativeOptimizerHook(OptimizerHook):
def __init__(self, cumulative_iters=1, **kwargs):
self.cumulative_iters = cumulative_iters
self.divisible_iters = 0 # 剩余的可以被 cumulative_iters 整除的训练迭代次数
self.remainder_iters = 0 # 剩余累加次数
self.initialized = False
def after_train_iter(self, runner):
# 只需要运行一次即可
if not self.initialized:
self._init(runner)
if runner.iter < self.divisible_iters:
loss_factor = self.cumulative_iters
else:
loss_factor = self.remainder_iters
loss = runner.outputs['loss']
loss = loss / loss_factor
loss.backward()
if (self.every_n_iters(runner, self.cumulative_iters)
or self.is_last_iter(runner)):
runner.optimizer.step()
runner.optimizer.zero_grad()
def _init(self, runner):
residual_iters = runner.max_iters - runner.iter
self.divisible_iters = (
residual_iters // self.cumulative_iters * self.cumulative_iters)
self.remainder_iters = residual_iters - self.divisible_iters
self.initialized = True
需要明白 divisible_iters 和 remainder_iters 的含义:
此时在开始训练时 iter=0,一共迭代 max_iters=102 次,梯度累加次数是 4,由于 102 无法被 4 整除,也就是最后的 102-(102 // 4)*4=2 个迭代是额外需要考虑的,在最后 2 个训练迭代中 loss_factor 不能除以 4,而是 2,这样才是最合理的做法。其中 remainder_iters=2,divisible_iters=100,residual_iters=102。
假设在梯度累加的中途退出,然后进行 resume 训练,此时 iter 不是 0,由于优化器对象需要重新初始化,为了保证剩余的不能被累加次数的训练迭代次数能够正常计算,需要重新计算 residual_iters。
梯度检查点是一种用训练时间换取显存的办法,其核心原理是在反向传播时重新计算神经网络的中间激活值而不用在前向时存储,torch.utils.checkpoint 包中已经实现了对应功能。简要实现过程是:在前向阶段传递到 checkpoint 中的 forward 函数会以 _torch.no_grad_
模式运行,并且仅仅保存输入参数和 forward 函数,在反向阶段重新计算其 forward 输出值。
具体用法非常简单,以 ResNet 的 BasicBlock 为例:
def forward(self, x):
def _inner_forward(x):
identity = x
out = self.conv1(x)
out = self.norm1(out)
out = self.relu(out)
out = self.conv2(out)
out = self.norm2(out)
if self.downsample is not None:
identity = self.downsample(x)
out += identity
return out
# x.requires_grad 这个判断很有必要
if self.with_cp and x.requires_grad:
out = cp.checkpoint(_inner_forward, x)
else:
out = _inner_forward(x)
out = self.relu(out)
return out
self.with_cp 为 True,表示要开启梯度检查点功能。
checkpoint 在用法上面需要注意以下几点:
None of the inputs have requires_grad=True. Gradients will be Non
警告
其核心实现如下所示:
class CheckpointFunction(torch.autograd.Function):
@staticmethod
def forward(ctx, run_function, preserve_rng_state, *args):
# 检查输入参数是否需要梯度
check_backward_validity(args)
# 保存必要的状态
ctx.run_function = run_function
ctx.save_for_backward(*args)
with torch.no_grad():
# 以 no_grad 模型运行一遍
outputs = run_function(*args)
return outputs
@staticmethod
def backward(ctx, *args):
# 读取输入参数
inputs = ctx.saved_tensors
# Stash the surrounding rng state, and mimic the state that was
# present at this time during forward. Restore the surrounding state
# when we're done.
rng_devices = []
with torch.random.fork_rng(devices=rng_devices, enabled=ctx.preserve_rng_state):
# detach 掉当前不需要考虑的节点
detached_inputs = detach_variable(inputs)
# 重新运行一遍
with torch.enable_grad():
outputs = ctx.run_function(*detached_inputs)
if isinstance(outputs, torch.Tensor):
outputs = (outputs,)
# 计算该子图梯度
torch.autograd.backward(outputs, args)
grads = tuple(inp.grad if isinstance(inp, torch.Tensor) else inp
for inp in detached_inputs)
return (None, None) + grads
为了验证上述策略是否真的能够省显存,采用 mmdetection 库进行验证,基本环境如下:
显卡: GeForce GTX 1660
PyTorch: 1.7.1
CUDA Runtime 10.1
MMCV: 1.3.16
MMDetection: 2.17.0
在 base 配置基础上新增如下配置即可:
fp16 = dict(loss_scale=512.)
在 base 配置基础上替换 optimizer_config 为如下:
# 累加2次
optimizer_config = dict(type='GradientCumulativeOptimizerHook', cumulative_iters=2)
在 base 配置基础上在 backbone 部分开启 with_cp 标志即可:
model = dict(backbone=dict(with_cp=True),
bbox_head=dict(num_classes=20))
每个实验总共迭代 1300 次,统计占用显存、训练总时长。
配置 | 显存占用(MB) | 训练时长 |
---|---|---|
base | 2900 | 7 分 45 秒 |
混合精度 AMP | 2243 | 36 分 |
梯度累加 | 3177 | 7 分 32 秒 |
梯度检查点 | 2590 | 8 分 37 秒 |
从上面简单实验可以发现,AMP、梯度累加和梯度检查点确实可以在不同程度减少显存,而且这三个策略是正交的,可以同时使用。
本文简要描述了三个在 MMCV 中集成且可以通过配置一行开启的节省显存策略,这三个策略比较常用也比较成熟。随着模型规模的不断增长,也出现了很多新的策略,例如模型参数压缩、动态显存优化、使用 CPU 内存暂存策略以及分布式情况下 PyTorch 1.10 最新支持的 ZeroRedundancyOptimizer 等等。
快速链接直达 MMCV 算法库,欢迎大家 Star:
https://github.com/open-mmlab/mmcv
如果觉得有用,就请分享到朋友圈吧!
公众号后台回复“CVPR21检测”获取CVPR2021目标检测论文下载~
# CV技术社群邀请函 #
备注:姓名-学校/公司-研究方向-城市(如:小极-北大-目标检测-深圳)
即可申请加入极市目标检测/图像分割/工业检测/人脸/医学影像/3D/SLAM/自动驾驶/超分辨率/姿态估计/ReID/GAN/图像增强/OCR/视频理解等技术交流群
每月大咖直播分享、真实项目需求对接、求职内推、算法竞赛、干货资讯汇总、与 10000+来自港科大、北大、清华、中科院、CMU、腾讯、百度等名校名企视觉开发者互动交流~