Programowanie równoległe Parallel Extensions W. Grześkowiak Instytut Informatyki, Wydział Elektroniki i Technik Informacyjnych, Politechnika Warszawska, ul. Nowowiejska 15/19, 00-665 Warszawa, Polska w.grzeskowiak@stud.elka.pw.edu.pl Streszczenie. Parallel Extensions to zbiór narzędzi przeznaczonych do programowania równoległego, wprowadzony w czwartej wersji platformy.net Framework firmy Microsoft. Zaproponowano nowy model programowania, oparty na fizycznej równoległości wykonywanych zadań. Takie podejście pozwala na pełne wykorzystanie aktualnie produkowanych procesorów wielordzeniowych. 1. Wstęp. Kiedy należy ocenić wydajność komputera, pierwszą rzeczą na którą powinno się zwrócić uwagę jest częstotliwość taktowania procesora. Większa liczba może oznaczać większą wydajność. Producenci układów nie mogli jednak przyśpieszać swoich procesorów w nieskończoność. Problemem okazała się technologia wytwarzania i rozmiary otrzymywanych tranzystorów bliskie rozmiarom atomów. Powstał problem który należało rozwiązać w inny sposób. Tak powstała idea procesorów wielordzeniowych i programowana kierowanego na te środowisko, określanego mianem programowania równoległego. 2. Rynek procesorów. W latach 90 w półświatku informatyków głośno krążyło prawo sformujłowane założyciela firmy Intel - Gordona Moora [1]. Mówiło ono że: Liczba tranzystorów w układzie elektrycznym podwaja się co 18-24 miesiące. Prawo to w tamtych czasach zgadzało się z rzeczywistością, postęp techniczny był bardzo gwałtowny, coraz więcej tranzystorów można było umieszczać w jednym układzie krzemowym, a jak wiadomo ich liczba decyduje pośrednio o częstotliwości pracy procesora. Im więcej tranzystorów umieści się w jednym układzie, tym większą częstotliwość można osiągnąć. Z czasem inżynierowie doszli do rozmiarów rzędu 32 nm. [2], czyli bardzo blisko rozmiarów atomu (10-10 m). Pomimo faktu że produkcja w coraz to mniejszym wymiarze jest bardzo trudna, Intel aktualnie inwestuje w technologię 22 nm [3]. Procesory wytworzone w tym rozmiarze technologicznym nie trafią jednak na rynek wcześniej niż w 2011 roku. Na problem zwiększania częstotliwości od dawna szukano alternatywy chociaż odpowiedz wydawała się w miarę oczywista. Skoro nie można zwiększać prędkości rdzeni procesorów, to należy umieszczać ich więcej w jednym układzie. Takie rozwiązanie jako pierwszy wprowadził Intel tworząc rodzinę procesorów Core Duo [4]. Ciekawostką jest fakt, że funkcjonowały także jednostki Core Solo [5], które tak naprawdę były ukrytymi jednostkami Duo, z pracującym jednym rdzeniem. Zastosowano taką praktykę ponieważ proces technologiczny w którym produkuje się procesory jest bardzo skomplikowany i często dochodzi do uszkodzeń podzespołów. Intel stwierdził, że jeśli usterka nastąpi w jednym rdzeniu, to nie warto wyrzucać całego układu do kosza. Wyłączano uszkodzony rdzeń i sprzedawano układ pod inna nazwą. Intel z czasem wprowadził na rynek jednostki 4 rdzeniowe, a ostatnie z nich (np. model Intel Core 1
7 950 [6]) wyposażone w technologie HT [7] pozwalają na uruchamianie do 8 wątków równolegle. Na rynku pojawiły się więc procesory wielordzeniowe, co zyskano? Aby odpowiedzieć na to pytanie najlepiej posłużyć się wypowiedzią Dana Reeda z Microsoftu Różnica jest taka jak między szybkim sportowym autem, a autobusem szkolnym. Pierwszy szybko przewiezie dwie osoby, a drugi, choć trochę wolniej czterdzieści. Zauważyć należy, że rdzenie jednostek wielordzeniowych nie są tak samo szybkie jak rdzenie z procesorów jednordzeniowych, to jest ciągle jedna płytka krzemu i ciągle bardzo mało miejsca na umieszczenie wszystkich modułów procesora. Analitycy z firmy Forrester Research przewidują, że już w 2012 roku zbudowane zostaną procesory wyposażone w 64 rdzenie. Dan Reed ostrzega Już niedługo zabraknie programistów z doświadczeniem w tworzeniu aplikacji wykorzystujących przetwarzanie równoległe.. To już ostatni dzwonek, aby przekonać młodych programistów o wartości przetwarzania równoległego dodaje. 3. Równoległość, a współbieżność. Zanim zaprezentowane zostanie rozwiązanie Parallel Extensions, na potrzeby artykułu zostaną rozróżnione dwa aspekty programowania równoległego, które nazwane zostaną współbieżnością i równoległością. Obliczenia są współbieżne, jeżeli kolejne do wykonanie obliczenie, rozpocznie się wcześniej niż skończy się poprzednie. Obliczenia współbieżne wykonywane są jednej jednostce obliczeniowej (np. procesorze), pracującej z podziałem czasu. Aby łatwiej zobrazować współbieżność, należy zdefiniować dwa zadania, które powinny wykonać się współbieżnie Zadanie 1 oraz Zadanie 2. Podział czasu polega na udostępnieniu zasobów jednostki obliczeniowej na określony czas na przemian jednemu i drugiemu zadaniu. W każdym cyklu jednostka wykona jedynie część zadania i przełączy się na drugie. Obliczenia równoległe są realizowane fizycznie jednocześnie, na wielu jednostkach obliczeniowych. Obliczenia równoległe mogą być wykonywane co najmniej na dwóch jednostkach obliczeniowych. Rozważając dwa zadania z poprzedniego przykładu, możliwość wykonywania ich równolegle wymaga dwóch jednostek obliczeniowych (np. dwóch procesorów lub dwóch rdzeni). 4. Parallel Extensions. Opisane na początku wydarzenia z rynku procesorów miały miejsce na początku tego dziesięciolecia. Już wtedy firma Microsoft widziała w procesorach wielordzeniowych duży potencjał. Oddział badawcza firmy, Microsoft Research [8], rozpoczął wtedy pacę nad narzędziem które pozwoliłoby programistą w łatwy i przyjemny sposób tworzyć aplikację wykorzystujące możliwość równoległego wykonywania kodu. W ten sposób powstała biblioteka TPL (Task Parallel Library), która stanowi fundament Parallel Extensions. Biblioteka ta jest częścią czwartej wersji platformy.net Framework [9]. 5. Równoległość w praktyce. W rozdziale tym zostanie zaprezentowany przykład praktycznego wykorzystania zalety przetwarzania równoległego. Do tego celu posłużono się aplikacją demonstracyjną, dostarczoną wraz z nową wersją platformy. Aplikacja ta generuje dynamiczne sceny 3D wykorzystując czasochłonną technikę śledzenia promieni, z ang. Ray Tracing. Sposób generowania obrazu zaimplementowano dwojako, w pierwszym wypadku wykorzystano typowe podejście sekwencyjne, w drugim wykorzystano bibliotekę Parallel Extensions. Maszyna testowa to: procesor Intel Core Quad (4x 2.2GHz), 1GB pamięci RAM, system operacyjny Windows Server 2008 zwirtualizowany przy pomocy technologii Hyper-V. Przy uruchomieniu aplikacji z sekwencyjną generacją obrazu, menadżer zadań systemu Windows pokazał zużycie procesora w granicach 25-26%, co w przypadku czterech rdzeni świadczy o tym że wtkorzystywano tylko jeden z nich. 2
Ilość FPS Zużycie procesora Współbieżnie 0,7 26 % Równolegle 2,8 100% Tabela 1 - Obciążenie procesora aplikacją Ray Tracing Uruchomienie aplikacji w trybie równoległym (z wykorzystaniem Parallel Extensions) spowodowało wzrost obciążenia procesora do 100%, dzięki czemu wykorzystywano pełną moc dostępnej maszyny testowej. Liczba generowanych klatek na sekundę FPS (z ang. frame per second) w drugim przypadku była 4 razy większa niż w przypadku pierwszym (Tabela 1). Różnica w kodzie algorytmu generowania obrazu równolegle, różni się od wersji synchronicznej jedynie jedna instrukcją. Biblioteka Parallel Extsnions wprowadza wprowadza bardzo wysoka abstrakcję równoległości, dzięki czemu implementacja takiego podejścia jest bardzo łatwa. 6. Imperatywny paralelizm. Przykłady programowania równoległego zostaną omówione na podstawie prezentacji Daniela Motha Parallel Programming for Managed Developers with the Next Version of Microsoft Visual Studio [10] przedstawionej na corocznej konferencji dla programistów związanych z technologiami Microsoftu, Professional Developer Conference. Konferencja ta odbyła się w październiku 2008 roku. Przykład polega na implementacji metody pewnej aplikacji, której celem jest wykonanie czasochłonnych obliczeń na każdym węźle dostarczonego drzewa binarnego. Obliczenia na węzłach są niezależne względem siebie. Kod aplikacji prezentuje listing 1, ciało metody przechodzącej po drzewie jest puste. Zadaniem jest napisanie tej metody w sposób minimalizujący czas potrzebny do przetworzenia całego drzewa o wysokości 9 węzłów. Pierwszym pomysłem na rozwiązaniem tego problemu jest rekurencja. W ten sposób stworzono drzewo więc w podobny sposób można je przeglądać. Funkcja wykorzystująca rekurencje może wyglądać podobnie do funkcji przedstawionej na listingu 2. using System; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; namespace Tree class Program static void Main(string[] args) TNode root = TNode.CreateTree(9, 1); Stopwatch watch = Stopwatch.StartNew(); WalkTree(root); Console.WriteLine( String.Format("Elapsed = 0", watch.elapsedmilliseconds)); /* To co musimy zaimplementować. */ public static int ProcessItem(int value) Thread.SpinWait(4000000); return value; class TNode public TNode LeftNode get; set; public TNode RightNode get; set; public int Value get; set; public static TNode CreateTree( int deep, int start) TNode root = new TNode(); root.value = start; if (deep > 0) root.leftnode = CreateTree( deep - 1, start + 1); root.rightnode = CreateTree( deep - 1, start + 1); return root; Listing 1- Kod aplikacji wykonującej obliczenia na węzłach drzewa 3
Rozwiązania będą testowe na maszynie z procesorem Intel Core 2 Duo T7100 (2x 1,8GHz), 4GB pamięci RAM oraz systemem operacyjnym Windows 7 64bit. if (node == null) return; WalkTree(node.LeftNode); WalkTree(node.RightNode); ProcessItem(node.Value); Listing 2 - Funkcja przeglądania drzewa wykorzystująca rekurencje Czas uzyskany przez rozwiązanie rekurencyjne to 24,998 s.. Zużycie procesora w tym teście wynosiło 50% (taką wartość pokazywał podczas testów menadżer zadań systemu Windows), czyli na dwa dostępne rdzenie, wykorzystywano tylko jeden z nich. Połowa mocy maszyny została niespożyta. Błędem w tym podejściu jest wykorzystanie jednego wątku (tak działa rekurencja). Skoro mamy procesor wielordzeniowy, to można wykorzystać wiele wątków, wtedy wszystkie rdzenie powinny pracować na pełnym obciążeniu. Funkcja implementująca takie podejście przedstawia listing 3. if (node == null) return; Thread left = new Thread((o) => WalkTree(node.LeftNode)); left.start(); Thread right = new Thread((o) => WalkTree(node.RightNode)); right.start(); left.join(); right.join(); ProcessItem(node.Value); Listing 3 - Funkcja przeglądania drzewa wykorzystująca wątki W powyższym rozwiązaniu wykorzystano składnie Lambda, dostępną w platformie.net od wersji 3.5, użyto także metody Join, aby rozwiązanie było funkcjonalnie porównywalne z rekurencją (czyli operacje na węzłach będą się odbywać w tej samej kolejności). Zmierzony czas to 14,116 s., czyli prawie 10 sekund szybciej. Podczas testowania uruchomiony menadżer zadań pokazywał stuprocentowe wykorzystanie mocy procesora, oto właśnie chodziło. Rozwiązanie wydaje się poprawne jednak należy dokładnie przemyśleć, takie podejście. Podczas obliczeń powstaje tyle wątków ile jest węzłów na których należy wykonać operację. W idealnym drzewie binarnym o wysokości 9 jest 511 węzłów, każdorazowe tworzenie wątku jest operacją bardzo czasochłonna ponieważ za każdym razem należy odwołać się do systemu, który przydzieli odpowiednie zasoby m.in. pamięć, licznik rozkazów itp. Ponadto przełączanie pomiędzy tak dużą liczbą wątków jest również czasochłonne. Jak można zrobić to lepiej? Tutaj z pomocą przychodzi Parallel Extensions, do takich właśnie celów stworzono tą bibliotekę. Rozwiązanie wykorzystujące nowe narzędzia i struktury danych, które zostaną opisane w dalszej części artykułu. Listing 4 prezentuje implementacje rozwiązania przy użyciu tej biblioteki. if (node == null) return; Task left = Task.Create((o) => WalkTree(node.LeftNode)); Task right = Task.Create((o) => WalkTree(node.RightNode)); left.wait(); right.wait(); ProcessItem(node.Value); Listing 4 - Funkcja przeglądania drzewa wykorzystująca bibliotekę Parallel Extensions Jak można zauważyć kod tej metody nie różni się znacząco od rozwiązania wielowątkowego. Słowo kluczowe Thread zastąpiono słowem Task, a metodę Join, metodą Wait. Zmierzony czas to 13,203 ms, czyli jedynie sekundę szybciej od rozwiązania wykorzystujące wątki. Największą 4
zaleta takiego podejścia jest pozyskana pamięć, która w niektórych przypadkach może mieć kluczowe znaczenie dla algorytmu przetwarzania. Czasy tych rozwiązań na maszynie 4 rdzeniowej, wykorzystanej podczas prezentacji Daniela Motha przedstawia Tabela 2, jak widać w tym przypadku otrzymano prawie 4 krotny wzrost wydajności. Rekurencja Wątki Zadania.NET 4.0 15,022 s 5,801 s 3,918 s Tabela 2 - Czasy rozwiązań zadania przechodzenia po drzewie otrzymane przez Daniela Motha podczas prezentacji PDC Podejście której stosuje bezpośrednio obiekty typu Task, nazywane jest imperatywnym paralelizmem. 7. Deklaratywny paralelizm. Drugim podejściem do równoległości w bibliotece Parallel Extensions jest tzw. deklaratywny paralelizm. Polega on na wprowadzeni równoległości w zapytaniach LINQ [11]. Aby zobrazować taki przypadek należy prześledzić przykładowe zapytanie przedstawione na listingu 5. var q = from p in people where p.age < MaxAge && p.age > MinAge && p.state == STATE order by age ascending select p; Listing 5 - Przykładowe zapytanie LINQ Zapytanie to ma na celu wybranie w kolejności rosnącej tych obiektów z kolekcji people, które spełniają pewne zadane kryteria. Normalne wykonanie tego zapytanie polega na przeglądaniu kolekcji obiekt po obiekcie. Te obiekty które spełniają podane kryteria są zwracane. W przypadku kiedy kolekcja liczy tysiące obiektów operacja ta jest czasochłonna i może mieć bezpośredni wpływ na wydajność całego programu. Wprowadzanie równoległości w takim przypadku, może polegać na rozdzieleniu kolekcji na rozłączne zbiory i analizowanie ich osobno np. na innych rdzeniach. Po zakończeniu wyniki osobnych analiz powinny być łączone. Takie podejście właśnie wykorzystuje Parallel Extensions. Zmodyfikowane zapytanie prezentuje listing 6. var q = from p in people.asparallel() where p.age < MaxAge && p.age > MinAge && p.state == STATE order by age ascending select p; Listing 6 - Przykładowe zapytanie LINQ, zrównoleglone przy pomocy Parallel Extensions Jak widać równoległe zapytanie różni się od wersji sekwencyjnej jedynie jedną instrukcją - na obiekcie kolekcji wykonano dodatkowo metodę AsParallel. Powoduje to rozdzielenie kolekcji do analizy. Podział kolekcji może nie być równy i zależeć może od aktualnego obciążenia rdzeni np. jeden z rdzeni może dostać większą część kolekcji, niż inne rdzenie. Deklaratywny paralelizm wewnętrznie działa na fundamentach imperatywnego podejścia tj. tworzone są obiekty typu Task, które rozdziale są poprzez menadżera zadań. 8. Zasada działania. Podstawą kwestią jaką należy zrozumieć w Parallel Extensions jest sposób działania menadżera zadań tej biblioteki. Rysunek 1 przedstawia schematyczną budowie tego elementu na maszynie 4 rdzeniowej. Dokładną budowę menadżera zadań można opisać następująco: jeśli dany jest procesor o N rdzeniach, to dla każdego rdzenia tworzona jest grupa robocza (z ang. work group), zaznaczona na rysunku zielona elipsą z napisem WG). Każda taka grupa robocza posiada jednego aktywnego pracownika (z ang. worker). Pracownik to czerwona elipsa na obręczy doczepionej do grupy roboczej. Pracowników może być więcej, ale tylko jeden może być aktywny w danej chwili, tzn. nie w stanie zawieszenia. Pracownik wykonuje zadania na rdzeniu, do którego przypisana jest grupa robocza. Każda 5
grupa robocza posiada własną kolejkę zadań, które utożsamiane są z obiektami klasy Task tworzonymi w kodzie programu. Dodatkowo istnieje także globalna kolejka zadań. Grupa robocza przypisana jest do konkretnego rdzenia procesora i wszystkie obliczenia w ramach tej grupy wykonywane są w jednym wątku. Biblioteka Parallel Extensions tworzy tylko tyle wątków ile dostępnych jest rdzeni na maszynie na której aktualnie jest uruchomiona. Nie ma tutaj kosztu przełączania, ponieważ każdy rdzeń ma swój wątek. Rysunek 1 - Menadżer zadań Parallel Extensions Pierwsze tworzone zadania trafiają do globalnej kolejki zadań (z ang. global queue). Z tego miejsca zostają rozdzielone do poszczególnych kolejek grup roboczych. Podział ten nie musi być sprawiedliwy i może zależeć od aktualnego obciążenia rdzeni. Kiedy pracownik nie wykonuje żadnych zadań sięga do kolejki swojej grup roboczej i pobiera z niej pierwsze zadania do wykonania. Zakładając że w przykładowym programie do każdej lokalnej kolejki trafiły jakieś zadania, każdy pracownik sięga zadanie z lokalnej kolejki i zaczyna je wykonywać. W tym momencie na każdym rdzeniu wykonywany jest jeden wątek obsługujący któreś z zadań, a zużycie procesora wynosi 100%, czyli wykorzystywany jest cały potencjał maszyny. Co się stanie jeśli pracownik skończył wykonywać swoje zadanie a w kolejce jego grupy nie ma kolejnych zadań? Pracownik chce dalej pracować, zagląda więc do globalnej kolejki z nadzieją że znajdzie tam kolejne zadania do wykonania. Jeśli są tam nierozdzielone jeszcze zadania, pracownik pobiera jedno z nich i zajmuje się jego wykonywaniem. Co jednak jeśli tam również jest pusto? W tym momencie stosowany jest mechanizm kradzieży pracy (z ang. work stealing). Kradzież pracy polega na przeglądaniu przez pracownika wszystkich lokalnych kolejek sąsiednich grup roboczych w poszukiwaniu wolnego zadania do wykonania. Jeśli któraś z kolejek zawiera zadania, to te znajdujące się na końcu tej kolejki zostaje skradzione. Celem tego mechanizmu jest całkowite wykorzystanie wszystkich dostępnych jednostek obliczeniowych. W przykładzie o przechodzeniu drzewa, zadania tworzyły kolejne zadania do wykonania. Jeśli pracownik wykonuje zadanie, które tworzy nowe zadania, to te nowe zadania umieszczane są na początku kolejki grupy roboczej tego pracownika. Taka reguła ma bezpośredni związek z mechanizmem kradzieży pracy. Zadanie które zostało stworzone jako ostatnie, będzie wykonywane jako następne, ponieważ znajduje się na pierwszym miejscu kolejki. Dane potrzebne do wykonania tego zadania mogą być jeszcze trzymane w pamięci podręcznej rdzenia, więc dostęp do nich będzie o wiele szybszy niż do kolejnych zadań z kolejki. Z kolei zadania na końcu kolejki były stworzone najwcześniej, więc dane o nich mogły zostać wyrzucone z pamięci podręcznej. Właśnie te zadania są kradzione podczas działania mechanizmu kradzieży pracy. Przy takiej kradzieży prawdopodobnie nie potrzebna będzie synchronizacja pomiędzy rdzeniami ponieważ dane o zadaniach zostały wyrzucone z pamięci podręcznych. Zadania mogą się zablokować czekając na zakończenie wykonywania innych zadań, do tego celu służy metoda Wait. Menadżer zadań po wykryciu że zadanie się zablokowało, zamraża 6
pracownika który je wykonywał, a grupa robocza tego pracownika tworzy kolejnego, który staje się jedynym aktywnym pracownikiem. Pracownik ten wykonuje kolejne zadania znajdujące się w lokalnej kolejce lub kradnie je innym grupom roboczym. Należy zaznaczyć, że tworzenie nowego pracownika nie wiąże się z tworzeniem nowego wątku. Cała grupa robocza oraz wszyscy jej pracownicy wykonują się w tym samym wątku. Kiedy zadanie się odblokowuje oczekuje na zakończenie zadania aktualnego pracownika i jest wykonywane poza kolejnością tj. przed zadaniami czekającymi w kolejce lokalnej grupy roboczej. 9. Podsumowanie. Parallel Extensions jest podstawowa biblioteką programowania równoległego dla platformy.net i kierowana jest głownie na maszyny wieloprocesorowe lub wielordzeniowe. Nowe podejście stanowi godną alternatywę dla dotychczasowego, wielowątkowego modelu programowania. Główną zaletą tej biblioteki nie jest szybkość przetwarzania zadań, oszczędność pamięć, przenośność oraz skalowalność. W erze procesorów wielordzeniowych, stosowanie tej biblioteki staje się obowiązkowe. Bibliografia. [1] Gordon E. Moore: Cramming more components onto integrated circuits, Electronics Magazine 38 (8), 19/04/1965, ftp://download.intel.com/museum /Moores_Law/Articles-Press_Releases/ Gordon_Moore_1965_Article.pdf [2] Introducing to Intel s 32nm Process Technology, Whitepaper, 2009, http://download.intel.com/pressroom/kits/32n m/westmere/intel_32nm_overview.pdf [3] Intel News Fact Sheet, Intel Developer Forum 22nm News Facts, 2009, http://download.intel.com/pressroom/kits/even ts/idffall_2009/pdfs/22nm_factsheet.pdf [4] Intel Core Duo Processor Family http://ark.intel.com/productcollection.aspx?fa milyid=22731 [5] Intel Core Solo Processor Family http://ark.intel.com/productcollection.aspx?fa milyid=18995 [6] Specyfikacja procesora Intel Core i7-950 Processor, http://ark.intel.com/product.aspx?id=37150 [7] Hyper - Threading Technology, Intel Technology Journal, Volume 06, Issue 01, 14 luty 2002, ISSN 1535766X, ftp://download.intel.com/technology/itj/2002/ volume06issue01/vol6iss1_hyper_threading_t echnology.pdf [8] Microsoft Research http://research.microsoft.com [9].NET Framework 4.0, Microsoft Software Developer Network, http://msdn.microsoft.com/plpl/library/w0x726c2(en-us).aspx [10] Parallel Programming for Managed Developers with the Next Version of Microsoft Visual Studio, Daniel Moth, Professional Developer Conference 2008, http://channel9.msdn.com/blogs/pdc2008/tl26 [11] LINQ, Microsoft Software Developer Network, http://msdn.microsoft.com/enus/netframework/aa904594.aspx 7