【问题标题】:May compiler optimizations be inhibited by multi-threading?多线程可能会抑制编译器优化吗?
【发布时间】:2013-05-24 08:26:54
【问题描述】:

在我身上发生过几次使用 OpenMP 并行化部分程序只是为了注意到最后,尽管具有良好的可扩展性,但由于单线程情况的性能不佳,大部分可预见的加速都失去了(如果与串行版本相比)。

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

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

编译器优化会被多线程抑制吗?万一,性能会受到怎样的影响?

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

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

【问题讨论】:

  • 如果您只包含您的代码示例和分析结果,它会更容易回答。
  • @GManNickG 老实说,我还没有准备好足够小的样本来张贴在这里。无论如何,我更感兴趣的是查看被多线程环境阻止的最简单的编译器优化示例(简要说明为什么会阻止它们),而不是解决特定问题的方法。希望能够将从这些示例中获得的知识外推到实际项目中。
  • 完全不确定这是否是 SO 的主题,尽管它可能很有趣。这更像是在做研究。
  • 我对OpenMP不太了解,它是使用用户线程还是内核线程?如果是用户线程,并且您正在使用大量系统调用,那么预计性能会很差
  • 在编译多线程代码时必须禁用机会性写入(优化可能导致写入内存的情况,否则不会发生),否则无法编写合理的多线程代码。

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


【解决方案1】:

上面有很多很好的信息,但正确的答案是在编译 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 ;
}

【讨论】:

    【解决方案2】:

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

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

    -fopenmp
    在 C/C++ 中启用 OpenMP 指令 #pragma omp 和 Fortran 中的 !$omp 处理。指定 -fopenmp 时,编译器根据 OpenMP Application Program Interface 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,也没有禁用优化。

    【讨论】:

    • 对 GCC @Shahbaz 的很好解释。看到关于 MSVC 的类似讨论会很有趣。
    • @raxman,谢谢。我最近才学习 OpenMP,所以我对它没有太多经验。我不知道MSVC。但是,如果您可以找到一个输出启用优化的选项,您可以对其进行测试。否则,一般的解释是独立于gcc的(这更像是我对OpenMP如何实现的想象)
    • 我也是最近才学习 OpenMP(现在大约 3 个月)。我使用过 pthreads(也在 Windows 上),所以你的解释很清楚。我不知道 MS 如何在 MSVC 中实现 OpenMP。我确实知道它的自动矢量化与 OpenMP 冲突,所以这曾经是 GCC 和 MSVC 之间的区别。我白天使用 MSVC,晚上使用 GCC,所以我都被困住了。
    【解决方案3】:

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

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

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

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

    【讨论】:

      【解决方案4】:

      这是一个很好的问题,即使它相当广泛,我期待着听取专家的意见。我认为@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 代码)。

      【讨论】:

      • 旧 GCC 版本无法证明 OpenMP 区域中数组的非混叠条件,因此无法正确矢量化,但最近的版本修复了这个问题 - 请参阅 this question。在矢量化方面,我真的不会试图超越编译器,尤其是英特尔的编译器。为了达到最大性能,必须实施适当的预取,有时还需要使用非临时内存访问。该编译器带有启发式引擎,该引擎使用依赖于 CPU 的成本函数,并且在手动编程时很难被击败。
      • 关于 GCC 和自动矢量化的好信息。但是,我使用 ICC 和 GCC,在许多情况下编写自己的 SIMD 代码会更快,例如获得高效 GEMM 的唯一方法是显式实现 SSE/AVX(我自己的代码获得 70% 的效率 -> 现在比 Eigen 更好)。编译器不会为你做这件事。另请参阅我对矩阵转置的回答的结尾。 stackoverflow.com/questions/16737298/… 编译器也无法解决这个问题。
      猜你喜欢
      • 2017-04-24
      • 2022-11-10
      • 2011-10-30
      • 2012-06-09
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2015-01-11
      • 1970-01-01
      相关资源
      最近更新 更多