PROJEKT 2”

Transkrypt

PROJEKT 2”
”PROJEKT 2”
PROGRAMOWANIE RÓWNOLEGŁE
K. Górzyński (89744), D. Kosiorowski (89762)
Informatyka, grupa dziekańska I3
20 grudnia 2010
Spis treści
1 Opis problemu
1.1 Analizowany kod . . . . . . . . . . . . . . . . . . . . . . . . .
2
2
2 Pomiar prędkości przetwarzania
2.1 Funkcja czasowa . . . . . . . . . . . . . . . . . . . . . . . . . .
4
4
3 Zrównoleglenie obliczeń
3.1 Współdzielenie sumy . . . . . . . . . . . . . . . .
3.1.1 Realizacja w kodzie programu . . . . . . .
3.1.2 Wnioski . . . . . . . . . . . . . . . . . . .
3.2 Scalanie wartości w lokalnych sumach częściowych
3.2.1 Realizacja w kodzie programu . . . . . . .
3.2.2 Wnioski . . . . . . . . . . . . . . . . . . .
3.3 Sumy częściowe w ramach współdzielonej tablicy .
3.3.1 Realizacja w kodzie programu . . . . . . .
3.3.2 Wnioski . . . . . . . . . . . . . . . . . . .
3.4 Badanie długości linii pamięci podręcznej . . . . .
3.4.1 Realizacja w kodzie programu . . . . . . .
3.4.2 Wnioski . . . . . . . . . . . . . . . . . . .
4 Porównania wersji programów
4.1 Wg liczby kroków całkowania . . . .
4.2 Wg liczby wątków przetwarzania . .
4.3 Wg powinowactwa wątków do rdzeni
4.4 Wnioski do wykresów . . . . . . . . .
5 Podsumowanie
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
5
5
5
6
7
7
7
8
8
8
9
9
10
.
.
.
.
11
11
12
14
16
18
1
Rozdział 1
Opis problemu
Generalnie problem opiera się na wykorzystaniu funkcjonalności standardu przetwarzania współbieżnego OpenMP. Badając naszą funkcję liczącą
liczbę P I przy użyciu odpowiednich mechanizmów standardu i porównując
je między sobą prowadzimy do stworzenia optymalnego schematu obliczeń.
To pozwala na dogłębne zrozumienie odpowiednich dyrektyw kompilatora
OpenMP przy równoczesnym zrozumieniu mechanizmu współbieżnych obliczeń. Ostatecznie za pomocą zmiany odległości pomiędzy modyfikowanymi
elementami współdzielonej tablicy (za pomocą pomiaru czasu przetwarzania) badamy długość linii pamięci podręcznej. Wyjaśniamy przyczynę spadku
czasu przetwarzania ze wzrostem odległości i przedstawiamy nasze spostrzeżenia.
1.1
Analizowany kod
#include <stdio.h>
#include <time.h>
long long num steps = 1000000000;
double step;
int main(int argc, char* argv[])
{
clock t start, stop;
double x, pi, sum=0.0;
int i;
step = 1./(double)num steps;
start = clock();
for (i=0; i<num steps; i++)
2
{
x = (i + .5)*step;
sum = sum + 4.0/(1.+ x*x);
}
pi = sum*step;
stop = clock();
printf(”Wartosc liczby PI wynosi % 15.12f\ n”,pi);
printf(”Czas przetwarzania wynosi % f sekund\ n”,((double)(stop - start)/1000.0));
return 0;
}
3
Rozdział 2
Pomiar prędkości przetwarzania
2.1
Funkcja czasowa
Sprawa bardzo dokładnych obliczeń wiąże się z dokładną funkcją pomiaru
czasu. Niestety funkcja clock() biblioteki time.h podaje czas z dokładnością
do 15-16ms (15625 taktów zegara - WinXP). Biorąc jednak pod uwagę 109
skoków pomiarowych przekłamania powinny być niezauważalne - nawet dla
optymalnej wersji implementacji. Jednak działając na 107 czy tym bardziej
na 105 skoków pomiarowych w drugiej i trzeciej wersji programu potrzebna
już jest dokładność do milisekund.
Schemat pomiaru:
unsigned int64 freq, counterStart, counterStop;
//...
QueryPerformanceFrequency(reinterpret cast<LARGE INTEGER*>(& freq));
QueryPerformanceCounter(reinterpret cast<LARGE INTEGER*>(& counterStart));
// blok operacji do pomiaru
QueryPerformanceCounter(reinterpret cast<LARGE INTEGER*>(& counterStop));
//...
printf(”Czas przetwarzania wynosi % f milisekund\ n”,((static cast<long double>(counterStop)
- counterStart) / freq * 1000));
Schemat polega na przypisaniu funkcją QueryPerformanceFrequency()
częstotliwości taktowania do zmiennej freq, która to jest potrzebna później do
wyznaczenia upływu czasu pomiędzy początkiem pomiaru i końcem - funkcja
QueryPerformanceCounter(). Takim sposobem czas zależny jest on licznika
taktów zegara. W przypadkach, w których nie będziemy potrzebowali bardzo
precyzyjnych porównań skorzystamy z funkcji clock().
4
Rozdział 3
Zrównoleglenie obliczeń
3.1
3.1.1
Współdzielenie sumy
Realizacja w kodzie programu
#pragma omp parallel
{
#pragma omp for private(x)
for (i=0; i<num steps; i++)
{
x = (i + .5)*step;
#pragma omp atomic
sum +=4.0/(1.+ x*x);
}
pi = sum*step;
}
Jak widzimy w kodzie pojawiły się nowe dyrektywy. Dyrektywa parallel
wskazuje kompilatorowi obszar kodu, który poddany zostaje zrównolegleniu.
Natomiast dyrektywa for informuje o zrównolegleniu pętli for. Ostatnia z
w/w dyrektyw to atomic - ma ona zadanie aby aktualizacje dokonywanie na
współdzielonej sumie były aktualizowane atomowo - brak zezwolenia wielu
wątkom na jednoczesny zapis.
Zapobiegamy przez to zjawisku utraconego zapisu. Zdecydowanie konieczne
jest już tutaj wprowadzenie nowej klauzuli private, która ma na celu stworzenie nowego obiektu tego samego typu, który podajemy w parametrze klauzuli, raz dla każdego wątku. Wówczas wszystkie odniesienia do oryginalnego
obiektu zastępuje się odniesieniami do nowego obiektu (lokalnego, prywatnego dla każdego wątku). W ten sposób nie dopuścimy do przekłamań mo5
gących powstać w działaniach operujących na wprowadzonej zmiennej prywatnej - tutaj x.
Alternatywą dla atomic byłoby zastosowanie dyrektywy critical, jednak w
naszym przypadku działałaby ona identycznie jak pierwotna. Skoro zmienna
x jest prywatna, to mimo iż byłaby blokowana w obszarze krytycznym, w
rzeczywistości nie miałoby to znaczenia, gdyż blokadzie podległby obiekt
prywatny unukalny dla każdego wątku. Zatem rzeczywiste i konieczne blokowanie zostałoby wprowadzone dla zmiennej sum, czyli identycznie, jak w
podejściu atomowym. Na poniższych zrzutach ekranu widzimy efekt pracy
programu - na pierwszy rzut oka widzimy pewną nieprawidłowość, którą w
realizacji dalszych zadań trzeba wykluczyć - otóż każde z wyników obliczeń
aplikacji w wyniku posiadają różne wartości PI. Więcej na ten temat we
wnioskach.
3.1.2
Wnioski
Pierwszy rozważany przypadek zrównoleglenia przebiegł sprawnie. Przekazaliśmy pętlę do zrównoleglenia, zrealizowaliśmy ciąg dostępów do współdzielonej sumy w sposób niepodzielny. Dogłębna analiza dyrektywy atomic
zmusiła nas do zastosowania zmiennej prywatnej x.
Atomowość (bez wprowadzania innych zabezpieczeń) ma zastosowanie jedynie do prostych wyrażeń. Dlaczego? Generalnie chodzi o to, że atomowości
podlega tylko działanie na danej z lewej strony operacji. Nie mam żadnej
gwarancji, że wyrażenie po prawej stronie (u nas 4.0/(1.+ x*x)) będzie oceniane atomowo. Po prawej stronie operacji, działając na współdzielonym x
otzymalibyśmy przekłamania wyniku. Aktualizacja x = (i + .5)*step nie
przebiegałaby w sposób bezpieczny. Prościej mówiąc - zanim wątek A (oznaczenie poglądowe) po aktualizacji x przeszedłby do atomowej aktualizacji
sumy współdzielonej, inny wątek B mógłby (i jest to więcej niż prawdopodobne) zaktualizować x i wprowadzać przekłamanie w wątku A. Ten prosty
opis sytuacji zmusza nas do wprowadzenia prywatności zmiennej x w powyższym algorytmie, współbieżnie liczącym wartość PI.
6
3.2
3.2.1
Scalanie wartości w lokalnych sumach częściowych
Realizacja w kodzie programu
#pragma omp parallel for private(x) reduction(+:sum)
for (i=0; i<num steps; i++)
{
x = (i + .5)*step;
sum += 4.0/(1.+ x*x);
}
pi = sum*step;
W tym przypadku pojawiła się nam dyrektywa reduction, której zadanie
polega na stworzeniu prywatnej kopii zmiennej (lub zmiennych, gdy na liście
redukcji jest ich więcej) dla każdego wątku preinicjalizowanej do określonej
wartości. Każda kopia inicjalizowana jest w sposób zależny od operatora.
Na końcu regionu ze zdefiniowaną klauzulą reduction następuje scalanie, za
pomocą określonego dla redukcji operatora, zmiennej globalnej i ostatecznych wersji zmiennych prywatnych. Dodatkowo zmienne na liście muszą być
zmiennymi skalarnymi - niemożliwe jest operowanie na tablicach i strukturach. Zmienne te muszą być uznane też za współdzielone w kontekście
otaczających.
3.2.2
Wnioski
Takim sposobem wraz z wprowadzeniem prywatności i wykorzystaniem
reduction doprowadzamy do znaczącej optymalizacji algorytmu. Nietrudno
wywnioskować, że klauzula scalania odciąża programistę, gdyż kilka czynności wykonuje za nas kompilator OpenMP. Przez to otrzymujemy bardzo
szybko prawidłowy wynik PI - mechanizm współdzielenie działa bardzo dobrze.
7
3.3
3.3.1
Sumy częściowe w ramach współdzielonej
tablicy
Realizacja w kodzie programu
const int threads = 4;
double sumTab[threads];
//...
#pragma omp parallel for shared(sumTab) private(x)
for (i=0; i<num steps; i++)
{
int thNum = omp get thread num();
x = (i + .5)*step;
sumTab[thNum] = sumTab[thNum] + 4.0/(1.+ x*x);
}
#pragma omp parallel for
for (int j=0; j<threads; j++) pi+=sumTab[j]*step;
W powyższym przypadku tablica (współdzielona rzecz jasna - shared ) o rozmiarze równym liczbie wątków w aplikacji pozwala na modyfikacje elementu
tablicy indeksowanego numerem wątku. Nie ma tutaj możliwości przekłamań, gdyż indeks tablicy przy takich operacjach jest unikalny, atomowy.
Wykorzystujemy tutaj, podobnie jak we wcześniejszych wersjach programu
prywatność zmiennej x z oczywistych powodów. Te modyfikacje prowadzą
do powstania w naszym przypadku czterech sum częściowych, które to z
kolei możemy współbieżnie już w niezauważalnym nakładzie czasowym wykorzystać do obliczeń końcowych wartości PI. Moglibyśmy tutaj wykorzystać
dyrektywę single. Sposób jej użycia jest bardzo prosty. Umieszczając ją w sekcji parallel nakazujemy programowi, aby dany blok kodu był wykonany tylko
przez jeden wątek - pierwszy wolny. Jednak po badaniach przeprowadzonych
na kodzie okazało to się porównywalne do powyższego zastosowania.
3.3.2
Wnioski
Możemy się przez chwilę zastanawiać dlaczego czasy osiągane tą metodą
są tak długie. Jednak jest to wytłumaczalne w bardzo prosty sposób.
Wpływa na to zjawisko migotania pamięci. Wyobraźmy sobie sytuację, w
której cała tablica sum częściowych mieści się w jednej linii pamięci Cache.
Gdy wątek zapisuje dane pod przynależnym mu indeksem, linia pamięci podręcznej zostaje oznaczona jako ”dirty” przez co kolejny wątek musi ściągać ją
do swojej pamięci podręcznej. Nasza tablica sum częściowych, przypomina8
jąc, składa się z czterech obiektów typu double. Zatem tablica osiąga rozmiar
32B, co przy rozmiarze linii 64B pozwala domyślać się, że mamy do czynienia z w/w efektem. Aby go uniknąć należy odseparować elementy tablicy,
przydzielone konkretnym wątkom, nieużywanymi indeksami, które dopełniając linię pamięci, eliminują efekt migotania - zmienna każdego wątku zostanie dodana pod wskazany element tablicy znajdujący się w ”własnej” (dla
wątku), niewspółdzielonej linii pamięci podręcznej. Tak, więc wyjaśniliśmy
przyczynę spadku czasu przetwarzania wraz z wzrostem odległości między
modyfikowanymi elementymi tablicy współdzielonej. Natomiast problemem
separowania zajmiemy się w następnym rozdziale.
3.4
3.4.1
Badanie długości linii pamięci podręcznej
Realizacja w kodzie programu
const int offset = ...;
const int threads = 4;
double sumTab[(threads-1)*offset];
//...
#pragma omp parallel for shared(sumTab) private(x)
for (i=0; i<num steps; i++)
{
int thNum = omp get thread num()*offset;
x = (i + .5)*step;
sumTab[thNum] = sumTab[thNum] + 4.0/(1.+ x*x);
}
#pragma omp parallel for
for (int j=0; j<threads*offset; j+=offset) pi+=sumTab[j]*step;
Za wspomnianą wcześniej odległość danych w pamięci odpowiada stała offset
zmieniana odpowiednio podczas dokonywania pomiarów. Struktura zrównolegleń jest identyczna, jak w poprzedniej wersji programu (opisanej wcześniej), dlatego nie będziemy powielać informacji dot. dyrektyw i klauzul.
Zatem pozostaje przedstawić otrzymane pomiary, których dokonaliśmy dla
liczby kroków 107 . Nadmieniamy, że Double=8B w środowisku, w którym
dokonaliśmy pomiarów (laboratorium).
9
Długość linii pamięci [B]
4*8
5*8
6*8
7*8
8*8
9*8
10*8
Czas [ms]
564,0641
324,7575
308,2361
75,0154
75,71046
75,11178
76,7
Widzimy po wynikach pomiarów, że długość linii pamięci podręcznej wynosi
w granicach 56-72B, co przy znajomości architektury systemowej pozwala
nam na określenie jasno, że długość ta to 64B. Wyniki pomiarów natomiast
dowodzą prawdziwości wyjaśnień dotyczących spadku czasu wraz z wzrostem
odległości modyfikowanych danych w tablicy współdzielonej.
Rysunek 3.1: Zależność czasu przetwarzania od odległości danych w pamięci
3.4.2
Wnioski
Czas przetwarzania dla odległości większej niż zalecana, będzie porównywalny. Jednak należy zauważyć, że im większa odległość (ponad 64B) tym
więcej linii pamięci będzie ”pustych” (żaden element nie będzie w nich modyfikowany) i będziemy mieli do czynienia z propagacją czasu potrzebnego na
”przejście” do określnej linii, oraz pozycji w linii (dla odległości nie będącej
wielokrotnościa 64B).
10
Rozdział 4
Porównania wersji programów
Implementacja w tym przypadku jest na tyle trywialna, że pozwoliliśmy
sobie pominąć wklejanie kodu. Przedstawiamy zatem pomiary:
Liczba kroków
105
107
4.1
Czas [ms]
2,1472
215,3993
Wg liczby kroków całkowania
Rysunek 4.1: Zależność czasu od liczby kroków sumowania
11
Na wykresie 2. przedstawiamy przyspieszenie (opóźnienie) współbieżnych
wersji programu do sekwencyjnej. Zależność odczytujemy ze skali logarytmicznej, co w prosty sposób pozwala określić czy mamy do czynienia z przyspieszeniem, czy wręcz przeciwnie. Jeśli przyspieszenie (jako krotność czasu
przetwarzania sekwencyjnego) jest mniejsze niż 1 to znaczy, że uzyskaliśmy
poprawę czasu, jeśli większa od 1 to czas jest gorszy.
Rysunek 4.2: Przyspieszenie jako krotność czasu przetwarzania sekwencyjnego
4.2
Wg liczby wątków przetwarzania
W tym przypadku wykorzystujemy funkcję OpenMP o nazwie omp set num threads(int
x), gdzie parametrem jest liczba wątków, które chcemy uruchomić. Dla potrzeb pomiarów wykorzystywaliśmy 1, 2, 4, 5, i 8 wątków. Zależności obrazujemy na wykresach (dla każdej z wersji implementacji):
12
Rysunek 4.3: Wersja pierwsza implementacji - zależność czasu od liczby wątków przetwarzania.
Rysunek 4.4: Wersja druga implementacji - zależność czasu od liczby wątków
przetwarzania.
13
Rysunek 4.5: Wersja trzecia implementacji - zależność czasu od liczby wątków
przetwarzania.
4.3
Wg powinowactwa wątków do rdzeni
Wykorzystanie powinowactwa wątków do rdzeni wiązało się z wykorzystaniem kilku funkcji OpenMP wykorzystywnych w obszarze zrównoleglonym:
int th id=omp get thread num();
DWORD PTR mask = (1 « (th id % 4 ));
DWORD PTR result = SetThreadAffinityMask( thCurr, mask );
gdzie thCurr to uchwyt wątku aplikacji.
Wyniki pomiarów na wykresach 4.6 i 4.7. Wykres 4.8 natomiast przedstawia
zależność czasów uzyskanych z powinowactwem wątków do rdzeni w każdej wersji implementacji z liczbą kroków sumowania 105 i 107 w stosunku
do czasu wersji sekwencyjnej implementacji. Krótko mówiąc powinowactwo
spowalnia procesy - krotność większa od 1, czyli uzyskaliśmy opóźnienie.
14
Rysunek 4.6: Wpływ powinowactwa na czas - liczba kroków 105
Rysunek 4.7: Wpływ powinowactwa na czas - liczba kroków 107
15
Rysunek 4.8: Wersja ze spowinowaconymi wątkami z rdzeniami - przyspieszenie jako krotność czasu przetwarzania sekwencyjnego
4.4
Wnioski do wykresów
Zależność od liczby kroków całkowania
Analizując wykres 4.1 widzimy przewagę wg. czasu przetwarzania podejścia drugiego (PI2) nad pozostałymi. Sekwencyjne obliczanie uzyskuje 2
miejsce, wersja trzecia współbieżności - kolejne, a podejście pierwsze zgodnie
z intuicją znajduje się na ostatnim miejscu. Widzimy ,że ta kolejność jest
zachowana dla obu przypadków liczby kroków całkowania, co pozwala wnioskowć, że nie ma przypadkowości w wynikach.
Z wykresu 4.2 widzimy, że wartość mniejszą od 1 (czyli poprawę czasu) uzyskało podejście drugie realizacji współbieżności, czyli podejście ze scalaniem
wartości lokalnych sum.
Zależność od liczby wątków przetwarzania
Wykres 4.3 obrazuje nam jak zmienia się czas przetwarzania w miarę
zmiany liczby wątków dla implementacji pierwszej. Widzimy wyraźny wzrost
czasu spowodowany stopniowym zwiększaniem liczby wątków. Przyczyna
16
wzrostu spowodowana jest głównie przez klauzule atomic, ponieważ współdzielona operacja sum+= musi wykonać się w danej chwili tylko na 1 wątku,
co powoduje czekanie pozostałych - wprowadza to bardzo znaczący wzrost
kosztu czasowego.
W przypadku implementacji wersji drugiej (wykres 4.4), gdzie nie ma klauzuli
atomic, zwiększenie liczby wątków powoduje przyśpieszenie przetwarzania
(zależność widoczna przede wszystkim dla większego stopnia całkowania).
Spowodowane jest to zastosowaniem klauzuli reduction, która tworzy prywatna kopie zmiennej sum dla każdego wątku i na końcu je sumuje, a więc
większa liczba zrównolegnionych wątków proporcjonalnie przyśpiesza operacje.
Na wykresie 4.5 mamy odwzorowanie trzeciej implementacji naszego problemu. Najlepszy rezultat otrzymujemy dla jednego wątku, ponieważ nie
występuje tutaj zjawisko ”migotania”. W przypadku 107 kroków całkowania, dla większej liczby wątków zaczyna się owe zjawisko, przy czym nasila się
dla liczby wątków równej liczbie procesorów, czyli czterech. Dla 5-8 wątków
tworzona jest kolejna linia pamięci podręcznej, co skutkuje zmniejszeniem
zjawiska ”migotania”, a co za tym idzie, krótszym czasem przetwarzania.
W przypadku 105 dla 5-8 wątków domniemamy, że dane mieszczą się nadal
na jednej linii pamięci, dlatego wynik czasu przetwarzania jest stosunkowo
zbliżony do przypadku czterech wątków.
Zależność od powinowactwa
Wykresy 4.6 i 4.7 pokazują, jak wpływa przypisanie konkretnym wątkom
konkretnych procesorów na czas przetwarzania. Widzimy spadek efektywności spoglądając na druga i trzecią wersję implementacji (o porównywalnej wielkości), natomiast dla wersji pierwszej programu uzyskaliśmy wysoki
wzrost efektywności (spadek czasu przetwarzania). Ta sytuacja w pełni obrazuje potwierdza zdanie (i odwrotnie): ”Ustalenie powinowactwa wątków
może kolidować z regułami szeregowania i może utrudniać modułowi szeregującemu uzyskanie efektywności przetwarzania w całym systemie.”
[http://www.cs.put.poznan.pl/rwalkowiak/My%20Webs/Systemy%20wieloprocesorowe.pdf]
Jednak żadna z implementacji w przypadku powinowactwa nie przyspiesza w
stosunku do przetwarzania sekwencyjnego (wykres 4.8) - najmniejsze opóźnienie ma wersja pierwsza, jednak nie na tyle małe, aby mówić o porównywalności.
17
Rozdział 5
Podsumowanie
Wyniki pomiarów pokazują, że efektywność drugiej i trzeciej metody są
w miarę zbliżone (spoglądając na wyniki obiektywnie, oceniając wszystkie
wersje jednoczesnie, a nie porównując każdy z każdym) - ze wskazaniem na
redukcję, a metoda pierwsza osiąga najgorsze wyniki. Długość linii pamięci
wynosi 64B, warto o tym pamiętać, aby uniknąć efektu ”migotania” i uzyskać najlepsze wyniki czasu przetwarzania.
Dzięki technologii OpenMp mamy możliwość badać zjawiska towarzyszące
przetwarzaniu równoległemu na wielu procesorach, aby móc, wraz ze wzrostem jakości sprzętu, zwiększyć jakość i wydajność naszych programów.
18

Podobne dokumenty