面向英特尔® 至强融核™ 协处理器的优化与性能调优 – 第 1 部分优化要素

摘要

英特尔® 至强融核协处理器是英特尔处理器和平台家族中的新成员首先配置在英特尔® 集成众核英特尔® MIC架构中。首款英特尔® 至强融核协处理器具有多个宽矢量内核及或 SIMD 寄存器。运行在这些新处理器上的软件应该支持用户利用多个内核及宽 SIMD(矢量)运算。本文介绍了一种流程,开发人员可按照该流程对软件进行调优,使它在英特尔® 至强融核协处理器上运行更快。本文还提供了有关其它资源的链接,以便于您了解更深入的信息。

概述

英特尔® 至强融核协处理器可提供卓越的计算数值性能前提是对软件进行适当调优。它必须高度可扩展和矢量化,并高效利用内存。本文为开发人员介绍了针对英特尔至强融核协处理器调整和优化软件所需的基本流程。对英特尔® 至强融核协处理器系统影响最大的 3 个基本要素是:可扩展性、矢量化和内存使用情况

英特尔® 至强融核协处理器

下面所列内容重点介绍了首款英特尔至强融核产品家族前称是“Knights Corner”的重要特性

·         运行英特尔指令集架构通常称为 x86 英特尔架构指令集 50 多个内核

·         每个物理内核带有 4 条线程

