Zaawansowane algorytmy i struktury danych/Wykład 13: Różnice pomiędzy wersjami

Z Studia Informatyczne
Przejdź do nawigacjiPrzejdź do wyszukiwania
Tprybick (dyskusja | edycje)
m Zastępowanie tekstu – „<math> ” na „<math>”
 
(Nie pokazano 38 wersji utworzonych przez 4 użytkowników)
Linia 4: Linia 4:




W module tym zajmiemy się przyspieszaniem obliczeń za pomocą korzystania z wielu procesrów (maszyn) działających równolegle. Niestety nie ma ogólnie przyjętego modelu obliczeń równoległych, rozważymy w tym module dwa modele: maszynę PRAM i układy arytmetyczne (logiczne). O ile maszyna PRAM jest modelem wysoko-poziomowym, to układy arytmetyczne są modelem niskopoziomowym, ale niewątpliwie bardzo istotnym niskopoziomowym, ale
W module tym zajmiemy się przyspieszaniem obliczeń za pomocą korzystania z wielu procesorów (maszyn) działających równolegle. Niestety nie ma ogólnie przyjętego modelu obliczeń równoległych. Rozważymy w tym module dwa modele: maszynę PRAM i układy arytmetyczne (logiczne). O ile maszyna PRAM jest modelem wysoko-poziomowym, to układy arytmetyczne są modelem niskopoziomowym, ale niewątpliwie bardzo istotnym.  
niewątpliwie bardzo istotnym.  


