PWiR lab 08: Wprowadzenie do MPI

MPI (ang. Message Passing Interface) jest specyfikacją API bibliotecznego w założeniu umożliwiającego budowanie równoległych programów, w których procesy komunikują się poprzez jawnie przekazywane komunikaty. Najwięcej zastosowań MPI znajduje w tworzeniu równoległych programów na komputery klastrowe i superkomputery bez rozproszonej pamięci dzielonej. Na dzisiejszym laboratorium poznamy środowisko, w którym będziemy testować nasze programy wykorzystujące MPI oraz poznamy podstawy samego MPI.

Literatura

Pytania


Spis treści

  1. Pliki, z których będziemy korzystać
  2. Podstawy programowania z MPI
  3. Ćwiczenie 1: Hello world
  4. Kompilowanie programów używających MPI
  5. Uruchamianie programów używających MPI
  6. Synchronizacja procesów za pomocą bariery
  7. Ćwiczenie 2: Hello world z barierą
  8. Komunikacja punkt do punktu
  9. Ćwiczenie 3: Hello world z komunikatami
  10. Mierzenie wydajności
  11. Ćwiczenie 4: Wydajność komunikacji

Pliki, z których będziemy korzystać

W niniejszym scenariuszu będziemy korzystać z następujących przykładowych programów (do pobrania tutaj):
Makefile
Plik Makefile.
hello-world-seq.c
Szablon programu "Hello world".

Podstawy programowania z MPI

Jako specyfikacja API bibliotecznego, MPI ma wsparcie w kilku językach programowania, między innymi C, C++, Fortranie, Javie. Prezentowane przykłady będą oparte o język C. Wszystkie prezentowane funkcje MPI mają odpowiedni wpisy manuala (man). Można je za to przeglądać np. na students.

Chęć korzystania z MPI deklarujemy w programie standardową dyrektywą #include:

   #include <mpi.h>

Pierwszą instrukcją, którą musi wykonać program korzystający z MPI jest:

   MPI_Init(&argc, &argv);

Instrukcja ta jest wykonywana przez wszystkie procesy. Ma ona za zadanie zainicjalizowanie środowiska MPI dla procesu, który ją wywołuje (man MPI_Init). Inicjalizacja informuje środowisko uruchomieniowe MPI o nowym procesie, jak również wykonuje inne czynności administracyjne. Wszystkie te czynności są jednak przeźroczyste dla użytkownika — zamknięte w wywołaniu pojedycznej funkcji — co znacznie ułatwia programowanie.

Funkcja MPI_Init musi dostać niezmodyfikowane parametry linii poleceń. Ewentualne przetwarzanie linii poleceń przez aplikację może być przeprowadzone dopiero po powrocie z MPI_Init. Podobnie, zabronione jest wywoływanie innych funkcji MPI przed powrotem z funkcji MPI_Init.

Symetrycznie, ostatnią instrukcją, którą musi wykonać program korzystający z MPI jest:

   MPI_Finalize();

Zadaniem tej funkcji (man MPI_Finalize) jest poinformowanie systemu uruchomieniowego MPI, że aktualny proces kończy pracę, i zwolnienie zasobów zaalokowanych przez implementację MPI. Funkcja MPI_Finalize może się zablokować, na przykład, dopóki komunikaty, które wysyłaliśmynie dotrą do odbiorców. Po powrocie z funkcji MPI_Finalize, proces wołający nie możne wołać innych funkcji MPI.

Jak wspomniano powyżej, MPI_Finalize zwalnia zasoby zaalokowane przez implementację MPI dla kończonego procesu. Z tego powodu ważne jest, aby MPI_Finalize było wołane w każdej ścieżce zakończenia procesu. W szczególności, jeśli w procesie wystąpił błąd nie związany z MPI (np. malloc zwrócił NULL-owy wskaźnik), przez zakończeniem proces powinien wywołać MPI_Finalize.

Jeśli chodzi o testowanie wyników funkcji, to w przeciwieństwie do wywołań funkcji systemowych, nasze programy nie muszą testować wyników wywołań funkcji MPI. Powodem tego jest fakt, że standardowa obsługa błędów przez MPI ma semantykę "all errors are fatal". Oznacza to, że jakikolwiek błąd środowiska uruchomieniowego MPI w jakimkolwiek procesie automatycznie zabija wszystkie procesy naszego programu równoległego. Jest to dość częsta praktyka w równoległych programach obliczeniowych na komputery klastrowe. Jej powodem jest fakt, że programy te są dość trudne nawet bez obsługi błędów. Zakłada się więc, że system uruchomieniowy wybranego środowiska do obliczeń równoległych powinien automatycznie zapewniać obsługę błędów: w sytuacji idealnej — pewien stopień odporności na błędy, minimalistycznie — automatyczne ubijanie błędnego wykonania obliczeń.

