Problema di prestazione del parallelismo multi-thread con la sequenza di Fibonacci in Julia (1.3)


14

Sto provando la funzione multithread di Julia 1.3con il seguente hardware:

Model Name: MacBook Pro
Processor Name: Intel Core i7
Processor Speed:    2.8 GHz
Number of Processors:   1
Total Number of Cores:  4
L2 Cache (per Core):    256 KB
L3 Cache:   6 MB
Hyper-Threading Technology: Enabled
Memory: 16 GB

Quando si esegue il seguente script:

function F(n)
if n < 2
    return n
    else
        return F(n-1)+F(n-2)
    end
end
@time F(43)

mi dà il seguente risultato

2.229305 seconds (2.00 k allocations: 103.924 KiB)
433494437

Tuttavia, quando si esegue il seguente codice copiato dalla pagina Julia sul multithreading

import Base.Threads.@spawn

function fib(n::Int)
    if n < 2
        return n
    end
    t = @spawn fib(n - 2)
    return fib(n - 1) + fetch(t)
end

fib(43)

quello che succede è che l'utilizzo di salti RAM / CPU da 3,2 GB / 6% a 15 GB / 25% senza alcun output (per almeno 1 minuto, dopo di che ho deciso di terminare la sessione julia)

Che cosa sto facendo di sbagliato?

Risposte:


19

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 @spawnha 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^43thread ordine . Se ognuno impiega 1µsper 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 @spawnsovraccarico.

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' fibimplementazione di cui sopra .


Se sei curioso di sapere come potremmo modificare la fibfunzione thread in modo che sia effettivamente utile, la cosa più semplice da fare sarebbe generare un fibthread solo se pensiamo che ci vorrà molto più tempo rispetto 1µsall'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 fibper Julia è molto più difficile ottimizzare. Questo sovraccarico extra fa la differenza per i piccoli ncasi.

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 < 23ci 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 @btimemacro BenchmarkTools di BenchmarkTools.jl eseguirà le funzioni più volte, saltando il tempo di compilazione e i risultati medi.


1
Sembra che questo sia un fattore di accelerazione di 2 usando 16 core. È possibile avvicinarsi a un fattore di 16 velocità?
Anush,

Utilizzare una custodia di base più grande. A proposito, ecco come funzionano efficacemente anche i programmi multithread come FFTW!
Chris Rackauckas,

Il case base più grande non aiuta. Il trucco è che fibper Julia è più difficile da ottimizzare rispetto a F, quindi usiamo solo Finvece di fibper n< 23. Ho modificato la mia risposta con una spiegazione e un esempio più approfonditi.
Mason,

È strano, in realtà ho ottenuto risultati migliori usando l'esempio del post sul blog ...
tpdsantos,

@tpdsantos Qual è l'output di Threads.nthreads()per te? Sospetto che potresti avere julia in esecuzione con un solo thread.
Mason

0

@Anush

Come esempio di utilizzo manuale di memoization e multithreading

_fib(::Val{1}, _,  _) = 1
_fib(::Val{2}, _, _) = 1

import Base.Threads.@spawn
_fib(x::Val{n}, d = zeros(Int, n), channel = Channel{Bool}(1)) where n = begin
  # lock the channel
  put!(channel, true)
  if d[n] != 0
    res = d[n]
    take!(channel)
  else
    take!(channel) # unlock channel so I can compute stuff
    #t = @spawn _fib(Val(n-2), d, channel)
    t1 =  _fib(Val(n-2), d, channel)
    t2 =  _fib(Val(n-1), d, channel)
    res = fetch(t1) + fetch(t2)

    put!(channel, true) # lock channel
    d[n] = res
    take!(channel) # unlock channel
  end
  return res
end

fib(n) = _fib(Val(n), zeros(Int, n), Channel{Bool}(1))


fib(1)
fib(2)
fib(3)
fib(4)
@time fib(43)


using BenchmarkTools
@benchmark fib(43)

Ma l'accelerazione è venuta dalla memmiozation e non dal multithreading. La lezione qui è che dovremmo pensare a algoritmi migliori prima del multithreading.


La domanda non riguardava mai il calcolo veloce dei numeri di Fibonacci. Il punto era "perché il multithreading non migliora questa implementazione ingenua?".
Mason,

Per me, la prossima domanda logica è: come renderlo veloce. Quindi qualcuno che legge questo può vedere la mia soluzione e imparare da essa, forse.
xiaodai,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.