Grokking PyTorch Intel CPU 性能的第一原则¶
创建时间: Apr 15, 2022 |上次更新时间:2024 年 1 月 16 日 |上次验证: Nov 05, 2024
使用英特尔® Extension for PyTorch* 优化的 TorchServe 推理框架案例研究。
作者: Min Jean Cho, Mark Saroufim
审稿人: Ashok Emani, Jiong Gong
在 CPU 上获得强大的开箱即用性能以进行深度学习可能很棘手,但如果您了解影响性能的主要问题、如何测量这些问题以及如何解决这些问题,就会容易得多。
TL;博士
问题 |
如何测量 |
溶液 |
受瓶颈的 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,它们将共享相同的核心资源,从而导致前端绑定,因此此前端绑定的开销大于同时运行两个逻辑线程的增益。
因此,我们通常建议避免对深度学习工作负载使用逻辑内核,以实现良好的性能。默认情况下,启动脚本仅使用物理内核;但是,用户只需切换 Launch Script 旋钮即可轻松试验 Logical Cores 与 Physical Core。--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))
在整篇博客中,我们将使用英特尔® VTune™ Profiler 来分析和验证优化。我们将在具有两个 Intel(R) Xeon(R) Platinum 8180M CPU 的机器上运行所有练习。CPU 信息如图 2.1 所示。
环境变量用于设置并行区域的线程数。我们将与 (1) 使用逻辑内核和 (2) 仅使用物理内核进行比较。OMP_NUM_THREADS
OMP_NUM_THREADS=2
两个 OpenMP 线程都尝试使用超线程内核共享的相同 GEMM 执行单元 (0, 56)
我们可以通过在 Linux 上运行 command 来可视化这一点,如下所示。htop
我们注意到 Spin Time 被标记出来,并且 Imbalance 或 Serial Spinning 贡献了大部分时间 - 总共 8.982 秒中的 4.980 秒。使用逻辑内核时的不平衡或串行旋转是由于工作线程的并发性不足,因为每个逻辑线程都在争夺相同的内核资源。
执行摘要的 Top Hotspots 部分指示花费了 4.589 秒的 CPU 时间 - 在 9.33% 的 CPU 执行时间内,由于线程同步,线程只是在此屏障处旋转。__kmp_fork_barrier
每个 OpenMP 线程在各自的物理内核中使用 GEMM 执行单元 (0,1)
我们首先注意到,通过避免使用逻辑内核,执行时间从 32 秒减少到 23 秒。虽然仍然存在一些不可忽略的不平衡或串行旋转,但我们注意到从 4.980 秒到 3.887 秒的相对改进。
通过不使用逻辑线程(而是每个物理内核使用 1 个线程),我们避免了逻辑线程争夺相同的内核资源。Top Hotspots (热门热点) 部分还指示时间的相对改进,从 4.589 秒增加到 3.530 秒。__kmp_fork_barrier
本地内存访问始终比远程内存访问更快¶
我们通常建议将进程绑定到本地套接字,以便该进程不会跨套接字迁移。通常,这样做的目标是在本地内存上利用高速缓存,并避免远程内存访问,远程内存访问可能会慢 ~2 倍。
图 1.双插槽配置
图 1.显示了典型的双插槽配置。请注意,每个套接字都有自己的本地内存。插槽通过英特尔超级路径互连 (UPI) 相互连接,允许每个插槽访问另一个称为远程内存的插槽的本地内存。本地内存访问始终比远程内存访问更快。
图 2.1.CPU 信息
用户可以通过在 Linux 计算机上运行 command 来获取其 CPU 信息。图 2.1.显示了在具有两个 Intel(R) Xeon(R) Platinum 8180M CPU 的计算机上执行的示例。请注意,每个插槽有 28 个内核,每个内核有 2 个线程(即启用超线程)。换句话说,除了 28 个物理内核之外,还有 28 个逻辑内核,每个插槽总共有 56 个内核。并且有 2 个插槽,总共有 112 个内核 ( x x )。lscpu
lscpu
Thread(s) per core
Core(s) per socket
Socket(s)
图 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.Non-Uniform Memory Access Analysis 图形
图 4.比较一段时间内的本地内存访问与远程内存访问。我们会验证远程内存的使用情况,这可能会导致性能欠佳。
设置线程关联以减少远程内存访问和跨套接字 (UPI) 流量
将线程固定到同一套接字上的内核有助于保持内存访问的位置。在此示例中,我们将固定到第一个 NUMA 节点 (0-27) 上的物理内核。使用启动脚本,用户只需切换启动脚本旋钮即可轻松尝试 NUMA 节点配置。--node_id
现在,让我们可视化 CPU 使用率。
图 5.NUMA 感知应用程序的 CPU 使用率
启动了 1 个主工作线程,然后在第一个 NUMA 节点上的所有物理内核上启动了线程。
图 6.Non-Uniform Memory Access Analysis 图形
如图 6 所示,现在几乎所有的内存访问都是本地访问。
通过核心固定实现高效的 CPU 使用率,以实现多工作线程推理¶
在运行多工作线程推理时,工作线程之间的内核重叠(或共享),从而导致 CPU 使用率低下。为了解决这个问题,启动脚本将可用内核的数量除以工作线程的数量,以便在运行时将每个工作线程固定到分配的内核。
使用 TorchServe 进行锻炼
在本练习中,让我们将到目前为止讨论的 CPU 性能调优原则和建议应用于 TorchServe apache-bench 基准测试。
我们将 ResNet50 与 4 个工作线程一起使用,并发 100,请求 10000。所有其他参数(例如,batch_size、input 等)与默认参数相同。
我们将比较以下三种配置:
默认 TorchServe 设置(无核心固定)
torch.set_num_threads =(无型芯固定)
number of physical cores / number of workers
通过启动脚本进行核心固定(所需的 Torchserve>=0.6.1)
在本练习之后,我们将验证我们更喜欢避免使用逻辑内核,并且更喜欢通过实际 TorchServe 用例的内核固定进行本地内存访问。
1. 默认 TorchServe 设置(无核心固定)¶
base_handler 未显式设置 torch.set_num_threads。因此,默认线程数是物理 CPU 内核数,如此处所述。用户可以在 base_handler 中按 torch.get_num_threads 来查看线程数。4 个主工作线程中的每一个都启动一个物理内核数 (56) 的线程,总共启动 56x4 = 224 个线程,这比内核总数 112 个还多。因此,可以保证内核与高逻辑内核利用率(多个 worker 同时使用多个内核)严重重叠。此外,由于线程未与特定 CPU 内核关联,因此操作系统会定期将线程调度到位于不同套接字中的内核。
CPU 使用率
启动了 4 个主工作线程,然后每个线程在所有核心(包括逻辑核心)上启动了一个物理核心编号 (56) 的线程。
Core Bound 停顿
我们观察到 88.4% 的非常高的 Core Bound 停滞,从而降低了管道效率。Core Bound 停顿表示 CPU 中可用执行单元的使用不理想。例如,连续的多个 GEMM 指令争夺超线程内核共享的融合乘加 (FMA) 或点积 (DP) 执行单元,可能会导致 Core Bound 停顿。如上一节所述,使用 logical cores 会放大这个问题。
未填充微操作 (uOps) 的空管道槽归因于停顿。例如,如果没有核心固定,CPU 使用率可能无法有效地在计算上,而是在其他操作上,例如来自 Linux 内核的线程调度。我们在上面看到,它促成了大部分的 Spin Time。__sched_yield
线程迁移
如果没有核心固定,调度器可能会将核心上执行的线程迁移到不同的核心。线程迁移可能会使线程与已提取到缓存中的数据取消关联,从而导致更长的数据访问延迟。在 NUMA 系统中,当线程跨套接字迁移时,此问题会加剧。已提取到本地内存上的高速缓存的数据现在成为远程内存,速度要慢得多。
通常,线程总数应小于或等于内核支持的线程总数。在上面的示例中,我们注意到大量线程在 core_51 上执行,而不是预期的 2 个线程(因为超线程在 Intel(R) Xeon(R) Platinum 8180 CPU 中启用了超线程)。这表示线程迁移。
此外,请注意,线程 (TID:97097) 正在大量 CPU 内核上执行,这表明 CPU 正在迁移。例如,此线程在 cpu_81 上执行,然后迁移到 cpu_14,然后迁移到 cpu_5,依此类推。此外,请注意,此线程在 socket 之间来回迁移了很多次,导致内存访问效率非常低。例如,此线程在 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
¶
为了与 Launcher 的核心固定进行苹果对苹果的比较,我们将线程数设置为核心数除以 worker 的数量(Launcher 在内部执行此操作)。在 base_handler 中添加以下代码片段:
torch.set_num_threads(num_physical_cores/num_workers)
与以前一样,没有内核固定,这些线程不隶属于特定的 CPU 内核,从而导致操作系统定期在位于不同插槽中的内核上调度线程。
CPU 使用率
启动了 4 个主工作线程,然后每个线程在所有内核(包括逻辑内核)上启动了一定数量的 (14) 个线程。num_physical_cores/num_workers
Core Bound 停顿
尽管 Core Bound 摊位的百分比从 88.4% 下降到 73.5%,但 Core Bound 仍然非常高。
线程迁移
与以前类似,无核心固定线程 (TID:94290) 在大量 CPU 核心上执行,表明 CPU 迁移。我们再次注意到跨套接字线程迁移,导致内存访问效率非常低。例如,此线程在 cpu_78(NUMA 节点 0)上执行,然后迁移到 cpu_108(NUMA 节点 1)。
非一致性内存访问分析
尽管比原来的 51.09% 有所改进,但仍有 40.45% 的内存访问是远程的,这表明 NUMA 配置不理想。
3. 启动器核心固定¶
Launcher 将在内部将物理核心平均分配给 worker,并将它们绑定到每个 worker。提醒一下,默认情况下,启动器仅使用物理内核。在此示例中,启动器会将工作线程 0 绑定到核心 0-13(NUMA 节点 0),将工作线程 1 绑定到核心 14-27(NUMA 节点 0),将工作线程 2 绑定到核心 28-41(NUMA 节点 1),将工作线程 3 绑定到核心 42-55(NUMA 节点 1)。这样做可以确保 worker 之间的内核不会重叠,并避免逻辑内核的使用。
CPU 使用率
启动了 4 个主工作线程,然后每个线程启动了与分配的物理内核关联的数量 (14) 个线程。num_physical_cores/num_workers
Core Bound 停顿
Core Bound 停顿从原来的 88.4% 大幅下降到 46.2% - 几乎提高了 2 倍。
我们验证了使用内核绑定时,大部分 CPU 时间都有效地用于计算 - Spin Time 为 0.256s。
线程迁移
我们验证 OMP 主线程 #0 已绑定到分配的物理内核 (42-55),并且没有跨插槽迁移。
非一致性内存访问分析
现在几乎所有 (89.52%) 的内存访问都是本地访问。
结论¶
在本博客中,我们展示了正确设置 CPU 运行时配置可以显著提高开箱即用的 CPU 性能。
我们介绍了一些通用的 CPU 性能调优原则和建议:
在启用超线程的系统中,通过仅通过内核固定将线程关联设置为物理内核来避免逻辑内核。
在具有 NUMA 的多套接字系统中,通过内核固定将线程关联设置为特定套接字,从而避免跨套接字远程内存访问。
我们从第一原则直观地解释了这些想法,并通过分析验证了性能提升。最后,我们将所有学习成果应用于 TorchServe,以提高开箱即用的 TorchServe CPU 性能。
这些原则可以通过易于使用的启动脚本自动配置,该脚本已集成到 TorchServe 中。
有兴趣的读者,请查看以下文档:
请继续关注后续文章,了解通过面向 PyTorch* 的英特尔®扩展在 CPU 上优化内核,以及内存分配器等高级启动器配置。
确认¶
我们要感谢 Ashok Emani (Intel) 和 Jiong Gong (Intel) 在本博客的许多步骤中提供的大量指导和支持,以及全面的反馈和评论。我们还要感谢 Hamid Shojanazeri (Meta)、Li Ning (AWS) 和 Jing Xu (Intel) 在代码审查方面的有益反馈。以及 Suraj Subramanian (Meta) 和 Geeta Chauhan (Meta) 在博客上提供的有用反馈。