Press "Enter" to skip to content

在CPU上扩展BERT推理(第一部分)

.centered { display: block; margin: 0 auto; } figure { text-align: center; display: table; max-width: 85%; /* 示例; 如果可以,请设置一些数量(px或%) */ margin: 10px auto; /* 除非要居中,否则不需要 */ }

1. 上下文和动机

回顾2019年10月,我的同事Lysandre Debut发表了一篇全面(当时而言)的推理性能基准测试博客(1)。

从那时起,🤗 transformers(2)迎来了大量新架构和成千上万个新模型加入到了🤗 hub(3),截至2021年第一季度,总数已超过9,000个。

随着NLP领域越来越多地使用类似BERT的模型,高效地部署和运行这些架构仍然具有挑战性。这就是为什么我们最近推出了我们的🤗 Inference API:让您专注于为用户和客户创建价值,而不是深入研究运行这些模型的高度技术化的方面。

本博客文章是一系列的第一部分,将涵盖大多数硬件和软件优化,以更好地利用CPU进行BERT模型推理。

对于这篇初始博客文章,我们将涵盖硬件部分:

  • 建立基准线 – 开箱即用的结果
  • 在利用现代CPU进行CPU限制任务时的实际和技术考虑
  • 核心数量的扩展 – 增加核心数量是否真的能提供更好的性能?
  • 批量大小扩展 – 使用多个并行和独立的模型实例增加吞吐量

我们决定聚焦于最著名的Transformer模型架构BERT(Delvin & al. 2018)(4)。虽然我们在本博客文章中聚焦于BERT-like模型以使文章简洁,但所描述的技术可以应用于Hugging Face模型hub上的任何架构。在本博客文章中,我们将不详细描述Transformer架构 – 要了解相关内容,我强烈推荐Jay Alammar的《Illustrated Transformer》博文(5)。

今天的目标是让您了解我们从开源角度使用PyTorch和TensorFlow进行BERT-like模型推理的进展,并介绍您可以轻松利用以加速推理的工具。

2. 基准测试方法

当涉及到从Hugging Face的模型hub中利用BERT-like模型时,有许多可以调整的参数来加快速度。为了量化”更快”的含义,我们将依赖广泛采用的指标:

  • 延迟:执行模型的时间(即前向调用)
  • 吞吐量:在固定时间内执行的次数

这两个指标将帮助我们理解本博客文章中的优点和权衡。

基准测试方法已从头开始重新实现,以便集成transformers提供的最新功能,并让社区以更加简单的方式运行和共享基准测试。整个框架现在基于Facebook AI & Research的Hydra配置库,使我们可以轻松报告和跟踪运行基准测试所涉及的所有项目,从而增加整体的可重复性。您可以在此处找到项目的整体结构

在2021年版本中,我们保留了通过PyTorch和Tensorflow运行推理工作负载的能力,就像之前的博客(1)中一样,还包括它们的追踪版本TorchScript(6)、Google加速线性代数(XLA)(7)。此外,我们决定包括对ONNX Runtime(8)的支持,因为它提供了许多专门针对基于transformers的模型的优化,使其成为考虑性能的一个很强的选择。

最后但并非最不重要的是,这个新的统一基准测试环境将使我们能够轻松地为不同的场景运行推理,例如使用较不精确的数值表示(float16int8int4)的量化模型(Zafrir & al.)(9)。这种被称为量化的方法在所有主要硬件提供商中都得到了广泛采用。在不久的将来,我们希望整合我们在Hugging Face上积极工作的其他方法,即蒸馏、修剪和稀疏化。

3. 基准

以下所有结果均在亚马逊网络服务(AWS)c5.metal实例上运行,利用Intel Xeon Platinum 8275 CPU(48核心/96线程)。选择此实例可提供所有有用的CPU功能,以加速深度学习工作负载,例如:

  • AVX512指令集(可能无法被各种框架直接利用)
  • Intel深度学习增强(也称为向量神经网络指令-VNNI),为运行量化网络(使用int8数据类型)提供了专用的CPU指令

