OpenMP

Dostęp do zasobów ICM
Konto użytkownika
Grant obliczeniowy
Uruchamianie obliczeń
Oprogramowanie
Pomoc
Poradniki różne

OpenMP to zestaw tzw. dyrektyw, czyli jakby komentarzy, podpowiadających kompilatorowi w jaki sposób można zrównoleglić kod. OpenMP pozwala zrównoleglać programy napisane w językach Fortran i C. Jest to bardzo wygodne z punktu widzenia programisty rozwiązanie, ponieważ pozwala na bardzo szybkie zrównoleglenie istniejącego kodu. Wady to ograniczona stosowalność, sprowadzająca się zwykle do jednego węzła w klastrze.

Spis treści

Model pamięci

OpenMP stosuje się w przypadku architektur o pamięci współdzielonej (można o tym myśleć jako o pojedynczym komputerze - stacji roboczej lub węźle klastra). Przypomnijmy, model pamięci współdzielonej zakłada, że każdy procesor "widzi" całą dostępną pamięć, co redukuje komunikację pomiędzy procesorami.

Myśląc obrazowo: pamięć współdzielona to jeden duży stół, dookoła którego pracują robotnicy (procesory). Każdy widzi rezultaty pracy pozostałych i może z nich natychmiast skorzystać. Dla kontrastu, model pamięci rozproszonej zakłada, że każdy robotnik siedzi przy swoim małym stoliku, a rezultaty pracy "rzuca", lub "wykrzykuje" do innego zainteresowanego robotnika (procesora).

Więcej o własnościach pamięci współdzielonej i rozproszonej, a także o różnicach pomiędzy nimi, znaleźć można we wprowadzeniu do programowania równoległego.

Pierwszy program

Zobaczmy na przykładzie, jak zrównoleglić program przy użyciu dyrektyw OpenMP. Wykorzystajmy w tym celu program obliczający wartość \pi korzystając ze wzoru \pi = 4 \int_0^1 \frac{1}{1+x^2}.

1.        program openmpi
2.        implicit none
3.        integer i, n
4.        real sumpi, mypart, x
5.        parameter (n = 1000000)
6.
7.        sumpi = 0.0
8.        do i = 1, n
9.          x = (i + 0.5) / n
10.         mypart = 1.0/(1.0 + x**2)
11.         sumpi = sumpi + mypart
12.       end do
13.       sumpi = 4.0 * sumpi / n
14.       print *, sumpi
15.       end program

Dla dużych wartości n, większość obliczeń wykonywanych jest w pętli w liniach 8-12. Chcielibyśmy więc podzielić pracę w ten sposób, aby każdy z procesorów "wziął" część iteracji pętli. W tym celu piszemy po prostu przed pętlą dyrektywę - informację dla kompilatora, że chcemy zrównoleglić tę pętlę:

1.        program openmpi
2.        implicit none
3.        integer i, n
4.        real sumpi, mypart, x
5.        parameter (n = 1000000)
6.
7.        sumpi = 0.0
8.  !$omp parallel do default(none)
9.  !$omp&  private(i, x, mypart)
10. !$omp&  shared(n, sumpi)
11.       do i = 1, n
12.         x = (i + 0.5) / n
13.         mypart = 1.0/(1.0 + x**2)
14. !$omp critical
15.         sumpi = sumpi + mypart
16. !$omp end critical
17.       end do
18.       sumpi = 4.0 * sumpi / n
19.       print *, sumpi
20.       end program

Pojawiło się kilka tajemniczych napisów, pora na wyjaśnienia. Zacznijmy od składni: !$omp na początku linijki, ewentualnie ze znakiem kontunuacji &, to informacja dla kompilatora, że linijkę należy traktować jako część dyrektywy OpenMP, a nie jako zwykły komentarz. parallel do, critical (i zamykające end critical) to dyrektywy, wszystko po nich (u nas: default(...), private(...), shared(...)) to dookreślenia dyrektyw.

Jak wstawiać dyrektywy

W Fortranie 77 dyrektywy sygnalizowane są pojawieniem się na początku linijki (od pierwszej kolumny) jednego z następujących przedrostków:

 !$OMP
 C$OMP
 *$OMP

Tak jak to się przyjęło w Fortranie 77, w 6. kolumnie można umieścić znak kontynuacji, jeśli treść dyrektywy nie zmieściła się w poprzedniej linijce.

