Parallel Extensions

Transkrypt

Parallel Extensions
Programowanie równoległe != współbieżne
na platformie .NET Framework 4.0
Wojciech Grześkowiak
Streszczenie: Parallel Extensions, to zbiór narzędzi do
programowania równoległego, które stanowi nowość na platformie
.NET 4.0. Środowisko te w finalnej postaci pojawi się w roku 2010,
ale dzięki wersjom beta juz dziś można poznać kluczowe
funkcjonalności tego rozwiązania. Wprowadza ono nowy model
programowania, oparty na równoległości, a nie współbieżności.
Umożliwia pełne wykorzystanie wielordzeniowych i wieloprocesorowych maszyn.
Keywords: .NET, wątki, współbieżność, paralelizm
1. Wstęp
Kiedy ocenia się moc komputer, pierwszą rzeczą na jaką powinno się
zwrócić uwagę jest częstotliwość procesora. Więcej znaczy lepiej. Producenci
nie mogli jednak przyśpieszać procesorów w nieskończoność. Na drodze
stanęła technologia, należało więc wymyśleć coś innego. Tak powstała idea
procesorów wielordzeniowych, a programowanie na nie, określono mianem
programowania równoległego.
2. Rynek procesorów
W latach 90 w półświatku informatyków głośno krążyło pewne
prawo. Sformułował je założyciel firmy Intel, Gordon Moore. Mówiło one że
Liczba tranzystorów w układzie elektrycznym podwaja się co 18-24 miesiące.
Na tamte czasy prawo to pasowało idealnie. Postęp techniczny był
niewyobrażalny. Coraz więcej tranzystorów potrafiono wcisnąć w jeden wafel
krzemu, a jak wiadomo ich liczba decyduje o częstotliwości pracy układu. Im
więcej się ich tam umieści, tym większą częstotliwość można osiągnąć.
Z czasem dotarto do rozmiarów rzędu 0.45 nm.. To bardzo blisko rozmiarów
atomu. Czy można jeszcze bardziej zmniejszyć wymiar technologiczny? Na
dzień dzisiejszy nie wiadomo. Faktem jest, że szukano alternatywy,
a poszukiwania nie trwały długo, bo odpowiedz wydawała się oczywista.
Skoro nie można zwiększać prędkości procesorów, to zaproponowano że
zamiast jednej jednostki umieści się dwie takie same. Pierwszy tą technologię
wprowadził Intel tworząc rodzinę procesorów Core Duo. Ciekawostką jest
fakt, że funkcjonowały także jednostki Core Solo, które tak naprawdę były
ukrytymi Duo, z pracującym jednym rdzenie. Czemu tak? Proces
technologiczny w którym produkuje się procesory, jest bardzo
skomplikowany i często dochodzi do uszkodzeń. Intel stwierdził że jeśli
usterka nastąpi w jednym rdzeniu, to nie warto wyrzucać całej płytki do kosza.
Wyłączał uszkodzony rdzeń i sprzedawał procesor pod inna nazwą. Kolejno
pojawiły się także jednostki 4 rdzeniowe, a ostatnie z nich, wyposażone
w technologie HT pozwalają na uruchamianie do 8 wątków jednocześnie.
Są 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 jednostki wielordzeniowe nie są
tak samo szybkie jak te jednordzeniowe., to ciągle jedna płytka i ciągle bardzo
mało miejsca. Jaka będzie przyszlość? Analitycy z firmy Forrester Research
przewidują, że już w 2012 roku rozbudowane 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 zostanie omówione rozwiązanie ParallelFX, należy zwrócić
uwagę na często popełniany błąd. W tytule artykułu pojawia się nierówność:
równoległe != (różne od) współbieżne. Napisano tak podkreślić że są to dwie
odrębne rzeczy. Współbieżność wykorzystywana jest aby nie blokować wątku
interfejsu użytkownika, gdy wykonywana jest jakaś asynchroniczna operacja,
lub gdy manipulowane jest I/O. Równoległość wykorzystywana jest w tych
samych przypadkach, lecz dodatkowo można o wiele bardziej zwiększyć
wydajność aplikacji, wykonując równolegle wiele rzeczy. Dokładniej mówiąc
współbieżność odnosi się jedynie do pojedynczego rdzenia. To tam dzięki
szeregowaniu powstaje złudny obraz równoległego wykonywania wątków.
Równoległość bowiem odnosi się do wielu rdzeni, bo tylko tam można
niezależnie i fizycznie w tym samym czasie, równolegle wykonywać kilka
wątków. Dzięki temu znacząco zwiększamy wydajność aplikacji.
Opisane na początku wydarzenia z rynku procesorów nie działy się
tak dawno, 3 może 4 lata wstecz. Już wtedy firma Microsoft widziała w tym
potencjał. Jej oddział badawcza, znany także jako Microsoft Research, zaczął
pracować nad czymś co pozwoliłoby developerem w łatwy i przyjemny
sposób tworzyć aplikację wykorzystując możliwość równoległego
wykonywania kodu. I tak powstała biblioteka TPL, czyli Task Parallel
Library, która stanowi fundament Parallel Extensions, lub jak kto woli
ParallelFX. Biblioteka ta jest częścią nadchodzącej, kolejnej wersji, platformy
.NET Framework.
2
4. ParallelFX w praktyce
Na samym początku należy pokazać przykład jak praktycznie
wykorzystać zalety przetwarzania równoległego. Do tego celu posłużono się
aplikacją która była dostarczona wraz z wersją CDP (Community Technology
Preview) tejże biblioteki. Aplikacja o której mowa zajmuje się generowaniem
realistycznych scen 3D wykorzystując dosyć ciężką technikę - śledzenia
promieni, z ang. Ray Tracing.
Maszyna testowa to: Core Quad (4x 2.2Ghz), 1GB RAM, Windows
Server 2008 na Hyper-V.
Współbieżnie
Równolegle
Ilość FPS
0,7
2,8
Zużycie procesora
26 %
100%
Przy pierwszym uruchomieniu, Menadżer zadań pokazał zużycie
procesora w 26% co, jak na cztery rdzenie, świadczy o tym że
wykorzystaliśmy tylko jeden z nich. Uruchomienie w trybie równoległym dało
wzrost pracy do 100%, dzięki czemu wykorzystywano pełną moc maszyny.
Odświeżanie obrazu (FPS) było wtedy prawie 4 razy szybsze. Najciekawszy
jest w tym wszystkim fakt, iż na poziomie kodu sposób równoległy od
współbieżnego różni się jedynie kilkunastoma znakami. Tak mało za tak
wiele.
5. Imperatywny paralelizm
Jak więc wygląda kod aplikacji wykorzystującej przetwarzanie
równoległe. Aby to przedstawić posłużono się kolejnym przykładem
zaczerpniętym z prezentacji Daniela Motha na konferencji PDC (Proffesiona
Developer Conferension) w październiku 2008 roku [2].
Jest więc aplikacji, w której istnieje metoda, której zadaniem jest
wykonać pewne obliczenia na każdym węźle dostarczonego do niej drzewa
binarnego. Ciało metody jest jak na razie puste. Zadaniem jest napisanie tej
metody w najlepszy możliwy sposób.
using
using
using
using
System;
System.Diagnostics;
System.Threading;
System.Threading.Tasks;
namespace Tree
{
class Program
{
static void Main(string[] args)
{
TNode root = TNode.CreateTree(12, 1);
Stopwatch watch = Stopwatch.StartNew();
WalkTree(root);
Console.WriteLine(
String.Format("Elapsed = {0}",
watch.ElapsedMilliseconds));
}
3
public static void WalkTree(TNode node)
{
/* 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;
}
}
}
Pierwszym pomysłem jaki powinien nam do głowy, to rekurencja.
W ten sposób tworzono drzewo w podobny sposób można je przeglądać.
Funkcja tak zaimplementowana wygląda następująco
public static void WalkTree(TNode node)
{
if (node == null)
return;
WalkTree(node.LeftNode);
WalkTree(node.RightNode);
ProcessItem(node.Value);
}
Mierzony zostanie czas takiej operacji.
Maszyna testowa to: Core 2 Duo T7100 (2x1,8Ghz), 1GB RAM, Windows
XP SP2
Uzyskany czas to 20389 ms.. Zużycie procesora w tym teście to
jedynie 50% (tak pokazywał menadżer zadań). Czyli jak na dwa rdzenie,
wykorzystywaliśmy tylko jeden z nich. Połowa mocy maszyny została nie
wykorzystana. Błędem jest wykorzystanie jednego wątku. Skoro mamy
procesor wielordzeniowy, to spróbujmy wykorzystać wiele wątków, wtedy
wszystkie rdzenie powinny pracować. Funkcja implementująca te podejście
wygląda następująco:
4
public static void WalkTree(TNode node)
{
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);
}
Wykorzystano tu składnie Lambda, nowość w platformie .NET 3.5.
Użyto także metod join, aby rozwiązanie było funkcjonalnie porównywalne
z rekurencją. Zmierzony czas to 11766 ms., czyli prawie dwa razy lepiej.
Podczas testowania uruchomiony menadżer zadań wyglądał następująco:
W 100% wykorzystywano moc procesora. Oto właśnie chodziło. I
gdyby nie jeden problem, rozwiązanie wydawałoby się ideale. W czym rzecz?
Do zastanowienia daje wykres zużycia pliku stronnicowego. Należało
zadeklarować ponad 1GB pamięci. Dla każdego węzła tworzono wątek,
węzłów w drzewie o wysokości 9 jest ponad tysiąc, a każdy wątek na
zarządzanej platformie .NET to 1MB pamięci, stąd właśnie owy 1GB. Co by
się stało gdybym zwiększył wysokość drzewa np. do 11. Pojawił się wyjątek
Out of memory exception, czyli brak pamięci. Jak widać rozwiązanie dobre dla
małych zbiorów danych. Zauważyć należy dodatkowo że taka ilość wątków
powodowała duży nakład związany z przełączania kontekstu. Wiele czasu
tracono właśnie w taki sposób. Podsumowując, rozwiązanie kiepskie. Jak
można zrobić to lepiej? Tutaj z pomocą przychodzi nam Parallel Extension.
5
Do takich celów właśnie stworzono
wykorzystującej PX wygląda następująco:
tą
bibliotekę.
Kod
funkcji
public static void WalkTree(TNode node)
{
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);
}
Czy w kodzie tym są duże zmiany w porównaniu do wersji
wielowątkowej? Nie. Słowo kluczowe Thread zastąpiono Task, a metodę Join,
Wait i to tyle. Zmierzony czas to 11573 ms, czyli jedynie pół sekundy
szybciej, czy to dużo? Co zysakno? Odpowiedź: pamięć. Podczas działania
aplikacji, zarezerwowano jedynie 4 MB pamięci. I gdyby drzewo miało
wysokość 11, czy 111, tyle samo pamięci zostałoby zajęte. Wracając jednak
do czasów. Należy pamiętać że tresowana jest wersja CTP z czerwca 2008.
Wiec jest to jeszcze naprawdę wczesna produkcja. Metoda fabrykująca
zadania (Task) jest tutaj dość ciężka. Do twórców ParallelFx trafił dość duży
feedback odnośnie tego problemu, naprawiono to, jednak nie wypuszczono
kolejnej wersji CTP dodatku. Wypuszczono za to VisualStudio 2010 oraz
.NET 4.0 w wersji CTP. Wyniki z tej wersji przedstawione są poniżej. Dane
pobrane z prezentacji Daniela Motha.
Rekurencja
Wątki
Zadania
.NET 4.0 CTP
15022ms
5801 ms
3918 ms
Do końca artykuły zaprezentowane testy będą wykonywane na wersji
CTP z czerwca 2008.
6
6. Zasada działania
Podstawą kwestią jaką należy zrozumieć to sposób działania menadżera
zadań, Parallel Extensions.
Menadżer zadań można wyobrazić sobie w poniższy sposób. Jeśli
dany jest procesor o N rdzeniach, to dla każdego takiego rdzenia tworzona jest
grupa robocza (work group) zaznaczona na rysunku zieloną kropą. Każda taka
grupa robocza posiada jednego aktywnego workera. Worker to czerwona
kropa na obręczy doczepionej do grupy roboczej. Workerów może być więcej,
ale tylko jeden może być aktywny. Worker wykonuje zadania na rdzeniu, do
którego przypisana jest grupa robocza. Każda grupa robocza posiada własną
kolejkę zadań (tak, tych zadań które tworzyliśmy w kodzie). Dodatkowo
istnieje także globalna kolejka zadań. Aha, tak naprawdę każda grupa robocza
to jeden wątek, niezależnie od ilości workerów w niej egzystujących. Tak
więc na każdym rdzeniu mam jeden wątek, skojarzony z naszą aplikacja.
Kiedy tworzone są pierwsze zadania, trafiają one do globalnej kolejki
zadań. Z tamtą zostają one 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 (worker) nie wykonuje
żadnych zadań zagląda do kolejki swojej grup roboczej i pobiera z niej kolejne
zadania do wykonania. Załóżmy że w przykładzie do każdej lokalnej kolejki
trafiły jakieś zadania. Tak więc każdy pracownik bierze zadanie z kolejki
i zaczyna je wykonywać. W tym momencie każdy rdzeń wykorzystywany jest
w 100%. Czyli wykorzystywany jest cały potencjał maszyny. Co jednak jeśli
pracownik skończył wykonywać swoje zadanie, a w kolejce jego grupy jest
pusto? Pracownik, bardzo chce dalej pracować. Tak więc zagląda do globalnej
kolejki z nadzieją że może znajdą się tam jakieś zadania do wykonania. Jeśli
tak, pobiera je i zajmuje się ich wykonywaniem. Co jednak jeśli tam również
jest pusto? Czy pracownik nie będzie pracował? Nie. Tu pojawia się
mechanizm, z którego tak bardzo dumni są programiści i inżynierowie
ParalleFX. Work Stealing, czyli kradzież pracy.
7
Pracownik przegląda wszystkie lokalne kolejki sąsiednich grup
roboczych i jeśli są tam zadania, kradnie je. Co oczywiście ma skutek w tym,
że znów wszystkie rdzenie pracują w 100%. Osiągnięto zatem równomierny
rozkład pracy na wszystkie rdzenie procesora. Mechanizm jak najbardziej
godny pochwały. W przykładzie o drzewie, zadania tworzyły kolejne zadania
do wykonywania? Jeśli zadanie tworzy kolejne, to pracownik które wykonuje
owo zadanie wrzuca je do lokalnej kolejki grupy roboczej. Czemu nie do
globalnej? Bo to jest jego zadania, i nie chce się dzielić. Wrzuca je do kolejki
która funkcjonuje jako LIFO, czyli last in first out. Zadanie które zostało
stworzone jako ostatnie, będzie wykonywane jako następne, bo znajduje się
na pierwszym miejscu kolejki. Z pewnością ma swoje dane jeszcze ciepłe w
pamięci podręcznej rdzenia, wiec nie trzeba będzie ściągać ich z pamięci.
Bardzo sprytnie. Co jednak z kolejnością zadań? Tu nie ma praktycznie żadnej
kontroli. Zadania jakie tworzymy powinny być od siebie niezależne, więc ich
kolejność wykonywania nie powinna mieć znaczenia.
Wracając do menadżera zadań. Jak wiadomo pracownicy lubią kraść
zadania innym. Kiedy to robią pobierają zadania ze szczytu kolejki, czyli nie z
tego końca z którego pobiera je pracownik. Czemu tak? Skoro zadanie jest na
końcu, to prawdopodobnie zostało stworzone dość dawno, a skoro tak, to dane
dotyczące tego zadania nie znajdują się pewnie w cashu rdzenia na którym
ustawiona jest grupa robocza od której kradniemy zadania. Tak więc nie
trzeba będzie synchronizować się miedzy rdzeniami.
Do omówienia został jeszcze jeden przypadek. W przykładzie
zadania blokowały się czekając aż wykonają się inne. Do tego celu służy
metoda Wait. Jak to wygląda w menadżerze? Kiedy zadanie się blokuje, to
pracownik który je wykonywał zamraża się, a grupa robocza tworzy kolejnego
pracownika, który staje się tym aktywnym i wykonuje zadania znajdujące się
w kolejce lub kradnie je innym. Należy wspomnieć ż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 może być kontynuowane, to zamrożony pracownik oddaje je
aktywnemu, lub inny pracownik z innej grup roboczej kradnie je i wykonuje
dalej.
7. Podsumowanie
Parallel Extensions to z pewnością przyszłość jeżeli chodzi o
programowanie aplikacji kierowanych na maszyny wieloprocesorowe lub
wielordzeniowe. Podejście te stanowi
godną alternatywę dla
dotychczasowego, wielowątkowego modelu. Główną zaletą tej platformy nie
jest szybkość przetwarzania żądań, ale zaoszczędzona pamięć, w porównaniu
z rozwiązaniami tradycyjnymi. Do wydania wersji końcowej z pewnością
minie jeszcze sporo czasu, pracownicy Microsoftu z pewnością dokładnie
dopracują swój projekt i uczynią go bardziej przyjaznym dla programistów.
8
Literatura
[1] Blog programistów tworzących Parallel Extensions,
http://blogs.msdn.com/pfxteam/
[2] Prezentacja Daniel’a Moth’a na Professional Developer Conference, 2008
http://channel9.msdn.com/pdc2008/TL26/
9

Podobne dokumenty