选择使用金属实例是为了避免使用云提供商时可能出现的任何虚拟化问题。这使我们可以完全控制硬件,特别是在针对NUMA(非统一内存架构)控制器时,我们将在本文后面讨论。

操作系统为Ubuntu 20.04(LTS),所有实验都使用了Hugging Face transformers版本4.5.0、PyTorch 1.8.1和Google TensorFlow 2.4.0进行。

4. 开箱即用结果

图1. PyTorch (1.8.1) vs Google TensorFlow (2.4.1) 开箱即用
图2. PyTorch (1.8.1) vs Google TensorFlow (2.4.1) 开箱即用 - (更大批次大小)

直截了当地说,就开箱即用而言,PyTorch在所有测试的配置中显示出比TensorFlow更好的推断结果。重要的是要注意,开箱即用的结果可能不反映PyTorch和TensorFlow的“最佳”设置,因此可能在这里具有欺骗性。

解释这两个框架之间的差异的一种可能方式是执行运算符内并行部分的底层技术。PyTorch内部使用OpenMP(10)和Intel MKL(现在为oneDNN)(11)进行高效线性代数计算,而TensorFlow依赖Eigen及其自己的线程实现。

5. 将BERT推断扩展到现代CPU以增加总吞吐量

5.1. 引言

有多种方法可以提高诸如BERT推断的延迟和吞吐量。可以通过启用操作系统功能、使用性能更好的依赖库替换、仔细调整框架属性,以及利用CPU上的所有核心进行并行化逻辑来进行改进和调优。

在本博客文章的剩余部分中,我们将重点关注后者,也称为多推断流

这个想法很简单:分配多个相同模型的实例,并将每个实例的执行分配给专用的、不重叠的CPU核心子集,以便真正并行化实例。

5.2. 现代CPU上的核心和线程

在优化CPU推断以更好地利用CPU核心的过程中,您可能已经看到了 – 至少在过去的20年中 – 现代CPU规格报告“核心”和“硬件线程”或“物理”和“逻辑”数字。这些概念是指一种称为同步多线程(SMT)或Intel平台上的超线程的机制。

为了说明这一点,想象一下两个任务AB,并行执行,每个任务在自己的软件线程上。在某些时候,这两个任务有很高的概率需要等待一些资源从主内存、SSD、HDD甚至网络中获取。如果线程被调度在不同的物理核心上,没有超线程,在这些时期执行任务的核心处于空闲状态,等待资源到达,并且实际上什么也不做…因此无法充分利用。

现在,借助同时多线程(SMT),任务A和任务B的两个软件线程可以在同一个物理核心上调度,从而使它们的执行在该物理核心上交错进行:

任务A和任务B将在物理核心上同时执行,当一个任务停止时,另一个任务仍然可以在核心上继续执行,从而增加了该核心的利用率。

图3. Intel超线程技术(SMT)的示意图

上图3简化了假设单核心设置的情况。如果您想了解有关SMT在多核CPU上的工作原理的更多详细信息,请参考以下两篇具有非常深入技术解释的文章:

  • 英特尔®超线程技术-技术用户指南(12)
  • 超线程技术简介(13)

回到我们的模型推理工作负载… 如果您考虑一下,在一个完美的设置下,计算占据了大部分时间。在这种情况下,使用逻辑核心不应该带来任何性能优势,因为两个逻辑核心(硬件线程)竞争核心的执行资源。因此,由于任务主要是一些通用矩阵乘法(gemms(14)),它们本质上是CPU绑定的,不会从SMT中获益。

5.3. 利用多插槽服务器和CPU亲和性

现今的服务器带有许多核心,其中一些甚至支持多插槽设置(即主板上的多个CPU)。在Linux上,命令lscpu报告了系统上存在的所有CPU的规格和拓扑结构:

ubuntu@some-ec2-machine:~$ lscpu
体系结构:                    x86_64
CPU 操作模式:                  32位,64位
字节顺序:                      小端
地址大小:                   46位物理,48位虚拟
CPU数:                          96
在线CPU列表:             0-95
每个核心的线程数:              2
每个插槽的核心数:              24
插槽数:                       2
NUMA节点数:                    2
供应商ID:                       GenuineIntel
CPU系列:                      6
型号:                           85
型号名称:                      Intel(R) Xeon(R) Platinum 8275CL CPU @ 3.00GHz
步进:                        7
CPU MHz:                         1200.577
最大CPU MHz:                     3900.0000
最小CPU MHz:                     1200.0000
BogoMIPS:                        6000.00
虚拟化:                  VT-x
L1d缓存:                       1.5 MiB
L1i缓存:                       1.5 MiB
L2缓存:                        48 MiB
L3缓存:                        71.5 MiB
NUMA节点0的CPU:               0-23,48-71
NUMA节点1的CPU:               24-47,72-95

在我们的情况下,我们有一台有2个插槽的机器,每个插槽提供24个物理核心,每个核心有2个线程(SMT)。另一个有趣的特性是NUMA节点(0, 1),它表示核心和内存在系统上的映射方式。

非统一内存访问(NUMA)与统一内存访问(UMA)相反,后者通过插槽之间的单一统一总线和主内存来访问整个内存池。而NUMA将内存池分割,每个CPU插槽负责寻址一部分内存,减少了总线拥堵。

图5. UMA和NUMA架构的差异示意图(来源(15))

为了充分利用这样一台强大的机器,我们需要确保我们的模型实例在所有插槽上的所有物理核心上正确分派,并强制执行内存分配为“NUMA-aware”。

在Linux上,NUMA的进程配置可以通过numactl进行调整,它提供了一个接口来将进程绑定到一组CPU核心(称为线程亲和性)。此外,它还允许调整内存分配策略,确保为进程分配的内存尽可能接近核心的内存池(称为显式内存分配指令)。

注意:在这里设置核心和内存亲和性都很重要。在第0个插槽上执行计算并在第1个插槽上分配内存会要求系统通过插槽共享总线进行内存交换,从而导致不必要的开销。

5.4. 调整线程亲和性和内存分配策略

现在我们拥有了控制模型实例资源分配所需的所有参数,我们进一步看看如何有效地部署它们,并观察对延迟和吞吐量的影响。让我们逐步进行,以了解每个命令和参数的影响。

首先,我们启动没有任何调整的推理模型,并观察计算如何在CPU核心上分派(左侧)。

python3 src/main.py model=bert-base-cased backend.name=pytorch batch_size=1 sequence_length=128

然后,我们通过numactl使用所有物理核心和仅一个线程(线程0)来指定核心和内存亲和性(右侧):

numactl -C 0-47 -m 0,1 python3 src/main.py model=bert-base-cased backend.name=pytorch batch_size=1 sequence_length=128
图6. Linux htop命令无线程亲和性设置和有线程亲和性设置的并排结果

正如您所看到的,没有任何特定的调整,PyTorch和TensorFlow将工作分派到单个插槽上,使用该插槽的所有逻辑核心(24个核心上的两个线程)。此外,正如我们之前强调的,我们不希望在这种情况下利用SMT功能,因此我们将进程的线程亲和性设置为仅目标硬件线程1个。

请注意,这是针对此次运行的特定情况,可能因个别设置而异。因此,建议检查每个具体用例的线程亲和性设置。

让我们花点时间来强调一下我们在numactl中所做的:

  • -C 0-47表示numactl的线程亲和性是哪些核心(0到47号核心)。
  • -m 0,1表示numactl在两个CPU插槽上分配内存。

