Du CUDA sur GPU à l'OpenCL sur FPGA

HPC
Écrit par Damien Dubuc, le 06 février 2018

Nous avons essayé de nous mettre à la place d'un de nos clients qui souhaitent explorer les possibilités d'utilisation du FPGA bien que venant du monde GPU.

Nous avons donc eu l'approche suivante : nous avons porté une application GPU dont les kernels de calcul sont typiquement écrits en CUDA vers une application FPGA dont les kernels sont écrits en OpenCL. Il s’agissait d’identifier les difficultés rencontrées au cours des développements, de quantifier l'effort et le temps nécessaires aux différentes phases de ceux-ci, et de s'intéresser dans la mesure du raisonnable aux optimisations et performances atteignables.

Bittware est une entreprise spécialiste des FPGAs proposant des solutions à base de produits Altera ou Xilinx. Elle nous a permis d'accéder à une machine distante disposant d'une carte FPGA Altera Stratix V et nous a également fourni une licence afin d'utiliser les outils de compilation et de profiling d'Altera AOCL. Ces outils sont désormais packagés sous le nom d'Intel FPGA SDK for OpenCL suite au rachat d'Altera par ce dernier.

Transition GPU du CUDA vers OpenCL

Tant dans l'utilisation de CUDA qu'OpenCL, deux versants sont à prendre en compte: d'une part, le côté host décrit le code CPU et permet de piloter la carte GPU et d'autre part celui côté device décrit explicitement le code de calcul à accélérer sur cette dernière.

Bien que les changements côté host soient un peu lourds par rapport au CUDA et pas très sexy de premier abord, la généricité d'OpenCL fait que vous pourrez réutiliser tels quels la majorités de ceux-ci pour tous vos développements futurs, quel que soit le device.

Côté host

En ce qui concerne le code côté host, OpenCL peut rebuter de par sa verbosité. En effet, comme il se destine à la programmation pour architectures hétérogènes en général, le programmeur doit passer par un rituel assez élaboré permettant d'identifier les devices disponibles, sélectionner celui qu'il désire utiliser, pointer vers le code source du kernel de calcul à compiler, le tout en utilisant des variables propres à OpenCL.

Derrière cette verbosité se cache la généricité du langage et sans doute un manque de développement qui aurait pu rendre l'interface plus user-friendly. Il est important de souligner que l'API C++ est tout de même plus light que l'API C, qui est celle que nous avons utilisée ci-dessus. Il suffit de reprendre, à peu de choses près, le setup sur des exemples OpenCL existants et on obtient alors quelque chose de réutilisable, indépendamment du device .

On retrouve côté host beaucoup de fonctions ayant un équivalent CUDA, comme les allocations et alignements mémoire sur device en mémoire globale ou constante, ou encore le paramétrage en workitems et workgroups (respectivement threads et blocks en terminologie CUDA). La terminologie change légèrement mais cela reste transparent pour quiconque étant familier du GPU. Quelques qualificatifs mémoire sont en revanche trompeurs; la table suivante donne l’équivalence de quelques-uns des principaux mots-clefs :

Terminologie OpenCL Terminologie CUDA
Workitem Thread
Workgroup Thread block
Compute unit SMP (streaming multi-processor)
Global / constant memory Global / constant memory
Local memory Shared memory
Private memory Local memory

Un changement notable concerne le paramétrage et le lancement du kernel OpenCL, écrit dans un fichier .cl, mais non-compilé à part, à l'instar des .cu en CUDA compilés par nvcc. Le développeur doit indiquer dans le code host le nom du fichier .cl désiré, ainsi que le nom du kernel à utiliser à l'intérieur. Ce kernel ne sera pas compilé avec le reste du code, car il est considéré en tant que chaine de caractères : toute erreur dans celui-ci n'apparait qu'au runtime. Enfin, les arguments d'un kernels doivent être définis un par un à l'aide d'une instruction déterminant leur valeur et position dans la liste des arguments.

Côté device

En revanche, côté device, la transition CUDA vers OpenCL est très simple. On retrouve de manière transparente les qualificateurs désignant les différents types de mémoire (globale, read-only, partagée...) et les fonctions permettant de récupérer la position locale ou globale d'un workitem sont encore plus simples que celles proposées par CUDA. Il est très rapide d'avoir un équivalent OpenCL d'un kernel CUDA, en supposant que celui-ci n'utilise pas de fonctionnalités avancées propres à l'architecture ou aux développements NVIDIA (dynamic parallelism, etc). Sans précédente expérience et avec l'aide d'une fiche récapitulative des mots-clefs du langage, l'effort se quantifie en minutes.

Au final, si on connaît déjà les différences principales avec CUDA et qu'on repart d'un exemple OpenCL existant, on peut en quelques heures (principalement passées côté host) accoucher d'une application fonctionnelle GPU OpenCL dès son premier essai.

Et enfin vers le FPGA

Cette transition implique des modifications côté host et device ; mais cette fois-ci c’est le kernel OpenCL qui va demander le plus d’expérimentations, de temps de réflexion et surtout de compilation. Plusieurs heures vont être nécessaires pour produire une première application FPGA fonctionnelle: on laisse de côté ici la partie optimisation.

Côté device

