从第一性原理理解 PyTorch 在 Intel CPU 上的性能
一个关于TorchServe推理框架的案例研究,该框架通过Intel® Extension for PyTorch*进行了优化。
作者:Min Jean Cho, Mark Saroufim
审阅者:Ashok Emani, Jiong Gong
在CPU上实现深度学习的强大开箱即用性能可能具有挑战性,但如果您了解影响性能的主要问题、如何衡量它们以及如何解决它们,事情就会容易得多。
TL;DR
问题 | 如何测量它 | 解决方案 |
瓶颈的 GEMM 执行单元 | *不平衡或连续旋转负载不平衡或串行自旋 * 前端瓶颈*前端瓶颈前端瓶颈 * 核心瓶颈*核心受限 | 通过将线程亲和性设置为物理核心的CPU绑定,避免使用逻辑核心。 |
非统一内存访问(NUMA) | * 本地与远程内存访问* 本地内存访问与远程内存访问 |
在启用超线程时,GEMM(通用矩阵乘法)在融合乘加(FMA)或点积(DP)执行单元上运行,会导致瓶颈,并在同步屏障处造成线程等待/自旋的延迟——这是因为使用逻辑核心会导致所有工作线程的并发性不足,每个逻辑线程争用相同的核心资源。相反,如果我们每个物理核心只使用一个线程,就可以避免这种争用。因此,我们通常建议通过核心绑定将CPU线程亲和性设置为物理核心,以避免使用逻辑核心。
多插槽系统具有非统一内存访问 (NUMA),这是一种共享内存架构,描述了主内存模块相对于处理器的布局。但是,如果一个进程不具备 NUMA 感知能力,在运行时通过Intel Ultra Path Interconnect (UPI)跨插槽迁移线程时,会频繁访问速度较慢的远程内存。我们通过核心绑定将 CPU 线程亲和性设置到特定插槽来解决这个问题。
了解这些原理后,正确的 CPU 运行时配置可以显著提升开箱即用的性能。
在本博客中,我们将带您了解 CPU 性能调优指南 中您应该注意的重要运行时配置,解释它们的工作原理、如何进行分析,以及如何通过我们已原生集成的易于使用的启动脚本将它们集成到像 TorchServe 这样的模型服务框架中。
我们将以视觉化的方式,从基本原理出发,结合大量性能分析,详细解释这些概念,并向您展示如何将我们的研究成果应用于提升 TorchServe 的默认 CPU 性能。
- 该功能需要通过将 config.properties 文件中的 cpu_launcher_enable=true 来显式启用。
深度学习应避免使用逻辑核心
避免在深度学习任务中使用逻辑核心通常可以提高性能。为了理解这一点,让我们回过头来看看GEMM。
优化GEMM就是优化深度学习
在深度学习训练或推理过程中,大部分时间都花在重复执行数百万次的GEMM操作上,这些操作是全连接层的核心。全连接层自多层感知机(MLP)以来已使用了几十年,因为MLP被证明是任何连续函数的通用近似器。任何MLP都可以完全表示为GEMM。甚至卷积操作也可以通过使用托普利兹矩阵表示为GEMM。
回到最初的主题,大多数GEMM(通用矩阵乘法)算子从禁用超线程中受益,因为在深度学习训练或推理中,大部分时间都花在数百万次重复的GEMM操作上,这些操作运行在由超线程核心共享的融合乘加(FMA)或点积(DP)执行单元上。如果启用超线程,OpenMP线程将争用相同的GEMM执行单元。
如果两个逻辑线程同时运行 GEMM,它们将共享相同的核心资源,导致前端瓶颈,使得这种前端瓶颈的开销大于同时运行两个逻辑线程所带来的增益。
因此,我们通常建议避免使用逻辑核心来处理深度学习工作负载,以获得更好的性能。启动脚本默认仅使用物理核心;然而,用户可以通过简单地切换启动脚本中的 --use_logical_core
参数来轻松实验逻辑核心与物理核心的差异。
练习
我们将使用以下示例来为 ResNet50 提供虚拟张量:
importtorch
importtorchvision.modelsasmodels
importtime
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 线程试图利用由超线程核心(0, 56)共享的相同 GEMM 执行单元
我们可以通过在 Linux 上运行 htop
命令来可视化这一点,如下所示。
我们注意到 Spin Time 被标记,其中 Imbalance 或 Serial Spinning 占据了大部分时间——总共 8.982 秒中的 4.980 秒。在使用逻辑核心时,Imbalance 或 Serial Spinning 的原因在于工作线程的并发性不足,因为每个逻辑线程都在竞争相同的核心资源。
执行摘要中的 Top Hotspots 部分表明,__kmp_fork_barrier
消耗了 4.589 秒的 CPU 时间——在 CPU 执行时间的 9.33% 期间,线程由于线程同步而在这个屏障上处于空转状态。
- 每个 OpenMP 线程在各自的物理核心(0,1)中利用 GEMM 执行单元
我们首先注意到,通过避免使用逻辑核心,执行时间从 32 秒减少到了 23 秒。尽管仍然存在一些不可忽视的负载不均或串行自旋问题,但我们观察到相对改进从 4.980 秒提升到了 3.887 秒。
通过不使用逻辑线程(而是每个物理核心使用 1 个线程),我们避免了逻辑线程争用同一核心资源的情况。Top Hotspots 部分也显示了 __kmp_fork_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 个核心。由于有 2 个插槽,总核心数为 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 使用情况。启动了一个主工作线程,然后它在所有核心(包括逻辑核心)上启动了物理核心数(56)个线程。
(注:如果未通过 torch.set_num_threads 设置线程数,默认线程数为启用了超线程的系统中的物理核心数。这可以通过 torch.get_num_threads 来验证。因此,我们在上面看到大约一半的核心在运行示例脚本。)
图 4. 非统一内存访问分析图
图 4. 比较了本地与远程内存访问随时间的变化。我们验证了远程内存的使用,这可能会导致性能下降。
设置线程亲和性以减少远程内存访问和跨插槽(UPI)流量
将线程固定到同一插槽上的核心有助于保持内存访问的局部性。在此示例中,我们将固定到第一个 NUMA 节点(0-27)的物理核心。通过启动脚本,用户可以通过简单地切换 --node_id
启动脚本旋钮来轻松实验 NUMA 节点配置。
现在让我们可视化 CPU 使用情况。
图 5. NUMA 感知应用的 CPU 使用情况
启动了一个主工作线程,随后它在第一个 NUMA 节点上的所有物理核心上启动了线程。
如图6所示,现在几乎所有内存访问都是本地访问。
通过核心绑定实现多工作进程推理的高效 CPU 使用
在执行多工作进程推理时,核心会在工作进程之间重叠(或共享),导致 CPU 使用效率低下。为了解决这个问题,启动脚本将可用核心数平均分配给工作进程的数量,使得每个工作进程在运行时被固定到分配的核心上。
TorchServe 练习
在本练习中,我们将应用迄今为止讨论的 CPU 性能调优原则和建议,对 TorchServe apache-bench 基准测试 进行调整。
我们将使用 ResNet50 模型,配置为 4 个工作进程,并发数为 100,请求数为 10,000。所有其他参数(例如 batch_size、输入等)与 默认参数 相同。
我们将比较以下三种配置:
-
默认的 TorchServe 设置(无核心绑定)
-
torch.set_num_threads =
物理核心数 / 工作线程数
(无核心绑定) -
通过启动脚本进行核心绑定(需要 Torchserve >= 0.6.1)
通过此次实践,我们已通过一个实际的 TorchServe 用例验证了我们更倾向于避免使用逻辑核心,并通过核心绑定的方式优先选择本地内存访问。
1. 默认的 TorchServe 设置(无核心绑定)
base_handler 没有显式设置 torch.set_num_threads。因此,默认的线程数是物理 CPU 核心数,如 此处 所述。用户可以通过 torch.get_num_threads 在 base_handler 中检查线程数。每个主工作线程会启动相当于物理核心数(56)的线程,总共启动 56x4 = 224 个线程,这超过了总核心数 112。因此,核心必然会高度重叠,逻辑核心利用率很高——多个工作线程同时使用多个核心。此外,由于线程并未绑定到特定的 CPU 核心,操作系统会定期将线程调度到位于不同插槽的核心上。
- CPU 使用率
启动了4个主要工作线程,随后每个线程在所有核心(包括逻辑核心)上启动了物理核心数(56)的线程。
- 核心绑定停顿
我们观察到核心绑定(Core Bound)的停顿率高达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 插槽来回迁移,导致内存访问效率非常低下。例如,该线程在 cpu_70(NUMA 节点 0)上执行,然后迁移到 cpu_100(NUMA 节点 1),再迁移到 cpu_24(NUMA 节点 0)。
- 非一致性内存访问分析
比较本地与远程内存访问随时间的变化。我们观察到大约有51.09%的内存访问是远程访问,这表明NUMA配置不够优化。
2. torch.set_num_threads = 物理核心数 / 工作线程数
(无核心绑定)
为了与启动器的核心绑定进行公平比较,我们将线程数设置为核心数除以工作线程数(启动器在内部执行此操作)。在base_handler中添加以下代码片段:
torch.set_num_threads(num_physical_cores/num_workers)
和之前没有核心绑定的情况一样,这些线程没有被固定到特定的 CPU 核心上,导致操作系统会定期将线程调度到位于不同插槽的核心上。
- CPU 使用率
启动了4个主工作线程,然后每个线程在所有核心(包括逻辑核心)上启动了num_physical_cores/num_workers
数量(14)的线程。
- 核心绑定停顿
尽管核心瓶颈(Core Bound)的停滞比例已从 88.4% 下降至 73.5%,但核心瓶颈仍然非常高。
- 线程迁移
与之前类似,在没有核心绑定的情况下,线程(TID:94290)在大量 CPU 核心上执行,表明存在 CPU 迁移。我们再次注意到跨插槽的线程迁移,导致非常低效的内存访问。例如,该线程在 cpu_78(NUMA 节点 0)上执行,然后迁移到 cpu_108(NUMA 节点 1)。
- 非统一内存访问分析
尽管相比最初的 51.09% 有所改善,仍有 40.45% 的内存访问是远程的,这表明 NUMA 配置尚未达到最佳状态。
3. launcher 核心绑定
启动器将在内部将物理核心均匀分配给各个工作进程,并将它们绑定到每个工作进程。需要注意的是,启动器默认仅使用物理核心。在此示例中,启动器会将工作进程 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% —— 几乎提升了 2 倍。
我们验证了通过核心绑定,大部分CPU时间被有效地用于计算——自旋时间为0.256秒。
- 线程迁移
我们验证了 OMP 主线程 #0 已绑定到指定的物理核心(42-55),并且没有跨插槽迁移。
- 非一致性内存访问分析
现在几乎所有(89.52%)的内存访问都是本地访问。
结论
在本博客中,我们展示了正确设置 CPU 运行时配置可以显著提升开箱即用的 CPU 性能。
我们介绍了一些通用的 CPU 性能调优原则和建议:
-
在启用了超线程的系统中,通过将线程亲和性设置为仅绑定到物理核心(通过核心绑定)来避免使用逻辑核心。
-
在支持 NUMA 的多插槽系统中,通过将线程亲和性绑定到特定插槽(通过核心绑定)来避免跨插槽的远程内存访问。
我们从基本原理出发,对这些概念进行了直观的解释,并通过性能分析验证了性能提升。最后,我们将所学到的知识应用到 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)对本博客的有益反馈。