多线程可以抑制编译器优化吗?

时间:2013-05-29 07:19:25

标签: c++ c multithreading openmp compiler-optimization

我偶然发现有几次将部分程序与OpenMP并行化只是为了注意到最后,尽管具有良好的可扩展性,但由于单线程外壳的性能不佳,大多数预见的加速都丢失了(如果与串口版本相比)。

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

所以,我想问那些编译人员:

多线程可以抑制编译器优化吗?如果性能如何受到影响?

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

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

4 个答案:

答案 0 :(得分:6)

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

这在C ++中会产生严重后果。对于图书馆作者来说,这是一个特别的问题,他们无法合理地预测他们的代码是否会在使用线程的程序中使用。在阅读公共C运行库和标准C ++库实现的源代码时非常明显。这样的代码往往充满了整个地方的小锁,以确保代码在线程中使用时仍能正常运行。即使您没有以线程方式使用该代码,您也需要付费。一个很好的例子是std :: shared_ptr<>。您支付引用计数的原子更新,即使智能指针仅在一个线程中使用过。并且标准没有提供要求非原子更新的方法,添加该功能的提议被拒绝。

另一方面它也是有害的,编译器没有任何东西可以确保你自己的代码是线程安全的。完全取决于你使线程安全。很难做到,而且这种方法一直存在微妙且非常难以诊断的问题。

大问题,不易解决。也许那是件好事,否则每个人都可能成为程序员;)

答案 1 :(得分:5)

我认为this answer充分描述了原因,但我会在这里进行一些扩展。

之前,这里是gcc 4.8's documentation on -fopenmp

  

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

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

然而,为什么openmp与1个线程相比没有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;

那么no 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,也没有禁用优化。

答案 2 :(得分: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引起的。请说服自己在预期的执行情况下,parallel for中的4个线程中只有一个写入该变量。 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 ;
}

答案 3 :(得分:2)

这是一个很好的问题,即使它相当广泛,我期待着听到专家的意见。我认为@JimCownie在以下讨论中对此有一个很好的评论Reasons for omp_set_num_threads(1) slower than no openmp

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

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