在现代 CPU 上扩展 BERT 类模型推理 - 第 2 部分

发布于 2021 年 11 月 4 日
在 GitHub 上更新

引言:使用英特尔软件优化 CPU 上的 AI 效率

正如我们在上一篇博文中详细介绍的,英特尔至强 CPU 提供了一套专为 AI 工作负载设计的功能,例如 AVX512 或 VNNI(向量神经网络指令),用于使用整数量化神经网络进行高效推理,以及额外的系统工具以确保工作以最有效的方式完成。在这篇博文中,我们将重点介绍软件优化,并向您展示英特尔新一代 Ice Lake 至强 CPU 的性能。我们的目标是向您全面展示软件方面可用的功能,以充分利用您的英特尔硬件。与上一篇博文一样,我们通过基准测试结果和图表展示性能,并提供新工具,使所有这些旋钮和功能易于使用。

早在 4 月份,英特尔就发布了其最新一代英特尔至强处理器,代号 Ice Lake,旨在实现更高效、更高性能的 AI 工作负载。更确切地说,与上一代 Cascade Lake 至强处理器相比,Ice Lake 至强 CPU 在各种 NLP 任务上的推理速度可提高高达 75%。这得益于硬件和软件的结合改进,例如新指令和新 Sunny Cove 架构中 PCIe 4.0 的特性,以支持机器学习和深度学习工作负载。最后但同样重要的是,英特尔致力于针对各种框架进行专门优化,这些框架现在都带有英特尔的版本,例如英特尔 Scikit Learn 扩展英特尔 TensorFlow英特尔 PyTorch 扩展

所有这些功能在数据科学家和机器学习工程师日常使用的工具栈中都处于非常低的级别。在绝大多数情况下,更常见的是依赖更高级别的框架和库来处理多维数组操作,例如PyTorchTensorFlow,并利用高度优化的数学运算符,例如用于计算部分的BLAS (基本线性代数子程序)

在此领域,英特尔通过提供 oneAPI 伞形下的软件组件发挥着重要作用,这使得通过英特尔 oneMKL (数学核心库)、具有英特尔 OpenMP 或 线程构建块 (oneTBB) 的更高级别并行化框架,可以非常容易地使用高效的线性代数例程。此外,oneAPI 还提供了一些领域特定的库,例如英特尔 oneDNN 用于深度神经网络原语(ReLU、全连接等)或 oneCCL 用于集体通信,当使用分布式设置访问多个主机上的高效 all-reduce 操作时尤其有用。

其中一些库,特别是 MKL 或 oneDNN,已原生包含在 PyTorch 和 TensorFlow 等框架中(自 2.5.0 版本起),以便为最终用户开箱即用地带来所有性能改进。当想要针对非常具体的硬件功能时,英特尔提供了最常用软件的自定义版本,特别是针对英特尔平台优化的版本。例如,对于 TensorFlow,英特尔提供了高度调整和优化的自定义框架版本,或者对于英特尔 PyTorch 扩展 (IPEX) 框架,可以将其视为一个在 PyTorch 上游化之前的特性实验室。

深入探讨:利用高级英特尔功能提高 AI 性能

性能调优旋钮

如上所述,我们将介绍一套新的可调项,以提高我们 AI 应用程序的性能。从宏观角度来看,每个机器学习和深度学习框架都由相同的组成部分构成:

  1. 内存中数据(向量、矩阵等)的结构化表示方式
  2. 数学运算符的实现
  3. 在目标硬件上高效并行化计算

除了上面列出的几点,深度学习框架还提供了表示数据流和依赖关系以计算梯度的 S方式。这超出了本博客的范围,它利用了上面列出的相同组件!


图 1. oneAPI 伞形下的英特尔库概述

1. 内存分配和管理库

这篇博客文章将刻意跳过关于数据表示的第一点,因为它更多地是框架特定的。作为参考,PyTorch 使用其自己的实现,称为ATen,而 TensorFlow 则为此目的依赖于开源库Eigen

