注意
转至页面底部 下载完整示例代码。
如何通过将优化器步骤融合到反向传播中来节省内存#
创建日期:2023 年 10 月 2 日 | 最后更新:2024 年 1 月 16 日 | 最后验证:2024 年 11 月 5 日
你好!本教程旨在展示一种通过减少梯度所占内存来降低训练循环内存占用量的方法。假设你有一个模型,并且正在寻找优化内存的方法,以避免 Out of Memory (OOM) 错误,或者仅仅是想从 GPU 中榨取更多性能。好吧,你可能很幸运(如果梯度占用了你一部分内存,且你不需要进行梯度累积的话)。我们将探索以下内容:
训练或微调循环中占用内存的内容,
如何捕获并可视化内存快照以确定瓶颈,
新的
Tensor.register_post_accumulate_grad_hook(hook)API,最后,如何通过 10 行代码整合一切以实现内存节省。
要运行本教程,你需要:
PyTorch 2.1.0 或更高版本,并安装
torchvision如果你想在本地进行内存可视化,需要 1 块 CUDA GPU。否则,此技术在任何设备上都能带来类似的收益。
让我们从导入所需的模块和模型开始。我们将使用 torchvision 中的视觉 Transformer 模型,但你可以随意替换为你自己的模型。我们还将使用 torch.optim.Adam 作为优化器,当然,也可以随意替换为你自己的优化器。
import torch
from torchvision import models
from pickle import dump
model = models.vit_l_16(weights='DEFAULT').cuda()
optimizer = torch.optim.Adam(model.parameters())
Downloading: "https://download.pytorch.org/models/vit_l_16-852ce7e3.pth" to /var/lib/ci-user/.cache/torch/hub/checkpoints/vit_l_16-852ce7e3.pth
0%| | 0.00/1.13G [00:00<?, ?B/s]
3%|▎ | 31.0M/1.13G [00:00<00:03, 324MB/s]
6%|▌ | 70.8M/1.13G [00:00<00:03, 379MB/s]
10%|▉ | 113M/1.13G [00:00<00:02, 407MB/s]
13%|█▎ | 155M/1.13G [00:00<00:02, 421MB/s]
17%|█▋ | 197M/1.13G [00:00<00:02, 429MB/s]
21%|██ | 240M/1.13G [00:00<00:02, 434MB/s]
24%|██▍ | 282M/1.13G [00:00<00:02, 437MB/s]
28%|██▊ | 324M/1.13G [00:00<00:02, 438MB/s]
31%|███▏ | 366M/1.13G [00:00<00:02, 402MB/s]
35%|███▍ | 404M/1.13G [00:01<00:02, 393MB/s]
38%|███▊ | 442M/1.13G [00:01<00:01, 393MB/s]
42%|████▏ | 485M/1.13G [00:01<00:01, 408MB/s]
45%|████▌ | 524M/1.13G [00:01<00:01, 386MB/s]
48%|████▊ | 561M/1.13G [00:01<00:01, 334MB/s]
52%|█████▏ | 600M/1.13G [00:01<00:01, 351MB/s]
55%|█████▍ | 635M/1.13G [00:01<00:01, 356MB/s]
58%|█████▊ | 675M/1.13G [00:01<00:01, 375MB/s]
62%|██████▏ | 718M/1.13G [00:01<00:01, 394MB/s]
65%|██████▌ | 759M/1.13G [00:02<00:01, 405MB/s]
69%|██████▉ | 798M/1.13G [00:02<00:00, 396MB/s]
72%|███████▏ | 838M/1.13G [00:02<00:00, 401MB/s]
75%|███████▌ | 876M/1.13G [00:02<00:00, 388MB/s]
79%|███████▊ | 914M/1.13G [00:02<00:00, 373MB/s]
82%|████████▏ | 956M/1.13G [00:02<00:00, 393MB/s]
86%|████████▌ | 999M/1.13G [00:02<00:00, 409MB/s]
90%|████████▉ | 1.02G/1.13G [00:02<00:00, 419MB/s]
93%|█████████▎| 1.06G/1.13G [00:02<00:00, 426MB/s]
97%|█████████▋| 1.10G/1.13G [00:02<00:00, 431MB/s]
100%|██████████| 1.13G/1.13G [00:03<00:00, 401MB/s]
现在让我们定义典型的训练循环。训练时应该使用真实图像,但为了本教程的目的,我们将传入虚假输入,而不必担心加载任何实际数据。
IMAGE_SIZE = 224
def train(model, optimizer):
# create our fake image input: tensor shape is batch_size, channels, height, width
fake_image = torch.rand(1, 3, IMAGE_SIZE, IMAGE_SIZE).cuda()
# call our forward and backward
loss = model.forward(fake_image)
loss.sum().backward()
# optimizer update
optimizer.step()
optimizer.zero_grad()
训练期间的内存使用情况#
我们即将查看一些内存快照,因此我们应该做好正确分析它们的准备。通常,训练内存由以下部分组成:
模型参数(大小为 P)
为反向传播保存的激活值(大小为 A)
梯度,其大小与模型参数相同,即大小 G = P。
优化器状态,其大小与参数大小成正比。在本例中,Adam 的状态需要 2 倍的模型参数,即大小 O = 2P。
中间张量,它们在计算过程中分配。我们目前无需担心它们,因为它们通常很小且瞬时存在。
捕获并可视化内存快照#
让我们获取一个内存快照!当你的代码运行时,考虑一下你所预期的 CUDA 内存时间轴是什么样的。
# tell CUDA to start recording memory allocations
torch.cuda.memory._record_memory_history(enabled='all')
# train 3 steps
for _ in range(3):
train(model, optimizer)
# save a snapshot of the memory allocations
s = torch.cuda.memory._snapshot()
with open(f"snapshot.pickle", "wb") as f:
dump(s, f)
# tell CUDA to stop recording memory allocations now
torch.cuda.memory._record_memory_history(enabled=None)
现在,通过拖放 snapshot.pickle 文件,在 CUDA 内存可视化工具 https://pytorch.ac.cn/memory_viz 中打开快照。内存时间轴是否符合你的预期?
模型参数在训练步骤之前已加载到内存中,因此我们一开始就会看到一块专门用于权重的内存。当我们开始前向传播时,内存会逐渐为激活值分配,或者为我们保存的张量分配,以便在反向传播中计算梯度。一旦我们开始反向传播,激活值会被逐渐释放,而梯度内存开始建立。
最后,当优化器开始工作时,其状态将被惰性初始化,因此我们应该看到优化器状态内存仅在第一个训练循环的优化器步骤中逐渐增加。在后续循环中,优化器内存将保持不变并进行原地更新。梯度内存随后会在每个训练循环结束调用 zero_grad 时相应地被释放。
这个训练循环中的内存瓶颈在哪里?换句话说,峰值内存出现在哪里?
峰值内存使用出现在优化器步骤期间!请注意,此时的内存由约 1.2GB 的参数、约 1.2GB 的梯度以及约 2.4GB (2*1.2GB) 的优化器状态组成。最后的约 1.2GB 来自 Adam 优化器所需的中间值内存,总计约 6GB 的峰值内存。从技术上讲,如果你设置 Adam(model.parameters(), foreach=False),可以消除对最后 1.2GB 优化器中间值的需求,但这会以牺牲运行时间为代价换取内存。如果关闭 foreach 运行时优化足以满足你的内存节省需求,那很好,但如果你好奇本教程如何帮你做得更好,请继续阅读!通过我们即将介绍的技术,我们将减少约 1.2GB 的梯度内存以及优化器中间值内存需求,从而降低峰值内存。现在,你认为新的峰值内存会是多少?答案将在下个快照中揭晓。
免责声明:此技术并非适用于所有人#
在我们太兴奋之前,必须考虑这种技术是否适用于你的用例。这不是万灵药!将优化器步骤融合到反向传播中的技术仅旨在减少梯度内存(作为副作用,也会减少优化器中间值内存)。因此,梯度占用的内存越大,内存节省的效果就越显著。在我们上面的示例中,梯度占了内存饼图的 20%,这相当可观!
如果你的情况并非如此,例如,如果你的权重本身已经很小(例如由于应用了 LoRa),那么梯度在你的训练循环中不会占用太多空间,带来的收益也就没那么令人兴奋。在这种情况下,你应该先尝试其他技术,如激活检查点 (Activations Checkpointing)、分布式训练、量化或减少批次大小。然后,当梯度再次成为瓶颈的一部分时,再回过头来阅读本教程!
还在看吗?太好了,让我们介绍 Tensor 上新的 register_post_accumulate_grad_hook(hook) API。
Tensor.register_post_accumulate_grad_hook(hook) API 与我们的技术#
我们的技术依赖于在 backward() 期间不需要保存梯度。相反,一旦梯度被累积,我们将立即对相应的参数应用优化器,并彻底丢弃该梯度!这消除了在优化器步骤之前需要保留一大块梯度缓冲区的需求。
那么,我们如何解锁更迫切地应用优化器的行为呢?在 2.1 版本中,我们添加了一个新的 API torch.Tensor.register_post_accumulate_grad_hook(),它允许我们在 Tensor 的 .grad 字段累积后为其添加钩子 (hook)。我们将把优化器步骤封装在这个钩子中。怎么做呢?
如何通过 10 行代码整合一切#
还记得我们最初的模型和优化器设置吗?我将它们注释掉放在下面,这样我们就不用浪费资源重新运行代码了。
model = models.vit_l_16(weights='DEFAULT').cuda()
optimizer = torch.optim.Adam(model.parameters())
# Instead of having just *one* optimizer, we will have a ``dict`` of optimizers
# for every parameter so we could reference them in our hook.
optimizer_dict = {p: torch.optim.Adam([p], foreach=False) for p in model.parameters()}
# Define our hook, which will call the optimizer ``step()`` and ``zero_grad()``
def optimizer_hook(parameter) -> None:
optimizer_dict[parameter].step()
optimizer_dict[parameter].zero_grad()
# Register the hook onto every parameter
for p in model.parameters():
p.register_post_accumulate_grad_hook(optimizer_hook)
# Now remember our previous ``train()`` function? Since the optimizer has been
# fused into the backward, we can remove the optimizer step and zero_grad calls.
def train(model):
# create our fake image input: tensor shape is batch_size, channels, height, width
fake_image = torch.rand(1, 3, IMAGE_SIZE, IMAGE_SIZE).cuda()
# call our forward and backward
loss = model.forward(fake_image)
loss.sum().backward()
# optimizer update --> no longer needed!
# optimizer.step()
# optimizer.zero_grad()
在我们的示例模型中,这大约需要修改 10 行代码,非常整洁。然而,对于真实模型来说,将优化器切换为优化器字典可能是一个相当具有侵入性的更改,特别是对于那些使用 ``LRScheduler``s 或在训练周期中操作优化器配置的人来说。使用这些更改来完善此 API 将会更复杂,并且很可能需要将更多配置移至全局状态,但这并非不可能。话虽如此,PyTorch 的下一步是使该 API 更易于与 LRSchedulers 以及你已经习惯的其他功能适配。
但我还是要继续说服你这项技术是值得的。我们将咨询我们的朋友:内存快照。
# delete optimizer memory from before to get a clean slate for the next
# memory snapshot
del optimizer
# tell CUDA to start recording memory allocations
torch.cuda.memory._record_memory_history(enabled='all')
# train 3 steps. note that we no longer pass the optimizer into train()
for _ in range(3):
train(model)
# save a snapshot of the memory allocations
s = torch.cuda.memory._snapshot()
with open(f"snapshot-opt-in-bwd.pickle", "wb") as f:
dump(s, f)
# tell CUDA to stop recording memory allocations now
torch.cuda.memory._record_memory_history(enabled=None)
是的,花点时间将你的快照拖入 CUDA 内存可视化工具中。
- 几个主要观察结果:
不再有独立的优化器步骤了!没错……我们已经将其融合到了反向传播中。
同样,反向传播的时间变长了,并且有更多用于中间值的随机分配。这是预料之中的,因为优化器步骤需要中间值。
最重要的是!峰值内存降低了!现在大约是 4GB(我希望这与你早期的预期非常接近)。
请注意,与之前相比,不再有为梯度分配的大块内存,这节省了约 1.2GB 的内存。相反,我们通过尽可能提前进行优化器步骤,在每个梯度计算后非常快地将其释放。太棒了!顺便提一下,另外约 1.2GB 的内存节省来自于将优化器拆分为每个参数对应的优化器,因此中间值按比例缩小了。这个细节比梯度内存节省“次要”一些,因为即使不使用此技术,仅通过设置 foreach=False 也可以获得优化器中间值的节省。
你可能会有疑问:如果我们节省了 2.4GB 内存,为什么峰值内存不是 6GB - 2.4GB = 3.6GB?好吧,峰值位置移动了!现在峰值出现在反向传播步骤的初期,此时我们内存中仍有激活值;而之前,峰值出现在激活值已被释放的优化器步骤中。因此,4.0GB - 3.6GB 之间约 0.4GB 的差异是由于激活值内存造成的。由此可以想象,该技术可以与激活检查点技术结合使用,以获得更多的内存收益。
结论#
在本教程中,我们学习了一种通过新的 Tensor.register_post_accumulate_grad_hook() API 将优化器融合到反向传播步骤中的内存节省技术,以及何时应用该技术(当梯度内存显著时)。在此过程中,我们还学习了内存快照,它们在内存优化中非常有用。
脚本总运行时间:(0 分钟 9.173 秒)