Zunifikowany interfejs programistyczny dla środowisk
Transkrypt
Zunifikowany interfejs programistyczny dla środowisk
Rok akademicki 2013/2014 Politechnika Warszawska Wydział Elektroniki i Technik Informacyjnych Instytut Informatyki PRACA DYPLOMOWA MAGISTERSKA Piotr Tomasz Monarski Zunifikowany interfejs programistyczny dla środowisk heterogenicznych i homogenicznych w języku Python dla operacji algebraicznych na macierzach. Opiekun pracy mgr inż. Rajmund Kożuszek Ocena: ..................................................... ................................................................. Podpis Przewodniczącego Komisji Egzaminu Dyplomowego Kierunek: Informatyka Specjalność: Inżynieria Systemów Informatycznych Data urodzenia: 1988.02.11 Data rozpoczęcia studiów: 2011.02.21 Życiorys Urodziłem się 11 lutego 1988 roku w Warszawie. Od najmłodszych lat byłem zafascynowany komputerami. Przygodę z programowaniem rozpocząłem od budowania prostych stron internetowych jeszcze przed rozpoczęciem szkoły średniej. W 2007 roku ukończyłem klasę matematyczno-fizyczno-informatyczną w Liceum Ogólnokształcącym im. Marszałka Józefa Piłsudskiego w Garwolinie, gdzie poznałem język programowania C++. Studia na kierunku Informatycznym wydziału Matematyki i Nauk Informacyjnych rozpocząłem w roku 2007 Studia te poszerzyły moje informatyczne horyzonty. Poza rozwojem naukowym byłem aktywnym samorządowcem w Samorządzie Studentów Wydziału MiNI oraz starostą grupy T1. W ramach pracy inżynierskiej zaimplementowałem grę logiczną Blokus wraz z licznymi algorytmami sztucznej inteligencji w oparciu o nowości platformy .NET 4.0. Celem dalszego pogłębienia wiedzy rozpocząłem w 2011 r. studia magisterskie na kierunku Inżynierii Systemów Informatycznych na Wydziale Elektroniki i Technik Informacyjnych Politechniki Warszawskiej. W roku 2013 skorzystałem z programu wymiany studentów Erasmus, w ramach którego odbyłem semestr na University of Surrey w Wielkiej Brytanii. ....................................................... Podpis studenta EGZAMIN DYPLOMOWY Złożył egzamin dyplomowy w dniu ..................................................................................2014 r z wynikiem ................................................................................................................................... Ogólny wynik studiów: ................................................................................................................ Dodatkowe wnioski i uwagi Komisji: .......................................................................................... ....................................................................................................................................................... ....................................................................................................................................................... STRESZCZENIE Języki skryptowe pozwalają na wydajne tworzenie programów niezależnych od środowiska wykonawczego. Wadą takiego rozwiązania jest niedostateczna wydajność operacji algebraicznych na dużych zbiorach danych. Technologią, która zapewnia znaczną moc obliczeniową dla tej grupy problemów jest programowanie na kartach graficznych. Takie rozwiązanie również ma wady takie jak wysoki koszt implementacji oraz ograniczona ilość komputerów wyposażonych w odpowiednią kartę graficzną. Wyeliminowanie wad obu technologi w kontekście operacji algebraicznych na dużych zbiorach danych jest istotą niniejszej pracy . W tym celu została stworzona biblioteka łącząca język Python z możliwościami obliczeniowymi kart graficznych. Efekt końcowy tego połączania został omówiony na bazie wyników uzyskanych z wykorzystaniem dwóch popularnych algorytmów: wstecznej propagacji błędu oraz k-najbliższych sąsiadów. Uzyskane wyniki dowodzą skuteczności takiego rozwiązania bazującego na obu technologiach. Słowa kluczowe: python, cuda, gpgpu, obliczenia równoległe, macierz, przetwarzanie heterogeniczne Python unified programming interface for heterogenic and homogenic environment for algebraic operations on matrices. Script languages allow efficient programing for independent platforms. The significant problem with scripting is insufficient performance for algebraic operations on large data sets (big-data). General Purpose computing on Graphics Processing Units is a technology, which offers great computation power for such operations. This technology has two major cons. The first one is high implementation cost and a second one is limited group of computers equipped with a proper dedicated graphic card. This thesis aims to bypass limitations of both technologies of algebraic operation on large data sets. To this end, I created library combining script languages and computation power of Graphical Processing Units. The final result are determined by performance of two algorithms: k-nearest neighbors and back propagation algorithms. The thesis concludes that combination of script languages with GPU power can overcome limitation of both technologies creating very efficient tool. Keywords: python, cuda, matrix, gpgpu, heterogenic computation, parallel computation Spis treści 1 2 3 4 Wprowadzenie 3 1.1 J˛ezyki skryptowe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4 1.2 Procesory graficzne ogólnego zastosowania . . . . . . . . . . . . . . . 7 1.2.1 8 GPGPU na przykładzie CUDA . . . . . . . . . . . . . . . . . . Projekt 12 2.1 Założenia projektu . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12 2.2 Architektura . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13 2.3 Interfejs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15 2.4 Oczekiwania . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15 Realizacja 16 3.1 Dost˛epne narz˛edzia . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17 3.2 Przebieg realizacji . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19 Implementacja 22 4.1 Architektura . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22 4.2 Inteligentne wskaźniki . . . . . . . . . . . . . . . . . . . . . . . . . . 23 4.3 Leniwa ewaluacja . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24 4.4 Operowanie na fragmentach macierzy . . . . . . . . . . . . . . . . . . 25 4.5 Komponenty . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25 4.5.1 Matrix . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26 4.5.2 Environment . . . . . . . . . . . . . . . . . . . . . . . . . . . 27 5 DataContainerFactory . . . . . . . . . . . . . . . . . . . . . . 27 4.5.4 DataContainer . . . . . . . . . . . . . . . . . . . . . . . . . . 28 4.5.5 DataCrawler . . . . . . . . . . . . . . . . . . . . . . . . . . . 29 4.5.6 DataCrawlerFactory . . . . . . . . . . . . . . . . . . . . . . . 30 4.5.7 Iterator . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30 4.5.8 ThrustIterator . . . . . . . . . . . . . . . . . . . . . . . . . . . 31 4.5.9 Operations . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31 4.6 Adapter dla j˛ezyka Python . . . . . . . . . . . . . . . . . . . . . . . . 33 4.7 Proces optymalizacji . . . . . . . . . . . . . . . . . . . . . . . . . . . 34 Zastosowanie w praktyce 37 5.1 Kontekst . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 38 5.2 Procedura testowa . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40 5.3 Sieci neuronowe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41 5.4 6 4.5.3 5.3.1 Wyniki . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43 5.3.2 Omówienie wyników . . . . . . . . . . . . . . . . . . . . . . . 46 K-najbliższych sasiadów ˛ . . . . . . . . . . . . . . . . . . . . . . . . . 48 5.4.1 Porównanie mtx i mtx_op . . . . . . . . . . . . . . . . . . . . 48 5.4.2 Wyniki . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 52 5.4.3 Omówienie wyników . . . . . . . . . . . . . . . . . . . . . . . 55 Zakończenie 57 6.1 Wnioski z przetwarzania danych . . . . . . . . . . . . . . . . . . . . . 57 6.2 Wnioski wynikajace ˛ z realizacji projektu . . . . . . . . . . . . . . . . . 58 6.3 Perspektywy rozwoju . . . . . . . . . . . . . . . . . . . . . . . . . . . 59 Bibliografia 61 Rozdział 1 Wprowadzenie Wprowadzenie dwurdzeniowych procesorów w 2005 roku przez firm˛e Intel, rozpocz˛eło er˛e przetwarzania równoległego na komputerach osobistych. Ten pierwszy dwurdzeniowy procesor umożliwił rozpowszechnienie technik wykorzystywanych dotychczas tylko w klastrach obliczeniowych czy superkomputerach. Kluczowy krok w rozwoju tego typu przetwarzania na komputerach osobistych nastapił ˛ dwa lata później. W 2007 roku umożliwiono wykorzystanie setek rdzeni dost˛epnych w procesorach graficznych do obliczeń ogólnego zastosowania. Stworzono w ten sposób nowe możliwości zwiazane ˛ z przetwarzaniem na maszynach heterogenicznych, które sa˛ poznawane po dziś dzień. Narz˛edzia udost˛epnione w 2007 roku umożliwiły programowanie w środowiskach heterogenicznych, wykorzystujacych ˛ do obliczeń różne architektury jednostek obliczeniowych (procesorów). Mimo dynamicznego rozwoju tych narz˛edzi programiści preferuja˛ środowiska homogeniczne wykorzystujace ˛ jeden typu procesora (np. x86). Na taki stan rzeczy nakłada si˛e znaczny koszt napisania programu dla środowisk heterogenicznych w niskopoziomowych interfejsach programistycznych. Fakt dużego zróżnicowania dost˛epnych konfiguracji sprz˛etowych również jest nie bez znaczenia. Uwzgl˛ednienie każdej konfiguracji wymaga dopasowania kodu do każdej z nich lub ograniczenie dopuszczalnej bazy sprz˛etowej. Wprowadzenie 4 J˛ezyki skryptowe sa˛ coraz cz˛eściej wybieranym narz˛edziem do rozwiazywania ˛ szerokiej gamy problemów. Wszechstronność j˛ezyków skryptowych umożliwia znaczne skrócenie czasu wytwarzania oprogramowania kosztem dodatkowego nakładu obliczeniowego. Przyczyna˛ tej popularności jest dost˛epność coraz szybszych procesorów centralnych (CPU). Pomimo swojej wszechstronności, j˛ezyki skryptowe nie znajduja˛ zastosowania do rozwiazywania ˛ problemów numerycznych na dużych zbiorach danych ze wzgl˛edu na niedostateczna˛ wydajność takiego rozwiazania. ˛ Połaczenie ˛ obu technologii w kontekście operacji numerycznych na dużych zbiorach danych może przyczynić si˛e do zniwelowania wad (ograniczeń) obu technologii. Czy takie połaczenie ˛ faktycznie pozwoli na wykorzystanie j˛ezyków skryptowych do tego typu problemów? Czy możliwe b˛edzie zachowanie niezależności skryptu od środowiska wykonawczego? Jak zrealizować takie połaczenie ˛ w oparciu o dost˛epne narz˛edzia? Czy uprości to programowanie na kartach graficznych? Udziel˛e odpowiedzi na te pytania poprzez projekt majacy ˛ na celu stworzenie biblioteki implementujacej ˛ operacje na macierzach w obu środowiskach wykonawczych przy zachowaniu spójnego interfejsu programistycznego. 1.1 J˛ezyki skryptowe J˛ezyki skryptowe w odróżnieniu od j˛ezyków kompilowanych do kodu maszynowego (C/C++) określanych również jako j˛ezyki systemowe, wymagaja˛ do działania dodatkowej aplikacji zwanej interpreterem. Interpreter jest programem przetwarzajacym ˛ skrypt, czyli program napisany w j˛ezyku skryptowym i dopiero na jego podstawie interpreter wykonuje odpowiednie operacje w docelowym środowisku. Tego typu j˛ezyki zostały stworzone z myśla˛ o zautomatyzowaniu powtarzalnych czynności i usprawnieniu administracji systemami operacyjnymi. Pierwszym j˛ezykiem skryptowym był JCL (Job Control Language) z lat 60-tych wykorzystywany do sekwencjonowania zadań w maszynach dla systemu OS/360. JCL odbiega od współczesnych standardów j˛ezyków skryptowych ze Wprowadzenie 5 wzgl˛edu na operowanie w innym środowisku wykonawczym. Prawdziwym protoplasta˛ dzisiejszych j˛ezyków skryptowych jest Thomson Shell (sh) napisany w 1971 roku jako pierwsza implementacja powłoki systemu Unix (Unix Shell). Stworzony w celu ułatwienia zarzadzania ˛ i administrowania systemem operacyjnym, umożliwia wywoływanie innych programów, proste operacje warunkowe oraz funkcje zwiazane ˛ z podstawowym przetwarzaniem tekstu. Dodatkowo Thomson Shell jest cz˛esto wykorzystywany do tworzenia nowych programów poprzez kompozycj˛e już istniejacych. ˛ J˛ezyk Ekspresywność wyrażenia Ekspresywność linii kodu C 1 1 C++ 2.5 1 Java 2.5 1.5 Python 6 6.5 Tablica 1.1: Ekspresywności j˛ezyków w odniesieniu do j˛ezyka C. Z czasem j˛ezyki skryptowe znajdowały coraz szersze zastosowanie od generowania interfejsów graficznych (TCL), po rozszerzenie funkcjonalności licznych programów biurowych (Visual Basic). W latach 90-tych różnica w funkcjonalności mi˛edzy j˛ezykami skryptowymi, a systemowymi była znaczna. Pierwsza grupa umożliwiała wywoływanie innych programów oraz proste przetwarzanie tekstu. Druga grupa reprezentowana przez j˛ezyki C/C++ umożliwiała rozwiazywanie ˛ dowolnych problemów. Celem uzupełniania tej luki, w roku 1989 rozpocz˛eto prace nad uniwersalnym j˛ezykiem skryptowym Python. Pierwsza wersja 0.9.0, opublikowana w 1991 roku, umożliwiała operacje na prostych kolekcjach danych przybliżajac ˛ tym samym j˛ezyki skryptowe do systemowych. Wersj˛e 1.0 wydano trzy lata później. Dostarczyła ona szeroki wachlarz narz˛edzi umożliwiajacy ˛ programowanie w ramach dowolnego paradygmatu programowania. Korzystanie z interpretera w miejscu kompilatora i kodu maszynowego umożliwia operowanie na wyższym poziomie abstrakcji, cz˛esto utożsamianym z ekspresywnościa˛ j˛ezyka. Opisuje ona średnia˛ liczb˛e instrukcji maszynowych przypadajacych ˛ na jedno wyrażenie w danym j˛ezyku. Ta zależność oznacza w praktyce, że j˛ezyki bardziej ekspresywne Wprowadzenie 6 umożliwiaja˛ opisanie tej samej funkcjonalności krótszym kodem. Tabela 1.1 przedstawia ekspresywność j˛ezyków ze wzgl˛edu na pojedyncze wyrażenie oraz pojedyncza˛ lini˛e kodu w porównaniu do j˛ezyka C. Realizacja wysokiego poziomu abstrakcji została osiagni˛ ˛ eta przez wykorzystanie dynamicznych zmiennych, automatycznego zwalniania pami˛eci oraz możliwości dynamicznej modyfikacji wykonywanego kodu. W j˛ezykach skryptowych możliwe jest odwoływanie si˛e do różnych typów obiektów za pomoca˛ jednej zmiennej dopóki poszczególne obiekty zapewniaja˛ implementacj˛e wywoływanych funkcji w kodzie programu. W przypadku braku wymaganej funkcjonalności interpreter informuje o bł˛edzie. Automatyczne oczyszczanie pami˛eci usprawnia proces programowania ze wzgl˛edu brak konieczności r˛ecznego zwalniania pami˛eci. Obiekty, do których nie odwołuje si˛e już żadna zmienna sa˛ automatycznie usuwane zapobiegajac ˛ w ten sposób wyciekom pami˛eci. Elementem wpływajacym ˛ również na ekspresywność j˛ezyka jest jego zgodność z paradygmatem metaprogramowania, czyli dynamicznego tworzenia programów poprzez generowanie i modyfikacj˛e wykonywanego kodu. Wymienione właściwości j˛ezyków skryptowych, na przykładzie j˛ezyka Python, sa˛ ich głównymi zaletami, a zarazem wadami. Dynamiczne typy danych, automatyczne odśmiecanie oraz przetwarzanie z wykorzystaniem interpretera sa˛ kosztowne obliczeniowo, co przez wiele lat ograniczało wykorzystanie j˛ezyków skryptowych. Były one głównie używane na potrzeby skryptów administracyjnych, programów generujacych ˛ strony internetowe czy jako narz˛edzia do prototypowania docelowych rozwiazań. ˛ Dzi˛eki rozwojowi techniki to ograniczenie przestało być problemem i coraz wi˛ecej aplikacji użytkowych pisanych jest dziś za pomoca˛ j˛ezyków skryptowych. Poza dodatkowym nakładem obliczeniowym dynamiczny charakter zmiennych ma jeszcze jedna˛ wad˛e: jest nia˛ brak możliwości sprawdzenia poprawności działania programu inaczej niż w momencie wychwycenia bł˛edów podczas wykonania programu. Jest to wada wykluczajaca ˛ wykorzystanie j˛ezyków skryptowych w aplikacjach czułych na bł˛edy i wymagajacych ˛ nieprzerwanego funkcjonowania. Wprowadzenie 1.2 7 Procesory graficzne ogólnego zastosowania Technologia umożliwiajaca ˛ wykorzystanie tysi˛ecy rdzeni do obliczeń ogólnego zastosowania ma swój poczatek ˛ wraz ze stworzeniem procesora graficznego (GPU - Graphics Processing Unit) w latach 80-tych. Firma Intel jako pierwsza wyprodukowała kontroler (SBX 275 Video Graphics Controller Multimodule Board), który umożliwiał sprz˛etowe wsparcie dla przetwarzania grafiki 2D. Kolejny krok został postawiony przez firm˛e Matrox w 1995 roku wprowadzajac ˛ a˛ wsparcie dla grafiki 3D. Przetwarzanie i renderowanie obrazu charakteryzuje si˛e wykonywaniem identycznych operacji na każdym elemencie zbioru danych (SIMD - Single Instruction Multiple Data) głównie reprezentowanych jako wektory lub macierze. Specyfika tego problemu przyczyniła si˛e do powstania wyspecjalizowanych procesorów wykonujacych ˛ równolegle identyczny zbiór instrukcji na różnych danych. Pierwsze procesory graficzne wspierały proste, zdefiniowane wcześniej operacje takie jak wypełnianie wielokatów. ˛ Na przełomie lat 90-tych i poczatku ˛ nowego milenium rozwój techniki pozwolił na pisanie własnych programów operujacych ˛ na każdym elemencie przetwarzanych danych w układzie cieniowania wierzchołków (vertex-shader) czy w układzie cieniowania pikseli (pixel-shader). Możliwości równoległego wykonania prostego programu operujacego ˛ na dużych zbiorach danych przy niewielkim nakładzie finansowym było pokusa˛ nie do odparcia dla środowisk naukowych. Superkomputery oraz klastry obliczeniowe sa˛ po dziś dzień drogim rozwiazaniem ˛ w porównaniu do kosztu zakupu pojedynczej karty graficznej, która umożliwia znaczace ˛ skrócenie czasu potrzebnego do rozwiazania ˛ szerokiej gamy problemów w kontekście architektury SIMD. Środowiska naukowe nie zostały zniech˛econe ograniczeniami zwiazanymi ˛ z dost˛epem do wyników obliczeń wykonanych na kartach graficznych. Projektowane wtedy karty graficzne nie zakładały dwukierunkowej komunikacji z procesorem centralnym. Zaangażowanie środowisk naukowych nie pozostało bez echa, potencjał kart graficznych Wprowadzenie 8 został dostrzeżony przez producentów procesorów graficznych, którzy rozpocz˛eli prace nad odpowiednia˛ technologia.˛ Wykorzystywanie ich do obliczeń niezwiazanych ˛ z renderowaniem grafiki zostało opisane nowym terminem jakim jest Obliczenia Ogólnego Zastosowania na Procesorach Graficznych (GPGPU - General Purpose computing on Graphics Processing Units). Pierwsze procesory graficzne stworzone w myśl tej idei ujrzały światło dzienne dopiero w lutym 2007 roku. Pionierem w tej dziedzinie jest firma Nvidia, która jako pierwsza zaprezentowała środowisko deweloperskie CUDA (Compute Unified Device Architecture) [18], które wraz z procesorem graficznym G80 w pełni urzeczywistniło id˛e obliczeń ogólnego zastosowania z wykorzystaniem kart graficznych. Konkurencyjna firma AMD wprowadziła odpowiednia˛ technologi˛e w grudniu tego samego roku pod nazwa˛ Steam Computing, a już w styczniu 2008 roku zostało założone konsorcjum i opracowany otwarty standard opisujacy ˛ GPGPU o nazwie OpenCL [20]. Kolejne lata min˛eły pod znakiem rewolucji obliczeniowej, która moc klastrów obliczeniowych oraz superkomputerów wprowadziła do komputerów osobistych. Obecnie do nowej technologii dostosowywane sa˛ liczne programy. Dzi˛eki temu przetwarzanie dużych zbiorów danych, takich jak obraz, dźwi˛ek, grafika, może być przyśpieszone nawet setki razy. 1.2.1 GPGPU na przykładzie CUDA Wykorzystanie procesora graficznego do obliczeń ogólnego zastosowania różni si˛e od standardowego podejścia z wykorzystaniem tylko jednostki centralnej (CPU). Różnice postaram si˛e opisać na przykładzie technologii CUDA. Procesory centralne projektowane sa˛ pod katem ˛ zminimalizowania opóźnień w dost˛epie do danych, możliwości wykonywania procesów poza kolejnościa˛ oraz optymalizacji kolejności ich wykonania. Karty graficzne maja˛ zapewnić maksymalna˛ wydajność przy przetwarzaniu danych dedykujac ˛ każdemu procesowi osobna˛ fizyczna˛ jednostk˛e wy- Wprowadzenie 9 CPU GPU Diagram 1.1: Różnica w budowie CPU i GPU konawcza.˛ Innymi słowy procesory sa˛ projektowane do interaktywnych dynamicznych operacji, natomiast karty graficzne działaja˛ w sposób wsadowy optymalizujac ˛ czas wykonania danego zadania poprzez jego zrównoleglenie. Istotnym elementem rozróżniajacym ˛ oba procesory jest organizacja pami˛eci. W procesorach centralnych z rodziny x86 możemy wyróżnić pami˛eć typu L1, L2, L3, pami˛eć operacyjna˛ i dysk twardy. Pami˛eci L1, L2 i L3 sa˛ pami˛eciami podr˛ecznymi procesora przeznaczonymi odpowiednio to przechowywania instrukcji wykonywanych programów, danych na których operuja˛ oraz do komunikacji mi˛edzy poszczególnymi rdzeniami. Pami˛eć podr˛eczna tego typu nie jest zarzadzana ˛ przez programist˛e a poprzez specjalny kontroler w procesorze i w nieznacznym stopniu przez kompilatory. Dopiero pami˛eć operacyjna i dane na dysku twardym sa˛ bezpośrednio adresowane przez programist˛e. Charakterystyka poszczególnych rodzajów pami˛eci została przedstawiona w tabeli 1.2. Typ Pojemność Czas dost˛epu Rejestry 32 B - 64 B < 1 ns L1 ≤ 128 KB 1 ns L2 ≤ 4 MB 1-2 ns L3 ≤ 144 MB 2-5 ns Pami˛eć operacyjna 256 MB.. 32GB Dysk twardy > 60 GB 10-50 ns > 10 ms Tablica 1.2: Charakterystyka pami˛eci dla procesora centralnego Wprowadzenie 10 Diagram 1.2: Schemat przetwarzania z wykorzystaniem GPGPU Technologia CUDA wprowadza bardziej rozbudowana˛ struktur˛e pami˛eci, w ramach której wyróżniamy: pami˛eć globalna˛ (global memory), pami˛eć tekstur (texture memory), pami˛eć stałych (constant memory), pami˛eć współdzielona˛ (shared memory) i pami˛eć lokalna˛ (local memory). Pami˛eć operacyjna i dyski twarde nie sa˛ dost˛epne dla procesora graficznego. Dost˛ep do nich kontrolowany jest przez procesor centralny (CPU) za pośrednictwem specjalnej magistrali (diagram 1.2). Procesor graficzny może czytać i zapisywać dane do wszystkich rodzajów pami˛eci z wyjatkiem ˛ pami˛eci tekstur i stałych, z których możliwy jest tylko odczyt, co umożliwia skuteczniejsza˛ optymalizacj˛e tej operacji. Charakterystyka poszczególnych typów pami˛eci została zebrana w tablicy 1.3. Dane wczytane z pami˛eci stałych lub tekstur sa˛ automatycznie przechowywane w pami˛eci podr˛ecznej, której rol˛e pełnia˛ rejestry. Nowsze wersje środowiska CUDA umożliwiaja˛ również przechowywanie w rejestrach danych wczytanych zarówno z pami˛eci lokalnej jak i pami˛eci globalnej. Schemat przetwarzania z wykorzystaniem karty graficznej został przedstawiony na diagramie 1.2. W pierwszej kolejności (1) dane sa˛ kopiowane z pami˛eci operacyjnej do pami˛eci globalnej na karcie graficznej. Nast˛epnie (2) procesor wywołuje odpowiednie Wprowadzenie 11 Typ Pojemność Wzgl˛edny czas dost˛epu Rejestry 32 KB - 256 KB x1 Pami˛eć lokalna 16 KB lub 512 KB x100 Pami˛eć współdzielona 16 KB lub 48 KB x1 Pami˛eć globalna ≤ 6 GB x100 Pami˛eć tekstur ≤ 6 GB x1 − x100 Pami˛eć stałych 64 KB x1 − x100 Tablica 1.3: Charakterystyka pami˛eci na kartach graficznych dla jednego multiprocesora funkcje na karcie graficznej zwane funkcjami jadra ˛ (kernel function). Program jest wykonywany asynchronicznie na wielu rdzeniach karty graficznej. Po zakończonym działaniu programu możliwe jest (4) skopiowanie danych z pami˛eci globalnej do pami˛eci operacyjnej. Rozdział 2 Projekt Niniejszy projekt stanowi drog˛e do poznania odpowiedzi na pytania zadane we wprowadzeniu (1). Poprzedni rozdział przybliża również rodzin˛e j˛ezyków skryptowych jak i termin obliczeń heterogenicznych. Istnieja˛ już projekty łacz ˛ ace ˛ obie technologie (3.1), ale w kontekście poczynionych założeń (2.1) jest to projekt nowatorski. Wypracowanie ostatecznego rozwiazania ˛ wiazało ˛ si˛e z licznymi próbami, które również przynosza˛ odpowiedzi na przedstawione pytania. 2.1 Założenia projektu Duża cz˛eść programów przetwarzajacych ˛ duże zbiory danych wykorzystuje do ich reprezentacji wektory i macierze, formalizujac ˛ w ten sposób proces ich przetwarzania w oparciu o algebr˛e liniowa.˛ Popularnym komercyjnym programem implementujacym ˛ znaczna˛ cz˛eść algebry liniowej do operacji na macierzach jest Matlab. Jest to program uznawany za bezkonkurencyjny na swoim polu, niestety nie tak wszechstronny jak j˛ezyki skryptowe ogólnego zastosowania (Python, Ruby). Cecha˛ charakterystyczna˛ tego narz˛edzia jest zaawansowany interfejs programistyczny oraz duża wydajność, która zainspirowała powstanie darmo- Projekt 13 wego odpowiednika jakim jest program Octave. Wykorzystanie tego przemyślanego interfejsu programistycznego (API) jest warte rozważenia przy projektach operujacych ˛ na obiektach typu macierz. Przetwarzanie za pomoca˛ j˛ezyków skryptowych zakłada niezależność wykonywanego skryptu od środowiska wykonawczego. Jest to możliwe dzi˛eki wykorzystaniu osobnej aplikacji do interakcji ze środowiskiem. Jest to ważna cecha j˛ezyków skryptowych, która zostanie uwzgl˛edniona w ramach projektu. W kontekście przetwarzania danych na karcie graficznej za pośrednictwem j˛ezyka skryptowego należy zagwarantować poprawne działanie skryptu również na maszynach bez odpowiedniego procesora graficznego. Wykorzystanie procesorów graficznych do przetwarzania dużych zbiorów danych niesie ze soba˛ silne ograniczenie, jakim jest dost˛epność pami˛eci globalnej. Współczesne karty moga˛ posiadać nawet 6 GB takiej pami˛eci, ale powszechnie dost˛epne karty posiadaja˛ jej tylko 1 GB. Przy uwzgl˛ednieniu równoległej pracy w środowisku graficznym i innych programów, jak na przykład przegladarki ˛ internetowe, pami˛eć dost˛epna na karcie graficznej może skurczyć si˛e do 100 MB. Przetwarzanie dużych zbiorów danych przy tak ograniczonych zasobach jest mocno utrudnione dlatego implementowany projekt b˛edzie umożliwiał kontrol˛e nad wykorzystaniem dost˛epnych zasobów. Powyższe wprowadzenie do założeń projektu można zebrać w niniejszej liście: 1. Interfejs programistyczny niezależny od środowiska wykonawczego. 2. Efektywne wykorzystanie dost˛epnych zasobów. 3. Opracowanie interfejsu programistycznego na wzór znanego z programu Matlab. 2.2 Architektura Środowiska deweloperskie umożliwiajace ˛ prac˛e z GPGPU sa˛ wspierane tylko przez j˛ezyki systemowe takie jak C/C++. Wymusza to implementacj˛e wymaganej funkcjonal- Projekt 14 ności w takim j˛ezyku i dopiero późniejsze udost˛epnienie funkcjonalności jako modułu dla docelowego j˛ezyka skryptowego. Kolejnym elementem wpływajacym ˛ na struktur˛e projektu sa˛ znaczne różnice pomi˛edzy interfejsem programistycznym dla GPGPU i standardowego CPU. Wymaga to stworzenia niezależnej implementacja każdego z nich. Pogladowy ˛ schemat takiego rozwiazania ˛ przedstawia diagram 2.1. pyMTX MTX GPGPU CPU Diagram 2.1: Schemat struktury projektu Fragment projektu napisany za pomoca˛ j˛ezyka systemowego, został nazwany MTX. Nazwa pochodzi od powszechnie stosowanego skrótu słowa macierz w j˛ezyku angielskim (matrix). Wykorzystanie takiego skrótu podkreśla główne zadanie tworzonej biblioteki, jaka˛ jest implementacja obiektu macierzy oraz operacji na nim. Zewn˛etrzna˛ warstwa˛ projektu jest pyMTX. Moduł ten udost˛epnia zaimplementowana˛ funkcjonalność do zadanego j˛ezyka skryptowego. Projektowanie komponentów pisanych w j˛ezykach kompilowanych na potrzeby j˛ezyków skryptowych wymaga uwzgl˛ednienia cech docelowego j˛ezyka. Jedna˛ z takich cech jest brak operacji przypisania, co wymusza płytkie kopiowanie obiektów. Implementacj˛e tej cechy oraz automatycznego zarzadzania ˛ pami˛ecia˛ w j˛ezykach systemowych można zrealizować za pomoca˛ inteligentnych wskaźników. Wskaźniki tego typu umożliwiaja˛ bezpiecznie płytkie kopiowanie obiektów zawierajacych ˛ referencje do dynamicznie alokowanych danych, oraz ich zwolnienie w momencie usuni˛ecia ostatniego wskaźnika. Projekt 2.3 15 Interfejs Tworzona biblioteka nie ma na celu dostarczenia pełnej funkcjonalności takich programów jak Matlab. Zakładane funkcje umożliwia˛ wykonanie podstawowych operacji na macierzach, umożliwiajacych ˛ implementacj˛e licznych algorytmów takich jak sieci neuronowe czy k-najbliższych sasiadów ˛ (k-nearest neighbors). Implementacj˛e tych i innych algorytmów umożliwi już zestaw takich funkcji jak: • Operacje na elementach macierzy za pomoca˛ funkcji logarytmicznych, trygonometrycznych oraz podnoszenie elementów macierzy do zadanej pot˛egi. • Dodawanie, odejmowanie, mnożenie, dzielenie wartości skalarnej i macierzy. • Dodawanie, odejmowanie, mnożenie, dzielenie macierzy ze soba˛ element do elementu. • Mnożenie macierzy. • Transponowanie macierzy. • Operowania na fragmentach macierzy (tak zwanych wyci˛eciach) 2.4 Oczekiwania Tworzona biblioteka nie ma na celu optymalizacji pojedynczych algorytmów, a powinna umożliwić przyśpieszenie wykonywania szerokiej gamy skryptów. Poprawa wydajności na komputerach wyposażonych w odpowiednia˛ kart˛e graficzna˛ powinna zdeklasować konkurencyjne rozwiazania ˛ w środowisku homogenicznym. Dodatkowo w przypadku braku takiej karty graficznej, wydajność nie powinna odbiegać od tej oferowanej przez alternatywne narz˛edzia. Uzyskanie takich wyników potwierdzi możliwość stworzenia zakładanego uniwersalnego interfejsu programistycznego. Sukces projektu w dużej mierze b˛edzie zależał od rozwoju technologii oraz zaangażowania samego środowiska programistów j˛ezyka Python. Rozdział 3 Realizacja Istnieje wi˛ecej niż jedna możliwa realizacja przedstawionego projektu w ramach dost˛epnych narz˛edzi i technologii. Istnieja˛ narz˛edzia umożliwiajace ˛ wykorzystanie kart graficznych do obliczeń, ale tylko dwa zostały stworzone z myśla˛ o obliczeniach ogólnego zastosowania. Sa˛ nimi OpenCL oraz CUDA. OpenCL jest uniwersalnym standardem wspieranym przez licznych producentów sprz˛etu komputerowego. CUDA, z drugiej strony, jest opatentowana˛ technologia˛ firmy Nvidia. Ze wzgl˛edu na ograniczone zasoby, wspieranie obu z nich było niemożliwe. Do realizacji projektu została wybrana platforma CUDA. Taki wybór został podyktowany wzgl˛edami praktycznymi. Po pierwsze, platforma firmy Nvidia wykorzystuje obiektowy j˛ezyk programowania C++, a nie strukturalny C jak to ma miejsce w OpenCL. Po drugie, ze wzgl˛edu na dost˛epność licznych i różnorodnych narz˛edzi, bibliotek oraz projektów dla środowiska CUDA. Z wielu j˛ezyków skryptowych ogólnego zastosowania zdecydowałem si˛e na j˛ezyk Python [22], który charakteryzuje si˛e przejrzysta˛ składnia,˛ bogata˛ dokumentacja˛ oraz gotowymi narz˛edziami do jego integracji z kodem napisanym w C++. Jest jednym z najpopularniejszych j˛ezyków wybieranych do realizacji nowych projektów oraz jako jedyny j˛ezyk skryptowy posiada już pewna˛ baz˛e projektów wykorzystujacych ˛ środowisko CUDA. Realizacja 3.1 17 Dost˛epne narz˛edzia Technologia CUDA w ramach podstawowego pakietu narz˛edzi [6] zawiera takie biblioteki jak cuBLAS, CUDA MATH, CUDA RAND czy Thrust [23]. Implementuja˛ one odpowiednio popularny interfejs programistyczny BLAS [2], zbiór funkcji matematycznych, funkcje do generowania liczb losowych oraz implementacj˛e STL (Standard Template Library) dla procesora graficznego. Wymienione biblioteki umożliwiaja˛ efektywna˛ i wydajna˛ implementacj˛e macierzy. Jest to dopiero cz˛eść gotowych narz˛edzi, jakie zostały stworzone dla środowisko CUDA. Biblioteki takie jak CUSP, CULV, cuSparse czy MAGMA implementuja˛ już gotowe rozwiazania ˛ z rodziny algebry liniowej w tym również macierze. Wartym wymienienia jest również komercyjny projekt ArrayFire [1], który udost˛epnia bogata˛ funkcjonalność. Wymienione biblioteki dost˛epne sa˛ dla j˛ezyka C++. Stanowia˛ one solidna˛ podstaw˛e dla rozpatrywanego projektu, ale same w sobie nie stanowia˛ rozwiazania ˛ problemu ponieważ docelowa˛ platforma˛ tego projektu jest j˛ezyk Python. Wspomniane projekty łacz ˛ ace ˛ j˛ezyk Python i środowisko CUDA to mi˛edzy innymi pyCuda [27], CUV [8], CUDAMat [7], copperhead [5] i Numba Pro [16]. Wymienione projekty zdawały si˛e być gotowa˛ lub prawie gotowa˛ implementacja˛ oczekiwanego projektu. Niestety próba wykorzystania ich w praktyce uwypukliła liczne braki i ograniczenia każdego z nich uniemożliwiajace ˛ zrealizowanie zakładanych funkcjonalności. Pierwszy z nich (pyCuda) jest w głównej mierze adapterem środowiska deweloperskiego CUDA dla j˛ezyka Python rozbudowanym o możliwość dynamicznej kompilacji kodu rdzeni. Złożone wywołania w j˛ezyku C++ zostały opakowane w przejrzyste obiekty o wysokim poziomie abstrakcji, czyniac ˛ z tego projektu idealne narz˛edzie do prototypowania. Rozwijana przez Andreasa Klöcknera biblioteka na tym nie poprzestaje, rozszerzajac ˛ moduł o implementacj˛e obiektu zgodnego z interfejsem numpy.array [17] na kartach graficznych tworzac ˛ bardzo uniwersalne i efektywne narz˛edzie. Kolejny projekt, CUV w przeciwieństwie od poprzedniego miał na celu stworzenie Realizacja 18 narz˛edzi umożliwiajacych ˛ sprawna˛ implementacj˛e licznych algorytmów z rodziny uczenia maszyn. CUV udost˛epnia obiekt macierzy wzorujacej ˛ si˛e na programach takich jak Matlab [14] czy Octave [19]. Posiada on pełne wsparcie operacji wymaganych w ramach niniejszego projektu dla środowiska GPGPU. Projekt udost˛epnia interfejs programistyczny zarówno dla j˛ezyka C++ jak i j˛ezyka Python czyniac ˛ go wszechstronnym narz˛edziem dla środowiska naukowego. CUDAMat jest projektem znacznie skromniejszym od już omówionych. Udost˛epnia czytelne API umożliwiajace ˛ realizacj˛e wi˛ekszości operacji na macierzach. Został stworzony by uzupełnić braki w raczkujacym ˛ wtedy połaczeniu ˛ środowiska CUDA i j˛ezyka Python. Niestety architektura, w której ściśle zwiazano ˛ kolumny macierzy z rdzeniami CUDA znaczaco ˛ utrudnia dalszy rozwój tego projektu. Ostatnie dwa projekty reprezentuja˛ zupełnie inne podejście do wykorzystania synergii j˛ezyka Python z moca˛ kart graficznych. Zamiast tworzyć osobna˛ bibliotek˛e wykorzystujac ˛ a˛ CUDA i opakowywać jej wywołania dla j˛ezyka Python, oba projekty wykorzystuja˛ natywny kod j˛ezyka skryptowego, poprzez rozszerzenie interpretera o możliwość kompilacji i wykonanie odpowiedniego fragmentu kodu na kartach graficznych. Pierwszy z nich copperhead jest udost˛epniony na zasadach otwartego oprogramowania i wspiera wiele obiektów i operacji ze standardowego zbioru j˛ezyka. Kolejny projekt to Numba Pro, oferuje znacznie wi˛eksza˛ funkcjonalność niż copperhead, ale jest rozwiazaniem ˛ płatnym. Wykorzystanie w projekcie już istniejacych, ˛ dojrzałych rozwiazań ˛ pozwala znacznie zredukować zarówno czas implementacji jak i testowania. Powyżej przedstawiona analiza dost˛epnych narz˛edzi nie pozwala ustalić właściwego projektu bazowego. Z tego powodu były one kolejno testowane. Proces ten został opisany w kolejnej cz˛eści (3.2). Doprowadził on do opracowania architektury projektu umożliwiajacej ˛ wydajna˛ realizacj˛e wszystkich wymagań. Realizacja 3.2 19 Przebieg realizacji Szeroki wachlarz dost˛epnych narz˛edzi i technologii pozwala na różne implementacje. Pierwszy krokiem w celu wybrania tej właściwej, jest analiza dost˛epnych projektów, na bazie których można zrealizować zakładana˛ funkcjonalność. Biblioteki CUSP i CULV dost˛epne w ramach podstawowego zestawu narz˛edzi projektu CUDA oferuja˛ implementacj˛e macierzy dla GPGPU. Niestety oba projekty nie wspieraja˛ operacji na fragmentach macierzy znacznie ograniczajac ˛ ich praktyczne wykorzystanie w projekcie. Projekt, który takie wsparcie zapewnia to ArrayFire, którego komercyjny charakter wyklucza go z dalszych rozważań. Inspiracja˛ dla niniejszej pracy był projekt pyCuda. Implementuje on obiekty takie jak puCuda.gpuArray oraz numpy.array, które wydawały si˛e być idealnym połaczeniem ˛ procesora graficznego z j˛ezykiem skryptowym. Próba wykorzystania tego obiektu zakładała stworzenie odpowiedniej fasady w j˛ezyku Python i w zależności od dost˛epności modułu pyCuda wykorzystywanie tego modułu lub jego odpowiednika dla procesora. Niestety pozorne zalety zostały zdominowane przez realne wady. Pierwsza˛ i najważniejsza˛ z nich jest brak możliwości wykorzystania już istniejacych ˛ obiektów w celu zapisania wyniku obliczeń, co uniemożliwia efektywne zarzadzanie ˛ pami˛ecia˛1 . Kolejnym istotnym problemem jest brak wsparcia dla operacji na fragmentach macierzy. Reszta projektu pyCuda również nie dostarczała pożadanej ˛ bazy wyjściowej. Wykorzystanie tego projektu wymusza napisanie wymaganej funkcjonalności od podstaw jako ciagów ˛ znaków w j˛ezyku Python i dynamiczne ich kompilowanie. Takie podejście bezwzgl˛ednie ust˛epuje napisaniu programu bezpośrednio w j˛ezyku C++. Kolejnymi przeanalizowanymi projektami, które stanowiły potencjalnie dobra˛ baz˛e wyjściowa˛ to CUDAMat oraz CUV. Oba projekty umożliwiaja˛ operacje na macierzach na kartach graficznych z poziomu j˛ezyka Python. Niestety i te projekty nie znalazły zastosowania w tym projekcie. Pierwszy z nich CUDAMat został odrzucony już przy wst˛epnej analizie architektury. Zbyt sztywne założenia konstrukcyjne uniemożliwiały 1 http://documen.tician.de/pycuda/util.html Realizacja 20 poprawna˛ implementacj˛e operacji na fragmentach macierzy. Projekt CUV również został odrzucony ze wzgl˛edu na architektur˛e, która jest duża˛ przeszkoda˛ do zrealizowania wszystkich poczynionych założeń. Znaczne rozmiary oraz rozległe zależności od środowiska CUDA praktycznie uniemożliwiaja˛ dostosowanie tego projektu dla środowisk homogenicznych. Obejściem tego ograniczenia może być wykorzystanie tylko cz˛eści odpowiedzialnej za przetwarzanie na kartach graficznych oraz dopisanie brakujacej ˛ funkcjonalności. Nie jest to rozwiazanie ˛ idealne. Z tego wzgl˛edu w pierwszej kolejności została podj˛eta próba stworzenia spójnej i elastycznej konstrukcji (architektury) zoptymalizowanej pod wzgl˛edem postawionych wymagań. Jeżeli zajdzie taka konieczność zawsze istnieje możliwość wykorzystania tej cz˛eści projektu CUV i połaczenia ˛ jej z reszta˛ tworzonego projektu. Próba wykorzystania projektu copperhead wykazała nieefektywność takiego rozwiaza˛ nia w kontekście możliwości implementacji pożadanych ˛ optymalizacji. Wykorzystanie j˛ezyka systemowego oraz bibliotek Thrust, Boost::Python mimo dłuższego kodu pozwoliło uzyskać znacznie lepsze wyniki od implementacji z wykorzystaniem projektu copperhead. Odrzucone projekty pozostawiaja˛ biblioteki standardowe CUDA i BOOST jako punkt wyjściowy dla tego projektu. Obie biblioteki sa˛ dost˛epne dla szerokiej bazy sprz˛etu i systemów operacyjnych, co umożliwia stworzenie uniwersalnego narz˛edzia. Dodatkowo spójny interfejs programistyczny (API-Application Programming Interface) tych bibliotek oraz bazowanie na szablonach (Templates) umożliwia wykorzystanie tego samego kodu dla różnych typów danych, jak i różnych środowisk wykonawczych. Taka implementacja jest możliwa z wykorzystaniem projektu Thrust, który dodatkowo gwarantuje optymalna˛ implementacj˛e dla obecnych i przyszłych kart graficznych firmy Nvidia. Implementacja w oparciu o jednolity kod dla środowisk homogenicznych i heterogenicznych jest dobra˛ baza˛ wyjściowa˛ dla tego projektu. Przy minimalnej ilości kodu obsługujacego ˛ oba środowiska i wszystkie typy danych uzyskano dobra˛ wydajność Realizacja 21 dla operacji niezależnych takich jak dodawanie wartości skalarnej do macierzy. Operacje takie jak mnożenie macierzy uzyskiwały wyniki znacznie poniżej oczekiwań. Zaimplementowana architektura pozwala na łatwa˛ optymalizacj˛e tych waskich ˛ gardeł w projekcie. Pierwszym krokiem było wykorzystanie biblioteki cuBLAS, która zapewnia optymalna˛ implementacj˛e operacji na typach zmiennoprzecinkowych (float, double). Uzyskane w ten sposób wyniki dla typów zmiennoprzecinkowych były bardziej niż zadowalajace. ˛ Niestety wydajność na procesorze centralnym wcia˛ż pozostawała znaczaco ˛ poniżej oczekiwań. Wykorzystanie wielowatkowej ˛ implementacji iteratorów (iterator) z biblioteki OMPTL [21] przyniosła zauważalne przyśpieszenie operacji niezależnych i brak istotnej poprawy dla działań takich jak mnożenie macierzy. W tym celu analogicznie jak dla GPU została wykorzystana biblioteka uzupełniajaca ˛ te luki. Wachlarz dost˛epnych projektów wartych rozważenia jest naprawd˛e szeroki. Do najbardziej popularnych należa˛ OpenBlas, ATLAS oraz MTL firmy Intel implementujace ˛ interfejs programistyczny BLAS. Równie popularnym projektem jest Eigen [10], który dostarcza najbardziej wszechstronne narz˛edzie w ramach otwartego oprogramowania (Open Source Software). Na ostateczny wybór tego ostatniego miały wpływ trzy elementy: bogata funkcjonalność, wydajność oraz licencjonowanie (MPL2 [15]). Wykorzystanie tego projektu wymusiło wi˛eksze odseparowanie implementacji dla CPU i GPU. Uzyskane przyspieszenie podstawowych operacji na macierzach z wykorzystaniem tej biblioteki pozwoliło uzyskać zadowalajace ˛ wyniki co kończy prace nad architektura˛ projektu. Rozdział 4 Implementacja Implementacja projektu została wykonana w systemie operacyjnym OpenSuse 12.3 x64 (Linux), przy wykorzystaniu kompilatora g++-4.6.3, środowiska deweloperskiego CUDA w wersji 5.5, w którego skład wchodza˛ takie komponenty jak kompilator nvcc5.5 oraz biblioteki Thrust, cuBLAS, cuRAND, CUDA Math Library. Dodatkowo projekt wykorzystuje bibliotek˛e standardowa˛ j˛ezyka C++, bibliotek˛e BOOST-1.49 oraz projekty OMPTL opublikowany 22 kwietnia 2012 roku i Eigen w wersji 3.2. Komponenty i biblioteki wykorzystane przy implementacji projektu sa˛ dost˛epne dla różnych systemów operacyjnych, umożliwiajac ˛ wykorzystanie projektu w systemach z rodziny Linux oraz Windows. 4.1 Architektura Implementacja ujednoliconego interfejsu programistycznego dla różnych środowisk wykonawczych wymusza stworzenie niezależnych komponentów dla każdego z nich. Projekt jest tworzony od podstaw, co umożliwia efektywne wydzielenie cz˛eści wspólnej. Obiektem agregujacym ˛ wspólna,˛ a zarazem niezależna˛ od środowiska cz˛eść funkcjonalności jest klasa Matrix. Komunikacja tego komponentu z obiektami operujacymi ˛ Implementacja 23 w danym środowisku odbywa si˛e za pośrednictwem klas i fabryk abstrakcyjnych [26]. Struktur˛e tego rozwiazania ˛ przedstawia diagram 4.1. Taka architektura umożliwia prosta˛ implementacj˛e poszczególnych komponentów dla każdego ze środowisk. Wada˛ takiego rozwiazania ˛ jest dodatkowy nakład obliczeniowy zwiazany ˛ z dynamicznymi zależnościami pomi˛edzy obiektami. data_type DataContainerFactory data_type data_type Matrix Environment data_type DataCrawlerFactory data_type Operations Diagram 4.1: Uogólniona architektura projektu 4.2 Inteligentne wskaźniki Wykorzystanie inteligentnych [26] wskaźników przy tworzeniu biblioteki umożliwia stworzenie prostego adaptera dla j˛ezyka skryptowego. W tym projekcie wykorzystano implementacj˛e inteligentnych wskaźników z biblioteki BOOST. Różne implementacje zostały przetestwane przed ostatecznym wyborem klasy shared_ptr ze wspomnianej biblioteki. Wszystkie przeanalizowane rozwiazania ˛ wykazywały zbliżona˛ wydajność. Inteligentny wskaźnik shared_ptr wnosił znaczac ˛ a˛ popraw˛e estetyki kodu, co zadecydowało o jego ostatecznym wyborze. Implementacja 24 Wszystkie obiekty reprezentowane poprzez klas˛e abstrakcyjna˛ sa˛ wskazywane za pośrednictwem takiego wskaźnika. W projekcie każda z klas abstrakcyjnych definiuje typ ::Ptr, który reprezentuje shared_ptr wskazujacy ˛ na dana˛ klas˛e. 4.3 Leniwa ewaluacja Leniwa ewaluacja jest popularna˛ metoda˛ wykorzystywana˛ do redukcji obcia˛żenia systemu. Działanie taj metody zakłada odłożenie wykonania konkretnego zadania do momentu próby odczytania wyników. Redukcja obcia˛żenia nast˛epuje mi˛edzy innymi przez unikni˛ecie niepotrzebnych obliczeń. Inna˛ zaleta˛ leniwej ewaluacji jest możliwość uwzgl˛ednienia kontekstu przy odczycie danych w celu usprawnienia wykonania odłożonego zadania. Wykorzystanie kontekstu umożliwia realizacj˛e zakładanej funkcjonalności. Brak operacji przypisania w j˛ezyku Python wymusza wykorzystanie innych operatorów do opisania tej funkcjonalności. Operacja przypisania powinna umożliwić wykorzystanie już zaalokowanej pami˛eci do zapisania wyniku obliczeń. Przy wykorzystaniu metody __setitme__ wykorzystujacej ˛ operator ”[]” możliwe jest wykonanie odłożonej operacji w momencie przypisania i tym samym wykorzystania już zaalokowanej pami˛eci. Do implementacji leniwej ewaluacji wykorzystano dwie flagi oraz funkcje dost˛epne w ramach biblioteki BOOST. Pierwsza flaga informuje, że operacje na danym obiekcie generuja˛ leniwe operacje. Druga informuje, że obiekt jest wynikiem takiej odłożonej operacji i czeka na wykonanie. Leniwe zadania sa˛ zapisywane za pomoca˛ klasy function z biblioteki BOOST. Pełni ona rol˛e wyrażenia lambda umożliwiajac ˛ przechowywanie anonimowych delegacji do drzewa wywołań. Za stworzenie takich delegatów odpowiedzialna jest funkcja bind również dost˛epna w ramach biblioteki BOOST. Implementacja 4.4 25 Operowanie na fragmentach macierzy data_type Tuple2D +data_type operator[] (index_type) data_type Shape data_type Slice +IsStartEdge () : bool +isEndEdge () : bool +GetEnd () : index_type +GetStart () : index_type +GetStep () : index_difference_type Position Diagram 4.2: Obiekty pomocnicze Operowanie na fragmentach macierzy (z ang. slicing - wycinanie) umożliwia wykonanie operacji z wykorzystaniem tylko niektórych elementów macierzy lub zapisanie wyników do zadanego fragmentu. Poszczególne fragmenty sa˛ reprezentowane przez dwa obiekty typu Slice (diagram 4.2). Opisuja˛ one fragment dla pojedynczego wymiaru jak wiersze czy kolumny. Fragment w danym wymiarze opisany jest przez indeks elementu poczatkowego, ˛ końcowego oraz odległość kroku pomi˛edzy wybieranymi indeksami. 4.5 Komponenty Po przedstawieniu najważniejszych mechanizmów wykorzystanych podczas implementacji możemy przystapić ˛ do omówienia poszczególnych komponentów. Projekt został zaimplementowany w j˛ezyku C++ i przedstawione poniżej obiekty sa˛ elementami z warstwy MTX. Komponenty w ramach warstwy MTX sa˛ napisane jako nagłówki (headers) w j˛ezyku C++ z wykorzystaniem szablonów (templates). Implementacja 4.5.1 26 Matrix Obiekt klasy Matrix jest centralnym elementem w projekcie, który reprezentuje interfejs programistyczny użytkownika końcowego dla j˛ezyka C++. Jest jedynym nieabstrakcyjnym komponentem niezależnym od środowiska wykonawczego, odpowiedzialnym za kontrol˛e logiki wywołań operacji na macierzach. Implementacj˛e poszczególnych obiektów odpowiedzialnych za przechowywanie danych (DataContainer) oraz operacji na nich (Operations) dostarczaja˛ fabryki abstrakcyjne (DataContinerFactory, OperationsFactory) udost˛epnione przez klas˛e Environment. Stan macierzy zapisany w klasie Matrix zawiera informacje o transpozycji, wykorzystaniu fragmentu, wymuszeniu operacji na elementach oraz operacji odłożonej do wykonania. Informacje te sa˛ niezb˛edne do prawidłowego przeprowadzenia operacji na macierzy. W celu umożliwienia transparentnego przekazania tych danych jako argumenty metod z klasy Operations została stworzona klasa DataCrawler. Stan wymuszajacy ˛ wykonanie operacji na elementach jest reprezentowany przez odpowiednia˛ flag˛e. Flaga ta jest brana pod uwag˛e przez operatory takie jak mnożenie czy pot˛egowanie do wybrania właściwej metody z klasy Operations. Transpozycja macierzy jest realizowana na dwa sposoby. Pierwszy z nich nie wykonuje żadnych operacji bezpośrednio na danych, a jedynie zmienia wartość odpowiedniej flagi. Informacja ta wpływa na metod˛e zwracajac ˛ a˛ informacj˛e o kształcie macierzy (GetShape) oraz obiekt DataCrawler, który dostarcza obiekt umożliwiajacy ˛ prawidłowa˛ iteracj˛e po zbiorze danych. Drugi sposób polega na wykonaniu transpozycji w miejscu, czyli bez zb˛ednych alokacji pami˛eci. Druga metoda ma znaczenie przy operacjach, które cz˛esto odwołuja˛ si˛e do tego samego elementu, jak ma to miejsce podczas mnożenia macierzy. Implementacja 27 data_type Environment GetFactory () : DataContainerFactory<data_type>::Ptr Operations () : Opeartions<data_type>::Ptr GetDataCrawlerFactory () : DataCrawlerFacotry<data_type>::Ptr data_type data_type AutoEnvironment CudaEnvironment data_type CpuEnvironment Diagram 4.3: Schemat komponentu reprezentujacej ˛ środowisko wykonawcze 4.5.2 Environment Komponentem reprezentujacym ˛ środowisko wykonawcze jest klasa Environment, przedstawiona na diagramie 4.3. Zadaniem tej klasy jest zapewnienie implementacji fabryk abstrakcyjnych na potrzeby obiektu Matrix. Poszczególne implementacje dostarczaja˛ fabryki tworzace ˛ obiekty dla konkretnego środowiska wykonawczego. 4.5.3 DataContainerFactory Obiekty reprezentujace ˛ kontenery na dane sa˛ z założenia zależne od środowiska wykonawczego, dlatego tworzone sa˛ za pomoca˛ fabryk abstrakcyjnych, jak DataContainerFactory tworzaca ˛ obiekty klasy DataContainer. Implementacj˛e dla poszczególnych środowisk dostarczaja˛ klasy CpuDataContainerFactory oraz CudaDataContainerFactory, co przedstawia diagram 4.6. Interfejs klasy DataContainerFactory dostarcza najcz˛eściej wykorzystywane metody tworzenia macierzy, takie jak tworzenie pustej macierzy (Empty), macierzy zainicjo- Implementacja 28 data_type Envrionment data_type data_type CpuEnvironment DataCrawlerFactory data_type Operations data_type CpuDataCrawlerFactory data_type data_type DataContainerFactory CpuOperations data_type CpuDataContainerFactory Diagram 4.4: Schemat implementacji dla środowiska cpu wanej jedna˛ wartościa˛ (FromValue) lub na podstawie już istniejacej ˛ sekwencji danych (FromVector, FromIterator). Klasa dostarcza również możliwość tworzenia macierzy jednostkowych (Identity), które sa˛ cz˛esto wykorzystywane przez różne algorytmy. Metoda Sequence tworzy zbiór danych wypełniony wartościami ciagu ˛ arytmetycznego o kroku 1. Tego typu obiekty sa˛ cz˛esto wykorzystywane przez programistów jako narz˛edzia pomocnicze do śledzenia indeksów macierzy przy sortowaniu. Metoda Random wypełnia macierz wartościami losowymi. Takie macierze sa˛ cz˛esto wykorzystywane przez programistów przy testowaniu algorytmów. 4.5.4 DataContainer Klasa˛ implementujac ˛ a˛ funkcje kontenera danych w danym środowisku przedstawia diagram 4.7. Interfejs zawiera metody pozwalajace ˛ na losowy dost˛ep do danych oraz informacje o strukturze przechowywanych danych. Dla środowiska CPU odpowiednia˛ implementacj˛e dostarcza klasa CpuDataContainer, Implementacja 29 data_type Envrionment data_type data_type CudaEnvironment DataCrawlerFactory data_type Operations data_type data_type CudaDataCrawlerFactory data_type DataContainerFactory CudaOperations data_type CudaDataContainerFactory Diagram 4.5: Schemat implementacji dla Cuda która wykorzystuje Eigen::Matrix do przechowywania danych. Klasa CudaDataContainer dla środowiska CUDA wykorzystuje thrust::device_vector z biblioteki standardowej CUDA. 4.5.5 DataCrawler DataCrawler pełni rol˛e mediatora pomi˛edzy klasa˛ Matrix i klasa˛ Operations umożliwiajac ˛ uniezależnienie operacji od stanu macierzy. Podstawowym zadaniem tej klasy przedstawionej na diagramie 4.8 jest dostarczenie obiektu Iterator, który umożliwia operowanie na fragmentach macierzy oraz ich transpozycjach bez konieczności ich wcześniejszego kopiowania. Funkcjonalność, która˛ zapewniaja˛ obie klasy, znaczaco ˛ poprawia czytelność i ułatwia tworzenie operacji na macierzach dla danego środowiska wykonawczego. Dodatkowo klasy pochodne CudaDataCrawler wraz z CpuDataCrawler, które pełnia˛ rol˛e mediatora dla poszczególnych środowisk, umożliwiaja˛ bezpośredni dost˛ep do Implementacja 30 data_type DataContainerFactory Empty () : DataContainer<data_type>::Ptr Empty (Shape) : DataContainer<data_type>::Ptr FromValue (Shape, data_type) : DataContainer<data_type>::Ptr FromVector (Shape, std::vector<data_type>) : DataContainer<data_type>::Ptr Sequence (Shape, data_type) : DataContainer<data_type>::Ptr Identity (index_type) : DataContainer<data_type>::Ptr Random (Shape) : DataContainer<data_type>::Ptr data_type CpuDataContainer data_type CudaDataContainer Diagram 4.6: Fabryka kontenerów danych danych przechowywanych w obiekcieDataContainer. Implementacja dla środowiska CUDA dostarcza również fabryki iteratorów opartych na bibliotece standardowej w formie ThrustIteratorFactory zwracanej przez GetThrustVersion. 4.5.6 DataCrawlerFactory Jest to fabryka abstrakcyjna dla obiektów DataCrawler. Interfejsem tej fabryki sa˛ dwie metody. Pierwsza z nich Create tworzy odpowiedni obiekt dla całego zbioru danych, druga metoda CreateSliced tworzy go dla jego wyci˛ecia (fragmentu). Implementacje w poszczególnych środowiskach realizuja˛ odpowiednio klasy CudaDataCrawler i CpuDataCrawler. 4.5.7 Iterator Klasa Iterator implementuje wzorzec projektowy iterator [26], umożliwiajacy ˛ transparentny dost˛ep do danych. Obiekt tej klasy umożliwia wyliczenie faktycznego indeksu elementu, do którego nastapiło ˛ odwołanie przy uwzgl˛ednieniu stanu macierzy Matrix. Implementacja 31 data_type DataContainer +Get (index_type) : data_type +Set (index_type, data_type) : void +Get (Position) : data_type +Set (Position, data_type) : void +GetSize () : index_type +GetShape () : Shape +Reshape () : void data_type CudaDataContainer +GetVector () : thrust::device_vector data_type CpuDataContainer +GetVector () : std::vector Diagram 4.7: Kontener danych 4.5.8 ThrustIterator ThrustIterator jest odpowiednikiem klasy Iterator zbudowanym ze złożenia obiektów dost˛epnych w ramach środowiska deweloperskiego CUDA takich jak permutation_iterator, transform_iterator, counting_iterator. Powstanie tej klasy zwiazane ˛ jest z ograniczeniem wykorzystania innej implementacji iteratorów z funkcjami z biblioteki Thrust. 4.5.9 Operations Ta klasa reprezentuje zbiór dost˛epnych operacji na macierzach. Implementacj˛e dla poszczególnych środowisk zapewniaja˛ klasy CpuOperations i CudaOperations. Każda z operacji (metod) charakteryzuje si˛e bezstanowym wykonaniem co oznacza, że każde wywołanie operuje tylko na przekazanych argumentach i nie modyfikuje stanu klasy Operations. Macierz jest reprezentowana przez obiekt klasy DataCrawler, który jest przekazywany jako argument funkcji umożliwiajac ˛ dost˛ep do danych za pośrednictwem klasy Iterator. Wykorzystanie iteratorów jako schematu dost˛epu do danych pozwala użyć wydajnych Implementacja 32 data_type DataCrawler +GetShape () : Shape +GetTrueShape () : Shape +GetSize () : index_type +GetBegin () : Iterator<data_type> +GetEnd () : Iterator<data_type> data_type CpuDataCrawler data_type CudaDataCrawler +GetThrustVersion () : ThrustIteratorFactory Diagram 4.8: Mediator pomi˛edzy stanem macierzy a operacjami data_type DataCrawlerFactory Create (DataContainer<data_type>::Ptr, Shape, bool) : DataCrawler<data_type>::Ptr CreateSliced (DataContainer<data_type>::Ptr, Shape, Slice, Slice, bool) : DataCrawler<data_type>::Ptr data_type CudaDataCrawlerFactory data_type CpuDataCrawlerFactory Diagram 4.9: Fabryka mediatorów funkcji z bibliotek standardowych. Pierwsza˛ z takich funkcji jest transform, która umożliwia wykonanie pewnej operacji na każdym elemencie zbioru i zapisanie wyniku za pomoca˛ zadanego iteratora. Kolejne funkcje dost˛epne w ramach obu środowisk to min_element oraz max_element, które pozwalaja˛ w wydajny sposób znaleźć minimalny i maksymalny element zbioru. Funkcja sort wykorzystuje optymalne algorytmy sortowania dla danego środowiska. Sortowanie na procesorze odbywa si˛e za pomoca˛ algorytmu ”quick sort”, natomiast dla GPGPU wykorzystane jest ”merge sort”. Ostatnia˛ wykorzystana˛ funkcja˛ jest accumulate, za pomoca˛ której zostały zaimplementowane takie metody jak Sum czy Prod. Implementacja 33 Wymienione funkcje operuja˛ na iteratorach dostarczajac ˛ optymalne osiagi ˛ przy zachowaniu czytelnego kodu. Standardowa implementacja tych funkcji jest dost˛epna w obu środowiskach. ˛ do tego CpuOperations implementuje operacje dla środowiska CPU wykorzystujac bibliotek˛e OMPTL. W ramach tego projektu zaimplementowane zostały funkcje transform, min_element, max_element, sort, accumulate z wykorzystaniem standardu OpenMP, który pozwala na optymalne wykorzystanie wielordzeniowości współczesnych procesorów. Dodatkowo niektóre operacje w szczególnych przypadkach wykorzystuja˛ operacje udost˛epnione przez projekt Eigen, który jest bardzo wydajna˛ implementacja˛ macierzy na procesorze. CudaOperations dostarcza implementacj˛e dla środowiska CUDA, z ta˛ różnica˛ że funkcje transform, reduce (odpowiednik accumulate), copy, min_element, max_element, sort pochodza˛ z projektu thrust b˛edacego ˛ cz˛eścia˛ standardowego środowiska deweloperskiego CUDA. Analogicznie do CpuOperations niektóre operacje w szczególnych przypadkach wykorzystuja˛ funkcje z biblioteki cuBlas. Operacje wykonane z wykorzystaniem tej biblioteki gwarantuja˛ optymalne wykonanie na każdej karcie graficznej. 4.6 Adapter dla j˛ezyka Python Adapter dla j˛ezyka Python jest warstwa˛ oznaczona˛ w projekcie jako pyMTX. Umożliwia ona wykorzystanie funkcji napisanych w j˛ezyku C++ w środowisku docelowym jakim jest j˛ezyk skryptowy Python. Konstrukcja adaptera jest złożona z dwóch warstw. Pierwsza warstwa eksportuje poszczególne komponenty jako moduły j˛ezyka Python. Druga warstwa agreguje dost˛epne komponenty i zapewnia wygodny interfejs dla j˛ezyka Python. Wykorzystanie struktury złożonej z dwóch warstw znaczaco ˛ poprawia czytelność oraz elastyczność tworzonego kodu. Implementacja 34 Pierwsza warstwa zastała napisana z wykorzystaniem projektu Boost.Python [4]. Dostarcza on zestaw funkcji wspomagajacych ˛ opakowywanie klas z warstwy MTX. Zadaniem pierwszej warstwy jest udost˛epnienie interfejsu C++ dla j˛ezyka Python. Poszczególne komponenty z warstwy napisanej w C++ zostały zaprojektowane z myśla˛ o tym etapie projektu. Ograniczone zostało wykorzystanie przecia˛żonych metod oraz dużej ilości domyślnych parametrów, co znaczaco ˛ upraszcza implementacj˛e tej warstwy adaptera. Pierwsza warstwa złożona jest z kilku plików. Każdy z nich to biblioteka współdzielona reprezentujaca ˛ moduł w j˛ezyku Python. Głównym modułem jest mtx (mtx.so). W ramach tego modułu zostały wyeksportowane obiekty typu Matrix, obiekty pomocnicze i abstrakcyjne. Udost˛epnione komponenty dostarczaja˛ kompletny interfejs programistyczny z wyłaczeniem ˛ obiektów implementujacych ˛ operacje dla danego środowiska. Implementacje klas abstrakcyjnych dla konkretnego środowiska wykonawczego zostały wydzielone do osobnych modułów. Taki podział wynika z braku możliwości skompilowania projektu Eigen i projektu CUDA jednocześnie. Rozdzielenie implementacji dla różnych środowisk pozwoliło wypracować modułowa,˛ rozszerzalna˛ struktur˛e całego projektu. Odpowiednio dla operacji na procesorze centralnym tworzony jest moduł cpu_mtx (cpu_mtx.so), który eksportuje obiekt klasy CpuEnvironment i z nim wszystkie klasy dla tego środowiska. Analogicznie tworzony jest moduł cuda_mtx (cuda_mtx.so), który dostarcza implementacj˛e CudaEnvironment. Druga warstwa jest odpowiedzialna za dynamiczne zagregowanie dost˛epnych komponentów i dostarczenie wygodnego interfejsu programistycznego dla j˛ezyka Python. Zadanie to zostało zrealizowane przez prosty skrypt napisany w docelowym j˛ezyku. 4.7 Proces optymalizacji Zadanie postawione przez niniejszy projekt wymaga stworzenia wszechstronnego narz˛edzia zdolnego do wydajnego przetwarzania dużych zbiorów danych. Generalizacja wykonywanych zadań w kontekście operacji na macierzach niesie ze soba˛ konsekwencje Implementacja 35 analogiczne do tych przedstawionych wcześniej w j˛ezykach skryptowych. Duża elastyczność kodu ma swoje negatywne konsekwencje zwiazane ˛ z wydajnościa.˛ Jest to szczególnie ważna właściwość ze wzgl˛edu na prób˛e omini˛ecia tego typu ograniczeń w j˛ezykach skryptowych. Projekt został stworzony w procesie ciagłej ˛ optymalizacji i rozszerzania funkcjonalności. Każdorazowa rozbudowa projektu skutkowała mniejszymi lub wi˛ekszymi spadkami wydajności. W końcowych etapach projektu niwelowanie tego efektu przy zachowaniu spójnej implementacji komponentów wymagało znacznych nakładów pracy przy niewielkiej poprawie wydajności. Wyniki uzyskane przez generyczna˛ implementacj˛e zakładanej funkcjonalności były niezadowalajace. ˛ W przypadku operacji na procesorze wydajność była nawet czterokrotnie gorsza od tej z wykorzystaniem biblioteki numpy [17]. Wyniki uzyskane z wykorzystaniem karty graficznej były nawet siedmiokrotnie lepsze w stosunku do analogicznej implementacji w środowisku Octave. Wyniki uzyskane w środowisku heterogenicznym były akceptowalne, ale te uzyskane na CPU były nie do przyj˛ecia. Przyczyna˛ takiego stanu rzeczy było wykorzystanie generycznych narz˛edzi ogólnego zastosowani. Poszczególne kroki algorytmu zaimplementowane z wykorzystaniem modułu numpy umożliwiały dużo wydajniejsze wykorzystanie zasobów oraz unikni˛ecie niepotrzebnych operacji, w porównaniu z implementacja˛ w oparciu o taka˛ uniwersalna˛ implementacj˛e. Najwi˛eksze straty były generowane przez obiekt klasy Iterator, który wykonywał liczne dodatkowe obliczenia uwzgl˛edniajace ˛ stan macierzy w celu wyliczenia właściwej komórki pami˛eci, do której nastapiło ˛ odwołanie. Jednym ze sposobów na omini˛ecie tego problemu jest wyspecjalizowanie operacji ze wzgl˛edu na stan macierzy. Już samo uproszczenie obliczeń w przypadku iterowania na całych nietransponowanych macierzach przyniósł wzrost wydajności od 50-100% w zależności od problemu. Wyniki uzyskiwane w kolejnych iteracjach specjalizacji kodu przynosiły oczekiwane wyniki, kosztem znacznie wi˛ekszej złożoności projektu. Skomplikowanie projektu wynika z implementacji dedykowanych rozwiazań ˛ dla różnych stanów macierzy. Implementacja 36 Wydajne przetwarzanie danych na procesorze centralnym jest popularnym problemem, skutecznie rozwiazanym ˛ w ramach licznych projektów. Naturalnym krokiem w rozwoju tego projektu ze wzgl˛edu na napotkane ograniczenia jest wykorzystanie już wypracowanych wydajnych rozwiazań. ˛ Analiza wydajności, funkcjonalności i dost˛epnych narz˛edzi doprowadzała do wybrania projektu Eigen. Implementuje on obiekt macierzy spełniajacy ˛ wszystkie wymagane funkcjonalności. Wykorzystanie tego projektu pozwoliło wielokrotnie przyśpieszyć popularne operacje uzyskujac ˛ bardzo pozytywne rezultaty. Operacje na karcie graficznej również wykorzystuja˛ wsparcie zewn˛etrznych bibliotek w celu przyśpieszenia wykonania popularnych operacji. Takim projektem jest cuBLAS b˛edacy ˛ cz˛eścia˛ standardowego środowiska deweloperskiego dla platformy CUDA. Stworzony został w celu dostarczenia optymalnej implementacji interfejsu BLAS na kartach graficznych. W szczególnych przypadkach zapewnia gigantyczny wzrost wydajności operacji takich jak mnożenie macierzy. Rozdział 5 Zastosowanie w praktyce Celem niniejszej pracy jest analiza wykorzystania procesora graficznego w celu zniwelowania ograniczenia j˛ezyków skryptowych do wydajnego przetwarzania dużych zbiorów danych. Weryfikacja tego stwierdzenia zostanie przeprowadzona w oparciu o rzeczywiste problemy. W tym celu zostały wybrane dwa algorytmy z rodziny rozpoznawania obrazów. Pierwszy algorytm jest algorytmem trenowania sieci neuronowej za pomoca˛ wstecznej propagacji bł˛edu. Algorytm ten charakteryzuje si˛e kosztownymi obliczeniowo operacjami takimi jak mnożenie dużych macierzy, czy wyliczenie funkcji aktywacji oraz jej pochodnej. Wyst˛epowanie tych operacji kładzie duży nacisk na wsadowe przetwarzanie danych, dla którego zostały stworzone karty graficzne. Drugi algorytm to k-najbliższych sasiadów. ˛ Jest to prosty algorytm, który nie wykonuje kosztownych obliczeń w przeciwieństwie do pierwszego. Właściwości tego algorytmu kłada˛ dużo wi˛ekszy nacisk na wydajne przetwarzanie kolejnych operacji oraz przepływ danych. Sa˛ to cechy, którymi charakteryzuje si˛e przetwarzanie na procesorze centralnym (CPU). Wybrane algorytmy powinny dobrze reprezentować analizowana˛ grup˛e problemów. Zastosowanie w praktyce 5.1 38 Kontekst Analiza uzyskanych wyników bez odpowiedniego kontekstu nie ma wi˛ekszego sensu, z tego powodu oba algorytmy zostały zaimplementowane z wykorzystaniem różnych narz˛edzi i j˛ezyków. Głównym punktem referencyjnym tego projektu sa˛ j˛ezyki systemowe. Sa˛ one popularnym narz˛edziem, wykorzystywanym do rozwiazywania ˛ tego typu problemów. Przedstawicielem tej grupy jest j˛ezyk C++. Zastosowano wydajna˛ implementacj˛e macierzy w tym j˛ezyku, jaka˛ zapewnia biblioteka Eigen. Środowisko naukowe równie cz˛esto wykorzystuje programy typu Matlab oraz jego darmowa˛ implementacj˛e Octave w celu szybkiej i wydajnej implementacji różnych algorytmów. Programy te działaja˛ w oparciu o wyspecjalizowany j˛ezyk skryptowy. Nie jest to j˛ezyk stworzony do programowania ogólnego zastosowania, ale ze wzgl˛edu na jego popularność zostanie uwzgl˛edniony w porównaniu. W celu dopełnienia kontekstu, algorytmy zostały przetestowane za pomoca˛ programu w j˛ezyku Python. Do przetwarzania danych został wykorzystany popularny modułu numpy. Jest to narz˛edzie wykorzystywane do przetwarzania realnych danych z pomini˛eciem dynamicznego typowania oraz zb˛ednych operacji wykonywanych przez interpreter j˛ezyka. Przy porównywaniu wyników nie zostana˛ wykorzystane programy napisane bezpośrednio (bez zewn˛etrznych bibliotek) w j˛ezyku Python lub CUDA. W pierwszym przypadku wyniki byłyby gorsze o kilka rz˛edów wielkości i nie wniosłyby nic innego jak potwierdzenie faktu, że j˛ezyki skryptowe nie służa˛ do rozwiazywania ˛ tego typu problemów. Wykorzystanie algorytmu napisanego bezpośrednio w CUDA również nie wniesie wartości merytorycznych do tego porównania. Konstrukcja procesorów graficznych oraz ich architektura (hierarchia) pami˛eci umożliwia daleko idac ˛ a˛ optymalizacj˛e konkretnego zadania. W praktyce wymaga to dużych nakładów pracy oraz wielu linii kodu, ale wyniki w ten sposób uzyskane moga˛ być wydajniejsze o par˛e rz˛edów wielkości od ich generycznych odpowiedników. Z tego powodu obie implementacje nie stanowia˛ bliskiego otoczenia rozpatrywanego problemu i nie zostały uwzgl˛ednione w tym porównaniu. Zastosowanie w praktyce 39 Poszczególne implementacje dla różnych środowisk i j˛ezyków, dla lepszej czytelności tekstu, zostały opisane nast˛epujacymi ˛ etykietami: mtx Algorytm napisany w oparciu o niniejszy projekt przy założeniu stworzenia kodu minimalizujacego ˛ ilości wykorzystanych instrukcji oraz linii kodu. mtx_op Algorytm napisany w oparciu o niniejszy projekt z wykorzystaniem dost˛epnych narz˛edzi w celu optymalizacji czasu wykonania, wykorzystuje leniwa˛ ewaluacj˛e oraz operacje na fragmentach macierzy. eigen Algorytm napisany w j˛ezyku C++ z wykorzystaniem biblioteki Eigen. octave Algorytm napisany w programie Octave. numpy Algorytm napisany w j˛ezyku skryptowym Python w oparciu o moduł numpy. Implementacje mtx oraz mtx_op korzystajace ˛ z niniejszego projektu umożliwiaja˛ wykonanie tego samego kodu zarówno w środowisku heterogenicznym jak i homogenicznym. Wyniki uzyskane przez poszczególne środowiska dla rozróżnienia zostana˛ poprzedzone przedrostkami cuda_ dla środowiska heterogenicznego oraz cpu_ dla homogenicznego. Zastosowanie w praktyce 5.2 40 Procedura testowa Wykonane pomiary dotycza˛ wyłacznie ˛ analizowanego algorytmu. Wczytanie oraz inicjalizacja danych nie jest wliczana do całkowitego czasu wykonania programu ani do jego długości wyrażonej w liniach kodu. Analizowane algorytmy b˛eda˛ realizowały ten sam pseudo kod. Poszczególne wyniki zostana˛ również przedstawione w porównaniu z implementacja˛ numpy oraz eigen. Porównanie zostanie zaprezentowane jako iloraz czasów dla danego testu wyrażony wzorem: ti (T ) ti (D) (5.1) Gdzie: • ti (x) czas uzyskany w teście i przez algorytm x • T ∈ {numpy, eigen, cpu_mtx, cuda_mtx, cpu_mtx_op, cuda_mtx_op, octave} • D ∈ {numpy, eigen} Testy zostały przeprowadzone na komputerze o nast˛epujacych ˛ parametrach: Procesor: Intel i5-2500k Karta graficzna: Gigabite 560 Ti 1 GB Pami˛eć operacyjna: DDR3-1600 8 GB Płyta główna: Asus P8P67 System operacyjny: OpenSuse 12.3 x64 Wszystkie testy wykonano przy wyłaczonym ˛ środowisku graficznym, na danych typu zmiennoprzecinkowego (float). Zastosowanie w praktyce 5.3 41 Sieci neuronowe Sztuczne sieci neuronowe przenosza˛ uproszczona˛ ide˛e działania mózgu na model matematyczny. Sieć złożona jest ze sztucznych neuronów zgrupowanych w warstwy (5.1). Analizowana sieć zbudowana jest z trzech warstw. Neurony w sasiaduj ˛ acych ˛ warstwach łacz ˛ a˛ si˛e każdy z każdym. Z matematycznego punktu widzenia sieci neuronowe to kompozycja funkcji. Każda funkcja f () reprezentujaca ˛ pojedynczy neuron jest złożeniem funkcji ze zbioru G = {g1 (), g2 (), ..., gn ()}. Kompozycj˛e tych funkcji można przedstawić z pomoca˛ grafu skierowanego (5.1). Każdemu sztucznemu i-temu neuronowi przypisana jest pewna waga wi . Najcz˛eściej spotykana postać neuronu to: fi () = k( ∑ wi g()) g∈G Gdzie k(x) jest nazywana funkcja˛ aktywacji. Funkcja˛ aktywacji wykorzystana˛ w tym projekcie jest tanh. k(x) = tanh(x) k0 (x) = 1 − tanh2 (x) Warstwa wejściowa Warstwa ukryta Warstwa wyjściowa Diagram 5.1: Sztuczna sieć neuronowa Zastosowanie w praktyce 42 Algorytm trenowania sieci neuronowej za pomoca˛ wstecznej propagacji bł˛edu opisuje program 1. Celem tego algorytmu w ramach tej pracy jest zbadanie wydajności danej implementacji, a nie wytrenowanie poprawnej sieci. W celu uzyskania miarodajnych wyników algorytm wykona dokładnie jedna˛ iteracj˛e po zbiorze trenujacym ˛ b˛edacym ˛ odpowiednikiem jednej epoki. Jest to jedyna różnica w stosunku od standardowo stosowanego algorytmu, gdzie wykorzystywane sa˛ inne warunki przerwania uwzgl˛edniajace ˛ wypracowane wartości i wyniki sieci. Program 1 Wsteczna propagacja bł˛edu // inicjalizacja wag krawędzi pomiędzy warstwami wejścia i ukrytą WWU = wartości losowe // inicjalizacja wag krawędzi pomiędzy warstwami ukrytą i wyjścia WUW = wartości losowe Dla każdego wektora V ze zbioru trenującego: wylicz wartości w~węzłach błąd wyjścia = wartość oczekiwana dla V - warstwa wyjścia różnica = pochodna funkcji aktywacji z~warstwy wyjścia \ * błąd wyjścia zmiana WUW = różnica * warstwa ukryta błąd ukrytej = błąd wyjścia * warstwa ukryta różnica = pochodna funkcji aktywacji z~warstwy ukrytej \ * błąd wyjścia zmiana WWU = różnica * V // wartości warstwy wejścia WUW += współczynnik nauki * zmiana WUW \ + współczynnik bezwładności * poprzednia zmiana WUW WWU += współczynnik nauki * zmiana WWU \ + współczynnik bezwładności * poprzednia zmiana WWU Przedstawiony program zostanie przetestowany w nast˛epujacych ˛ konfiguracjach warstw i na zbiorze trenujacym ˛ złożonym z 1000 wektorów : Zastosowanie w praktyce 43 nr. liczba w˛ezłów w warstwach testu wejściowych ukrytych wyjściowych 1 100 100 10 2 400 400 10 3 400 800 10 4 400 1600 10 5 1600 3000 10 6 5000 5000 10 7 4000 10000 10 Tablica 5.1: Testowane struktury sieci neuronowej 5.3.1 Wyniki Numer testu 1 2 3 4 5 6 7 numpy 0.2282 2.4327 5.7660 23.6990 98.1920 504.5195 864.2208 cpu_mtx 0.4019 1.9773 3.9715 18.0000 68.9690 389.0182 598.3393 cpu_mtx_op 0.4474 1.3745 2.6760 12.5660 43.8610 214.0212 380.5067 cuda_mtx 1.4983 2.6929 2.9844 5.4400 16.5930 88.0082 — cuda_mtx_op 0.7463 0.7926 0.9629 1.8534 5.0906 23.5022 37.5403 eigen 0.0380 0.7654 1.8077 10.0180 33.1460 157.2790 292.8660 octave 0.2692 1.3299 2.9722 11.2990 48.2230 237.6764 376.0105 Tablica 5.2: Czas wykonania algorytmu wstecznej propagacji bł˛edu wyrażony w sekundach Zastosowanie w praktyce 44 Numer testu 1 numpy x1 cpu_mtx 2 x1 3 4 5 6 7 x1 x1 x1 x1 x1 x0.568 x1.230 x1.507 x1.317 x1.424 x1.297 x1.444 cpu_mtx_op x0.510 x1.770 x2.237 x1.886 x2.239 x2.357 x2.271 cuda_mtx x0.152 x0.903 x2.006 x4.356 x5.918 x5.733 — cuda_mtx_op x0.306 x3.069 x6.217 x12.787 x19.289 x21.467 x23.021 eigen x6.005 x3.178 x3.312 x2.366 x2.962 x3.208 x2.951 octave x0.848 x1.829 x2.014 x2.097 x2.036 x2.123 x2.298 Tablica 5.3: Porównanie czasów wzgl˛edem implementacji nympy wyrażonych ilorazem opisanym wzorem (5.1) (wartości > 1 oznaczaja˛ przyśpieszenie) Diagram 5.2: Graficzne porównanie czasów wzgl˛edem implementacji nympy wyrażonych ilorazem opisanym wzorem (5.1) (wartości > 1 oznaczaja˛ przyśpieszenie) Zastosowanie w praktyce 45 Numer testu 1 2 numpy x0.167 x0.315 cpu_mtx x0.095 cpu_mtx_op cuda_mtx 3 4 5 6 7 x0.302 x0.423 x0.338 x0.312 x0.339 x0.387 x0.455 x0.557 x0.481 x0.404 x0.489 x0.085 x0.557 x0.676 x0.797 x0.756 x0.735 x0.770 x0.025 x0.284 x0.606 x1.842 x1.998 x1.787 — cuda_mtx_op x0.051 x0.966 x1.877 x5.405 x6.511 x6.692 x7.801 eigen x1 x1 x1 x1 x1 x1 octave x0.141 x0.576 x0.608 x0.887 x0.687 x0.662 x0.779 x1 Tablica 5.4: Porównanie czasów wzgl˛edem implementacji eigen wyrażonych ilorazem opisanym wzorem (5.1) (wartości > 1 oznaczaja˛ przyśpieszenie) Diagram 5.3: Graficzne porównanie czasów wzgl˛edem implementacji eigen wyrażonych ilorazem opisanym wzorem (5.1) (wartości > 1 oznaczaja˛ przyśpieszenie) Zastosowanie w praktyce 46 Algorytm Linie kodu numpy 25 mtx 27 mtx_op 50 octave 38 eigen 52 Tablica 5.5: Długość programu wstecznej propagacji bł˛edu 5.3.2 Omówienie wyników Z zebranych wyników wyróżnia si˛e pierwszy przypadek testowy przeprowadzony dla małej sieci neuronowej. Przetwarzanie małych zbiorów danych dobrze oddaje charakterystyk˛e wykorzystanych j˛ezyków. Rewelacyjny wynik programu napisanego w j˛ezyku systemowym wynika z operowania bezpośrednio na procesorze, w przeciwieństwie do j˛ezyków skryptowych, które wykonuja˛ liczne operacje zwiazane ˛ z dynamicznym typem danych, przetwarzaniem skryptu oraz operowaniem za pośrednictwem interpretera. Najgorsze wyniki dla pierwszego testu uzyskały algorytmy pracujace ˛ w środowisku heterogenicznym ze wzgl˛edu na dodatkowy wpływ opóźnień wynikajacych ˛ z komunikacji pomi˛edzy procesorem centralnym a procesorem graficznym. Brak wyniku dla cuda_mtx w siódmym teście wynika z braku dostatecznej ilości wolnej pami˛eci na karcie graficznej. Test przeprowadzony z wykorzystaniem zoptymalizowanej wersji cuda_mtx_op był możliwy do wykonania ze wzgl˛edu na lepsze zarzadzanie ˛ pami˛ecia˛ z poziomu skryptu. W tabelach 5.2 i 5.4 zostały przedstawione wyniki porównania poszczególnych algorytmów do referencyjnego rozwiazania ˛ w j˛ezyku Python oraz punktu docelowego jakim jest implementacja w j˛ezyku systemowym. Wyniki uzyskane przez eigen sa˛ średnio trzykrotnie lepsze od tych uzyskanych przez numpy. Drugim popularnym j˛ezykiem wykorzystywanym do przetwarzania dużych zbiorów danych jest Octave, którego śred- Zastosowanie w praktyce 47 nie czasy sa˛ dwukrotnie krótsze od implementacji w j˛ezyku Python i niecałe dwa razy dłuższe od eigen. Wyniki uzyskane przez algorytm cpu_mtx uplasowuja˛ go pomi˛edzy implementacjami numpy a ocatve, co z perspektywy sposobu implementacji oraz wykorzystanego j˛ezyka należy uznać za bardzo dobry wynik. Znacznie lepszymi wynikami wykazała si˛e zoptymalizowana wersja cpu_mtx_op uzyskujac ˛ a˛ wyniki porównywalne ze środowiskiem Octave. Kosztem takich osiagów ˛ jest znacznie dłuższy kod w porównaniu do wersji niezoptymalizowanej czy skryptu octave. W przypadku środowiska heterogenicznego uzyskane wyniki jako jedyne moga˛ konkurować z implementacja˛ w j˛ezyku systemowym. Pomimo słabego startu implementacja mtx uzyskała wyniki dwukrotnie lepsze od eigen w trzecim i kolejnych testach. Świadczy to o znacznym marnowaniu zasobów przez generyczne rozwiazania, ˛ które sa˛ rekompensowane przez wydajność GPGPU dopiero przy dostatecznie dużych zbiorach danych. Ciekawie przedstawiaja˛ si˛e wyniki dla zoptymalizowanego wariantu mtx_op, który już w drugim teście uzyskał wynik porównywalny z implementacja˛ w j˛ezyku C++. Wyniki kolejnych testów tylko powi˛ekszyły różnic˛e w wydajności dochodzacej ˛ w ostatnim teście do 680% na korzyść projektu. Wynik uzyskany w ostatnim teście wr˛ecz przytłacza w porównaniu do algorytmu opartego na bibliotece numpy, od którego jest szybszy dwudziestotrzykrotnie. Wyniki uzyskane przez algorytm mtx_op w środowisku heterogenicznym, potwierdzaja˛ znaczne możliwości jakie drzemia˛ w procesorach graficznych. Wykorzystanie generycznej implementacji mtx pozwoliło uzyskać jedynie zadowalajace ˛ wyniki. Zarówno wyniki dla środowiska homogenicznego jak i heterogenicznego sa˛ zgodne z przewidywaniami. Wypracowujac ˛ na procesorze czasy porównywalne z wydajnym środowiskiem Octave oraz uzyskujac ˛ znacznie lepsze czasy we współpracy z GPGPU. Wpływ na taki stan rzeczy ma również charakterystyka analizowanego algorytmu, który bardzo dobrze skaluje si˛e na procesorach graficznych. Zastosowanie w praktyce 5.4 48 K-najbliższych sasiadów ˛ K-najbliższych sasiadów ˛ (k nearest neighbours), jest prostym algorytmem klasyfikacji. Klasyfikacja za jego pomoca˛ polega na wyliczeniu odległości pomi˛edzy badanym obiektem, a elementami zbioru trenujacego. ˛ Tak uzyskane odległości zostaja˛ posortowane i na podstawie k-najbliższych elementów zostaje wybrana najcz˛eściej wyst˛epujaca ˛ etykieta. Sam algorytm jest bardzo prosty i zarazem bardzo skuteczny. Jego specyfik˛e określaja˛ wartość k oraz metryka opisujaca ˛ odległość miedzy obiektami. W niniejszym projekcie zastosowano metryk˛e Euklidesowa.˛ Działanie tego algorytmu przedstawia program 2. Program 2 Algorytm k-najbliższych sasiadów ˛ S = pusta lista Dla każdego wektora V ze zbioru trenującego: v = odległość testowanego elementu od V dodaj do S parę odległość v i~etykietę wektora V sortuj S wybierz k najbliższych elementów Zwróć najpopularniejszą etykietę z~wybranych elementów Analogicznie do sieci neuronowych, algorytm knn również został przetestowany w kilku wariantach. Ze wzgl˛edu na krótkie czasy wykonania, jednorazowo przeprowadzono klasyfikacj˛e 100 wektorów. 5.4.1 Porównanie mtx i mtx_op Krótkie implementacje mtx i mtx_op algorytmu knn pozwalaja˛ przedstawić różnice mi˛edzy nimi oraz sam proces optymalizacji. Implementacja mtx (program 3) jest bardzo podobna do domyślnej implementacji w j˛ezyku Python (program 4). Taka zbieżność ułatwia proces poznawania tworzonego narz˛edzia. Dodatkowo istnieje możliwość Zastosowanie w praktyce 49 nr. testu wielkość zbioru trenujacego ˛ liczba wymiarów 1 1000 100 2 5000 400 3 10000 400 4 15000 400 5 15000 800 6 15000 1600 7 10000 5000 Tablica 5.6: Przypadki testowe dla algorytmu knn modyfikacji drugiej warstwy adaptera do j˛ezyka Python i dostosowanie interfejsu do indywidualnych upodobań programisty. Zoptymalizowana˛ implementacj˛e przedstawia program 5. Odbiega on od utartych schematów programowania w j˛ezyku Python. Pierwsza˛ wprowadzona˛ zmiana˛ jest stworzenie obiektów tmat oraz tvec na poczatku ˛ programu. Sa˛ one nast˛epnie wielokrotnie wykorzystywane w głównej p˛etli programu. Bez tej zmiany wariant mtx tworzy nowe tymczasowe kopie przy każdym przebiegu owej p˛etli. Druga˛ zmiana˛ jest rozbicie poszczególnych linii algorytmu na operacje maksymalnie dwuargumentowe. Taki podział zwiazany ˛ jest z architektura˛ biblioteki, która ogranicza kompozycj˛e leniwych funkcji do jednego wywołania. W przypadku kiedy dany obiekt reprezentuje taka˛ odłożona˛ operacj˛e i zostanie wykorzystany w kolejnej operacji, odłożone zadanie zostaje wykonane i zapisane do nowego tymczasowego obiektu. Modyfikacja programu 3 w celu poprawy wydajności jest prosta. Wymaga unikania zb˛ednego tworzenia tymczasowych obiektów oraz rozbicie poszczególnych kroków algorytmu na dwuargumentowe funkcje. Zysk wydajności płynacy ˛ z tak prostych modyfikacji jest znaczny w szczególności dla środowiska heterogenicznego. Zastosowanie w praktyce 50 Program 3 Kod algorytmu mtx def most_common(L): return max(grby(L),key=lambda(x,v):(len(list(v)),-L.index(x)))[0] def knn (train,labels, test, k): srt = Matrix ((test.get_shape()[0], k)) for i in xrange (test.get_shape()[0]): tmp=splay(labels,pow((train-Matrix(test[i,:])).e(),2).sum(1),1) srt[i,:] = tmp.sort_by_key(1,0)[:k,0].get_t() srt.sort (1) sl = srt.to_list () return [most_common(sl[i]) for i in xrange(srt.get_shape()[0])] Program 4 Kod algorytmu numpy def most_common(L): return max(grby(L),key=lambda(x,v):(len(list(v)),-L.index(x)))[0] def knn (train,labels, test, k): srt = zeros ((test.shape[0], k)) for i in xrange (len(test)): tmp = sum(pow ((train - test[i,:]), 2), 1) ind = lexsort ((tmp,)) for n in xrange(k): srt[i,n] = labels[ind[n]] sort(srt, axis = 1) return [most_common(list(srt[i,:])) for i in xrange(srt.shape[0])] Zastosowanie w praktyce 51 Program 5 Kod algorytmu mtx_op def most_common(L): return max(grby(L),key=lambda(x,v):(len(list(v)),-L.index(x)))[0] def knn (train,labels,test, k): srt = Matrix ((test.get_shape()[0], k)) tmat = Matrix ((train.get_shape ())) tvec = Matrix ((train.get_shape ()[0], 2)) for i in xrange (test.get_shape()[0]): tmat[:,:] = train - Matrix(test[i,:]) tmat[:,:] = tmat.e() * tmat tvec[:,1] = tmat.sum(1); tvec[:,0] = labels tvec[:,:] = tvec.sort_by_key(1,0) srt[i,:] = tvec[:k,0].get_t() srt.sort (1) sl = srt.to_list() return [most_common(sl[i]) for i in xrange(srt.get_shape()[0])] Zastosowanie w praktyce 5.4.2 52 Wyniki Numer testu 1 2 3 4 5 6 7 numpy 0.0597 0.8720 1.6970 2.4867 4.8400 9.4974 20.1430 cpu_mtx 0.210 5.532 8.459 17.067 33.912 83.002 cpu_mtx_op 0.1036 0.7072 1.3835 2.1204 4.1053 8.1251 16.1900 cuda_mtx 0.1503 0.5384 1.0860 1.6923 3.5305 7.5443 16.7810 cuda_mtx_op 0.1045 0.3005 0.5419 0.8339 1.7435 3.6406 7.9922 eigen 0.0198 0.3954 0.7876 1.2007 2.3527 4.9691 11.9950 octave 0.1262 1.1024 2.0157 3.1153 5.9305 11.4820 23.2750 2.893 Tablica 5.7: Czas wykonania algorytmu k-najbliższych sasiadów ˛ wyrażone w sekundach Algorytm Linie kodu numpy 10 mtx 9 mtx_op 15 octave 6 eigen 35 Tablica 5.8: Długość programu k-najbliższych sasiadów ˛ Zastosowanie w praktyce 53 Numer testu 1 2 3 numpy x1 x1 x1 cpu_mtx x0.284 x0.301 cpu_mtx_op x0.577 cuda_mtx 4 x1 5 6 7 x1 x1 x1 x0.307 x0.294 x0.284 x0.280 x0.243 x1.233 x1.227 x1.173 x1.179 x1.169 x1.244 x0.397 x1.620 x1.563 x1.469 x1.371 x1.259 x1.200 cuda_mtx_op x0.572 x2.902 x3.132 x2.982 x2.776 x2.609 x2.520 eigen x3.013 x2.205 x2.155 x2.071 x2.057 x1.911 x1.679 octave x0.473 x0.791 x0.842 x0.798 x0.816 x0.827 x0.865 Tablica 5.9: Porównanie czasów wzgl˛edem implementacji nympy wyrażonych ilorazem opisanym wzorem (5.1) (wartości > 1 oznaczaja˛ przyśpieszenie) Diagram 5.4: Graficzne porównanie czasów wzgl˛edem implementacji nympy wyrażonych ilorazem opisanym wzorem (5.1) (wartości > 1 oznaczaja˛ przyśpieszenie) Zastosowanie w praktyce 54 Numer testu 1 2 numpy x0.332 x0.453 cpu_mtx x0.094 cpu_mtx_op cuda_mtx 3 4 5 6 7 x0.464 x0.483 x0.486 x0.523 x0.595 x0.137 x0.142 x0.142 x0.138 x0.147 x0.145 x0.191 x0.559 x0.569 x0.566 x0.573 x0.612 x0.741 x0.132 x0.734 x0.725 x0.710 x0.666 x0.659 x0.715 cuda_mtx_op x0.190 x1.316 x1.454 x1.440 x1.349 x1.365 x1.501 eigen x1 x1 x1 x1 x1 x1 octave x0.157 x0.359 x0.391 x0.385 x0.397 x0.433 x0.515 x1 Tablica 5.10: Porównanie czasów wzgl˛edem implementacji eigen wyrażonych ilorazem opisanym wzorem (5.1) (wartości > 1 oznaczaja˛ przyśpieszenie) Diagram 5.5: Graficzne porównanie czasów wzgl˛edem implementacji eigen wyrażonych ilorazem opisanym wzorem (5.1) (wartości > 1 oznaczaja˛ przyśpieszenie) Zastosowanie w praktyce 5.4.3 55 Omówienie wyników Uzyskane wyniki znaczaco ˛ odbiegaja˛ od tych dla poprzedniego algorytmu. Analogiczna dysproporcja w pierwszym teście pozostaje pomi˛edzy skryptowa,˛ a systemowa˛ implementacja.˛ Implementacja w j˛ezyku systemowym jest bezkonkurencyjna na małych zbiorach danych, ale różnica już nie jest tak duża jak przy poprzednim algorytmie. Różnica ta wynika z mniejszej ilości instrukcji wykonywanych w każdej p˛etli programu co sumarycznie przekłada si˛e na mniejsze opóźnienia w porównaniu do algorytmu wstecznej propagacji bł˛edu. W pozostałych przypadkach testowych algorytm napisany w j˛ezyku C++ uzyskuje najlepsze wyniki w środowisku homogenicznym, lecz różnica pomi˛edzy pozostałymi implementacjami nie jest już tak znaczna jak dla pierwszego analizowanego algorytmu. J˛ezyk Python i moduł numpy uzyskał wyniki średnio dwukrotnie gorsze niż eigen oraz wynik lepszy o 20% od programu Octave. Dodatkowo dysproporcja ta maleje wraz ze wzrostem wielkości zbioru danych. Algorytm k-nn charakteryzuje si˛e wzgl˛ednie mała˛ złożonościa˛ obliczeniowa.˛ Wszystkie operacje na danych, z wykluczeniem operacji sortowania, maja˛ złożoność liniowa.˛ Ważna˛ własnościa˛ tego algorytmu jest duża wydajność przetwarzania danych przez poszczególne operacje. Obie wymienione cechy sa˛ doskonale zobrazowane w uzyskanych wynikach. Różnica pomi˛edzy poszczególnymi implementacjami wynika z różnej wydajności przetwarzania kolejnych kroków algorytmu. Z tego wzgl˛edu najlepszy wynik uzyskuje algorytm eigen, a implementacje w j˛ezyku skryptowym maja˛ zbliżona˛ wydajność. Charakterystyczna jest również malejaca ˛ różnica w czasach wykonania wraz z wzrostem wielkości zbioru danych. Wynika to z faktu wzrostu udziału przetwarzania danych, które sa˛ wykonywane ze zbliżona˛ wydajnościa˛ dla każdej z implementacji. Na tle uzyskanych wyników wariant cpu_mtx znaczaco ˛ wyróżnia si˛e, uzyskujac ˛ siedmiokrotnie gorszy wynik niż eigen. Wynika to z konieczności wykonania licznych dodatkowych operacji w każdym przebiegu iteracji. Kompaktowa składnia programu wymusza Zastosowanie w praktyce 56 wykonanie zb˛ednych alokacji i kopiowań danych, które w dużej mierze wpływaja˛ na taki wynik. Wykorzystanie tego samego algorytmu w środowisku heterogenicznym nie wykazuje już tak złych wyników. Wykonywanie takich obcia˛żajacych ˛ operacji jak alokacja i kopiowanie danych na karcie graficznej przebiega w sposób asynchroniczny, co pozwala ukryć te dodatkowe koszty. Zoptymalizowany wariant uzyskuje wyniki lepsze od reszty skryptowych implementacji w środowisku homogenicznym. Warto zwrócić uwag˛e na zbieżność wyników cpu_mtx_op i cuda_mtx. Pierwszy algorytm minimalizuje zb˛edne operacje kopiowania i alokacji pami˛eci, drugi ukrywa je poprzez asynchroniczne wykonania. Jedynie zoptymalizowana implementacja ze wsparciem procesora graficznego uzyskała wyniki lepsze od programu napisanego w C++. Poprawa wydajności jest mniejsza niż ta uzyskana dla sieci neuronowych i nie przekracza 50%. Tak mały wzrost wynika z małego wpływu czasu przetwarzania danych do wydajności samego j˛ezyka przy interpretowaniu kolejnych poleceń. Rozdział 6 Zakończenie Stworzony projekt oraz uzyskane wyniki pozwalaja˛ udzielić pozytywnej odpowiedzi na zadane we wst˛epie pytania. Tak, połaczenie ˛ obu technologi pozwala na efektywne wykorzystanie j˛ezyków skryptowych do przeprowadzania operacji numerycznych na dużych zbiorach danych, przy zachowaniu niezależności skryptu od środowiska wykonawczego. Wykorzystanie procesora graficznego z pomoca˛ stworzonego narz˛edzia jest równie wygodne jak samo programowanie w j˛ezyku skryptowym. Dodatkowo końcowe rozwiazanie ˛ uzyskało wyniki lepsze od zakładanej wydajności j˛ezyków systemowych. 6.1 Wnioski z przetwarzania danych Wydajna implementacja algorytmów operujacych ˛ na dużych zbiorach danych poprzez operacje z algebry liniowej wymaga wyspecjalizowanego kodu zoptymalizowanego pod dany problem. Generyczna implementacja nie zapewnia dostatecznej wydajności. Z tego wzgl˛edu projekt wymagał dostosowania poszczególnych metod przez uwzgl˛ednienie różnych wariantów wykonania poszczególnych operacji. Takie optymalizacje sa˛ koniecznie by biblioteka ogólnego zastosowania mogła konkurować z optymalnymi implementacjami algorytmów. Próby zoptymalizowania biblioteki na potrzeby jednego Zakończenie 58 algorytmu (np. k-nn) cz˛esto negatywnie wpływały na wyniki uzyskiwane przez inne algorytmy (np. neural networks). Taka zależność wymusza liczne kompromisy w celu stworzenia wydajnego, a zarazem uniwersalnego narz˛edzia. Możliwe jest stworzenie optymalnej implementacji przystosowanej do każdego algorytmu, które polegałoby na napisaniu licznych wariantów tej samej operacji zoptymalizowanej pod każdy z algorytmów. Jest to bardzo kosztowne rozwiazanie, ˛ które dodatkowo znaczaco ˛ komplikuje sama˛ bibliotek˛e oraz nie daje gwarancji istotnego przyśpieszenia dla każdego problemu w każdym środowisku. 6.2 Wnioski wynikajace ˛ z realizacji projektu Realizacja projektu operujacego ˛ w ramach różnych architektur jest możliwa przy wykorzystaniu generycznego kodu wspólnego dla każdej z nich. Takie rozwiazanie ˛ pomimo wielu zalet nie zapewnia dostatecznej wydajności, b˛edacej ˛ kluczowym aspektem takiego projektu. Uzyskanie wymaganej mocy obliczeniowej wymaga wyspecjalizowania poszczególnych metod ze wzgl˛edu na stan macierzy jak i sama˛ operacj˛e w danym środowisku wykonawczym. Takie optymalizacje prowadza˛ do niezależnych implementacji dla każdego środowiska. Te niezależne implementacje dla każdego środowiska w dużej mierze sprowadzaja˛ połaczenie ˛ obu technologi do roli adaptera czy fasady odpowiednich bibliotek dla każdego ze środowisk. Istnieja˛ liczne biblioteki dla procesora centralnego (CPU) realizujace ˛ postawione wymagania. Jednym z takich projektów jest Eigen. Niestety dla środowiska graficznego (GPU) taka biblioteka jeszcze nie istnieje. Potencjalnym kandydatem jest projekt CUV. Stworzona biblioteka zakłada maksymalna˛ integracj˛e obu środowisk wykraczajac ˛ a˛ poza funkcjonalność adaptera i fasady. Wypracowane rozwiazanie ˛ pozawala wykorzystać wspólny kod dla licznych operacji niezależnych takich jak dodawanie wartości skalarnej do macierzy. Dodatkowo generyczna implementacja każdej operacji pozwala w prosty sposób rozbudować implementacj˛e o obsług˛e nowych typów danych i umożliwia póź- Zakończenie 59 niejsza˛ niezależna˛ optymalizacj˛e każdej z metod. Wydajność takiego rozwiazania ˛ jest zadowalajaca, ˛ dlatego implementacja tej biblioteki jako tylko adaptera już istniejacych ˛ bibliotek nie została zbadana. Prawdopodobnie rozwiazanie ˛ ograniczone do funkcji adaptera, fasady łacz ˛ acej ˛ wydajne biblioteki uzyska lepsze wyniki kosztem wi˛ekszych nakładów na oprogramowanie obsługi różnych typów danych. Te wnioski moga˛ ulec znaczacej ˛ zmianie wraz z rozwojem technologii. Środowiska CUDA w kolejnej wersji 6.0 ma wprowadzić wspólna˛ przestrzeń adresowa˛ dla CPU i GPU, co może istotnie wpłynać ˛ na optymalna˛ realizacj˛e takiego połaczenia. ˛ 6.3 Perspektywy rozwoju Projekt dostarczył odpowiedzi na pytania zadane we wst˛epie tej pracy oraz ukazał potencjał tkwiacy ˛ w połaczeniu ˛ j˛ezyków skryptowych z procesorem graficznym. Stworzona biblioteka zapewnia jedynie podstawowe operacje na macierzach. Jest to dobry punkt wyjściowy do stworzenia uniwersalnego narz˛edzia operujacego ˛ w środowisku heterogenicznym. Pierwszym krokiem w tym celu powinna być rozbudowa o nowe metody, które zbliża˛ projekt do konkurencyjnych rozwiazań ˛ dla środowisk homogenicznych. Kolejnym etapem jest uwzgl˛ednienie kolejnych wariantów wykonania i popraw˛e wydajności różnych operacji. W przypadku cz˛eści operujacej ˛ na procesorze można zrealizować to zadanie przez dalsza˛ integracj˛e z biblioteka˛ Eigen. Cz˛eść odpowiedzialna˛ za obliczenia na GPGPU należy rozszerzyć o integracj˛e z biblioteka˛ cuBlas. Dodatkowo warto rozbudować projekt o możliwość kompozycji operacji (wywołań), która umożliwi zmniejszenie opóźnień wynikajacych ˛ z komunikacji pomi˛edzy procesorami oraz kosztownego przetwarzania poleceń w j˛ezyku Python. Rozwiazaniem ˛ wartym implementacji jest dynamiczny wybór środowiska wykonawczego. Taki wybór uwzgl˛edniałby dost˛epne zasoby wielkość zbioru danych, jak i sama˛ Zakończenie 60 operacj˛e. Dla przykładu operacje na małych zbiorach danych wykonywane byłyby na procesorze centralnym w celu unikni˛ecia opóźnień zwiazanych ˛ z komunikacja˛ pomi˛edzy procesorami, natomiast do przetwarzania dużych zbiorów danych głównie wykorzystany byłby procesor graficzny. Bibliografia [1] ArrayFire. http://www.accelereyes.com/products/arrayfire. [2] BLAS. http://www.netlib.org/blas/. [3] Boost. http://www.boost.org/. [4] Boost.Python. http://www.boost.org/doc/libs/1_54_0/libs/python/ doc/index.html. [5] Copperhead. http://code.google.com/p/copperhead/. [6] CUDA-Toolkit. http://developer.nvidia.com/cuda/cuda-toolkit. [7] CUDAMat. http://code.google.com/p/cudamat/. [8] CUV. https://github.com/deeplearningais/CUV. [9] Cython. https://github.com/cython/cython. [10] Eigen. http://eigen.tuxfamily.org. [11] Encog. http://www.heatonresearch.com/encog. [12] GPU-Accelerated-libraries. http://developer.nvidia.com/cuda/ gpu-accelerated-libraries. [13] GPUMLib. http://gpumlib.sourceforge.net/. BIBLIOGRAFIA 62 [14] Matlab. http://www.mathworks.com/products/matlab/. [15] MPL2. http://www.mozilla.org/MPL/2.0/. [16] Numbra Pro. http://docs.continuum.io/numbapro/. [17] Numpy. http://www.numpy.org/. [18] Nvidia-CUDA. http://www.nvidia.com/object/cuda_home_new.html. [19] Octave. http://www.gnu.org/software/octave/. [20] OpenCL. http://www.khronos.org/opencl/. [21] OpenMP Multi-Threaded Template Library. http://tech.unige.ch/omptl/. [22] Python. http://www.python.org/. [23] thrust. https://github.com/thrust/thrust. [24] G. Aparício, I. Blanquer, V. Hernández. A parallel implementation of the k nearest neighbours classifier in three levels: threads, MPI processes and the grid. Proceedings of the 7th international conference on High performance computing for computational science, VECPAR’06, strony 225–235, Berlin, Heidelberg, 2007. Springer-Verlag. [Online]. Protokół dost˛epu: http://dl.acm.org/citation. cfm?id=1761728.1761748. [25] Richard O. Duda, Peter E. Hart, David G. Stork. Pattern Classification. John Wiley & Sons, New York, NY, wydanie 2., 2001. [26] Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides. Design patterns: elements of reusable object-oriented software. Addison-Wesley Longman Publishing Co., Inc., Boston, MA, USA, 1995. [27] Andreas Klöckner. pycuda/. PyCUCA. http://mathema.tician.de/software/ BIBLIOGRAFIA [28] Andreas Klöckner. 63 PyOpenCl. http://mathema.tician.de/software/ pyopencl/. [29] Canasai Kruengkrai, Chuleerat Jaruskulchai. A parallel learning algorithm for text classification. Proceedings of the eighth ACM SIGKDD international conference on Knowledge discovery and data mining, KDD ’02, strony 201–206, New York, NY, USA, 2002. ACM. [Online]. Protokół dost˛epu: http://doi.acm.org/10. 1145/775047.775077. [30] Andrew Rau-Chaplin Laurence Boxer, Russ Miller. Scalable Parallel Algorithms for Geometric Pattern Recognition. Journal of Parallel and Distributed Computing, (58):466–486, 1999. [31] Rhonda D. Phillips, Layne T. Watson, Randolph H. Wynne. Hybrid image classification and parameter selection using a shared memory parallel algorithm. Computers & Geosciences, 33(7):875–897, 2007.