PROJEKT 3 PROGRAMOWANIE RÓWNOLEGŁE K. Górzyński (89744), D. Kosiorowski (89762) Informatyka, grupa dziekańska I3 17 lutego 2011
Spis treści 1 Opis problemu 2 2 Implementacja problemu 3 2.1 Kod współdzielony........................ 3 2.2 Opis kodu i podejście programowe................ 4 2.2.1 Klasyczne mnożenie macierzy.............. 5 2.2.2 Mnożenie macierzy po transponowaniu......... 7 2.2.3 Wpływ kolejności pętli w mnożeniu........... 10 3 Analiza kodu 12 3.1 Efektywność............................ 12 3.2 Intel Parallel Studio....................... 15 3.3 AMD CodeAnalyst........................ 19 4 Badanie 23 4.1 Koszt współdzielenia i unieważniania danych.......... 23 4.2 Niezamierzone współdzielenie.................. 23 4.3 Przyspieszenie, efektywność, skalowalność............ 23 5 Podsumowanie 24 1
Rozdział 1 Opis problemu Projekt związany jest z zagadnieniem mnożenia macierzy. W jego ramach podejmujemy się analizy przetwarzania równoległego, poprawności kodu, ale także pomiaru zdarzeń procesora wpływających na efektywność. Podejmiemy się wyznaczenia miary efektywności, a także przypieszenia. Przedstawimy optymalny przydział macierzy wynikowej do odpowiednich procesorów. Pokażemy również jaki wpływ na przetwarzanie ma transponowanie macierzy. Spróbujemy ten, mimo wszystko złożony proces badawczy, w sposób zwięzły, poparty odpowiednimi argumentami. 2
Rozdział 2 Implementacja problemu 2.1 Kod współdzielony #pragma omp parallel sections{ #pragma omp section{ for (int z=0; z <Wiersze/2; z++){ for(int u=0; u <Kolumny/2;u++){ for(int k=0; k <Kolumny;k++){ if(transpose) sumtab[z][u]+=tab1[z][k]*tab2[u][k]; else sumtab[z][u]+=tab1[z][k]*tab2[k][u]; #pragma omp section{ for (int z=wiersze/2; z <Wiersze; z++){ for(int u=0; u <Kolumny/2;u++){ for(int k=0; k <Kolumny;k++){ if(transpose) sumtab[z][u]+=tab1[z][k]*tab2[u][k]; else sumtab[z][u]+=tab1[z][k]*tab2[k][u]; 3
#pragma omp section{ for (int z=0; z <Wiersze/2; z++){ for(int u=kolumny/2; u <Kolumny;u++){ for(int k=0; k <Kolumny;k++){ if(transpose) sumtab[z][u]+=tab1[z][k]*tab2[u][k]; else sumtab[z][u]+=tab1[z][k]*tab2[k][u]; #pragma omp section{ for (int z=wiersze/2; z <Wiersze; z++){ for(int u=kolumny/2; u <Kolumny;u++){ for(int k=0; k <Kolumny;k++){ if(transpose) sumtab[z][u]+=tab1[z][k]*tab2[u][k]; else sumtab[z][u]+=tab1[z][k]*tab2[k][u]; 2.2 Opis kodu i podejście programowe Mając na uwadze cel minimalizacji czasu wykonywania wszystkich obliczeń związany z osiągnięciem wyniku mnożenia dwóch macierzy - wynik znajduje się w macierzy sumtab[][] - jedynym słusznym przydziałem zadań do procesorów jest przydział blokowy. Dysponując czterema procesorami podzieliliśmy dane wyjściowe (tablicę wynikową) między procesory. Sposób podziału przedstawiony jest na rysunku 2.1. OpenMP pozwala w łatwy sposób odzwierciedlić ten przydział w kodzie. Jak widać w kodzie zastosowaliśmy cztery bloki sekcji, które odpowiadają poszczególnym ćwiartkom macierzy wynikowej. Dla potrzeb badania zmienne globalne Wiersze oraz Kolumny określające rozmiar macierzy są sobie równe. To pozwala na to by najbardziej wewnętrzna pętla iterowana po zmiennej k przyjmowała wartości od zera do rozmiaru (czyli liczby Wierszy lub Kolumn). 4
Rysunek 2.1: Podział blokowy macierzy wyjściowej. W implementacji wspomogliśmy się również zmienną warunkową, określającą czy mamy do czynienia z podejściem wykorzystującym transponowanie macierzy, czy też mnożymy w klasyczny sposób. 2.2.1 Klasyczne mnożenie macierzy W tym przypadku mnożąc macierz AÖB nie transponujemy macierzy B, zatem w schematyczny sposób każdy z procesorów w podejściu blokowym wykorzystuje połowę wierszy macierzy A i połowę kolumn macierzy B, aby w przydzielonej ćwiartce macierzy wynikowej zamieścić wyniki mnożenia. Przedstawiamy to na rysunku 2.2. Na pewno takie mnożenie jest nieefektywne, z tego względu, że podczas każdego (zależne jest to od rozmiaru macierzy i wielkości Cache procesora) mnożenia danej z macierzy A z daną z macierzy B, następuje ściągnięcie do pamięci podręcznej procesora nowej linii. Dlaczego? W macierzy B odwołujemy się w każdej kolejnej iteracji do nowego wiersza, tym samym do danej, której nie ma w pamięci podręcznej (przy odpowiednio dużej macierzy!). 5
Wpływa to na nieefektywne wykorzystanie pamięci podręcznej, gdyż z każdej ściągniętej linii odczytujemy potencjalnie tylko jedną daną (maksymalnie kilka - nigdy wszystkie). Rysunek 2.2: Schemat mnożenia macierzy (brak transpozycji). Dla przykładu, mając macierz 500Ö500, by osiągnąc w macierzy wynikowej jedną komórkę z wynikiem potrzebny jest dostęp do jednego wiersza macierzy A i 500 wierszy macierzy B. W środowisku w jakim badamy problem długość linii Cache to 64B, a całe Cache posiada 1024 linie. Natomiast dane na jakich 6
operujemy są typu Integer = 4B. Dane potrzebne do mnożenia z macierzy B to 500 linii Cache (64B) razy 16 (tyle linii Cache zajmują dane w jednym wierszu o szerokości 250 kolumn). Jak widzimy jest fizycznie niemożliwe wykorzystanie wszystkich danych macierzy B wykorzystywanych przy mnożeniu podczas jednego pobrania określonej linii do pamięci podręcznej procesora. Poglądowy rysunek utwierdza nas w przekonaniu, że ponowny dostęp do linii PP zawierającej dane z macierzy B następuje po < n n działaniach. Natomiast ściągnięte dane z macierzy A są w kolejnych iteracjach - odwołaniach 2 po kolei odczytywane z Cache, po czym stają się rzecz jasna niepotrzebne i mogą zostać nadpisane. Rysunek 2.3: Poglądowy odzwierciedlenie macierzy w Cache. 2.2.2 Mnożenie macierzy po transponowaniu Pisaliśmy wcześniej o nieefektywnym wykorzystaniu pamięci przez procesory. Teraz nadszedł czas rozwiązania tego problemu. Jak wiemy proste transponowanie macierzy B (musi być to druga macierz!) przebiega w sposób następujący: 7
int i,j; for(i=0; i<wiersze; i++) for(j=0; j i; j++) swap(i,j); gdzie definicja funkcji swap() to: void swap(int i, int j){ int temp = tabb[i][j]; tabb[i][j] = tabb[j][i]; tabb[j][i] = temp; W ramach badania dowiedziemy, że koszt transpozycji macierzy B jest na tyle mały, żeby rzec, iż transpozycja jest opłacalna. Dlaczego warto transponować? Otóż jest to bardzo przydatne, by efektywnie wykorzystać pamięć podręczną procesora. Z uwagi, że teraz mnożenie macierzy A razy B wiąże się z mnożeniem de facto wiersza razy wiersz, więc każda kolejna dana mnożona znajduje się w pamięci podręcznej obok danej pobieranej wcześniej. Inaczej mówiąc ściągnięta linia do Cache zostaje w pełni wykorzystana (na tym etapie zakładamy, że wielkość wiersza macierzy jest wielokrotnością rozmiaru linii Cache procesorów). Obszary macierzy wejściowych i wynikowych jakimi zajmuje się dany procesor przedstawione są na rysunku 2.4. Z uwagi na to, że pamięć podręczna procesora nie jest aż tak diametralnie mała dla małych rozmiarów macierzy czas mnożenia może być porównywalny - mówimy o efekcie utrzymywania danych za całej macierzy B w pamięci podręcznej. Natomiast kiedy macierz osiąga większe rozmiary może być tak, że raz ściągnięta linia (wielkości linii Cache) danych może nie doczekać się ponownego wykorzystania, z uwagi na brak miejsca i bezwzględną konieczność ściągnięcia innych danych w to miejsce. Mówimy tutaj o n2 n2 pobrań do PP. 4 2 8
Rysunek 2.4: Schemat mnożenia macierzy (po transponowaniu macierzy B). Dla każdego procesora taka operacja oznacza tyle, że ściągnięta linia danych do Cache zostaje w kolejnych iteracjach wykorzystywana. Inaczej - każda dana z linii po kolei zostaje odczytywana celem otrzymania wyniku we wskazanej komórce macierzy wynikowej. Nie oznacza to jednak, że wskazana linia z danymi wystarczy, aby była raz pobrana dla zapewnienia wyników we wskazanej ćwiartce macierzy wynikowej. Z uwagi na ograniczenie wielkościowe Cache dla macierzy rozmiaru 180 (180*90/16 1013 linii Cache; 180 kolumn, 90 wierszy, 16 obiektów Integer w linii) potrzeba ponownego ściągania tych samych danych z pamięci operacyjnej do pamięci podręcznej. 9
2.2.3 Wpływ kolejności pętli w mnożeniu Zmiana kolejności pętli iterującej po idenksach wierszów i kolumna w przypadku metody bez transponowania macierzy niesie za sobą na pewno negatywny skutek jeżeli spojrzymy na macierz wynikową. W przypadku macierzy wejściowych zamienią się one tylko charakterystyką - odzwierciedlenie macierzy A w pamięci przyjmie charakterystykę macierzy B i odwrotnie. Natomiast w przypadku macierzy wynikowej może wystąpić masowo zjawisko ponownych pobrań do PP procesora wskazanej linii, w której nastapi modyfikacja (zapis) jednej danej. W przypadku metody wykorzystującej transponowanie skutek in minus będzie dotyczyć tylko macierzy wynikowej. Jeszcze kilka słów odnośnie kolejności samych iteracji w pętlach. Rzeczą pożądaną wydaje się być manualne ustawienie kolejności (innej kolejności) iteracji w pętlach iterujących po wierszach i kolumnach dla każdego z czterech procesorów (w kodzie - sekcji). Dla przykładu - powiedzmy, że procesor P0 i procesor P1 iterują od zerowego wiersza. W takim wypadku pobrania linii z pamięci operacyjnej dotyczące tych samych danych macierzy A mogą się pokrywać czasowo, co wpłynie rzecz jasna na konieczne opóźnienia. Eliminacja tego zjawiska powoduje zrównoleglenie komunikacji na linii pamięć RAM - pamięć Cache procesorów (przesyłanie przez różne porty pamięci). Rysunek 2.5: Przykładowa realizacja kolejności iteracji w pętlach dla każdego z procesorów. 10
Rysunek 2.6: Mnożenie macierzy - odzwierciedlenie w Cache. 11
Rozdział 3 Analiza kodu 3.1 Efektywność Już kilka słów dotyczących efektywności zostało przedstawionych przy opisie dwóch metod obliczeń. Teraz postaram się w sposób bardziej zrozumiały poprzeć założenia konkretnymi liczbami i dowieść prawdziwość przyjętego przydziału blokowego do procesorów. Otóż jeden, konkretny procesor dla wyznaczenia sobie przydzielonej części jednego wiersza macierzy wynikowej sumtab[][] potrzebuje ½liczby kolumn macierzy B i jeden wiersz macierzy A. Po tych obliczeniach wskazany wiersz macierzy A staje się niepotrzebny. Zatem pobierany zostaje następny wiersz macierzy A (kolumny macierzy B są pamiętane), co pozwala przeprowadzić obliczenia i wyznaczyć kolejny wiersz (skrót myślowy - mamy na myśli część wiersza przydzieloną temu procesorowi) macierzy wynikowej. Stąd możemy wyznaczyć wielkość Cache niezbędnej do wykonania obliczeń: ˆ macierz A: 1 wiersz * liczba kolumn * 4B (rozmiar Integer) ˆ macierz B: ½liczby kolumn * liczba wierszy * 4B ˆ macierz C: długość linii Cache procesora (64B) To pozwala wyznaczyć granicę rozmiaru macierzy, dla której w ramach jednego procesora zapewnimy jednokrotne pobranie do pamięci podręcznej danych z tablic A i B a tym samym optymalizujemy liczbę trafień dla podanego podziału pracy. 12
Zgodnie z naszym środowiskiem laboratoryjnym procesor ma do dyspozycji 64kB Cache, więc: 64kB = 64B + n 4B + 1 2 n2 4B 65534B = 64B + n 4B + n 2 2B 65470 = 4n + 2n 2 n < 180 W takim wypadku, zakładając rozmiar macierzy na poziomie n=178 (lub mniej) możemy obliczyć liczbę dostępów do danych w ramach jednego procesora: ˆ macierz A: n3 = 1783 1.345M (każdy element wiersza (n-elementów, 4 4 n-wierszy) wykorzystywany jeden raz dla każdej pobranej kolumny macierzy B ( n-kolumn)) 2 2 ˆ macierz B: n3 = 1783 1.345M (każdy element kolumny (n-elementów, 4 4 n-kolumn) wykorzystywany jeden raz dla każdego pobranego wiersza 2 macierzy A ( n-wierszy)) 2 W założonym przypadku dane są czytane po ich jednokrotnym pobraniu do Cache procesora, a dodatkowo wszystkie dane są wykorzystane. Wnioskując, liczba pobrań linii Cache z pamięci operacyjnej odpowiada liczbie braku trafień. W łatwy sposób możemy wyznaczyć liczbę pobrań z pamięci operacyjnej do Cache: ˆ macierz A: n2 2 = 1782 2 = 15842 obiekty typu Integer ˆ macierz B: n2 2 = 15842 obiekty typu Integer Z uwagi, że linia Cache procesora (64B) zawiera 16 obiektów typu Integer (4B) liczba pobrań wynosi: 15842 2 16 = 1981 Stosunek trafień natomiast wynosi: 15842 2 1981 15842 2 0.9375 Analiza czasów i koncepcji, rozważania nt. problemów mogących wstąpić w realizacji kodu doprowadziły nas do wniosku, że procesory, które wykorzystują do obliczeń te same fragmenty tablic A i B (kolumny lub wiersze) powinny rozpoczynać pracę każdy od innego. Chodzi tutaj o zawartość w potencjalnie innej linii pamięci, co umożliwia komunikację i przesyłanie danej linii do Cache przez różne porty pamięci. Jest to konieczne dla umożliwienia jednoczesnego dla wszystkich procesorów dostępu do pamięci operacyjnej. 13
Ciekawym przypadkiem jest jest macierz wynikowa sumtab[][], której elementy są wynikiem mnożenia elementów macierzy A i B, a w konsekwencji dodawania sum częściowych. Liczba takich dostępów w celu inkrementacji wartości byłaby równa rozmiarowi macierzy, co staje się mało efektywne. Zatem optymalizując wykonanie mnożenia możemy przyjąć iż sumy częściowe będą przechowywane w rejestrach procesora - to doprowadza do jednokrotnej aktualizacji elementu macierzy wynikowej. To ułatwia pracę, bo elementy macierzy wynikowej przydzielone do danego procesora są jednokrotnie sprowadzane do Cache, i wykonywany jest na każdym z elementów linii jednokrotny odczyt i zapis. Dla każdego procesora przypada n2 = 1782 = 7921 elementów macierzy wynikowej. Liczba pobrań danych do Cache to 7921 = 496. Liczba trafień w takim 4 4 16 razie wynosi 2 7921 496 = 15346 (liczba odczytów + liczba zapisów - liczba pobrań). Natomiast stosunek pobrań to 15346 0.9687. 2 7921 Pozostaje nam do przeanalizowania stosunek trafień w stosunku do wszystkich macierzy, a ten wynosi: 15842 2 1981+7921 2 496 0.9479 15842 2+7921 2 14
3.2 Intel Parallel Studio Concurrency Analysis Zmiana kolejności iteracji pętli - optymalizacja dostępu do RAM Rysunek 3.1: Porównanie analiz dla metody wykorzystującej transpozycję (kolor jaśniejszy) i bez transpozycji macierzy B (kolor ciemny) dla różnych rozmiarów macierzy. Na powyższym rysunku widzimy bezpośrednie porównanie, z których możemy odczytać, że przewaga metody wykorzystującej transpozycję jest utrzymana. Przewaga wzrasta wraz ze wzrostem rozmiaru macierzy. Z wykresów przedstawiających oddzielnie poszczególne implementacje możemy odczytać średnie użycie procesora. Stąd dla transpozycji użycie średnie wynosi od 3.78 do 3.84 na rzecz jasna 4 rdzenie. Natomiast dla wersji bez transpozycji mamy spadek zużycia do poziomu 3.17 wraz ze wzrostem rozmiaru macierzy do n=500. //dodać dlaczego tak jest 15
Zmiana kolejności pętli Rysunek 3.2: Porównanie analiz dla metody wykorzystującej transpozycję (kolor jaśniejszy) i bez transpozycji macierzy B (kolor ciemny) dla różnych rozmiarów macierzy. Tutaj godny uwagi jest fakt, że koszt transpozycji macierzy B staje się bardzo wysoki jeśli weźmiemy pod uwagę, że mnożymy macierze małych rozmiarów - ten koszt oczywiście staje się niezauważalny dla większych macierzy, ale wówczas występuje strata w części zrównoleglonej (4 procesory wykonujące obliczenia na przydzielonego bloku). Z czym związany jest taki wzrost kosztu? Transponując macierz B, do uzyskania wyniku w jednej komórce macierzy ostatecznej, potrzebujemy jednego wiersza danych. Oczywiście, jak pisaliśmy wcześniej, rozmieszczenie danych w pamięci jest w tym wypadku bardzo korzystne. Systuacja się zmienia kiedy zamienimy pętle, przez co w konsekwencji pętla iterująca po wszystkich elementach takiego wiersza jest umieszczona na zewnątrz pętli iterującej po kolejnych wierszach. Skutek jest taki, że elementy macierzy wynikowej nie są generowane po kolei (stąd brak możliwości użycia sum częściowych) a jedynie uzupełniane aż obliczenia dla danej pozycji dobiegną końca. To wiąże się z nieoptymalnym wykorzystaniem pamięci i co za tym idzie odpowiednim kosztem. Natomiast korzystniej zamiana pętli wpływa na metodę beztranspozycyjną - po sobie wykorzystywane są elementy macierzy B leżące obok siebie w pobranej linii. Jednak 16
koszt wielokrotnego uzupełnienia wyniku pozostaje. Dlaczego koszt obliczeń na macierzy rozmiaru n=120 nie uległ pogorszeniu? Nie przypadkowo wybraliśmy taki rozmiar macierzy. W dziale 3.1 podaliśmy rozmiar macierzy który przy założonej obsłudze prowadziłby do optymalnego wykorzystania pamięci podręcznej procesorów. Rozmiar ten pozwala na wykorzystanie elementów macierzy B raz pobranych do pamięci podręcznej procesorów. Inaczej mówiąc - liczba obiektów macierzy i ich rozmiar jest mniejsza od wielkości pamięci podręcznej. Zatem wykorzystanie procesora dla metody z transpozycją wzrasta z uwagi na dłuższy czas obliczeń (związany z pobieraniem wielokrotnie dancych do pamięci podręcznej) dla coraz większych macierzy. Natomiast dla małych macierzy, poprzez koszt związany z samą transpozycją (transpozycja wykonywana sekwencyjnie na jednym wątku) to zużycie mieści się pomiędzy 1.0 a 2.5. Klasyczny algorytm wykorzystujący sumy częściowe Rysunek 3.3: Porównanie analiz dla metody wykorzystującej transpozycję (kolor jaśniejszy) i bez transpozycji macierzy B (kolor ciemny) dla różnych rozmiarów macierzy. Obecny rysunek przedstawia niemal takie same wyniki jak poprzedni (dla zamienionych pętli) z tą jednak ważna i oczywistą różnicą, że teraz trans- 17
pozycja jest lepszą metodą dla coraz większych rozmiarów macierzy. Brak zamiany pętli prowadzi do odwrócenia sytuacji. Jednak przyglądając się bliżej wynikom można dojść do jeszcze jednego wniosku. Otóż dla każdej metody czasy tutaj są nieco lepsze od odpowiadającym im czasom z rysunku 3.2. Nie ingerując w kolejność pętli wykorzystać sumy częściowe - jest to możliwe, ponieważ elementy macierzy wynikowej są obliczane w pełni po kolei. Threading Errors i Memory Errors Przeprowadziliśmy inspekcję pamięci, jednak ku uciesze naszych oczu znajwisko wycieku pamięci nie następowało. Podobnie sytuacja miała się z wątkami - Parallel Inspector nie wykrył żadnych błędów. 18
3.3 AMD CodeAnalyst Thread Profiling Zmiana kolejności iteracji pętli - optymalizacja dostępu do RAM Rysunek 3.4: Porównanie profili dla metody wykorzystującej transpozycję (u dołu) i bez transpozycji macierzy B (u góry) dla różnych rozmiarów macierzy. Widzimy, że nie w implementacji nie ustawiono maski powinowactwa - poszczególne wątki nie są na sztywno przypisane do konkretnych rdzeni. Przewaga transpozycji zostaje utrzymana. Sekcje tworzone w bloku zrównoleglonym na rysynkach przedstawiają poszczególne procesory. Rozpoczącie sekcji (wątków) jest niemal jednoczesne - oczekiwanie pozostałych procesorów są niewielkie - podobnie jak zakończenia. Świadczy to o tym, że każdy procesor ma mniej więcej tyle samo pracy do wykonania. Nie występują nielokalne dostępy do pamięci. 19
Zmiana kolejności pętli Rysunek 3.5: Porównanie profili dla metody wykorzystującej transpozycję (u dołu) i bez transpozycji macierzy B (u góry). Profilowanie satysfakcjonujące - w tej metodzie przewagę powinna uzyskać metoda beztranspozycyjna. cechy profilowania identyczne jak poprzednio. Brak nielokalnych, kosztownych dostępów do pamięci. Klasyczny algorytm wykorzystujący sumy częściowe Klasyczna metoda wykorzystująca sumy częściowe i transpozycję pozwala, jak widać bardzo szybko osiągnąć wynik. W profilach nie widać nic niepokojącego. Nie zidentyfikowano żadnych nielokalnych odwołań do pamięci. Na następnej stronie został zobrazowany efekt profilowania. 20
Rysunek 3.6: Porównanie profili dla metody wykorzystującej transpozycję (u dołu) i bez transpozycji macierzy B (u góry). Investigate Data Access Zmiana kolejności pętli oraz klasyczny algorytm wykorzystujący sumy częściowe 21
Rysunek 3.7: Badanie dostępów do danych w pamięci: A) metoda ze zmianą kolejności pętli, B) klasyczny algorytm; Najpierw metoda z bez transpozycji, zaraz pod nią metoda z transpozycją. 22
Rozdział 4 Badanie 4.1 Koszt współdzielenia i unieważniania danych 4.2 Niezamierzone współdzielenie 4.3 Przyspieszenie, efektywność, skalowalność 23
Rozdział 5 Podsumowanie 24