1.引言
为什么要并行
近几年,依赖大规模标注数据和大量的可学习参数,深度神经网络才能异军突起,占得机器学习半壁江山。然而,也是因为这两点使得深度学习的训练变得极其困难,尤其是对语音和图像这样的大型非结构化数据来说,训练更加困难。即使通过GPU卡加速可以缓解这个问题,但是单卡上能够放置的数据量还是有限的,所以给超大数据集(如图像数据集ImageNet)带来的加速仍然杯水车薪。基于以上单卡硬件能力的局限性,进一步加速训练就需要诉诸于分布式训练,即使用单机多卡或多机多卡的进行模型训练,从而起到加速训练的效果。
2.基础知识
2.1 为什么要了解GPU底层通信知识
目前主流的深度学习框架都已经封装了使用上很友好的基于多GPU的分布式数据并行训练功能(Distributed DataParrallel, DDP),但是只有充分了解了DDP的底层知识和工作原理,才能更好地理解并使用DDP,并且排查可能出现的并行加速性能问题。本文便是尝试进一步深挖多GPU卡的底层工作原理,网上关于DDP的实现和原理已经有很多比较全面的文章(例如Pytorch的官方文档),所以本文不会包含这些内容。
2.2 多GPU与CPU的物理连接及拓扑
不管单机多卡还是多机多卡,多个卡之间都会进行数据交换(推理激活值或者梯度)。为了能够更好地了解多GPU卡是如何通信或进行数据交换,我们首先得从多GPU卡在物理主板上的连接拓扑开始探究。后面讨论的内容基础都是基于Intel X86架构,其他架构(如IBM PowerPC)不在讨论范围内。在Intel X86架构下讨论多GPU卡的物理连接,则不得不讨论PCI和PCIe相关内容。
2.2.1 PCI
我们知道,现代计算机是以存储(寄存器和内存)和CPU为中心的(主要是存储),其他的如键盘、显示器和磁盘等设备相对内存和CPU来说都是外围设备(又称I/O设备,简称外设),因为他们本身没有汇总多方数据并进行逻辑运算的能力。例如,显示器显示的字符或图像只是CPU计算的结果,键盘也只是告知CPU人类输入了什么字符,收到字符后的其他操作就是CPU的任务了。有些外设是用来与人类进行交互的,例如显示器和键盘,它们一个从CPU获取数据,一个向CPU发送数据;有些外设是提供数据持久化的,如磁盘,因为寄存器和内存都是非永久性存储,断电后所有数据就会丢失,使用磁盘我们可以将CPU执行的结果进行持久化保存;有些外设是进行多个主机CPU之间数据交换的,例如网卡;而有些外设执行的任务更加特殊,比如GPU,它的作用主要是帮助CPU分担大量的数值计算任务,一开始GPU的作用主要是执行图形相关运算的,从而让CPU腾出更多的时间执行逻辑计算。从上面的描述我们可以看出,大量的外设都要与CPU或内存进行数据交换,外设有很多,CPU只有一个或几个,这时CPU就成为了稀缺资源,因为同一时间单一CPU不可能同时与磁盘和显示器进行通信。这时就需要有一套提前声明的规定,也就是协议,来规范所有外设与CPU的通信行为,在Intel X86架构中,这便是早期的PCI协议。
PCI(Peripheral Component Interconnect,外围设备互连)总线和设备树是X86硬件体系架构中很重要的组成部分,几乎所有的外围硬件都以这样或那样的形式连接到PCI设备树上。下面是桌面系统主机常见的PCI总线树。
图中有两个总线,分别为“PCI Local Bus #0”和“PCI Local Bus #1”。从图中我们可以了解之所以将PCI总线称为总线树,是因为PCI支持总线扩展,即当0号总线不够用时(接入更多的设备,但总线插槽数有限),可以通过“PCI-to-PCI Bridge”进行扩展,PCI-to-PCI Bridge可以连接其他总线,如果以与CPU直接连接的总线作为树的根节点的话,其他的外设加上通过扩展增加的外设就构成了一个设备树。
另外,从图中也可以看出PCI总线结构是一种共享总线结构,即挂载在同一总线上的设备之间是共享总线带宽的。随着PCI设备读写速度不断提高(如显卡、网卡等设备),这种架构就变得不适用了,经过几代的更新,最终从PCI架构演变出目前主流的PCIe架构。
2.2.2 PCIe
PCIe(Peripheral Component Interconnect Express),即高速的PCI,其在PCI的基础上做了很多扩展,其中一个就是“PCIe是点对点结构,而PCI是总线结构”,一个典型的PCIe系统结构图如下:
点对点意味着每一个PCIe设备都拥有自己独立的数据连接,设备之间数据传输并发互不影响,而对于PCI共享总线方式,PCI总线上只能有一个设备进行通信,一旦PCI总线上挂接的设备增多,每个设备的实际传输速率就会下降,性能得不到保证。PCIe以点对点的方式处理通信,每个设备在要求传输数据的时候各自建立自己的传输通道,对于其他设备这个通道是封闭的,这样的操作保证了通道的专有性,避免其他设备的干扰。例如,图中最右下角的两个外设(两个PCI Express Endpoint)想要通信的话,只需要通过直连的Switch设备进行数据交换(类似于网络交换机),而不必通过“外设1-->CPU-->外设2-->CPU-->外设1”这样的链路进行数据交互,这样就不会左上角的外设与其他设备或者CPU进行数据交互的带宽。当然,这两个外设的数据交互还是会影响挂载在同一Switch设备上的其他外的带宽,但这样也大大降低了PCI中外设数据必须通过CPU才能进行交互的问题。
图中的Root Complex是各种资源的集合,如中断控制器、电源管理控制器、内存控制器、错误检测和报告逻辑等。Root Complex也包含一个内部总线(Host Bridge),它表示整个PCIe树结构中的0号总线。它代表处理器发起外设事务请求,从它的端口发送数据包或在它的端口接收数据包,然后传输到内存。我们在下文中还会遇到Root Complex和Host Bridge。
2.2.3 NUMA
服务器的情况要复杂一点,这需要了解NUMA的概念:就如之前说的,在若干年前,对于x86架构的计算机,那时的内存控制器还没有整合进CPU,所有内存的访问都需要通过北桥芯片来完成。此时的内存访问如下图所示,被称为UMA(uniform memory access, 一致性内存访问 )。这样的访问对于软件层面来说非常容易实现:总线模型保证了所有的内存访问是一致的,不必考虑由不同内存地址之前的差异。
之后的x86平台经历了一场从“拼频率”到“拼核心数”的转变(核心就是CPU中最小的逻辑运算单元,核心越多,同时并发执行任务的任务数量就越多),越来越多的核心被尽可能地塞进了同一块芯片上,各个核心对于内存带宽的争抢访问成为了瓶颈,因此X86推出了NUMA(Non-uniform memory access, 非一致性内存访问)架构(还有其他的原因),示意图如下:
从图中我们可以得出NUMA架构的两个特点:
1.CPU芯片被划分成不同的组,称为node,每个node有自己独立的内存访问地址,每个node也就有自己的内存单元;
2.如果想要跨node访问内存(如node0想要访问下面的内存),则需要通过跨node的CPU通信进行数据交互(node之间通过QPI连接)。
下图是我手头使用的一台服务器的node和CPU数量,该信息是命令lscpu
的显示结果, 从图中可以看出,这条服务器将2个CPU芯片(每个芯片有20个核心)划分成两个组(node), 其中0 ~ 9和20 ~ 29号核心属于属于node0,10 ~ 19和30~39核心属于node1:
既然内存划分给不同的CPU组(即node),那么类似地,将PCI设备划分给不同的node,也会提高外设与特定CPU的数据交换效率。同样,如果想要跨node访问PCI外设,也需要经过QPI连接进行跨node的CPU通信进行数据交互。在NUMA架构下具有两个node(即两个CPU芯片组)的PCIe物理结构如下图,图中每个node只有一个CPU芯片,所以用CPU代替了node:
上图中差不多就是简易的现代服务器的物理连接结构图,从图中我们可以看出,如果一个服务器上挂载了多个GPU卡,可能由于每个卡挂载的物理位置不同,GPU之间的通信链路方式就会有多种,使用命令nvidia-smi topo --matrix
可以直接获得服务器上每两个卡之间的物理通信方式:
可以看出,英伟达给出了6中GPU卡的物理通信方式,下面我们可以结果上面的PCIe物理结构图来了解这些通信方式:
SYS: 通过QPI(PCIe + QPI总线)跨NUMA node间GPU通信,相当于上上图中的GPU1到GPU5;
NODE: 单个NUMA node内经过Host Bridge PCIe总线通信(一个NUMA node上有多个CPU芯片),没遇到过这种情况,就不举例子了;
PHB: 经过Host Bridge(Root complex中)的PCIe总线通信,同一Root complex下多个PCIe总线,相当于上上图中的GPU1到GPU3;
PXB: 跨越多个PCIe Bridge (switch),但没有经过Host Bridge PCIe总线,相当于上上图中的GPU3到GPU4;
PIX: 经过一个PCIe Bridge (switch)通信,即挂载在同一个PCIe Bridge上的GPU卡之间通信,相当于上上图中的GPU1到GPU2;
NV#: 使用NVLink通信(后面会介绍);
描述中所指的Host Bridge就是Host主桥,是连接处理器和PCI总线的一个设备器件,用来隔离处理器的存储器域与PCI总线域的特殊桥片,管理PCI总线域,集成在Root Complex中,此处可以认为就是图中的Root Complex。
从上面的例子可以直观地看出,两个GPU卡的物理距离越近(如GPU1到GPU2),则通信效率越高,距离越远效率越低,此处的效率一般体现在通信时延上。
2.3 GPU通信技术
GPU强大的并行计算能力,大大提升了运算性能。随着运算数据量的不断攀升,GPU间需要交换大量的数据,GPU通信性能成为并行计算非常重要的性能指标。前面介绍的NUMA架构和PCIe协议只是物理层面的协议(类似于网络协议中的物理层和数据链路层),如何在其上进行高效的GPU通信是接下来的内容(类似于网络协议中的IP层和传输层)。
GPU通信的发展简史如下:
GPUDirect Shared Memory (2012) : Nvidia在PCIe上实现了单机上的GPUDirect Shared Memory 技术;
GPUDirect P2P (2014): Nvidia在PCIe上实现了单机上的GPUDirect P2P技术;
NVLink(2014) :解决了单机多卡通信时PCIe瓶颈问题;
GPUDirect RDMA(2014):提升多机多卡通信性能;
2.3.1 GPUDirect Shared Memory
Nvidia在PCIe上实现了单机上的GPUDirect Shared Memory 技术,使得GPU与第三方PCIe设备通过共享的固定的(pinned)CPU内存实现共享内存访问从而加速通信,原理如下图所示:
此处的“固定的CPU内存”指的就是我们在使用Pytorch的DataLoader时指定的pin_memory参数,由于设置pin_memory=True
后会导致部分CPU内存无法被其他进程使用并且无法参与Swap换出到磁盘,如果在主机内存不足时设置该参数,则会造成服务器运行效率降低。
2.3.2 GPUDirect P2P
后来GPUDirect又增加了相同PCI Express root complex下的GPU之间的内存数据直接访问或交换的能力,即Peer to Peer(P2P) Direct Access和Direct Transfers,进一步降低了GPU数据交换的成本,原理如下图所示:
2.3.3 NVLink
由于PCIe带宽瓶颈问题,Nvidia直接放弃使用PCIe,提出了NVLink通信协议,能在多GPU之间和GPU与CPU之间实现更高的通信带宽,但是放弃PCIe也就相当于放弃Intel X86,所以目前在主流的X86服务器上是看不到NVLink的。
2.3.4 GPUDirect RDMA
对于大规模深度学习训练任务,单机已经无法满足计算要求,多机多卡的分布式训练成了必要的需求,多机间的通信成为了分布式训练性能的重要指标,因此GPUDirect增加了对RDMA( Remote Direct Memory Access )的支持,使得GPU可以不经过CPU直接访问其他主机上GPU卡的内存数据。
目前我们使用的X86单个服务器用不到NVLink和RDMA,主要GPU通信受益于P2P技术,下面是我手头使用的服务器在使用P2P后的GPU卡通信时延对比(该数据使用p2pBandwidthLatencyTest工具得到)。
下图是0~7卡P2P连接情况,1代表支持P2P连接,0代表不支持:
从图中红框可以看到,使用P2P技术后GPU间通信实验降低了2~3倍。
2.4 多卡信息收集(collective)通信技术NCCL
之前介绍的都是GPU的物理连接和通信协议,开发者想要使用这些功能进行多卡通信(如深度学习框架中支持多卡方式训练模型),则需要在其之上封装一个友好的库(类似于操作系统提供的socket通信编程接口)。这便是NCCL(Nvidia Collective multi-GPU Communication Library)诞生的目的:它是一个实现多GPU的数据汇集(collective)通信库,支持的数据通信原语有reduce,all-reduce,broadcast ,gather,all-gather等;使用ring-based的collective通信方式,可以显著降低通信时间;同时NCCL做了很多优化,可以在PCIe、NVLink、InfiniBand上实现较高的通信速度。
下面简单介绍几个常用NCCL通信原语的原理:
- NCCL通信原语——reduce,从多个sender那里接收数据,最终combine到一个节点上
- NCCL通信原语——all-reduce,从多个sender那里接收数据,最终combine到每一个节点上
- NCCL通信原语——broadcast,从单个sender数据发送到其他节点上
- NCCL通信原语——gather,将多个sender上的数据收集到单个节点上
另外,需要额外介绍下NCCL的ring-base collective通信方式,它将所有的通信节点(GPU卡)通过首尾连接形成一个单向环,数据在环上依次传输,使用ring-base collective具有以下好处:
- 去掉数据(参数)服务器节点的概念,所有在环中的节点功能相同,通过分摊通信负载从而降低单个节点的通信压力;
- 可以实现通信时间不随节点数的增加而增加,只和数据总量以及带宽有关;
下面是传统的单server多worker和多server多worker通信模式,不管哪种通信模式,它们的最大缺点是随着通信节点的增加,将极大增加网络通信压力,尤其是server端的通信压力,因为所有节点的数据都要先汇聚到server上,然后在分发到每个节点上。
下面是以传统方式broadcast通信原语实现的示意图:
从上面的图中可以看出传统broadcast操作的通信时间适合GPU卡的数量相关的(图中公式),下面用ring-base collective通信方式实现broadcast通信原语的示意图,可以看出当数据分块的数量远大于GPU卡数时,通信耗时接近,而该值只由通信数据的总量和通信带宽有关,与GPU卡数无关。
all-reduce操作也有类似的操作:
下图是NCCL ring-base collective通信方式在单机8卡之间构造的通信环路(该图是理想情况下:每四张卡挂载同一Switch上):
目前主流的深度学习框架,使用NVIDIA GPU计算时,DDP的多卡数据同步基本上使用NCCL是效率最高的,如下是Pytorch使用DDP时指定NCCL作为通信方式的官方示例代码:
import torch
import torch.multiprocessing as mp
def example(rank, world_size):
# create default process group
torch.distributed.init_process_group(backend="nccl",
init_method="tcp://127.0.0.1:8790",
world_size=world_size, rank=rank)
# create local model
model = torch.nn.Linear(10, 10).to(rank)
# construct DDP model
ddp_model = torch.nn.parallel.DistributedDataParallel(model, device_ids=[rank])
# define loss function and optimizer
loss_fn = torch.nn.MSELoss()
optimizer = torch.optim.SGD(ddp_model.parameters(), lr=0.001)
# forward pass
outputs = ddp_model(torch.randn(20, 10).to(rank))
labels = torch.randn(20, 10).to(rank)
# backward pass
loss_fn(outputs, labels).backward()
# update parameters
optimizer.step()
def main():
world_size = 2
mp.spawn(example, args=(world_size,), nprocs=world_size, join=True)
if __name__=="__main__":
main()
以上就是本文的所有内容,如有错漏之处欢迎指正。
3.参考资料
- 深挖NUMA
- 浅析GPU通信技术(上)-GPUDirect P2P
- https://developer.aliyun.com/article/599183
- https://developer.aliyun.com/article/603617
- 如何理解Nvidia英伟达的Multi-GPU多卡通信框架NCCL?
- 深入PCI与PCIe之一:硬件篇
- THE PCI EXPRESS BUS
- PCI Overview: PCI vs PCI Express
- Horovod: fast and easy distributed deep learning in TensorFlow
- NCCL: ACCELERATED MULTI-GPU
COLLECTIVE COMMUNICATIONS