虽然对不同的对象结构和布局应用通用优化非常复杂,但有一个领域我们可以施加影响:内存分配。简单来说,内存分配在这里指的是程序化地向操作系统请求一个动态(事先未知)的系统区域,我们可以在其中存储项目,例如 C 中的 malloc 和派生函数或 C++ 中的 new 运算符。内存效率,无论是速度还是碎片化方面,都是一个广泛的科学和工程主题,根据任务和底层硬件有多种解决方案。在过去的几年里,我们在这个领域看到了越来越多的工作,尤其是

每一个都提出了不同的方法来改进各种软件上的内存分配和管理方面。

2. 高效的计算并行化

现在我们有了表示数据的高效方法,我们需要一种方法来充分利用我们可用的计算硬件。有趣的是,在推理方面,CPU 在某种程度上比 GPU 具有潜在优势,因为它们无处不在,而且不需要特定的应用程序组件和管理人员来操作它们。

现代 CPU 拥有众多核心和复杂的机制,旨在提高软件的整体性能。然而,正如我们在第一篇博客文章中强调的那样,它们还具有可以根据您所针对的工作负载类型(CPU 密集型或 I/O 密集型)进行调整的功能,以进一步提高应用程序的性能。

尽管如此,实现并行算法可能不像投入更多核心来完成工作那么简单。许多因素,例如使用的数据结构、并发数据访问、CPU 缓存失效——所有这些都可能阻碍您的算法有效提高速度。作为参考,如果您有兴趣深入探讨该主题,我们推荐观看 Scott Meyers 的演讲:CPU 缓存及其重要性

幸运的是,有一些库可以使这种并行算法的开发过程更容易且不易出错。在最常见的并行库中,我们可以提到 OpenMP 和 TBB(线程构建块),它们在不同级别上工作,从 C/C++ 中的编程 API 到环境变量调整和动态调度。在英特尔硬件上,建议使用 OpenMP 规范的英特尔实现,通常称为“IOMP”,它是 英特尔 oneAPI 工具包 的一部分。


图 2. 通过 OpenMP 完成并行计算的代码片段

3. 优化的数学运算符

现在我们已经介绍了设计高效数据结构和并行算法所需的构建模块,剩下最后一块是运行计算的,即实现各种数学运算符和神经网络层,以完成我们最喜欢的事情——设计神经网络!😊

在每个程序员工具包中,都有多个级别可以提供数学运算支持,这些支持可以根据各种因素进行不同的优化,例如使用的数据存储布局(连续内存、分块、打包等)、表示每个标量元素的数据格式(Float32、Integer、Long、Bfloat16 等),当然还有您的处理器支持的各种指令。

如今,几乎所有处理器都支持对标量项(一次一个项)或矢量化模式(意味着它们在相同的 CPU 指令中对多个项进行操作,称为 SIMD“单指令多数据”)进行基本数学运算。著名的 SIMD 指令集包括 SSE2、AVX、AVX2 和最新一代英特尔 CPU 中存在的 AVX-512,它们能够在单个 CPU 时钟内操作超过 16 字节的内容。

大多数时候,人们不必过多担心为执行两个向量之间的简单元素级加法而生成的实际汇编代码,但如果您确实需要,也有一些库允许您在编写调用 CPU 特定内在函数的代码之上再高一层,以实现高效的数学内核。例如,英特尔的 MKL“数学核心库”就提供了这一点,以及著名的 BLAS“基本线性代数子程序”接口来实现线性代数的所有基本操作。

最后,在此之上,还可以找到一些领域特定的库,例如英特尔的 oneDNN,它提供了实现神经网络层所需的所有最常见和基本的构建块。英特尔 MKL 和 oneDNN 原生集成在 PyTorch 框架中,可以为某些操作(如线性 + ReLU 或卷积)实现性能加速。在 TensorFlow 方面,可以通过设置环境变量 TF_ENABLE_ONEDNN_OPTS=1 (TensorFlow >= 2.5.0) 来启用 oneDNN,以在底层实现类似的机制。