如果你想知道为什么我们将进程绑定到[0…47]号核心,你需要回头看一下lscpu的输出。从那里你会找到NUMA node0NUMA node1这一部分,它的形式是NUMA node<X> <logical ids>

在我们的例子中,每个插槽是一个NUMA节点,一共有2个NUMA节点。每个插槽或每个NUMA节点有24个物理核心和每个核心2个硬件线程,所以一共有48个逻辑核心。对于NUMA节点0,0-23是第0个硬件线程,24-47是第1个硬件线程,分布在插槽0的24个物理核心上。同样,对于NUMA节点1,48-71是第0个硬件线程,72-95是第1个硬件线程,分布在插槽1的24个物理核心上。

由于我们只针对每个物理核心选择1个线程,如前所述,我们只选择每个核心的线程0,因此逻辑处理器为0-47。由于我们同时使用了两个插槽,我们还需要相应地绑定内存分配(0,1)。

请注意,同时使用两个插槽可能并不总是能够获得最佳结果,特别是对于小规模问题。通过在两个插槽上使用计算资源的好处可能会因跨插槽通信开销的增加而减少甚至抵消。

6. 核心数量扩展 – 使用更多核心是否真的能提高性能?

在考虑如何提高我们的模型推理性能时,第一个合理的解决方案可能是为同样的工作投入更多资源。在本博客系列的其余部分中,我们将称此设置为核心数量扩展,意思是系统上用于完成任务的核心数量将会变化。在高性能计算领域,这通常被称为强扩展。

此时,您可能会想知道只分配一部分核心而不是将所有资源都投入到任务中以实现最低延迟的意义何在。

确实,根据问题的规模不同,将更多资源投入到任务中可能会得到更好的结果。对于小型问题,将更多CPU核心投入工作并不能提高最终延迟。

为了说明这一点,下图6.展示了不同问题规模(batch_size = 1, sequence_length = {32, 128, 512})下,使用不同CPU核心数量运行PyTorch和TensorFlow进行计算的延迟。

通过限制参与计算的资源数量,可以通过限制内部操作(此处指在执行计算的运算符内部,也称为“内核”)中涉及的CPU核心数量来实现。

通过以下API实现:

  • PyTorch:torch.set_num_threads(x)
  • TensorFlow:tf.config.threading.set_intra_op_parallelism_threads(x)
图7. 延迟测量

如您所见,根据问题的规模,参与计算的线程数量对延迟测量有积极影响。

对于小规模问题和VoAGI规模的问题,仅使用一个插槽将获得最佳性能。对于大规模问题,跨插槽通信的开销被计算成本所覆盖,因此可以从使用两个插槽上的所有核心获益。

7. 多流推理 – 并行使用多个实例

如果您还在阅读本文,那么您现在应该已经掌握了在CPU上设置并行推理工作负载的方法。现在,我们将重点介绍我们拥有的强大硬件所提供的一些可能性,并调整之前描述的参数,以尽可能线性地扩展我们的推理。

在接下来的部分中,我们将探讨另一种可能的扩展解决方案批量大小扩展,但在深入研究之前,让我们看一下如何利用Linux工具来分配线程亲和性,以实现有效的模型实例并行。

与核心数量扩展设置中一样,我们不再将更多核心投入到任务中,而是使用更多模型实例。每个实例将独立运行在自己分配的硬件资源子集上,以真正并行的方式运行在一部分CPU核心上。

7.1. 如何分配多个独立实例

让我们从简单的开始,如果我们想要生成2个实例,每个插槽分配24个核心:

numactl -C 0-23 -m 0 python3 src/main.py model=bert-base-cased batch_size=1 sequence_length=128 backend.name=pytorch backend.num_threads=24
numactl -C 24-47 -m 1 python3 src/main.py model=bert-base-cased batch_size=1 sequence_length=128 backend.name=pytorch backend.num_threads=24

