22

有几次我用 OpenMP 并行化部分程序只是为了注意到最后,尽管具有良好的可扩展性,但由于单线程情况的性能不佳(如果与串行版本)。

对于这种行为,网络上出现的通常解释是编译器生成的代码在多线程情况下可能会更糟。无论如何,我无法在任何地方找到解释为什么装配可能更糟的参考。

所以,我想问编译器的人是:

多线程可能会抑制编译器优化吗?万一,性能如何受到影响?

如果它可以帮助缩小我主要对高性能计算感兴趣的问题。

免责声明:如评论中所述,下面的部分答案可能会在将来过时,因为它们简要讨论了在提出问题时编译器处理优化的方式。

4

4 回答 4

6

除了 OMP 的显式编译指示之外,编译器只是不知道代码可以由多个线程执行。因此,他们既不能提高代码效率,也不能降低代码效率。

这在 C++ 中产生了严重的后果。这对库作者来说尤其是一个问题,他们无法合理地预先猜测他们的代码是否将用于使用线程的程序中。当您阅读常见 C 运行时和标准 C++ 库实现的源代码时非常明显。这样的代码往往到处都是小锁,以确保代码在线程中使用时仍然可以正确运行。您为此付费,即使您实际上并未以线程方式使用该代码。一个很好的例子是 std::shared_ptr<>。即使智能指针只在一个线程中使用过,您也需要为引用计数的原子更新付费。而且该标准没有提供请求非原子更新的方法,添加该功能的提议被拒绝了。

另一方面,它也非常有害,编译器无法做任何事情来确保您自己的代码是线程安全的。让它成为线程安全的完全取决于你。很难做到,而且这总是以微妙且非常难以诊断的方式出错。

大问题,解决起来并不简单。也许这是一件好事,否则每个人都可以成为程序员;)

于 2013-05-29T11:36:35.623 回答
6

我认为这个答案充分描述了原因,但我会在这里扩展一下。

然而,在此之前,这里是gcc 4.8 的文档-fopenmp

-fopenmp
在 C/C++ 中启用 OpenMP 指令 #pragma omp 和 Fortran 中的 !$omp 处理。指定 -fopenmp 时,编译器根据 OpenMP 应用程序接口 v3.0 http://www.openmp.org/生成并行代码。此选项暗示 -pthread,因此仅在支持 -pthread 的目标上受支持。

请注意,它没有指定禁用任何功能。事实上,gcc 没有理由禁用任何优化。

然而,为什么具有 1 个线程的 openmp 相对于没有 openmp 具有开销的原因是编译器需要转换代码,添加函数,以便它可以为具有 n>1 个线程的 openmp 的情况做好准备。所以让我们想一个简单的例子:

int *b = ...
int *c = ...
int a = 0;

#omp parallel for reduction(+:a)
for (i = 0; i < 100; ++i)
    a += b[i] + c[i];

这段代码应该被转换成这样的:

struct __omp_func1_data
{
    int start;
    int end;
    int *b;
    int *c;
    int a;
};

void *__omp_func1(void *data)
{
    struct __omp_func1_data *d = data;
    int i;

    d->a = 0;
    for (i = d->start; i < d->end; ++i)
        d->a += d->b[i] + d->c[i];

    return NULL;
}

...
for (t = 1; t < nthreads; ++t)
    /* create_thread with __omp_func1 function */
/* for master thread, don't create a thread */
struct master_data md = {
    .start = /*...*/,
    .end = /*...*/
    .b = b,
    .c = c
};

__omp_func1(&md);
a += md.a;
for (t = 1; t < nthreads; ++t)
{
    /* join with thread */
    /* add thread_data->a to a */
}

现在,如果我们使用 运行它nthreads==1,代码实际上会简化为:

struct __omp_func1_data
{
    int start;
    int end;
    int *b;
    int *c;
    int a;
};

void *__omp_func1(void *data)
{
    struct __omp_func1_data *d = data;
    int i;

    d->a = 0;
    for (i = d->start; i < d->end; ++i)
        d->a += d->b[i] + d->c[i];

    return NULL;
}

