Copperhead : un Python parallélisé
By   |  September 14, 2013

Allier la productivité de Python et l’efficacité de la programmation parallèle sur GPU, telle est la vocation de Copperhead. Compte tenu de l’intérêt que suscite l’un et l’autre chez les scientifiques programmeurs, un petit tour de piste s’impose…

Brian Catanzaro
Research Scientist, NVIDIA

Les langages tels que C et Fortran ont un gros avantage : ils permettent de tirer le maximum des ressources matérielles sous-jacentes. En contrepartie, ils nécessitent une bonne connaissance de ces ressources, notamment dans leur architecture de bas niveau. Orientés résultats, ils sont irremplaçables lorsque la performance prime.

Mais les choses changent. Plutôt orientés productivité, les langages de plus haut niveau tels que Python ou Ruby peuvent afficher des performances élevées en calcul intensif lorsqu’on les utilise intelligemment. Ce faisant, ils libèrent le programmeur de contraintes techniques liées à la parallélisation – en tous cas au niveau matériel – et produisent des codes qui peuvent souvent être réutilisés sur des plateformes différentes.

C’est l’objectif de Copperhead Copperhead, un projet expérimental de NVIDIA Research, que d’offrir à chacun ces multiples avantages. Les codes Copperhead permettent d’utiliser des fonctions primitives connues telle que map et reduce, et s’exécutent en parallèle sur les GPU CUDA et les CPU multicœurs. D’où l’attention que nous lui portons ce mois-ci…

axpy, le Hello World parallèle

Plongeons tout de suite au cœur du sujet, et commençons par le commencement ! Le listing 1 montre une version Copperhead d’axpy, la forme générique de saxpy (“Single-Precision A.X Plus Y”), qui est un peu le Hello World des programmes parallèles. On le voit, les fonctions Copperhead sont intégrées à des programmes Python. Elles n’utilisent d’ailleurs qu’un sous-ensemble bien délimité de ses instructions. Pour marquer ces fonctions, on utilise tout simplement le décorateur Python @cu.

A l’exécution, les programmes se déroulent normalement, jusqu’à ce que l’interpréteur Python rencontre un premier appel à une fonction décorée Copperhead. Là, l’exécution transite par le runtime Copperhead puis retourne à l’interpréteur Python, en reprenant au point d’entrée de la fonction d’origine. Les données sont alors ramassées par le garbage collector standard de Python.

Quand on regard le corps de notre programme axpy, on voit qu’une opération par éléments est lancée sur les deux tableaux d’entrée, via une comprehension list. C’est elle qui sera exécutée en parallèle. Notez qu’on aurait également pu coder axpy en utilisant map et une fonction anonyme lambda, comme au listing 2, ou une fonction imbriquée nommée, comme au listing 3. Les trois implémentations fonctionnent de la même façon et donnent les mêmes résultats. Libre au programmeur d’utiliser celle qu’il préfère.

Le map de Python exécute les opérations élément par élément sur les séquences, en passant les éléments des séquences d’entrée à la fonction invoquée. Que se passe-t-il pour les données auxiliaires qui ne sont pas traitées par élément, mais qui doivent être utilisées dans un calcul par éléments ? Les programmes Copperhead utilisent des protections pour pouvoir diffuser ces données ; dans notre exemple, vous pouvez observer que la fonction par éléments utilise la scalaire a de portée parente. C’est une pratique idiomatique courante en Python, et c’est de cette façon que les programmes Copperhead transmettent les données aux opérations par éléments.

Autre point intéressant dans cet exemple, l’interaction avec l’incontournable bibliothèque numpy pour la constitution de tableaux homogènes et bien typés. axpy est appelé avec des valeurs 64-bit en virgule flottante (ce qui le transformant en daxpy), dans la mesure où c’est le type par défaut pour les littéraux fp de Python (a = 2.0) mais également pour les tableaux aléatoires qui utilisent numpy.random.rand. Pour appeler ce programme avec des valeurs fp 32-bit (ce qui en fait un saxpy) il faut utiliser les mécanismes numpy standards comme au listing 4.

