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

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

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

Листинг 4.2. Программа из листинга 4.1, но с измерением среднего

времени выполнения основного цикла (13,3 секунды)

1

# include

" time .h"

 

 

 

 

2

# include

" math .h"

 

 

 

 

3

# include

" stdio .h"

 

 

 

 

4

 

 

 

 

 

 

 

 

 

 

 

5

int

NP =

10000

0

0000;

 

 

 

6

 

 

 

 

 

 

 

 

 

 

 

7

int

main ( int

argc ,

char **

argv )

8

{

 

 

 

 

 

 

 

 

 

 

9

float

m

=

1;

 

 

 

 

 

 

10

double

v

=

0;

 

 

 

 

 

11

double

E

=

0;

 

 

 

 

 

12

for ( int

j

=

 

0; j

<

20;

j ++)

13

{

 

 

 

 

 

 

 

 

 

 

14

 

time_t t1 = time ( NULL );

 

15

 

for

( int

i

 

= 0;

i

< NP ;

i ++)

16

 

{

 

 

 

 

 

 

 

 

 

17

 

if

(i

!=

 

5000000)

 

 

18

 

{

 

 

 

 

 

 

 

 

 

19

 

 

v

=

v

+

1;

 

 

 

 

20

 

 

E +=

m

*

pow (v ,

2)

/

2;

21}

22}

23time_t t2 = time ( NULL );

24dt += difftime (t2 , t1 );

25}

26printf (" averaged total work time = %f seconds \n", 0.05 * dt);

27}

51

Результат оказался близок к предыдущему: 13,3 секунды. Следовательно, полученное значение не является случайным, оно дает реальное представление о скорости работы кода.

Основная и довольно распространенная ошибка, существенно замедляющая работу данной программы – возведение в квадрат при помощи функции pow. Эта функция была разработана для вычисления произвольной вещественной степени. Она использует разложение в ряд Тейлора, т. е. производит большое количество арифметических операций, особенно для аргументов с двойной точностью. Функция pow работает значительно медленее функции извлечения корня (sqrt), которая в свою очередь работает медленнее умножения. Заменим девятую строку листинга 4.2 на строку

E += m * v * v / 2;. Среднее время выполнения сократится до 3,95 секунд, т. е. более, чем в три раза.

Продолжим оптимизацию. В выражении m * v * v / 2 присутствует деление на два. Деление – самая дорогостоящая из арифметических операций, поэтому нужно по возможности заменять его умножением на обратное число (умножение на 0,5 выполняется быстрее деления на 2). Однако в приведенном примере рациональнее до начала цикла ввести переменную m2 = 0.5 * m и затем на каждом шаге умножать на нее. Более того, можно один раз умножить на m2 после суммирования, так как массы всех частиц одинаковы. Однако если в дальнейшем планируется производить расчеты для частиц различной массы, следует оставить умножение на массу внутри цикла. Когда мы убираем из цикла деление на два, длительность выполнения падает до 3,65 секунд. Борьба за доли секунды может показаться бессмысленной, однако когда общая продолжительность работы кода составляет недели и месяцы, ускорение на 10 % может быть ощутимым.

Следует также учитывать неявное приведение типов данных. Переменная m в десятой строке листинга 4.2 задана с одинарной точностью (float). Далее переменные v и E имеют вещественный тип с двойной точностью double. Это кажется логичным с точки зрения использования оперативной памяти: в используемой нормировке масса равна единице, и кажется нерациональным хранить ее значение с высокой точностью. Однако при выполнении арифметических операций все переменные должны иметь один и тот же

52

тип. Если это не так, ошибки компиляции не возникает, но компилятор автоматически производит перед арифметической операцией операцию приведения типов данных. В данном случае мантисса float m перед умножением на квадрат скорости дополняется нулями до достижения двойной точности, что может снижать быстродействие.

Во многих компиляторах операции вида v = v + 1 тоже выполняются с приведением типов данных: единица в такой записи воспринимается как константа типа int. Вещественная константа должна записываться как

