Ótima pergunta.
Esta implementação multithread da função Fibonacci não é mais rápida que a versão single threaded. Essa função foi mostrada apenas na postagem do blog como um exemplo de brinquedo de como os novos recursos de encadeamento funcionam, destacando que ele permite gerar muitos encadeamentos em funções diferentes e o planejador descobrirá uma carga de trabalho ideal.
O problema é que @spawn
tem uma sobrecarga não trivial 1µs
, portanto, se você gerar um thread para executar uma tarefa que leva menos de 1µs
, provavelmente prejudicará seu desempenho. A definição recursiva de fib(n)
tem complexidade exponencial de tempo da ordem 1.6180^n
[1]; portanto, quando você chama fib(43)
, gera algo de 1.6180^43
threads da ordem . Se cada um demorar 1µs
para gerar, levará cerca de 16 minutos apenas para gerar e agendar os threads necessários, e isso nem sequer leva em conta o tempo necessário para fazer os cálculos reais e re-mesclar / sincronizar threads, o que leva até mais tempo.
Coisas como essa em que você gera um encadeamento para cada etapa de uma computação só fazem sentido se cada etapa da computação demorar muito tempo em comparação com a @spawn
sobrecarga.
Observe que há trabalho para diminuir a sobrecarga @spawn
, mas, pela própria física dos chips de silicone multicore, duvido que possa ser rápido o suficiente para a fib
implementação acima .
Se você estiver curioso sobre como poderíamos modificar a fib
função de fib
encadeamento para ser realmente benéfico, a coisa mais fácil a fazer seria gerar um encadeamento apenas se acharmos que levará muito mais tempo do que 1µs
para executar. Na minha máquina (rodando em 16 núcleos físicos), recebo
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)
portanto, são duas boas ordens de magnitude sobre o custo de gerar um thread. Parece um bom ponto de corte para usar:
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
agora, se eu seguir a metodologia adequada de benchmark com o BenchmarkTools.jl [2], acho
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 pergunta nos comentários: Este é um fator de 2 velocidades usando 16 núcleos que parece. É possível obter algo mais próximo de um fator de 16 velocidades?
Sim, ele é. O problema com a função acima é que o corpo da função é maior que o da F
, com muitos condicionais, geração de função / encadeamento e tudo mais. Convido você a comparar @code_llvm F(10)
@code_llvm fib(10)
. Isso significa que fib
é muito mais difícil para julia otimizar. Essa sobrecarga extra faz muita diferença para os pequenos n
casos.
julia> @btime F(20);
28.844 μs (0 allocations: 0 bytes)
julia> @btime fib(20);
242.208 μs (20 allocations: 320 bytes)
Ah não! todo esse código extra que nunca é tocado n < 23
está nos atrasando em uma ordem de magnitude! Porém, existe uma solução fácil: quando n < 23
, não recuar fib
, chame o thread único 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
o que dá um resultado mais próximo do que esperávamos para tantos threads.
[1] https://www.geeksforgeeks.org/time-complexity-recursive-fibonacci-program/
[2] A @btime
macro BenchmarkTools do BenchmarkTools.jl executará funções várias vezes, pulando o tempo de compilação e os resultados médios.