Rafał Toboła nr albumu 149151 CUDA - Compute Unified Device Architecture Wykorzystanie kart graficznych firmy NVIDIA do obliczeń Wrocław, 13 maja 2009 Spis treści 1. Wstęp..................................... 1 2. CUDA SDK.................................. 4 2.1. Struktura katalogów........................... 4 2.2. CUDA SDK Konfiguracja........................ 4 3. Przykładowy kod.............................. 5 3.1. Szkielet aplikacji............................. 5 3.2. Dodawanie wektorów.......................... 5 3.3. Dodawanie macierzy........................... 7 3.4. Dodawanie macierzy inaczej....................... 8 3.5. Akceleracja Matlaba przy pomocy CUDA............... 8 4. Podsumowanie................................ 9 Literatura..................................... 9 1. Wstęp W ostatnim czasie karty graficzne zdominowały pod względem mocy obliczeniowej jednostki CPU. Od czasu kiedy przestano tworzyć osobne wyspecjalizowane jednostki jak Vertex Shader, czy Pixel Shader, a zaczęto projektować układy, które mogą pełnić każdą z tych funkcji, procesory graficzne (GPU - Graphical Processor Unit) stały się bardziej uniwersalne. Możliwe stało się implementowanie na nich dowolnych działań matematycznych. Obecne karty graficzne posiadają już nawet 1024 rdzenie na których mogą być wykonywane równolegle obliczenia. Jeżeli weźmiemy pod uwagę to, że karty graficzne taktowane są zegarami, np. 800M Hz, to widzimy, że jednostka czterordzeniowa z zegarem 4GHz nie ma tutaj najmniejszych szans. Ponadto, karty graficzne posiadają bardzo szybki dostęp do dużej ilości pamięci, co także znacznie przyspiesza obliczenia. Porównanie prędkości standardowych procesorów rodziny x86 oraz kart graficznych NVIDIA pod względem mocy obliczeniowej jak i prędkości dostępu do pamięci, przedstawione zostało na rysunkach 1, 2. Na rysunkach 3 oraz 4 pokazano strukturę
1 WSTĘP wewnętrzną procesora CPU oraz GPU. Jak widać w jednostkach obliczeniowych kart graficznych, na przetwarzanie danych poświęca się więcej tranzystorów niż w tradycyjnych procesorach. Rysunek 1. Porównanie mocy obliczeniowej procesorów rodziny x86 oraz kart graficznych firmy NVIDIA Rysunek 2. Porównanie prędkości dostępu do pamięci dla procesorów i kart graficzncych 2
1 WSTĘP Rysunek 3. Struktura wewnętrzna CPU Rysunek 4. Struktura wewnętrzna GPU 3
2 CUDA SDK 2. CUDA SDK Jeśli chcemy rozpocząć naszą przygodę z interfejsem programistycznym CUDA, nic prostszego. NVIDIA daje nam CUDA SDK, które po zainstalowaniu daje nam dostęp do wielu przykładowych aplikacji o otwartym kodzie źródłowym, dokumentacji (w przypadku bardziej skomplikowanych algorytmów) oraz wielu materiałów na stronie internetowej CUDA Zone [2]. Po zainstalowaniu CUDA SDK otrzymujemy strukturę katalogu jak poniżej. 2.1. Struktura katalogów ENVIRONMENT Makefile ReleaseNotes.html bin/ common/ doc/ lib/ projects/ releasenotesdata/ tools/ Widzimy tutaj plik Makefile. Po uruchomieniu go, skompilują nam się wszystkie przykładowe aplikacjie. Pliki wykonywalne powstaną w katalogu bin. Źródła przykładowych programów znajdują się w katalogu projects. 2.2. CUDA SDK Konfiguracja Interesującym katalogiem jest katalog common, to tutaj znajduje się plik common.mk, który jest dołączany do wszystkich plików Makefile. W nim ustawione są wszystkie ścieżki dostępu do kompilatorów 1, bibliotek oraz plików nagłówkowych. common/ Makefile Makefile paramgl Makefile rendercheckgl common.mk cutil readme.txt inc/ lib/ obj/ src/ 1 Jeżeli posiadamy kompilator GNU GCC w wersji 4.4.0, to prawdopodobnie nie będzie on chciał współpracować z naszym CUDA SDK. Jednym ze sposobów rozwiązania tego problemu jest podlinkowanie starszej wersji kompilatora, np. do NVIDIA CUDA SDK/bin, a następnie zmienienie ścieżki do NVCC na następującą: NVCC := $(CUDA INSTALL PATH)/bin/nvcc --compiler-bindir ŚCIEŻKA DO NVIDIA CUDA SDK/bin. 4
3 PRZYKŁADOWY KOD 3. Przykładowy kod Poniżej postaram omówić się podstawy programowania przy użyciu interfejsu programistycznego CUDA. 3.1. Szkielet aplikacji Szkielet aplikacji przedstawiono na listingu 1. Widzimy na nim definicję jądra (ang. kernel), czyli funkcji która jest wykonywana na karcie graficznej. Poprzedzona ona jest kwalifikatorem global, co oznacza że jest ona uruchamiana na urządzeniu, ale może być wywołana zarówno po stronie urządzenia jak i hosta. Innymi kwalifikatorami są device, który oznacza że funkcja jest zarówno uruchamiana, jak i wywoływana po stronie urządzenia oraz host, który mówi nam, że funkcja będzie wywoływana i uruchamiana po stronie hosta. Funkcje, które są wykonywane po stronie hosta posiadają kilka ograniczeń, m. in. nie wspierają rekurencji, w ich ciele nie możemy definiować zmiennych statycznych, nie mogą mieć zmiennej liczby parametrów oraz funkcje oznaczone jako global muszą zwracać typ void. Dodatkowo CUDA wprowadzają nowy element do składni języka C. Jest nim operator <<<Dg, Db, Ns, S>>>, gdzie Dg typu dim3, określa rozmiar siatki bloków w których ma zostać uruchomiona nasza funkcja; Db typu dim3, określa rozmiar każdego bloku, Db.x * Db.y *Db.z, mówi nam ile wątków zostanie uruchomionych w danym bloku; Ns typu size t, parametr opcjonalny (nie będzie przez nas używany); S typu cudastream t, j.w. Listing 1. Szkielet aplikacji 1 // Kernel d e f i n i t i o n 2 g l o b a l void 3 samplefnct ( f loat A, float B, float C) { 4 5 int main ( ) { 6 // Kernel i n v o c a t i o n 7 samplefnct <<<1, N>>>(A, B, C) ; 8 3.2. Dodawanie wektorów Poniżej na listingu 2 przedstawiono sposób w jaki zaimplementowane może być dodawanie wektorów na karcie graficznej. Jest to przykład ogólny, bez alokacji pamięci na karcie graficznej. Przykład, który możemy skompilować przedstawiono na listingu 3. Listing 2. Dodawanie wektorów 1 g l o b a l void 2 vecadd ( f loat A, f loat B, float C) { 3 int i = threadidx. x ; 4 C[ i ] = A[ i ] + B[ i ] ; 5
3.2 Dodawanie wektorów 3 PRZYKŁADOWY KOD 5 6 int main ( ) { 7 // Kernel i n v o c a t i o n 8 vecadd<<<1, N>>>(A, B, C) ; 9 W lini 9 widzimy definicję naszej funkcji, która ma zostać uruchomiona na karcie graficznej. Zmienna threadidx jest zmienną globalną typu uint3. Posiada ona pola x, y, z, które dają nam dostęp do różnych wątków. Dzięki nim możemy operować na wektorach, macierzach i bardziej skomplikowanych strukturach. Szybko możemy zauważyć konieczność alokowania pamięci zarówno po stronie hosta, jak i urządzenia (karty graficznej). Wszystko co dzieje się po stronie hosta jest programowane przy pomocy standardowych poleceń dobrze nam znanego języka C. Alokacja pamięci na urządzeniu odbywa się poprzez polecenie cudamalloc((void**), int). Pierwszym argumentem tego polecenia jest wskaźnik na miejsce pod którym mamy zaalokować pamięć, a drugim jej rozmiar. Aby przenieść dane z hosta na urządzenie oraz odwrotnie, używamy odpowiednio funkcji cudamemcpy. Jej pierwszym parametrem jest wskaźnik do miejsca gdzie przenosimy dane, drugim wskaźnik do miejsca z którego przenosimy dane, a trzecim opcja, która określa czy przenosimy dane z hosta na urządzenie, czy odwrotnie. Najlepiej obrazuje to listing 3 w liniach 39, 44 i 51. Wywołanie naszej funkcji znajduje się w lini 48. Jak widać uruchamiamy funkcję w jendym bloku o rozmiarze czterech wątków. Listing 3. Dodawanie wektorów kod praktyczny 1 #include <s t d l i b. h> 2 #include <s t d i o. h> 3 #include <s t r i n g. h> 4 #include <math. h> 5 #include < c u t i l i n l i n e. h> 6 #include <iostream > 7 8 // Kernel 9 g l o b a l void 10 samplefnct ( f loat A, float B, float C) { 11 const unsigned int i = threadidx. x ; 12 C[ i ] = A[ i ] + B[ i ] ; 13 14 15 int main ( int argc, char argv ) 16 { 17 // bedziemy dodawali 4 elementy typu f l o a t 18 int N=4 sizeof ( f loat ) ; 19 // miejsce na 2 wektory do dodania oraz na wynik 20 f loat A=0, B=0, C=0; 21 // alokowanie pamieci na h o s c i e 22 f loat h i d a t a = ( float ) malloc (N) ; 23 // alokowanie pamieci na wynik na h o s c i e 24 f loat h odata = ( float ) malloc (N) ; 25 6
3.3 Dodawanie macierzy 3 PRZYKŁADOWY KOD 26 // jako k a r t e g r a f i c z n a z k t o r e j bedziemy k o r z y s t a c, 27 // ustawiamy n a j s z y b s z a dostepna 28 cudasetdevice ( cutgetmaxgflopsdeviceid ( ) ) ; 29 30 // alokujemy pamiec na urzadzeniu 31 cudamalloc ( ( void ) &A, N) ; 32 cudamalloc ( ( void ) &B, N) ; 33 cudamalloc ( ( void ) &C, N) ; 34 35 // i n i c j a l i z u j e m y wektory A i B 36 // poczatkowymi wartosciami 37 for ( unsigned int i = 0 ; i < N; ++i ) 38 h i d a t a [ i ] = ( float ) i ; 39 c u t i l S a f e C a l l ( cudamemcpy ( A, h idata, N, 40 cudamemcpyhosttodevice ) ) ; 41 42 for ( unsigned int i = 0 ; i < N; ++i ) 43 h i d a t a [ i ] = ( float ) i i ; 44 c u t i l S a f e C a l l ( cudamemcpy ( B, h idata, N, 45 cudamemcpyhosttodevice ) ) ; 46 47 // wywolanie n a s z e j f u n k c j i 48 samplefnct <<<1, 4>>>(A, B, C) ; 49 50 // kopiujemy wynik z urzadzenia 51 c u t i l S a f e C a l l ( cudamemcpy ( h odata, C, N, 52 cudamemcpydevicetohost ) ) ; 53 54 // wyswietlamy wynik 55 for ( int i =0 ; i <4 ; ++i ) 56 p r i n t f ( %f \n, h odata [ i ] ) ; 57 58 cudathreadexit ( ) ; 59 3.3. Dodawanie macierzy Na listingu 4 przedstawiono schematycznie sposób na dodanie do siebie dwóch macierzy. Jak widać funkcja matadd() uruchamiana jest w jednym bloku o wymiarze N N wątków. Każdy odpowiadający sobie element macierzy dodawany jest w osobnym wątku. Listing 4. Dodawanie macierzy 1 g l o b a l void matadd( 2 f loat A[N ] [ N], 3 f loat B[N ] [ N], 4 f loat C[N ] [ N] 5 ) { 6 int i = threadidx. x ; 7 int j = threadidx. y ; 8 C[ i ] [ j ] = A[ i ] [ j ] + B[ i ] [ j ] ; 9 7
3.4 Dodawanie macierzy inaczej 3 PRZYKŁADOWY KOD 10 int main ( ) { 11 // Kernel i n v o c a t i o n 12 dim3 dimblock (N, N) ; 13 matadd<<<1, dimblock>>>(a, B, C) ; 14 3.4. Dodawanie macierzy inaczej Na listingu 5 przedstawiono inny sposób dodawania macierzy. Tutaj funkcję matadd() uruchamiamy w kilku blokach (w zależności od wymiarów macierzy). Listing 5. Dodawanie macierzy 2 1 g l o b a l void matadd( 2 f loat A[N ] [ N], 3 f loat B[N ] [ N], 4 f loat C[N ] [ N] 5 ) { 6 int i = blockidx. x blockdim. x + threadidx. x ; 7 int j = blockidx. y blockdim. y + threadidx. y ; 8 i f ( i < N && j < N) 9 C[ i ] [ j ] = A[ i ] [ j ] + B[ i ] [ j ] ; 10 11 int main ( ) { 12 // Kernel i n v o c a t i o n 13 dim3 dimblock (16, 1 6 ) ; 14 dim3 dimgrid ( (N + dimblock. x 1) / dimblock. x, 15 (N + dimblock. y 1) / dimblock. y ) ; 16 matadd<<<dimgrid, dimblock>>>(a, B, C) ; 17 3.5. Akceleracja Matlaba przy pomocy CUDA Jeśli chcemy napisać funkcję do programu Matlab, która korzysta z dobrodziejstw oferowanych nam przez interfejs programistyczny CUDA, to nie możemy zapomnieć o trzech sprawach: 1. dołączeniu pliku mex.h, 2. zaimplementowaniu funkcji mexfunction(int nlhs, mxarray *plhs[],int nrhs, const mxarray *prhs[]), 3. używaniu wyłącznie typu mxarray (matlabowe macierze, wektory, napisy, itp.). Opis parametrów, jakie przyjmuje funkcja mexfunction znajduje się poniżej. nlhs = liczba oczekiwanych danych (mxarrays) wejściowych (Left Hand Side) plhs = tablica wksaźników do oczekiwanych wyjść nrhs = liczba oczekiwanych wejść (Right Hand Size) prhs = tablica wskaźników do danych wejśćiowych (tylko do odczytu). 8
Literatura Aby skompilować nasze rozszerzenie, w powłoce matlaba wpisujemy >> mex jakas funkcja.c. Powstaną nam trzy wersje plików (dla różnych platform): square me.mexw32 (Windows 32 bit), square me.mexglx (Linux 32 bit), square me.mexa64 (Linux 64 bit). 4. Podsumowanie Na stronie [2] znajduje się wiele materiałów, przykładowych aplikacji, kodów źródłowych, które mogą być bardzo pomocne na początku naszej przygody z obliczeniami na kartach graficznych. Instalacja sterowników do karty graficznej oraz CUDA SDK znajduje się również na stronie producenta, np. dla CUDA 2.2 oraz systemu linux patrz [3]. Literatura [1] Strona główna producenta, www.nvidia.com. [2] CUDA Zone, www.nvidia.com/cuda. [3] Getting Started, NVIDIA CUDA 2.2 Installation and Verification on Linux, http://developer.download.nvidia.com/compute/cuda/2 2/toolkit/docs/ CUDA Getting Started 2.2 Linux.pdf. [4] NVIDIA CUDA Compute Unified Device Architecture Programming Guide. [5] Accelerating MATLAB with CUDA TM Using MEX Files. 9