1.0 (с двойной точностью) или 1.0f (с одинарной). Если убрать в листинге 4.2 неявное приведение типов, то среднее время работы сократится еще на 0,05 секунд. Листинг 4.3 содержит текст программы после оптимизации.

Листинг 4.3. Оптимизированный пример из листингов 4.1 и 4.2

1

# include

" time .h"

 

 

 

2

# include

" math .h"

 

 

 

3

# include

" stdio .h"

 

 

 

4

 

 

 

 

 

 

 

 

5

int

NP =

10000

0000

0;

 

6

 

 

 

 

 

 

 

 

7

int

main ( int

argc ,

 

char **

argv )

8

{

 

 

 

 

 

 

 

9

double dt = 0.0;

 

 

 

10

double m2 = 0.5;

 

 

 

11

double v = 0.0;

 

 

 

12

double E = 0.0;

 

 

 

13

for ( int j

=

0;

j

< 20;

j ++)

14

{

 

 

 

 

 

 

 

15

 

time_t t1 = time ( NULL );

16

 

for

( int

i

= 0;

i < NP ; i ++)

17

 

{

 

 

 

 

 

 

18

 

if

(i

!=

5000000)

 

19

 

{

 

 

 

 

 

 

53

20

21

v = v + 1.0;

E += m2 * v * v;

22}

23}

24time_t t2 = time ( NULL );

25dt += difftime (t2 , t1 );

26}

27printf (" averaged total work time = %f seconds \n", 0.05 * dt);

28}

Воптимизированном тексте программы присутствует ветвление внутри цикла. Операция if достаточно дорогостоящая, кроме того, при появлении внутри цикла она препятствует векторизации (см. разд. 4.2). Иногда выгодно убрать условие из-под цикла и разбить цикл на две суммы: для i от 0 до

4 999 999 и от 5 000 001 до 109. Однако при инициализации цикла for выделяется место в стеке под переменную i, которая будет отсчитывать шаги. Поэтому инициализация дополнительного цикла отнимает время. Проверка показала, что в приведенном примере оставить условие внутри цикла оказывается несколько выгоднее, чем разбить цикл на два (различие составляет 0,1 секунды). Но иногда бывает рациональнее убрать ветвление из цикла. Например, если условие не зависит от переменной цикла i, можно поменять местами if и for, чтобы операция if оказалась снаружи цикла. Текст программы при этом может стать длиннее, а время работы – уменьшиться.

Еще один пример, требующий внимания – работа с многомерными массивами. Любой массив вида a[N1][N2][...] хранится в оперативной памяти компьютера последовательно, в языке C++ построчно (т. е. за последним элементом первой строки следует первый элемент второй и т. д.). В языке Fortran хранение массивов осуществляется по столбцам.

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

54

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

В листинге 4.4 приведен пример измерения времени суммирования элементов массива. Единственное отличие двух циклов (строки 20 и 28) заключается в порядке суммирования. При этом время выполнения первого из них составило 0,25 cекунды, а второго – 0,92 секунды, т. е. почти в четыре раза дольше. Это время было потрачено из-за обращения к двумерному массиву по столбцам.

Листинг 4.4. Пример суммирования двумерного массива

 

 

 

 

 

 

 

 

в различном порядке

1

# include

 

" time .h"

 

 

2

# include

 

" math .h"

 

 

3

# include

 

" stdio .h"

 

 

4

 

 

 

 

 

 

 

 

 

 

5

int

NI

=

 

10000;

 

 

 

6

int

NJ

=

 

10000;

 

 

 

7

 

 

 

 

 

 

 

 

 

 

8

int main ( int argc ,

char ** argv )

9