W rozluźnionej składni Fortranu 90 dyrektywy poprzedzone muszą być:

 !$OMP

który nie musi rozpoczynać się od pierwszej kolumny, ale wtedy musi być poprzedzony spacjami. Kontynuacje linii zgodne ze zwyczajem Fortranu 90.

W języku C dyrektywy poprzedzamy:

 #pragma omp

Regiony równoległe

Dyrektywa parallel do dotyczy zawsze pętli znajdującej się pod nią, nie ma więc potrzeby (choć można) pisania end parallel do po pętli. Dyrektywa ta tworzy tzw. region równoległy, w którym praca dzielona jest pomiędzy procesory.

Wykonanie programu wygląda następująco: poza regionami równoległymi, kod wykonywany jest przez jeden procesor, tzw. master processor. Gdy natrafiamy na początek regionu równoległego, zatrudniane są pozostałe procesory, które przy kończeniu regionu równoległego są zwalniane. Jest to tzw. model fork-join, schematycznie przedstawiony na poniższym rysunku.

Model fork-join

Zasięg zmiennych

Konstrukcje default, private, shared wiążą się z bardzo ważnym w OpenMP pojęciem zasięgu zmiennych. Jak powszechnie wiadomo, całkowity brak własności prywatnej jest kiepską sprawą. Podobnie jest w świecie komputerów: czasem w trakcie obliczeń każdy z procesorów chciałby mieć swoje prywatne zmienne robocze. Z drugiej strony, warto mieć także zmienne wspólne, na przykład przechowujące końcowy wynik prac, czy stałe używane podczas obliczeń.

Tak więc w momencie, gdy otwierany jest region równoległy i pojawiają się procesory gotowe do pracy, należy stwierdzić: które ze zmiennych używanych w regionie równoległym są współdzielone (występować mają w jednej kopii, wspólnej dla wszystkich), a które są prywatne (każdy z procesorów otrzyma kopię do prywatnego użytku).

Wracając do naszego programu, przyjrzyjmy się po kolei zmiennym używanym w regionie równoległym i ustalmy ich zakres (współdzielona - shared, czy prywatna - private):

  • sumpi to suma częściowa, na którą pracują wszystkie procesory. Każdy z nich dorzuca swój wynik cząstkowy do tej wspólnej zmiennej. Czyli: shared.
  • n to stała. Nie ma potrzeby, aby procesory miały własne kopie wartości, która w żaden sposób się nie zmienia. Werdykt: shared.
  • x to robocza zmienna używana do obliczenia, dla danej iteracji, wartości x na podstawie i. Każdy procesor powinien mieć swoją prywatną wartość, a więc: private.
  • mypart jest wkładem danej iteracji w całkowitą sumę. Jest więc zmienną roboczą, jej wartość nie jest używana poza daną iteracją: private.
  • i jest zmienną, po której iterowana jest zrównoleglana pętla. Standard OpenMP wymusza, aby była to zmienna private, co jest skądinąd intuicyjne.

Region krytyczny

Pozostaje wyjaśnienie dyrektywy critical. Zauważmy, że gdy dwa procesory wykonują jednocześnie linię 15. zrównoleglonego kodu, to następuje jednoczesny zapis zapewne różnych wartości do tej samej komórki pamięci (bo sumpi jest zmienną współdzieloną). Takie zachowanie jest niedopuszczalne, w większości wypadków oznacza to błędnie zrównoleglony kod, gdyż w gruncie rzeczy stan komórki pamięci po tych operacjach jest nieokreślony.

Powinniśmy więc zagwarantować, że nie nastąpi jednoczesne wykonywanie linii 15. przez dwa lub więcej procesory. W tym celu tworzymy tzw. region krytyczny, w którym może przebywać jednocześnie co najwyżej jeden procesor. O dyrektywie critical można można myśleć jak o barierze, przed którą ustawiają się kolejne gotowe procesory, czekając aż region krytyczny będzie wolny.

Skąd się biorą różnice w wynikach?

Po kilkukrotnym uruchomieniu powyższego kodu na wielu procesorach zauważymy, że za każdym razem otrzymujemy różne wyniki. Powód? Operacja dodawania w arytmetyce zmiennoprzecinkowej nie jest łączna, tzn. nie zawsze (a+b)+c=a+(b+c).

W naszym przypadku, przy każdym uruchomieniu programu procesory wchodzą do regionu krytycznego w innej kolejności, a więc inna jest także kolejność dodawania składników sumy sumpi.

