首页 > 解决方案 > 打开 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 中受益?

标签: c++gccg++openmpsimd

解决方案


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

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

这听起来很容易确保,但是如果此代码用于 Unix/Linux 上的共享库(使用 编译-fPIC ,那么符号插入规则意味着float halve(float x) { return x * 0.5f; }1即使在同一个翻译单元中也不能内联。请参阅Linux 上动态库的抱歉状态

即使在构建共享库时,也可以使用inline关键字允许内联;就像static它允许编译器根本不发出函数的独立定义一样,如果它决定在每个调用站点内联。

inlinehalvesquare和上使用apply_simd。(因为apply_simd需要内联到作为函数 arg 传递的调用者中halve。独立定义 ofapply_simd是无用的,因为它不能内联未知函数。)如果它们在 a.cpp而不是 a 中.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.

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

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

我在 Godbolt compiler explorer 上检查了 g++ 8.2 和 clang++ 6.0 的 asm 输出。即使__restrict告诉它 x 和 y 不重叠,编译器仍然制作了 2 个单独的循环。


将 lambda 作为函数指针传递

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

如果您的halve(float)函数是重要事物的占位符,则可以在 lambda 中使用它来将其与其他事物组合。例如square(halve(a))

在早期的 C++ 标准中,您需要将 lambda 分配给函数指针。( Lambda 作为函数参数)

// 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 编译器资源管理器

.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 错误阻止您将其与 lambda 一起使用,您可能想要使用它。

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

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 编译得很好,但是 g++ 错误地拒绝了它,即使-std=gnu++17

不幸的是,gcc 有一个错误(83258)以这种方式使用 lambdas。 请参阅我可以使用 C++17 无捕获 lambda constexpr 转换运算符的结果作为函数指针模板非类型参数吗?详情。

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

// `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;而不是先做halve,看看我们得到什么样的错误代码。这就是 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 上进行微融合。所以这个循环实际上是 7 个微指令,每次迭代最多运行 7/4 个循环。

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


脚注 1:幸运的是 gcc 和 clang 可以优化x / 2.x * 0.5f,避免升级到double.

使用乘法而不是除法是可能的,-ffast-math因为0.5f它完全可以表示为 a float,这与分母不是 2 的幂的分数不同。

请注意,0.5 * x不会优化到; gcc 和 clang 实际上确实可以前后扩展。我不确定这是否是与.0.5f * xdoublex / 2.float


推荐阅读