== Model równoległej abstrakcyjnej Maszyny PRAM ==
== Model równoległej abstrakcyjnej Maszyny PRAM ==
Na początku rozważymy wyidealizowany model obleczeń równoległych zwany Równoległą Maszyną ze Swobodnym Dostępem do Pamięci, w skrócie
Na początku rozważymy wyidealizowany model obliczeń równoległych zwany Równoległą Maszyną ze Swobodnym Dostępem do Pamięci, w skrócie
PRAM (od ang. ''Parallel Random Access Machine'', wymawiany ''piram''.
PRAM (od ang. ''Parallel Random Access Machine'', wymawiany ''piram'').


<center>[[Grafika:Parallel1-1.png]]<br>Rysunek 1: Struktura koncepycyjna PRAMu </center>
<center>[[Grafika:Parallel1-1.png]]<br>Rysunek 1: Struktura koncepcyjna PRAMu </center>


Maszyna   PRAM ''składa się'' z wielu procesorów pracujących synchronicznie, korzystących ze wspólnej pamięci (która oprócz przechowywania danych służy do komunikacji między procesorami). Każdy procesor jest standardowym komputerm typu RAM (ang. ''Random Access Machine''). Zakładamy, że procesory są ponumerowane liczbami naturalnymi. Procesory wykonują jeden wspólny program, ale wykonanie poszczególnych instrukcji zależy od indeksu procesora. W jednym kroku procesor pobiera dane z pamięci, potem wykonuje operację, którą może być wpisanie pewnych danych. Wszystkie procesory wykonują jeden krok jednocześnie. Rówoległość jest wyrażona poprzez następującą instrukcję:
Maszyna PRAM składa się z wielu procesorów pracujących synchronicznie, korzystących ze wspólnej pamięci (która oprócz przechowywania danych służy do komunikacji między procesorami). Każdy procesor jest standardowym komputerem typu RAM (ang. ''Random Access Machine''). Zakładamy, że procesory są ponumerowane liczbami naturalnymi. Procesory wykonują jeden wspólny program, ale wykonanie poszczególnych instrukcji zależy od indeksu procesora. W jednym kroku procesor pobiera dane z pamięci, potem wykonuje operację, którą może być wpisanie pewnych danych. Wszystkie procesory wykonują jeden krok jednocześnie. Równoległość jest wyrażona poprzez następującą instrukcję:
 
<center> <math>\textbf{for all } i \in X  \textbf{do in parallel}\ akcja (i)</math>.</center>


\myskip
\begin{center}
\textbf{for all} <math>i \in X</math> \textbf{do in parallel}\ akcja<math>(i)</math>.
\end{center}
\myskip
\noindent
Wykonanie tej instrukcji polega na wykonaniu dwóch równoległych operacji:
Wykonanie tej instrukcji polega na wykonaniu dwóch równoległych operacji:
*przydzielenie procesora do każdego elementu ze zbioru <math>X</math>,
*przydzielenie procesora do każdego elementu ze zbioru <math>X</math>,
*jednoczesne wykonanie przez każdy procesor operacji akcja<math>(i)</math>.
*jednoczesne wykonanie przez każdy procesor operacji akcja<math>(i)</math>.
Przeważnie zapis ``<math>i \in X</math>'' jest w rodzaju\ ``<math>1\leq i\leq n</math>'' jeśli  <math>X</math> jest zbiorem liczb naturalnych.
 
<!--%%
Przeważnie zapis ''<math>i \in X</math>'' jest w rodzaju  ``<math>1\leq i\leq n</math>'' jeśli  <math>X</math> jest zbiorem liczb naturalnych.
-->Litera C (od ang. concurrent) oznacza możliwość jednoczesnego wykonania operacji przez wiele procesorów,
 
== Podstawowe typy maszyny PRAM ==
 
Mamy kilka rodzajów maszyny PRAM w zależności od konfliktów czytania/zapisu we wspólnej pamięci. Litera C (od ang. concurrent) oznacza możliwość jednoczesnego wykonania operacji przez wiele procesorów,
E (od ang. exclusive) wyklucza taką możliwość.  Operacjami są R (czytanie, od ang. read) oraz W (zapis, od ang. write)
E (od ang. exclusive) wyklucza taką możliwość.  Operacjami są R (czytanie, od ang. read) oraz W (zapis, od ang. write)
w tej samej komórce przez wiele procesorów w tym samym momencie.
w tej samej komórce przez wiele procesorów w tym samym momencie.
Mamy zatem EREW PRAM, CREW PRAM, CRCW PRAM (modelu ERCW nie rozważamy jako zupełnie sztuczny).  
Mamy zatem EREW PRAM, CREW PRAM, CRCW PRAM (modelu ERCW nie rozważamy jako zupełnie sztuczny).  


Podstawowym naszym mdoelem PRAMu będzie CREW PRAM: wiele
Podstawowym naszym modelem PRAM-u będzie CREW PRAM: wiele procesrów może jednocześnie czytać z tej samej komórki, ale tylko jeden może zapisywać.
procesrów może jednocześnie czytać z tej samej komórki, ale tylko jeden może zapisywać.
\myskip
Prostym przykładem
obliczenia na CREW jest liczenie kolejnych wierszy trójkąta Pasacala. Początkowo zakładamy, że
<math>A = [0,0,0,0,0,1]</math>. Wykonujemy:
\vskip 0.2cm
\begin{center}
\begin{minipage}{10cm}
repeat 6 times


'''for each''' <math>1 \le i \le 5</math> '''do in parallel''' \\
\hspace*{1cm} <math>A[i]:=A[i]+A[i+1]</math>
\end{minipage}
\end{center}
\vskip 0.2cm \noindent
Kolejnymi wartościami tablicy A są wektory:
\vskip 0.2cm
\begin{center}
\noindent
0\ \  0\ \  0\ \  0\ \  0\ \  1


\noindent 0\ \  0\ \  0\ \  0\ \  0\ \   1
Prostym przykładem obliczenia na CREW jest liczenie kolejnych wierszy trójkąta Pasacala. Początkowo zakładamy, że <math>A = [0,0,0,0,0,1]</math>. Wykonujemy:
 
&nbsp;&nbsp;&nbsp;repeat 6 times
 
&nbsp;&nbsp;&nbsp;'''for each''' <math>1 \le i \le 5</math> '''do in parallel'''


\noindent 0\ \  0\ \  0\ \  0\ \  1\ \  1
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<math>A[i]:=A[i]+A[i+1]</math>


\noindent 0\ \  0\ \  0\ \  1\ \  2\ \  1
Kolejnymi wartościami tablicy A są wektory:


\noindent 0\ \  0\ \  1\ \  3\ \  3\ \  1
<table border=0 align="center" cellpading=5 cellspacing=5>


\noindent 0\ \  1\ \  4\ \  6 \ \  4\ \  1
<tr>
<td align="center">0</td>
<td align="center">0</td>
<td align="center">0</td>
<td align="center">0</td>
<td align="center">0</td>
<td align="center">1</td>
</tr>
<tr>
<td align="center">0</td>
<td align="center">0</td>
<td align="center">0</td>
<td align="center">0</td>
<td align="center">0</td>
<td align="center">1</td>
</tr><tr>
<td align="center">0</td>
<td align="center">0</td>
<td align="center">0</td>
<td align="center">0</td>
<td align="center">1</td>
<td align="center">1</td>
</tr><tr>
<td align="center">0</td>
<td align="center">0</td>
<td align="center">0</td>
<td align="center">1</td>
<td align="center">2</td>
<td align="center">1</td>
</tr><tr>
<td align="center">0</td>
<td align="center">0</td>
<td align="center">1</td>
<td align="center">3</td>
<td align="center">3</td>
<td align="center">1</td>
</tr><tr>
<td align="center">0</td>
<td align="center">1</td>
<td align="center">4</td>
<td align="center">6</td>
<td align="center">4</td>
<td align="center">1</td>
</tr>
<tr>
<td align="center">1</td>
<td align="center">5</td>
<td align="center">10</td>
<td align="center">10</td>
<td align="center">5</td>
<td align="center">1</td>
</tr></table>


\noindent 1\ \  5\    10 \  10\ \  5\ \  1
== Klasa NC i równoległe algorytmy optymalne ==
\end{center}


Najważniejszą klasą problemów algorytmicznych stanowią problemy które można obliczyć w czasie
Najważniejszą klasą problemów algorytmicznych stanowią problemy, które można obliczyć w czasie wielomianowo-logarytmicznym, używając wielomianowej liczby procesorów. Klasę tę oznaczamy przez NC, odpowiadające algorytmy nazywamy algorytmami typu NC.
wielomianowo-logarytmicznym używając wielomianowej liczby procesorów. Klasę tę oznaczamy przez NC, odpowiadające
algorytmy nazywamy algorytmami typu NC.


Niech <math>\cal{P}</math> oznacza klasę problemów wykonywanych w deterministycznym czasie wielomianowym na maszynie
Niech <math>\cal{P}</math> oznacza klasę problemów wykonywanych w deterministycznym czasie wielomianowym na maszynie
sekwencyjnej. Podstawowym problemem teoretycznym algorytmiki równoległej jest pytanie:
sekwencyjnej. Podstawowym problemem teoretycznym algorytmiki równoległej jest pytanie:


\centerline{<math> \cal{P}\ = \ NC \ </math>?}
<center> <math>\cal{P}\ = \ NC \ </math>? </center>
\myskip
 
Podobnie jak problemy NP-zupełne można zdefiniować probly P-zupełne. Są to te problemy <math>X \in \cal{P}</math>, takie
 
 
Podobnie jak problemy NP-zupełne można zdefiniować problemy P-zupełne. Są to te problemy <math>X \in \cal{P}</math>, takie
że dla każdego innego problemu <math>Y \in \cal{P}</math> istnieje NC-redukcja Y do X.
że dla każdego innego problemu <math>Y \in \cal{P}</math> istnieje NC-redukcja Y do X.
Inaczej mówiąc
Inaczej mówiąc


\centerline{<math> \cal{P} =  NC \ </math> wtedy i tylko wtedy gdy \ <math>X \in NC</math>}
<center> <math>\cal{P} =  NC \ </math> wtedy i tylko wtedy gdy <math>X \in NC</math> </center>
\myskip
 
'''Przykłady problemów P-zupełnych:\ '''
 
 
'''Przykłady problemów P-zupełnych:'''
programowanie liniowe, maksymalny przepływ w grafie, konstrukcja drzewa DFS, obliczanie wartości układów logicznych,
programowanie liniowe, maksymalny przepływ w grafie, konstrukcja drzewa DFS, obliczanie wartości układów logicznych,
sprawdzanie czy gramatyka bezkontekstowa generuje język pusty.
sprawdzanie czy gramatyka bezkontekstowa generuje język pusty.
\myskip
Przez pracę algorytmu równoległego rozumiemy liczbę procesorów pomnożoną orzez czas.
Algorytm jest optymalny gdy jego praca jest tego samego rzędu co czas najlepszego znanego algorytmu sekwencyjnego dla
danego problemu.


W szczególności interesują nas algorytmy, które są jednocześnie optymalne i są typu NC.
Przez pracę algorytmu równoległego rozumiemy liczbę procesorów pomnożoną przez czas. Algorytm jest optymalny, gdy jego praca jest tego samego rzędu co czas najlepszego znanego algorytmu sekwencyjnego dla danego problemu.
Z praktycznego punku widzenia czynnik <math>log n</math> przy liczbie procesorów i przy pracy algorytmu
 
nie jest zbyt istotny. Natomiast czynnik logarytmiczny jest istotny jeśli chodzi o równoległy czas. W tym przypadku
W szczególności interesują nas algorytmy, które są jednocześnie optymalne i są typu NC. Z praktycznego punku widzenia czynnik <math>log n</math> przy liczbie procesorów i przy pracy algorytmu nie jest zbyt istotny. Natomiast czynnik logarytmiczny jest istotny, jeśli chodzi o równoległy czas. W tym przypadku potęga logarytmu odgrywa podobną rolę co potęga wielomianu opisującego czas obliczenia sekwencyjnego. Różnica między CRCW i CREW PRAMem w aspekcie klasy NC polega przeważnie na dodaniu jednego czynnika logarytmicznego w funkcji równoległego czasu obliczenia.
potęga logarytmu odgrywa podobną rolę co potęga wielomianu opisującego czas obliczenia sekwencyjnego.
 
Różnica między CRCW i CREW PRAMem w aspekcie klasy NC polega przeważnie na dodaniu jednego czynnika logarytmicznego
== Przykłady obliczeń na modelu CRCW PRAM ==
w funkcji równoległego czasu obliczenia.  
 
<!--%
W przypadku CRCW PRAM założymy, że procesory, jeśli wpisują jednocześnie do tej samej komórki pamięci, to
--><!--%-------------------------------------------------------------------------------------------
wpisują to samo. Na przykład jeśli początkowo <math>output=0</math>, wówczas następujący algorytm obliczy logiczną
--><!--%
--><!--%
-->W przypadku CRCW PRAM założymy, że procesory, jeśli wpisują jednocześnie do tej samej komórki pamięci, to
wpisują to samo. Na przykład jeśli początkowo <math>output=0</math> wówczas następujący algorytm obliczy logiczną
alternatywę w czasie stałym na CRCW PRAM.
alternatywę w czasie stałym na CRCW PRAM.
\myskip
'''for each''' <math>1 \le i \le n</math> '''do in parallel''' \\
\hspace*{1cm} if <math>A[i] =1</math> then output=1; \vskip 0.6cm Na CREW potrzebujemy logarytmicznego czasu równoległego
aby to zrobić. Pokażemy jeszcze dwa proste problemy, które można na CRCW PRAM wykonać w czasei stałym.
Następujący algorytm liczy pierwszą  pozycję minimalnego elementu w  tablicy  C[1 . . n] w czasie  O(1). \vskip 0.4cm
'''for each''' <math>1 \le i\le n</math> '''do in parallel'''  <math>M[i]</math> := 0;


'''for each''' <math>1 \le i,\ j \le n</math> '''do in parallel'''
\\ \hspace*{1cm}
if <math>i \ne j</math> and <math>C[i] \le C[j]</math> then <math>M[i]</math>:=1;


'''for each''' <math>1 \le i\le n</math> '''do in parallel'''  
'''for each''' <math>1 \le i \le n</math> '''do in parallel'''  
\\ \hspace*{1cm} if <math>M[i]</math> = 0 then output :=i ;
 
\myskip Oznaczmy ten algorytm przez <math>A_1</math>. Algorytm korzysta z <math>n^2</math> procesorów. Spróbujemy zmniejszyć tę
&nbsp;&nbsp;&nbsp; if <math>A[i] =1</math> then output=1;
liczbę do <math>O(n^{1+\epsilon})</math> zachowując czas <math>O(1)</math>, dla dowolnie małego <math>\epsilon >0</math>.
 
 
Na CREW potrzebujemy logarytmicznego czasu równoległego, aby to zrobić. Pokażemy jeszcze dwa proste problemy, które można na CRCW PRAM wykonać w czasie stałym. Następujący algorytm liczy pierwszą  pozycję minimalnego elementu w  tablicy  C[1 . . n] w czasie  O(1).
 
{{algorytm|||
'''for each''' <math>1 \le i\le n</math> '''do in parallel''' <br> 
&nbsp;&nbsp;&nbsp;<math>M[i]:= 0</math> ;
 
'''for each''' <math>1 \le i,\ j \le n</math> '''do in parallel''' <br>
&nbsp;&nbsp;&nbsp; if <math>i \ne j</math> and <math>C[i] \le C[j]</math> then <math>M[i]:=1</math>;
 
'''for each''' <math>1 \le i\le n</math> '''do in parallel''' <br>
&nbsp;&nbsp;&nbsp;if <math>M[i] = 0</math> then <math>output :=i</math> ;
}}
 
Oznaczmy ten algorytm przez <math>A_1</math>. Algorytm korzysta z <math>n^2</math> procesorów. Spróbujemy zmniejszyć tę liczbę do <math>O(n^{1+\epsilon})</math>, zachowując czas <math>O(1)</math> dla dowolnie małego <math>\epsilon >0</math>.
 
Niech
 
<center><math>P_k(n)= n^{1+\epsilon_k}</math>, gdzie <math>\epsilon_k= \frac{1}{2^{k}-1}</math>.</center>
 
 
Przypuśćmy, że mamy algorytm <math>A_k</math> liczenia minimum w czasie <math>O(1)</math> z <math>O(P_k(n))</math> procesorami. Skonstruujemy algorytm <math>A_{k+1}</math>, który działa w czasie stałym i używa tylko <math>O(P_{k+1}(n))</math> procesorów.


\noindent Niech


\centerline{<math>P_k(n)= n^{1+\epsilon_k}</math>, gdzie <math>\epsilon_k\ =\ \frac{1}{2^{k}-1}</math>.}
{{algorytm|<math>A_{k+1}</math>||
\myskip
\noindent Przypuśćmy, że mamy algorytm <math>A_k</math> liczenia minimum w czasie <math>O(1)</math> z <math>O(P_k(n))</math> procesorami.
Skonstruujemy algorytm <math>A_{k+1}</math> który działa w czasie stałym i używa tylko  <math>O(P_{k+1}(n)) </math> procesorów.
\vskip 0.5cm \noindent '''Algorytm}\ <math>A_{k+1'''</math>:


niech <math>\alpha=\frac{1}{2^{k}+1}</math>;
niech <math>\alpha=\frac{1}{2^{k}+1}</math>;
Linia 132: Linia 167:
równolegle zastosuj algorytm  <math>A_k</math> do każdego z tych bloków;
równolegle zastosuj algorytm  <math>A_k</math> do każdego z tych bloków;


zastosuj algorytm  <math>A_k</math> do tablicy C' składającej się z  <math>\frac{n}{n^{\alpha}}</math> minimów w blokach. \vskip
zastosuj algorytm  <math>A_k</math> do tablicy C' składającej się z  <math>\frac{n}{n^{\alpha}}</math> minimów w blokach.  
0.5cm \noindent Algorithm <math>A_{k+1}</math> działa w czasie <math>O(1)</math> korzystając z  <math>P_{k+1}(n))</math> procesorów.
}}
Uzasadnienie pozostawiamy jako ćwiczenie. \vskip 0.3cm \noindent Algorytmy  <math>A_2,\ A_3,\  A_4, \ldots</math> używają
 
