Ottima domanda
Questa implementazione multithread della funzione Fibonacci non è più veloce della versione a thread singolo. Tale funzione è stata mostrata nel post del blog solo come un esempio di come funzionano le nuove funzionalità di threading, evidenziando che consente di generare molti thread in diverse funzioni e lo scheduler riuscirà a capire un carico di lavoro ottimale.
Il problema è che @spawn
ha un sovraccarico non banale di circa 1µs
, quindi se si genera un thread per eseguire un'attività che richiede meno di 1µs
, probabilmente si è compromesso il rendimento. La definizione ricorsiva di fib(n)
ha una complessità temporale esponenziale dell'ordine 1.6180^n
[1], quindi quando chiami fib(43)
, si genera qualcosa di 1.6180^43
thread ordine . Se ognuno impiega 1µs
per spawn, ci vorranno circa 16 minuti solo per spawnare e pianificare i thread necessari, e questo non tiene nemmeno conto del tempo necessario per eseguire i calcoli effettivi e ri-unire / sincronizzare i thread che richiedono anche più tempo.
Cose come questa in cui si genera un thread per ogni passaggio di un calcolo hanno senso solo se ogni passaggio del calcolo richiede molto tempo rispetto al @spawn
sovraccarico.
Si noti che è in corso un lavoro per ridurre il sovraccarico di @spawn
, ma dalla fisica stessa dei chip in silicone multicore dubito che possa mai essere abbastanza veloce per l' fib
implementazione di cui sopra .
Se sei curioso di sapere come potremmo modificare la fib
funzione thread in modo che sia effettivamente utile, la cosa più semplice da fare sarebbe generare un fib
thread solo se pensiamo che ci vorrà molto più tempo rispetto 1µs
all'esecuzione. Sulla mia macchina (in esecuzione su 16 core fisici), ottengo
function F(n)
if n < 2
return n
else
return F(n-1)+F(n-2)
end
end
julia> @btime F(23);
122.920 μs (0 allocations: 0 bytes)
quindi sono buoni due ordini di grandezza rispetto al costo di generare un filo. Sembra un buon taglio da usare:
function fib(n::Int)
if n < 2
return n
elseif n > 23
t = @spawn fib(n - 2)
return fib(n - 1) + fetch(t)
else
return fib(n-1) + fib(n-2)
end
end
ora, se seguo la corretta metodologia di benchmark con BenchmarkTools.jl [2] trovo
julia> using BenchmarkTools
julia> @btime fib(43)
971.842 ms (1496518 allocations: 33.64 MiB)
433494437
julia> @btime F(43)
1.866 s (0 allocations: 0 bytes)
433494437
@Anush chiede nei commenti: questo è un fattore di 2 velocità che sembra usare 16 core. È possibile avvicinarsi a un fattore di 16 velocità?
Sì. Il problema con la funzione sopra è che il corpo della funzione è più grande di quello di F
, con molti condizionali, generazione di funzioni / thread e tutto il resto. Ti invito a confrontare @code_llvm F(10)
@code_llvm fib(10)
. Ciò significa che fib
per Julia è molto più difficile ottimizzare. Questo sovraccarico extra fa la differenza per i piccoli n
casi.
julia> @btime F(20);
28.844 μs (0 allocations: 0 bytes)
julia> @btime fib(20);
242.208 μs (20 allocations: 320 bytes)
Oh no! tutto quel codice extra che non viene mai toccato n < 23
ci sta rallentando di un ordine di grandezza! C'è una soluzione semplice però: quando n < 23
, non ricorrere a fib
, invece chiama il singolo thread F
.
function fib(n::Int)
if n > 23
t = @spawn fib(n - 2)
return fib(n - 1) + fetch(t)
else
return F(n)
end
end
julia> @btime fib(43)
138.876 ms (185594 allocations: 13.64 MiB)
433494437
che dà un risultato più vicino a quello che ci aspetteremmo da così tanti thread.
[1] https://www.geeksforgeeks.org/time-complexity-recursive-fibonacci-program/
[2] La @btime
macro BenchmarkTools di BenchmarkTools.jl eseguirà le funzioni più volte, saltando il tempo di compilazione e i risultati medi.