Сказ о том, как «цифирь» не сошлась

745e81c7c36e4e1280994fb0cf9d1579.png


Некоторое время назад я писал про то, как получать воспроизводимые результаты и какие сложности с этим связаны. Также подробно рассказал про модели, позволяющие контролировать работу с числами с плавающей точкой в компиляторе и отдельно уточнил, что, если мы используем какие-либо библиотеки или стандарты, то должны позаботится, чтобы нужные флаги были указаны и для них. И вот совсем недавно я натолкнулся на интересную проблемку, связанную именно с воспроизводимостью результатов при работе с OpenMP.
Что такое воспроизводимость? Да всё просто — мы хотим получать одну и ту же «хорошую цифирь» от запуска к запуску, потому что для нас это важно. Это критично во многих областях, где сейчас активно используются параллельные вычисления.
Итак, как вы помните, для машинных вычислений существенную роль играет порядок суммирования, и, если у нас имеются циклы, распараллеленные с помощью любой технологии, то неизбежно возникнет проблема воспроизводимости результатов, потому что никто не знает в каком порядке будет проводиться суммирование, и на сколько «кусков» будет разбит наш исходный цикл. В частности это проявляется при использовании OpenMP в редукциях.
Рассмотрим простой пример.

!$OMP PARALLEL DO schedule(static)
do i=1,n
  a=a+b(i)
enddo

В этом случае при использовании статического планировщика мы просто разобьем всё пространство итераций на равное количество частей и дадим каждому потоку выполнить эти итерации. Скажем, если у нас 1000 итераций, то при работе 4 потоков мы получим по 250 итераций «на брата». Наш массив является примером общих данных для разных потоков, и поэтому нам нужно позаботится о безопасности кода. Вполне рабочий вариант использовать редукцию и вычислять в каждом потоке своё значение, а затем складывать полученные «промежуточные» результаты:

!$OMP PARALLEL DO REDUCTION(+:a) schedule(static)
do i=1,n
  a=a+b(i)
enddo


Так вот, даже на таком простом примере получить разброс в значениях можно достаточно просто.
Я поменял число потоков с помощью OMP_SET_NUM_THREADS и получил, что при 2 потоках a= 204.5992, а при 4 уже 204.6005. Способ инициализации массива b (i) и a я опустил.
Интересно то, что говорить о воспроизводимых результатах можно только при соблюдении целого ряда условий. Так вот, архитектура, ОС, версия компилятора, которым собиралось приложение и число потоков должно быть всегда постоянным от запуска к запуску. Если мы изменили число потоков, то результаты будут отличаться, и это абсолютно нормально. Тем не менее, даже при соблюдении всех этих условий результат всё равно может отличаться, и здесь нам должна помочь переменная окружения KMP_DETERMINISTIC_REDUCTION и статический планировщик. Оговорюсь, что её использование не даст нам гарантию совпадения результатов параллельной и последовательной версий приложения, равно как и с другим запуском, при котором использовалось отличное количество потоков. Это важно понимать.

Речь о достаточно узком случае, когда мы действительно ничего не меняли, а результаты не сошлись. И вот самый главный сюрприз заключается в том, что в некоторых случаях и KMP_DETERMINISTIC_REDUCTION не работает, хотя мы и «играли по правилам».
Такой код, который незначительно сложнее первого примера, даёт различные результаты:

!$OMP PARALLEL DO REDUCTION(+:ue) schedule(static)
    do is=1,ns
        do y=1,ny
            do x=1,nx
                ue(x,y)=ue(x,y) + ua(x,y,is)
            enddo
        enddo
    enddo
!$OMP END PARALLEL DO

Даже после выставленной переменной KMP_DETERMINISTIC_REDUCTION, ничего не изменилось. Почему? Оказывается, в некоторых случаях компилятор из соображений производительности создаёт свою собственную реализацию цикла с использованием локов, и не заботится о результатах при этом. Эти случаи легко отследить по ассемблеру. В «хороших» вариантах обязательно должен быть вызов функции __kmp_reduce_nowait. А вот для моего примера подобного сделано не было, что несколько подрывает доверие к KMP_DETERMINISTIC_REDUCTION.

Итак, если вы написали код и результаты ваших вычислений скачут от запуска к запуску, не спешите посыпать голову пеплом. Отключите оптимизации и запустите ваше приложение. Проверьте, выравнены ли ваши данные и задайте «строгую» модель работы с числами с плавающей точкой. Для теста могут быть полезны следующие опции компилятора:

ifort -O0 -openmp -fp-model strict -align array32byte


Если и при таком наборе опций результаты вас удивляют, проверьте циклы, распараллеленные с помощью OpenMP и редукций и включите KMP_DETERMINISTIC_REDUCTION. Это может сработать и решить проблему. Если нет, то посмотрите на ассемблер и проверьте наличие вызова __kmp_reduce_nowait. В случае наличия этого вызова — проблема, вероятно, не с OpenMP и не с компилятором, а в код закралась ошибка. Кстати, проблему с KMP_DETERMINISTIC_REDUCTION мы должны решить в скором времени. Но учитывайте эту особенность уже сейчас.

© Habrahabr.ru