Enfin, remarquez que la fonction ne contient pas de boucle. En principe, Copperhead ne supporte pas les effets de bord comme les modification de valeur des variables. C’est une restriction importante, qui a pour avantage de sécuriser la parallélisation des programmes. Ainsi, par exemple, le listing 5 montre une implémentation de axpy dans laquelle une boucle écrase une des données en entrée.

Ce style de codage n’a pas cours avec Copperhead. Sécuriser les boucles de parallélisation telles que celle-ci est pose donc un problème, sachant que certaines boucles ne peuvent tout simplement pas être parallélisées. Alors, plutôt que des boucles, les codes Copperhead utilisent des primitives parallèles conçues pour être toujours parallélisables. Le runtime peut ainsi planifier les calculs en fonction de la plateforme cible. Et pour les cas où une itération séquentielle est nécessaire, Copperhead passe par une classique récursion. Compte tenu de ce qui précède, tous les branchements vers Copperhead doivent se terminer par une instruction return.

Les primitives Copperhead

Les programmes Copperhead utilisent un jeu de primitives parallèles bien connues. Partout où cela est possible, ces primitives exposent les mêmes interfaces que les primitives natives de Python. Parmi les plus importantes, citons :

map(f, …) applique la fonction f sur un certain nombre de séquences. Son arité doit être égale au nombre d’arguments, et les séquences doivent avoir la même longueur. Pour des questions pratiques, cette arité est limitée à 10. Et pour le confort du programmeur, les list comprehensions sont traitées de façon équivalente à map. Les deux lignes du listing 6 sont par conséquent équivalentes.

reduce(fn, x, init) applique une fonction binaire fn aux éléments  de x de manière cumulative pour les réduire à une seule valeur. La fonction fn doit nécessairement être cumulative et commutative et, contrairement au reduce de Python, la sémantique de parallélisation ne garantit pas que les éléments seront réduits de gauche à droite.

filter(fn, x) retourne une séquence contenant les éléments de x pour lesquels fn(x) renvoie True. L’ordre des éléments de la séquence x est préservé.

gather(x, indices) retourne la séquence [x[i] for i in indices].

scatter(src, indices, dst) crée une copie de dst et la met à jour en envoyant chaque src[i] vers indices[i] de la copie. Si un indice quelconque est dupliqué, l’une des valeurs correspondantes de src sera choisie arbitrairement et placée dans le résultat. C’est alors la copie mise à jour qui est renvoyée.

scan(fn, x) retourne le scan inclusif (appelé également “prefix sum”) de fn sur la séquence x. Il en va de même pour rscan, exclusive_scan, et exclusive_rscan.

zip(…)retourne envoie une séquence de tuples. Toutes les séquences en entrée doivent avoir la même longueur.

unzip(x) retourne un tuple de séquences.

sort(fn, x) retourne une copie triée de x à partir d’un comparateur binaire fn(a,b) tel que (a < b) est  True.

indices(x) retourne une séquence contenant tous les indices des éléments contenus dans x.

replicate(x, n) retourne une séquence contenant n copies de x, où x est un scalaire.

range(n)
retourne une séquence contenant [0, n).

bounded_range(a, b) retourne une séquence contenant [a, b).


Copperhead et le typage

Les programmes Python se basent sur le duck-typing : si ça marche comme un canard, si ça nage comme un canard et si ça fait coin-coin comme un canard, alors c’est un canard. Les listes, par exemple, peuvent contenir des éléments de tous types. C’est plus facile à programmer mais l’introspection nécessaire ralentit significativement les codes. Une implémentation Python native de axpy nécessiterait que soient vérifiés tous les éléments des vecteurs d’entrée, ne serait-ce que pour savoir quel type d’addition leur appliquer. Pour contourner le problème, Copperhead utilise l’inférence de type. Le runtime applique un type-check à toutes les procédures marquées du décorateur @cu. Celles qui ne passent pas l’examen d’inférence sont rejetées.

