我们平时用 PyTorch 训练模型,有时会碰到显存不足的情况。除了找对应的解决办法,比如累加梯度、使用自动混合精度,我们还应该弄清楚,训练时显存究竟在哪些环节被大量占用。主要有下面这几个环节:
- CUDA 运行内存
- 模型的固定参数
- 模型的前向计算
- 模型的反向计算
- 优化方法统计量
1. 运行内存
CUDA(Compute Unified Device Architecture,计算统一设备架构)是显卡厂商 NVIDIA 推出的运算平台。通过它,我们才能调用 GPU 的处理能力,大幅提升计算性能。
对我们来说,CUDA 本质上是一套跑在 GPU 硬件上的软件平台,我们的计算任务要建立在这个平台之上,才能用上 GPU 的算力。既然它本身也是一套软件,运行起来自然会占一部分显存,占多少得看 CUDA 的版本,有的占 600M 左右,有的会占到 1G 以上。
在说 CUDA 占多少之前,我们先了解下 PyTorch 的显存使用机制。GPU 显存就是我们手头全部可用的资源。写过 C/C++ 的同学都知道,频繁地申请和释放资源,比如 C 的 malloc/free、C++ 的 new/delete,会明显拖慢系统性能。为了减少这类操作,就有了资源池的概念。它的思路是,预先从全部可用资源里申请一大块,程序需要资源时就从池子里拿,省掉复杂耗时的系统调用,用完再放回池子里,池子不够了再向系统要。这样一来,资源使用这个环节的效率就高了。
PyTorch 给张量分配显存也是这套做法,先申请一大块内存,张量要用时从内存池里取,不用了就归还。可以想见,如果没有这种资源缓存机制,运行效率会非常慢。
接下来,我们用一段代码来验证 CUDA 平台一运行就会占用部分显存。先装一个库:
pip install pynvml
import torch
import pynvml
# 初始化 pynvml 库
pynvml.nvmlInit()
convert = lambda x: int(x / 1024 / 1024)
# 获得显卡设备对象
device_object = pynvml.nvmlDeviceGetHandleByIndex(0)
# 查看显存资源
def show_usage():
# 获得显存信息
device_memory = pynvml.nvmlDeviceGetMemoryInfo(device_object)
# 全部可用显存
total = convert(device_memory.total)
# 已经使用显存
used = convert(device_memory.used)
# 剩余可用显存
free = convert(device_memory.free)
print('总共:', total, '使用:', used, '剩余:', free)
# 1. CUDA 初始化会占用部分显存
def test01():
show_usage()
# 如果张量创建在 CPU 是不会占用显存,并且也不会初始化 CUDA
torch.tensor(0.0, device='cpu')
show_usage()
torch.tensor(0.0, device='cuda')
# 清空缓存
torch.cuda.empty_cache()
show_usage()
if __name__ == '__main__':
test01()
程序输出结果:
总共: 5932 使用: 0 剩余: 5932 总共: 5932 使用: 0 剩余: 5932 总共: 5932 使用: 586 剩余: 5346
上面这段代码如果不清空缓存,输出会是 588 而不是 586,即 588 等于 586 加上 PyTorch 自己缓存的部分。另外,我们创建的 cuda 张量没有被任何变量引用,创建完就会被自动回收,这时候清空缓存才得到 586,否则仍是 588。原因在于,每次向 cuda 设备创建张量,分配的显存都是 512 的倍数。
import torch
def test02():
# 0
print(torch.cuda.memory_allocated())
a = torch.tensor(0.0, device='cuda')
# 512
print(torch.cuda.memory_allocated())
# 1024
b = torch.tensor(0.0, device='cuda')
print(torch.cuda.memory_allocated())
# 1536
c = torch.tensor(0.0, device='cuda')
print(torch.cuda.memory_allocated())
if __name__ == '__main__':
test02()
程序输出结果:
0 512 1024 1536
torch.cuda.memory_allocated 可以获得目前分配的内存数量。
2. 固定参数
这一部分就比较好理解了,加载模型本质上就是加载模型参数,所以参数本身会占一部分显存。默认情况下,PyTorch 里的参数用的是 float32 类型。我们看下面的代码:
import torch
import torch.nn as nn
def test01():
print(torch.cuda.memory_allocated())
linear = nn.Linear(in_features=1, out_features=1, bias=False).cuda()
print(torch.cuda.memory_allocated())
if __name__ == '__main__':
test01()
程序输出结果:
0 512
前面创建的线性层不带偏置,只有一个参数,按说只该占 4 字节,为什么这里显示 512 字节呢?原因是 PyTorch 分配显存时按 512 的倍数分配,也就是按块分。这样不怕浪费吗?这也是出于效率考虑,按块分配便于内存管理,尽量避免产生内存碎片。
import torch
import torch.nn as nn
def test02():
print(torch.cuda.memory_allocated())
linear = nn.Linear(in_features=128, out_features=1, bias=False).cuda()
print(torch.cuda.memory_allocated())
if __name__ == '__main__':
test02()
输出仍然是 512 字节。如果把 in_features 从 128 换成 129,就会分配 1024 字节显存。注意一个参数的大小是 4 字节。
思考:下面的模型占用多大显存?
import torch
import torch.nn as nn
class Net(nn.Module):
def __init__(self):
super(Net, self).__init__()
self.linear1 = nn.Linear(1, 1, bias=False)
self.linear2 = nn.Linear(1, 1, bias=False)
def forward(self, inputs):
inputs = self.linear1(inputs)
inputs = self.linear2(inputs)
return inputs
def test03():
print(torch.cuda.memory_allocated())
model = Net().cuda()
print(torch.cuda.memory_allocated())
if __name__ == '__main__':
test03()
程序输出结果是:
0 1024
3. 前向反向计算
网络做前向计算时会把中间结果保存下来。为什么要保存?因为反向求梯度时要用到这些中间结果。而反向算出来的梯度值本身也需要显存来存,所以前向和反向计算都会占显存。
另外,输入的 batch_size 越大,占用的显存也越大。
import torch
import torch.nn as nn
def test():
print(torch.cuda.memory_allocated())
model = nn.Linear(1, 1).cuda()
print(torch.cuda.memory_allocated())
# 前向计算
# 5120 = 1024 + 4096(1024 个输入大小)
inputs = torch.randn(size=(1024, 1)).cuda()
print(torch.cuda.memory_allocated())
# 正向计算需要缓存中间计算结果(outputs)
# 注意:用变量承接相当于缓存了中间结果
# 9216 = 5120 + 4096(1024 个缓存结果)
outputs = model(inputs)
print(torch.cuda.memory_allocated())
# 计算损失
# 9728 = 9216 + 512 缓存损失结果
loss = torch.mean(outputs)
print(torch.cuda.memory_allocated())
# 反向计算
# 10752 = 9728 + 512 保存梯度值
loss.backward()
print(torch.cuda.memory_allocated())
if __name__ == '__main__':
test()
程序执行结果:
0 1024 5120 9216 9728 10752
反向传播之后,可以释放 outputs、loss 这些变量。
4. 优化统计量
不同的优化器内部会维护一些统计量。比如 SGD 会记录每个参数的历史动量梯度,Adam 会记录每个参数的一阶、二阶梯矩估计。这些在训练过程中同样要占显存,而且参数量越大,优化器占的显存就越大。
import torch
import torch.nn as nn
import torch.optim as optim
def test():
# 0
print(torch.cuda.memory_allocated())
# 512
model = nn.Linear(1, 1, bias=False).cuda()
print(torch.cuda.memory_allocated())
# 1024
inputs = torch.randn(size=(1, 1)).cuda()
print(torch.cuda.memory_allocated())
# 1536
outputs = model(inputs)
print(torch.cuda.memory_allocated())
# 2048
loss = torch.mean(outputs)
print(torch.cuda.memory_allocated())
# 2560
loss.backward()
print(torch.cuda.memory_allocated())
# 3584
optimizer = optim.Adam(model.parameters(), lr=1e-3)
optimizer.step()
print(torch.cuda.memory_allocated())
if __name__ == '__main__':
test()
程序执行结果:
0 512 1024 1536 2048 2560 3584
SGD 一旦设置了 momentum,内部就会为每个参数记录一份历史梯度,Adam 要记录的东西更多,所以 Adam 的显存占用通常比 SGD 更大。




冀公网安备13050302001966号