在最新的英特尔 Ice Lake CPU 上实现更高效的 AI 处理

为了报告 Ice Lake 产品线的性能,我们将严格遵循本系列第一篇博客中使用的测试方法。提醒一下,我们将采用完全相同的模式来测试本第二篇博客中将重点介绍的各种设置。更具体地说,以下部分中显示的结果基于:

  • PyTorch: 1.9.0
  • TensorFlow: 2.5.0
  • 批大小:1、4、8、16、32、128
  • 序列长度:8、16、32、64、128、384、512

我们将通过该领域公认的指标来呈现结果,以建立所提出优化的性能:

  • 延迟:执行单个推理请求(即“前向调用”)通过模型所需的时间,以毫秒表示。
  • 吞吐量:系统在规定时间内可以维持的推理请求(即“前向调用”)数量,以调用/秒表示。

我们还将提供一个初始基线,显示开箱即用的结果,以及一个应用了我们在第一篇博客文章中强调的所有不同优化的第二个基线。所有测试均在英特尔提供的云实例上运行,该实例配备了 Ice Lake 至强铂金 8380 CPU,运行 Ubuntu 20.04.2 LTS。

您可以在各种云提供商处找到相同的处理器:


图 3. 英特尔 Ice Lake 至强 8380 规格

建立基线

如前所述,基线将由两种不同的设置组成:- 开箱即用:我们按原样运行工作负载,不进行任何调整 - 优化:我们应用博客 #1 中存在的各种旋钮

此外,根据我们对上一篇博文的评论,我们希望更改在最终基准测试中呈现框架的方式。因此,在第二篇博文的其余部分,我们将根据以下内容划分框架基准测试结果:

  • 使用“急切”模式进行计算的框架(PyTorch、TensorFlow)
  • 使用“图”模式进行计算的框架(TorchScript、TensorFlow Graph、Intel Tensorflow)

基线:急切框架的延迟

以急切模式运行的框架通常在执行时发现实际图。更确切地说,实际计算图事先是未知的,您逐渐(*急切地*)执行一个操作符,它将成为下一个操作符的输入,依此类推,直到您到达叶节点(输出)。

这些框架通常在您实现的算法中提供更大的灵活性,但代价是增加了运行时开销,并可能略微增加内存使用量以跟踪反向传播所需的所有元素。

最后但同样重要的是,通过这些框架通常更难启用图优化,例如运算符融合。例如,许多深度学习库(如 oneDNN)针对卷积 + ReLU 具有优化的内核,但您实际上需要在执行图之前知道这种模式将出现在操作序列中,这在急切框架中通过设计是不可能的。


图 4. PyTorch 延迟与所涉及核心数量的关系


图 5. Google TensorFlow 延迟与所涉及核心数量的关系


图 6. 启用 oneDNN 的 Google TensorFlow 延迟与所涉及核心数量的关系


图 7. 英特尔 TensorFlow 延迟与所涉及核心数量的关系

整体趋势突显了核心数量对观测到的延迟的积极影响。在大多数情况下,增加核心数量可以减少不同工作负载大小的计算时间。尽管如此,投入更多核心并不总是导致单调的延迟降低,在工作负载大小和分配给执行任务的资源数量之间始终存在权衡。

正如您在上面的图表中看到的,在一个以上 CPU(多于一个插槽)的系统上使用所有可用核心时,一种非常常见的模式倾向于出现。插槽间通信引入了显著的延迟开销,导致总体延迟的改进非常小。

此外,这种插槽间通信开销随着工作负载的增大而变得越来越不明显,这意味着所有计算资源的利用都受益于使用所有可用核心。在这个领域,PyTorch(图 1)和 Intel TensorFlow(图 4)似乎具有略好的并行支持,正如序列长度 384 和 512 的结果所示,其中使用所有核心仍然可以降低观察到的延迟。

基线:图框架延迟

这次我们比较使用“图”模式的框架的性能,在这种模式下,图是事先完全已知的,并且可以进行所有的分配和优化,例如图剪枝和运算符融合。