En lui-même assez simple, le système de types de Copperhead se limite aux scalaires, aux tuples et aux séquences. Les classes et les autres types définis par l’utilisateur ne sont pas supportés. Ce système interagit avec la bibliothèque numpy largement utilisée par Python pour les calculs numériques. C’est elle qui fournit les types numériques et les tableaux typés de façon homogène. Ainsi, Copperhead utilise les cinq types scalaires suivants :

np.float32 : nombre à virgule flottante 32-bit

np.float64 : nombre à virgule flottante 64-bit

np.int32 : nombre entier 32-bit

np.int64 : nombre entier 64-bit

np.bool : Booléen

Copperhead interprète également les types en utilisant les conventions numpy :

bool est naturellement interprété comme un booléen

float est interprété comme un nombre à virgule flottante 64-bit

int est interprété comme un nombre entier. Point important, Copperhead n’implémente pas de type entier infini comme le int de Python.

En plus des cinq types scalaires de base, les programmes Copperhead peuvent utiliser les tuples de ces types, et ces tuples peuvent être imbriqués. Si Copperhead ne supporte pas les classes, les tuples peuvent servir de structures de données de nature différente. L’utilisation de tuples est même assez facile. Le listing 7 compare par exemple l’élément k de deux paires key-value, et retourne la paire ayant la plus petite clé. Notez que nous avons exprimé les tuples de deux façons différentes, pour montrer que ces deux notations sont supportées.

On accède aux éléments d’un tuple en le décompressant en plusieurs éléments, comme on le fait habituellement en Python.  Par exemple,

k0, v0 = kv0

décompresse kv0 en deux éléments. Cette décompression peut être effectuée avec un élément bind, comme dans le listing 6, ou dans les paramètres d’une fonction.

Les tuples se créent en assignant plusieurs éléments à une variable ou en retournant des éléments multiples. Ainsi,

kv0 = k0, v0

compresse deux éléments en un tuple, et

return x, y

retourne un tuple de deux éléments.

Contrairement à Python standard, Copperhead ne supporte pas l’accès dynamique aléatoire au tuples via l’opérateur [ ]. Les tuples étant typés de façon hétérogène, l’accès aléatoire nécessiterait des types dynamiques, non supportés par Copperhead.

En complément des scalaires et les tuples, les programmes Copperhead utilisent donc des séquences. Elles sont identiques aux listes Python, si ce n’est que, comme les tableaux numpy, elles doivent être typées de façon homogène. Ces séquences peuvent être indexées en utilisant l’opérateur [ ], mais uniquement en lecture. Les éléments ne peuvent être assignés via [ ] dans la mesure où donné que Copperhead ne supporte pas les effets de bord.

Lors des appels de fonction, les valeurs en entrée doivent êtres des types que le runtime Copperhead peut comprendre. Ces types sont les suivants :

– les 5 types scalaires de numpy, ainsi que les trois types scalaires de Python que nous avons décrits précédemment

– les listes Python typées de façon homogène, dans lesquelles chaque élément est soit un des types de scalaires décrits précédemment, soit un tuple de ces types

– des tableaux numpy unidimensionnels dont chaque élément est un type scalaire supporté

– les tuples des cinq types scalaires et des types de séquence

copperhead.cuarray. Ce type de séquence, renvoyé par les programmes Copperhead, gère la mémoire automatiquement dans les espaces mémoires hétérogènes, en conservant bande passante par transfert de données “lazy” (c’est-à-dire en différé).

