• Tutorials >
  • Grokking PyTorch Intel CPU performance from first principles
Shortcuts

从第一原理理解PyTorch在Intel CPU上的性能

创建于:2022年4月15日 | 最后更新:2024年1月16日 | 最后验证:2024年11月5日

一个关于使用Intel® Extension for PyTorch*优化的TorchServe推理框架的案例研究。

作者:Min Jean Cho, Mark Saroufim

审阅者:Ashok Emani, Jiong Gong

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

太长不看

问题

如何测量它

解决方案

瓶颈的GEMM执行单元

通过核心绑定将线程亲和性设置为物理核心,避免使用逻辑核心

非统一内存访问 (NUMA)

  • 本地与远程内存访问

  • UPI Utilization

  • 内存访问中的延迟

  • 线程迁移

通过核心绑定将线程亲和性设置为特定插槽,以避免跨插槽计算

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

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

了解这些原则后,适当的CPU运行时配置可以显著提高开箱即用的性能。

在本博客中,我们将引导您了解从CPU性能调优指南中您应该了解的重要运行时配置,解释它们的工作原理,如何进行分析,以及如何通过我们集成的原生启动脚本将它们集成到像TorchServe这样的模型服务框架中。

我们将从第一原理出发,通过大量的配置文件直观地解释所有这些概念,并向您展示我们如何应用我们的学习成果,使TorchServe的默认CPU性能更好。

  1. 该功能需要通过将config.properties中的cpu_launcher_enable=true设置为显式启用。

避免使用逻辑核心进行深度学习

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

优化GEMM优化深度学习

深度学习训练或推理中的大部分时间都花在了数百万次的GEMM操作上,这是全连接层的核心。全连接层自多层感知器(MLP)被证明是任何连续函数的通用逼近器以来已经使用了几十年。任何MLP都可以完全表示为GEMM。甚至卷积也可以通过使用Toepliz矩阵表示为GEMM。

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

../_images/1_.png

如果2个逻辑线程同时运行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))

在整个博客中,我们将使用Intel® VTune™ Profiler来进行性能分析和验证优化。我们将在配备两个Intel(R) Xeon(R) Platinum 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

我们注意到Spin Time被标记了,其中大部分是由Imbalance或Serial Spinning引起的 - 总共8.982秒中的4.980秒。当使用逻辑核心时,Imbalance或Serial Spinning是由于工作线程的并发性不足,因为每个逻辑线程都在争夺相同的核心资源。

执行摘要中的Top Hotspots部分表明,__kmp_fork_barrier占用了4.589秒的CPU时间 - 在CPU执行时间的9.33%期间,线程由于线程同步而在此屏障处自旋。

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

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

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

通过不使用逻辑线程(而是每个物理核心使用1个线程),我们避免了逻辑线程争夺相同的核心资源。Top Hotspots部分还显示了__kmp_fork_barrier时间从4.589秒到3.530秒的相对改进。

本地内存访问总是比远程内存访问更快

我们通常建议将进程绑定到本地套接字,这样进程就不会跨套接字迁移。这样做的目的通常是利用本地内存上的高速缓存,并避免远程内存访问,因为远程内存访问可能比本地访问慢约2倍。

../_images/6.png

图1. 双插槽配置

图1显示了一个典型的双插槽配置。请注意,每个插槽都有自己的本地内存。插槽通过Intel Ultra Path Interconnect(UPI)相互连接,这使得每个插槽可以访问另一个插槽的本地内存,称为远程内存。本地内存访问总是比远程内存访问更快。

../_images/7.png

图2.1. CPU信息

用户可以通过在Linux机器上运行lscpu命令来获取他们的CPU信息。图2.1展示了一个在配备两个Intel(R) Xeon(R) Platinum 8180M CPU的机器上执行lscpu的示例。请注意,每个插槽有28个核心,每个核心有2个线程(即启用了超线程技术)。换句话说,除了28个物理核心外,还有28个逻辑核心,每个插槽总共有56个核心。并且有2个插槽,总共有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),这比通过Intel UPI进行的跨插槽通信要快得多。

既然我们已经理解了NUMA、跨插槽(UPI)流量、多处理器系统中的本地与远程内存访问,现在让我们来分析和验证我们的理解。

练习

我们将重用上面的ResNet50示例。

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

../_images/9.gif

图3. 非NUMA感知应用程序的CPU使用情况。启动了一个主工作线程,然后它在所有核心(包括逻辑核心)上启动了物理核心数(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,input等)与默认参数相同。

我们将比较以下三种配置:

  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主线程#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)对博客的有益反馈。