• 教程 >
  • 从第一原理掌握 PyTorch Intel CPU 性能
快捷方式

从第一原理掌握 PyTorch Intel CPU 性能

一个关于使用 英特尔® 扩展 for PyTorch* 优化的 TorchServe 推理框架的案例研究。

作者:Min Jean Cho、Mark Saroufim

审阅者:Ashok Emani、Jiong Gong

在 CPU 上获得强大的深度学习开箱即用性能可能很棘手,但如果您了解影响性能的主要问题、如何衡量它们以及如何解决它们,就会变得容易得多。

TL;DR

问题

如何衡量

解决方案

成为瓶颈的 GEMM 执行单元

通过核心绑定将线程关联到物理核心,避免使用逻辑核心

非一致性内存访问 (NUMA)

  • 本地与远程内存访问

  • UPI 利用率

  • 内存访问延迟

  • 线程迁移

通过核心绑定将线程关联到特定套接字,避免跨套接字计算

GEMM(通用矩阵乘法)在融合乘加 (FMA) 或点积 (DP) 执行单元上运行,当启用超线程时,这些单元将成为瓶颈并导致线程等待/在同步屏障处自旋延迟 - 因为使用逻辑核心会导致所有工作线程的并发性不足,因为每个逻辑线程争用相同的核心资源。相反,如果我们每个物理核心使用 1 个线程,则可以避免这种争用。因此,我们通常建议通过核心绑定将 CPU线程亲和性设置为物理核心来避免逻辑核心

多插槽系统具有非一致内存访问 (NUMA),这是一种共享内存架构,描述了主内存模块相对于处理器的放置位置。但是,如果一个进程不是 NUMA 感知的,当进程在运行时通过英特尔超路径互连 (UPI)跨插槽迁移线程时,会频繁访问速度较慢的远程内存。我们通过核心绑定将 CPU线程亲和性设置为特定插槽来解决此问题。

牢记这些原理,正确的 CPU 运行时配置可以显著提高开箱即用的性能。

在本博文中,我们将引导您了解您应该了解的重要运行时配置,这些配置来自CPU 性能调优指南,解释它们的工作原理、如何对其进行分析以及如何在像TorchServe这样的模型服务框架中通过易于使用的启动脚本集成它们,我们已将该脚本集成1到原生代码中。

我们将从基本原理出发,以可视化的方式解释所有这些想法,并提供大量分析结果,并向您展示我们如何应用我们的学习成果来改进 TorchServe 的开箱即用 CPU 性能。

  1. 必须通过在config.properties中设置cpu_launcher_enable=true来显式启用此功能。

避免深度学习中的逻辑核心

避免深度学习工作负载中的逻辑核心通常可以提高性能。为了理解这一点,让我们退一步回到 GEMM。

优化 GEMM 优化深度学习

深度学习训练或推理中的大部分时间都花费在数百万次重复的 GEMM 操作上,GEMM 是全连接层的核心。全连接层几十年来一直被使用,因为多层感知器 (MLP) 被证明可以近似任何连续函数。任何 MLP 都可以完全表示为 GEMM。甚至卷积也可以使用托普利兹矩阵表示为 GEMM。

回到最初的话题,大多数 GEMM 运算符都受益于不使用超线程,因为深度学习训练或推理中的大部分时间都花费在数百万次重复的 GEMM 操作上,这些操作在由超线程核心共享的融合乘加 (FMA) 或点积 (DP) 执行单元上运行。启用超线程后,OpenMP 线程将争用相同的 GEMM 执行单元。

../_images/1_.png

如果两个逻辑线程同时运行 GEMM,它们将共享相同的核心资源,导致前端绑定,使得前端绑定的开销大于同时运行两个逻辑线程带来的收益。

因此,我们通常建议避免在深度学习工作负载中使用逻辑核心以获得良好的性能。启动脚本默认仅使用物理核心;但是,用户可以通过简单地切换--use_logical_core启动脚本旋钮来轻松尝试逻辑核心与物理核心的对比。

练习

我们将使用以下示例,向 ResNet50 提供虚拟张量

import torch
import torchvision.models as models
import time

model = models.resnet50(pretrained=False)
model.eval()
data = torch.rand(1, 3, 224, 224)

# warm up
for _ in range(100):
    model(data)

start = time.time()
for _ in range(100):
    model(data)
end = time.time()
print('Inference took {:.2f} ms in average'.format((end-start)/100*1000))

