Pytorch:CUDA Semantics(语义)

CUDA Semantics

文章大部分内容来自PyTorch-CUDA Semantics,如若侵权请联系本人进行删除,本文仅用作个人学习之用,不做他用,转载请注明出处。

基本介绍

pytorch提供torch.cuda来创建和运行CUDA操作。torch.cuda会一直跟踪当前选中的GPU,所有分配的CUDA tensors都会默认在该GPU上创建分配内存空间。但是,仍然可以通过torch.cuda.device来改变选中的GPU(即去选择另外一个GPU)。
一旦一个tensor被分配了空间,则可以操作这个tensor,而不需要管选中了哪个GPU(根据我的理解是:这个Tensor创建的时候已经指定了在哪个GPU上创建,所以之后并不需要再关心)。在这个tensor上的相关计算后的结果仍会保存在该tensor所在的GPU的存储空间中。
默认情况下,跨GPU的运算操作是不被允许的,除了copy_()to()cuda()等涉及存储拷贝的函数。除非启用了点对点存储访问(peer-to-peer memory access),否则任何尝试在不同的GPU上执行相关计算的操作都会引发错误。
代码示例:

cuda = torch.device('cuda') # 默认的CUDA设备
cuda0 = torch.device('cuda:0')
cuda2 = torch.device('cuda:2') # GPU2

x = torch.tensor([1.,2.],device=cuda0) # 在cuda0上创建一个tensor x
y = torch.tensor([1.,2.]).cuda()

with torch.cuda.device(1):
	a = torch.tensor([1.,2.],device=cuda) # 在GPU1上分配一个tensor
	b = torch.tensor([1.,2.]).cuda() # 将tensor从CPU传输到GPU1
	b2 = torch.tensor([1.,2.]).to(device=cuda) # 将tensor从CPU传输到GPU1
	
	c = a + b
	z = x + y

	# 在GPU2上做tensor相关的操作
	d = torch.randn(2,device=cuda2)
	e = torch.randn(2).to(cuda2)
	f = torch.randn(2).cuda(cuda2)

异步执行(Asynchronous execution)

默认情况下,GPU的操作是异步的。在使用GPU的情况下调用某个函数(执行某个功能),操作会排队进入某个特定的GPU设备,但是不会等到以后执行(可能不是按进入队列的顺序来顺序执行)。因为GPU的并行计算的特性,这些操作可以在GPU中并行执行。
通常来说,异步计算的过程对于调用者是不可见的,原因有两点:第一、每一个GPU设备以操作序列进入设备的顺序来执行这个操作序列;第二、当数据在CPU和GPU之间或者GPU之间进行拷贝时,PyTorch会自动执行必要的同步操作。于是,计算过程的外在表现就是仿佛每一个操作都在同步执行。
使用者可以通过设置环境变量CUDA_LAUNCH_BLOCKING=1来强制使用同步计算。当一个error在GPU上发生时,可以手动设置来排错。
异步计算的一个后果就是当没有进行时间同步时,时间的测量(耗时测量)就会不准确。为了得到精确的时间测量,要么在测量前调用torch.cuda.synchronize(),要么使用torch.cuda.Event来记录时间,详细代码示例如下:

# 使用torch.cuda.Event的方式
start_event = torch.cuda.Event(enable_timing=True)
end_event = torch.cuda.Event(enable_time=True)
start_event.record()

# DO SOMETHING...

end_event.record()
torch.cuda.synchronize() # 等待事件被记录
elapsed_time_ms = start_event.elapsed_time(end_time)

作为一个例外(As an exception),一些形如to()copy()的函数允许显示的non_blocking参数,这样可以使得调用者避开(bypass)不必要时刻的同步操作。另外的例外情况就是CUDA streams

CUDA流(CUDA streams)

一个CUDA stream是一个相信执行序列,该序列属于特定的GPU设备。通常不需要显示创建,因为每一个GPU设备默认情况下会创建一个自己的stream
stream的操作序列以特们被创建的顺序在stream中序列化好,但是来自不同streams的操作序列可以以一个相对顺序并发执行,除非显示调用了synchronize()或者wait_stream()等同步函数的情况下不能并发执行。以下举出流使用的示例代码:

cuda = torch.device('cuda')
s = torch.cuda.Stream() # 创建流对象
A = torch.empty((100,100),device=cuda).normal_(0.0,1.0)

with torch.cuda.streams(s):
	# sum()函数可能在normal_()函数结束之前就已经开始执行了,体现了并发执行
	B = torch.sum(A)

当当前的stream是默认的stream,当发生数据移动时,PyTorch会自动执行一些必要的同步操作。然而,当没有使用默认的stream时,则需要调用者来确保正常的同步过程。

内存管理

PyTorch会使用缓存内存分配器(caching memory allocator)来加速内存分配。使用该机制可以使得在内存回收时速度更快,因为这过程没有GPU设备的同步过程。但是,被allocator管理的未使用的内存在nvidia-smi上还会显示这部分内存仍被占用。使用者可以使用memory_allocated()max_memory_allocated()来对tensors占用的内存进行监控,可以使用memory_reserved()max_memory_reserved()来监控caching allocator管理的内存数量。可以调用empty_cache()来释放所有没有使用的cached memory,这样这部分内存就可以被其他的GPU应用使用了。被tensors占用的GPU内存不会被释放因此可以使用的GPU内存的数量不会增加,除非把这部分内存显式释放掉。
对于更多的advanced users,Pytorch提供了memory_stats()来监控内存数量,提供了memory_snapshot()函数来捕获内存分配状态。可以通过这两个函数来查看自己的代码对于内存的使用情况。