odpowiednio następującą (asymptotycznie) liczbę procesorów
 
Algorytm <math>A_{k+1}</math> działa w czasie <math>O(1)</math>, korzystając z  <math>P_{k+1}(n))</math> procesorów. Uzasadnienie pozostawiamy jako ćwiczenie.
 
Algorytmy  <math>A_2,\ A_3,\  A_4, \ldots</math> używają odpowiednio następującą (asymptotycznie) liczbę procesorów
 
<center><math>n^{1+\frac{1}{3}},\ \ n^{1+\frac{1}{15}},\  \ n^{1+\frac{1}{255}},  \ \ n^{1+\frac{1}{65535}} ..</math>. </center>
 
Rozważmy jeszcze na CRCW PRAM następujący '''problem pierwszej jedynki''': dana jest tablica zerojedynkowa, znaleźć pozycję pierwszej jedynki (od lewej).
 
Następujący algorytm rozwiązuje problem w czasie stałym z kwadratową liczbą procesorów. Zakładamy na razie, że w ciągu jest jakaś jedynka.


<math>n^{1+\frac{1}{3}},\ \ n^{1+\frac{1}{15}},\  \ n^{1+\frac{1}{255}},  \ \ n^{1+\frac{1}{65535}} ..</math>. \myskip
{{algorytm|Pierwsza-Jedynka-1||
Rozważmy jeszcze  na CRCW PRAM następujący '''problem pierwszej jedynki''':
\
dana tablica zerojedynkowa, znaleźć pozycję pierwszej jedynki (od lewej). \myskip Następujący algorytm
rozwiązuje problem w czasie stałym z kwadratową liczbą procesorów. Zakładamy na razie, że w ciągu jest jakaąs jedynka.
\vskip 0.3cm '''Algorytm'''
Pierwsza-Jedynka-1;


'''for each''' <math>1 \leq i < j \le n</math> '''do in parallel'''  
'''for each''' <math>1 \leq i < j \le n</math> '''do in parallel'''  


\hspace*{0.6cm} if A[i]=1] and A[j]=1 then A[j]:= 0;
&nbsp;&nbsp;&nbsp;if A[i]<math>=</math>1 and A[j]<math>=</math>1 then A[j]<math>:=</math>0;


'''for each''' <math>1 \leq i \le n</math> '''do in parallel'''  
'''for each''' <math>1 \leq i \le n</math> '''do in parallel'''  


\hspace*{0.6cm} if A[i]=1 then FirstOne :=i. \vskip 0.3cm \noindent Możemy podobnie  łatwo sprawdzić czy w
&nbsp;&nbsp;&nbsp;if A[i]<math>=</math>1 then FirstOne <math>:=</math>i.  
ogóle jest jedynka. \vskip 0.3cm '''Algorytm''' CzyJestJedynka;
}}
 
 
Możemy podobnie  łatwo sprawdzić, czy w ogóle jest jedynka.
 
{{algorytm|CzyJestJedynka||


jest-jedynka := 0;
jest-jedynka <math>:=</math>0;


'''for each''' <math>1 \leq i \le n</math> '''do in parallel'''  
'''for each''' <math>1 \leq i \le n</math> '''do in parallel'''  


\hspace*{0.6cm} if A[i]=1 then jest-jedynka := 1;
&nbsp;&nbsp;&nbsp;if A[i]<math>=</math>1 then jest-jedynka <math>:=</math>1;
}}


\vskip 0.2cm \noindent Oba powyższe algorytmy korzystają z <math>O(n^2)</math> procesorów. Możemy łatwo tę liczbę
zmniejszyć do liniowej. \myskip '''Algorytm''' Pierwsza-Jedynka; \vskip 0.1cm '''(1)\ ''' Podziel tablicę A na
segmenty długośći <math>\sqrt{n}</math>;


'''(2)\ ''' W każdym segmencie zastosuj algorytm CzyJestJedynka;
Oba powyższe algorytmy korzystają z <math>O(n^2)</math> procesorów. Możemy łatwo tę liczbę zmniejszyć do liniowej.


'''(3)\ } Otrzymujemy ciąg zerojedynkowy <math>C</math> długości  <math>\sqrt{n'''</math> jako wynik kroku (2);


'''(4)\ ''' znajdź pierwszą jedynkę w ciągu C za pomocą algorytmu Pierwsza-Jedynka-1;;
{{algorytm|Pierwsza-Jedynka||
'''(1)''' Podziel tablicę A na segmenty długośći <math>\sqrt{n}</math>;


'''(5)\ ''' Zastosuj algorytm Pierwsza-Jedynka-1 do segmentu odpowiadającego
'''(2)''' W każdym segmencie zastosuj algorytm CzyJestJedynka;
\\ \hspace*{1.4cm}
 
pierwszej jedynce w C;
'''(3)''' Otrzymujemy ciąg zerojedynkowy <math>C</math> długości <math>\sqrt{n}</math> jako wynik kroku (2);
\myskip W ten sposób stosujemy trzy  razy algorytm o pracy kwadratowej do segmentu długości <math>\sqrt{n}</math>,
 
otrzymujemy złożoność <math>O(\sqrt{n}^2) = O(n)</math>. Czas jest <math>O(1)</math>.
'''(4)''' znajdź pierwszą jedynkę w ciągu C za pomocą algorytmu Pierwsza-Jedynka-1;
<!--%
 
-->Do szybkich obliczeń równoległych najbardziej nadają się problemy związane z drzewami, chociaż czasami w tych
'''(5)''' Zastosuj algorytm Pierwsza-Jedynka-1 do segmentu odpowiadającego pierwszej jedynce w C;
problemach nie widać od razu struktury drzewiastej. Struktura taka odpowiada również drzewu rekursji.
}}
Jako przykład rozważmy problem obliczenia sumy <math>A[1]+A[2]+\ldots A[n]</math>. Dla uproszczenia
 
załóżmy, że n jest potęgą dwójki.
W ten sposób stosujemy trzy  razy algorytm o pracy kwadratowej do segmentu długości <math>\sqrt{n}</math>. Otrzymujemy złożoność <math>O(\sqrt{n}^2) = O(n)</math>. Czas jest <math>O(1)</math>.
<!--%
 
--><!--%
== Drzewa i równoległa wersja metody dziel i zwyciężaj ==
-->
 
Do szybkich obliczeń równoległych najbardziej nadają się problemy związane z drzewami, chociaż czasami w tych problemach nie widać od razu struktury drzewiastej. Struktura taka odpowiada również drzewu rekursji. Jako przykład rozważmy problem obliczenia sumy <math>A[1]+A[2]+\ldots A[n]</math>. Dla uproszczenia załóżmy, że n jest potęgą dwójki.
 
<center> [[Grafika:Parallel1-2.png]]<br> Rysunek 2: Metoda pełnego zrównoważonego drzewa binarnego: układ arytmetyczny obliczania sumy. Maksymalny poziom <math>m=\log n</math>.</center>
 
Wysokością węzła jest jego maksymalna odległość od liścia, wysokość liścia wynosi 0. Przez <math>p</math>-ty poziom rozumiemy zbiór węzłów o wysokości <math>p</math>. Załóżmy, że elementy <math>A[1], A[2], ..A[n]</math> są umieszczone w liściach pełnego
zrównoważonego drzewa binarnego, następnie wykonujemy (patrz rysunek):


<!--%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%              %%
-->\begin{figure}[htbp]
\begin{center}
\mbox{\ }
\includegraphics[width=10.cm]{parallel_fig2.eps}
\caption{ Metoda pełnego zrównoważonego drzewa binarnego:\ układ arytmetyczny obliczania sumy.
Maksymalny poziom <math>m=\log n</math>.}
<span id="Figure1" \>
\end{center}
\end{figure}
<!--%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-->


\noindent Wysokością węzła jest jego maksymalna odległość od liścia, wysokość liścia wynosi 0.
Przez <math>p</math>-ty poziom rozumiemy zbiór węzłów o wysokości <math>p</math>.
Załóżmy, że elementy <math>A[1], A[2], ..A[n]</math> są umieszczone w liściach pełnego
zrównoważonego drrzewa binarnego, następnie wykonujemy (patrz rysunek):
\vskip 0.4cm
'''for''' <math>p:=1</math> '''to''' <math>\log n</math> '''do'''
'''for''' <math>p:=1</math> '''to''' <math>\log n</math> '''do'''


\hspace*{0.5cm} oblicz jedmocześnie wartości węzłów na poziomie <math>p</math>-tym;
&nbsp;&nbsp;&nbsp; oblicz jednocześnie wartości węzłów na poziomie <math>p</math>-tym;
\myskip
 
<!--%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%              %%
 
-->Drzewo  jest strukturą koncepcyjną, każdemu węzłowi możemy przypisać miejsce w pamięci. W naszym przypadku
Drzewo  jest strukturą koncepcyjną, każdemu węzłowi możemy przypisać miejsce w pamięci. W naszym przypadku węzły na poziomie <math>p</math>-tym mogą odpowiadać elementom  
węzły na poziomie <math>p</math>-tym mogą odpowiadać elementom  
 
\\
<center> <math>A[2^p],\ A[2*2^p], .. A[3*2^p]\ldots</math>. </center>
\centerline{<math>A[2^p],\ A[2*2^p], .. A[3*2^p]\ldots</math>. }
 
Poprzedni algorytm można zapisać w formie:
Poprzedni algorytm można zapisać w formie:
\vskip 0.4cm \noindent
 
'''for''' <math>p:=1</math> '''to''' <math>\log n</math> '''do'''\\
'''for''' <math>p:=1</math> '''to''' <math>\log n</math> '''do'''
\hspace*{0.5cm} <math>\Delta=2^p</math>;
 
\\
&nbsp;&nbsp;&nbsp;<math>\Delta=2^p</math>;
\hspace*{0.5cm} '''for each''' <math>1 \le i \le n/\Delta</math> '''do in parallel'''  
 
\\
&nbsp;&nbsp;&nbsp;'''for each''' <math>1 \le i \le n/\Delta</math> '''do in parallel'''  
\hspace*{0.9cm} <math>A[i*\Delta]\ :=\  A[i*\Delta]-\Delta/2]+A[i*\Delta]</math>;
 
\\
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<math>A[i*\Delta]\ :=\  A[i*\Delta]-\Delta/2]+A[i*\Delta]</math>;
 
wynik := <math>A[n]</math>;
wynik := <math>A[n]</math>;
\myskip
\begin{figure}[hbtp]
\begin{center}
\mbox{\ }
\includegraphics[width=9.cm]{parallel_fig3.eps}
\caption{Koncepcyjna struktura równoległej wersji metody {\em dziel i zwycieżaj}. }
<span id="Figure1" \>
\end{center}
\end{figure}
<!--%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-->


Drzewa odpowiadają w pewnym sensie rekursji. Wysokość drzew odpowiada czasowi równoległemu. Podobnie
głębokość rekursji odpowiada też czasowi równoległemu.
Równoległa wersja tzw. metody dziel i zwyciężaj polega na tym że wywołania rekurencyjne
wykonujemy jednocześnie (patrz rysunek). Oczywiście może się zdarzyć, że jedno z nich zakończy się wcześniej.
Tym niemniej algorytm czeka na zakończenie obu wywołań.


