Parallel Computing sur la plateforme .net

le 19/03/2009 par Olivier Roux
Tags: Software Engineering

Dual Core, Quad Core ... Récemment encore réservé à des serveurs, l'arrivée du multiprocesseurs chez M. et Mme Tout-Le-Monde doit remettre en cause notre façon de réaliser des applications performantes ou tout au moins tirant pleinement partie des ressources à disposition. Les deux grandes plateformes J2EE et .net s'adaptent à cette évolution et nous préparent pour leur prochaine version (Java 7 et .net 4.0) des outils pour nous permettre de résoudre ce problème. Je vous propose à travers ce post de présenter les évolutions du framework .net et vous renvoie à la série d'articles sur la Parallélisation et la distribution réalisée par Marc Bojoly pour des informations complémentaires (et notamment la partie Java).

Parallel Extensions for .net

Le tableau ci-dessous reprend les principales étapes de maturation des Parallel Extensions.

NomRelease (CTP)Version framework
Parallel Extensions for .netJuin 20083.5
Parallel Extensions for .netOctobre 20084.0 (et intégré à CTP VS 2010)

Si l'approche est restée la même au fil des CTP, l'API, quant à elle, a quelque peu évolué (la documentation n'est d'ailleurs pas tout à fait à jour). Les exemples de code exposés par la suite se basent sur la dernière version disponible. Ils sont globalement inspirés des Webcasts de la Profesional Developper Conference (PDC) 2008 de Microsoft (je recommande au passage de visionner la session intitulée :  « Parallel programming for Managed Developper with VS2010 ».

Task : la nouvelle unité de code

Dans cette nouvelle librairie, le concept principal est celui de Tâche représenté par la Classe Task et son alter égo générique Task<T>. Une tâche est une petite unité de code à exécuter (souvent représentée par une lambda expression). Le gestionnaire de tâches (TaskManager) gère quant à lui une file (Queue) de tâches qu'il dispache selon la disponibilité des processeurs et l'état du cache (une tâche au fond de la file est préférentiellement envoyée vers un autre processeur pour laisser aux tâches les plus proches accès au processeur en cours de travail et espérer ainsi bénéficier du cache de données le plus à jour). Un algorithme similaire est utilisé en java (cf. Parallélisation, Distribution avec Java 7).

Déclaration de tâches

L'exemple de code ci-dessus illustre la déclaration de 3 tâches (la première en appelant une troisième dès que son traitement est fini : sorte de callback). L'affichage Console ne traduit pas la déclaration successive des mots ce qui est met en évidence un point clef de la parallélisation : les traitements ne sont pas réalisées selon un ordre prévisible ce qui pourra nécessiter de réordonner les informations en sortie (surtout lors de la manipulation de données).

Même s'il est toujours possible de créer des Threads avec les anciennes API (ce qui peut être intéressant pour certaines problématiques : traitement asynchrone avec le « background Thread » pour une IHM, serveur d'application gérant des pools de threads), il est préférable d'utiliser la notion de Tâche pour bénéficier d'un ordonnancement « optimal ». En effet, le Task Manager garantit (par défaut) que le nombre de tâches réalisées simultanément n'excède pas le nombre de processeurs.  Cette limitation est cruciale, car, trop souvent, des threads sont créés sans tenir compte du coût que représente l'allocation d'un contexte et la synchronisation en fin de traitement (sans parler du problème de concurrence d'accès).

Ce concept de tâche est donc à la base des deux principales notions apportées par ces Parallel Extensions : la Task Parallelism Library et Plinq.

Task Parallelism Library (TPL)

Créer des tâches c'est bien mais s'il faut gérer manuellement toutes les mécaniques de synchronisation, ça n'enrichit pas grandement la boîte à outil du développeur .net. Apparait avec ce framework, la Task Parallelism Library, qui offre quelques méthodes intéressantes avec notamment :

  • Parallel.For et Parallel.For<T> pour itérer
  • Parallel.ForEach et Parallel.ForEach<T> pour parcourir des Enumerables
  • Parallel.Invoke pour invoquer plusieurs méthodes simultanément

Méthodes pour la parallélisation

Ci-dessus un exemple simple d'implémentation des méthodes de la TPL, les trois lignes retournent le même résultat à l'ordre près. Même si cet exemple est trivial, on voit quand même à quel point il sera simple de déclarer son intention d'utiliser le parallélisme (sans même connaître la notion de tâche). On verra plus loin en revanche qu'une bonne utilisation est beaucoup plus difficile.

PLinq (Parallel Linq)

Linq (Language Integrated Query) est apparu dans le framework pour permettre au développeur d'interroger une collection de données de manière déclarative sous forme de pseudo-SQL. PLinq étend cette idée en y apportant le parallélisme. Attention ceci n'est valable que dans le cas de Linq sur des objets en mémoire : c'est-à-dire LinqToObjects et LinqToXML (ceci exclut entre autres LinqToEntities et LinqToSQL).

Ci-dessous la même requête qui retourne les nombres pairs de la collection data, à gauche non parallélisé et à droite parallélisée.

Code non paralléliséCode parallelisé
Requête LinqRequête PLinq

L'appel de la méthode AsParallel() caste la collection data qui est au départ IEnumerable<int> en IParallelEnumerable<int>. De ce fait les méthodes d'extensions Where et Select utilisées ensuite ne sont pas celles de Linq mais celles de PLinq (en raison de la différence de signature).

Limitations ou conditions de (bonne) utilisation

Si on récapitule, on dispose maintenant d'une librairie qui nous permet (très) facilement de paralléliser boucles et requêtes. Donc, optimiser du code devient facile, il suffit de remplacer tous les for par des Parallel.For et ajouter .AsParallel() dans chaque requête Linq ... Facile, non ?

