0

OpenMP, czyli jak wyciągnąć maksymalną wydajność z komputera [programowanie współbieżne]

OpenMP - programowanie równoległe i współbieżne

Zazwyczaj młodzi programiści pisząc swoje aplikacje używają tylko jednego wątku. Gdy jednak obliczenia wykonywane przez dany program są zaawansowane jego wykonanie może odbywać się stosunkowo wolno. W prosty sposób można to zmienić używając oprogramowania OpenMP, które w prawie przezroczysty sposób pozwala korzystać z przetwarzania współbieżnego, przede wszystkim w językach C i C++. Jest to też niesamowicie przydatne narzędzie, kiedy chcemy wykorzystać potencjał superkomputerów.

Do zrozumienia niniejszego artykułu konieczna będzie znajomość podstaw języka C oraz niektórych wiadomości dotyczących systemów operacyjnych, np. wątków.

O co chodzi z programowaniem współbieżnym?

Obecnie praktycznie wszystkie procesory składają się z wielu rdzeni. Dzięki temu możemy uruchomić równocześnie tyle wątków, ile jest rdzeni procesora. Możemy uruchomić ich nawet znacznie więcej, ale wtedy ich równoczesne wykonanie będzie trochę „ściemniane”, o czym uczy się każdy student informatyki (przydział czasu procesora, scheduler, różne algorytmy z tym związane).

O ile samo zagadnienie może być więc dosyć skomplikowane gdybyśmy chcieli dokładnie wnikać w jego szczegóły, o tyle korzystanie z tego typu pomysłów może być stosunkowo proste, a co najważniejsze: może powodować pełniejsze użycie procesora, a co za tym idzie przyspieszenie wykonania naszych programów.

Jakie instrukcje możemy wykonywać współbieżnie?

Tak jak wyżej wspomniałem, programowanie współbieżne polega na wykonywaniu jakiegoś fragmentu naszego programu równocześnie przez wiele wątków. Każdy z nich działa jednak niezależnie, a więc czas ich wykonania może być różny: zazwyczaj nie kończą się dokładnie w tym samym momencie. W związku z tym ma sens wykonywanie współbieżnie tylko takich instrukcji, których kolejność jest bez znaczenia.

Przykładowo zazwyczaj nie ma sensu korzystanie w przetwarzaniu współbieżnym z instrukcji printf (lub cout), ponieważ z reguły zależy nam na tym, żeby wypisane informacje były ukazane w ścisłym porządku. Natomiast przykładem, który jak najbardziej nadaje się do tego typu przetwarzania, jest wykonywanie obliczeń w pętlach.

Czym jest OpenMP?

OpenMP jest przykładem API, który świetnie sprawdza się do wykorzystania współbieżnego przebiegu w naszym programie. Wymaga korzystania z pamięci współdzielonej (co jest problemem przy programowaniu rozproszonym), a jego najczęstsze zastosowanie to pętle for (choć oczywiście nie jedyne).

Odpowiednie biblioteki OpenMP powinny byś standardowo dostępne w popularnych kompilatorach języków C czy C++. Kompilacja programów jest również dosyć prosta. Normalnie możemy ją wykonać za pomocą polecenia: cc plik.c -o plik, a w przypadku korzystania z OpenMP musimy dodać tylko odpowiednią flagę, co będzie wyglądało jak poniżej:

cc plik.c -o plik -fopenmp

Podstawowe użycie OpenMP

Rozważmy przykład sumowania liczb od 1 do 1000. Najprostsza implementacja takiego programu w języku C wyglądałaby mniej więcej tak:

#include <stdio.h>

int main(){
    int suma = 0;
    int i;

    for (i = 1; i <= 1000; i++){
        suma += i;
    }

    printf("Suma: %d", suma);

    return 0;
}

W takim programie korzystamy jednak tylko z jednego wątku. W prosty sposób możemy jednak go zmodyfikować tak, aby był znacznie wydajniejszy. Wystarczy dodać tylko odpowiednie dyrektywy:

#include <stdio.h>

int main(){
    int suma = 0;
    int i;

    #pragma omp parallel for
    for (i = 1; i <= 1000; i++){    // teraz ta pętla będzie wykonywana równolegle
        #pragma omp atomic
        suma += i;                  // chcemy, aby ta operacja była atomowa
                                    // a więc, żeby każdy wątek miał dostęp do
                                    // "poprawnej" wartości zmiennej "suma"
    }

    printf("Suma: %d", suma);

    return 0;
}

