从第一性原理理解 PyTorch 英特尔 CPU 性能¶
创建于:2022 年 4 月 15 日 | 最后更新:2025 年 1 月 23 日 | 最后验证:2024 年 11 月 05 日
关于使用 Intel® Extension for PyTorch* 优化的 TorchServe 推理框架的案例研究。
作者:Min Jean Cho, Mark Saroufim
审阅者:Ashok Emani, Jiong Gong
在 CPU 上为深度学习获得强大的开箱即用性能可能很棘手,但如果您了解影响性能的主要问题、如何衡量这些问题以及如何解决这些问题,就会容易得多。
TL;DR(太长不看)
问题 |
如何衡量 |
解决方案 |
GEMM 执行单元瓶颈 |
通过核心绑定将线程亲和性设置为物理核心,避免使用逻辑核心 |
|
非一致性内存访问 (NUMA) |
|
通过核心绑定将线程亲和性设置为特定插槽,避免跨插槽计算 |
GEMM(通用矩阵乘法) 在融合乘加 (FMA) 或点积 (DP) 执行单元上运行,当启用超线程时,这些执行单元将成为瓶颈,并导致线程在同步屏障处等待/自旋延迟 - 因为使用逻辑核心会导致所有工作线程的并发性不足,因为每个逻辑线程争用相同的核心资源。相反,如果我们每个物理核心使用 1 个线程,我们就可以避免这种争用。因此,我们通常建议通过核心绑定将 CPU 线程亲和性设置为物理核心来避免使用逻辑核心。
多插槽系统具有非一致性内存访问 (NUMA),这是一种共享内存架构,描述了主内存模块相对于处理器的放置位置。但是,如果进程不感知 NUMA,则当线程迁移跨插槽通过 Intel Ultra Path Interconnect (UPI) 在运行时,会频繁访问慢速远程内存。我们通过核心绑定将 CPU 线程亲和性设置为特定插槽来解决此问题。
了解这些原则后,适当的 CPU 运行时配置可以显著提升开箱即用性能。
在本博客中,我们将带您了解您应该了解的来自 CPU 性能调优指南 的重要运行时配置,解释它们的工作原理、如何进行性能分析以及如何通过易于使用的 启动脚本 将它们集成到像 TorchServe 这样的模型服务框架中,我们已经 原生集成 1 了该脚本。
我们将从第一性原理出发,用大量的性能分析以 可视化 的方式解释所有这些想法,并向您展示我们如何应用我们的学习成果来改进 TorchServe 的开箱即用 CPU 性能。
该功能必须通过在 config.properties 中设置 cpu_launcher_enable=true 显式启用。
避免逻辑核心用于深度学习¶
对于深度学习工作负载,避免使用逻辑核心通常可以提高性能。为了理解这一点,让我们回到 GEMM。
优化 GEMM 即可优化深度学习
深度学习训练或推理的大部分时间都花在数百万次重复的 GEMM 运算上,GEMM 是全连接层的核心。自从多层感知器 (MLP) 被证明是任何连续函数的通用逼近器 以来,全连接层已被使用了数十年。任何 MLP 都可以完全表示为 GEMM。甚至卷积也可以通过使用 Toepliz 矩阵 表示为 GEMM。
回到最初的主题,大多数 GEMM 运算符都受益于使用非超线程,因为深度学习训练或推理的大部分时间都花在数百万次重复的 GEMM 运算上,这些运算在超线程核心共享的融合乘加 (FMA) 或点积 (DP) 执行单元上运行。启用超线程后,OpenMP 线程将争用相同的 GEMM 执行单元。
如果 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) 仅使用物理核心的情况。
两个 OpenMP 线程都试图利用超线程核心(0, 56)共享的相同 GEMM 执行单元
我们可以通过在 Linux 上运行 htop
命令来可视化这一点,如下所示。
我们注意到 Spin Time 被标记,而不平衡或串行自旋贡献了其中的大部分 - 在总共 8.982 秒中占了 4.980 秒。使用逻辑核心时的不平衡或串行自旋是由于工作线程的并发性不足,因为每个逻辑线程争用相同的核心资源。
执行摘要的“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 Ultra Path Interconnect (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 信息
2 个插槽分别映射到 2 个 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 使用率效率低下。为了解决这个问题,启动脚本将可用核心数平均分配给工作进程数,以便在运行时将每个工作进程绑定到分配的核心。
TorchServe 练习
对于本练习,让我们将到目前为止讨论的 CPU 性能调优原则和建议应用于 TorchServe apache-bench 基准测试。
我们将使用 ResNet50,包含 4 个工作进程、并发数 100、请求数 10,000。所有其他参数(例如,batch_size、input 等)与 默认参数 相同。
我们将比较以下三种配置
默认 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 中检查线程数。4 个主工作线程中的每一个都启动物理核心数 (56) 个线程,总共启动 56x4 = 224 个线程,这超过了核心总数 112。因此,可以保证核心会高度重叠,并且逻辑核心利用率很高 - 多个工作进程同时使用多个核心。此外,由于线程未绑定到特定的 CPU 核心,因此操作系统会定期将线程调度到位于不同插槽中的核心。
CPU 使用率
启动了 4 个主工作线程,然后每个线程在所有核心(包括逻辑核心)上启动了物理核心数 (56) 个线程。
核心受限停顿
我们观察到非常高的核心受限停顿,达到 88.4%,降低了流水线效率。核心受限停顿表明 CPU 中可用执行单元的利用率欠佳。例如,一连串争用超线程核心共享的融合乘加 (FMA) 或点积 (DP) 执行单元的 GEMM 指令可能会导致核心受限停顿。正如前一节所述,使用逻辑核心会放大这个问题。
未填充微操作 (uOps) 的空流水线槽归因于停顿。例如,在没有核心绑定的情况下,CPU 使用率可能无法有效地用于计算,而是用于 Linux 内核中的其他操作,如线程调度。我们看到上面 __sched_yield
对 Spin Time 的贡献最大。
线程迁移
在没有核心绑定的情况下,调度器可能会将在一个核心上执行的线程迁移到另一个核心。线程迁移可能会使线程与已提取到缓存中的数据脱离关联,从而导致更长的数据访问延迟。当线程跨插槽迁移时,这个问题在 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 中添加以下代码片段
torch.set_num_threads(num_physical_cores/num_workers)
与之前未进行核心绑定时一样,这些线程未与特定的 CPU 核心关联,导致操作系统定期在位于不同 socket 的核心上调度线程。
CPU 使用率
启动了 4 个主工作线程,然后每个线程在所有核心(包括逻辑核心)上启动了 num_physical_cores/num_workers
数量(14 个)的线程。
核心受限停顿
尽管 Core Bound 停顿的百分比已从 88.4% 降至 73.5%,但 Core Bound 仍然非常高。
线程迁移
与之前类似,在未进行核心绑定的情况下,线程 (TID:94290) 在大量 CPU 核心上执行,表明发生了 CPU 迁移。我们再次注意到跨 socket 线程迁移,导致非常低效的内存访问。例如,此线程在 cpu_78 (NUMA 节点 0) 上执行,然后迁移到 cpu_108 (NUMA 节点 1)。
非一致性内存访问分析
尽管相比最初的 51.09% 有所改进,但仍有 40.45% 的内存访问是远程的,表明 NUMA 配置欠佳。
3. launcher 核心绑定¶
Launcher 将在内部平等地将物理核心分配给 worker,并将它们绑定到每个 worker。提醒一下,launcher 默认仅使用物理核心。在此示例中,launcher 将 worker 0 绑定到核心 0-13 (NUMA 节点 0),worker 1 绑定到核心 14-27 (NUMA 节点 0),worker 2 绑定到核心 28-41 (NUMA 节点 1),worker 3 绑定到核心 42-55 (NUMA 节点 1)。这样做确保了核心在 worker 之间不会重叠,并避免了逻辑核心的使用。
CPU 使用率
启动了 4 个主工作线程,然后每个线程启动了 num_physical_cores/num_workers
数量(14 个)的线程,这些线程被绑定到分配的物理核心。
核心受限停顿
Core Bound 停顿已从最初的 88.4% 显着降低至 46.2%,几乎提高了 2 倍。
我们验证了通过核心绑定,大部分 CPU 时间有效地用于计算 - 自旋时间为 0.256 秒。
线程迁移
我们验证了 OMP Primary Thread #0 已绑定到分配的物理核心 (42-55),并且没有跨 socket 迁移。
非一致性内存访问分析
现在几乎所有内存访问 (89.52%) 都是本地访问。
结论¶
在这篇博客中,我们展示了正确设置 CPU 运行时配置可以显着提升开箱即用的 CPU 性能。
我们已经介绍了 CPU 性能调优的一些通用原则和建议
在启用超线程的系统中,通过核心绑定将线程亲和性设置为仅物理核心,从而避免使用逻辑核心。
在具有 NUMA 的多 socket 系统中,通过核心绑定将线程亲和性设置为特定 socket,从而避免跨 socket 的远程内存访问。
我们从第一性原理直观地解释了这些想法,并通过性能分析验证了性能提升。最后,我们将所有学习成果应用于 TorchServe,以提升开箱即用的 TorchServe CPU 性能。
这些原则可以通过易于使用的启动脚本自动配置,该脚本已集成到 TorchServe 中。
对于感兴趣的读者,请查看以下文档
请继续关注后续文章,了解通过 Intel® Extension for PyTorch* 优化的 CPU 内核以及诸如内存分配器等高级 launcher 配置。
致谢¶
我们要感谢 Ashok Emani (Intel) 和 Jiong Gong (Intel) 的巨大指导和支持,以及在本博客的许多步骤中提供的详尽反馈和评论。我们还要感谢 Hamid Shojanazeri (Meta)、李宁 (AWS) 和 Jing Xu (Intel) 在代码审查中提供的有益反馈。以及 Suraj Subramanian (Meta) 和 Geeta Chauhan (Meta) 在博客上提供的有益反馈。