在整个博文中,我们将使用英特尔® VTune™ 分析器来分析和验证优化。我们将在配备两颗英特尔(R) 至强(R) 白金 8180M CPU 的机器上运行所有练习。CPU 信息如 图 2.1 所示。

环境变量OMP_NUM_THREADS用于设置并行区域的线程数。我们将比较OMP_NUM_THREADS=2与 (1) 使用逻辑核心和 (2) 仅使用物理核心。

  1. 两个 OpenMP 线程都试图利用由超线程核心 (0, 56) 共享的相同 GEMM 执行单元

我们可以通过在 Linux 上运行htop命令来可视化这一点,如下所示。

../_images/2.png
../_images/3.png

我们注意到“自旋时间”被标记,并且“不平衡或串行自旋”占了大部分 - 在总共 8.982 秒中占了 4.980 秒。使用逻辑核心时出现“不平衡或串行自旋”是由于工作线程的并发性不足,因为每个逻辑线程都争用相同的核心资源。

执行摘要的“顶级热点”部分指示__kmp_fork_barrier花费了 4.589 秒的 CPU 时间 - 在 9.33% 的 CPU 执行时间内,线程只是在这个屏障处自旋,因为线程同步。

  1. 每个 OpenMP 线程分别利用各自物理核心 (0,1) 中的 GEMM 执行单元

../_images/4.png
../_images/5.png

我们首先注意到,通过避免逻辑核心,执行时间从 32 秒下降到 23 秒。虽然仍然存在一些不可忽略的“不平衡或串行自旋”,但我们注意到从 4.980 秒到 3.887 秒的相对改进。

通过不使用逻辑线程(而是每个物理核心使用 1 个线程),我们避免了逻辑线程争用相同的核心资源。“顶级热点”部分还表明__kmp_fork_barrier时间从 4.589 秒相对改善到 3.530 秒。

本地内存访问始终快于远程内存访问

我们通常建议将进程绑定到本地插槽,以便进程不会跨插槽迁移。通常这样做是为了利用本地内存上的高速缓存并避免远程内存访问,远程内存访问速度可能慢约 2 倍。

../_images/6.png

图 1. 双插槽配置

图 1. 显示了典型的双插槽配置。请注意,每个插槽都有自己的本地内存。插槽通过英特尔超路径互连 (UPI) 相互连接,这允许每个插槽访问另一个插槽的本地内存,称为远程内存。本地内存访问始终快于远程内存访问。

../_images/7.png

图 2.1. CPU 信息

用户可以通过在他们的 Linux 机器上运行lscpu命令来获取他们的 CPU 信息。图 2.1. 显示了在配备两颗英特尔(R) 至强(R) 白金 8180M CPU 的机器上执行lscpu的示例。请注意,每个插槽有 28 个核心,每个核心有 2 个线程(即启用了超线程)。换句话说,除了 28 个物理核心之外,还有 28 个逻辑核心,每个插槽总共有 56 个核心。并且有两个插槽,总共有 112 个核心(Thread(s) per core x Core(s) per socket x Socket(s))。

../_images/8.png

图 2.2. CPU 信息

这两个插槽分别映射到两个 NUMA 节点(NUMA 节点 0、NUMA 节点 1)。物理核心在逻辑核心之前被索引。如图 2.2. 所示,第一个插槽上的前 28 个物理核心 (0-27) 和前 28 个逻辑核心 (56-83) 位于 NUMA 节点 0 上。第二个插槽上的第二个 28 个物理核心 (28-55) 和第二个 28 个逻辑核心 (84-111) 位于 NUMA 节点 1 上。同一插槽上的核心共享本地内存和最后一级缓存 (LLC),这比通过英特尔 UPI 进行的跨插槽通信快得多。

现在我们了解了 NUMA、跨插槽 (UPI) 流量、多处理器系统中的本地与远程内存访问,让我们对其进行分析并验证我们的理解。

练习

我们将重复使用上述 ResNet50 示例。

由于我们没有将线程绑定到特定插槽的处理器核心,因此操作系统会定期在位于不同插槽的处理器核心上调度线程。

../_images/9.gif

图 3. 非 NUMA 感知应用程序的 CPU 使用情况。启动了 1 个主工作线程,然后它在所有核心(包括逻辑核心)上启动了物理核心编号 (56) 的线程。

(旁注:如果线程数不是由torch.set_num_threads设置,则默认线程数是在启用超线程的系统中的物理核心数。这可以通过torch.get_num_threads来验证。因此,我们看到上面大约有一半的核心忙于运行示例脚本。)

../_images/10.png

图 4. 非一致内存访问分析图

