Przybliżone zapytania do baz danych z akceleracją obliczeń

Transkrypt

Przybliżone zapytania do baz danych z akceleracją obliczeń
XVI Konferencja PLOUG
Kościelisko
Październik 2010
Przybliżone zapytania do baz danych
z akceleracją obliczeń rozkładów
prawdopodobieństwa
Witold Andrzejewski
Politechnika Poznańska
[email protected]
Artur Gramacki, Jarosław Gramacki
Uniwersytet Zielonogórski
[email protected], [email protected]
Abstrakt. Artykuł pokazuje przykładowe zastosowanie architektury CUDA opracowanej przez firmę NVIDIA dla swoich kart graficznych. CUDA to uniwersalna architektura procesorów wielordzeniowych instalowanych we współczesnych, najbardziej wydajnych,
kartach graficznych. Karta taka, oprócz oczywistych zastosowań w dziedzinie ogólnie pojętego przetwarzania obrazu, może być
z powodzeniem wykorzystywana do wykonywania złożonych obliczeń numerycznych, zwłaszcza takich, które poddają się operacji
zrównoleglenia (można wówczas efektywnie wykorzystywać moc zainstalowanych na karcie graficznej tzw. multiprocesorów strumieniowych). Jako przykład bardzo czasochłonnych obliczeń wybrano procedury wyznaczania tzw. parametrów wygładzania estymatorów
jądrowych służących do wyznaczania rozkładów prawdopodobieństwa danych. Znajomość takich rozkładów pozwala na ekstremalnie
szybkie wyznaczanie przybliżonych wyników zapytań agregujących.
Informacja o autorze. Witold Andrzejewski pracuje na stanowisku adiunkta na Politechnice Poznańskiej. Prowadzi lub prowadził
zajęcia na studiach dziennych i zaocznych z przedmiotów: Systemy Baz Danych, Programowanie Obiektowe, Grafika Komputerowa,
Zaawansowane Systemy Baz Danych, Multimedialne i Obiektowe Systemy Baz Danych oraz Wizualizacja Danych 3D. Prowadził
również zajęcia na Studium Podyplomowym (Systemy Baz Danych). Poza Politechniką Poznańską prowadził zajęcia w Oracle University (Administration Worskhop), Wyższej Szkole Języków Obcych (Management Information Systems) oraz w Wyższej Szkole Nauk
Humanistycznych i Dziennikarstwa (Systemy Baz Danych, Technologie Internetowe, Analiza Danych).
Przybliżone zapytania do baz danych z akceleracją obliczeń rozkładów prawdopodobieństwa
253
1. Wstęp
Hurtownie danych znalazły szerokie zastosowania we współczesnych przedsiębiorstwach.
Podstawowym zadaniem hurtowni danych jest integracja danych dotyczących działalności przedsiębiorstwa, z dłuższego okresu czasu, w ujednoliconym schemacie danych i późniejsze wykonywanie na tych danych różnych analiz. Wśród przykładowych analiz, jakie mogą być wykonywane
w hurtowniach danych można wymienić: analizę sprzedaży, analizę trendów, eksplorację danych
i analizę rozwiązań alternatywnych. Wiele z tych analiz (np. analiza sprzedaży) polega na obliczeniu podsumowań danych (wykonywanie tzw. zapytań agregacyjnych). Przykładowym podsumowaniem wykonywanym tutaj może być: oblicz sumaryczne dochody ze sprzedaży pieczywa
w latach 1990-2010 w każdym z województw.
Ponieważ hurtownie danych integrują dane o działalności całego przedsiębiorstwa, mogą one
osiągać olbrzymie rozmiary. Konieczna jest zatem optymalizacja wykonywania zapytań w hurtowniach danych, a w szczególności zapytań agregacyjnych. Wiele prac poświęconych optymalizacji realizacji zapytań agregacyjnych poświęconych jest zagadnieniom związanym z indeksowaniem, materializowaniem wyników, partycjonowaniem itp. Rozwiązania te pozwalają na uzyskanie wyników dokładnych, jednak jest to zwykle okupione pewnym (często wysokim) kosztem,
zarówno z punktu widzenia czasu pracy procesora jak i wykorzystywanych zasobów dyskowych.
Alternatywnym rozwiązaniem jest wykorzystanie metod statystycznych w celu znajdywania
przybliżonych wyników zapytań agregacyjnych. Otrzymywane wartości nie są co prawda dokładne, jednak w wielu zastosowaniach wystarczające, zwłaszcza na wczesnych etapach analizy danych. Co więcej, otrzymywanie przybliżonych wyników zapytań jest znacznie szybsze od dokładnej ich realizacji. Aby podejście takie było możliwe, konieczne jest uprzednie oszacowanie statystycznych parametrów rozkładów danych w bazie danych. Gdy rozkłady te są typowe, czyli np.
zbliżone do normalnego lub innego, ale o znanej analitycznie postaci, estymacja parametrów tych
rozkładów jest stosunkowo prosta. Gdy rozkłady prawdopodobieństwa znacznie odbiegają od
typowych, można rozważyć ich konwersję za pomocą odpowiednich przekształceń zmiennych lub
też estymowanie ich za pomocą metod nieparametrycznych, np. opartych o tzw. estymatory jądrowe. Znalezienie niektórych współczynników estymatorów jądrowych (chodzi głównie o tzw.
parametr wygładzania, zwykle oznaczany jako h) jest jednak dość kosztowne i wymaga dużo czasu, znacznie tym samym zmniejszając korzyści płynące ze stosowania metod statystycznych.
W niniejszej publikacji przedstawiono modyfikacje algorytmów wyznaczania parametru wygładzania dla jądrowych estymatorów gęstości pozwalające na ich wydajne wykonywanie na procesorach kart graficznych (ang. Graphics Processing Unit; GPU) firmy NVIDIA przy wykorzystaniu platformy CUDA. Zmodyfikowane algorytmy uwzględniają specyfikę przetwarzania danych przez GPU, oraz wykorzystują różne typy pamięci znajdujących się na kartach graficznych,
przy zachowaniu zasad wydajnego korzystania z tych pamięci. Przedstawione rozwiązania pozwalają na skrócenie czasów wyznaczania poszukiwanych estymatorów jądrowych nawet o 2 rzędy wielkości w stosunku do czasów uzyskiwanych na klasycznych procesorach.
Struktura niniejszej publikacji jest następująca. W sekcji 2 przedstawiono dotychczasowe osiągnięcia w dziedzinie wykonywania przybliżonych zapytań do baz danych, oraz zastosowań procesorów kart graficznych do obliczeń ogólnych. W sekcji 3 przedstawiono dwie metody wyznaczania parametru wygładzania estymatorów jądrowych (metodę podstawiania i metodę walidacji
krzyżowej). W sekcji 4 przedstawiono krótki opis platformy CUDA wraz z opisami terminów
stosowanymi w późniejszych sekcjach. W sekcjach 5 i 6 przedstawiono najważniejsze osiągnięcia
niniejszej publikacji - implementacje metod wyznaczania parametru wygładzania wykorzystujące
GPU w celu przyspieszenia najbardziej czasochłonnych etapów obliczeń. W sekcji 7 przedstawiono wyniki eksperymentów, gdzie porównano dwie wersje programów do obliczania parametru
254
Witold Andrzejewski, Artur Gramacki, Jarosław Gramacki
wygładzania estymatorów jądrowych: pierwsza wersja intensywnie wykorzystuje GPU, druga
natomiast korzysta wyłącznie z CPU. W sekcji 8 przedstawiono podsumowanie niniejszej publikacji oraz plany dalszych prac.
2. Dotychczasowe prace
2.1. Przybliżone zapytania do baz danych
Klasyczne zapytanie kierowane do bazy danych zwraca zawsze dokładne wyniki, niezależnie
od tego czy adresatem zapytania jest baza operacyjna, czy też baza pełniąca funkcję hurtowni
danych. Przykładowe zapytanie „oblicz sumę wartości sprzedaży za dany rok z podziałem na poszczególne miesiące” daje wynik, który jest prostym efektem pogrupowania oraz podsumowania
odpowiednich danych wejściowych. Czas otrzymania wyniku może być bardzo różny, zależny
głównie od ilości danych, które należy odczytać oraz od ogólnego obciążenia bazy danych (np. od
liczby równocześnie wykonywanych zapytań). Wykonywanie jednocześnie dużej liczby czasochłonnych analiz, może spowodować, iż czasy oczekiwania na wyniki będą nadmiernie długie.
Jednym z możliwych rozwiązań w takiej sytuacji, jest zastosowanie technik pozwalających na
otrzymywanie wyników przybliżonych, których czas działania jest znacznie krótszy od technik
dokładnych. To, że otrzymane wyniki są tylko przybliżone, w wielu zastosowaniach nie będzie
stanowiło istotnego ograniczenia, gdyż w typowych zadaniach eksploracyjnych (wykorzystujących zapytania agregacyjne) nie jest to element krytyczny (dla przykładu jak wyżej, wynik zaokrąglony do pełnych tysięcy złotych jest wystarczający, pomimo tego, że wynik dokładny można
podać z dokładnością do jednego grosza).
Znane są różne metody wyznaczania przybliżonych wyników zapytań bazodanowych (ang.
approximate query processing). Jedno z najprostszych podejść polega na ograniczeniu zbioru
analizowanych danych do pewnej reprezentatywnej próbki. Próbka taka powinna zachowywać
parametry statystyczne całej populacji danych (np. średnia, wariancja, skośność). Wówczas wyniki takich zapytań, jak przykładowo wyznaczanie wartości średnich dla pewnego atrybutu, będą
bardzo podobne, jak analogiczne zapytania kierowane do całej populacji danych. Próbkowanie
jest więc rodzajem redukcji liczności danych (ang. numerosity reduction, instance selection,
example selection) [Han06]. Inne podejście zakłada, że wyznaczane są swego rodzaju streszczania
(ang. synopsis) czy też podsumowania danych. Aby wyliczyć te podsumowania należy przejrzeć
wszystkie zgromadzone dane, jednak czynność ta wykonywana jest tylko raz, lub też co pewien
okres czasu, gdy np. zmieni się znacząco charakterystyka danych. Podsumowania te mogą być
budowane z wykorzystaniem np. techniki histogramów [Ioa99], falek (ang. wavelets) [Vit99] lub
też statystycznych cech analizowanych danych [Sha99]. W tym ostatnim wykorzystywany jest
aparat statystycznej analizy danych, którego literatura przedmiotu jest bardzo bogata – przykładowo można wymienić tu prace [KoMi04, KoCw08, Klo99], które mają charakter przeglądowy.
Warto zapoznać się również z obszernym tutorialem [GaGi01], gdzie autorzy omawiają wszystkie
najważniejsze zagadnienia z dziedziny przybliżonych zapytań do baz danych.
W przypadku podejścia statystycznego, przede wszystkim należy wyznaczyć rozkład prawdopodobieństwa danych [KoMi04, Klo99]. Znając go, możliwe jest przybliżone i bardzo szybkie
wyznaczanie wartości takich funkcji agregujących jak liczność, suma, wartość średnia i podobne
[Sha99]. Dane mogą posiadać rozkład prawdopodobieństwa, który można opisać pewną funkcją
analityczną (np. rozkład normalny), lub też opis taki nie będzie możliwy. W tym drugim przypadku należy go wyznaczyć korzystając z metod nieparametrycznych, np. opartych o tzw. estymatory
jądrowe. Klasyczne pozycje, które ten temat omawiają bardzo obszernie to [WaJo95, Sil86,
Sim96]. Pierwsza z nich daje bardzo dokładny wykład w mocno zmatematyzowanej postaci. Pozostałe dwie pozycje są napisane dużo bardziej przystępnie. W języku polskim ukazała się książka
[Kul05], gdzie podano w przystępnej formie najważniejsze informacje o estymatorach jądrowych,
Przybliżone zapytania do baz danych z akceleracją obliczeń rozkładów prawdopodobieństwa
255
głównie w kontekście wyznaczania z ich pomocą gęstości prawdopodobieństwa. W pracy [She04]
w sposób przystępny i zwarty podano podstawowe informacje na temat nieparametrycznej estymacji funkcji gęstości prawdopodobieństwa. Ostatecznie, w jednej z wcześniejszych prac autorów
na ten temat [GrGr10] podano w skrótowej postaci najważniejsze informacje o istocie gęstości
prawdopodobieństwa, estymatorach jądrowych służących do jej wyznaczania oraz związku gęstości prawdopodobieństwa z zapytaniami kierowanymi do baz danych.
Wyznaczanie gęstości prawdopodobieństwa metodami nieparametrycznymi jest zadaniem czasochłonnym obliczeniowo, głównie za sprawą konieczności wyznaczenia parametru wygładzania.
Istnieją generalnie trzy podstawowe grupy metod jego wyznaczania: metody przybliżone (ang.
normal reference rules), podstawień (ang. plug-in) oraz walidacji krzyżowej (ang. crossvalidation). Dwie ostatnie metody wymagają dość dużych nakładów obliczeniowych, przez co
zastosowanie ich dla większych zbiorów danych jest bardzo czasochłonne. Są to jednak metody
dużo dokładniejsze od metod przybliżonych i dlatego ich zastosowanie jest preferowane.
W przypadku metody podstawień uproszczenie polega na przyjęciu założenia o normalności
rozkładów przy wyprowadzaniu pewnych szczegółowych wzorów [WaJo95, strona 72], mimo że
rozkłady w rzeczywistości przecież normalne nie są (gdyby były normalne, nie byłoby potrzeby
estymacji gęstości za pomocą metod nieparametrycznych). W przypadku metody walidacji krzyżowej zamiast obliczać pewne bardzo czasochłonne sumy na danych wejściowych, wcześniej
dokonuje się odpowiedniej transformaty Fouriera tych danych [Sil86, strona 65]. W przypadku
wariantów tych metod omawianych w niniejszej publikacji, wyżej opisane optymalizacje nie są
jednak stosowane.
2.2. Obliczenia ogólnego przeznaczenia na kartach graficznych
Większość prac naukowych poświęconych ogólnym obliczeniom na procesorach kart graficznych (General Processing on Graphics Processing Units – GPGPU) jest poświęconych takim
zagadnieniom jak: zaawansowane generowanie obrazów, przetwarzanie obrazów (np. kompresja,
śledzenie cech) oraz obliczenia naukowe (symulacja, obliczenia numeryczne). Niewiele publikacji
dotyczących GPGPU jest poświęconych ogólnie pojętym zadaniom wykonywanym, bądź związanym z bazami danych. Z tych publikacji, które powstały, duża część dotyczy wydajnego sortowania [GGK06, GZ06] lub optymalizacji typowych operacji w bazach danych (projekcja, selekcja itp.)
[SAA03, GLW04]. Karty graficzne są również wykorzystywane do akceleracji kompresji i dekompresji indeksów bitmapowych [AnWr10a, AnWr10b]. Wśród innych zastosowań kart graficznych w bazach danych znajduje się eksploracja danych. Powstały tutaj między innymi publikacje
dotyczące grupowania danych, w tym: grupowania metodami k-means [GKV05, CTZ06]
i k-medoids [And07, AnKa10], grupowania w oparciu o analizę gęstości [BNP09a, BNP09b] oraz
grupowania hierarchicznego [CKO09].
Zgodnie z naszą wiedzą, nie opracowano jak dotąd żadnych rozwiązań wykorzystujących procesory kart graficznych do optymalizacji przybliżonych algorytmów realizacji zapytań agregacyjnych.
3. Wyznaczanie parametru wygładzania estymatorów jądrowych
Poniżej podajemy, praktycznie bez komentarzy i jakichkolwiek objaśnień, procedury wyznaczania parametrów wygładzania wspomnianą wcześniej metodą podstawień oraz walidacji krzyżowej (dokładniej: walidacji krzyżowej najmniejszych kwadratów, ang. least squares crossvalidation, LSCV). Przytaczamy je w formie, którą podaje pozycja [Kul05], zmieniając jednak
czasami pewne oznaczenia, aby były bardziej zgodne z tymi, które pojawiają się w anglojęzycznej
literaturze przedmiotu (głównie wzorując się na klasycznej pozycji [WaJo95]). Procedury te można uważać za gotowe do użycia wzorce.
256
Witold Andrzejewski, Artur Gramacki, Jarosław Gramacki
3.1. Wyznaczanie parametru wygładzania metodą podstawień (PLUG-IN)
Oznaczenia:
dr
dx r
n – ilość próbek, k=2 – rząd metody, f ( r ) =
1. Oblicz wartość estymatora wariancji:
⎞
1 ⎛ n
⎜ xi ⎟
−
⎜
n(n − 1) ⎝ i =1 ⎟⎠
i =1
2. Oblicz wartość estymatora odchylenia standardowego:
n
1
Vˆ =
n −1
∑
∑
xi2
2
(1.1)
σˆ = Vˆ
(1.2)
3. Oblicz estymatę Ψ̂8NS funkcjonału Ψ8 :
ˆ NS =
Ψ
8
105
(1.3)
32 π σˆ 9
4. Oblicz wartość parametru wygładzania estymatora jądrowego g1 funkcji f
( 4)
:
1/ 9
⎛ − 2 K 6 ( 0) ⎞
⎟
g1 = ⎜⎜
ˆ NS n ⎟
Ψ
K
μ
(
)
8
⎝ 2
⎠
(1.4)
15
K 6 ( 0) = −
2π
μ 2 (K ) = 1
ˆ ( g ) funkcjonału Ψ :
5. Oblicz estymatę Ψ
6
1
6
ˆ (g ) =
Ψ
6
1
K 6 ( x) =
1
2π
1
n 2 g 17
n
n
∑∑ K
6⎛
i =1 j =1
⎜
⎜
⎝
xi − x j ⎞
⎟
g 1 ⎟⎠
( x 6 − 15 x 4 + 45 x 2 − 15)e
1
− x2
2
6. Oblicz wartość parametru wygładzania estymatora jądrowego g 2 funkcji f
⎛ − 2 K 4 (0) ⎞
⎟
g2 = ⎜
⎜ μ ( K )Ψ
ˆ ( g )n ⎟
6
1
⎝ 2
⎠
(1.5)
(1.6)
( 2)
:
1/ 7
(1.7)
3
K 4 (0) = −
2π
μ 2 (K ) = 1
ˆ ( g ) funkcjonału Ψ :
7. Oblicz estymatę Ψ
4
2
4
ˆ (g ) =
Ψ
4
2
1
n 2 g 25
n
n
∑∑ K
i =1 j =1
4⎛
⎜
⎜
⎝
xi − x j ⎞
⎟
g 2 ⎟⎠
(1.8)
Przybliżone zapytania do baz danych z akceleracją obliczeń rozkładów prawdopodobieństwa
1
K 4 ( x) =
2π
( x 4 − 6 x 2 + 3)e
1
− x2
2
257
(1.9)
8. Oblicz wynikową wartość współczynnika wygładzania h:
⎛
⎞
R( K )
⎟
h=⎜
2
⎜ μ (K ) Ψ
ˆ ( g )n ⎟
4
2
⎝ 2
⎠
R( K ) =
1/ 5
(1.10)
1
2 π
μ 2 (K ) = 1
3.2. Wyznaczanie parametru wygładzania metodą walidacji krzyżowej
(LSCV)
Oznaczenia:
n – ilość próbek, d – wymiarowość zadania, j – numer elementu próby (j = 1 … n), i – numer wymiaru (i = 1 … d)
1. Oblicz macierz kowariancji:
Niech dane wejściowe będą miały następującą postać:
X = xi, j
⎡ x1,1
⎢x
2 ,1
=⎢
⎢ M
⎢
⎢⎣ x d ,1
x1, 2
x 2, 2
M
x d ,2
L x1, n ⎤
L x 2, n ⎥⎥
O
M ⎥
⎥
L x d , n ⎥⎦
Macierz kowariancji ma postać:
⎡ σ 12 σ 1, 2
⎢
σ
σ 22
∑ = ⎢ 2,1
⎢ M
M
⎢
⎢⎣σ d ,1 σ d , 2
L σ 1, d ⎤
⎥
L σ 2, d ⎥
O
M ⎥
⎥
L σ d2 ⎥⎦
(2.1)
gdzie:
σ i2 – wariancje poszczególnych wymiarów badanej zmiennej losowej,
σ i1 ,i2 – kowariancje między zmiennymi losowymi i1 oraz i2 .
1
σ =
n −1
2
i
σ i1 ,i2 =
1
n −1
n
∑
x i2, j
j =1
n
∑x
j =1
i1 , j x i2 , j
1 ⎛⎜
−
n(n − 1) ⎜⎝
−
1
n( n − 1)
⎞
xi, j ⎟
⎟
j =1
⎠
2
n
n
n
∑
(2.2)
∑x ∑x
j =1
i1 , j
j =1
i2 , j
(2.3)
2. Obliczyć wyznacznik macierzy kowariancji ∑ : det(∑) .
3. Obliczyć macierz odwrotną do ∑ : ∑ −1 .
4. Wyznaczyć postać funkcji celu, która podlegać będzie minimalizacji względem nieznanego
(szukanego) parametru h. Poniżej wprowadzamy następującą notację:
258
Witold Andrzejewski, Artur Gramacki, Jarosław Gramacki
⎡ x1 ⎤
⎢x ⎥
x=⎢ 2⎥
⎢M ⎥
⎢ ⎥
⎣xd ⎦
gdzie x1 , x2 L, xd oznaczają kolejne współrzędne d-wymiarowego wektora x.
g ( h) =
1 ⎡ 2
⎢
h d ⎢⎣ n 2
n
n
⎤
⎛ xi − x j ⎞ 1
⎟ + R( K )⎥
⎟
⎥⎦
⎝ h ⎠ n
∑ ∑ T ⎜⎜
i =1 j ,i < j
(2.4)
T ( x) = ( K * K )( x) − 2 K ( x)
K ( x) =
1
(2π ) d / 2
( K * K )( x) =
⎞
⎛ 1
exp⎜ − x T ∑ −1 x ⎟
2
⎝
⎠
det(∑)
1
( 4π )
(2.5)
d/2
⎛ 1
exp⎜ − x T ∑ −1
⎝ 4
det( ∑)
(2.6)
⎞
x⎟
⎠
(2.7)
5. Obliczyć przybliżoną wartość parametru h:
⎛
⎞
dR( K )
⎟
h0 = ⎜⎜
2
⎟
μ
(
K
)
R
(
f
'
'
)
n
⎝ 2
⎠
R( K )
μ 2 (K )
2
=
R( f ' ' ) =
1 /( d + 4 )
(2.8)
1
2 π
d
d/2
d2
d (d + 2)
2 d +2 π d / 2
6. Niech zakres poszukiwania minimum funkcji g(h) wynosi:
Z (ho ) = [h0 / 4,4h0 ]
(2.9)
arg min h∈Z ( h0 ) g ( h)
(2.10)
wówczas rozwiązaniem jest:
Ponieważ funkcja (2.4) ma zwykle dość „łagodny” przebieg, znalezienie jej minimum nie jest
zadaniem trudnym. Nie są zatem stosowane tutaj żadne wyszukane algorytmy znajdowania minimum funkcji. Zamiast tego, wybierana jest pewna liczba równoodległych wartości z dziedziny
poszukiwania (2.9), a następnie wartość funkcji (2.4) jest obliczana dla każdej z tych wartości.
Najmniejsza otrzymana wartość jest przyjmowana za jej rzeczywiste minimum. Zwykle punktów,
dla których należy wyznaczyć wartość funkcji (2.4) nie jest więcej niż 100-200. Gdyby z jakiegoś
powodu okazało się, że wyznaczanie wartości funkcji (2.4) w tylu punktach może trwać zbyt długo (a może tak być, gdy obliczenia wykonywane są dla rzeczywiście dużych ilości danych) można
użyć np. metody złotego podziału. Szczegóły można znaleźć np. w pracy [Kul05].
4. Platforma CUDA
W niniejszej sekcji przedstawiono krótki opis platformy CUDA i architektury sprzętowej kart
NVIDIA. Za względu na szerokość zagadnień związanych z pisaniem programów na karty graficzne, opis ten jest mocno uproszczony i zawiera jedynie informacje konieczne do zrozumienia
zasadności przedstawionych w niniejszej publikacji rozwiązań.
Przybliżone zapytania do baz danych z akceleracją obliczeń rozkładów prawdopodobieństwa
259
CUDA (ang. Compute Unified Device Architecture), to uniwersalna architektura procesorów
wielordzeniowych służących do obliczeń równoległych opracowana przez firmę NVIDIA dla
swoich kart graficznych. Wykorzystanie CUDA możliwe jest dzięki dystrybuowanemu przez
NVIDIA pakietowi programów CUDA toolkit, na który składa się profiler, debugger oraz kompilator odmiany języka C nazywanej C for CUDA. Podstawową zaletą programów zaimplementowanych w C for CUDA jest możliwość uruchomienia bardzo dużej liczby wątków wykonujących
podobne, bądź identyczne operacje na różnych danych wejściowych. Operacje te są definiowane
przez specjalną funkcję, tzw. kernel1. Dany kernel może zostać uruchomiony w wielu wątkach
zorganizowanych w tzw. siatkę obliczeń (ang. grid). Siatka obliczeń jest dwuwymiarową tablicą
o maksymalnych wymiarach 65535x65535 tzw. bloków. Każdy blok jest jedno-, dwu- lub trójwymiarową tablicą wątków (maksymalnie 512 wątków). Każdy blok w ramach jednej siatki obliczeń ma takie same rozmiary. Każdy wątek uruchomiony w ramach siatki obliczeń może odczytać
swoje położenie w bloku (poprzez predefiniowaną zmienną threadIdx), oraz położenie swojego
bloku w siatce (poprzez predefiniowaną zmienną blockIdx). Te dwie współrzędne pozwalają na
jednoznaczną identyfikację każdego uruchomionego wątku. Możliwe jest również odczytanie
wymiarów siatki (poprzez zmienną gridDim) oraz wymiarów bloku (poprzez zmienną blockDim).
Synchronizacja wątków w siatce jest bardzo uproszczona, i możliwa jedynie w ramach wątków
zawartych w jednym bloku. Nie jest możliwa synchronizacja pomiędzy wątkami umieszczonymi
w różnych blokach, choć istnieją metody obejścia tego problemu. Każdy wątek może korzystać
z wielu różnych rodzajów pamięci. Każdy rodzaj pamięci jest charakteryzowany przez jej wielkość, czas dostępu, zasięg dostępu i czas życia (jak długo dane w niej przechowywane są dostępne). Poniżej przedstawiono krótki opis każdego z dostępnych rodzajów pamięci:
• pamięć globalna – duża pamięć, o czasie życia aplikacji (dane umieszczone w tej pamięci są
usuwane po zakończeniu aplikacji), dostępna dla każdego wątku w dowolnym bloku, ale
o dość długim czasie dostępu wynoszącym ok. 400-600 taktów zegara,
• pamięć współdzielona – niewielka pamięć o czasie życia bloku (zakończenie działania blo-
ku powoduje usunięcie danych w niej przechowywanych), dostępna dla każdego wątku
w bloku dla którego jest dedykowana, o bardzo krótkim czasie dostępu,
• pamięć stałych – niewielki fragment pamięci globalnej, który jest cache-owany, przez co
dostęp do niego jest bardzo szybki. Jest ona tylko do odczytu. Czas życia pamięci stałych
oraz jej dostępność jest taka sama jak pamięci globalnej,
• rejestry – niewielka, bardzo szybka pamięć o czasie życia wątku (po zakończeniu wątku
dane z rejestrów są usuwane). Tylko jeden wątek może w danym momencie korzystać z danego rejestru,
• pamięć lokalna i pamięć tekstur – podobnie jak w przypadku pamięci stałych, są to dedy-
kowane fragmenty pamięci globalnej. Pamięć lokalna jest wykorzystywana do przechowywania danych lokalnych wątku, które nie mieszczą się w rejestrach, a pamięć tekstur posiada specyficzne metody adresowania i cachowanie specyficzne dla zastosowań graficznych.
Obydwa te rodzaje pamięci nie są wykorzystywane w rozwiązaniach przedstawionych
w niniejszej publikacji.
Przyjęta budowa siatki obliczeń jest mocno związana ze sprzętową architekturą kart graficznych firmy NVIDIA. Procesor GPU składa się z wielu (obecnie od 1 do 30) multiprocesorów
strumieniowych (ang. streaming multiprocesor, w skrócie SM), z których każdy zawiera 8 proce1
Uwaga: w pracy pojęcie kernel używane jest w dwóch całkowicie różnych znaczenia. W dziedzinie estymatorów jądrowych kernelem (jądrem) przyjęło się nazywać pewną funkcję matematyczną, mającą pewne
określone właściwości i służącą do konstrukcji właściwego estymatora. Natomiast w dziedzinie związanej
z architekturą CUDA kernelem jest funkcja napisana w języku C for CUDA, która może zostać uruchomiona na GPU.
260
Witold Andrzejewski, Artur Gramacki, Jarosław Gramacki
sorów skalarnych (ang. scalar processor, w skrócie SP). Każdy procesor skalarny posiada własne
rejestry oraz jednostki obliczeń całkowitych i zmiennoprzecinkowych pojedynczej precyzji. Prócz
tego każdy multiprocesor posiada dwie jednostki do zadań specjalnych, jednostkę sterującą oraz
niewielką szybką pamięć „na krzemie”, która przechowuje dane z pamięci współdzielonej opisanej wcześniej. Pamięć globalna, to obszar pamięci przechowywany w pamięci znajdującej się
poza GPU, najczęściej na samej karcie graficznej, w postaci osobnych układów. Niektóre GPU
posiadają SM z dodatkową jednostką do obliczeń podwójnej precyzji, ale tylko jedną na cały SM.
Co więcej mechanizmy dostępu do pamięci na GPU są zoptymalizowane na odczytywanie danych
32-bitowych (np. liczb pojedynczej precyzji). Z obliczeń podwójnej precyzji należy zatem korzystać tylko w ostateczności. Stanowi to pewne ograniczenie w niektórych zastosowaniach. Przy
obliczaniu współczynnika wygładzania jednak nie ma to dużego znaczenia (dla referencyjnych
zestawów danych o znanych wartościach h otrzymano praktycznie identyczne wyniki). Należy się
również spodziewać, że wraz z rozwojem technologii kart graficznych, pojawi się wydajna obsługa obliczeń podwójnej precyzji. Przedstawione w niniejszej publikacji rozwiązania powinny się
wówczas w dość łatwy sposób dać zmodyfikować w celu wykorzystania zwiększonej dokładności
obliczeń.
Kiedy aplikacja uruchamia siatkę obliczeń związaną z danym kernelem, bloki z siatki są automatycznie dystrybuowane pomiędzy multiprocesory. Każdy multiprocesor może wykonywać
współbieżnie wiele bloków, ale jeden blok może być uruchomiony tylko na jednym multiprocesorze. Kiedy wszystkie wątki z danego bloku zakończą się, kolejny blok uruchamiany jest na zwolnionym multiprocesorze. Multiprocesor wykonuje kolejne wątki w grupach składających się z 32
wątków, tzw. warpów. Wyróżnia się również half-warpy, czyli pierwsze, lub drugie 16 wątków
w ramach warpa. Wszystkie polecenia w wątkach zawartych w jednym warpie wykonywane są
synchronicznie (cztery ćwiartki warpa po kolei na 8 procesorach skalarnych multiprocesora).
Pamięć współdzielona podzielona jest na 16 banków. Kolejne 32-bitowe słowa z pamięci są
przydzielone do kolejnych banków. Równoczesny odczyt/zapis do pamięci współdzielonej jest
możliwy wtedy, gdy każdy wątek w half-warpie wykonuje dostęp do innego banku. Wyjątkiem
jest tutaj sytuacja, gdy wiele wątków odczytuje z pamięci współdzielonej dokładnie tą samą wartość (ale nie różne wartości dostępne przez ten sam bank). Istnieje wówczas możliwość, iż taką
grupę wątków obsłuży mechanizm rozgłaszania i będzie to równoczesny, wydajny odczyt.
Wydajny dostęp do pamięci globalnej (zarówno odczyt jak i zapis) jest możliwy wtedy, gdy
każdy wątek w half-warpie wykonuje dostęp do danych zawartych w jednym segmencie o wielkości 128B (w sytuacji, gdy odczytywane wartości mają 32 bity). Należy tutaj zwrócić uwagę, iż
powyższe twierdzenie jest prawdziwe tylko dla nowszych kart graficznych. Starsze karty graficzne mają znacznie bardziej ograniczone schematy wydajnego dostępu do pamięci globalnej.
Z powyższego opisu można wywnioskować kilka ogólnych zasad optymalizacji kodu pisanego na platformę CUDA:
• na początku kernela należy przepisywać przetwarzany fragment danych z pamięci globalnej
do pamięci współdzielonej, starając się unikać dostępów poza jednym segmentem w ramach
jednego half-warpa,
• unikać konfliktów w dostępach do banków pamięci współdzielonej,
• unikać wykonywania różnych ścieżek kodu w ramach jednego warpa. Ze względu na spo-
sób wykonywania kolejnych instrukcji we wszystkich wątkach jednego warpa przez GPU,
każdy wątek w warpie musi wykonywać dokładnie tą samą instrukcję. Jeżeli tak nie jest,
wykonanie wszystkich alternatywnych ścieżek kodu jest serializowane, przez co jest znacznie wolniejsze,
• uruchamiać możliwie dużo bloków, żeby wykorzystać wszystkie multiprocesory,
Przybliżone zapytania do baz danych z akceleracją obliczeń rozkładów prawdopodobieństwa
261
• obliczenia zmiennoprzecinkowe wykonywać przede wszystkim na pojedynczej precyzji
(obliczenia podwójnej precyzji wykonywane są obecnie ze znacznie mniejszą szybkością).
Z tego też powodu wszelkie dalsze rozważania dotyczące obliczeń na GPU dotyczą obliczeń pojedynczej precyzji.
Przedstawione w niniejszej publikacji rozwiązania stosują się do wszystkich powyższych
wskazówek.
5. Implementacja algorytmu PLUG-IN na GPU
5.1. Optymalizacja obliczania estymatora wariancji
Optymalizację obliczania wartości estymatora wariancji (1.1) oparto o rozwiązania przedstawione w [Har] W prezentacji tej przedstawiono wydajną implementację algorytmu redukcji
(agregacji) jednowymiarowej tablicy wartości. Niektóre kernele przedstawione w niniejszej publikacji, są prostymi modyfikacjami kernela przedstawionego w [Har] i wszystkie rozważania dotyczące procesu redukcji wprowadzone w [Har] znajdują zastosowanie również tutaj. Z tego też powodu opisane zostanie jedynie ogólne działanie kerneli redukujących z uwzględnieniem zmian
specyficznych dla obliczania wartości koniecznych do wyznaczenia współczynnika wygładzania.
Czytelników zainteresowanych zastosowanymi optymalizacjami implementacyjnymi odsyłamy do
[Har].
Ogólny schemat równoległej redukcji tablicy wartości (w przypadku niniejszej publikacji –
sumy wartości), przedstawiono na rysunku 1. Na początku tablica zawiera 8 różnych wartości.
Równolegle wykonywane są 4 operacje sumowania par wartości z tablicy. Do każdego elementu
z pierwszej połowy tablicy, dodawany jest element odległy od niego o połowę długości tablicy,
np. na rysunku, do elementu numer 1 dodawany jest element numer 5. Wyniki zapisywane są
w miejscu w oryginalnej tablicy. Proces jest powtarzany, ale w każdym kolejnym kroku, zakres
dodawanych pozycji tablicy jest zmniejszany o połowę. Ostatecznie uzyskiwana jest jedna wartość, która zawiera wynik redukcji.
3
∑Ai
A 1A 5
A 3A 7
2
A0A4
A2A6
A 1A 5
A 3A 7
A2A6
1
A0 A 4
A 1A 5
0
A0
A4
A5 A6 A7
A3A7
A4
A5 A6 A7
A2A6
A3A7
A4
A5 A6 A7
A1 A2
A3
A4
A5 A6 A7
A2+A6 A3+A7
Rys. 1. Schemat równoległej redukcji tablicy wartości
Kernel reduceKernel implementujący wyżej opisany schemat został przedstawiony na listingu
1. Istotnym dla wytłumaczenia działania omawianego kernela jest struktura siatki obliczeń, dla
której jest on wywoływany. Siatka obliczeń składa się z jednowymiarowych bloków o liczbie
wątków będącej potęgą 2. Ze względu na wydajność, dla obecnych kart graficznych powinno to
być 256 lub 512 wątków. Bloki w siatce są zorganizowane w jednowymiarową siatkę obliczeń.
262
Witold Andrzejewski, Artur Gramacki, Jarosław Gramacki
Siatka obliczeń powinna zawierać tyle bloków, żeby całkowita liczba wątków w wierszu tej macierzy była równa połowie liczby wartości do zsumowania (np. do zsumowania 8 wartości powinny być uruchomine 4 wątki). W sytuacji, gdy nie jest możliwe uruchomienie tak dużej siatki, powinno zostać uruchomione maksymalne 65535 bloków w wierszu siatki. Każdemu uruchomionemu blokowi przydzielany jest obszar pamięci współdzielonej pozwalający na przechowanie tylu
wartości, ile jest wątków w bloku. Jak łatwo zauważyć, kernel reduceKernel został zaimplementowany jako szablon, którego parametrem jest liczba wątków w bloku. Takie rozwiązanie pozwala
na znaczną optymalizację kodu na etapie kompilacji (patrz [Har]). Kernel przyjmuje jako parametry: wskaźnik na obszar pamięci globalnej zawierającej dane do zsumowania g_idata, wskaźnik na
obszar pamięci globalnej, do której powinny zostać zapisane cząstkowe wyniki sumowania
g_odata oraz liczbę sumowanych wartości n. W wierszu 3 kernela reduceKernel następuje pobranie adresu pamięci współdzielonej przydzielonej do bloku, w którym znajduje się wątek. W wierszu 5 obliczana jest pierwsza z pozycji w tablicy wejściowej, którą powinien dodać aktualny wątek. W wierszu 6 obliczana jest liczba wątków w siatce obliczeń. Wartość ta jest potrzebna później w celu wykrycia i ewentualnego obsłużenia sytuacji, gdy nie było możliwe uruchomienie
wystarczającej liczby bloków. W wierszu 7 przydzielona pamięć współdzielona jest zerowana.
W wierszach 8 do 10 każdy wątek sumuje przynajmniej dwie wartości z tablicy wejściowej i zapisuje wynik do pamięci współdzielonej. Jeżeli okaże się, że liczba alokowanych wątków jest niewystarczająca, sekwencyjnie dodawane są kolejne wartości z redukowanej tablicy. Wiersz 11
obsługuje sytuacje, w których n nie jest potęgą dwójki. W takich przypadkach, nie każda wartość
z tablicy może znaleźć parę do dodania i następuje po prostu przepisanie tylko jednej wartości bez
dodawania. W wierszu 12 odbywa się synchronizacja wątków w celu zapewnienia, aby wszystkie
pozycje w pamięci współdzielonej zostały wypełnione, zanim kernel przejdzie do kolejnego etapu
obliczeń. Wiersze 13 do 30 implementują schemat redukcji przedstawiony na rysunku 1. Redukcja
wykonywana jest w całości w pamięci współdzielonej. Wynik redukcji zapisywany jest do tablicy
wynikowej, pod pozycją o numerze aktualnego bloku. Ponieważ każdy uruchomiony blok wykonuje redukcję 2*blocksize wartości do jednej, może się okazać, że jednorazowe wykonanie kernela
reduceKernel może nie być wystarczające do obliczenia całkowitej sumy wszystkich wartości
w tablicy (jeżeli wartości w tablicy jest więcej niż 2*blocksize). Aby zapewnić pełną redukcję,
można wykorzystać jeden z dwóch następujących schematów postępowania. Pierwszy schemat,
tzw. nieniszczący rozpoczyna się od zaalokowania w pamięci globalnej dwóch tablic pomocniczych A i B o wymiarach pozwalających pomieścić wynik pierwszej redukcji. Wynik działania
kernela reduceKernel jest zapisywany do tablicy A. Jeżeli wynik zawiera jedną wartość, to dalsze
obliczenia nie są wykonywane, gdyż znaleziony został wynik. W przeciwnym wypadku tablica A
jest redukowana a wynik zapisywany jest do tablicy B. Jeżeli zachodzi konieczność dalszej redukcji, to tablica B jest redukowana, a wynik z powrotem zapisywany do tablicy A i tak dalej na
przemian, aż pozostanie tylko jedna wartość stanowiąca wynik redukcji. Drugi schemat, tzw. niszczący wykorzystuje tylko jedną tablicę pomocniczą A. Postępowanie jest analogiczne, jak
w schemacie nieniszczącym, ale rolę tablicy B pełni oryginalna tablica z danymi wejściowymi.
Schemat jest zatem niszczący, gdyż niszczy oryginalne, redukowane dane.
1. template <unsigned int blockSize>
2. void reduceKernel(float *g_idata, float *g_odata, unsigned int n) {
3.
extern __shared__ float sdata[];
4.
unsigned int tid = threadIdx.x;
5.
unsigned int i = blockIdx.x * (blockSize * 2) + tid;
6.
unsigned int gridSize = blockSize * 2 * gridDim.x;
7.
sdata[tid] = 0.0f;
8.
while (i + blockSize < n) {
9.
sdata[tid] += g_idata[i] + g_idata[i + blockSize]; i += gridSize;
10. }
11. if (i < n ) { sdata[tid] += g_idata[i]; }
12. __syncthreads();
Przybliżone zapytania do baz danych z akceleracją obliczeń rozkładów prawdopodobieństwa
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32. }
263
if (blockSize >= 512) { if (tid < 256){ sdata[tid] += sdata[tid + 256];
}
__syncthreads();
}
if (blockSize >= 256) { if (tid < 128){ sdata[tid] +=sdata[tid + 128]; }
__syncthreads();
}
if (blockSize >= 128) { if (tid < 64){ sdata[tid] +=sdata[tid + 64]; }
__syncthreads();
}
if (tid < 32) {
volatile float* smem = sdata;
if (blockSize >= 64) smem[tid] += smem[tid + 32];
if (blockSize >= 32) smem[tid] += smem[tid + 16];
if (blockSize >= 16) smem[tid] += smem[tid + 8];
if (blockSize >=
8) smem[tid] += smem[tid + 4];
if (blockSize >=
4) smem[tid] += smem[tid + 2];
if (blockSize >=
2) smem[tid] += smem[tid + 1];
}
if (tid == 0) g_odata[blockIdx.x] = sdata[0];
Listing 1. Kernel reduceKernel służący do prostej redukcji (sumowania) elementów tablicy
Kernel reduceKernel wykorzystywany jest do zarówno do obliczenia sumy wartości wektora
X, jak i sumy kwadratów wartości wektora X, które to sumy są konieczne do obliczenia estymatora wariancji (1.1). Do obliczenia tych sum wykorzystywany jest kernel reduceKernel pracujący
w schemacie niszczącym, oraz kernel generateArrays, przedstawiony na listingu 2. Na początku
alokowane są w pamięci globalnej dwie tablice o rozmiarze n. Następnie uruchamiany jest kernel
generateArrays, którego zadaniem jest przepisać do pierwszej z tych tablic oryginalne wartości
wektora X, a do drugiej, kwadraty wartości wektora X. Siatka obliczeń dla tego kernela powinna
być jednowymiarowa i składać się z maksymalnie dużych, jednowymiarowych bloków (256 lub
512 wątków). Liczba bloków w siatce powinna być taka, żeby liczba uruchomionych wątków była
większa lub równa n. Kernel generateArrays jako parametry przyjmuje wskaźniki do 3 obszarów
pamięci globalnej: in – tablica z wartościami wektora X, out1 – tablica tymczasowa, do której
przepisywane są dane z tablicy in, oraz out2 – tablica tymczasowa, do której zapisywane są kwadraty wartości z tablicy in. Ostatnim parametrem kernela jest wartość n, czyli długość wektora X.
W wierszu 3 kernel oblicza pozycję w tablicy wejściowej, którą ma przewarzać aktualny wątek.
Jeżeli pozycja ta nie wychodzi poza tablicę wejściową, to w wierszu 5 pobierana jest do rejestru
wartość z tablicy, a następnie jest ona zapisywana po ewentualnych modyfikacjach to tablic out1
i out2 w wierszach 6 i 7.
1. __global__ void generateArrays(float *in,float *out1,float *out2,
2. unsigned int n) {
3. unsigned int i = threadIdx.x + blockIdx.x * blockDim.x;
4. if ( i < n ) {
5.
float x = in[i];
6.
out1[i] = x;
7.
out2[i] = x * x;
8. }
9. }
Listing 2. Kernel generateArrays służący do przygotowania danych przy obliczaniu estymatora wariancji
Po zakończeniu działania kernela generateArrays, kernel reduceKernel pracujący w schemacie
niszczącym wykorzystywany jest do obliczenia sum wartości w wygenerowanych tablicach. Uzyskane w ten sposób sumy są wykorzystywane do obliczenia wartości estymatora wariancji.
264
Witold Andrzejewski, Artur Gramacki, Jarosław Gramacki
5.2. Optymalizacja obliczania estymat (1.5) oraz (1.8)
Kolejnym czasochłonnym etapem algorytmu PLUG-IN, jest obliczenie estymat (1.5) oraz
(1.8), a w szczególności podwójnych sum, które tam się znajdują (powodują one, że złożoność
obliczeniowa algorytmu jest na poziomie O(n2)). Jak łatwo zauważyć, w obydwu przypadkach
chodzi o obliczenie sumy wartości pewnej funkcji, której jako parametr przekazywane są różnice
dwóch wartości z wektora X. Schemat obliczenia tych sum jest zatem w obydwu przypadkach taki
sam. Z tej przyczyny przedstawiony zostanie jedynie opis obliczania sumy wartości funkcji (1.6),
a później wskazane zostaną proste modyfikacje, które należy wprowadzić, aby obliczana była
suma wartości funkcji (1.9).
W celu obliczenia sumy wartości funkcji (1.6) wykorzystywany jest zmodyfikowany kernel
reduceKernel o nazwie reduceK6Kernel (patrz listing 3). Kernel przyjmuje jako parametry:
wskaźnik do obszaru pamięci globalnej g_idata, w którym znajdują się wartości wektora X,
wskaźnik do obszaru pamięci globalnej g_odata, do którego zapisane zostaną cząstkowe wyniki
dodawania, wartość deltaY, której znaczenie zostanie opisane później (chwilowo należy przyjąć
założenie, że deltaY=0), wartość g1 oraz wartość n, czyli liczba wartości w wektorze X. Jak łatwo
zauważyć, kernel reduceK6Kernel jest bardzo podobny do reduceKernel. Różnice pojawiają się
w pętli w wierszu 10 i w powiązanym z nią warunkiem w wierszu 11 oraz w zapisie wyników
obliczeń w wierszu 25. Pojawia się również dodatkowe pobranie danych w wierszu 8. Aby zrozumieć działanie niniejszego wariantu kernela służącego do redukcji danych, konieczne jest
uprzednie poznanie siatki obliczeń, w jakiej kernel ten powinien działać. Siatka obliczeń powinna
być dwuwymiarowa, z jednowymiarowymi blokami o maksymalnej liczbie wątków (podobnie jak
w przypadku kernela reduceKernel). Liczba bloków w pojedynczym wierszu siatki powinna być
obliczana w taki sam sposób, jak w przypadku kernela reduceKernel. Liczba bloków w kolumnie
siatki powinna być równa n, a w sytuacji, gdy n>65535, to wtedy powinna wynosić dokładnie
65535. Przypadek ten zostanie opisany później. Chwilowo należy przyjąć założenie, że
n<=65535. A zatem, jak działa kernel reduceK6Kernel? W wierszu 8 kernela pobierana jest wartość z wektora X o numerze odpowiadającym numerowi wiersza bloków w siatce, w którym znajduje się aktualny wątek. Pobrana wartość wykorzystywana jest w pętli w wierszu 10 do obliczenia
parametru funkcji k6d stanowiącej implementację funkcji (1.6) (patrz listing 4). W omawianej
pętli, podstawową różnicą w stosunku do jej oryginalnej postaci jest to, iż dodawane są wartości
funkcji k6d, zamiast niezmodyfikowanych wartości odczytanych z tablicy, jak to było w oryginalnej postaci kernela. Można tutaj również zauważyć, że od odczytanych z pamięci globalnej wartości wektora X odejmowana jest wartość zapisana w zmiennej xj pobrana w wierszu 8. Co więcej,
pozycje odczytywanych wartości w wierszu 10 zależą tylko od położenia wątku w wierszu bloków. Można zatem zauważyć, że kernel reduceK6Kernel, realizuje schemat redukcji przedstawiony na rysunku 1 w każdym wierszu siatki niezależnie, ale sumowane są wartości funkcji k6d, a nie
oryginalne dane. Co więcej, w każdym wierszu siatki obliczana jest suma wartości funkcji k6d,
której parametr jest obliczany na podstawie innej wartości xj. W wierszu 25 na podstawie położenia aktualnego bloku w siatce obliczana jest pozycja w tablicy wyjściowej, do której należy zapisać częściową sumę obliczoną w ramach bloku, a następnie obliczona suma częściowa jest tam
zapisywana. Uzyskane w opisany powyżej sposób częściowe sumy można następnie zredukować
do pojedynczej wartości stosująć kernel reduceKernel w schemacie niszczącym. Pozostaje jedynie
problem z sytuacją, w której n>65535. Wówczas nie jest możliwe uwzględnienie w jednym wywołaniu kernela reduceK6Kernel wszystkich wartości xj. Należy zatem wywołać ten kernel wielokrotnie w pętli. Aby uniknąć powtórzenia obliczeń, należy przypisać do parametru deltaY liczbę
pierwszych wartości xj, które należy pominąć.
1.
2.
3.
4.
5.
template <unsigned int blockSize>
__global__ void reduceK6Kernel(float *g_idata, float *g_odata,
unsigned int deltaY, float g1, unsigned int n) {
extern __shared__ float sdata[];
unsigned int tid = threadIdx.x;
Przybliżone zapytania do baz danych z akceleracją obliczeń rozkładów prawdopodobieństwa
6.
7.
8.
9.
10.
265
unsigned int i = blockIdx.x * (blockSize * 2) + tid;
unsigned int gridSize = blockSize * 2 * gridDim.x;
float xj=g_idata[blockIdx.y + deltaY];
sdata[tid] = 0.0f;
while (i + blockSize < n) { sdata[tid] += k6d((g_idata[i] - xj) / g1) +
k6d((g_idata[i + blockSize] - xj)/g1); i += gridSize; }
if (i < n ) { sdata[tid] += k6d((g_idata[i] - xj) / g1); }
__syncthreads();
if (blockSize >= 512) { if (tid < 256) {sdata[tid] += sdata[tid + 256];}
__syncthreads(); }
if (blockSize >= 256) { if (tid < 128) {sdata[tid] += sdata[tid + 128];}
__syncthreads(); }
if (blockSize >= 128) { if (tid < 64) {sdata[tid] += sdata[tid + 64];}
__syncthreads(); }
if (tid < 32) {
volatile float* smem = sdata;
if (blockSize >= 64) smem[tid] += smem[tid + 32];
if (blockSize >= 32) smem[tid] += smem[tid + 16];
if (blockSize >= 16) smem[tid] += smem[tid + 8];
if (blockSize >=
8) smem[tid] += smem[tid + 4];
if (blockSize >=
4) smem[tid] += smem[tid + 2];
if (blockSize >=
2) smem[tid] += smem[tid + 1];
}
if (tid == 0)
g_odata[blockIdx.x + gridDim.x * (blockIdx.y + deltaY)]= data[0];
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26. }
Listing 3. Kernel reduceK6Kernel służący do częściowego zsumowania wartości funkcji (1.6)
Jak wspominano wcześniej, funkcja k6d przedstawiona na listingu 4 jest prostą implementacją
funkcji (1.6). Krótki komentarz do tej funkcji jest jednak niezbędny. Po pierwsze należy wspomnieć o wartości k_coeff występującej w wierszu 3. Jest to zdefiniowana globalnie stała o wartości 1 / 2π . Po drugie należy również zaznaczyć, iż wielomian występujący w definicji funkcji
(1.6) został przekształcony do postaci, która wymaga wykonania mniejszej liczby operacji matematycznych.
1.
2.
3.
4.
__device__ float k6d(float x) {
float x2 = x * x;
return k_coeff * exp(-0.5f * x2) * (x2 * (x2 * (x2 - 15.0f) + 45.0f)
- 15.0f);
}
Listing 4. Funkcja k6d obliczająca wartość funkcji (1.6)
Ostatnią rzeczą, którą należy omówić, jest obliczanie sumy wartości funkcji (1.9). Obliczanie
tej sumy przebiega analogicznie jak w przypadku sumy wartości funkcji (1.6). Konieczne jest
jedynie przygotowanie kernela prawie identycznego z reduceK6Kernel, w którym parametr g1
zastąpiono parametrem g2, a wszystkie wywołania funkcji k6d, wywołaniami funkcji k4d (patrz
listing 5). Wielomian w funkcji k4d, podobnie jak w przypadku funkcji k6d, również został przekształcony do postaci, w której wymagane jest mniej obliczeń.
1.
2.
3.
4.
__device__ float k4d(float x) {
float x2=x*x;
return k_coeff * exp(-0.5f * x2) * (x2 * (x2 - 6.0f) + 3.0f);
}
Listing 5. Funkcja k4d obliczająca wartość funkcji (1.9).
266
Witold Andrzejewski, Artur Gramacki, Jarosław Gramacki
6. Implementacja algorytmu LSCV na GPU
6.1. Optymalizacja podstawowego algorytmu
Obliczenie wzoru (2.4) jest bardzo kosztowne ze względu na konieczność wielokrotnego obliczenia wartości funkcji T (wzór (2.5)) dla różnych parametrów. Obliczenie funkcji (2.5) również
wymaga dość dużej liczby operacji. Nie licząc operacji „stałych”, które trzeba wykonać zawsze,
dla obliczenia wzoru (2.4), konieczne jest wykonanie iloczynu xTΣx, który wymaga wykonania
d2+d mnożeń i (d-1)(d+1) dodawań. Obliczenie jednej wartości wzoru (2.4) wymaga n(n-1)/2
wyliczeń wartości funkcji (2.5). Jak łatwo zatem zauważyć, koszt obliczenia wzoru (2.4) jest
znaczny. Możliwa jest jednak optymalizacja pozwalająca na znaczne zmniejszenie tego kosztu.
Rozważmy ponownie wzór (2.6):
K ( x) =
1
(2π )
d /2
⎛ 1
⎞
exp⎜ − x T ∑ −1 x ⎟
⎝ 2
⎠
det(∑)
Jak łatwo zauważyć ze wzoru (2.4), wektor x zawsze ma postać, y i , j / h gdzie yi , j = xi − x j .
Można zatem zapisać nowy wariant funkcji K(x) w następującej postaci:
K ( y i , j , h) =
⎛ 1
exp⎜ −
⎝ 2
(2π )
det(∑)
1
⎛ 1
exp⎜ −
d/2
⎝ 2
(2π )
det(∑)
1
d/2
1 T −1 1
⎞
yi, j ∑
y i, j ⎟ =
h
h
⎠
1 T −1
⎞
y i, j ∑ y i, j ⎟
2
h
⎠
(3.1)
Niech
Yi , j = y iT, j ∑ −1 yi , j ,
(3.2)
wówczas:
K (i , j , h ) =
1
(2π )
d/2
⎛ 1 1
⎞
exp⎜ −
Y ⎟
2 i, j
⎝ 2h
⎠
det(∑)
(3.3)
W analogiczny sposób można zmodyfikować postać funkcji ( K ∗ K )( x) :
( K * K )(i, j, h) =
1
(4π )
d /2
⎛ 1 1
⎞
Y ⎟
exp⎜ −
2 i, j
4
h
det(∑)
⎝
⎠
(3.4)
Propagując zmiany do wzorów (2.5) oraz (2.4) można uzyskać:
T (i, j , h) = ( K * K )(i, j , h) − 2 K (i, j , h)
g ( h) =
1 ⎡ 2
⎢
h d ⎢⎣ n 2
n
n
1
(3.5)
⎤
∑ ∑ T (i, j, h ) + n R( K )⎥⎥
i =1 j ,i < j
⎦
(3.6)
Jak łatwo zauważyć, wartości (3.2) są skalarami, a co więcej, są one stałe, niezależnie od wartości parametru h, dla której obliczana jest wartość funkcji (2.4). Pozwala to na jednorazowe obliczenie wartości (3.2) na początku działania algorytmu i wielokrotne ich wykorzystywanie na etapie szukania minimum funkcji (2.4).
Najbardziej czasochłonnymi operacjami zmodyfikowanej wersji algorytmu LSCV są: obliczanie wartości (3.2) oraz wielokrotne obliczanie wartości (2.4). Pozostałe operacje, takie jak: obliczenie macierzy kowariancji, jej odwrotności oraz wyznacznika, dla niewielkich wartości d, zaj-
Przybliżone zapytania do baz danych z akceleracją obliczeń rozkładów prawdopodobieństwa
267
muje tak mało czasu, że próba ich przyspieszenia za pomocą GPU nie daje żadnych zauważalnych
zysków.
6.2. Optymalizacja obliczania wartości (3.2)
Przyjęte tutaj rozwiązania oparte są o pomysły z publikacji [CKO09] poświęconej grupowaniu
obiektów na platformie CUDA. Przedstawiono tam między innymi metodę wydajnego obliczania
macierzy współczynników korelacji Pearsona pomiędzy każdą parą wektorów z zadanego zbioru
wektorów. Przedstawione tam rozwiązanie jest jednak na tyle ogólne, że pozwala ono na obliczenie macierzy wartości szerokiej klasy funkcji dwóch wektorów. Modyfikacja rozwiązań z publikacji [CKO09] przedstawiona poniżej uwzględnia 3 dodatkowe czynniki pozwalające na dodatkowe optymalizacje, w przypadku obliczania macierzy wartości (3.2):
• macierz wartości (3.2) jest macierzą trójkątną – pozwala to na ograniczenie liczby oblicza-
nych wartości i zmniejszenie liczby wątków koniecznych do uruchomienia,
• kolejność wartości Yi,j w tablicy wynikowej jest obojętna. Nie musi być to nawet tablica
wielowymiarowa,
• wartości d są niewielkie, przez co można ograniczyć liczbę niektórych niepożądanych kon-
strukcji językowych, które zmniejszają wydajność. Przedstawione przez nas rozwiązanie
pozwala na przetwarzanie macierzy danych dla d=1,2,…,16 choć rozszerzenie go dla większych wartości d nie jest trudne.
Ogólny schemat obliczania wartości (3.2) przedstawiono na rys. 1. Niech będzie dana wartość
side. Rysunek zakłada, iż d=side (dla obecnych kart graficznych rekomendowana jest wartość
side=16), oraz że n jest wielokrotnością size. Bardziej ogólne sytuacje zostaną omówione później.
q
l
X
d
n
Rys. 3. Schemat obliczania wartości Yi,j
Ogólny pomysł na wydajne obliczanie wartości Yi,j, przedstawiony na rysunku 3 wygląda następująco. Na karcie graficznej uruchamianych jest wiele bloków wątków, każdy z nich składający
się z side2 wątków. Wątki w bloku uorganizowane są w kwadratową macierz o boku side (każdy
wątek zna swoje położenie w bloku). Każdemu blokowi przydzielane są dwie liczby l=0..n/side-1
oraz q=0..n/side-1. Każdy blok posiada inną kombinację tych dwóch wartości. Liczby l oraz q są
268
Witold Andrzejewski, Artur Gramacki, Jarosław Gramacki
numerami kwadratowych fragmentów macierzy X, tak jak zostało to pokazane na rysunku 3.
Kwadratowy fragment macierzy o numerze l składa się z kolumn xl*side , xl*side+1 , K , xl*side+ side−1
(i analogicznie w przypadku q). Dla każdego bloku alokowany jest obszar pamięci współdzielonej
wystarczający do przechowania dwóch kwadratowych macierzy o boku side. Wątki w każdym
bloku mają dwa zadania. Pierwszym zadaniem jest pobranie z pamięci globalnej kwadratowych
podmacierzy macierzy X wskazanych przez wartości l oraz q, do pamięci współdzielonej przydzielonej do bloku, w którym znajdują się wątki. Operacja ta zilustrowana jest przez szare strzałki
na rysunku 3. Kiedy wszystkie wątki zakończą kopiowanie danych do pamięci współdzielonej,
obliczają one wartości kwadratowego fragmentu macierzy wyjściowej: wątek, o współrzędnych
(tx,ty) w bloku, któremu przydzielono wartości l oraz q, oblicza poszukiwaną wartość funkcji
wektorów xl*side+ tx i x q*side+ ty wykorzystując dane zapisane w pamięci współdzielonej.
Opisany powyżej schemat pozwala na obliczenie macierzy kwadratowej wartości funkcji każdej wariacji dwóch wektorów (kolumn) z macierzy X. Należy jednak zauważyć, iż wartości (3.2)
wystarczy obliczyć dla każdej kombinacji dwóch wektorów z macierzy X (macierz wartości (3.2)
jest trójkątna). Aby ograniczyć liczbę niepotrzebnych obliczeń, bloki uruchamiane są zgodnie ze
schematem przedstawionym na rysunku 2. Rysunek, w celu zwiększenia czytelności, zakłada, iż
side=4 a n=20. Jak można zobaczyć na rysunku, uruchamiane są jedynie takie bloki, dla których
kombinacja wartości l i q umieszcza wynik ich pracy w dolnym trójkącie macierzy wynikowej.
Można również zauważyć, iż niewielka część wątków będzie w dalszym ciągu obliczać wartości
na i powyżej przekątnej macierzy. W tych nielicznych przypadkach obliczone wartości są ignorowane.
q
0
1
2
3
4
0
1
l
2
3
4
Rys. 2. Ilustracja położenia uruchamianych wątków w macierzy z wynikami obliczeń
Kernel findExp implementujący wyżej opisane rozwiązania został przedstawiony na listingu 6.
Jak łatwo zauważyć, kernel został zaimplementowany w postaci szablonu funkcji. Parametrami
szablonu są wartości side i d (nie obowiązuje już założenie, iż d=side oraz, że n jest wielokrotnością side). Przyjęto takie rozwiązanie, ponieważ na obecnych kartach graficznych side zawsze
powinno wynosić 16, a dopuszczalne wartości d muszą być mniejsze lub równe side. Umożliwia
to proste wyliczenie wszystkich poprawnych instancji tego wzorca (jest ich 16, po jednej dla każdej wartości d) i wybór odpowiedniej za pomocą konstrukcji switch. Znajomość wartości d i side
na etapie kompilacji pozwala kompilatorowi w znacznym stopniu zoptymalizować kod (np. rozwinąć pętle). Kernel, jako parametry formalne przyjmuje: wskaźnik na obszar pamięci globalnej,
w której zapisano macierz X (parametr data), wskaźnik na obszar pamięci globalnej, do której
należy zapisać wynik obliczeń out oraz wartość n. Kernel zakłada, iż dane wejściowe (macierz X)
są umieszczone w jednowymiarowej tablicy, wierszami: x1,1, x1,2,…, x1,n, x2,1, x2,2,…,x2,n, …,xd,n.
Obszar pamięci wskazywany przez out to jednowymiarowa tablica o długości n(n-1)/2. W wierszach 3,4 i 5 następuje pobranie adresu pamięci współdzielonej przydzielonej do bloku w którym
Przybliżone zapytania do baz danych z akceleracją obliczeń rozkładów prawdopodobieństwa
269
znajduje się wątek, oraz określenie początków obszarów tej pamięci do których możliwe jest zapisanie pierwszej i drugiej kwadratowej podmacierzy. Adresy tych obszarów zapisywane są do
zmiennych Ml oraz Mq. W wierszach 6, 7 i 8 następuje obliczenie wartości l oraz q. Przyjęte tutaj
rozwiązanie numerowania bloków jest niestandardowe i wymaga wytłumaczenia. Wynika ono
z dwóch czynników:
• nie jest możliwe stworzenie dolnotrójkątnej siatki obliczeń, a zatem nie jest możliwe wyko-
rzystanie blockIdx.x i blockIdx.y jako l i q,
• możliwe jest ustalenie odpowiednich współrzędnych l oraz q na podstawie numeru bloku,
jeżeli są one numerowane sekwencyjnie, ale wówczas liczba możliwych do uruchomienia
bloków jest zbyt ograniczona (tylko 65535 bloków).
Drugi ze wspomnianych czynników można rozwiązać uruchamiając prostokątną siatkę obliczeń, o odpowiedniej liczbie bloków, i przeliczyć dwuwymiarowe położenie bloku w siatce na
jego jednowymiarowy numer. Uszczegółowiając, można powiedzieć, iż kernel findExp powinien
być uruchamiany w siatce o dowolnych wymiarach, tak długo, jak liczba bloków w siatce jest
równa, lub większa od, ale bliska ⎡n / side)⎤ * ( ⎡n / side⎤ + 1) / 2 . Każdy blok powinien być jednowymiarowy i zawierać side2 wątków. Wracając do opisu kernela, liniowy numer bloku obliczany jest w wierszu 6 i zapisywany do zmiennej bx. Wartości l oraz q obliczane są następnie na podstawie bx w wierszach 7 i 8. Wiersze te implementują wzory:
⎡ 8bx + 9 − 3 ⎤
l (l + 1)
l=⎢
⎥ q = bx −
2
2
⎢
⎥
Wyprowadzenie powyższych wzorów jest proste i nie zostało umieszczone w niniejszej publikacji. Wytłumaczenia wymaga jedynie funkcja ceilSquareRoot wykorzystana w wierszu 7. Jej kod
przedstawiono na listingu 7. Zadaniem tej funkcji jest obliczenie pierwiastka liczby przekazanej
jako parametr i uniknięcia niedokładności obliczeń, w przypadku, kiedy parametr jest kwadratem
liczby całkowitej. W wierszach 9 i 10 następuje określenie współrzędnych wątku w bloku, na
podstawie jego jednowymiarowego numeru. Alternatywnie można po prostu alokować od razu
bloki dwuwymiarowe o odpowiednich wymiarach. W wierszach 11 i 12 obliczane są pozycje początkowe kwadratowych podmacierzy macierzy X w tablicy data. W wierszach 13 i 14 inicjowane
są zmienne pomocnicze, wykorzystywane podczas obliczania wartości (3.2). W wierszach 15 i 16
następuje przepisanie wybranych podmacierzy kwadratowych do pamięci współdzielonej. Wykorzystanie funkcji min w tych wierszach gwarantuje, iż nie zostaną wykonane dostępy do pamięci
spoza dopuszczalnego obszaru. W wierszu 17 następuje synchronizacja wszystkich wątków, dzięki której gwarantowane jest przepisanie wszystkich wymaganych danych do pamięci współdzielonej zanim zostaną wykonane kolejne operacje kernela. Warunki w wierszach 18 i 19 pozwalają
na wykonywanie dalszej pracy jedynie wątkom, które obliczają wartość (3.2) z dolnego trójkąta
macierzy (wiersz 18) i przetwarzają dane nie wychodzące poza macierz X (wiersz 19). Pętle
w wierszach od 20 do 26 obliczają wartość (3.2) dla odpowiedniej pary wektorów (kolumn
z macierzy X). W wierszu 23 wykorzystywana jest tablica invSigma, która nie została wcześniej
zadeklarowana, ani przekazana przez parametr. Jest to globalna tablica zawierająca macierz Σ-1.
W celu optymalizacji czasu obliczeń została ona umieszczona w pamięci stałych. Jak łatwo zauważyć, każdy wątek w warpie będzie wykonywać dostęp do tej samej wartości pamięci stałych,
co jest optymalną metodą dostępu do tego typu pamięci [NV10]. Istotnym szczegółem, na który
należy zwrócić uwagę jest tutaj również sposób adresowania pozycji w tablicach Ml i Mq umieszczonych w pamięci współdzielonej. Ze względu na sposób ułożenia macierzy X w pamięci globalnej, kolejne wartości w tych macierzach, po przepisaniu danych (patrz wiersze 15 i 16), ułożone
są w następujący sposób. Pierwsze side wartości, to wartości z pierwszego wiersza odpowiedniej
kwadratowej podmacierzy macierzy X, kolejne side wartości, to wartości z drugiego wiersza odpowiedniej kwadratowej podmacierzy itd. Wynika z tego, że kolejne wartości dowolnej kolumny
270
Witold Andrzejewski, Artur Gramacki, Jarosław Gramacki
podmacierzy są dostępne co side wartości w tablicy. Takie ułożenie danych umożliwia dostęp do
pamięci współdzielonej w wierszach 23 i 25, który nie powoduje konfliktów przy dostępie do
banków tej pamięci. Wynika to z następujących obserwacji. Przy założeniu, że side=16, jeden
half-warp przetwarza jeden wiersz bloku. Ponieważ konflikty mogą wystąpić jedynie w ramach
half-warpa, dalsze rozważania zostaną ograniczone do jednego wiersza bloku. Jak łatwo zauważyć, kolejne wątki w half-warpie będą miały przydzielone kolejne wartości tx. Jak również łatwo
zauważyć, tx w wyrażeniach obliczających adres w tablicy Ml jest dodawany jako wyraz wolny,
bez żadnego współczynnika. Oznacza to, że kolejne wątki w half-warpie wykonują dostępy do
kolejnych wartości z tablicy Ml, a co za tym idzie do kolejnych banków. Ponieważ w half-warpie
jest 16 wątków, a pamięć współdzielona ma 16 banków, konflikt w dostępie do banków nigdy nie
wystąpi. Dostęp do tablicy Ml jest zatem wydajny. Wydawałoby się natomiast, że w przypadku
tablicy Mq, wszystkie wątki w ramach half-warpa wykonują dostęp do tego samego banku, gdyż
mają identyczne wartości zmiennych p, k i ty, które są wykorzystywane podczas obliczania adresu
w tej tablicy. Okazuje się jednak, że nie tylko jest to jeden bank, ale zawsze dokładnie ten sam
adres, a zatem GPU może wykorzystać mechanizm rozgłaszania i efektywnie pobrać dane z pamięci współdzielonej. Wiersze 28 i 29 wykonują zapis wyników obliczeń do tablicy wynikowej.
Wytłumaczenia wymaga wartość correction obliczana w wierszu 28. Wartość ta, to liczba wątków, które normalnie zostałyby zapisane w tablicy wynikowej wcześniej niż aktualny wątek, ale
zostały pominięte gdyż obliczały wartości z przekątnej macierzy, bądź z jej części górnotrójkątnej. W wierszu 29, od liniowej pozycji w tablicy wynikowej, obliczonej na podstawie liczby bloków, numeru aktualnego bloku i numeru aktualnego wątku konieczne jest odjęcie wartości correction, aby wyniki zostały zapisane w tablicy wynikowej w sposób ciągły. Wyprowadzenie wzoru na correction można oprzeć o rysunek 2. Jak łatwo zauważyć, liczba wątków, w których zostały pominięte obliczenia w bloku znajdującym się na przekątnej macierzy, wynosi zawsze:
side( side + 1) / 2 . Bloki takie będą nazywane niepełnymi. Pozostałe bloki będą nazywane blokami pełnymi (na rysunku 2 są narysowane w całości na biało). Przed każdym pełnym blokiem
w ”wierszu” bloków pominięto l ∗ side( side + 1) / 2 wątków (po side( side + 1) / 2 wątków na
każdy poprzedni wiersz). W przypadku wątków znajdujących się w bloku niepełnym, należy dodatkowo doliczyć wątki, które zostały pominięte w poprzednich wierszach wątków w ramach
bloku: ty ( 2 side − ty + 1) / 2 . Powyższy wzór na odpowiednią liczbę wątków można wyprowadzić
ze wzoru na sumę szeregu arytmetycznego. Samo wyprowadzenie jest proste i nie zostało umieszczone w niniejszej publikacji. Obydwa wyżej opisane wzory są sumowane, przy czym wzór
ty ( 2 side − ty + 1) / 2 mnożony jest razy wyrażenie ( q == l ) , które wynosi 1, gdy warunek jest
spełniony (obsługiwany jest blok niepełny) i 0 w przeciwnym wypadku. Zapis ten pozwala na
uniknięcie niepotrzebnych struktur sterujących (konstrukcji if).
1. template <int side,int d>
2. __global__ void findExp(float *data, float* out, int n) {
3.
extern __shared__ float base[];
4.
float* Ml=base;
5.
float* Mq=base + side * side;
6.
int bx = blockIdx.x + blockIdx.y * gridDim.x;
7.
int l = ceil((ceilSquareRoot((bx << 3) + 9) - 3.0f) / 2.0f);
8.
int q = bx - ((l * (l + 1)) >> 1);
9.
int tx = threadIdx.x % side;
10. int ty = threadIdx.x / side;
11. int lBegin = l * side;
12. int qBegin = q * side;
13. float part = 0.0f;
14. float res = 0.0f;
15. Mq[ty * side + tx] = data[min(qBegin + tx , n - 1) + min(ty, d – 1) *
n];
Przybliżone zapytania do baz danych z akceleracją obliczeń rozkładów prawdopodobieństwa
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31. }
271
Ml[ty * side + tx] = data[min(lBegin + tx , n - 1) + min(ty, d - 1) *
n];
__syncthreads();
if ((qBegin + tx) < (lBegin + ty)) {
if ((qBegin + tx)< n && (lBegin + tx) < n ) {
for (int p = 0; p < d ; p++) {
part = 0.0f;
for (int k = 0; k < d; k++) {
part += invSigma[k * d + p] * (Ml[k * side + tx]
- Mq[ k * side + ty]);
}
res += part * (Ml[ p * side + tx] - Mq[ p * side + ty]);
}
}
int correction=(side * (side + 1) * l + (q == l) * (2 * side
- ty + 1) * ty) / 2;
out[threadIdx.x + bx * blockDim.x - correction] = res;
}
Listing 6. Kernel obliczający wartości Yi,j
1.
2.
3.
4.
__device__ float ceilSquareRoot(int x) {
float a = sqrtf(x);
return round(a) * round(a) == x ? round(a) : a;
}
Listing 7. Funkcja obliczająca pierwiastek kwadratowy z liczby, unikająca niektórych błędów obliczeń
6.3. Optymalizacja obliczania wartości (2.4)
Najbardziej kosztowny etapem obliczania wartości (2.4) dla wielu różnych wartości h jest obliczanie odpowiednich sum wartości funkcji (3.5). Sumy te można obliczyć stosując rozwiązania
podobne do tych zastosowanych przy obliczaniu sum wartości funkcji (1.6), omówione w sekcji
5.2. Zadanie to jest wykonywane przez kernele reduceKstarKernel (patrz listing 8), który oblicza
częściowe sumy wartości funkcji (3.5) i reduceMultirowKernel (patrz listing 10), który pozwala
na obliczenie ostatecznych sum wartości funkcji (3.5). Obliczone za pomocą tych kerneli wartości
są następnie przetwarzane przez kernel finalizeCalc, który oblicza ostateczne wartości (2.4).
Kernel reduceKstarKernel przyjmuje jako parametry: wskaźnik g_idata na obszar pamięci
globalnej zawierającej wartości Yi,j (3.2), wskaźnik g_odata do obszaru pamięci globalnej, do
której zostanie zapisany wynik sumowania, wartość n, wartość C1 = ( 2π ) d / 2 det( ∑) , wartość
C 2 = ( 4π )d / 2 det( ∑) , wartość minh – minimalną wartość parametru h, dla której ma zostać
obliczona wartość funkcji (2.4) oraz wartość step – różnica pomiędzy kolejnymi wartościami parametru h. Istotnym dla wytłumaczenia działania omawianego kernela jest struktura siatki obliczeń, dla której jest on wywoływany. Siatka obliczeń składa się z jednowymiarowych bloków
o liczbie wątków będącej potęgą 2. Ze względu na wydajność, dla obecnych kart graficznych powinno to być 256 lub 512 wątków. Bloki w siatce są uorganizowane w dwuwymiarową macierz.
Wiersze tej macierzy powinny zawierać tyle bloków, żeby liczba wątków w wierszu tej macierzy
była równa połowie liczby wartości do zsumowania (tutaj, połowie liczby wartości (3.2)). W sytuacji, gdy nie jest możliwe uruchomienie tak dużej siatki, powinno zostać uruchomione maksymalne 65535 bloków w wierszu siatki. Z kolei liczba wierszy bloków w siatce obliczeń powinna odpowiadać liczbie różnych wartości (2.4) do obliczenia. Każdemu blokowi przydzielany jest obszar
pamięci pozwalający na przechowanie tylu wartości ile jest wątków w bloku. Wiersze 3 do 7 mają
272
Witold Andrzejewski, Artur Gramacki, Jarosław Gramacki
analogiczne znaczenie jak odpowiadające im wiersze w kernelu reduceK6Kernel. W wierszach 8 i
9 obliczany jest kwadrat parametru funkcji obliczanej funkcji (2.4). Jak łatwo zauważyć, aktualna
wartość h obliczana jest na podstawie numeru wiersza bloków w siatce obliczeń. Wynika z tego,
iż wszystkie wątki w jednym wierszu siatki obliczeń kooperują w celu obliczenia wartości (2.4)
dla jednej wartości h. Wiersze 10 do 14 mają analogiczne znaczenie jak odpowiadające im wiersze w kernelu reduceK6Kernel. Tutaj jednak obliczane są nie wartości funkcji K6 a wartości funkcji (3.5) dla odpowiedniej, wyznaczonej wierszem siatki obliczeń wartości h. Wartość (3.5) jest
obliczana przez funkcję t_dev (patrz listing 9), która jest prostą implementacją wzorów omawianych w sekcji 5.2. Wiersze 15 do 33 mają analogiczne znaczenie jak odpowiadające im wiersze
w kernelu reduceK6Kernel. W Wierszu 34 wynik zapisywany jest do tablicy z wynikami pośrednimi. Jak łatwo zauważyć, redukcji w wyniku działania jednego bloku ulega 2 razy tyle liczb ile
jest wątków w bloku, np. 512 wątków obliczy sumę 1024 wartości (3.5). Tablica wynikowa
w pamięci globalnej, wskazywana przez g_odata zawiera zatem częściowe sumy wartości (3.5)
dla różnych wartości h. Ze względu na ułożenie danych w pamięci, można tą tablicę traktować
jako macierz zapisaną w pamięci wierszami, w której wiersze odpowiadają różnym wartościom h,
a kolumny różnym zsumowanym zakresom liczb. Konieczne jest zatem zsumowanie liczb w każdym wierszu tej macierzy. Do wykonania takiego sumowania służy kernel reduceMultirowKernel.
Kernel ten jest bardzo podobny do kernela reduceKstarKernel. Podobnie jak poprzednio siatka
obliczeń powinna składać się z jednowymiarowych bloków o liczbie wątków będącej potęgą 2.
Wiersze tej macierzy powinny zawierać tyle bloków, żeby liczba wątków w wierszu tej macierzy
była równa połowie liczby wartości do zsumowania (tutaj liczbie wartości w wierszu wynikowej
macierzy poprzedniego kernela). Kernel przyjmuje jako parametry: wskaźnik do obszaru pamięci
globalnej g_idata zawierającego wynik działania poprzedniego kernela, wskaźnik do obszaru
pamięci globalnej g_odata, do którego zapisywany będzie wynik działania kernela, oraz wartość
n. Kernel generuje wyniki o takim samym formacie jak kernel poprzedni, ale o mniejszej liczbie
wartości w wierszu (podobnie jak poprzednio redukcji w wierszu ulega 2 razy tyle wartości ile jest
wątków w bloku, czyli dla bloków złożonych z 512 wątków, każde 1024 wartości redukują się do
jednej). Wiersze 3 do 7 mają taką samą funkcjonalność jak wiersze 3 do 7 kernela reduceKstarKernel. W wierszu 8 obliczana jest wartość deltaY określająca pozycję w tablicy wejściowej, od
której zaczyna się wiersz tablicy wejściowej przetwarzany przez aktualny blok. Wiersze 9 do 11
mają podobną funkcjonalność jak wiersze 10 do 15 w kernelu reduceKStarKernel. Są jednak dwie
różnice. Po pierwsze, przy dostępie do tablicy z danymi wejściowymi uwzględniana jest wartość
deltaY, a po drugie nie jest już obliczana funkcja t_dev (nie jest to konieczne bo dodawane wartości są już sumami wartości tej funkcji). Pozostałe wiersze mają działanie analogiczne, jak w reduceKStarKernel. Jak łatwo zauważyć, w wyniku działania tego kernela otrzymywana jest nowa
macierz, w której wiersze odpowiadają różnym wartościom h, a kolumny częściowym sumom
wartości (3.5). Kernel ten wywoływany jest wielokrotnie, zgodnie ze schematem nieniszczącym,
przy czym redukcja kończy się w momencie, kiedy w wynikowej tablicy znajdzie się tylko jedna
n
kolumna. Uzyskana tablica składa się wówczas z wartości
n
∑ ∑ T (i, j, h ) dla różnych wartości
i =1 j , i < j
h. Aby uzyskać ostateczny wynik konieczne jest tylko wykonanie kilku prostych obliczeń na tych
wartościach. Ostateczne obliczenia wykonywane są przez kernel finalizeCalc przedstawiony na
listingu 11. Kernel ten przyjmuje następujące parametry: wskaźnik do obszaru w pamięci globalnej, w którym zapisany jest wynik działania poprzednich kerneli g_idata, wskaźnik do obszaru
pamięci globalnej g_odata, do której powinny zostać zapisane wartości (2.4), liczba wartości (2.4)
do obliczenia values oraz wartości n, d, minh, step, i C2, które mają takie same znaczenie jak
w przypadku kernela reduceKstarKernel. Kernel powinien być uruchamiany w jednowymiarowej
siatce obliczeń, składającej się z jednowymiarowych bloków wątków, takiej, że liczba wątków
jest równa, bądź przekracza wartość values. Kernel w pierwszym wierszu oblicza, która pozycja
wejściowej tablicy ma być przetwarzana przez aktualny wątek. Jeżeli obliczona pozycja nie wskazuje na obszar poza tablicą, to w wierszu 4 pobierana jest wartość sumy (3.5) z tablicy wejścio-
Przybliżone zapytania do baz danych z akceleracją obliczeń rozkładów prawdopodobieństwa
273
wej, w wierszu 5 wyznaczana jest wartość h, dla której ma zostać obliczona wartość (2.4),
a w wierszu 6 obliczana jest wartość (2.4) i zapisywana do tablicy wynikowej.
Obliczone w wyżej opisany sposób wartości (2.4) są przesyłane do CPU w celu znalezienia
najmniejszej wartości. Oczywiście nic nie stoi na przeszkodzie, aby najmniejsza wartość była
znajdowana na karcie graficznej. Kernel wykonujący to zadanie byłby podobny do kernela reduceKernel, ale zamiast sumy wykorzystywany byłby operator minimum, a dodatkowo musiałaby
być śledzona pozycja najmniejszej wartości. Niemniej jednak znalezienie najmniejszej wartości
spośród kilkuset liczb jest zadaniem tak banalnym i szybkim, że nie ma potrzeby angażować do
tego „potęgi obliczeniowej” ukrytej w GPU.
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
template <unsigned int blockSize>
__global__ void reduceKstarKernel(float *g_idata, float *g_odata,
unsigned int n, float C1, float C2, float minh, float step) {
extern __shared__ float sdata[];
unsigned int tid = threadIdx.x;
sdata[tid] = 0.0f;
unsigned int i = blockIdx.x * (blockSize * 2) + tid;
unsigned int gridSize = blockSize * 2 * gridDim.x;
float h2=minh + step * blockIdx.y;
h2=h2 * h2;
while (i + blockSize < n) {
sdata[tid] += t_dev(h2 , g_idata[i] , C1 , C2) +
t_dev(h2 , g_idata[i + blockSize] , C1 , C2);
i += gridSize;
}
if (i < n ) { sdata[tid] += kstar_dev(h2 , g_idata[i] , C1 , C2); }
__syncthreads();
if (blockSize >= 512) { if(tid < 256){sdata[tid] += sdata[tid + 256];}
__syncthreads();
}
if (blockSize >= 256) { if(tid < 128){sdata[tid] += sdata[tid + 128];}
__syncthreads();
}
if (blockSize >= 128) { if(tid < 64){sdata[tid] += sdata[tid + 64];}
__syncthreads();
}
if (tid < 32) {
volatile float* smem = sdata;
if (blockSize >= 64) smem[tid] += smem[tid + 32];
if (blockSize >= 32) smem[tid] += smem[tid + 16];
if (blockSize >= 16) smem[tid] += smem[tid + 8];
if (blockSize >=
8) smem[tid] += smem[tid + 4];
if (blockSize >=
4) smem[tid] += smem[tid + 2];
if (blockSize >=
2) smem[tid] += smem[tid + 1];
}
if (tid == 0) g_odata[blockIdx.x + gridDim.x * blockIdx.y] = sdata[0];
}
Listing 8. Kernel reduceKstarKernel służący do częściowego zsumowania wartości funkcji (3.5)
1.
2.
3.
4.
__device__ float t_dev(float h2,float e,float C1, float C2) {
float k;
float k2;
k = exp( -e / (2.0f * h2)) / C1;
274
Witold Andrzejewski, Artur Gramacki, Jarosław Gramacki
5.
6.
7.
k2 = exp(-e / (4.0f * h2)) / C2;
return k2 - 2.0f * k;
}
Listing 9. Kernel t_dev obliczający wartość funkcji (3.5)
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
template <unsigned int blockSize>
__global__ void reduceMultirowKernel(float *g_idata, float *g_odata, unsigned int n) {
extern __shared__ float sdata[];
unsigned int tid = threadIdx.x;
sdata[tid] = 0.0f;
unsigned int i = blockIdx.x * (blockSize * 2) + tid;
unsigned int gridSize = blockSize * 2 * gridDim.x;
unsigned int deltaY=n * blockIdx.y;
while (i + blockSize < n) { sdata[tid] += g_idata[i + deltaY] +
g_idata[i + blockSize + deltaY]; i += gridSize; }
if (i < n ) { sdata[tid] += g_idata[i+deltaY]; }
__syncthreads();
if (blockSize >= 512) { if (tid < 256) { sdata[tid] += sdata[tid + 256];
}
__syncthreads();
}
if (blockSize >= 256) { if (tid < 128) { sdata[tid] += sdata[tid + 128];
}
__syncthreads();
}
if (blockSize >= 128) { if (tid < 64) { sdata[tid] += sdata[tid + 64];
}
__syncthreads();
}
if (tid < 32) {
volatile float* smem = sdata;
if (blockSize >= 64) smem[tid] += smem[tid + 32];
if (blockSize >= 32) smem[tid] += smem[tid + 16];
if (blockSize >= 16) smem[tid] += smem[tid + 8];
if (blockSize >=
8) smem[tid] += smem[tid + 4];
if (blockSize >=
4) smem[tid] += smem[tid + 2];
if (blockSize >=
2) smem[tid] += smem[tid + 1];
}
if (tid == 0) g_odata[blockIdx.x + gridDim.x * blockIdx.y] = sdata[0];
}
Listing 10. Kernel reduceMultirowKernel służący do całkowitego zsumowania wartości funkcji (3.5)
1.
2.
3.
4.
5.
6.
7.
8.
__global__ void finalizeCalc(float *g_idata, float *g_odata, uint values,
float n, float d, float minh, float step, float C2) {
unsigned int tid=threadIdx.x + blockIdx.x * blockDim.x;
if (tid < values) {
float sum = g_idata[tid];
float h = minh + tid * step;
g_odata[tid] = (2 * sum / ((float)(n * n)) + 1.0f / (n * C2))
/ pow(h,d);
}
}
Listing 11. Kernel finalizeCalc obliczający wartość (2.4) na podstawie sumy wartości funkcji (3.5)
Przybliżone zapytania do baz danych z akceleracją obliczeń rozkładów prawdopodobieństwa
275
7. Eksperymenty wydajnościowe
W niniejszej sekcji przedstawiono wyniki eksperymentalne porównujące wydajność opracowanych przez nas rozwiązań wykorzystujących intensywnie GPU oraz implementacji tych samych
algorytmów wykorzystujących wyłącznie CPU.
Eksperymenty wykonano na komputerze Intel Core i7 2,8GHz, 4GB RAM, NVIDIA GeForce
285GTX, pod kontrolą systemu operacyjnego Arch Linux (jądro w wersji 2.6.34). Przygotowano
następujące implementacje algorytmów:
• implementacja algorytmu PLUG-IN wykorzystująca do obliczeń jedynie CPU,
• implementacja algorytmu PLUG-IN wykorzystująca rozwiązania przedstawione w niniej-
szej publikacji,
• implementacja algorytmu LSCV wykorzystująca do obliczeń jedynie CPU,
• implementacja algorytmu LSCV wykorzystująca rozwiązania przedstawione w niniejszej
publikacji.
Wszystkie algorytmy zostały zaimplementowane w języku C, a w przypadku implementacji
wykorzystujących GPU, C for CUDA. Implementacje dla CPU były kompilowane za pomocą gcc
w wersji 4.5.0. Implementacje w C for CUDA (dla GPU) były kompilowane za pomocą narzędzia
nvcc z CUDA Toolkit w wersji 3.1.
Eksperyment testujący wydajność rozwiązań dla algorytmu PLUG-IN wykonano w następujący sposób. Przygotowano 30 zestawów danych wejściowych z danymi losowymi. Kolejne zestawy zawierały odpowiednio n=1024, 2048, …, 30720 próbek. Wielkości zestawów będące wielokrotnością 1024 wybrano ze względu na fakt, iż pozwalają one na najpełniejsze wykorzystanie
mocy obliczeniowej kart graficznych. Ponieważ te zestawy danych i tak są tylko zbiorami próbek,
można ich wielkością bez problemu sterować w rzeczywistych zastosowaniach. Na każdym
z wyżej opisanych zestawów uruchomiono obydwie implementacje algorytmu PLUG-IN mierząc
czasy obliczeń, a następnie wyznaczając stosunek czasu na CPU do czasu obliczeń za pomocą
wersji GPU. Otrzymany stosunek określa tzw. przyspieszenie. Wykres zależności przyspieszenia
od liczby próbek przedstawiono na rysunku 3. Jak łatwo zauważyć, nawet dla niewielkich zbiorów danych, czas obliczeń za pomocą wersji GPU programu jest ok. 140 razy krótszy od czasu
uzyskania wyniku za pomocą wersji CPU. Przyspieszenie rośnie wraz ze wzrostem liczby próbek
w zestawie danych i osiąga wielkości bliskie 330 dla zbiorów danych o 30720 próbkach.
Eksperyment testujący wydajność rozwiązań dla algorytmu LSCV przeprowadzono w podobny
sposób jak dla algorytmu PLUG-IN. Przygotowano 160 zestawów danych, dla n=1024, 2048, …,
10240 i d=1, 2, …, 16. Dane zostały wygenerowane w sposób losowy. Podobnie jak w poprzednim eksperymencie, wartości n dobrano ze względu na to, iż pozwalają one na lepsze wykorzystanie mocy obliczeniowej karty graficznej. Parametr d przyjmuje wartości od 1 do 16, gdyż dla
większych wartości przedstawione w niniejszej publikacji rozwiązanie nie będzie działało ze
względu na ograniczenie implementacji. Rozszerzenie implementacji o obsługę większych d jest
bardzo proste (patrz [CKO09]). Należy jednak zwrócić uwagę, że w zastosowaniach praktycznych
większe wartości d występują bardzo rzadko. Na wygenerowanych zestawach danych uruchomiono obydwie implementacje mierząc czasy obliczeń, a następnie obliczono przyspieszenia. Uzyskane wyniki można zobaczyć na rysunku 4. Na wykresie przedstawiono zależność przyspieszenia
od liczby próbek w zestawie danych i wartości d, gdzie każda przedstawiona krzywa przedstawia
wynik eksperymentu dla innej wartości d. Jak łatwo zauważyć, uzyskane przyspieszenia są pomiędzy 370, a 470. Najmniejsze przyspieszenia uzyskano dla zestawów danych o d=1. Im większe d, tym lepsze przyspieszenia. Można również zaobserwować wzrost przyspieszeń wraz ze
wzrostem liczby próbek w zestawie danych.
276
Witold Andrzejewski, Artur Gramacki, Jarosław Gramacki
Rys. 3. Przyspieszenia uzyskiwane przy zastosowaniu implementacji algorytmu PLUG-IN na GPU
Rys. 4. Przyspieszenia uzyskiwane przy zastosowaniu implementacji algorytmu LSCV na GPU
Przybliżone zapytania do baz danych z akceleracją obliczeń rozkładów prawdopodobieństwa
277
Wzrost przyspieszenia wraz ze wzrostem rozmiaru zestawu danych można wytłumaczyć w następujący sposób. Dla niewielkich zestawów danych nie jest uruchamianych dużo wątków. Przykładowo, dla zsumowania 1024 wartości potrzeba 512 wątków, a zatem jeden blok. Jeden blok
jest wykonywany tylko przez jeden SM, a zatem moc obliczeniowa karty graficznej nie jest wykorzystywana w pełni. Dodatkowo, duża liczba bloków przypadających na jeden SM pozwala na
lepsze wykorzystanie jego mocy obliczeniowej. Przykładowo, kiedy wątki jednego z bloków uruchomionych na SM czekają na pobranie danych z pamięci globalnej, SM może w tym czasie
przetwarzać wątki z innego bloku. Jak łatwo również zauważyć, szybkość wzrostu przyspieszenia
maleje dla większych zestawów danych. Wynika to ze zbliżania się do górnych granic wydajności
karty graficznej. W przypadku algorytmu LSCV, przy obliczaniu wartości (3.2) dochodzi jeszcze
jeden czynnik. Dla d mniejszych od 16 niektóre wątki wykonują niepotrzebne pobrania danych
z pamięci globalnej, przez co implementacja działa wolniej niż byłaby w stanie i stąd też mniejsze
przyspieszenia. Należy jednak pamiętać, iż czas obliczania wartości (3.2) zajmuje niewielką tylko
część czasu wymaganego do obliczenia wartości (2.4), a zatem czynnik ten ma tylko znikomy
wpływ na uzyskiwane przyspieszenia.
8. Podsumowanie i wnioski
W niniejszej publikacji przedstawiono implementacje algorytmów PLUG-IN oraz LSCV pozwalających na znajdowanie wartości parametru wygładzania estymatorów jądrowych. Implementacje te wykorzystują moc procesorów kart graficznych do akceleracji czasochłonnych obliczeń i pozwalają na uzyskiwanie przyspieszeń nawet ponad czterystukrotnych. Uzyskanie tak
dużych przyspieszeń sugeruje, iż wykorzystanie GPU w bazach danych może dać olbrzymie korzyści w przyszłości. Niskie koszty kart graficznych, w połączeniu z ich wysoką mocą obliczeniową, może pozwolić na uzyskanie tanich i wysokowydajnych serwerów baz danych. Pozwoli to
na sprostanie rosnącym wraz z rozwojem zastosowań baz danych wymaganiom użytkowników.
Konieczne jest jednak opracowanie nowych algorytmów, koncepcji i rozwiązań dla baz danych,
które będą uwzględniać specyfikę przetwarzania danych na GPU. Jest to zatem olbrzymie pole dla
przyszłych badań. W przyszłości planowane są dalsze prace, w szczególności autorzy planują pracować nad następującymi zagadnieniami:
• próba implementacji zaproponowanych mechanizmów i algorytmów w Systemie Zarządza-
nia Bazą Danych firmy Oracle, w celu sprawdzenia jak działają one w rzeczywistych zastosowaniach,
• dalsze badania dotyczące zastosowań procesorów kart graficznych w bazach danych,
• eksperymenty z rozwiązaniem hybrydowym polegającym na połączeniu w jeden mecha-
nizm obliczeniowy zarówno GPU, jak i technologii klastrów obliczeniowych, np. z wykorzystaniem środowiska MPI. Pozwoliłoby to wykorzystać nie tylko moc obliczeniową jaka
tkwi w GPU, ale dodatkowo wykorzystać potencjał obliczeniowy rozwiązań klastrowych.
Można spodziewać się wówczas jeszcze większych sumarycznych przyśpieszeń obliczeń.
Bibliografia
rd
[And07]
Andrzejewski W.:”Fast K-Medoids Clustering on PCs”, Proceedings of the 3
shop on Data Mining and Knowledge Discovery (ADMKD ’07), 2007.
ADBIS Work-
[AnKa10]
Andrzejewski W., Kaźmierczak T., “Wydajne grupowanie obiektów metodą K-Medoids z wykorzystaniem technologii CUDA”, Materiały konferencyjne III Krajowej Konferencji Naukowej:
Technologie Przetwarzania Danych, p. 466-483, WNT, 2010.
278
Witold Andrzejewski, Artur Gramacki, Jarosław Gramacki
[AnWr10a]
Andrzejewski W., Wrembel R.: “GPU-WAH: Applying GPUs to Compressing Bitmap Indexes
st
with Word Aligned Hybrid”, Proceedings of the 21 International Conference on Database and
Expert Systems Applications (DEXA 2010), 2010.
[AnWr10b]
Andrzejewski W., Wrembel R., “GPU-PLWAH: GPU-based implementation of the PLWAH
algorithm for compressing bitmaps”, Materiały konferencyjne III Krajowej Konferencji Naukowej: Technologie Przetwarzania Danych, p. 56-70, WNT, 2010.
[BNP09a]
Böhm C., Noll R., Plant C., Wackersreuther B.:”Density based clustering using graphics procth
essors”, Proceedings of the 18 ACM Conference on information and knowledne management
(CIKM ’09), p 661-670, 2009.
[BNP09b]
Böhm C., Noll R., Plant C., Wackersreuther B., Zherdin A.:”Data mining using graphics processing units”, Journal on Transactions on Large Scale Data and Knowledge Centered Systems, Vol. 1 No. 1, p. 63-90, 2009.
[CKO09]
Chang D.J., Kantardzic M., Ouyang M.: Hierarchical Clustering with CUDA/GPU, Materiały
konferencyjne PDCCS 2009.
[CTZ06]
Cao F., Tung A., Zhou A.:”Scalable clustering using graphics processors”, Proceedings of the
WAIM 2006, p. 372-384, 2006.
[GaGi01]
Garofalakis M., Gibbons P.B.: Approximate Query Processing:Taming the TeraBytes!, tutorial
zaprezentowany na konferencji VLDB'01 (http://www.pittsburgh.intel-research.net/people/
gibbons/papers/vldb01-tutorial.ppt).
[GGK06]
Govindaraju N.K., Gray J., Kumar R., Manocha D.:GPUTeraSort: High Performance graphics
coprocessor sorting for large database management”, Proceedings of the 2006 ACM SIGMOD
International Conference on Management of Data.
[GKV05]
Guha S., Krishnan S., Venkatasubramanian S.: “Tutorial: data visualization and mining using
the GPU”, 2005.
[GLW04]
Govindaraju N.K., Lloyd B., Wang W., Lin M., Manocha D.: ”Fast computation od database
operations using graphics processors”, Proceedings of the 2004 ACM SIGMOD International
Conference on Management of Data, p. 215-226, 2004.
[GrGr10]
Gramacki A., Gramacki J.: Wykorzystanie informacji o rozkładzie prawdopodobieństwa do
wyznaczania przybliżonych wartości agregacji, Technologie przetwarzania danych : III Krajowa
konferencja naukowa. Poznań, 2010.
[GZ06]
Greβ A., Zachmann G., “GPU-Abisort: Optimal parallel sorting on stream architectures”, Proth
ceesings of the 20 IEEE International Parallel and Distributed Processing Symposium, 2006.
[Han06]
Han J., Kamber M.: Data Mining: Concepts and Techniques,The Morgan Kaufmann Series in
Data Management Systems, 2006.
[Har]
Harris M.: Optimizing Parallel Reduction in CUDA. http://developer.download.nvidia.com/
compute/cuda/1_1/Website/projects/reduction/doc/reduction.pdf
[Ioa99]
Ioannidis Y.E., Poosala V.: Histogram-Based Approximation of Set-Valued Query Answers,
Proceedings of the 25th International Conference on Very Large Data Bases,1999, s.174-185
[Klo99]
Klonecki W.: Statystyka dla inżynierów, Wydawnictwo Naukowo-Techniczne, 1999.
[KoCw08]
Koronacki J., Ćwik J.: Statystyczne systemy uczące się, Wydawnictwo Exit, Warszawa, 2008
[KoMi04]
Koronacki J., Mielniczuk J.: Statystyka dla studentów kierunków technicznych i przyrodniczych,
Wydawnictwo Naukowo-Techniczne, 2004.
[Kul05]
Kulczycki P.: Estymatory jądrowe w analizie systemowej, Wydawnictwo Naukowo-Techniczne,
Warszawa, 2005.
[NV10]
NVIDIA CUDA C Programming Guide 3.1, 2010.
[SAA03]
Sun C., Agrawal D., Abbadi A. E.: “Hardware acceleration for spatial selections and joins”,
Proceedings of the 2003 ACM SIGMOD International Conference on Management of Data
p. 455-466. 2003.
Przybliżone zapytania do baz danych z akceleracją obliczeń rozkładów prawdopodobieństwa
279
[Sha99]
Shanmugasundaram J., Fayyad U., Bradley P.S.: Compressed data cubes for OLAP aggregate query approximation on continuous dimensions, Proceedings of the Fifth ACM SIGKDD
International Conference on Knowledge Discovery and Data Mining, 1999, s. 223-232.
[She04]
Sheather S.J.: Density Estimation, Statistical Science, Vol. 19, No. 4, 2004, s. 588-597.
[Sil86]
Silverman B.: Density Estimation For Statistics And Data Analysis, Chapman and Hall / Monographs on Statistics and Applied Probability, London, 1986.
[Sim96]
Simonoff J.: Smoothing Methods in Statistics, Springer Series in Statistics, 1996.
[Vit99]
Vitter J.S., Wang M.: Approximate Computation of Multidimensional Aggregates of Sparse
Data Using Wavelets, Proceedings of the 1999 ACM SIGMOD International Conference on
Management of Data, s. 193-204.
[WaJo95]
Wand M.P., Jones M.C.: Kernel Smoothing, Chapman & Hall/CRC Monographs on Statistics &
Applied Probability, 1995.