Przeskocz do treści

Delta mi!

Programowanie na platformie CUDA

Wojciech Śmietanka

o artykule ...

  • Publikacja w Delcie: wrzesień 2011
  • Publikacja elektroniczna: 31-08-2011
  • Autor: Wojciech Śmietanka
    Afiliacja: student, Wydział Matematyki Informatyki i Mechaniki Uniwersytetu Warszawskiego

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 math 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.

obrazek

Rys. 1 Struktura karty graficznej (część zacieniona) i przepływ danych w pamięci komputera podczas obliczeń.

Rys. 1 Struktura karty graficznej (część zacieniona) i przepływ danych w pamięci komputera podczas obliczeń.

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ń

obrazek

Rys. 2 Struktura obliczeń.

Rys. 2 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 math  Programista dzieli je na zbiór mniejszych obliczeń: math  Pojedynczy element obliczenia math to blok, który jest z kolei złożony z pojedynczych wątków:

display-math

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 math  i math  obie wymiaru math Przez math  oznaczmy macierz wynikową. Obliczenie macierzy math wprost z definicji wymaga wykonania math  operacji. Używając platformy CUDA, można zaproponować lepsze rozwiązanie, w którym wszystkie macierze dzielimy na podmacierze rozmiaru math (oznaczamy je przez math ). Dla uproszczenia analizy założymy, że rozmiar macierzy math jest wielokrotnością rozmiaru podmacierzy math Jeden blok obliczenia będzie miał za zadanie obliczyć jedną z podmacierzy math Zauważmy, że math  W jednej iteracji będziemy chcieli obliczyć jeden składnik postaci math  Można to zrobić następująco.

  • pobieramy do pamięci multiprocesora macierze math – tę pracę wykonuje pierwszy wątek z każdego bloku;
  • równolegle obliczamy math  każdej komórce macierzy wynikowej przyporządkowujemy jeden wątek dpowiedzialny za obliczenie tej wartości;
  • dodajemy do wyniku obliczoną wartość math

Po wykonaniu obliczeń dla wszystkich math mamy obliczoną podmacierz math którą możemy zapisać do pamięci głównej karty.

obrazek

Rys. 3 Schemat mnożenia macierzy. Tutaj math oraz math

Rys. 3 Schemat mnożenia macierzy. Tutaj math oraz math

Spróbujmy oszacować złożoność tego rozwiązania. W tym celu przez math oznaczmy liczbę procesorów wewnątrz jednego multiprocesora, a przez math  – liczbę multiprocesorów.

Przyjrzyjmy się czasowi wykonywania pojedynczego bloku. Przy obliczaniu iloczynu math najpierw pobieramy dwie macierze rozmiaru math  do pamięci multiprocesora, co zajmuje czas math  a następnie wykonujemy math mnożeń, ale to zrównolegla się między math procesorów, koszt tego wynosi więc math  Taki ciąg operacji należy wykonać dla math Zatem jeden blok wykonuje się w czasie math

Mamy math bloków, ich wykonanie zrównoleglamy między math multiprocesorów, zatem łączny czas to math razy czas wykonania pojedynczego bloku. Daje to złożoność czasową math  Przyjmując, że math co jest możliwe, gdyż to programista dobiera wartość math daje to złożoność math  czyli  math W ten sposób klasyczną złożoność math  podzieliliśmy przez math  czyli liczbę procesorów. We wspomnianej wcześniej karcie GeForce GTX 580 mamy math   math  czyli programista ma do dyspozycji math 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.