Expérience de parallélisation d’une application monolithique monothread old-fashion sur un cluster de machines

  • Sharebar

Que faire lorsque l’un fournisseur de logiciel nous propose une architecture monolithique séquentielle, uniquement accessible au travers d’une GUI pour résoudre un problème éminemment parallélisable et relativement massif ?

Hadoop vs C

Voici la question à laquelle nous avons eu à répondre au cours des derniers mois. Ce billet présente essentiellement les difficultés rencontrées et les solutions apportées.

Merci à notre client d’en avoir autorisé la publication.

I) Prendre la décision de paralléliser l’exécution d’une application monolithique

1) Notre expérience

Au démarrage de ce projet, nous disposions d’une application de calcul issue d’un travail de plusieurs dizaines d’années, qui réalise des calculs scientifiques dans le domaine de notre client avec une efficacité remarquable.

Malheureusement pour nous, il s’agissait d’une application monolithique, relativement ancienne, codée en C sous Windows et que l’on manipule exclusivement au travers d’une GUI.

En face de cette application nous avions des données à traiter : 2000 archives zip contenant une centaine de GB de XML, qu’il fallait lire, traduire dans le modèle de données de l’application, corriger et sur lesquels il fallait finalement lancer des calculs.

Le temps théorique observé pour la totalité du traitement était de l’ordre de 2 semaines de temps continu. Ce délai n’ayant jamais été atteint, car un incident est toujours advenu en milieu de traitement : crash, coupure réseau, reboot système ou toute autre interruption inopinée.

Heureusement pour nous, chacun de ces fichiers était autonome. C’est à dire que l’on peut le traiter complètement indépendamment des autres. Immédiatement nous est donc venue l’idée de paralléliser : En répartissant les traitements sur n machines, nous saurions diviser le temps de traitement par un facteur pouvant aller jusqu’à 2000, ce qui nous amènerait à un traitement idéal de 10 minutes.

2) Les résultats

Une telle réalisation n’est pas forcément ni très académique ni très rassurante a priori. Notre client nous a fait confiance pour tenter une telle réalisation, nous l’en remercions.

Bien l’en a pris, puisqu’elle est aujourd’hui couronnée de succès : non-seulement nous atteignons désormais des temps de traitement très raisonnables (de l’ordre de 2 heures), mais nous avons aussi substantiellement enrichi l’étendue des traitements effectués sur les données. Nous fournissons donc au client un service à la fois plus rapide, plus robuste et plus complet.

Serialisation

AVANT

Serialisation

APRES

3) Avant de commencer

En décrivant ici les problèmes que nous avons rencontré, je m’adresse à nos collaborateurs qui se trouveraient dans une situation analogue : j’espère les rassurer quant à la faisabilité d’une telle réalisation, mais aussi leur permettre d’anticiper une partie des difficultés qu’ils pourraient rencontrer.

Toutefois, avant de vous mettre à l’œuvre, la première question à vous poser est celle du gain visé.

Si vous n’êtes pas familiers de la loi d’Amdahl , je vous en livre mon interprétation : il ne sert à rien d’essayer de paralléliser un problème qui ne serait pas au moins à 99.9% parallélisable.

Notre cas était de ce point de vue, idéal :

  • Il n’y avait aucune corrélation du cœur du traitement
  • Le “split initial” (*) consistait à rendre accessible les fichiers sources sur les n machines de traitement.
  • Le “merge final” (*) consistait à injecter les données produites dans une base de donnée commune.

Parrallelisation

Nous étions donc dans une cas de parrallélisabilité très avantageux, les seuls éléments non-parfaitement parallélisables étant gérés d’un coté par le système de fichier et de l’autre par une base de donnée.

De plus, les temps jouaient en notre faveur : pour un “split inital” et un “merge final” qui duraient une dizaine de minutes chacun, on avait un cœur de traitement qui durait environ 2 semaines, notre limite de gain, selon Amdahl était donc de l’ordre de 1000.

Le nombre maximal de machine à notre disposition étant de l’ordre de la centaine, cette limite ne nous gênait pas.

(*) Note : je n’emploie pas ici les mots de Map et Reduce pour ne pas surestimer implicitement notre réalisation. A proprement parler, notre solution n’est *pas* un MapReduce. Néanmoins, si vous êtes familiers avec ces notions, nous attaquons bel et bien ici les mêmes problématiques, à un facteur d’échelle moindre.

II) Première étape : Outrepasser la GUI

Évidement la première difficulté a consisté a outrepasser la GUI : si l’on ne peut pas lancer un calcul unique en ligne de commande, on ne pourra pas en lancer 2000 en parallèle.

1) Un robot

Une des solutions que j’ai déjà eu l’occasion de mettre en œuvre à titre personel consiste à réaliser un robot qui simule le comportement de l’utilisateur en :

- manipulant souris et clavier,

- analysant les données reçues à l’écran.

Cette solution pour le moins approximative, et très peu robuste, ne mérite d’être considérée qu’en absence de toute autre solution.

De nombreux outils peuvent nous permettre de réaliser ce genre de choses. Parmi ceux que j’ai pu tester à ce jour, celui qui me convient le mieux est AutoIt.