{

 

 

 

 

 

 

 

 

 

10

int **

a

=

new

int *

[ NI ];

11

int

i ,

 

j;

 

 

 

 

 

12

for

(i

 

=

0;

i

<

NI ;

i ++)

13

 

a[i]

 

= new int [ NI ];

14

for

(i

 

=

0;

i

<

NI ;

i ++)

15

 

for

(j

=

0;

j

< NJ ; j ++)

16

 

 

a[i ][ j] = rand () ;

17

clock_t t1 , t2 ;

 

 

18

int

sum

=

0;

 

 

 

19

t1

=

clock () ;

 

 

 

55

20

for ( int i

=

0;

i

<

NI ; i

++)

21

for ( int

j

=

0;

j

< NJ ;

j ++)

22sum += a[i ][ j ];

23t2 = clock () ;

24printf (" total work time = %f seconds \n", double (t2 - t1) / CLOCKS_PER_SEC );

25

26sum = 0;

27t1 = clock ();

28

for (int j

=

0;

j

<

NJ; j

++)

29

for (int

i

=

0;

i

< NI;

i ++)

30sum += a[i][j];

31t2 = clock ();

32printf (" total work time = %f seconds \n", double (t2 - t1) / CLOCKS_PER_SEC );

33}

Взавершение перечислим основные моменты, на которые следует обратить внимание при оптимизации.

1.Использование специальных математических функций без необходи-

мости.

2.Выполнение в цикле арифметических операций, которые можно было бы вынести за его пределы.

3.Ветвление в цикле.

4.Неявное приведение типов, в том числе для численных констант.

5.Порядок обращения к многомерным массивам.

Также следует избегать лишних циклов и тщательно продумывать алгоритм. В листинге 4.5 приведен пример достаточно распространенной у студентов ошибки при вычислении плотности частиц методом Nearest Grid Point (NGP) (метод изложен в первой части пособия). Для вычисления необходимо определить, в какой ячейке находится частица. В первом случае (строки 24–29) это осуществляется методом перебора, во втором (строки 34– 38) – при помощи прямого вычисления (при этом размер ячейки считается

56

равным единице). Время выполнения в первом случае (2,6 секунды) почти на три порядка больше времени выполнения во втором случае (0,003 секунды), потому что для каждой частицы вместо одного действия совершается полный обход тысячи ячеек.

Листинг 4.5. Вычисление плотности частиц на сетке методом NGP

(два способа)

1

# include

" time .h"

 

 

2

# include

" math .h"

 

 

3

# include

" stdio .h"

 

 

4

 

 

 

 

 

 

5

int

NP

=

1000000;

 

 

6

int

NC

=

1000;

 

 

7

 

 

 

 

 

 

8

double

GetRand ( double

min ,

double max )

9

{

 

 

 

 

 

10

return

( min + ( max

- min )

* rand () / ( RAND_MAX

 

 

+

1.0) );

 

 

11 }

12

13 int main ( int argc , char ** argv )

14

{

 

 

 

15

clock_t t1 , t2 ;

 

16

double * x = new double [ NP ];

17

int *

n =

new int [ NP ];

18

for

( int

i = 0; i < NP ; i ++)

19

x[i] =

GetRand (0 ,

NC );

20

for

( int

i = 0; i <

NC ; i ++)

21

n[i] =

0;

 

22

 

 

 

 

23t1 = clock () ;

24for ( int i = 0; i < NP ; i ++)

57

25

{

 

26

for ( int j = 0; j

< NC ; j ++)

27

if (( x[i] >= j)

&& (x[i] < j +1) )

28

n[j ]++;

 

29}

30t2 = clock () ;

31printf (" total work time = %f seconds \n",

double (t2 - t1) / CLOCKS_PER_SEC );

32

33t1 = clock ();

34for (int i = 0; i < NP; i ++)

35{

36int j = int(x[i]);

37n[j ]++;

38}

39t2 = clock ();

40printf (" total work time = %f seconds \n", double (t2 - t1) / CLOCKS_PER_SEC );

41}

4.2. Векторный регистр. Принцип SIMD. Директивы препроцессора