·         用于 SIMD 运算(矢量运算 512 位寄存器

·         每个内核带有 512K 二级高速缓存

·         连接 50 多个内核的高速双向环

这些物理内核比英特尔® 至强® 处理器更为简单具有双重顺序执行管线不同于英特尔至强处理器上的乱序执行模式。每个物理内核的 4 条硬件线程可帮助屏蔽延迟对顺序指令执行应用可扩展性的影响,这点很重要,因为在 MIC 系统上,应用可能具有 200 多条活动线程。强大的计算能力源自宽 512 位寄存器。英特尔至强融核协处理器上的高性能代码将需要使用这些宽 SIMD 指令实现理想的性能水平。实现最佳性能的唯一前提是,所有的内核、线程与 SIMD 或矢量运算都得到高效使用。每个内核具有 512K 的组合式二级高速缓存大小,可提供超过 25MB 的二级高速缓存。与标准英特尔至强平台相比,双向环可提供更高的吞吐量。英特尔至强融核协处理器的主内存作为协处理器驻留在相同的物理卡片 (physical card) 上,完全独立,不会与协处理器主机系统上的内存同步。在该卡片上,英特尔至强融核协处理器运行 Linux* 操作系统。开发人员将会发现该操作系统和开发工具会成为 HPC 和企业客户的标准要件。有关英特尔至强融核协处理器的更多信息,您可访问: http://www.slideshare.net/IntelXeon/under-the-armor-of-knights-corner-intel-mic-architecture-at-hotchips-2012

可扩展性使用多核

阿姆达尔定律

本部分内容回顾了阿姆达尔定律与 Gustafson 推论及一些基本的可扩展性调优技巧。为了表述目的,本文将可扩展性视为一种减少运行时间(与处理器数量成反比)的能力。例如,可完美扩展的代码运行在 1000 个内核上的时间是运行在 1 个内核上的时间的 1/1000。现在就是这样完美。阿姆达尔定律的合理模式可表示为:

TP = TS( P/n + S) + OVH

其中

·         TP表示并行执行的时间

·         TS表示顺序执行的时间

·         P 表示并行化区域顺序运行的时间百分比

·         n 表示工作分布其间的处理器的数量假设为同构处理器

·         S 表示顺序区域顺序运行的时间百分比

·         OVH 表示设置并行任务方面的费用、同步成本或用于处理并行性的任何其它操作。

阿姆达尔定律非常适于设定期望值。让我们来看看 P 90%S 10% 的情况。即使无数的内核被用于这一固定问题大小,但最大的加速将不会超过 10 倍,因为 S 10%S 必须减小到 10% 以下才能获得超过 10 倍的加速(并拥有 超过 10 个处理器或内核)。开发人员需要考虑各种要求和所有的资源(处理器内核不是唯一重要的资源)。如果 S 10%,是否就意味开发人员不应考虑 10 个以上的内核?不是。TS 100 秒并要求在 13 秒内完成任务又怎样?31 个处理器可实现该性能目标。即使它不能如某些人所喜欢的那样高效使用所有的 31 个内核,它也可满足指定的性能要求,令人满意。多数英特尔® 至强融核协处理器开发人员需要在并行区域 P(及更小的 S)中开展大量工作。不同的开发项目、要求和目标使指定 S 的最小值变得没有必要。每个项目团队必须确定可扩展性的目标和必要的可扩展性水平,然后对可实现的合理任务做出粗略预测。许多充满挑战的高性能计算任务都试图解决单个系统上内存中的问题。系统或集群的总体资源需要解决这一问题,这包括处理器、内核、内存、互连和磁盘空间及时间。在这些情况中,TS 这一数字甚至无法合理计算出,因为整个问题无法连续解决。大量英特尔至强融核协处理器将成为大型高性能集群的一部分。

Gustafson 推论

这就引出了 Gustafson 推论。John Gustafson 曾指出并行解决的问题比顺序系统上需解决的问题更大。或者,当 n (内核或处理器的数量)增加,问题大小也会增加,而当问题大小增加,并行区域中的时间百分比 (P) 也会增加,所以扩展性也会增加。阿姆达尔定律的不足在于,在问题大小增加时它未考虑 P S 的变化。通常的目标是在顺序解决较小问题的时间内,并行解决较大问题。Gustafson 认为,当问题大小增加,并行完成的工作部分以快于顺序区域中所花时间的速度增加。因此当问题大小增加时,可扩展性及相对加速与效率会增强。如欲通过简单的实例来阐述这点,可考虑使用局部定支点 (partial pivoting) LU 矩阵因子分解。当矩阵大小 n 增加时,所需的内存数量会以近视 n2的速度增加,但所需的计算量则以 n3的速度增加,且并行计算可顺利进行。因此,LU 矩阵因子分解已成为最通用的超级计算机并行性能指标评测标准。所以不应忽略阿姆达尔定律,但需要注意,P 可能会随问题大小的增加而增加,而可扩展性也可能随之增加。

可扩展优化技巧:粒度、平衡、障碍和错误共享

粒度

如果内核数量增加时问题大小保持不变那么每个内核的工作量会减少越来越少我们将其称为 a “粒度。如果每个工作单元的费用保持不变,那么该费用将在较小工作单元的运行时间中占有更大比例。因此,粒度减小时,效率也会降低。在顺序系统上检查这点的一种方式是思考问题大小及将并行完成的工作量。想想更小的顺序问题,对于这个问题,顺序情况中完成的工作与每个内核针对计划的并行工作将要完成的工作量相匹配。使用阿姆达尔定律确定这些比率,并思考它是否将会扩展。

工作负载平衡

在英特尔 MIC 架构上进程很可能具有 200 多条活动线程。如果所有线程都在发布和执行 SIMD 指令,那么一切都会很高效。但是,如果一小组线程需要完成更多的计算且其它线程必须空闲等待这些少数线程赶上来,那么费用会增加、效率则会降低。换句话说,应用就无法通过扩展来使用可用的资源。任务分配不平衡会导致许多线程或进程处于闲置状态,从而降低系统性能。有些代码包含大量循环,及固定的迭代计数,每次迭代都执行完全相同的计算。这些示例(极易或不易并行)通常可实现出色的工作负载平衡,且 OpenMP* 结构可出色地处理它们。对于其它情况,英特尔® Cilk™ Plus 并行扩展或英特尔® 线程构建模块的工作窃取属性可能实现更出色的工作负载平衡。如果发现应用中的工作负载平衡较差,开发人员可能希望找到改进它的技术。如果您不熟悉英特尔 Cilk Plus 技术或英特尔线程构建模块,那么现在是了解它们的绝佳时机。请访问: www.threadingbuildingblocks.org http://software.intel.com/en-us/intel-cilk-plus-archive,了解更多信息。

障碍

任何叉、连接、互斥、锁或障碍都可能降低效率。最好是只选择必要的锁/障碍或控件,以确保代码中不会出现数据竞跑。不过,不要冒着导致数据竞跑的风险来消除障碍。在桌面系统上使用英特尔® Parallel Advisor XE 2013 等产品可帮助模式并行化顺序区域,并在提交给线程模式前识别共享数据。代码一旦被线程化,英特尔® Inspector XE 可帮助查找常见的数据竞跑与内存问题。请记住,在 16 条线程间共享 1 个障碍所需的时间比同步 200 多条线程的时间还要少。当您的应用通过扩展在数百条线程上运行,最小化或合并全局障碍和锁就尤为重要。有时可去除锁,具体做法是为每条线程提供重要数据的本地副本,并只在特定位置同步它,无需锁定每个全局地址参考。您可能需要重新考虑所用锁的类型。例如,如果许多条线程访问一些数据但很少修改它,您可能已发现当线程仅为 2-16 条时,通用互斥运行良好,但线程数多于 200 时可能就是另外一回事。在这种情况下,读取器-写入器锁可能比较适用。这将支持多条线程访问数据,前提是它们仅仅读取数据,很少进行更新或修改(写入器)。英特尔线程构建模块包括读取器-写入器锁和用户空间优化的障碍和控件。

系统调用是许多开发人员经常忽视的障碍。影响可扩展性的 2 种最常见的意外调用是 malloc gettimeofdaymalloc 调用会遇到它里面的锁,它可序列化调用者和执行。如果线程分配 1 个大型内存块,然后运行较长时间,那么该费用就没那么急需。使用更高效的内存分配器,顺畅运行多次调用 malloc 的应用。英特尔线程构建模块包括可为该目的而极致扩展的内存分配调用。其它可用的第三方内存分配库也可比 malloc 表现出色。在第二个示例中,当 200 多条线程同时调用 gettimeofday,它也可能像顺序区域一样运行。只有一条线程调用 gettimeofday使用本地计时器在线程内计时。可通过配置使 gettimeofday 调用恢复本地内核计数器或全局处理器计数器,该技巧假设它正利用全局处理器而非本地内核 tsc 计数器。考虑其它可能成为障碍的系统调用和库,使用替代选项或尽可能不使用它们。

错误共享

另一个需要考虑的事项是错误共享。它发生在 2 个不同的内核读取和写入同一高速缓存行中的相邻数据时。例如

1

float a[32];

2

#pragma omp parallel for num_threads(32)

3

for (int i = 0; i < 100000;  ++i)

4

    a[omp_get_thread_num()]+= 1.0;

以上示例假设阵列在高速缓存行边界开启及每条线程运行在不同的内核上这可使用编译器用于绑定的 OpenMP® API 扩展实现—— 请参阅编译器文档了解详情。然后,每个内核将访问不同的元素,因此并未真正共享。然而,由于高速缓存行包含 16 个四字节浮点,因此 16 个内核将访问 1 个高速缓存行,16 个内核将访问另 1 个高速缓存行,从而导致性能极差,因为当内核一个接一个试图通过写入更新数据元素时,这些高速缓存行会在不同内核的高速缓存间连续移动。通常,可通过填充高速缓存行边界或使用私有变量修复错误共享。

并行编程参考:

下列书籍是进一步了解并行编程的宝贵资源。

《英特尔多线程应用开发指南》 - http://software.intel.com/en-us/articles/intel-guide-for-developing-multithreaded-applications

《结构化并行编程高效计算模式》作者 Michael McCoolJames ReindersArch RobisoMorgan Kaufman 出版社 2012 年出版

《并发的艺术线程猿并行应用编写指南》作者 Clay BreshearsO’Reilly Media 出版社 2009 年出版

《英特尔线程构建模块教程》90 www.threadingbuildingblocks.org选择文档

《英特尔线程构建模块采用 C++ 实现多核处理器并行化》作者 James ReindersO'Reilly Media 2007 年出版。

矢量化使用 SIMD 寄存器和运算

阵列符号 (Array notation)

如上所述英特尔至强融核协处理器的性能值的一项关键要素是 512 位寄存器和相关的 SIMD 运算。本部分阐述了一些用于对软件进行调优以矢量化或有效利用宽 SIMD 运算的方法。

如欲对现有代码进行调优首先要做的一件事就是了解时间应花在何处。消耗最多计算周期的代码部分是热点,是调优的首要对象。英特尔® VTune™ Amplifier XE 可收集时钟滴答,并将它们映射回源代码,以显示什么地方的执行耗费了最多的 CPU 周期。聚焦于 VTune Amplifier XE 识别的热点。采取下列步骤确保这些区域的优化进展顺利。

利用 512 位宽 SIMD 指令的最佳方法是使用阵列符号样式如英特尔 Cilk™ Plus Fortran 90 中的样式进行写入。使用阵列符号后,该编译器将对 SIMD 指令集进行矢量化或利用。Fortran 早已采用阵列符号语法。我们鼓励 Fortran 开发人员熟悉 Fortran90 的这一特性。英特尔借助英特尔® Cilk™ Plus 推出了一种阵列语法。为了推广编写编译器可在不同指令集上矢量化的代码的方法,英特尔发布了 Cilk Plus 规范。gcc 4.8 编译器分支中具有英特尔 Cilk Plus 特性,包括阵列符号和线程。

在英特尔 Cilk Plus 中,若要引用一个阵列或一个阵列段,语法如下:[<lower bound> :<length> :<stride>]; 其中 lower bound 是运算所包含阵列的第一个元素,length 是运算所涉及阵列的元素数,stride 是所用的每个阵列元素之间的距离(通常是 1)。如果整个阵列进行了运算,则可能会省略这些值。因此,如果 A B 是两个长度为 n 的一维阵列,c 是一个标量,那么也可以使用以下命令:

1

A[:] = c * B[:] ; // for (i=0;i<n;i++) A[i] = c * B[i] ;

2

A[0:n:1] = c * B[0:n:1] ; // for (i=0;i<n;i++) A[i] = c * B[i] ;

阵列符号语法是确保编译器可以在英特尔 MIC 架构(或其它任何英特尔平台)上有效利用 SIMD 运算的首选方法。

初等函数

正如 Fortran 支持用户定义的初等函数一样,C/C++ 的英特尔 Cilk 并行扩展也支持用户定义的初等函数。初等函数是一个可以在标量或阵列元素上并行调用的正则函数。当开发人员声明了一个初等函数时,编译器将生成该函数的两个版本:一个是该名称的普通标量函数,另一个是从 for 循环或通过向量输入调用该函数时将被调用的数据并行函数。初等函数是通过在其声明中添加属性向量来定义的,如下例所示。MyVecMult 函数可以按照下例来编写:

1

__attribute__(vector (optional clauses))void MyVecMult(double *a, double *b, double *c)

2

{ c[0] = a[0] * b[0] ; return ;}

该函数可以这样调用

1

For (i=0;i<n;i++) MyvecMult(a[i],b[i],c[i]) ;

or

1

MyvecMult(a[:],b[:],c[:]);

另外可能还会添加其它向量子句,以指定向量长度和附加提示。使用用户定义的初等函数是开发人员用来表达运算的另一种技巧,可充分利用 SIMD 指令。用户定义的初等函数的作用有限(没有 switch 语句,没有 for 循环 goto. . .参见http://software.intel.com/sites/default/files/article/181418/whitepaperonelementalfunctions.pdf 了解更多详情)。英特尔编译器文档中也包含有关英特尔 Cilk Plus 阵列表示法的更多信息。

指令和编译指示

大部分软件不需要在阵列符号中重写便可从矢量化中受益。英特尔编译器会针对循环和结构对许多代码进行自动矢量化。然而,单纯依赖于自动矢量化往往是不够或不可取的。开发人员应该做好相关准备,帮助编译器生成有效的向量代码。在这些情况下,添加编译指示或指令可以为编译器提供对代码进行矢量化处理所需的足够信息。在更改代码前,使用英特尔 VTune Amplifier XE 来收集性能数据将精力主要放在英特尔 VTune Amplifier XE 所确定的热点上。此外还需要在编译过程中打开向量报告(我通常使用 –vecreport3)。在编译器报告中找到带有已确定热点的源文件,并确认这些热点是否正在进行矢量化处理。如果没有进行矢量化处理,考虑通过阵列符号进行重写,或添加编译指示或指令,以帮助编译器高效地执行矢量化。

第一步是确保编译器对已确定的所有热点进行了矢量化处理(如可能)。添加到 for-loop 上的最简单的编译指示就是 #pragma ivdep or cDIR$ IVDEP(注意,此处的“c”代表固定和选项卡源形式的 Fortran 注释字符;否则自由格式的语法将使用 “!”..这会让编译器忽略潜在或假设的指针依赖关系(在严格意义上为忽略向量的依赖关系)。仅在您知道指针可以始终取消引用独立存储区的情况下使用。一种常用的编译指示/指令是 #pragma simd cDIR$ SIMD。这种编译指示/指令会让编译器忽略所有冲突并设法通过 SIMD 运算生成代码(如果有可能)。由于开发人员让编译器忽略掉其无法消除歧义的冲突,因此开发人员应该在使用这一指令或编译指示之前清楚地了解代码中的潜在依赖关系。

高效的矢量化也很重要。为编译器提供更多信息可以帮助其生成质量更高的矢量化代码。因此在确认编译器报告了某个代码段正在进行矢量化处理后,还要确认编译器能否高效地报告。您需要确认代码没有使用拆分加载或存储且消除了集中/分散运算(除非您处理的代码需要以集中-分散的方式储存数据许多稀疏矩阵代码都是这样的)。向量校准很重要。声明阵列时,确保这些阵列已在 64 位地址上对齐,例如:__declspec(align(64)) float A[1000]。在 Fortran 中,使用 cDIR$ ATTRIBUTE ALIGN 指令。对于 C 语言的动态内存分配,您可以使用 _aligned_malloc()。在您将指针传递到函数或例程中时,您可以在循环之前使用 #pragma vector aligned,以便让编译器知道循环中的所有指针都是对齐的。如果一个例程中的所有循环都是这样,那么您可以在指针中使用 call __assume_aligned(),这样编译器就知道指针在例程的所有循环中都是对齐的,而不必在每个循环之前插入汇编指示。Fortran 语言中有 CDIR$ ASSUME_ALIGNED 指令。请参见编译器文档了解这些命令的使用方法。

当两个嵌套循环有短时循环计数 (short loop count) 时,如果两个嵌套循环合并为一个 for 循环,编译器通常可以表现的更好。有些开发人员自己进行这一工作,这完全没有必要,因为利用指令/编译指示同样可以完成。pragma #pragma nounroll_and_jam cDIR$ NOUNROLL_AND_JAM 或类似指令 unroll_and_jam/ UNROLL_AND_JAM 可将两个循环合并为一个循环;其中一个展开循环,另一个则不展开。

内存和高速缓存

寻址和预取

在内存中以连续地址顺序访问数据时,代码可实现最佳运行。通常,开发人员会更改数据结构,以支持这一线性访问模式。一般是从结构阵列变为阵列结构(AoS SoA)。

对数据的访问始终很重要,而预取可以最大限度减少等待采集计算所需数据的延迟。英特尔编译器会在其进行矢量化处理的循环中自动预取数据。如果您的循环支持可计算内存访问模式但编译时不是很清楚,那么通过在该模式中指定预取,您的代码将可以受益。这项工作可以通过编译指示/指令或通过内在命令进行。记住,如果开发人员在一个 for 循环中插入预取指令,那么预取通常应该是针对该循环在将来的迭代,而不是现在的迭代。开发人员可参阅英特尔编译器文档,以了解有关实施的详细信息。

阻止或平铺 (tiling)

当仍在处理器寄存器或处理器高速缓存中的数据得到复用时,代码可以运行的更快。一般情况下,通过阻止或平铺运算来确保数据在脱离高速缓存前被复用是可行的。下面两个使用阵列符号的示例说明了这一点。

示例 1 –  没有为 large N 的数据复用进行阻止

1

A [0:N]=B[0:N]+C[0:N];

2

D [0:N]=E[0:N]+A[0:N];

示例 2  – 为数据复用阻止或平铺了对向量 A 的访问:

1

#define VLEN 4

2

for(i=0;i<N;i+=VLEN){

3

    A[i:VLEN]=  B[i:VLEN]+C[i:VLEN];

4

    D[i:VLEN]=  E[i:VLEN]+A[i:VLEN];

5

}

少数应用可在英特尔至强融核协处理器上实现大于 n 的加速因子。通常这些应用都是能力有限的并行应用(也称为易并行应用),很少同步,并且其数据集可融入到英特尔至强融核协处理器上的高速缓存中。此类工作负载可能不适合高速缓存,比如说代号为 Sandy Bridge 系统的英特尔微架构,因此在主内存中进行数据检索时会出现更多的延迟。这些应用并不常见,但开发人员却应该从中享受到乐趣。即使数据不能始终完全保留在高速缓存中,为数据复用平铺或阻止运算也可以让您受益。

有些应用可从较大的页面中受益。我们鼓励开发人员参考基于事件的优化指南来收集数据,以确定一款应用何时可以从较大的页面中受益。

带宽

带宽受限的应用在英特尔至强融核协处理器系统上运行的更快。在这种情况下,加速与额外内核的数量不存在比例关系,而与英特尔至强融核协处理器的总可用带宽(超过了当前类别的英特尔至强处理器的带宽,该处理器基于代号为 Sandy Bridge 的微架构)的关系更为密切。良好的内存访问模式(单位步长 1)和预取有助于最大限度降低内存带宽。

总结

英特尔至强融核协处理器引入了一个更广泛的 SIMD 寄存器、相关 SIMD 向量运算和 50 多个内核。开发人员将发现,为这个全新的协处理器调整其软件是有价值的。调整后的代码将可以在至强主机平台上保持良好运行,并允许开发人员保持单一的代码库。文本中所列的步骤是一个入门级的软件调整方法。想要进行更深入的优化和更深入了解性能的开发人员可以阅读第二部分:英特尔® 至强融核平台的优化和性能调整:了解和使用硬件事件。第二部分采用了一种基于事件的调整方法,更详细地介绍了重要的调整步骤。

Einzelheiten zur Compiler-Optimierung finden Sie in unserem Optimierungshinweis.