Evg |
СОДЕРЖАНИЕ
Первые шаги в OpenMP
Запись от Evg размещена 26.11.2014 в 21:40
Показов 93123
Комментарии 12
1. Предисловие Прежде всего хотелось бы объяснить, зачем (нафига) я взялся за написание того, что и так уже много где описано Сложность восприятия прочитанного материала - это субъективное понятие, сильно зависящее от склада ума конкретного человека. Одним людям проще понять, когда прочтёшь всю теорию и только потом берёшься за практику. Другим людям проще сначала пощупать функционал на практике, эмпирически догадываясь, как он работает, и только потом читать теоретическое описание и укладывать его под практически увиденные результаты. Третьи людям проще понимать, когда сначала идёт немного практики, потом немного теории, потом опять немного практики и опять немного теории и т.п. Из-за этого получается, что одна и та же книга/статья разными людьми воспринимается по разному: одним всё просто и понятно, другие не могут свести концы с концами. Многое зависит в том числе и от уровня начальной подготовки читателя. В своё время мне приходилось читать разную литературу по OpenMP (правда только на русском языке) и из того, что я читал или сейчас могу по быстрому найти поиском, мне ни в одной книге/статье не понравилась последовательность изложения информации. А когда тебе что-то не нравится, то один из способов решить проблему - сделать это самому. В данной статье я не собираюсь писать полноценный учебник по OpenMP. Я всего лишь хочу сделать попытку правильно (с моей точки зрения) изложить самые первые шаги на понятных примерах от простых к сложным. И надеюсь, что после прочтения статьи у читателя появится хоть какое-то понимание того, как использовать OpenMP в реальной жизни, а следовательно, сильно упростится процесс чтения учебников/спецификаций и освоения прочитанного. Я не буду глубоко внедряться в вопросы из разряда "а как это работает", поскольку об этом можно прочитать в литературе В качестве примеров я буду использовать в том числе и "плохие" тесты, которые демонстрируют проблему. Такие примеры буду явно отмечать в комментариях, чтобы проще было тем, кому понадобится читать статью с середины (т.е. использовать в качестве справочника с примерами) Я предполагаю, что читатель понимает, для чего нужны многоядерные/многопроцессорные системы, что такое поток (по английски "thread", по русски обычно называют словами "поток" или "нить"), что многопоточная программа имеет практический смысл только на многоядерной/многопроцессорной системе. Ну и, разумеется, понимает, что распараллеливание на несколько потоков нужно для ускорения работы программы (т.е. для сокращения времени исполнения). Полагаю так же, что читатель хотя бы в теории понимает, что такое многопоточное программирование и какие у него особенности работы с данными программы и внешними ресурсами 2. Немного теории 2.1. Программная модель OpenMP Программа, которую хочется распараллелить при помощи OpenMP, должна состоять из цепочки последовательных и параллельных участков (регионов) кода, как это изображено на картинке (картинка взята с сайта computing.llnl.gov): На данной картинке слева направо отображён ход исполнения программы. Там, где нарисована одна горизонтальная линия, программа выполняет последовательные действия. Там, где в столбик отображено несколько горизонтальных линий (написано "parallel region"), программа выполняется в несколько потоков. В точке завершения параллельного региона происходит ожидание завершения исполнения всех запущенных потоков, и только после этого наступает момент завершения параллельного региона. После завершения параллельного региона продолжается исполнение программы в последовательном режиме и так далее. Каждая ветвь исполнения в параллельном регионе внутри себя может быть устроена таким же образом (т.е. иметь параллельные регионы внутри параллельного региона). На надпись "master thread" можно не обращать внимания, т.к. в рамках данной статьи мы подобные тонкости описывать не будем В качестве примера программы, работающей в такой модели, можно привести программу, которая сначала читает данные из файла или из сети (последовательное действие), далее обрабатывает их в несколько потоков (параллельное действие), затем записывает результат в файл или отправляет по сети (последовательной действие) и далее всё повторяется сначала. 2.2. Идеология OpenMP Смысл использования OpenMP заключается в том, что мы берём программу, которая работает последовательно и, путём добавления в исходник программы небольшого количества специальных директив, превращаем программу в набор последовательных и параллельных регионов (параллельные регионы будут исполняться в несколько потоков). При этом нужно понимать, что для использования OpenMP подойдёт не любой исходник. Программу нужно писать таким образом, чтобы её работа укладывалась в программную модель, описанную в разделе 2.1. Если в программе в принципе нет участков, которые можно было бы исполнять параллельно (т.е. все фрагменты строго последовательно зависят друг от друга), то такую программу нельзя распараллелить Поддержка компиляторами директив OpenMP делается по специальной опции. При этом подразумевается, что исходник программы пишется таким образом, чтобы он был корректным в том числе и в варианте без распараллеливания. Т.е. берём исходник, компилируем его обычным образом, получаем последовательно работающую программу. Далее берём тот же самый исходник, компилируем с дополнительной опцией поддержки OpenMP, получаем многопоточную программу, которая на выходе формирует такой же результат, что и последовательная программа. При этом на программиста возлагается обязанность правильного написания исходников, никакими автоматическими средствами контроль за криво написанными программами не производится. Такой подход имеет дополнительный бонус: программу можно по частям отладить в последовательном режиме, а потом уже суммарно запускать в параллельном режиме. Отдельным вопросом является "а что считать результатом исполнения программы". Ведь если мы в каждом исполняющемся потоке добавим печать, то последовательная и параллельная версия программы будут выдавать разные печати. Точно так же печати будут разными при выполнении в разное количество потоков. Поэтому программист должен сам для себя решить, что является результатом выполнения программы. Если нужно посчитать сумму чисел от 1 до N, то результатом работы должна быть вычисленная сумма, а все отладочные печати, варьирующиеся от количества потоков, к результату не относить и считать побочным выхлопом работы программы (отладочным средством). Нужно так же понимать, что при работе с вещественной арифметикой на процессорах НЕ соблюдается математический принцип ассоциативности, а потому при накопительных вычислениях результат может немного меняться в зависимости от количества запущенных потоков. Немного подробнее об этом будет рассказано в разделе 3.7.2 2.3. Как в компиляторе включить поддержку OpenMP По умолчанию в компиляторах поддержка OpenMP выключена. Для её включения следует использовать дополнительную опцию
Все компиляторы в режиме поддержки OpenMP взводят предопределённый макрос _OPENMP 2.4. Пара слов о синтаксисе директивы #pragma в языках C/C++ OpenMP поддерживается для языков C/C++ и Fortran. У меня нет практики работы на Fortran'е, а потому я не стану лезть в ту область, в которой не разбираюсь. Всё то, что справедливо для языка C в контексте OpenMP, справедливо и для языка C++. Таким образом, все используемые примеры будут написаны на языке C. Использование OpenMP сводится либо к использованию функций библиотечной поддержки (в данной статье будет рассмотрена только одна такая функция), либо к написанию директивы #pragma. На всякий случай напомню, что с точки зрения синтаксиса языков C/C++ директива #pragma является конструкцией, которую компилятор вправе игнорировать. Это лежит в основе работы компиляторов: в режиме с поддержкой OpenMP компилятор принимает к рассмотрению директивы #pragma (относящиеся к OpenMP), а в режиме без поддержки OpenMP - игнорирует. Директива #pragma обычно привязывается к оператору (statement'у) языка. Оператор может быть простым и составным. Здесь приведу несколько примеров, поясняющих, как рассматривает компилятор привязку директивы #pragma к операторам языка C /* Директива привязывается только к "a=b", поскольку * "c=d" - это уже следующий оператор */ #pragma trampampam a = b; c = d; /* Директива привязывается ко всему лексическому блоку, * содержащему "a=b" и "c=d" */ #pragma trampampam { a = b; c = d; } /* Директива привязывается к составному оператору "запятая", который * включает в себя "a=b" и "c=d" */ #pragma trampampam a = b, c = d; /* Директива привязывается к составному оператору for, который * включает в себя "a=b" и "c=d" */ #pragma trampampam for (i = 0; i < 10; i++) { a = b; c = d; } C #define FOO() \ a = b; \ c = d /* Директива будет привязана только к "a=b" */ #pragma trampampam FOO(); При написании примеров я использовал компилятор gcc и операционную систему linux. В строках запуска компиляции и исполнения я везде указывал именно строки запуска gcc. Тем кто использует другой компилятор или другую операционную систему, эти строки придётся отредактировать под свои нужды, ну а пользователям linux можно просто копировать строки запуска из статьи В основной массе примеров для наглядной демонстрации параллельного исполнения я использовал функцию printf. Для того, чтобы на практике пощупать и убедиться, что потоки действительно исполняются параллельно, важно, чтобы печати на экране шли вперемешку. В этом месте может возникнуть проблема, связанная с системной буферизацией потоков вывода при печати данных на экран. Как итог при реально параллельном исполнении потоков на экране можно увидеть печать, соответствующую последовательному исполнению. В этом случае придётся немного модифицировать программу, чтобы нивелировать потуги системы по буферизации печати. Есть несколько разных способов, какой конкретно способ поможет на вашей системе, выяснится только методом тыка:
3.1. Первая программа, пока бесполезная В качестве самой первой программы возьмём тот пример, который часто используют в качестве первой программы в других учебных пособиях. Однако я оговорюсь сразу - данная программа из разряда "бесполезных". Она просто демонстрирует возможность механизма OpenMP, но никак не объясняет, как правильно пользоваться этим механизмом. C /* Демонстрационный код, не имеющий полезного смысла */ #include <stdio.h> int main (void) { printf ("serial region 1\n"); #pragma omp parallel { printf ("parallel region\n"); } printf ("serial region 2\n"); return 0; } Code $ gcc t.c $ ./a.out serial region 1 parallel region serial region 2 $ gcc -fopenmp t.c $ ./a.out serial region 1 parallel region parallel region parallel region parallel region serial region 2 C /* Демонстрационный код, не имеющий полезного смысла */ #include <stdio.h> int main (void) { printf ("serial region 1\n"); #pragma omp parallel { printf ("a"); printf ("b"); printf ("c"); printf ("d"); printf ("e"); printf ("f"); printf ("g"); printf ("h"); printf ("\n"); } printf ("serial region 2\n"); return 0; } Code $ gcc -fopenmp t.c $ ./a.out serial region 1 abcdaebfacagbdbhcec dfdegefhfg ghh serial region 2 Ещё раз хочется подчеркнуть, что программа, которую мы только что написали, является бесполезной, т.к. никому не нужен код, который в несколько потоков выполняет одно и то же. Программа всего лишь демонстрирует то, как в OpenMP выделяется параллельный регион в программе. С точки зрения идеологии OpenMP программа может показаться некорректной, т.к. результат её исполнения зависит от того, включена в компиляторе поддержка OpenMP, или нет, а если включена, то во сколько потоков исполнится программа. Но в данном примере printf выполняет отладочную роль, а не роль полезной нагрузки. Полезную нагрузку мы пока не моделировали, а потому этим моментом заморачиваться пока не будем Упомянем здесь и о том, что в директиву "#pragma omp parallel" можно добавить опцию (по английски называется clause) num_threads, чтобы явно задать количество потоков при исполнении параллельного региона. Задать количество потоков можно несколькими способами, но в рамках данной статьи мы не будем на этом останавливаться, т.к. для начального уровня это не играет большой роли. Поэтому примерах опцию num_threads мы писать не будем C #pragma omp parallel num_threads(4)C int n = 4; #pragma omp parallel num_threads(n) В отладочных целях полезно видеть в печати информацию о том, какой поток что делает. Для идентификации потоков есть функция "int omp_get_thread_num (void)", описанная в заголовочном файле omp.h. C /* Демонстрационный код, не имеющий полезного смысла */ #include <stdio.h> #include <omp.h> int main (void) { printf ("serial region 1\n"); #pragma omp parallel { printf ("parallel region, thread=%d\n", omp_get_thread_num()); } printf ("serial region 2\n"); return 0; } Code $ gcc -fopenmp t.c $ ./a.out serial region 1 parallel region, thread=0 parallel region, thread=2 parallel region, thread=1 parallel region, thread=3 serial region 2 C /* Демонстрационный код, не имеющий полезного смысла */ #include <stdio.h> #include <omp.h> int main (void) { printf ("serial region 1\n"); #pragma omp parallel { #ifdef _OPENMP printf ("parallel region, thread=%d\n", omp_get_thread_num()); #else printf ("parallel region, thread=main\n"); #endif } printf ("serial region 2\n"); return 0; } Code $ gcc t.c $ ./a.out serial region 1 parallel region, thread=main serial region 2 $ gcc -fopenmp t.c $ ./a.out serial region 1 parallel region, thread=0 parallel region, thread=2 parallel region, thread=1 parallel region, thread=3 serial region 2 3.3. Параллельное исполнение независимых участков кода Теперь попробуем написать программу, которая моделирует реальные потребности. Допустим, у нас есть функция, которая обрабатывает один массив и складывает результаты в другой массив. У нас имеется четыре массива: A, B, C, D. Нам нужно обработать массив A и поместить результаты в массив B, а так же обработать массив C и положить результаты в массив D. Мы так же исходим из того, что функция обработки является "чистой" (pure): т.е. работает только с данными, пришедшими через параметры и локальными переменными, НЕ модифицирует никакие глобальные переменные и внутри себя может вызывать только функции, которые так же являются "чистыми". При таком раскладе две обработки массивов A -> B и C -> D являются полностью независимыми и могут быть проведены параллельно Сначала напишем программу, которая моделирует последовательную обработку массивов. С обработкой самих массивов мы возиться не будем, а будем всего лишь делать вид, что их обрабатываем. При этом для наглядности опять воспользуемся printf'ами C #include <stdio.h> /* Нужно для sleep. На системах, отличных от linux, может потребоваться * другой заголовочный файл */ #include <unistd.h> void foo (const char *in, const char *out) { printf ("%s -> %s: start\n", in, out); /* Эмуляция того, что данная функция работает долго. На системах, отличных от linux, * данная функция может называться по другому */ sleep (1); printf ("%s -> %s: finish\n", in, out); } int main (void) { printf ("start\n"); foo ("A", "B"); foo ("C", "D"); printf ("finish\n"); return 0; } Code $ gcc t.c $ ./a.out start A -> B: start A -> B: finish C -> D: start C -> D: finish finish C /* Ошибочный код */ #pragma omp parallel { foo ("A", "B"); foo ("C", "D"); } Нам нужно как-то указать, что в параллельном регионе имеются фрагменты кода, которые нужно исполнять в разных потоках, вместо того, чтобы дублировать весь параллельный регион на несколько потоков. Для этого используются директивы "#pragma omp sections" и "#pragma omp section". Первая описывает кусок кода, в котором программистом явно должны быть указаны независимые фрагменты (секции), а вторая описывает непосредственно сам фрагмент (секцию):
C #include <stdio.h> /* Нужно для sleep. На системах, отличных от linux, может потребоваться * другой заголовочный файл */ #include <unistd.h> void foo (const char *in, const char *out) { printf ("%s -> %s: start\n", in, out); /* Эмуляция того, что данная функция работает долго. На системах, отличных от linux, * данная функция может называться по другому */ sleep (1); printf ("%s -> %s: finish\n", in, out); } int main (void) { printf ("start\n"); #pragma omp parallel sections { #pragma omp section { foo ("A", "B"); } #pragma omp section { foo ("C", "D"); } } printf ("finish\n"); return 0; } Code $ gcc t.c $ ./a.out start A -> B: start A -> B: finish C -> D: start C -> D: finish finish $ gcc -fopenmp t.c $ ./a.out start A -> B: start C -> D: start A -> B: finish C -> D: finish finish В качестве самостоятельного упражнения рекомендуется на базе данного примера написать программу, в которой выполняется копирования массивов из A в B и из C в D. При этом в параллельном регионе функции main следует избегать использования локальных переменных, т.к. при работе с ними имеются особенности, которые будут описаны в разделе 3.5. Внутри функции foo использовать локальные переменные можно свободно 3.4. Параллельное исполнение циклов с независимыми итерациями Во многих программах имеются длинные циклы, итерации которых являются независимыми, т.е. результат исполнения i-ой итерации никак не зависит от результат исполнения k-й итерации (k != i). Простым примером такого цикла является обработка массивов, где на каждой итерации цикла выполняется код вида "A[i] = B[i] + C[i]". Циклы с независимыми итерациями можно выполнять параллельно. Допустим, у нас есть обработка массивов размера 100. C int i; for (i = 0; i < 100; i++) printf ("iteration %d\n", i); C /* Неэффективный код */ #pragma omp parallel sections { #pragma omp section { /* Важным моментом, о котором мы пока не знаем, является то, что * переменная "i" должна быть объявлена внутри лексического блока, * с которым ассоциирована "#pragma omp section". Пока просто * верим на слово, а объяснение будет дано в разделе 3.5 */ int i; for (i = 0; i < 25; i++) printf ("iteration %d\n", i); } #pragma omp section { int i; for (i = 25; i < 50; i++) printf ("iteration %d\n", i); } #pragma omp section { int i; for (i = 50; i < 75; i++) printf ("iteration %d\n", i); } #pragma omp section { int i; for (i = 75; i < 100; i++) printf ("iteration %d\n", i); } } C int i; #pragma omp parallel { /* Директива "#pragma omp for" может быть привязана только к циклу, * а не к лексическому блоку или другому оператору */ #pragma omp for for (i = 0; i < 100; i++) printf ("iteration %d\n", i); } C #include <stdio.h> #include <omp.h> int main (void) { /* Здесь уже переменная цикла может быть объявлена вне параллельного * региона. Но опять-таки об этом поясним в разделе 3.5 */ int i; printf ("start\n"); #pragma omp parallel for for (i = 0; i < 100; i++) printf ("iteration %d, thread=%d\n", i, omp_get_thread_num()); printf ("finish\n"); return 0; } Code $ gcc -fopenmp t.c $ ./a.out start iteration 75, thread=3 iteration 25, thread=1 iteration 50, thread=2 iteration 76, thread=3 iteration 0, thread=0 iteration 26, thread=1 iteration 51, thread=2 iteration 77, thread=3 iteration 1, thread=0 ... я не буду описывать выдачу со всех 100 итераций ... finish В качестве самостоятельного упражнения рекомендуется на базе данного примера написать программу, в которой выполняется векторное суммирование массивов B и C с помещением результата в массив A, т.е. выполняется поэлементная операция A[i] = B[i] + C[i]. При этом в параллельном регионе функции main следует избегать использования локальных переменных, кроме счётчика цикла, т.к. при работе с ними имеются особенности, которые будут описаны в разделе 3.5. Важным моментом является то, что существуют довольно жёсткие ограничения того, каким должен быть цикл, к которому допустимо применять директиву "#pragma omp for" (помимо того, что итерации цикла должны быть независимыми). Все эти ограничения можно прочитать в спецификации или в любом учебном пособии. На пальцах опишу лишь то, что "плохие циклы", в которых нет индуктивной переменной (по простому "счётчика цикла"), в которых есть break/continue, есть метки внутри цикла, на которые переходят goto снаружи цикла, и т.п. через OpenMP распараллелить не получится 3.5. Базовые сведения о работе с переменными внутри параллельного региона В предыдущих разделах я упоминал про некоторые особенности работы с локальными переменными. В данном разделе мы на этом остановимся несколько подробнее. Возьмём пример, в котором явно описаны независимые секции кода, и попробуем там использовать локальную переменную. C /* Ошибочный код */ #include <stdio.h> int main (void) { int i; printf ("start\n"); #pragma omp parallel sections { #pragma omp section { for (i = 0; i < 10; i++) printf ("iteration %d\n", i); } #pragma omp section { for (i = 10; i < 20; i++) printf ("iteration %d\n", i); } } printf ("finish\n"); return 0; } Code $ gcc -fopenmp t.c $ ./a.out start iteration 10 iteration 0 iteration 11 iteration 13 iteration 14 iteration 15 iteration 16 iteration 17 iteration 18 iteration 19 finish Чтобы такого не происходило, в директиву "#pragma omp parallel" необходимо добавить информацию о том, как работать с переменной в потоках: все потоки должны работать с локальной (независимой от других потоков) копией переменной или с общей для всех потоков переменной. Делается это через опцию private (в каждом потоке будет своя собственная копия переменной) или shared (все потоки будут работать с одной и той же копией переменной). При этом нужно понимать, что записывать в shared-переменную внутри потока нужно правильно, чтобы не возникало проблем в случаях, когда несколько потоков одновременно используют одну и ту же переменную, но подробнее об этом будет написано в разделе 3.7 Итак, правильный вариант программы: C #include <stdio.h> int main (void) { int i; printf ("start\n"); #pragma omp parallel sections private(i) { #pragma omp section { for (i = 0; i < 10; i++) printf ("iteration %d\n", i); } #pragma omp section { for (i = 10; i < 20; i++) printf ("iteration %d\n", i); } } printf ("finish\n"); return 0; } Code $ gcc -fopenmp t.c $ ./a.out start iteration 0 iteration 10 iteration 1 iteration 11 iteration 2 iteration 12 iteration 3 iteration 13 iteration 4 iteration 14 iteration 5 iteration 15 iteration 6 iteration 16 iteration 7 iteration 17 iteration 8 iteration 18 iteration 9 iteration 19 finish Опции private и shared применяются только к локальным (более правильный термин - "автоматическим") переменным. К глобальным (более правильный термин - "статическим") переменным применяются другие директивы, я их здесь описывать не буду - о них можно прочесть в спецификации и учебниках Все локальные переменные, описанные внутри директивы "#pragma omp parallel" являются private, что вполне логично, т.к. снаружи этого региона они не видны, а потому нет смысла делать их shared, т.к. итоговое значение за пределами региона прочесть уже не получится Если переменная не описана опциями private и shared, то по умолчанию она является shared (что мы и наблюдали на примере из начала данного раздела). Но для лучшей читабельности кода имеет смысл явно описывать private или shared, чтобы не приходилось держать в голове значение по умолчанию. Переменная цикла для "#pragma omp for" - особый случай, компилятор сам с этой переменной разбирается, а потому её не надо помечать ни private, ни shared. Начальное значение локальной копии private параллельном регионе не определено. Если нужно, чтобы начальное значение локальной переменной было таким же, каким оно было в последовательном регионе перед вхождением в параллельный регион, то вместо опции private следует использовать опцию firstprivate. Все эти тонкости можно почитать в учебниках, я не хочу на них останавливаться, т.к. идея статьи заключается в том, чтобы на простых примерах дать начальные сведения об OpenMP 3.6. Критические секции В разделе 3.1 мы уже наблюдали такой эффект, как перемешивание печати из разных потоков и рассматривали его как подтверждение тому, что исполнение действительно идёт параллельно. Но в реальных программах бывают случаи, когда из потоков параллельного исполнения действительно нужно уметь обращаться к ресурсу, который по своей природе является последовательным. Этим ресурсом в том числе может быть и консоль, в том числе и для полезной отладочной печати. Этим ресурсом может быть файл. И даже shared переменная. Для работы с такими ресурсами в параллельном программировании используется понятие "критическая секция". Это такой фрагмент кода, который будет выполнен каждым потоком, но при этом в любой момент времени код из критической секции исполняет только один поток, а остальные потоки, если они добрались до этого места, стоят в очереди и ждут окончания работы первого потока Напишем пример, который моделирует обращение к последовательному ресурсу
Code $ gcc -fopenmp t.c $ ./a.out start start: parallel-1, serial-1 start: parallel-2, serial-2 parallel-1 parallel-2 parallel-1 parallel-2 parallel-1 parallel-2 parallel-1 parallel-2 parallel-1 parallel-2 parallel-1 parallel-2 parallel-1 parallel-2 serial-1 parallel-2 serial-1 parallel-2 serial-1 parallel-2 serial-1 serial-2 serial-1 serial-2 serial-1 serial-2 serial-1 serial-2 finish: parallel-1, serial-1 serial-2 serial-2 serial-2 serial-2 serial-2 serial-2 finish: parallel-2, serial-2 finish Чтобы побороть параллельное исполнение кода при обращении к последовательному ресурсу, нам нужно второй цикл выделить в критическую секцию. Для этого используется директива "#pragma omp critical". Оператор, к которому применена эта директива, будет выделен в критическую секцию. После чего наша программа будет выглядеть как: C #include <stdio.h> void foo (const char *s1, const char *s2, int n) { int i; printf ("start: %s, %s\n", s1, s2); /* Здесь моделируется полезная работа, которая может распараллеливаться */ for (i = 0; i < n; i++) printf ("%s\n", s1); /* Здесь моделируется обращение к последовательному ресурсу * Все итерации цикла рассматриваются как одно единое обращение, * оно делается в цикле для наглядности */ #pragma omp critical { for (i = 0; i < n; i++) printf ("%s\n", s2); } printf ("finish: %s, %s\n", s1, s2); } int main (void) { printf ("start\n"); #pragma omp parallel sections { #pragma omp section { foo ("parallel-1", "serial-1", 7); } #pragma omp section { foo ("parallel-2", "serial-2", 10); } } printf ("finish\n"); return 0; } Code $ gcc -fopenmp t.c $ ./a.out start start: parallel-1, serial-1 start: parallel-2, serial-2 parallel-1 parallel-2 parallel-1 parallel-2 parallel-1 parallel-2 parallel-1 parallel-2 parallel-1 parallel-2 parallel-1 parallel-2 parallel-1 parallel-2 serial-1 parallel-2 serial-1 parallel-2 serial-1 parallel-2 serial-1 serial-1 serial-1 serial-1 finish: parallel-1, serial-1 serial-2 serial-2 serial-2 serial-2 serial-2 serial-2 serial-2 serial-2 serial-2 serial-2 finish: parallel-2, serial-2 finish 3.7. Работа с shared переменными Часто возникают случаи, когда разные потоки должны работать с переменной, которая одна общая для всех потоков. Как правило эта переменная ассоциирована с записью общего результата всех потоков. Несложно догадаться, что такая переменная по смыслу является последовательным ресурсом, а потому любое обращение к такой переменной можно выделить в критическую секцию. Однако накладные системные расходы на построение критической секции (во время выполнения программы) являются большими по сравнению с непосредственно обращением к переменной, поэтому работа с shared-переменной через критическую секцию является неэффективной. OpenMP предоставляет дополнительные возможности для работы с shared-переменными. 3.7.1. Атомарное приращение значения shared-переменной в параллельных фрагментах Рассмотрим пример с параллельными фрагментами кода, в которых накапливается результат в общую для всех потоков переменную C /* Ошибочный код */ #include <stdio.h> int foo (int x) { /* Моделирование некоторых вычислительных действий. В реальности * ничего делать не будем */ return x; } int main (void) { int sum = 0; #pragma omp parallel sections shared(sum) { #pragma omp section { /* Строго говоря, хотелось бы в данном примере видеть вычисления с редкими * обращениями к переменной sum. Но в этом случае для визуального наблюдения * проблемы пришлось бы писать много кода, несимметричного в двух потоках, * и выполнять много запусков программы. Поэтому для упрощения здесь написан * цикл с частыми обращениями к переменной sum. При этом в голове надо держать, * что здесь должна быть другая модель программы. Это пишу для того, чтобы * не путать данный пример с примером из следующего раздела, в котором * будем распараллеливать тело цикла */ int i; for (i = 0; i < 10000; i++) sum += foo (i); } #pragma omp section { int i; for (i = 0; i < 10000; i++) sum -= foo (i); } } printf ("sum=%d\n", sum); return 0; } Code $ gcc -fopenmp t.c $ ./a.out sum=2439463 $ ./a.out sum=-49966559 $ ./a.out sum=5857920 Как уже говорилось выше, проблему можно решить в "лоб" и коды с обращением к переменной sum поместить в критическую секцию C /* Неэффективный код */ #pragma omp section { int i; for (i = 0; i < 10000; i++) { /* Основные вычисления (которые моделируются вызовом функции foo) * должны проводиться в параллельном режиме, т.е. вне критической секции */ int tmp = foo (i); #pragma omp critical { sum += tmp; } } } /* Аналогичный фрагмент пишем для второй секции с вычитанием */ C #include <stdio.h> int foo (int x) { /* Моделирование некоторых вычислительных действий. В реальности * ничего делать не будем */ return x; } int main (void) { int sum = 0; #pragma omp parallel sections shared(sum) { #pragma omp section { /* Строго говоря, хотелось бы в данном примере видеть вычисления с редкими * обращениями к переменной sum. Но в этом случае для визуального наблюдения * проблемы пришлось бы писать много кода, несимметричного в двух потоках, * и выполнять много запусков программы. Поэтому для упрощения здесь написан * цикл с частыми обращениями к переменной sum. При этом в голове надо держать, * что здесь должна быть другая модель программы. Это пишу для того, чтобы * не путать данный пример с примером из следующего раздела, в котором * будем распараллеливать тело цикла */ int i; for (i = 0; i < 10000; i++) { /* Директива "#pragma omp atomic" по смыслу относится только * к левой части выражения. Т.е. правая часть нормально будет * работать в параллельном режиме, и только изменение значения * переменной пройдёт атомарно (последовательно). Таким образом, * нам даже не нужно возиться с заведением дополнительной * переменной "tmp", как мы это делали в неэффективном примере * с критическими секциями */ #pragma omp atomic sum += foo (i); } } #pragma omp section { int i; for (i = 0; i < 10000; i++) { #pragma omp atomic sum -= foo (i); } } } printf ("sum=%d\n", sum); return 0; } Code $ gcc -fopenmp t.c $ ./a.out sum=0 $ ./a.out sum=0 $ ./a.out sum=0 В предыдущем разделе мы работали с примером, в котором цикл использовался исключительно для демонстрационных целей. Теперь рассмотрим пример, где есть настоящий распараллеленный цикл, в котором на каждой итерации осуществляется приращение shared-переменной C /* Неэффективный код */ #include <stdio.h> int foo (int x) { /* Моделирование некоторых вычислительных действий. В реальности * ничего делать не будем */ return x; } int main (void) { int i, sum; sum = 0; #pragma omp parallel for shared(sum) for (i = 0; i < 10000; i++) { #pragma omp atomic sum += foo (i); } printf ("sum=%d\n", sum); return 0; } Code $ gcc -fopenmp t.c
$ ./a.out
sum=49995000Для улучшения эффективности программы сделаем демонстрационное ручное разбиение кода на два потока, в котором минимизируем работу с shared-переменной
C #include <stdio.h> int foo (int x) { /* Моделирование некоторых вычислительных действий. В реальности * ничего делать не будем */ return x; } int main (void) { int i, sum; sum = 0; #pragma omp parallel for reduction(+:sum) for (i = 0; i < 10000; i++) sum += foo (i); printf ("sum=%d\n", sum); return 0; } Code $ gcc -fopenmp t.c
$ ./a.out
sum=49995000В качестве одного из подводных камней следует упомянуть то, что такая работа с reduction-переменной строится на математическом принципе ассоциативности: (a + b) + c == a + (b + c). В реальной жизни принцип ассоциативности НЕ соблюдается в вычислительных машинах для вещественных типов. Поэтому результат работы программы, обрабатывающей вещественные данные, может немного (а иногда и много) различаться в зависимости от количества потоков. Это плата за скорость, которую нужно всегда держать в уме и учитывать при параллельном программировании 4. Заключение В статье я привёл лишь небольшой минимум сведений и примеров, которые на мой взгляд необходимы для базового понимания принципов работы с OpenMP. В принципе, этих сведений вполне достаточно для распараллеливания и отладки простых счётных задач, пусть и не самым эффективным образом. Данную статью следует рассматривать лишь как получение начальных сведений, упрощающих чтение полноценных учебных пособий по OpenMP или технических спецификаций. Для людей, которым всего-то нужно, что распараллелить один-единственный критический цикл с независимыми итерациями, как мне кажется, данной информации будет достаточно, чтобы ускорить свою программу и больше этим вопросом не мучиться Хочется отметить, что OpenMP - это высокоуровневая абстракция, чем-то по смыслу напоминающая контейнеры C++. Можно использовать std::list или std::set, не вникая в то, как эти контейнеры устроено изнутри, но рано или поздно наступит момент, когда, не понимая фундаментальные основы реализации списка или множества, программист не будет полноценно понимать, а что же он делает и как выжать максимально высокую скорость работы программы. Точно так же с OpenMP. Можно писать программы с использованием OpenMP, не имея реального представления о том, как вручную создать поток, что такое семафор/мьютекс, как организована поддержка со стороны операционной системы. Но рано или поздно придётся столкнуться с тем, что без знания фундаментальных основ двигаться дальше не получится. А поэтому без знаний того, как устроено многопоточное программирование на низком прикладном уровне, хорошим стать специалистом может быть и не получится | |||||||||||||||
Размещено в Статьи по программированию
Надоела реклама? Зарегистрируйтесь и она исчезнет полностью.
Всего комментариев 12
Комментарии
-
Огромное спасибо за статью. Как всегда превосходное изложение материала. Со своей стороны хотел бы добавить (к разделу 2.3), что в xlc/xlc++ OpenMP можно включить опцией -qsmp=omp. Ссылка на документацию.Запись от HighPredator размещена 27.11.2014 в 13:48
-
Запись от Evg размещена 27.11.2014 в 20:56
-
Запись от Убежденный размещена 27.11.2014 в 21:06
-
Спасибо!Запись от AndrSlav размещена 27.11.2014 в 22:14
-
Запись от snake32 размещена 11.12.2014 в 19:02
-
Запись от Evg размещена 11.12.2014 в 21:44
-
Размер статьи уже упёрся в ограничения. Так что помещу сюда, чтобы не потерялось
https://habrahabr.ru/company/intel/blog/203618/ - Coarray в Fortran'е. К обсуждаемому вопросу напрямую не относится, но полезно знать о том, какими ещё способами параллельность встраивается в языкЗапись от Evg размещена 01.11.2016 в 09:26
-
Полезная ссылка https://www.ibm.com/developerw... framework/Запись от Evg размещена 16.11.2019 в 18:36
-
Запись от XLAT размещена 17.11.2019 в 21:41
-
Добавить уже ничего не могу, т.к. размер статьи достиг лимита. В любом случае тут не планировалось никакое полноценное описание OpenMP, а всего лишь первые шаги, чтобы хоть что-то реально работающее можно было пощупать на практике. А дальше уже самостоятельное изучение по более полным статьям/книгамЗапись от Evg размещена 17.11.2019 в 23:36
-
Запись от Avazart размещена 18.11.2019 в 14:33
-
Данная статья закончена, я в неё больше не собираюсь ничего добавлять. Точнее, хотел добавить полезные ссылки, но из-за этого начинать искусственно распиливать не хочу - от этого станет неудобно читать и пользоваться поиском внутри статьи, причём на пустом месте. Да и просто начнёт выглядеть по уродски
Сообщение от Avazart
Запись от Evg размещена 18.11.2019 в 21:11


