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.