Il permet de réaliser rapidement un exécutable qui manipulera votre application, pourvu que celle-ci vous fournisse des données d’interface suffisantes.

Voici un exemple d’utilisation d’AutoIt pour identifier de façon certaine un contrôle de la calculatrice Windows.AutoIt

2) Un robot “intelligent”

Il arrive souvent que les contrôles fournis par l’application ne soient pas de contrôles windows standard. C’est par exemple le cas, lorsque l’affichage est réalisé directement par DirectX ou OpenGL. Dans ce cas-là les outils comme AutoIt ne suffisent plus et il faut faire appel à des techniques de traitement de l’image directement sur des captures d’écrans.

Voici un exemple de traitement du signal appliqué à un robot, issu d’un ancien projet. Le robot était supposé jouer à un jeu vidéo, dont l’un des buts était de cliquer sur les différents “rochers” présents à l’écran pour en récupérer le minerais.

Source

Source

Resultat

Resultat

Code

Code

Contrairement à ce que croient la plupart des développeurs que j’ai pu rencontrer dans ma carrière : traiter une capture d’écran n’est pas quelque chose de techniquement compliqué. Le code ci-dessus en témoigne. Ce qui est compliqué, c’est de concevoir l’algorithme, c’est d’expliquer avec des mots, ce à quoi nous reconnaissons telle ou telle forme. Essayez sur l’image source, vous verrez que ce n’est pas facile. Mais nous sortons là du cadre de ce billet.

Si j’ai pris ici un exemple issu d’un projet de robot-joueur, c’est parce qu’il s’agit d’un cas beaucoup plus complexe que ceux que vous pourrez rencontrer dans le cadre de GUI classiques : les formes changent en fonction de la position de la caméra (il s’agit d’une vue en 3D), les couleurs changent avec l’heure du jour (plus sombres la nuit, plus rouge à l’aube et au crépuscule…).

Malgré cela, vous pouvez constater qu’il est possible d’identifier les rochers en une quarantaine de ligne de code en n’utilisant que les bibliothèques Java standard. La difficulté n’est donc pas technique.

3) Inconvénients des robots

Le plus gros inconvénient de ces outils reste qu’ils sont très fragiles face à un changement de contexte ou de comportement de l’application. Ils nécessitent donc la mise en place d’un système de reprise sur erreur efficace. (Le robot-joueur sus-mentionné par exemple, possédait un ratio nombre de fonction / nombre de fonction de reprise proche de 1/20)

Un autre inconvénient majeur est qu’ils s’appuient sur les ressources d’affichages, qui sont rapidement limitantes dans des contextes de machines virtuelles.

De plus, la version “intelligente” consomme énormément de ressources processeur, même si l’on a recours à des techniques de traitements du signal plus avancées (qui en outre sont moins accessibles aux développeurs n’ayant pas eu la chance de suivre une formation de traitement du signal).

Enfin, aussi complexes soient-elles à mettre en œuvre, de telles solutions laissent aux techos que nous sommes un arrière goût amer d’amateurisme et d’absence de qualité, puisque l’on tente de reconstruire de l’information après l’avoir perdue.

Dans le cadre de l’application décrit dans ce billet : nos premiers essais avec AutoIt ont donné d’excellents résultats. Mais nous avons finalement identifié une solution plus robuste.

4) Attaquer directement les dll de l’application

Le fournisseur de l’application est venu à notre secours en nous permettant d’exploiter une idée ingénieuse : nous interfacer directement avec les dll qui composent le produit.

Il faut dire qu’ils ont réalisé un énorme boulot dans ce sens là, en nous présentant une API C relativement simple et lisible qui nous permet de lancer les fonctionnalités de base du produit.

De plus il faut ajouter que l’un des atouts majeurs du produit en question est d’embarquer son propre langage de scripting. Ainsi, les fonctionnalités qui ne sont pas directement accessible via l’API, le sont le plus souvent en créant et lançant, via cette même API, un script idoine.

Au final nous avons donc réalisé un petit programme en C++ qui nous permet de lancer un unique script, et dont voici le workflow :

Exescript : Workflow

Nous avons appelé ce programme “Exescript”.

5) GUI versus ligne de commande

Au lancement de ce projet, nous avions sous-estimé la différence de “culture” qu’il existe entre un programme fait pour être manipulé au travers d’une GUI et un programme fait pour être manipulé en ligne de commande.

Voici la liste des problèmes liés à ce point que nous avons du régler.

5.1) Les logs ne sont pas cruciaux pour une GUI

Vous avez pu voir au chapitre précédent que nous redirigions les logs dans un fichier. Nous croyions que cette redirection suffirait à traiter le sujet des logs. Nous nous trompions.

  • Pour une application en ligne de commande, les logs sont cruciaux : on lance une action, on s’attend à ce qu’elle renvoie un log qui explique, a minima, si elle a réussi ou échoué.
  • Pour une application accessible via une GUI, les logs sont souvent anecdotiques : on lance une action, on s’attend à voir le résultat sous forme graphique (le bouton a changé de couleur, le résultat de notre calcul s’est affiché à l’écran …)

