web-dev-qa-db-fra.com

Problème de performances de parallélisme multi-thread avec la séquence de Fibonacci dans Julia (1.3)

J'essaie la fonction multithread de Julia 1.3 avec le matériel suivant:

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

Lors de l'exécution du script suivant:

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

cela me donne la sortie suivante

2.229305 seconds (2.00 k allocations: 103.924 KiB)
433494437

Cependant lors de l'exécution du code suivant copié à partir de la page Julia sur le 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)

ce qui se passe, c'est que l'utilisation de la RAM/CPU passe de 3,2 Go/6% à 15 Go/25% sans aucune sortie (pendant au moins 1 minute, après quoi j'ai décidé de tuer la session Julia)

Qu'est-ce que je fais mal?

14
ecjb

Grande question.

Cette implémentation multithread de la fonction Fibonacci n'est pas plus rapide que la version à thread unique. Cette fonction n'a été présentée dans le billet de blog que comme un exemple de jouet du fonctionnement des nouvelles capacités de threading, soulignant qu'elle permet de générer de nombreux threads dans différentes fonctions et le planificateur trouvera une charge de travail optimale.

Le problème est que @spawn A une surcharge non triviale d'environ 1µs, Donc si vous générez un thread pour effectuer une tâche qui prend moins de 1µs, Vous avez probablement nuire à votre performance. La définition récursive de fib(n) a une complexité temporelle exponentielle de l'ordre 1.6180^n [1], donc lorsque vous appelez fib(43), vous générez quelque chose d'ordre 1.6180^43 fils. Si chacun prend 1µs Pour se reproduire, cela prendra environ 16 minutes juste pour générer et planifier les threads nécessaires, et cela ne tient même pas compte du temps qu'il faut pour effectuer les calculs réels et re-fusionner/synchroniser les threads, ce qui prend encore plus de temps.

Des choses comme celle-ci où vous générez un thread pour chaque étape d'un calcul n'ont de sens que si chaque étape du calcul prend beaucoup de temps par rapport à la surcharge @spawn.

Notez qu'il y a du travail pour réduire les frais généraux de @spawn, Mais par la physique même des puces en silicone multicœurs, je doute que cela puisse jamais être assez rapide pour la mise en œuvre fib ci-dessus.


Si vous êtes curieux de savoir comment nous pourrions modifier la fonction filetée fib pour qu'elle soit réellement bénéfique, la chose la plus simple à faire serait de ne générer un thread fib que si nous pensons que cela prendra beaucoup plus de temps. que 1µs pour s'exécuter. Sur ma machine (fonctionnant sur 16 cœurs physiques), je reçois

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)

c'est donc deux bons ordres de grandeur par rapport au coût de création d'un fil. Cela semble être une bonne coupure à utiliser:

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

maintenant, si je respecte la méthodologie de référence appropriée avec BenchmarkTools.jl [2] je trouve

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 demande dans les commentaires: C'est un facteur de 2 accélération en utilisant 16 cœurs semble-t-il. Est-il possible de rapprocher quelque chose d'un facteur 16?

Oui, ça l'est. Le problème avec la fonction ci-dessus est que le corps de la fonction est plus grand que celui de F, avec beaucoup de conditions, la génération de fonctions/threads et tout ça. Je vous invite à comparer @code_llvm F(10)@code_llvm fib(10). Cela signifie que fib est beaucoup plus difficile à optimiser pour Julia. Cette surcharge supplémentaire fait toute la différence pour les petits cas n.

Julia> @btime F(20);
  28.844 μs (0 allocations: 0 bytes)

Julia> @btime fib(20);
  242.208 μs (20 allocations: 320 bytes)

Oh non! tout ce code supplémentaire qui n'est jamais touché pour n < 23 nous ralentit d'un ordre de grandeur! Cependant, il existe une solution simple: lorsque n < 23, Ne recursez pas jusqu'à fib, appelez plutôt le single 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

ce qui donne un résultat plus proche de ce que nous attendions pour tant de threads.

[1] https://www.geeksforgeeks.org/time-complexity-recursive-fibonacci-program/

[2] La macro BenchmarkTools @btime De BenchmarkTools.jl exécutera des fonctions plusieurs fois, ignorant le temps de compilation et les résultats moyens.

19
Mason

@Anush

Comme exemple d'utilisation manuelle de la mémorisation et du 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)

Mais l'accélération est venue de la memmiozation et pas tellement du multithreading. La leçon ici est que nous devrions penser à de meilleurs algorithmes avant le multithreading.

0
xiaodai