Chcąc uzyskać, np. dla celów debugowania, powtarzalność obliczeń, możemy zamiast dyrektywy critical użyć ordered, która działa z grubsza rzecz biorąc tak samo, ale dodatkowo wymusza, aby procesory weszły do regionu krytycznego dokładnie w tej kolejności, w której weszłyby, gdyby kod wykonywany był na jednym procesorze. Zauważmy, że użycie dyrektywy ordered może w przypadku dużych obliczeń spowolnić nieco czas wykonywania pętli, bo może się zdarzyć, że procesory będą czekać przed wolnym regionem krytycznym na marudera o niższym numerze i.

Nie jest prawdą, że użycie dyrektywy ordered daje dokładne/dokładniejsze wyniki. W większości wypadków wyniki uzyskane przy użyciu orderedrównie niedokładne jak wyniki z critical.

Wydajność: co robić, czego nie robić

Jak zostało wspomniane wyżej, należy unikać dyrektywy ordered. Co więcej, należy również unikać dyrektywy critical, ponieważ wprowadza ona dającą duży narzut czasowy barierę.

Oczywista uwaga: im większa część kodu jest zrównoleglona, tym szybciej (zazwyczaj) kod działa. Mniej oczywista i intuicyjna jest natomiast kara za niezrównoleglenie. Załóżmy, że zrównoleglony został kod zajmujący oryginalnie 80% czasu obliczeń. Taki kod, uruchamiany np. na 8 procesorach, będzie wykonywał się zaledwie ok. 3.3x szybciej, niż kod jednoprocesorowy. To wynik prostych obliczeń, zwanych poważniej prawem Amdahla. Ta obserwacja sugerowałaby, że chcąc liczyć na wielu procesorach, należy bezwzględnie starać się zrównoleglać wszystkie możliwe fragmenty programu.

Prawo Amdahla po zrownolegleniu 80% kodu

Jest jednak druga strona medalu: zrównoleglanie wprowadza dodatkowy narzut czasowy, który może de facto spowolnić wykonanie kodu. Warto więc sprawdzać, czy zrównoleglenie rzeczywiście przekłada się na skrócenie czasu obliczeń.

Warto także pamiętać, że otworzenie i zamknięcie regionu równoległego jest bardzo kosztowne. Dlatego warto łączyć kilka obszarów równoległych w jeden, oszczędzając przy tym na inicjalizacji procesorów. Dokonuje się tego rozdzielając kilka dyrektyw parallel do na jedną dyrektywę parallel oraz kilka dyrektyw do. Z braku miejsca modyfikacja ta nie zostanie tu szczegółowo opisana, zachęcam do przejrzenia dokumentacji OpenMP.

Rozbicie dyrektywy parallel-do

Inkrementacyjne zrównoleglanie

Jako podstawową zaletę OpenMP wymienia się fakt, że nie wymaga ono przepisywania kodu "od zera". Zamiast tego, możemy zacząć od kodu jednoprocesorowego i stopniowo zrównoleglać kolejne jego fragmenty. W każdym punkcie prac mamy działający kod, który możemy testować, debugować, lub używać do rzeczywistych obliczeń, jeśli jest już wystarczająco szybki.

Jak kompilować i uruchamiać

Kod z dyrektywami OpenMP trzeba odpowiednio skompilować, inaczej wykona się sekwencyjnie. Zazwyczaj sprowadza się to do przekazania odpowiedniej opcji kompilatorowi.

W przypadku kompilatorów GNU (gcc, gfortran) opcją tą jest -fopenmp.

Kompilatory firmy Intel wykorzystują w tym celu opcję -openmp.

Kompilatory XL firmy IBM (xlc, bgxlc, xlf, itd.), z kolei, opcję -qsmp=omp.

Kompilując kompilatorem Portland Group (PGI) należy dodać opcję -mp. Przykładowa kompilacja:

 use_pgi
 pgf77 -mp openmpi.f -o openmpi.x

i przykładowe uruchomienie:

 setenv OMP_NUM_THREADS 2
 ./openmpi.x

Oczywiście obliczenia należy wykonywać za pośrednictwem systemu kolejkowego. Pamiętajmy, że skrypcie dla systemu kolejkowego należy zażądać co najmniej dwa rdzenie obliczeniowe (w ramach jednego węzła).

Dokumentacja