Ó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 @spawntem 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^43threads da ordem . Se cada um demorar 1µspara 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 @spawnsobrecarga.
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 fibimplementação acima .
Se você estiver curioso sobre como poderíamos modificar a fibfunção de fibencadeamento 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µspara 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 ncasos.
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 < 23está 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 @btimemacro BenchmarkTools do BenchmarkTools.jl executará funções várias vezes, pulando o tempo de compilação e os resultados médios.