web-dev-qa-db-fra.com

Comment et quand utiliser @async et @sync dans Julia

J'ai lu le documentation pour le @async et @sync les macros mais je ne sais toujours pas comment et quand les utiliser, et je ne trouve pas non plus de ressources ou d'exemples pour elles ailleurs sur Internet.

Mon objectif immédiat est de trouver un moyen de configurer plusieurs travailleurs pour qu'ils travaillent en parallèle, puis d'attendre qu'ils aient tous terminé pour continuer dans mon code. Ce message: En attente d'une tâche à terminer sur le processeur distant dans Julia contient une façon réussie d'accomplir cela. J'avais pensé que cela devrait être possible en utilisant le @async et @sync macros, mais mes échecs initiaux à le faire m'ont amené à me demander si je comprends bien comment et quand utiliser ces macros.

23
Michael Ohlrogge

Selon la documentation sous ?@async, "@async Encapsule une expression dans une tâche." Cela signifie que pour tout ce qui relève de sa portée, Julia démarrera cette tâche en cours d'exécution, puis passera à ce qui vient ensuite dans le script sans attendre la fin de la tâche. Ainsi, par exemple, sans la macro, vous obtiendrez:

Julia> @time sleep(2)
  2.005766 seconds (13 allocations: 624 bytes)

Mais avec la macro, vous obtenez:

Julia> @time @async sleep(2)
  0.000021 seconds (7 allocations: 657 bytes)
Task (waiting) @0x0000000112a65ba0

Julia> 

Julia permet ainsi au script de continuer (et à la macro @time De s'exécuter complètement) sans attendre la fin de la tâche (dans ce cas, en dormant pendant deux secondes).

La macro @sync, En revanche, "attendra que toutes les utilisations fermées dynamiquement de @async, @spawn, @spawnat Et @parallel Soient Achevée." (selon la documentation sous ?@sync). Ainsi, nous voyons:

Julia> @time @sync @async sleep(2)
  2.002899 seconds (47 allocations: 2.986 KB)
Task (done) @0x0000000112bd2e00

Dans cet exemple simple, il est inutile d'inclure une seule instance de @async Et @sync Ensemble. Mais, où @sync Peut être utile, c'est lorsque vous avez @async Appliqué à plusieurs opérations que vous souhaitez permettre à toutes de démarrer en même temps sans attendre que chacune se termine.

Par exemple, supposons que nous ayons plusieurs travailleurs et que nous souhaitions que chacun d'entre eux travaille simultanément sur une tâche, puis récupère les résultats de ces tâches. Une tentative initiale (mais incorrecte) peut être:

using Distributed
cell(N) = Vector{Any}(undef, N)

addprocs(2)
@time begin
    a = cell(nworkers())
    for (idx, pid) in enumerate(workers())
        a[idx] = remotecall_fetch(sleep, pid, 2)
    end
end
## 4.011576 seconds (177 allocations: 9.734 KB)

Le problème ici est que la boucle attend que chaque opération remotecall_fetch() se termine, c'est-à-dire que chaque processus termine son travail (dans ce cas, il dort pendant 2 secondes) avant de continuer à démarrer la prochaine remotecall_fetch() opération. En termes de situation pratique, nous n'obtenons pas les avantages du parallélisme ici, car nos processus ne font pas leur travail (c'est-à-dire dormir) simultanément.

Nous pouvons cependant corriger cela en utilisant une combinaison des macros @async Et @sync:

@time begin
    a = cell(nworkers())
    @sync for (idx, pid) in enumerate(workers())
        @async a[idx] = remotecall_fetch(sleep, pid, 2)
    end
end
## 2.009416 seconds (274 allocations: 25.592 KB)

Maintenant, si nous comptons chaque étape de la boucle comme une opération distincte, nous voyons qu'il y a deux opérations distinctes précédées de la macro @async. La macro permet à chacun d'entre eux de démarrer et au code de continuer (dans ce cas à l'étape suivante de la boucle) avant la fin de chacun. Mais, l'utilisation de la macro @sync, Dont la portée englobe toute la boucle, signifie que nous ne permettrons pas au script de passer au-delà de cette boucle tant que toutes les opérations précédées de @async Ne seront pas terminées. .

Il est possible d'obtenir une compréhension encore plus claire du fonctionnement de ces macros en ajustant davantage l'exemple ci-dessus pour voir comment il change sous certaines modifications. Par exemple, supposons que nous ayons juste le @async Sans le @sync:

@time begin
    a = cell(nworkers())
    for (idx, pid) in enumerate(workers())
        println("sending work to $pid")
        @async a[idx] = remotecall_fetch(sleep, pid, 2)
    end
end
## 0.001429 seconds (27 allocations: 2.234 KB)

Ici, la macro @async Nous permet de continuer dans notre boucle avant même que chaque opération remotecall_fetch() ne termine son exécution. Mais, pour le meilleur ou pour le pire, nous n'avons pas de macro @sync Pour empêcher le code de continuer après cette boucle jusqu'à ce que toutes les opérations remotecall_fetch() se terminent.

Néanmoins, chaque opération remotecall_fetch() fonctionne toujours en parallèle, même une fois que nous continuons. Nous pouvons le voir parce que si nous attendons deux secondes, le tableau a, contenant les résultats, contiendra:

sleep(2)
Julia> a
2-element Array{Any,1}:
 nothing
 nothing

(L'élément "rien" est le résultat d'une extraction réussie des résultats de la fonction sleep, qui ne renvoie aucune valeur)

Nous pouvons également voir que les deux opérations remotecall_fetch() démarrent essentiellement en même temps car les commandes d'impression qui les précèdent s'exécutent également en succession rapide (sortie de ces commandes non représentée ici). Comparez cela à l'exemple suivant où les commandes d'impression s'exécutent avec un décalage de 2 secondes:

Si nous plaçons la macro @async Sur toute la boucle (au lieu de simplement l'étape interne de celle-ci), alors à nouveau notre script continuera immédiatement sans attendre la fin des opérations remotecall_fetch(). Maintenant, cependant, nous autorisons uniquement le script à continuer au-delà de la boucle dans son ensemble. Nous ne permettons pas à chaque étape individuelle de la boucle de démarrer avant la fin de la précédente. En tant que tel, contrairement à l'exemple ci-dessus, deux secondes après le script continue après la boucle, il y a le tableau de résultats a toujours un élément comme #undef indiquant que la deuxième opération remotecall_fetch() n'est toujours pas terminée.

@time begin
    a = cell(nworkers())
    @async for (idx, pid) in enumerate(workers())
        println("sending work to $pid")
        a[idx] = remotecall_fetch(sleep, pid, 2)
    end
end
# 0.001279 seconds (328 allocations: 21.354 KB)
# Task (waiting) @0x0000000115ec9120
## This also allows us to continue to

sleep(2)

a
2-element Array{Any,1}:
    nothing
 #undef    

Et, sans surprise, si nous mettons les @sync Et @async L'un à côté de l'autre, nous obtenons que chaque remotecall_fetch() s'exécute séquentiellement (plutôt que simultanément) mais nous ne le faisons pas ' t continuez dans le code jusqu'à ce que chacun ait fini. En d'autres termes, ce serait, je crois, essentiellement l'équivalent de si nous n'avions ni macro en place, tout comme sleep(2) se comporte essentiellement de manière identique à @sync @async sleep(2)

@time begin
    a = cell(nworkers())
    @sync @async for (idx, pid) in enumerate(workers())
        a[idx] = remotecall_fetch(sleep, pid, 2)
    end
end
# 4.019500 seconds (4.20 k allocations: 216.964 KB)
# Task (done) @0x0000000115e52a10

Notez également qu'il est possible d'avoir des opérations plus compliquées dans la portée de la macro @async. Le documentation donne un exemple contenant une boucle entière dans le cadre de @async.

Mise à jour: Rappelez-vous que l'aide pour les macros de synchronisation indique qu'elle "attendra jusqu'à ce que toutes les utilisations fermées dynamiquement de @async, @spawn, @spawnat Et @parallel Sont terminés. " Pour ce qui compte comme "complet", il importe de définir les tâches dans le cadre des macros @sync Et @async. Prenons l'exemple ci-dessous, qui est une légère variation par rapport à l'un des exemples donnés ci-dessus:

@time begin
    a = cell(nworkers())
    @sync for (idx, pid) in enumerate(workers())
        @async a[idx] = remotecall(sleep, pid, 2)
    end
end
## 0.172479 seconds (93.42 k allocations: 3.900 MB)

Julia> a
2-element Array{Any,1}:
 RemoteRef{Channel{Any}}(2,1,3)
 RemoteRef{Channel{Any}}(3,1,4)

L'exemple précédent a pris environ 2 secondes à exécuter, indiquant que les deux tâches ont été exécutées en parallèle et que le script attend que chacune termine l'exécution de leurs fonctions avant de continuer. Cet exemple, cependant, a une évaluation de temps beaucoup plus faible. La raison en est qu'aux fins de @sync, L'opération remotecall() est "terminée" une fois qu'elle a envoyé le travail à effectuer. (Notez que le tableau résultant, a, ici, ne contient que des types d'objets RemoteRef, qui indiquent simplement qu'il se passe quelque chose avec un processus particulier qui pourrait en théorie être récupéré à un moment donné dans le futur). En revanche, l'opération remotecall_fetch() n'est "terminée" que lorsqu'elle reçoit le message du travailleur que sa tâche est terminée.

Ainsi, si vous cherchez des moyens de vous assurer que certaines opérations avec les travailleurs sont terminées avant de passer à votre script (comme par exemple, est discuté dans cet article: En attente d'une tâche à terminer sur le processeur distant dans Julia ) il est nécessaire de bien réfléchir à ce qui compte comme "complet" et comment vous allez mesurer et opérationnaliser cela dans votre script.

48
Michael Ohlrogge