Une des conséquences de cette différence de philosophie, c’est que nous nous sommes retrouvés dans de nombreux cas sans aucune trace à exploiter pour déterminer si les traitements s’étaient correctement déroulés.

Plus grave encore, les logs sont la seule indication que nous ayons du fonctionnement de l’application. De nombreuses applications développées autour de GUIs s’autorisent à se mettre occasionellement dans des états instables et supposent que l’utilisateur voyant la GUI figée aura l’idée de redémarrer l’application. En décidant d’utiliser une telle application sans passer par sa GUI, on se retrouve dans des situations où seuls les logs nous permettent de déterminer que l’application n’est pas dans un tel état instable.

Il nous a donc fallu largement et un peu artificiellement enrichir les logs de nos scripts afin de pouvoir suivre leur comportement.

5.2) L’initialisation des logs n’est pas loguée

Le script de redirection des logs lui-même a posé un problème supplémentaire, dans la mesure où, tant qu’il n’a pas été exécuté avec succès, nous sommes dans le noir : s’il échoue, il logue une erreur dans la GUI. Mais nous ne pourrons la voir puisque la redirection a échoué.

Pour résoudre ce problème nous avons :

  • effectué un maximum de tests en C++ sur la créabilité du fichier avant d’appeler le script de redirection
  • encapsulé la fonction d’appel du script de redirection dans une boucle de tentative-échec, controlée par la lecture du fichier.

Quelque chose comme ceci :

Redirection : Workflow

5.3) Les pop-ups

Autre différence de comportement entre l’utilisation entre une GUI et la ligne de commande : les pop-ups.

Par moment, quand l’application rencontre un évènement inattendu, elle le signale par le moyen d’une fenêtre qui apparait à l’écran. Dans le cas d’une utilisation en ligne de commande, ces pop-ups, sont un problème, puisqu’aucun utilisateur n’étant disponible pour cliquer sur le bouton “ok”, l’application reste en attente indéfiniment.

Trois solutions ont été successivement mises en place pour résoudre ce problème :

  • un robot écrit en Java qui vérifie les noms des fenêtres ouvertes et envoie le message (KEYPRESSED / touche entrée) à toute pop-up.
  • le monitoring des fichiers de log de l’application : tant que l’on observe des changement de taille, c’est que l’application tourne. Si l’application cesse de loguer pendant plus de n minutes, c’est qu’elle est arrêtée. On tue alors le thread et l’on considère que l’action a échoué.
  • une demande évolution de l’application auprès du fournisseur : lorsqu’elle est lancée depuis l’API, celle-ci ne doit plus afficher plus de pop-ups mais lancer des exceptions.

Si le robot a disparu aussi tôt que le monitoring a été mis en œuvre, ce dernier a, lui, vocation a être conservé. En effet, il pourra servir de garde fou à l’évolution de l’application.

S’il advenait qu’un jour l’application génère des boucles infinies, de nouvelles pop-up ou simplement qu’elle cesse d’émettre des logs, ce mécanisme nous permettrait une détection de l’erreur et une reprise immédiatement sans autre développement.

5.4) Les échecs d’identification de l’utilisateur

Le comportement par défaut de l’application, lorsque l’utilisateur entre un login non-présent dans la base de donnée est ce que le fournisseur appelle un “hard exit”.

Autrement dit : il y a quelque part dans la méthode d’identification des dll que nous attaquons, un test qui ressemble à :

if (!exists(bdd,login)){exit (1);}

Un tel comportement est réellement problématique pour nous, car il n’y a aucun moyen pour notre application de récupérer la main et donc d’agir en conséquence. Ceci est d’autant plus grave que :

  • le fournisseur de l’application a refusé de faire évoluer l’application dans ce sens argumentant qu’il s’agit d’un mécanisme de sécurité
  • en cas de non-disponibilité de la base de donnée, le login n’est pas trouvé et le comportement est exactement le même.

Ce problème n’a pas été résolu à ce niveau là de l’application. Nous allons voir dans la suite de ce billet que sa gestion a été déportée au niveau du LoadBalancer.

5.5) Les crashs

Malheureusement, comme cela arrive parfois avec les applications monolithiques un peu anciennes (surtout celles qui embarquent des langages de scripts), notre application peut occasionellement crasher. Les causes peuvent être diverses :

  • fuites de mémoire impliquant sur une absence de mémoire disponible,
  • buffer overflow (l’application utilise des tableaux de taille fixe),
  • segmentation fault (en particulier à cause des scripts embarqués),

Dans notre cas, ces crashs conduisaient à une congestion complète de l’application, car le comportement par défaut de l’OS était d’ouvrir une pop-up de crash qui attendait simplement que l’utilisateur choisisse une action à lancer :

Windows Crash

Outre la résolution des cas de crashs identifiés, notre action a été de paramétrer l’OS de manière à ce qu’en cas de crash, plutôt que de lancer une pop-up, il génère un dump de l’application, que nous pouvons envoyer au fournisseur de manière à ce qu’il puisse l’ouvrir avec son débogueur et ainsi identifier la cause du crash.

Voici un premier et un second point d’entrée dans la MSDN qui vous permettront d’approfondir la question.

III) Deuxième étape : paralléliser

1) Architecture

