Подтвердить что ты не робот

Может ли оптимизация компилятора блокироваться многопоточными?

Мне приходилось несколько раз распараллеливать часть программ с OpenMP, чтобы заметить, что в конце концов, несмотря на хорошую масштабируемость, большая часть предполагаемого ускорения была потеряна из-за плохой производительности однопоточного случая ( если сравнивать с серийной версией).

Обычное объяснение, которое появляется в Интернете для этого поведения, состоит в том, что код, сгенерированный компиляторами, может быть хуже в многопоточном случае. Во всяком случае, я не могу найти нигде ссылку, которая объясняет, почему сборка может быть хуже.

Итак, я бы хотел спросить у компилятора:

Можно оптимизировать оптимизацию компилятора с помощью многопоточности? В случае, как повлиять на производительность?

Если это может помочь сузить вопрос, меня интересуют в основном высокопроизводительные вычисления.

Отказ от ответственности. Как указано в комментариях, часть ответов ниже может стать устаревшей в будущем, поскольку они вкратце обсуждают, как оптимизация обрабатывается компиляторами во время постановки вопроса.

4b9b3361

Ответ 1

Я думаю, этот ответ достаточно подробно описывает причину, но я немного расширю здесь.

Прежде чем, однако, gcc 4.8 документация на -fopenmp:

-fopenmp
    Включить обработку директив OpenMP #pragma omp в C/С++ и! $Omp в Fortran. Когда задан параметр -fopenmp, компилятор генерирует параллельный код в соответствии с интерфейсом прикладных программ OpenMP v3.0 http://www.openmp.org/. Этот параметр подразумевает -pthread и, следовательно, поддерживается только для целей, поддерживающих -pthread.

Обратите внимание, что он не указывает на отключение любых функций. Действительно, нет никакой причины для gcc отключать любую оптимизацию.

Тем не менее причина, по которой openmp с 1 потоком имеет накладные расходы по отношению к openmp, заключается в том, что компилятор должен преобразовать код, добавив функции, чтобы он был готов для случаев с openmp с n > 1 потоками. Поэтому подумайте о простом примере:

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

Взяв diff, мы видим, что единственное различие заключается в том, что при -fopenmp мы определили -D_REENTRANT (и, конечно, -fopenmp). Итак, будьте уверены, gcc не приведет к ухудшению кода. Просто нужно добавить код подготовки, когда число потоков больше 1, и у него есть некоторые накладные расходы.


Обновление: Я действительно должен был проверить это с включенной оптимизацией. Во всяком случае, с gcc 4.7.3, вывод тех же команд, добавленных -O3, даст такую ​​же разницу. Таким образом, даже с -O3 отключена оптимизация.

Ответ 2

Сокращение от явных прагм для OMP, компиляторы просто не знают, что код может выполняться несколькими потоками. Поэтому они не могут сделать этот код более или менее эффективным.

Это имеет серьезные последствия в С++. Это особенно проблема для авторов библиотек, они не могут разумно угадать, будет ли их код использоваться в программе, использующей потоки. Очень заметно, когда вы читаете источник общей реализации C-runtime и стандартной библиотеки С++. Такой код, как правило, набивается небольшими замками по всему месту, чтобы гарантировать, что код по-прежнему работает правильно, когда он используется в потоках. Вы платите за это, даже если вы на самом деле не используете этот код в поточном режиме. Хорошим примером является std:: shared_ptr < > . Вы платите за атомное обновление счетчика ссылок, даже если интеллектуальный указатель используется только в одном потоке. И стандарт не предоставляет способ запросить неатомные обновления, предложение о добавлении функции было отклонено.

И это очень вредно и так, но компилятор не может сделать, чтобы ваш собственный код был потокобезопасным. Это полностью зависит от вас, чтобы сделать его потокобезопасным. Трудно делать, и это происходит неправильно в тонкой и очень сложной диагностике способов все время.

Большие проблемы, а не просто решить. Может быть, это хорошо, иначе все могут быть программистом;)

Ответ 3

Это хороший вопрос, даже если он довольно широкий, и я с нетерпением жду услышать от экспертов. Я думаю, @JimCownie хорошо отзывался об этом в следующем обсуждении Причины для omp_set_num_threads (1) медленнее, чем nompmp

Авто-векторизация и распараллеливание, я думаю, часто являются проблемой. Если вы включите автоматическую распараллеливание в MSVC 2012 (автоматическая векторизация включена по умолчанию), они, похоже, не хорошо сочетаются друг с другом. Использование OpenMP, похоже, отключает автоинъекцию MSVC. То же самое может быть верно для GCC с OpenMP и автоматической векторизации, но я не уверен.

Я вообще не доверяю автоинъекции в компиляторе. Одна из причин заключается в том, что я не уверен, что это делает цикл-разворачивание, чтобы исключить связанные петлевые зависимости, а также скалярный код. По этой причине я стараюсь и сам делаю это. Я сама выполняю вектозацию (используя векторный класс Agner Fog), и я самостоятельно разворачиваю петли. Делая это вручную, я чувствую себя более уверенным в том, что я максимизирую все parallelism: TLP (например, с OpenMP), ILP (например, путем удаления зависимостей данных с разворачиванием цикла) и SIMD (с явным кодом SSE/AVX).

Ответ 4

В приведенном выше разделе содержится много хорошей информации, но правильный ответ - некоторые оптимизации должны быть отключены при компиляции OpenMP. Некоторые компиляторы, такие как gcc, этого не делают.

Пример программы в конце этого ответа заключается в поиске значения 81 в четырех неперекрывающихся диапазонах целых чисел. Он всегда должен находить эту ценность. Тем не менее, для всех версий gcc, по крайней мере, до 4.7.2, программа иногда не заканчивается правильным ответом. Чтобы убедиться в этом, сделайте следующее:

  • Скопируйте программу в файл parsearch.c
  • Скомпилируйте его с помощью gcc -fopenmp -O2 parsearch.c
  • Запустите его с помощью OMP_NUM_THREADS=2 ./a.out
  • Запустите еще несколько (возможно, 10) раз, вы увидите два разных ответа.

В качестве альтернативы вы можете скомпилировать без -O0 и увидеть, что результат всегда правильный.

Учитывая, что программа свободна от условий гонки, это поведение компилятора под -O2 неверно.

Поведение связано с глобальной переменной globFound. Убедите себя, что при ожидаемом выполнении только одна из 4 потоков в parallel for записывает эту переменную. Семантика OpenMP определяет, что если глобальная (совместно используемая) переменная записывается только одним потоком, значение глобальной переменной после параллельного значения является значением, которое было написано этим единственным потоком. Между потоками через глобальную переменную не существует связи, и это не допускается, так как это приводит к возникновению условий гонки.

Что оптимизирует компилятор в -O2, так это то, что он оценил, что запись в глобальную переменную в цикле является дорогостоящей и поэтому кэширует ее в регистре. Это происходит в функции findit, которая после оптимизации будет выглядеть так:

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

Но с этим "оптимизированным" кодом каждый поток читает и записывает globFound, а сам ранг вводится самим компилятором.

Оптимизация компилятора должна быть осведомлена о параллельном выполнении. Отличный материал об этом опубликован Хансом-Дж. 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 ;
}