65.9K
CodeProject 正在变化。 阅读更多。
Home

使用最新的 SIMD 扩展和 Intel® Advanced Vector Extensions 512 进行成功调优

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2020年4月30日

CPOL

24分钟阅读

viewsIcon

5329

利用最新架构特性的最佳实践。

英特尔® 高级矢量扩展 512 (Intel® AVX-512) 是最新的 x86 矢量指令集,它拥有多达两个融合乘加单元及其他优化。它能够加速以下工作负载的性能:

  • 科学模拟
  • 金融分析
  • 人工智能
  • 3D 建模和模拟
  • 图像和音频/视频处理
  • 密码学
  • 数据压缩/解压缩

在本文中,我们将简要概述 Intel AVX-512 指令集架构 (ISA),并描述 Intel 18.0 编译器针对Intel® Xeon® 可扩展处理器的新特性。接下来,我们将介绍英特尔最新编译器中针对 Intel AVX-512 支持的几种新的 simd 语言扩展。最后,我们将分享我们实现 Intel AVX-512 ISA 最佳性能的性能优化最佳实践。

新特性

Intel Xeon 可扩展处理器引入了 Intel AVX-512 指令支持的几种新变体。与上一代 Intel AVX2 相比,基于 Intel Xeon 可扩展处理器的服务器上,主要的 Intel AVX-512 ISA 性能特性包括:

  • Intel AVX-512 基础
    • 512 位矢量宽度
    • 32 个 512 位长矢量寄存器
    • 数据扩展和压缩指令
    • 三元逻辑指令
    • 八个新的 64 位长掩码寄存器
    • 两个源交叉通道置换指令
    • 散射指令
    • 嵌入式广播/舍入
    • 超越函数支持
  • Intel AVX-512 双字和四字指令 (DQ):QWORD 支持
  • Intel AVX-512 字节和字指令 (BW):字节和字支持
  • Intel AVX-512 矢量长度扩展 (VL):矢量长度正交性
  • Intel AVX-512 冲突检测指令 (CDI):Vconflict 指令

这些不同的 AVX-512 特性旨在由硬件直接支持并启用。在本文中,我们将重点关注 simd 语言扩展、编译器支持以及针对 Intel Xeon 可扩展处理器中的 AVX-512-F、AVX-512-BW、AVX-512-CD、AVX-512-DQ 和 AVX-512-VL 特性的优化。

编译器和程序员面临的优化挑战

图 1 显示了一个理论应用程序的预期加速,其中代码的很大一部分可以实现完美的矢量加速。在 XMM 寄存器中计算的 32 位整数/浮点数等四向矢量时代,应用程序的 80% 矢量化可实现 2.5 倍的加速。编译器和程序员为了将 90% 或 95% 的代码矢量化而更努力地工作所带来的增量效益是有限的。

图 1 — 理论应用程序的理想矢量加速

然而,增加到 8 路,特别是 16 路矢量,可以显著提升高度矢量化应用程序的性能。图 1 清楚地表明,当编译器和程序员从应用程序中挤出更多矢量并行性以享受 Intel Xeon 可扩展处理器中 YMM 和 ZMM 矢量的好处时,潜在的性能提升。除了加宽矢量寄存器外,AVX-512 ISA 扩展还附带了各种架构增强功能,有助于矢量化更多类型的代码模式。

AVX-512F、AVX-512VL 和 AVX-512BW

AVX-512F、AVX-512VL 和 AVX-512BW 包括 AVX 和 AVX2 的自然扩展,支持 32 个寄存器和掩码。它们共同涵盖了 Intel AVX-512 扩展家族的最大部分。AVX-512F 将矢量寄存器大小扩展到 512 位,用于 32 位和 64 位整数和浮点元素数据。它还

  • 架构矢量寄存器的数量从 16 个增加到 32 个
  • 引入 专用掩码寄存器
  • 非加载/存储操作添加掩码支持

AVX-512VL 扩展了 AVX-512F,使得对 32 个寄存器和掩码的支持可以应用于 128 位和 256 位矢量。Intel AVX-512BW 扩展了 Intel AVX-512F,使得 32 个寄存器和掩码可以应用于 8 位和 16 位整数元素数据。

