PFortran jest rozszerzeniem składni Fortranu 77 o dwa operatory służące do komunikacji między procesami:
zmienna@procesor - służący do odwołań do pamięci
innego procesu. Używany w instrukcji przypisania w następujący sposób:
a@1=a@3(przeniesienie zawartości zmiennej
a z procesu 3 do 1.x@(1:5)=y@0
x@(1:5)=y@(1:5)
fun{zmienna} - operacja międzyprocesowej redukcji
danych z użyciem przemiennej funkcji fun. Typowy przykład,
to sumowanie wyników częściowych:
suma=+{suma}
Procesy w PFortranie numerowane są od 0. Ilość procesów określa
zmienna nProc, numer aktualnego procesu - zmienna
myProc. Dostępna jest również zmienna nProc0
równa nProc-1.
PFortran wprowadza również kilka dodatkowych procedur z których dla użytkownika dostępne są:
psync - synchronizacja wszystkich procesów (jak
MPI_Barrier()).pclock(zmienna) - ładuje do zmiennej (typu REAL*8)
aktualny czas systemowyTranslator języka działa w ten sposób, że kod w PFortranie (plik z
rozszerzeniem .pf) jest konwertowany do postaci
zwykłego programu w Fortranie 77 używającego wywołań biblioteki MPI.
Następnie program jest kompilowany kompilatorem f77 i
linkowana jest z nim biblioteka pfmpi (oraz odpowiednia
biblioteka MPI dla Fortranu).
Translator napisany jest w języku C z użyciem generatora analizatorów
składniowych bison. Implementacja jego rozszerzeń będzie
prawdopodobnie w dużej części polegała na rozszerzeniu plików źródłowych
bisona.
Główną zaletą PFortranu w porównaniu z bibliotekami równoległymi typu MPI czy PVM jest prostota i zwięzłość kodu. Pozwala to na pisanie programów w sposób bardziej czytelny - publikacje dotyczące algorytmów równoległych używają podobnej notacji do ich zapisu. Daje to również szansę na pisanie programów równoległych ludziom, którzy nie mają ochoty uczyć się skomplikowanej składni wywołań funkcji bibliotek message passing mając do napisania naprawdę prosty program.
Kod w PFortranie jest przenośny - wszystkie szczegóły implementacyjne są ukryte w bibliotece dołączanej przy linkowaniu oraz w generowanym kodzie Fortranu 77.
Niestety uproszczenie modelu programowania pociąga za sobą pewien spadek wydajności w porównaniu z ręcznym przesyłaniem danych - w tym momencie nie mamy już wpływu na to, jak dane będą przesyłane. Dalej podaję przykład takiej sytuacji (operacja redukcji).
Operacja redukcji w PFortranie może zostać zapisana na dwa sposoby: jako operacja przypisania:
wynik=FUN{op}
lub jako wywołanie procedury dwuargumentowej:
call FUN{wynik,op}
W drugim przypadku wynik redukcji zostanie zwrócony w pierwszym argumencie jako efekt uboczny działania procedury.
Funkcja będąca argumentem operacji redukcji musi być przemienna. Może nią
być któraś z wbudowanych funkcji Fortranu (np. AMIN), lub
własna funkcja użytkownika (patrz niżej).
Kod jest generowany w taki sposób, że na danej maszynie przy takiej samej
ilości procesorów wyniki będą powtarzalne (kolejność wykonywania działań
jest funkcją ilości procesorów).
Własne operatory redukcji możemy tworzyć definiując funkcje lub procedury o następującej postaci:
<typ> FUNCTION <nazwa>(<arg1>,<arg2> [,<dodatkowe argumenty>]) SUBROUTINE <nazwa>(<arg1>,<arg2>,<wynik> [,<dodatkowe argumenty>])
W przypadku definiowania procedury musi ona zwracać wynik w trzecim argumencie.
Funkcje redukcji powinny być typu REAL lub INTEGER.
Dodatkowe parametry funkcji/procedury są opcjonalne i przy wywołaniu
będą traktowane jako globalne - identyczne na wszystkich procesorach.
Parametry procedury (arg1,arg2 i wynik)
muszą być tej samej postaci - jeżeli dane są tablicami dwuelementowymi,
to wynik musi być również tablicą dwuelementową.
Oto przykładowy prosty operator redukcji (własna suma):
REAL*8 FUNCTION MYSUM(IN1,IN2)
MYSUM=IN1+IN2
RETURN
END
Możemy go używać podobnie jak standardowego: WYNIK=MYSUM{ELEM}.
Sposób definicji operatora redukcji (procedura/funkcja) nie ma nic
wspólnego ze sposobem jego używania! Oznacza to, że możemy np. zdefiniować
sumowanie kilkuelementowych tablic jako procedurę, a używać jej następnie
przy pomocy składni TAB=SUMA{TAB}. Upraszcza to trochę
programowanie, bo nie musimy definiować tablic pomocniczych do przekazywania
danych.
Wbudowany operator redukcji +{...} pozwala na podawanie
jako argumentów tablic jednowymiarowych. Przykładowo następujący kod
wyliczy sumy elementów na każdej pozycji tablicy A i umieści te sumy w
tablicy B na wszystkich procesorach:
B=+{A}
Ograniczenia translatora PFortranu (opisane niżej) są efektem sposobu
realizacji komunikacji wybranego przez autorów. Każdy komunikat jest
bowiem opatrywany unikalnym znacznikiem (tag). Jest on zwiększany o 1
po każdej operacji komunikacji i używany przez procedury komunikacyjne
niskiego poziomu (np. MPI) do rozróżniania komunikatów. Jeżeli zatem
na niektórych procesach nie wykona się instrukcja postaci
a=b@1 to liczniki znaczników się rozsynchronizują i dalsza
komunikacja będzie niemożliwa!
Po obejrzeniu kodu generowanego np. przez operator redukcji widać, że
zwiększenie licznika realizuje procedura pf_inctag()
I/O w PFortranie można realizować na kilka sposobów, przy czym wybór konkretnej realizacji jest uzależniony z reguły od warunków sprzętowych którymi dysponujemy.
Pierwsza, najczęściej stosowana metoda jest taka, że wyróżniony proces (najczęściej proces numer 0) czyta stosowne dane z dysku i wysyła je do pozostałych procesów używająć standardowych sposobów komunikacji PFortranu. Oto przykład:
IF (myProc.EQ.0) CALL OpenFile(coord,unit)
DO 100,P=0,nProc-1
IF (myProc.EQ.0) READ(unit,*)(X(i),i=1,N/P)
X(1:N/P)@p=X(1:N/P)@(nProc-1)
100 CONTINUE
IF (myProc.EQ.0) CLOSE(unit)
Inną metodą jest wczytywanie danych kolejno bezpośrednio przez zainteresowane procesy. Należy wtedy zadbać o ich synchronizację, aby nie powodować zbędnego przeciążenia systemu dyskowego:
DO 110,P=0,nProc-1
IF (P.EQ.myProc) THEN
CALL OpenFile(coord,unit)
DO 100,B=0,myProc-1
READ(unit,*)(X(I),I=1,N/P)
100 CONTINUE
READ(unit,*)(X(i),i=1,N/P)
CLOSE(unit)
ENDIF
110 CONTINUE
CALL Synchronize
Jeżeli dysponujemy odpowiednim sprzętem, umożliwiającym równoległe wczytywanie danych z pliku do różnych procesów, możemy pokusić się o następujące rozwiązanie:
CALL OpenFile(coord,unit)
CALL PositionFile(unit,(N/P)*myProc-1)
READ(unit,*)(X(I),I=1,N/P)
CLOSE(unit)
Pomocnicze operacje wejścia/wyjścia takie jak wydruki diagnostyczne, możemy wykonywać na podobnych zasadach, jak z użyciem samej biblioteki message-passing. W przypadku MPICH oznacza to, że komunikaty pojawią się na ekranie w kolejności numerów procesów, które je wysłały.
Nieprawidłowy jest zatem kod zawierający odwołania do nich w instrukcji
warunkowej której warunek jest uzależniony od myProc:
IF (myPROC.EQ.0) THEN
suma=+{suma}
ENDIF
Nie jest to jednak dużym ograniczeniem, gdyż to samo możemy zapisać:
suma@0=+{suma}
(Ale patrz ograniczenie numer 3!)
Należy zatem szczególnie uważać, jeśli adresy te nie są stałe - nie możemy na przykład napisać:
p=0
IF (myProc.EQ.0) THEN
p=1
ENDIF
j=j@p
ponieważ proces zerowy będzie miał w ostatniej linii inną wartość p. Zamiast tego możemy napisać na przykład tak:
j=j@0
j@0=j@1
W obecnej wersji translator nie radzi sobie najlepiej ze złożonymi wyrażeniami zawierającymi nowe operatory. Należy zatem starać się pisać kod prosty, w którym przesyłanie danych jest realizowane etapami.
Jak na razie, użycie @ jest ograniczone do instrukcji przypisania. Nie powinien on zatem pojawiać się np. w warunkach pętli, lub argumentach funkcji.
myProcJeśli chcemy używać zmiennej myProc w adresach operatora @,
to musimy ją wpisać bezpośrednio, bez używania zmiennych pomocniczych.
Zatem zamiast pisać:
p=myProc
a@p=b@(p-1)
powinniśmy pisać:
a@myProc=b@(myProc-1)
pamiętając oczywiście o zastrzeżeniu numer 1
Pisząc program na równoległe mnożenie macierzy
(matmul.pf) zauważyłem,
że translator generuje nie do końca działający kod w przypadku
używania operatora @ do przesyłania większych fragmentów
tablic dwuwymiarowych. Oto przykładowy kod:
DO 302,I=0,nProc-1
DO 301,J=PROC_START(I),PROC_START(I+1)-1
C(J,1:N)=C(J,1:N)@I
301 CONTINUE
302 CONTINUE
W założeniu ten fragment miał rozsyłać obliczone fragmenty macierzy
wynikowej do wszystkich procesów. Niestety po przetłumaczeniu kodu na
zwykły FORTRAN wynik translacji instrukcji C(J,1:N)=C(J,1:N)@I
wygląda następująco:
o10000=((n-1+1))*4
o10001=1
do 55000 o10002 = 1, n
pf_rb(o10001)=c(j,o10002)
o10001=o10001+1
55000 continue
call pf_brdst(i, pf_rb(1), o10000)
Widać tu mniej więcej intencję translatora: budujemy wektor pf_rb
z odpowiednich elementów macierzy C i wykonujemy broadcast
w taki sposób, że wszystkie procesy otrzymają fragment zbudowany przez
proces I. Niestety brakuje tu jeszcze jednej instrukcji -
przepisania otrzymanego wektora pf_rb do odpowiedniego
fragmentu macierzy C! Po ręcznym wstawieniu odpowiedniej
pętli program oczywiście działa, jednak z pewnością nie jest to dobre
rozwiązanie.
Widać też, że translator nie potrafi skorzystać ze znajomości rozmieszczenia elementów macierzy w tablicy - tak naprawdę, to pomocniczy wektor nie jest tu wcale potrzebny! Aby ten kod jednak zadziałał, należy go w PFortranie zapisać następująco:
DO 303,I=0,nProc-1
DO 302,J=PROC_START(I),PROC_START(I+1)-1
DO 300,K=1,N
ROW(K)=C(J,K)
300 CONTINUE
ROW(1:N)=ROW(1:N)@I
DO 301,K=1,N
C(J,K)=ROW(K)
301 CONTINUE
302 CONTINUE
303 CONTINUE
Ale to jest wlasciwie przepisywanie kodu generowanego przez translator!
Po obejrzeniu wyniku kompilacji programu
dotprod.pf widać, że do wyliczenia sumy stosowany
jest algorytm drzewa binarnego, jednak jest ona wyliczana najpierw w
procesie 0, a następnie rozsyłana do pozostałych procesów (również
z użyciem struktury drzewiastej). O wiele efektywniejszy byłby
tutaj algorytm hypercube - jest to przeciez odpowiednik operacji
AllReduce standardu MPI. Oto kod generowany dla prostej
instrukcji SUM=+{SUM}:
Cpf SUM=+{SUM}
sum=sum
sum=sum
Cpf
Cpf dimxxx: non-powers of 2 allowed
o10001=pflog2ceil(nProc)
Cpf
Cpf reduce to process 0
do 55000 o10006 = o10001, 1,-1
o10005=2**o10006
o10004=2**(o10006-1)
call pf_inctag()
if (myProc.LT.o10004) then
o10003=xor(myProc,o10004)
if (o10003.LE.nProc0) then
call pf_rcv(o10000,4)
sum=sum+o10000
endif
else if (myProc.LT.o10005) then
o10002=xor(myProc,o10004)
call pf_snd(o10002,sum,4)
endif
55000 continue
Cpf
Cpf propogate result up from zero
do 55001 o10006 = 1, o10001
o10004=2**o10006
o10005=2**(o10006-1)
call pf_inctag()
if (myProc.LT.o10005) then
o10002=xor(myProc,o10005)
if (o10002.LT.nProc) then
call pf_snd(o10002,sum,4)
endif
else if (myProc.LT.o10004) then
o10003=xor(myProc,o10005)
call pf_rcv(sum,4)
endif
55001 continue
Co dziwne, zauważyłem drobne rozbieżności w wynikach w zależności od ilości
procesów! Nie można tego zrzucić na błędy zaokrągleń, gdyż pojawiały się
one nawet przy wektorach złożonych z kilkudziesięciu elementów typu
REAL*8.