Parmi les contraintes de planification que nous avions, il y avait, en particulier :

  • La nécessité d’obtenir les premiers résultats immédiatement. En effet, la solution proposée étant peu académique, il n’était pas question de proposer au client d’investir plusieurs semaines de travail avant de pouvoir constater sa viabilité. Autrement dit : il a fallu convaincre.
  • De fortes contraintes quant aux applicatifs que nous pouvions installer. En effet, l’ensemble de l’infrastructure informatique du client étant gérée par un organisme dédiée à cette tâche, celui-ci impose une certaine rationalisation des applicatifs installés.

Ces deux contraintes, associées à notre complète ignorance des frameworks de parallélisation (TerraCotta, OpenMPI, GridGrain …), excluait d’emblée l’utilisation de l’un d’entre eux.

De plus, notre besoin étant relativement basique, nous avons opté pour une architecture spécifique simple :

Architecture

Le principe est simple :

  • un maitre détient une liste de tâches à réaliser et contrôle des esclaves. Maitre et esclaves disposent d’un thread qui contrôle le nombre de tâches lancées en local, notamment en fonction des contraintes locales (logicielles et matérielles : nombre d’installation de l’application disponibles, drivers de base de donnée accessibles, disponibilité des processeurs …).
  • la communication client / maitre, se fait par telnet, les instructions de bases sont :
    • rafraichir la liste des données à traiter,
    • ajouter une tâche,
    • stopper l’ensemble des tâches,
    • mettre à jour l’application
  • la communication maitre / esclave , se fait aussi par telnet, sur un autre port évidemment, les commandes de bases sont :
    • le client demande une ou plusieurs tâches au maitre,
    • le maitre envoie une tâche à un esclave,
    • l’esclave informe le maitre du succès ou de l’échec d’une tâche,
    • le maître demande à l’esclave de s’arrêter,
    • le maître demande à l’esclave de se mettre à jour
  • la plupart des tâches consistent simplement à lancer l’Exescript, c’est à dire l’application que nous avons décrite dans la première partie de ce post, en lui fournissant les paramètres spécifique

2) LoadBalancer

L’application installée sur chaque machine est qui prend le rôle master ou slave (selon la machine) a été développée en Java, nous la nommons “LoadBalancer”.

A ce jour, c’est à dire un peu plus d’un an après la version 0.1, ses métriques sont les suivantes : 11 packages / 121 classes / 560 méthodes / 7500 lignes de code

Mais nous sommes aujourd’hui en version 4.6, un certain nombre de fonctionnalités ont été ajoutées qui ne sont pas strictement nécessaires au fonctionnement :

  • l’analyse des logs à la volée
  • un système de reprise pour les tâches échouées
  • la capacité de brancher et débrancher des slaves à chaud, sans perdre de tâches
  • des verrous qui permettent de détecter des erreurs récurrentes et d’alerter l’administrateur d’une contrainte d’infrastructure anormale
  • une tâche métiers permettant la préparation des données avant le traitement (cf chapitre dédié plus bas)
  • la capacité pour l’application de se mettre à jour à chaud (sans s’arrêter), et à mettre à jour à chaud les installation de l’application monitorée
  • un embryon de capacité à ajouter des tâches sous forme de script lua

Il ne faut donc pas surestimer le travail nécessaire à la création d’une telle application. La toute première version a été écrite en moins de trois jours, et elle permettait déjà de valider le concept, et de diviser les temps de traitement par un facteur 2 ou 3.

3) La raison d’être des instructions : le fonctionnement au flux

Outre la parallélisation, nous avons décidé de mettre en place un fonctionnement “au flux”. C’est à dire que plutôt qu’une application que l’on lance en lui précisant où trouver toutes les données de départ et qui rend la main une fois que toutes les données sont traitées, nous avons réalisé une application qui reste allumée en permanence, et à laquelle on peut demander (par le biais des instructions) de traiter toutes les données qui n’ont pas encore été traitées.

De mon point de vue, il n’y a que des avantages à concevoir toute application de batch “au flux” :

  • lorsqu’on le conçoit dès le départ, le fonctionnement au flux n’est pas techniquement plus compliqué à réaliser qu’un fonctionnement mono-tâche.
  • il permet de se poser de bonnes questions au cours du développement. Par exemple dans notre cas :
    • est-il possible de remplir deux bases de données en même temps (pré-prod et qualif par exemple) ?
    • est-il possible de traiter deux sources de données ?
    • peut-on traiter des données partielles ?
    • peut-on impacter une modification des données ayant eu lieu a posteriori ?

    aucune de ces fonctionnalités ne faisait partie du besoin à l’origine, et à ce jour toutes n’en font pas encore partie. Mais certaines nous ont été demandées a posteriori et d’autres ont été utilisées parce qu’elles étaient disponibles.

  • il est toujours possible d’utiliser de façon unitaire une application développée pour gérer des flux (lancer un serveur mail pour envoyer un mail par exemple), mais il est toujours nécessaire et souvent compliqué de transformer une application développée pour gérer un traitement de façon à ce qu’elle gère un flux

