从头理解PyTorch在Intel CPU上的性能¶
Created On: Apr 15, 2022 | Last Updated: Jan 23, 2025 | Last Verified: Nov 05, 2024
关于TorchServe推理框架在 Intel® Extension for PyTorch* 优化下的案例研究。
作者:Min Jean Cho, Mark Saroufim
审阅者:Ashok Emani, Jiong Gong
在CPU上获得较强的深度学习性能可能很难,但如果了解影响性能的主要问题、如何测量它们以及如何解决它们,就会容易得多。
概述
问题 |
如何测量 |
解决方案 |
受限的GEMM执行单元 |
通过核心绑定将线程亲和性设置为物理核心,避免使用逻辑核心。 |
|
非统一内存访问(NUMA) |
|
通过核心绑定将线程亲和性设置为特定插槽,避免跨插槽计算。 |
GEMM(一般矩阵乘法)*运行在融合乘加(FMA)或点积(DP)执行单元上,可能因启用超线程导致线程等待/*同步点旋转*障碍而受限,因为使用逻辑核心会导致所有工作线程之间的并发性不足,因为每个逻辑线程*争用同一核心资源。相反,如果我们每个物理核心使用1个线程,我们就避免了这种争用。因此我们通常建议通过*核心绑定*将CPU线程亲和性设置为物理核心,*避免逻辑核心*的使用。
多插槽系统具有*非统一内存访问(NUMA)*,它是一种共享内存架构,描述了主内存模块相对于处理器的位置。但如果一个进程不是NUMA感知的,当运行时线程通过*Intel超路径互连(UPI)*跨插槽迁移时会频繁访问较慢的*远程内存*。我们通过将CPU线程亲和性设置为特定插槽来解决该问题。
牢记这些原则,正确的CPU运行时配置可以显著提升开箱即用的性能。
在这篇博客中,我们将带领您了解 CPU性能调优指南 中应该注意的重要运行时配置,解释它们的工作原理、如何剖析它们以及如何将它们集成到诸如 TorchServe 这样的模型服务框架中,通过一个易于使用的 启动脚本,我们已经 原生集成。
我们将从 第一原理 通过 可视化 和大量 分析结果 来解释所有这些想法,并展示如何应用我们的学习成果以改进TorchServe上的开箱即用CPU性能。
必须在 config.properties 文件中通过设置 cpu_launcher_enable=true 显式启用此功能。
避免在深度学习中使用逻辑核心¶
避免在深度学习工作负载中使用逻辑核心通常可以提高性能。为了解这一点,让我们先回到GEMM展开。
优化GEMM即优化深度学习
在深度学习的训练或推理中,大部分时间花在了数百万次的GEMM操作上,它是全连接层的核心。全连接层几十年来一直被使用,因为多层感知机(MLP)被证明是任何连续函数的通用逼近器。https://en.wikipedia.org/wiki/Universal_approximation_theorem 任何MLP都可以完全表示为GEMM。甚至卷积也可以通过使用 Toeplitz矩阵 表示为GEMM。
回到原话题,大多数GEMM操作在使用非超线程时受益,因为深度学习训练或推理的绝大部分时间都在数百万次的GEMM操作上,这些操作运行于由超线程核心共享的融合乘加(FMA)或点积(DP)执行单元上。如果启用了超线程,OpenMP线程会争用同一GEMM执行单元。
如果两个逻辑线程同时运行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) 仅使用物理核心。
两个OpenMP线程试图利用由超线程核心共享的同一GEMM执行单元(0,56)
我们可以通过在Linux上运行 htop
命令进行可视化,如下所示。
我们注意到自旋时间被标记出来,不平衡或串行自旋占多数——8.982秒总时间中的4.980秒。使用逻辑核心时,由于工作线程的并发性不足,每个逻辑线程争用同一核心资源,这导致不平衡或串行自旋。
执行摘要的热点部分表明 __kmp_fork_barrier
使用了4.589秒CPU时间——CPU执行时间的9.33%期间,线程仅在此障碍处自旋以同步线程。
每个OpenMP线程在各自的物理核心(0,1)中利用GEMM执行单元
我们首先注意到,通过避免逻辑核心,执行时间从32秒下降到23秒。尽管仍有一些不可忽略的不平衡或串行自旋,但我们注意到相对的改进,从4.980秒减少到3.887秒。
通过不使用逻辑线程(而是每个物理核心使用1个线程),我们避免了逻辑线程争用同一核心资源。热点部分还表明 __kmp_f오k_barrier
时间从4.589秒相对改进到3.530秒。
本地内存访问总是比远程内存访问快¶
我们通常建议将进程绑定到本地插槽,以确保进程不会在插槽之间迁移。这样做的目的是利用本地内存上的高速缓存,并避免~2倍缓慢的远程内存访问。
图1. 双插槽配置
图1显示了典型的双插槽配置。注意,每个插槽都有自己的本地内存。插槽通过Intel超路径互连(UPI)相互连接,允许每个插槽访问另一个插槽的本地内存(称为远程内存)。本地内存访问总是比远程内存访问快。
图2.1. CPU信息
用户可以通过在其Linux机上运行 lscpu
命令获得其CPU信息。图2.1显示了在具有两个Intel(R) Xeon(R) Platinum 8180M CPU的机器上的``lscpu`` 执行示例。注意,每个插槽有28个核心,每个核心有2个线程(即启用了超线程)。换句话说,除了28个物理核心外,还有28个逻辑核心,总共有56个核心每插槽。而且有两个插槽,总共112个核心(每个核心线程数
x 每插槽核心数
x 插槽数
)。
图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示例。
由于没有将线程绑定到特定插槽的处理器核心,操作系统会定期安排线程在不同插槽的处理器核心上运行。
图3:非NUMA感知型应用程序的CPU使用情况。启动了1个主工作线程,然后在所有核心(包括逻辑核心)上启动了等于物理核心数(56)的线程。
(旁注:如果未通过`torch.set_num_threads <https://pytorch.org/docs/stable/generated/torch.set_num_threads.html>`_设置线程数量,则默认线程数量是启用了超线程系统中物理核心的数量。这可以通过`torch.get_num_threads <https://pytorch.org/docs/stable/generated/torch.get_num_threads.html>`_验证。因此,我们在上面的示例脚本中看到大约一半的核心处于忙碌状态。)
图4:非统一内存访问分析图
图4比较了本地内存访问和远程内存访问的时间变化。我们验证了远程内存的使用,这可能导致性能次优。
设置线程亲和性以减少远程内存访问和跨插槽(UPI)流量
将线程绑定到同一插槽上的核心可以帮助保持内存访问的本地性。在本例中,我们将线程绑定到第一个NUMA节点(0-27)的物理核心上。通过启动脚本,用户可以轻松地通过切换``–node_id``脚本选项调整NUMA节点配置。
现在让我们可视化CPU使用情况。
图5:NUMA感知应用程序的CPU使用情况
启动了1个主工作线程,然后在第一个NUMA节点上的所有物理核心上启动了线程。
图6:非统一内存访问分析图
如图6所示,现在几乎所有的内存访问都是本地访问。
通过核心绑定实现多工作者推理的高效CPU使用¶
运行多工作者推理时,工作者之间核心会重叠(或共享),导致CPU使用效率低下。为解决此问题,启动脚本将可用核心的数量平均分配给工作者,并将其绑定到指定核心。
使用TorchServe的练习
在此练习中,让我们将之前讨论的CPU性能调优原则和建议应用于`TorchServe apache-bench性能测试 <https://github.com/pytorch/serve/tree/master/benchmarks#benchmarking-with-apache-bench>`_。
我们将使用ResNet50与4个工作者,100的并发性,10,000的请求。所有其他参数(例如batch_size、输入等)与`默认参数 <https://github.com/pytorch/serve/blob/master/benchmarks/benchmark-ab.py#L18>`_相同。
我们将比较以下三种配置:
默认TorchServe设置(无核心绑定)
torch.set_num_threads = ``物理核心数量 / 工作者数量``(无核心绑定)
通过启动脚本实现核心绑定(需要Torchserve>=0.6.1)
在此练习结束时,我们将验证我们更倾向于避开逻辑核心,并通过核心绑定实现本地内存访问的实际TorchServe用例。
1. 默认TorchServe设置(无核心绑定)¶
base_handler <https://github.com/pytorch/serve/blob/master/ts/torch_handler/base_handler.py>`_不会显式设置`torch.set_num_threads。因此,默认线程数量是物理CPU核心的数量,如`此处 <https://pytorch.org/docs/stable/notes/cpu_threading_torchscript_inference.html#runtime-api>`_所述。用户可以通过base_handler中的`torch.get_num_threads <https://pytorch.org/docs/stable/generated/torch.get_num_threads.html>`_检查线程数量。每个4个主工作线程启动了等于物理核心数(56)的线程,总共启动了56x4 = 224个线程,超过了核心总数112。因此,核心重叠程度很高,逻辑核心使用率也很高——多个工作者同时使用多个核心。此外,由于线程未绑定到指定的CPU核心,操作系统会定期将线程调度到不同插槽的核心上。
CPU使用
启动了4个主工作线程,然后每个线程在所有核心(包括逻辑核心)上启动了等于物理核心数(56)的线程。
核心受限停滞
我们观察到非常高的核心受限停滞率为88.4%,导致流水线效率降低。核心受限停滞指示CPU中可用执行单元的次优使用。例如,一系列连续的GEMM指令在超线程核心共享的融合乘加(FMA)或点积(DP)执行单元上竞争可能导致核心受限停滞。正如前一节所述,使用逻辑核心会放大这个问题。
一个没有填充微操作(uOps)的流水线插槽会被归因于停滞。例如,没有核心绑定的情况下,CPU使用可能不会有效地用于计算,而是用于其他操作,例如线程从Linux内核中的计划调度。我们在上面看到``__sched_yield``占用了大部分的旋转时间。
线程迁移
没有核心绑定时,调度器可能会将一个正在核心上执行的线程迁移到另一个核心。线程迁移会导致线程与已经提取到缓存中的数据解除关联,从而导致更长的数据访问延迟。在NUMA系统中,当线程跨插槽迁移时,这个问题会加剧。已经提取到本地内存高速缓存中的数据现在成为远程内存,速度大大降低。
通常,总线程数应小于或等于核心支持的总线程数。在上述示例中,我们注意到大量线程在core_51上执行,而不是预期的2个线程(因为Intel(R) Xeon(R) Platinum 8180 CPU中启用了超线程)。这表明线程迁移。
此外,注意线程(TID:97097)在大量CPU核心上执行,表明CPU迁移。例如,该线程先在cpu_81上执行,然后迁移到cpu_14,再迁移到cpu_5,如此往复。此外,请注意该线程多次跨插槽迁移,导致非常低效的内存访问。例如,该线程在cpu_70(NUMA节点0)上执行,然后迁移到cpu_100(NUMA节点1),再迁移到cpu_24(NUMA节点0)。
非统一内存访问分析
比较本地和远程内存访问随时间的变化。我们观察到大约一半(51.09%)的内存访问是远程访问,这表明NUMA配置次优。
2. torch.set_num_threads = ``物理核心数 / 工作者数``(无核心绑定)¶
为了和启动器的核心绑定进行公平比较,我们将线程数设置为核心数除以工作者数(启动器内部会执行此操作)。在`base_handler <https://github.com/pytorch/serve/blob/master/ts/torch_handler/base_handler.py>`_中添加以下代码片段:
torch.set_num_threads(num_physical_cores/num_workers)
和之前一样,没有核心绑定,所以这些线程未绑定到指定的CPU核心,导致操作系统会定期将线程调度到不同插槽的核心。
CPU使用
启动了4个主工作线程,然后每个线程在所有核心(包括逻辑核心)上启动了``物理核心数/工作者数``(14)的线程。
核心受限停滞
虽然核心受限停滞比例从88.4%下降到73.5%,但核心受限比例仍然很高。
线程迁移
与之前类似,没有核心绑定时,线程(TID:94290)在大量CPU核心上执行,表明CPU迁移。我们再次注意到跨插槽线程迁移,导致非常低效的内存访问。例如,该线程首先在cpu_78(NUMA节点0)上执行,然后迁移到cpu_108(NUMA节点1)。
非统一内存访问分析
尽管比最初的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)。这样可以确保工作者之间的核心不重叠,并避免使用逻辑核心。
CPU使用
启动了4个主工作线程,然后每个线程以``物理核心数/工作者数``(14)的线程数绑定到分配的物理核心。
核心受限停滞
核心受限停滞比例显著降低,从最初的88.4%下降到46.2%,几乎提高了2倍。
我们验证了通过核心绑定,大多数CPU时间被有效地用于计算——旋转时间仅为0.256秒。
线程迁移
我们验证了`OMP主线程#0`绑定到指定的物理核心(42-55),并且没有跨插槽迁移。
非统一内存访问分析
现在几乎所有的内存访问(89.52%)都是本地访问。
结论¶
在这篇博客中,我们展示了正确设置CPU运行时配置如何显著提升开箱即用的CPU性能。
我们讨论了一些通用的CPU性能调优原则和建议:
在启用了超线程的系统中,通过核心绑定仅将线程设置为物理核心,避免使用逻辑核心。
在具有NUMA的多插槽系统中,通过核心绑定将线程限制在特定插槽内,避免跨插槽的远程内存访问。
我们从第一原则出发直观地解释了这些理念,并通过性能分析验证了性能提升。最后,我们将所有学习成果应用于TorchServe,大幅提升了开箱即用的TorchServe CPU性能。
这些原则可以通过一个易于使用的启动脚本自动配置,该脚本已集成到TorchServe中。
对于感兴趣的读者,请查看以下文档:
敬请关注后续博客,关于通过 Intel® Extension for PyTorch* 在 CPU 上优化内核和高级启动器配置,例如内存分配器。
致谢¶
我们感谢 Ashok Emani(Intel)和 Jiong Gong(Intel)在博客的多个步骤中提供了巨大的指导和支持,以及详细的反馈和审查。我们还感谢 Hamid Shojanazeri(Meta)、Li Ning(AWS)和 Jing Xu(Intel)在代码审查中的有益反馈。以及 Suraj Subramanian(Meta)和 Geeta Chauhan(Meta)对博客的有益反馈。