Exécution d'un graphe de flot de données
La première étape du projet consiste en l'écriture d'un moteur d'exécution parallèle pour un graphe de flot de données. Le moteur d'exécution se charge d'ordonnancer les calculs, et d'exécuter les tâches lorsque toutes leurs dépendances sont satisfaites.
Un patron contenant une API sous forme de traits et une implémentation séquentielle d'un moteur d'exécution est fourni. Le but de cette partie est d'écrire une version parallèle de l'architecture décrite ci-dessous.
Il est suggéré d'utiliser la bibliothèque crossbeam pour l'implémentation du runtime parallèle, en créant un moteur d'exécution composé de plusieurs threads. Afin de se distribuer le travail, les threads doivent soit partager leur file d'attente via des piles concurrentes, soit utiliser un mécanisme de vol de tâche.
Vous devrez également implémenter un méchanisme de synchronisation en utilisant par exemple des barrières ou des variables de condition entre les threads du moteur d'exécution. Ceci doit permettre au moteur de s'arrêter plutôt que de tourner en boucle infinie une fois que toutes les tâches sont exécutées, tout en s'assurant que les threads ne s'arrêtent pas de traiter des tâches trop tôt. Gardez à l'esprit qu'une seule tâche peut ensuite en activer un nombre arbitraire quand elle a fini de s'exécuter !
Familles de trait
Au sein de l'API, de nombreux concepts sont définis à travers une famille de
traits similaire à celle des closures, FnOnce
, FnMut
et Fn
. Ces familles
de trait permettent d'encoder les différences entre les instances du trait à
utilisation unique, réutilisable avec modification d'état, et réutilisable sans
modification d'état.
Ports
La communication entre les tâches est gérée par des ports (définis à travers
les familles de traits Receiver
et Sender
). Les ports sont constituées
d'une partie émettrice et une partie réceptrice: la partie réceptrice peut lire
la dernière valeur qui a été écrite dans le port par la partie émettrice.
Tâches
Une tâche représente un élément de calcul à effectuer. Les tâches sont
définies par la famille de traits TaskOnce
, Task
et TaskMut
dans l'API.
Une tâche prend en argument un ordonnanceur, un tuple de ports d'entrée à
utilisation unique et un tuple de ports de sortie à utilisation unique, et
contient le code à exécuter.
Les applications bas niveau de la bibliothèque définiront généralement des
tâches qui seront ensuite connectées au sein du graphe d'exécution. Afin de
faciliter leur écriture, la structure StrictTask
fournie dans le module
common
permet d'encapsuler une fonction stricte (qui utilise toutes ses
entrées et définit toutes ses sorties) dans une tâche. Cette structure permet
d'écrire des tâches comme des fonctions usuelles et gère automatiquement
l'interaction avec les ports d'entrée et de sortie.
Vous pouvez (et devrez !) bien évidemment aussi définir des implémentations supplémentaires de la famille de traits afin d'encoder des comportements plus complexes, par exemple qui n'utilisent que certaines entrées, ne définissent que certaines sorties, ou ont un ordre précis d'utilisation de leurs ports.
Noeuds
Un noeud représente une tâche intégrée au graphe de flot de données. Le noeud
se charge de connecter la tâche avec ses dépendances, et permet d'encapsuler le
type exact des entrées et sorties de la tâche à travers un trait object de
taille dynamique. Cela est nécessaire car on veut être capable de garder une
list de noeuds à exécuter dans le moteur, et tous doivent donc avoir le même
type. La famille de traits Node
permet cette encapsulation; cependant, la
seule implémentation de cette famille de traits avec laquelle il devrait être
nécessaire d'intéragir est la structure TaskNode
qui contient une tâche ainsi
que des ports appropriés.
Flot de contrôle
Le flot de contrôle au sein du graphe de tâches est encodé à travers des activateurs. Essentiellement, un activateur représente un pointeur partagé vers un noeud qui contient également un compteur, initialisé au nombre d'entrées du noeud. À chaque fois qu'une valeur est écrite dans un port d'entrée, le compteur associé à l'activateur est décrémenté et, quand il atteint zéro, le noeud est activé et sera ensuite exécuté par l'ordonnanceur.
Moteur d'exécution
Le moteur d'exécution implémente le trait Scheduler
qui lui permet
d'ordonnancer des noeuds. Il fournit également quelques méthodes
supplémentaires permettant l'exécution du graphe de tâches jusqu'à ce que
toutes les tâches aient été exécutées, ainsi que quelques facilités pour la
création de noeuds.
Dans le patron, deux moteurs d'exécutions sont fournis: un moteur à exécution
unique (single_use
) qui exécute chaque tâche au plus une fois et ne permet
pas la création de cycle de dépendances; et un moteur à exécutions multiples
plus complexe (multiple_uses
) qui peut exécuter les tâches plusieurs fois et
permet l'exécution répétée de tâches sans allocation dynamique. Il est demandé
d'implémenter une version parallèle de chacun de ces deux moteurs d'exécution,
en commençant par la version à exécution unique, plus simple. Vous pourrez
ensuite choisir entre les deux moteurs d'exécution pour les étapes suivantes du
projet.
Ordonnanceur
Le trait Scheduler
est séparé du moteur d'exécution lui-même, et les
activateurs sont en général indépendant de l'ordonnanceur exact utilisé (à
partir du moment où il peut activer le bon type de noeuds): ceci permettra
d'intégrer facilement des structures de contrôle telles qui possèdent leur
propre liste de tâches à exécuter lorsqu'une condition est vérifiée, comme par
exemple les signaux réactifs.