PyTorch 1.x 模型训练加速指南(二)
NNI 有很多模块,但为了简化模型,我们只会使用其中的两个,即pruning和speedup在本章中,您了解到通过减少参数数量来简化模型可以加速网络训练过程,使模型能够在资源受限的平台上运行。接着,我们看到简化过程包括两个阶段:剪枝和压缩。前者负责确定网络中必须删除的参数,而后者则有效地从模型中移除这些参数。尽管 PyTorch 提供了一个 API 来剪枝模型,但它并不完全有助于简化模型。因此,介
原文:
zh.annas-archive.org/md5/787ca80dbbc0168b14234d14375188ba
译者:飞龙
第六章:简化模型
您听说过“简约原则”吗?简约原则在模型估计的背景下,意味着尽可能保持模型简单。这一原则来自这样的假设:复杂模型(参数数量较多的模型)会过度拟合训练数据,从而降低泛化能力和良好预测的能力。
另外,简化神经网络有两个主要好处:减少模型训练时间和使模型能够在资源受限的环境中运行。简化模型的一种方法是通过使用修剪和压缩技术来减少神经网络参数的数量。
在本章中,我们展示如何通过减少神经网络参数的数量来简化模型,而不牺牲其质量。
以下是本章节将学到的内容:
-
简化模型的关键好处
-
模型修剪和压缩的概念与技术
-
如何使用 Microsoft NNI 工具包简化模型
技术要求
您可以在书的 GitHub 代码库中找到本章节提到的所有示例的完整代码,网址为 github.com/PacktPublishing/Accelerate-Model-Training-with-PyTorch-2.X/blob/main
。
您可以访问您喜爱的环境来执行这个笔记本,比如 Google Colab 或者 Kaggle。
了解模型简化的过程
简单来说,简化模型涉及移除神经网络的连接、神经元或整个层,以获得一个更轻的模型,即具有减少参数数量的模型。当然,简化版本的效率必须非常接近原始模型的效果。否则,简化模型就没有任何意义。
要理解这个主题,我们必须回答以下问题:
-
为什么要简化模型?(原因)
-
如何简化模型?(过程)
-
什么时候简化模型?(时机)
我们将在接下来的章节中逐一回答这些问题,以便全面理解模型简化。
注意
在继续本章之前,必须指出模型简化仍然是一个开放的研究领域。因此,本书中提到的一些概念和术语可能与其他资料或框架工具包中的使用稍有不同。
为什么要简化模型?(原因)
为了深入了解为什么需要简化模型,让我们利用一个简单而又生动的类比。
考虑这样一个假设情境,我们必须建造一座桥来连接河的两岸。为了安全起见,我们决定在桥每两米放置一个支柱,如图 6**.1所示:
图 6.1 – 桥梁类比
这座桥似乎非常安全,由其 16 根柱子支撑。然而,有人可能会看到这个项目并说我们不需要所有 16 根柱子来维持桥梁。作为桥梁设计者,我们可以主张安全第一;因此,增加柱子以确保桥梁完整性是没有问题的。
即使如此,如果我们可以稍微减少柱子数量而不影响桥梁的结构呢?换句话说,也许用 16 根柱子支撑桥梁在安全性上有些过剩。正如我们在图6.2中看到的那样,也许只需要九根柱子在这种情况下就足够了:
图6.2 - 移除一些柱子后桥梁仍然保持竖立状态
如果我们在桥上减少柱子数量并保持其安全性,我们将减少预算和建造时间,同时简化未来的维护过程。没有合理的理由反驳这种方法。
这个天真的类比有助于加热我们关于简化模型原因的讨论。至于桥梁上的柱子,一个神经网络模型是否需要所有的参数来实现良好的准确性?答案并不简单,而是取决于问题类型和模型本身。然而,考虑到简化模型的表现与原始模型完全相同,为什么不尝试前者呢?
毕竟,简化模型具有明显的好处:
-
加速训练过程:通常,由较少参数组成的神经网络训练速度更快。正如在第一章**,拆解训练过程中讨论的那样,参数数量直接影响神经网络的计算负担。
-
在资源受限环境中运行推理:有些模型太大无法存储,执行起来太复杂,无法适应内存和计算能力有限的环境。在这种情况下,唯一的办法是尽可能简化模型来在这些环境中运行。
现在,简化模型的好处已经十分明显,让我们跳到下一节来学习如何执行这个过程。
如何简化模型?(过程)
我们通过应用包括两个步骤的工作流程来简化模型:修剪和压缩:
图 6.3 - 简化工作流程
如图6.3所示,简化工作流程接收密集神经网络作为输入,其中所有神经元都与自身完全连接,输出原始模型的简化版本。换句话说,该工作流程将密集神经网络转换为稀疏神经网络。
注
术语密集和稀疏来自数学,并用于描述矩阵。密集矩阵充满有用的数字,而稀疏矩阵则具有大量空值(零)。由于神经网络的参数可以用 n 维矩阵表示,非全连接神经网络也被称为稀疏神经网络,因为神经元之间的空连接数量很高。
让我们详细查看工作流程,以理解每个步骤的作用,从修剪阶段开始。
修剪阶段
修剪阶段负责接收原始模型并剪除连接(权重)、神经元(偏置)和滤波器(核值)中存在的参数,从而得到一个修剪过的模型:
图 6.4 – 修剪阶段
正如图 6.4 所示,原始模型中的许多连接已被禁用(在修剪模型中表示为不透明的线条)。修剪阶段根据过程中应用的技术决定应移除哪些参数。
修剪技术具有三个维度:
-
准则:定义要切断的参数
-
范围:确定是否丢弃整个结构(神经元、层或滤波器)或孤立的参数
-
方法:定义是一次性修剪网络还是迭代修剪模型,直到达到某个停止准则
注意
模型修剪是一个崭新的世界。因此,您可以轻松找到许多提出新方法和解决方案的科学论文。一篇有趣的论文名为深度神经网络修剪调查:分类、比较、分析和建议,概述了这一领域的最新进展,并简要介绍了诸如量化和知识蒸馏等其他简化技术。
实际上,修剪后的模型占用与原始模型相同的内存量,并且需要相同的计算能力。这是因为 null 参数虽然在前向和反向计算及结果上没有实际影响,但并未从网络中完全移除。
例如,假设由三个神经元组成的两个全连接层。连接的权重可以表示为一个矩阵,如图 6.5 所示:
图 6.5 – 权重表示为矩阵
在应用修剪过程后,神经网络有三个连接被禁用,正如我们在图 6.6 中所见:
图 6.6 – 修剪后的权重更改为 null
注意,权重已更改为 null(0.00),这些权重所代表的连接在网络计算中已被移除。因此,这些连接在神经网络结果的意义上实际上并不存在。
然而,数据结构与原始模型完全相同。我们仍然有九个浮点数,所有这些数仍然乘以它们各自神经元的输出(虽然没有实际效果)。从内存消耗和计算负担的角度来看,到目前为止什么都没有改变。
如果简化模型的目的是减少参数数量,为什么我们继续使用与之前相同的结构呢?保持冷静,让我们继续执行简化工作流的第二阶段:压缩阶段。
压缩阶段
如图 6**.7所示,压缩阶段接收修剪模型作为输入,并生成一个仅由未修剪参数组成的新脑模型,即修剪过程未触及的参数:
图 6.7 – 压缩阶段
新网络的形状可以与原始模型完全不同,神经元和层次的布置也各异。总之,压缩过程可以自由生成一个新模型,因为它遵循修剪步骤保留的参数。
因此,压缩阶段有效地去除了修剪模型的参数,从而得到一个真正简化的模型。让我们以图 6**.8中的例子来理解模型压缩后发生了什么:
图 6.8 – 应用于修剪网络的模型压缩
在这个例子中,一组禁用的参数——连接——已从模型中删除,将权重矩阵减少了三分之一。因此,权重矩阵现在占用更少的内存,并且在完成前向和后向阶段时需要更少的操作。
注意
我们可以把修剪和压缩阶段之间的关系想象成从磁盘中删除文件的过程。当我们要求操作系统删除文件时,它只是将分配给文件的块标记为自由。换句话说,文件内容仍然存在,只有当被新文件覆盖时才会被擦除。
我们何时简化模型?(时刻)
我们可以在训练神经网络之前或之后简化模型。换句话说,模型简化过程可以应用于未训练、预训练或已训练的模型,如下所述:
-
未训练模型:我们的目标是加快训练过程。由于模型尚未训练,神经网络填充有随机参数,大多数修剪技术无法有效地工作。为了解决这个问题,通常在简化模型之前会运行一个预热阶段,即在简化模型之前对网络进行单个时期的训练。
-
预训练模型:我们使用预训练网络来解决特定问题域的问题,因为这些网络在一般领域上具有通用的效率。在这种情况下,我们不需要执行预热阶段,因为模型已经训练好了。
-
训练好的模型:通常简化训练好的模型是为了在资源受限的环境中部署训练好的网络。
现在我们已经回答了关于模型简化的问题,我们应该使用 PyTorch 及其工具包来简化模型吗?请跟随我到下一节来学习如何做到这一点!
使用 Microsoft NNI 简化模型
神经网络智能(NNI)是微软创建的开源项目,旨在帮助深度学习从业者自动化任务,如超参数自动化和神经架构搜索。
NNI 还有一套工具集,用于更简单直接地处理模型简化。因此,我们可以通过在原始代码中添加几行代码来轻松简化模型。NNI 支持 PyTorch 和其他知名的深度学习框架。
注意
PyTorch 有自己的 API 来修剪模型,即torch.prune
。不幸的是,在编写本书时,这个 API 不提供压缩模型的机制。因此,我们决定引入 NNI 作为完成此任务的解决方案。有关 NNI 的更多信息,请访问github.com/microsoft/nni
。
让我们从下一节开始对 NNI 进行概述。
NNI 概述
由于 NNI 不是 PyTorch 的本地组件,我们需要通过执行以下命令使用 pip 安装它:
pip install nni
NNI 有很多模块,但为了简化模型,我们只会使用其中的两个,即pruning
和speedup
:
from nni.compression.pytorch import pruning, speedup
修剪模块
pruning
模块提供一组修剪技术,也称为修剪器。每个修剪器应用特定的方法来修剪模型,并需要一组特定的参数。在修剪器所需的参数中,有两个是强制性的:模型和配置列表。
配置列表是一个基于字典结构的结构,用于控制修剪器的行为。从配置列表中,我们可以指示修剪器必须处理哪些结构(层、操作或过滤器),以及哪些结构它应该保持不变。
例如,以下配置列表告诉修剪器处理所有实施Linear
运算符的层(使用类torch.nn.Linear
创建的层),除了名为layer4
的层。此外,修剪器将尝试使 30%的参数归零,如sparse_ratio
键中所示:
config_list = [{ 'op_types': ['Linear'],
'exclude_op_names': ['layer4'],
'sparse_ratio': 0.3
}]
注意
您可以在nni.readthedocs.io/en/stable/compression/config_list.html
找到配置列表接受的键值对的完整列表。
设置了配置列表后,我们就可以实例化修剪器,如下所示:
pruner = pruning.L1NormPruner(model, config_list)
由修剪器提供的最关键方法称为compress
。尽管名称暗示的是另一回事,它通过应用相应的剪枝算法执行剪枝过程。
compress
方法返回一个名为masks的数据结构,该结构表示剪枝算法丢弃的参数。进一步使用此信息有效地从网络中移除被修剪的参数。
注意
如前所述,简化过程仍在进行中。因此,我们可能会遇到一些技巧,比如术语的不一致使用。这就是为什么 NNI 将剪枝阶段称为compress
,尽管压缩步骤是由另一种称为speedup
的方法完成的。
请注意,到目前为止,原始模型确实没有任何变化;还没有。要有效地移除被修剪的参数,我们必须依赖于speedup
模块。
speedup 模块
speedup
模块提供了一个名为ModelSpeedup
的类,用于创建速度器。速度器在修剪模型上执行压缩阶段,即有效地移除修剪器丢弃的参数。
在修剪器方面,我们还必须从ModelSpeedup
类实例化一个对象。此类需要三个必填参数:修剪模型、一个输入样本和由修剪器生成的掩码:
speeder = speedup.ModelSpeedup(model, input_sample, masks)
之后,我们只需调用speedup_model
方法,使速度器可以压缩模型并返回原始模型的简化版本:
speeder.speedup_model()
现在您已经概述了通过 NNI 简化模型的基本步骤,让我们跳到下一节,学习如何在实际示例中使用此工具包。
NNI 的实际应用!
为了看到 NNI 在实践中的工作,让我们简化我们众所周知的 CNN 模型。在此示例中,我们将通过使用 CIFAR-10 数据集来简化此模型。
注意
此部分显示的完整代码可在github.com/PacktPublishing/Accelerate-Model-Training-with-PyTorch-2.X/blob/main/code/chapter06/nni-cnn_cifar10.ipynb
找到。
让我们从计算 CNN 模型的原始参数数目开始:
model = CNN()print(count_parameters(model))
2122186
CNN 模型在偏差、权重和神经网络的滤波器之间有2,122,186
个参数。我们仅在 CPU 机器上使用 CIFAR-10 数据集进行了 10 个时期的训练,因为我们有兴趣比较不同修剪配置之间的训练时间和相应的准确性。因此,原始模型在 122 秒内训练,达到 47.10%的准确性。
好的,让我们移除桥的一些支柱,看看它是否仍然站立。我们将通过考虑以下策略简化 CNN 模型:
-
操作类型:Conv2d
-
每层稀疏度:0.50
-
修剪器算法:L1 Norm
这个策略告诉简化过程只关注神经网络的卷积层,并且对于每一层,裁剪算法必须丢弃 50%的参数。由于我们正在简化一个全新的模型,我们需要执行预热阶段来为网络引入一些有价值的参数。
对于这个实验,我们选择了 L1 范数裁剪器,它根据 L1 范数测量的大小来移除参数。简单来说,裁剪器会丢弃对神经网络结果影响较小的参数。
注意
您可以在L1 Norm pruner找到更多关于 L1 范数裁剪器的信息。
下面的代码摘录显示了应用上述策略简化 CNN 模型所需的几行代码:
config_list = [{'op_types': ['Conv2d'], 'sparsity_per_layer': 0.50}]
pruner = pruning.L1NormPruner(model, config_list)
_, masks = pruner.compress()
pruner._unwrap_model()
input_sample, _ = next(iter(train_loader))
speeder = speedup.ModelSpeedup(model, input_sample, masks)
speeder.speedup_model()
在简化过程中,NNI 将输出一堆如下的行:
[2023-09-23 19:44:30] start to speedup the model[2023-09-23 19:44:30] infer module masks...
[2023-09-23 19:44:30] Update mask for layer1.0
[2023-09-23 19:44:30] Update mask for layer1.1
[2023-09-23 19:44:30] Update mask for layer1.2
[2023-09-23 19:44:30] Update mask for layer2.0
[2023-09-23 19:44:30] Update mask for layer2.1
[2023-09-23 19:44:30] Update mask for layer2.2
[2023-09-23 19:44:30] Update mask for .aten::size.8
[2023-09-23 19:44:30] Update mask for .aten::Int.9
[2023-09-23 19:44:30] Update mask for .aten::reshape.10
在完成这个过程后,我们可以验证原始神经网络的参数数量如预期般减少了约 50%:
print(count_parameters(model))1059306
好吧,模型变得更小更简单了。但是训练时间和效率呢?让我们看看!
我们使用相同的超参数通过相同数量的 epochs 对 CIFAR-10 进行了简化模型的训练。简化模型的训练过程只需 89 秒完成,表现提升了 37%! 虽然模型的效率略有下降(从 47.10%降至 42.71%),但仍接近原始版本。
有趣的是要注意训练时间、准确性和稀疏比之间的权衡。如表 6.1所示,当从网络中移除 80%的参数时,模型的效率降至 38.87%。另一方面,训练过程仅需 76 秒完成,比训练原始网络快 61%:
每层稀疏度 | 训练时间 | 准确性 |
---|---|---|
10% | 118 | 47.26% |
20% | 113 | 45.84% |
30% | 107 | 44.66% |
40% | 100 | 45.18% |
50% | 89 | 42.71% |
60% | 84 | 41.90% |
70% | 81 | 40.84% |
80% | 76 | 38.87% |
表 6.1 – 模型准确性、训练时间和稀疏度水平之间的关系
俗话说,天下没有免费的午餐。因此,当简化模型时,准确性有望略微下降。这里的目标是在模型质量略微降低的情况下找到性能改进的平衡点。
在本节中,我们学习了如何使用 NNI 来简化我们的模型。通过在我们原始代码中改变几行代码,我们可以通过减少一定数量的连接来简化模型,从而减少训练时间,同时保持模型的质量。
下一节将提出一些问题,帮助您记住本章学到的内容。
测验时间!
让我们通过回答一些问题来回顾本章所学内容。首先,请尝试不查阅资料回答这些问题。
注意
所有这些问题的答案都可以在github.com/PacktPublishing/Accelerate-Model-Training-with-PyTorch-2.X/blob/main/quiz/chapter06-answers.md
找到。
在开始测验之前,请记住这根本不是一个测试!本节旨在通过回顾和巩固本章节涵盖的内容来补充您的学习过程。
为以下问题选择正确的选项。
-
在简化工作流程时需要执行哪两个步骤?
-
减少和压缩。
-
剪枝和减少。
-
剪枝和压缩。
-
减少和压缩。
-
-
剪枝技术通常具有以下维度:
-
标准、范围和方法。
-
算法、范围和大小。
-
标准、约束和目标。
-
算法、约束和目标。
-
-
关于压缩阶段,我们可以断言以下哪一个?
-
它接收一个压缩过的模型作为输入,并验证模型的完整性。
-
它接收一个压缩过的模型作为输入,并生成一个仅由未剪枝参数部分组成的模型。
-
它接收一个被剪枝的模型作为输入,并生成一个仅由未剪枝参数组成的新脑模型。
-
它接收一个被剪枝的模型作为输入,并评估应用于该模型的剪枝程度。
-
-
我们可以在以下哪些情况下执行模型简化过程?
-
仅预训练模型。
-
仅预训练和非训练模型。
-
仅非训练模型。
-
非训练、预训练和已训练模型。
-
-
简化训练模型的主要目标之一是什么?
-
加速训练过程。
-
将其部署在资源受限的环境中。
-
提高模型的准确性。
-
没有理由简化已训练的模型。
-
-
考虑以下配置列表传递给剪枝者:
config_list = [{ 'op_types': ['Conv2d'], 'exclude_op_names': ['layer2'], 'sparse_ratio': 0.25 }]
剪枝者会执行以下哪些操作?
-
剪枝者将尝试使所有网络参数的 75%无效化。
-
剪枝者将尝试使所有全连接层参数的 25%无效化。
-
剪枝者将尝试使卷积层参数的 25%无效化,除了标记为“layer2”的那个。
-
剪枝者将尝试使卷积层参数的 75%无效化,除了标记为“layer2”的那个。
-
-
在执行简化工作流程后,模型的准确性更可能发生什么变化?
-
模型的准确性倾向于增加。
-
模型的准确性肯定会增加。
-
模型的准确性倾向于降低。
-
模型的准确性保持不变。
-
-
在简化以下哪些内容之前,有必要执行热身阶段?
-
非训练模型。
-
已训练的模型。
-
预训练模型。
-
以上都不是。
-
摘要
在本章中,您了解到通过减少参数数量来简化模型可以加速网络训练过程,使模型能够在资源受限的平台上运行。
接着,我们看到简化过程包括两个阶段:剪枝和压缩。前者负责确定网络中必须删除的参数,而后者则有效地从模型中移除这些参数。
尽管 PyTorch 提供了一个 API 来剪枝模型,但它并不完全有助于简化模型。因此,介绍了 Microsoft NNI,一个强大的工具包,用于自动化与深度学习模型相关的任务。在 NNI 提供的功能中,这个工具提供了一个完整的工作流程来简化模型。所有这些都是通过向原始代码添加几行新代码来实现的。
在接下来的章节中,您将学习如何减少神经网络采用的数值精度,以加快训练过程并减少存储模型所需的内存量。
第七章:采用混合精度
科学计算是科学家用来推动已知界限的工具。生物学、物理学、化学和宇宙学是依赖科学计算来模拟和建模真实世界的领域的例子。在这些知识领域中,数值精度对于产生一致的结果至关重要。由于在这种情况下每个小数位都很重要,科学计算通常采用双精度数据类型来表示具有最高可能精度的数字。
然而,额外信息的需求是有代价的。数值精度越高,处理这些数字所需的计算能力就越高。此外,更高的精度还要求更高的内存空间,增加内存消耗。
面对这些缺点,我们必须问自己:我们是否需要如此高的精度来构建我们的模型?通常情况下,我们不需要!在这方面,我们可以针对一些操作降低数值精度,从而加快训练过程并节省一些内存空间。当然,这个过程不应影响模型产生良好预测的能力。
在本章中,我们将向您展示如何采用混合精度策略来加快模型训练过程,同时又不损害模型的准确性。除了总体上减少训练时间外,这种策略还可以利用特殊硬件资源,如 NVIDIA GPU 上的张量核心。
以下是本章的学习内容:
-
计算机系统中数值表示的概念
-
为什么降低精度可以减少训练过程的计算负担
-
如何在 PyTorch 上启用自动混合精度
技术要求
您可以在本书的 GitHub 仓库中找到本章中提到的示例代码的完整代码:github.com/PacktPublishing/Accelerate-Model-Training-with-PyTorch-2.X/blob/main
。
你可以使用你喜欢的环境来执行这段代码,比如 Google Colab 或者 Kaggle。
记住数值精度
在深入探讨采用混合精度策略的好处之前,重要的是让您理解数字表示和常见数据类型的基础知识。让我们首先回顾一下计算机如何表示数字。
计算机如何表示数字?
计算机是一种机器,拥有有限的资源,旨在处理比特,即它能管理的最小信息单位。由于数字是无限的,计算机设计师不得不付出很大努力,找到一种方法来在实际机器中表示这一理论概念。
为了完成这项工作,计算机设计师需要处理与数值表示相关的三个关键因素:
-
符号:数字是正数还是负数
-
范围:表示数字的区间。
-
精度:小数位数。
考虑到这些因素,计算机架构师成功地定义了数值数据类型,不仅可以表示整数和浮点数,还可以表示字符、特殊符号,甚至复数。
让我们通过一个例子来使事情更具体化。计算机架构和编程语言通常使用 32 位来通过所谓的 INT32 格式表示整数,如图 7**.1所示:
图 7.1 – 32 位整数的数字表示
在这 32 位中,有 1 位用于表示数字的符号,其中 0 表示正数,1 表示负数。其余的 31 位用于表示数字本身。有了 31 位,我们可以得到 2,147,483,648 个不同的 0 和 1 的组合。因此,这种表示法的数值范围为-2,147,483,648 到+2,147,483,647。注意正数部分比负数部分少一个数,因为我们必须表示零。
这是有符号整数表示的一个示例,其中 1 位用于确定数值的正负。然而,如果在某些情况下只有正数是相关的,可以使用无符号表示法。无符号表示法使用所有 32 位来表示数字,因此其数值区间为 0 到 4,294,967,295。
在不需要更大区间的情况下,可以采用更便宜的格式 - 仅有 8 位 - 来表示整数:INT8 表示法,如图 7**.2所示。此表示法的无符号版本提供 0 到 255 之间的区间:
图 7.2 – INT8 格式中数字的示例
假设 1 字节等于 8 位(在某些计算机架构上这种关系可能不同),INT32 格式需要 4 字节来表示一个整数,而 INT8 只需 1 字节即可。因此,INT32 格式比 INT8 昂贵四倍,需要更多的内存空间和计算能力来处理这些数字。
整数表示方法非常简单。然而,要表示浮点数(小数),计算机架构师们必须设计更复杂的解决方案,这是我们将在下一节中学习的内容。
浮点数表示
现代计算机采用 IEEE 754 标准来表示浮点数。该标准定义了两种浮点数表示方式,即单精度和双精度。单精度,也称为 FP32 或 float32,使用 32 位,而双精度,也称为 FP64 或 float64,使用 64 位。
单精度和双精度在结构上相似,由三个元素组成:符号、指数和分数部分(有效数字),如图 7**.3所示。
图 7.3 – 浮点表示结构
符号位与整数表示的意义相同,即定义数字是正数还是负数。指数部分定义了数值范围,而分数部分则决定了数值的精度,即小数位数。
两种格式都使用 1 位来表示符号。关于其他部分,FP32 和 FP64 分别使用 8 和 11 位来表示指数,23 和 52 位来表示分数部分。粗略地说,FP64 的范围略高于 FP32,因为前者在指数部分比后者多使用了 3 位。另一方面,FP64 由于为分数部分保留了 52 位,因此提供了超过 FP32 两倍的双精度。
FP64 提供的高数值精度使其非常适合科学计算,其中每一位额外的小数点对于解决这一领域的问题至关重要。由于双精度需要 8 字节来表示一个数字,通常仅在需要如此高精度的任务中使用。在没有这种要求的情况下,使用单精度数据类型更为合适。这也是为什么训练过程通常采用 FP32 的原因。
新型数据类型
根据 IEEE 754 标准定义,单精度是表示浮点数的默认格式。然而,随着时间的推移,新问题的出现要求新的方法、方法和数据类型。
在新型数据类型中,我们可以突出显示三种特别适合机器学习模型的类型:FP16、BFP16 和 TF32。
FP16
FP16 或 float16,正如你可能猜到的那样,使用 16 位来表示浮点数,如 图 7*.4* 所示。由于它只使用了单精度的一半 32 位,这种新数据类型被称为半精度。
FP16 的结构与其兄弟 FP32 和 FP64 相同。区别在于用于表示指数和分数部分的位数。FP16 使用 5 位和 10 位分别表示指数和分数部分:
图 7.4 – FP16 格式结构
在需要的精度超出 float32 提供的情况下,FP16 是 FP32 的一种替代方案。在这些情况下,使用更简单的数据类型来节省内存空间和减少操作数据所需的计算能力要好得多。
BFP16
BFP16 或 bfloat16 是由谷歌大脑(Google Brain)——谷歌的人工智能研究团队创造的一种新型数据类型。BFP16 类似于 FP16,使用 16 位来表示浮点数。然而,与 FP16 不同,BFP16 的重点是保留与 FP32 相同的数值范围,同时显著降低精度。因此,BFP16 使用 8 位来表示指数部分(与 FP32 相同),但只使用 7 位来表示分数部分,如 图 7*.5* 所示:
图 7.5 – BFP16 格式的结构
谷歌创建了 BFP16 以满足机器学习和人工智能工作负载的需求,其中精度并不是很重要。截至撰写时,bfloat16 受到英特尔 Xeon 处理器(通过 AVX-512 BF16 指令集)、谷歌 TPU v2 和 v3、NVIDIA GPU A100 以及其他硬件平台的支持。
请注意,虽然这些硬件平台支持 TF32,但不能保证 bfloat16 会被所有软件支持和实现。例如,PyTorch 只支持在 CPU 上使用 bfloat16,尽管 NVIDIA GPU 也支持这种数据类型。
TF32
TF32代表 TensorFloat 32,尽管名字如此,但是这是由 NVIDIA 创建的 19 位格式。TF32 是 FP32 和 FP16 格式的混合体,因为它使用 8 位表示指数和 10 位表示小数部分,类似于 FP32 和 FP16 的格式。因此,TF32 结合了 FP16 提供的精度和 FP32 的数值范围。图 7.6以图形方式描述了 TF32 的格式:
图 7.6 – TF32 格式的结构
与 bfloat16 类似,TF32 也是专门为处理人工智能工作负载而创建的,目前受到较新的 GPU 代数的支持,从安培架构(NVIDIA A100)开始。除了在范围和精度之间提供平衡的好处外,TF32 还受 Tensor Core 的支持,这是 NVIDIA GPU 上可用的特殊硬件组件。我们稍后将在本章更详细地讨论 Tensor Core。
总结一下!
是的,当然!那是大量信息需要消化。因此,表 7.1对此进行了总结:
格式 | 指数位数 | 小数位数 | 字节数 | 别名 |
---|---|---|---|---|
FP32 | 8 | 23 | 4 | Float32,单精度 |
FP64 | 11 | 52 | 8 | Float64,双精度 |
FP16 | 5 | 10 | 2 | Float16,半精度 |
BFP16 | 8 | 7 | 2 | Bfloat16 |
TF32 | 8 | 10 | 4 | TensorFloat32 |
表 7.1 – 数值格式摘要
注意
Grigory Sapunov 撰写了一个关于数据类型的好摘要。您可以在moocaholic.medium.com/fp64-fp32-fp16-bfloat16-tf32-and-other-members-of-the-zoo-a1ca7897d407
找到它。
正如您可能已经注意到的那样,数值范围和精度越高,表示这些数字所需的字节数也越多。因此,数值格式会增加存储和处理这些数字所需的资源量。
如果我们不需要如此高的精度(和范围)来训练我们的模型,为什么不采用比通常的 FP32 更便宜的格式呢?这样做可以节省内存并加速整个训练过程。
我们有选择不必更改整个构建过程的数值精度,而是采用下面解释的混合精度方法的选项。
理解混合精度策略
使用低精度格式的好处是显而易见的。除了节省内存外,处理低精度数据所需的计算能力也比处理高精度数字所需的少。
加速机器学习模型训练过程的一种方法涉及采用混合精度策略。沿着第六章的思路,简化模型,我们将通过关于这种方法的一些简单问题(当然也会回答)来理解这一策略。
注意
当你搜索有关降低深度学习模型精度的信息时,你可能会遇到一个称为模型量化的术语。尽管这些是相关的术语,但混合精度的目标与模型量化有很大不同。前者旨在通过采用降低的数值精度格式加速训练过程。后者侧重于减少已训练模型的复杂性以在推理阶段使用。因此,务必不要混淆这两个术语。
让我们首先回答最基本的问题:这个策略到底是什么?
什么是混合精度?
正如你可能猜到的那样,混合精度方法将不同精度的数值格式混合使用。该方法旨在尽可能使用更便宜的格式 - 换句话说,它仅在必要时保留默认精度。
在训练过程的上下文中,我们希望将 FP32 - 在此任务中采用的默认数值格式 - 与 FP16 或 BFP16 等低精度表示混合。具体来说,我们在某些操作中使用 FP32,而在其他操作中使用低精度格式。通过这样做,我们在需要更高精度的操作上保持所需的精度,同时也享受半精度表示的优势。
正如图 7**.7所示,混合方法与传统策略相反,我们在训练过程中使用相同的数值精度:
图 7.7 – 传统与混合精度方法的差异
鉴于使用低精度格式的优势,你可能会想知道为什么不在训练过程中涉及的所有操作中使用纯低精度方法 - 比如说纯低精度方法而不是混合精度策略。这是一个合理的问题,我们将在接下来的部分中回答它。
为什么使用混合精度?
这里提出的问题不是关于使用混合精度的优势,而是为什么我们不应该采用绝对的低精度方法。
好吧,我们不能使用纯低精度方法,因为有两个主要原因:
-
梯度信息的丢失
-
缺乏低精度操作
让我们更详细地看一下每一种。
梯度信息的丢失
降低精度可能导致梯度问题,从而影响模型的准确性。随着优化过程的进行,由降低精度导致的梯度信息丢失可以阻碍整个优化过程,使模型无法收敛。因此,训练后的模型可能表现出较低的准确性。
我们应该澄清这个问题吗?假设我们处于一个假设情境,正在使用两种不同的精度格式 A 和 B 来训练模型。格式 A 支持五位小数精度,而格式 B 只支持三位小数精度。
假设我们已经对模型进行了五个训练步骤的训练。在每个训练步骤中,优化器计算了梯度来指导整体优化过程。然而,正如图 7**.8所示,在第三个训练步骤后,格式 B 上的梯度变为零。此后,优化过程将是盲目的,因为梯度信息已丢失:
图 7.8 – 梯度信息丢失
这是一个关于梯度信息丢失的朴素而形象的例子。然而,总体而言,这是我们在选择使用低精度格式时可能面临的问题。
因此,我们必须将一些操作保持在默认的 FP32 格式上运行,以避免这些问题。然而,当使用较低精度表示时,我们仍然需要注意梯度的处理,正如我们将在本章后面理解的那样。
缺乏低精度操作
关于第二个原因,我们可以说许多操作没有更低精度的实现。除了技术限制外,实施某些操作的低精度版本的成本效益比可能非常低,因此不值得这样做。
因此,PyTorch 维护了一个合格操作列表,以在较低精度下运行,以查看当前支持给定精度和设备的操作。例如,conv2d
操作可以在 CUDA 设备上以 FP16 运行,在 CPU 上以 BFP16 运行。另一方面,softmax
操作既不在 GPU 上也不在 CPU 上支持低精度运行。一般来说,在撰写本文时,PyTorch 仅在 CUDA 设备上支持 FP16,仅在 CPU 上支持 BFP16。
注意
您可以在 PyTorch 的pytorch.org/docs/stable/amp.html
找到可以在低精度下运行的所有合格操作的完整列表。
也就是说,我们必须始终评估我们的模型是否至少执行了其中一个可以在低精度下运行的合格操作,然后再全力采用混合精度方法。否则,我们将徒劳地尝试从这种策略中获益。
即使可以减少训练过程的数值精度,我们也不应该期望在任何情景下都会有巨大的性能增益。毕竟,目前只有训练过程中执行的操作的子集支持较低精度的数据类型。另一方面,任何从无需费力的过程中获得的性能改进总是受欢迎的。
如何使用混合精度
通常,我们依赖于自动解决方案来应用混合精度策略到训练过程中。这种解决方案称为自动混合精度,简称 AMP。
如 图 7*.9* 所示,AMP 自动评估在训练过程中执行的操作,以决定哪些操作可以以低精度格式运行:
图 7.9 – AMP 过程
一旦 AMP 发现符合要求以低精度执行的操作,它就会接管并用低精度版本替换以默认精度运行的操作。这是一个优雅而无缝的过程,旨在避免难以检测、调查和修复的错误。
尽管强烈建议使用自动解决方案来应用混合精度方法,但也可以手动进行。然而,我们必须意识到一些事情。一般来说,当自动解决方案提供的结果不好或不合理时,或者简单地不存在时,我们寻求手动实施过程。由于自动解决方案已经可用,并且无法保证手动操作能获得显著的性能改进,我们应该把手动方法仅作为采用混合精度的最后选择。
注意
您始终可以尝试并手动实施混合精度。深入了解这个主题可能是个好主意。您可以从 NVIDIA GTC 2018 上的材料开始,该材料可在 on-demand.gputechconf.com/gtc-taiwan/2018/pdf/5-1_Internal%20Speaker_Michael%20Carilli_PDF%20For%20Sharing.pdf
上获取。
张量核心如何?
张量核心是一种处理单元,能够加速矩阵乘法执行,这是在人工智能和高性能计算工作负载中经常执行的基本操作。为了使用这一硬件资源,软件(库或框架)必须能够处理张量核心支持的数值格式。如 表 7.2 所示,张量核心支持的数值格式根据 GPU 架构而异:
表 7.2 – 张量核心支持的数据类型(来自 NVIDIA 官方网站)
新型 GPU 模型的张量核心,如 Hopper 和 Ampere(分别为系列 H 和 A),支持 TF32、FP16 和 bfloat16 等低精度格式,以及双精度格式(FP64),这对于处理传统 HPC 工作负载特别重要。
注意
Hopper 架构开始支持 FP8,一种全新的数字表示,只使用 1 字节来表示浮点数。NVIDIA 创建了这种格式,以加速 Transformer 神经网络的训练过程。使用张量核心来运行 FP8 操作依赖于 Transformer 引擎库,超出了本书的范围。
另一方面,现有的架构都不配备支持 FP32 的张量核心,默认精度格式。因此,为了利用这种硬件能力的计算能力,我们必须调整我们的代码,以便它可以使用更低精度格式。
此外,激活张量核心取决于其他因素,超出了采用较低精度表示的范围。在其他事项中,我们必须注意给定架构、库版本和数字表示的矩阵维度所需的内存对齐。例如,在 A100(安培架构)的情况下,当使用 FP16 和 CuDNN 版本在 7.6.3 之前时,矩阵的维度必须是 8 字节的倍数。
因此,采用较低精度是使用张量核心的第一个条件,但这并不是启用此资源的唯一要求。
注意
您可以在docs.nvidia.com/deeplearning/performance/dl-performance-matrix-multiplication/index.html#requirements-tc
找到有关使用张量核心要求的更多详细信息。
现在我们了解了混合精度的基础知识,我们可以学习如何在 PyTorch 中使用这种方法。
启用 AMP
幸运的是,PyTorch 提供了方法和工具,通过在我们的原始代码中进行少量更改即可执行 AMP。
在 PyTorch 中,AMP 依赖于启用一对标志,用torch.autocast
对象包装训练过程,并使用梯度缩放器。更复杂的情况是在 GPU 上实施 AMP,涉及这三个部分,而最简单的场景(基于 CPU 的训练)只需要使用torch.autocast
。
让我们从涵盖更复杂的情况开始。因此,跟随我进入下一节,学习如何在我们基于 GPU 的代码中激活这种方法。
在 GPU 上激活 AMP。
要在 GPU 上激活 AMP,我们需要对我们的代码进行三处修改:
-
启用 CUDA 和 CuDNN 后端标志。
-
用
torch.autocast
对象包装训练循环。 -
使用梯度缩放器。
让我们仔细看看。
启用后端标志
正如我们在第四章中学到的,使用专业库,PyTorch 依赖第三方库(在 PyTorch 术语中也称为后端)来帮助执行专业任务。在 AMP 的上下文中,我们必须启用与 CUDA 和 CuDNN 后端相关的四个标志。所有这些标志默认情况下都是禁用的,应在代码开头打开。
注意
CuDNN 是一个 NVIDIA 库,提供了在深度学习神经网络上常执行的优化操作。
第一个标志是torch.backend.cudnn.benchmark
,它激活了 CuDNN 的基准模式。在此模式下,CuDNN 执行一组简短的测试,以确定在给定平台上执行哪些操作是最佳的。尽管此标志与混合精度不直接相关,但它在过程中发挥着重要作用,增强了 AMP 的正面效果。
CuDNN 在第一次被 PyTorch 调用时执行此评估。一般来说,这一时刻发生在第一个训练时期。因此,如果第一个时期执行比训练过程的其余时期需要更多的时间,请不要感到惊讶。
另外两个标志称为cuda.matmul.allow_fp16_reduced_precision_reduction
和cuda.matmul.allow_bf16_reduced_precision_reduction
。它们告诉 CUDA 在执行 FP16 和 BFP16 表示时使用减少精度的matmul
操作。matmul
操作与矩阵乘法相关,这是通常可以在神经网络上执行的最基本的计算任务之一。
最后一个标志是torch.backends.cudnn.allow_tf32
,它允许 CuDNN 使用 TF32 格式,从而启用 NVIDIA Tensor Cores 支持的格式之一。
在启用这些标志之后,我们可以继续更改训练循环部分。
使用torch.autocast
包装训练循环
torch.autocast
类负责在 PyTorch 上实现 AMP。我们可以将torch.autocast
用作上下文管理器或装饰器。其使用取决于我们如何实现我们的代码。无论使用哪种方法,AMP 的工作方式都是相同的。
具体来说,在上下文管理器的情况下,我们必须包装在训练循环中执行的前向和损失计算阶段。所有其他在训练循环中执行的任务必须排除在torch.autocast
的上下文之外。
torch.autocast
接受四个参数:
-
device_type
:这定义了自动转换将在哪种设备上执行 AMP。接受的值包括cuda
、cpu
、xpu
和hpu
– 即,我们可以分配给torch.device
对象的相同值。自然地,这个参数最常见的值是cuda
和cpu
。 -
dtype
:在 AMP 策略中使用的数据类型。此参数接受与我们想要在自动转换中使用的数据类型相关联的数据类型对象 – 从torch.dtype
类实例化。 -
enabled
:一个标志,用于启用或禁用 AMP 过程。默认情况下是启用的,但我们可以将其切换为false
来调试我们的代码。 -
cache_enabled
:在 AMP 过程中,torch.autocast
是否应启用权重缓存。此参数默认启用。
使用torch.autocast
必须传入device_type
和dtype
参数。其他参数是可选的,仅用于微调和调试。
下面的摘录展示了在训练循环中作为上下文管理器使用torch.autocast
(为了简单起见,此示例仅显示了训练循环的核心部分):
with torch.autocast(device_type=device, dtype=torch.float16): output = model(images).to(device)
loss = criterion(output, labels)
训练循环中执行的其他任务没有被torch.autocast
封装,因为我们只对前向和损失计算阶段应用 AMP 感兴趣。此外,训练过程的剩余任务都被梯度缩放器包装,如下所述。
梯度缩放器
正如我们在本章开头所学到的,使用低精度表示法可能导致梯度信息的丢失。为了解决这个问题,我们需要使用torch.cuda.amp.GradScaler
来包装优化和反向传播阶段:
scaler = torch.cuda.amp.GradScaler()with torch.autocast(device_type=device, dtype=torch.float16):
output = model(images).to(device)
loss = criterion(output, labels)
optimizer.zero_grad()
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
首先,我们实例化了一个来自torch.cuda.amp.GradScaler
的对象。接下来,我们将optimizer.step()
和loss.backward()
调用包装到梯度缩放器中,以便它控制这些任务。最后,训练循环要求缩放器最终更新网络参数。
我们将这些乐高积木拼接成一个独特的建筑块,看看 AMP 在下一节中能做些什么!
AMP,展示你的能力!
为了评估使用 AMP 的好处,我们将使用 EfficientNet 神经网络架构进行训练,该架构位于torch.vision.models
包中,并使用 CIFAR-10 数据集。
注意
此部分显示的完整代码可在github.com/PacktPublishing/Accelerate-Model-Training-with-PyTorch-2.X/blob/main/code/chapter07/amp-efficientnet_cifar10.ipynb
中找到。
在这个实验中,我们将在 GPU NVIDIA A100 上使用 FP16 和 BFP16 评估 AMP 的使用情况,运行 50 个 epochs。我们的基准执行涉及使用 EfficientNet 的默认精度(FP32),但启用了 CuDNN 基准标志。通过这样做,我们将使事情变得公平,因为尽管 CuDNN 基准标志对 AMP 过程很重要,但并不直接相关。
基准执行花了 811 秒完成,达到了 51%的准确率。这里我们不关心准确率本身;我们感兴趣的是评估 AMP 是否会影响模型的质量。
通过采用 BFP16 精度格式,训练过程耗时 754 秒完成,这表示性能提升了 8%,这是一个较为微小和令人失望的改进。这是因为仅实现了在 CPU 上执行的 BFP16-适用操作,尽管我们正在使用 GPU 训练模型,但仍然有一些操作在 CPU 上执行。因此,这种微小的性能改进来自于继续在 CPU 上执行的一小部分代码片段。
注意
我们正在 GPU 上使用 BFP16 运行这个实验,以展示处理 AMP 的细微差别。尽管 PyTorch 并未提供 BFP16-适用的操作在 GPU 上执行的功能,我们却没有收到任何关于此操作的警告。这是一个重要示例,说明了了解我们在代码中使用的过程细节是多么重要。
好的,但是 FP16 呢?嗯,在 Float16 下运行的 AMP 训练过程完成了 50 个 epochs,耗时 486 秒,性能提升了 67%。由于梯度缩放器的工作,模型的准确性没有受到使用低精度格式的影响。事实上,这种场景下训练的模型与基线代码达到了相同的 51% 准确率。
我们必须牢记,这种性能提升只是 AMP 加速训练过程的一个示例。根据模型、库和设备在训练过程中的使用情况,我们可以获得更令人印象深刻的结果。
下一节将提供几个问题,以帮助您巩固本章学到的内容。
测验时间!
让我们通过回答一些问题来回顾我们在本章学到的知识。首先,试着在不查阅材料的情况下回答这些问题。
注意
所有这些问题的答案都可以在 github.com/PacktPublishing/Accelerate-Model-Training-with-PyTorch-2.X/blob/main/quiz/chapter07-answers.md
找到。
在开始测验之前,请记住这不是一次测试!本节旨在通过复习和巩固本章内容来补充您的学习过程。
选择以下问题的正确选项:
-
以下哪种数值格式仅使用 8 位表示整数?
-
FP8。
-
INT32。
-
INT8。
-
INTFB8。
-
-
FP16 是一种使用 16 位表示浮点数的数值表示。这种数值格式也被称为什么?
-
半精度浮点表示。
-
单精度浮点表示。
-
双精度浮点表示。
-
四分之一精度浮点表示。
-
-
以下哪种数值表示是由 Google 创建,用于处理机器学习和人工智能工作负载的?
-
GP16。
-
GFP16。
-
FP16。
-
BFP16。
-
-
NVIDIA 创建了 TF32 数据表示。以下哪种位数用于表示浮点数?
-
32 位。
-
19 位。
-
16 位。
-
20 位。
-
-
PyTorch 在执行训练过程的操作时使用的默认数值表示是什么?
-
FP32。
-
FP8。
-
FP64。
-
INT32。
-
-
混合精度方法的目标是什么?
-
混合精度试图在训练过程执行期间采用较低精度的格式。
-
混合精度试图在训练过程执行期间采用较高精度的格式。
-
混合精度在训练过程执行期间避免了使用较低精度的格式。
-
混合精度在训练过程执行期间避免了使用更高精度的格式。
-
-
使用 AMP 方法而不是手动实现的主要优势是什么?
-
简单使用和性能提升的减少。
-
简单使用和减少功耗。
-
复杂使用和避免涉及数值表示的错误。
-
简单使用和避免涉及数值表示的错误。
-
-
除了缺少低精度操作外,以下哪个选项是不使用纯低精度方法进行训练过程的另一个原因?
-
低性能提升。
-
高能耗。
-
梯度信息的丢失。
-
高使用主存储器。
-
总结
在本章中,您学习到采用混合精度方法可以加速我们模型的训练过程。
尽管可以手动实现混合精度策略,但最好依赖于 PyTorch 提供的 AMP 解决方案,因为这是一个优雅且无缝的过程,旨在避免涉及数值表示的错误发生。当出现此类错误时,它们非常难以识别和解决。
在 PyTorch 上实施 AMP 需要在原始代码中添加几行额外的代码。基本上,我们必须将训练循环包装在 AMP 引擎中,启用与后端库相关的四个标志,并实例化梯度放大器。
根据 GPU 架构、库版本和模型本身,我们可以显著改善训练过程的性能。
本章结束了本书的第二部分。接下来,在第三部分中,我们将学习如何将训练过程分布在多个 GPU 和机器上。
第三部分:进入分布式
在这部分中,您将学习如何将训练过程分布到多个设备和机器上。首先,您将了解与分布式训练过程相关的基本概念。然后,您将学习如何在单台机器上的多个 CPU 上分布训练过程。之后,您将学习如何通过多个 GPU 在单台机器上训练模型。最后,您将学习如何在多台机器上的多个设备之间分布训练过程。
本部分包括以下章节:
-
第八章, 一览分布式训练
-
第九章, 使用多个 CPU 进行训练
-
第十章, 使用多个 GPU 进行训练
-
第十一章, 使用多台机器进行训练
第八章:一览分布式训练
当我们在现实生活中面对复杂问题时,通常会尝试通过将大问题分解为易于处理的小部分来解决。因此,通过结合从原始问题的小部分获得的部分解决方案,我们达到最终解决方案。这种称为分而治之的策略经常用于解决计算任务。我们可以说这种方法是并行和分布式计算领域的基础。
原来,将一个大问题分解成小块的想法在加速复杂模型的训练过程中非常有用。在单个资源无法在合理时间内训练模型的情况下,唯一的出路是分解训练过程并将其分散到多个资源中。换句话说,我们需要分布训练过程。
您将在本章中学到以下内容:
-
分布式训练的基本概念
-
用于分散训练过程的并行策略
-
在 PyTorch 中实现分布训练的基本工作流程
技术要求
您可以在本章提到的书籍 GitHub 存储库中找到完整的代码示例,网址为 github.com/PacktPublishing/Accelerate-Model-Training-with-PyTorch-2.X/blob/main
。
您可以访问您喜爱的环境来执行提供的代码,例如 Google Colab 或 Kaggle。
分布式训练的首次介绍
我们将从讨论将训练过程分布到多个资源中的原因开始本章。然后,我们将了解通常用于执行此过程的资源。
我们何时需要分布训练过程?
分布训练过程最常见的原因涉及加速构建过程。假设训练过程花费很长时间并且我们手头有多个资源,那么我们应该考虑在这些各种资源之间分布训练过程以减少训练时间。
将大型模型加载到单个资源中存在内存泄漏的第二个动机与分布式训练相关。在这种情况下,我们依靠分布式训练将大型模型的不同部分分配到不同的设备或资源中,以便可以将模型加载到系统中。
然而,分布式训练并非解决所有问题的灵丹妙药。在许多情况下,分布式训练可以达到与传统执行相同的性能,或者在某些情况下甚至更差。这是因为准备初始设置和在多个资源之间进行通信所带来的额外开销可能会抵消并行运行训练过程的好处。此外,我们可以首先尝试简化模型的复杂性,如第六章中描述的那样,简化模型,而不是立即转向分布式方法。如果成功,简化过程的结果模型现在可能适合设备上运行。
因此,分布式训练并不总是减少训练时间或将模型适配到给定资源的正确答案。因此,建议冷静下来,并仔细分析分布式训练是否有望解决问题。简而言之,我们可以使用图8.1 中描述的流程图来决定何时采用传统或分布式方法:
图 8.1 – 用于决定何时使用传统或分布式方法的流程图
面对内存泄漏或长时间训练,我们应该在考虑采用分布式方法之前,应用所有可能的性能改进技术。通过这样做,我们可以避免诸如浪费未有效使用的分配资源之类的问题。
除了决定何时分发训练过程外,我们还应评估在分布式方法中使用的资源量。一个常见的错误是获取所有可用资源来执行分布式训练,假设资源量越多,训练模型的时间就越短。然而,并没有保证增加资源量将会带来更好的性能。结果甚至可能更糟,正如我们之前讨论过的那样。
总之,分布式训练在训练过程需要较长时间完成或模型无法适应给定资源的情况下非常有用。由于这两种情况都可以通过应用性能改进技术来解决,因此我们应首先尝试这些方法,然后再考虑采用分布式策略。否则,我们可能会面临资源浪费等副作用。
在接下来的部分,我们将对用于执行此过程的计算资源提供更高层次的解释。
我们在哪里执行分布式训练?
更一般地说,我们可以说分布式训练涉及将训练过程划分为多个部分,每个部分管理整个训练过程的一部分。每个部分都分配在单独的计算资源上运行。
在分布式训练的背景下,我们可以在 CPU 或加速器设备上运行部分训练过程。尽管 GPU 是常用于此目的的加速器设备,但还存在其他不太流行的选项,如 FPGA、XPU 和 TPU。
这些计算资源可以在单台机器上或分布在多台服务器上。此外,一台单机可以拥有一个或多个这些资源。
换句话说,我们可以在 一台具有多个计算资源的机器 上分发训练过程,也可以跨 具有单个或多个资源的多台机器 进行分布。为了更容易理解这一点,Figure 8*.2* 描述了在分布式训练过程中可以使用的可能计算资源安排:
Figure 8.2 – 计算资源的可能安排
安排 A,即将多个设备放置在单个服务器中,是运行分布式训练过程最简单最快的配置。正如我们将在 第十一章 中了解的那样,使用多台机器进行训练,在多台机器上运行训练过程取决于用于互连节点的网络提供的性能。
尽管网络的性能表现不错,但使用此附加组件可能会单独降低性能。因此,尽可能采用安排 A,以避免使用网络互连。
关于安排 B 和 C,最好使用后者,因为它具有更高的每台机器设备比率。因此,我们可以将分布式训练过程集中在较少数量的机器上,从而避免使用网络。
然而,即使没有安排 A 和 C,使用安排 B 仍然是一个好主意。即使受到网络施加的瓶颈限制,分布式训练过程很可能会胜过传统方法。
通常情况下,GPU 不会在多个训练实例之间共享 – 即分布式训练过程会为每个 GPU 分配一个训练实例。在 CPU 的情况下,情况有所不同:一个 CPU 可以执行多个训练实例。这是因为 CPU 是一个多核设备,因此可以分配不同的计算核心来运行不同的训练实例。
例如,我们可以在具有 32 个计算核心的 CPU 中运行两个训练实例,其中每个训练实例使用可用核心的一半,如 Figure 8*.3* 所示:
Figure 8.3 – 使用不同计算核心运行不同训练实例
尽管可以以这种方式运行分布式训练,但通常情况下在单台(或多台)多个 GPU 或多台多个机器上运行更为常见。这种配置在许多情况下可能是唯一的选择,因此了解如何操作非常重要。我们将在第十章,使用 多个 CPU,中详细了解更多。
在了解了分布式训练世界之后,现在是时候跳到下一节,您将在这一节中学习这种方法的基本概念。
学习并行策略的基础知识
在前一节中,我们了解到分布式训练方法将整个训练过程分解为小部分。因此,整个训练过程可以并行解决,因为这些小部分中的每一个在不同的计算资源中同时执行。
并行策略定义如何将训练过程分解为小部分。主要有两种并行策略:模型并行和数据并行。接下来的章节将详细解释这两种策略。
模型并行
模型并行将训练过程中执行的操作集合划分为较小的计算任务子集。通过这样做,分布式过程可以在不同的计算资源上并行运行这些较小的操作子集,从而加快整个训练过程。
结果表明,在前向和后向阶段执行的操作彼此并非独立。换句话说,一个操作的执行通常依赖于另一个操作生成的输出。由于这种约束,模型并行并不容易实现。
尽管如此,卓越的人类头脑发明了三种技术来解决这个问题:层间、操作内和操作间范式。让我们深入了解。
层间范式
在层间范式中,每个模型层在不同的计算资源上并行执行,如图 8.4所示:
图 8.4 – 层间模型并行范式
然而,由于给定层的计算通常依赖于另一层的结果,层间范式需要依赖特定策略来实现这些条件下的分布式训练。
在采用这种范式时,分布式训练过程建立了一个连续的训练流程,使神经网络在同一时间处理多个训练步骤 – 也就是说,同时处理多个样本。随着事情的发展,一个层在给定的训练步骤中所需的输入已经在训练流程中被处理,并且现在可用作该层的输入。
因此,在特定时刻,分布式训练过程可以并行执行不同层。这个过程在前向和反向阶段都执行,从而进一步提高可以同时计算的任务的并行性水平。
在某种程度上,这种范式与现代处理器中实现的指令流水线技术非常相似,即多个硬件指令并行执行。由于这种相似性,内部层范式也被称为流水线并行主义,其中各阶段类似于训练步骤。
跨操作范式
跨操作范式依赖于将在每个层上执行的操作集合分成更小的可并行计算任务的块,如图8**.5所示。每个这些计算任务块在不同的计算资源上执行,因此并行化层的执行。在计算所有块之后,分布式训练过程将来自每个块的部分结果组合以得出层输出:
图 8.5 – 跨操作模型并行主义范式
由于在层内执行的操作之间存在依赖关系,跨操作范式无法将依赖操作放入不同的块中。这种约束对将操作分割为并行计算任务块施加了额外的压力。
例如,考虑图8**.6中所示的图形,它表示在层中执行的计算。该图由两个输入数据块(矩形)和四个操作(圆形)组成,箭头表示操作之间的数据流动:
图 8.6 – 跨操作范式中操作分区的示例
易于看出,操作 1 和 2 仅依赖于输入数据,而操作 3 需要操作 1 的输出来执行其计算。操作 4 在图中的依赖最强,因为它依赖于操作 2 和 3 的结果才能执行。
因此,如图8**.6所示,这个图的独特分区为此图创建了两个并行操作块,以同时运行操作 1 和 2。由于操作 3 和 4 依赖于先前的结果,它们在其他任务完成之前无法执行。因此,根据层内操作之间的依赖程度,跨操作范式无法实现更高水平的并行性。
内部操作范式
内部操作范式将操作的执行分成较小的计算任务,其中每个计算任务在不同的输入数据块中应用操作。通常,跨操作方法需要结合部分结果来完成操作。
虽然间操作在不同计算资源上运行不同操作,内操作则将同一操作的部分分布到不同计算资源上,如图8**.7所示:
图 8.7 – 内操作模型并行化范式
例如,考虑一种情况,即图8**.8中所示,一个层执行矩阵到矩阵乘法。通过采用内操作范式,这种乘法可以分成两部分,其中每部分将在矩阵 A 和 B 的不同数据块上执行乘法。由于这些部分乘法彼此独立,因此可以同时在不同设备上运行这两个任务:
图 8.8 – 内操作范式中数据分区示例
在执行这两个计算任务之后,内操作方法需要将部分结果合并以生成最终的矩阵。
根据操作类型和输入数据的大小,内操作可以实现合理的并行性水平,因为可以创建更多数据块并提交给额外的计算资源。
然而,如果数据太少或操作太简单而无法进行计算,将计算分布到不同设备可能会增加额外的开销,超过并行执行操作的潜在性能改进。这种情况适用于内操作和间操作方法。
摘要
总结我们在本节学到的内容,表 8.1涵盖了每种模型并行化范式的主要特征:
范式 | 策略 |
---|---|
间层 | 并行处理层 |
内操作 | 并行计算不同操作 |
间操作 | 并行计算同一操作的部分 |
表 8.1 – 模型并行化范式总结
虽然模型并行化可以加速训练过程,但它也有显著的缺点,比如扩展性差和资源使用不均衡,除此之外,还高度依赖网络架构。这些问题解释了为什么这种并行策略在数据科学家中并不那么流行,并且通常不是分布式训练过程的首选。
即便如此,模型并行化可能是在模型不适合计算资源的情况下的独特解决方案——也就是说,当设备内存不足以分配整个模型时。这种情况适用于大型语言模型(LLMs),这些模型通常有数千个参数,在内存中加载时占用大量字节。
另一种策略,称为数据并行化,更加健壮、可扩展且实现简单,我们将在下一节中学习。
数据并行化
数据并行策略的思想非常容易理解。与将网络执行的计算任务集合进行分割不同,数据并行策略将训练数据集分成更小的数据块,并使用这些数据块来训练原始模型的不同副本,如图8**.9所示。由于每个模型副本彼此独立,它们可以并行训练:
图 8.9 – 数据并行策略
在每个训练步骤结束时,分布式训练过程启动一个同步阶段,以更新所有模型副本的权重。此同步阶段负责收集并分享所有在不同计算资源中运行的模型之间的平均梯度。收到平均梯度后,每个副本根据此共享信息调整其权重。
同步阶段是数据并行策略的核心机制。简单来说,它确保了模型副本在执行单个训练步骤后获得的知识被与其他副本共享,反之亦然。因此,在完成分布式训练过程时,生成的模型具有与传统训练相同的知识:
图 8.10 – 数据并行中的同步阶段
有半打方法执行此同步阶段,包括参数服务器和全 reduce。前者在可扩展性方面表现不佳,因为使用唯一服务器聚合每个模型副本获得的梯度,计算平均梯度,并将其发送到各处。随着训练过程数量的增加,参数服务器成为分布式训练过程的主要瓶颈。
另一方面,全 reduce 技术具有更高的可扩展性,因为所有训练实例均匀参与更新过程。因此,此技术已被所有框架和通信库广泛采用,以同步分布式训练过程的参数。
我们将在下一节详细了解它。
全 reduce 同步
全 reduce 是一种集体通信技术,用于简化由多个进程执行的计算。由于全 reduce 源自 reduce 操作,让我们在描述全 reduce 通信原语之前了解这种技术。
在分布式和并行计算的背景下,reduce 操作在多个进程中执行一个函数,并将该函数的结果发送给一个根进程。Reduce 操作可以执行任何函数,尽管通常应用于诸如求和、乘法、平均值、最大值和最小值等简单的函数。
图 8*.11* 展示了将减少操作应用于四个进程持有的向量的示例。在这个示例中,减少原语执行四个向量的和,并将结果发送到进程 0,这是此场景中的根进程。
图 8.11 – Reduce 操作
All-Reduce 操作是减少原语的一个特例,其中所有进程接收函数的结果,如 图 8*.12* 所示。因此,与仅将结果发送给根进程不同,All-Reduce 将结果与参与计算的所有进程共享。
图 8.12 – All-Reduce 操作
有不同的方式可以实施 All-Reduce 操作。在分布式训练环境中,最有效的解决方案之一是环形 All-Reduce。在这种实现中,进程使用逻辑环形拓扑(如 图 8*.13* 所示)在它们之间交换信息。
图 8.13 – 环形 All-Reduce 实现
信息通过环路流动,直到所有进程最终拥有相同的数据。有一些库提供了优化版本的环形 All-Reduce 实现,比如 NVIDIA 的 NCCL 和 Intel 的 oneCCL。
总结
数据并行性易于理解和实施,而且灵活且可扩展。然而,正如万事皆有不完美之处一样,这种策略也有其缺点。
尽管与模型并行主义方法相比,它提供了更高层次的并行性,但它可能面临一些限制因素,这些因素可能阻碍其实现高度的可扩展性。由于每个训练步骤后梯度在所有副本之间共享,这些副本之间的通信延迟可能会减慢整个训练过程。
此外,数据并行策略并未解决训练大模型的问题,因为模型完全如原样加载到设备上。同一个大模型将以不同的计算资源加载,这反过来将无法支持它们。对于无法放入设备的模型,问题依旧存在。
即便如此,如今,数据并行策略是分布式训练过程的直接途径。这种策略的简单性和灵活性使其能够训练广泛的模型类型和架构,因此成为了默认的分布式训练选择。从现在开始,我们将使用术语分布式训练来指代基于数据并行策略的分布式训练。
构建机器学习模型的最常用框架都内置了分布式训练的实现,PyTorch 也不例外!在接下来的部分,我们将首次探讨如何实施这个过程。
PyTorch 上的分布式训练
本节介绍了在 PyTorch 上实现分布式训练的基本工作流程,同时介绍了此过程中使用的组件。
基本工作流程
总体来说,实现 PyTorch 分布式训练的基本工作流程包括图 8**.14中展示的步骤:
图 8.14 – 在 PyTorch 中实现分布式训练的基本工作流程
让我们更详细地看看每一步。
注意
本节展示的完整代码可在github.com/PacktPublishing/Accelerate-Model-Training-with-PyTorch-2.X/blob/main/code/chapter08/pytorch_ddp.py
找到。
初始化和销毁通信组
通信组是 PyTorch 用来定义和控制分布式环境的逻辑实体。因此,编写分布式训练的第一步涉及初始化通信组。通过实例化torch.distributed
类的对象并调用init_process_group
方法来执行此步骤,如下所示:
import torch.distributed as distdist.init_process_group()
严格来说,初始化方法不需要任何参数。但是,有两个重要的参数,虽然不是必须的。这些参数允许我们选择通信后端和初始化方法。我们将在第九章中学习这些参数,使用多 CPU 进行训练。
在创建通信组时,PyTorch 识别将参与分布式训练的进程,并为每个进程分配一个唯一标识符。这个标识符称为get_rank
方法:
my_rank = dist.get_rank()
由于所有进程执行相同的代码,我们可以使用 rank 来区分给定进程的执行流程,从而将特定任务的执行分配给特定进程。例如,我们可以使用 rank 来分配执行最终模型评估的责任:
if my_rank == 0: test(ddp_model, test_loader, device)
在分布式训练中执行的最后一步涉及销毁通信组,这在代码开头创建。这个过程通过调用destroy_process_group()
方法来执行:
dist.destroy_process_group()
终止通信组是重要的,因为它告诉所有进程分布式训练已经结束。
实例化分布式数据加载器
由于我们正在实现数据并行策略,将训练数据集划分为小数据块以供每个模型副本使用是必需的。换句话说,我们需要实例化一个数据加载器,它了解分布式训练过程。
在 PyTorch 中,我们依赖于DistributedSampler
组件来简化这个任务。DistributedSampler
组件将程序员不需要的所有细节抽象化,并且非常易于使用:
from torch.utils.data.distributed import DistributedSamplerdist_loader = DistributedSampler(train_dataset)
train_loader = torch.utils.data.DataLoader(train_dataset,
batch_size=batch_size,
shuffle=False,
sampler=dist_loader)
唯一的变化是在原始的DataLoader
创建行中添加了一个额外的参数,称为sampler
。sampler
参数必须填写一个从DistributedSampler
组件实例化的对象,该对象仅需要原始数据集对象作为输入参数。
最终的数据加载器已准备好处理分布式训练过程。
实例化分布式模型
现在已经有了通信组和准备好的分布式数据加载器,是时候实例化原始模型的分布式版本了。
PyTorch 提供了本地的DistributedDataParallel
组件(简称 DDP),用于封装原始模型并准备以分布式方式进行训练。DDP 返回一个新的模型对象,然后用于执行分布式训练过程:
from torch.nn.parallel import DistributedDataParallel as DDPmodel = CNN()
ddp_model = DDP(model)
在实例化分布式模型之后,所有进一步的步骤都在分布式模型的版本上执行。例如,优化器接收分布式模型作为参数,而不是原始模型:
optimizer = optimizer(ddp_model.parameters(), lr, weight_decay=weight_decay)
此时,我们已经具备运行分布式训练过程所需的一切。
运行分布式训练过程
令人惊讶的是,在分布式方式下执行训练循环几乎与执行传统训练相同。唯一的区别在于将 DDP 模型作为参数传递,而不是原始模型:
train(ddp_model, train_loader, num_epochs, criterion, optimizer, device)
除此之外不需要任何其他内容,因为到目前为止使用的组件具有执行分布式训练过程的内在功能。
PyTorch 持续运行分布式训练过程,直到达到定义的 epoch 数。在完成每个训练步骤后,PyTorch 会自动在模型副本之间同步权重。程序员无需进行任何干预。
检查点和保存训练状态
由于分布式训练过程可能需要很多小时才能完成,并涉及不同的计算资源和设备,因此更容易受到故障的影响。
因此,建议定期检查点和保存当前训练状态,以便在出现故障时恢复训练过程。我们将在第十章,使用多个 GPU 进行训练中详细讨论此主题。
总结
我们可能需要实例化其他模块和对象来实现分布式训练的特殊功能,但这个工作流程通常足以编写一个基本的——虽然功能齐全的——分布式训练实现。
通信后端和程序启动器
在 PyTorch 上实现分布式训练涉及定义一个通信后端,并使用程序启动器在多个计算资源上执行进程。
下面的小节简要解释了每个组件。
通信后端
正如我们之前所学的,在分布式训练过程中,模型副本彼此交换梯度信息。从另一个角度来看,运行在不同计算资源上的进程必须彼此通信,以传播这些数据。
同样地,PyTorch 依赖于后端软件来执行模型编译和多线程操作。它还依赖于通信后端来提供模型副本之间优化的通信渠道。
有些通信后端专注于与高性能网络配合工作,而其他一些适合处理单台机器内多个设备之间的通信。
PyTorch 支持的最常见的通信后端包括 Gloo、MPI、NCCL 和 oneCCL。每个这些后端在特定场景下的使用都非常有趣,我们将在接下来的几章中了解到。
程序启动器
运行分布式训练并不同于执行传统的训练过程。任何分布式和并行程序的执行都与运行任何传统和顺序程序有显著的区别。
在 PyTorch 的分布式训练环境中,我们使用程序启动器来启动分布式进程。这个工具负责设置环境并在操作系统中创建进程,无论是本地还是远程。
用于此目的的最常见启动器包括mp.spawn
,该启动器由torch.multiprocessing
包提供。
将所有内容整合起来
图 8.15 所示的分布式训练过程概念图展示了 PyTorch 提供的组件和资源:
图 8.15 – PyTorch 分布式训练概念图
正如我们所学的,PyTorch 依赖于通信后端来控制多个计算资源之间的通信,并使用程序启动器将分布式训练提交到本地或远程操作系统。
有多种方法可以完成同样的事情。例如,我们可以使用某种程序启动器基于两种不同的通信后端执行分布式训练。反之亦然 – 也就是说,有些通信后端支持多个启动器的情况。
因此,定义元组通信后端 x 程序启动器将取决于分布式训练过程中使用的环境和资源。在接下来的几章中,我们将更多地了解这一点。
下一节提供了一些问题,帮助您记住本章学到的内容。
测验时间!
让我们通过回答一些问题来复习一下本章学到的内容。最初,试着在不查阅资料的情况下回答这些问题。
注意
所有这些问题的答案都可以在github.com/PacktPublishing/Accelerate-Model-Training-with-PyTorch-2.X/blob/main/quiz/chapter08-answers.md
找到。
在开始测验之前,请记住这不是一个测试!本节旨在通过复习和巩固本章节涵盖的内容来补充您的学习过程。
选择以下问题的正确选项。
-
分布训练的两个主要原因是什么?
-
可靠性和性能改进。
-
内存泄漏和功耗。
-
功耗和性能改进。
-
内存泄漏和性能改进。
-
-
分布训练过程的两个主要并行策略是哪些?
-
模型和数据并行。
-
模型和硬件并行。
-
硬件和数据并行。
-
软件和硬件并行。
-
-
模型并行主义方法使用哪种范式?
-
模型间。
-
间数据。
-
内操作。
-
参数间。
-
-
什么是内操作范式并行处理?
-
不同操作。
-
相同操作的部分。
-
模型的层。
-
数据集样本。
-
-
除参数服务器外,数据并行策略还使用了哪种同步方法?
-
所有操作。
-
全部聚集。
-
全部减少。
-
全部分散。
-
-
在 PyTorch 中执行分布式训练的第一步是什么?
-
初始化通信组。
-
初始化模型副本。
-
初始化数据加载器。
-
初始化容器环境。
-
-
在 PyTorch 中的分布式训练背景下,使用哪个组件来启动分布式过程?
-
执行库。
-
通信后端。
-
程序启动器。
-
编译器后端。
-
-
PyTorch 支持哪些作为通信后端?
-
NDL。
-
MPI。
-
AMP。
-
NNI。
-
总结
在本章中,您学到了分布式训练有助于加速训练过程以及训练不适合设备内存的模型。虽然分布式可能是这两种情况的出路,但在采用分布式之前,我们必须考虑应用性能改进技术。
我们可以通过采用模型并行策略或数据并行策略来进行分布式训练。前者采用不同的范式将模型计算分配到多个计算资源中,而后者创建模型副本,以便在训练数据集的各个部分上进行训练。
我们还了解到,PyTorch 依赖于第三方组件,如通信后端和程序启动器来执行分布式训练过程。
在下一章中,我们将学习如何分散分布式训练过程,使其可以在单台机器上的多个 CPU 上运行。
第九章:使用多个 CPU 进行训练
当加速模型构建过程时,我们立即想到配备 GPU 设备的机器。如果我告诉您,在仅配备多核处理器的机器上运行分布式训练是可能且有利的,您会怎么想?
尽管从 GPU 获得的性能提升是无法比拟的,但我们不应轻视现代 CPU 提供的计算能力。处理器供应商不断增加 CPU 上的计算核心数量,此外还创建了复杂的机制来处理共享资源的访问争用。
使用 CPU 来运行分布式训练对于我们无法轻松访问 GPU 设备的情况尤其有趣。因此,学习这个主题对丰富我们关于分布式训练的知识至关重要。
在本章中,我们展示了如何通过采用通用方法并使用 Intel oneCCL 后端,在单台机器上的多个 CPU 上执行分布式训练过程。
以下是本章的学习内容:
-
在多个 CPU 上分布训练的优势
-
如何在多个 CPU 之间分布训练过程
-
如何通过使用 Intel oneCCL 来突破分布式训练
技术要求
您可以在书籍的 GitHub 仓库中找到本章提到的示例的完整代码,网址为github.com/PacktPublishing/Accelerate-Model-Training-with-PyTorch-2.X/blob/main
。
您可以访问您喜爱的环境来执行此笔记本,如 Google Colab 或 Kaggle。
为什么要在多个 CPU 上分布训练?
乍一看,想象将训练过程分布在单台机器上的多个 CPU 之间听起来有点令人困惑。毕竟,我们可以增加训练过程中使用的线程数以分配所有可用的 CPU(计算核心)。
然而,正如巴西著名诗人卡洛斯·德·安德拉德所说,“在路中央有一块石头。在路中央有一块石头。” 让我们看看在具有多个核心的机器上仅增加线程数量时训练过程会发生什么。
为什么不增加线程数?
在第四章,使用专用库中,我们了解到 PyTorch 依赖于 OpenMP 通过采用多线程技术来加速训练过程。OpenMP 将线程分配给物理核心,旨在改善训练过程的性能。
因此,如果我们有一定数量的可用计算核心,为什么不增加训练过程中使用的线程数,而不是考虑分布式呢?答案实际上很简单。
当使用多线程运行训练过程时,PyTorch 对并行性的限制意味着在超过某个线程数后,性能不会有所提升。简单来说,达到一定阈值后,训练时间将保持不变,无论我们使用多少额外的核心来训练模型。
这种行为不仅限于 PyTorch 执行的训练过程。在许多种类的并行应用中,这种情况都很普遍。根据问题和并行策略的设计,增加线程数可能会导致并行任务变得太小和简单,以至于并行化问题的好处会被控制每个并行任务的开销所抑制。
看一个行为的实际例子。表 9.1 展示了使用一台装备有 16 个物理核心的机器,在 CIFAR-10 数据集上训练 CNN 模型五个周期的执行时间:
线程 | 执行时间 |
---|---|
1 | 311 |
2 | 189 |
4 | 119 |
8 | 93 |
12 | 73 |
16 | 73 |
表 9.1 – 训练过程的执行时间
如 表 9.1 所示,无论是使用 12 还是 16 个核心来训练模型,执行时间都没有差异。由于并行级别的限制,尽管核心数量增加了超过 30%,PyTorch 在相同的执行时间上被限制住了。而且,即使训练过程使用了比之前多 50% 的线程(8 到 12),性能改善也不到 27%。
这些结果表明,在这种情况下,使用超过八个线程执行训练过程将不会显著减少执行时间。因此,我们会因为 PyTorch 分配了一定数量的核心而产生资源浪费,这些核心并没有加速训练过程。实际上,线程数越多,可能会增加通信和控制任务的开销,从而减慢训练过程。
要解决这种相反的效果,我们应考虑通过在同一台机器上运行不同的训练实例来分布训练过程。与其看代码,让我们直接看结果,这样您就能看到这种策略的好处!
救援上的分布式训练
我们使用与前一个实验相同的模型、参数和数据集进行了以下测试。当然,我们也使用了同一台机器。
在第一个测试中,我们创建了两个分布式训练过程的实例,每个实例使用八个核心,如 图 9.1 所示:
图 9.1 – 分布式训练实例的分配
分布式训练过程花费了 58 秒完成,代表了执行模型构建过程所需时间的26%改善。我们通过采用并行数据策略技术将执行时间减少了超过 25%。除此之外,硬件能力和软件堆栈都没有改变。此外,性能改善对于具有更多计算核心的机器来说可能会更高。
然而,正如本书中一直所言,一切通常都有代价。在这种情况下,成本与模型准确性有关。传统训练过程构建的模型准确性为 45.34%,而分布式训练创建的模型达到了 44.01%的准确性。尽管差异很小(约为 1.33%),但我们不应忽视它,因为模型准确性与分布式训练实例的数量之间存在关系。
表格 9.2展示了涉及不同训练实例组合和每个训练实例使用的线程数的测试结果。由于测试是在一个具有 16 个物理核心的机器上执行的,并考虑到 2 的幂次方,我们有三种可能的训练实例和线程组合:
训练实例 | 线程数 | 执行时间 | 准确性 |
---|---|---|---|
2 | 8 | 58 | 44.01% |
4 | 4 | 45 | 40.11% |
8 | 2 | 37 | 38.63% |
表格 9.2 – 分布式训练过程的执行时间
如表格 9.2所示,训练实例数量越多,模型准确性越低。这种行为是预期的,因为模型副本根据平均梯度更新其参数,这导致了关于优化过程的信息损失。
相反,训练实例数量增加时,执行时间减少。当使用每个 8 个训练实例 2 个线程时,分布式训练过程仅需 37 秒即可完成,几乎比使用 16 个线程的传统训练快两倍。然而,准确性从 45%下降到 39%。
无可否认,将训练过程分布在多个处理核心之间在加速训练过程方面是有利的。我们只需关注模型的准确性。
在下一节中,我们将学习如何在多个 CPU 上编码和运行分布式训练。
在多个 CPU 上实施分布式训练
本节展示了如何使用Gloo,一种简单而强大的通信后端,在多个 CPU 上实施和运行分布式训练。
Gloo 通信后端
在第八章,一瞥分布式训练,我们学习到 PyTorch 依赖于后端来控制涉及到的设备和机器之间的通信。
PyTorch 支持的最基本通信后端称为 Gloo。这个后端默认随 PyTorch 提供,不需要特别的配置。Gloo 后端是由 Facebook 创建的集体通信库,现在是一个由 BSD 许可证管理的开源项目。
注意
你可以在 github.com/facebookincubator/gloo
找到 Gloo 的源代码。
由于 Gloo 使用简单且在 PyTorch 中默认可用,因此看起来是在只包含 CPU 和通过常规网络连接的机器的环境中运行分布式训练的第一选择。让我们在接下来的几节中实际看一下这个后端的运作。
编写在多个 CPU 上运行分布式训练的代码
本节展示了在 单机多核心 上运行分布式训练过程的代码。这段代码基本与 第八章 中展示的一致,只是与当前情境相关的一些细节不同。
注意
本节展示的完整代码可以在 github.com/PacktPublishing/Accelerate-Model-Training-with-PyTorch-2.X/blob/main/code/chapter09/gloo_distributed-cnn_cifar10.py
找到。
因此,本节描述了调整基本工作流程以在多核心上运行分布式训练所需的主要更改。基本上,我们需要进行两个修改,如下两个部分所述。
初始化通信组
第一个修改涉及通信组的初始化。不再像以前那样调用 dist.init_process_group
而是要传入两个参数,正如我们在 第八章 中提到的 一览分布式训练:
dist.init_process_group(backend="gloo", init_method="env://")
backend
参数告诉 PyTorch 使用哪个通信后端来控制多个训练实例之间的通信。在这个主要的例子中,我们将使用 Gloo 作为通信后端。所以,我们只需要向参数传递后端名称的小写字符串。
注意
要检查后端是否可用,我们可以执行 torch.distributed.is_<backend>_available()
命令。例如,要验证当前 PyTorch 环境中是否有 Gloo 可用,我们只需要调用 torch.distributed.is_gloo_available()
。该方法在可用时返回 True
,否则返回 False
。
第二个名为 init_method
的参数,定义了 PyTorch 用于创建通信组的初始化方法。该方法告诉 PyTorch 如何获取其初始化分布式环境所需的信息。
现在,有三种可能的方法来通知初始化通信组所需的配置:
-
TCP:使用指定的 IP 地址和 TCP 端口
-
共享文件系统:使用一个对所有参与通信组的进程都可访问的文件系统
-
环境变量:使用操作系统范围内定义的环境变量
正如你可能猜到的那样,这个例子中使用的 env://
值,指的是初始化通信组的第三种方法,即环境变量选项。在下一节中,我们将了解用于设置通信组的环境变量。现在,重要的是记住 PyTorch 如何获取所需信息以建立通信组。
CPU 分配映射
第二次修改 指的是定义每个训练实例中线程分配到不同核心的操作。通过这样做,我们确保所有线程使用独占的计算资源,不会竞争给定的处理核心。
为了解释这意味着什么,让我们举一个实际例子。假设我们希望在一个具有 16 个物理核心的机器上运行分布式训练。我们决定运行两个训练实例,每个实例使用八个线程。如果我们不注意这些线程的分配,两个训练实例可能会竞争同一个计算核心,导致性能瓶颈。这恰恰是我们所不希望的。
要避免这个问题,我们必须在代码开始时为所有线程定义分配映射。以下代码片段显示了如何做到这一点:
import osnum_threads = 8
index = int(os.environ['RANK']) * num_threads
cpu_affinity = "{}-{}".format(index, (index + num_threads) - 1)
os.environ['OMP_NUM_THREADS'] = "{}".format(num_threads)
os.environ['KMP_AFFINITY'] = \
"granularity=fine,explicit,proclist=[{}]".format(cpu_affinity)
注意
需要记住,所有通信组进程执行相同的代码。如果我们需要为进程定义不同的执行流程,必须使用等级。
让我们逐行理解这段代码在做什么。
我们首先定义每个参与分布式训练的进程使用的线程数:
num_threads = 8
接下来,我们计算进程的 index
,考虑其等级和线程数。等级从称为 RANK
的环境变量中获得,该变量由程序启动器正确定义:
index = int(os.environ['RANK']) * num_threads
此索引用于标识分配给该进程的第一个处理核心。例如,考虑到 8 个线程和两个进程的情况,等级为 0 和 1 的进程的索引分别为 0 和 8。
从该索引开始,每个进程将为其线程分配后续的核心。因此,以前述场景为例,等级为 0 的进程将把其线程分配给计算核心 0、1、2、3、4、5、6 和 7。同样,等级为 1 的进程将使用计算核心 8、9、10、11、12、13、14 和 15。
由于 OpenMP 接受间隔列表格式作为设置 CPU 亲和性的输入,我们可以通过指示第一个和最后一个核心来定义分配映射。第一个核心是索引,最后一个核心通过将索引与线程数相加并从 1 中减去来获得:
cpu_affinity = "{}-{}".format(index, (index + num_threads) - 1)
在考虑我们的例子时,等级为 0 和 1 的进程将使用变量 cpu_affinity
分别设置为“0-7”和“8-15”。
我们代码片段的最后两行根据之前获得的值定义了 OMP_NUM_THREADS
和 KMP_AFFINITY
环境变量:
os.environ['OMP_NUM_THREADS'] = "{}".format(num_threads)os.environ['KMP_AFFINITY'] = \
"granularity=fine,explicit,proclist=[{}]".format(cpu_affinity)
正如您应该记得的那样,这些变量用于控制 OpenMP 的行为。OMP_NUM_THREADS
变量告诉 OpenMP 在多线程中使用的线程数,KMP_AFFINITY
定义了这些线程的 CPU 亲和性。
这两个修改足以调整第八章中介绍的基本工作流程,以在多个 CPU 上执行分布式训练。
当代码准备好执行时,接下来的步骤涉及定义程序启动器和配置参数以启动分布式训练。
在多个 CPU 上启动分布式训练
正如我们在第八章中学到的,一览分布式训练,PyTorch 依赖程序启动器来设置分布环境并创建运行分布式训练所需的进程。
对于这个场景,我们将使用 torchrun
,它是一个本地的 PyTorch 启动器。除了使用简单外,torchrun
已经包含在默认的 PyTorch 安装中。让我们看看关于这个工具的更多细节。
torchrun
粗略地说,torchrun
执行两个主要任务:定义与分布式环境相关的环境变量 和 在操作系统上实例化进程。
torchrun
定义了一组环境变量,用于通知 PyTorch 关于初始化通信组所需的参数。设置适当的环境变量后,torchrun
将创建参与分布式训练的进程。
注意
除了这两个主要任务外,torchrun 还提供了更多高级功能,如恢复失败的训练进程或动态调整训练阶段使用的资源。
要在单台机器上运行分布式训练,torchrun 需要一些参数:
-
nnodes
: 分布式训练中使用的节点数 -
nproc-per-node
: 每台机器上运行的进程数 -
master-addr
:用于运行分布式训练的机器的 IP 地址
执行我们示例的 torchrun
命令如下:
maicon@packt:~$ torchrun --nnodes 1 --nproc-per-node 2 --master-addr localhost pytorch_ddp.py
由于分布式训练将在单台机器上运行,我们将 nnodes
参数设置为 1
,并将 master-addr
参数设置为 localhost,这是本地机器的别名。在此示例中,我们希望运行两个训练实例;因此,nproc-per-node
参数设置为 2
。
从这些参数中,torchrun
将设置适当的环境变量,并在本地操作系统上实例化两个进程来运行程序 pytorch_ddp.py
,如图 9**.2所示。
图 9**.2 – torchrun 执行方案
如图 9**.2所示,每个进程都有其排名,并通过 Gloo 相互通信。此外,每个进程将创建八个线程,每个线程将在不同的物理核心上运行,如 CPU 分配图中定义的那样。尽管在同一台机器上执行并在相同的 CPU die 上运行,但这些进程将作为分布式训练过程的不同实例。
为了简化操作,我们可以创建一个 bash 脚本来简化 torchrun
在不同情况下的使用。让我们在下一节学习如何做到这一点。
启动脚本
我们可以创建一个 bash 脚本来简化分布式训练过程的启动,并在具有多个计算核心的单台机器上运行它。
此启动脚本的示例如下:
TRAINING_SCRIPT=$1NPROC_PER_NODE=$2
NNODES= "1"
MASTER_ADDR= "localhost"
TORCHRUN_COMMAND="torchrun --nnodes $NNODES --nproc-per-node $NPROC_PER_NODE --master-addr $MASTER_ADDR $TRAINING_SCRIPT"
$TORCHRUN_COMMAND
重要提示
此部分显示的完整代码在 github.com/PacktPublishing/Accelerate-Model-Training-with-PyTorch-2.X/blob/main/scripts/chapter09/launch_multiple_cpu.sh
上可用。
此脚本设置不变的参数,如 nnodes
和 master-addr
,并留下可自定义的参数,例如程序的名称和 nproc-per-node
,以便在执行行中定义。因此,要运行我们之前的示例,只需执行以下命令:
maicon@packt:~$ ./launch_multiple_cpu.sh pytorch_ddp.py 2
脚本 launch_multiple_cpu.sh
将使用适当的参数调用 torchrun
。正如你所想象的那样,更改此脚本的参数以与其他训练程序一起使用,或者运行不同数量的训练实例,都非常简单。
此外,我们可以修改此脚本,以与 Apptainer 和 Docker 等解决方案提供的容器镜像一起使用。因此,不直接在命令行上调用 torchrun
,而是在容器镜像中执行 torchrun
的脚本可以被修改为:
TRAINING_SCRIPT=$1NPROC_PER_NODE=$2
SIF_IMAGE=$3
NNODES= "1"
MASTER_ADDR= "localhost"
TORCHRUN_COMMAND="torchrun --nnodes $NNODES --nproc-per-node $NPROC_PER_NODE --master-addr $MASTER_ADDR $TRAINING_SCRIPT"
apptainer exec $SIF_IMAGE $TORCHRUN_COMMAND
考虑到一个名为 pytorch.sif
的容器镜像,这个新版本 local_launch
的命令行将如下所示:
maicon@packt:~$ ./launch_multiple_cpu_container.sh pytorch_ddp.py 2 pytorch.sif
在下一节中,我们将学习如何运行相同的分布式训练过程,但使用 Intel oneCCL 作为通信后端。
使用 Intel oneCCL 加速
在 Table 9.2 中显示的结果证明,Gloo 在 PyTorch 的分布式训练过程中很好地完成了通信后端的角色。
尽管如此,还有另一种选择用于通信后端,可以在 Intel 平台上更快地运行:Intel oneCCL 集体通信库。在本节中,我们将了解这个库是什么,以及如何将其用作 PyTorch 的通信后端。
Intel oneCCL 是什么?
Intel oneCCL 是由英特尔创建和维护的集体通信库。与 Gloo 类似,oneCCL 还提供诸如所谓的“全局归约”之类的集体通信原语。
Intel oneCCL 自然地优化为在 Intel 平台环境下运行,尽管这并不一定意味着它在其他平台上无法工作。我们可以使用此库在同一台机器上执行的进程之间(进程内通信)或在多节点上运行的进程之间提供集体通信。
尽管其主要用途在于为深度学习框架和应用程序提供集体通信,但任何用 C++ 或 Python 编写的分布式程序都可以使用 oneCCL。
与 Intel OpenMP 一样,Intel oneCCL 不会默认随常规 PyTorch 安装一起提供;我们需要自行安装它。在考虑基于 pip 的环境时,我们可以通过执行以下命令轻松安装 oneCCL:
pip install oneccl_bind_pt==2.1.0 --extra-index-url https://pytorch-extension.intel.com/release-whl/stable/cpu/us/
安装完 oneCCL 后,我们准备将其整合到我们的代码中,并启动分布式训练。让我们看看如何在接下来的章节中实现这一点。
注意
您可以在 oneapi-src.github.io/oneCCL/
找到关于 Intel oneCCL 的更多信息。
代码实现和启动
要将 Intel oneCCL 作为通信后端使用,我们必须更改前一节中呈现的代码的几个部分。
注意
本节展示的完整代码可在 github.com/PacktPublishing/Accelerate-Model-Training-with-PyTorch-2.X/blob/main/code/chapter09/oneccl_distributed-cnn_cifar10.py
找到。
第一个修改涉及导入一个 artifact 和设置三个环境变量:
import oneccl_bindings_for_pytorchos.environ['CCL_PROCESS_LAUNCHER'] = "torch"
os.environ['CCL_ATL_SHM'] = "1"
os.environ['CCL_ATL_TRANSPORT'] = "ofi"
这些环境变量配置了 oneCCL 的行为。CCL_PROCESS_LAUNCHER
参数用于与 oneCCL 通信,并启动它。在我们的情况下,必须将此环境变量设置为 torch
,因为 PyTorch 在调用 oneCCL。环境变量 CCL_ATL_SHM
和 CCL_ATL_TRANSPORT
分别设置为 1
和 ofi
,以将共享内存配置为 oneCCL 用于进程间通信的手段。
共享内存是一种进程间通信技术。
注意
您可以通过访问此网站深入了解 Intel oneCCL 的环境变量:oneapi-src.github.io/oneCCL/env-variables.html
。
第二次修改涉及更改初始化通信组中的后端设置:
dist.init_process_group(backend="ccl", init_method="env://")
代码的其余部分和启动方法与 Gloo 的代码相同。我们可以将 CCL_LOG_LEVEL
设置为 debug
或 trace
环境变量,以验证是否正在使用 oneCCL。
在进行这些修改之后,您可能会想知道 oneCCL 是否值得。让我们在下一节中找出答案。
oneCCL 真的更好吗?
如 表 9.3 所示,与 Gloo 的实现相比,oneCCL 将我们的训练过程加速了约 10%。如果与传统的 16 线程执行进行比较,使用 oneCCL 的性能改进几乎达到了 40%:
oneCCL | Gloo | ||
---|---|---|---|
训练 实例 | 线程数 | 执行时间 | 准确率 |
2 | 8 | 53 | 43.12% |
4 | 4 | 42 | 41.03% |
8 | 2 | 35 | 37.99% |
表 9.3 – 在 Intel oneCCL 和 Gloo 下运行的分布式训练过程的执行时间
关于模型的准确性,使用 oneCCL 和 Gloo 进行的分布式训练在所有场景中实际上取得了相同的结果。
因此,让我们心中产生的问题是,何时使用一种后端而不是另一种后端?如果我们使用的是基于 Intel 的环境,那么 oneCCL 更可取。毕竟,使用 Intel oneCCL 进行的训练过程比使用 Gloo 快了 10%。
另一方面,Gloo 默认与 PyTorch 一起使用,非常简单,并实现了合理的性能改进。因此,如果我们不在 Intel 平台上训练,也不追求最大可能的性能,那么 Gloo 是一个不错的选择。
下一节提供了一些问题,帮助您记住本章学到的内容。
测验时间!
让我们通过回答几个问题来回顾本章学到的内容。首先,尝试回答这些问题时不要查阅材料。
注意
所有这些问题的答案都可以在 github.com/PacktPublishing/Accelerate-Model-Training-with-PyTorch-2.X/blob/main/quiz/chapter09-answers.md
上找到。
在开始测验之前,请记住这根本不是一个测试!本节旨在通过复习和巩固本章涵盖的内容来补充您的学习过程。
选择以下问题的正确选项。
-
在多核系统中,通过增加 PyTorch 使用的线程数,可以改善训练过程的性能。关于这个话题,我们可以确认以下哪一个?
-
在超过一定数量的线程后,性能改进可能会恶化或保持不变。
-
性能改善始终在增加,无论线程数量如何。
-
增加线程数时没有性能改善。
-
仅使用 16 个线程时才能实现性能改善。
-
-
PyTorch 支持的最基本的通信后端是哪一个?
-
NNI。
-
Gloo。
-
MPI。
-
TorchInductor。
-
-
PyTorch 提供的默认程序启动器是哪一个?
-
PyTorchrun。
-
Gloorun。
-
MPIRun。
-
Torchrun。
-
-
在 PyTorch 的上下文中,Intel oneCCL 是什么?
-
通信后端。
-
程序启动器。
-
检查点自动化工具。
-
性能分析工具。
-
-
在考虑非 Intel 环境时,通信后端的最合理选择是什么?
-
Gloorun。
-
Torchrun。
-
oneCCL。
-
Gloo。
-
-
在使用 Gloo 或 oneCCL 作为通信后端时,关于训练过程的性能,我们可以说以下哪些内容?
-
完全没有任何区别。
-
Gloo 比 oneCCL 总是更好。
-
oneCCL 在 Intel 平台上可以超越 Gloo。
-
oneCCL 总是比 Gloo 更好。
-
-
在将训练过程分布在多个 CPU 和核心之间时,我们需要定义线程的分配以完成以下哪些任务?
-
保证所有线程都独占计算资源的使用。
-
保证安全执行。
-
保证受保护的执行。
-
保证数据在所有线程之间共享。
-
-
torchrun 的两个主要任务是什么?
-
创建共享内存池并在操作系统中实例化进程。
-
定义与分布式环境相关的环境变量,并在操作系统上实例化进程。
-
定义与分布式环境相关的环境变量,并创建共享内存池。
-
确定在 PyTorch 中运行的最佳线程数。
-
概要。
在本章中,我们了解到,将训练过程分布在多个计算核心上比传统训练中增加线程数量更有优势。这是因为 PyTorch 在常规训练过程中可能会面临并行级别的限制。
要在单台机器上的多个计算核心之间分布训练,我们可以使用 Gloo,这是 PyTorch 默认提供的简单通信后端。结果显示,使用 Gloo 进行的分布式训练在保持相同模型精度的同时,实现了 25%的性能改善。
我们还了解到,Intel 的一个集体通信库 oneCCL 在 Intel 平台上执行时可以进一步加快训练过程。使用 Intel oneCCL 作为通信后端,我们将训练时间缩短了 40%以上。如果我们愿意稍微降低模型精度,可以加快两倍的训练速度。
在下一章中,我们将学习如何将分布式训练过程扩展到单台机器上的多个 GPU 上运行。
第十章:使用多个 GPU 进行训练
无疑,GPU 提供的计算能力是推动深度学习领域发展的因素之一。如果单个 GPU 设备可以显著加速训练过程,那么想象一下在多 GPU 环境下我们可以做什么。
在本章中,我们将展示如何利用多个 GPU 加速训练过程。在描述代码和启动过程之前,我们将深入探讨多 GPU 环境的特性和细微差别。
以下是您将在本章学到的内容:
-
多 GPU 环境的基础知识
-
如何将训练过程分布到多个 GPU 中
-
NCCL,NVIDIA GPU 上分布式训练的默认后端
技术要求
您可以在本书的 GitHub 仓库中找到本章提到的所有代码,网址为github.com/PacktPublishing/Accelerate-Model-Training-with-PyTorch-2.X/blob/main
。
您可以访问您喜欢的环境来执行此代码,例如 Google Colab 或 Kaggle。
解密多 GPU 环境
多 GPU 环境是一个具有多个 GPU 设备的计算系统。虽然只有一个 GPU 的多个互连机器可以被认为是多 GPU 环境,但我们通常使用此术语来描述每台机器具有两个或更多 GPU 的环境。
要了解此环境在幕后的工作原理,我们需要了解设备的连接性以及采用的技术,以实现跨多个 GPU 的有效通信。
然而,在我们深入讨论这些话题之前,我们将回答一个让您担忧的问题:我们是否能够访问像那样昂贵的环境?是的,我们可以。但首先,让我们简要讨论多 GPU 环境的日益流行。
多 GPU 环境的流行度
十年前,想象一台拥有多个 GPU 的机器是不可思议的事情。除了设备成本高昂外,GPU 的适用性仅限于解决科学计算问题,这是仅由大学和研究机构利用的一个小众领域。然而,随着人工智能(AI)工作负载的蓬勃发展,GPU 的使用在各种公司中得到了极大的普及。
此外,在过去几年中,随着云计算的大规模采用,我们开始看到云服务提供商以竞争性价格提供多 GPU 实例。例如,在亚马逊网络服务(AWS)中,有多种实例配备了多个 GPU,例如p5.48xlarge、p4d.24xlarge和p3dn.24xlarge,分别为 H100、A100 和 V100 型号提供了 8 个 NVIDIA GPU。
微软 Azure 和谷歌云平台(GCP)也提供多 GPU 实例。前者提供配备 4 个 NVIDIA A100 的NC96ads,而后者提供配备 8 个 NVIDIA H100 的a3-highgpu-8g 实例。即使是次要的云服务提供商,如 IBM、阿里巴巴和Oracle 云基础设施(OCI),也有多 GPU 实例。
在本地环境方面,我们有重要的供应商,如 Supermicro、惠普和戴尔,在其产品组合中提供多 GPU 平台。例如,NVIDIA 提供了专门设计用于运行 AI 工作负载的完全集成服务器,称为 DGX 系统。例如,DGX 版本 1 配备了 8 个 Volta 或 Pascal 架构的 GPU,而 DGX 版本 2 的 GPU 数量是其前身的两倍。
考虑到这些环境越来越受欢迎,可以合理地说,数据科学家和机器学习工程师迟早会接触到这些平台。需要注意的是,许多专业人士已经拥有这些环境,尽管他们不知道如何利用它们。
注意
虽然多 GPU 环境可以显著提高训练过程的性能,但也存在一些缺点,比如获取和维护这些环境的高成本,以及控制这些设备温度所需的大量能源。
为了有效地使用这个资源,我们必须学习这个环境的基本特征。所以,让我们朝着这个方向迈出第一步,了解 GPU 如何连接到这个平台。
理解多 GPU 互连
多 GPU 环境可以被视为资源池,不同的用户可以单独分配设备来执行其训练过程。然而,在分布式训练的背景下,我们有兴趣同时使用多个设备——也就是说,我们将每个 GPU 用于运行分布式训练过程的模型副本。
由于每个模型副本得到的梯度必须在所有其他副本之间共享,多 GPU 环境中的 GPU 必须连接,以便数据可以流过系统上的多个设备。GPU 连接技术有三种类型:PCI Express、NVLink 和 NVSwitch。
注意
您可以在由 Ang Li 等人撰写的论文评估现代 GPU 互连:PCIe、NVLink、NV-SLI、NVSwitch 和 GPUDirect中找到这些技术的比较。您可以通过 ieeexplore.ieee.org/document/8763922
访问此论文。
下面的章节描述了每一个部分。
PCI Express
PCI Express,也称为 PCIe,是连接各种设备(如网络卡、硬盘和 GPU)到计算机系统的默认总线,如图 10**.1所示。因此,PCIe 并不是一种特定的连接 GPU 的技术。相反,PCIe 是一种通用且与厂商无关的扩展总线,连接外围设备到系统中:
图 10.1 – PCIe 互连技术
PCIe 通过两个主要组件互连外围设备:PCIe 根复杂和PCIe 交换机。前者将整个 PCIe 子系统连接到 CPU,而后者用于将端点设备(外围设备)连接到子系统。
注意
PCIe 根复杂也称为 PCIe 主机桥或 PHB。在现代处理器中,PCIe 主机桥位于 CPU 内部。
如图 10**.2所示,PCIe 使用交换机以分层方式组织子系统,连接到同一交换机的设备属于同一层次结构级别。同一层次结构级别的外围设备之间的通信成本低于层次结构不同级别中的外围设备之间的通信成本:
图 10.2 – PCIe 子系统
例如,GPU #0
与NIC #0
之间的通信比GPU #1
与NIC #0
之间的通信要快。这是因为第一组连接到同一个交换机(switch #2
),而最后一组设备连接到不同的交换机。
类似地,GPU #3
与Disk #1
之间的通信比GPU #3
与Disk #0
之间的通信要便宜。在后一种情况下,GPU #3
需要穿过三个交换机和根复杂来到达Disk #0
,而Disk #1
距离GPU #3
只有两个交换机的距离。
PCI Express 不提供直接连接一个 GPU 到另一个 GPU 或连接所有 GPU 的方法。为了解决这个问题,NVIDIA 发明了一种新的互连技术称为 NVLink,如下一节所述。
NVLink
NVLink是 NVIDIA 的专有互连技术,允许我们直接连接成对的 GPU。NVLink 提供比 PCIe 更高的数据传输速率。单个 NVLink 可以提供每秒 25 GB 的数据传输速率,而 PCIe 允许的最大数据传输速率为每秒 1 GB。
现代 GPU 架构支持多个 NVLink 连接。每个连接可以用于连接 GPU 到不同的 GPU(如*图 10**.3 (a)所示)或者将连接绑定在一起以增加两个或多个 GPU 之间的带宽(如图 10**.3 (b)*所示)。例如,P100 和 V100 GPU 分别支持四个和六个 NVLink 连接:
图 10.3 – NVLink 连接
如今,NVLink 是连接 NVIDIA GPU 的最佳选择。与 PCIe 相比,使用 NVLink 的好处非常明显。通过 NVLink,我们可以直接连接 GPU,减少延迟并提高带宽。
尽管如此,PCIe 在一个方面胜过 NVLink:可扩展性。由于 GPU 中存在的连接数量有限,如果每个 GPU 仅支持四个 NVLink 连接,则 NVLink 将无法连接某些数量的设备。例如,如果每个 GPU 仅支持四个 NVLink 连接,那么是无法将八个 GPU 全部连接在一起的。另一方面,PCIe 可以通过 PCIe 交换机连接任意数量的设备。
要解决这个可扩展性问题,NVIDIA 开发了一种名为NVSwitch的 NVLink 辅助技术。我们将在下一节详细了解它。
NVSwitch
NVSwitch 通过使用 NVLink 交换机扩展了 GPU 的连接度。粗略来说,NVSwitch 的思想与 PCIe 技术上使用交换机的方式相似 - 也就是说,两者的互连都依赖于像聚集器或集线器一样的组件。这些组件用于连接和聚合设备:
图 10.4 – NVSwitch 互连拓扑
正如图 10**.4所示,我们可以使用 NVSwitch 连接八个 GPU,而不受每个 GPU 支持的 NVLink 数量的限制。其他配置包括 NVLink 和 NVSwitch,如图 10**.5所示:
图 10.5 – 使用 NVLink 和 NVSwitch 的拓扑示例
在图 10**.5中所示的示例中,所有 GPU 都通过 NVSwitch 连接到自身。然而,一些 GPU 对通过两个 NVLink 连接,因此可以使这些 GPU 对之间的数据传输速率加倍。此外,还可以使用多个 NVSwitch 来提供 GPU 的完全连接性,改善设备对或元组之间的连接。
总之,在多 GPU 环境中,可以通过不同的通信技术连接 GPU,提供不同的数据传输速率和不同的设备连接方式。因此,我们可以有多条路径来连接两个或多个设备。
系统中设备连接的方式称为互连拓扑,在训练过程的性能优化中扮演着至关重要的角色。让我们跳到下一节,了解为什么拓扑是值得关注的。
互连拓扑如何影响性能?
为了理解互联拓扑对训练性能的影响,让我们考虑一个类比。想象一个城市,有多条道路,如高速公路、快速路和普通街道,每种类型的道路都有与速限、拥堵等相关的特征。由于城市有许多道路,我们有不同的方式到达同一目的地。因此,我们需要决定哪条路径是使我们的路线尽可能快的最佳路径。
我们可以把互联拓扑看作是我们类比中描述的城市。在城市中,设备之间的通信可以采用不同的路径,一些路径快速,如高速公路,而其他路径较慢,如普通街道。如同在城市类比中所述,我们应始终选择训练过程中使用的设备之间最快的连接。
要了解设备互联拓扑选择无意识可能影响的潜在影响,考虑图 10.6中的块图,该图代表一个用于运行高度密集计算工作负载作为训练过程的环境:
图 10.6 - 系统互联拓扑示意图示例
注意
图 10.6中显示的图表是真实互联拓扑的简化版本。因此,我们应将其视为真实拓扑结构方案的教学表现。
图 10.6中展示的环境可以被归类为多设备平台,因为它拥有多个 GPU、CPU 和其他重要组件,如超快速磁盘和网络卡。除了多个设备外,此类平台还使用多种互联技术,正如我们在前一节中学到的那样。
假设我们打算在图 10.6所描述的系统上使用两个 GPU 来执行分布式训练过程,我们应该选择哪些 GPU?
如果我们选择GPU #0
和GPU #1
,通信速度会很快,因为这些设备通过 NVLink 连接。另一方面,如果我们选择GPU #0
和GPU #3
,通信将穿越整个 PCIe 子系统。在这种情况下,与 NVLink 相比,通过 PCIe 进行通信具有较低的带宽,通信会穿过各种 PCIe 交换机、两个 PCIe 根复杂以及两个 CPU。
自然而然地,我们必须选择提供最佳通信性能的选项,这可以通过使用数据传输速率更高的链接和使用最近的设备来实现。换句话说,我们需要使用具有最高亲和力的 GPU。
您可能想知道如何发现您环境中的互联拓扑。我们将在下一节中学习如何做到这一点。
发现互联拓扑
要发现 NVIDIA GPU 的互联拓扑结构,我们只需执行带有两个参数的nvidia-smi
命令:
maicon@packt:~$ nvidia-smi topo –m
topo
参数代表拓扑,并提供获取系统中采用的互连拓扑的更多信息的选项。-m
选项告诉nvidia-smi
以矩阵格式打印 GPU 的亲和性。
由nvidia-smi
打印的矩阵显示了系统中每对可用 GPU 之间的亲和性。由于同一设备之间的亲和性是不合逻辑的,矩阵对角线标有 X。在其余坐标中,矩阵展示了标签,以表示该设备对的最佳连接类型。矩阵可能的标签如下(从nvidia-smi
手册调整):
-
SYS:连接通过 PCIe 以及 NUMA 节点之间的互联(例如 QPI/UPI 互联)
-
NODE:连接通过 PCIe 以及 NUMA 节点内 PCIe 根复杂的连接
-
PHB:连接通过 PCIe 以及 PCIe 根复杂(PCIe 主机桥)
-
PXB:连接通过多个 PCIe 桥(而不通过任何 PCIe 根复杂)
-
PIX:连接最多通过单个 PCIe 桥
-
NV#:连接通过一组#个 NVLink 的绑定
让我们评估一个由nvidia-smi
生成的亲和矩阵的示例。在一个由 8 个 GPU 组成的环境中生成的表 10.1 所示的矩阵:
GPU0 | GPU1 | GPU2 | GPU3 | GPU4 | GPU5 | GPU6 | GPU7 | |
---|---|---|---|---|---|---|---|---|
GPU0 | X | NV1 | NV1 | NV2 | NV2 | 系统 | 系统 | 系统 |
GPU1 | NV1 | X | NV2 | NV1 | 系统 | NV2 | 系统 | 系统 |
GPU2 | NV1 | NV2 | X | NV2 | 系统 | 系统 | NV1 | 系统 |
GPU3 | NV2 | NV1 | NV2 | X | 系统 | 系统 | 系统 | NV1 |
GPU4 | NV2 | 系统 | 系统 | 系统 | X | NV1 | NV1 | NV2 |
GPU5 | 系统 | NV2 | 系统 | 系统 | NV1 | X | NV2 | NV1 |
GPU6 | 系统 | 系统 | NV1 | 系统 | NV1 | NV2 | X | NV2 |
GPU7 | 系统 | 系统 | 系统 | NV1 | NV2 | NV1 | NV2 | X |
表 10.1 - 由 nvidia-smi 生成的亲和矩阵示例
在表 10.1中描述的亲和矩阵告诉我们,一些 GPU 通过两个 NVLink 连接(标记为NV2
),而其他一些只通过一个 NVLink 连接(标记为NV1
)。此外,许多其他 GPU 没有共享 NVLink 连接,仅通过系统中的最大路径连接(标记为SYS
)。
因此,在分布式训练过程中,如果我们需要选择两个 GPU 一起工作,建议使用例如 GPU #0
和 #3
,GPU #0
和 #4
,以及 GPU #1
和 #2
,因为这些设备对通过两个绑定 NVLink 连接。相反,较差的选择将是使用 GPU #0
和 #5
或者#2
和 #4
,因为这些设备之间的连接跨越整个系统。
如果我们有兴趣了解两个特定设备的亲和性,可以执行带有-i
参数的nvidia-smi
,然后跟上 GPU 的 ID:
maicon@packt:~$ nvidia-smi topo -p -i 0,1Device 0 is connected to device 1 by way of multiple PCIe switches.
在这个例子中,GPU #0
和 #1
通过多个 PCIe 开关连接,虽然它们不经过任何 PCIe 根复杂。
注意
另一种映射 NVIDIA GPU 拓扑的方法是使用 NVIDIA 拓扑感知 GPU 选择(NVTAGS)。NVTAGS 是 NVIDIA 创建的工具集,用于自动确定 GPU 之间最快的通信通道。有关 NVTAGS 的更多信息,您可以访问此链接:developer.nvidia.com/nvidia-nvtags
设置 GPU 亲和性
设置 GPU 亲和性最简单的方法是使用 CUDA_VISIBLE_DEVICES
环境变量。此变量允许我们指示哪些 GPU 将对基于 CUDA 的程序可见。要做到这一点,我们只需指定 GPU 的编号,用逗号分隔即可。
例如,考虑一个配备 8 个 GPU 的环境,我们必须将 CUDA_VISIBLE_DEVICES
设置为 2,3
,以便可以使用 GPU #2
和 #3
:
CUDA_VISIBLE_DEVICES = "2,3"
注意 CUDA_VISIBLE_DEVICES
定义了 CUDA 程序将使用哪些 GPU,而不是设备的数量。因此,如果变量设置为 5
,例如,CUDA 程序将只看到系统中可用的八个设备中的 GPU 设备 #5
。
有三种方法可以设置 CUDA_VISIBLE_DEVICES
以选择我们想在训练过程中使用的 GPU:
-
在启动训练程序之前 导出 变量:
maicon@packt:~$ export CUDA_VISIBLE_DEVICES="4,6"maicon@packt:~$ python training_program.py
-
在训练程序内部 设置 变量:
os.environ['CUDA_VISIBLE_DEVICES'] ="4,6"
-
在训练程序的同一命令行中 定义 变量:
maicon@packt:~$ CUDA_VISIBLE_DEVICES="4,6" python training_program.py
在下一节中,我们将学习如何在多个 GPU 上编写和启动分布式训练。
在多个 GPU 上实施分布式训练
在本节中,我们将向您展示如何使用 NCCL 在多个 GPU 上实施和运行分布式训练,NCCL 是 NVIDIA GPU 的 事实上 通信后端。我们将首先简要概述 NCCL,之后我们将学习如何在多 GPU 环境中编写和启动分布式训练。
NCCL 通信后端
NCCL 代表 NVIDIA 集体通信库。顾名思义,NCCL 是为 NVIDIA GPU 提供优化集体操作的库。因此,我们可以使用 NCCL 来执行诸如广播、归约和所谓的全归约操作等集体例程。粗略地说,NCCL 在 Intel CPU 上的作用类似于 oneCCL。
PyTorch 原生支持 NCCL,这意味着默认安装的 PyTorch 针对 NVIDIA GPU 已经内置了 NCCL 版本。NCCL 可在单台或多台机器上工作,并支持高性能网络的使用,如 InfiniBand。
与 oneCCL 和 OpenMP 类似,NCCL 的行为也可以通过环境变量进行控制。例如,我们可以通过 NCCL_DEBUG
环境变量来控制 NCCL 的日志级别,接受 trace
、info
和 warn
等值。此外,还可以通过设置 NCCL_DEBUG_SUBSYS
变量来根据子系统过滤日志。
注意
可以在 docs.nvidia.com/deeplearning/nccl/user-guide/docs/env.html
找到完整的 NCCL 环境变量集合。
在下一节中,我们将学习如何使用 NCCL 作为分布式训练过程中的通信后端,实现多 GPU 环境下的分布式训练。
编写和启动多 GPU 的分布式训练
将训练过程分布在多个 GPU 上的代码和启动脚本与《第九章》中介绍的几乎相同,即多 CPU 训练。在这里,我们将学习如何将它们调整为多 GPU 环境下的分布式训练。
编写多 GPU 的分布式训练
我们只需要对多 CPU 代码进行两处修改。
注意
本节展示的完整代码可以在 github.com/PacktPublishing/Accelerate-Model-Training-with-PyTorch-2.X/blob/main/code/chapter10/nccl_distributed-efficientnet_cifar10.py
找到。
第一个修改涉及将 nccl
作为 init_process_group
方法(第 77 行)中 backend
参数的输入传递:
dist.init_process_group(backend="nccl", init_method="env://")
第二个修改尽管最重要。由于我们正在多 GPU 环境中运行训练进程,因此需要确保每个进程专属地分配系统上可用的一个 GPU。
通过这样做,我们可以利用进程排名来定义将分配给进程的设备。例如,考虑一个由四个 GPU 组成的多 GPU 环境,进程排名 0 将使用 GPU #0
,进程排名 1 将使用 GPU #1
,依此类推。
尽管这个变更对于正确执行分布式训练至关重要,但实现起来非常简单。我们只需将存储在 my_rank
变量中的进程排名分配给 device
变量即可。
device = my_rank
关于 GPU 的关联性,您可能会想知道,如果每个进程分配对应于其排名的 GPU,那我们该如何选择要使用的 GPU 呢? 这个问题是合理的,通常会导致很多混淆。幸运的是,答案很简单。
结果表明,CUDA_VISIBLE_DEVICES
变量从训练程序中抽象出真实的 GPU 标识。因此,如果我们将该变量设置为6,7
,训练程序将只看到两个设备 - 即标识为 0 和 1 的设备。因此,等级为 0 和 1 的进程将分配 GPU 号码 0 和 1,这些号码实际上是 6 和 7 的真实 ID。
总结一下,这两个修改就足以使代码在多 GPU 环境中准备好执行。那么,让我们继续下一步:启动分布式训练过程。
启动在多 GPU 上的分布式训练过程
在多 GPU 上执行分布式训练的脚本与我们用于在多 CPU 上运行分布式训练的脚本逻辑相同:
TRAINING_SCRIPT=$1NGPU=$2
TORCHRUN_COMMAND="torchrun --nnodes 1 --nproc-per-node $NGPU --master-addr localhost $TRAINING_SCRIPT"
$TORCHRUN_COMMAND
在 GPU 版本中,我们将 GPU 数量作为输入参数传递,而不是进程数量。因为我们通常将一个完整的 GPU 分配给一个单独的进程,在分布式训练中,进程数量等于我们打算使用的 GPU 数量。
关于执行脚本的命令行,CPU 版本和 GPU 版本之间没有区别。我们只需调用脚本的名称并通知训练脚本,然后是 GPU 的数量:
maicon@packt:~$ ./launch_multiple_gpu.sh nccl_distributed-efficientnet_cifar10.py 8
我们也可以调整脚本,使其像 CPU 实现一样使用容器:
TRAINING_SCRIPT=$1NGPU=$2
SIF_IMAGE=$3
TORCHRUN_COMMAND="torchrun --nnodes 1 --nproc-per-node $NGPU --master-addr localhost $TRAINING_SCRIPT"
apptainer exec --nv $SIF_IMAGE $TORCHRUN_COMMAND
GPU 实现的独特差异涉及 Apptainer 命令行。当使用 NVIDIA GPU 时,我们需要使用--nv
参数调用 Apptainer 来启用容器内这些设备的支持。
注意
本节展示的完整代码可在github.com/PacktPublishing/Accelerate-Model-Training-with-PyTorch-2.X/blob/main/scripts/chapter10/launch_multiple_gpu.sh
获取。
现在,让我们看看使用多个 GPU 进行快速分布式训练的速度能有多快。
实验评估
为了评估在多个 GPU 上的分布式训练,我们使用了一台配备 8 个 NVIDIA A100 GPU 的单机对 CIFAR-10 数据集训练 EfficientNet 模型进行了 25 个 epoch。作为基准,我们将使用仅使用 1 个 GPU 训练此模型的执行时间,即 707 秒。
使用 8 个 GPU 训练模型的执行时间为 109 秒,相比仅使用 1 个 GPU 训练模型的执行时间,性能显著提升了 548%。换句话说,使用 8 个 GPU 进行的分布式训练比单个训练方法快了近 6.5 倍。
然而,与使用多个 CPU 进行的分布式训练过程一样,使用多 GPU 进行的训练也会导致模型准确率下降。使用 1 个 GPU 时,训练的模型达到了 78.76%的准确率,但使用 8 个 GPU 时,准确率降至 68.82%。
这种模型准确性的差异是相关的;因此,在将训练过程分配给多个 GPU 时,我们不应该将其置之不理。相反,我们应该考虑在分布式训练过程中考虑这一点。例如,如果我们不能容忍模型准确性差异达到 10%,我们应该尝试减少 GPU 的数量。
为了让您了解性能增益与相应模型准确性之间的关系,我们进行了额外的测试。结果显示在表 10.2中:
GPU 的数量 | 执行时间 | 准确性 |
---|---|---|
1 | 707 | 78.76% |
2 | 393 | 74.82% |
3 | 276 | 72.70% |
4 | 208 | 70.72% |
5 | 172 | 68.34% |
6 | 142 | 69.44% |
7 | 122 | 69.00% |
8 | 109 | 68.82% |
表 10.2 - 使用多个 GPU 进行分布式训练的结果
如表 10.2所示,随着 GPU 数量的增加,准确性往往会下降。然而,如果我们仔细观察,我们会发现 4 个 GPU 在保持准确性超过 70%的同时,实现了非常好的性能提升(240%)。
有趣的是,当我们在训练过程中使用 2 个 GPU 时,模型准确性下降了 4%。这个结果显示,即使使用最少数量的 GPU,分布式训练也会影响准确性。
另一方面,从 5 个设备开始,模型准确性几乎保持稳定在约 68%,尽管性能改进不断上升。
简而言之,在增加分布式训练过程中 GPU 数量时,注意模型准确性至关重要。否则,对性能提升的盲目追求可能会导致训练过程中不良结果的产生。
下一节提供了一些问题,以帮助您巩固本章学到的内容。
测验时间!
让我们通过回答几个问题来回顾本章学到的内容。最初,尝试在不查阅材料的情况下回答这些问题。
注意
所有这些问题的答案都可以在github.com/PacktPublishing/Accelerate-Model-Training-with-PyTorch-2.X/blob/main/quiz/chapter10-answers.md
找到。
在开始测验之前,请记住这不是一次测试!本节旨在通过复习和巩固本章内容来补充您的学习过程。
选择以下问题的正确选项。
-
GPU 互联的三种主要类型是哪三种?
-
PCI Express、NCCL 和 GPU-Link。
-
PCI Express、NVLink 和 NVSwitch。
-
PCI Express、NCCL 和 GPU-Switch。
-
PCI Express、NVML 和 NVLink。
-
-
NVLink 是一种专有的互联技术,允许您做哪些事情?
-
将 GPU 连接到 CPU。
-
将 GPU 连接到主存储器。
-
将 GPU 对直接连接到一起。
-
将 GPU 连接到网络适配器。
-
-
用于定义 GPU 亲和性的环境变量是哪一个?
-
CUDA_VISIBLE_DEVICES
。 -
GPU_VISIBLE_DEVICES
。 -
GPU_ACTIVE_DEVICES
。 -
CUDA_AFFINITY_DEVICES
。
-
-
什么是 NCCL?
-
NCCL 是用于连接 NVIDIA GPU 的互连技术。
-
NCCL 是用于分析在 NVIDIA GPU 上运行的程序的库。
-
NCCL 是用于为 NVIDIA GPU 生成优化代码的编译工具包。
-
NCCL 是为 NVIDIA GPU 提供优化集体操作的库。
-
-
哪个程序启动器可用于在多个 GPU 上运行分布式训练?
-
GPUrun。
-
Torchrun。
-
NCCLrun。
-
oneCCL。
-
-
如果我们将
CUDA_VISIBLE_DEVICES
环境变量设置为“2,3
”,那么训练脚本将传递哪些设备号?-
2 和 3。
-
3 和 2。
-
0 和 1。
-
0 和 7。
-
-
如何获取有关在特定多 GPU 环境中采用的互连拓扑的更多信息?
-
运行带有
-``interconnection
选项的nvidia-topo-ls
命令。 -
运行带有
-``gpus
选项的nvidia-topo-ls
命令。 -
运行带有
-``interconnect
选项的nvidia-smi
命令。 -
运行带有
-``topo
选项的nvidia-smi
命令。
-
-
PCI Express 技术用于在计算系统中连接 PCI Express 设备的哪个组件?
-
PCIe 交换机。
-
PCIe nvswitch。
-
PCIe 连接。
-
PCIe 网络。
-
摘要
在本章中,我们学习了如何通过使用 NCCL,在多个 GPU 上分发训练过程,这是优化的 NVIDIA 集体通信库。
我们从理解多 GPU 环境如何利用不同技术来互连设备开始本章。根据技术和互连拓扑,设备之间的通信可能会减慢整个分布式训练过程。
在介绍多 GPU 环境后,我们学习了如何使用 NCCL 作为通信后端和 torchrun
作为启动提供者,在多个 GPU 上编码和启动分布式训练。
我们的多 GPU 实现的实验评估显示,使用 8 个 GPU 进行分布式训练比单个 GPU 运行快 6.5 倍;这是显著的性能改进。我们还了解到,在多 GPU 上进行分布式训练可能会影响模型的准确性,因此在增加用于分布式训练过程中的设备数量时必须考虑这一点。
结束我们加速 PyTorch 训练过程的旅程,下一章中,我们将学习如何在多台机器之间分发训练过程。
更多推荐
所有评论(0)