Programowanie równoległe Wprowadzenie do programowania GPU Rafał Skinderowicz
CPU Fetch/ Decode ALU (Execute) Data cache (a big one) Execution Context Out-of-order control logic Fancy branch predictor Memory pre-fetcher Rysunek : Źródło: Introduction to GPU Architecture Ofer Rosenberg, AMD Rozbudowane układy dekodowania i predykcji instrukcji Duża pamięć cache
CPU Celem CPU jest jak najszybsze wykonanie danego strumienia instrukcji Pamięć cache oraz układy predykcji rozgałęzień mają na celu redukcję czasu oczekiwania na dane potrzebne do obliczeń Rozbudowana potokowość (ang. pipelining) wykonywania instrukcji Zmiana kolejności wykonania rozkazów (ang. out-of-order execution)
Potokowość Instr. No. 1 2 3 4 5 Clock Cycle Pipeline Stage IF ID EX MEM WB IF ID EX MEM WB IF ID EX MEM WB IF ID EX MEM IF ID EX 1 2 3 4 5 6 7 Uproszczony schemat potokowego wykonania instrukcji. Pobranie instrukcji z pamięci ang. instruction fetch (IF); Zdekodowanie instrukcji ang. instruction decode (ID); Wykonanie instrukcji ang. execute (EX); Dostęp do pamięci ang. memory access (MEM); Zapisanie wyników działania instrukcji ang. store; write back (WB)
W kierunku GPU Fetch/ Decode ALU (Execute) Execution Context Usuwamy układy odpowiedzialne za szybkie wykonanie potoku instrukcji redukując istotnie rozmiar rdzenia. Rysunek : Źródło: Introduction to GPU Architecture Ofer Rosenberg, AMD
W kierunku GPU Fetch/ Decode ALU (Execute) Execution Context Usuwamy układy odpowiedzialne za szybkie wykonanie potoku instrukcji redukując istotnie rozmiar rdzenia. Rysunek : Źródło: Introduction to GPU Architecture Ofer Rosenberg, AMD Większość tranzystorów we współczesnych CPU to pamięć cache
W kierunku GPU Rysunek : Źródło: Introduction to GPU Architecture Ofer Rosenberg, AMD Uproszczona budowa obniża koszty pozwalając na użycie większej ich liczby 16 rdzeni to 16 jednoczesnych strumieni instrukcji
W kierunku GPU Fetch/ Decode ALU 1 ALU 2 ALU 3 ALU 4 ALU 5 ALU 6 ALU 7 ALU 8 Ctx Ctx Ctx Ctx Ctx Ctx Ctx Ctx Shared Ctx Data Rysunek : Źródło: Introduction to GPU Architecture Ofer Rosenberg, AMD Jeżeli rdzenie będą wykonywać te same instrukcje, ale na różnych danych to mogą dzielić układy pobierania i dekodowania rozkazów Przetwarzanie typu SIMD single instruction, multiple data Problem: oczekiwanie na dane wstrzymuje wszystkie ALU duże opóźnienie wykonania instrukcji
Ukrywanie opóźnień Fetch/ Decode ALU 1 ALU 2 ALU 3 ALU 4 ALU 5 ALU 6 ALU 7 ALU 8 Fetch/ Decode ALU 1 ALU 2 ALU 3 ALU 4 ALU 5 ALU 6 ALU 7 ALU 8 Wspólna pamięć 128 KB Rysunek : Źródło: Introduction to GPU Architecture Ofer Rosenberg, AMD Rysunek : Źródło: Introduction to GPU Architecture Ofer Rosenberg, AMD W pamięci można przechowywać kontekst obliczeniowy dla wielu niezależnych strumieni instrukcji i przełączać się między nimi w miarę potrzeby zwiększając przepustowość
Przykład 16 rdzeni 8 mul-add ALU na rdzeń (128 łącznie) 16 niezależnych strumieni instrukcji 64 współbieżne strumienie instrukcji (metoda przeplotu) 512 współbieżnych kontekstów Moc 256 GFLOPs (przy 1GHz) Rysunek : Źródło: Introduction to GPU Architecture Ofer Rosenberg, AMD
Podsumowanie Główne idee architektury GPU to: Wiele prostych rdzeni obliczeniowych umożliwiających równoległe obliczenia Rdzenie z wieloma ALU umożliwiającymi obliczenia typu SIMD Przeplatane wykonanie wielu grup instrukcji na jednym rdzeniu, żeby ukryć opóźnienia
Podsumowanie CPU Thread 1 Thread 2 Thread 3 Thread 4 Lower Latencies GPU Thread 1 Thread 2 Thread 3 Thread 4 Higher Throughput Time Time
NVidia Fermi SM NVIDIA GeForce GTX 580 (architektura Fermi) Fetch/ Decode Fetch/ Decode Execution contexts (128 KB) ALU - 16 na procesor strumieniowy (1 instr. MUL-ADD na cykl) * Rdzeń zawiera 32 jednostki ALU * Dwa strumienie instrukcji są wykonywane na cykl * Maksymalnie 48 współbieżnych strumieni (osnów) * Maksymalnie 1536 indywidualnych kontekstów obliczeniowych = 1536 wątków CUDA Shared memory (16+48 KB) Source: Fermi Compute Architecture Whitepaper CUDA Programming Guide 3.1, Appendix G
NVidia Fermi GTX 580 16 rdzeni SM pozwala na współbieżne wykonanie 24576 wątków CUDA Rysunek : Źródło: Introduction to GPU Architecture Ofer Rosenberg, AMD
Model obliczeń CUDA Program na hoście zleca wykonanie obliczeń na GPU w postaci kerneli Kod kernela wykonywany jest przez wątki tworzące kratę (ang. grid) Wątki kraty wykonywane są w blokach każdy blok na jednym multiprocesorze (SM) Wątki bloku wykonywane są w grupach, tzw. osnowach (ang. warp) po 32 Host Kernel 1 Kernel 2 Device Grid 2 Block (1, 1) Thread (0,0,0) Thread (0,1,0) Grid 1 Block (0, 0) Block (0, 1) (0,0,1) (1,0,1) (2,0,1) (3,0,1) Thread (1,0,0) Thread (1,1,0) Block (1, 0) Block (1, 1) Thread Thread (2,0,0) (3,0,0) Thread Thread (2,1,0) (3,1,0) Block (2, 0) Block (2, 1)
Przykład dodawanie wektorów 1 // Kernel odpowiedzialny za obliczenia 2 void vecadd(float *A, float *B, float *C, int N) { 3 for(int i = 0; i < N; i++) 4 C[i] = A[i] + B[i]; 5 } 6 int main() { 7 int N = 4096; 8 // Alokacja pamięci 9 float *A = (float *)malloc(sizeof(float)*n); 10 float *B = (float *)malloc(sizeof(float)*n); 11 float *C = (float *)malloc(sizeof(float)*n); 12 // Wprowadzenie danych wej. 13 init(a); init(b); 14 // Wywołanie kernela 15 vecadd(a, B, C, N); 16 // Zwolnienie pamięci 17 free(a); free(b); free(c); 18 }
Przykład dodawanie wektorów na GPU 1 // Kernel CUDA obliczający sumę wektorów 2 global 3 void gpuvecadd(float *A, float *B, float *C) { 4 int tid = blockidx.x * blockdim.x + threadidx.x; 5 C[tid] = A[tid] + B[tid]; 6 } blockidx.x threadidx.x GRID BLOCK (0,0) (0,0) (1,0) (2,0)... (31,0) blockdim.x = 32 BLOCK (1,0) (0,0) (1,0) (2,0)... (31,0)... tid = blockidx.x * blockdim.x + threadidx.x Rysunek : Obliczanie globalnego identyfikatora wątku w kernelu CUDA
Przykład dodawanie wektorów na GPU 1 int main() { 2 int N = 4096; 3 float *A = (float *)malloc(sizeof(float)*n); 4 float *B = (float *)malloc(sizeof(float)*n); 5 float *C = (float *)malloc(sizeof(float)*n); 6 init(a); // Inicjuj dane wejściowe 7 init(b);
Przykład dodawanie wektorów na GPU 1 // c.d. 2 // Alokacja buforów w pamięci GPU 3 float *d_a, *d_b, *d_c; 4 cudamalloc(&d_a, sizeof(float)*n); 5 cudamalloc(&d_b, sizeof(float)*n); 6 cudamalloc(&d_c, sizeof(float)*n); 7 8 // Kopiowanie danych do pamięci GPU 9 cudamemcpy(d_a, A, sizeof(float)*n, 10 cudamemcpyhosttodevice); 11 cudamemcpy(d_b, B, sizeof(float)*n, 12 cudamemcpyhosttodevice); Kierunek kopiowania: cudamemcpyhosttodevice z pam. hosta (RAM) do pam. GPU cudamemcpydevicetohost z pam. urządzenia do głównej
Przykład dodawanie wektorów na GPU 1 // c.d. 2 // Ustalenie podziału obliczeń na wątki 3 dim3 dimblock(32,1); // Rozmiar bloku 4 dim3 dimgrid(n/32,1); // Rozmiar kraty 5 // Uruchomienie kernela 6 gpuvecadd <<< dimblock,dimgrid >>> (d_a, d_b, d_c); 7 // Kopiowanie wyników do pamięci głównej 8 cudamemcpy(c, d_c, sizeof(float)*n, cudamemcpydevicetohost); 9 // Zwolnienie zasobów 10 cudafree(d_a); 11 cudafree(d_b); cudafree(d_c); 12 free(a); free(b); free(c); 13 }
Dodawanie wektorów porównanie kerneli 1 // Kernel CPU 2 void vecadd(float *A, float *B, float *C, int N) { 3 for(int i = 0; i < N; i++) 4 C[i] = A[i] + B[i]; 5 } 6 7 // Kernel CUDA 8 global 9 void gpuvecadd(float *A, float *B, float *C) { 10 int i = blockidx.x * blockdim.x + threadidx.x; 11 C[i] = A[i] + B[i]; 12 } 13 14 // Kernel OpenCL 15 kernel 16 void gpuvecadd( global float *A, global float *B, global float *C) 17 { 18 int i = get_global_id(0); 19 C[i] = A[i] + B[i]; 20 }
Dodawanie wektorów porównanie kerneli Uwaga: w przypadku gdy rozmiar wektora jest mniejszy niż liczba wątków konieczne jest sprawdzanie zakresu 1 // Kernel OpenCL 2 kernel 3 void gpuvecadd( global float *A, global float *B, global float *C, 4 int N) 5 { 6 int i = get_global_id(0); 7 if (i < N) { 8 C[i] = A[i] + B[i]; 9 } 10 }
Przykład dodawanie macierzy Dodawanie macierzy A oraz B o wymiarach N M a 11 a 12... a 1m a 21 a 22... a 2m A = a 31 a 32... a 3m...... a n1 a n2... a nm b 11 b 12... b 1m b 21 b 22... b 2m B = b 31 b 32... b 3m...... b n1 b n2... b nm C = A + B = [a ij + b ij ] dla wszystkich i, j.
Dodawanie macierzy porównanie kerneli 1 // Kernel CPU, N - liczba wierszy, M - liczba kolumn 2 void vecadd(float *A, float *B, float *C, int N, int M) { 3 for(int i = 0; i < N; i++) 4 for(int j = 0; j < M; j++) 5 C[i * M + j] = A[i * M + j] + B[i * M + j]; 6 } 7 // Kernel CUDA 8 global 9 void gpuvecadd(float *A, float *B, float *C) { 10 int i = blockidx.y * blockdim.y + threadidx.y; 11 int j = blockidx.x * blockdim.x + threadidx.x; 12 C[i * M + j] = A[i * M + j] + B[i * M + j]; 13 } 14 // Kernel OpenCL 15 kernel 16 void gpuvecadd( global float *A, global float *B, global float *C, 17 int M, int N) 18 { 19 int j = get_global_id(0); 20 int i = get_global_id(1); 21 C[i * M + j] = A[i * M + j] + B[i * M + j]; 22 }
Organizacja obliczeń Wybór liczby wymiarów przestrzeni indeksowania (1D, 2D lub 3D) zależy zazwyczaj od natury danych i wybranego algorytmu W przypadku przetwarzania danych 2D naturalne jest umieszczenie indeksów wątków w przestrzeni 2D
Organizacja obliczeń 16 16 blocks Rysunek : Przykład pokrycia tablicy 76x62 grupami po 16x16 wątków (Źródło: David B. Kirk and Wen-mei W. Hwu, Programming Massively Parallel Processors A Hands-on Approach) 80 64 wątki będą przetwarzać tablicę o wymiarach 76 62 Konieczne jest sprawdzanie, czy wątek odwołuje się do elementu w dozwolonym zakresie, np. if (get_global_id(0)< 62) W 408 ( 8%) przypadkach wątki nie wykonają użytecznej pracy
Organizacja obliczeń Ponieważ do kernela można przekazywać dane w postaci tablic jednowymiarowych, to dane wielowymiarowe muszą być serializowane Tablicę dwuwymiarową można zapisać w tablicy jednowymiarowej wiersz po wierszu (row-major) lub kolumna za kolumną (column-major) M 0,0 M 0,1 M 0,2 M 0,3 M 1,0 M 1,1 M 1,2 M 1,3 M 2,0 M 2,1 M 2,2 M 2,3 M M 3,0 M 3,1 M 3,2 M 3,3 M 0,0 M 0,1 M 0,2 M 0,3 M 1,0 M 1,1 M 1,2 M 1,3 M 2,0 M 2,1 M 2,2 M 2,3 M 3,0 M 3,1 M 3,2 M 3,3 M M 0 M 1 M 2 M 3 M 4 M 5 M 6 M 7 M 8 M 9 M 10 M 11 M 12 M 13 M 14 M 15 Rysunek : Serializacja macierzy wiersz po wierszu M[row][column] M[row * width + column]
Organizacja obliczeń M 0,0 M 0,1 M 0,2 M 0,3 M 1,0 M 1,1 M 1,2 M 1,3 M 2,0 M 2,1 M 2,2 M 2,3 M M 3,0 M 3,1 M 3,2 M 3,3 M 0,0 M 1,0 M 2,0 M 3,0 M 0,1 M 1,1 M 2,1 M 3,1 M 0,2 M 1,2 M 2,2 M 3,2 M 0,3 M 1,3 M 2,3 M 3,3 M M 0 M 1 M 2 M 3 M 4 M 5 M 6 M 7 M 8 M 9 M 10 M 11 M 12 M 13 M 14 M 15 Rysunek : Serializacja macierzy kolumna po kolumnie M[row][column] M[column * width + row]
Organizacja obliczeń Wątki dzielone są na bloki (grupy), z których każdy wykonuje się na pojedynczym multiprocesorze (PE) Maksymalny rozmiar bloku jest określony w wersji standardu CUDA / OpenCL wspieranej przez urządzenie np. 512/1024 (Compute Capability 1.x / 2.x-3.x) Maksymalny rozmiar bloku w każdym wymiarze (x, y i z) jest również ograniczony, przy czym x y z nie może przekroczyć rozmiaru bloku Wątki w bloku nie mogą używać więcej niż 8k/16k/32k rejestrów (Compute 1.0,1.1/1.2,1.3/2.x) Blok nie może użyć więcej niż 16kb/48kb współdzielonej pamięci (Compute 1.x/2.x)
Organizacja obliczeń Uwzględniając ograniczenia duży rozmiar bloku może ograniczyć wydajność, np. ze względu na zapotrzebowanie na pamięć Zbyt mały rozmiar bloku również jest niekorzystny niewystarczające wykorzystanie mocy obliczeniowej ze względu na to, że PE pracują w modelu SIMD/SIMT
Organizacja obliczeń wewnątrz bloku W kartach Nvidia 32 wątki bloku tworzą tzw. osnowę (ang. warp) wykonywane są w modelu SIMD/SIMT (ang. single instruction multiple threads) W kartach AMD odpowiednikiem osnowy jest tzw. wavefront złożony zazwyczaj z 64 wątków (w starszych modelach 16 lub 32) W procesorach ogólnego przeznaczenia rozmiar osnowy może zależeć od instrukcji kernela
Organizacja obliczeń wewnątrz bloku Rozmiar bloku powinien być wielokrotnością rozmiaru osnowy Jeżeli rozmiar osnowy wynosi 32, a bloku 16 to tracimy 50% mocy obliczeniowej Rozmiar osnowy nie jest ustandaryzowany, jednak udostępniany przez środowisko uruchomieniowe: CUDA CL_DEVICE_WARP_SIZE_NV OpenCL CL_KERNEL_PREFERRED_WORK_GROUP_SIZE_MULTIPLE
Organizacja obliczeń wewnątrz bloku 1 size_t preferredsizemultiple; 2 clgetkernelworkgroupinfo(kernel, device, 3 CL_KERNEL_PREFERRED_WORK_GROUP_SIZE_MULTIPLE, 4 sizeof(preferredsizemultiple), 5 &preferredsizemultiple, 6 NULL); 1 const size_t N =...; // Rozmiar danych do przetworzenia 2 size_t local_work_size[] = { preferredsizemultiple }; 3 // global_work_size[0] musi być wielokrotnością local_work_size[0] 4 size_t global_work_size[] = { 5 (size_t)ceil(n / (float)local_work_size[0]) * local_work_size[0] 6 }; Uwaga! Liczba wątków może być większa niż rozmiar danych
Organizacja obliczeń wewnątrz bloku 1 const size_t N =...; // Rozmiar danych do przetworzenia 2 size_t local_work_size[] = { preferredsizemultiple }; 3 // global_work_size[0] musi być wielokrotnością local_work_size[0] 4 size_t global_work_size[] = { 5 (size_t)ceil(n / (float)local_work_size[0]) * local_work_size[0] 6 }; Jeżeli N = 1234, preferredsizemultiple = 32 to global_work_size[0] = 39 * 32 = 1248 Wymaga to uwzględnienia w kodzie kernela, np. instrukcja if do sprawdzenia, czy nie odwołujemy się do elementów poza dopuszczalnym zakresem
Synchronizacja wątków Wątki wewnątrz grupy / bloku mogą być synchronizowane Wątki należące do różnych grup nie mogą być synchronizowane Jeżeli bariera synchronizacyjna umieszczona jest w bloku instrukcji warunkowej, to albo dla wszystkich wątków warunek jest spełniony, albo dla żadnego nie jest W przeciwnym razie program zakleszczy się Wątek 0 Wątek 1 Wątek 3 Wątek 4 Wątek 5 Wątek n-3 Wątek n-2 Wątek n-1 Czas
Synchronizacja wątków Synchronizacja wątków w OpenCL realizowana jest za pomocą funkcji void barrier (cl_mem_fence_flags flags), gdzie flags może przyjmować wartości: CLK_LOCAL_MEM_FENCE zapewnia, że zmiany w pamięci lokalnej dokonane przez wątki zostaną uszeregowane i wątki zobaczą spójny stan pamięci lokalnej CLK_GLOBAL_MEM_FENCE zapewnia, że wszystkie zapisy do pamięci głównej (obiektów pamięci, obrazów) zostaną wykonane i wątki zobaczą jej spójny stan Wywołanie funkcji barrier w różnych miejscach kernela oznacza odrębne bariery synchronizacyjne
Ukrywanie opóźnień Wiele uwagi zarówno w architekturze GPU, jak i środowisku programistycznym i wykonawczym poświęcono problemowi ukrywania opóźnień wynikających z powolnych operacji, takich jak odczyt i zapis do pamięci, barier synchronizacyjnych Jednym ze sposobów jest wykonywanie metodą przeplotu większej liczby wątków niż wynika to z liczby jednostek przetwarzających
Ukrywanie opóźnień Pojedynczy SM w architekturze Nvidia Fermi może wykonywać współbieżnie: do 48 osnów (warps) 32 wątki = 1536 wątków do 8 bloków jednocześnie SM wyposażony jest w 2 planistów (ang. warp scheduler) SM może jednocześnie wykonywać 2 grupy (osnowy, ang. warp) po 32 wątków Jak widać liczba wątków znacząco przekracza liczbę rdzeni CUDA (jednostek ALU/FP) Rysunek : Schemat SM w architekturze Nvidia Fermi
Ukrywanie opóźnień Duża liczba wątków konieczna jest aby ukryć opóźnienia Jeżeli wątki w osnowie muszą czekać na zakończenie wykonywania wolnej instrukcji, to w tym czasie wybierana jest inna osnowa, która gotowa jest do wykonania Jeżeli gotowych jest więcej, to wybór dokonywany jest na podstawie priorytetów Przełączanie pomiędzy osnowami nie powoduje opóźnień, jest to tzw. zero-overhead thread scheduling
Ukrywanie opóźnień przykład Pożądane jest by liczba bloków i wątków była blisko granic sprzętowych W przypadku architektury Nvidia Fermi mamy maks. 8 bloków (po maks. 1024 wątki), ale nie więcej niż 1536 wątków na SM Rozmiar bloku Wątki na blok Liczba bloków Wątki razem 8x8 64 8 512 16x16 256 6 1536 32x32 1024 1 1024
Ukrywanie opóźnień pamięć Nawet duża liczba bloków i wątków nie zawsze jest w stanie ukryć wszystkich opóźnień, szczególnie wynikających z operacji na pamięci globalnej Opóźnienie w dostępie do pamięci globalnej może sięgać setek cykli, stąd należy redukować liczbę operacji na pamięci, np. przez cacheowanie danych zapewnić ich uporządkowanie łączone odczyty danych z pamięci głównej
Hierarchia pamięci GPU Hierarchia pamięci na przykładzie Nvidia Fermi Fermi Memory Hierarchy Review SM-0 SM-1 SM-N Fermi Chip Registers L1 SMEM Registers L1 SMEM Registers L1 SMEM L2 Global Memory NVIDIA Corporation 2011
Pamięć GPU opóźnienia W architekturze Nvidia Fermi: rejestry mają łączną przepustowość ok. 8TB/sek. (maks. 63 rejestry 32 bitowe na kernel) pamięć współdzielona / L1 (64KB) ma przepustowość łączną ok 1.6TB/sek. i bardzo niskie opóźnienie (10-20 cykli) pamięć globalna ma przepustowość do 177GB/sek. i opóźnienie rzędu 400-800 cykli Jak widać, im mniej operacji na pamięci globalnej, tym lepiej
Optymalizacja dostępu do pamięci na przykładzie Przyjrzymy się wzorcom dostępu do pamięci w programie mnożącym macierze Złożoność standardowego algorytmu mnożenia to O(n 3 ), dla porównania: Algorytm Strassena ma złożoność O(n 2.807355 ) Najlepszy znany algorytm (2014) autorstwa François Le Gall ma złożoność O(n 2.3728639 )
Mnożenie macierzy wersja CPU A B Rysunek : Matrix multiplication diagram, autor Bilou 1 /* 2 A, B, C - tablice 2D o rozmiarze size x size 3 */ 4 for (int i = 0; i < size; ++i) { 5 for (int j = 0; j < size; ++j) { 6 sum = 0; 7 for (int k = 0; k < size; ++k) { 8 sum += A[i][k] * B[k][j]; 9 } 10 C[i][j] = sum; 11 } 12 }
Mnożenie macierzy wersja CPU A B Rysunek : Matrix multiplication diagram, autor Bilou 1 /* 2 Wersja dla macierzy zapisanych w tablicach 1D 3 A, B, C - tablice 1D o długości size x size 4 */ 5 for (size_t i = 0; i < size; ++i) { 6 for (size_t j = 0; j < size; ++j) { 7 int sum = 0; 8 for (size_t k = 0; k < size; ++k) { 9 sum += A[i*size + k] * B[k*size + j]; 10 } 11 C[i*size + j] = sum; 12 } 13 }
Optymalizacja dostępu do pamięci Kernel OpenCL obliczający iloczyn macierzy 1 kernel void matrix_multiply(int size, 2 global float *A, 3 global float *B, 4 global float *C) { 5 const int col = get_global_id(0); 6 const int row = get_global_id(1); 7 if (row < size && col < size) { 8 float sum = 0; 9 for (int i = 0; i < size; ++i) { 10 sum += A[row * size + i] * B[i * size + col]; 11 } 12 C[row * size + col] = sum; 13 } 14 }
Analiza dostępu do pamięci Za większość operacji na pamięci głównej odpowiedzialny jest fragment: 1 for (int i = 0; i < size; ++i) { 2 sum += A[row * size + i] * B[i * size + col]; 3 } W każdej iteracji pętli wykonywane jest 1 mnożenie, 1 dodawanie i 2 odczyty z tablic, odpowiednio, A oraz B Stosunek liczby instrukcji obliczeń do liczby instrukcji dostępu do pamięci wynosi 1:1 Nazywany również compute to global memory access (CGMA) ratio
Analiza dostępu do pamięci CGMA ma kluczowy wpływ na wydajność obliczeń GPU Zakładając przepustowość pamięci na poziomie 200GB/sek. pozwala to załadować 200GB/4B = 50Giga liczb typu float Przy CGMA = 1.0 kernel może wykonać co najwyżej 50 GFLOPS operacji na sekundę znacznie mniej, niż szczytowa moc obliczeniowa (np. 1500 GFLOPS) W celu poprawy wydajności należy zwiększyć wartość CGMA, tj. więcej obliczeń na daną liczbę odczytów/zapisów pamięci głównej
Optymalizacja dostępu do pamięci Zauważmy, że wątki odwołują się w części do tych samych danych B 0,0 B 0,1 B 1,0 B 1,1 B 2,0 B 2,1 B 3,0 B 3,1 A 0,0 A 0,1 A 0,2 A 0,3 C 0,0 C 0,1 C 0,2 C 0,3 A 1,0 A 1,1 A 1,2 A 1,3 C 1,0 C 1,1 C 1,2 C 1,3 C 2,0 C 2,1 C 2,2 C 2,3 C 3,0 C 3,1 C 3,2 C 3,3 4 wątki wykonują 4 (4 + 4) = 32 odczyty pamięci, przy czym różnych jest jedynie 16
Optymalizacja dostępu do pamięci Jeżeli udałoby się zmusić wątki do współpracy i wykorzystania raz załadowanych danych to transfer danych zmniejszyłby się o połowę Rozwiązaniem jest podzielenie obliczeń na małe porcje 2 2 Każdy wątek ładuje element do pomocniczych tablic N oraz M umieszczonych w pamięci lokalnej B 0,0 B 0,1 B 1,0 B 1,1 B 2,0 B 2,1 B 3,0 B 3,1 A 0,0 A 0,1 A 0,2 A 0,3 C 0,0 C 0,1 C 0,2 C 0,3 A 1,0 A 1,1 A 1,2 A 1,3 C 1,0 C 1,1 C 1,2 C 1,3 C 2,0 C 2,1 C 2,2 C 2,3 C 3,0 C 3,1 C 3,2 C 3,3
Optymalizacja dostępu do pamięci Faza I: wątek (0,0) wykonuje N 0,0 = A 0,0 M 0,0 = B 0,0 wątek (1,0) wykonuje N 1,0 = A 1,0 M 1,0 = B 1,0... Faza II: wątek (0,0) wykonuje N 0,0 = A 0,0+2 M 0,0 = B 0+2,0 wątek (1,0) wykonuje N 1,0 = A 1,0+2 M 1,0 = B 1+2,0... A 0,0 A 0,1 A 0,2 A 0,3 B 0,0 B 0,1 B 1,0 B 1,1 B 2,0 B 2,1 B 3,0 B 3,1 C 0,0 C 0,1 C 0,2 C 0,3 A 1,0 A 1,1 A 1,2 A 1,3 C 1,0 C 1,1 C 1,2 C 1,3 C 2,0 C 2,1 C 2,2 C 2,3 C 3,0 C 3,1 C 3,2 C 3,3
Optymalizacja dostępu do pamięci Wartość elementu C 0,0 jest sumą (N 0,0 M 0,0 + N 0,1 M 1,0 ) I faza + (N 0,0 M 0,0 + N 0,1 M 1,0 ) II faza Analogicznie dla pozostałych elementów Dzięki podzieleniu obliczeń na kafelki (bloki) poprawiamy lokalność odwołań Oczywiście, można stosować kafelki o rozmiarze większym od 2 2, np. 16 16
Optymalizacja dostępu do pamięci 1 #define TILE_WIDTH 16 2 int col = get_global_id(0); 3 int row = get_global_id(1); 4 local float M[TILE_WIDTH][TILE_WIDTH]; 5 local float N[TILE_WIDTH][TILE_WIDTH]; 6 // Współrzędne wątku wew. kafelka / bloku 7 int tx = get_local_id(0); 8 int ty = get_local_id(1); 9 float sum = 0; 10 for (int m = 0; m < ceil(size / (float)tile_width); ++m) { 11 // Wspólne ładowanie macierzy do tablic pomocniczych 12 M[ty][tx] = A[row * size + m * TILE_WIDTH + tx]; 13 N[ty][tx] = = B[(m * TILE_WIDTH + ty) * size + col]; 14 barrier(clk_local_mem_fence); 15 for (int k = 0; k < TILE_WIDTH; ++k) { 16 sum += M[ty][k] * N[k][tx]; 17 } 18 barrier(clk_local_mem_fence); 19 } 20 C[row * size + col] = sum;
Optymalizacja dostępu do pamięci Kafelki o rozmiarach 16 16 pozwalają zmniejszyć ilość odczytywanych danych 16 razy Liczba obliczeń pozostaje taka sama, czyli CGMA rośnie z 1 do 16 Przy 200GB/sek pozwala to wykonać 200GB/4B = 50Giga x 16 = 800GFLOPS
Optymalizacja wzorców dostępu do pamięci Wątki wewnątrz warpów wykonują jednocześnie odczyt / zapis do pamięci (SIMD) 32 wątki 32 adresy Jeżeli jeden czeka, to wszystkie czekają W zależności od adresów odczyt może być wykonany w jednej transakcji na pamięci albo wielu
Wzorce dostępu do pamięci globalnej Pamięć globalna wątki Łączony (ang. coalesced) dostęp do pamięci Dane odczytywane są segmentami (ang. chunk) nawet jeżeli ptrzebujemy tylko jeden element (słowo)
Wzorce dostępu do pamięci globalnej Pamięć globalna wątki Łączony (ang. coalesced) dostęp do pamięci Rozłączny dostęp do pamięci
Łączony dostęp do pamięci 1 // Przykład łączonego dostępu do pamięci 2 sum += data[ get_global_id(0) ]; 1 // Przykład niełączonego dostępu 2 sum += data[ get_global_id(0) * 4 ];
Wzorce dostępu do pamięci globalnej W architekturze Nvidia Fermi dostępne są dwa rodzaje odczytów: Buforowany (ang. cached) tryb domyślny: próba odczytu z L1, następnie L2, następnie pam. globalnej dane odczytywane są w 128 bajtowych porcjach (32 x 4B) wiersz pam. podręcznej Nie buforowane: próba odczytu z L2, następnie z pam. globalnej dane odczytywane są w porcjach po 32 bajty Jeden rodzaj zapisu do pam. globalnej unieważnienie L1 zapis do L2
Przykładowe wzorce odczytu 32 wątki odczytują kolejno 4-bajtowe słowa Adresy mieszczą się w 1 wierszu pam. podręcznej 128 bajtów przesyłanych magistralą Addresses from a warp... 0 32 64 96 128 160 192 224 256 288 320 352 384 416 448 Memory addresses
Przykładowe wzorce odczytu 32 wątki odczytują 4-bajtowe słowa, adresy wymieszane Adresy mieszczą się w 1 wierszu pam. podręcznej 128 bajtów przesyłanych magistralą addresses from a warp... 0 32 64 96 128 160 192 224 256 288 320 352 384 416 448 Memory addresses
Przykładowe wzorce odczytu 32 wątki odczytują 4-bajtowe słowa, kolejne adresy Adresy mieszczą się w 2 wierszach pam. podręcznej 256 bajtów przesyłanych magistralą (50% transferu użyteczne) addresses from a warp... 0 32 64 96 128 160 192 224 256 288 320 352 384 416 448 Memory addresses
Przykładowe wzorce odczytu 32 wątki odczytują to samo 4-bajtowe słowo Adres mieści się w 1 wierszu pam. podręcznej 128 bajtów przesyłanych magistralą (4/128 = 3.125% transferu użyteczne) addresses from a warp... 0 32 64 96 128 160 192 224 256 288 320 352 384 416 448 Memory addresses
Przykładowe wzorce odczytu 32 wątki odczytują 4 bajtowe słowa pod różnymi, rozproszonymi adresami Adresy mieszczą się w N wierszach pam. podręcznej N 128 bajtów przesyłanych magistralą z czego 128/(N 128) użyteczne addresses from a warp... 0 32 64 96 128 160 192 224 256 288 320 352 384 416 448 Memory addresses
Pamięć współdzielona konflikty Pamięć współdzielona podzielona jest na banki W pojedynczym cyklu można odczytać słowo z każdego banku W przypadku gdy kilka wątków próbuje odczytać dane z tego samego banku następuje konflikt żądania odczytu są szeregowane Bank 0 Bank 1 Bank 2 Bank 3 Bank 4 Bank 5 Bank 6 Bank 7 Bank 15
Pamięć współdzielona konflikty Thread 0 Thread 1 Thread 2 Thread 3 Thread 4 Thread 5 Thread 6 Thread 7 Bank 0 Bank 1 Bank 2 Bank 3 Bank 4 Bank 5 Bank 6 Bank 7 Thread 0 Thread 1 Thread 2 Thread 3 Thread 4 Thread 5 Thread 6 Thread 7 Bank 0 Bank 1 Bank 2 Bank 3 Bank 4 Bank 5 Bank 6 Bank 7 Thread 15 Bank 15 Thread 15 Bank 15 Jeżeli każdy wątek próbuje odczytać dane z innego banku nie ma konfliktu
Pamięć współdzielona konflikty Thread 0 Thread 1 Thread 2 Thread 3 Thread 4 Thread 8 Thread 9 Thread 10 Thread 11 Bank 0 Bank 1 Bank 2 Bank 3 Bank 4 Bank 5 Bank 6 Bank 7 Bank 15 Thread 0 Thread 1 Thread 2 Thread 3 Thread 4 Thread 5 Thread 6 Thread 7 Thread 15 x8 x8 Bank 0 Bank 1 Bank 2 Bank 7 Bank 8 Bank 9 Bank 15 Przykłady konfliktów 2-drożnego oraz 8-drożnego
Pamięć współdzielona konflikty W celu wykrycia konfliktów można skorzystać z profilera Nvidia Visual Profiler AMD CodeXL Usunięcie konfliktów wymaga zmiany kolejności operacji na pamięci współdzielonej lub umieszczenia danych w pamięci w taki sposób, by nie zachodził konflikt