Evidemment si c'était si simple on pourrait se demander pourquoi la parallélisation des traitements n'est pas implicite et pourquoi il est nécessaire de déclarer son intention de l'utiliser.

Privilégier une utilisation empirique

Comme nous l'avions évoqué avant en parlant des threads, créer une tâche et son contexte d'exécution a un coût qui est loin d'être négligeable. La meilleure façon de s'en convaincre est de comparer les temps d'exécution d'une requête Linq (par exemple celle du paragraphe précédent) en séquentiel et en parallèle sur une petite collection (un millier d'éléments). On constate alors que le traitement séquentiel est plus rapide. Le problème est d'autant plus difficile à résoudre que le nombre d'éléments à partir duquel il est plus intéressant de paralléliser dépend de plusieurs paramètres externes (nombre de processeurs, fréquence ...). Avant de se décider à paralléliser un traitement il est primordial de bien connaître les données réelles qui seront manipulées et de mesurer les temps des deux algorithmes. Ceci pose à mon avis un problème de design du code puisqu'il devra être facile de pouvoir basculer d'une implémentation séquentielle à une implémentation parallélisée. De plus, il semble peu envisageable tester une à une toutes les requêtes : il faudra donc surement passer par une classe (ou un framework) spécialisé capable d'abstraire le mécanisme.

Gérer les concurrences d'accès

Tant que les traitements que l'on souhaite paralléliser sont indépendants, le risque de mauvaise utilisation est faible (juste un ralentissement des traitements) mais dès qu'il va s'agir de paralléliser des traitements partageant des données, il va falloir être beaucoup plus prudent et notamment utiliser les mécanismes de Lock à bon escient (sous peine à nouveau de produire du code extrêmement lent voire même inter-bloqué : dead-lock).

Gérer les exceptions

Jusqu'ici, il ne pouvait subvenir à un instant t qu'une seule exception provenant du thread principal. L'utilisation de tâches exécutées simultanément rend possible la levée de plusieurs exceptions différentes simultanément. Le framework 4.0 gère donc un nouveau type d'exceptions : AggregateException qui se charge de collecter toutes les exceptions survenues au sein des tâches.

Multi-Exceptions

L'exemple de code ci-dessus montre comment capturer des exceptions survenues dans plusieurs tâches.

Des outils pour analyser / debugger

Ecrire du code parallélisé, même avec un framework « clé en main » reste une tâche ardue sans outils. L'avantage des Parallel Extensions (sur framework 4.0) est de sortir en même temps que Visual Studio 2010 qui apporte de son côté un outillage précieux pour comprendre comment le programme est en train d'être exécuté :

  • Parallel Stacks: vue sous forme hiérarchique des tâches en cours d'exécution, avec une correspondance entre le point d'arrêt et la tâche active
  • Parallel Tasks: vue sous forme de TreeView des tâches en cours avec des informations sur leur statut,
  • Profiler Concurrency Analysis: outil permettant de suivre et d'auditer le code
    • CPU utilization analysis: Evolution de l'utilisation CPU au fil du temps
    • Thread blocking Analysis: Identification des blocages de thread
    • Core execution Analysis: Répartition des threads sur les ressources systèmes

Comme il est particulièrement difficile de débugger (voire de reproduire certains bugs), Microsoft a mis au point un outil baptisé CHESS permettant d'analyser le code parallèle .net. CHESS automatise la recherche d'erreurs dans les programmes parallélisés en faisant une recherche systématique sur l'ensemble des threads et leur planification-dépendance. Il identifies des problèmes commes le dead-lock, la corruption de données ... problèmes difficiles s'il en est à trouver avec les outils de tests actuels. Il est notamment utilisé par l'équipe chargé de développer les parallèles extensions.

Cas d'utilisation

On peut également s'interroger sur les cas d'utilisation de cette extension. Les domaines fonctionnels qui semblent les plus à même de bénéficier d'une plus grande puissance de calcul sont :

  • Environnement de réalité virtuelle: moteur de rendu physique, simulation (ex: jeux)
  • Modélisation (ex: météorologie, médecine)
  • Simulation (ex: analyse financière, analyse de risques ...)
  • Algorithmes de traduction, d'apprentissage ...

Les cas d'utilisation pour l'application de gestion de M et Mme ToutLeMonde paraissent plus difficiles à envisager en raison du faible nombre d'objets manipulés (exemple de l'application bureautique).

Conclusion

En attendant une version aboutie des Parallel Extensions et des outils connexes, force est de constater qu'utiliser correctement le parallélisme en l'état actuel ne sera pas à la portée de tous les développeurs. Il semble plus raisonnable de considérer pour l'instant cette version comme une première itération offrant déjà des outils puissants et simple à utiliser. Et d'attendre, dans un second temps, de voir quels moyens (framework, classe/collection spécialisée ...) seront proposés pour abstraire le choix de l'algorithme (séquentiel vs parallèle) et mutualiser ce choix sur un ensemble de traitements. En effet sans cette abstraction, il sera difficile de basculer d'un paradigme à l'autre et donc difficile de mesurer et comparer l'impact des choix d'implémentations.

Si un mauvais code séquentiel tourne plus vite sur un processeur plus rapide, un mauvais code parallèle ne s'améliorera pas en ajoutant des processeurs...

Pour plus d'infos :

Pour les plus curieux, vous pouvez télécharger la CTP de VS 2010 mais il faut convertir l'image VPC pour Hyper-V pour pleinement tester les features des Parallel Extensions ;  il faut en effet pouvoir virtualiser plusieurs processeurs ce que ne fait pas Virtual PC 2007.