Komputerowe Obliczenia Równoległe: Wstęp do OpenMP i MPI Patryk Mach Uniwersytet Jagielloński, Instytut Fizyki im. Mariana Smoluchowskiego
Zadania w OpenMP technika zrównoleglania bardziej złożonch problemów, ale również istotna część terminologii OpenMP. Fragment ze specyfikacji: Task A specyfic instance of executable code and its data environment, generated when a thread encounters a task construct or a parallel construct. (... ) When any thread encounters a parallel construct, the thread creates a team of itself and zero or more additional threads and become the master of new team. A set of implicit tasks, one per thread, is generated. The code for each task is defined by the code inside the parallel construct. Each task is assigned to a different thread in the team and becomes tied; that is, it is always executed by the thread to which it is initially assigned. The task region of the task being executed by the encountering thread is suspended, and each member of the new team executes its implicit task.
(... ) When any thread encounters a task construct, a new explicit task is generated. Execution of explicitly generated tasks is assigned to one of the threads in the current team, subject to the thread s availability to execute work. Thus, execution of the new task could be immediate, or deferred until later. Zadania wykonywane przy udziale jawnej konstrukcji task są najczęściej zagnieżdżone. Omówienie takiej sytuacji wymaga wprowadzenia paru pojęć. Obszar zadania (task region) obszar składający się z kodu wykonywanego w danym zadaniu. Zadanie generujące (generating task) zadanie, którego wykonanie spowodowało wygenerowanie zadania związanego z aktualnym obszarem kodu. (Kod poza obszarem równoległym traktowany jest również jako zadanie).
Równocześnie, każde zadanie jest traktowane jako zadanie dziecko (child task) obszaru swojego zadania generującego. Zadanie dziecko nie jest częścią zadania generującego. Mówi się również o zadaniach potomnych (descendant tasks). Punkt rozdzielania zadań miejsce w programie, w którym wykonywanie aktualnego zadania może zostać tymczasowo zawieszone, albo miejsce, w którym zadanie się kończy. W punkcie rozdzielania zadań, wątek wykonujący zadanie może rozpocząć wykonywanie kolejnego zadania. Zadania mogą być określone jako tied albo untied. Zadanie określone jako tied musi być dokończone przez wątek, który rozpoczął jego wykonywanie. Zadania untied mogą być dokończone przez dowolny wątek. W obszarach zadań typu untied punkt rozdziału zadań może wystąpić w dowolnym miejscu. W obszarach zadań typu tied punkt rozdziału zadań może wystąpić jedynie tam, gdzie pojawia się bariera (jawna lub wymuszona), oraz w konstrukcjach taskwait i task.
Wszystkie zadania związane z danym obszarem równoległym muszą być ukończone, nim główny wątek opuści dany obszar równoległy. W punkcie rozdziału zadań wątek rozocząć wykonywanie nowego zadania przypisanego do danego obszaru równoległego. Nowe zadanie może być zarówno typu tied, jak i untied. Wątek może też przejść do kontynuacji zawieszonego zadania typu untied lub przypisanego mu zadania tied.
Konstrukcja task definiuje nowe jawne zadanie. Składnia w Fortranie:!$omp task [klauzula[[,] klauzula]...] blok!$omp end task Składnia w C/C++: #pragma omp task [klauzula[[,] klauzula]...] nowa linia blok Dopuszczalne klauzule: if, untied, default, private, firstprivate, shared.
Klauzula if ma postać if(wyrażenie logiczne). Jeśli wyrażenie ma wartość.false. w Fortranie lub 0 w C/C++, wątek przystępuje do natychmiastowego wykonywania zadania. W przeciwnym wypadku zadanie może zostać odłożone na potem. Zadanie utworzone konstrukcją task jest typu tied, chyba, że pojawi się klauzula untied. Pozostałe klauzule dotyczą rodzaju zmiennych występujących w obszarze zadania. Punkty rozdziału zadań związane bezpośredni z dyrektywą task znajdują się przed pierwszą i za ostatnią instrujcą stowarzyszonego z dyrektywą obszaru zadania.
Konstrukcja taskwait wymusza zakończenie wszystkich zadań dzieci określonych od momentu rozpoczęcia aktywnego zadania. Składnia w Fortranie:!$omp taskwait Składnia w C/C++: #pragma omp taskwait nowa linia Z konstrukcją taskwait związany jest punkt rozdziału zadań.
Przykład: Uwaga na błędy w oryginalnej wersji zawartej w specyfikacji! Chcemy policzyć n-ty wyraz ciągu Fibonacciego zadany rekurencją F n = F n 1 + F n 2, F 0 = 0, F 1 = 1. Istnieją przyzwoite metody rozwiązania tego problemu, ale na potrzeby ilustracji spróbujmy zastosować definicję bezpośrednio.
program fibonacci implicit none interface recursive function fib(n) result(res) implicit none integer:: res, n end function end interface integer :: n, fn n = 40 fn = fib(n) write(*,*) Fibonacci series: write(*,*) n =, n, Fn =, fn end program fibonacci
recursive function fib(n) result(res) implicit none integer :: res, n integer :: i, j if (n.lt. 2) then res = n else i = fib(n-1) j = fib(n-2) res = i+j end if end function
program fibonacci implicit none interface recursive function fib(n) result(res) implicit none integer:: res, n end function end interface integer :: n, fn n = 40!$omp parallel shared(n,fn)!$omp single fn = fib(n)!$omp end single!$omp end parallel write(*,*) Fibonacci series: write(*,*) n =, n, Fn =, fn end program fibonacci
recursive function fib(n) result(res) implicit none integer :: res, n integer :: i, j if (n.lt. 2) then res = n else!$omp task shared(i) i = fib(n-1)!$omp end task!$omp task shared(j) j = fib(n-2)!$omp end task!$omp taskwait res = i+j end if end function
Powyższy przykład jest czysto akademicki. W zastosowaniu do ciągu Fibonacciego nie uzyskujemy przyspieszenia wykonywania zadania. Jest to jednak poprawny sposób zrównoleglenia mniej ziarnistych rekurencji. Kolejne przykłady pochodzą od Yuana Lina z Sun Microsystems.
Rozpoczynanie obszaru równoległego z jednym głównym zadaniem: #pragma omp parallel #pragma omp single nowait /* this is the initial root task */ #pragma omp task /* this is first child task */ #pragma omp task /* this is second child task */
Rozważmy: void foo () #pragma omp task A(); #pragma omp task B(); Zadanie generujące jest też zadaniem i może być wykonywane równocześnie: void foo () #pragma omp task A(); B();
Grupa zadań. Przypuśćmy, że chcemy policzyć następujące zadanie: /* Compute f2 (A, f1 (B, C)) */ int foo () int a, b, c, x, y; a = A(); b = B(); c = C(); x = f1(b, c); y = f2(a, x); return y;
Poszukujemy rozwiązania w poniższym stylu, ale konstrukcja _taskgroup_ nie istnieje. /* Compute f2 (A, f1 (B, C)) */ void foo () int a, b, c, x, y; #pragma omp task shared(a) a = A(); #pragma omp _taskgroup_ #pragma omp task shared(b) b = B(); #pragma omp task shared(c) c = C(); x = f1 (b, c); #pragma omp taskwait y = f2 (a, x);
Rozwiązanie: /* Compute f2 (A, f1 (B, C)) */ void foo () int a, b, c, x, y; #pragma omp task shared(a) a = A(); #pragma omp task if (0) shared (b, c, x) #pragma omp task shared(b) b = B(); #pragma omp task shared(c) c = C(); #pragma omp taskwait x = f1 (b, c); #pragma omp taskwait y = f2 (a, x);
Nie warto nadużywać konstrukcji task. Poniższe dwie pętle powinny dawać równoważne rezultaty, ale pierwsza jest szybsza. /* An OpenMP worksharing for loop */ #pragma omp for for (i=0; i<n; i++) foo(i); /* The above loop converted to use tasks */ #pragma omp single nowait for (i=0; i<n; i++) #pragma omp task firstprivate(i) foo(i);
Redukcja w połączonej liście. Przypomnienie: typedef struct item int data; struct item *next; item_t; Liczymy dobre elementy w liście: int count_good (item_t *item) int n = 0; while (item) if (is_good(item)) n ++; item = item->next; return n;
int count_good (item_t *item) int n = 0; #pragma omp parallel #pragma omp single nowait while (item) #pragma omp task firstprivate(item) if (is_good(item)) #pragma omp atomic n ++; item = item->next; return n;
Zamki (locks) w OpenMP z zadaniami związane są zamki, tj. specjalne zmienne oraz operujący na nich układ funkcji bibliotecznych. Zmienne te służą do synchronizacji zadań. Zmienna tworząca zamek może istnieć w 3 stanach: niezainicjalizowanym (uninitialized), zamkniętym (locked) oraz otwartym (unlocked). Zadanie może zmienić stan zamka z otwartego na zamknięty. Takie zadanie staje się właścicielem zamka. Właściciel zamka może, na powrót, zmienić jego stan z zamkniętego na otwarty. Pozostałym zadaniom nie wolno tego robić. OpenMP wprowadza 2 rodzaje zamków: proste (simple) oraz zagnieżdżalne (nestable). Zagnieżdżalny zamek może być zamykany wiele razy przez to samo zadanie można zamykać zamknięty zamek. Prosty zamek musi być otwarty, aby można było go zamknąć.
W Fortranie prosty zamek jest zmienną integer typu kind=omp_lock_kind, a zamek zagnieżdżalny typu kind=omp_nest_lock_kind. W C/C++ określony jest typ omp_lock_t dla zamka prostego oraz typ omp_nest_lock_t dla zamka zagnieżdżalnego. Wszystkie funkcje operujące na zamkach wymuszają operację flush pracujemy zawsze na aktualnych wartościach zamków; nie ma potrzeby ręcznego wymuszania operacji flush. Zajmiemy się teraz funkcjami pracującymi na zamkach prostych.
Inicjalizacja zamka: W Fortranie: subroutine omp_init_lock (zamek) integer (kind=omp_lock_kind) zamek W C/C++: void omp_init_lock(omp_lock_t *zamek);
Zapewnienie, że zamek będzie w stanie niezainicjalizownym: W Fortranie: subroutine omp_destroy_lock (zamek) integer (kind=omp_lock_kind) zamek W C/C++: void omp_destroy_lock(omp_lock_t *zamek);
Zamykanie zamka: W Fortranie: subroutine omp_set_lock (zamek) integer (kind=omp_lock_kind) zamek W C/C++: void omp_set_lock(omp_lock_t *zamek);
Otwieranie zamka: W Fortranie: subroutine omp_unset_lock (zamek integer (kind=omp_lock_kind) zamek W C/C++: void omp_unset_lock(omp_lock_t *zamek);
Sprawdzanie zamka: W Fortranie: logical function omp_test_lock (zamek) integer (kind=omp_lock_kind) zamek W C/C++: int omp_test_lock(omp_lock_t *zamek); Funkcja omp_test_lock próbuje zamknąć zamek. Jeśli zamek był w stanie otwartym, zamyka go i zwraca wartość odpowiadającą prawdzie. Jeśli zamka nie udało się zamknąć (zamek należy do innego zadania), funkcja zwraca wartość odpowiadającą fałszowi.
Funkcje odpowiadające zamkom zagnieżdżalnym zachowują się niemal tak samo. W nazwach występuje nest_lock zamiast lock. Ponadto, zarówno w Fortranie, jak i w C/C++ funkcja omp_test_nest_lock zwraca zaktualną wartość licznika mówiącego o stopniu zamknięcia zamka, lub zero w przypadku, gdy zamka nie udało się zamknąć. Funkcja omp_set_nest_lock zwiększa wartość licznika o 1, funkcja omp_unset_nest_lock zmniejsza wartość licznika o 1. Funkcje te operują na zmiennych właściwego typu, tj. integer (kind=omp_nest_lock_kind) w Fortranie i omp_nest_lock_t w C/C++.
program zamek use omp_lib integer(omp_lock_kind) :: lck integer :: id call omp_init_lock(lck)!$omp parallel shared(lck) private(id) id = omp_get_thread_num() do while (.not. omp_test_lock(lck))! nie mamy zamka i wykonujemy coś zastępczego call skip(id) end do! mamy zamek i wykonujemy pracę call work(id) call omp_unset_lock(lck)!$omp end parallel call omp_destroy_lock(lck) end program zamek
W OpenMP 2.5 zamki były przypisane do wątków. Wersja 3.0 przypisuje zamki do zadań. W wersji 3.0 poniższy program nie jest poprawny (przykład ze specyfikacji): program lock use omp_lib integer :: x integer(kind=omp_lock_kind) :: lck call omp_init_lock(lck) call omp_set_lock(lck) x = 0!$omp parallel shared(x)!$omp master x = x + 1 call omp_unset_lock(lck)!$omp end master! tutaj jakaś praca!$omp end parallel call omp_destroy_lock(lck) end