从这里开始,每个实例不与其他实例共享任何资源,从硬件的角度来看,一切都以最高效率运行。延迟测量与单个实例达到的相同,但吞吐量实际上提高了2倍,因为两个实例以真正并行的方式运行。

我们可以进一步增加实例的数量,降低每个实例分配的内核数量。让我们运行4个独立的实例,每个实例有效地绑定到12个CPU核心。

numactl -C 0-11 -m 0 python3 src/main.py model=bert-base-cased batch_size=1 sequence_length=128 backend.name=pytorch backend.num_threads=12
numactl -C 12-23 -m 0 python3 src/main.py model=bert-base-cased batch_size=1 sequence_length=128 backend.name=pytorch backend.num_threads=12
numactl -C 24-35 -m 1 python3 src/main.py model=bert-base-cased batch_size=1 sequence_length=128 backend.name=pytorch backend.num_threads=12
numactl -C 36-47 -m 1 python3 src/main.py model=bert-base-cased batch_size=1 sequence_length=128 backend.name=pytorch backend.num_threads=12

结果仍然相同,我们的4个实例在真正的并行方式下运行。延迟会略高于之前的示例(使用的内核数量减少了一半),但吞吐量将再次提高2倍。

7.2. 智能分发 – 为不同问题大小分配不同的模型实例

由于此设置提供的另一个可能性是为各种问题大小精心调整多个实例。通过智能分发方法,可以根据请求工作负载选择最佳延迟的正确配置来重定向传入的请求。

# 小型问题(序列长度≤32)仅使用8个核心(在套接字0上使用8/24个核心)
numactl -C 0-7 -m 0 python3 src/main.py model=bert-base-cased batch_size=1 sequence_length=32 backend.name=pytorch backend.num_threads=8

# VoAGI大小的问题(32>序列≤384)使用剩余的16个核心(在套接字0上使用(8+16)/24个核心)
numactl -C 8-23 -m 0 python3 src/main.py model=bert-base-cased batch_size=1 sequence_length=128 backend.name=pytorch backend.num_threads=16

# 大型问题(序列≥384)使用整个CPU(在套接字1上使用24/24个核心)
numactl -C 24-37 -m 1 python3 src/main.py model=bert-base-cased batch_size=1 sequence_length=384 backend.name=pytorch backend.num_threads=24

8. 批量大小缩放 – 使用多个并行独立的模型实例来提高吞吐量和延迟

另一个非常有趣的扩展推理方法是实际上将更多的模型实例放入池中,并与实际工作负载按比例减少每个实例接收的工作负载。

此方法实际上改变了问题的大小(批量大小)和计算中涉及的资源(内核)。

举例来说,假设您有一个具有C个CPU核心的服务器,您想要运行一个包含B个样本和S个标记的工作负载。您可以将此工作负载表示为形状为[B,S]的张量,其中B是批量大小,S是B个样本中的最大序列长度。

对于所有实例(N),它们中的每一个都在C / N个核心上执行,并且会接收任务的一个子集[B / N,S]

每个实例不接收全局批次,而是都接收其子集[B / N,S],因此称为批量大小缩放。为了突出这种缩放方法的好处,下面的图表报告了在扩展模型实例的同时对延迟的影响以及对吞吐量的影响。

在查看结果时,让我们关注延迟和吞吐量方面:

一方面,我们取实例池中的最大延迟来反映处理批次中的所有样本所需的时间。换句话说,由于实例以真正的并行方式运行,从所有实例中收集批次块所需的时间由池中个别实例完成其块所需的最长时间驱动。

如您在图7中所示,增加实例数量时的实际延迟增益确实取决于问题的规模。在所有情况下,我们可以找到一种最佳资源分配(批处理大小和实例数量)来最小化延迟,但在计算中涉及的核心数量没有特定的模式。

此外,需要注意的是,结果在另一个系统(即操作系统、内核版本、框架版本等)上可能完全不同。

