通过OpenMP SIMD进行256位向量化是否会阻止编译器的优化(比如函数内联)?

6
考虑下面这个玩具示例,其中A是以列为主序存储的n x 2矩阵,我想计算它的列和。sum_0仅计算第一列的和,而sum_1也计算第二列的和。这实际上是一个人工示例,因为没有必要为这个任务定义两个函数(我可以编写一个双层循环嵌套的单个函数,其中外部循环从0j迭代)。它被构造出来演示我在现实中遇到的模板问题。
/* "test.c" */
#include <stdlib.h>

// j can be 0 or 1
static inline void sum_template (size_t j, size_t n, double *A, double *c) {

  if (n == 0) return;
  size_t i;
  double *a = A, *b = A + n;
  double c0 = 0.0, c1 = 0.0;

  #pragma omp simd reduction (+: c0, c1) aligned (a, b: 32)
  for (i = 0; i < n; i++) {
    c0 += a[i];
    if (j > 0) c1 += b[i];
    }

  c[0] = c0;
  if (j > 0) c[1] = c1;

  }

#define macro_define_sum(FUN, j)            \
void FUN (size_t n, double *A, double *c) { \
  sum_template(j, n, A, c);                 \
  }

macro_define_sum(sum_0, 0)
macro_define_sum(sum_1, 1)

如果我使用以下方式进行编译

gcc -O2 -mavx test.c

在进行内联、常量传播和死代码消除后,GCC(最新的8.2版本)会优化掉函数sum_0中涉及的代码(可以在GodBolt上查看)。

我喜欢这个技巧。通过编写一个单一的模板函数并传递不同的配置参数,优化编译器可以生成不同的版本。这比复制大量代码并手动定义不同的函数版本要简洁得多。

然而,如果我激活OpenMP 4.0+,这种便利就会丧失。

gcc -O2 -mavx -fopenmp test.c

sum_template不再内联,也没有应用死代码消除 (在Godbolt上查看)。但是如果我删除-mavx标志以使用128位SIMD,则编译器优化按照我的预期工作 (在Godbolt上查看)。那么这是一个错误吗?我使用的是x86-64 (Sandybridge)。


备注

使用GCC的自动向量化-ftree-vectorize -ffast-math将不会出现此问题 (在Godbolt上查看)。但我希望使用OpenMP,因为它允许在不同编译器之间使用可移植的对齐pragma。

背景

我为一个R包编写模块,需要在各种平台和编译器上移植。编写R扩展程序不需要Makefile。当在一个平台上构建R时,它知道该平台上的默认编译器,并配置一组默认编译标志。R没有自动向量化标志,但它有OpenMP标志。这意味着使用OpenMP SIMD是在R包中利用SIMD的理想方式。有关稍微详细的说明,请参见12


1
建议:如果你要发明另一种自定义函数的宏语言,那么至少使用“X宏”,这是一种比较知名的技术。 - Lundin
@李哲源 基本上,您可以使用它来定义需要更改的所有内容列表,并仅从单个位置维护它。缺点是代码变得更难阅读,但对于任何其他形式的宏魔法也是如此。 - Lundin
顺便提一下,所有的 pragma 都是不可移植的。有些只是比其他的编译器支持更广泛。 - Lundin
omp simd aligned 应该是可移植的。GCC仍然需要ffast-math来启用simd reduction,因此从gcc的角度来看,它并不是可移植的,因为gcc没有选项可以通过设置omp simd来为循环启用fast-math。在没有aligned的情况下,omp simd在gcc中的效果与本地restrict限定符相似,对于reduction而言是不必要的。在单个for()中优化的reductions数量可能会有限制。 - tim18
我认为死代码消除是通过-ffunction-sections,-fdata-sections编译器选项和-Wl,--gc-sections链接器选项实现的(Apple链接器使用-Wl,-dead_code)。 - jww
显示剩余2条评论
2个回答

3
解决这个问题最简单的方法是使用__attribute__((always_inline)),或者其他特定于编译器的重写方式。
#ifdef __GNUC__
#define ALWAYS_INLINE __attribute__((always_inline)) inline
#elif defined(_MSC_VER)
#define ALWAYS_INLINE __forceinline inline
#else
#define ALWAYS_INLINE  inline  // cross your fingers
#endif


