我迫切需要解决这个问题,因为在我真正的 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
仅在后期编译时传播。
不幸的是,上面的宏给出了错误。我无法在另一个宏中定义或测试宏(参见1、2、3)。开头的 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_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)
其他变化包括:
- 我使用令牌连接来生成函数名;
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进行循环展开,以提高吞吐量。
关于本次问答
我想大多数人会以比我更专业的方式对待这个问答。他们可能对使用编译器属性或调整编译器标志/参数来强制函数内联感兴趣。因此,彼得的回答以及马克在答案下的评论仍然非常有价值。再次感谢。