...
struct master_data md = {
    .start = 0,
    .end = 100
    .b = b,
    .c = c
};

__omp_func1(&md);
a += md.a;

那么无openmp版本和单线程openmp版本有什么区别呢?

一个区别是有额外的胶水代码。需要传递给openmp创建的函数的变量需要放在一起形成一个参数。所以有一些开销准备函数调用(以及稍后检索数据)

然而,更重要的是,现在代码不再是一体的。跨功能优化还没有那么先进,大多数优化都是在每个功能内完成的。较小的功能意味着优化的可能性较小。


为了完成这个答案,我想向您确切展示-fopenmp影响gcc的选项。(注意:我现在在一台旧电脑上,所以我有 gcc 4.4.3)

运行gcc -Q -v some_file.c给出了这个(相关的)输出:

GGC heuristics: --param ggc-min-expand=98 --param ggc-min-heapsize=128106
options passed:  -v a.c -D_FORTIFY_SOURCE=2 -mtune=generic -march=i486
 -fstack-protector
options enabled:  -falign-loops -fargument-alias -fauto-inc-dec
 -fbranch-count-reg -fcommon -fdwarf2-cfi-asm -fearly-inlining
 -feliminate-unused-debug-types -ffunction-cse -fgcse-lm -fident
 -finline-functions-called-once -fira-share-save-slots
 -fira-share-spill-slots -fivopts -fkeep-static-consts -fleading-underscore
 -fmath-errno -fmerge-debug-strings -fmove-loop-invariants
 -fpcc-struct-return -fpeephole -fsched-interblock -fsched-spec
 -fsched-stalled-insns-dep -fsigned-zeros -fsplit-ivs-in-unroller
 -fstack-protector -ftrapping-math -ftree-cselim -ftree-loop-im
 -ftree-loop-ivcanon -ftree-loop-optimize -ftree-parallelize-loops=
 -ftree-reassoc -ftree-scev-cprop -ftree-switch-conversion
 -ftree-vect-loop-version -funit-at-a-time -fvar-tracking -fvect-cost-model
 -fzero-initialized-in-bss -m32 -m80387 -m96bit-long-double
 -maccumulate-outgoing-args -malign-stringops -mfancy-math-387
 -mfp-ret-in-387 -mfused-madd -mglibc -mieee-fp -mno-red-zone -mno-sse4
 -mpush-args -msahf -mtls-direct-seg-refs

并运行gcc -Q -v -fopenmp some_file.c给出这个(相关的)输出:

GGC heuristics: --param ggc-min-expand=98 --param ggc-min-heapsize=128106
options passed:  -v -D_REENTRANT a.c -D_FORTIFY_SOURCE=2 -mtune=generic
 -march=i486 -fopenmp -fstack-protector
options enabled:  -falign-loops -fargument-alias -fauto-inc-dec
 -fbranch-count-reg -fcommon -fdwarf2-cfi-asm -fearly-inlining
 -feliminate-unused-debug-types -ffunction-cse -fgcse-lm -fident
 -finline-functions-called-once -fira-share-save-slots
 -fira-share-spill-slots -fivopts -fkeep-static-consts -fleading-underscore
 -fmath-errno -fmerge-debug-strings -fmove-loop-invariants
 -fpcc-struct-return -fpeephole -fsched-interblock -fsched-spec
 -fsched-stalled-insns-dep -fsigned-zeros -fsplit-ivs-in-unroller
 -fstack-protector -ftrapping-math -ftree-cselim -ftree-loop-im
 -ftree-loop-ivcanon -ftree-loop-optimize -ftree-parallelize-loops=
 -ftree-reassoc -ftree-scev-cprop -ftree-switch-conversion
 -ftree-vect-loop-version -funit-at-a-time -fvar-tracking -fvect-cost-model
 -fzero-initialized-in-bss -m32 -m80387 -m96bit-long-double
 -maccumulate-outgoing-args -malign-stringops -mfancy-math-387
 -mfp-ret-in-387 -mfused-madd -mglibc -mieee-fp -mno-red-zone -mno-sse4
 -mpush-args -msahf -mtls-direct-seg-refs

