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 klastrowe, w którym będziemy testować nasze programy MPI oraz poznamy podstawy samego MPI.

Literatura

Pytania


Spis treści

  1. Pliki, z których będziemy korzystać
  2. Środowisko obliczeniowe
  3. Podstawy programowania z MPI
  4. Kompilowanie programów używających MPI
  5. Uruchamianie programów używających MPI
  6. Ćwiczenie samodzielne: Pierwsze komunikaty

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.c
Szablon programu "Hello World!".
hello-world.ll
Specyfikacja zadania dla programu "Hello World!".

Środowisko obliczeniowe

Nasze programy będziemy testować na klastrach ICM. ICM pełni rolę centrum komputerów dużej mocy, w którym prowadzi się obliczenia z rozmaitych dziedzin, między innymi biologii, fizyki, chemii, technologii materiałowych, nauk o Ziemi i innych.

Do systemu ICM logujemy się przez serwer gw lub delta:

   ssh <login>@gw.icm.edu.pl

Alternatywnie możemy otworzyć sobie sesję X Window:

   ssh -Y <login>@gw.icm.edu.pl

Na serwerze mamy swoje konta, na które można ładować napisane programy. Niestety, system plików z kontami nie jest podmontowany na węzłach poszczególnych klastrów.

Z serwera możemy logować się na poszczególne klastry. W ramach przedmiotu mamy dostęp do klastra notos, a zatem

   ssh notos

loguje nas na ten klaster. Aby wyeliminować konieczność podawania hasła przy każdym takim logowaniu, warto skorzystać z możliwości bezhasłowego logowania dawanej przez SSH.

W tym celu na serwerze (gw/delta) generujemy parę kluczy poleceniem:

   ssh-keygen -t rsa

Przy czym przy każdym pytaniu od programu, po programu po prostu wciskamy ENTER. Wyjście powinno wyglądać mniej więcej tak:

Generating public/private rsa key pair.
Enter file in which to save the key (/home/users/<login>/.ssh/id_rsa): 
Enter passphrase (empty for no passphrase): 
Enter same passphrase again: 
Your identification has been saved in /home/users/<login>/.ssh/id_rsa.
Your public key has been saved in /home/users/<login>/.ssh/id_rsa.pub.
The key fingerprint is:
XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX <login>@delta
The key's randomart image is:
<tutaj obrazek ASCI art>

Następnie kopiujemy klucz publiczny na klaster notos poleceniem (będziemy proszeni o hasło):

   scp ~/.ssh/id_rsa.pub notos:~/

Logujemy się na klaster notos (jeszcze z hasłem):

   ssh notos

Instalujemy klucz poleceniami:

   mkdir -p ~/.ssh
   cat ~/id_rsa.pub >>~/.ssh/authorized_keys
   rm -f ~/id_rsa.pub
   exit

I ponownie logujemy się na notos, tym razem system nie powinien już zapytać nas o hasło:

   ssh notos

Podobnie możemy usprawnić sobie logowanie na serwer gw/delta z kont wydziałowych.

Sam klaster notos ma następującą specyfikację:

Producent: IBM
Model: Blue Gene/P
Architektura: ppc
Liczba węzłów: 1024
Sumaryczna liczba rdzeni: 4096
Sumaryczna pamięć operacyjna: 4 TB
Technologia sieciowa: trójwymiarowy torus
System operacyjny: Blue Gene/P Linux 2.6
System plików: GPFS
System kolejkowy: LoadLeveler

Węzeł notos to natomiast maszyna o następujących parametrach:

Procesor: Quad-Core PowerPC 450
Częstotliwość: 850 MHz
Pamięć operacyjna: 4 GB
Dysk twardy: brak

Logowanie na notos to tak naprawdę logowanie na węzeł dostępowy do klastra. Węzły obliczeniowe są osobnymi maszynami, na które na ogół nie ma potrzeby się logować.


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), ale bez kroków prezentowanych w dalszej części scenariusza, wpisy te nie są dostępne na notos (można je za to zobaczyć 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 unikatowy numer od 0 do N - 1, tzw. rank. 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);

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;

Zadanie: 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ę, a następnie wypisać na standardowe wyjście napis "Hello world from <i>!", gdzie <i> to ranga procesu. Można zacząć od prostego szablonu dla programów w C.


Kompilowanie programów używających MPI

Próba skompilowania programu na notos 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. 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

Niestety, na notos taka zmiana jest niewystarczająca, ponieważ polecenie mpicc jest standardowo niewidoczne. Jest to związane z pakietem Modules, który w ICM zarządza środowiskiem użytkownika i który standardowo nie udostępnia bibliotek MPI, co możemy sprawdzić używając polecenia:

   module list

które powinno dać następujący wynik:

   No Modulefiles Currently Loaded.

Aby załadować do środowiska niezbędne komponenty MPI, wykonujemy polecenie:

   module load mpi_default
co załaduje standardową wersję MPI opartą o GCC, bądź też
   module load mpi_fast
co załaduje wersję opartą o dedykowane kompilatory IBM. UWAGA: Nie należy ładować obu wersji naraz.

Tym razem

   module list
powinno dać:
   Currently Loaded Modulefiles:
     1) mpi_default
dla standardowej wersji MPI lub odpowiednio:
   Currently Loaded Modulefiles:
     1) mpi_fast
dla dedykowanej wersji IBM.

Na laboratoriach będziemy korzystać głównie z wersji IBM. Wobec tego warto dodać do swojego pliku ~/.bashrc polecenie module load mpi_fast, aby przyszłości MPI był dostępny automatycznie po każdym logowaniu:

   echo "module load mpi_fast" >>~/.bashrc