Procesy realizujące obliczenia przy wykorzystaniu MPI są uruchamiane w grupach. W ramach grupy, procesy mogą się ze sobą komunikować. Taka grupa procesów jest określana mianem komunikatora. Komunikator w szczególności określa liczbę procesów w grupie, N. Dodatkowo, każdy proces ma w ramach komunikatora unikalny numer od 0 do N - 1, tzw. rangę. W czasie inicjalizacji programu MPI tworzy domyślny komunikator o nazwie MPI_COMM_WORLD zawierający wszystkie procesy realizujące obliczenia oraz MPI_COMM_SELF zawierający jedynie aktualny proces. Programista może definiować własne komunikatory zawierające podzbiory procesów. Podstawowymi funkcjami związanymi z komunikatorami są:

   int numProcesses, myRank;
   MPI_Comm_size(MPI_COMM_WORLD, &numProcesses);
   MPI_Comm_rank(MPI_COMM_WORLD, &myRank);

Ćwiczenie 1: Hello world

Naszym pierwszym zadaniem na dzisiaj będzie napisanie trywialnego programu korzystającego z MPI. Każdy proces ma pobrać z komunikatora MPI_COMM_WORLD liczbę procesów oraz swoją rangę, zasnąć na losową liczbę sekund pomiędzy 0 a 4 (włącznie), a następnie wypisać na standardowe wyjście napis "Hello world from <i>/<N> (slept <t> s)!", gdzie <i> to ranga procesu, <N> to liczba wszystkich procesów, a <t> to czas w sekundach, przez który proces spał. Można zacząć od prostego szablonu dla programów w C.


Kompilowanie programów używających MPI

Próba skompilowania programu na students (po ewentualnym wcześniejszym dodaniu odpowiedniego celu do zmiennej ALL) za pomocą polecenia

   make

najprawdopodobniej zakończy się błędem, ponieważ kompilator C nie będzie w stanie znaleźć plików nagłówkowych MPI. Aby ułatwić proces kompilacji, implementacje MPI dostarczają własny kompilator dla języka C — mpicc (lub mpiCC dla języka C++). Jest on bądź to opakowaniem na GCC, które odpowiednio ustawia ścieżki do plików nagłówkowych i bibliotek, bądź też dedykowaną implementacją kompilatora (patrz niżej). W pliku Makefile naszego szablonu należy zatem zmienić linię

CC          := gcc

na

CC          := mpicc

Po powyższych zmianach kompilacja za pomocą polecenia

   make

powinna działać poprawnie.


Uruchamianie programów używających MPI

W tym roku, przynajmniej na początkowych laboratoriach, nie będziemy mieli dostępu do superkomputera. Dostęp ten być może zostanie przyznany później, na potrzeby zadania zaliczeniowego. Dlatego też nasze programy w MPI będziemy uruchamiać w laboratoriach.

Na komputerach laboratoryjnych zainstalowana jest implementacja MPI zwana MPICH, w wersji 1.4.1. W MPICH aplikacje uruchamiamy bezpośrednio za pomocą polecenia:

   mpirun -np <N> <ścieżka_programu>

gdzie <N> to liczba procesów a <ścieżka_programu> to ścieżka do pliku wykonywalnego z aplikacją wykorzystującą MPI.

Możemy jednak dodatkowo wyspecyfikować maszyny, na których zostaną uruchomione nasze procesy. Robi się to za pomocą opcji -machinefile:

   mpirun -hosts <maszyna_1>[,<maszyna_2>[,...]] -np <N> <ścieżka_programu>

Do odpalania procesów na wyspecyfikowanych maszynach, MPICH domyślnie używa polecenia rsh. Ważne jest, aby wszystkie maszyny "widziały" system plików, z którego uruchamiamy naszą aplikację.

Dla zainteresowanych — instalacja MPICH jest w miarę prosta, szczególnie w systemie Windows i systemach Ubuntu. Zabawa z własnym środowiskiem MPICH jest więc jak najbardziej zalecana.


Synchronizacja procesów za pomocą bariery

Uruchamiając nasz program "Hello world" możemy zaobserwować, że procesy wypisują swoje komunikaty w losowej kolejności, co jest intuicyjne biorąc pod uwagę, że każdy z nich śpi przez losowy okres czasu przed wypisaniem swojego komunikatu. Innymi słowy, procesy nie synchronizują między sobą wypisywania komunikatów.

Jednym z mechanizmów synchronizacji procesów w MPI jest tak zwana bariera (man MPI_Barrier). Pełni ona rolę punktu synchronizacyjnego, do którego każdy proces musi dojść, aby którykolwiek z nich mógł przejść dalej. Ważne jest, aby wszystkie procesy wywołały funkcję MPI_Barrier (dokładniej, wszystkie procesy w komunikatorze użytym jako parametr tej funkcji).


