Форум программистов, компьютерный форум, киберфорум
Наши страницы
Evg
Войти
Регистрация
Восстановить пароль
Рейтинг: 3.40. Голосов: 5.

Первые шаги в OpenMP

Запись от Evg размещена 26.11.2014 в 21:40
Обновил(-а) Evg 22.03.2015 в 20:12

  • 1. Предисловие
  • 2. Немного теории
    2.1. Программная модель OpenMP
    2.2. Идеология OpenMP
    2.3. Как в компиляторе включить поддержку OpenMP
    2.4. Пара слов о синтаксисе директивы #pragma в языках C/C++
  • 3. Примеры программ с использованием OpenMP
    3.1. Первая программа, пока бесполезная
    3.2. Идентификация потоков
    3.3. Параллельное исполнение независимых участков кода
    3.4. Параллельное исполнение циклов с независимыми итерациями
    3.5. Базовые сведения о работе с переменными внутри параллельного региона
    3.6. Критические секции
    3.7. Работа с shared переменными
    3.7.1. Атомарное приращение значения shared-переменной в параллельных фрагментах
    3.7.2. Распараллеленный цикл с приращением значения shared-переменной
  • 4. Заключение







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;
    }
При этом нужно быть внимательным, когда директиву #pragma привязываем к операторам, порождённым макросом

C
#define FOO() \
  a = b; \
  c = d
 
/* Директива будет привязана только к "a=b" */
#pragma trampampam
  FOO();
3. Примеры программ с использованием OpenMP

При написании примеров я использовал компилятор gcc и операционную систему linux. В строках запуска компиляции и исполнения я везде указывал именно строки запуска gcc. Тем кто использует другой компилятор или другую операционную систему, эти строки придётся отредактировать под свои нужды, ну а пользователям linux можно просто копировать строки запуска из статьи

В основной массе примеров для наглядной демонстрации параллельного исполнения я использовал функцию printf. Для того, чтобы на практике пощупать и убедиться, что потоки действительно исполняются параллельно, важно, чтобы печати на экране шли вперемешку. В этом месте может возникнуть проблема, связанная с системной буферизацией потоков вывода при печати данных на экран. Как итог при реально параллельном исполнении потоков на экране можно увидеть печать, соответствующую последовательному исполнению. В этом случае придётся немного модифицировать программу, чтобы нивелировать потуги системы по буферизации печати. Есть несколько разных способов, какой конкретно способ поможет на вашей системе, выяснится только методом тыка:
  • Замена "printf (" на "fprintf (stderr"
  • Для компилятора gcc опция -fno-builtin (gcc по умолчанию заменяет вызов медленного printf'а на быстрый puts, а по опции это действие отменяется)
  • Изменение количества итераций циклов или увеличение повторов одиночно стоящего 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;
}
Код:
$ 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
В этой программе моделируется два последовательных региона (первый и последний printf'ы), а так же параллельный регион (второй printf). Директива "#pragma omp parallel", применённая к оператору, приводит к тому, что создастся несколько потоков, в каждом из которых будет выполнено тело оператора (в нашем случае таким оператором является лексический блок с вызовом printf'а). Количество создаваемых потоков по умолчанию равно количеству исполняющих ядер в системе (всё это можно настроить, но об этом чуть ниже). Каждый поток выполняет одно и то же действие. В моём примере исполнение проходило на 4-ядерной машине, а потому было создано 4 потока, которые выполнялись параллельно. То, что потоки исполнялись параллельно, по результатам исполнения программы на самом деле не видно, т.к. мы видим 4 последовательно напечатанных текста "parallel region". Обусловлено это тем, что под linux'ом printf умеет работать с многопоточными приложениями: внутри одного вызова printf он умеет не перемешивать на экране выдачу от разных потоков. Но на других системах вполне может оказаться, что printf работает не так, а потому 4 текста, одновременно печатаемые на экран из 4 потоков напечатаются вперемешку. Для наглядности такое поведение мы сможем смоделировать даже на linux'е, немного переписав программу

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;
}
Код:
$ gcc -fopenmp t.c
$ ./a.out
serial region 1
abcdaebfacagbdbhcec
dfdegefhfg
ghh

serial region 2
Здесь уже наглядно видно параллельное исполнение. А так же видно то, что параллельный регион работает после того, как отработал первый последовательный регион, а второй последовательный регион работает только после того, как отработали все потоки в параллельном регионе. Если даже для такого примера вы не наблюдаете перемешивание печати, то можно воспользоваться рекомендациями, данными в начале раздела 3

Ещё раз хочется подчеркнуть, что программа, которую мы только что написали, является бесполезной, т.к. никому не нужен код, который в несколько потоков выполняет одно и то же. Программа всего лишь демонстрирует то, как в 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)
3.2. Идентификация потоков

В отладочных целях полезно видеть в печати информацию о том, какой поток что делает. Для идентификации потоков есть функция "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;
}
Код:
$ 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
Однако если мы скомпилируем этот код без поддержки OpenMP, то произойдёт слом на компиляции, т.к. в таком режиме будет отсутствовать библиотечная функция omp_get_thread_num. В этом случае нам на помощь придёт макрос _OPENMP, который все компиляторы взводят в режиме поддержки OpenMP

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;
}
Код:
$ 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
Сразу же хочется предостеречь читателя от желания использовать номер потока для распределения работы между потоками в процессе исполнения программы. При таком подходе программа может оказаться заточенной на конкретное число потоков. Может получиться так, что при отключении OpenMP (т.е. при запуске без распараллеливания) программа начнёт работать неправильно. Конечно, при умелом использовании номера потока всех этих проблем можно избежать, но для большинства случаев, которые придут в горячую голову после познания функции omp_get_thread_num, вся необходимая функциональность в OpenMP уже реализована, нужно просто о ней почитать

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;
}
Код:
$ gcc t.c
$ ./a.out
start
A -> B: start
A -> B: finish
C -> D: start
C -> D: finish
finish
В данном примере два вызова функции foo являются "долгими", и при этом независимыми. Поэтому, если мы их запустим в параллель, то исполнение программы, грубо говоря, ускорится в два раза на многоядерной/многопроцессорной системе. Очевидно, что параллельным регионом в нашем случае являются два вызова функции foo. Однако если мы параллельный регион опишем в виде

C
/* Ошибочный код */
 
#pragma omp parallel
  {
    foo ("A", "B");
    foo ("C", "D");
  }
то нам ничего это не даст, т.к. будет создано 4 потока (на 4-ядерной машине), каждый из которых будет делать одно и то же: делать два последовательных вызова функции foo. В скобках можно заметить, что при таком написании есть шанс неправильной работы программы (которая уже и так неправильная сама по себе), т.к. во всех 4 потоках будет происходить запись в массивы B и D (явно их в коде у нас нет, но если бы они были, то было бы именно так). Более подробно на этом моменте остановимся в разделе 3.7

Нам нужно как-то указать, что в параллельном регионе имеются фрагменты кода, которые нужно исполнять в разных потоках, вместо того, чтобы дублировать весь параллельный регион на несколько потоков. Для этого используются директивы "#pragma omp sections" и "#pragma omp section". Первая описывает кусок кода, в котором программистом явно должны быть указаны независимые фрагменты (секции), а вторая описывает непосредственно сам фрагмент (секцию):

C
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#pragma omp parallel
  {
#pragma omp sections
    {
#pragma omp section
      {
        foo ("A", "B");
      }
#pragma omp section
      {
        foo ("C", "D");
      }
    }
  }
при этом синтаксически мы видим "#pragma omp parallel", тело которой состоит из единственной конструкции "#pragma omp sections" (телом которой является блок с двумя директивами "#pragma omp section"). Синтаксис позволяет эти две pragm'ы объединить в одну для наглядности, а потому наш итоговый пример будет выглядеть так:

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;
}
Код:
$ 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
Здесь мы видим, что два вызова функции foo выполняются параллельно. При этом мы опять наблюдаем, что формально программа с OpenMP и без него выдаёт разные результаты на экран. Но результаты на экране - это всего лишь отладочное средство. Настоящим результатом работы программы являются значения массивов B и D. И с этой точки зрения два запуска были бы идентичны, если бы мы в программе написали реальную обработку данных

В качестве самостоятельного упражнения рекомендуется на базе данного примера написать программу, в которой выполняется копирования массивов из 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);
Для 4-ядерной машины этот цикл можно разбить на 4 цикла с итерациями 0-24, 25-49, 50-74, 75-99 и эти 4 цикла исполнять параллельно. Если подойти к вопросу "в лоб", то можно воспользоваться уже известной нам конструкцией "#pragma omp sections":

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);
    }
  }
Такое написание обладает очевидными недостатками: фиксированное количество потоков исполнения, наличие дублирования кода, большая возня и плохо читаемый код в случаях, когда изначальный цикл будет не от 0 до 100, а от n до m. Для таких случаев в OpenMP имеется специальная директива "#pragma omp for", описывающая цикл с независимыми итерациями, который будет автоматически приведён (по смыслу) к тому виду, который мы только что написали с использованием "#pragma omp sections"

C
int i;
 
#pragma omp parallel
  {
    /* Директива "#pragma omp for" может быть привязана только к циклу,
     * а не к лексическому блоку или другому оператору */
#pragma omp for
    for (i = 0; i < 100; i++)
      printf ("iteration %d\n", i);
  }
По аналогии с разделом 3.3 здесь мы так же видим, что тело директивы "#pragma omp parallel" состоит из единственной директивы "#pragma omp for" (тело которой состоит из цикла). Синтаксис так же позволяет эти две директивы объединить в одну для наглядности. В итоге получаем код:

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;
}
Код:
$ 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
Если вы не наблюдаете перемешивание печатей от разных итераций, можно воспользоваться рекомендациями, данными в начале раздела 3

В качестве самостоятельного упражнения рекомендуется на базе данного примера написать программу, в которой выполняется векторное суммирование массивов 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;
}
Код:
$ 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
По печатям видим, что в первом потоке цикл исполнился не полностью (конкретно в нашем случае отработала только первая итерация цикла - печать "iteration 0"). Если вы подобного эффекта не наблюдаете, можно воспользоваться рекомендациями, данными в начале раздела 3. Почему программа повела себя так странно? Проблема в том, что в обеих секциях, которые исполняются параллельно, используется одна и та же переменная "i", а потому два потока начинают друг другу мешать. После того, как второй поток записал в "i" значение 10, в первом потоке произошло завершение цикла, т.к. перестало выполняться условие "i < 10".

Чтобы такого не происходило, в директиву "#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;
}
Код:
$ 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
Тут уже видим, что в обоих потоках отработали все итерации цикла. Если вы не наблюдаете перемешивание печатей от разных итераций, можно воспользоваться рекомендациями, данными в начале раздела 3

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

Напишем пример, который моделирует обращение к последовательному ресурсу

C
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/* Ошибочный код */
 
#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);
 
  /* Здесь моделируется обращение к последовательному ресурсу
   * Все итерации цикла рассматриваются как одно единое обращение,
   * оно делается в цикле для наглядности */
  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;
}
Код:
$ 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
При помощи параметра "n" в функции foo мы моделировали различную нагрузку внутри потоков. В печати мы видим перемешивание фрагментов "parallel", что есть изначальная задумка, видим перемешивание фрагментов "parallel" и "serial", что есть вполне нормально, т.к. доступ к последовательному ресурсу происходит только из одного потока, и видим перемешивание фрагментов "serial", что есть плохо, т.к. они должны отработать по очереди. Если вы не наблюдаете перемешивание печатей "serial-1" и "serial-2" от двух разных потоков, можно воспользоваться рекомендациями, данными в начале раздела 3

Чтобы побороть параллельное исполнение кода при обращении к последовательному ресурсу, нам нужно второй цикл выделить в критическую секцию. Для этого используется директива "#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;
}
Код:
$ 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
Здесь мы уже видим, что фрагменты "serial" исполняются по очереди

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;
}
Код:
$ gcc -fopenmp t.c
$ ./a.out
sum=2439463
$ ./a.out
sum=-49966559
$ ./a.out
sum=5857920
Мы видим, что от запуск к запуску результат меняется. Причина здесь ровно такая же, как и в примере из раздела 3.5 - одновременное обращение потоков к одной и той же переменной sum. Если подобный эффект не наблюдается, то конкретно в данном тесте следует увеличить количество итераций цикла. Рекомендации из начала раздела 3 касаются проблем с буферизацией вывода (в потоках), которого в нашем случае нету, а потому здесь работаем исключительно с количеством итераций

Как уже говорилось выше, проблему можно решить в "лоб" и коды с обращением к переменной sum поместить в критическую секцию

C
/* Неэффективный код */
 
#pragma omp section
    {
      int i;
      for (i = 0; i < 10000; i++)
      {
        /* Основные вычисления (которые моделируются вызовом функции foo)
         * должны проводиться в параллельном режиме, т.е. вне критической секции */
        int tmp = foo (i);
#pragma omp critical
        {
          sum += tmp;
        }
      }
    }
 
/* Аналогичный фрагмент пишем для второй секции с вычитанием */
Для операций типа "<var> += <expression>", "<var> -= <expression>" и т.п. можно воспользоваться директивой "#pragma omp atomic", которая построит код, по смыслу похожий на наш неэффективный код с критической секцией, но при этом будет построен частный случай критической секции - атомарное изменение значения переменной. Накладные расходы (по времени исполнения) в таком коде будут в разы меньше, чем при построении критической секции

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;
}
Код:
$ gcc -fopenmp t.c
$ ./a.out
sum=0
$ ./a.out
sum=0
$ ./a.out
sum=0
3.7.2. Распараллеленный цикл с приращением значения shared-переменной

В предыдущем разделе мы работали с примером, в котором цикл использовался исключительно для демонстрационных целей. Теперь рассмотрим пример, где есть настоящий распараллеленный цикл, в котором на каждой итерации осуществляется приращение 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;
}
Код:
$ gcc -fopenmp t.c
$ ./a.out
sum=49995000
Данный пример я обозначил как неэффективный. Почему? Проблема в том, что на каждой итерации цикла мы обращаемся к переменной sum. Переменная sum является общей для всех потоков. Такие переменные могут находиться только в памяти и никак не могут быть расположены на регистре. Из-за этого и получается неэффективность - каждая итерация цикла вынуждена лезть в память для изменения значения переменной. Плюс ко всему работа с такой переменной выполняется атомарно, а это дополнительное (хоть и небольшое) время исполнения программы.

Для улучшения эффективности программы сделаем демонстрационное ручное разбиение кода на два потока, в котором минимизируем работу с shared-переменной

C
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* Неэффективный код */
 
#pragma omp parallel sections shared(sum)
  {
#pragma omp section
    {
      int i;
      int tmp = 0;
      for (i = 0; i < 5000; i++)
        tmp += foo (i);
#pragma omp atomic
      sum += tmp;
    }
#pragma omp section
    {
      int i;
      int tmp = 0;
      for (i = 5000; i < 10000; i++)
        tmp += foo (i);
#pragma omp atomic
      sum += tmp;
    }
  }
Здесь в каждом исполняющемся потоке накопление результата происходит в private переменную, которую можно разместить на регистре. И только по одному разу выполняется обращение к shared-переменной. Однако по аналогии с примером из раздела 3.4, данный пример является "плохим": в нём фиксированное количество потоков и наличие дублирования кода. Для автоматизации этого процесса следует воспользоваться опцией reduction вместо опции 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;
}
Код:
$ gcc -fopenmp t.c
$ ./a.out
sum=49995000
Опция reduction указывает компилятору, что переменная является shared, но для каждого потока нужно построить отдельную локальную копию этой переменной, и построить в конце исполнения потока указанную операцию (в нашем случае это +) между shared-переменной и её локальной копией. Локальная копия в каждом потоке будет инициализирована значением 0 для операций типа плюс или минус, либо значением 1 для операций типа умножить

В качестве одного из подводных камней следует упомянуть то, что такая работа с reduction-переменной строится на математическом принципе ассоциативности: (a + b) + c == a + (b + c). В реальной жизни принцип ассоциативности НЕ соблюдается в вычислительных машинах для вещественных типов. Поэтому результат работы программы, обрабатывающей вещественные данные, может немного (а иногда и много) различаться в зависимости от количества потоков. Это плата за скорость, которую нужно всегда держать в уме и учитывать при параллельном программировании

4. Заключение

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

Хочется отметить, что OpenMP - это высокоуровневая абстракция, чем-то по смыслу напоминающая контейнеры C++. Можно использовать std::list или std::set, не вникая в то, как эти контейнеры устроено изнутри, но рано или поздно наступит момент, когда, не понимая фундаментальные основы реализации списка или множества, программист не будет полноценно понимать, а что же он делает и как выжать максимально высокую скорость работы программы. Точно так же с OpenMP. Можно писать программы с использованием OpenMP, не имея реального представления о том, как вручную создать поток, что такое семафор/мьютекс, как организована поддержка со стороны операционной системы. Но рано или поздно придётся столкнуться с тем, что без знания фундаментальных основ двигаться дальше не получится. А поэтому без знаний того, как устроено многопоточное программирование на низком прикладном уровне, хорошим стать специалистом может быть и не получится

Нажмите на изображение для увеличения
Название: fork_join2_ed.gif
Просмотров: 3523
Размер:	12.9 Кб
ID:	2873
Просмотров 9993 Комментарии 7
Всего комментариев 7
Комментарии
  1. Старый комментарий
    Аватар для HighPredator
    Огромное спасибо за статью. Как всегда превосходное изложение материала. Со своей стороны хотел бы добавить (к разделу 2.3), что в xlc/xlc++ OpenMP можно включить опцией -qsmp=omp. Ссылка на документацию.
    Запись от HighPredator размещена 27.11.2014 в 13:48 HighPredator вне форума
  2. Старый комментарий
    Аватар для Evg
    Добавил
    Запись от Evg размещена 27.11.2014 в 20:56 Evg вне форума
  3. Старый комментарий
    Аватар для Убежденный
    Спасибо, отличная статья для быстрого старта в OpenMP !
    Запись от Убежденный размещена 27.11.2014 в 21:06 Убежденный вне форума
  4. Старый комментарий
    Спасибо!
    Запись от AndrSlav размещена 27.11.2014 в 22:14 AndrSlav вне форума
  5. Старый комментарий
    Аватар для snake32
    Первые шаги в OpenCL будет?
    Запись от snake32 размещена 11.12.2014 в 19:02 snake32 вне форума
  6. Старый комментарий
    Аватар для Evg
    К сожалению, с OpenCL не знаком
    Запись от Evg размещена 11.12.2014 в 21:44 Evg вне форума
  7. Старый комментарий
    Аватар для Evg
    Размер статьи уже упёрся в ограничения. Так что помещу сюда, чтобы не потерялось

    https://habrahabr.ru/company/intel/blog/203618/ - Coarray в Fortran'е. К обсуждаемому вопросу напрямую не относится, но полезно знать о том, какими ещё способами параллельность встраивается в язык
    Запись от Evg размещена 01.11.2016 в 09:26 Evg вне форума
 
КиберФорум - форум программистов, компьютерный форум, программирование
Powered by vBulletin® Version 3.8.9
Copyright ©2000 - 2019, vBulletin Solutions, Inc.
Рейтинг@Mail.ru