Ces deux mécanismes (le fonctionnement au flux et la parallélisation) sont à la fois complémentaires et concurrents : chacun des deux si nous avions les moyens de l’exploiter entièrement nous permettrait idéalement de ramener le temps de traitement à dix minutes, mais dans des conditions réelles, ils se complètent pour nous permettre d’obtenir un temps de l’ordre de 2 heures.

4) Les tâches de préparation des données

Nous avons dit plus haut qu’il existait des tâches préalables au traitement des données. Concrètement il s’agit d’absorber un défaut du SI émetteur qui n’empaquète pas toujours les données dans les bonnes archives.

orphans

Réorganiser les archives zips ne semble pas être un problème très compliqué en soi. Et de fait nous avons réalisé en quelques dizaines de minutes, en marge du projet, un outil python capable de réaliser cette réorganisation.

Cependant, ce problème est doublement pénalisant pour nous :

  • il ne s’insère pas facilement dans un process au flux
  • il tend à augmenter le temps de “split initial” des données, c’est à dire à faire décroitre notre facteur de Amdahl

Fort heureusement nous avons trouvé une solution permettant de paralléliser la résolution de ce problème en :

  • concervant un pool de fichiers mal positionnés que nous réintégrons à la volée dans les futurs traitements
  • relançant la tâche de traitement d’une archive si l’on reçoit un fichier qu’elle auraient du contenir
  • fusionnant les tâches identiques dans notre task-queue

Cette solution ne fonctionne que parce que le nombre de fichiers déplacés est faibles, mais s’ils étaient beaucoup plus nombreux, l’ensemble de notre travail eut pu être largement impacté. Notre outil python sus-mentionné effectue le travail de réorganisation complet en environ 30 minutes. Comparé aux 2 heures de traitement, ce n’est pas négligeable.

5) De l’importance du logging

Si vous avez lu la première partie de ce post, vous aurez constaté que nous avons déjà deux niveaux de logs :

  • les logs engendrés par l’application
  • les logs engendrés par l’exescript

A ces deux niveaux de logs, nous rajoutons évidement un troisième niveau qui correspond à celui du LoadBalancer lui-même, et en particulier tous les logs liés au travail de synchronisation entre les instances de l’application.

Mais plus important : nous avons intégré dans le LoadBalancer une fonctionnalité de traitement des deux autres niveaux logs à chaud. C’est à dire qu’il parse et synthétise les quelques millions de lignes de log des deux premiers niveaux (4 lors du dernier import), remontant les erreurs graves, ainsi que diverses statistiques.

6) Dimensionnement processeurs

6.1) Architecture matérielle

Une fois notre prototype réalisé et testé sur une dizaines de processeurs, il nous a fallu dimensionner le parc informatique dont nous avions besoin.

Notre première approche fut d’appliquer une règle de trois, mais c’était sans compter :

  • les problèmes de ressources partagées inattendues qui sont traités dans la dernière partie de ce billet
  • les spécificités de l’architecture VM-ware détaillées ci-dessous

Sans réexpliquer les architectures VMware, dont je ne suis d’ailleurs pas spécialiste, l’un des principes en est le suivant : une VM voit n cœurs (ou processeurs) virtuels. Du point de vue de l’OS ou des applications, ces cœurs virtuels sont identiques à des cœurs physiques sur une machine réelle. Mais en réalité, les diverses opérations censément réalisées par ces cœurs virtuels sont ventilées à la volée sur les n cœurs physiques ou logiques.

Il existe donc un facteur multiplicatif qui sert à mesurer la charge d’une VM, il s’agit du ratio entre les cœurs virtuels et physiques (ou logiques). Ce facteur, chez notre client, est nommé “contention”.

Ce facteur multiplicatif, doit être multiplié, dans notre cas, un second facteur qui est lié à l’hyper-threading. En effet, les cœurs des ESX mis à notre disposition ne sont pas des cœurs physiques réels, mais des cœurs logiques.

VMWare

Je ne reviens pas sur les nombreux bienfaits de la virtualisation. Il est pour moi évident qu’à part dans des cas très spécifiques, l’on veut aller vers de plus en plus de virtualisation du hardware.

6.2) Parenthèse sur l’hyperthreading

Je ne reviens pas non-plus sur le très controversé débat sur l’hyper-threading, il me semble que tout le monde est d’accord sur les deux conclusions suivantes :

  • un processeur hyperthreadé est plus rapide que le même processeur non-hyperthreadé
  • un processeur hyperthreadé n’est en aucun cas comparable à deux processeurs physiques

A mon avis l’essentiel du débat réside d’ailleurs dans l’angle marketing sous lequel Intel l’a présenté : avoir introduit la notion de “processeur logique” sous-entend qu’un processeur hyperthreadé équivaut à deux processeurs physiques.

Ce n’est pas le cas, ce n’est d’ailleurs pas ce que Intel prétend.

6.3) Cas pratique

La virtualisation des machines, aussi utile soit elle, nous a toutefois joué un tour, pas tant du à la technologie elle-même qu’à la compréhension que nous en avions.

En effet, notre client négocie deux choses auprès de son opérateur informatique :

  • un nombre de VM
  • une contention maximale (cf plus haut)