Ćwiczenie 2: Hello world z barierą

Używając bariery w programie "Hello world" spraw, aby procesy wypisywały komunikaty w kolejności swoich rang.


Komunikacja punkt do punktu

Nawet tak napisany "Hello world" nie gwarantuje, że komunikaty pojawią się na wyjściu w kolejności rosnących rang procesów. Wszystko zależy od tego, jak działają mechanizmy środowiska uruchomieniowego MPI do konkatenacji standardowego wyjścia od różnych procesów. Zanim zabierzemy się za ten problem, zajmijmy się mechanizmami komunikacji w MPI.

Dokładniej, będziemy zainteresowani tak zwaną komunikacją punkt do punktu, gdzie nadawca wysyła komunikat do konkretnego odbiorcy. Jest ona realizowana przez parę funkcji MPI_Send oraz MPI_Recv.

Skupmy się na początek na funkcji użytej do wysyłania.

  MPI_Send(
      <messagePtr>,
      <messageLen>,
      MPI_<TYPE>,
      <dstProcessNo>,
      <APP_DEFINED_MESSAGE_TAG>,
      MPI_COMM_WORLD
  );

Powyższa funkcja (man MPI_Send) przesyła komunikat od aktualnego procesu do dokładnie jednego procesu docelowego. Identyfikatorem/adresem procesu docelowego jest jego ranga (w przykładzie powyżej <dstProcessNo>) w komunikatorze będącym ostatnim parametrem funkcji (zarówno w przykładzie, jak i zwykle w praktyce, jest to MPI_COMM_WORLD).

Wysyłany komunikat jest przekazywany do funkcji MPI_Send przez nietypowany wskaźnik (<messagePtr> w naszym przykładzie). Jednakże, mimo użycia nietypowanych wskaźników do wysyłania (i odbierania), każdy komunikat w MPI ma określony format. Dokładniej, pojedynczy komunikat MPI jest tablicą elementów pewnego ustalonego przez użytkownika typu. Typ elementów tablicy jest opisywany przez trzeci parametr funkcji MPI_Send (w naszym przypadku MPI_<TYPE>), zaś liczba elementów w tablicy — przez drugi parametr (w naszym przypadku <messageLen>). MPI definiuje pewną liczbę typów prostych (np. MPI_CHAR, MPI_INT, MPI_LONG, MPI_FLOAT, MPI_DOUBLE) oraz umożliwia aplikacjom tworzenie własnych typów złożonych (patrz literatura).

Jako że komunikaty mają ustalone formaty, nadawca musi mieć możliwość przekazania odbiorcy, jakiego formatu komunikat do niego wysyła, tj. jakiego typu są elementy tablicy stanowiącej komunikat. Odbiorca zaś musi mieć możliwość selektywnego odbierania komunikatów, tj. komunikatów o określonym formacie lub też komunikatów, na które obecnie oczekuje. W tym celu komunikaty opatrywane są tzw. znacznikami (piąty parametr funkcji MPI_Send). Znaczniki to po prostu liczby całkowite unikalnie identyfikujące rodzaj komunikatu w aplikacji równoległej (w naszym przykładzie jest to <APP_DEFINED_MESSAGE_TAG>).

Kolejnym zagadnieniem, na którym zatrzymamy się przy okazji funkcji MPI_Send jest semantyka tej funkcji. Dokładniej, jakie gwarancje daje MPI_Send w momencie zakończenia jeśli chodzi dostarczenie wysyłanego komunikatu oraz o obszar pamięci zawierającej wysyłany komunikat. Otóż standard MPI tego nie precyzuje. Generalnie, MPI_Send gwarantuje, że komunikat zostanie kiedyś dostarczony oraz że w momencie zakończenia MPI_Send, obszar pamięci zajmowany przez komunikat może być bezpiecznie użyty. Jednakże nie ma żadnych gwarancji co do tego, że po opuszczeniu funkcji MPI_Send, wysyłany komunikat został odebrany (i przetworzony przez obiorcę). Nie ma nawet gwarancji, że odbiorca zaczął odbierać komunikat. MPI mówi tylko, że funkcja MPI_Send może się zablokować do momentu, gdy kontynuowanie działania będzie bezpieczne z punktu widzenia wysyłanego komunikatu (tj. pamięć z komunikatem będzie można zmieniać). Aby umożliwiać bardziej precyzyjną kontrolę nad tym, co dzieje się z wysyłanym komunikatem, MPI udostępnia całą gamę wariantów funkcji MPI_Send z różną semantyką, ale o tym na innym laboratorium.

Rozważmy teraz funkcję odbierającą.

  MPI_Recv(
      <messageBufPtr>,
      <messageBufLen>,
      MPI_<TYPE>,
      <srcProcessNo>,
      <APP_DEFINED_MESSAGE_TAG>,
      MPI_COMM_WORLD,
      &<status>
  );