cuFFT plan cache

对于每一个CUDA设备,一个关于cuFFT 计划的LRU算法被用来加速在CUDA tensor上运行的FFT方法,这些tensors具有相同的形状和配置。由于一些cuFFT计划可能分配GPU内存,这些caches有一个最大容量。
可以通过以下API参数来查询当前设备的cache的配置情况:

  • torch.backends.cuda.cufft_plan_cache.max_size:给定cache的容量(在CUDA 10 及以上版本是4096,老版本是1023)。通过设置该属性可以修改容量
  • torch.backends.cuda.cufft_plan_cache.size:当前cache中仍存在的plans的数量
    -torch.backends.cuda.cufft_plan_cache.clear():清空cache

为了在non-default设备上控制和查询plan caches,可以通过torch.device或者GPU设备的索引来索引torch.backends.cuda.cufft_plan_cache对象,并访问以上描述到的三个属性。举例如下:

torch.backends.cuda.cufft.plan_cache[1].max_size=10 # 设置GPU设备1的容量为10

设备不可知代码(Device-agnostic code)

由于Pytorch的结构,使用者需要显示写设备不可知的代码。第一步是决定是都使用GPU。一个通用的做法是使用Pythonargparse模块来读取用户参数,结合使用is_available(),使用一个标记来不适用CUDA。

import argparse
import torch

parse = argparse.ArgumentParser(description='Pytorch example')
parse.add_argument('--disable-cuda',action='store_true',help='DisableCUDA')
args = parser.parse_args()
args.device = None
if not args.disable_cuda and torch.cuda.is_available():
	args.device = torch.device('cuda')
else:
	args.device = torch.device('cpu')

# 在设备上创建tensor和神经网络(根据参数才知道设备是CPU还是GPU)
x = torch.empty((8,42),device=args.device)
net = Network().to(device=args.device)

使用数据加载器(dataloader)加载数据:

cuda0 = torch.device('cuda:0')
for i,x in enumerate(train_loader):
	x = x.to(cuda0)

使用torch.cuda.device来使用控制 tensor创建时所在的GPU设备

print("Outside device is 0")
with torch.cuda.device(1): # 选择GPU设备1
	print("Inside device is 1")
print("Outside device is still 0")

当存在一个tensor前提下,想要在相同的GPU设备上创建一个新的tensor,此时可以使用torch.Tensor.new_*这一类的方法。torch.*类的方法使用需要当前GPU的context信息和传入的属性参数,torch.Tensor.new_*方法保留设备信息和tensor的其他属性。
以下代码展示了在参数的前向传播过程中模型创建时,新的tensors在其内部创建的过程。

cuda = torch.device('cuda')
x_cpu = torch.empty(2)
x_gpu = torch.empty(2,device=cuda)
x_cpu_long = torch.empty(2,dtype=torch.int64)

# 在CPU(因为x_cpu是CPU上的Tensor)上使用new_full创建一个tensor,元素值为0.3
y_cpu = x_cpu.new_full([3,2],fill_value=0.3)
print(y_cpu)

# 在GPU(因为x_gpu是GPU上的Tensor)上使用new_full创建一个tensor,元素值为-5
y_gpu = x_gpu.new_full([3,2],fill_value=-5)
print(y_gpu)

# 在CPU上创建Tensor
y_cpu_long = x_cpu_long.new_tensor([[1,2,3]])
print(y_cpu_long)

输出结果如下:

tensor([[ 0.3000,  0.3000],
            [ 0.3000,  0.3000],
            [ 0.3000,  0.3000]])
            
tensor([[-5.0000, -5.0000],
            [-5.0000, -5.0000],
            [-5.0000, -5.0000]], device='cuda:0')
            
tensor([[ 1,  2,  3]])

tensor的new_full函数说明如下:
Pytorch:CUDA Semantics(语义)_第1张图片
创建同类型和同大小的tensor:ones_like()或者zeros_like()

x_cpu = torch.empty(2,3)
x_gpu = torch.empty(2,3)

y_cpu = torch.ones_like(x_cpu)
y_gpu = torch.zeros_like(x_gpu)

使用锁内存缓存(pinned memory buffer)

当来自固定(页面锁定)内存时,主机到GPU的内存拷贝要更快。CPU中的tensorsstorages向外暴露pin_memory()方法,然后返回一个对象的拷贝,数据放在锁住区域(pinned region)。
一旦对一个tensor或者storage进行锁定,则可以使用异步GPU拷贝操作。只需要将参数non_blocking=True传递到to()或者cuda()方法中即可。这样可以使得数据传输和计算过程一起进行。
可以将pin_memory=True传递到DataLoader的构造器中来返回批量数据,这部分数据被放置在锁住内存(pinned memory)中。

使用 nn.DataParallel来代替multiprocessing

许多涉及到批量数据处理和多块GPU的案例应该默认使用DataParallel来利用多块GPU。甚至使用GIL,一个Python的单进程程序可以渗入到多块GPU中。

你可能感兴趣的:(torch知识点)