C’est le runtime qui convertit tous les types de séquences en types copperhead.cuarray avant que soit appelées les fonctions Copperhead. Pourquoi ? Pour préparer les données à l’exécution GPU, si nécessaire. Bien que le runtime puisse utiliser les listes Python et les tableaux numpy, les conversions sont coûteuses si elles sont répétitives. Par conséquent, si les fonctions Copperhead sont appelées à partir d’une boucle depuis l’interpréteur Python, il est recommandé de construire explicitement les types copperhead.cuarray, pour éviter les conversions inutiles.

Sachez enfin que les littéraux Copperhead sont faiblement typés : un entier littéral sera de type np.int64, un réel de type np.float64, à moins qu’il ne soit utilisé dans un contexte de typage alternatif.

On peut utiliser des fonctions en tant que valeurs dans Copperhead, mais les lambdas ne peuvent actuellement pas sortir de la portée dans laquelle on les été définies. En pratique, cela revient à dire qu’un programme Copperhead ne peut accepter une fonction en tant qu’argument lorsqu’il est appelé à partir de Python, ni produire une fonction et la renvoyer à Python. Il se pourrait toutefois que cette restriction soit bientôt supprimée…

Un runtime intelligent

C’est au programmeur de déterminer si l’exécution aura lieu sur le CPU ou sur le GPU. Comment ? En utilisant les instructions with, comme au listing 7. A charge pour le runtime Copperhead de migrer les données en différé entre les espaces mémoires si nécessaire. Par exemple, lorsque l’on appelle une fonction Copperhead sur le GPU, le runtime vérifie que les données sont sur le GPU avant d’exécuter la fonction. En fin de course, le résultat se trouvera également en mémoire GPU. Si une fonction utilise ce résultat en entrée, et si elle est invoquée sur le même GPU, les données ne seront pas déplacées. Si elle est invoquée à un autre endroit, par exemple sur le CPU, les données seront déplacées vers le CPU avant que la fonction ne soit exécutée.

Les valeurs renvoyées par les procédures Copperhead sont des futures. Dans certains cas, l’interpréteur Python retrouve immédiatement le contrôle du programme cependant que le calcul est effectué en dehors de lui. Une synchronisation peut avoir lieu lors du déréférencement d’un objet copperhead.cuarray depuis l’interpréteur Python. Les appels successifs aux fonctions Copperhead attendent toujours que les appels précédents soient terminés avant de démarrer, pour préserver l’ordre logique du code.

Le runtime Copperhead invoque automatiquement les compilateurs pour créer des binaires des fonctions Copperhead. Ces binaires sont persistés en cache dans un répertoire __pycache__ qui contient les extensions Python pour toutes les fonctions Copperhead appelées à partir du module, ainsi que le code source C++/CUDA généré pour la fonction. Lorsqu’une fonction a été compilée et mise en cache, la latence de l’appel depuis l’interpréteur Python est généralement comprise entre 10 et 100 microsecondes, selon le nombre d’arguments.

Notez pour terminer que le compilateur Copperhead fusionne de façon intensive les opérations sur les primitives, afin d’optimiser autant que possible l’utilisation de la bande passante. Il génère du code Thrust pour implémenter les calculs, et la sortie du compilateur est disponible dans le répertoire __pycache__ après que le programme ait été exécuté.

De ce qui précède, il ressort que Copperhead allie élégamment la productivité de Python et l’efficacité de la programmation parallèle multiplateformes. Bâti sur Thrust, il libère le développeur des contraintes liées aux itérateurs complexes de C++. Les programmes Copperhead ne peuvent actuellement pas être utilisés sur des séquences unidimensionnelles, mais cette restriction pourrait elle aussi être bientôt levée par NVIDIA. Le sujet vous intéresse ? Copperhead vous paraît convaincant ? Faites-le nous savoir, et si tel est le cas, nous lui consacrerons un article avec des exemples applicatifs complets.

Bons développements !

© HPC Today 2020 - All rights reserved.

Thank you for reading HPC Today.

Express poll

Do you use multi-screen
visualization technologies?

Industry news

Brands / Products index