W funkcji odbierającej (man MPI_Recv) musimy określić znacznik komunikatu, który chcemy odebrać (w powyższym przykładzie zdefiniowany wcześniej <APP_DEFINED_MESSAGE_TAG>). Musimy także zdecydować, od którego procesu chcemy odebrać komunikat (w przykładzie jest to proces o randze <srcProcessNo> interpretowanej w komunikatorze zawierającym wszystkie procesy — MPI_COMM_WORLD).

Po drugie, przekazujemy wskaźnik do bufora na tablicę elementów stanowiącą komunikat (<messageBufPtr>), maksymalną liczbe elementów tablicy mieszczących się w buforze (<messageBufLen>) oraz typ pojedynczego elementu (MPI_<TYPE>). Jeśli liczba elementów w tablicy odpowiadającej wysłanemu komunikatowi (przekazana do MPI_Send) była mniejsza lub równa długości tablicy bufora odbiorczego (przekazanej do MPI_Recv) komunikat zostanie odebrany poprawnie. Jeśli natomiast wysłany komunikat był dłuższy niż rozmiar bufora do odbioru, nastąpi błąd (i zgodnie z semantyką "all errors are fatal" nasza aplikacja zostanie przerwana).

Wywołanie funkcji MPI_Recv jest blokowane do momentu, gdy komunikat, na który oczekujemy nadejdzie.

Jako ostatni parametr, funkcja MPI_Recv przyjmuje wskaźnik na strukturę <status> typu MPI_Status. W momencie odebrania komunikatu (powrotu z funkcji MPI_Recv) struktura ta będzie zawierać dodatkowe dane o komunikacie. Przykładowo, jeśli potrzebowalibyśmy znać dokładną liczbę elementów tablicy reprezentującej otrzymany komunikat, możemy uzyskać tę informację wywołując funkcję:

  MPI_Get_count(&<status>, MPI_<TYPE>, &<messageActLen>);

Parametr <status> ma także inne zastosowania. Jeśli do MPI_Recv zamiast <srcProcessNo> przekazalibyśmy stałą MPI_ANY_SOURCE, oznaczałoby to, że chcemy odebrać komunikat od dowolnego procesu. Po odebraniu takiego komunikatu, możemy dowiedzieć się od jakiego procesu go właściwie odebraliśmy używając pola MPI_SOURCE struktury <status>. Podobnie, jeśli do MPI_Recv zamiast <APP_DEFINED_MESSAGE_TAG> przekazalibyśmy MPI_ANY_TAG, oznaczałoby to, że chcemy odebrać komunikat dowolnego typu. Po odebraniu komunikatu, pole MPI_TAG struktury status zawierało będzie znacznik komunikatu. Uwaga! W tym ostatnim przypadku, ważne jest, aby poprawnie zinterpretować typ elementów tablicy stanowiącej komunikat.


Ćwiczenie 3: Hello world z komunikatami

Zamiast barier w programie "Hello world" zastosuj komunikaty do synchronizacji procesów. Dokładniej, wybieramy jeden proces (np. o randze zero), który będzie odpowiedzialny za wypisanie wszystkich komunikatów. Każdy z pozostałych procesów przesyła do tego procesu swoją rangę oraz czas spania. Wybrany proces odbiera te komunikaty w kolejności rang i wypisuje je na standardowe wyjście. Ponieważ wszystkie napisy są produkowane przez jeden proces, będą one dobrze uporządkowane.


Mierzenie wydajności

Kolejnymi przydatnymi funkcjami MPI są funkcje do mierzenia czasu: MPI_Wtime oraz MPI_Wtick. Przykładowe użycie tych funkcji:

   double startTime;
   double endTime;
   double executionTime;

   startTime = MPI_Wtime();

   // obliczenia, których czas trwania chcemy zmierzyć

   endTime = MPI_Wtime();
  
   executionTime = endTime - startTime;

Ćwiczenie 4: Wydajność komunikacji

Przerób program "Hello world" na program "Echo", w którym jeden proces kilkakrotnie wysyła do innego procesu tablicę liczb. Proces odbierający natomiast odsyła otrzymaną tablicę z powrotem.

Uruchamiając program na różnych maszynach, zbadaj jaka jest przepustowość (ang. throughput) oraz dwukierunkowe opóźnienie (ang. round-trip time) komunikacji w zależności od typu wysyłanych liczb oraz rozmiaru tablicy. Do badania przepustowości użyj bardzo dużych tablic, do badania opóźnień — jednoelementowych. Pomiary rób wielokrotnie, odrzucając skrajne wyniki i uśredniając pozostałe. Postaraj się, aby inne operacje (np. wypisywanie wartości na standardowe wyjście) nie zakłócały pomiarów.


Ostatnia modyfikacja: 04/05/2016