Découverte :
Cython: votre programme Python mais 100x plus vite

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.

Cython logo

Logo de Cython - http://cython.org

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 :

  1. Pour vous convaincre (ou non)
  2. La version minimale
  3. Passons à Cython
  4. Le mode "pur Python"
  5. Intégrer du C dans Python, ou l'inverse
  6. 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 :

benchmark_cython

Benchmark pour les 4 tests, relativement au temps d'exécution par Python. De gauche à droite : print "Bonjour", lecture de gros fichier, opérations mathématiques en boucle, alignement de séquences. En bleu : Python ; en vert : compilation avec Cython seul sans typage ; en orange : avec spécification des types des variables.

 

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. Dans le même dossier que "script.py", créer un "setup.py" avec le contenu suivant :

2. En ligne de commande, tapez

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) :

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 :

On peut spécifier les types en passant à

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,

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 :

Pour les classes, on utilise :

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

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

et un "A.pxd" avec

au moment de la compilation ce sera équivalent à

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::

  • Les (re-)définitions de fonctions doivent être déclarées avec cpdef inline::

  • Les classes cdef doivent être déclarées avec cdef class;
  • Les attributs de classe cdef doivent être déclarés avec cdef public;
  • Les méthodes de classescdef doivent être déclarées avec cpdef.
  • 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. Le switch compiled :
C'est une variable spéciale qui vaut True quand le compilateur tourne, et False quand c'est l'interpréteur :

2. Typage fixe :

  • cython.declare remplace une déclaration du type cdef <type> <var> [= <valeur>] :

On peut aussi l'utiliser pour typer les constructeurs de classes (en particulier dans un .pxd) :

  • 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 :

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" :

Dans un "script.pyx", on redéclare la fonction comme suit :

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 :

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 :

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 :

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.

 

  • À propos de
  • Bioinformaticien @ CHUV / UNIL
    Diplômé EPFL en mathématiques appliquées.
    Développement software, analyse de données génomiques.

9 commentaires sur “Cython: votre programme Python mais 100x plus vite

  1. Très intéressant ! Merci beaucoup !

  2. Une superbe présentation, très détaillée et très utile ! Merci !

  3. Excellente présentation, complète et claire (en tout cas plus que les exemples officiels) !
    J\'ai cependant un problème. Je dispose d\'un code en C que je souhaite intégrer dans une interface utilisateur codée en Python. Le code C (NN1_1_predict.c) comporte une fonction \"ComputePrediction\".

    J\'ai écrit les codes suivants :
    NN1_1_predict.h :

    #ifndef NN1_1_PREDICT_H
    #define NN1_1_PREDICT_H
    char* ComputePrediction(double*);
    #endif

    NN1_test.pyx :

    cdef extern from \"NN1_1_predict.h\":
    char* ComputePrediction(double*)

    setup.py :

    # avec setuptools plutot que distutils pour eviter l\'erreur \"Unable to find vcvarsall.bat\"
    from setuptools import setup
    from setuptools import Extension
    from Cython.Build import cythonize
    from Cython.Distutils import build_ext

    extensions = [Extension(\"NN1_test\",[\"NN1_test.pyx\"])]
    setup(cmdclass = {\'build_ext\':build_ext},ext_modules = cythonize(extensions))

    main.py :

    from NN1_test import ComputePrediction
    # suite du code

    Après avoir écrit \"python setup.py build_ext --inplace\" (aucun problème), lorsque j\'exécute \"python main.py\", j\'ai une erreur lors de l\'importation de la fonction, qui est introuvable : \"ImportError : cannot import name ComputePrediction\".

    D\'ailleurs si j\'importe directement \"import NN1_test\" (sans erreur) et que j\'exécute \"dir(NN1_test)\", la fonction \"ComputePrediction\" n\'apparaît pas.
    Si vous pouvez m\'expliquer comment résoudre mon problème ça serait génial, ça fait plusieurs heures que je suis dessus en vain ! Si j\'ai bien compris le fonctionnement de Cython, je pense que le problème vient de l\'utilisation du \"cdef\" plutôt que \"cpdef\" pour la création du wrapper python, mais intuilisable avec \"extern\".

    Merci d\'avance !

    • Je ne peux pas y répondre car je n\'ai jamais utilisé \"extern\", et cette question irait mieux sur StackOverflow. Mais \"cpdef\" sert à appeler la fonction depuis du code Python, alors que ce qui est \"cdef\" n\'est lisible que depuis une autre fonction Cython.

  4. Merci tout de même pour la réponse rapide. Je précise que j\'ai utilisé \"extern\" car c\'est ce que vous suggérez dans la partie du tutoriel intitulé \"Du C dans Python\". J\'ai d\'ailleurs le même problème en recopiant le code de cette partie :/

  5. Quelques jours plus tard, j\'ai réussi à résoudre mon problème, du coup je me permets de mentionner la solution, et de rectifier au passage une petite erreur dans le tutoriel (en tout cas avec ma configuration), si d\'autres utilisateurs rencontrent le même problème que moi.

    Dans la section 5, pour intégrer une fonction C dans Python, il faut ajouter \"cpdef\" dans la déclaration de la fonction (sinon elle n\'est pas lisible par Python), de la façon suivante :

    cdef extern from \"external.h\":
    cpdef double fonctionC(double a, double b)

    Et ensuite importer, par exemple dans un fichier \"test.py\" :

    import script
    print script.fonctionC

    (ou avec \"from script import fonctionC\" c\'est la même chose)

    Voilà, et bonne continuation !

  6. Merci bcp c\'est très intéressant.
    Par contre j\'ai essayé le test \"La version minimale\" ça ne marche pas quand je tape en ligne de commande, \"python setup.py build_ext --inplace\" il me affiche le message d\'erreur suivant:
    error:Unable to find vcvarsall.bat

    sachant que j\'ai la version python 3.5.1 avec MSC v.1900 on win32
    Merci c\'est vous avez idée sur ce type d\'erreurs et leurs solutions

    • Je ne connais rien du développement sous Windows sinon que c\'est courageux, mais Google répond ça : https://support.enthought.com/hc/en-us/articles/204469210-Windows-Unable-to-find-vcvarsall-bat-cython-other-c-extensions-, http://stackoverflow.com/questions/27488163/python-3-4-compile-cython-module-for-64-bit-windows, etc.

  7. Merci beaucoup

Laisser un commentaire