Julia Language
Przetwarzanie równoległe
Szukaj…
pmap
pmap
przyjmuje funkcję (którą określasz) i stosuje ją do wszystkich elementów w tablicy. Ta praca jest podzielona między dostępnych pracowników. pmap
następnie zwraca wyniki z tej funkcji do innej tablicy.
addprocs(3)
sqrts = pmap(sqrt, 1:10)
jeśli twoja funkcja pobiera wiele argumentów, możesz podać wiele wektorów do pmap
dots = pmap(dot, 1:10, 11:20)
Podobnie jak w przypadku @parallel
, jeśli funkcja przekazana do pmap
nie znajduje się w podstawowej Julii (tzn. Jest zdefiniowana przez użytkownika lub zdefiniowana w pakiecie), należy najpierw upewnić się, że funkcja jest dostępna dla wszystkich pracowników:
@everywhere begin
function rand_det(n)
det(rand(n,n))
end
end
determinants = pmap(rand_det, 1:10)
Patrz również to SO Q i A.
@równolegle
@parallel może być użyty do sparaliżowania pętli, dzieląc kroki pętli w górę na różnych pracowników. Jako bardzo prosty przykład:
addprocs(3)
a = collect(1:10)
for idx = 1:10
println(a[idx])
end
W przypadku nieco bardziej złożonego przykładu rozważ:
@time begin
@sync begin
@parallel for idx in 1:length(a)
sleep(a[idx])
end
end
end
27.023411 seconds (13.48 k allocations: 762.532 KB)
julia> sum(a)
55
Widzimy zatem, że gdybyśmy wykonali tę pętlę bez @parallel
, @parallel
jej zajęłoby 55 sekund, a nie 27.
Możemy również dostarczyć operator redukcji dla makra @parallel
. Załóżmy, że mamy tablicę, chcemy zsumować każdą kolumnę tablicy, a następnie pomnożyć te sumy przez siebie:
A = rand(100,100);
@parallel (*) for idx = 1:size(A,1)
sum(A[:,idx])
end
Podczas korzystania z @parallel
należy pamiętać o kilku ważnych sprawach, aby uniknąć nieoczekiwanego zachowania.
Po pierwsze: jeśli chcesz korzystać z funkcji w pętli, które nie są w podstawowej Julii (np. Funkcje zdefiniowane w skrypcie lub importowane z pakietów), musisz udostępnić te funkcje pracownikom. Dlatego na przykład następujące elementy nie działałyby:
myprint(x) = println(x)
for idx = 1:10
myprint(a[idx])
end
Zamiast tego musielibyśmy użyć:
@everywhere begin
function myprint(x)
println(x)
end
end
@parallel for idx in 1:length(a)
myprint(a[idx])
end
Po drugie Chociaż każdy pracownik będzie mógł uzyskać dostęp do obiektów w zakresie kontrolera, nie będzie mógł ich modyfikować. A zatem
a = collect(1:10)
@parallel for idx = 1:length(a)
a[idx] += 1
end
julia> a'
1x10 Array{Int64,2}:
1 2 3 4 5 6 7 8 9 10
Natomiast gdybyśmy wykonali pętlę bez @parallel, z powodzeniem zmodyfikowałaby tablicę a
.
Aby rozwiązać ten możemy zamiast zrobić a
do SharedArray
typ obiektu tak, że każdy pracownik może dostęp i modyfikowanie go:
a = convert(SharedArray{Float64,1}, collect(1:10))
@parallel for idx = 1:length(a)
a[idx] += 1
end
julia> a'
1x10 Array{Float64,2}:
2.0 3.0 4.0 5.0 6.0 7.0 8.0 9.0 10.0 11.0
@spawn i @spawnat
Makra @spawn
i @spawnat
to dwa narzędzia, które Julia udostępnia do przypisywania zadań pracownikom. Oto przykład:
julia> @spawnat 2 println("hello world")
RemoteRef{Channel{Any}}(2,1,3)
julia> From worker 2: hello world
Oba te makra będą oceniać wyrażenie na procesie roboczym. Jedyna różnica między nimi polega na tym, że @spawnat
pozwala wybrać, który pracownik oceni wyrażenie (w powyższym przykładzie podano pracownika 2), podczas gdy w @spawn
pracownik zostanie automatycznie wybrany na podstawie dostępności.
W powyższym przykładzie po prostu mieliśmy pracownika 2 do wykonania funkcji println. Nie było nic interesującego do odzyskania lub odzyskania z tego. Często jednak wyrażenie, które wysłaliśmy do pracownika, przyniesie coś, co chcemy odzyskać. Zwróć uwagę w powyższym przykładzie, kiedy zadzwoniliśmy do @spawnat
, zanim otrzymaliśmy wydruk z pracownika 2, zobaczyliśmy, co następuje:
RemoteRef{Channel{Any}}(2,1,3)
Oznacza to, że makro @spawnat
zwróci obiekt typu RemoteRef
. Ten obiekt z kolei będzie zawierał wartości zwracane z naszego wyrażenia, które jest wysyłane do pracownika. Jeśli chcemy pobrać te wartości, możemy najpierw przypisać RemoteRef
który @spawnat
zwraca do obiektu, a następnie użyć funkcji fetch()
która działa na RemoteRef
typu RemoteRef
, aby pobrać wyniki zapisane z oceny wykonanej na pracownik.
julia> result = @spawnat 2 2 + 5
RemoteRef{Channel{Any}}(2,1,26)
julia> fetch(result)
7
Kluczem do skutecznego korzystania z @spawn
jest zrozumienie natury wyrażeń , na których działa. Używanie @spawn
do wysyłania poleceń do pracowników jest nieco bardziej skomplikowane niż tylko bezpośrednie wpisywanie tego, co byś @spawn
gdybyś uruchomił „tłumacza” na jednym z pracowników lub natywnie wykonał na nich kod. Załóżmy na przykład, że chcemy użyć @spawnat
do przypisania wartości zmiennej do pracownika. Możemy spróbować:
@spawnat 2 a = 5
RemoteRef{Channel{Any}}(2,1,2)
Zadziałało? Cóż, zobaczmy poprzez pracownika 2 próby drukowania . a
julia> @spawnat 2 println(a)
RemoteRef{Channel{Any}}(2,1,4)
julia>
Nic się nie stało. Dlaczego? Możemy to zbadać bardziej szczegółowo, używając fetch()
jak wyżej. fetch()
może być bardzo przydatna, ponieważ pobiera nie tylko pomyślne wyniki, ale także komunikaty o błędach. Bez tego moglibyśmy nawet nie wiedzieć, że coś poszło nie tak.
julia> result = @spawnat 2 println(a)
RemoteRef{Channel{Any}}(2,1,5)
julia> fetch(result)
ERROR: On worker 2:
UndefVarError: a not defined
Komunikat o błędzie mówi, że a
nie jest zdefiniowane dla pracownika 2. Ale dlaczego tak jest? Powodem jest to, że musimy zawinąć naszą operację przypisania do wyrażenia, które następnie używamy @spawn
aby poinformować pracownika o ocenie. Poniżej znajduje się przykład z następującym wyjaśnieniem:
julia> @spawnat 2 eval(:(a = 2))
RemoteRef{Channel{Any}}(2,1,7)
julia> @spawnat 2 println(a)
RemoteRef{Channel{Any}}(2,1,8)
julia> From worker 2: 2
Składnia :()
jest tym, czego używa Julia do oznaczania wyrażeń . Następnie używamy funkcji eval()
w Julii, która ocenia wyrażenie, i używamy makra @spawnat
aby poinstruować, że wyrażenie należy ocenić na pracowniku 2.
Możemy również osiągnąć ten sam wynik, co:
julia> @spawnat(2, eval(parse("c = 5")))
RemoteRef{Channel{Any}}(2,1,9)
julia> @spawnat 2 println(c)
RemoteRef{Channel{Any}}(2,1,10)
julia> From worker 2: 5
Ten przykład pokazuje dwa dodatkowe pojęcia. Po pierwsze, widzimy, że możemy również utworzyć wyrażenie za pomocą funkcji parse()
wywołanej na łańcuchu. Po drugie, widzimy, że możemy używać nawiasów podczas wywoływania @spawnat
, w sytuacjach, w których mogłoby to uczynić naszą składnię bardziej przejrzystą i łatwiejszą do zarządzania.
Kiedy używać @parallel vs. pmap
Julia dokumentacji informuje, że
pmap () jest przeznaczony dla przypadków, w których każde wywołanie funkcji wykonuje dużą ilość pracy. Natomiast @parallel for może obsłużyć sytuacje, w których każda iteracja jest niewielka, być może po prostu sumując dwie liczby.
Jest tego kilka przyczyn. Po pierwsze, pmap
ponosi większe koszty początkowe, inicjując zlecenia dla pracowników. Tak więc, jeśli zadania są bardzo małe, te koszty początkowe mogą stać się nieefektywne. Z drugiej strony jednak pmap
wykonuje „mądrzejszą” pracę polegającą na przydzielaniu miejsc pracy pracownikom. W szczególności buduje kolejkę zadań i wysyła nowe zadanie do każdego pracownika, ilekroć ten pracownik będzie dostępny. @parallel
, dzieli wszystkie prace, które mają być wykonane między pracownikami, gdy są wywoływane. W związku z tym, jeśli niektórzy pracownicy podejmą pracę dłużej niż inni, możesz skończyć z sytuacją, w której większość pracowników zakończyła pracę i jest bezczynna, podczas gdy kilku pozostaje aktywnych przez nadmierną ilość czasu, kończąc pracę. Taka sytuacja jest jednak mniej prawdopodobna w przypadku bardzo małych i prostych prac.
Ilustruje to: załóżmy, że mamy dwóch pracowników, z których jeden jest wolny, a drugi dwa razy szybszy. Idealnie chcielibyśmy dać szybkiemu pracownikowi dwa razy więcej pracy niż pracownikowi powolnemu. (lub moglibyśmy mieć szybkie i wolne zadania, ale zlecenie jest dokładnie takie samo). pmap
to osiągnie, ale @parallel
nie.
Dla każdego testu inicjujemy:
addprocs(2)
@everywhere begin
function parallel_func(idx)
workernum = myid() - 1
sleep(workernum)
println("job $idx")
end
end
Teraz dla testu @parallel
następujące czynności:
@parallel for idx = 1:12
parallel_func(idx)
end
I odzyskaj wydruk:
julia> From worker 2: job 1
From worker 3: job 7
From worker 2: job 2
From worker 2: job 3
From worker 3: job 8
From worker 2: job 4
From worker 2: job 5
From worker 3: job 9
From worker 2: job 6
From worker 3: job 10
From worker 3: job 11
From worker 3: job 12
Jest prawie słodki. Pracownicy „dzielili się” pracą równomiernie. Zauważ, że każdy pracownik wykonał 6 zadań, mimo że pracownik 2 jest dwa razy szybszy niż pracownik 3. Może się dotykać, ale jest nieefektywny.
W przypadku testu pmap
uruchamiam:
pmap(parallel_func, 1:12)
i uzyskaj wynik:
From worker 2: job 1
From worker 3: job 2
From worker 2: job 3
From worker 2: job 5
From worker 3: job 4
From worker 2: job 6
From worker 2: job 8
From worker 3: job 7
From worker 2: job 9
From worker 2: job 11
From worker 3: job 10
From worker 2: job 12
Teraz zauważ, że pracownik 2 wykonał 8 zadań, a pracownik 3 wykonał 4. Jest to dokładnie proporcjonalne do ich prędkości i tego, co chcemy dla optymalnej wydajności. pmap
jest trudnym mistrzem zadań - od każdego według ich umiejętności.
@async i @sync
Zgodnie z dokumentacją pod ?@async
, „ @async
zawija wyrażenie w zadaniu”. Oznacza to, że dla wszystkiego, co mieści się w jego zakresie, Julia rozpocznie to zadanie, ale następnie przejdzie do tego, co będzie dalej w skrypcie, nie czekając na zakończenie zadania. Na przykład bez makra otrzymasz:
julia> @time sleep(2)
2.005766 seconds (13 allocations: 624 bytes)
Ale dzięki makrze otrzymujesz:
julia> @time @async sleep(2)
0.000021 seconds (7 allocations: 657 bytes)
Task (waiting) @0x0000000112a65ba0
julia>
Julia pozwala zatem na kontynuowanie skryptu (i @time
makra @time
) bez czekania na zakończenie zadania (w tym przypadku @time
przez dwie sekundy).
Natomiast makro @sync
„ @sync
, aż wszystkie dynamicznie zamknięte zastosowania @async
, @spawn
, @spawnat
i @parallel
zostaną zakończone”. (zgodnie z dokumentacją pod ?@sync
). Widzimy zatem:
julia> @time @sync @async sleep(2)
2.002899 seconds (47 allocations: 2.986 KB)
Task (done) @0x0000000112bd2e00
W tym prostym przykładzie nie ma więc sensu @async
pojedynczej instancji @async
i @sync
razem. Ale, gdzie @sync
może być przydatny, to gdzie @async
zastosowano do wielu operacji, na które chcesz zezwolić, aby wszystkie rozpoczęły się jednocześnie, bez czekania na zakończenie każdej z nich.
Załóżmy na przykład, że mamy wielu pracowników i chcielibyśmy, aby każdy z nich pracował jednocześnie nad zadaniem, a następnie pobierał wyniki z tych zadań. Początkowa (ale niepoprawna) próba może być:
addprocs(2)
@time begin
a = cell(nworkers())
for (idx, pid) in enumerate(workers())
a[idx] = remotecall_fetch(pid, sleep, 2)
end
end
## 4.011576 seconds (177 allocations: 9.734 KB)
Problem polega na tym, że pętla czeka na remotecall_fetch()
każdej remotecall_fetch()
, tj. Na zakończenie każdego procesu (w tym przypadku remotecall_fetch()
przez 2 sekundy) przed kontynuowaniem kolejnej operacji remotecall_fetch()
. Jeśli chodzi o sytuację praktyczną, nie czerpiemy korzyści z równoległości, ponieważ nasze procesy nie wykonują swojej pracy (tj. Spania) jednocześnie.
Możemy to jednak poprawić, używając kombinacji makr @async
i @sync
:
@time begin
a = cell(nworkers())
@sync for (idx, pid) in enumerate(workers())
@async a[idx] = remotecall_fetch(pid, sleep, 2)
end
end
## 2.009416 seconds (274 allocations: 25.592 KB)
Jeśli teraz policzymy każdy krok pętli jako osobną operację, zobaczymy, że istnieją dwie osobne operacje poprzedzone @async
. Makro pozwala na uruchomienie każdego z nich, a kod kontynuuje (w tym przypadku do następnego kroku pętli) przed każdym zakończeniem. Jednak użycie makra @sync
, którego zasięg obejmuje całą pętlę, oznacza, że nie zezwalamy skryptowi na przejście poza tę pętlę, dopóki wszystkie operacje poprzedzone @async
nie @async
zakończone.
Możliwe jest jeszcze lepsze zrozumienie działania tych makr poprzez dalsze ulepszenie powyższego przykładu, aby zobaczyć, jak zmienia się on przy pewnych modyfikacjach. Załóżmy na przykład, że mamy @async
bez @sync
:
@time begin
a = cell(nworkers())
for (idx, pid) in enumerate(workers())
println("sending work to $pid")
@async a[idx] = remotecall_fetch(pid, sleep, 2)
end
end
## 0.001429 seconds (27 allocations: 2.234 KB)
W tym przypadku makro @async
pozwala nam kontynuować naszą pętlę, nawet zanim każda remotecall_fetch()
zakończy wykonywanie. Ale, na lepsze lub gorsze, nie mamy makra @sync
aby zapobiec dalszemu przechodzeniu kodu przez tę pętlę do momentu zakończenia wszystkich operacji remotecall_fetch()
.
Niemniej jednak każda remotecall_fetch()
jest nadal uruchomiona równolegle, nawet gdy będziemy kontynuować. Widzimy to, ponieważ jeśli poczekamy dwie sekundy, tablica a zawierająca wyniki będzie zawierać:
sleep(2)
julia> a
2-element Array{Any,1}:
nothing
nothing
(Element „none” jest wynikiem pomyślnego pobrania wyników funkcji uśpienia, które nie zwraca żadnych wartości)
Widzimy również, że dwie remotecall_fetch()
zaczynają się zasadniczo w tym samym czasie, ponieważ poprzedzające je polecenia print
również są wykonywane szybko po sobie (dane wyjściowe tych poleceń nie zostały pokazane tutaj). Porównaj to z kolejnym przykładem, w którym polecenia print
wykonywane z 2-sekundowym opóźnieniem:
Jeśli @async
makro @async
w całej pętli (zamiast tylko jego wewnętrznego kroku), wtedy nasz skrypt będzie kontynuował natychmiast, nie czekając na zakończenie remotecall_fetch()
. Teraz jednak zezwalamy tylko na to, aby skrypt kontynuował omijanie pętli jako całości. Nie zezwalamy na rozpoczęcie każdego kroku pętli przed zakończeniem poprzedniego. W związku z tym, w przeciwieństwie do powyższego przykładu, dwie sekundy po tym, jak skrypt kontynuuje działanie po pętli, tablica results
nadal ma jeden element jako #undef
co oznacza, że druga remotecall_fetch()
nadal nie została zakończona.
@time begin
a = cell(nworkers())
@async for (idx, pid) in enumerate(workers())
println("sending work to $pid")
a[idx] = remotecall_fetch(pid, sleep, 2)
end
end
# 0.001279 seconds (328 allocations: 21.354 KB)
# Task (waiting) @0x0000000115ec9120
## This also allows us to continue to
sleep(2)
a
2-element Array{Any,1}:
nothing
#undef
I nie jest zaskoczeniem, że jeśli umieścimy @sync
i @async
obok siebie, otrzymamy, że każda remotecall_fetch()
działa sekwencyjnie (a nie jednocześnie), ale nie kontynuujemy działania w kodzie, dopóki się nie skończy. Innymi słowy, byłby to w zasadzie odpowiednik, gdybyśmy nie mieli żadnego makra, podobnie jak sleep(2)
zachowuje się zasadniczo identycznie jak @sync @async sleep(2)
@time begin
a = cell(nworkers())
@sync @async for (idx, pid) in enumerate(workers())
a[idx] = remotecall_fetch(pid, sleep, 2)
end
end
# 4.019500 seconds (4.20 k allocations: 216.964 KB)
# Task (done) @0x0000000115e52a10
Zauważ również, że możliwe jest wykonywanie bardziej skomplikowanych operacji w zakresie makra @async
. Dokumentacja zawiera przykład zawierający całą pętlę w zakresie @async
.
Przypomnij sobie, że pomoc dla makr synchronizacji mówi, że „Poczekaj, aż wszystkie dynamicznie zamknięte zastosowania @async
, @spawn
, @spawnat
i @parallel
zostaną zakończone”. Dla celów, które liczą się jako „ukończone”, ważne jest, w jaki sposób definiujesz zadania w ramach makr @sync
i @async
. Rozważ poniższy przykład, który jest niewielką odmianą jednego z przykładów podanych powyżej:
@time begin
a = cell(nworkers())
@sync for (idx, pid) in enumerate(workers())
@async a[idx] = remotecall(pid, sleep, 2)
end
end
## 0.172479 seconds (93.42 k allocations: 3.900 MB)
julia> a
2-element Array{Any,1}:
RemoteRef{Channel{Any}}(2,1,3)
RemoteRef{Channel{Any}}(3,1,4)
Wcześniejszy przykład trwał około 2 sekund, co wskazuje, że dwa zadania zostały uruchomione równolegle i że skrypt czeka, aż każde z nich zakończy wykonywanie swoich funkcji przed kontynuowaniem. Ten przykład ma jednak znacznie krótszą ocenę czasu. Powodem jest to, że dla celów @sync
remotecall()
„zakończyła się” po wysłaniu zadania do wykonania przez pracownika. (Zauważ, że wynikowa tablica, a tutaj, po prostu zawiera typy obiektów RemoteRef
, co po prostu wskazuje, że dzieje się coś z określonym procesem, który teoretycznie może zostać pobrany w pewnym momencie w przyszłości). Natomiast remotecall_fetch()
zakończyła się dopiero, gdy otrzyma wiadomość od pracownika, że jego zadanie zostało ukończone.
Dlatego jeśli szukasz sposobów, aby upewnić się, że pewne operacje z pracownikami zostały zakończone przed przejściem do skryptu (jak na przykład omówiono w tym poście ), należy dokładnie przemyśleć, co liczy się jako „zakończone” i jak to zrobisz zmierzyć, a następnie operacjonalizować to w skrypcie.
Dodawanie pracowników
Po pierwszym uruchomieniu Julii domyślnie będzie działał tylko jeden proces i będzie można go wykonać. Możesz to sprawdzić za pomocą:
julia> nprocs()
1
Aby skorzystać z przetwarzania równoległego, musisz najpierw dodać dodatkowych pracowników, którzy będą wtedy dostępni do wykonania powierzonej im pracy. Możesz to zrobić w skrypcie (lub z interpretera), używając: addprocs(n)
gdzie n
jest liczbą procesów, których chcesz użyć.
Alternatywnie możesz dodać procesy podczas uruchamiania Julii z wiersza poleceń, używając:
$ julia -p n
gdzie n
to liczba dodatkowych procesów, które chcesz dodać. Zatem jeśli zaczniemy od Julii
$ julia -p 2
Kiedy Julia zacznie, otrzymamy:
julia> nprocs()
3