ALWAYS_INLINE
static inline void sum_template (size_t j, size_t n, double *A, double *c) {
 ...
}

这是一个Godbolt测试,证明它有效。

另外,不要忘记使用-mtune=haswell,而不仅仅是-mavx。这通常是个好主意。(然而,承诺对齐数据将阻止gcc的默认-mavx256-split-unaligned-load调整将256位负载拆分为128位vmovupd+vinsertf128,因此使用tune = haswell的代码生成对于函数是可以的。但通常你希望gcc自动向量化任何其他函数时使用它。)
你不需要使用staticinline;如果编译器决定不内联它,它至少可以在编译单元之间共享相同的定义。
通常,gcc根据函数大小启发式决定是否内联。但即使设置-finline-limit=90000也不能让gcc使用你的#pragma omp进行内联(如何强制gcc内联函数?)。我一直猜测gcc没有意识到内联后的常量传播会简化条件,但90000个“伪指令”似乎足够大了。可能还有其他启发式方法。
可能OpenMP以某些方式以不同的方式设置每个函数的一些内容,这可能会破坏优化器,如果它允许它们内联到其他函数中。使用__attribute__((target("avx")))可以防止该函数内联到没有AVX编译的函数中(因此您可以在运行时调度而不会通过if(avx)条件将内联“感染”其他函数与AVX指令。)
OpenMP做的一件事是,即使不启用-ffast-math,也可以对规约进行矢量化。
不幸的是,OpenMP仍然不会打开多个累加器或任何隐藏FP延迟的东西。 #pragma omp是一个相当好的提示,表明循环实际上很热门,并值得花费代码大小,因此gcc应该真正做到这一点,即使没有-fprofile-use
因此,特别是如果这个程序在L2或L1缓存(或者可能是L3)中运行的数据非常热门,你应该采取一些措施来获得更好的吞吐量。
顺便说一下,在Haswell上,对齐通常并不是很重要。但是,对于SKX上的AVX512,64字节对齐实际上更重要。对于未对齐的数据,可能会减慢约20%,而不仅仅是几个%。
(但在编译时承诺对齐是一个单独的问题,与运行时实际对齐你的数据不同。两者都有帮助,但在gcc7及更早版本或任何没有AVX的编译器上,承诺编译时对齐可以使代码更紧凑。)

@李哲源:我不知道当这篇文章首次发布时是否错过了它,还是我没有时间阅读并考虑__attribute__。无论如何,我的答案已经包括了可移植地为GNU编译器(gcc / clang / icc)和MSVC定义ALWAYS_INLINE#ifdef块,这涵盖了大多数主流编译器。其他编译器则需要自行解决,而常规的inline关键字希望能够为它们提供足够的帮助。 - Peter Cordes
1
@李哲源:如果数据在运行时对齐,-mavx256-split-unaligned-load 总是更糟。如果数据在运行时未对齐,则在 Sandybridge/IvyBridge(以及可能一些 AMD)上会有所提升,但在 Haswell 及之后的版本上总是会损失性能(这些版本越来越普遍)。这就是为什么在编译时承诺对齐的情况下可以帮助优化性能。但是,一个通常看到对齐数据但仍应以小的速度惩罚处理不对齐数据的函数,不应使用此调优选项进行编译。 - Peter Cordes
1
--param large-stack-frame=512 可以让它内联,但这是作弊。否则,如果你添加一些(热点)调用者,编译器会注意到 sum_0 和 sum_1 值得花费一些空间,并且它将基于传递的常量参数(IPA-CP)克隆 sum_template。 - Marc Glisse
1
“--param ipa-cp-eval-threshold=100” 是一种玩转IPA-CP启发式算法的方式。但实际上,你想增加GCC认为的“频率”。我正在使用“-fdump-ipa-all-all”来查看更多信息。“-fprofile-use”可能是一种方法,但即使只是添加一个循环调用sum_0函数的函数也足够了。 - Marc Glisse
1
Ivy Bridge的设计人员特别努力地减少了未对齐的256位加载的惩罚,因此除非您的全面性能支持要求追溯到Sandy Bridge,否则不建议将其分裂。 - tim18
显示剩余5条评论

