Open MP:SIMD循环中的SIMD兼容功能?

时间:2018-08-07 14:28:30

标签: c++ gcc g++ openmp simd

通常,我可能会编写一个类似simd的循环:

float * x = (float *) malloc(10 * sizeof(float));
float * y = (float *) malloc(10 * sizeof(float));

for(int i = 0; i < 10; i++)
    y[i] = 10;

#pragma omp simd
for(int i = 0; i < 10; i++)
    x[i] = y[i]*y[i];

假设我有两个任务在心中:

float square(float x) {
    return x * x;
}
float halve(float x) {
    return x / 2.;
}

还有一个omp循环原语:

void apply_simd(float * x, float * y, int length, float (*simd_func)(float c)){
    #pragma omp simd
    for(int i = 0; i < length; i++)
         x[i] = simd_func(y[i])
}

在SIMD的参数范围内这合法吗?还是与我显式内联所有内容相比,编译器将产生效率更低的代码?

正在写:

float inline square(float x){ ... } 

更改任何内容?还是仅当我仅根据本机函数/运算符明确写下该操作时,才能期望从SIMD中受益?

1 个答案:

答案 0 :(得分:3)

是的,启用优化(-O3 -march=native),如果满足以下条件,现代编译器可以可靠地通过函数指针内联:

  • 功能指针具有编译时常量值
  • 它指向编译器可以看到其定义的函数

这听起来很容易确保,但是如果此代码在Unix / Linux上的共享库中(用-fPIC编译)中使用,则符号插入规则意味着{{1} } 1 即使在同一翻译单元中也不能内联。参见Sorry state of dynamic libraries on Linux

使用float halve(float x) { return x * 0.5f; }关键字即使在构建共享库时也允许内联;像inline一样,如果编译器决定在每个调用位置进行内联,则根本不发出该函数的独立定义。

staticinlinehalve上使用square。 (因为apply_simd需要内联到作为函数arg传递apply_simd的调用方中。halve的独立定义没有用,因为它不能内联未知函数。)如果它们位于apply_simd中,而不是.cpp中,您也可以将它们也设为.h,或者改为static


一次完成尽可能多的工作

我怀疑您想写出这样的效率很低的东西:

inline

仅执行apply_simd(x, y, length, halve); // copy y to x apply_simd(x, x, length, square); // then update x in-place // NEVER DO THIS, make one function that does both things // with gcc and clang, compiles as written to two separate loops. 的复制和循环的循环通常会成为内存带宽的瓶颈。像Haswell(或Skylake)这样的现代CPU的FMA / mul(或增加)吞吐量(每个时钟2x 256位向量)是存储带宽的两倍(每个时钟1x 256位向量至L1d)。 计算强度很重要。不要通过编写执行单独的琐碎操作的多个循环来修饰代码

展开任何循环,或者如果数据不适合L1d,则SIMD 0.5f的吞吐量将与其中任何一个操作相同。

我检查了g ++ 8.2和clang ++ 6.0 on the Godbolt compiler explorer的asm输出。即使x[i] = 0.25f * y[i]*y[i]告诉x和y不重叠,编译器仍会进行2个单独的循环。


传递lambda作为函数指针

我们可以使用lambda轻松地将任意操作组合为一个函数,并将其作为函数指针传递。这解决了上面创建两个单独的循环的问题,同时仍然为您提供了将循环包装到函数中所需的语法。

如果您的__restrict函数是不重要内容的占位符,则可以在lambda中使用它来与其他内容组合。例如halve(float)