表 1 使用矢量加法指令来说明元素数据类型 (b/w/d/q/ps/pd)、指令特性 (xmm/ymm/zmm、带掩码或无掩码以及寄存器编号 0-15/16-31) 之间的关系,

表 1. 元素数据类型与指令特性(xmm/ymm/zmm、带掩码/无掩码等)之间的关系

以及 Intel64 平台上所需的最低 ISA 扩展。例如,要在 XMM16 寄存器上添加字节向量 (vpaddb),无论操作是否使用掩码,都需要支持 AVX-512VL 和 AVX-512BW。通过提供对 AVX-512F、AVX-512VL 和 AVX-512BW 的全面支持,Intel Xeon 可扩展处理器允许程序员在大多数操作中利用所有 32 个可用寄存器和所有寄存器大小(XMM、YMM 或 ZMM)和所有数据元素类型(字节、字、双字、四字、浮点和双精度)的掩码功能。

除了 AVX 和 AVX2 的自然扩展,AVX-512F 还包括

  • 数据压缩(vcompress)操作,它根据掩码寄存器 1 的位指定的索引从输入缓冲区读取元素。然后,读取的元素被写入目标缓冲区。如果元素数量小于目标寄存器大小,则其余空间填充为零。
  • 数据扩展(vexpand)操作,它从源数组(寄存器)读取元素,并根据掩码寄存器中启用的位指示的位置将它们放入目标寄存器。如果启用的位数小于目标寄存器大小,则忽略多余的值。

AVX-512CDI

Intel AVX-512CDI 是一组指令,它与 Intel AVX-512F 一起,能够高效地对可能通过内存存在矢量依赖(即冲突)的循环进行矢量化。最重要的指令是 VPCONFLICT,它执行单个矢量寄存器内元素的横向比较。具体来说,VPCONFLICT 将矢量寄存器中的每个元素与该寄存器中的所有先前元素进行比较,然后输出所有比较结果。Intel AVX-512CDI 中的其他指令允许高效地操纵比较结果。我们可以以不同的方式使用 VPCONFLICT 来帮助我们矢量化循环。最简单的方法是检查给定 simd 寄存器中是否存在重复索引。如果没有,我们可以安全地使用 simd 指令同时计算所有元素。如果存在,我们可以为该组元素执行标量循环。如果重复项非常罕见,那么在任何重复索引处分支到循环的标量版本可能效果很好。然而,如果给定矢量化循环的迭代中出现一个重复项的可能性足够大,那么我们将更倾向于尽可能多地使用 simd,以尽可能多地利用并行性。

编译器 18.0 在 AVX-512 优化方面的新功能