Pokażemy dwa przykłady zastosowania metody dziel i zwyciężaj w wersji równoległej.
<center>[[Grafika:Parallel1-3.png]]<br> Rysunek 3. Koncepcyjna struktura równoległej wersji metody ''dziel i zwyciężaj''.</center>
Zaczniemy od sumy elementów tablicy <math>A[1..n]</math>.
 
 
Drzewa odpowiadają w pewnym sensie rekursji. Wysokość drzew odpowiada czasowi równoległemu. Podobnie głębokość rekursji odpowiada czasowi równoległemu. Równoległa wersja tzw. metody dziel i zwyciężaj polega na tym, że wywołania rekurencyjne wykonujemy jednocześnie (patrz rysunek). Oczywiście może się zdarzyć, że jedno z nich zakończy się wcześniej. Niemniej algorytm czeka na zakończenie obu wywołań.
 
 
Pokażemy dwa przykłady zastosowania metody dziel i zwyciężaj w wersji równoległej. Zaczniemy od sumy elementów tablicy <math>A[1..n]</math>.
Wynik obliczamy jako <math>SUMA(1,n)</math>. Zakładamy znowu, że <math>n</math> jest potęgą dwójki.
Wynik obliczamy jako <math>SUMA(1,n)</math>. Zakładamy znowu, że <math>n</math> jest potęgą dwójki.
\myskip
<!--%-----------------------------------------------------------------
-->\begin{center}
\begin{minipage}{12cm}
\vskip0.3cm
\hspace*{0.6cm}\textbf{funkcja} <math>SUMA(i,j)</math>; \\
\hspace*{1.2cm}\textbf{if} <math>j=i</math> \textbf{then return} <math>A[i]</math> \textbf{else }\\
\hspace*{1.8cm}{'''do in parallel} '''\\
\hspace*{2.5cm} wynik1 := <math>SUMA(i,\ \lfloor (i+j)/2 \rfloor</math>;\\
\hspace*{2.5cm} wynik2 := <math>SUMA(\lceil (i+j)/2 \rceil,\ j)</math>;\\
\hspace*{1.8cm}\textbf{return} wynik1 + wynik2;
\vskip0.4cm
\end{minipage}
\end{center}
\myskip
\noindent Podobny jest schemat sortowania na PRAMie.
Niech <math>ParallelMerge(x)</math> będzie algorytmem który, otrzymawszy tablicę <math>x</math> z posortowanymi lewą
i prawą połową da wyniku tablicę <math>x</math> posortowaną. łatwo to zrobić w czasie <math>O(\log n)</math> z <math>n</math>
procesorami. Dla <math>i>n/2</math>-ty procesor znajduje w pierwszej połówce sekwencyjnie metodą {\em binary search}
najmniejszy element większy od <math>x[i]</math>. Wymaga to czasu <math>O(\log n)</math>.
Potem każdy procesor{\em wie} gdzie wstawić {\em swój} element.
W sumie otrzymujemy algorytm sortowania w czasie <math>O(\log^2)</math> z <math>n</math> procesorami.
\begin{center}
\begin{minipage}{12cm}
\vskip0.3cm
\hspace*{0.6cm}\textbf{funkcja} <math>ParallelSort(x)</math>; \\
\hspace*{1.2cm}<math>n:=size(x)</math>;\\
\hspace*{1.2cm}\textbf{if} <math>n>1</math> \textbf{then } \\
\hspace*{1.8cm}\textbf{do in parallel}\\
\hspace*{2.5cm} <math>ParallelSort(FirstHalf(x))</math>;\\
\hspace*{2.5cm} <math>ParallelSort(SecondHalf(x))</math>;\\
\hspace*{1.8cm} <math>ParallelMerge(x)</math>
\vskip0.4cm
\end{minipage}
\end{center}
\myskip
Liczbę procesorów można zmniejszyc do <math>n/(\log n))</math>. Natomiast nietrywialnym jest zmniejszenie
czasu na CREW PRAM. Zostało to zrobione przez Richarda Cole'a, który skonstruował algorytm działający w czaie <math>O(\log n)</math>
z <math>O(n)</math> procesorami maszyny EREW PRAM. Algorytm ten jest bardzo interesujący ale skomplikowany.
<!--%
-->\newpage
Być może najbardziej podstawowym modelem obliczeń równoległych są układy arytmetyczne (lub logiczne):
acyckliczne grafy z przypisaniem pewnych operacji węzłom wewnętrznym.
Każdy węzeł liczy pewną wartość w momencie gdy wartości jego poprzedników są policzone.
Podobnie jak w drzewie możemy zdefinować pojęcie liścia: węzeł bez poprzedników.
Natomiast graf nie musi mieć jednego korzenia, zamiast korzenia w grafie wyróżniamy węzły wynikowe (na rysunku te z których wychodzi strzałka {\em do nikąd}).


Równoległy czas obliczenia odpowiada maksymalej wysokości węzła. Poziomy definiujemy podobnie jak dla drzewa.
'''funkcja''' <math>SUMA(1,n)</math>
Algorytm równoległy w jednym równoległym kroku oblicza kolejny poziom.
 
Liczba procesorów odpowiada z reguły maksymalnemu roziarowi poziomu, chociaż możemy inaczej
'''if''' <math>j=i</math> '''then return''' <math>A[i]</math> '''else'''
rozplanować obliczenie gdy jedne poziomy są duże, a drugie małe (ale możemy wtedy zmienić strukturę
 
grafu tak aby temu odpowiadała).
&nbsp;&nbsp;&nbsp;'''do in parallel '''
 
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;wynik1 <math>:= SUMA(i,\ \lfloor (i+j)/2 \rfloor</math>;
 
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;wynik2 <math>:= SUMA(\lceil (i+j)/2 \rceil,\ j)</math>;
 
&nbsp;&nbsp;&nbsp;'''return''' wynik1 + wynik2;


Przykładem układu arytmetycznego jest drzewo z rysunku powyżej, które opisuje sumowanie n elementów.
Zajmiemy się teraz pewnym rozszerzeniem problemu sumowania. Niech <math>\oplus</math> będzie pewną łączną operacją arytmetyczną
(np. suma, mnożenie, maksimum, minimum, pozycja pierwszej jedynki z lewej strony, podobnie z prawej strony).


\noindent'''Problem sum p[refiksowych.'''\  
Podobny jest schemat sortowania na PRAMie. Niech <math>ParallelMerge(x)</math> będzie algorytmem, który, otrzymawszy tablicę <math>x</math> z posortowanymi lewą i prawą połową, da w wyniku posortowaną  tablicę <math>x</math>. Łatwo to zrobić w czasie <math>O(\log n)</math> z <math>n</math>
dany wektor  <math>x</math>  rozmiaru <math>n</math>, obliczyć ciąg <math>y</math> taki, gdzie
procesorami. Dla <math>i>n/2</math>-ty procesor znajduje w pierwszej połówce sekwencyjnie, metodą ''binary search'',
\myskip
najmniejszy element większy od <math>x[i]</math>. Wymaga to czasu <math>O(\log n)</math>. Potem każdy procesor\mathit{ wie} gdzie wstawić \mathit{ swój} element. W sumie otrzymujemy algorytm sortowania w czasie <math>O(\log^2)</math> z <math>n</math> procesorami.
\begin{center}
 
<math>y[1]=x[1]</math>, <math>y[2]=x[1]\oplus x[2]</math>, <math>y[3]=x[1]\oplus x[2]\oplus x[3]</math>, \ldots
 
\end{center}
 
'''funkcja''' <math>ParallelSort(x)</math>;
 
<math>n:=size(x)</math>;
 
'''if''' <math>n>1</math> '''then'''
 
&nbsp;&nbsp;&nbsp; '''do in parallel'''
 
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <math>ParallelSort(FirstHalf(x))</math>;
 
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <math>ParallelSort(SecondHalf(x))</math>;
 
&nbsp;&nbsp;&nbsp;<math>ParallelMerge(x)</math>
 
 
Liczbę procesorów można zmniejszyć do <math>n/(\log n))</math>. Natomiast nietrywialnym jest zmniejszenie czasu na CREW PRAM. Zostało to zrobione przez Richarda Cole'a, który skonstruował algorytm działający w czasie <math>O(\log n)</math> z <math>O(n)</math> procesorami maszyny EREW PRAM. Algorytm ten jest bardzo interesujący, ale niestety również bardzo skomplikowany.
 
== Układy arytmetyczne: sumy prefiksowe ==
 
Być może najbardziej podstawowym modelem obliczeń równoległych są układy arytmetyczne (lub logiczne): acykliczne grafy z przypisaniem pewnych operacji węzłom wewnętrznym. Każdy węzeł liczy pewną wartość w momencie, gdy wartości jego poprzedników są policzone. Podobnie jak w drzewie, możemy zdefiniować pojęcie liścia: węzeł bez poprzedników. Natomiast graf nie musi mieć jednego korzenia. Zamiast korzenia w grafie wyróżniamy węzły wynikowe (na rysunku te, z których wychodzi strzałka do ‘’nikąd’’).
 
Równoległy czas obliczenia odpowiada maksymalnej wysokości węzła. Poziomy definiujemy podobnie jak dla drzewa. Algorytm równoległy w jednym równoległym kroku oblicza kolejny poziom. Liczba procesorów odpowiada z reguły maksymalnemu rozmiarowi poziomu, chociaż możemy inaczej rozplanować obliczenie, gdy jedne poziomy są duże, a drugie małe (ale trzeba  wtedy zmienić strukturę grafu tak, aby temu odpowiadała).
 
Przykładem układu arytmetycznego jest drzewo z rysunku powyżej, które opisuje sumowanie n elementów.
 
Zajmiemy się teraz pewnym rozszerzeniem problemu sumowania. Niech <math>\oplus</math> będzie pewną łączną operacją arytmetyczną (np. suma, mnożenie, maksimum, minimum, pozycja pierwszej jedynki z lewej strony, podobnie z prawej strony).
 
'''Problem sum prefiksowych.''' dany wektor  <math>x</math>  rozmiaru <math>n</math>, obliczyć ciąg <math>y</math> taki, gdzie
 
<center><math>y[1]=x[1]</math>, <math>y[2]=x[1]\oplus x[2]</math>, <math>y[3]=x[1]\oplus x[2]\oplus x[3]</math>, ... </center>
 
gdzie
gdzie
\myskip
 
Opiszemy dwa rekurencyjne algorytmy dla tego problemu. Niech <math>FirstHalf</math>, <math>SecondHalf</math> oznaczją lewą
 
Opiszemy dwa rekurencyjne algorytmy dla tego problemu. Niech <math>FirstHalf</math>, <math>SecondHalf</math> oznaczają lewą
i prawą (odpowiednio) połówkę ciągu. Zakładamy, że n jest potęgą dwójki.
i prawą (odpowiednio) połówkę ciągu. Zakładamy, że n jest potęgą dwójki.
\myskip
<!--%-----------------------------------------------------------------
-->\begin{center}
\begin{minipage}{12cm}
\vskip0.3cm
\hspace*{0.6cm}\textbf{Algorytm} <math>PrefSums1(x)</math>; \\
\hspace*{1.2cm}<math>n:=size(x)</math>;\\
\hspace*{1.2cm}\textbf{if} <math>n>1</math> \textbf{then } \\
\hspace*{1.8cm}\textbf{do in parallel}\\
\hspace*{2.5cm} <math>PrefSums1(FirstHalf(x))</math>;\\
\hspace*{2.5cm} <math>PrefSums1(SecondHalf(x))</math>;\\
\hspace*{1.8cm}\textbf{for each } <math>n/2 < j\leq n</math>, \textbf{do in parallel}
\\
\hspace*{2.4cm} <math>x[j]:= x[n/2] \oplus x[j]</math>;\\
\vskip0.4cm
\end{minipage}
\end{center}
\myskip
<!--%
--><!--%------------------- NOWA SEKCJA ---------------------------------------------------------
--><!--%
-->


Układ arytmetyczny odpowiadający algorytmowi PrefSums1 jest przedstawiony na Rysunku&nbsp;[[#parallel_fig5]] dla  
{{algorytm| <math>PrefSums1(x)</math>; ||
<math>n=4</math> i <math>n=8</math>. Zauważmy, że zasadniczą częśCią układu dla <math>n=8</math>  są dwie kopie układu dla <math>n=4</math>.
 
Dodatkowo dodajemy węzły odpowiadającej ostatniej instrukcji w algorytmie PrefSums1.
<math>n:=size(x)</math>;
 
'''if'''<math>n>1</math> '''then'''
 
&nbsp;&nbsp;&nbsp;'''do in parallel'''
 
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<math>PrefSums1(FirstHalf(x))</math>;
 
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<math>PrefSums1(SecondHalf(x))</math>;
 
&nbsp;&nbsp;&nbsp;'''for each''' <math>n/2 < j\leq n</math>, '''do in parallel'''
 
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<math>x[j]:= x[n/2] \oplus x[j]</math>;
}}
 
Układ arytmetyczny odpowiadający algorytmowi PrefSums1 jest przedstawiony na Rysunku 4 dla <math>n=4</math> i <math>n=8</math>. Zauważmy, że zasadniczą częścią układu dla <math>n=8</math>  są dwie kopie układu dla <math>n=4</math>. Dodatkowo dodajemy węzły odpowiadającej ostatniej instrukcji w algorytmie PrefSums1.
 
 
<center>[[Grafika:Parallel1-4.png]]<br> Rysunek 4. Układ arytmetyczny odpowiadający algorytmowi PrefSums1. Kolejne grafy powstają jako podwójne kopie porzednich grafów (dla <math>n/2</math> elementów)  z dodanymi elementami odpowiadającymi operacji <math>x[j]:= x[n/2] \oplus x[j]</math>. </center>
 
 
Opiszemy teraz inny algorytm rekurencyjny, w którym mamy tylko jedno wywołanie rekurecyjne (w jednej instancji rekursji).
 
{{Algorytm|<math>PrefSums2(x)</math>||
 
<math>n:=size(x)</math>;
 
'''if''' <math>n>1</math> '''then'''
 
&nbsp;&nbsp;&nbsp;utwórz nową tablicę y;
 
&nbsp;&nbsp;&nbsp;'''for each ''' <math>1 \le i \le n/2</math> '''do in parallel'''
 
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <math>y[i]\ :=\ x[2i-1]\oplus x[2i]</math>;
 
&nbsp;&nbsp;&nbsp;<math>PrefSums2(y)</math>;
 
&nbsp;&nbsp;&nbsp;'''for each ''' <math>1 \le i \le n/2</math> '''do in parallel'''
 
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<math>x[2i]:= y[i]</math>;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;'''if''' <math>i>1</math> '''then'''  <math>x[2i-1]:= y[i-1] \oplus x[2i-1]</math>;
}}
 


<!--%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%              %%
Układ arytmetyczny odpowiadający algorytmowi jest pokazany na rysunku 5.
-->\begin{figure}[bhtp]
\begin{center}
\mbox{\ }
\includegraphics[width=11.5cm]{parallel_fig5.eps}
\caption{Układ arytmetyczny odpowiadający algorytmowi PrefSums1. Kolejne grafy powstają jako podwójne kopie porzednich grafów (dla <math>n/2</math> elementów)
z dodanymi elementami odpowiadającymi operacji <math>x[j]:= x[n/2] \oplus x[j]</math>. }
<span id="parallel_fig5" \>
\end{center}
\end{figure}
<!--%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-->


\noindent Opiszemy teraz inny algorytm rekurencyjny, w którym mamy tylko jedno wywołanie rekurecyjne (w jednej instancji
rekursji).


\myskip
<center> [[Grafika:Parallel1-5.png]] <br> Rysunek 5. Układ arytmetyczny odpowiadający PrefSums2. Kolejny graf składa się z pojedynczej kopii  
<!--%-----------------------------------------------------------------
poprzedniego grafu (dla <math>n/2</math>) oraz <math>n/2-1</math> dodatkowych operacji. </center>
-->\begin{center}
\begin{minipage}{12cm}
\vskip0.3cm
\hspace*{0.6cm}\textbf{Algorytm} <math>PrefSums2(x)</math>; \\
\hspace*{1.2cm}<math>n:=size(x)</math>;\\
\hspace*{1.2cm}\textbf{if} <math>n>1</math> \textbf{then } \\
\hspace*{1.8cm} utwórz nową tablicę y;\\
\hspace*{1.8cm} '''for each } <math>1 \le i \le n/2</math> \textbf{do in parallel'''\\
\hspace*{2.5cm} <math>y[i]\ :=\ x[2i-1]\oplus x[2i]</math>;\\
\hspace*{1.8cm} <math>PrefSums2(y)</math>;\\
\hspace*{1.8cm} '''for each } <math>1 \le i \le n/2</math> \textbf{do in parallel'''\\
\hspace*{2.4cm} <math>x[2i]:= y[i]</math>;\\
\hspace*{2.4cm} '''if''' <math>i>1</math> '''then''' \ <math>x[2i-1]:= y[i-1] \oplus x[2i-1]</math>;\\
\vskip0.4cm
\end{minipage}
\end{center}
\myskip
<!--%
--><!--%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%  mx            %%
-->Układ arytmetyczny odpowiadający algorytmowi jest pokazany na rysunku &nbsp;[[#parallel_fig4]].
\begin{figure}[bhtp]
\begin{center}
\mbox{\ }
\includegraphics[width=7.2cm]{parallel_fig4.eps}
\caption{Układ arytmetyczny odpowiadający PrefSums2. Kolejny graf składa się z pojedyńczej kopii  
poprzedniego grafu (dla <math>n/2</math>), oraz <math>n/2-1</math> dodatkowych operacji. }
<span id="parallel_fig4" \>  
\end{center}
\end{figure}

Aktualna wersja na dzień 22:12, 11 wrz 2023

Algorytmy równoległe I


W module tym zajmiemy się przyspieszaniem obliczeń za pomocą korzystania z wielu procesorów (maszyn) działających równolegle. Niestety nie ma ogólnie przyjętego modelu obliczeń równoległych. Rozważymy w tym module dwa modele: maszynę PRAM i układy arytmetyczne (logiczne). O ile maszyna PRAM jest modelem wysoko-poziomowym, to układy arytmetyczne są modelem niskopoziomowym, ale niewątpliwie bardzo istotnym.

Model równoległej abstrakcyjnej Maszyny PRAM

Na początku rozważymy wyidealizowany model obliczeń równoległych zwany Równoległą Maszyną ze Swobodnym Dostępem do Pamięci, w skrócie PRAM (od ang. Parallel Random Access Machine, wymawiany piram).


Rysunek 1: Struktura koncepcyjna PRAMu

Maszyna PRAM składa się z wielu procesorów pracujących synchronicznie, korzystących ze wspólnej pamięci (która oprócz przechowywania danych służy do komunikacji między procesorami). Każdy procesor jest standardowym komputerem typu RAM (ang. Random Access Machine). Zakładamy, że procesory są ponumerowane liczbami naturalnymi. Procesory wykonują jeden wspólny program, ale wykonanie poszczególnych instrukcji zależy od indeksu procesora. W jednym kroku procesor pobiera dane z pamięci, potem wykonuje operację, którą może być wpisanie pewnych danych. Wszystkie procesory wykonują jeden krok jednocześnie. Równoległość jest wyrażona poprzez następującą instrukcję:

foralliXdoinparallel akcja(i).

Wykonanie tej instrukcji polega na wykonaniu dwóch równoległych operacji:

  • przydzielenie procesora do każdego elementu ze zbioru X,
  • jednoczesne wykonanie przez każdy procesor operacji akcja(i).

Przeważnie zapis iX jest w rodzaju ``1in jeśli X jest zbiorem liczb naturalnych.

Podstawowe typy maszyny PRAM

Mamy kilka rodzajów maszyny PRAM w zależności od konfliktów czytania/zapisu we wspólnej pamięci. Litera C (od ang. concurrent) oznacza możliwość jednoczesnego wykonania operacji przez wiele procesorów, E (od ang. exclusive) wyklucza taką możliwość. Operacjami są R (czytanie, od ang. read) oraz W (zapis, od ang. write) w tej samej komórce przez wiele procesorów w tym samym momencie. Mamy zatem EREW PRAM, CREW PRAM, CRCW PRAM (modelu ERCW nie rozważamy jako zupełnie sztuczny).

Podstawowym naszym modelem PRAM-u będzie CREW PRAM: wiele procesrów może jednocześnie czytać z tej samej komórki, ale tylko jeden może zapisywać.


Prostym przykładem obliczenia na CREW jest liczenie kolejnych wierszy trójkąta Pasacala. Początkowo zakładamy, że A=[0,0,0,0,0,1]. Wykonujemy:

   repeat 6 times

   for each 1i5 do in parallel

      A[i]:=A[i]+A[i+1]

Kolejnymi wartościami tablicy A są wektory:

0 0 0 0 0 1
0 0 0 0 0 1
0 0 0 0 1 1
0 0 0 1 2 1
0 0 1 3 3 1
0 1 4 6 4 1
1 5 10 10 5 1

Klasa NC i równoległe algorytmy optymalne

Najważniejszą klasą problemów algorytmicznych stanowią problemy, które można obliczyć w czasie wielomianowo-logarytmicznym, używając wielomianowej liczby procesorów. Klasę tę oznaczamy przez NC, odpowiadające algorytmy nazywamy algorytmami typu NC.

Niech 𝒫 oznacza klasę problemów wykonywanych w deterministycznym czasie wielomianowym na maszynie sekwencyjnej. Podstawowym problemem teoretycznym algorytmiki równoległej jest pytanie:

𝒫 = 𝒩𝒞 ?


Podobnie jak problemy NP-zupełne można zdefiniować problemy P-zupełne. Są to te problemy X𝒫, takie że dla każdego innego problemu Y𝒫 istnieje NC-redukcja Y do X. Inaczej mówiąc

𝒫=𝒩𝒞  wtedy i tylko wtedy gdy XNC


Przykłady problemów P-zupełnych: programowanie liniowe, maksymalny przepływ w grafie, konstrukcja drzewa DFS, obliczanie wartości układów logicznych, sprawdzanie czy gramatyka bezkontekstowa generuje język pusty.

Przez pracę algorytmu równoległego rozumiemy liczbę procesorów pomnożoną przez czas. Algorytm jest optymalny, gdy jego praca jest tego samego rzędu co czas najlepszego znanego algorytmu sekwencyjnego dla danego problemu.

W szczególności interesują nas algorytmy, które są jednocześnie optymalne i są typu NC. Z praktycznego punku widzenia czynnik logn przy liczbie procesorów i przy pracy algorytmu nie jest zbyt istotny. Natomiast czynnik logarytmiczny jest istotny, jeśli chodzi o równoległy czas. W tym przypadku potęga logarytmu odgrywa podobną rolę co potęga wielomianu opisującego czas obliczenia sekwencyjnego. Różnica między CRCW i CREW PRAMem w aspekcie klasy NC polega przeważnie na dodaniu jednego czynnika logarytmicznego w funkcji równoległego czasu obliczenia.

Przykłady obliczeń na modelu CRCW PRAM

W przypadku CRCW PRAM założymy, że procesory, jeśli wpisują jednocześnie do tej samej komórki pamięci, to wpisują to samo. Na przykład jeśli początkowo output=0, wówczas następujący algorytm obliczy logiczną alternatywę w czasie stałym na CRCW PRAM.


for each 1in do in parallel

    if A[i]=1 then output=1;


Na CREW potrzebujemy logarytmicznego czasu równoległego, aby to zrobić. Pokażemy jeszcze dwa proste problemy, które można na CRCW PRAM wykonać w czasie stałym. Następujący algorytm liczy pierwszą pozycję minimalnego elementu w tablicy C[1 . . n] w czasie O(1).

Algorytm


for each 1in do in parallel
   M[i]:=0 ;

for each 1i, jn do in parallel
    if ij and C[i]C[j] then M[i]:=1;

for each 1in do in parallel
   if M[i]=0 then output:=i ;

Oznaczmy ten algorytm przez A1. Algorytm korzysta z n2 procesorów. Spróbujemy zmniejszyć tę liczbę do O(n1+ϵ), zachowując czas O(1) dla dowolnie małego ϵ>0.

Niech

Pk(n)=n1+ϵk, gdzie ϵk=12k1.


Przypuśćmy, że mamy algorytm Ak liczenia minimum w czasie O(1) z O(Pk(n)) procesorami. Skonstruujemy algorytm Ak+1, który działa w czasie stałym i używa tylko O(Pk+1(n)) procesorów.


Algorytm Ak+1



niech α=12k+1;

podziel tablicę C na rozłączne bloki rozmiaru nα każdy;

równolegle zastosuj algorytm Ak do każdego z tych bloków;

zastosuj algorytm Ak do tablicy C' składającej się z nnα minimów w blokach.


Algorytm Ak+1 działa w czasie O(1), korzystając z Pk+1(n)) procesorów. Uzasadnienie pozostawiamy jako ćwiczenie.

Algorytmy A2, A3, A4, używają odpowiednio następującą (asymptotycznie) liczbę procesorów

n1+13,  n1+115,  n1+1255,  n1+165535...

Rozważmy jeszcze na CRCW PRAM następujący problem pierwszej jedynki: dana jest tablica zerojedynkowa, znaleźć pozycję pierwszej jedynki (od lewej).

Następujący algorytm rozwiązuje problem w czasie stałym z kwadratową liczbą procesorów. Zakładamy na razie, że w ciągu jest jakaś jedynka.

Algorytm Pierwsza-Jedynka-1



for each 1i<jn do in parallel

   if A[i]=1 and A[j]=1 then A[j]:=0;

for each 1in do in parallel

   if A[i]=1 then FirstOne :=i.


Możemy podobnie łatwo sprawdzić, czy w ogóle jest jedynka.

Algorytm CzyJestJedynka



jest-jedynka :=0;

for each 1in do in parallel

   if A[i]=1 then jest-jedynka :=1;


Oba powyższe algorytmy korzystają z O(n2) procesorów. Możemy łatwo tę liczbę zmniejszyć do liniowej.


Algorytm Pierwsza-Jedynka


(1) Podziel tablicę A na segmenty długośći n;

(2) W każdym segmencie zastosuj algorytm CzyJestJedynka;

(3) Otrzymujemy ciąg zerojedynkowy C długości n jako wynik kroku (2);

(4) znajdź pierwszą jedynkę w ciągu C za pomocą algorytmu Pierwsza-Jedynka-1;

(5) Zastosuj algorytm Pierwsza-Jedynka-1 do segmentu odpowiadającego pierwszej jedynce w C;

W ten sposób stosujemy trzy razy algorytm o pracy kwadratowej do segmentu długości n. Otrzymujemy złożoność O(n2)=O(n). Czas jest O(1).

Drzewa i równoległa wersja metody dziel i zwyciężaj

Do szybkich obliczeń równoległych najbardziej nadają się problemy związane z drzewami, chociaż czasami w tych problemach nie widać od razu struktury drzewiastej. Struktura taka odpowiada również drzewu rekursji. Jako przykład rozważmy problem obliczenia sumy A[1]+A[2]+A[n]. Dla uproszczenia załóżmy, że n jest potęgą dwójki.


Rysunek 2: Metoda pełnego zrównoważonego drzewa binarnego: układ arytmetyczny obliczania sumy. Maksymalny poziom m=logn.

Wysokością węzła jest jego maksymalna odległość od liścia, wysokość liścia wynosi 0. Przez p-ty poziom rozumiemy zbiór węzłów o wysokości p. Załóżmy, że elementy A[1],A[2],..A[n] są umieszczone w liściach pełnego zrównoważonego drzewa binarnego, następnie wykonujemy (patrz rysunek):


for p:=1 to logn do

    oblicz jednocześnie wartości węzłów na poziomie p-tym;


Drzewo jest strukturą koncepcyjną, każdemu węzłowi możemy przypisać miejsce w pamięci. W naszym przypadku węzły na poziomie p-tym mogą odpowiadać elementom

A[2p], A[2*2p],..A[3*2p].

Poprzedni algorytm można zapisać w formie:

for p:=1 to logn do

   Δ=2p;

   for each 1in/Δ do in parallel

       A[i*Δ] := A[i*Δ]Δ/2]+A[i*Δ];

wynik := A[n];



Rysunek 3. Koncepcyjna struktura równoległej wersji metody dziel i zwyciężaj.


Drzewa odpowiadają w pewnym sensie rekursji. Wysokość drzew odpowiada czasowi równoległemu. Podobnie głębokość rekursji odpowiada czasowi równoległemu. Równoległa wersja tzw. metody dziel i zwyciężaj polega na tym, że wywołania rekurencyjne wykonujemy jednocześnie (patrz rysunek). Oczywiście może się zdarzyć, że jedno z nich zakończy się wcześniej. Niemniej algorytm czeka na zakończenie obu wywołań.


Pokażemy dwa przykłady zastosowania metody dziel i zwyciężaj w wersji równoległej. Zaczniemy od sumy elementów tablicy A[1..n]. Wynik obliczamy jako SUMA(1,n). Zakładamy znowu, że n jest potęgą dwójki.

funkcja SUMA(1,n)

if j=i then return A[i] else

   do in parallel

      wynik1 :=SUMA(i, (i+j)/2;

      wynik2 :=SUMA((i+j)/2, j);

   return wynik1 + wynik2;


Podobny jest schemat sortowania na PRAMie. Niech ParallelMerge(x) będzie algorytmem, który, otrzymawszy tablicę x z posortowanymi lewą i prawą połową, da w wyniku posortowaną tablicę x. Łatwo to zrobić w czasie O(logn) z n procesorami. Dla i>n/2-ty procesor znajduje w pierwszej połówce sekwencyjnie, metodą binary search, najmniejszy element większy od x[i]. Wymaga to czasu O(logn). Potem każdy procesor\mathit{ wie} gdzie wstawić \mathit{ swój} element. W sumie otrzymujemy algorytm sortowania w czasie O(log2) z n procesorami.


funkcja ParallelSort(x);

n:=size(x);

if n>1 then

    do in parallel

       ParallelSort(FirstHalf(x));

       ParallelSort(SecondHalf(x));

   ParallelMerge(x)


Liczbę procesorów można zmniejszyć do n/(logn)). Natomiast nietrywialnym jest zmniejszenie czasu na CREW PRAM. Zostało to zrobione przez Richarda Cole'a, który skonstruował algorytm działający w czasie O(logn) z O(n) procesorami maszyny EREW PRAM. Algorytm ten jest bardzo interesujący, ale niestety również bardzo skomplikowany.

Układy arytmetyczne: sumy prefiksowe

Być może najbardziej podstawowym modelem obliczeń równoległych są układy arytmetyczne (lub logiczne): acykliczne grafy z przypisaniem pewnych operacji węzłom wewnętrznym. Każdy węzeł liczy pewną wartość w momencie, gdy wartości jego poprzedników są policzone. Podobnie jak w drzewie, możemy zdefiniować pojęcie liścia: węzeł bez poprzedników. Natomiast graf nie musi mieć jednego korzenia. Zamiast korzenia w grafie wyróżniamy węzły wynikowe (na rysunku te, z których wychodzi strzałka do ‘’nikąd’’).

Równoległy czas obliczenia odpowiada maksymalnej wysokości węzła. Poziomy definiujemy podobnie jak dla drzewa. Algorytm równoległy w jednym równoległym kroku oblicza kolejny poziom. Liczba procesorów odpowiada z reguły maksymalnemu rozmiarowi poziomu, chociaż możemy inaczej rozplanować obliczenie, gdy jedne poziomy są duże, a drugie małe (ale trzeba wtedy zmienić strukturę grafu tak, aby temu odpowiadała).

Przykładem układu arytmetycznego jest drzewo z rysunku powyżej, które opisuje sumowanie n elementów.

Zajmiemy się teraz pewnym rozszerzeniem problemu sumowania. Niech będzie pewną łączną operacją arytmetyczną (np. suma, mnożenie, maksimum, minimum, pozycja pierwszej jedynki z lewej strony, podobnie z prawej strony).

Problem sum prefiksowych. dany wektor x rozmiaru n, obliczyć ciąg y taki, gdzie

y[1]=x[1], y[2]=x[1]x[2], y[3]=x[1]x[2]x[3], ...

gdzie


Opiszemy dwa rekurencyjne algorytmy dla tego problemu. Niech FirstHalf, SecondHalf oznaczają lewą i prawą (odpowiednio) połówkę ciągu. Zakładamy, że n jest potęgą dwójki.

Algorytm PrefSums1(x);



n:=size(x);

ifn>1 then

   do in parallel

      PrefSums1(FirstHalf(x));

      PrefSums1(SecondHalf(x));

   for each n/2<jn, do in parallel

      x[j]:=x[n/2]x[j];

Układ arytmetyczny odpowiadający algorytmowi PrefSums1 jest przedstawiony na Rysunku 4 dla n=4 i n=8. Zauważmy, że zasadniczą częścią układu dla n=8 są dwie kopie układu dla n=4. Dodatkowo dodajemy węzły odpowiadającej ostatniej instrukcji w algorytmie PrefSums1.



Rysunek 4. Układ arytmetyczny odpowiadający algorytmowi PrefSums1. Kolejne grafy powstają jako podwójne kopie porzednich grafów (dla n/2 elementów) z dodanymi elementami odpowiadającymi operacji x[j]:=x[n/2]x[j].


Opiszemy teraz inny algorytm rekurencyjny, w którym mamy tylko jedno wywołanie rekurecyjne (w jednej instancji rekursji).

Algorytm PrefSums2(x)



n:=size(x);

if n>1 then

   utwórz nową tablicę y;

   for each 1in/2 do in parallel

       y[i] := x[2i1]x[2i];

   PrefSums2(y);

   for each 1in/2 do in parallel

      x[2i]:=y[i];       if i>1 then x[2i1]:=y[i1]x[2i1];


Układ arytmetyczny odpowiadający algorytmowi jest pokazany na rysunku 5.



Rysunek 5. Układ arytmetyczny odpowiadający PrefSums2. Kolejny graf składa się z pojedynczej kopii poprzedniego grafu (dla n/2) oraz n/21 dodatkowych operacji.