La contention maximale négociée, tient compte d’un fonctionnement “normal” des applications. La plupart des applications étant la plupart du temps en attente, le foisonnement naturel fait qu’une très grande contention est parfaitement acceptable.

En écrivant ce billet, je me suis rendu compte, que je n’avais aucune idée de la charge “moyenne” d’une application, j’ai donc réalisé l’opération suivante sur une de mes machines (sur laquelle j’ai installé un postfix) :

$ time smtp-source -s 1 -l 1024 -m 500 -c -f root@localhost -t spam@localhost -4 127.0.0.1:25
500
real 0m33.245s
user 0m0.088s
sys 0m0.460s

J’en déduis que la charge nécessaire à l’envoi de 500 emails sur cette machine (il s’agit d’un Kimsufi de chez OVH) est de l’ordre d’une demi-seconde (répartie sur 33 secondes de temps réel). Autrement dit, qu’une contention maximale acceptable pour cette machine si elle ne faisant qu’envoyer des emails et était située sur une VM serait de l’ordre de 60.

De ce chiffre, l’on comprend facilement l’un des objectifs de l’utilisation de VM : l’économie de processeurs. Chaque application n’ayant besoin d’un processeur que pour quelques millions d’instructions de temps en temps, on partage l’accès processeurs entre les applications sans que cela soit néfaste pour celles-ci.

Cependant, notre cas d’utilisation n’entre pas dans ce schéma de fonctionnement. En effet, nous avons réparti un traitement lourd sur l’ensemble de nos machines. C’est à dire que quand l’une de nos machine travaille, il y a fort à parier que les autres aussi. Ainsi, le mécanisme de virtualisation, vient jouer un rôle directement opposé à celui du LoadBalancer.

Foisonnement

A cela, il faut ajouter le fait que notre application est une application de calcul, et donc typiquement consommatrice de temps CPU, alors que dans de nombreuses autres applications, les accès à d’autres ressources (disque ou réseau) peuvent être limitants. (A cet égard, les chapitres sur l’utilisation successive de NAS, de SAN et de RamDisks explique en quoi nos logs ne nous ralentissent pas).

Lors de nos premiers tests, nous utilisions une dizaines de processeurs sur un ESX présentant un certain nombre de cœurs physiques (16 ou 32 je ne sais plus). Lorsque nous utilisions nos machines à 100%, nous privions les autres projets ayant des VM sur ce même ESX de nos 10 processeurs physiques. Cela représentait pour eux une charge importante de l’ESX.

Cependant, lorsque nous sommes passés à 50 processeurs, nous avons commencé à nous faire concurrence à nous-même. C’est à dire que nous occupions un volume de l’ESX suffisamment important pour ne plus pouvoir compter sur le foisonnement naturel des applications.

Ainsi, nous avons pu obtenir de très belles courbes où nous observions une saturation très nette de nos performances (ici la courbe bleu représente la vitesse de traitement des données alors que la courbe verte représente le nombre de cœurs utilisé ) :

VM-Ware saturation

Pour ce problème, toute solution est bonne à prendre dès qu’elle augmente le ration nombre de processeurs physiques réellement sollicités sur nombre de tâches en parallèle :

  • diminuer la contention théorique
  • répartir les VM sur différents ESX
  • réserver des machines virtuelles sur nos ESX et ne pas les utiliser
  • désactiver l’hyperthreading (en conservant le même calcul de contention : logique / virtuels)

6.4) Un outil pratique : la rampe

J’ouvre ici une parenthèse pour commenter l’outil ayant permis la génération du graphe ci-dessus. Il s’agit ni-plus ni moins que de notre LoadBalancer auquel nous avons apporté deux modifications :

  • le nombre de tâches qu’il s’autorise à lancer en parallèle est fixé par le temps (il s’agit de la rampe verte)
  • lorsqu’il termine la liste de tâches qu’on lui a confié, il recommence, de manière à ne jamais manquer de tâches

Nous avons beaucoup hésité à réaliser un tel outil, entre autre pour la raison qu’il était structurellement opposé aux choix que nous avions fait jusque là. En particulier notre application était basée sur le fait que ce sont les slaves qui s’enregistrent auprès du master et donc que leur nombre et leur répartition est entièrement dynamique.

Finalement nous avons opté pour la création d’une branche “brouillon” de notre application principale. Ainsi cette application de rampe n’est ni industrialisée ni maintenue.

Étant donné les énormes avancées de diagnostics que cet outil nous a permis de réaliser, je pense que si c’était à refaire, nous inclurions ce fonctionnement dans l’application dès la conception de celle-ci.

7) Autre dimensionnements

Comme nous l’avons dit précédemment, notre application est typiquement consommatrice de CPU (par opposition aux accès réseaux ou disque) : elle effectue des opérations de calcul lourdes sur de faibles volumes de données. Il ne nous a donc pas paru pertinent d’effectuer d’autres dimensionnements que ceux liés au processeurs.

Dans une des premières version, le LoadBalancer travaillait directement sur un disque dur réseau commun ou NAS (pour Network Attached Storage).

Etant donnée que la lecture des données d’entrée et l’écriture des données de sortie ne représente qu’un faible pourcentage du temps total de traitement, nous avions calculé que ce facteur ne serait pas limitant. Cette solution s’est maintenue un certain temps, mais le nombre de VM et la quantité de logs produits augmentant, le NAS est devenu une ressource limitante.