为了在 Intel Xeon 可扩展处理器上实现最佳性能,应用程序应使用处理器特定选项 –xCORE-AVX512(Windows* 上为 /QxCORE-AVX512)进行编译。此类可执行文件将无法在非 Intel® 处理器或仅支持旧指令集的 Intel 处理器上运行。Intel® 编译器 18.0 提供了一个新选项
–qopt-zmm-usage=[high|low] 供用户调优 ZMM 代码生成

  • 选项值 low 为 Skylake (SKX) 目标(例如企业应用程序)提供了从 AVX2 ISA 到 AVX-512 ISA 的平滑过渡体验。鼓励程序员通过显式矢量语法(如 #pragma omp simd simdlen())来调整 ZMM 指令的使用。
  • 选项值 high 建议用于受矢量计算限制的应用程序(例如 HPC 应用程序),以便通过更宽的矢量在每个指令中实现更多的计算。

对于 SKX 系列编译目标(例如 -xCORE-AVX512),默认值为 low。英特尔编译器还提供了一种通过使用 –axtarget 选项生成支持多个指令集的胖二进制文件的方法。例如,如果应用程序使用 –axCORE-AVX512,CORE-AVX2 进行编译,编译器可以为 AVX-512 和 AVX2 目标生成专门的代码,同时还会生成一个默认代码路径,该路径可以在支持至少 SSE2 的任何英特尔或兼容的非英特尔处理器上运行。在运行时,应用程序会自动检测它是否正在英特尔处理器上运行。如果是,它会为英特尔处理器选择最合适的代码路径;如果不是,则选择默认代码路径。

需要注意的是,无论使用何种选项,编译器都可能插入对专门库例程(例如优化版 memset/memcpy)的调用,这些例程将根据处理器检测在运行时调度到适当的代码路径。

AVX-512 的新 SIMD 扩展

英特尔编译器率先实现了 C/C++ 和 Fortran* 应用程序的显式向量化,并引领了 OpenMP* 4.0 和 4.53,4,5,7,8 的标准化工作。以下是我们持续创新以扩展显式向量化能力的结果。我们建议将它们纳入未来的 OpenMP 规范,并已在英特尔编译器 18.0 中实现,作为 OpenMP 标准化的主要载体3,8

压缩与展开

图 2 展示了压缩和展开习语。压缩和展开术语源自代码语义。例如,压缩模式会压缩数组 A[] 中的所有正元素,并将它们连续存储在数组 B[] 中。已知这两种模式都包含循环携带依赖(图 34),因此无法并行化。

// A is compressed into B
int count = 0;
for (int i = 0; i < n; ++i) {
    if (A[i] > 0) {
        B[count] = A[i];
        ++count;
    }
}
// A is expanded into B
int count = 0;
for (int i = 0; i < n; ++i) {
    if (A[i] > 0) {
        B[i] = A[count];
        ++count;
    }
}
图 2 — 压缩和展开习语

图 3 — 压缩和展开模式的数据依赖图

图 4 — 压缩和展开执行

然而,通过对依赖项进行特殊处理,对压缩和展开习语进行矢量化是可行的。AVX-512 ISA 添加了 v[p]compressv[p]expand 指令,有助于有效地对这些代码进行矢量化。图 5 展示了使用新的 AVX-512 指令 vcompressps 对压缩习语生成的 simd 代码。

..B1.14:
vmovups (%rdi,%r11,4), %zmm2
vcmpps $6, %zmm0, %zmm2, %k1
kmovw %k1, %edx
testl %edx, %edx
je ..B1.16
..B1.15:
popcnt %edx, %edx
movl $65535, %r10d
vcompressps %zmm2, %zmm1{%k1}
movslq %ebx, %rbx
shlx %edx, %r10d, %r12d
notl %r12d
kmovw %r12d, %k1
vmovups %zmm1, (%rsi,%rbx,4){%k1}
addl %edx, %ebx
..B1.16:
addq $16, %r11
cmpq %r9, %r11
jb ..B1.14
图 5 — AVX-512 的压缩习语向量化

对于简单情况,编译器已实现压缩和展开习语的自动习语识别和向量化,但不保证复杂情况。为了为这些习语提供向量化保证,我们提议并实现了对 OpenMP 4.5 simd 规范的简单语言扩展,以表达压缩和展开习语。图 6 展示了添加到 OpenMP ordered simd 构造中的新子句 monotonic

int j = 0;
#pragma omp simd
for (int i = 0; i < n; i++) { if (A[i]>0) {
#pragma omp ordered simd monotonic(j:1)
        {
            B[j++] = A[i];
        }
}
    }
int j = 0;
#pragma omp simd
for (int i = 0; i < n; i++) {
    if (A[i]>0) {
#pragma omp ordered simd monotonic(j:1)
        {
            A[i] = B[j++];
        }
}
    }
图 6 基于块的压缩和展开模式语法

monotonic 子句及其相关的 ordered simd 代码块施加以下语义和规则

  • 当一个矢量迭代到达结构化代码块时,它会评估步骤。 这必须产生一个相对于 simd 循环不变的整数或指针值。同时计算执行结构化代码块的 simd 执行掩码。为结构化代码块创建每个单调列表项的私有副本,并初始化为 item, item + step, item + 2 * step, ...,用于每个执行(mask==T)的 simd 元素(从较低迭代索引到较高迭代索引)。在结构化代码块执行结束时,项的统一副本更新为 item + popcount(mask) * step,并且列表项的私有副本变得未定义。
  • 不允许在关联的有序 simd 构造外部使用单调列表项(图 7)。也不允许在多个有序 simd 构造中多次使用相同的列表项。
int j = 0;
#pragma omp simd
for (int i = 0; i < n; i++) {
    if (A[i]>0) {
       #pragma omp ordered simd monotonic(j:1)
       {
            ++j;
       }
       B[j] = A[i];
    }
}
图 7 — 在块外部不允许使用单调项
  • 带有单调子句的有序 simd 构造可以实现为没有单调子句的有序 simd 构造。

直方图

直方图(图 8)是一种众所周知的习语。直方图代码的语义是计算数组 B[] 中相等元素的数量,并将结果存储在数组 A[] 中。显然,根据数组 B[] 的内容,这个循环可能存在也可能不存在循环携带依赖。

for (int i = 0; i < n; ++i) { ++A[B[i]]; }
图 8 — 一个简单的直方图习语

本质上,有三种情况

  1. 如果数组 B[ ] 具有唯一值,则没有循环携带依赖。
  2. 如果数组 B[ ] 的所有值都相等,则此语句表示标量值的归约。
  3. 如果数组 B[ ] 中某些值重复,则此循环具有循环携带依赖。

在编译时,如果编译器无法证明 B[] 的值是否可能引入数据依赖,则编译器会悲观地假设存在循环携带依赖。对于情况 1,程序员可以通过使用 #pragma ivpdep 来帮助编译器。对于情况 2,可能要求程序员进行手动转换。对于情况 3,根据目标架构,可以通过以下方式使用显式向量注解来实现向量化:

  • 数组归约实现(使用 simd 构造和数组 A[] 上的归约)
  • 串行实现(对自更新语句使用有序 simd 构造)
  • 收集-更新-散射实现,并对相等的 B[] 值重复

对于收集-更新-散射实现,AVX-512 提供了 AVX-512CDI,其中包含 v[p] conflict 指令,该指令允许高效地计算索引向量内的重叠索引。为了有效地利用 AVX-512CDI 并确保程序员处理直方图习语,英特尔编译器中提议并实现了一个新的 overlap 子句,如图 9 所示。

#pragma omp simd
for (int i = 0; i < n; i++) {
   #pragma omp ordered simd overlap(B[i])
   {
       A[B[i]]++;
   }
}
图 9 — 直方图习语中 overlap 子句的使用示例

这个新子句具有以下语言语义:

  • 当执行到达有序 simd 构造时,它会评估重叠表达式 B[i] 的值作为右值,该值必须产生一个整数或指针值。实现必须确保如果封闭 simd 循环的两次迭代计算相同的值,则具有较低逻辑迭代次数的迭代必须在其他迭代开始执行相同代码块之前完成有序 simd 代码块的执行。
  • 编译器不保证总顺序。程序员有责任检查部分顺序是否足以处理对 A[] 的存储与对 A[] 和 B[] 的任何加载之间的任何可能别名。
  • 有序 simd 块指定了应检查重叠的区域。因此,对于有序 simd 重叠块之外的语句,将不执行重叠检查。
  • 带 overlap 子句的有序 simd 构造可以实现为不带 overlap 子句的有序 simd 构造。

条件 lastprivate

许多程序中常见的一种情况是,循环会产生其最后一次迭代的值,其中赋值语句实际执行(即对标量进行写入)。该标量的值将在循环之后使用(图 10)。

int t = 0;
#pragma omp simd lastprivate(t)
for (int i = 0; i < n; i++) {
    t = A[i];
}
int t = 0;
for (int i = 0; i < n; i++) {
    if (A[i] > 0) {
        t = A[i];
    }
}
图 10 — 左侧代码获取 A[i] 的最后一个元素,而右侧代码获取 A[i] 的最后一个正元素。

图 10 中的两个代码片段都可以矢量化,但只有左侧的代码通过 lastprivate 子句在 OpenMP 中得到了显式支持。由于其语义,lastprivate 子句不能用于右侧的代码。标量 t 从循环的最后一次执行迭代中获取值;因此,如果条件 A[n-1]>0 不为真,则 t 是未定义的。为了解决这个问题,提出并添加了一个新的修饰符 conditionallastprivate 子句中(图 11)。

int t = 0;
#pragma omp simd lastprivate(conditional:t)
for (int i = 0; i < n; i++) {
    if (A[i] > 0) { t = A[i]; }
}
图 11 — 支持条件赋值标量的 lastprivate 示例

以下是新 conditional 修饰符的语言语义:

  • 出现在条件 lastprivate 子句中的列表项受 private 子句语义的约束。在 simd 构造结束时,原始列表项获得的值就好像词法上最后一次条件赋值发生在循环的标量执行期间。
  • 如果项在定义之前被使用,或者超出了定义的范围,则不应使用该项。

带提前退出的循环

OpenMP 4.5 中现有的 simd 向量化不支持具有多个出口的循环。例如,一种常见的用法,例如在数组中查找某些元素的索引(图 12),无法使用现有的 simd 构造进行显式正确的向量化。

for (int i = 0; i < n; ++i) {
    if (A[i] == B[i]) {
        j = i; break;
    }
}
// use of j.
int j = 0;
#pragma omp simd early_exit lastprivate(conditional:j)
for (int i = 0; i < n; i++) {
     if (A[i] == B[i]) { j = i; break; }
}
// use of j.
图 12 — 一个有两个出口的循环,用于搜索数组 A[] 和 B[] 的第一个等效元素。

为了解决这个问题,英特尔编译器引入了一个新的循环级子句 early_exit,如图 12 右侧框中所示,其语义如下:

  • 循环的最后一个词法提前退出之前的每个操作都可以执行,就好像在 simd 块中没有触发提前退出一样。
  • 在循环的最后一个词法提前退出之后,所有操作都执行,就好像找到了循环的最后一次迭代一样。
  • linearconditional lastprivate 的最后一个值相对于标量执行被保留。
  • 归约的最终值计算为,就好像在退出循环时执行了最后一个 simd 块中的最后一次迭代一样。
  • 共享内存状态可能无法相对于标量执行得到保留。
  • 不允许异常。
  • 如果最内层循环具有跳出 simd 构造的出口,则最内层循环的执行类似于已完全展开。

性能结果

表 2表 4 提供了前面讨论的 monotonicoverlapearly_exit 扩展的微内核程序的性能结果。性能测量是在一台配备预生产 Intel Xeon 可扩展处理器(运行频率为 2.1GHz)的 Intel® 系统上进行的,采用 2 插槽配置,配有 24 个 2666MHz DIMM。特定配置下的实际性能可能有所不同。循环是前面章节中所示的示例。循环迭代次数 n 为 109,数据类型均为 32 位整数,矢量长度为 16,编译命令行是 icc –xCORE-AVX512。基线是相同循环集的标量执行。

表 2 显示了矢量化压缩和展开习语的标准化加速比。在“全部假”列中,压缩/展开条件都为假。压缩存储或展开加载完全跳过。条件的矢量评估以及矢量化带来的迭代次数减少,仍然导致 1.17 倍至 1.19 倍的加速比。对于“全部真”列,压缩/展开条件为真;因此,所有 16 个元素都被存储/加载。

表 2 — 压缩/展开与标量执行的标准化加速比。每列的真实条件数量不同。

在中间列中,16 路向量中的 2、4、8 或 15 个元素被压缩存储或展开加载。此表表明,无论实际压缩/展开的元素数量如何,这些惯用模式的向量化相对于标量执行都是有利的。我们使用对齐和未对齐的压缩到/展开自数组运行了相同的实验。正如我们所预期的那样,对齐和未对齐情况之间的趋势非常相似,除了“全部为真”的极端情况,其中对齐情况具有更好的缓存行访问特性。

表 3 显示了矢量化直方图示例的标准化加速比。左侧是没有重叠的情况,运行速度比标量执行慢 23%。考虑寻找替代方案,程序员可以断言没有重叠,从而避免使用重叠扩展。有时,选择较短的矢量长度可能会实现这一点。如果矢量执行需要冲突解决才有意义,则循环的其他部分需要高度可向量化才能获得收益。值得注意的是,八次重复和完全重叠(16 次重复)的情况看起来比重复次数较少的情况更有利。这可能是由于矢量执行中基于寄存器的重复赶上了标量执行上内存依赖的速度。有关冲突性能的更多信息,请参阅本文末尾的参考文献 9。

表 3 直方图相对于标量执行的标准化加速。每列在一个矢量迭代内有不同数量的冲突。

表 4 显示了向量化搜索循环示例的标准化加速。从左到右,找到匹配项的循环索引值从 0 增加到 5,000。需要进行几次向量迭代才能从搜索循环的向量化中获得明显的加速。

表 4 — 搜索相对于标量执行的标准化加速

需要注意的是,我们讨论的显式向量化技术的标准化仍在进行中。未来的实现中,语法、语义和性能特征可能会发生变化。某些功能可能仍作为英特尔特定的扩展。

AVX-512 优化的最佳实践

设置基准

所有优化工作都应从设置适当的性能基线开始。Intel Xeon 可扩展处理器的优化也不例外。假设程序员已经使用 Intel 编译器 17.0 的 –xCORE-AVX2 标志对应用程序进行了合理的优化,以适应 Intel Xeon 处理器。我们建议使用 Intel 编译器 18.0 从以下三个二进制文件开始:

  1. 使用 –xCORE-AVX2(Windows 为 /QxCORE-AVX2)构建二进制文件
  2. 通过将 –xCORE-AVX2 替换为 –xCORE-AVX512(Windows 为 /QxCORE-AVX512)来构建二进制文件
  3. 通过将 –xCORE-AVX2 替换为 –xCORE-AVX512 –qopt-zmm-usage=high(Windows 为 /QxCORE-AVX512 /Qopt-zmm-usage=high)来构建二进制文件

尽管我们预计二进制文件 B 和/或二进制文件 C 的总体性能与二进制文件 A 持平或更好,但最好还是仔细检查一下。

阅读热点优化报告

当编译时使用 –qopt-report(Windows 为 /Qopt-report),英特尔编译器会生成优化报告。在尝试更改编译器的行为之前,最好先了解编译器已经做了什么以及它已经知道了什么。本文末尾的参考文献部分中的文章 1 是一个很好的入门指南,
介绍如何使用优化报告。[编者注 使用 AVX-512 改进性能的向量化机会 也对编译器报告进行了很好的、更最新的概述。]

比较热点性能

对不同编译标志组合的响应可能因热点而异。如果一个热点在二进制文件 B 下运行很快,而另一个热点在二进制文件 C 下运行很快,那么了解编译器在这些情况下做了什么将极大地帮助您在两者之间取得最佳效果。

ZMM 使用的微调

对于二进制文件 B 和二进制文件 C,使用 OpenMP simd 构造,可以通过 simdlen 子句显式控制向量长度。尝试在几个热点上使用更大或更小的向量长度,以查看应用程序性能如何响应。您可能需要结合使用一种或多种调优技术。

对齐

当您使用 Intel AVX-512 矢量加载/存储指令时,建议将数据对齐到 64 字节以获得最佳性能6,因为每次加载/存储都是一个缓存行拆分,只要执行 64 字节的 Intel AVX-512 未对齐加载/存储,给定缓存行是 64 字节。这比使用 32 字节寄存器的 Intel® AVX2 代码的缓存行拆分率高出两倍。内存密集型代码中高缓存行拆分率可能会导致 20% 到 30% 的性能下降。考虑以下结构体:

struct RGB_SOA {
__declspec(align(64)) float Red[16];
__declspec(align(64)) float Green[16];
__declspec(align(64)) float Blue[16];
}

如果按如下方式使用此结构体,则为该结构体分配的内存将对齐到 64 字节:

RGB_SOA rgb;

然而,如果使用如下动态内存分配,则 __declspec 注释会被忽略,并且不能保证 64 字节的内存对齐。

RGB_SOA* rgbPtr = new RGB_SOA();

在这种情况下,您应该使用动态对齐内存分配和/或重新定义运算符 new。对于 AVX-512,尽可能使用以下方法将数据对齐到 64 字节:

  • 使用 Intel® 编译器的 _mm_malloc 内联函数,或 Microsoft* 编译器的 _aligned_malloc 进行动态数据对齐——例如: DataBuf = (float *)_mm_malloc (1024 * 1024 * sizeof(float), 64);
  • 使用 __declspec(align(64)) 进行静态数据对齐——例如:__declspec(align(64)) float DataBuf[1024*1024];

数据分配和使用可能发生在不同的子例程/文件中。因此,在数据使用站点工作的编译器通常不知道在分配站点已经进行了对齐优化。__assume_aligned()/ASSUME_ALIGNED 是一种常见的断言,用于指示数据使用站点的对齐情况。(有关详细信息,请参阅本文参考文献部分中的文章 2 和适用的 Intel 编译器开发人员指南和参考手册。)

剥离还是不剥离?

向量化器通常会生成相同循环的三个版本:

  1. 一个处理循环的开头(称为剥离循环)
  2. 一个用于主向量循环
  3. 一个处理循环的末尾(称为剩余循环)

剥离循环是为了对齐优化而生成的,但这种优化是一把双刃剑。如果循环迭代次数较少,它可能会减少主向量循环处理的数据元素数量。如果编译器选择错误的数组进行剥离,它会恶化循环执行的数据对齐。编译器标志 –qopt-dynamic-align=F(Windows 上为 /Qopt-dynamic-align=F)可用于抑制循环剥离优化。vector unaligned pragma 可用于每个循环。如果数据在进入循环时都已对齐,则 vector aligned pragma 甚至更好。

向量化剥离/剩余循环和短行程计数循环

AVX-512 掩码允许创建向量化的剥离和剩余循环,并对短行程计数循环进行向量化。然而,这些特殊循环的向量化版本可能并不总能带来性能提升,因为大多数指令需要被掩码。可能导致性能问题的主要原因是存储转发的限制以及在合并掩码操作时可能出现的停顿,尤其是在操作依赖于其他操作时。在编译器中,可以通过彻底分析数据流并尽可能避免掩码来解决存储转发问题。当存储转发问题不可避免时,首选未掩码版本的循环(例如,标量循环或具有较短向量长度的向量化循环)。您可以通过尝试使用零掩码操作来避免合并掩码操作时的停顿。以下 pragmas 允许提供提示来改变编译器的行为:

  • 对于剩余循环
    #pragma vector novecremainder,不向量化剩余循环
    #pragma vector vecremainder,根据编译器成本模型向量化剩余循环
    #pragma vector always vecremainder,始终向量化剩余循环
  • 对于短行程计数循环
    #pragma vector always,强制向量化
    #pragma novector,禁用向量化

聚集和散射优化

聚集和散射指令允许我们向量化更多的循环。然而,向量化代码可能不会带来性能优势。包含聚集/散射代码的循环性能取决于计算数量与数据加载/存储数量之间的比率,以及选择最优的向量长度以减少聚集和散射指令的延迟。较短的向量长度意味着聚集/散射代码的延迟较低。通常,源代码级别的一些简单优化可以帮助减少聚集和散射指令的数量。

图 13 显示了两个简单的手动聚集/散射优化,而编译器正在尝试自动执行这些优化。第一个优化是当两个聚集/散射的结果混合时减少聚集/散射的数量,如左侧所示。在这种情况下,混合索引可以实现一次聚集而不是两次,如右侧所示。请注意,我们可以将相同的过程应用于散射优化。

第二个聚集/散射优化是相反的转换(图 14)。在这种情况下,聚集是使用由单位步长线性索引混合而成的索引执行的,如左侧所示。为了提高性能,最好执行两次单位步长加载,然后进行混合,如右侧所示。

// Two gathers in the loop
float *a, sum = 0; int *b, *c;
… …
for (int i; i < n; i++) {
  if ( pred(x[i]))
      sum += a[b[i]]; // gather
  else
      sum += a[c[i]]; // gather
}
// Optimized with one gather in the loop
for (int i; i < n; i++) {
  int t;
  if ( pred(x[i]))
     t = b[i];
else
   t = c[i];
sum += a[t]; // one gather remain
}
图 13 — 减少循环中的聚集操作
// One gather in the loop
float *a, sum = 0; int a;
for (int i; i < n; i++) {
  int t;
  if (pred(x[i])) {
      t = i + b;
  }
  else {
   t = i;
  }
  sum += a[t]; // gather
}
// Replace gather with unit-stride loads + blending
for (int i; i < n; i++) {
  float s;
  if ( pred(x[i])) {
      s = a[i + b];   // unit stride vector load
  }
  else {
       s = a[i];     // unit stride vector load
  }
  sum += s;
}
图 14 — 减少循环中带有两次单位步长加载 + 混合的聚集操作

执行延迟改进

对于循环向量化,编译器的一个已知问题是循环迭代次数在编译时未知。然而,程序员通常可以预测迭代次数并使用 #pragma loop count 向编译器提供提示。在某些情况下,循环迭代次数也可以根据 #pragma unroll 信息进行近似预测。最好将 unroll pragma 用于足够小的循环体,以减少循环迭代次数并增加循环迭代独立性,以便它们可以在乱序 Intel® 架构上并行执行以隐藏执行延迟。循环展开对于计算密集型循环(即计算时间长于内存访问时间)非常有帮助。例如,当展开因子(UF)设置为 8 时,图 15 所示的循环将比 UF 等于 0 时快约 45%。测量是在 n=1,000 的情况下进行的。

float *a,*b, *c;
#pragma unroll(8)
#pragma omp simd
for (i= 0; i < n; i++) { 
  if (a[i] > c[i]) sum += b[i] * c[i];
}
图 15 — 使用 simd + unroll(8) 减少延迟

总结与未来工作

我们研究了英特尔编译器 18.0 中针对 Intel Xeon 可扩展处理器的 Intel AVX-512 支持的几种新的 simd 语言扩展。我们分享并讨论了一套性能优化和调优实践,以实现 AVX-512 的最佳性能。我们提供了基于微内核的性能结果,以展示这些新的 simd 扩展和调优实践的有效性。未来,C/C++ 还有几种新的 simd 扩展正在考虑中,以通过 OpenMP 和 C++ Parallel STL 充分利用 AVX-512,包括 inclusive-scan、exclusive-scan、基于范围的 C++ 循环和 lambda 表达式支持。

了解更多

参考文献

  1. M. Corden。“利用新的优化报告充分发挥您的 Intel® 编译器的潜能”,英特尔开发人员专区,2014 年。
  2. R. Krishnaiyer。“数据对齐以辅助向量化”,英特尔开发人员专区,2015 年。
  3. H. Saito, S. Preis, N. Panchenko 和 X. Tian。“缩小自动向量化与显式向量化之间的功能差距。”发表于国际 OpenMP 研讨会 (IWOMP) 会议录,LNCS9903,第 173-186 页,Springer,2016 年。
  4. H. Saito, S. Preis, A. Cherkasov 和 X. Tian。“获取条件赋值私有变量的最终值”,OpenMPCon 开发者大会,2016 年。
  5. H. Saito,“扩展 LoopVectorizer:OpenMP4.5 SIMD 和外层循环自动向量化”,LLVM 开发者大会演讲,2016 年。
  6. X. Tian, H. Saito, M. Girkar, S. Preis, S. Kozhukhov, A. Duran。“利用 OpenMP* SIMD 让向量编程发挥作用”,《并行宇宙》杂志,第 22 期,2015 年 9 月。
  7. X. Tian, R. Geva, B. Valentine。“通过架构、编译器和代码现代化释放 AVX-512 的强大功能”,ACM 并行架构与编译器技术大会,2016 年 9 月 11 日至 15 日,以色列海法。
  8. X. Tian, H. Saito, M. Girkar, S. Preis, S. Kozhukhov, A. G. Cherkasov, C. Nelson, N. Panchenko, R. Geva。IEEE IPDPS Workshops 2012: 2349-23。在多核 SIMD 处理器上编译 C/C++ SIMD 扩展以进行函数和循环向量化。
  9. 英特尔公司。“冲突检测”在《Intel® 64 和 IA-32 架构优化参考手册》第 13.16 节中,2017 年 7 月。
© . All rights reserved.