从第一性原理理解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上实现深度学习的出色开箱即用性能可能会有些困难,但如果你了解影响性能的主要问题、如何衡量这些问题以及如何解决它们,将会容易得多。
TL;DR
问题 |
如何衡量它 |
解决方案 |
瓶颈型 GEMM 执行单元 |
通过核心绑定将线程亲和力设置为物理核心,以避免使用逻辑核心 |
|
非统一内存访问 (NUMA) |
|
通过核心绑定将线程亲和力设置到特定的插槽,以避免跨插槽计算 |
GEMM (通用矩阵乘法) 在融合乘加 (FMA) 或点积 (DP) 执行单元上运行,当启用 超线程 时,这些执行单元将成为瓶颈,导致线程在同步屏障处等待/自旋 产生延迟 - 因为使用逻辑核心会导致所有工作线程的并发不足,每个逻辑线程 争夺相同的内核资源。相反,如果我们为每个物理核心使用一个线程,就可以避免这种竞争。因此,我们通常建议 避免使用逻辑核心,通过 核心绑定 将 CPU 线程亲和性 设置为物理核心。
多插槽系统具有 非统一内存访问(NUMA),这是一种共享内存架构,描述了主内存模块相对于处理器的位置。但如果进程不具备NUMA意识,当 线程迁移 通过 Intel 超路径互连(UPI) 跨插槽时,会频繁访问 远程内存。我们通过 核心固定 设置CPU 线程亲和性 到特定插槽来解决此问题。
了解这些原则后,适当的 CPU 运行时配置可以显著提升开箱即用的性能。
在这篇博客中,我们将向您介绍您应该了解的来自CPU性能调优指南的重要运行时配置,解释它们的工作原理、如何分析它们以及如何通过易于使用的启动脚本将它们集成到像TorchServe这样的模型服务框架中,我们已经将其集成1原生支持。
我们将通过大量示例,从第一原理开始直观地解释所有这些概念,并向您展示我们如何应用这些学习成果,使TorchServe在CPU上的开箱即用性能更好。
该功能必须通过在 config.properties 中设置 cpu_launcher_enable=true 显式启用。
避免使用逻辑核心进行深度学习¶
避免为深度学习工作负载使用逻辑核心通常可以提高性能。为了理解这一点,让我们先回顾一下GEMM。
优化GEMM以优化深度学习
深度学习训练或推理中的大部分时间都花在了数百万次重复的GEMM操作上,这些操作是全连接层的核心。自从多层感知器(MLP)被证明可以作为任何连续函数的通用逼近器以来,全连接层已经被使用了几十年。任何MLP都可以完全表示为GEMM。甚至卷积也可以通过使用Toepliz矩阵表示为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 信息如图 2.1 所示。
环境变量 OMP_NUM_THREADS 用于设置并行区域的线程数。我们将比较 OMP_NUM_THREADS=2 与 (1) 使用逻辑核心和 (2) 仅使用物理核心的情况。
两个 OpenMP 线程试图利用超线程核心(0, 56)共享的同一组 GEMM 执行单元
我们可以通过在Linux上运行如下的 htop 命令来可视化此内容。
我们注意到 Spin Time 被标记出来,且不平衡或串行旋转导致了其中的大部分时间 - 在总时间 8.982 秒中占 4.980 秒。在使用逻辑核心时,由于工作线程的并发性不足,每个逻辑线程争夺相同的 core 资源,从而导致了不平衡或串行旋转。
执行摘要中的热门点部分表明 __kmp_fork_barrier 占用了4.589秒的CPU时间 - 在CPU执行时间的9.33%期间,由于线程同步,线程在此屏障处只是空转。
每个使用各自物理核心(0,1)中 GEMM 执行单元的 OpenMP 线程
我们首先注意到,通过避免使用逻辑核心,执行时间从32秒降至23秒。尽管仍然存在一些不可忽视的不平衡或串行循环现象,但我们注意到性能相对提升了,从4.980秒降至3.887秒。
不使用逻辑线程(而是每个物理核心使用1个线程),可以避免逻辑线程争夺同一核心资源。Top Hotspots 部分还表明,从4.589秒到3.530秒,性能提升了__kmp_fork_barrier倍。
本地内存访问始终比远程内存访问更快¶
我们通常建议将进程绑定到本地套接字,以防止进程跨套接字迁移。通常这样做的目的是利用本地内存中的高速缓存,并避免远程内存访问,后者可能慢约2倍。
图1. 双插槽配置
图1显示了一个典型的双插槽配置。请注意,每个插槽都有自己的本地内存。插槽之间通过英特尔超路径互连(UPI)连接,这使得每个插槽可以访问另一个插槽的本地内存,称为远程内存。本地内存访问速度总是比远程内存访问速度快。
图 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))。
图 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 设置线程数,则默认线程数是超线程系统中物理核心的数量。可以通过 torch.get_num_threads 验证这一点。因此我们上面看到大约有一半的核心在运行示例脚本。)
图4. 非均匀内存访问分析图
图4 比较了随时间推移的本地与远程内存访问。我们验证了可能导致性能不佳的远程内存使用情况。
设置线程亲和力以减少远程内存访问和跨插槽(UPI)流量
将线程固定到同一插槽的内核有助于保持内存访问的局部性。在本例中,我们将线程固定到第一个NUMA节点的物理内核(0-27)。通过启动脚本,用户只需切换--node_id启动脚本开关即可轻松实验NUMA节点配置。
现在我们来可视化CPU的使用情况。
图5. NUMA感知应用程序的CPU使用率
1 个主工作线程被启动,然后它在第一个 NUMA 节点上的所有物理核心上启动了线程。
图6. 非均匀内存访问分析图
如图6所示,现在几乎所有内存访问都是本地访问。
高效CPU使用,通过核心绑定实现多工作者推理¶
在进行多工作者推理时,不同工作者之间会共享(或重叠)CPU核心,导致CPU使用效率低下。为了解决这个问题,启动脚本会将可用核心数均分给各个工作者,使得每个工作者在运行时被固定分配到指定的核心上。
使用 TorchServe 练习
对于这个练习,让我们将迄今为止讨论的CPU性能调优原则和建议应用于 TorchServe apache-bench 基准测试。
我们将使用ResNet50,4个工作线程,并发数100,请求次数10,000。所有其他参数(例如,batch_size、input等)与默认参数相同。
我们将比较以下三种配置:
默认的 TorchServe 设置(未固定核心)
torch.set_num_threads =
number of physical cores / number of workers(无核心绑定)通过启动脚本进行核心固定(需要 Torchserve>=0.6.1)
在此练习之后,我们将验证在真实的 TorchServe 使用案例中,我们更倾向于避免逻辑核心,并通过核心绑定来优先使用本地内存访问。
1. 默认的TorchServe设置(无核心固定)¶
The base_handler 不显式设置 torch.set_num_threads。因此,默认线程数是物理CPU核心的数量,如 此处 所述。用户可以通过 torch.get_num_threads 在 base_handler 中检查线程数。每个主要的4个工作线程启动56个物理核心的线程,总共启动了56x4=224个线程,这超过了总共有112个核心的数量。因此,核心会被大量重叠使用,逻辑核心利用率很高——多个工作者同时使用多个核心。此外,由于线程未被绑定到特定的CPU核心,操作系统会定期将线程调度到位于不同插槽中的核心上。
CPU使用率
4个主要工作线程被启动,然后每个线程在所有核心(包括逻辑核心)上启动了物理核心数量(56)的线程。
核心限制停滞
我们观察到一个非常高的核心绑定停滞(Core Bound stall)达到88.4%,导致流水线效率下降。核心绑定停滞表明CPU中可用执行单元的使用不够优化。例如,多个连续的GEMM指令竞争融合乘加(FMA)或点积(DP)执行单元,而这些执行单元被超线程核心共享,可能会导致核心绑定停滞。正如前一节所述,逻辑核心的使用会加剧这一问题。
一个未被微操作(uOps)填充的空流水线插槽被视为停滞。例如,没有核心固定的情况下,CPU使用可能不是在计算上,而是在其他操作上,如Linux内核的线程调度。我们上面看到 __sched_yield 是大部分Spin Time的主要贡献者。
线程迁移
如果没有核心固定,调度器可能会将正在某个核心上执行的线程迁移到不同的核心。线程迁移可能导致该线程与已经加载到缓存中的数据分离,从而导致更长的数据访问延迟。在NUMA系统中,当线程跨插槽迁移时,这个问题会更加严重。原本加载到本地内存高速缓存中的数据现在变成了远程内存,速度要慢得多。
通常,线程的总数应小于或等于核心支持的线程总数。在上面的例子中,我们注意到大量线程在 core_51 上执行,而不是预期的 2 个线程(由于 Intel(R) Xeon(R) Platinum 8180 处理器启用了超线程技术)。这表明发生了线程迁移。
此外,请注意线程(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 = number of physical cores / number of workers (无核心固定)¶
对于与启动器核心固定功能的直接比较,我们将线程数设置为核心数除以工作线程数(启动器内部会执行此操作)。在 base_handler 中添加以下代码片段:
torch.set_num_threads(num_physical_cores/num_workers)
与之前不进行核心绑定的情况一样,这些线程未被分配到特定的 CPU 核心上,导致操作系统会定期将线程调度到位于不同插槽中的核心上。
CPU使用率
4个主要工作线程已启动,然后每个启动了num_physical_cores/num_workers个数字(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个主要工作线程已启动,然后每个启动了num_physical_cores/num_workers个数字(14)的线程,并将其绑定到分配的物理核心上。
核心限制停滞
核心绑定停滞率已从原来的88.4%显著下降至46.2% - 几乎提升了两倍。
我们验证了通过核心绑定,大部分CPU时间有效地用于计算,自旋时间为0.256秒。
线程迁移
我们验证了 OMP Primary Thread #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)对博客内容提供的有帮助的反馈。






