Dyrektywa #pragma omp parallel for wskazuje na to, aby pętla for następująca bezpośrednio po tej dyrektywie była wykonywana współbieżnie (a przynajmniej równolegle). Potrzebujemy również dyrektywy #pragma omp atomic, po to, aby następująca po niej operacja została wykonana w sposób atomowy. Wynika to z tego, że operacja suma += i; to tak naprawdę dwie operacje, mianowicie: suma = suma + i;, a więc sumowanie oraz przypisanie. Bez tej dyrektywy wątki mogłyby więc korzystać z nieaktualnej wartości zmiennej „suma” powodując, że nasz wynik byłby niepoprawny (proponuję sprawdzić to u siebie!).

Ambitniejszy przykład użycia OpenMP

Rozbudujmy nasz przykład: teraz użyjmy dokładnie 5 wątków w naszej aplikacji oraz wyświetlmy sumy częściowe (sumy obliczone przez każdy z wątków). Do tego przyda nam się na pewno plik nagłówkowy omp.h, który powinien być dostępny w każdym współczensym kompilatorze C/C++. Do przechowywania sum częściowych użyjemy tablicy, której elementy na samym końcu zsumujemy.

#include <stdio.h>
#include <omp.h>

int main(){
    int suma = 0;
    int sumy_czesciowe[5] = {0,0,0,0,0};
    int i;

    omp_set_num_threads(5); // ustawiamy liczbę wątków 

    #pragma omp parallel for
    for (i = 1; i <= 1000; i++){    
        sumy_czesciowe[omp_get_thread_num()] += i;   // potrzebujemy dostać numer wątku (0-4)               
    }

    #pragma omp parallel for
    for(i=0; i<5; i++){
        #pragma omp atomic
        suma += sumy_czesciowe[i];

        printf("Suma częściowa #%d: %d\n", i, sumy_czesciowe[i]);
    }

    printf("\nSuma: %d\n", suma);

    return 0;
}

Wynik działaniu takiego programu prezentuje się jak poniżej:

Przykład obliczeń współbieżnych (równoległych)

Przykład obliczeń współbieżnych (równoległych)

Zmienne prywatne i współdzielone

Korzystając z OpenMP dzielimy nasze zmienne na współdzielone oraz prywatne. Prywatne zmienne oznaczają, że każdy wątek będzie miał ich osobną instancję, a współdzielone… są współdzielone przez wszystkie wątki.  Tak więc prywatną zmienną może być przykładowo licznik pętli, a współdzieloną suma końcowa.

Domyślnie zmienne zadeklarowane wewnątrz bloku wykonywanego równolegle są traktowane jako prywatne, a zmienne ponad tym blokiem są współdzielone. Można to zachowanie kontrolować dodatkowo poprzez dodanie dodatkowych określeń w dyrektywach, np. #pragma omp parallel for shared(zmienna1, zmienna2) private(zmienna3,zmienna4)

Czy OpenMP oferuje coś więcej?

W moich artykułach z reguły nie staram się wnikać całkowicie w szczegóły danego zagadnienia. Tak jest i tym razem. Pokazałem tutaj najważniejsze cechy środowiska (czy może lepiej narzędzia/biblioteki) openMP, jednak oferuje ono więcej możliwości. Wychodzę z założenia, że jeśli ktoś będzie chciał pisać coś poważnego to i tak będzie musiał sięgnąć po dokumentację, a ten artykuł ma mu tylko pokazać podstawy, które znacznie ułatwią mu pracę.

Myślę też, że powinienem wspomnieć jeszcze o bloku parallel, a mianowicie poniższy blok:

#pragma omp parallel
{
    #pragma omp for
    for(i; i<1000; i++)
    {
    }
}

Jest równoznaczny poniższemu:

#pragma omp parallel for
for(i; i<1000; i++)
{
}

Samej konstrukcji #pragma omp parallel możemy jednak użyć, gdy chcemy wykorzystać równoległość poza pętlą for – czasem może się to okazać bardzo przydatne.

Na podsumowanie napiszę jeszcze, że OpenMP jest bardzo przydatne jeśli mamy dużo obliczeń w pętli – może naprawdę przyspieszyć wykonywanie naszego programu. Jeśli jednak nasze pętle to kilka iteracji to lepszym rozwiązanie może być nieużywanie współbieżności (równoleglości) w tym fragmencie kodu, ponieważ narzut czasu przeznaczony na wykonanie operacji fork i join może być większy niż potencjalny zysk. Mimo wszystko wydaje mi się, że warto korzystać z tego API, ponieważ niekiedy może ono przyspieszyć nasz program o kilkaset procent – a w dodatku sprawdzi się niesamowicie przy korzystaniu z superkomputera.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *