Добавил:
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:

Математическое моделирование в астрофизике. В 2 частях. Ч.2. Учебное пособие

.pdf
Скачиваний:
0
Добавлен:
12.08.2026
Размер:
873 Кб
Скачать

+

a

1

2

3

4

5

6

7

8

b

1

2

3

4

5

6

7

8

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

+ + + +

a

1

2

3

4

5

6

7

8

b

1

2

3

4

5

6

7

8

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Рис. 4.2. Векторизация цикла a[i] = b[i] + a[i + 1]

5. Независимость итераций цикла на дистанции размера вектора. Первое условие очевидно. Следующие три диктуются технологией век-

торизации. Перед началом счета данные из векторизуемого массива переписываются в массив векторов. Например, в схеме, изображенной на рис. 4.1, массивы a и b из четырех элементов преобразуются в два элемента-вектора a и b, после чего производится одна операция сложения вместо четырех. Затем данные из результирующего вектора c переписываются в массив c.

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

Пятое условие – условие независимости итераций – требует отдельных пояснений.

Например, мы выполняем в цикле операцию над двумя массивами:

a[i] = b[i] + a[i + 1];

Предположим, цикл векторизован. На рис. 4.2 схематически показаны действия компилятора без векторизации (вверху) и с векторизацией (внизу). В качестве примера взят векторный регистр, вмещающий в себя четыре

61

+

a

1

2

3

4

5

6

7

8

b

1

2

3

4

5

6

7

8

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

+ + + +

a

1

2

3

4

5

6

7

8

b

1

2

3

4

5

6

7

8

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Рис. 4.3. Векторизация цикла a[i] = b[i] + a[i - 1]

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

заполнится числами 3, 5, 7, . . . , (2i + 1), . . . .

Ситуация изменится в случае, изображенном на рис. 4.3, когда в цикле выполняется операция

a[i] = b[i] + a[i - 1];

Тогда на каждом шаге используется значение, измененное на предыдущем. Рассмотрим верхнюю часть рис. 4.3: значение a[i - 1], равное 1, копируется в буфер обмена, суммируется с b[i] = 2, и полученное значение, равное 3, записывается в i-ю ячейку массива a. На следующем шаге данное значение, уже измененное, будет прибавлено к b[i + 1], равному 3. Таким образом, в массив a будут записаны значения 1, 3, 6, . . . , i(i+1)/2, . . . . Если векторизовать цикл, результат изменится: первые четыре элемента массива a (1, 2, 3, 4) будут прибавлены к четырем элементам массива b (2, 3, 4, 5) и записаны в массив a со сдвигом на одну ячейку. Таким образом, начало массива a заполнится числами 1, 3, 5, 7, 9. Очевидно, что в данном случае

62

векторизация приведет к некорректному результату вследствие зависимости итераций.

Однако если бы мы векторизовали цикл a[i] = b[i] + a[i - 4]

(рис. 4.4), то векторизация с длиной регистра 4 элемента прошла бы успешно, потому что итерации на этой дистанции независимы. При этом векторизация с длиной регистра 8 элементов привела бы к ошибкам.

При попытке векторизации компилятор действует максимально консервативно. Если он не может доказать отсутствие зависимости итераций или инвариантность переменной цикла, он делает самые худшие предположения и отказывается от векторизации. Более того, компилятор оценивает, выгодна ли векторизация, и производит ее только в случае положительного ответа. Структура кода иногда препятствует автоматической векторизации, хотя на самом деле ее можно было бы успешно произвести и в разы увеличить быстродействие.

Следует отметить некоторые особенности компилятора GNU. Помимо того, что векторизация не включается по умолчанию и требует флага O3, простые математические операции (sin(x), sqrt(x)) векторизуются только с флагом -Ofast. Также компилятор GNU чаще испытывает проблемы с условиями внутри цикла.

+

a

1

2

3

4

5

6

7

8

b

1

2

3

4

5

6

7

8

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

+ + + +

a

1

2

3

4

5

6

7

8

b

1

2

3

4

5

6

7

8

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Рис. 4.4. Векторизация цикла a[i] = b[i] + a[i - 4]

63

Во многих случаях можно модифицировать программу для облегчения автоматической векторизации, например убрать из цикла оператор if:

for

( int

i

= 0; i < N; i ++)

{

 

 

 

if

(i

!=

M)

 

A[i] =

B[i] + C[i ];

}

 

 

 

Если отчет о векторизации показал, что она не состоялась, можно убрать ветвление и разбить цикл на два: от 0 до M - 1 и от M + 1 до N. При достаточно большом N это может быть оправданно, однако всегда следует сравнивать время работы программы до и после изменений.

Также векторизация может не состояться при задании пользовательских функций:

for ( int

i = 0; i <

N; i ++)

{

 

 

A[i] =

mysum (B[i],

C[i ]) ;

}

 

 

Даже если функция mysum просто складывает аргументы, компилятору будет сложнее распознать векторизуемый цикл. Можно при определении функции использовать модификатор inline (встраиваемая), тогда тело функции будет подставляться в код на уровне компиляции. Иногда простые функции делаются встраиваемыми автоматически, но это следует проверять в каждом конкретном случае.

Также можно устранять короткие вложенные циклы для векторизации более длинных внешних (сам компилятор векторизует только внутренний цикл). Например, программу

for

( int

i = 0; i < 10000)

for ( int k = 0; k <

3)

 

A[i ][ k] = B[i ][ k]

+ C[i ][ k ];

рационально переписать в виде

for

( int

i = 0; i < 10000)

64

{

A[i ][0] A[i ][1] A[i ][2]

}

=B[i ][0]

=B[i ][1]

=B[i ][2]

+C[i ][0];

+C[i ][1];

+C[i ][2];

Некоторые компиляторы делают это автоматически. Также можно использовать директиву #pragma unroll перед началом внутреннего цикла.

Однако модификация программы не всегда дает нужный результат. Тогда могут помочь директивы препроцессора. При этом необходимо компилировать с флагом -fopenmp-simd для компилятора GNU или

-qopenmp-simd для Intel.

Директива #pragma vector always требует от компилятора векторизовать цикл, даже если он считает, что это будет невыгодно. Директива

#pragma ivdep заставляет снять худшие предположения о зависимости итераций. С ней векторизация будет осуществлена, если не удалось доказать зависимость итераций, но не удалось доказать и их независимость.

Самая сильная директива – #pragma simd, которая в некоторых случаях может привести к векторизации вопреки требованиям безопасности (т. е., к некорректному результату). Также в этой директиве можно непосредственно указать длину вектора. Например, #pragma simd vectorlength(2) задает векторы из двух элементов, что в некоторых случаях может снять зависимость итераций. Также #pragma simd позволяет некоторым компиляторам проводить редукцию, т. е. векторизовать простые арифметические операции над элементами массива, результат которых представляет собой одно число:

double

sum = 0.0;

# pragma

simd reduction (+: sum )

for (i = 0; i < N; i ++) sum += a[i ];

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

65

4.3.Технология OpenMP. Конфликт данных

иего избежание

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

Распараллеливание подразумевает создание нескольких нитей (потоков), каждый из которых выполняет алгоритм независимо от других. При создании потоков возможно указать, какие переменные должны храниться в каждом потоке отдельно. Остальные будут по умолчанию общими для всех потоков. Работа с потоками управляется директивами препроцессора, содержащимися в модуле omp.h. Для того чтобы эти директивы работали, необходимо компилировать программу с флагом -fopenmp, -qopenmp или

-openmp (в зависимости от компилятора).

Рассмотрим простейший пример параллельной программы, приведенный в листинге 4.6. В строке 1 подключается модуль omp.h. В строке 7 задаются переменные, в которых будут храниться количество потоков и номер текущего потока. В строке 8 создаются потоки, причем переменные nthreads

и tid копируются в каждый поток опцией private. В строках 10–17 алгоритм выполняется всеми потоками параллельно. В строке 10 каждый поток определяет свой номер и выводит его на экран. Порядок вывода абсолютно случаен и зависит от текущей скорости работы ядер. Ядро, которое завершило работу быстрее, выведет свой номер раньше. В строке 13 содержится условие tid == 0. В данном случае мы условно назначаем нулевой поток «мастером» и только он выполняет строки 15–16, т. е. определяет общее количество потоков и выводит его на экран. В строке 18 закрывается фигурная скобка, открытая в строке 6, и параллельный участок заканчивается.

66

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

Листинг 4.6. Пример параллельного кода

с использованием технологии OpenMP

1

# include

<omp .h >

2

# include

< stdio .h >

3

# include

< stdlib .h >

4

 

 

5

int main ( int argc , char ** argv )

6

{

 

7

int nthreads , tid ;

8

# pragma

omp parallel private ( nthreads , tid )

9

{

 

10tid = omp_get_thread_num () ;

11printf ( ’’ Hello World from thread = %d \n ’’, tid );

12if ( tid == 0)

13{

14nthreads = omp_get_num_threads () ;

15printf (‘‘ Number of threads = %d\n ’’,

nthreads );

16}

17}

18}

Технология OpenMP позволяет произвольное количество раз создавать и уничтожать параллельные потоки, но следует помнить, что это достаточно ресурсоемкая операция. Желательно по возможности объединять параллельные участки.

67