2

我急需解决这个问题,因为在我的真实C项目中,如果没有使用模板技巧进行不同函数版本的自动生成(以下简称“版本控制”),我将需要编写9个不同版本的总共1400行代码,而不仅仅是单个模板的200行代码。

我找到了一种解决方法,并在此发布一个使用问题中的玩具示例的解决方案。


我计划利用一个内联函数sum_template进行版本控制。如果成功,它会在编译器执行优化时发生。然而,OpenMP pragma无法通过这种编译时版本控制。选项是仅使用在预处理阶段进行版本控制。

为了摆脱内联函数sum_template,我手动将其内联到宏macro_define_sum中:

#include <stdlib.h>

// j can be 0 or 1
#define macro_define_sum(FUN, j)                            \
void FUN (size_t n, double *A, double *c) {                 \
  if (n == 0) return;                                       \
  size_t i;                                                 \
  double *a = A, * b = A + n;                               \
  double c0 = 0.0, c1 = 0.0;                                \
  #pragma omp simd reduction (+: c0, c1) aligned (a, b: 32) \
  for (i = 0; i < n; i++) {                                 \
    c0 += a[i];                                             \
    if (j > 0) c1 += b[i];                                  \
    }                                                       \
  c[0] = c0;                                                \
  if (j > 0) c[1] = c1;                                     \
  }

macro_define_sum(sum_0, 0)
macro_define_sum(sum_1, 1)

在这个仅包含宏的版本中,j在宏扩展期间直接被替换为0或1。而在问题中的内联函数+宏方法中,我只有sum_template(0, n, a, b, c)sum_template(1, n, a, b, c)在预处理阶段,sum_template中的j仅在后续编译时传播。
不幸的是,上述宏会导致错误。我不能在另一个宏中定义或测试宏(请参见123)。以#开头的OpenMP指示在这里引起了问题。因此,我必须将此模板分成两个部分:pragma之前的部分和之后的部分。
#include <stdlib.h>

#define macro_before_pragma   \
  if (n == 0) return;         \
  size_t i;                   \
  double *a = A, * b = A + n; \
  double c0 = 0.0, c1 = 0.0;

#define macro_after_pragma(j) \
  for (i = 0; i < n; i++) {   \
    c0 += a[i];               \
    if (j > 0) c1 += b[i];    \
    }                         \
  c[0] = c0;                  \
  if (j > 0) c[1] = c1;

void sum_0 (size_t n, double *A, double *c) {
  macro_before_pragma
  #pragma omp simd reduction (+: c0) aligned (a: 32)
  macro_after_pragma(0)
  }

void sum_1 (size_t n, double *A, double *c) {
  macro_before_pragma
  #pragma omp simd reduction (+: c0, c1) aligned (a, b: 32)
  macro_after_pragma(1)
  }

我不再需要macro_define_sum了。我可以使用已定义的两个宏直接定义sum_0sum_1。我也可以适当地调整pragma。这里我没有模板函数,而是有函数代码块的模板,并且可以轻松地重用它们。

在这种情况下,编译器输出如预期的那样(在Godbolt上检查)。


更新

感谢各种反馈;它们都非常有建设性(这就是为什么我喜欢Stack Overflow)。

感谢Marc Glisse指出了在#define内使用openmp pragma。是的,我犯了错误,没有搜索这个问题。#pragma是一个指令,而不是真正的宏,因此必须有一些方法将其放入宏中。这里是使用_Pragma运算符的整洁版本:

/* "neat.c" */
#include <stdlib.h>

// stringizing: https://gcc.gnu.org/onlinedocs/cpp/Stringizing.html
#define str(s) #s

// j can be 0 or 1
#define macro_define_sum(j, alignment)                                   \
void sum_ ## j (size_t n, double *A, double *c) {                        \
  if (n == 0) return;                                                    \
  size_t i;                                                              \
  double *a = A, * b = A + n;                                            \
  double c0 = 0.0, c1 = 0.0;                                             \
  _Pragma(str(omp simd reduction (+: c0, c1) aligned (a, b: alignment))) \
  for (i = 0; i < n; i++) {                                              \
    c0 += a[i];                                                          \
    if (j > 0) c1 += b[i];                                               \
    }                                                                    \
  c[0] = c0;                                                             \
  if (j > 0) c[1] = c1;                                                  \
  }

