Julia Language
Parallell bearbetning
Sök…
pmap
pmap
tar en funktion (som du anger) och tillämpar den på alla element i en matris. Detta arbete är uppdelat mellan de tillgängliga arbetarna. pmap
returnerar sedan resultaten från den funktionen i en annan grupp.
addprocs(3)
sqrts = pmap(sqrt, 1:10)
Om du fungerar tar flera argument kan du leverera flera vektorer till pmap
dots = pmap(dot, 1:10, 11:20)
Som med @parallel
, men om funktionen som ges till pmap
inte är i bas Julia (dvs. den är användardefinierad eller definierad i ett paket) måste du se till att den funktionen är tillgänglig för alla arbetare först:
@everywhere begin
function rand_det(n)
det(rand(n,n))
end
end
determinants = pmap(rand_det, 1:10)
Se även denna SO Q&A.
@parallell
@ Parallell kan användas för att parallellisera en slinga, genom att dela loopens loop upp mellan olika arbetare. Som ett mycket enkelt exempel:
addprocs(3)
a = collect(1:10)
for idx = 1:10
println(a[idx])
end
För ett lite mer komplext exempel, tänk på:
@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
Därför ser vi att om vi hade @parallel
den här slingan utan @parallel
skulle det ha tagit 55 sekunder, snarare än 27, att köra.
Vi kan också leverera en reduktionsoperatör för makro @parallel
. Anta att vi har en matris, vi vill summera varje kolumn i matrisen och sedan multiplicera dessa summor med varandra:
A = rand(100,100);
@parallel (*) for idx = 1:size(A,1)
sum(A[:,idx])
end
Det finns flera viktiga saker att tänka på när du använder @parallel
att undvika oväntat beteende.
Först: Om du vill använda några funktioner i dina slingor som inte är i bas Julia (t.ex. antingen funktioner som du definierar i ditt skript eller som du importerar från paket), måste du göra dessa funktioner tillgängliga för arbetarna. Följaktligen skulle följande inte fungera:
myprint(x) = println(x)
for idx = 1:10
myprint(a[idx])
end
Istället skulle vi behöva använda:
@everywhere begin
function myprint(x)
println(x)
end
end
@parallel for idx in 1:length(a)
myprint(a[idx])
end
För det andra Även om varje arbetare kommer att kunna komma åt objekten inom kontrollerns räckvidd kommer de inte att kunna ändra dem. Således
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
Medan om vi hade utfört slingan utan @ parallellen hade det lyckats modifiera matrisen a
.
För att adressera detta kan vi istället göra a
SharedArray
typen SharedArray
så att varje arbetare kan komma åt och ändra det:
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 och @spawnat
@spawn
och @spawnat
är två av verktygen som Julia ställer till för att tilldela uppgifter till arbetarna. Här är ett exempel:
julia> @spawnat 2 println("hello world")
RemoteRef{Channel{Any}}(2,1,3)
julia> From worker 2: hello world
Båda dessa makron utvärderar ett uttryck för en arbetarprocess. Den enda skillnaden mellan de två är att @spawnat
låter dig välja vilken arbetare som ska utvärdera uttrycket (i exemplet ovan anges arbetare 2) medan med @spawn
en arbetare att automatiskt väljas, baserat på tillgänglighet.
I exemplet ovan hade vi helt enkelt arbetare 2 att utföra println-funktionen. Det var inget av intresse att återvända eller hämta från detta. Ofta kommer emellertid uttrycket vi skickade till arbetaren att ge något vi vill hämta. Lägg märke till i exemplet ovan, när vi ringde @spawnat
, innan vi fick utskriften från arbetare 2, såg vi följande:
RemoteRef{Channel{Any}}(2,1,3)
Detta indikerar att @spawnat
returnerar ett RemoteRef
objekt. Detta objekt i sin tur kommer att innehålla returvärdena från vårt uttryck som skickas till arbetaren. Om vi vill hämta dessa värden kan vi först tilldela RemoteRef
som @spawnat
returnerar till ett objekt och sedan använda fetch()
-funktionen som fungerar på ett RemoteRef
objekt för att hämta resultaten lagrade från en utvärdering utförd på en arbetare.
julia> result = @spawnat 2 2 + 5
RemoteRef{Channel{Any}}(2,1,26)
julia> fetch(result)
7
Nyckeln till att kunna använda @spawn
effektivt är att förstå naturen bakom de uttryck som det fungerar på. Att använda @spawn
att skicka kommandon till arbetare är lite mer komplicerat än att bara skriva direkt vad du skulle skriva om du kör en "tolk" på en av arbetarna eller kör koden på dem. Anta till exempel att vi ville använda @spawnat
att tilldela ett värde till en variabel på en arbetare. Vi kan försöka:
@spawnat 2 a = 5
RemoteRef{Channel{Any}}(2,1,2)
Fungerade det? Låt oss se genom att arbetare 2 försöker skriva ut a
.
julia> @spawnat 2 println(a)
RemoteRef{Channel{Any}}(2,1,4)
julia>
Inget hände. Varför? Vi kan undersöka detta mer genom att använda fetch()
som ovan. fetch()
kan vara mycket praktiskt eftersom det inte bara hämtar framgångsrika resultat utan också felmeddelanden. Utan det kanske vi inte ens vet att något har gått fel.
julia> result = @spawnat 2 println(a)
RemoteRef{Channel{Any}}(2,1,5)
julia> fetch(result)
ERROR: On worker 2:
UndefVarError: a not defined
Felmeddelandet säger att a
inte definieras på arbetare 2. Men varför är det här? Anledningen är att vi måste lägga in vår uppdragsoperation till ett uttryck som vi sedan använder @spawn
att berätta för arbetaren att utvärdera. Nedan är ett exempel med följande förklaring:
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
Syntaxen :()
är det Julia använder för att beteckna uttryck . Vi använder eval()
funktionen eval()
i Julia, som utvärderar ett uttryck, och vi använder @spawnat
att instruera att uttrycket utvärderas på arbetare 2.
Vi kan också uppnå samma resultat som:
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
Det här exemplet visar två ytterligare uppfattningar. Först ser vi att vi också kan skapa ett uttryck med hjälp av funktionen parse()
kallas på en sträng. För det andra ser vi att vi kan använda parenteser när @spawnat
ringer @spawnat
, i situationer där detta kan göra vår syntax mer tydlig och hanterbar.
När ska du använda @parallel kontra pmap
Julia- dokumentationen rekommenderar det
pmap () är utformad för fallet där varje funktionssamtal gör en stor mängd arbete. Däremot kan @parallel för hantera situationer där varje iteration är liten, kanske bara summerar två siffror.
Det finns flera skäl till detta. För det pmap
medför pmap
större startkostnader för att initiera arbetstillfällen för arbetstagare. Om jobbet är mycket små kan dessa startkostnader alltså bli ineffektiva. Omvänt gör emellertid pmap
ett "smartare" jobb med att fördela jobb mellan arbetare. I synnerhet bygger det en kön med jobb och skickar ett nytt jobb till varje arbetare när den arbetaren blir tillgänglig. @parallel
däremot dividerar upp allt arbete som ska göras bland arbetarna när det kallas. Som sådan, om vissa arbetare tar längre tid på sina jobb än andra, kan du hamna i en situation där de flesta av dina arbetare är färdiga och är lediga medan några är aktiva under en överdriven tid och slutar sina jobb. En sådan situation är dock mindre benägna att uppstå med mycket små och enkla jobb.
Följande illustrerar detta: anta att vi har två arbetare, varav en är långsam och den andra är dubbelt så snabb. Helst skulle vi vilja ge den snabba arbetaren dubbelt så mycket arbete som den långsamma arbetaren. (eller, vi kan ha snabba och långsamma jobb, men rektoren är exakt densamma). pmap
kommer att uppnå detta, men @parallel
inte.
För varje test initierar vi följande:
addprocs(2)
@everywhere begin
function parallel_func(idx)
workernum = myid() - 1
sleep(workernum)
println("job $idx")
end
end
För @parallel
testet kör vi följande:
@parallel for idx = 1:12
parallel_func(idx)
end
Och få tillbaka utskriften:
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
Det är nästan söt. Arbetarna har "delat" arbetet jämnt. Observera att varje arbetare har slutfört 6 jobb, även om arbetare 2 är dubbelt så snabb som arbetare 3. Det kan vara rörande, men det är ineffektivt.
För pmap
testet kör jag följande:
pmap(parallel_func, 1:12)
och få utdata:
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
Notera nu att arbetare 2 har utfört 8 jobb och arbetare 3 har utfört 4. Detta är exakt i proportion till deras hastighet och vad vi vill ha för optimal effektivitet. pmap
är en hård uppgiftsmeister - från var och en efter deras förmåga.
@async och @sync
Enligt dokumentationen under ?@async
, " @async
ett uttryck i en uppgift." Vad detta betyder är att för vad som helst som faller inom dess ram kommer Julia att starta den här uppgiften, men fortsätter sedan till vad som kommer i skriptet utan att vänta på att uppgiften ska slutföras. Således får du till exempel utan makro:
julia> @time sleep(2)
2.005766 seconds (13 allocations: 624 bytes)
Men med makroen får du:
julia> @time @async sleep(2)
0.000021 seconds (7 allocations: 657 bytes)
Task (waiting) @0x0000000112a65ba0
julia>
Julia tillåter således skriptet att fortsätta (och @time
att köra helt) utan att vänta på att uppgiften (i detta fall sover i två sekunder) ska slutföras.
@sync
makro, däremot, "Vänta tills alla dynamiskt slutna användningar av @async
, @spawn
, @spawnat
och @parallel
är fullständiga." (enligt dokumentationen under ?@sync
). Således ser vi:
julia> @time @sync @async sleep(2)
2.002899 seconds (47 allocations: 2.986 KB)
Task (done) @0x0000000112bd2e00
I detta enkla exempel är det ingen @async
att inkludera en enda instans av @async
och @sync
tillsammans. Men, där @sync
kan vara användbart är där du har @async
tillämpat på flera operationer som du vill tillåta att alla startar på en gång utan att vänta på att alla ska slutföras.
Anta till exempel att vi har flera arbetare och vi vill börja var och en av dem arbeta på en uppgift samtidigt och sedan hämta resultaten från dessa uppgifter. Ett första (men felaktigt) försök kan vara:
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)
Problemet här är att slingan väntar på att varje remotecall_fetch()
-operation ska slutföras, dvs för varje process att slutföra sitt arbete (i detta fall sova i 2 sekunder) innan du fortsätter med nästa remotecall_fetch()
. När det gäller en praktisk situation får vi inte fördelarna med parallellitet här, eftersom våra processer inte gör sitt arbete (dvs. sover) samtidigt.
Vi kan dock korrigera detta genom att använda en kombination av @async
och @sync
makron:
@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)
Om vi nu räknar varje steg i loopen som en separat operation ser vi att det finns två separata operationer som föregås av @async
. Makroen tillåter var och en av dessa att starta, och koden fortsätter (i detta fall till nästa steg i loopen) innan varje avslutas. Men användningen av @sync
, vars omfattning omfattar hela slingan, betyder att vi inte kommer att låta skriptet fortsätta förbi den slingan förrän alla operationer som föregås av @async
har slutförts.
Det är möjligt att få en ännu tydligare förståelse för hur dessa makron fungerar genom att ytterligare justera exemplet ovan för att se hur det förändras under vissa modifieringar. Anta till exempel att vi bara har @async
utan @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)
Här tillåter @async
oss att fortsätta i vår slinga även innan varje remotecall_fetch()
-operation är klar. Men, för bättre eller sämre, har vi inget @sync
makro för att förhindra att koden fortsätter förbi denna slinga tills alla remotecall_fetch()
-operationerna är slut.
Ändå remotecall_fetch()
varje remotecall_fetch()
-operation fortfarande parallellt, även när vi fortsätter. Vi kan se att eftersom om vi väntar i två sekunder, kommer matrisen a, som innehåller resultaten, att innehålla:
sleep(2)
julia> a
2-element Array{Any,1}:
nothing
nothing
(Elementet "ingenting" är resultatet av en framgångsrik hämtning av resultaten från sömnfunktionen, som inte ger några värden)
Vi kan också se att de två remotecall_fetch()
-operationerna börjar i stort sett på samma gång eftersom print
som föregår dem också körs i snabb följd (utdata från dessa kommandon som inte visas här). Kontrast detta med nästa exempel där print
körs med två sekunders fördröjning från varandra:
Om vi sätter @async
på hela slingan (istället för bara det inre steget i det), kommer vårt skript igen att fortsätta omedelbart utan att vänta på att remotecall_fetch()
-operationerna ska slutföras. Nu tillåter vi dock bara att skriptet fortsätter förbi slingan som helhet. Vi tillåter inte varje enskilt steg i loopen att starta innan det föregående slutade. Som sådan, till skillnad från i exemplet ovan, två sekunder efter att skriptet fortsätter efter slingan, har results
fortfarande ett element som #undef
indikerar att den andra remotecall_fetch()
fortfarande inte har slutförts.
@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
Och inte överraskande, om vi lägger @sync
och @async
intill varandra, får vi att varje remotecall_fetch()
körs i följd (snarare än samtidigt) men vi fortsätter inte med koden förrän varje har slutförts. Med andra ord skulle detta i huvudsak motsvara om vi inte hade någon makro på plats, precis som sleep(2)
uppför sig väsentligen identiskt med @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
Observera också att det är möjligt att ha mer komplicerade operationer inom ramen för @async
. Dokumentationen ger ett exempel som innehåller en hel slinga inom ramen för @async
.
Kom ihåg att hjälp för synkroniseringsmakronen säger att det kommer att "Vänta tills alla dynamiskt slutna användningar av @async
, @spawn
, @spawnat
och @parallel
är fullständiga." För vad som räknas som "komplett" är det viktigt hur du definierar uppgifterna inom ramen för @sync
och @async
@sync
@async
. Tänk på nedanstående exempel, som är en liten variation på ett av exemplen ovan:
@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)
Det tidigare exemplet tog ungefär 2 sekunder att utföra, vilket indikerar att de två uppgifterna kördes parallellt och att manus som väntar på att varje skulle slutföra körningen av sina funktioner innan de fortsatte. Detta exempel har dock en mycket lägre tidsutvärdering. Anledningen är att med hjälp av @sync
har remotecall()
-operationen "slutförts" när den har skickat arbetaren att göra. (Observera att den resulterande matrisen, a, här, bara innehåller RemoteRef
objekttyper, som bara indikerar att det är något som händer med en viss process som i teorin kan hämtas någon gång i framtiden). Däremot har remotecall_fetch()
-operationen bara "slutförts" när den får meddelandet från arbetaren att dess uppgift är klar.
Således, om du letar efter sätt att säkerställa att vissa operationer med arbetare har slutförts innan du går vidare i ditt skript (som till exempel diskuteras i det här inlägget ) är det nödvändigt att tänka noga över vad som räknas som "komplett" och hur du kommer att göra det mät och operationella det sedan i ditt skript.
Lägga till arbetare
När du först startar Julia, kommer det som standard bara en enda process att köras och är tillgänglig att ge arbete till. Du kan verifiera detta med:
julia> nprocs()
1
För att kunna dra fördel av parallellbehandling måste du först lägga till ytterligare arbetare som sedan är tillgängliga för att utföra arbete som du tilldelar dem. Du kan göra detta i ditt skript (eller från tolkaren) med: addprocs(n)
där n
är antalet processer du vill använda.
Alternativt kan du lägga till processer när du startar Julia från kommandoraden med:
$ julia -p n
där n
är hur många ytterligare processer du vill lägga till. Således, om vi börjar Julia med
$ julia -p 2
När Julia börjar får vi:
julia> nprocs()
3