Komputerowe Obliczenia Równoległe: Wstęp do OpenMP i MPI Patryk Mach Uniwersytet Jagielloński, Instytut Fizyki im. Mariana Smoluchowskiego
OpenMP (Open Multi Processing) zbiór dyrektyw kompilatora, funkcji bibliotecznych, zmiennych środowiskowych pozwalających na zrównoleglenie programów wykonywanych na komputerach współdzielących pamięć. OpenMP standaryzuje lata doświadczeń w programowaniu z wykorzystaniem współdzielonej pamięci. Implementacje dla Fortranu oraz C/C++ dostarczane przez producentów kompilatorów. Strona internetowa projektu: http://openmp.org można pobrać najnowszą specyfikację.
Dyrektywy OpenMP mają formę odpowiednich komentarzy (Fortran) lub dyrektyw #pragma (C/C++). Kompilator wspierający OpenMP interpretuje je jako odpowiednie instrukcje. Kompilator bez wsparcia OpenMP lub z wyłączonym wsparciem może je łatwo zignorować. Wsparcie OpenMP wymaga zwykle zastosowania odpowiedniej flagi podczas kompilacji, np. ifort -openmp prog.f90 -o prog icc -openmp prog.c -o prog Dla kompilatorów z grupy gcc stosowna flaga ma postać -fopenmp.
Równoległość w OpenMP uzyskuje się w ramach modelu fork join.
Przykład na dobry początek: Podstawowa konstrukcja parallel rozpoczyna obszar równoległy. Do momentu jej napotkania program wykonywany jest seryjnie, po czym tworzony jest zespół wątków wykonujących dalsze zadania. Po wyjściu z obszaru równoległego program wykonywany jest przez jeden wątek. program hello1 write(*,*) 'Jestem w obszarze seryjnym.'!$omp parallel write(*,*) 'Jestem w obszarze rownoleglym.'!$omp end parallel write(*,*) 'Jestem w obszarze seryjnym.' end program hello1
Wersja C++ powyższego programu: #include <iostream> using namespace std; main(){ cout << "Jestem w obszarze seryjnym.\n"; #pragma omp parallel { cout << "Jestem w obszarze rownoleglym.\n"; } } cout << "Jestem w obszarze seryjnym.\n";
Dyrektywy w Fortranie: W formie fixed source wszystkie linijki dyrektyw muszą rozpoczynać się od!$omp, c$omp lub *$omp. Powyższe komentarze muszą rozpoczynać się w 1. kolumnie. W kolumnie 6. pierwszej dyrektywy musi znajdować się spacja lub zero. Kolumna 6. kontynuacji dyrektywy nie może zawierać zera ani spacji. Stosują się przy tym zwykłe zasady dotyczące długości linii, kontynuacji, pozycji w kolumnach itp. Przykład równoważnych dyrektyw: c23456789!$omp parallel do shared(a,b,c) c$omp parallel do c$omp+shared(a,b,c) c$omp paralleldoshared(a,b,c)
W formie free source dyrektywy rozpoczynają się od!$omp. Dyrektywa musi być poprzedzona białymi znakami. Stosują się zwykłe zasady dotyczące kontynuacji linii. Przykład:!23456789!$omp parallel do &!$omp shared(a,b,c)!$omp parallel &!$omp&do shared(a,b,c)
Kompilacja warunkowa chcemy, aby pewne fragmenty kodu były wykonywane jedynie gdy wspierany jest OpenMP. Fortran: Odpowiednie fragmenty kodu umieszczamy w linijkach rozpoczynających się znacznikiem typu komentarza. W notacji fixed source możliwe są znaczniki!$, *$ oraz c$. Znaczniki te wymieniane są na 2 spacje. Po takiej wymianie muszą być spełnione normalne reguły notacji. W notacji free source możliwy jest tylko jeden znacznik!$. C/C++: W implementacjach wspierających OpenMP i preprocesor, zdefiniowane jest makro _OPENMP. Wartość _OPENMP ustalona jest na yyyymm, gdzie yyyy oznacza rok, a mm miesiąc indentyfikujące wspieraną wersję OpenMP.
Przykład: program hello2!$ use omp_lib implicit none!$ integer :: num_threads write(*,*) 'Jestem w obszarze seryjnym'!$omp parallel write (*,*) 'Jestem w obszarze rownoleglym'!$omp single!$ num_threads = omp_get_num_threads()!$ write(*,*) 'liczba watkow: ', num_threads!$omp end single!$omp end parallel write (*,*) 'Jestem w obszarze seryjnym' end program hello2
Podstawowa kwestia do dobrego zrozumienia: model pamięci używany przez OpenMP. Każdy z wątków ma dostęp do wspólnej części pamięci. Każdy z wątków może używać tymaczasowego widoku (temporary view) pamięci pamięci pełniącej rolę typu cache odwoływanie się do zmiennych ze wspólnej pamięci nie musi faktycznie oznaczać zmianu stanu zmiennych wspólnej pamięci. Zmiana ta może odbyć się w ramach tymczasowego widoku. Każdy z wątków dysponuje obszarem pamięci zarezerwowanym dla zmiennych prywatnych dostępnych jedynie dla danego wątku.
Zmienne w ramach OpenMP mogą być deklarowane jako współdzielone (shared) bądź prywatne (private). Zmienne wspołdzielone przechowywane są we wspólnej części pamięci, a ich kopie mogą znajdować się również w tymczasowym widoku pamięci. Operacja synchronizująca tymczasowy widok pamięci danego wątku oraz wspólną pamięć to operacja flush. Operacja flush jest wywoływana automatycznie przez część konstrukcji OpenMP, ale może być też wymuszona dedykowaną dyrektywą flush, gdy zachodzi taka potrzeba.
W chwili, w której rozpoczyna się wykonywanie obszaru równoległego, w prywatnej pamięci każdego z wątków tworzona jest osobna kopia każdej zmiennej zadeklarowanej jako zmienna prywatna w danym obszarze równoległym. Zmiany takiej zmiennej dokonywane przez poszczególne wątki dotyczą jedynie pamięci prywatnej i nie wpływają na wartość danej zmiennej widzianej przez pozostałe wątki. Kopie zmiennych prywatnych mogą być inicjalizowane wartością z obszaru seryjnego, jeśli jest to wyraźnie zaznaczone (klauzula firstprivate).
Składnia konstrukcji parallel w Fortranie:!$omp parallel [klauzula[[,] klauzula]...] blok!$omp end parallel W C/C++: #pragma omp parallel [klauzula[[, ]klauzula]...] blok Dopuszczalne klauzule: if, num_threads, default, private, firstprivate, shared, copyin, reduction. Będzie o nich mowa w dalszej części wykładu.
Klauzula if ma postać if(wyrażenie warunkowe). Jeśli wyrażenie warunkowe ma wartość.false. (Fortran) lub 0 (C/C++), obszar określony strukturą parallel wykonywany jest przez jeden wątek (seryjnie). W przeciwnym wypadku liczba wątków może zostać określona kaluzulą num_threads(liczba watków). Domyślna liczba wątków przechowywana jest przez zmienną środowiskową OMP_NUM_THREADS. W obrębie struktury parallel liczba wątków zwracana jest przez funkcję biblioteczną omp_get_num_threads(). Każdy z wątków w obszarze równoległym (określonym poprzez konstrukcję parallel) numerowany jest począwszy od 0, do n 1, gdzie n jest liczbą wątków. Wątek może uzyskać swój numer wywołująć funkcję omp_get_thread_num(). Reguły określania liczby wątków stają się bardziej skomplikowne w przypadku zagnieżdżonych obszarów równoległych.
Zmienne wymienione w klauzuli shared(lista zmiennych rozdzielana przecinkami) są traktowane jako współdzielone w obszarze zadanym konstrukcją parallel. Domyślnie, większość zmiennych jest współdzielona. Wyjątek stanowią np. indeksy występujące w pętlach do (Fortran) oraz for (C/C++), z którymi związane są odpowiednie dyrektywy OpenMP. Indeksy te traktowane są jako zmienne prywatne. Podobnie prywatne są zmienne wymienione w klauzuli private(lista zmiennych rozdzielana przecinkami).
Przykład: obliczany liczbę π. Wiemy, że 1 dx π = 4 0 1 + x 2. spróbujmy obliczyć tę wartość metodą trapezów.
program pi_integral implicit none integer, parameter :: lp = selected_int_kind(8) integer, parameter :: dp = kind(1.0d0) real(dp) :: pi, x, dx integer(lp) :: num_points, i num_points = 1000 dx = 1.0_dp/real(num_points,dp) pi = 0.0_dp do i = 1, num_points x = (i - 0.5_dp)*dx pi = pi + 4.0_dp/(1.0_dp + x**2 ) end do pi = pi*dx write(*,*) 'wartosc pi: ', pi end program pi_integral
Poniższy kod przedstawia wersję programu pi_integral zrównolegloną wyłącznie przy użyciu konstrukcji parallel. program pi_integral!$ use omp_lib implicit none integer, parameter :: lp = selected_int_kind(8) integer, parameter :: dp = kind(1.0d0) real(dp) :: pi, x, dx integer(lp) :: num_points, i integer(lp) :: num_i, num_f!$ integer(lp) :: thread_num, num_threads, points_per_thread, reszta!$ real(dp), dimension(:), allocatable :: pi_parts num_points = 1000 num_i = 1 num_f = num_points dx = 1.0_dp/real(num_points,dp)
!$omp parallel!$omp single!$ num_threads = omp_get_num_threads()!$omp end single!$omp end parallel!$ allocate(pi_parts (0:num_threads - 1))!$ points_per_thread = num_points«um_threads!$ reszta = mod(num_points, num_threads)!$omp parallel private(thread_num, num_i, num_f, x, pi, i)!$ thread_num = omp_get_thread_num()!$ if (thread_num.lt. reszta) then!$ num_i = thread_num*(points_per_thread+1) + 1!$ num_f = num_i + points_per_thread!$ else!$ num_i = reszta + thread_num*points_per_thread + 1!$ num_f = num_i + points_per_thread - 1!$ endif
pi = 0.0_dp do i = num_i, num_f x = (i - 0.5_dp)*dx pi = pi + 4.0_dp/(1.0_dp + x**2 ) end do!$ pi_parts(thread_num) = pi!$omp end parallel!$ pi = 0.0_dp!$ do i = 0, num_threads-1!$ pi = pi + pi_parts(i)!$ end do!$ deallocate (pi_parts) pi = pi*dx write(*,*) 'wartosc pi: ', pi end program pi_integral
W powyższym przykładzie mamy do czynienia z 2 konstrukcjami, które stworzyliśmy ręcznie, a które pojawiają się często w zastosowaniach i którym dedykowane są odpowiednie dyrektywy OpenMP. Pierwsza konstrukcja polega na rozdzieleniu pracy wykonywanej w pętli do (Fortran) lub for (C/C++) na poszczególne wątki tak, aby każdy wątek wykonywał pracę odpowiadającą jedynie pewne u zakresowi indeksów pętli. Jest to przykład ogólniejszej kategorii konstrukcji dzielących pracę (work-sharing constructs). Po drugie, wprowadzamy mechanizm zbierania wyników pracy każdego z wątków (macierz pi_parts), a następnie sumujemy uzyskane wyniki, tak aby uzyskać pożądaną wartość całki. Jest to przykład tak zwanej redukcji. Redukcja implementowana jest w OpenMP jako klauzula dyrektywy parallel.