Obliczenia z wykorzystaniem komputerów, takie jak symulacje fizyczne, są nieodłączną częścią współczesnej nauki i przemysłu. Podstawą obliczeń komputerowych są obliczenia równoległe. Gdy korzystamy z jednego komputera komputera - nawet bardzo mocnego - jesteśmy ograniczeni do kilkudziesięciu/kilkuset rdzeni procesora i kilkuset/kilku tysięcy GB pamięci RAM. Czas obliczeń jest odwrotnie proporcjonalny do liczby rdzeni (przynajmniej w optymistycznym scenariuszu), dlatego z reguły potrzebujemy i chcemy wykorzystać ich jak najwięcej. Na tych zajęciach zobaczymy, jak prowadzić obliczenia równoległe w modelu pamięci rozproszonej, tzn. z wykorzystaniem wielu komputerów połączonych siecią. W tym celu potrzebujemy dwóch elementów:
Przed przystąpieniem do zajęć, powinieneś/powinnaś był/a założyć konto w systemie ICM UW, pozwalające na dostęp do maszyn obliczeniowych centrum. Resztę niniejszej instrukcji będzimy wykonywać właśnie na nich.
sshDo logowania na zdalne maszyny służy komenda ssh (od
secure shell). Aby połączyć się z klastrem ICM należy wykonać
polecenie
Po podaniu hasła oraz kodu OTP wyświetli się wiadomość powitalna oraz prompt sugerujący, że jesteśmy na węźle dostępowym:
[user@login ~]$
Jak nazwa wskazuje, węzeł loginowy nie służy do prowadzenia obliczeń.
W tym celu musimy zlecić naszą pracę na węzły obliczeniowe. Ponieważ z
klastra korzysta wielu użytkowników, musi dziać się to w sposób
zorganizowany, a zasoby powinny być przydzielane sprawiedliwie. Do
rozwiązania tego problemu służą systemy kolejkowe. Najpopularniejszy z
nich, obecny na maszynach ICM, to slurm. Posiada on bogatą
dokumentację,
która może okazać się pomocna w czasie tego laboratorium.
Zanim zlecimy pierwsze zadanie, sprawdźmy stan kolejki. W tym celu użyj 2 poleceń:
sinfo - wyświetla informacje o obciążeniu węzłów
obliczeniowych (idle - węzły wolne, allocated - węzły
zajęte w całości, mixed - węzły obciążone częściowo)squeue - wyświetla informacje o uruchomionych i
oczekujących zadaniach. Przydatna jest opcja --me,
ograniczająca output jedynie do naszych zadań (obecnie nie mamy żadnych
w kolejce)Zadania obliczeniowe możemy zlecać przy pomocy srun i
sbatch, podając jako argument program lub skrypt, które
mają zostać wykonane. Polecenia te przyjmują także kilka innych
argumentów (pełna lista dostępna w dokumentacji):
-A/--account (wymagane) - konto, z którego
pobierany będzie budżet godzin obliczeniowych (CPUh), do odczytania z
portalu ICM lub informacji wyświetlanych przy logowaniu (prowadzący/a
powinien był dodać Twoje konto do odpowiedniego projektu przed
zajęciami)-p/--parititon (wymagane) - partycja
klastra, na której ma zostać uruchomione zadanie. Tutaj podaj wartość
topola (nazwa maszyny)-n/--ntasks (domyślnie 1) - liczba
równoległych instancji zadania (procesów), która ma zostać
uruchomiona-N/--nodes (domyślnie 1) - min. liczba
węzłów, na których ma zostać uruchomione zadanie-c/--cpus-per-task (domyślnie 1) - liczba
rdzeni per instancja zadania--mem - ilość pamięci per węzeł na zadanie-t/--time - limit czasowy zadania-J/--job-name - nazwa zadania-D/--chdir - katalog roboczy dla zadania
(domyślnie katalog, z którego zadanie zostało zlecone)Zadania możemy zlecać w trybie interaktywnym lub wsadowym (batchowym). Przyjrzyjmy się teraz jak to zrobić.
Sesja interaktywna polega efektywnie na logowaniu ssh na
węzeł obliczeniowy. Uruchamiamy jako zadanie srun program
bash -l (terminal, flaga -l jest potrzebna do
odpowiedniej konfiguracji środowiska), podając dodatkowo opcję
--pty.
Po wykonaniu powyższej komendy wyświetli się informacja o węźle, do którego otrzymaliśmy dostęp. Zmieni się także prompt.
Tryb interaktywny jest przydatny do krótkich eksperymentów, natomiast
przeważnie chcemy zlecić większe, równoległe zadanie. W takim wypadku
nie mamy pewności, że zasoby będą dostępne od razu, a same obliczenia
mogą trwać długo (np. kilka dni). Wobec tego nie chcemy pozostawiać
otwartej konsoli, tylko zlecić zadanie do kolejki i za jakiś czas
sprawdzić wyniki. Dokładnie do tego służy tryb wsadowy (komenda
sbatch).
Siła trybu wsadowego pochodzi od możliwości uruchamiania zadań w
trybie równoległym. Możemy to zrobić używając komendy srun
wewnątrz skryptu zlecanego przez sbatch.
Wykonaj ponownie ostatnie zadanie, podając opcję -n 2.
Ile razy wyświetli się data? Teraz podmień komendę date na
srun date. Czy teraz data wyświetli się 2 razy?
Na klastrze zainstalowane jest wiele różnych programów, często te
same programy w różnych wersjach. Aby uniknąć konfliktów, programy nie
są dostępne od razu, tylko musimy załadować te, których potrzebujemy.
Robimy to za pomocą modułów. Aby wyświetlić listę modułów, użyj (z węzła
obliczeniowego, w sesji interaktywnej) komendy module av.
Moduły ładujemy za pomocą komendy module load (moduł).
Rozpocznij nową sesję interaktywną. Sprawdź wersję zainstalowanego
kompilatora gcc komendą gcc --version.
Następnie załaduj moduł common/compilers/gcc/13.2.0 i
sprawdź ponownie wersję gcc.
Każdy program przygotowany do pracy równoległej oprócz podstawowego algorytmu, potrzebuje mechanizmu komunikacji. W naszym przypadku będzie to standard MPI (Message Passing Interface). Biblioteka OpenMPI dostarcza nam narzędzi niezbędnych do uruchamiania i komunikacji między poszczególnymi procesami składającymi się na nasz “program”.
Przygotuj plik program.cpp o poniższej treści. Następnie
skompiluj go za pomocą programu mpic++. Nie zapomnij
załadować najpierw modułu common/mpi/openmpi/5.0.3.
#include "mpi.h"
#include <stdio.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
int numprocs, rank, namelen;
char processor_name[MPI_MAX_PROCESSOR_NAME];
MPI_Init(&argc, &argv);
MPI_Comm_size(MPI_COMM_WORLD, &numprocs);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Get_processor_name(processor_name, &namelen);
printf("Hello World! from process %d out of %d on %s\n",
rank, numprocs, processor_name);
MPI_Finalize();
}Powyższy program można skompilować i uruchomić zlecając następujący
skrypt komendą sbatch:
Przeanalizujmy teraz program. Funkcje MPI_Init i
MPI_Finalize służą odpowiednio do inicjalizacji i do
zakończenia komunikacji pomiędzy procesami. Powinny one być wołane
odpowiednio na początku i na końcu programu, ponieważ tylko pomiędzy
nimi można wykonać jakiekolwiek wywołanie biblioteki MPI i
komunikować się z innymi procesami w grupie. Wywołanie
MPI_Comm_size zwróci nam liczbę procesów (np.
4), zaś MPI_Comm_rank zwróci nam numer
naszego procesu (np.
0,1,2 lub 3).
Zmienna rank jest wiec jedną z najważniejszych w kodzie,
ponieważ odróżna nasze procesy. Jeśli nigdzie jej nie użyjemy, to
wszystkie nasze procesy zrobią dokładnie to samo.
Rozszerz program tak, by każdy proces losował pewne liczby i wypisywał pewne statystyki:
a o rozmiarze n = 10000 * (rank + 1)a[0]Pamiętaj aby we wszystkich komunikatach umieszczać zmienną
rank, tak aby było wiadomo, który komunikat pochodzi od
którego procesu. By mieć pewność, że komunikaty wypisywane są
rzeczywiście wtedy, kiedy występują w kodzie (a nie są buforowane przez
system), dodaj komendę fflush(stdout);
zaraz po każdym wywołaniu printf.
Komenda ta powoduje, że cały buforowany tekst zostanie wyświetlony na
ekran od razu.
Aktualnie, losowanie jest bardzo niedoskonałe. Wszystkie procesy
wylosowały ten sam ciąg losowy (można zobaczyć to już po pierwszym
elemencie, który jest identyczny we wszystkich procesach). Żeby tego
uniknąć przekaż np. wartość time(NULL) + rank jako
ziarno do funkcji srand, tak aby ciąg losowy był
zainicjalizowany inną liczbą w każdym procesie.
Zaobserwuj, że różne procesy dochodzą do różnych etapów algorytmu w
różnych momentach. Np. średnia dla procesu 0 może być wyznaczona przed
wypełnieniem liczbami tablicy w procesie 1. Możemy wymusić, aby procesy
czekały na siebie nawzajem, dodając instrukcję MPI_Barrier(MPI_COMM_WORLD);
po wywołaniach printf/fflush. W programach
równoległych bariera jest mechanizmem synchronizacji, który powoduje, że
wszystkie procesy czekają w tym miejscu kodu, aż reszta procesów dojdzie
do tego miejsca, a następnie wszystkie razem ruszają dalej. Zauważ, że
powoduje to, że program działa tak wolno, jak jego najwolniejszy
element. Przebieg programu we wszystkich procesach jest pokazany
poglądowo na poniższym obrazku:

Użyj funkcji wykonującej redukcję aby obliczyć średnią globalną (po wszystkich procesach) i wariancję z \(a\). Redukcja w programowaniu równoległym polega na wykonaniu jakiejś operacji (np. sumowania albo wzięcia maxiumum) na danych ze wszystkich procesów. W bibliotece MPI mamy do dyspozycji funkcję:
source to wskaźnik do danych, które
mamy np. zsumować.destination to wskaźnik do miejsca, gdzie ma być
umieszczony wynik.count to liczba elementów danych do zsumowania. Czyli
np. 1 jeśli dane to jedna liczba.datatype to typ danych, które sumujemy:
MPI_INT lub MPI_DOUBLE.operation to operacja, którą wykonujemy, np:
MPI_SUM lub MPI_MAX.root to numer procesu, do którego przesyłamy wynik, np:
0.MPI_COMM_WORLD.Użyj tej funkcji aby obliczyć globalne statystyki, a następnie
wyświetl je (pamietaj, że mają one sens tylko na węźle
root). Weź pod uwagę, że globalne n jest inne
niż n lokalne.
Bliźniaczą do funkcji MPI_Reduce jest funkcja
MPI_Allreduce. Przesyła ona wynik do wszystkich procesów, a
nie tylko do procesu root.
Stwórz nowy program równoległy program2.cpp, który
będzie obliczał powyższą średnią i wariancję, używając tylko jednej
pętli, bez alokowania tablicy a (tzn., będzie liczył
średnią i wariancję bez przechowywania pojedyńczych elementów). By to
zrobić przekształć wzór na wariancję:
\(\sigma^2 = \frac{1}{n-1}\sum_i\left(a_i - \frac{1}{n}\sum_j a_j\right)^2\)
tak aby był wyrażony za pomocą \(S_1\) i nowego \(\hat S_2 = \sum_i a_i^2\), który da się
obliczyć bez znajomości średniej \(\mu\). Użyj we wszystkich procesach tego
samego (bardzo wysokiego) n. Porównaj czas wykonania
wykonując:
Spróbuj wykonać zadania z którejś z wcześniejszych instrukcji, np.
konwersję obrazków z .jpg na .gif, jako
nieinteraktywne zadanie w kolejce.