Le kernel OpenCL va être compilé à part par le compilateur aoc du SDK d’Altera (enfin Intel, quoi). Ce compilateur fournit un gros travail d’optimisation et de traduction vers du hardware description langage (HDL) afin de produire le design qui sera utilisé lors du run de l’application: un binaire .aocx pouvant peser ~100 Mo ou plus. Cette phase peut prendre plusieurs heures et s’avérer être un goulot d’étranglement du processus de développement ou d'optimisation. En effet, le design étant généré à partir de ce seul fichier, tout changement dans celui-ci implique la nécessité d’une recompilation et d’une génération d’un nouveau design. On notera que cette compilation se fait donc dans l'ignorance du reste du code host, et bien sûr des paramètres runtime.

Le travail à fournir sur le kernel OpenCL se situe à deux niveaux :
- L’ajout de pragmas et attributs simples permettant d’indiquer les tailles / dimensions des workgroups, et puis éventuellement le nombre de pipelines à répliquer, le déroulage de boucles et la taille des opérations vectorielles si on parle déjà de performance.
- Les changements à apporter au code initial, afin de mettre en évidence les possibilités de vectorisation au compilateur, ou revenir à un workgroup composé d’un unique workitem (faisant l’équivalent du travail d’un workgroup entier, avec un retour aux boucles).

Le premier point consiste en un travail très succinct à effectuer, mais la quantité de combinaisons de paramètres est grande et chacune nécessite une compilation d’un nouveau binaire, coûteuse en temps. A terme, pour la recherche d’une bonne performance, il se cache une réflexion profonde sur l’utilisation du hardware en termes d’utilisation de ses ressources ("l'occupation") et de performance des pipelines d'instructions.

Les pragmas __attribute__ insérés avant le kernel spécifient, respectivement :
- Le nombre de pipelines générés, désignés par le terme générique OpenCL compute units
- La taille des opérations vectorielles (ici 16 éléments)
- La taille du workgroup requise (ici, sa 1ère dimension vaut 16, et les deux autres 1)

Les pragmas de déroulage de boucle sont en revanche à insérer dans le code, et peuvent préciser le facteur de déroulage (pouvant être 1 si on veut absolument empêcher le compilateur de dérouler quoi que ce soit). Leur efficacité et paramétrage peut faire l’objet d’une étude en soi.

On comprend alors bien que tout changement dans ces précisions peut générer un design très différent du précédent et requiert donc une nouvelle compilation, qui sera décrite dans la section suivante avec plus de précision.

Pas si simple dans le fond ...

La seconde partie nécessite une certaine compréhension de l’architecture FPGA, particulièrement la manière dont sont ordonnancés et exécutés les workgroups/workitems et met en évidence le fait qu’un kernel adapté au GPU n’est pas toujours adapté au FPGA, en l’état.

Pour des applications non « embarassingly parallel » exhibant des dépendances de données ou des synchronisations entre workitems, il faudra sans doute repasser (temporairement ou non) à des kernel « single workitem ». C’est-à-dire un retour aux boucles pour générer un pipeline sans dépendances de données à l’intérieur d’un workgroup : c’est concrètement ramener la granularité d’une application du workitem au workgroup, pour tirer le meilleur du pipeline.

Pour les design multi-workitem avec opérations vectorielles (SIMD), le problème est plutôt de faire comprendre au compilateur qu'il peut vectoriser certaines opérations. En effet, le design est compilé dans l'ignorance du reste du code host (puisque compilé à part par aoc) et des paramètres runtime. Contrairement à un code CPU où par exemples des boucles de calculs seraient versionnées et qui utiliserait à chaque fois la "bonne" au runtime, le design FPGA lui est associé à un pipeline figé le temps de l'exécution. Donc il va flaloir le convaincre que vos workitems / threads exécutent bien tous les mêmes instructions, que vos boucles de travail tombent juste...

Côté host

La quantité de travail est négligeable, grâce à la généricité du langage OpenCL. Le FPGA est un device comme un autre et il suffit de passer le chemin et nom du design (binaire .aocx) à appeler lors de la création des variables OpenCL, au lieu du chemin du fichier .cl comme anciennement fait dans l’application GPU. En revanche, il faudra vérifier que le contexte d’exécution du kernel (tailles des workgroups) corresponde bien à ce qui a été spécifié dans le binaire .aocx lié au kernel à utiliser.

Pour passer au FPGA, la grande majorité du travail se situe côté device. Cela implique de penser son application en termes de pipeline pour lui trouver une formulation appropriée. Le retour au single workitem propose une solution par défaut, assez simple à implémenter, consistant à revenir aux boucles. La recherche d’un kernel vectorisé correct sur une application non-triviale est plus complexe et peut se voir comme étant déjà un pas vers l’optimisation de design.L’ajout de pragmas et lignes d’attributs au code OpenCL pour caractériser le design est extrêmement rapide, mais sa (ses) compilation(s) prennent plusieurs heures. Enfin, les modifications côté host sont très peu nombreuses, figées et réutilisables : elles se quantifient en minutes.
Nous poursuivrons dans le prochain billet avec une présentation des outils de développement fournis avec le SDK d'Altera, en prémices d'un cas pratique du portage d'une application vers le FPGA.