通过OpenMP SIMD进行256位向量化可以防止编译器的优化(比如函数内联)?

李哲源*_*李哲源 6 c gcc simd openmp auto-vectorization

考虑以下玩具示例,其中A是以n x 2列主要顺序存储的矩阵,我想计算其列总和.sum_0仅计算第1列的总和,同时sum_1也计算第2列的总和.这实际上是一个人为的例子,因为基本上不需要为这个任务定义两个函数(我可以编写一个带有双循环嵌套的函数,其中外循环从中迭代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)
Run Code Online (Sandbox Code Playgroud)

如果我用它编译它

gcc -O2 -mavx test.c
Run Code Online (Sandbox Code Playgroud)

在内联,常量传播和死代码消除之后,GCC(比如最新的8.2)将优化涉及c1函数的代码sum_0(在Godbolt上检查它).

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

但是,如果我激活OpenMP 4.0+,这种便利性就会丢失

gcc -O2 -mavx -fopenmp test.c
Run Code Online (Sandbox Code Playgroud)

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.

李哲源*_*李哲源 2

我迫切需要解决这个问题,因为在我真正的C项目中,如果没有使用模板技巧来自动生成不同的函数版本(以下简称“版本控制”),我总共需要编写1400行代码9 个不同的版本,而不是单个模板只有 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)
Run Code Online (Sandbox Code Playgroud)

在此仅包含的版本中,j在宏扩展期间直接用 0 或 1 替换。而在问题中的内联函数+方法中,我只有sum_template(0, n, a, b, c)orsum_template(1, n, a, b, c)在预处理阶段,并且j在 的主体中sum_template仅在稍后的编译时传播。

不幸的是,上面的给出了错误。我无法在另一个宏中定义或测试宏(参见123)。以 开头的 OpenMP 编译指示#在这里导致了问题。所以我必须把这个模板分成两部分:编译指示之前的部分和编译指示之后的部分。

#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)
  }
Run Code Online (Sandbox Code Playgroud)

我不再需要了macro_define_sum。我可以直接使用定义的两个宏来定义sum_0和直接使用。sum_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)
Run Code Online (Sandbox Code Playgroud)

其他变化包括:

  • 我使用标记串联来生成函数名称;
  • alignment进行了宏观论证。对于 AVX,值 32 意味着良好对齐,而值 8 ( sizeof(double)) 基本上意味着没有对齐。需要字符串化_Pragma来将这些标记解析为需要的字符串。

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


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

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

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

我知道static inline和之间的区别inline。如果一个inline函数仅由一个文件访问,我会将其声明为,static以便编译器不会生成它的副本。

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

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


关于这个问答

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