Nous sommes donc passé à un système où nous :

  • commençons par copier les données source sur le disque local (dans notre environnement VMWare il s’agit d’un SAN avec réseau fibre dédié)
  • effectuons les traitements
  • finissons par copier les données et les logs produits sur le NAS

Ce simple basculement suffit à rendre le facteur disque complètement négligeable.

A posteriori, il me semble évident que les logs à eux seuls étaient responsables de l’essentiel de la congestion. Et nous aurions sans doute pu nous permettre de ne déplacer qu’eux. Si toutefois il fallait prendre à nouveau la décision, je pense que nous prendrions la même étant donné qu’une donnée produite sans les logs associés à sa production n’a que peu de valeur.

8 ) Chronogrammes de “Suivi” des actions

Pour enrichir la liste des outils que nous avons créé et qui ont été très pratique, je voudrais mentionner le générateur de chronogrammes, c’est à dire l’outil qui fabrique ces graphes :

Serialisation

Idéalement, nous aurions pu nous interfacer avec une application de monitoring de type Jenkins, mais là encore le “vite développé” et “ne rien installer” a triomphé sur le long terme. Nous avons donc développé un petit (250 lignes) script en PERL qui parse les logs et génère un fichier au format svg.

Le gros avantage d’une telle solution est qu’elle est rapidement développée, et surtout qu’elle est facile à faire évoluer au fil de l’évolution des logs. A chaque fois que les logs change, on peut facilement adapter le script. Bien sur cet avantage vient au prix d’une faible maintenabilité : il est très souvent nécessaire d’adapter le script.

Quoi qu’il en soit : ce genre d’outil s’est avéré indispensable à la mise au point de notre application. Un bug dans un traitement se voir presque immédiatement sous cette forme, y compris pour les bugs que l’on n’a jamais rencontré. Si nous devions développer une telle application à nouveau, je pense que nous n’hésiterions pas à intégrer la génération de ce type de graphe aux objectifs des toutes premières versions.

Ci dessous plusieurs exemples d’erreurs :
Chronograms errors

9 ) Reprises sur erreur et verrous

La fragilité du développement décrit dans ce billet, en en particulier l’outrepassement de la GUI, implique qu’un certain nombre d’erreurs se produisent au cours de nos traitements. Ces erreurs sont compensées par un simple système de reprise : une tâche échouée est remise dans la queue du master avec une priorité moindre.
Une tâche échouée trois fois de suite est abandonnée.

Notons qu’une tâche est considérée échouée si on n’a pas la confirmation de son succès, y compris si l’on n’a pas eu non-plus de confirmation de sa réussite. Cette considération est importante dans la mesure où elle nous prive de la possibilité de gérer des tâches dont on ne sait pas vérifier le succès.

Lorsqu’une tâche échoue, elle peut blâmer une ressource (pas d’accès au fichier, disque absent, temps d’accès à la BDD trop long, etc.). L’ensemble des blâmes sont rapatriés sur le serveur, et celui-ci, s’il détecte un trop grand nombre de blâmes liés à une même ressource, peut générer un verrou sur cette ressource. Toute tâche qui nécessite l’utilisation de cette ressource ne peut alors plus être lancée jusqu’à ce que le verrou soit levé. En parallèle une alerte devrait (*) être envoyée à l’administrateur pour qu’il vérifie et/ou redémarre la ressource en question.

(*) Les alertes ne sont pas encore développées.

IV) Les problèmes spécifiques

Outre les problèmes d’outre-passage de la GUI et les problèmes de parallélisations, nous avons eu à diagnostiquer et résoudre un certain nombre de problème spécifiques à la conjonction de ces deux actions.

1) Les ressources partagées inattendues

1.1) Les mutex et évènements Windows

Parmi les choses que nous avons découvert à l’usage, il y a le fait que l’application monitorée utilise des Mutex et évènements Windows nommés. Ainsi, en lançant de multiples instances de cette application sur la même machine (à la fois en série et en parallèle), nous générions des interférences entre ces instances qui conduisaient à des temps de latence.

S’il nous était venu, avant de commencer le développement, l’idée de demander au fournisseur de l’application de nous renseigner sur l’existence de tels éléments, cela ne nous aurait pas aidé pour autant. En effet celui-ci a découvert cette existence en même temps que nous.

Parmi les outils qui nous ont permis de détecter ces interactions il y a les outils système ProcMon et ProcExp qui permettent de monitorer les différentes ressources et actions mobilisées par les différentes applications en cours d’exécution.
ProcExp

A ce problème nous n’avons en l’occurrence pas trouvé de solution. Aujourd’hui nous vivons avec. L’analyse des latences générées nous a permis de réorganiser nos traitements de manière à en tenir compte et à ne plus être pénalisé par ce phénomène. A ce jour, les seules limites qu’il induit sont :

  • l’impossibilité d’augmenter indéfiniment le nombre de traitements sur une même VM. La limite imposée étant de toute façon beaucoup plus haute que l’architecture choisie.
  • un coût en temps mesuré à une dizaine de seconde sur 10 minutes de temps de traitement (< 2%)

