Python est un langage extrêmement pratique car il est facile à lire et à écrire, comparé à un langage de "bas niveau" et compilé comme le C. D'un autre côté, à l'exécution il est beaucoup plus lent. C'est un compromis entre les deux qu'offre Cython, permettant d'accélérer votre programme d'un facteur 2 à plus de 100, et d'intégrer facilement des fonctions déjà écrites en C — d'où son nom.
Un très bon exemple d'utilisation de Cython en bioinformatique est la librairie "pysam", le wrapper Python de samtools (code source ici).
Cython est en fait une extension du langage Python, c'est-à-dire que toute commande Python (ou presque) est valide en Cython — mais pas l'inverse, même si on verra qu'il y a moyen de ne pas toucher du tout au script original. En pratique, ça signifie que d'habitude vous ne pouvez plus utiliser l'interpréteur ("python monscript.py" vous répondra gentiment "Syntax Error") ; en contrepartie, vous obtenez une traduction/extension en C qui fait la même chose mais beaucoup plus vite. C'est donc une étape que l'on franchit seulement quand le programme fonctionne déjà bien et qu'on désire l'optimiser.
Soyons honnêtes : passer à Cython ne va pas faire tourner tous vos programmes 100 fois plus vite. Ca dépend largement de la structure du problème — de préférence ceux avec des calculs complexes et des boucles répétées un grand nombre de fois — et du degré de précision qu'on décide d'y ajouter. Il ne s'agit pas non plus de transformer tout votre script, mais seulement une ou deux fonctions qui font le "goulot d'étranglement" au niveau de la vitesse d'exécution. Mais après un petit nombre d'ajouts bien placés, on quadruple déjà facilement.
Sa documentation officielle se trouve ici — en anglais. Je la trouve assez mauvaise, c'est pourquoi le forum officiel est d'une grande aide. C'est aussi pourquoi j'ai écrit ce guide. Il n'est pas difficile de trouver des "articles découverte" qui présentent Cython sur le web ; ici c'est un mode d'emploi relativement complet que je vous propose.
L'installation se fait comme d'habitude pour une librairie Python : "easy_install cython" ou "pip install cython".
Table des matières :
- Pour vous convaincre (ou non)
- La version minimale
- Passons à Cython
- Le mode "pur Python"
- Intégrer du C dans Python, ou l'inverse
- Complément et conseils
Pour vous convaincre (ou non)
Pour évaluer le gain apporté par Cython dans différentes situations, voici quatre tests pour chacun desquels j'ai comparé le temps d'exécution par Python, Cython compilé tel quel sans toucher le code (cf. section suivante), et Cython avec spécification des types des variables.
Le premier exécute print "Bonjour"
un grand nombre de fois. Le deuxième lit ligne par ligne l'annotation du génome de C.Elegans, et compte le nombre de paires de bases codantes. Le troisième est une triple boucle "for" avec des opérations mathématiques simples. Le quatrième est un alignement de deux séquences avec l'algorithme de Smith-Waterman (et utilise Numpy). Voilà une illustration des temps relatifs :
Pour commenter ce graphe, commençons par regarder la barre verte qui montre qu'on peut obtenir un gain significatif sans rien faire d'autre que compiler le code original. Des tests 3 et 4 comparés au 1, on réalise que le typage fixe n'a beaucoup d'effet que sur les calculs numériques. Le deuxième montre bien que le langage n'a aucun effet sur les temps d'accès en lecture du disque dur, responsables des trois quarts du temps mesuré. Le test 4 illustre comment Cython est optimisé pour Numpy. Dans les tests 3 et 4 le ratio entre Python et Cython est de l'ordre de 1000000:1.
Les scripts utilisés sont disponibles ici. La doc officielle donne aussi d'autres exemples comme le calcul des nombres premiers, la suite de fibonacci ou l'intégration numérique.
La version minimale
La manière la plus simple d'accélérer sans effort un script Python, c'est de le donner tel quel à Cython. Rien à modifier donc, et un gain de vitesse d'environ 20–50% à l'exécution.
C'est aussi utile pour comprendre le fonctionnement de Cython. Il va d'abord traduire votre code en C, puis le compiler et le "linker". A partir de "script.py", vous obtenez donc un "script.c" (la traduction) et un "script.so" : une collection de fonctions déjà compilées et importables.
Voici comment faire, partant d'un fichier "script.py" au contenu très simple :
1 2 |
def mafonction(): print "Bonjour!" |
1. Dans le même dossier que "script.py", créer un "setup.py" avec le contenu suivant :
1 2 3 4 5 6 7 8 9 10 11 12 13 |
from distutils.core import setup from distutils.extension import Extension from Cython.Build import cythonize from Cython.Distutils import build_ext extensions = [ Extension("script", ["script.py"]) # à renommer selon les besoins ] setup( cmdclass = {'build_ext':build_ext}, ext_modules = cythonize(extensions), ) |
2. En ligne de commande, tapez
1 |
python setup.py build_ext –inplace |
ce qui va créer le .c et le .so.
3. Dans une console Python ou un nouveau script, importez et exécutez les fonctions fraîchement compilées (présentes dans le .so) :
1 2 3 |
from script import mafonction mafonction() |
Et voilà, il vous répond "Bonjour!" (mais en principe plus vite). Avec une telle fonction bien sûr on ne va pas très loin… Testez plutôt avec vos propres programmes.
Remarques :
* Comme les objets "cdef" du .pyx ne sont plus importables, ce sont bien celles du .so qui sont importées dans "main.py", et le "namespace" est le même.
* Il peut y avoir de nombreux "warnings" lors de la compilation ; on peut ordonner au compilateur de les ignorer en donnant au compilateur les options suivantes (à coller dans le shell ou dans le .bashrc ou équivalent) :
export CFLAGS="-Qunused-arguments ‑Wno-unused-function ‑Wno-unused-variable"
* Pour les utilisateurs de OSX Mavericks, sachez que le Xcode5 de ce dernier est buggé (gcc remplacé par clang qui traite certains avertissements bénins comme des erreurs). Il faut ajouter l'option suivante :
export ARCHFLAGS=-Wno-error=unused-command-line-argument-hard-error-in-future ;
* Pour les expérimentés en C, on peut aussi lancer simplement "cython script.py" et ensuite compiler/linker soi-même à partir du "script.c" obtenu.
Passons à Cython
Motivés par la promesse d'obtenir un gain de vitesse de l'ordre de 50–100x au lieu des 1.2x obtenus ci-dessus, intéressons-nous à introduire Cython dans notre code. A partir de là, il est quand même recommandé de savoir vaguement comment C fonctionne, et ce qu'est un compilateur.
Un programme contenant des directives propres à Cython prendra l'extension ".pyx", pour bien montrer que ce n'est plus du Python et qu'il est inutile de lancer l'interpréteur dessus. Pensez à le renommer aussi dans "setup.py".
Précisons à nouveau qu'il est inutile d'appliquer cette syntaxe à tout votre code, mais qu'on la réservera aux parties exécutées de nombreuses fois, comme des boucles internes, et aux calculs mathématiques complexes. Vous ne gagnerez rien à parser les options de ligne de commande en Cython par exemple, mais beaucoup là où se trouvent les 2000 produits de matrices. Toute syntaxe Python étant valide en Cython, vous pouvez laisser une grande partie du code inchangé.
Typage fixe
Avant tout il faut comprendre que si Python est relativement lent, c'est parce que son interpréteur — le truc qui transforme à la volée votre code en information binaire pour le processeur -, doit deviner tout un tas de choses que dans d'autres langages on force l'utilisateur à spécifier, et surtout le type des variables. Prenons l'exemple d'une fonction f(x,y)->x+y. Si on spécifie que x et y sont des entiers, l'interpréteur saura 1) qu'on peut les additionner sans problème et 2) que le résultat est aussi un entier. Sinon, il devra aller vérifier toutes ces choses à chaque addition — et annoncer une erreur le cas échéant. De plus, si on connaît le type d'une variable on peut lui allouer une taille fixe de mémoire ; sinon il faut en réserver beaucoup plus "au cas où", et éventuellement l'étendre encore par la suite, d'où une perte d'efficacité.
Donc ce qui peut beaucoup accélérer un programme, c'est de spécifier le type des variables ("static typing", ou "typage fixe"), ce que Cython permet de faire.
Prenons un exemple de code dans script.pyx :
1 2 3 |
def mafonction(a, b): c = a - 8 return a + b + c |
On peut spécifier les types en passant à
1 2 3 4 |
cpdef int mafonction(int a,int b): cdef int c c = a - 8 return a + b + c |
On a spécifié au compilateur que les arguments a,b, la variable locale c et la valeur de retour de la fonction sont des entiers, ce qui permettra au programme de s'exécuter plus rapidement sans se poser de questions. Par contre, à présent interdit de donner autre chose que des entiers à mafonction ! Notez qu'aucune des déclarations de type n'est nécessaire, et si on les enlève le code considérera les variables non typées comme des "objets" dynamiques, ce qui laisse beaucoup de flexibilité comparé à du C.
On remarque aussi l'utilisation des mots-clés cdef
et cpdef
pour définir des types (C). On les étudiera ci-dessous.
Si vous reprenez le "setup.py" de tout à l'heure en remplaçant "script.py" par "script.pyx" dans les Extensions, vous pouvez de nouveau le compiler et importer mafonction dans Python.
Déclarations de types C : "cdef"
Le mot-clé cdef
dit au compilateur : "voici une variable C de ce <type>", "voici une fonction C" ou "une structure C", etc.
cdef
comprend tous les types C :
char, short, int, long, longlong, uchar, ushort, uint, ulong, ulonglong,
les pointeurs :
p_int, pp_int, etc.,
les valeurs booléennes :
bint,
et tous les types de base de Python (qu'il convertit) :
list, dict, tuple, str, etc.
y compris classes et fonctions.
Tout ce qu'on "cdef-init" accélérera le programme, mais ne sera plus accessible par Python. Par exemple,
1 2 |
cdef int mafonction(int a,int b): ... |
deviendra une fonction C, plus performante, mais il sera impossible à un autre script Python de demander import mafonction
. Pour pallier ce manque, il est possible de déclarer une variable ou fonction avec cpdef
, ce qui créera à la fois une version C (mais légèrement moins performante) et un wrapper Python qu'on peut importer :
1 2 |
cpdef int mafonction(int a,int b): ... |
Pour les classes, on utilise :
1 2 3 4 5 6 7 8 9 10 |
cdef class Maclasse : cdef public int a cdef int b cdef double c def __init__(self, a, b=3): self.a = a self.b = b cpdef mamethode(self): c = 3.1 print c / float(self.b) |
Ici beaucoup de choses : d'abord on déclare la classe avec cdef class
. Ensuite, les attributs de classe sont introduits soit avec cdef public
si on veut pouvoir modifier l'attribut par la suite, soit avec cdef readonly
pour pouvoir seulement lire la valeur de l'attribut, soit juste cdef
ce qui rend la variable propre aux calculs internes à la classe. Par exemple ici, une instance M de Maclasse aura un attribut accessible M.a, mais pas de M.b, utilisé seulement au sein de la classe tout comme c.
Pour rendre mamethode importable depuis Python, on la déclare avec cpdef
. Si elle est utilisée uniquement en interne (appelée seulement à l'intérieur du même script.pyx — ou dans du code C), on peut laisser un simple cdef
. Curieusement, les classes sont toujours cdef
mais toujours accessibles, on précise seulement pour leurs méthodes et variables.
On a aussi dû changer le type de self.b
pour la division en ajoutant float()
, car on ne peut diviser un nombre à virgule que par autre un nombre à virgule. C'est là que les contraintes apparaissent… mais toujours moins qu'en C.
Le constructeur __init__
enfin, contrairement à la méthode mamethode
, est déclaré avec def
et pas cdef
, comme toutes les méthodes spéciales (avec __ autour). Il existe par contre une méthode __cinit__
particulière à Cython sur laquelle je ne m'étendrai pas ici. Inutile au passage de re-spécifier le type des arguments dans la déclaration de __init__
.
Pour aller plus loin
Il existe encore de nombreuses options pour accélérer votre code. Des méthodes spéciales comme __cinit__
, l'utilisation des malloc
, calloc
pour l'allocation de mémoire, le support excellent de numpy, le support du parallélisme, l'utilisation des pointeurs, les directives au compilateur (par exemple ne pas vérifier si le dénominateur est nul avant une division, ou si un indice peut dépasser la taille d'une liste), etc. Ce dernier point en particulier peut avoir un impact important, mais il faut être bien sûr de ce qu'on fait ! Si vous êtes sûr de vous, ajoutez par exemple tout au sommet de votre programme les commandes
1 2 3 |
#cython : wraparound=False #cython : boundscheck=False #cython : cdivision=True |
Enfin on obtient des performances optimales en important directement des fonctions C ou C++ (voir plus bas). C'est ce que fait pysam par exemple, samtools étant une librairie C.
Le mode "pur Python"
Il existe aussi la possibilité d'appliquer le typage fixe sans toucher du tout au script d'origine, ou tout du moins en le gardant lisible par l'interpréteur. Les deux possibilités sont d'augmenter le Python avec un fichier externe (.pxd) ou d'utiliser des commandes qui seront lues par le compilateur C mais ignorées par l'interpréteur.
Ce mode n'est pas véritablement recommandé. D'une part il se restreint au typage fixe, ce qui passe à côté de certaines possibilités du langage. D'autre part ce qui se passe en arrière-plan est assez flou — difficile de savoir si ce que vous avez rajouté dans le .pxd a bien été pris en compte. Mais on peut avoir de bonnes raisons de l'utiliser, comme la facilité de test liée à l'interpréteur, la collaboration avec d'autres développeurs Python, un boss qui ne veut rien savoir, etc.
Les fichiers .pxd
En utilisant un fichier d'augmentation .pxd, on laisse le programme Python original intact. D'un autre côté, il faut alors maintenir les deux fichiers en parallèle.
Si un fichier .pxd se trouve au même endroit qu'un fichier .py du même nom, le compilateur va chercher les définitions de type "cdef" du premier et convertir les classes/fonctions/méthodes correspondantes dans le .py.
Donc si on a un fichier "A.py" avec
1 2 3 4 5 6 7 8 9 10 |
def mafonction(x, y=2): a = x-y return a + x * y class A : def __init__(self, b=0): self.a = 3 self.b = b def foo(self, x): print x + 1.0 |
et un "A.pxd" avec
1 2 3 4 5 |
cpdef int mafonction(int x,int y=*) cdef class A : cdef public int a,b cpdef foo(self, double x) |
au moment de la compilation ce sera équivalent à
1 2 3 4 5 6 7 8 9 10 11 |
cpdef int mafonction(int x,int y): a = x-y return a + x * y cdef class A : cdef public int a,b def __init__(self, b=0): self.a = 3 self.b = b cpdef foo(self, double x): print x + 1.0 |
tout en laissant la possibilité de lancer "python A.py" comme avant.
Pour résumer ce qu'on voit en exemple, on note que pour que ça marche,
- Les signatures de fonctions doivent être déclarées avec
cpdef
::
1 |
cpdef int mafonction(int x,int y) |
- Les (re-)définitions de fonctions doivent être déclarées avec
cpdef inline
::
1 2 |
cpdef inline int mafonction(int x,int y):<br> pass |
- Les classes
cdef
doivent être déclarées aveccdef class
; - Les attributs de classe
cdef
doivent être déclarés aveccdef public
; - Les méthodes de classes
cdef
doivent être déclarées aveccpdef
. - Les arguments par défaut dans le .pxd sont spécifiés avec
<var>=*
, comme dans
cdef int mafonction(x, y=*).
Remarques :
* On ne peut pas fixer le type des variables locales des fonctions (comme dans "myfunction" de l'exemple ci-dessus). Pour ça il faudra utiliser l'"attribut magique"
@locals
— voir plus loin.* On ne peut pas augmenter des fonctions utilisant
*args
ou**kwargs
dans le .pxd.* Le script Python doit garder l'extension .py, et pas .pyx. S'il voit un .pyx il va se comporter différemment — en fait de la façon normale, et ici ce serait plutôt un hack.
Les "attributs magiques" du module cython
On peut aussi utiliser, uniquement ou en complément, des fonctions et décorateurs spéciaux disponibles avec le module cython
. Ces directives sont ignorées par l'interpréteur, mais pas par le compilateur. On peut donc les utiliser directement dans le .py, bien que cela ajoute évidemment une dépendance au module. On peut aussi les introduire dans le .pxd si on a choisi cette voie.
On commence par importer le module :
1 |
import cython |
1. Le switch compiled
:
C'est une variable spéciale qui vaut True
quand le compilateur tourne, et False
quand c'est l'interpréteur :
1 2 3 4 |
if cython.compiled : print("Je suis compilé en C.") else : print("Je suis un bête script Python.") |
2. Typage fixe :
cython.declare
remplace une déclaration du typecdef <type> <var> [= <valeur>]
:
1 |
cython.declare(x=cython.int, y=cython.double) # -> cdef int x ; cdef double y |
On peut aussi l'utiliser pour typer les constructeurs de classes (en particulier dans un .pxd) :
1 2 3 4 5 |
class A : cython.declare(a=cython.int, b=cython.int) def __init__(self, b=0): self.a = 3 self.b = b |
cython.locals
est un décorateur servant à typer des variables locales au corps d'une fonction.cython.returns
spécifie le type retourné par une fonction.
Il existe encore cython.cclass
, cython.cfunc
, cython.ccall
pour déclarer respectivement une classe "cdef", une fonction "cdef", une fonction "cpdef"
Voilà un exemple où on utilise un peu de tout :
1 2 3 4 5 6 |
@cython.cfunc @cython.returns(cython.bint) @cython.locals(a=cython.int, b=cython.int) def compare(a,b): return a == b |
Intégrer du C dans Python, ou l'inverse
Cython permet aussi d'interfacer du code C et du code Python de manière relativement simple. Il supporte aussi complètement le C++.
Du C dans Python
On peut importer des fonctions C directement dans un module Cython. Voici un exemple. Disons que vos fonctions C sont dans "external.h" :
1 2 3 4 5 6 |
#include <stdio.h> #include <stdlib.h> double fonctionC(double a, double b){ return a*b ; } |
Dans un "script.pyx", on redéclare la fonction comme suit :
1 2 |
cdef extern from "external.h": double fonctionC(double a, double b) |
Après compilation (qui aura créé un "script.so" contenant un wrapper de la fonction), vous pourrez importer fonctionC comme n'importe quelle autre fonction Python :
1 2 3 |
import script print fonctionC(21,24) |
Du Python dans C
Si vous avez déjà un programme en C et que vous voulez lui ajouter une fonctionnalité déjà implémentée en Python, c'est possible aussi puisque la compilation à travers Cython produit directement du code source C.
Partons cette fois de "script.pyx" contenant :
1 2 |
cdef public mafonction(double a,double b): return a*b |
On notera le mot-clé public
qui rend la fonction accessible à l'import (crée un "script.h"). Maintenant pour l'utiliser dans un programme C "main.c" (il ne doit pas avoir le même nom puisque Cython crée déjà "script.c" !), on utilise cette structure :
1 2 3 4 5 6 7 8 9 10 11 12 |
#include <stdio.h> #include <stdlib.h> #include <Python.h> #include "script.h" // wrapper Cython de vos fonctions int main(){ Py_Initialize(); result mafonction(345, 765); printf("Resultat : %i\n", result); Py_Finalize(); } |
On voit qu'on a besoin de "Python.h" qui est fourni avec Cython. Pour le trouver, tapez "locate Python.h" dans une console ; vous pouvez alors soit le copier à l'endroit de votre script, soit spécifier au compilateur où le trouver (avec le flag "-I").
Honnêtement pour moi la compilation est toujours une angoisse, tout comme le C d'ailleurs, et je n'ai jamais réussi à faire fonctionner cette partie. Mais si vous vous y connaissez un peu plus, ça devrait tourner sans problème.
Complément et conseils
Il existe des alternatives à Cython, notamment PyPy et Numba pour l'accélération, weave pour l'interfaçage avec C. Plus généralement, le langage Julia, encore en développement, est censé résoudre complètement le problème dans un futur proche.
Pensez aussi que nombreuses librairies possèdent déjà des parties écrites en C ou en Fortran, utilisent déjà Cython, etc. En particulier, Numpy et Scipy sont déjà très rapides.
En ce qui concerne la bioinfo, ce qui ralentit souvent les programmes sont les multiples accès au disque lors de la lecture/écriture de fichiers, et ça, le langage quel qu'il soit n'y peut rien. Il ne peut accélérer que les calculs (côté processeur).
Pour terminer, rappelons quelques bons conseils pour l'optimisation : 1. Commencez par produire un programme qui fonctionne ; seulement ensuite pensez à l'accélérer. 2. Ce sont plus probablement les défauts de votre algorithme qui ralentissent le programme, que le choix d'un langage plus lent. Quand 1. et 2. sont résolus, passez à Cython !
Merci à Yoann M., bilouweb, nahoy et Nonore pour leur relecture.
Laisser un commentaire