Programowanie na platformie CUDA
Dziesięć lat temu kolejne generacje procesorów charakteryzowały się wykładniczo rosnącą częstotliwością taktowania. Teraz ta sytuacja uległa zmianie. Obecnie to liczba rdzeni w jednym procesorze zaczyna rosnąć wykładniczo. W użytku są już procesory firmy Intel dla zwyczajnych PC-tów mające 8 rdzeni, a co jakiś czas pojawiają się informacje o tym, że niedługo zostanie wyprodukowany procesor o 50 rdzeniach...
Niestety, pisanie programu, który wykorzystuje w pełni moc rdzeni, nie jest dla programisty łatwym zadaniem. Jest to spowodowane dość uciążliwymi metodami synchronizacji wielu wątków i używanym modelem pamięci, który jest bardziej przystosowany do programowania jednowątkowego.
Okazuje się, że w większości współczesnych komputerów znajduje się drugi układ scalony, który od początku był projektowany do obliczeń równoległych. Chodzi o kartę graficzną. Zwyczajowo karta graficzna ma za zadanie obliczyć wartości koloru pojedynczych pikseli na ekranie. Widać, że wyniki poszczególnych obliczeń są niezależne. W tym artykule chciałbym przybliżyć platformę CUDA, która służy do programowania na kartach graficznych firmy NVIDIA. O tym, że warto zastanowić się nad programowaniem na karcie graficznej, może świadczyć następujące porównanie:
- jeden z najlepszych obecnie procesorów Intela – Core i7 980X – kosztuje ok. 1000 dolarów i osiąga moc obliczeniową ok. 100 Gflopsów;
- jedna z najlepszych kart graficznych NVIDIA – GeForce GTX 580 – kosztuje ok. 500 dolarów i oferuje moc obliczeniową ok. 1500 Gflopsów.
Struktura karty graficznej
Karta graficzna składa się (w uproszczeniu) z multiprocesorów, przy czym jeden multiprocesor składa się z 8 lub 16 pojedynczych procesorów i jednej niewielkiej współdzielonej pamięci na cały multiprocesor (pamięć multiprocesora jest szybka, umożliwia jednoczesny odczyt/zapis), oraz jednej dużej pamięci, która jest wspólna dla wszystkich multiprocesorów. Pamięć ta jest o rząd wielkości wolniejsza od pamięci multiprocesora, umożliwia także jednoczesny odczyt/zapis.
Standardowo program używający do obliczeń karty graficznej będzie działał według następującego schematu: skopiowanie danych z pamięci komputera do pamięci głównej karty graficznej i dalej do pamięci multiprocesora; wykonanie obliczenia na multiprocesorach; skopiowanie częściowych wyników z pamięci multiprocesora do pamięci głównej karty graficznej i skopiowanie końcowego wyniku do pamięci komputera.
Struktura obliczeń
A teraz ciekawe pytanie: w jaki sposób programista rozdziela zadania między multiprocesory i procesory? Przypuśćmy, że chcemy wykonać jakieś duże obliczenie Programista dzieli je na zbiór mniejszych obliczeń: Pojedynczy element obliczenia to blok, który jest z kolei złożony z pojedynczych wątków:
Specyfikacja techniczna platformy CUDA daje następujące gwarancje: każdy blok będzie wykonywany w obrębie jednego multiprocesora; nic nie wiadomo o tym, w jakiej kolejności wykonają się bloki; jeden wątek wykona się na jednym procesorze; w obrębie jednego multiprocesora wykonuje się w danym momencie co najwyżej jeden blok.
Zadaniem programisty jest napisanie kodu pojedynczego wątku. Każdy wątek wykonuje dokładnie ten sam kod, przy czym wątek może sprawdzić, w którym bloku się znajduje, a także jaki ma numer wewnątrz tego bloku. Obliczenie wykonywane przez wątek zależy od tak zdefiniowanych współrzędnych tego wątku.
Należy jeszcze podkreślić, że programista nie specyfikuje dokładnie, na którym procesorze wykona się dany wątek. Zadaniem programisty jest zdefiniować strukturę obliczeń, a przydziałem wątków do procesorów zajmuje się CUDA.
Praktyczny przykład – mnożenie macierzy
Chcemy pomnożyć dwie macierze i obie wymiaru Przez oznaczmy macierz wynikową. Obliczenie macierzy wprost z definicji wymaga wykonania operacji. Używając platformy CUDA, można zaproponować lepsze rozwiązanie, w którym wszystkie macierze dzielimy na podmacierze rozmiaru (oznaczamy je przez ). Dla uproszczenia analizy założymy, że rozmiar macierzy jest wielokrotnością rozmiaru podmacierzy Jeden blok obliczenia będzie miał za zadanie obliczyć jedną z podmacierzy Zauważmy, że W jednej iteracji będziemy chcieli obliczyć jeden składnik postaci Można to zrobić następująco.
- pobieramy do pamięci multiprocesora macierze – tę pracę wykonuje pierwszy wątek z każdego bloku;
- równolegle obliczamy każdej komórce macierzy wynikowej przyporządkowujemy jeden wątek dpowiedzialny za obliczenie tej wartości;
- dodajemy do wyniku obliczoną wartość
Po wykonaniu obliczeń dla wszystkich mamy obliczoną podmacierz którą możemy zapisać do pamięci głównej karty.
Spróbujmy oszacować złożoność tego rozwiązania. W tym celu przez oznaczmy liczbę procesorów wewnątrz jednego multiprocesora, a przez – liczbę multiprocesorów.
Przyjrzyjmy się czasowi wykonywania pojedynczego bloku. Przy obliczaniu iloczynu najpierw pobieramy dwie macierze rozmiaru do pamięci multiprocesora, co zajmuje czas a następnie wykonujemy mnożeń, ale to zrównolegla się między procesorów, koszt tego wynosi więc Taki ciąg operacji należy wykonać dla Zatem jeden blok wykonuje się w czasie
Mamy bloków, ich wykonanie zrównoleglamy między multiprocesorów, zatem łączny czas to razy czas wykonania pojedynczego bloku. Daje to złożoność czasową Przyjmując, że co jest możliwe, gdyż to programista dobiera wartość daje to złożoność czyli W ten sposób klasyczną złożoność podzieliliśmy przez czyli liczbę procesorów. We wspomnianej wcześniej karcie GeForce GTX 580 mamy czyli programista ma do dyspozycji procesorów. To daje duże możliwości zrównoleglania.
Czy przyszłość programowania leży w równoległości?
Jest cała lista problemów związanych z programowaniem równoległym, jak np.: mała liczba doświadczonych programistów; wyższy niż w przypadku programowania jednowątkowego poziom skomplikowania; część algorytmów ciężko się zrównolegla (np. nie jest znany dobry równoległy algorytm sprawdzania, czy w danym grafie dwudzielnym jest doskonałe skojarzenie); błędy typu race condition, gdy więcej niż jeden wątek jednocześnie zapisuje w danym miejscu w pamięci.
Z drugiej strony programiści chcieliby, aby ich programy działały szybko. Podnoszenie wydajności pojedynczego rdzenia nie odbywa się w takim tempie jak kiedyś, można więc przypuszczać, że do dalszego poprawiania wydajności oprogramowania potrzebne jest odrzucenie modelu programowania z jednym procesorem i myślenie w sposób równoległy. NVIDIA CUDA jest jednym z ciekawszych modeli oferujących możliwość programowania współbieżnego. Wprowadza to za cenę jednej zasadniczej nowości: programiści muszą nauczyć się dzielić obliczenia tworzonego oprogramowania na możliwie niezależne kawałki. Nie jest to takie łatwe, bo całkowicie zmienia sposób myślenia o programowaniu.