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