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.