Programowanie równoległe i rozproszone – opracowanie lab nr 2
GPGPU
(ang. General-Purpose computing on Graphics Processor Units –
obliczenia ogólnego przeznaczenia na układach GPU) - technika,
dzięki której GPU, zwykle zajmujący się tylko obliczeniami
związanymi z grafiką komputerową, umożliwia wykonywanie obliczeń
ogólnego przeznaczenia tak jak CPU. Dzięki temu wiele obliczeń,
głównie obliczenia równoległe, można przeprowadzić znacznie
szybciej.
Procesor jest przede wszystkim jednostką wykonującą i podejmującą decyzje, zgodnie z zaleceniami oprogramowania.
Procesory wideo są znacznie lepsze w wykonywaniu rutynowych zadań. GPU mają duże ilości ALU w porównaniu do procesorów. W wyniku tego, mogą one wykonywać duże ilości zadań matematycznych w odróżnieniu od CPU (źródło: http://infocoin.pl/tech/teoria/cpu-vs-gpu/).
CPU
składa się z kilku rdzeni zoptymalizowanych pod sekwencyjne
szeregowe działanie, podczas gdy GPU ma w znacznym stopniu
równoległą architekturę składają się z tysięcy małych,
bardziej efektywnych rdzeni stworzonych do obsługi wielokrotnych
zadań równocześnie (za
http://www.nvidia.com/object/what-is-gpu-computing.html).
Aktualnie istnieją dwa kompleksowe środowiska do programowania procesorów GPU (ATI Stream, nVidia CUDA). Równolegle powstaje OpenCL — otwarty standard do obliczeń równoległych.
CUDA (ang. Compute Unified Device Architecture) - opracowana przez firmę Nvidia uniwersalna architektura procesorów wielordzeniowych (głównie kart graficznych) umożliwiająca wykorzystanie ich mocy obliczeniowej do rozwiązywania ogólnych problemów numerycznych w sposób wydajniejszy niż w tradycyjnych, sekwencyjnych procesorach ogólnego zastosowania.
CUDA
posiada oparte na języku programowania C środowisko programistyczne
wysokiego poziomu, w którego skład wchodzą m.in. specjalny
kompilator (nvcc), debugger (cuda-gdb), profiler oraz interfejs
programowania aplikacji. Istnieją biblioteki do Python, Fortran,
Java, C# oraz Matlab. Wspiera Windows, Linux oraz Mac Os X (od wersji
2.0).
Zalety i wady:
Zalety:
Język programowania oparty na językach C/C++, w tym pełna obsługa szablonów C++.
Model pamięci procesora ściśle odpowiadający architekturze sprzętowej, co umożliwia świadome, efektywne wykorzystywanie dostępnych zasobów GPU, w tym pamięci współdzielonej.
Kod uruchamiany na GPU może odczytywać i zapisywać dane z dowolnego adresu w pamięci GPU.
Pełna kompatybilność programów – napisany dziś program wykonywalny ma w przyszłości działać bez żadnych zmian na coraz wydajniejszych procesorach graficznych posiadających coraz większą liczbę rdzeni, rejestrów, pamięci operacyjnej i innych zasobów.
Dostępność na wszystkich kartach firmy NVIDIA począwszy od serii GeForce 8 w tym Quadro oraz Tesla.
Obsługa procesorów ARM (od wersji 6.5).
Wady:
CUDA korzysta z podzbioru języka C++. Nie można na przykład definiować statycznych zmiennych wewnątrz funkcji, a funkcje mogą mieć tylko stałą liczbę parametrów.
Dla liczb zmiennoprzecinkowych podwójnej precyzji (dostępnych w nowszych procesorach) istnieją pewne odstępstwa od standardu w zakresie zaokrąglania liczb.
Przepustowość i opóźnienia magistrali PCI-Express łączącej CPU i GPU mogą być wąskim gardłem w przypadku przesyłania dużej ilości danych.
CUDA jest dostępna jedynie dla kart graficznych produkowanych przez firmę NVIDIA
Jądra (ang. kernels) i wątki w CUDA
Współbieżne bloki aplikacji są wykonywane na urządzeniu jako jądra (kernels). Tylko jedno jądro może się wykonywać w danej chwili. Wiele wątków (tysiące) wykonuje to samo jądro. Różnice pomiędzy wątkami CUDA a CPU: - wątki CUDA są „bardzo lekkie” (bardzo krótki czas tworzenia, natychmiastowe przełączanie), - CUDA używa tysiące wątków do osiągnięcia wydajności, wielordzeniowe CPU może obsługiwać tylko kilka wątków.
Słowniczek: - urządzenie = GPU, - host = CPU, - kernel – funkcja uruchamiana na urządzeniu.
Jądro
CUDA jest wykonywane przez tablicę wątków. Każdy wątek uruchamia
ten sam kod. Każdy wątek posiada ID używany do obliczania adresów
pamięci oraz podejmowania decyzji.
Siatka (grid) bloków z wątkami
Jądro uruchamia siatkę bloków z wątkami. Wątki w obrębie bloku korzystają ze wspólnego obszaru pamięci oraz mogą być synchronizowane. Wątki w różnych blokach nie mogą współpracować.
Adres bloku w siatce może być dwuwymiarowy (jest strukturą z polami x oraz y). Adres wątku w bloku może być dwuwymiarowy (jest strukturą z polami x, y). Na powyższym rysunku adres jest jednowymiarowy — pola y, z są równe 1. Zatem jeżeli wątek operuje na wektorze 0, ..., N*M to numer elementu wektora może być obliczony przy pomocy wyrażenia – numer_wątku_w_bloku+rozmiar_bloku*numer_bloku.
Skalowalność
Sprzęt
może zaszeregować dowolny blok na dowolnym procesorze. Brak założeń
o kolejności wykonywania bloków.
Model pamięci w architekturze CUDA
Global
memory (odczyt i zapis) – wolna i niecache'owalna, host może
odczytywać i zapisywać do tej pamięci
Texture memory (tylko odczyt) – pamięć cache zooptymalizowana do wzorców z dostępem dwuwymiarowym
Constant memory – przechowywane są tam stałe oraz argumenty jądra, wolna ale cache'owalna
Shared memory – mała, szybka, współdzielona przez blok
Local memory – używana na wszystko, co się nie zmieściło w rejestrach wątku, część pamięci globalnej więc wolna i niecache'owalna
Registers – szybka, każdy wątek posiada swoje
CUDA SDK
Pliki źródłowe kompilowane za pomocą nvcc mogą zawierać kod hosta jak również kod urządzenia
nvcc rozdziela kod dla urządzenia oraz kod dla hosta
kod dla urządzenia kompiluje do postaci assemblerowej (PTX kod) i/lub binarnej postaci (obiekt cubin),
modyfikuje kod hosta zamieniając znaczniki <<<...>>> występujące przy wywoływaniu jądra na potrzebne funkcje środowiska CUDA ładujące i uruchamiające każde skompilowane jądro z kodu PTX i/lub obiektu cubin.
Zmodyfikowany plik hosta jest traktowany jako plik w języku C i jest zostawiany do kompilacji za pomocą innego narzędzia lub do skompilowania podczas ostatniego etapu kompilacji nvcc.
Aplikacja następnie może:
zlinkować do skompilowanego pliku hosta (najpopularniejszy przypadek)
lub zignorować zmodyfikowany plik hosta i użyć API sterownika CUDA w celu załadowania i wykonania kodu PTX lub obiektu cubin.
Wbudowane typy wektorów
int1, float2, double3, char4, … - typy wektorów zbudowane na podstawie typów całkowitych i zmiennoprzecinkowych. Możliwość stworzenia maksymalnie czterowymiarowego wektora, dostęp do składowych poprzez zmienne x, y, z i w. Generowanie: „make_<type name>”.
int2 make_int2(int x, int y); |
dim3 – wektor liczb całkowitych bazujący na typie uint3, który używany jest to określania wymiarów. Jeśli, któryś z elementów pozostawimy niezdefiniowany to domyślnie przyjmie on wartość 1.
dim3 grid( 512 ); // 512 x 1 x 1 dim3 block( 1024, 1024 ); // 1024 x 1024 x 1 |
Wbudowane zmienne
Wbudowane zmienne określają wymiary siatki oraz blok wątków i indeks bloku oraz wątku w bloku. Zmienne te dostępne są tylko w obrębie funkcji wykonywanych w urządzeniu (GPU).
gridDim – zmienna typu dim3, zawiera wymiary siatki.
blockIdx – zmienna typu uint3, zawiera indeks bloku w siatce.
blockDim – zmienna typu dim3, zawiera wymiary bloku.
threadIdx – zmienna typu uint3, zawiera indeks wątku w bloku.
int ThreadIDX= threadIdx.x+blockDim.x*blockIdx.x; |
Wykonywanie kodu na GPU
Jądra są funkcjami języka C z pewnymi ograniczeniami:
Nie mogą odwoływać się do pamięci CPU.
Muszą zwracać void jako wynik.
Nie mogą przyjmować zmiennej liczby argumentów.
Nie mogą być rekurencyjne.
Nie mogą deklarować zmiennych statycznych.
Parametry są kopiowane automatycznie z CPU na GPU. Kod jąder umieszcza się w plikach
.cu.
Deklarowane z kwalifikatorem __global__ oznaczającym funkcję wywoływaną
z CPU i wykonującą się na GPU. Inne kwalifikatory to:
__device__ oznacza funkcję wywoływaną z GPU i wykonującą się na GPU.
__host__ oznacza funkcję wywoływaną z CPU w wykonującą się na CPU (domyślnie)
__device__ i __host__ mogą być łączone.
__global__ i __host__ nie mogą być łączone.
Przykład deklaracji:
__global__ void doSomethingOnDevice(...) { …. } |
Wywołanie jądra
Wywoływanie jądra odbywa się w podobny sposób do wywołania zwykłej funkcji w C, z tym że dodatkowo musimy zadeklarować wymiar siatki, wymiar każdego bloku wątków i opcjonalnie liczbę bajtów w przestrzeni wspólnej, która jest dynamicznie alokowana przez blok dodatkowo do już przydzielonej statycznie pamięci (domyślnie 0) używając nawiasów <<< … , …[, …] >>>.
int blocksize=256; dim3 dimBlock (blocksize); dim3 dimGrid( ceil( N / (float)blocksize) ); inc_gpu<<<dimGrid, dimBlock>>>(d_a, N); |
Kwalifikatory typów zmiennych w CUDA
Deklaracja zmiennej |
Pamięć |
Zasięg |
Czas życia taki jak |
__device__ __local__ int LocalVar; |
lokalna |
wątek |
wątek |
__device__ __shared__ int SharedVar; |
współdzielona |
blok |
blok |
__device__ int GlobalVar; |
globalna |
siatka bloków |
aplikacja |
__device__ __constant__ int ConstantVar; |
stała |
siatka bloków |
aplikacja |
Opcja __device__ jest opcjonalna, kiedy używana jest z __local__, __shared__ lub __constant__.
Zmienne bez żadnego kwalifikatora zapisywane są w rejestrach wątku (skalary oraz wbudowane typy wektorów). Te dane, które nie zmieszczą się w rejestrach lub tablice zapisywane są do pamięci lokalnej.
Kiedy deklarujemy zmienną w pamięci współdzielonej jako zewnętrzną tablice w następujący sposób:
extern __shared__ float shared[]; |
rozmiar tablicy określany jest podczas uruchamiania jądra (parametr trzeci wywołania jądra). Wszystkie zmienne zadeklarowane w ten sposób mają początek w tym samym adresie w pamięci.
__global__ void kernel(…) { … extern __shared__ float sData[]; … } int main(void) { … smBytes = blockSize*sizeof(float); kernel<<<nBlocks, blockSize, smBytes>>>(…); … } |
Wskaźniki mogą wskazywać tylko na pamięć za alokowaną lub zadeklarowaną w pamięci globalnej GPU.
Gdzie
deklarować zmienne?
Alokacja pamięci GPU
CPU zarządza pamięcią GPU:
cudaMalloc(void ** pointer, size_t nbytes)
cudaMemset(void * pointer, int value, size_t count)
cudaFree(void* pointer)
int n = 1024; int nbytes = 1024*sizeof(int); // Wskaźnik na pamięć GPU; int * d_a = 0; // cudaMalloc( (void**)&d_a, nbytes ); // Próba odwołania się do pamięci wskazywanej przez d_a // w kodzie CPU prowadzi do błędu ochrony pamięci cudaMemset( d_a, 0, nbytes); cudaFree(d_a); |
Kopiowanie danych
cudaMemcpy(void *dst, void *src, size_t nbytes,enum cudaMemcpyKind direction); |
„Direction” określa lokalizację (urządzenie lub host) zmiennych src lub dst.
Blokuje wątek CPU – wznawia wątek jak wszystkie dane zostaną przekopiowane.
Nie rozpoczyna kopiowania, dopóki wcześniejsze wywołania CUDA nie zostały skończone.
enum cudaMemcpyKind
cudaMemcpyHostToDevice
cudaMemcpyDeviceToHost
cudaMemcpyDeviceToDevice
Funkcja cudaMemcpyAsync() nie blokuje kodu CPU
Synchronizacja CPU z GPU
Wywołania jąder są asynchroniczne
Sterowanie powraca od razu do CPU bez oczekiwania na zakończenie wątków jądra.
Jądro jest kolejkowane i wykona się po zakończeniu wszystkich poprzednich wywołań CUDA.
Funkcja cudaMemcpy jest synchroniczna
Kopiowanie rozpocznie się po wykonaniu poprzednich wywołań CUDA.
Sterowanie powraca do CPU po wykonaniu kopii.
Kopie pomiędzy pamięciami GPU są asynchroniczne.
Funkcja cudaThreadSynchronize() wstrzymuje kod CPU do momentu wykonania wszystkich kolejkowanych wywołań CUDA.
Synchronizacja wątków w bloku
CUDA umożliwia współpracę wątków w ramach bloku. Do tego celu służy funkcja void _syncthreads().
Synchronizacja barierowa: każdy wątek w bloku oczekuje, aż wszystkie inne wątki osiągną punkt wywołania funkcji _syncthreads().
Bariera dotyczy wyłącznie wątków w bloku, nie w siatce. Tylko wątki w obrębie danego bloku mogą korzystać z pamięci współdzielonej.
Stosowana w celu uniknięcia wyścigów przy dostępie do pamięci wspólnej.
Jeżeli występuje w kodzie warunkowym np. instrukcja if, to musimy zadbać aby wszystkie wątki ją wywołały.
Atomowe operacje w kodzie GPU
Operacje atomowe na danych typu int/unsigned w pamięci globalnej.
add, sub, min, max
and, or, xor
inc, dec
atomicAdd() |
Kody błędów CUDA
Wszystkie funkcje API, z wyjątkiem wywołań jąder zwracają kody błędów.
Wyrażenie typu cudaError_t
Funkcja cudaError_t cudaGetLastError(void) zwraca kod ostatniego błędu
char* cudaGetErrorString(cudaError_t code) zwraca ciąg znaków zakończony zerem zawierający opis błędu w języku angielskim
printf(“%s\n”, cudaGetErrorString( cudaGetLastError() ) ); |
Przykładowe biblioteki CUDA
CUBLAS — podzbiór bibliotek BLAS, operacje ma macierzach i wektorach
CUFFT — szybka transformata Fourier'a (jednowymiarowa i dwuwymiarowa)
Do zadania:
Filtracja obrazów — filtracja obrazów jest zaliczana do metod cyfrowego przetwarzania sygnałów. Filtracja jest operacją matematyczną na pikselach obrazu źródłowego, w której wyniku uzyskiwany jest nowy, przekształcony obraz. Filtrację określa się jako przekształcenie kontekstowe, gdyż dla wyznaczenia nowej wartości piksela obrazu docelowego potrzebna jest informacja z wielu pikseli obrazu źródłowego.
Filtracja stosowana jest przeważnie jako metoda wydobycia z oryginalnego obrazu szeregu informacji w celu ich dalszej obróbki. Informacjami takimi mogą być: położenie krawędzi, pozycje rogów obiektów itp. Innym zastosowaniem filtracji jest usuwanie szumów (filtr medianowy i inne) lub rozmycie obrazu (filtry uśredniające, Gaussa). Filtrację można przeprowadzać zarówno w dziedzinie przestrzennej, jak i częstotliwościowej. Filtracje w dziedzinie przestrzennej uzyskuje się wykorzystując operacje splotu. W dziedzinie częstotliwości odpowiednikiem splotu jest operacja mnożenia transformat obrazu i filtru.
Filtry dolnoprzepustowe. Działanie tego typu filtrów opiera się na usuwaniu elementów obrazu o wysokiej częstotliwości i przepuszczaniu elementów o niskiej częstotliwości. Ponieważ większość szumów występujących w obrazach zawiera się w wysokich częstotliwościach, filtry te przeważnie wykorzystuje się właśnie do eliminacji zakłóceń.
Filtry górnoprzepustowe. Filtry te działają w sposób odwrotny do filtrów dolnoprzepustowych, tłumią one nisko częstotliwościowe elementy obrazu, wzmacniają natomiast elementy o wysokich częstotliwościach (szczegóły). Wynikiem działania tego typu filtrów jest podkreślenie, uwypuklenie elementów obrazu o dużej częstotliwości poprzez zwiększenie ich jasności, koloru itp. Dla obrazu jako całości efektem jest zazwyczaj zwiększenie kontrastu poprzez podkreślenie ostrych krawędzi obiektów.
OpenCV – biblioteka funkcji wykorzystywanych podczas obróbki obrazu, oparta na otwartym kodzie i zapoczątkowana przez Intela. Biblioteka ta jest wieloplatformowa, można z niej korzystać w Mac OS X, Windows, jak i Linux. Autorzy jej skupiają się na przetwarzaniu obrazu w czasie rzeczywistym.
Pytania prowadzącego:
jakie mamy rodzaje pamięci w CUDA?
co to jest i jak działa Kernel?
dlaczego w obecnych czasach zauważamy wzrost zainteresowania obliczeniami w CUDA?
rozwiń skrót CUDA
jakie zasięgi mają pamięci?
co potrzebujesz żeby programować w CUDA?
jakie mamy kompilatory?