Ci-dessous : l’illustration du rapport qu’il existe entre les mutex et la limite du nombre d’instance utilisable en parallèle sur chaque VM :
Mutexes limit

1.2) L’affichage

Alors même que nos traitements n’affichaient rien à l’écran, il s’est avéré qu’ils réservaient quand même les ressources (OS et/ou hardware) d’affichage. Il s’ensuit que le lancement de certaines parties du traitement étaient fortement ralenties lorsqu’elles étaient déclenchées de façon synchrones.

Ce comportement a pu être isolé par observation des vitesses de traitement (mesurées en nombre de lignes de logs par minute) sur des traitements parfaitement identique lancés ou non en parallèle sur la même machine. Voici l’une de ces observations :
Graphical latencies

Une fois la zone de log posant problème isolée, notre connaissance du traitement a permis d’établir les particularités de cette zone, à savoir la création d’objets graphiques (en vue d’une utilisation ultérieure).

La correction à ce problème a été apportée par l’éditeur du logiciel. Aucune des options envisagées pour contourner le problème n’était de toute façon réellement satisfaisante. En particulier, la possibilité de démultiplier les VM (afin de n’installer qu’une unique instance de l’application par VM) avait un vrai coût en terme d’architecture (par exemple de nombre de cartes graphiques virtuelles sollicitées).

1.3) Le presse-papier

De façon fort étonnante, il s’est avéré que l’application monitorée vidait régulièrement le presse-papier de l’OS. Dans le cas d’une utilisation en parallèle, par de multiples applications sur la même machine, cette utilisation pouvait générer des concurrences et des latences.

Là encore la solution est venue de l’éditeur. Le diagnostic quant à lui a été obtenu en enrichissant les logs de l’application jusqu’à se rendre compte que des crashes apparaissaient dans cette zone.

2) Les ressources partagées attendues

2.1) Le serveur de licence

L’une des ressources partagées que nous avions anticipée est le serveur auquel chaque instance de l’application se connecte pour négocier une licence en début de traitement.
Nos premières estimations évaluaient le risque d’étranglement lié à ce serveur comme faible, le serveur répondant très facilement à une dizaine de demande en quelques secondes.

Toutefois, il s’est avéré à l’usage qu’un mécanisme de protection contre les attaques par dénis de service avait été implémenté dans ce serveur. Lorsque nos traitement demandaient “au même moment” plus de 20 licences (ce qui n’arrivait finalement pas spécialement souvent), le serveur se mettait à ignorer toute demande pendant quelques secondes.

Or le comportement par défaut du client était d’attendre une dizaine de minutes que le serveur réponde. Nous observions donc des initialisations de traitement de cette durée là, qui aboutissaient à des crashs.

Le diagnostic a finalement été réalisé par le fournisseur de l’application, de même que la correction.

2.2) les disques dur locaux (SAN) ?

L’autre ressource partagée que nous avions identifié sans la craindre était l’utilisation du disque dur. Un premier aménagement (détaillé plus haut), nous avait permis de passer d’un disque NAS à un disque SAN pour le traitement des logs.
A un moment de doute, alors que nous avions des difficultés à diagnostiquer d’autres problèmes sus mentionnés, nous avons eu un doute sur la capacité du SAN.
Dans la mesure où nous rapatriions et renvoyions les données/log/résultats sur le NAS en début et fin de traitement, nous nous n’avions en local que des fichiers temporaires. Nous avons donc décidé de mettre en place des RamDisks, pour ces fichiers.

S’il s’agissait d’une fausse piste (les derniers tests montrant des performances assez similaire en utilisant les RamDisks ou le SAN), il s’est avéré que les quelques % de gagnés nous permettaient à cette époque de passer en dessous d’une barrière bloquante dans un nombre important de cas.

A ce jour, nous travaillions encore sur RamDisk, il se peut que nous les abandonnions pour raisons de compatibilité avec le référentiel logiciel supporté par l’exploitant informatique.

V) Conclusion

Entre le début du projet et la rédaction de ce post, il se sera écoulé 18 mois. La taille de l’équipe a varié de 1 à 3 personnes, aucune n’étant affectée à temps complet sur cet outil. Si par moment nous avons douté de la rentabilité et parfois de la faisabilité de ce développement, il semble aujourd’hui tout à fait amorti.

Les excellents résultats obtenus encouragent aujourd’hui notre client à nous demander régulièrement d’intégrer de nouvelles tâches au traitement : extension du périmètre des données, nouveaux calculs et à nous confier de plus en plus de traitements.

Pour moi, il s’agit avant tout d’une expérience win-win, d’un exemple d’interaction positive avec le client. Notre proposition a été écoutée, entendue et nous avons eu le loisir de la mener à bien. Le client, en prenant le risque de nous faire confiance, s’est doté d’un outil aujourd’hui indispensable à la réalisation de ses objectifs.

Quant à nous, en nous faisant force de proposition et en nous investissant dans le projet, nous avons eu la chance de travailler sur un projet motivant mettant en jeu des complexités et des technologies choisies.

This entry was posted in Moteurs de Calculs. Bookmark the permalink.

Leave a Reply