Oprócz wyżej wspomnianych, na rynku istnieją inne wersje MPI, jak MPICH, która jest zainstalowana na students, czy też Intel MPI i HP MPI, które niestety nie są darmowe.

Po załadowaniu modułu MPI, kompilacja za pomocą polecenia

   make
powinna działać poprawnie.

Uruchamianie programów używających MPI

Wszystkie czynności, które dotychczas robiliśmy odbywały się na węźle dostępowym notos nie zaś na węzłach obliczeniowych klastra. Aby uruchomić nasz program na węzłach obliczeniowych, musimy skorzystać z tzw. systemu kolejkowego. System kolejkowy pośredniczy między użytkownikiem a maszynami obliczeniowymi decydując o przydziale zasobów i uruchamianiu zadań. Systemem kolejkowym dla klastra notos jest aktualnie LoadLeveler.

Podstawowe polecenia związane z systemem kolejkowym to:

W szczególności llq pokazuje wszystkie aktualne zadania na klastrze, zarówno działające (oznaczone R) jak i zakolejkowane (zwykle oznaczone I). Natomiast llq -s <job_id> pokazuje status konkretnego zadania. Więcej informacji można znaleźć na przykład w man llq.

Aby skorzystać z systemu kolejkowego, musimy stworzyć zadanie, czyli opis wymaganych zasobów i instrukcji uruchamiających nasz program. Zadanie takie to po prostu plik tekstowy o określonej składni, który będzie argumentem do llsubmit.

Przykładowe zadanie:

  # @ job_name = HelloWorld
  # @ account_no = G52-5
  # @ class = kdm
  # @ error = hello-world.err
  # @ output = hello-world.out
  # @ environment = COPY_ALL
  # @ wall_clock_limit = 00:02:00
  # @ notification = error
  # @ notify_user = $(user)@icm.edu.pl
  # @ job_type = bluegene
  # @ bg_size = 2
  # @ queue
  echo "Started at" `date`
  mpirun -exe hello-world.exe -np 8 -mode VN
  echo "Finished at" `date`

gdzie job_name = HelloWorld to nazwa naszego zadania, account_no = G52-5 to nazwa naszego grantu obliczeniowego (zawsze powinna być G52-5), class = kdm to nazwa kolejki, do której nasze zadanie będzie przydzielone (nazwa klasy obliczeniowej; musi być kdm, ewentualnie kdm-dev), error = hello-world.err oraz output = hello-world.out to pliki, do których zostaną przekierowane standardowe wyjście diagnostyczne i standardowe wyjście wszystkich procesów stworzonych w ramach zadania, environment = COPY_ALL to polecenie skopiowania wszystkich zmiennych środowiskowych użytkownika do środowiska tych procesów, wall_clock_limit = 00:02:00 to limit czasu wykonania na nasze zadanie, notification = error to informacja, iż notyfikacja nastąpi tylko w przypadku błędu, notify_user = $(user)@icm.edu.pl to adres e-mail, pod który notyfikacja zostanie wysłana, job_type = bluegene to typ zadania (zawsze powinien być bluegene), bg_size = 2 to liczba węzłów, które zostaną zarezerwowane na zadanie. Komenda queue to z kolei zakolejkowanie kolejnego kroku obliczenia. Nasze obliczenie składa się tylko z jednego kroku, ale generalnie kroków może być więcej. Komenda queue musi pojawić się na końcu każdego kroku.

Istotne są także parametry przekazywane do mpirun. Parametr -exe specyfikuje plik, który będzie wykonany przez każdy z procesów, -np specyfikuje liczbę tych procesów, zaś -mode tryb pracy węzłów. Dostępne tryby to:

Wartość Znaczenie
VN 4 procesy na węzeł
DUAL 2 procesy na węzeł
SMP 1 proces na węzeł

Dodatkowo jest jeszcze opcja -args, która pozwala przekazać dodatkowe argumenty do wykonywanego programu.

Po stworzeniu specyfikacji zadania, wysyłamy je do kolejki poleceniem:

   llsubmit <nazwa_pliku_z_zadaniem.ll>

i czekamy na jego zakończenie. Status (w szczególności, numer przydzielony w kolejce) możemy sprawdzić za pomocą llq. Po zakończeniu zadania dostępne będą pliki: hello-world.out oraz hello-world.err, które będą zawierają komunikaty wypisywane przez procesy na odpowiednio standardowe wyjście i wyjście diagnostyczne.


Ćwiczenie samodzielne: Pierwsze komunikaty

Zmień program hello-world.c tak, aby wszystkie komunikaty były wypisywane jedynie przez proces o randze zero. Pozostałe procesy natomiast, zamiast wypisywać, muszą wysłać swoje komunikaty do procesu zero. Do wysyłania użyj funkcji:

   MPI_Send(
       <WskaznikDoBuforaZKomunikatem>,
       <DlugoscKomunikatuWrazZKonczacymZerem>,
       MPI_CHAR,
       0,
       123,
       MPI_COMM_WORLD
   );
zaś do odbioru funkcji:
   MPI_Status status;
   MPI_Recv(
       <WskaznikDoBuforaNaKomunikat>,
       <MaksymalnaDlugoscKomunikatuWrazZKonczacymZerem>,
       MPI_CHAR,
       <RangaProcesuWysylajacego>,
       123,
       MPI_COMM_WORLD,
       &status
   );

Uruchom następnie ten program na klastrze i zobacz, jak zmienia się jego wyjście.


Ostatnia modyfikacja: 16/03/2014