Problema de desempenho de paralelismo multithread com sequência de Fibonacci em Julia (1.3)

14

Estou tentando a função multithread Julia 1.3com o seguinte 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

Ao executar o seguinte script:

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

isso me dá a seguinte saída

2.229305 seconds (2.00 k allocations: 103.924 KiB)
433494437

No entanto, ao executar o seguinte código copiado da página Julia sobre 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)

o que acontece é que a utilização de RAM / CPU salta de 3,2 GB / 6% para 15 GB / 25% sem saída (por pelo menos 1 minuto, após o qual decidi encerrar a sessão julia)

O que estou fazendo errado?

ecjb
fonte

Respostas:

19

Ó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.

Pedreiro
fonte
11
Parece que esse é um fator de 2 velocidades usando 16 núcleos. É possível obter algo mais próximo de um fator de 16 velocidades?
Anush 28/11/19
Use uma base maior. BTW, é assim que programas multithread efetivamente, como o FFTW, também funcionam sob o capô!
31919 Chris Rackauckas
Caso base maior não ajuda. O truque é que fibé mais difícil para julia otimizar do que F, então usamos apenas em Fvez de fibpara n< 23. Editei minha resposta com uma explicação e um exemplo mais aprofundado.
Mason
Isso é estranho, eu realmente obtive melhores resultados usando o exemplo de postagem no blog ...
tpdsantos
@tpdsantos Qual é o resultado Threads.nthreads()para você? Eu suspeito que você pode ter julia rodando com apenas um único thread.
Mason
0

@Anush

Como um exemplo do uso de memorização e multithreading manualmente

_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)

Mas a velocidade veio da memmização e não de tanto multithreading. A lição aqui é que devemos pensar em algoritmos melhores antes de multithreading.

xiaodai
fonte
A questão nunca foi sobre calcular números de Fibonacci rapidamente. O ponto era 'por que o multithreading não está melhorando essa implementação ingênua?'.
Mason
Para mim, a próxima pergunta lógica é: como torná-lo rápido. Então, alguém lendo isso pode ver minha solução e aprender com ela, talvez.
Xiaodai 29/11/19