在早期的C ++标准中,您需要将lambda分配给函数指针。 (Lambda as function parameter

square(halve(a))

C ++ 11调用者:

// your original function mostly unchanged, but with size_t and inline
inline  // allows inlining even with -fPIC
void apply_simd(float * x, const float *y, size_t length, float (*simd_func)(float c)){
    #pragma omp simd
    for(size_t i = 0; i < length; i++)
         x[i] = simd_func(y[i]);
}

在C ++ 17中,它甚至更容易实现,并且可以与文字匿名lambda一起使用:

// __restrict isn't needed with OpenMP, but you might want to assert non-overlapping for better auto-vectorization with non-OpenMP compilers.
void test_lambda(float *__restrict x, const float *__restrict y, size_t length)
{
    float (*funcptr)(float) = [](float a) -> float {
         float h=0.5f*a; // halve first allows vmulps with a memory source operand
         return h*h;    // 0.25 * a * a doesn't optimize to that with clang :/
    };

    apply_simd(x, y, length, funcptr);
}

它们都可以通过gcc和clang高效地编译为这样的内部循环,例如Godbolt compiler explorer

void test_lambda17(float *__restrict x, const float *__restrict y, size_t length)
{
    apply_simd(x, y, length, [](float a) {
        float h = 0.5f*a;
        return h * h;
      }
    );
}

clang展开了一些,可能接近每个时钟加载+存储的一个256位向量,并乘以2。 (非索引寻址模式可以通过展开隐藏两个指针增量来实现。傻傻的编译器。:/)


Lambda或函数指针作为模板参数

使用本地lambda作为模板参数(在函数内部定义),编译器肯定可以始终内联。但是(由于gcc错误)目前无法使用。

但是只有一个函数指针,它实际上并不能帮助您发现忘记使用.L4: vmulps ymm0, ymm1, YMMWORD PTR [rsi+rax] vmulps ymm0, ymm0, ymm0 vmovups YMMWORD PTR [rdi+rax], ymm0 add rax, 32 cmp rax, rcx jne .L4 关键字或破坏编译器内联能力的情况。它仅表示函数地址必须是动态链接时间常数(即直到动态库的运行时绑定才知道),因此不会使您免于插入符号的麻烦。在使用inline进行 compile 时,编译器仍然不知道它可以看到的全局函数的版本是否是链接时实际解析的版本,或者{{ 1}}或主可执行文件中的符号将覆盖它。因此,它只是发出从GOT加载函数指针的代码,并在循环中调用它。 SIMD当然是不可能的。

它确实通过以不总是内联的方式传递函数指针来阻止您脚踏实地。不过,可能使用-fPIC,您仍然可以在模板中使用它们之前将它们作为args传递。 因此,如果不是因为gcc错误导致您无法在Lambda上使用它,您可能要使用它。

C ++ 17允许将没有捕获的自动存储lambda传递为函数对象。 (以前的标准要求通过外部或内部(LD_PRELOAD)链接来传递作为模板参数的函数。)

constexpr

clang可以很好地进行编译,但是即使使用static

,g ++也会错误地拒绝它

不幸的是,gcc在使用lambda时存在一个错误(83258)。有关详细信息,请参见Can I use the result of a C++17 captureless lambda constexpr conversion operator as a function pointer template non-type argument?

不过,我们可以在模板中使用常规功能。

template <float simd_func(float c)>
void apply_template(float *x, const float *y, size_t length){
    #pragma omp simd
    for(size_t i = 0; i < length; i++)
         x[i] = simd_func(y[i]);
}


void test_lambda(float *__restrict x, const float *__restrict y, size_t length)
{
    // static // even static doesn't help work around the gcc bug
    constexpr auto my_op = [](float a) -> float {
         float h=0.5f*a; // halve first allows vmulps with a memory source operand
         return h*h;    // 0.25 * a * a doesn't optimize to that with clang :/
    };

    // I don't know what the unary + operator is doing here, but some examples use it
    apply_lambda<+my_op>(x, y, length); // clang accepts this, gcc doesn't
}

然后,我们从g ++ 8.2 -std=gnu++17得到了这样一个内部循环。请注意,我使用// `inline` is still necessary for it to actually inline with -fPIC (in a shared lib) inline float my_func(float a) { return 0.25f * a*a;} void test_template(float *__restrict x, const float *__restrict y, size_t length) { apply_lambda<my_func>(x, y, length); // not actually a lambda, just a function } 而不是先进行-O3 -fopenmp -march=haswell来查看我们得到了什么样的错误代码。这就是g ++ 8.2所做的。

0.25f * a * a;

如果gcc未使用索引寻址模式(在Haswell / Skylake上为stops it from micro-fusing),则最好重载两次相同的向量以保存指令是一个好主意。因此,该循环实际上发出的是7微妙的信号,每次迭代最多运行7/4个循环。

根据英特尔的优化手册,展开时,对于宽向量,每个时钟限制接近2读取+ 1写入显然是持续运行的问题。 (他们说Skylake可能每个时钟维持82个字节,而不是一个时钟中存储96个加载+的峰值。)如果不知道数据是否对齐,并且gcc8已针对未知的情况采用了乐观策略,这是不明智的,对齐数据:使用未对齐的加载/存储,并让硬件处理没有32字节对齐的情况。 gcc7和更早版本的指针在主循环之前对齐,并且仅将向量加载一次。


脚注1:幸运的是,gcc和clang可以将halve优化为.L25: vmulps ymm0, ymm1, YMMWORD PTR [rsi+rax] # ymm0 = 0.25f * y[i+0..7] vmulps ymm0, ymm0, YMMWORD PTR [rsi+rax] # reload the same vector again vmovups YMMWORD PTR [rdi+rax], ymm0 # store to x[i+0..7] add rax, 32 cmp rax, rcx jne .L25 ,避免升级为x / 2.

在没有x * 0.5f的情况下可以使用乘法而不是除法,因为double可以精确地表示为-ffast-math,而分母不是2的幂。

但是请注意,0.5f不会 优化为float; gcc和clang实际上确实扩展到0.5 * x并返回。我不确定这与0.5f * x相比是否错过了优化,或者不确定是否存在真正的语义差异,以至于当它可以完全表示为double时,无法将double常量优化为float。