图8总结了在目标最小延迟时采用的最佳多实例配置,通过将参与实例数量的最小值取出。例如,对于{批处理大小=8,序列长度=128},使用4个实例(每个实例具有{批处理大小=2}和12个核心)可以得到最佳的延迟测量。

图9报告了在各种问题规模下,PyTorch和TensorFlow都能最小化延迟的所有设置。

剧透:我们将在后续博客文章中讨论许多其他优化措施,这些措施将极大地影响此图表。

图8. 针对批处理大小为8的实例数量的最大延迟演变
图9. 针对批处理大小为8的总体延迟最小化的实例数量

另一方面,我们观察到吞吐量是所有模型实例并行执行的总和。这使我们能够可视化系统的可扩展性,当添加越来越多的实例时,每个实例都具有更少的资源,但工作量也成比例增加。在这里,结果显示几乎线性可扩展性,因此是最佳的硬件使用。

图10. 针对批处理大小为8的实例数量的总吞吐量

9. 结论

通过本博客文章,我们介绍了在PyTorch和TensorFlow中可以期望到的开箱即用的BERT推理性能,它们可以通过简单的PyPi安装并且不需要进一步调整。需要强调的是,这里提供的结果反映了开箱即用的框架设置,因此可能并不提供绝对最佳性能。我们决定不在本博客文章中包含优化措施,以便专注于硬件和效率。优化措施将在第二部分中讨论!🚀

然后,我们详细介绍了设置线程亲和性对目标问题大小和所需核心数量之间的影响和重要性的权衡。此外,重要的是要定义在优化部署时要使用的哪些标准(即延迟 vs 吞吐量),因为得出的设置可能完全不同。

在更一般的情况下,小型问题(短序列和/或小批次)可能需要比大型问题(非常长的序列和/或大批次)更少的核心来实现最佳延迟。

在考虑最终部署平台时,涵盖所有这些方面是很有意思的,因为它可能大大降低基础设施的成本。例如,我们的48个核心机器每小时收费4.848美元,而只有8个核心的较小实例可以将成本降低到0.808美元,从而实现6倍的成本降低

最后但并非最不重要的是,本博客文章中讨论的许多调整选项可以通过启动脚本自动调整,该启动脚本受到Intel原始脚本的启发,并可在此处获得。启动脚本能够以正确的线程亲和性自动启动您的Python进程,有效地在实例之间分配资源,以及许多其他性能提示!我们将在第二部分中详细介绍其中的许多技巧🧐。

在后续的博客文章中,还将涉及更高级的设置和调整技术,以进一步降低模型延迟,例如:

  • 启动脚本演示
  • 调整内存分配库
  • 使用Linux的透明巨大页面机制
  • 使用特定供应商的数学/并行库

敬请关注! 🤗

致谢

  • Omry Yadan(Facebook FAIR)- OmegaConf和Hydra的作者,为正确设置Hydra提供了所有的技巧。
  • 所有Intel和Intel Labs的NLP同事们- 对transformers和NLP领域中的持续优化和研究工作。
  • Hugging Face同事们- 在审查过程中的所有评论和改进。

参考资料

  1. 基准测试Transformers:PyTorch和TensorFlow
  2. HuggingFace的Transformers:最先进的自然语言处理
  3. HuggingFace的模型中心
  4. BERT- 深度双向Transformer的预训练(Devlin等,2018)
  5. Jay Alammar的Illustrated Transformer博文
  6. PyTorch- TorchScript
  7. Google加速线性代数(XLA)
  8. ONNX Runtime- 优化和加速机器学习推理和训练
  9. Q8BERT- 量化8位BERT(Zafrir等,2019)
  10. OpenMP
  11. Intel oneDNN
  12. Intel®超线程技术- 技术用户指南
  13. 超线程技术介绍
  14. BLAS(Basic Linear Algebra Subprogram)- 维基百科
  15. 为NUMA优化应用程序
Leave a Reply

Your email address will not be published. Required fields are marked *