В приведенном примере время работы программы не уменьшалось, поскольку алгоритм выполнялся всеми потоками одновременно. Однако основное назначение OpenMP заключается в распараллеливании циклов. Для этого существует директива #pragma omp for, которая распределяет между потоками выполнение различных частей цикла. Иногда эту директиву совмещают с директивой parallel. Тогда потоки создаются в начале цикла. Например,

1

# pragma omp parallel for

2

for ( int

i = 0;

i < 100; i ++)

3

{

 

 

4

a[i] =

b[i] +

c[i ];

5

}

 

 

Эта программа в случае четырех потоков заставит каждый из них произвести 25 операций сложения: один поток сложит элементы с 1-го по 25-й, другой – с 26-го по 50-й и т. д. Количество потоков может задаваться снаружи системной переменной OMP_NUM_THREADS, которая в операционной системе Linux, например, задается командой вида export OMP_NUM_THREADS=4. Также можно задать его изнутри программы, вызвав перед параллельной областью процедуру omp_set_num_threads(int num_threads).

Разумно задавать количество потоков равным числу физических ядер процессора. Во многих современных процессорах число логических ядер вдвое больше числа физических. Это связано с особенностями оптимизации счета. В принципе можно задать число процессов равным числу логических ядер, но это не всегда выгодно с точки зрения скорости. Правильнее всего тестировать различные варианты и выбирать оптимальный.

В приведенном выше примере каждый процесс получает по 25 элементов цикла, но это не значит, что все процессы завершатся одновременно. Тактовая частота ядер может отличаться как систематически (т. е. одно ядро стабильно быстрее), так и стохастически (в данный момент какое-то ядро опережает). В итоге какое-то время часть потоков будет бездействовать. Во избежание этого можно ввести условие schedule (расписание). По умолчанию оно принимает значение static, т. е. между всеми потоками

68

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

Расписание guided работает похожим образом, но размер пакета уменьшается по мере приближения к концу цикла. Например, сначала все потоки получают по восемь итераций, потом по четыре, и т. д. Для введения расписания пишут, например, #pragma omp for schedule(dynamic, 15). Данная команда устанавливает динамическое расписание с размером пакета 15 итераций. Оптимальное расписание подбирается методом проб и ошибок.

Пример в листинге 4.7 демонстрирует некорректное использование технологии OpenMP. Программа суммирует арифметическую прогрессию, и ответ должен равняться единице. Однако параллельная версия будет выдавать вместо ответа случайные значения от 0 до 1. Например, одна из реализаций выдала ответ

s = 0.220008 working time = 0.000596

Листинг 4.7. Пример конфликта данных при использовании

OpenMP, приводящего к случайному результату

1

# include

" time .h"

2

# include

" stdio .h"

3

# include

" omp .h"

4

 

 

 

 

5

long

int

N =

100000;

6

 

 

 

 

7

int

main ( int

argc , char ** argv )

8

{

 

 

 

9clock_t t1 , t2 ;

10

double * b = new double [N ];

 

11

for ( int

i =

0;

i < N; i ++)

12

b[i] =

2.0

*

double (i) /

double (( N - 1) * N);

69

13double s = 0.0;

14t1 = clock () ;

15# pragma omp parallel for

16for ( int i = 0; i < N; i ++)

17s += b[i ];

18t2 = clock () ;

19printf ("s = %f working time = %f\n", s, double (t2 - t1) / CLOCKS_PER_SEC );

20}

Если убрать строку 15, результат будет правильным, хотя время выполнения увеличится:

s = 1.000000 working time = 0.033069

Следовательно, ошибка заключается именно в операции распараллеливания. Для того чтобы это понять, достаточно вспомнить, как работает операция суммирования (см. рис. 4.2–4.4). Значение s и b[i] копируются в буфер, затем суммируются и снова записываются в s. Однако при параллельной работе нескольких ядер часто случается ситуация, когда два ядра одновременно считывают значение s, затем одно из них прибавляет к нему b[i], второе прибавляет b[j], и наконец оба пытаются записать результат в одну и ту же ячейку памяти. При этом результат первого записавшего потока затирается вторым. Как следствие, часть слагаемых теряется, и конечная сумма принимает непредсказуемое значение (но всегда меньше правильного ответа). Это называется конфликтом данных.

В OpenMP есть специальные директивы для избежания конфликта данных:

1.#pragma omp critical – директива, за которой в фигурных скобках следует защищенный участок. Этот участок все процессоры выполняют по очереди.

2.#pragma omp atomic – то же, что critical, но действует только на одну следующую за ней строку в случае, если она содержит элементарную арифметическую операцию.

70

Соседние файлы в предмете [НЕСОРТИРОВАННОЕ]