未验证 提交 6f23af8e 编写于 作者: G goldmermaid 提交者: GitHub

[polish] chapter 12 peer review (#1018)

* chapter12 0-3 polish

* soft polish chapter 12 done
上级 62914ade
......@@ -45,7 +45,7 @@ with d2l.Benchmark('mxnet.np'):
```{.python .input}
#@tab pytorch
# GPU 计算热身
# GPU计算热身
device = d2l.try_gpu()
a = torch.randn(size=(1000, 1000), device=device)
b = torch.mm(a, a)
......@@ -94,7 +94,7 @@ with d2l.Benchmark():
广义上说,PyTorch有一个用于与用户直接交互的前端(例如通过Python),还有一个由系统用来执行计算的后端。如 :numref:`fig_frontends`所示,用户可以用各种前端语言编写Python程序,如Python和C++。不管使用的前端编程语言是什么,PyTorch程序的执行主要发生在C++实现的后端。由前端语言发出的操作被传递到后端执行。后端管理自己的线程,这些线程不断收集和执行排队的任务。请注意,要使其工作,后端必须能够跟踪计算图中各个步骤之间的依赖关系。因此,不可能并行化相互依赖的操作。
:end_tab:
![编程语言前端和深度学习框架后端](../img/frontends.png)
![编程语言前端和深度学习框架后端](../img/frontends.png)
:width:`300px`
:label:`fig_frontends`
......@@ -115,12 +115,12 @@ z = x * y + 2
z
```
![后端跟踪计算图中各个步骤之间的依赖关系](../img/asyncgraph.svg)
![后端跟踪计算图中各个步骤之间的依赖关系](../img/asyncgraph.svg)
:label:`fig_asyncgraph`
上面的代码片段在 :numref:`fig_asyncgraph`中进行了说明。每当Python前端线程执行前三条语句中的一条语句时,它只是将任务返回到后端队列。当最后一个语句的结果需要被打印出来时,Python前端线程将等待C++后端线程完成变量`z`的结果计算。这种设计的一个好处是Python前端线程不需要执行实际的计算。因此,不管Python的性能如何,对程序的整体性能几乎没有影响。 :numref:`fig_threading`演示了前端和后端如何交互。
![前端和后端的交互](../img/threading.svg)
![前端和后端的交互](../img/threading.svg)
:label:`fig_threading`
## 障碍器与阻塞器
......
......@@ -141,7 +141,7 @@ with d2l.Benchmark('复制到CPU'):
:end_tab:
:begin_tab:`pytorch`
这种方式效率不高。注意到当列表中的其余部分还在计算时,我们可能就已经开始将`y`的部分复制到CPU了。例如,当我们计算一个小批量的(反传)梯度时。某些参数的梯度将比其他参数的梯度更早可用。因此,在GPU仍在运行时就开始使用PCI-Express总线带宽来移动数据对我们是有利的。在PyTorch中,`to() ``copy_()`等函数都允许显式的`non_blocking`参数,这允许在不需要同步时调用方可以绕过同步。设置`non_blocking=True`让我们模拟这个场景。
这种方式效率不高。注意到当列表中的其余部分还在计算时,我们可能就已经开始将`y`的部分复制到CPU了。例如,当我们计算一个小批量的(反传)梯度时。某些参数的梯度将比其他参数的梯度更早可用。因此,在GPU仍在运行时就开始使用PCI-Express总线带宽来移动数据对我们是有利的。在PyTorch中,`to()``copy_()`等函数都允许显式的`non_blocking`参数,这允许在不需要同步时调用方可以绕过同步。设置`non_blocking=True`让我们模拟这个场景。
:end_tab:
```{.python .input}
......@@ -159,11 +159,11 @@ with d2l.Benchmark('在GPU1上运行并复制到CPU'):
torch.cuda.synchronize()
```
两个操作所需的总时间少于它们各部分操作所需时间的总和。请注意,与并行计算的区别是通信操作使用的资源:CPU和GPU之间的总线。事实上,我们可以在两个设备上同时进行计算和通信。如上所述,计算和通信之间存在的依赖关系是必须先计算`y[i]`,然后才能将其复制到CPU。幸运的是,系统可以在计算`y[i]` 的同时复制`y[i-1]`,以减少总的运行时间。
两个操作所需的总时间少于它们各部分操作所需时间的总和。请注意,与并行计算的区别是通信操作使用的资源:CPU和GPU之间的总线。事实上,我们可以在两个设备上同时进行计算和通信。如上所述,计算和通信之间存在的依赖关系是必须先计算`y[i]`,然后才能将其复制到CPU。幸运的是,系统可以在计算`y[i]`的同时复制`y[i-1]`,以减少总的运行时间。
最后,我们给出了一个简单的两层多层感知机在CPU和两个GPU上训练时的计算图及其依赖关系的例子,如 :numref:`fig_twogpu`所示。手动调度由此产生的并行程序将是相当痛苦的。这就是基于图的计算后端进行优化的优势所在。
![在一个 CPU 和两个 GPU 上的两层的多层感知机的计算图及其依赖关系。](../img/twogpu.svg)
![在一个CPU和两个GPU上的两层的多层感知机的计算图及其依赖关系](../img/twogpu.svg)
:label:`fig_twogpu`
## 小结
......@@ -174,7 +174,7 @@ with d2l.Benchmark('在GPU1上运行并复制到CPU'):
## 练习
1. 在本节定义的`run` 函数中执行了八个操作,并且操作之间没有依赖关系。设计一个实验,看看深度学习框架是否会自动地并行地执行它们。
1. 在本节定义的`run`函数中执行了八个操作,并且操作之间没有依赖关系。设计一个实验,看看深度学习框架是否会自动地并行地执行它们。
1. 当单个操作符的工作量足够小,即使在单个CPU或GPU上,并行化也会有所帮助。设计一个实验来验证这一点。
1. 设计一个实验,在CPU和GPU这两种设备上使用并行计算和通信。
1. 使用诸如NVIDIA的[Nsight](https://developer.nvidia.com/nsight-compute-2019_5) 之类的调试器来验证你的代码是否有效。
......
......@@ -3,10 +3,10 @@
很好地理解算法和模型才可以捕获统计方面的问题,构建出具有出色性能的系统。同时,至少对底层硬件有一定的了解也是必不可少的。本节不能替代硬件和系统设计的相关课程。相反,本节的内容可以作为理解某些算法为什么比其他算法更高效以及如何实现良好吞吐量的起点。一个好的设计可以很容易地在性能上造就数量级的差异,这也是后续产生的能够训练网络(例如,训练时间为$1$周)和无法训练网络(训练时间为$3$个月,导致错过截止期)之间的差异。我们先从计算机的研究开始。然后深入查看CPU和GPU。最后,再查看数据中心或云中的多台计算机的连接方式。
![每个程序员都应该知道的延迟数字](../img/latencynumbers.png)
![每个程序员都应该知道的延迟数字](../img/latencynumbers.png)
:label:`fig_latencynumbers`
不耐烦的读者也许可以通过 :numref:`fig_latencynumbers`进行简单的了解。图片源自科林·斯科特的[互动帖子](https://people.eecs.berkeley.edu/~rcs/research/interactive_latency.html),在帖子中很好地概述了过去十年的进展。原始的数字是取自于杰夫迪恩的[Stanford讲座](https://static.googleusercontent.com/media/research.google.com/en//people/jeff/Stanford-DL-Nov-2010.pdf)。下面的讨论解释了这些数字的一些基本原理,以及它们如何指导我们去设计算法。下面的讨论是非常笼统和粗略的。很显然,它并不能代替一门完整的课程,而只是为了给统计建模者提供足够的信息,让他们做出合适的设计决策。对于计算机体系结构的深入概述,我们建议读者参考 :cite:`Hennessy.Patterson.2011`或关于该主题的最新课程,例如[Arste Asanovic](http://inst.eecs.berkeley.edu/~cs152/sp19/)
你也可以通过 :numref:`fig_latencynumbers`进行简单的了解,图片源自科林·斯科特的[互动帖子](https://people.eecs.berkeley.edu/~rcs/research/interactive_latency.html),在帖子中很好地概述了过去十年的进展。原始的数字是取自于杰夫迪恩的[Stanford讲座](https://static.googleusercontent.com/media/research.google.com/en//people/jeff/Stanford-DL-Nov-2010.pdf)。下面的讨论解释了这些数字的一些基本原理,以及它们如何指导我们去设计算法。下面的讨论是非常笼统和粗略的。很显然,它并不能代替一门完整的课程,而只是为了给统计建模者提供足够的信息,让他们做出合适的设计决策。对于计算机体系结构的深入概述,我们建议读者参考 :cite:`Hennessy.Patterson.2011`或关于该主题的最新课程,例如[Arste Asanovic](http://inst.eecs.berkeley.edu/~cs152/sp19/)
## 计算机
......@@ -18,7 +18,7 @@
* 高速扩展总线(PCIe)用于系统连接一个或多个GPU。服务器最多有$8$个加速卡,通常以更高级的拓扑方式连接,而桌面系统则有$1$个或$2$个加速卡,具体取决于用户的预算和电源负载的大小。
* 持久性存储设备,如磁盘驱动器、固态驱动器,在许多情况下使用高速扩展总线连接。它为系统需要的训练数据和中间检查点需要的存储提供了足够的传输速度。
![计算机组件的连接](../img/mobo-symbol.svg)
![计算机组件的连接](../img/mobo-symbol.svg)
:label:`fig_mobo-symbol`
如 :numref:`fig_mobo-symbol`所示,高速扩展总线由直接连接到CPU的多个通道组成,将CPU与大多数组件(网络、GPU和存储)连接在一起。例如,AMD的Threadripper3有$64$个PCIe4.0通道,每个通道都能够双向传输16Gbit/s的数据。内存直接连接到CPU,总带宽高达100GB/s。
......@@ -35,7 +35,7 @@
因为GPU的处理单元比CPU多得多,因此它对内存带宽的需要也更高。解决这种问题大体上有两种选择。首要方法是使内存总线变得更宽。例如:NVIDIA的RTX 2080Ti有一条$352$位宽的总线,这样就可以同时传输更多的信息。再有方法就是在GPU中使用特定的高性能内存。一种选择是如NVIDIA的消费级设备RTX和Titan系列中通常使用[GDDR6](https://en.wikipedia.org/wiki/GDDR6_SDRAM)芯片,其总带宽超过500GB/s。另一种选择是使用HBM(高带宽存储器)模块。这些模块使用截然不同的接口在专用硅片上与GPU直接连在一起。这导致其非常昂贵,通常仅限于在高端服务器的芯片上使用,如NVIDIA Volta V100系列的加速卡。
GPU内存的带宽要求甚至更高,因为它们的处理单元比CPU多得多。总的来说,解决这些问题有两种选择。首先是使内存总线变得更宽。例如,NVIDIA的RTX2080Ti有一条352位宽的总线。这样就可以同时传输更多的信息。其次,GPU使用特定的高性能内存。消费级设备,如NVIDIA的RTX和Titan系列,通常使用[GDDR6](https://en.wikipedia.org/wiki/GDDR6_SDRAM)芯片,总带宽超过500GB/s。另一种选择是使用HBM(高带宽存储器)模块。它们使用截然不同的接口,直接与专用硅片上的GPU连接。这使得它们非常昂贵,通常仅限于高端服务器芯片,如NVIDIA Volta V100系列加速卡。毫不意外的是GPU的内存通常比CPU的内存小得多,因为前者的成本更高。就目的而言,它们的性能与特征大体上是相似的,只是GPU的速度更快。就本书而言,我们完全可以忽略细节,因为这些技术只在调整GPU核心以获得高吞吐量时才起作用。
GPU内存的带宽要求甚至更高,因为它们的处理单元比CPU多得多。总的来说,解决这些问题有两种选择。首先是使内存总线变得更宽。例如,NVIDIA的RTX 2080Ti有一条352位宽的总线。这样就可以同时传输更多的信息。其次,GPU使用特定的高性能内存。消费级设备,如NVIDIA的RTX和Titan系列,通常使用[GDDR6](https://en.wikipedia.org/wiki/GDDR6_SDRAM)芯片,总带宽超过500GB/s。另一种选择是使用HBM(高带宽存储器)模块。它们使用截然不同的接口,直接与专用硅片上的GPU连接。这使得它们非常昂贵,通常仅限于高端服务器芯片,如NVIDIA Volta V100系列加速卡。毫不意外的是GPU的内存通常比CPU的内存小得多,因为前者的成本更高。就目的而言,它们的性能与特征大体上是相似的,只是GPU的速度更快。就本书而言,我们完全可以忽略细节,因为这些技术只在调整GPU核心以获得高吞吐量时才起作用。
## 存储器
......@@ -43,7 +43,7 @@ GPU内存的带宽要求甚至更高,因为它们的处理单元比CPU多得
### 硬盘驱动器
*硬盘驱动器*(hard disk drives,HDDs)已经使用了半个多世纪。简单的说,它们包含许多旋转的盘片,这些盘片的磁头可以放置在任何给定的磁道上进行读写。高端磁盘在$9$个盘片上可容纳高达16TB的容量。硬盘的主要优点之一是相对便宜,而它们的众多缺点之一是典型的灾难性故障模式和相对较高的读取延迟。
*硬盘驱动器*(hard disk drive,HDD)已经使用了半个多世纪。简单的说,它们包含许多旋转的盘片,这些盘片的磁头可以放置在任何给定的磁道上进行读写。高端磁盘在$9$个盘片上可容纳高达16TB的容量。硬盘的主要优点之一是相对便宜,而它们的众多缺点之一是典型的灾难性故障模式和相对较高的读取延迟。
要理解后者,请了解一个事实即硬盘驱动器的转速大约为7200RPM(每分钟转数)。它们如果转速再快些,就会由于施加在碟片上的离心力而破碎。在访问磁盘上的特定扇区时,还有一个关键问题:需要等待碟片旋转到位(可以移动磁头,但是无法对磁盘加速)。因此,可能需要$8$毫秒才能使用请求的数据。一种常见的描述方式是,硬盘驱动器可以以大约100IOPs(每秒输入/输出操作)的速度工作,并且在过去二十年中这个数字基本上没变。同样糟糕的是,带宽(大约为100-200MB/s)也很难增加。毕竟,每个磁头读取一个磁道的比特,因此比特率只随信息密度的平方根缩放。因此,对于非常大的数据集,HDD正迅速降级为归档存储和低级存储。
......@@ -61,9 +61,9 @@ GPU内存的带宽要求甚至更高,因为它们的处理单元比CPU多得
## CPU
中央处理器(central processing unit,CPU)是任何计算机的核心。它们由许多关键组件组成:*处理器核心*(processor cores)用于执行机器代码的、*总线*(bus)用于连接不同组件(注意,总线会因为处理器型号、各代产品和供应商之间的特定拓扑结构有明显不同)和*缓存*(caches)相比主内存实现更高的读取带宽和更低的延迟内存访问。最后,因为高性能线性代数和卷积运算常见于媒体处理和机器学习中,所以几乎所有的现代CPU都包含*向量处理单元*(vector processing units)为这些计算提供辅助。
中央处理器(central processing unit,CPU)是任何计算机的核心。它们由许多关键组件组成:*处理器核心*(processor cores)用于执行机器代码的、*总线*(bus)用于连接不同组件(注意,总线会因为处理器型号、各代产品和供应商之间的特定拓扑结构有明显不同)和*缓存*(cach)相比主内存实现更高的读取带宽和更低的延迟内存访问。最后,因为高性能线性代数和卷积运算常见于媒体处理和机器学习中,所以几乎所有的现代CPU都包含*向量处理单元*(vector processing unit)为这些计算提供辅助。
![Intel Skylake消费级四核CPU](../img/skylake.svg)
![Intel Skylake消费级四核CPU](../img/skylake.svg)
:label:`fig_skylake`
:numref:`fig_skylake`描述了Intel Skylake消费级四核CPU。它包含一个集成GPU、缓存和一个连接四个核心的环总线。例如:以太网、WiFi、蓝牙、SSD控制器和USB这些外围设备要么是芯片组的一部分,要么通过PCIe直接连接到CPU。
......@@ -72,7 +72,7 @@ GPU内存的带宽要求甚至更高,因为它们的处理单元比CPU多得
每个处理器核心都由一组相当复杂的组件组成。虽然不同时代的产品和供应商的细节有所不同,但基本功能都是标准的。前端加载指令并尝试预测将采用哪条路径(例如,为了控制流),然后将指令从汇编代码解码为微指令。汇编代码通常不是处理器执行的最低级别代码,而复杂的微指令却可以被解码成一组更低级的操作,然后由实际的执行核心处理。通常执行核心能够同时执行许多操作,例如, :numref:`fig_cortexa77`的ARM Cortex A77核心可以同时执行多达$8$个操作。
![ARM Cortex A77 微体系结构](../img/a77.svg)
![ARM Cortex A77微体系结构](../img/a77.svg)
:label:`fig_cortexa77`
这意味着高效的程序可以在每个时钟周期内执行多条指令,前提是这些指令可以独立执行。不是所有的处理单元都是平等的。一些专用于处理整数指令,而另一些则针对浮点性能进行了优化。为了提高吞吐量,处理器还可以在分支指令中同时执行多条代码路径,然后丢弃未选择分支的结果。这就是为什么前端的分支预测单元很重要,因为只有最有希望的路径才会被继续执行。
......@@ -84,7 +84,7 @@ GPU内存的带宽要求甚至更高,因为它们的处理单元比CPU多得
![128位NEON矢量化](../img/neon128.svg)
:label:`fig_neon128`
根据体系结构的选择,此类寄存器最长可达$512$位,最多可组合$64$对数字。例如,我们可能会将两个数字相乘,然后与第三个数字相加,这也称为乘加融合(fused multiply-add)。Intel的[OpenVino](https://01.org/openvinotoolkit)就是使用这些处理器来获得可观的吞吐量,以便在服务器级CPU上进行深度学习。不过请注意,这个数字与GPU的能力相比则相形见绌。例如,NVIDIA的RTX2080Ti拥有$4352$个CUDA核心,每个核心都能够在任何时候处理这样的操作。
根据体系结构的选择,此类寄存器最长可达$512$位,最多可组合$64$对数字。例如,我们可能会将两个数字相乘,然后与第三个数字相加,这也称为乘加融合(fused multiply-add)。Intel的[OpenVino](https://01.org/openvinotoolkit)就是使用这些处理器来获得可观的吞吐量,以便在服务器级CPU上进行深度学习。不过请注意,这个数字与GPU的能力相比则相形见绌。例如,NVIDIA的RTX 2080Ti拥有$4352$个CUDA核心,每个核心都能够在任何时候处理这样的操作。
### 缓存
......@@ -93,11 +93,11 @@ GPU内存的带宽要求甚至更高,因为它们的处理单元比CPU多得
* **寄存器**,严格来说不是缓存的一部分,用于帮助组织指令。也就是说,寄存器是CPU可以以时钟速度访问而没有延迟的存储位置。CPU有几十个寄存器,因此有效地使用寄存器取决于编译器(或程序员)。例如,C语言有一个`register`关键字。
* **一级缓存**是应对高内存带宽要求的第一道防线。一级缓存很小(常见的大小可能是32-64KB),内容通常分为数据和指令。当数据在一级缓存中被找到时,其访问速度非常快,如果没有在那里找到,搜索将沿着缓存层次结构向下寻找。
* **二级缓存**是下一站。根据架构设计和处理器大小的不同,它们可能是独占的也可能是共享的。即它们可能只能由给定的核心访问,或者在多个核心之间共享。二级缓存比一级缓存大(通常每个核心256-512KB),而速度也更慢。此外,我们首先需要检查以确定数据不在一级缓存中,才会访问二级缓存中的内容,这会增加少量的额外延迟。
* **三级缓存**在多个核之间共享,并且可以非常大。AMD的Epyc3服务器的CPU在多个芯片上拥有高达256MB的高速缓存。更常见的数字在4-8MB范围内。
* **三级缓存**在多个核之间共享,并且可以非常大。AMD的EPYC 3服务器的CPU在多个芯片上拥有高达256MB的高速缓存。更常见的数字在4-8MB范围内。
预测下一步需要哪个存储设备是优化芯片设计的关键参数之一。例如,建议以*向前*的方向遍历内存,因为大多数缓存算法将试图*向前读取*(read forward)而不是向后读取。同样,将内存访问模式保持在本地也是提高性能的一个好方法。
添加缓存是一把双刃剑。一方面,它能确保处理器核心不缺乏数据。但同时,它也增加了芯片尺寸,消耗了原本可以用来提高处理能力的面积。此外,*缓存未命中* 的代价可能会很昂贵。考虑最坏的情况,如 :numref:`fig_false sharing`所示的*错误共享*(false sharing)。当处理器$1$上的线程请求数据时,内存位置缓存在处理器$0$上。为了满足获取需要,处理器$0$需要停止它正在做的事情,将信息写回主内存,然后让处理器$1$从内存中读取它。在此操作期间,两个处理器都需要等待。与高效的单处理器实现相比,这种代码在多个处理器上运行的速度可能要慢得多。这就是为什么缓存大小(除了物理大小之外)有实际限制的另一个原因。
添加缓存是一把双刃剑。一方面,它能确保处理器核心不缺乏数据。但同时,它也增加了芯片尺寸,消耗了原本可以用来提高处理能力的面积。此外,*缓存未命中* 的代价可能会很昂贵。考虑最坏的情况,如 :numref:`fig_falsesharing`所示的*错误共享*(false sharing)。当处理器$1$上的线程请求数据时,内存位置缓存在处理器$0$上。为了满足获取需要,处理器$0$需要停止它正在做的事情,将信息写回主内存,然后让处理器$1$从内存中读取它。在此操作期间,两个处理器都需要等待。与高效的单处理器实现相比,这种代码在多个处理器上运行的速度可能要慢得多。这就是为什么缓存大小(除了物理大小之外)有实际限制的另一个原因。
![错误共享(图片由英特尔提供)](../img/falsesharing.svg)
:label:`fig_falsesharing`
......@@ -110,17 +110,17 @@ GPU内存的带宽要求甚至更高,因为它们的处理单元比CPU多得
回想一下如 :numref:`fig_neon128`所示的矢量化。处理器核心中添加向量处理单元可以显著提高吞吐量。例如,在 :numref:`fig_neon128`的例子中,我们能够同时执行$16$个操作。首先,如果我们添加的运算不仅优化了向量运算,而且优化了矩阵运算,会有什么好处?稍后我们将讨论基于这个策略引入的张量核(tensor cores)。第二,如果我们增加更多的核心呢?简而言之,以上就是GPU设计决策中的两种策略。 :numref:`fig_turing_processing_block`给出了基本处理块的概述。它包含$16$个整数单位和$16$个浮点单位。除此之外,两个张量核加速了与深度学习相关的附加操作的狭窄的子集。每个流式多处理器都由这样的四个块组成。
![NVIDIA Turing 处理块(图片由英伟达提供)](../img/turing-processing-block.png)
![NVIDIA Turing处理块(图片由英伟达提供)](../img/turing-processing-block.png)
:width:`150px`
:label:`fig_turing_processing_block`
接下来,将$12$ 个流式多处理器分组为图形处理集群,这些集群构成了高端TU102处理器。充足的内存通道和二级缓存完善了配置。 :numref:`fig_turing`有相关的细节。设计这种设备的原因之一是可以根据需要独立地添加或删除模块,从而满足设计更紧凑的芯片和处理良品率问题(故障模块可能无法激活)的需要。幸运的是,在CUDA和框架代码层之下,这类设备的编程对深度学习的临时研究员隐藏得很好。特别是,只要有可用的资源GPU上就可以同时执行多个程序。尽管如此,了解设备的局限性是值得的,以避免对应的设备内存的型号不合适。
![NVIDIA Turing 架构(图片由英伟达提供)](../img/turing.png)
![NVIDIA Turing架构(图片由英伟达提供)](../img/turing.png)
:width:`350px`
:label:`fig_turing`
最后值得一提的是*张量核*(tensor cores)。它们是最近增加更多优化电路趋势的一个例子,这些优化电路对深度学习特别有效。例如,TPU添加了用于快速矩阵乘法的脉动阵列 :cite:`Kung.1988`,这种设计是为了支持非常小数量(第一代TPU支持数量为1)的大型操作。而张量核是另一个极端。它们针对$4 \times 4$和$16 \times 16$矩阵之间的小型运算进行了优化,具体取决于它们的数值精度。 :numref:`fig_tensorcore`给出了优化的概述。
最后值得一提的是*张量核*(tensor core)。它们是最近增加更多优化电路趋势的一个例子,这些优化电路对深度学习特别有效。例如,TPU添加了用于快速矩阵乘法的脉动阵列 :cite:`Kung.1988`,这种设计是为了支持非常小数量(第一代TPU支持数量为1)的大型操作。而张量核是另一个极端。它们针对$4 \times 4$和$16 \times 16$矩阵之间的小型运算进行了优化,具体取决于它们的数值精度。 :numref:`fig_tensorcore`给出了优化的概述。
![NVIDIA Turing架构中的张量核心(图片由英伟达提供)](../img/tensorcore.jpg)
:width:`400px`
......@@ -132,10 +132,10 @@ GPU内存的带宽要求甚至更高,因为它们的处理单元比CPU多得
每当单个设备不足以进行优化时,我们就需要来回传输数据以实现同步处理,于是网络和总线就派上了用场。我们有许多设计参数:带宽、成本、距离和灵活性。应用的末端我们有WiFi,它有非常好的使用范围,非常容易使用(毕竟没有线缆),而且还便宜,但它提供的带宽和延迟相对一般。头脑正常的机器学习研究人员都不会用它来构建服务器集群。在接下来的内容中,我们将重点关注适合深度学习的互连方式。
* **PCIe**,一种专用总线,用于每个通道点到点连接的高带宽需求(在$16$通道插槽中的PCIe4.0上高达32GB/s),延迟时间为个位数的微秒(5μs)。PCIe链接非常宝贵。处理器拥有的数量:AMD的EPYC3有$128$个通道,Intel的Xeon每个芯片有$48$个通道;在桌面级CPU上,数字分别是$20$(Ryzen9)和$16$(Core i9)。由于GPU​通常有$16$个通道,这就限制了以全带宽与CPU连接的GPU数量。毕竟,它们还需要与其他高带宽外围设备(如存储和以太网)共享链路。与RAM访问一样,由于减少了数据包的开销,因此更适合大批量数据传输。
* **PCIe**,一种专用总线,用于每个通道点到点连接的高带宽需求(在$16$通道插槽中的PCIe4.0上高达32GB/s),延迟时间为个位数的微秒(5μs)。PCIe链接非常宝贵。处理器拥有的数量:AMD的EPYC 3有$128$个通道,Intel的Xeon每个芯片有$48$个通道;在桌面级CPU上,数字分别是$20$(Ryzen9)和$16$(Core i9)。由于GPU​通常有$16$个通道,这就限制了以全带宽与CPU连接的GPU数量。毕竟,它们还需要与其他高带宽外围设备(如存储和以太网)共享链路。与RAM访问一样,由于减少了数据包的开销,因此更适合大批量数据传输。
* **以太网**,连接计算机最常用的方式。虽然它比PCIe慢得多,但它的安装成本非常低,而且具有很强的弹性,覆盖的距离也要长得多。低级服务器的典型带宽为1GBit/s。高端设备(如云中的[C5实例](https://aws.amazon.com/ec2/instance-types/c5/))提供10到100GBit/s的带宽。与以前所有的情况一样,数据传输有很大的开销。请注意,原始以太网几乎从不被直接使用,而是在物理互连之上使用执行的协议(例如UDP或TCP/IP)。这进一步增加了开销。与PCIe类似,以太网旨在连接两个设备,例如计算机和交换机。
* **交换机**,一种连接多个设备的方式,该连接方式下的任何一对设备都可以同时执行(通常是全带宽)点对点连接。例如,以太网交换机可能以高带宽连接$40$台服务器。请注意,交换机并不是传统计算机网络所独有的。甚至PCIe通道也可以是[可交换的](https://www.broadcom.com/products/pcie-switches-bridges/pcie-switches),例如:[P2实例](https://aws.amazon.com/ec2/instance-types/p2/)就是将大量GPU连接到主机处理器。
* **NVLink**,是PCIe的替代品,适用于非常高带宽的互连。它为每条链路提供高达300Gbit/s的数据传输速率。服务器GPU(Volta V100)有六个链路。而消费级GPU(RTX2080Ti)只有一个链路,运行速度也降低到100Gbit/s。我们建议使用[NCCL](https://github.com/NVIDIA/nccl)来实现GPU之间的高速数据传输。
* **NVLink**,是PCIe的替代品,适用于非常高带宽的互连。它为每条链路提供高达300Gbit/s的数据传输速率。服务器GPU(Volta V100)有六个链路。而消费级GPU(RTX 2080Ti)只有一个链路,运行速度也降低到100Gbit/s。我们建议使用[NCCL](https://github.com/NVIDIA/nccl)来实现GPU之间的高速数据传输。
## 更多延迟
......
# 编译器和解释器
:label:`sec_hybridize`
目前为止,本书主要关注的是*命令式编程*(imperative programming)。命令式编程使用诸如`print``+``if`之类的语句来更改程序的状态。考虑下面这段简单的命令式程序。
目前为止,本书主要关注的是*命令式编程*(imperative programming)。
命令式编程使用诸如`print`、“`+`”和`if`之类的语句来更改程序的状态。
考虑下面这段简单的命令式程序:
```{.python .input}
#@tab all
......@@ -19,7 +21,7 @@ print(fancy_func(1, 2, 3, 4))
Python是一种*解释型语言*(interpreted language)。因此,当对上面的`fancy_func`函数求值时,它按顺序执行函数体的操作。也就是说,它将通过对`e = add(a, b)`求值,并将结果存储为变量`e`,从而更改程序的状态。接下来的两个语句`f = add(c, d)``g = add(e, f)`也将执行类似地操作,即执行加法计算并将结果存储为变量。 :numref:`fig_compute_graph`说明了数据流。
![命令式编程中的数据流](../img/computegraph.svg)
![命令式编程中的数据流](../img/computegraph.svg)
:label:`fig_compute_graph`
尽管命令式编程很方便,但可能效率不高。一方面原因,Python会单独执行这三个函数的调用,而没有考虑`add`函数在`fancy_func`中被重复调用。如果在一个GPU(甚至多个GPU)上执行这些命令,那么Python解释器产生的开销可能会非常大。此外,它需要保存`e``f`的变量值,直到`fancy_func`中的所有语句都执行完毕。这是因为程序不知道在执行语句`e = add(a, b)``f = add(c, d)`之后,其他部分是否会使用变量`e``f`
......@@ -195,7 +197,7 @@ net(x)
#@tab all
#@save
class Benchmark:
"""用于测量运行时间"""
"""用于测量运行时间"""
def __init__(self, description='Done'):
self.description = description
......@@ -377,7 +379,7 @@ net(x)
:end_tab:
:begin_tab:`pytorch,tensorflow`
1. 回顾前几章中你感兴趣的模型,你能通过重新实现它们来提高它们的计算性能吗?
1. 回顾前几章中你感兴趣的模型,你能提高它们的计算性能吗?
:end_tab:
:begin_tab:`mxnet`
......
# 计算性能
:label:`chap_performance`
在深度学习中,数据集和模型通常都很大,导致计算量也会很大。因此,计算的性能非常重要。本章将集中讨论影响计算性能的主要因素:命令式编程、符号编程、异步计算、自动并行和多GPU计算。通过学习本章,你可以进一步提高前几章中实现的那些模型的计算性能。例如,我们可以在不影响准确性的前提下,减少训练时间。
在深度学习中,数据集和模型通常都很大,导致计算量也会很大。
因此,计算的性能非常重要。
本章将集中讨论影响计算性能的主要因素:命令式编程、符号编程、
异步计算、自动并行和多GPU计算。
通过学习本章,对于前几章中实现的那些模型,你可以进一步提高它们的计算性能。
例如,我们可以在不影响准确性的前提下,大大减少训练时间。
```toc
:maxdepth: 2
......
......@@ -24,7 +24,7 @@ from torch import nn
```{.python .input}
#@save
def resnet18(num_classes):
"""稍加修改的 ResNet-18 模型。"""
"""稍加修改的ResNet-18模型"""
def resnet_block(num_channels, num_residuals, first_block=False):
blk = nn.Sequential()
for i in range(num_residuals):
......@@ -36,7 +36,7 @@ def resnet18(num_classes):
return blk
net = nn.Sequential()
# 该模型使用了更小的卷积核、步长和填充,而且删除了最大汇聚层
# 该模型使用了更小的卷积核、步长和填充,而且删除了最大汇聚层
net.add(nn.Conv2D(64, kernel_size=3, strides=1, padding=1),
nn.BatchNorm(), nn.Activation('relu'))
net.add(resnet_block(64, 2, first_block=True),
......@@ -51,7 +51,7 @@ def resnet18(num_classes):
#@tab pytorch
#@save
def resnet18(num_classes, in_channels=1):
"""稍加修改的 ResNet-18 模型。"""
"""稍加修改的ResNet-18模型"""
def resnet_block(in_channels, out_channels, num_residuals,
first_block=False):
blk = []
......@@ -63,12 +63,13 @@ def resnet18(num_classes, in_channels=1):
blk.append(d2l.Residual(out_channels, out_channels))
return nn.Sequential(*blk)
# 该模型使用了更小的卷积核、步长和填充,而且删除了最大汇聚层
# 该模型使用了更小的卷积核、步长和填充,而且删除了最大汇聚层
net = nn.Sequential(
nn.Conv2d(in_channels, 64, kernel_size=3, stride=1, padding=1),
nn.BatchNorm2d(64),
nn.ReLU())
net.add_module("resnet_block1", resnet_block(64, 64, 2, first_block=True))
net.add_module("resnet_block1", resnet_block(
64, 64, 2, first_block=True))
net.add_module("resnet_block2", resnet_block(64, 128, 2))
net.add_module("resnet_block3", resnet_block(128, 256, 2))
net.add_module("resnet_block4", resnet_block(256, 512, 2))
......@@ -185,8 +186,8 @@ def train(num_gpus, batch_size, lr):
npx.waitall()
timer.stop()
animator.add(epoch + 1, (evaluate_accuracy_gpus(net, test_iter),))
print(f'test acc: {animator.Y[0][-1]:.2f}, {timer.avg():.1f} sec/epoch '
f'on {str(ctx)}')
print(f'测试精度:{animator.Y[0][-1]:.2f},{timer.avg():.1f}秒/轮,'
f'{str(ctx)}')
```
```{.python .input}
......@@ -198,7 +199,7 @@ def train(net, num_gpus, batch_size, lr):
if type(m) in [nn.Linear, nn.Conv2d]:
nn.init.normal_(m.weight, std=0.01)
net.apply(init_weights)
# 在多个 GPU 上设置模型
# 在多个GPU上设置模型
net = nn.DataParallel(net, device_ids=devices)
trainer = torch.optim.SGD(net.parameters(), lr)
loss = nn.CrossEntropyLoss()
......@@ -215,8 +216,8 @@ def train(net, num_gpus, batch_size, lr):
trainer.step()
timer.stop()
animator.add(epoch + 1, (d2l.evaluate_accuracy_gpu(net, test_iter),))
print(f'test acc: {animator.Y[0][-1]:.2f}, {timer.avg():.1f} sec/epoch '
f'on {str(devices)}')
print(f'测试精度:{animator.Y[0][-1]:.2f},{timer.avg():.1f}秒/轮,'
f'{str(devices)}')
```
让我们看看这在实践中是如何运作的。我们先[**在单个GPU上训练网络**]进行预热。
......@@ -245,27 +246,27 @@ train(net, num_gpus=2, batch_size=512, lr=0.2)
:begin_tab:`mxnet`
* Gluon通过提供一个上下文列表,为跨多个设备的模型初始化提供原语。
* *神经网络可以在(可找到数据的)单GPU上进行自动评估。
* 注意每台设备上的网络需要先初始化,然后再尝试访问该设备上的参数,否则会遇到错误。
* 神经网络可以在(可找到数据的)单GPU上进行自动评估。
* 每台设备上的网络需要先初始化,然后再尝试访问该设备上的参数,否则会遇到错误。
* 优化算法在多个GPU上自动聚合。
:end_tab:
:begin_tab:`pytorch`
* 神经网络可以在(可找到数据的)单GPU上进行自动评估。
* 注意每台设备上的网络需要先初始化,然后再尝试访问该设备上的参数,否则会遇到错误。
* 每台设备上的网络需要先初始化,然后再尝试访问该设备上的参数,否则会遇到错误。
* 优化算法在多个GPU上自动聚合。
:end_tab:
## 练习
:begin_tab:`mxnet`
1. 本节使用ResNet-18尝试不同的迭代周期数、批量大小和学习率,以及使用更多的GPU进行计算。如果使用$16$个GPU(例如,在AWS p2.16xlarge实例上)尝试此操作,会发生什么?
1. 本节使用ResNet-18,请尝试不同的迭代周期数、批量大小和学习率,以及使用更多的GPU进行计算。如果使用$16$个GPU(例如,在AWS p2.16xlarge实例上)尝试此操作,会发生什么?
1. 有时候不同的设备提供了不同的计算能力,我们可以同时使用GPU和CPU,那应该如何分配工作?为什么?
1. 如果去掉`npx.waitall()`会怎样?你将如何修改训练,以使并行操作最多有两个步骤重叠?
:end_tab:
:begin_tab:`pytorch`
1. 本节使用ResNet-18尝试不同的迭代周期数、批量大小和学习率,以及使用更多的GPU进行计算。如果使用$16$个GPU(例如,在AWS p2.16xlarge实例上)尝试此操作,会发生什么?
1. 本节使用ResNet-18,请尝试不同的迭代周期数、批量大小和学习率,以及使用更多的GPU进行计算。如果使用$16$个GPU(例如,在AWS p2.16xlarge实例上)尝试此操作,会发生什么?
1. 有时候不同的设备提供了不同的计算能力,我们可以同时使用GPU和CPU,那应该如何分配工作?为什么?
:end_tab:
......
......@@ -34,10 +34,10 @@
当通道或单元的数量不太小时,使计算性能有良好的提升。
此外,由于可用的显存呈线性扩展,多个GPU能够处理不断变大的网络。
![由于GPU显存有限,原有AlexNet设计中的模型并行](../img/alexnet-original.svg)
![由于GPU显存有限,原有AlexNet设计中的模型并行](../img/alexnet-original.svg)
:label:`fig_alexnet_original`
然而,我们需要大量的同步或*屏障操作*(barrier operations),因为每一层都依赖于所有其他层的结果。
然而,我们需要大量的同步或*屏障操作*(barrier operation),因为每一层都依赖于所有其他层的结果。
此外,需要传输的数据量也可能比跨GPU拆分层时还要大。
因此,基于带宽的成本和复杂性,我们同样不推荐这种方法。
......@@ -49,7 +49,7 @@
而且,GPU的数量越多,小批量包含的数据量就越大,从而就能提高训练效率。
但是,添加更多的GPU并不能让我们训练更大的模型。
![在多个GPU上并行化。从左到右:原始问题、网络并行、分层并行、数据并行](../img/splitting.svg)
![在多个GPU上并行化。从左到右:原始问题、网络并行、分层并行、数据并行](../img/splitting.svg)
:label:`fig_splitting`
:numref:`fig_splitting`中比较了多个GPU上不同的并行方式。
......@@ -64,7 +64,7 @@
给定需要训练的模型,虽然每个GPU上的参数值都是相同且同步的,但是每个GPU都将独立地维护一组完整的模型参数。
例如, :numref:`fig_data_parallel`演示了在$k=2$时基于数据并行方法训练模型。
![利用两个GPU上的数据,并行计算小批量随机梯度下降](../img/data-parallel.svg)
![利用两个GPU上的数据,并行计算小批量随机梯度下降](../img/data-parallel.svg)
:label:`fig_data_parallel`
一般来说,$k$个GPU并行训练过程如下:
......@@ -199,11 +199,11 @@ def get_params(params, device):
```{.python .input}
#@tab all
new_params = get_params(params, d2l.try_gpu(0))
print('b1 weight:', new_params[1])
print('b1 grad:', new_params[1].grad)
print('b1 权重:', new_params[1])
print('b1 梯度:', new_params[1].grad)
```
由于还没有进行任何计算,因此偏置参数的梯度仍然为零。
由于还没有进行任何计算,因此权重参数的梯度仍然为零。
假设现在有一个向量分布在多个GPU上,下面的[**`allreduce`函数将所有向量相加,并将结果广播给所有GPU**]。
请注意,我们需要将数据复制到累积结果的设备,才能使函数正常工作。
......@@ -228,17 +228,17 @@ def allreduce(data):
```{.python .input}
data = [np.ones((1, 2), ctx=d2l.try_gpu(i)) * (i + 1) for i in range(2)]
print('before allreduce:\n', data[0], '\n', data[1])
print('allreduce之前:\n', data[0], '\n', data[1])
allreduce(data)
print('after allreduce:\n', data[0], '\n', data[1])
print('allreduce之后:\n', data[0], '\n', data[1])
```
```{.python .input}
#@tab pytorch
data = [torch.ones((1, 2), device=d2l.try_gpu(i)) * (i + 1) for i in range(2)]
print('before allreduce:\n', data[0], '\n', data[1])
print('allreduce之前:\n', data[0], '\n', data[1])
allreduce(data)
print('after allreduce:\n', data[0], '\n', data[1])
print('allreduce之后:\n', data[0], '\n', data[1])
```
## 数据分发
......@@ -251,9 +251,9 @@ print('after allreduce:\n', data[0], '\n', data[1])
data = np.arange(20).reshape(4, 5)
devices = [npx.gpu(0), npx.gpu(1)]
split = gluon.utils.split_and_load(data, devices)
print('input :', data)
print('load into', devices)
print('output:', split)
print('输入:', data)
print('设备:', devices)
print('输出:', split)
```
```{.python .input}
......@@ -324,7 +324,8 @@ def train_batch(X, y, device_params, devices, lr):
# 将每个GPU的所有梯度相加,并将其广播到所有GPU
with torch.no_grad():
for i in range(len(device_params[0])):
allreduce([device_params[c][i].grad for c in range(len(devices))])
allreduce(
[device_params[c][i].grad for c in range(len(devices))])
# 在每个GPU上分别更新模型参数
for param in device_params:
d2l.sgd(param, lr, X.shape[0]) # 在这里,我们使用全尺寸的小批量
......@@ -339,7 +340,7 @@ def train_batch(X, y, device_params, devices, lr):
def train(num_gpus, batch_size, lr):
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
devices = [d2l.try_gpu(i) for i in range(num_gpus)]
# 将模型参数复制到 `num_gpus` 个GPU
# 将模型参数复制到num_gpus个GPU
device_params = [get_params(params, d) for d in devices]
num_epochs = 10
animator = d2l.Animator('epoch', 'test acc', xlim=[1, num_epochs])
......@@ -354,8 +355,8 @@ def train(num_gpus, batch_size, lr):
# 在GPU 0 上评估模型
animator.add(epoch + 1, (d2l.evaluate_accuracy_gpu(
lambda x: lenet(x, device_params[0]), test_iter, devices[0]),))
print(f'test acc: {animator.Y[0][-1]:.2f}, {timer.avg():.1f} sec/epoch '
f'on {str(devices)}')
print(f'测试精度:{animator.Y[0][-1]:.2f},{timer.avg():.1f}秒/轮,'
f'{str(devices)}')
```
```{.python .input}
......@@ -363,7 +364,7 @@ def train(num_gpus, batch_size, lr):
def train(num_gpus, batch_size, lr):
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
devices = [d2l.try_gpu(i) for i in range(num_gpus)]
# 将模型参数复制到`num_gpus`个GPU
# 将模型参数复制到num_gpus个GPU
device_params = [get_params(params, d) for d in devices]
num_epochs = 10
animator = d2l.Animator('epoch', 'test acc', xlim=[1, num_epochs])
......@@ -378,8 +379,8 @@ def train(num_gpus, batch_size, lr):
# 在GPU 0上评估模型
animator.add(epoch + 1, (d2l.evaluate_accuracy_gpu(
lambda x: lenet(x, device_params[0]), test_iter, devices[0]),))
print(f'test acc: {animator.Y[0][-1]:.2f}, {timer.avg():.1f} sec/epoch '
f'on {str(devices)}')
print(f'测试精度:{animator.Y[0][-1]:.2f},{timer.avg():.1f}秒/轮,'
f'{str(devices)}')
```
让我们看看[**在单个GPU上运行**]效果得有多好。
......@@ -411,7 +412,7 @@ train(num_gpus=2, batch_size=256, lr=0.2)
## 练习
1. 在$k$个GPU上进行训练时,将批量大小从$b$更改为$k \cdot b$,即按GPU的数量进行扩展。
1. 比较不同学习率时模型的精确度随着GPU数量的增加学习率应该如何扩展?
1. 比较不同学习率时模型的精确度随着GPU数量的增加学习率应该如何扩展?
1. 实现一个更高效的`allreduce`函数用于在不同的GPU上聚合不同的参数?为什么这样的效率更高?
1. 实现模型在多GPU下测试精度的计算。
......
......@@ -7,12 +7,12 @@
## 数据并行训练
让我们回顾一下在分布式架构中数据并行的训练方法,因为在实践中它的实现相对简单,因此本节将排除其他内容只对其进行介绍。由于当今的GPU拥有大量的显存,因此在实际场景中(不包括图深度学习)只有数据并行这种并行训练策略值得推荐。图 :numref:`fig_parameterserver`描述了在 :numref:`sec_multi_gpu`中实现的数据并行的变体。其中的关键是梯度的聚合需要在GPU0上完成,然后再将更新后的参数广播给所有GPU。
让我们回顾一下在分布式架构中数据并行的训练方法,因为在实践中它的实现相对简单,因此本节将排除其他内容只对其进行介绍。由于当今的GPU拥有大量的显存,因此在实际场景中(不包括图深度学习)只有数据并行这种并行训练策略值得推荐。图 :numref:`fig_parameterserver`描述了在 :numref:`sec_multi_gpu`中实现的数据并行的变体。其中的关键是梯度的聚合需要在GPU 0上完成,然后再将更新后的参数广播给所有GPU。
![左图:单GPU训练。右图:多GPU训练的一个变体:(1)计算损失和梯度,(2)所有梯度聚合在一个GPU上,(3)发生参数更新,并将参数重新广播给所有GPU。](../img/ps.svg)
![左图:单GPU训练;右图:多GPU训练的一个变体:(1)计算损失和梯度,(2)所有梯度聚合在一个GPU上,(3)发生参数更新,并将参数重新广播给所有GPU](../img/ps.svg)
:label:`fig_parameterserver`
回顾来看,选择GPU0进行聚合似乎是个很随便的决定,当然也可以选择CPU上聚合,事实上只要优化算法支持,在实际操作中甚至可以在某个GPU上聚合其中一些参数,而在另一个GPU上聚合另一些参数。例如,如果有四个与参数向量相关的梯度$\mathbf{g}_1, \ldots, \mathbf{g}_4$,还可以一个GPU对一个$\mathbf{g}_i (i = 1, \ldots, 4$)地进行梯度聚合。
回顾来看,选择GPU 0进行聚合似乎是个很随便的决定,当然也可以选择CPU上聚合,事实上只要优化算法支持,在实际操作中甚至可以在某个GPU上聚合其中一些参数,而在另一个GPU上聚合另一些参数。例如,如果有四个与参数向量相关的梯度$\mathbf{g}_1, \ldots, \mathbf{g}_4$,还可以一个GPU对一个$\mathbf{g}_i (i = 1, \ldots, 4$)地进行梯度聚合。
这样的推断似乎是轻率和武断的,毕竟数学应该是逻辑自洽的。但是,我们处理的是如 :numref:`sec_hardware`中所述的真实的物理硬件,其中不同的总线具有不同的带宽。考虑一个如 :numref:`sec_hardware`中所述的真实的$4$路GPU服务器。如果它的连接是特别完整的,那么可能拥有一个100GbE的网卡。更有代表性的数字是1-10GbE范围内,其有效带宽为100MB/s到1GB/s。因为CPU的PCIe通道太少(例如,消费级的Intel CPU有$24$个通道),所以无法直接与所有的GPU相连接,因此需要[multiplexer](https://www.broadcom.com/products/pcie-switches-bridges/pcie-switches)。CPU在16x Gen3链路上的带宽为16GB/s,这也是每个GPU连接到交换机的速度,这意味着GPU设备之间的通信更有效。
......@@ -30,18 +30,18 @@
当谈及现代深度学习硬件的同步问题时,我们经常会遇到大量的定制的网络连接。例如,AWS p3.16xlarge和NVIDIA DGX-2实例中的连接都使用了 :numref:`fig_nvlink`中的结构。每个GPU通过PCIe链路连接到主机CPU,该链路最多只能以16GB/s的速度运行。此外,每个GPU还具有$6$个NVLink连接,每个NVLink连接都能够以300Gbit/s进行双向传输。这相当于每个链路每个方向约$300\div 8\div 2\approx 18 \mathrm{GB/s}$。简言之,聚合的NVLink带宽明显高于PCIe带宽,问题是如何有效地使用它。
![ 8 台 V100 GPU 服务器上连接 NVLink(图片由英伟达提供)](../img/nvlink.svg)
![8台V100 GPU服务器上连接NVLink(图片由英伟达提供)](../img/nvlink.svg)
:label:`fig_nvlink`
:cite:`Wang.Li.Liberty.ea.2018`的研究结果表明最优的同步策略是将网络分解成两个环,并基于两个环直接同步数据。
:numref:`fig_nvlink_twoloop`描述了网络可以分解为一个具有双NVLink带宽的环(1-2-3-4-5-6-7-8-1)和一个具有常规带宽的环(1-4-6-3-5-8-2-7-1)。在这种情况下,设计一个高效的同步协议是非常重要的。
![ NVLink 网络分解为两个环。](../img/nvlink-twoloop.svg)
![NVLink网络分解为两个环。](../img/nvlink-twoloop.svg)
:label:`fig_nvlink_twoloop`
考虑下面的思维试验:给定由$n$个计算节点(或GPU)组成的一个环,梯度可以从第一个节点发送到第二个节点,在第二个结点将本地的梯度与传送的梯度相加并发送到第三个节点,依此类推。在$n-1$步之后,可以在最后访问的节点中找到聚合梯度。也就是说,聚合梯度的时间随节点数线性增长。但如果照此操作,算法是相当低效的。归根结底,在任何时候都只有一个节点在通信。如果我们将梯度分为$n$个块,并从节点$i$开始同步块$i$,会怎么样?因为每个块的大小是$1/n$,所以总时间现在是$(n-1)/n \approx 1$。换句话说,当我们增大环的大小时,聚合梯度所花费的时间不会增加。这是一个相当惊人的结果。 :numref:`fig_ringsync`说明了$n=4$个节点上的步骤顺序。
![跨4个节点的环同步。每个节点开始向其左邻居发送部分梯度,直到在其右邻居中找到聚合的梯度](../img/ringsync.svg)
![跨4个节点的环同步。每个节点开始向其左邻居发送部分梯度,直到在其右邻居中找到聚合的梯度](../img/ringsync.svg)
:label:`fig_ringsync`
如果我们使用相同的例子,跨$8$个V100 GPU同步160MB,我们得到的结果大约是$2 \times 160 \mathrm{MB} \div (3 \times18 \mathrm{GB/s}) \approx 6 \mathrm{ms}$。这比使用PCIe总线要好,即使我们现在使用的是$8$个GPU。请注意,这些数字在实践中通常会差一些,因为深度学习框架无法将通信组合成大的突发传输。
......@@ -61,12 +61,12 @@
6. 更新后的参数信息发送到本地一个(或多个)GPU中。
7. 所有GPU上的参数更新完成。
![多机多 GPU 分布式并行训练。](../img/ps-multimachine.svg)
![多机多GPU分布式并行训练](../img/ps-multimachine.svg)
:label:`fig_ps_multimachine`
以上这些操作似乎都相当简单,而且事实上它们可以在一台机器内高效地执行,但是当我们考虑多台机器时,就会发现中央的参数服务器成为了瓶颈。毕竟,每个服务器的带宽是有限的,因此对于$m$个工作节点来说,将所有梯度发送到服务器所需的时间是$\mathcal{O}(m)$。我们也可以通过将参数服务器数量增加到$n$来突破这一障碍。此时,每个服务器只需要存储$\mathcal{O}(1/n)$个参数,因此更新和优化的总时间变为$\mathcal{O}(m/n)$。这两个数字的匹配会产生稳定的伸缩性,而不用在乎我们需要处理多少工作节点。在实际应用中,我们使用同一台机器既作为工作节点还作为服务器。设计说明请参考 :numref:`fig_ps_multips`(技术细节请参考 :cite:`Li.Andersen.Park.ea.2014`)。特别是,确保多台机器只在没有不合理延迟的情况下工作是相当困难的。我们在下面忽略了关于阻塞的细节,只简单介绍一下同步和异步(unsynchronized)更新。
![上图:单参数服务器是一个瓶颈,因为它的带宽是有限的。下图:多参数服务器使用聚合带宽存储部分参数。](../img/ps-multips.svg)
![上图:单参数服务器是一个瓶颈,因为它的带宽是有限的;下图:多参数服务器使用聚合带宽存储部分参数](../img/ps-multips.svg)
:label:`fig_ps_multips`
## 键值存储
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册