En bioinformatique, nous sommes souvent amenés à travailler avec des données à grande échelle. Il suffit de voir le nombre de publications incluant les termes “large-scale”, “extensive” ou encore “high-throughput” pour s’en rendre compte. Dans la majorité des cas, on est satisfait du développement d’un outil quand celui-ci fait son job correctement en se souciant assez peu de son optimisation. Il est donc assez courant de lancer des tâches de plusieurs heures sur des giga-octets de données pour réaliser des analyses. Cela ne veut pas dire que bioinformaticien rime avec bourrin (enfin si, la, en l’occurrence, ça rime…), mais les quantités de données à traiter sont telles qu’il est parfois difficile de faire autrement.
Dans cet article, je vais tenter de présenter un exemple concret d’utilisation de la technologie GPGPU (General-Purpose computation on Graphic Processing Units) qui n’est, d’après moi, pas encore assez exploitée dans notre domaine.
Il n'est pas toujours possible d'avoir accès à un supercalculateur pour lancer ses analyses, la technologie GPGPU est une alternative peu coûteuse et parfaitement adaptée pour le traitement des tâches massivement parallèles. Les processeurs graphiques actuels ont une capacité de calcul souvent sous-estimée, il serait dommage de les exploiter uniquement pour les calculs de pixels de vos jeux vidéos !
Architecture matérielle
Les processeurs classiques (Central Processing Unit ou CPU) sont très efficaces pour exécuter rapidement des tâches séquentielles. En raison de son architecture différente, un GPU, lui, ne sera pas aussi performant pour ce type de taches, par contre, il convient très bien au traitement d’algorithmes « parallélisables ». Ce qui rend cette technologie très intéressante pour notre domaine .
Le schéma suivant aide à comprendre un peu mieux cette capacité de parallélisation du GPU :
Les ALU ("Arithmetic Logic Unit" ou Unité arithmétique et logique) sont les composants chargés des calculs : on comprend mieux, d'après cette image, pourquoi les GPU sont puissants pour la parallélisation. En effet, c'est sur ces parties matérielles que la majorité des opérations de calculs sont réalisées et les processeurs graphiques en ont un très grand nombre en comparaison aux processeurs classiques.
Architecture logicielle
Il existe différentes API de bas niveau permettant d’exploiter l’architecture de nos GPU, les plus populaires étant CUDA et OpenCL (Open Computing Language). CUDA a été spécialement développé pour les GPU NVidia, en conséquence le code a été optimisé pour ce matériel et permettrait donc des implémentations plus performantes. OpenCL est un standard ouvert, l'avantage majeur de cette API est sa portabilité. En effet, une application développée en OpenCL pourra être utilisée sur tout type de plate-formes et systèmes d'exploitation.
Je ne vais pas m’étendre sur les avantages et inconvénients de ces deux implémentations. Actuellement, il semble que l’adoption du standard OpenCL soit en pleine croissance et c’est l’API que j’ai choisi d’utiliser dans mes développements. Dans les deux cas, la structure des applications reste relativement similaire et le code se décompose principalement en deux parties :
- “host code”: est exécuté sur le processeur. Cette partie de l’application est utilisée pour toutes les tâches non-parallélisées (lecture des données, interaction avec l’utilisateur, etc.). L’hôte est communément écrit en C mais différents langages de programmation peuvent être utilisés grâce à une multitude de bindings (Python, Java, C, C++, Ruby, etc.).
- “kernel” : ce code interagit directement au niveau du GPU. En conséquence, l’API est relativement bas niveau (syntaxe proche du C99) et donc pas spécialement accueillant au premier abord. Le kernel contient donc toutes les opérations arithmétiques parallélisables de votre programme. Pour simplifier, c’est un peu le type de code que l’on utiliserait dans le corps d’une boucle.
Il existe de nombreux documents au sujet de cette API qui fournissent de très bons exemples d’implémentations et expliqueront tout cela bien mieux que moi. Je vous invite à consulter l'API officielle pour plus de détails. Je tenterai de présenter différents exemples d'implémentations dans un futur billet.
Exemple de problème "parallélisable"
Pour en revenir à nos moutons, j’ai été récemment amené à développer un outil permettant l’analyse de liaison génétique (genetic linkage en anglais) à grande échelle.
Pour simplifier, il s’agit d’analyser comment certains gènes sont transmis à la descendance. Par exemple, deux gènes étant systématiquement transmis (ensemble) chez des personnes ayant un phénotype particulier (maladie génétique par exemple) joueront potentiellement un rôle dans le développement de ce phénotype. En gros, c’est une manière d’identifier de nouveaux gènes pouvant être impliqués dans le développement d’un trait phénotypique particulier.
Je reçois donc un premier jeu de données incluant :
- l’expression relative de 250 000 gènes ;
- 150 000 SNP ;
- le tout pour un groupe de 2500 patients atteints d’une maladie génétique particulière.
Sans rentrer dans les détails de l’algorithme, il s’agit de tester les 250 000 gènes contre les 150 000 SNP.
La première approche évidente est donc :
1 2 3 4 5 |
foreach (genes){ foreach (snps){ // do some kickass statistical analysis } } |
Soit 250000 * 150000 = 37.5 milliards de tests… même pas peur.
Une semaine de développement plus tard (oui, le traitement est un peu plus compliqué qu’une simple (double) boucle !), l'application a l’air de fonctionner et l’analyse prend plus ou moins 12 heures. Demi-journées pendant lesquelles la machine n'est pas très réactive car les ressources sont saturées mais ce n’est pas très grave, ça tourne de nuit !
Première reunion, l’analyse semble fonctionner correctement, il m’a été demandé de l’étendre à quelques jeux de données supplémentaires. Deux jours plus tard, pas moins de 750 fichiers m'attendaient sur le serveur. (On se sera sans doute mal compris sur le terme “quelques jeux de données” 😉 ). Petit calcul rapide, si tout va bien j’aurais peut-être fini l’année prochaine. Bref, il va falloir revoir l’implémentation rapidement, sinon je vais passer pour un jambon incompétent…
Les analyses sont réalisées sur des couples gène//SNP mais chaque analyse reste indépendante. Ceci est donc un problème parfaitement adapté à la parallélisation. Trois semaines de développement supplémentaires ont été nécessaires pour traduire l’implémentation initiale en C et récrire les tests statistiques sous forme de kernel OpenCL.
Les résultats sont maintenant générés en moins de 2h (sur un simple portable doté d'une NVIDIA GeForce GT 330M). La quantité de jeux de données n'est donc plus un problème, le patron est content et je conserve mon emploi 😉
Conclusion
De nombreux problèmes liés à la bioinformatique sont parallélisables mais la technologie GPGPU n’est pas encore très répandue dans le domaine. Les langages de bas niveau utilisés par les kernels en sont sans doute la cause principale. Il est également important de noter que le GPGPU est uniquement efficace pour des taches parallélisables. Une implémentation plus classique sera dans certains cas plus performante. Un état des lieux précis du problème à résoudre est donc indispensable afin d'être sûr qu'une telle implémentation sera bénéfique avant de se lancer dans le développement. De plus, il est évident qu’une étape d’apprentissage est nécessaire et que ce type d’implémentation est moins rapide qu’avec un langage de plus haut niveau.
Plusieurs outils courants tels que GPU Blast, CUDASW+, GPU HMMER, etc. ont été traduits afin d’exploiter cette technologie, avec des résultats plutôt concluants. Le GPGPU semble être une approche relativement puissante et peu coûteuse. Son utilisation reste limitée à certains problèmes mais cela reste une possibilité à ne pas négliger pour de futurs développements d'outils d'analyse à grande échelle.
Que la (Ge)Force soit avec vous !
Remerciements à J. Geinz pour son illustration.
Laisser un commentaire