比较一下,我们可以看到唯一的区别是-fopenmp我们已经-D_REENTRANT定义了(当然也-fopenmp启用了)。所以,请放心,gcc 不会产生更糟糕的代码。只是它需要为线程数大于1时添加准备代码并且有一些开销。


更新:我真的应该在启用优化的情况下对此进行测试。无论如何,使用 gcc 4.7.3,添加相同命令的输出-O3将给出相同的差异。因此,即使使用-O3,也没有禁用优化。

于 2013-05-29T16:09:44.710 回答
3

这是一个很好的问题,即使它相当广泛,我期待着听取专家的意见。我认为@JimCownie 在以下讨论中对此有很好的评论omp_set_num_threads(1) 比没有 openmp 慢的原因

我认为自动矢量化和并行化通常是一个问题。如果您在 MSVC 2012 中打开自动并行化(自动矢量化是我的默认设置),它们似乎不能很好地混合在一起。使用 OpenMP 似乎禁用了 MSVC 的自动矢量化。具有 OpenMP 和自动矢量化的 GCC 可能也是如此,但我不确定。

无论如何,我并不真正相信编译器中的自动矢量化。一个原因是我不确定它是否会进行循环展开以消除携带循环依赖以及标量代码。出于这个原因,我尝试自己做这些事情。我自己进行矢量化(使用 Agner Fog 的矢量类)并自己展开循环。通过手动执行此操作,我对最大化所有并行性感到更加自信:TLP(例如,使用 OpenMP)、ILP(例如,通过展开循环消除数据依赖关系)和 SIMD(使用显式 SSE/AVX 代码)。

于 2013-05-29T09:37:35.077 回答
3

上面有很多很好的信息,但正确的答案是在编译 OpenMP 时必须关闭一些优化。一些编译器,例如 gcc,不这样做。

此答案末尾的示例程序是在四个不重叠的整数范围中搜索值 81。它应该总是找到那个值。但是,在至少 4.7.2 之前的所有 gcc 版本上,程序有时不会以正确的答案终止。要亲自查看,请执行以下操作:

  • 将程序复制到文件中parsearch.c
  • 编译它gcc -fopenmp -O2 parsearch.c
  • 运行它OMP_NUM_THREADS=2 ./a.out
  • 再运行几次(可能是 10 次),你会看到两个不同的答案

或者,您可以不使用 进行编译-O0,并查看结果始终正确。

鉴于程序没有竞态条件,编译器的这种行为-O2是不正确的。

该行为是由于全局变量globFound。请说服自己,在预期的执行下,只有 4 个线程中的一个会parallel for写入该变量。OpenMP 语义定义如果全局(共享)变量仅由一个线程写入,则在 parallel-for 之后的全局变量的值是由该单个线程写入的值。线程之间没有通过全局变量进行通信,这是不允许的,因为它会引起竞争条件。

编译器优化所做的-O2是它估计在循环中写入全局变量是昂贵的,因此将其缓存在寄存器中。这发生在函数findit中,经过优化后,它看起来像:

int tempo = globFound ;
for ( ... ) {
    if ( ...) {
        tempo = i;
    }
globFound = tempo;

但是有了这个“优化”的代码,每个线程都会读写globFound,并且编译器本身会引入竞争条件。

编译器优化确实需要注意并行执行。Hans-J 出版了关于这方面的优秀材料。Boehm,在内存一致性的一般主题下。

#include <stdio.h>
#define BIGVAL  (100 * 1000 * 1000)

int globFound ;

void findit( int from, int to )
{
    int i ;

    for( i = from ; i < to ; i++ ) {
        if( i*i == 81L ) {
            globFound = i ;
        }
    }
}

int main( int argc, char *argv )
{
    int p ;

    globFound = -1 ;

    #pragma omp parallel for
    for( p = 0 ; p < 4 ; p++ ) {
        findit( p * BIGVAL, (p+1) * BIGVAL ) ;
    }
    if( globFound == -1 ) {
        printf( ">>>>NO 81 TODAY<<<<\n\n" ) ;
    } else {
        printf( "Found! N = %d\n\n", globFound ) ;
    }
    return 0 ;
}
于 2014-04-29T20:19:29.693 回答