图 4. 比较了随时间推移的本地与远程内存访问。我们验证了远程内存的使用,这可能导致性能不佳。

设置线程亲和性以减少远程内存访问和跨插槽 (UPI) 流量

将线程绑定到同一插槽上的核心有助于保持内存访问的局部性。在本例中,我们将绑定到第一个 NUMA 节点 (0-27) 上的物理核心。使用启动脚本,用户可以通过简单地切换--node_id启动脚本旋钮来轻松尝试 NUMA 节点配置。

现在让我们可视化 CPU 使用情况。

../_images/11.gif

图 5. NUMA 感知应用程序的 CPU 使用情况

启动了 1 个主工作线程,然后它在第一个 numa 节点上的所有物理核心上启动了线程。

../_images/12.png

图 6. 非一致内存访问分析图

如图 6. 所示,现在几乎所有内存访问都是本地访问。

使用核心绑定进行多工作器推理的有效 CPU 使用

在运行多工作器推理时,核心在工作器之间重叠(或共享),导致 CPU 使用效率低下。为了解决此问题,启动脚本将可用核心数除以工作器数,以便每个工作器在运行时都绑定到分配的核心。

使用 TorchServe 进行练习

对于此练习,让我们将到目前为止讨论的 CPU 性能调优原则和建议应用于TorchServe apache-bench 基准测试

我们将使用 ResNet50,4 个工作器,并发度 100,请求数 10,000。所有其他参数(例如,batch_size、输入等)与默认参数相同。

我们将比较以下三种配置

  1. 默认 TorchServe 设置(无核心绑定)

  2. torch.set_num_threads = number of physical cores / number of workers(无核心绑定)

  3. 通过启动脚本进行核心绑定(需要 Torchserve>=0.6.1)

在此练习之后,我们将验证我们更倾向于避免逻辑核心,并更倾向于通过核心绑定进行本地内存访问,并使用真实的 TorchServe 使用案例。

1. TorchServe默认设置(无核心绑定)

base_handler没有明确设置torch.set_num_threads。因此,默认线程数为物理CPU核心数,如此处所述。用户可以通过torch.get_num_threads在base_handler中检查线程数。4个主要工作线程中的每一个都启动了物理核心数量(56)个线程,总共启动了56x4 = 224个线程,这超过了核心总数112。因此,核心必然会严重重叠,逻辑核心利用率很高——多个工作线程同时使用多个核心。此外,由于线程未绑定到特定的CPU核心,操作系统会定期将线程调度到不同插槽上的核心。

  1. CPU使用率

../_images/13.png

启动了4个主要工作线程,然后每个线程在所有核心(包括逻辑核心)上启动了物理核心数量(56)个线程。

  1. 核心绑定停顿

../_images/14.png

我们观察到非常高的核心绑定停顿,达到88.4%,降低了流水线效率。核心绑定停顿表示对CPU中可用执行单元的利用不足。例如,连续几个GEMM指令争用超线程核心共享的融合乘加(FMA)或点积(DP)执行单元可能会导致核心绑定停顿。如上一节所述,使用逻辑核心会加剧此问题。

../_images/15.png
../_images/16.png

未填充微操作(uOps)的空流水线槽归因于停顿。例如,在没有核心绑定时,CPU 使用率可能并不有效地用于计算,而是用于其他操作,例如来自Linux内核的线程调度。我们看到上面__sched_yield导致了大部分自旋时间。

  1. 线程迁移

在没有核心绑定时,调度程序可能会将正在某个核心上执行的线程迁移到另一个核心。线程迁移可能会使线程与已提取到缓存中的数据分离,从而导致更长的数据访问延迟。当线程跨插槽迁移时,此问题在NUMA系统中会加剧。已提取到本地内存高速缓存中的数据现在变为远程内存,速度要慢得多。

../_images/17.png

通常,线程总数应小于或等于核心支持的线程总数。在上面的示例中,我们注意到大量线程在core_51上执行,而不是预期的2个线程(因为在Intel(R) Xeon(R) Platinum 8180 CPU中启用了超线程)。这表明发生了线程迁移。

../_images/18.png

此外,请注意线程(TID:97097)在大量CPU核心上执行,表明发生了CPU迁移。例如,此线程在cpu_81上执行,然后迁移到cpu_14,然后迁移到cpu_5,依此类推。此外,请注意,此线程多次跨插槽来回迁移,导致内存访问效率非常低。例如,此线程在cpu_70(NUMA节点0)上执行,然后迁移到cpu_100(NUMA节点1),然后迁移到cpu_24(NUMA节点0)。

  1. 非一致性内存访问分析

