Каждая новая спецификация OpenMP вводит очень полезные и необходимые дополнения к уже существующему функционалу. Например, в версии 3.0 были добавлены так ожидаемые задачи (tasks), позволившие решать ещё больший спектр задач по распараллеливанию приложений. В 3.1 целый ряд улучшений по работе с задачами и редукциями.
Но по сравнению с тем, что нам теперь даёт стандарт 4.0, предыдущие нововведения кажутся какими-то мелкими. Последняя версия расширила типы поддерживаемого параллелизма, чего раньше никогда не замечалось.
Все мы знаем, что параллелизм – он ведь очень разный. Начинается он ещё на уровне инструкций, когда современные процессорные архитектуры предоставляют нам функционал конвейера и суперскалярности. Уже на этом уровне мы можем говорить о том, что наше приложение параллельно. Но это то, что уже заложено в «железе» на сегодняшний день, и что для разработчиков используется неявно. Есть у существующих на сегодняшний день процессоров и другие «сладости» — количество ядер и векторные регистры (SSE, AVX и т.д.).
Они дают нам ещё два типа параллелизма:
- по задачам.
В данном случае используются все ядра нашего процессора. Идея заключается в выделении независимых задач, которые одновременно могут выполняться на нескольких ядрах. - по данным (векторизация).
Используются регистры увеличенной длины каждого ядра. В этом случае происходит обработка сразу нескольких элементов (векторов) данных за одну операцию/инструкцию, отсюда и название «векторизация».
Вот, собственно, и все типы, доступные на системах с общей памятью. Стоит отметить, что есть ещё распределённые системы (привет кластерам и MPI), но это не фокус нашего сегодняшнего разговора. Мы говорим об OpenMP, а он классически «заточен» на системы с общей памятью.
Больше того, до последнего момента, а именно июля 2013 года, речь шла всегда о параллелизме по задачам. Причём использование OpenMP стало одним из наиболее популярных методов, универсальным в плане поддержки языка (подходит как для С/С++, так и Фортрана) и простым в плане реализации (идея добавления параллельности шаг за шагом через директивы).
Но не меньшую роль в плане производительности имеет и параллелизм по данным. Важно отметить, что векторизация – один из ключевых моментов при использовании компилятора Intel, если вы работаете над приложением, для которого действительно важна высокая производительность. Кстати, и для любого другого компилятора тоже. Поэтому относительно давно существует целый набор различных директив, помогающих векторизовать код – то есть сгенерировать такие инструкции, которые будут использовать всю длину регистров, будь то SSE, AVX или AVX2, или что-то ещё более «свежее» и «длинное». Не стоит забывать, что и на новой MIC архитектуре векторизация играет ключевую роль.
Поняли это и в комитете, работающем над спецификациями OpenMP. Поэтому волевым решением в последний документ были добавлены хорошо известные людям, работающим с компилятором Intel, директивы для работы с параллелизмом по данным, весьма напоминающие аналогичные из Intel® Cilk™ Plus.
Итак, что же появилось.
Теперь мы можем не просто «раскидывать» нашу работу по разным потокам через существовавшие и раньше директивы, но и обеспечить векторизацию соответствующего кода. И всё это в рамках OpenMP, то есть без привязки к конкретному компилятору, благо стандарты стремятся поддерживать все, рано или поздно.
Хотел бы сначала показать, как это было в Cilk Plus’е на простом примере.
Допустим, у нас есть некий цикл:
void add_floats(float *a, float *b, float *c, float *d, float *e, int n){
int i;
for (i=0; i<n; i++){
a[i] = a[i] + b[i] + c[i] + d[i] + e[i];
}
}
Так вот если мы скомпилируем его с ключом vec-report, чтобы узнать, был ли он векторизован, то увидим, что нет. Причина в консервативности компилятора – если он «чувствует», что векторизация опасна, то предпочтёт «перестраховаться» и ничего не делать. В нашем случае это происходит из-за большого количества указателей, переданных в функцию. Компилятор просто думает, что какие-то из них вполне могут иметь пересечения в памяти. Но если мы как разработчики знаем, что это не так и векторизовать этот цикл безопасно, можем использовать директиву pragma simd из Intel® Cilk™Plus:
#pragma simd
for (i=0; i<n; i++){
a[i] = a[i] + b[i] + c[i] + d[i] + e[i];
Пересобираем и вуаля – цикл векторизован! Но ещё раз – это специфично для интеловского компилятора. Теперь же такой функционал появился и в OpenMP, только директива стала в более «привычной» форме, а именно:
#pragma omp simd
Кроме того, есть и смешанная конструкция, для использования двух типов параллелизма одновременно:
#pragma omp parallel for simd
Стоит отметить, что использовать конструкцию в «голом» виде не очень рекомендовано, потому что компиляторные проверки для данного цикла полностью отключаются. Поэтому можно и нужно задавать, как и в других OMP директивах, дополнительные опции, помогающие компилятору (о длине вектора, о выравнивании памяти, о доступе к ней и т.д.). Кстати, они так же весьма похожи на те, что уже были в компиляторе Intel, но с небольшими изменениями.
Была так же добавлена и другая очень полезная вещь – так называемые элементные функции. Не секрет, что если в цикле вызывается некая функция, то в общем случае этот цикл не векторизуется. В Cilk Plus использовалось магическое __declspec(vector), позволяющее сделать из самой обычной функции элементную — ту, которая могла бы «выдавать» вектор результатов. В итоге её можно было вызывать и в цикле, с последующей его удачной векторизацией. В OpenMP это выглядит теперь так:
#pragma omp declare simd
Причём сейчас возрадуются и разработчики на Фортране, потому как в нём такой поддержки не было (Cilk Plus только для С/С++) до последних дней.
Таким образом, в руках разработчиков появился мощный инструмент в использовании всех типов параллелизма в рамках одной спецификации, что не может не радовать. Но это далеко не всё что появилось. Не обошли вниманием и крайне популярную сейчас модель программирования на «гибридных» системах с ускорителями. И не важно, какой тип ускорителя – GPU ли это, или какой другой неведомый зверь — модель его использования с точки зрения разработчика теперь общая, и она так же часть new OpenMP. С помощью директивы target мы теперь можем провести вычисления на ускорители и получить результаты на хосте:
#pragma omp target map(to(b:count)) map(to(c,d)) map(from(a:count))
{
#pragma omp parallel for
for (i=0; i<count; i++)
a[i] = b[i] * c + d;
}
Но это отдельная большая тема, о которой стоит поговорить. Ктасти OpenMP теперь всё чаще начинают сравнивать с другими стандартами, в частности OpenCL, OpenACC и другими. И интересное получается сравнение – с большим количеством «плюсов» для OpenMP. Но, вернёмся к теме.
Кроме всего вышесказанного, появились так же и другие «приятности», но я бы их отфильтровал как менее значимые, хотя и очень полезные для решения различного рода задач. Я говорю о новых средствах для обработки ошибок, о привязке потоков к конкретным ядрам, новых фичах для задач (tasking extensions), поддержке user-defined редукций и ряде других нововведений.
Полное описание можно классически найти на сайте OpenMP. Ну а один из первых компиляторов, который уже частично поддерживает ряд новых функций, здесь. Как всегда доступна и пробная 30-дневная версия. Так что welcome пробовать новые возможности OpenMP 4.0!
Автор: ivorobts