Applying the Roofline Model for Deep Learning performance optimizations 以 Intel Xeon 为例,介绍了一种为非统一内存访问( NonUnified Memory Access,NUMA[8])自动创建 Roofline 模型的方法,并对 Intel oneDNN 库中实现的高效深度学习原语进行了评估。
所有实验均在禁用 Intel Turbo Boost 技术的 Intel Xeon Scalable 处理器上进行,正如 Applying the roofline model 中所建议的那样。
本工作中的单核 Roofline 只是对使用单线程可用的峰值计算和带宽进行了基准测试。对于峰值计算是合适的,因为这将随着使用的核心数量而扩展。但是检查带宽的情况有所不同,因为内存预取器在后台工作。无论使用了单插槽上多少核心,我们总是拥有相同数量的内存预取器流。因此内存带宽不会随着使用的核数的增加而线性扩大。或许,对每个核心独立地并行运行基准测试程序,并将平均值报告为单个核心可用的带宽更有意义。
我们选择实现自己的基准来检查峰值计算能力,以便更好地控制测试资源的使用(线程、插槽)。此外,我们希望我们的基准测试与编译器优化无关,但要达到最高性能。
我们的峰值性能检查例程由在每个可用的处理器线程上独立执行运行时生成的汇编代码组成。在实现基准测试时,编译器删除死(无用)代码通常是一个问题,但是当在运行时使用 Xbyak 生成基准测试代码时,我们不会遇到该问题,并且整体性能与编译器无关。
vfmadd132ps zmm0,zmm1,zmm2
vfmadd132ps zmm3,zmm1,zmm2
vfmadd132ps zmm4,zmm1,zmm2
vfmadd132ps zmm5,zmm1,zmm2
vfmadd132ps zmm6,zmm1,zmm2
vfmadd132ps zmm7,zmm1,zmm2
汇编代码是 Intel 高级向量扩展指令集(Advanced Vector Extensions: AVX, AVX2, AVX512)提供的 FMA 指令序列,旨在避免链式依赖(写后读),从而可以接近最高性能。使用此基准测试,我们测量了以下处理器场景的峰值计算能力:
测量峰值内存带宽很复杂,因为结果可能会因我们测量的操作而异[13]。为此,我们决定通过独立检查确定最大吞吐量值:
memset
函数memcpy
函数两种类型的基准测试都以单线程和多线程运行,并处理0.5GB 的内存。在针对单路和双路场景进行实验时,我们自己使用非暂时指令的实现是最快的方法。另一方面,memcpy
和memset
在单线程场景中报告了更高的内存吞吐量,我们将其归结为内存预取机制。
我们在单线程情况下的内存带宽测试中遇到了一个问题——如果我们更好地利用内存预取器来对内存带宽进行基准测试,我们可能会获得更高的带宽。这个问题存在于一些带有内存限制内核的 Roofline 图中。在单线程环境中执行的高度优化的内存瓶颈内核,其实际运行时间可能会过于接近(乃至超过)实际屋顶。对于基于单插槽或双插槽的执行,这不是一个重要因素,因为内存传输的最高值是使用流指令(非临时存储)获得的。
值得一提的是,在 Intel Xeon 处理器上运行带宽检查时,我们将单线程和单插槽实验的内存分配和线程绑定到一个选定的插槽。这是需要的,因为当使用全集线程时,一个插槽上没有足够的可用内存带宽。我们观察到线程和内存分配都在向第二个插槽迁移,以便利用该插槽的内存通道。这通常是高效的实践,但在我们的实验中这是不需要的行为,因为我们希望将执行限制在单个插槽上。
另一个重要因素是,为了在使用两个插槽(我们目标平台的所有可用插槽)时最大化吞吐量,我们通过并行运行基准测试程序的两个副本来检查内存带宽。一个运行基准测试的线程和内存分配绑定到一个节点,第二个基准测试绑定到第二个节点。报告两个吞吐量之和为平台内存吞吐量的峰值。我们采用这种方法的理由是,当线程分配在一个节点上而内存分配在另一个节点上时,访问内存所需的时间要比在同一节点上分配两种资源所需的时间要长。
计数 FLOPS 的方法与 Applying the roofline model 中所述类似。下面,perf 工具用于读取 PMU 计数器:
我们在外部使用 perf 来对 work 计数,这使得我们对每个评估的内核进行两次测量:
使用上述运行中的 PMU 计数器值,我们可以从整体测量中减去框架开销,以获得内核实际执行的计数器值。接下来,我们将计数器值相应地乘以8(对于 AVX2)和16(对于 AVX-512)以获得实际的 FLOPS。
在此过程中,我们曾担心是否精确地统计出 FMA 指令的 FLOPS,因为 Intel AVX2 的单个 FMA 指令实际上执行 16 FLOPS,而 Intel AVX-512 则执行 32 FLOPS。因此,我们实现了vfma132ps
FMA 指令和vfaddps
向量加法指令的汇编代码并观测了 PMU 计数器的值。我们发现 FMA 指令的单次写回使计数器加2,而常规向量指令使计数器加1。这证明 FLOPS 计算是精确的。 此外,我们还实现了一个更复杂的汇编代码——汇编实现的代码很容易计数执行的 FLOPS,并将其实际的 FLOPS 与基于 PMU 计数器方法得出的 FLOPS 进行了比较。两个结果都匹配,因此我们得出结论,这种计算 work 的方法是准确的。
确定内存流量 (Q) 是 Roofline 模型中最具挑战性的部分。与 Applying the roofline model 类似,我们首先计算从最后一级缓存到内存的内存传输。由于预取器机制,这种方法产生的值远低于预期。接下来,我们禁用了硬件内存预取器,如下所述。对于简单的评估内核——我们实现的归约求和内核,它提供了准确的结果,但对于更复杂的算法,例如在英特尔 oneDNN 库中实现的算法,结果仍然远低于预期。这是因为 Intel oneDNN 库在 GEMM 和 Winograd 实现中显式使用软件内存预取器指令,而 [13] 中描述的方法无法禁用这些指令。因此,我们最终以类似于 Applying the roofline model 中所描述的方法,通过 IMC(Internal memory controller,内部内存控制器)来检查原始内存传输。由于现代 Linux 分析器 perf 配备支持 IMC 的 PMU 计数器[3],我们不必自己添加 PMU 计数器。
由于 IMC 的 PMU 是对整个平台的内存传输进行计数,而不仅是执行被评估算法的 CPU 核,因此计数的流量不仅仅与测试算法的执行有关。可以从 perf 的命令行界面检查 IMC uncore 计数器,因此为了将流量的测量限制为我们评估的内核的执行,我们检查了 perf 工具的源代码,以获取 perf 与 Linux 内核通信的系统调用的参数值。有了这些知识,我们可以在代码中调用相同的系统调用。
对于大于1M 字节的已处理数据,此方法可提供令人满意的结果。本文中的分析仅限于处理较大数据(吞吐量)而不是单个数据块(延迟)的算法。
我们测量了进行多次执行的时间,并将其平均值报告为 runtime。我们感兴趣的是在三种使用情况下测量性能:
我们发现对于单路执行场景,需要使用 numactl 实用程序来控制线程和内存分配。事实证明,这是一个关键因素,因为当来自插槽的所有线程大量访问内存时,内存带宽就会不足。然后,操作系统可以将线程和分配迁移到另一个插槽,以使用另一个插槽的一些内存带宽。这与2.2节中描述的情况相同。没有此限制(例如,使用 NUMA 工具控制资源的放置)将导致运行时性能高于所分析内核算术强度的实际上限。
在测量内核的执行时间之前,我们决定为每次迭代清除缓存。据 Applying the roofline model 报告在数据大小较小时会使测量失效,但对于我们的实验,缓冲区大小相当大——基于深度学习工作负载中实际使用的大小,因此我们没有发现测量不稳定的问题。唯一的问题是,重写缓存是耗时的,这是我们实验的运行时间。
在进行实际测量之前,我们执行实际内核若干次以预热缓存,然后运行要测量的执行。现代体系结构内置了先进的内存预取机制,因此从这个角度来看,冷缓存和热缓存的差异可能并不总是明显的,特别是在一些使用软件预取指令的 oneDNN 内核中。
我们在本工作中运行分析的目标处理器是 Intel Xeon Gold 6248 CPU。该处理器有44个核心,均匀分布在两个插槽之间,属于 NUMA 架构,因此每个核心对同一内存位置的访问时间可能不同。我们针对三种情景进行了分析:
我们开始分析只使用单线程执行的卷积操作。这是一个适用于 PaddlePaddle 深度学习框架的用例,该框架针对单线程执行进行了优化。图 3 显示了屋顶线图——出于本文的目的,绝对基准值已转换为相对百分比度量。我们绘制了卷积运算的 Roofline 模型,使用固定大小的数据在三个子情况下处理(图 4 中从左到右的垂直虚线):
首先,我们在 Roofline 图上有三个不同的卷积核。除了计算能力(运行时计算)的相对利用率,我们还测量了相对执行时间(ET)。NCHW 卷积是最慢的,因此我们将其 ET 表示为 100 % 100\% 100%。 我们可以看到 NCHW16C 卷积内核的实现效率稍高一些,因为它利用了峰值计算的 86 % 86\% 86%,而 NCHW 卷积内核仅使用了 48 % 48\% 48% 的可用计算资源。这是相当直观的;我们比较了两种不同的实现,从概念上讲,同一种算法使用大致相同数量的 FLOPS 执行相同的数学运算。另一方面,Winograd 卷积是一种完全不同的算法,它使用根本上不同的计算方法产生相同的结果。因此,在实现完全不同的算法时,比较内核的意义非常有限。更多的是关于给定内核将如何更好地利用计算平台资源。我们可以看到 Winograd 卷积利用率要低得多( 31 % 31\% 31%),但它是三个中最快的一个。我们可以看到,Winograd 的实现还有改进的余地,因为它的运行时计算距离屋顶还很远。虽然 Winograd 是最快的,但它的应用仅限于特定大小的卷积核,因此直接卷积算法的应用范围更广。
接下来我们比较了直接卷积的两种实现: NCHW 与缓存和向量化友好的 NCHW16C。Intel oneDNN 库正在实施布局传播[4]的思想,将卷积模型输入从其原始数据排列转换为块数据排列(例如 NCHW8C 或 NCHW16C)。然后所有后续的深度学习操作(卷积、归一化、非线性)都在这个数据排列上工作。分块数据排列有助于保证向量指令(AVX、AVX2、AVX512等),使用的所有数据都来自同一个高速缓存块,从而减少内存延迟,并有助于实现更高的计算利用率。
可以看出,NCHW16C 数据排列的总计算利用率的百分比远高于 NCHW。大多数计算友好的场景,例如使用 NCHW16C 数据布局执行的卷积,在处理器上实现了超过 86.0 % 86.0\% 86.0% 的可用最大 FLOPS。如此高的计算利用率表明,进一步优化此实现(不进行概念性重新设计或更改卷积算法)将十分困难。如果存在一种算法,则将其更改为更高效的算法可能更容易。一种选择可能是用 Winograd 卷积(如果适用)替换直接卷积,如本节开头所述。
在图 4 中,与单核执行(上一节)相比可以看出各自的计算资源利用率略低:
我们将其归因于多线程处理以及内存预取器(缓存)限制。如果没有更深入的分析,除了实现一个高效的单线程内核比多线程内核更容易以外,很难得出不同的结论。
从提出的 Roofline 模型中得出的另一个观察结果是,当我们将评估卷积的执行从单个线程迁移到单路或双路执行时,我们可以看到效率较低的实现开始受到内存限制。对此的解释与算法无关,是 Roofline 模型的刚性点向右移动了。这是因为当使用所有可用硬件线程时,每个线程的可用内存带宽低于单线程执行时的可用内存带宽。
如前所述,Intel Xeon Gold 6248 具有 NUMA 架构。在本实验中我们对所有可用的计算和内存资源进行了分析,以检查利用率并将其与单路执行(3.1.2节)进行比较。
图 5 展示了我们使用评估处理器的全部能力的结果。我们可以看到,在缓存友好用例(NCHW16C)中,总计算利用率的百分比( 48 % 48\% 48%)相对于单路执行( 78 % 78\% 78%)相对较低,对于其他两个内核的执行也是如此。我们核实两种执行场景中执行了相同的实现,因此我们研究了 Intel oneDNN 的卷积执行从单路扩展到双路的效果。双路场景下计算资源利用率较低的原因是单内核执行难以利用 NUMA 架构的全部计算资源。
在本节中,我们将了解内积,它是神经网络的基础。特别是在诸如基于 transformer 的模型 [15]等现代自然语言处理 (NLP) 解决方案中,内积占用了大部分执行时间。内积处理的数据大小如图 6 所示,可装进所使用的处理器——Intel Xeon 6248的 L3缓存。因此,应该可以观察到使用冷缓存执行与使用热缓存执行之间的差异。
观察 Roofline 模型,我们可以得出结论,在热缓存的情况下,内存流量比缓存冷时要小得多。我们执行相同的代码,所以 work 是相同的,但在热缓存执行的情况下,算术强度要高得多,因此在这种情况下内存流量必定小得多。现代处理器使用内存预取器来读取数据,这使得预测内存流量变得困难[10]。
我们可以得出的另一个结论是,Intel oneDNN 库的内积针对特定的输入信号形状进行了优化,因为对于单线程执行,运行时效率达到可用峰值计算能力的 71 % 71\% 71% 以上。其他场景的 Roofline 图(例如,使用单路和双路执行)见附录。
我们尝试使用 Roofline 模型分析两种最流行的池化算法:
对于最大池化,本工作中使用的方法不适用于此操作,因为最大池化由数据移动和 max 操作组成,这些操作不被识别为 FLOPS,也未被相关的 FLOPS PMU 计数器跟踪。因此,将计数的 work 值将不具有代表性和有用性。在本文中,我们只给出平均池化的 Roofline 图。
图 7 显示在冷缓存情况下,NCHW 和分块布局数据排列(NCHW16C)的算术强度几乎相同。相同的观察结果适用于热缓存场景。这本身并不奇怪,但有趣的是,CPU 计算利用率的百分比存在巨大差异。使用 NCHW 数据排列的实现实现了 0.35 % 0.35\% 0.35% 的计算利用率,而 NCHW16C 实现利用率在 14.8 % 14.8\% 14.8% 左右,利用率提高了 42 倍以上。我们发现这很有趣,并寻求解释。
Intel oneDNN 库可以工作在verbose
模式,以提供内部执行的详细信息,如下所示:
根据这些输出,我们可以看到 NCHW 使用的是一个名为:simple_nchw
的平均池化实现,而分块数据排列正在使用 jit::avx512_common
实现。 前者是基于 C++ 的简单实现,后者是使用 Xbyak 项目实现的运行时生成的汇编代码。NCHW 池化需要在 simd 寄存器中进行操作(因为空间的步长为 1),而 NHWC 和 NCHW16C 池化可以直接在寄存器上进行操作。这是 NCHW 计算利用率低的主要原因。
我们分析的另一个 oneDNN 原语是最近引入的高斯误差线性单元[5](Gaussian Error Linear Units,GELU)激活。我们选择分析它的原因是 GELU 是一种元素操作,因此数据排列不应该对执行性能产生影响。此外,与卷积相比,激活的算术强度较小,因为它们受内存限制,我们想检查我们的工作是否也适用于受内存限制的原语。
图 8 显示了通过 Intel oneDNN 库执行的 GELU 操作的屋顶线模型。令我们惊讶的是,我们观察到使用块数据排列(NCHW16C)执行的算术强度低于 NCHW 实现。我们预计两种数据排列的性能相同。但是当查看 work 和 traffic 的实际值时,我们发现 NCHW16C 消耗的内存是 NCHW 实现的四倍,FLOPS 是 NCHW 实现的两倍。我们的屋顶线模型图没有显示 W 和 T 值,看到 NCHW16C 的操作强度较低,我们检查了收集的数据。
对消耗两倍资源的原因的解释是,输入信号的维度[256,3,227,227]的第二维(通道)等于3。oneDNN 提供的 NCHW16C、NCHW8C 的高效实现要求通道值是8的倍数。因此 oneDNN 当强制使用分块数据排列并因此将输入张量扩展为具有 [256,8,227,227]的形状。在这种情况下,使用 NCHW 数据排列的效率较低。
这是否意味着用户必须了解 oneDNN 内核的实现细节才能有效地使用它们?答案是否定的,因为 Intel oneDNN 库的使用模式是计算原语自行选择要使用的实现。出于分析的目的,我们选择了对分块处理不利的维度和强制 GELU 进行分块处理。换句话说,oneDNN 内部逻辑将触发高效的实现,在这种情况下,GELU 不会在分块布局上工作。如附录所示,GELU 屋顶线图更经常遇到的维度(对 oneDNN 更有利)证实了分块和 NCHW 数据排列的算术强度在所有效率方面都非常相似。
Work(W) 是通过 FLOPS PMU 事件测量的,该事件对减法、加法和乘法等浮点运算进行计数,但它不统计数据移动以及获取最大值操作(可以使用 AVX 指令集的vmaxps
指令实现)。这意味着分析深度学习算法时,使用 PMU 事件(如本文中使用的)测量 Work 不是合适如校正线性单元(Rectified Linear Units,ReLU)、最大池、重排序以及其他大多数工作是由计数 FLOPS 时未考虑的操作(如 min \min min、 max \max max 和数据移动)执行的原语。