图 8. TorchScript 延迟与所涉及核心数量的关系


图 9. Google TensorFlow 延迟与所涉及核心数量的关系


图 10. 启用 oneDNN 的 Google TensorFlow 延迟与所涉及核心数量的关系


图 11. 英特尔 TensorFlow 延迟与所涉及核心数量的关系

这通常被称为“跟踪”图,正如您在这里看到的,TorchScript(PyTorch 的图执行模式)与 TensorFlow(s) 的结果并没有太大不同。所有 TensorFlow 实现的性能似乎都优于 TorchScript,当并行度受限时(操作内部计算涉及的核心数量较少),但随着计算资源的增加,这种优势似乎无法有效扩展,而 TorchScript 似乎能够更好地利用现代 CPU 的能力。

尽管如此,在大多数情况下,所有这些框架之间的差距都非常有限。

调整内存分配器:这会影响观察到的延迟吗?

每个动态分配内存的程序都依赖一个关键组件:内存分配器。如果您熟悉 C/C++ 编程,此组件提供 malloc/free 或 new/delete 的低位功能。大多数情况下,您不必过多担心它,并且默认的分配器(例如大多数 Linux 发行版上的 glibc)将提供出色的开箱即用性能。尽管如此,在某些情况下,它可能无法提供最有效的性能,因为这些默认分配器通常旨在在大多数情况下“良好”,而不是针对特定工作负载或并行性进行微调。

那么,有哪些替代方案,以及它们何时比默认方案更适用呢?这再次取决于您的软件所处的上下文。

可能的情况是:大量的分配/释放操作随着时间的推移导致碎片化;您运行软件的特定硬件和/或架构;最后是您应用程序的并行度。

你明白这是怎么回事了吗?深度学习,以及所有进行大量计算的应用程序,都是高度多线程的,PyTorch、TensorFlow 和任何其他面向机器学习工作负载的软件库也是如此。

默认的内存分配器策略通常依赖于全局内存池,这需要使用同步原语来操作,从而增加了系统的整体压力,降低了应用程序的性能。谷歌、Facebook 和微软等公司最近的一些工作提供了替代的内存分配策略,这些策略在自定义内存分配器库中实现,可以直接集成到其软件组件中,或使用动态共享库预加载来替换用于分配/释放的库。

在这些库中,我们可以举几个例子,如tcmallocjemallocmimalloc




图 12. 在不同任务上进行基准测试的各种内存分配器

通过这篇博客文章,我们将只专注于对 tcmalloc 和 jemalloc 作为潜在的内存分配器替代品进行基准测试。为了完全透明,对于以下结果的范围,我们使用了 Ubuntu 2.9 版本上 gperftools 包中的 tcmalloc 和 jemalloc 5.1.0-1。

内存分配器基准测试

再次,我们首先比较在急切模式下执行框架的性能。这可能是分配器可以发挥最大作用的用例:由于图在执行之前是未知的,每个框架必须在遇到上层节点的实际执行时管理每个操作所需的内存,无法提前规划。在这种情况下,由于所有用于分配和回收内存的系统调用,分配器是一个主要组成部分。


图 13. PyTorch 内存分配器和核心扩展延迟


图 14. Google TensorFlow 内存分配器和核心扩展延迟


图 15. 启用 oneDNN 的 Google TensorFlow 内存分配器和核心扩展延迟


图 16. 英特尔 TensorFlow 内存分配器和核心扩展延迟

根据上图,您可以注意到标准库分配器 (glibc) 的性能通常落后,但提供了合理的性能。Jemalloc 分配器有时是速度最快的,但在并发度不高的非常特定情况下,这可以通过 jemalloc 内部使用的底层结构来解释,这超出了本博客的范围,但如果您想了解更多信息,可以阅读 Facebook 工程博客

最后,tcmalloc 似乎在所有此处基准测试的工作负载中都提供了最佳性能。同样,tcmalloc 在分配资源方面与 Jemalloc 采取了不同的方法,特别是 tcmalloc 为每个线程在本地维护一个内存段池,这减少了对全局、独占、关键路径的需求。

