GPU
查看 GPU 信息
更多接口,参考 torch.cuda
1  | torch.cuda.is_available() # 判断 GPU 是否可用  | 
torch.device
torch.device 表示 torch.Tensor 分配到的设备的对象。其包含一个设备类型(cpu 或 cuda),以及可选的设备序号。如果设备序号不存在,则为当前设备,即 torch.cuda.current_device() 的返回结果。
可以通过如下方式创建 torch.device 对象:
1  | # 通过字符串  | 
还可以通过设备类型加上编号,来创建 device 对象:
1  | device = torch.device('cuda', 0)  | 
配置 CUDA 访问限制
可以通过如下方式,设置当前 Python 脚本可见的 GPU。
在命令行设置
1  | CUDA_VISIBLE_DEVICES=1 python my_script.py  | 
实例
1  | Environment Variable Syntax Results  | 
在 Python 代码中设置
1  | import os  | 
使用函数 set_device
1  | import torch  | 
官方建议使用
CUDA_VISIBLE_DEVICES,不建议使用set_device函数。
用 GPU 训练
默认情况下,使用 CPU 训练模型。可以通过如下方式,通过 GPU 进行训练。使用 GPU 时,模型和输入必须位于同一张 GPU 上。
.to(device) 和 .cuda() 的区别如下:
.to()中的参数必不可少- 对于 
module而言,.to()是inplace的,而.cuda()不是;而对于tensor而言,两者一致。 
注:实测,两者时间消耗持平。推荐使用
.to()函数
方式 1 :使用cuda
1  | device = torch.device("cuda:1") # 指定模型训练所在 GPU  | 
方法 2 :使用to
1  | device = torch.device("cuda:1") # 指定模型训练所在 GPU  | 
存在的问题
batch size 太大
当想要用大批量进行训练,但是 GPU 资源有限,此时可以通过梯度累加(accumulating gradients)的方式进行。
梯度累加的基本思想在于,在优化器更新参数前,也就是执行 optimizer.step() 前,进行多次反向传播,使得梯度累计值自动保存在 parameter.grad 中,最后使用累加的梯度进行参数更新。
这个在 PyTorch 中特别容易实现,因为 PyTorch 中,梯度值本身会保留,除非我们调用 model.zero_grad() 或 optimizer.zero_grad()。
修改后的代码如下所示:
1  | model.zero_grad() # 重置保存梯度值的张量  | 
model 太大
当模型本身太大,以至于不能放置于一个 GPU 中时,可以通过梯度检查点 (gradient-checkpoingting) 的方式进行处理。
梯度检查点的基本思想是以计算换内存。具体来说就是,在反向传播的过程中,把梯度切分成几部分,分别对网络上的部分参数进行更新。如下图所示:

梯度检查点图示
这种方法速度很慢,但在某些例子上很有用,比如训练长序列的 RNN 模型等。
具体可参考:From zero to research — An introduction to Meta-learning
单机多卡训练,即并行训练。并行训练又分为数据并行 (Data Parallelism) 和模型并行两种。
数据并行指的是,多张 GPU 使用相同的模型副本,但是使用不同的数据批进行训练。而模型并行指的是,多张GPU 分别训练模型的不同部分,使用同一批数据。
两者对比如下图所示:

模型并行 VS 数据并行
数据并行
Pytorch API
【Class 原型】
1  | torch.nn.DataParallel(module, device_ids=None, output_device=None, dim=0)  | 
【参数】
- module :要进行并行的 
module。这里隐含了一点 ,即网络中的某一层也是可以进行数据并行的,但是一般不会这么使用。 - device_ids : 
CUDA列表,可以为torch.device类型,也可以是编号组成的int列表。默认使用全部 GPU - output_device : 某一 
GPU编号或torch.device。指定输出的GPU,默认为第一个,即device_ids[0] 
【返回值】
要进行并行的模型。
【基本使用方式】
1  | > net = torch.nn.DataParallel(model, device_ids=[0, 1, 2])  | 
数据并行的原理
数据并行的具体原理流程为:

将模型加载至主设备上,作为
controller,一般设置为cuda:0在每次迭代时,执行如下操作:
将
controller模型复制(broadcast)到每一个指定的GPU上将总输入的数据
batch,进行均分,分别作为各对应副本的输入 (scatter)每个副本独立进行前向传播,并进行反向传播,但只是求取梯度,每个GPU上的loss都要进行
loss.backward(),得到各自的梯度将各副本的梯度汇总(
gather)到controller设备,并进行求和 (reduced add)During the backwards pass, gradients from each replica are summed into the original module.
更具总体度,更新
controller设备上的参数
注意事项
【警告 1】
- 设置的 
batch size为总的批量尺寸,其必须大于GPU数量。 - 在 
parallelized module运行之前,必须保证其在controller设备上,存在参数和buffers。 - 并行的 
GPU列表中,必须包含主GPU - 当 
forward()中,module返回一个标量,那么并行的结果将返回一个vector,其长度等于device的数量,对应于各个设备的结果。 
【警告 2】
在每次前向传播过程中,module 都先会被复制到每一个 device 上。因此,在前向传播中,任何对该运行的 module 的副本的更新,在此后都将会丢失。
比方说,如果 module 有一个 counter 属性,每次前向传播都会进行累加,则它将会保持为初始值。因为更新是发生在模型的副本(在其他 device 上的副本)上的,并且这些更新在前向传播结束之后将会被销毁。
然而,DataParallel 保证 controller 设备上的副本的参数和 buffers 与其他并行的 modules 之间共享存储。因此,如若对 controller device 的 参数和 buffers 的更改,将会被记录。例如,BatchNorm2d 和 spectral_norm() 依赖于这种行为来更新 buffers。
【警告 3】
定义于 module 及其子 module 上的前向传播和反向传播 hooks,将会被调用 len(device_ids) 次,每个设备对应一次。
具体来说,hooks 只能保证按照正确的顺序执行对应设备上的操作,即在对应设备上的 forward() 调用之前执行,但是不能保证,在所有 forward)() 执行之前,通过 register_forward_pre_hook() 执行完成所有的 hooks。
【警告 4】
任何位置和关键字 (positional and keyword) 输入都可以传递给 DataParallel,处理一些需要特殊处理的类型。
tensors 将会在指定维度(默认为 0)上被 scattered。 tuple, list 和 dict 类型则会被浅拷贝。其他类型则会在不同的线程之间进行共享,且在模型前向传播过程中,如果进行写入,则可被打断。
【警告 5】
当对 pack sequence -> recurrent network -> unpack sequence 模式的 module 使用 DataParallel 或 data_parallel 时,有一些小的问题。
每个设备上的 forward 的对应输入,将仅仅是整个输入的一部分。因为默认的 unpack 操作 torch.nn.utils.rnn.pad_packed_sequence() 只会将该设备上的输入 padding 成该设备上的最长的输入长度,因此,将所有设备的结构进行汇总时,可能会发生长度的不匹配的情况。
因此,可以利用 pad_packed_sequence() 的 total_length 参数来保证 forward() 调用返回的序列长度一致。代码如下所示:
1  | from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence  | 
示例程序
下面是使用 DataParrel 的核心代码,其余部分与一般的训练流程一致。
1  | # 设置当前脚本可见的 GPU 列表  | 
模型的加载
1  | def _Single2Parallel(self, origin_state):  | 
模型并行
如果模型本身较大,一张 GPU 放置不下时,要通过模型并行来处理。模型并行指的是,将模型的不同部分,分别放置于不同的 GPU 上,并将中间结果在 GPU 之间进行传递。
尽管从执行时间上来看,将模型的不同部分部署在不同设备上确实有好处,但是它通常是出于避免内存限制才使用。具有特别多参数的模型会受益于这种并行策略,因为这类模型需要很高的内存占用,很难适应到单个系统。
基本使用
下面,我们以一个 toy 模型为例,讲解模型并行。模型并行的实现方式如下所示:
1  | class Net(nn.Module):  | 
上面的 toy 模型看起来和在单个 GPU 上运行的模型没什么区别,只不过用 to(device) 来将模型内的不同层分散到不同的 GPU 上进行运行,并且将中间结果转移到对应的 GPU 上即可。
backward() 和 torch.optim 将会自动考虑梯度,与在一个 GPU 上没有区别。
注意:在调用
loss函数时,labels与output必须在同一个GPU上。
1  | # 此时,不在此需要使用 model = model.cuda()  | 
模型并行的性能分析
以上的实现解决了单个模型太大,不能存放于一个 GPU 的情况。然而,需要注意的是,相较于在单个 GPU 上运行,其速度更慢。因为任何时候,只有一个 GPU 在工作,而另一个则闲置。而当中间结果在 GPU 之间进行转移时,速度会进一步下降。
下面同时实例分析。以 resnet50 为例,用随机生成的数据输入,比较两个版本的运行时间。
1  | from torchvision.models.resnet import ResNet, Bottleneck  | 
1  | import torchvision.models as models  | 
1  | import matplotlib.pyplot as plt  | 
结果如下所示。模型并行相较于单 GPU 训练的模型,训练时间开销多出 4.02/3.75-1=7% 左右。当然,这存在优化空间,因为多 GPU 中,每一时刻只有一个 GPU 进行训练,其他闲置。而在中间数据转移过程中,又消耗一定的时间。

模型并行 VS 单 GPU
输入流水线
解决上面的问题的最直接的方式就是使用流水线技术,即 GPU-0 输出到 GPU-1 之后,在 GPU-1 训练的同时,GPU-0 接收下一批数据,这样就可以多 GPU 同时执行了。
下面,我们将 120 个样本的 batch 再次细分,分为 20 张样本每份的小 batch。由于 Pytorch 同步启动 CUDA 操作,因此,该操作不需要使用额外的多线程来处理。
1  | class PipelineParallelResNet50(ModelParallelResNet50):  | 
需要注意的是,device-to-device 的 tensor copy 操作是同步的。如果创建多个数据流,则需要保证 copy 操作以合适的同步方式进行。
在完成 tensor 拷贝之前,对 source tensor 进行写入,或者对 target tensor 进行读写,都可能会导致不可预期的行为。上面的实现中,在源和目标设备中,均只使用了默认的 stream,因此无需额外的强化同步操作。

模型并行 VS 单 GPU VS 流水线模型并行
如上图所示,流水线输入确实加速了训练进程,大约 3.75/2.51-1=49%,但距离 100% 的加速相去甚远。由于我们在流水线并行实现中,引入了一个新的参数 split_sizes,但是并不知晓其对训练时间的影响。
直觉上来说,使用一个小的 split_sizes 将会导致许多微小的 CUDA 内核的启动,而使用较大的 split_sizes,则会导致较长的空闲时间。下面是一个搜索最佳 split_sizes 的实验。
1  | means = []  | 
实验结果如下所示:

流水线输入分割份数
如上图所示,最佳的参数为 12,其将导致 3.75/2.43-1=54% 的加速。但这仍存在加速的可能。例如,所有在 cuda:0 上的操作放在默认的 stream 上。这意味着,在下一个 split 上的计算,不能与上一个 split 的 copy 操作进行重叠。然而,由于 next_split 和 prev_plit 是不同的 tensor,因此这不存在问题。
该实现需要在每个 GPU 上使用多个 stream,并且模型中不同的子网络需要使用不同的 stream 管理策略。
