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

256-bit vectorization via OpenMP SIMD prevents compiler's optimization (say function inlining)?

考虑以下玩具示例,其中 A 是一个按列优先顺序存储的 n x 2 矩阵,我想计算它的列总和。 sum_0 只计算第一列的和,而 sum_1 也计算第二列。这实际上是一个人为的例子,因为基本上不需要为此任务定义两个函数(我可以编写一个带有双循环嵌套的函数,其中外部循环从 0 迭代到 j) .构造来演示我在现实中遇到的模板问题

/* "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 (Check it on Godbolt) 优化涉及 c1 的代码。

我喜欢这个技巧。通过编写单个模板函数并传入不同的配置参数,优化编译器可以生成不同的版本。它比复制和粘贴大部分代码并手动定义不同的函数版本要干净得多。

但是,如果我使用

激活 OpenMP 4.0+,就会失去这种便利
gcc -O2 -mavx -fopenmp test.c

sum_template 不再内联,也不应用死代码消除 (Check it on Godbolt). But if I remove flag -mavx to work with 128-bit SIMD, compiler optimization works as I expect (Check it on Godbolt)。那么这是一个错误吗?我在 x86-64 (Sandybridge) 上。


备注

使用 GCC 的自动矢量化 -ftree-vectorize -ffast-math 不会有这个问题 (Check it on Godbolt)。但我希望使用 OpenMP,因为它允许跨不同编译器的可移植对齐编译指示。

背景

我为 R 包编写模块,需要跨平台和编译器移植。编写 R 扩展不需要 Makefile。当 R 在平台上构建时,它知道该平台上的默认编译器是什么,并配置一组默认编译标志。 R 没有自动矢量化标志,但它有 OpenMP 标志。这意味着使用 OpenMP SIMD 是在 R 包中使用 SIMD 的理想方式。有关更多详细信息,请参阅 and

我迫切需要解决这个问题,因为在我真正的C项目中,如果不使用模板技巧来自动生成不同的函数版本(以下简称"versioning"),我需要写一个9 个不同版本总共 1400 行代码,而不是单个模板只有 200 行代码。

我找到了出路,现在我使用问题中的玩具示例发布解决方案。


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

为了摆脱 inline 函数 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)

在这个-only版本中,j在宏展开时直接被0或1代替。而在问题中的 inline 函数 + macro 方法中,我在预处理时只有 sum_template(0, n, a, b, c)sum_template(1, n, a, b, c)阶段,并且 sum_template 主体中的 j 仅在稍后的编译时传播。

不幸的是,上面的给出了错误。我无法在另一个宏中定义或测试宏(参见 1, 2, )。以 # 开头的 OpenMP pragma 在这里引起了问题。所以我必须把这个模板分成两部分: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。这里没有模板函数,我有函数代码块的模板,可以轻松重用它们。

本例中的编译器输出符合预期 (Check it on Godbolt)。


更新

感谢各种反馈;他们都非常有建设性(这就是我喜欢 Stack Overflow 的原因)。

谢谢Marc Glisse for point me to Using an openmp pragma inside #define。是的,没有搜索这个问题是我的错。 #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)

其他变化包括:

  • 我用token concatenation生成了函数名;
  • alignment 是一个宏参数。对于 AVX,值 32 表示对齐良好,而值 8 (sizeof(double)) 基本上表示没有对齐。 Stringizing 需要将这些标记解析为 _Pragma 需要的字符串。

使用gcc -E neat.c检查预处理结果。编译给出所需的汇编输出 (Check it on Godbolt)。


对 Peter Cordes 信息性回答的一些评论

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

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

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

即使没有 GCC-ffast-math,OpenMP SIMD 也可以有效地进行缩减。但是,它并没有在归约结束时使用水平加法来聚合累加器寄存器内部的结果;它运行一个标量循环来将每个双字相加(参见 Godbolt output 中的代码块 .L5 和 .L27)。

吞吐量是个好点(尤其是对于延迟比较大但吞吐量高的浮点运算)。我应用 SIMD 的真实 C 代码是三重循环嵌套。我展开外部两个循环以扩大最内部循环中的代码块以提高吞吐量。最里面的矢量化就足够了。在这个问答中的玩具示例中,我只是对一个数组求和,我可以使用 -funroll-loops 请求 GCC 进行循环展开,使用多个累加器来提高吞吐量。


关于这个问答

我想大多数人会比我更专业地对待这个问答。他们可能对使用编译器属性或调整编译器标志/参数来强制函数内联感兴趣。因此,Peter的回答以及Marc在回答下的评论还是很有价值的。再次感谢。

解决此问题的最简单方法是使用 __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 proof that it works.

此外,不要忘记使用 -mtune=haswell,而不仅仅是 -mavx。这通常是个好主意。 (但是,有前途的对齐数据将阻止 gcc 的默认 -mavx256-split-unaligned-load 调整将 256 位负载拆分为 128 位 vmovupd + vinsertf128,因此代码生成 this function is fine with tune=haswell. 但通常你希望 gcc 自动矢量化任何其他函数。

你真的不需要 staticinline;如果编译器决定不内联它,它至少可以在编译单元之间共享相同的定义。


通常 gcc 根据函数大小试探法决定是否内联。但是即使设置 -finline-limit=90000 也不会让 gcc 与您的 #pragma omp (How do I force gcc to inline a function?) 内联。我一直在猜测 gcc 没有意识到内联后的常量传播会简化条件,但 90000 "pseudo-instructions" 似乎很大。可能还有其他启发式方法。

可能 OpenMP 以不同的方式设置了一些每个函数的东西,如果它让它们内联到其他函数,可能会破坏优化器。使用 __attribute__((target("avx"))) 会阻止该函数内联到未使用 AVX 编译的函数中(因此您可以安全地进行运行时调度,而无需在 if(avx) 条件下使用 AVX 指令内联 "infecting" 其他函数。)

OpenMP 做的一件事是常规自动矢量化无法做到的,即可以在不启用 -ffast-math.

的情况下对缩减进行矢量化

不幸的是,OpenMP 仍然懒得展开多个累加器或任何隐藏 FP 延迟的东西。 #pragma omp 是一个很好的暗示,表明循环实际上很热并且值得花费代码大小,所以 gcc 应该真正做到这一点,即使没有 -fprofile-use.

因此,特别是如果它在 L2 或 L1 缓存(或可能是 L3)中的热数据上运行,您应该采取一些措施来获得更好的吞吐量。

顺便说一句,对齐对于 Haswell 上的 AVX 通常不是什么大问题。但是对于 SKX 上的 AVX512,64 字节对齐在实践中确实更重要。就像未对齐的数据可能会减速 20%,而不是几个 %。

(但在编译时保证对齐与实际在运行时对齐数据是不同的问题。两者都有帮助,但在编译时保证对齐会使 gcc7 和更早版本的代码更紧凑,或者在没有 AVX 的任何编译器上。 )