Архитектура современных процессоров включает векторный регистр – буферы оперативной памяти, в которые может быть одновременно загружено несколько элементов численного массива. При этом элементарные арифметические операции с этими числами могут выполняться синхронно, что значительно увеличивает скорость счета (рис. 4.1). Эта технология называется SIMD (Single Instruction Multiple Data).

Размер векторного регистра зависит от типа процессора. Например, в персональных компьтерах часто встречается 64-, 128-, 256- и 512-битные регистры, а в специализированных вычислительных комплексах на видеокартах размер векторного регистра может быть существенно больше. Целочисленные переменные могут занимать от одного до восьми байт, т. е. от 8

58

 

 

 

 

 

 

 

 

SIMD

 

 

 

a0

+

b0

=

c0

 

Single Instruction Multiple Data

 

 

 

 

 

 

 

 

 

+

 

=

 

 

 

a[0]

 

b[0]

 

c[0]

 

 

 

 

 

 

 

 

 

a1

b1

c1

vectorize

 

 

 

 

 

 

 

 

a[1]

+

b[1]

=

c[1]

 

 

+

 

=

 

 

 

 

 

 

a2

b2

c2

 

 

 

 

 

 

 

 

a[2]

b[2]

c[2]

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

=

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

a3

+

b3

c3

 

 

a[3]

 

b[3]

 

c[3]

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Рис. 4.1. Схема векторизации операции суммирования: четыре элемента складываются со скоростью одного

до 64 бит, вещественные – четыре (float) или восемь (double) байт. Таким образом, в векторном регистре персонального компьютера можно разместить одновременно до 64 чисел, при этом скорость счета увеличится в 64 раза.

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

Рассмотрим для начала флаги компиляции (на примере языка C++). Самым общим является флаг оптимизации (-O1, -O2, -O3), где O1 соответствует минимальному уровню оптимизации, а O3 – максимальному. По умолчанию устанавливается уровень оптимизации O2. Компиляция из командной строки с использованием флага оптимизации может выглядеть

следующим образом:

g ++ -O3 myprogram . cpp

В компиляторе GNU C++ (g++) попытка векторизации предпринимается начиная с уровня O3, а в компиляторе Intel C++ – с уровня O2, т. е.

59

по умолчанию. Для того чтобы проверить, удалась ли векторизация, необходимо включить специальный флаг компилятора. В последних версиях g++ используется флаг -fopt-info-vec-all, в компиляторе Intel – -qopt-report

или -Qvec-report (в Visual studio). При этом после компиляции на экране появляется информация вида

myprogram . cpp :23:20: optimized : loop

vectorized

using

16 byte vectors

 

 

myprogram . cpp :20:20: optimized : loop

vectorized

using

16 byte vectors

 

 

myprogram . cpp :15:5: note : vectorized

2 loops in

 

function .

 

 

Это означает, что в коде содержатся два цикла, на 20-й и 23-й строках, которые были успешно векторизованы со 128-битным (16-байтным) векторным регистром. Конкретные формулировки зависят от типа и версии компилятора, но в любом случае будет выведена информация обо всех векторизованных циклах.

Если векторизация не состоялась, появится информация вида

myprogram . cpp :23:20: missed : couldn ’t vectorize loop myprogram . cpp :23:20: missed : not vectorized : control

flow in loop .

Во второй строчке указана причина control flow in loop, т. е. наличие условия внутри цикла (в данном случае – наличие оператора if).

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

1.Векторизуемость типов данных (int, float, double).

2.Цикл фиксированной длины: for (i = a1; i < a2; i += a3), где

a1, a2, a3 – целочисленные инварианты цикла (т. е. внутри цикла нет, например, команды a2 = a2 * 2).

3. Отсутствие дополнительных точек входа и выхода из цикла (return, break, continue, goto).

4. Простое тело цикла (без вложенных циклов и сложных условных конструкций). Это условие более жесткое для компилятора GNU, в то время как компилятор Intel справляется с более сложными задачами.

60

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