Message Passing Interface (MPI) (z ang. Interfejs Transmisji Wiadomości) – protokół komunikacyjny będący standardem przesyłania komunikatów pomiędzy procesami programów równoległych działających na jednym lub więcej komputerach. Interfejs ten wraz z protokołem oraz semantyką specyfikuje, jak jego elementy winny się zachowywać w dowolnej implementacji. Celami MPI są wysoka jakość, skalowalność oraz przenośność. MPI jest dominującym modelem wykorzystywanym obecnie w klastrach komputerów oraz superkomputerach. Pierwsza wersja standardu ukazała się w maju 1994 r. Standard MPI implementowany jest najczęściej w postaci bibliotek, z których można korzystać w programach tworzonych w różnych językach programowania, np. C, C++, Ada, Fortran.
MPI jest specyfikacją biblioteki funkcji opartych na modelu wymiany komunikatów dla potrzeb programowania równoległego. Transfer danych pomiędzy poszczególnymi procesami programu wykonywanymi na procesorach maszyn będących węzłami klastra odbywa się za pośrednictwem sieci.
Główny model MPI-1 nie wspiera koncepcji współdzielonej pamięci, MPI-2 wspiera (w sposób ograniczony) rozproszony system pamięci dzielonej. Mimo tego programy MPI są bardzo często uruchamiane na komputerach o współdzielonej pamięci.
Program w MPI składa się z niezależnych procesów operujących na różnych danych (MIMD). Każdy proces wykonuje się we własnej przestrzeni adresowej, aczkolwiek wykorzystanie pamięci współdzielonej też jest możliwe.
Zaletami MPI nad starszymi bibliotekami przekazywania wiadomości są przenośność oraz prędkość. Przenośność, ponieważ MPI został zaimplementowany dla każdej architektury opartej na rozproszonej pamięci. Prędkość, ponieważ każda implementacja jest zoptymalizowana pod sprzęt, na którym działa. Standard udostępnia zbiór precyzyjnie zdefiniowanych metod, które mogą być efektywnie zaimplementowane. Stał się on punktem wyjściowym do stworzenia praktycznego, przenośnego, elastycznego i efektywnego narzędzia do przesyłania komunikatów (ang. message passing). Standard MPI pozwala na jego zastosowanie zarówno w komputerach równoległych, jak i heterogenicznych sieciach stacji roboczych.
Standard nie zabrania, aby poszczególne procesy były wielowątkowe. Nie są też udostępnione mechanizmy związane z rozłożeniem obciążenia pomiędzy poszczególne procesy, z architekturą rozkładu procesorów, z dynamicznym tworzeniem i usuwaniem procesów. Procesy są identyfikowane poprzez ich numer w grupie w zakresie 0 .. groupsize – 1.
umożliwia efektywną komunikację bez obciążania procesora operacjami kopiowania pamięci,
udostępnia funkcje dla języków C/C++, Fortran oraz Ada,
specyfikacja udostępnia hermetyczny interfejs programistyczny, co pozwala na skupienie się na samej komunikacji, bez wnikania w szczegóły implementacji biblioteki i obsługi błędów,
definiowany interfejs zbliżony do standardów takich jak: PVM, NX czy Express,
udostępnia mechanizmy komunikacji punkt – punkt oraz grupowej,
może być używany na wielu platformach, tak równoległych, jak i skalarnych, bez większych zmian w sposobie działania.
Zalety MPI
dobra efektywność w systemach wieloprocesorowych,
dobra dokumentacja,
bogata biblioteka funkcji,
posiada status „public domain”,
przyjął się jako standard.
Wady MPI
statyczna konfiguracja jednostek przetwarzających,
statyczna
struktura procesów w trakcie realizacji programu (dotyczy to
implementacji opartych na MPI-1). Wersja MPI-2 (wspierana np przez
LAM 7.0.4) umożliwia dynamiczne zarządzanie strukturą procesów
biorących udział w obliczeniach – MPI_Spawn()
,
brak wielowątkowości.
Interfejs MPI ma na celu dostarczenie wirtualnej topologii, synchronizacji oraz funkcjonalności komunikacji pomiędzy zestawem procesów (które zostały zmapowane do węzłów/serwerów/instancji komputerów) w niezależny od języka sposób, przy podobnej do niego składni. Programy MPI zawsze współpracują z procesami, aczkolwiek programiści powszechnie odnoszą się do procesów jako procesorów. Zazwyczaj dla uzyskania maksymalnej wydajności, każdy CPU (lub rdzeń w wielordzeniowym procesorze) ma przypisany pojedynczy proces. Przypisanie to ma miejsce w czasie rzeczywistym przez agenta, który uruchamia program MPI (zazwyczaj nazywany mpirun lub mpiexec).
Komunikatory są obiektami łączącymi, tj. grupującymi procesy podczas sesji MPI. W obrębie każdego komunikatora każdy zawarty proces ma niezależny identyfikator, zaś same procesy organizowane są w uporządkowaną topologię.
Szereg ważnych funkcji w API MPI dotyczy komunikacji pomiędzy dwoma określonymi procesami. Operacje punkt – punkt są szczególnie użyteczne w nieregularnej komunikacji, np. w architekturze opartej na równoległości danych, w której każdy procesor stale wymienia regiony danych z innymi określonymi procesorami pomiędzy kolejnymi krokami obliczeń. Innym przykładem jest architektura master-slave, w której master wysyła nowe dane zadania do slave’a niezależnie od tego, czy poprzednie zostało ukończone.
Funkcje zbiorowe w API MPI dotyczą komunikacji pomiędzy wszystkimi procesami w grupie procesów. Wywołania te są często używane na początku lub końcu dużych rozproszonych obliczeń, gdzie każdy z procesorów operuje na części danych, po czym łączy je w wynik.
Wiele funkcji MPI wymaga, ażeby określić typ danych, jaki jest przesyłany pomiędzy procesorami. Dzieje się tak, ponieważ argumentami funkcji są zmienne, nie zaś typy definiowane. Jeśli typ danych jest standardowy, jak np. int, char, double itd., można użyć predefiniowanych w MPI typów takich jak: MPI_INT, MPI_CHAR, MPI_DOUBLE. Dane mogą być również w postać klas lub struktur danych. Można wykorzystywać pochodne typy danych z typów predefiniowanych.
MPI-2 definuje trzy operacje jednostronnej komunikacji: Put, Get i Accumulate, które to umożliwiają zapis do zdalnej pamięci, odczyt z niej oraz zmniejszają liczbę operacji pamięci dla wielu zadań. Definiowane są również trzy różne metody dla synchronizacji komunikacji – globalna, parami oraz przy wykorzystaniu zdalnych blokad – specyfikacja nie gwarantuje jednak, iż operacje te będą wykonywane aż do momentu punktu synchronizacji.
Kluczowym aspektem dynamicznego zarządzania procesami w MPI-2 jest zdolność procesów do tworzenia nowych procesów bądź ustanowienia komunikacji z procesami, które rozpoczęły się oddzielnie.
Równoległe I/O wprowadzone w MPI-2 jest często nazywane w skrócie MPI-IO oraz odnosi się do zbioru funkcji przeznaczonych do ułatwienia zarządzania I/O w systemach rozproszonych w sposób abstrakcyjny oraz umożliwienia łatwego dostępu do plików korzystając z funkcjonalności istniejących typów pochodnych.
1. Ogólna koncepcja systemu
a) Podstawy
MPI realizuje model przetwarzania współbieżnego zwany MIMD ( Multiple Instruction Multiple Data), a dokładniej SPMD (Single Program Multiple Data). Zakłada on, że ten sam kod źródłowy wykonuje się jednocześnie na kilku maszynach i procesy mogą przetwarzać równocześnie różne fragmenty danych, wymieniając informacje przy użyciu komunikatów
Takie podejście ma wiele zalet, z których najbardziej spektakularną jest chyba możliwość współbieżnych obliczeń wykonywanych na maszynach o zupełnie różnych architekturach (np. Linux-x86 oraz Solaris-Sparc).
Realizując ten model, MPI umożliwia:
Wymianę
komunikatów między procesami
(Główny nacisk jest położony
na wymianę danych, ale możliwe jest również wysyłanie
komunikatów kontrolnych, czy synchronizacja procesów)
Uzyskiwanie
informacji o środowisku
(Typowy przykład to ilość aktywnych
proces[-ów/-orów], czy numer aktualnego procesu)
Kontrolę
nad systemem
(Inicjalizacja/kończenie programu, kontrola
poprawności przesyłanych komunikatów itp.)
Wszystkie te rzeczy są realizowane przy minimalnym stopniu skomplikowania kodu źródłowego.
b) Komunikaty
Przy przesyłaniu komunikatów między procesami MPI stara się zachować niezależność od platformy (np. kolejności bajtów). Dla standardowych typów jest to proste, natomiast dla typów niestandardowych MPI dostarcza funkcje pozwalające na zdefiniowanie typów użytkownika dla potrzeb przesyłania komunikatów.
Możliwe jest adresowanie komunikatów zarówno do konkretnych procesów, jak i do określonych grup odbiorców. Dostępne są funkcje do definiowania grup procesów i późniejszego rozsyłania komunikatów do tych grup. Komunikaty opatrzone są tagami pozwalającymi na późniejsze selektywne odbieranie ich z kolejki w zależności od rodzaju.
Możliwa jest wymiana komunikatów w trybie non-blocking pozwalającym na jeszcze większe zrównoleglenie obliczeń.
c) Zaawansowana komunikacja
Główną zaletą MPI przy bardziej złożonych schematach wymiany danych jest ukrywanie przed programistą szczegółów implementacyjnych oraz możliwość optymalizacji ścieżki przepływu danych. Wyobraźmy sobie przykładowo, że mamy 8 procesów i pierwszy z nich ma przekazać pewną porcję danych wszystkim pozostałym. Najprostsza możliwość:
jest oczywiście niezbyt optymalna - widać, że w ogólnym przypadku złożoność czasowa procesu przesyłania jest zależna liniowo od liczby procesów biorących udział w tej operacji.
Kolejny przykład jest już krokiem w dobrym kierunku - przesyłanie danych trwa dwukrotnie krócej niż poprzednio
Oczywiście i tak każdy wie, że optymalny schemat broadcastu danych w obrębie grupy procesów wygląda następująco :)
jednak zaletą MPI jest fakt, że takie decyzje (dotyczące wyboru dróg przesyłania danych) są ukryte przed programistą - może on po prostu założyć, że dane "kiedyś" i "jakoś" dotrą na miejsce przeznaczenia, a "kiedy" i "jak" - decyduje system i stara się to zrobić w sposób optymalny.
Wszystkie te rozważania można również przeprowadzić przeprowadzić w odwrotną stronę - dla agregacji danych z kilku procesorów. MPI definiuje zbiór funkcji służących do zbierania danych z kilku procesów. Typowy przykład to obliczanie sumy elementów wektora na podstawie zbioru sum częściowych (lub np. wyszukiwanie maksymalnego elementu).
Z punktu widzenia programisty MPI składa się z dwóch części:
Biblioteki (do języka C lub Fortranu) zawierającej niezbędne funkcje i pliki nagłówkowe
Środowiska
uruchamiania (runtime)
(Można
tu zauważyć pewną użytkową analogię np. z Javą - program
mpirun
jest tu odpowiednikiem polecenia java
)
Najprostszy sposób kompilacji programu to użycie któregoś ze standardowych skryptów dostarczanych z MPI. Są to:
mpicc
-
dla programów w C
mpiCC
-
dla programów w C++
mpif77
- dla programów w Fortranie 77
mpif90
- dla programów w Fortranie 90
Uruchamianie
programów w MPI
jest równie proste i sprowadza się do uruchomienia skryptu mpirun
.
Jego jedynym interesującym nas w tej chwili parametrem jest -np
<n>
.
Określa on ilość równoległych procesów, jakie ma uruchomić
system w celu wykonania obliczeń.
Przed
uruchomieniem mpirun
musimy upewnić się, że w naszym katalogu domowym na wszystkich
maszynach potencjalnie mogących wchodzić w grę przy wykonywaniu
naszego programu w odpowiednim katalogu znajduje się kopia pliku
wykonywalnego programu. Najczęściej w takich przypadkach stosuje
się montowanie katalogów przez NFS.
Szczegóły techniczne ukryte za mechanizmem rozsyłania procesów na różne maszyny w zasadzie nas tu nie interesują, warto może tylko wspomnieć o ADI (Abstract Device Interface). Jest to wewnętrzny standard mpich określający sposób komunikacji między procesami na niskim poziomie.
Zaletą używania takiego modelu jest oddzielenie części systemu zależnej od konkretnej architektury, od części identycznej dla wszystkich. Wynika z tego na przykład, że ten sam program uruchomiony na maszynie wieloprocesorowej może używać do komunikacji pamięci wspólnej (shared memory), a wykonywany na klastrze kilku silnych pecetów używa TCP/IP bez rekompilacji kodu!
Podstawowe funkcje służące do inicjalizacji/zamykania programu oraz do wysyłania najprostszych komunikatów (typu Point-to-point) to:
int MPI_Init(int *argc, char ***argv);
Funkcja
inicjalizuje środowisko wykonywania programu, m.in. tworzy domyślny
komunikator MPI_COMM_WORLD
.
Dopiero od momentu wywołania MPI_Init
można używać pozostałych funkcji MPI.
int MPI_Finalize();
Funkcja zwalnia zasoby używane przez MPI i przygotowuje system do zamknięcia.
int MPI_Comm_rank(MPI_Comm comm, int *rank);
Funkcja
pobiera numer aktualnego procesu (w obrębie komunikatora comm
)
i umieszcza go w zmiennej rank
.
int MPI_Comm_size(MPI_Comm comm, int *size);
Funkcja
pobiera ilość procesów (w obrębie komunikatora comm
i umieszcza ją w zmiennej size
.
int MPI_Send(void *msg, int count, MPI_Datatype datatype, int dest, int tag, MPI_Comm comm);
Funkcja
wysyła komunikat typu datatype
do
procesu numer dest
oznaczony znacznikiem tag
w obrębie komunikatora comm
.
Typ
komunikatu jest zawarty w zmiennej datatype
i może to być któryś z predefiniowanych typów takich jak
MPI_INT
,
MPI_FLOAT
,
MPI_DOUBLE
,
MPI_CHAR
,
lub inne (zob. instrukcja), jak i typy zdefiniowane przez
użytkownika (o tym jeszcze będzie).
Tag
jest liczbą w zakresie [0..MPI_TAG_UB
]
i określa dodatkowy typ komunikatu wykorzystywany przy selektywnym
odbiorze funkcją MPI_Recv
.
int MPI_Recv(void *msg, int count, MPI_Datatype datatype, int source, int tag, MPI_Comm comm, MPI_Status *status);
Funkcja
odczytuje z kolejki komunikatora comm
(z ewentualnym blokowaniem do czasu nadejścia) pierwszy komunikat
od procesu source
oznaczony znacznikiem tag
typu datatype
.
Wynik umieszczany jest w buforze msg
a status operacji w zmiennej status
.
Jeżeli
proces ustawi source==MPI_ANY_SOURCE
to odczytany będzie pierwszy komunikat od dowolnego procesu.
Podobnie, dla tag==MPI_ANY_TAG
nie będzie sprawdzany znacznik typu wiadomości.
Bufor
stanu status
musi zostać uprzednio stworzony przez programistę. Dla C jego
pojedynczy element składa się z trzech liczb całkowitych:
MPI_SOURCE
,
MPI_TAG
oraz MPI_STATUS
.
W ogólnym przypadku bowiem (przy odbieraniu komunikatów w których
count>1
)
tablica status
określa nam źródło i typ każdego komunikatu z osobna. Do
pobierania ilości odebranych komunikatów na podstawie zmiennej
stanu służy kolejna funkcja:
int MPI_Get_count(MPI_Status *status, MPI_Datatype datatype, int *count);
która
umieszcza szukaną ilość w zmiennej count
.
int MPI_Bcast(void *msg, int count, MPI_Datatype datatype, int root, MPI_Comm comm);
Funkcja
rozsyła komunikat do wszystkich procesów w obrębie komunikatora
comm
poczynając od procesu root
.
Pozostałe argumenty - podobnie jak w MPI_Send()
.
int MPI_Reduce(void *operand, void *result, int count, MPI_Datatype datatype, MPI_Op op, int root, MPI_Comm comm);
Bardzo
ważna funkcja - pozwala wykonać na przykład sumowanie wszystkich
częściowych wyników otrzymanych w procesach i umieszczenie wyniku
w zmiennej. Argument root
wskazuje dla
którego procesu
wynik ma być umieszczony w zmiennej result
.
Oto przykład użycia tej funkcji:
MPI_Reduce(&suma_cz,&suma,1,MPI_FLOAT,MPI_SUM,0,MPI_COMM_WORLD);
Przykładowe
operatory to MPI_MAX
,
MPI_MIN
,
MPI_SUM
.
int MPI_Allreduce(void *operand, void *result, int count, MPI_Datatype datatype, MPI_Op op, MPI_Comm comm);
Funkcja
identyczna z poprzednią, różniąca się jedynie tym, że po jej
wykonaniu wynik agregacji z użyciem operatora op
znajduje się w zmiennej result
we wszystkich
procesach.
int MPI_Scatter(void *send_buf, int send_count, MPI_Datatype send_type, void *recv_buf, int recv_count, MPI_Datatype recv_type, int root, MPI_Comm comm);
Funkcja
rozproszenia
("scatter")
danych między procesami. Działa w ten sposób, że proces root
rozsyła zawartość send_buff
między wszystkie procesy. Jest ona dzielona na p
segmentów, każdy składający się z send_count
elementów. Pierwszy segment trafia do procesu 0, drugi do procesu 1
itp. Oczywiście argumenty, których nazwy zaczynają się na Send
mają znaczenie tylko dla procesu który jest nadawcą.
int MPI_Gather(void *send_buf, int send_count, MPI_Datatype send_type, void *recv_buf, int recv_count, MPI_Datatype recv_type, int root, MPI_Comm comm);
Każdy
proces w grupie comm
wysyła zawartość send_buff
do procesu root
.
Ten proces układa przysłane dane w recv_buff
w kolejności numerów procesów.
int MPI_Allgather(void *send_buf, int send_count, MPI_Datatype send_type, void *recv_buf, int recv_count, MPI_Datatype recv_type, MPI_Comm comm);
Funkcja
ta jest analogiczna do MPI_Gather
z tą różnicą, że wynik jest umieszczany w recv_buff
każdego
procesu. Można tą funkcję traktować jako ciąg kolejnych wywołań
MPI_Gather
każdorazowo z innym numerem procesu root
Zależność między trzema ostatnimi funkcjami ilustruje następujący rysunek:
W zastosowaniach rzeczywistych najczęściej wysyłamy większą ilość danych, bądź to w strukturach, albo w postaci wektorów. Przesyłanie ich w postaci oddzielnych komunikatów powodowałoby powstanie dużego narzutu czasowego związanego z organizacją przesyłania danych. Potrzebna jest więc możliwość "pakowania" danych i wysyłania większej ich ilości za jednym razem.
W MPI jest to dość spory problem, gdyż najczęściej typ przesyłanych danych jest różny od standardowych typów zdefiniowanych w bibliotece. Zdecydowano się na rozwiązanie w którym programista ma możliwość tworzenia nowych typów danych w czasie wykonywania programu.
int MPI_Address(void *data, MPI_Aint *address);
Funkcja
zapisuje adres zmiennej data
do zmiennej address
.
int MPI_Type_struct(int count, int *array_of_block_lengths, MPI_Aint *array_of_displacements, MPI_Datatype *array_of_types, MPI_Datatype *newtype);
Funkcja
tworzy nowy typ - strukturę o count
składowych. Ilość elementów każdej składowej jest zapisana w
tablicy array_of_block_lengths
.
Długości składowych (a dokładniej - offsety względem początku
rekordu) należy umieścić w tablicy array_of_displacements
.
Typy pojedynczych danych składowych umieszczamy w array_of_types
.
Wynikowy typ MPI
jest umieszczany w strukturze wskazywanej przez newtype
.
int MPI_Type_vector(int count, int block_length, int stride, MPI_Datatype element_type, MPI_Datatype *newtype);
Funkcja
tworzy nowy typ - wektor długości count
.
Każdy element wektora zawiera block_length
elementów typu element_type
,
elementy wektora są zaś rozdzielone dodatkowo stride
elementami tego typu.
int MPI_Type_contiguous(int count, MPI_Datatype oldtype, MPI_Datatype *newtype);
Tworzenie
prostszego (jednowymiarowego) wektora elementów typu oldtype
długości count
.
int MPI_Type_indexed(int count, int *array_of_block_lengths, int *array_of_displacements, MPI_Datatype element_type, MPI_Datatype *newtype);
Jest
to prostsza wersja funkcji MPI_Type_struct
.
Różni się tym, że wszystkie elementy struktury mają ten sam typ
bazowy (element_type
)
i mogą być przesunięte względem początku o zadaną (tablica
array_of_displacements
)
ilość elementów.
int MPI_Type_commit(MPI_Datatype *newtype);
Tą funkcję należy wywołać po zdefiniowaniu nowego typu przy użyciu którejś z poprzednich funkcji - dopiero wtedy zacznie on być widziany przez MPI.
Istnieje
również prostsza metoda wysyłania większej ilości danych za
jednym zamachem - polega ona na tym, że przed każdą operacją typu
MPI_Send()
ręcznie pakujemy wszystkie dane do jednego obszaru pamięci, a druga
strona po odebraniu komunikatu może je rozpakować w analogiczny
sposób. Odpowiednie funkcje mają następującą postać:
int MPI_Pack(void *pack_data, int in_count, MPI_Datatype datatype, void *buffer, int size, int *position_ptr, MPI_Comm comm);
Parametr
pack_data
powinien zawierać in_count
elementów typu datatype
.
Parametr position_ptr
zawiera adres początkowy w buforze wyjściowym buffer
i po wykonaniu funkcji jego wartość jest uaktualniana (dodawana
jest długość zapisanych danych). Parametr size
określa rozmiar obszaru pamięci wskazywanej przez buffer
.
int MPI_Unpack(void *buffer, int size, int *position_ptr, void *unpack_data, int count, MPI_Datatype datatype, MPI_Comm comm);
Funkcja
odwrotna do poprzedniej - rozpakowuje count
elementów typu datatype
ze zmiennej buffer
począwszy od pozycji position
do obszaru pamięci wskazywanego przez unpack_data
i odpowiednio uaktualnia zmienną position
.
Podstawową (i najczęściej stosowaną) funkcją MPI służącą do synchronizacji procesów jest
int MPI_Barrier(MPI_Comm comm);
Wywołanie jej w pewnym miejscu programu powoduje, będzie on czekał, aż wszystkie pozostałe jego instancje dojdą do tego miejsca i dopiero potem ruszy dalej.
MPI
umożliwia, oprócz klasycznej komunikacji typu MPI_Send
/
MPI_Recv
również przesyłanie wiadomości bez blokowania programu. W skrócie
wygląda to tak, że po wywołaniu funkcji MPI_Isend
program może wykonywać inne czynności (oczywiście nie zmieniające
bufora wysyłanego komunikatu), natomiast w chwili, gdy chce się
upewnić, że komunikat został już wysłany w całości, wywołuje
MPI_Wait
,
lub MPI_Test
.
W
podobny sposób realizowany jest odbiór komunikatów bez blokowania
- analogiczna funkcja nosi nazwę MPI_Irecv
.