# 参数服务器

🏷 sec_parameterserver

当我们从一个 GPU 迁移到多个 GPU 时,以及再迁移到包含多个 GPU 的多个服务器时(可能所有服务器的分布跨越了多个机架和多个网络交换机),分布式并行训练算法也需要变得更加复杂。通过细节可以知道,一方面是不同的互连方式的带宽存在极大的区别(例如,NVLink 可以通过设置实现跨66 条链路的高达 100GB/s 的带宽,16 通道的 PCIe4.0 提供 32GB/s 的带宽,而即使是高速 100GbE 以太网也只能提供大约 10GB/s 的带宽);另一方面是期望开发者既能完成统计学习建模还精通系统和网络也是不切实际的。

参数服务器的核心思想首先是由 :cite: Smola.Narayanamurthy.2010 在分布式隐变量模型的背景下引入的。然后,在 :cite: Ahmed.Aly.Gonzalez.ea.2012 中描述了 Push 和 Pull 的语义,又在 :cite: Li.Andersen.Park.ea.2014 中描述了系统和开源库。下面,我们将介绍用于提高计算效率的组件。

# 数据并行训练

让我们回顾一下在分布式架构中数据并行的训练方法,因为在实践中它的实现相对简单,因此本节将排除其他内容只对其进行介绍。由于当今的 GPU 拥有大量的显存,因此在实际场景中(不包括图深度学习)只有数据并行这种并行训练策略值得推荐。图 :numref: fig_parameterserver 描述了在 :numref: sec_multi_gpu 中实现的数据并行的变体。其中的关键是梯度的聚合需要在单个 GPU(GPU 0)上完成,然后再将更新后的参数广播给所有 GPU。

左图是单GPU训练;右图是多GPU训练的一个变体:(1)计算损失和梯度,(2)所有梯度聚合在一个GPU上,(3)发生参数更新,并将参数重新广播给所有GPU
🏷 fig_parameterserver

