6

考虑以下玩具示例,其中A是以n x 2列优先顺序存储的矩阵,我想计算其列总和。sum_0仅计算第一列的总和,而sum_1第二列也计算。这实际上是一个人为的例子,因为基本上不需要为这个任务定义两个函数(我可以编写一个带有双循环嵌套的单个函数,其中外循环从0to迭代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),在内联、持续传播和死代码消除之后,将优化涉及c1for 函数的代码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,因为它允许跨不同编译器的可移植对齐编译指示。

背景

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

4

2 回答 2

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)

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

不幸的是,上面的给出了错误。我无法在另一个宏中定义或测试宏(参见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)

其他变化包括:

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


关于 Peter Cordes 信息丰富的答案的一些评论

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

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

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

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

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


关于本次问答

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

于 2018-09-07T22:38:31.390 回答
2

解决此问题的最简单方法是使用__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 自动矢量化任何其他函数.

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


通常 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 上的 AVX 来说通常不是什么大问题。但在 SKX 上的 AVX512 的实践中,64 字节对齐确实更重要。就像未对齐的数据可能会减速 20%,而不是几个 %。

(但在编译时承诺对齐与在运行时实际对齐数据是一个单独的问题。两者都有帮助,但在编译时承诺对齐会使 gcc7 和更早版本的代码更紧密,或者在任何没有 AVX 的编译器上。)

于 2018-09-08T01:13:02.217 回答