同样,有关更多详细信息,我邀请您阅读 Google Abseil 团队的完整博客

现在,回到图模式,我们对拥有整体计算图全知表示的框架进行基准测试。


图 17. TorchScript 内存分配器和核心扩展延迟


图 18. Google TensorFlow 内存分配器和核心扩展延迟


图 19. 启用 oneDNN 的 Google TensorFlow 内存分配器和核心扩展延迟


图 20. 英特尔 TensorFlow 内存分配器和核心扩展延迟

这次,通过了解操作符流和涉及的矩阵形状的底层结构,框架可以提前规划和预留所需的资源。在这种情况下,如上图所示,框架之间的差异非常小,jemalloc 和 tcmalloc 之间没有明显的赢家。当然,glibc 作为通用内存分配器仍然略微落后,但其差距不如急切设置中那么显著。总而言之,调整内存分配器可以在优化过程结束时提供一个有趣的项来争取最后的毫秒改进,特别是如果您已经在使用跟踪计算图。

OpenMP

在上一节中,我们讨论了机器学习软件中的内存管理,主要涉及 CPU 密集型工作负载。此类软件通常依赖于 PyTorch 或 TensorFlow 等中间框架进行深度学习,这些框架通常抽象出所有底层高度并行的操作符实现。

编写如此高度并行和优化的算法是一个真正的工程挑战,它需要对 CPU 操作的所有实际元素(同步、内存缓存、缓存有效性等)有非常底层的理解。在这种情况下,能够利用原语来实现如此强大的算法非常重要,与从头开始实现所有内容相比,可以大幅缩短交付时间和计算时间。

有许多库可用于提供此类高级功能,以加速算法开发。其中最常见的包括 OpenMP、Thread Building Blocks 以及直接从 C++ 中针对最新标准版本的功能。在本文的以下部分中,我们将仅限于 OpenMP,特别是比较 GNU 的开源和社区实现与英特尔的 OpenMP。后者专门针对英特尔 CPU 进行了优化,在替代 GNU OpenMP 时,可提供一流的性能。

OpenMP 暴露了许多环境变量,用于自动配置将参与计算的底层资源,例如用于分发计算的线程数(操作内线程),系统调度程序应如何根据 CPU 资源(线程、核心、套接字)绑定每个线程,以及其他一些为用户提供进一步控制的变量。英特尔 OpenMP 暴露了更多此类环境变量,为用户提供更大的灵活性,以调整其软件的性能。


图 21. 运行 PyTorch 时 OpenMP 与 Intel OpenMP 的延迟比较


图 22. 运行 PyTorch 时 OpenMP 与 Intel OpenMP 的延迟比较

如前所述,调整 OpenMP 是在您尝试了所有其他与系统相关的调整旋钮之后可以开始尝试的。它只需设置一个环境变量,即可为您的模型带来最终的加速。

此外,值得注意的是,调优 OpenMP 库仅适用于内部使用 OpenMP API 的软件。更具体地说,目前只有 PyTorch 和 TorchScript 真正使用了 OpenMP,因此受益于 OpenMP 后端调优。

这也解释了我们为什么只报告了这两个框架的延迟。

自动性能调优:使用 Intel SigOpt 进行贝叶斯优化

如上所述,可以调整许多旋钮来提高英特尔 CPU 上的延迟和吞吐量,但由于旋钮众多,调整所有旋钮以获得最佳性能可能会很繁琐。例如,在我们的实验中,调整了以下旋钮:

  • 核心数量:虽然使用尽可能多的核心通常是一个好主意,但它并不总是能提供最佳性能,因为它也意味着不同线程之间的更多通信。最重要的是,使用更少的核心获得更好的性能非常有用,因为它允许同时运行多个实例,从而提高延迟和吞吐量。
  • 内存分配器:默认的 malloc、谷歌的 tcmalloc 和 Facebook 的 jemalloc 中,哪个内存分配器提供最佳性能?
  • 并行库:GNU OpenMP 和 Intel OpenMP 中,哪个并行库提供最佳性能?
  • 透明大页:在系统上启用透明大页 (THP) 是否能提供更好的性能?
  • KMP 阻塞时间参数:设置线程在完成并行区域执行后,进入休眠之前应等待的时间(以毫秒为单位)。

