Programowanie aplikacji równoległych i rozproszonych Dr inż. Krzysztof Rojek krojek@icis.pcz.pl Instytut Informatyki Teoretycznej i Stosowanej Politechnika Częstochowska
Strumienie operacji na GPU Domyślne API: Kernele wykonują się asynchronicznie względem CPU Transfery danych (D2H, H2D) blokują CPU Funkcje CUDA wykonują się synchronicznie względem siebie Strumień sekwencja operacji wykonywanych w uporządkowanej kolejności (issiue-order) Operacje przydzielone do różnych strumieni mogą nachodzić na siebie w czasie Kernele i transfery danych mogą wykonywać się jednocześnie jeżeli są przydzielone do różnych strumieni Strumienie i funkcje asynchroniczne umożliwiają: Asynchroniczne transfery danych (D2H, H2D) względem CPU Możliwość wykonywania kilku kerneli i transferów danych równolegle
Alokacja pamięci bez stronicowania Pamięć bez stronicowania (pinned (non-pageable) memory, page locked memory) umożliwia asynchroniczne kopiowanie danych względem CPU i GPU Zarządzanie pamięcią: cudahostalloc / cudafreehost Zamiast new / delete Alokacja odbywa się z poziomu CPU cudahostalloc jest zazwyczaj bardzo kosztowną operacją
Jednoczesny transfer danych z obliczeniami Wymagania: Transfery D2H lub H2D muszą odbywać się z wykorzystaniem pamięci bez stronicowania Urządzenie musi wspierać compute capability 1.1 Kernel i transfery danych muszą być przydzielone do różnych, niezerowych strumieni Przykład kodu: cudastream_t stream1, stream2; cudastreamcreate(&stream1); cudastreamcreate(&stream2); cudamemcpyasync( dst, src, size, dir, stream1 ); kernel<<<grid, block, 0, stream2>>>( ); cudastreamdestroy(stream1); cudastreamdestroy(stream2);
Techniki optymalizacji kodu dla GPU Podstawowe czynniki ograniczające wydajność kodu dla GPU Dostęp do pamięci globalnej karty graficznej (memory optimization) Instrukcje arytmetyczno-logiczne (instruction optimization) Konfiguracja obliczeń (configuration optimization) Podstawowe sposoby poprawy wydajności: Dokonanie odpowiednich pomiarów w celu zlokalizowania wąskich gardeł kodu: Efektywnej przepustowości do pamięci Zajętości karty graficznej Transferu danych między pamięcią hosta a pamięcią GPU Liczby przetwarzanych instrukcji w czasie Iteracyjne eliminowanie wąskich gardeł
Optymalizacje dostępu do pamięci Niezbędne, gdy: kod jest ograniczony dostępem do pamięci uzyskana przepustowość do pamięci jest dużo niższa niż przepustowość maksymalna Podstawowe sposoby optymalizacji: Pobieranie tylko tych danych, które są absolutnie niezbędne Redukcja wielokrotnego dostępu do tych samych danych (wykorzystanie pamięci wspólnej) Zapewnienie dostępu do danych w trybie łączonym (coalescing memory access) Zapewnienie dostępu wątkom w obrębie jednego warpa do ciągłego obszaru w pamięci Wątki w obrębie jednego warpa powinny odwoływać się do danych o rozmiarze będącym wielokrotnością 128B linijki pamięci L1 cache (np. 1 wątek pobiera 1 element typu float) Należy zapewnić dopasowanie adresu dla każdego warpa do granicy 128B
Wyłączenie cacheowania dostępów do pamięci globalnej Domyślnie wszystkie odwołania do pamięci globalnej karty graficznej są cacheowane w pamięci L1 i L2 Wyłączenie cacheowania w pamięci L1 może być wykonane flagą -Xptxas -dlcm=cg kompilatora nvcc Przy wyłączonym cachowaniu, kryteria uzyskania dostępu do danych w trybie łączonym (coalescing) są takie same, ale rozmiar dostępu do danych może być zredukowany do 32B
Odwołania do pamięci globalnej w trybie łączonym Transpozycja danych (Array Of Structures na Structure Of Arrays) struct Data { int a, b, c; }; Data AOS[10]; //a0 b0 c0 a1 b1 c1... struct Data { int a[10], b[10], c[10]; }; Data SOA; //a0 a1 a2 b0 b1 c2... Padding zapewnienie wyrównania adresów w każdym wierszu macierzy int size = 200; int align = 128; int realsize = (size*sizeof(float)+align-1)/align*align; float tab[size*realsize]; Wykorzystanie pamięci wspólnej do zredukowania odwołań do pamięci globalnej Wykorzystanie pamięci textur dla danych, które nie mogą być dopasowane
Zajętość karty graficznej Zajętość karty graficznej (occupancy): stosunek aktywnych warpów na multiprocesor strumieniowy (SM) do maksymalnej liczby warpów wspieranej przez architekturę (dla Fermi: 48) Zajętość karty jest ograniczona przez architekturę GPU (rejestry, pamięć wspólną, ) Im większa zajętość tym więcej można ukryć opóźnień wynikających z zależności między instrukcjami lub dostępu do pamięci
Zwiększanie zajętości GPU Informacje dotyczące wykorzystania zasobów karty graficznej: Na etapie kompilacji: -Xptxas -v Na etapie wykonania: CUDA Visual profiler (nvvp) profiler zainstalowany lokalnie lub połączenie ssh -Y Wyznaczenie zajętości GPU może zostać dokonane za pomocą kalkulatora NVIDII: CUDA Occupancy calculator Ograniczenie wykorzystania rejestrów: Flaga --maxrregcount=n dla kompilatora nvcc Definiowanie kernela z wykorzystaniem launch_bounds - informacje dla kompilatora, które mogą przyczynić się do zmniejszenia zapotrzebowania na rejestry global void launch_bounds (x, y) kernel(); x rozmiar bloku y liczba jednocześnie ładowanych bloków
Optymalizacje instrukcji (1/2) Redukcja liczby wykonywanych instrukcji Eliminacja automatycznej konwersji pomiędzy typami double i float (literały x.y są domyślnie typu double, x są domyślnie typu int); float a; a=a+1.0; // rzutowanie a na double, sumowanie double+double, rzutowanie sumy na float float a; a=a+1.0f; // sumowanie float+float Operacje dzielenia i modulo są bardzo kosztowne: Dzielenie przez 2^n można zastąpić: >> n Modulo 2^n można zastąpić & (2^n 1) Dwa typy funkcji matematycznych: func(): wolniejsza ale bardziej dokładna func(): szybsza ale mniej dokładna Flaga -use_fast_math: wymuszenie by każda funkcja func() była traktowana jako func()
Optymalizacje instrukcji (2/2) Rozbieżne rozgałęzienia (divergent branches): Występują, gdy wątki w obrębie tego samego warpa wykonują różną ścieżkę instrukcji np. if (threadidx.x > 2) {...} else {...} Każde z takich rozgałęzień wykonywane jest sekwencyjnie Przykład uniknięcia rozbieżnych rozgałęzień: if (threadidx.x / WARP_SIZE > 2) {...} else {...} Pomiar liczby rozbieżnych rozgałęzień podczas wykonania algorytmu a.out: nvprof --events divergent_branch./a.out
Transfer pomiędzy CPU i GPU Transfer danych pomiędzy hostem i urządzeiem ma dużo mniejszą przepustowość niż pamięć globalna GPU 8 GB/s PCIe 2.0, 16 GB/s PCIe 3.0, do 288 GB/s NVIDIA GeForce GTX Titan Minimalizacja transferu: Dane tymczasowe przechowywać bezpośrednio w pamięci GPU Czasem lepszym rozwiązaniem jest kilkukrotne wykonanie tych samych obliczeń, niż przechowywanie ich w pamięci hosta Grupowanie transferów Jeden duży transfer jest znacznie bardziej wydajny niż wiele małych: dla 10 mikrosekund opóźnienia i przepustowości 8 GB/s opóźnienie zajmuje więcej czasu niż transfer danych o rozmiarze < 80 KB
Pytania?