macro_define_sum(0, 32)
macro_define_sum(1, 32)

其他更改包括:
  • 我使用标记连接生成函数名称;
  • alignment成为宏参数。对于AVX,32的值表示良好的对齐,而8(sizeof(double))的值基本上表示没有对齐。字符串化需要将这些标记解析为_Pragma所需的字符串。

使用gcc -E neat.c检查预处理结果。编译会产生所需的汇编输出(在Godbolt上检查)。


对Peter Cordes详细答案的一些评论

使用编译器的函数属性。我不是专业的C程序员。我的C经验仅来自编写R扩展。开发环境决定了我对编译器属性不太熟悉。我知道一些,但实际上并不使用它们。

-mavx256-split-unaligned-load在我的应用程序中不是问题,因为我将分配对齐内存并应用填充以确保对齐。我只需要承诺编译器对齐,以便它可以生成对齐的加载/存储指令。我确实需要在非对齐数据上进行一些矢量化,但这只占整个计算的很小一部分。即使我在分裂非对齐负载上受到性能惩罚,现实中也不会注意到。我也不会用自动向量化来编译每个C文件。我只在操作在L1缓存上很热时(即它是CPU绑定而不是内存绑定)使用SIMD。顺便说一下,-mavx256-split-unaligned-load是为GCC设计的;其他编译器呢?

我知道static inlineinline之间的区别。如果一个inline函数只被一个文件访问,我会将其声明为static,这样编译器就不会生成它的副本。

OpenMP SIMD可以有效地执行归约,即使没有GCC-ffast-math。然而,它不使用水平加法来在累加器寄存器中聚合结果,而是运行标量循环来相加每个双字(请参见代码块.L5和.L27在Godbolt输出中)。

吞吐量是一个很好的优点(特别是对于具有相对较大延迟但高吞吐量的浮点运算)。我应用了SIMD的真实C代码是三重循环嵌套。我展开外部两个循环,以增大最内层循环中的代码块,以增强吞吐量。然后对最内层进行向量化即可。在这个问题和答案中的玩具示例中,我可以使用-funroll-loops来请求GCC展开循环,并使用多个累加器来增强吞吐量。

关于这个问题和答案

我认为大多数人会比我更以技术的方式看待这个问题和答案。他们可能对使用编译器属性或调整编译器标志/参数来强制函数内联感兴趣。因此,Peter的答案以及Marc在答案下的评论仍然非常有价值。再次感谢。


只有当操作在L1缓存上非常热时(即它是CPU绑定而不是内存绑定)我才会使用SIMD。这正是-mavx256-split-unaligned-load最重要的时候,因为它使用更多的指令来完成相同的工作。(但至少它不会在洗牌端口上成为瓶颈,因为vinsertf128 ymm,m128,imm8对于任何ALU端口+一个加载端口都是2个uops。https://agner.org/optimize/)。无论如何,如果您的代码实际上将主要运行在Haswell及更高版本上,则`-mtune=haswell`是一个好主意。(或者对于在自己的计算机上构建的人来说,可以使用`-march=native`)。 - Peter Cordes
但是,如果所有重要的循环都针对对齐数据,并使用OpenMP或p = __builtin_assume_aligned(p, 64);_mm256_load_ps告诉编译器有关该对齐的信息,则您的代码生成将是良好的。尽管如此,由于使用3个uop而不是1个来加载向量,特别是如果它们的输入有时确实对齐,因此您的非对齐循环可能会受到轻微影响。 - Peter Cordes
@PeterCordes 谢谢Peter。我对编译器标志没有完全的控制权。我只能写一个小册子或者其他建议用户自定义他们的个人Makevar,如果他们想要最佳性能。我可以建议他们在使用GCC时打开-mavx256-split-unaligned-load。实际上,我也不喜欢分裂负载。检查ASM时需要读取更多指令,很烦人。 - Zheyuan Li

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接