当然,蛮力方法,即尝试所有可能性,将提供最佳旋钮值以获得最佳性能,但是,搜索空间的大小为 N x 3 x 2 x 2 x 2 = 24N,这可能需要很长时间:在一台具有 80 个物理核心的机器上,这意味着最多尝试 24 x 80 = 1920 种不同的设置!😱

幸运的是,英特尔的 SigOpt 通过贝叶斯优化,使这些调优实验更快、更方便分析,同时提供与蛮力方法相似的性能。

当我们分析绝对最佳延迟与 SigOpt 提供结果之间的相对差异时,我们发现虽然它通常不如暴力搜索(除了序列长度 = 512 的特定情况),但它提供了非常接近的性能,在此图中最大的差距为 8.6%

图 23. SigOpt 自动调优找到的绝对最佳延迟与暴力搜索的比较
图 24. SigOpt 自动调优找到的相对最佳延迟与暴力搜索的比较

SigOpt 在分析方面也很有用:它提供了许多数据和有价值的信息。首先,它给出了它能够找到的最佳值,相应的旋钮,以及试验历史以及随着试验的进行它如何改进,例如,当序列长度 = 20 时:

图 25. SigOpt 最佳值报告
图 26. SigOpt 最佳值报告

在此特定设置中,16 个核心以及其他旋钮能够提供最佳结果,这一点非常重要,因为如前所述,这意味着可以并行运行模型的多个实例,同时每个实例仍能获得最佳延迟。

它还表明它在大约 20 次试验后收敛,这意味着也许 25 次试验而不是 40 次就足够了。还有更多有价值的信息可用,例如参数重要性:

正如预期的那样,核心数量是迄今为止最重要的参数,但其他参数也发挥了作用,并且它非常依赖于实验。例如,对于序列长度 = 512 的实验,参数重要性如下:

图 27. SigOpt 最佳值,批大小 = 1,序列长度 = 20
图 28. SigOpt 最佳值,批大小 = 1,序列长度 = 512

在这里,使用 OpenMP 与 Intel OpenMP 的影响不仅大于分配器的影响,而且每个旋钮的相对重要性也比序列长度 = 20 的实验中更加平衡。SigOpt 还提供更多图表,通常是交互式的,例如:

  • 2D 实验历史,允许比较旋钮与旋钮或旋钮与目标
  • 3D 实验历史,允许进行与 2D 实验历史相同的事情,增加一个旋钮/目标。

结论 - 加速生产中的 Transformers

在这篇文章中,我们展示了新的英特尔 Ice Lake 至强 CPU 如何适用于大规模运行 AI 工作负载,以及您可以互换和调整的软件元素,以充分发挥硬件的潜力。所有这些项目都应在设置上一篇博客中详述的各种低级旋钮之后进行考虑,以最大限度地利用所有核心和资源。

在 Hugging Face,我们的使命是使最先进的机器学习民主化,我们工作的关键部分是使这些最先进的模型尽可能高效,以在规模上消耗更少的能源和内存,并使各种规模的公司都能更经济地运行。

我们与英特尔通过 🤗 硬件合作伙伴计划的合作使我们能够通过我们新的 🤗 Optimum 开源库,将先进的效率和优化技术轻松地提供给社区,该库专门致力于生产性能。

对于希望加速其 Transformer 模型推理的公司,我们新的 🤗 Infinity 产品提供即插即用的容器化解决方案,在 GPU 上实现低至 1 毫秒的延迟,在 Intel Xeon Ice Lake CPU 上实现 2 毫秒的延迟。

如果您觉得这篇文章有趣或对您的工作有帮助,请考虑给 Optimum 加星。如果这篇文章让您感到心动,请考虑加入我们的机器学习优化团队

社区

注册登录以评论