回顾来看,选择 GPU 0 进行聚合似乎是个很随便的决定,当然也可以选择 CPU 上聚合,事实上只要优化算法支持,在实际操作中甚至可以在某个 GPU 上聚合其中一些参数,而在另一个 GPU 上聚合另一些参数。例如,如果有四个与参数向量相关的梯度g1,,g4\mathbf{g}_1, \ldots, \mathbf{g}_4,还可以一个 GPU 对一个gi(i=1,,4\mathbf{g}_i (i = 1, \ldots, 4)地进行梯度聚合。

这样的推断似乎是轻率和武断的,毕竟数学应该是逻辑自洽的。但是,我们处理的是如 :numref: sec_hardware 中所述的真实的物理硬件,其中不同的总线具有不同的带宽。考虑一个如 :numref: sec_hardware 中所述的真实的44 路 GPU 服务器。如果它的连接是特别完整的,那么可能拥有一个 100GbE 的网卡。更有代表性的数字是 1-10GbE 范围内,其有效带宽为 100MB/s 到 1GB/s。因为 CPU 的 PCIe 通道太少(例如,消费级的 Intel CPU 有2424 个通道),所以无法直接与所有的 GPU 相连接,因此需要 multiplexer。CPU 在 16x Gen3 链路上的带宽为 16GB/s,这也是每个 GPU 连接到交换机的速度,这意味着 GPU 设备之间的通信更有效。

一个4路GPU服务器
🏷 fig_bw_hierarchy

为了便于讨论,我们假设所有梯度共需 160MB。在这种情况下,将其中33 个 GPU 的梯度发送到第44 个 GPU 上需要3030 毫秒(每次传输需要1010 毫秒 = 160MB/16GB/s)。再加上3030 毫秒将权重向量传输回来,得到的结果是总共需要6060 毫秒。如果将所有的数据发送到 CPU,总共需要8080 毫秒,其中将有4040 毫秒的惩罚,因为44 个 GPU 每个都需要将数据发送到 CPU。最后,假设能够将梯度分为44 个部分,每个部分为4040MB,现在可以在不同的 GPU 上同时聚合每个部分。因为 PCIe 交换机在所有链路之间提供全带宽操作,所以传输需要2.5×3=7.52.5\times 3=7.5 毫秒,而不是3030 毫秒,因此同步操作总共需要1515 毫秒。简而言之,一样的参数同步操作基于不同的策略时间可能在1515 毫秒到8080 毫秒之间。 :numref: fig_ps_distributed 描述了交换参数的不同策略。

参数同步策略
🏷 fig_ps_distributed

请注意,我们还可以使用另一个工具来改善性能:在深度网络中,从顶部到底部计算所有梯度需要一些时间,因此即使还在忙着为某些参数计算梯度时,就可以开始为准备好的参数同步梯度了。想了解详细信息可以参见 :cite: Sergeev.Del-Balso.2018 ,想知道如何操作可参考 Horovod

# 环同步(Ring Synchronization)

当谈及现代深度学习硬件的同步问题时,我们经常会遇到大量的定制的网络连接。例如,AWS p3.16xlarge 和 NVIDIA DGX-2 实例中的连接都使用了 :numref: fig_nvlink 中的结构。每个 GPU 通过 PCIe 链路连接到主机 CPU,该链路最多只能以 16GB/s 的速度运行。此外,每个 GPU 还具有66 个 NVLink 连接,每个 NVLink 连接都能够以 300Gbit/s 进行双向传输。这相当于每个链路每个方向约300÷8÷218GB/s300\div 8\div 2\approx 18 \mathrm{GB/s}。简言之,聚合的 NVLink 带宽明显高于 PCIe 带宽,问题是如何有效地使用它。

在8台V100 GPU服务器上连接NVLink(图片由英伟达提供)
🏷 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网络分解为两个环。
🏷 fig_nvlink_twoloop

考虑下面的思维试验:给定由nn 个计算节点(或 GPU)组成的一个环,梯度可以从第一个节点发送到第二个节点,在第二个结点将本地的梯度与传送的梯度相加并发送到第三个节点,依此类推。在n1n-1 步之后,可以在最后访问的节点中找到聚合梯度。也就是说,聚合梯度的时间随节点数线性增长。但如果照此操作,算法是相当低效的。归根结底,在任何时候都只有一个节点在通信。如果我们将梯度分为nn 个块,并从节点ii 开始同步块ii,会怎么样?因为每个块的大小是1/n1/n,所以总时间现在是(n1)/n1(n-1)/n \approx 1。换句话说,当我们增大环的大小时,聚合梯度所花费的时间不会增加。这是一个相当惊人的结果。 :numref: fig_ringsync 说明了n=4n=4 个节点上的步骤顺序。

跨4个节点的环同步。每个节点开始向其左邻居发送部分梯度,直到在其右邻居中找到聚合的梯度
🏷 fig_ringsync

如果我们使用相同的例子,跨88 个 V100 GPU 同步 160MB,我们得到的结果大约是2×160MB÷(3×18GB/s)6ms2 \times 160 \mathrm{MB} \div (3 \times18 \mathrm{GB/s}) \approx 6 \mathrm{ms}。这比使用 PCIe 总线要好,即使我们现在使用的是88 个 GPU。请注意,这些数字在实践中通常会差一些,因为深度学习框架无法将通信组合成大的突发传输。

注意到有一种常见的误解认为环同步与其他同步算法在本质上是不同的,实际上与简单的树算法相比其唯一的区别是同步路径稍微精细一些。

# 多机训练

新的挑战出现在多台机器上进行分布式训练:我们需要服务器之间相互通信,而这些服务器又只通过相对较低的带宽结构连接,在某些情况下这种连接的速度可能会慢一个数量级,因此跨设备同步是个棘手的问题。毕竟,在不同机器上运行训练代码的速度会有细微的差别,因此如果想使用分布式优化的同步算法就需要同步(synchronize)这些机器。
:numref: fig_ps_multimachine 说明了分布式并行训练是如何发生的。

  1. 在每台机器上读取一组(不同的)批量数据,在多个 GPU 之间分割数据并传输到 GPU 的显存中。基于每个 GPU 上的批量数据分别计算预测和梯度。
  2. 来自一台机器上的所有的本地 GPU 的梯度聚合在一个 GPU 上(或者在不同的 GPU 上聚合梯度的某些部分)。
  3. 每台机器的梯度被发送到其本地 CPU 中。
  4. 所有的 CPU 将梯度发送到中央参数服务器中,由该服务器聚合所有梯度。
  5. 然后使用聚合后的梯度来更新参数,并将更新后的参数广播回各个 CPU 中。
  6. 更新后的参数信息发送到本地一个(或多个)GPU 中。
  7. 所有 GPU 上的参数更新完成。

多机多GPU分布式并行训练
🏷 fig_ps_multimachine

以上这些操作似乎都相当简单,而且事实上它们可以在一台机器内高效地执行,但是当我们考虑多台机器时,就会发现中央的参数服务器成为了瓶颈。毕竟,每个服务器的带宽是有限的,因此对mm 个工作节点来说,将所有梯度发送到服务器所需的时间是O(m)\mathcal{O}(m)。我们也可以通过将参数服务器数量增加到nn 来突破这一障碍。此时,每个服务器只需要存储O(1/n)\mathcal{O}(1/n) 个参数,因此更新和优化的总时间变为O(m/n)\mathcal{O}(m/n)。这两个数字的匹配会产生稳定的伸缩性,而不用在乎我们需要处理多少工作节点。在实际应用中,我们使用同一台机器既作为工作节点还作为服务器。设计说明请参考 :numref: fig_ps_multips (技术细节请参考 :cite: Li.Andersen.Park.ea.2014 )。特别是,确保多台机器只在没有不合理延迟的情况下工作是相当困难的。

上图:单参数服务器是一个瓶颈,因为它的带宽是有限的;下图:多参数服务器使用聚合带宽存储部分参数
🏷 fig_ps_multips

# 键值存储

在实践中,实现分布式多 GPU 训练所需要的步骤绝非易事。这就是公共抽象值得使用的原因,公共抽象即重新定义具有更新语义的键-值存储(key-value store)的抽象。

在许多工作节点和许多 GPU 中,梯度ii 的计算可以定义为

gi=kworkersjGPUsgijk,\mathbf{g}_{i} = \sum_{k \in \text{workers}} \sum_{j \in \text{GPUs}} \mathbf{g}_{ijk},

其中gijk\mathbf{g}_{ijk} 是在工作节点kk 的 GPUjj 上拆分的梯度ii 的一部分。这个运算的关键在于它是一个交换归约(commutative reduction),也就是说,它把许多向量变换成一个向量,而运算顺序在完成向量变换时并不重要。这对实现我们的目标来说是非常好的,因为不需要为何时接收哪个梯度进行细粒度的控制。此外,请注意,这个操作在不同的ii 之间是独立的。

这就允许我们定义下面两个操作:push(用于累积梯度)和 pull(用于取得聚合梯度)。因为我们有很多层,也就有很多不同的梯度集合,因此需要用一个键ii 来对梯度建索引。这个与 Dynamo :cite: DeCandia.Hastorun.Jampani.ea.2007 中引入的键-值存储之间存在相似性并非巧合。它们两个定义都拥有许多相似的性质,特别是在多个服务器之间分发参数时。

键-值存储的 push 与 pull 操作描述如下:

  • **push(key,value)** 将特定的梯度值从工作节点发送到公共存储,在那里通过某种方式(例如,相加)来聚合值;
  • **pull(key,value)** 从公共存储中取得某种方式(例如,组合来自所有工作节点的梯度)的聚合值。

通过将同步的所有复杂性隐藏在一个简单的 push 和 pull 操作背后,我们可以将统计建模人员(他们希望能够用简单的术语表达优化)和系统工程师(他们需要处理分布式同步中固有的复杂性)的关注点解耦。

# 小结

  • 同步需要高度适应特定的网络基础设施和服务器内的连接,这种适应会严重影响同步所需的时间。
  • 环同步对于 p3 和 DGX-2 服务器是最佳的,而对于其他服务器则未必。
  • 当添加多个参数服务器以增加带宽时,分层同步策略可以工作的很好。

# 练习

  1. 请尝试进一步提高环同步的性能吗。(提示:可以双向发送消息。)
  2. 在计算仍在进行中,可否允许执行异步通信?它将如何影响性能?
  3. 怎样处理在长时间运行的计算过程中丢失了一台服务器这种问题?尝试设计一种容错机制来避免重启计算这种解决方案?

Discussions