c-打开MP:SIMD循环中的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中受益?

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

>函数指针具有编译时常数值
>指向编译器可以看到其定义的函数

听起来很容易确定,但是如果在Unix / Linux上的共享库(与-fPIC编译)中使用此代码,则符号插入规则意味着float halve(float x){return x * 0.5f; } 1甚至不能在同一翻译单元中内联.参见Sorry state of dynamic libraries on Linux.

使用inline关键字即使在构建共享库时也可以进行内联;像静态一样,如果编译器决定在每个调用位置进行内联,则它根本不会发出该函数的独立定义.

在一半,正方形和apply_simd上使用内联. (因为apply_simd需要内联到作为函数arg传递一半的调用程序中.apply_simd的独立定义没有用,因为它不能内联未知函数.)如果它们位于.cpp而不是.h中,您也可以将它们设置为静态,也可以改为使其为静态,否则只需将其内联.

一次完成尽可能多的工作

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

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.

仅执行0.5f复制乘以的循环通常会成为内存带宽的瓶颈.像Haswell(或Skylake)这样的现代CPU的FMA / mul(或增加)吞吐量(每个时钟2x 256位向量)是存储带宽的两倍(每个时钟1x 256位向量至L1d).计算强度很重要.不要通过编写执行单独的琐碎操作的多个循环来修饰代码

如果展开任何循环,或者如果数据不适合L1d,则SIMD x [i] = 0.25f * y [i] * y [i]的吞吐量将与其中任何一个操作相同.

我检查了g 8.2和clang 6.0 on the Godbolt compiler explorer的asm输出.即使使用__restrict告诉它x和y不重叠,编译器仍然进行了2个单独的循环.

传递lambda作为函数指针

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

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

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

// 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 11来电者:

// __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);
}

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

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;
      }
    );
}

它们都可以使用gcc和clang高效地编译为这样的内部循环(Godbolt compiler explorer).

.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

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

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

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

但是仅使用一个函数指针,它实际上并不能帮助您捕获忘记使用inline关键字或破坏编译器内联能力的情况.它仅表示函数地址必须是动态链接时间常数(即直到动态库的运行时绑定才知道),因此不会使您免于插入符号的麻烦.在使用-fPIC进行编译时,编译器仍然不知道它可以看到的全局函数的版本是否是链接时实际解析的版本,或者LD_PRELOAD或主可执行文件中的符号是否会覆盖它.因此,它只是发出从GOT加载函数指针的代码,并在循环中调用它. SIMD当然是不可能的.

但是,通过以并非总是内联的方式传递函数指针,确实可以阻止您脚踏实地.也许使用constexpr,您仍然可以在模板中使用它们之前将它们作为args传递.因此,如果不是因为gcc错误导致您无法将其与lambdas配合使用,则可能要使用它.

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

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
}

clang编译就很好了,但是即使使用-std = gnu 17,g也会错误地拒绝它

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

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

// `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
}

然后我们从g 8.2 -O3 -fopenmp -march = haswell得到了这样一个内部循环.注意,我使用了0.25f * a * a;而不是先减半,看看我们得到什么样的错误代码.这就是g 8.2所做的.

.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

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

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

脚注1:幸运的是,gcc和clang可以将x / 2优化为x * 0.5f,避免了提升为两倍.

没有-ffast-math的情况下可以使用乘法而不是除法,因为与分母不是2的幂的分数不同,0.5f可以精确地表示为浮点数.

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

转载注明原文:c-打开MP:SIMD循环中的SIMD兼容功能? - 代码日志