../_images/19.png

比较一段时间内的本地与远程内存访问。我们观察到大约一半(51.09%)的内存访问是远程访问,表明NUMA配置不佳。

2. torch.set_num_threads = number of physical cores / number of workers(无核心绑定)

为了与启动器的核心绑定进行苹果对苹果的比较,我们将线程数设置为核心数除以工作线程数(启动器在内部执行此操作)。在base_handler中添加以下代码片段

torch.set_num_threads(num_physical_cores/num_workers)

与之前一样,在没有核心绑定时,这些线程不会绑定到特定的CPU核心,导致操作系统定期在不同插槽上的核心上调度线程。

  1. CPU使用率

../_images/20.gif

启动了4个主要工作线程,然后每个线程在所有核心(包括逻辑核心)上启动了num_physical_cores/num_workers个(14)线程。

  1. 核心绑定停顿

../_images/21.png

虽然核心绑定停顿的百分比从88.4%下降到73.5%,但核心绑定停顿仍然很高。

../_images/22.png
../_images/23.png
  1. 线程迁移

../_images/24.png

与之前类似,在没有核心绑定时,线程(TID:94290)在大量CPU核心上执行,表明发生了CPU迁移。我们再次注意到跨插槽的线程迁移,导致内存访问效率非常低。例如,此线程在cpu_78(NUMA节点0)上执行,然后迁移到cpu_108(NUMA节点1)。

  1. 非一致性内存访问分析

../_images/25.png

虽然比最初的51.09%有所改善,但仍有40.45%的内存访问是远程访问,表明NUMA配置不佳。

3. 启动器核心绑定

启动器将在内部将物理核心平均分配给工作线程,并将它们绑定到每个工作线程。提醒一下,启动器默认仅使用物理核心。在此示例中,启动器将工作线程0绑定到核心0-13(NUMA节点0),工作线程1绑定到核心14-27(NUMA节点0),工作线程2绑定到核心28-41(NUMA节点1),工作线程3绑定到核心42-55(NUMA节点1)。这样做可以确保核心在工作线程之间不重叠,并避免使用逻辑核心。

  1. CPU使用率

../_images/26.gif

启动了4个主要工作线程,然后每个线程启动了num_physical_cores/num_workers个(14)线程,这些线程绑定到分配的物理核心。

  1. 核心绑定停顿

../_images/27.png

核心绑定停顿与最初的88.4%相比大幅下降到46.2%——几乎提高了2倍。

../_images/28.png
../_images/29.png

我们验证了在进行核心绑定后,大多数CPU时间都被有效地用于计算——自旋时间为0.256秒。

  1. 线程迁移

../_images/30.png

我们验证了OMP Primary Thread #0绑定到分配的物理核心(42-55),并且没有跨插槽迁移。

  1. 非一致性内存访问分析

../_images/31.png

现在几乎所有(89.52%)内存访问都是本地访问。

结论

在本博文中,我们展示了正确设置CPU运行时配置可以显著提高开箱即用的CPU性能。

我们介绍了一些通用的CPU性能调整原则和建议

  • 在启用了超线程的系统中,通过核心绑定将线程关联性仅设置为物理核心,从而避免使用逻辑核心。

  • 在具有NUMA的多插槽系统中,通过核心绑定将线程关联性设置为特定插槽,从而避免跨插槽的远程内存访问。

我们从第一原理直观地解释了这些想法,并通过分析验证了性能提升。最后,我们将所有经验教训应用于TorchServe,以提高开箱即用的TorchServe CPU性能。

这些原则可以通过易于使用的启动脚本自动配置,该脚本已集成到TorchServe中。

对于感兴趣的读者,请查看以下文档

敬请关注后续文章,文章将介绍通过Intel® Extension for PyTorch*在CPU上优化内核以及高级启动器配置(例如内存分配器)。

致谢

我们要感谢Ashok Emani(英特尔)和Jiong Gong(英特尔)提供的巨大指导和支持,以及在本文的许多步骤中提供的详尽反馈和审查。我们还要感谢Hamid Shojanazeri(Meta)、Li Ning(AWS)和Jing Xu(英特尔)在代码审查中提供的宝贵反馈。以及Suraj Subramanian(Meta)和Geeta Chauhan(Meta)对本文提供的宝贵反馈。

文档

访问PyTorch的全面开发人员文档

查看文档

教程

获取针对初学者和高级开发人员的深入教程

查看教程

资源

查找开发资源并获得问题的解答

查看资源