Introduction aux extensions Python avec CFFI


Ceci est un post invité de Realitix posté sous licence creative common 3.0 unported.

Préambule

Vous avez réalisé une analyse de votre code et vous avez un bottleneck ?
Vous souhaitez utiliser une bibliothèque bas niveau (C/C++/Rust) ?

Pas de problème, dans cet article, je vais vous expliquer les différentes solutions et pénétrer en profondeur dans la plus charmante d’entre elles, son petit nom: CFFI.

Sam&Max me faisant l’honneur d’accepter mon article, je vais suivre la guideline du site avec un langage détendu et beaucoup d’exemples.

Qu’est-ce qu’une extension Python ?

Guido, pendant l’acte créateur, n’a pas oublié une chose importante: les extensions Python !
Une extension Python est un module compilé pouvant être importé dans votre code Python.
Cette fonctionnalité est très puissante: cela vous permet d’utiliser un langage bas niveau (et toutes ses capacités) pour créer un module Python.
Vous utilisez très probablement des extensions Python dans vos projets sans le savoir.
Par exemple, si vous souhaitez embarquer la bibliothèque de calcul physique Bullet, vous pouvez le faire au sein d’une extension Python.

Il y a deux points à différencier:

  • Importer une bibliothèque tierce
  • Améliorer les performances de son code

En y réfléchissant bien, créer un code performant revient à créer une bibliothèque tierce et l’importer au sein de l’interpréteur.

Ça laisse rêveur, alors comment fait-on ?

Plusieurs solutions:

  1. L’API C de CPython. Y’en a qu’ont essayé, ils ont eu des problèmes…
    En utilisant cette méthode, vous aurez accès à tout l’interpréteur Python et vous pourrez tout faire… mais à quel prix ?
    Je ne vous recommande pas cette approche, je me suis cassé les dents pendant 4 mois dessus avec un succès mitigé.
  2. Cython est une bonne solution mais plus orienté sur l’optimisation de code.
  3. CFFI: le saint Graal, alléluia!

CFFI: Première mise en bouche

CFFI va vous permettre de créer des extensions Python mais pas que…
Tout comme le module ctypes, il va permettre d’importer une bibliothèque dynamique au runtime.
Si vous ne savez pas ce qu’est une bibliothèque, c’est par ici.

CFFI va donc vous permettre:

  • D’importer une bibliothèque dynamique au runtime comme ctypes mais avec une meilleure API -> Mode ABI -> Pas de compilation
  • De réaliser une extension Python compilée comme `cython` ou comme avec l’API C de CPYTHON -> Mode API -> Phase de compilation

Par rapport à ctypes, CFFI apporte une API pythonic et légère, l’API de ctypes étant lourde.
Par rapport à l’API C de CPython… Ha non! Je n’en parle même pas de celle-là!

J’ai dit qu’il y aurait beaucoup d’exemples, alors c’est parti !

D’abord, on installe le bouzin, il y a une dépendance système avec libffi, sur Ubuntu:

sudo apt-get install libffi-dev python3-dev

Sur Windows, libffi est embarquée dans CPython donc pas de soucis.
Ensuite, on conserve les bonnes habitudes avec le classique:

pip install cffi

Je vous conseille d’utiliser un virtualenv mais ce n’est pas le sujet!

Les trois modes

Il y a trois moyens d’utiliser CFFI, comprenez bien cela car c’est la partie tricky:

  1. Le mode ABI/Inline
  2. Le mode API/Out-of-line
  3. Le mode ABI/Out-of-line

On a déjà évoqué les modes ABI et API, mais je n’ai pas encore parlé de Inline et Out-of-line.
CFFI utilise une phase de “compilation” pour parser les header C. Ce n’est pas une vraie compilation mais une phase de traitement qui peut être lourde.
Le mode Inline signifie que ce traitement va être effectué à l’import du module alors que Out-of-line met en cache ce traitement à l’installation du module.

Evidemment, le mode API/Inline ne peut pas exister puisque le mode API impose une phase de “vraie” compilation.

Le mode ABI/Inline

# On commence par import le module cffi qui contient la classe de base FFI
from cffi import FFI
 
# 1 - On instancie l'object FFI, cet objet est la base de cffi
ffi = FFI()
 
# 2 - On appelle la méthode cdef.
# Cette méthode attend en paramètre un header C, c'est à dire
# les déclarations des fonctions C qui seront utilisées par la suite.
# CFFI ne connaîtra que ce qui a été déclaré dans le cdef.
# La puissance de CFFI réside dans cette fonction, à partir d'un header C,
# il va automatiquement créer un wrapper léger.
# A noter: le code dans cdef ne doit pas contenir de directive pré-processeur.
# Ici, on déclare la fonction printf appartenant au namespace C
ffi.cdef("""
    int printf(const char *format, ...);
""")
 
# 3 - On charge la bibliothèque dynamique
# dlopen va charger la biliothèque dynamique et la stocker dans la variable nommée cvar.
# L'argument passé est None, cela demande à cffi de charger le namespace C.
# On peut ici spécifier un fichier .so (Linux) ou .dll (Windows).
# Seul ce qui a été déclaré dans cdef sera accessible dans cvar.
cvar = ffi.dlopen(None)

Comme vous pouvez le voir dans ce bout de code, CFFI est très simple d’utilisation, il suffit de copier le header C pour avoir accès aux fonctions de la bibliothèque.
A noter: si les déclarations dans le cdef ne correspondent pas aux déclarations présentes dans la bibliothèque (au niveau ABI), vous obtiendrez une erreur de segmentation.

Le mode API/Out-of-line

Pour bien comprendre ce mode, nous allons implémenter la fonction factorielle.

# Comme pour le mode ABI, FFI est la classe principale
from cffi import FFI
 
# Par convention, en mode API, on appelle l'instance ffibuilder car le compilateur va être appelé
ffibuilder = FFI()
 
# En mode API, on utilise pas dlopen, mais la fonction set_source.
# Le premier argument est le nom du fichier C à générer, le 2e est le code source.
# Ce code source va être passé au compilateur, il peut donc contenir des directives pré-processeur.
# Dans l'exemple, je passe directement le code source mais en général, on va plutôt ouvrir le fichier avec open().
ffibuilder.set_source("_exemple", """
    long factorielle(int n) {
        long r = n;
        while(n > 1) {
            n -= 1;
            r *= n;
        }
        return r;
    }
""")
 
# Comme pour le mode ABI, on déclare notre fonction avec la méthode cdef.
ffibuilder.cdef("""
    long factorielle(int);
""")
 
 
# Enfin, on va appeler la méthode compile() qui génère l'extension en 2 étapes:
# 1 - Génération d'un fichier C contenant la magie CFFI et notre code C
# 2 - Compilation de ce fichier C en extension Python
if __name__ == "__main__":
    ffibuilder.compile(verbose=True)

Après éxécution du script, voici ce que l’on voit dans le terminal:

generating ./_exemple.c  -> Étape 1: Génération du fichier C
the current directory is '/home/realitix/test'
running build_ext
building '_exemple' extension  -> Étape 2: Génération de l'extension
x86_64-linux-gnu-gcc -pthread -DNDEBUG -g -fwrapv -O2 -Wall -Wstrict-prototypes -g -fdebug-prefix-map=/build/python3.6-sXpGnM/python3.6-3.6.3=. -specs=/usr/share/dpkg/no-pie-compile.specs -fstack-protector-strong -Wformat -Werror=format-security -Wdate-time -D_FORTIFY_SOURCE=2 -fPIC -I/home/realitix/venv/py36/include -I/usr/include/python3.6m -c _exemple.c -o ./_exemple.o
x86_64-linux-gnu-gcc -pthread -shared -Wl,-O1 -Wl,-Bsymbolic-functions -Wl,-Bsymbolic-functions -specs=/usr/share/dpkg/no-pie-link.specs -Wl,-z,relro -Wl,-Bsymbolic-functions -specs=/usr/share/dpkg/no-pie-link.specs -Wl,-z,relro -g -fdebug-prefix-map=/build/python3.6-sXpGnM/python3.6-3.6.3=. -specs=/usr/share/dpkg/no-pie-compile.specs -fstack-protector-strong -Wformat -Werror=format-security -Wdate-time -D_FORTIFY_SOURCE=2 ./_exemple.o -o ./_exemple.cpython-36m-x86_64-linux-gnu.so

Et vous pouvez trouver l’extension Python `_exemple.cpython-36m-x86_64-linux-gnu.so`.
Étudions le module généré, dans un interpréteur Python:

>>> from _exemple import ffi, lib
>>> dir(ffi)
['CData', 'CType', 'NULL', 'RTLD_DEEPBIND', 'RTLD_GLOBAL', 'RTLD_LAZY', 'RTLD_LOCAL', 'RTLD_NODELETE', 'RTLD_NOLOAD', 'RTLD_NOW', '__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'addressof', 'alignof', 'buffer', 'callback', 'cast', 'def_extern', 'dlclose', 'dlopen', 'errno', 'error', 'from_buffer', 'from_handle', 'gc', 'getctype', 'init_once', 'integer_const', 'list_types', 'memmove', 'new', 'new_allocator', 'new_handle', 'offsetof', 'sizeof', 'string', 'typeof', 'unpack']
>>> dir(lib)
['factorielle']

Les modules générés par CFFI contiennent 2 objets ffi et lib.

  • ffi: Les fonctions de l’API CFFI ainsi que les typedef et structs
  • lib: Toutes nos fonctions C, ici, il n’y a que factorielle

Ça vous dit d’utiliser notre extension avec un petit test de performance ? Allons y !

import time
from contextlib import contextmanager
 
# On import lib qui contient notre fonction factorielle
from _exemple import lib
 
# On créé l'équivalent de notre fonction C en Python
def py_factorielle(n):
    r = n
    while n > 1:
        n -= 1
        r *= n
    return r
 
# Un petit contextmanager pour mesurer le temps
@contextmanager
def mesure():
    try:
        debut = time.time()
        yield
    finally:
        fin = time.time() - debut
        print(f'Temps écoulé: {fin}')
 
def test():
    # On va réaliser un factorielle 25 un million de fois
    loop = 1000000
    rec = 25
    # Version Python
    with mesure():
        for _ in range(loop):
            r = py_factorielle(rec)
    # Version CFFI
    with mesure():
        for _ in range(loop):
            r = lib.factorielle(rec)
 
if __name__ == '__main__':
    test()

Le résultat sur ma machine:

Temps écoulé: 1.9101519584655762
Temps écoulé: 0.13172173500061035

La version CFFI est 14 fois plus rapide, pas mal !
CFFI permet de faire vraiment beaucoup de choses simplement, je ne vous ai montré que la surface afin de vous donner envie d’aller plus loin.

Où trouver des ressources

  • La doc de CFFI est vraiment bien, un bon readthedocs classique: cffi.readthedocs.io
  • Une de mes présentations à PyConAU, la version EuroPython est moins bonne: ICI
  • Mes projets CFFI:
    1. vulkan: Mode ABI ICI
    2. Pour les curieux, la version en utilisant l’API C de CPython ICI
    3. vulk-bare: Mode API, un module très simple ICI
    4. PyVma: Celui-là est très intéressant, mode API qui étend le module vulkan qui en mode ABI, c’est un très bon exemple

A savoir que CFFI a été créé par Armin Rigo et Maciej Fijalkowski, les deux créateurs de Pypy.
Toutes les extensions créées avec CFFI sont compatibles avec Pypy !

Conclusion

J’espère que cette introduction vous a plu. Si les retours sont bons, je pourrai m’atteler à un tuto plus conséquent.
Vive Python !

Si vous avez des remarques, n’hésitez pas à me le faire savoir: @realitix sur Twitter

16 thoughts on “Introduction aux extensions Python avec CFFI

  • Blop

    Super pour le tuto!

    Du coup, c’est moi qui rêve ou Sam, tu viens de faire ton coming out en dehors de ton pseudonymat ?

  • Erwan

    Article très sympa j’avais jamais pris le temps de comprendre comment fonctionnait cffi.

    Petite question en comparaison avec les autres binding comme python.boost, cython ou pybind11.

    J’ai beaucoup utilisé pybind11 et le début fut très compliqué. Lorsque l’on essaye de faire le binding de code C/C++ avec du python le problème majeur que j’ai rencontré vient de la conversion des types C++.

    Les types classiques comme int/vector sont en général prévu par la lib mais quand est-il des types plus complexe comme de cv::Mat (les images en opencv). Est-ce qu’il est facile d’utiliser des types custom facilement avec cffi et de les transférer au python ?

  • dmerej

    Swig ? (https://fr.wikipedia.org/wiki/SWIG)

    Y’en a d’autres qui ont essayé, ils ont eu d’autres problèmes.

    Plus sérieusement, d’expérience ça marche assez bien quand tu veux faire un wrapper un peu à l’arrache.

    Mais tu te retrouve pas avec une API Python agréable à utiliser … cffi te permet d’avoir des bindings beaucoup plus “pythoniques”

  • R2D2

    … et donc je suppose que ceux qui ont utilisés boost::python ont, eux aussi, eu des problèmes

  • realitix

    et donc je suppose que ceux qui ont utilisés boost::python ont, eux aussi, eu des problèmes

    Je n’ai pas la prétention d’avoir été exhaustif sur les différentes solutions permettant de réaliser des extensions Python.

    Pour être plus juste, on peut en effet ajouter

    4. D’autres solutions existent mais ne seront pas détaillées dans cet article

    A part ça, j’espère que l’article vous a plu.

  • buffalo974

    Est-ce qu’on peut exploiter vulkan à partir de pyglet ?

    Si oui, on doit pouvoir donc pouvoir compiler / pypyser un projet batît sur pyglet+vulkan ? (pyglet en pur python)

  • realitix

    Est-ce qu’on peut exploiter vulkan à partir de pyglet ?

    Pour exploiter Vulkan, il faut juste récupérer les infos système de création de ta fenêtre. Par exemple, pour wayland cela va être le wl_display et le wl_surface. Si pyglet te permet de récupérer les infos sous-jacentes de ta fenêtre, tu peux créer une instance Vulkan sur cette fenêtre.

    Vulkan ne fonctionne pas comme OpenGL, le contexte n’a pas besoin d’être créé à la création de la fenêtre, il est découplé de la fenêtre. Il est même possible de créer une instance Vulkan sans fenêtre (Off-screen rendering).

  • Sam Post author

    @Blop: c’est un article invité, il est pas de moi. Mais le coming out pourrait arriver cette année. En fait on a déjà laissé pas mal d’indices peu discrets.

  • buffalo974

    il est top ce site, pourvu qu’il y ait encore des millions d’ articles, du grand Maître, comme de ses apôtres…

  • bobn

    Dîtes-moi les amis ? Est-ce qu’on va se taper encore longtemps la propagande LGBT dans la board des flux de Seb Sauvage…Autant j’aime bien ses interventions techniques, autant il me tape sur le système avec sa propagande LGBT…

  • Sam Post author

    @buffalo974 @batisteo : )

    @bobn: ben déjà, pourquoi tu viens ici pour le dire ? Envoie un mail à Seb. Ensuite, autant sa fixette sur les LGBT rend son flux moins intéressant pour moi également (du coup je le lis moins en ce moment) parce que ce n’est pas une information que je cherche (je baigne dans le milieu), autant le mot “propagande” n’a aucun sens. C’est pas une religion ou un parti politique hein. C’est juste un sujet que seb à a coeur et il partage. C’est son blog, il fait bien ce qu’il veut.

  • bobn

    @Sam,

    Pardonne-moi de te contredire, mais pour moi cela est bien de la propagande…mais bon, on ne va pas faire un débat ici, je me charge de le contacter.

  • Cedric

    Je vais vite essayer ca! simplicité, performance, compatibilité avec pypy, effectivement ca laisse rêveur…

    J’ai beaucoup utilisé py++ qui est un remarquable projet permettant de créer automatiquement les wrappers de boost-python. Outil super puissant, qui a un moment était plus très à jour, mais qui semble bien repartir. Le problème: ca génère des fichiers C gigantesques, dont la compilation peut prendre des dizaines d’heure pour certaines de mes libs à wrapper.

  • kontre

    Moi j’ai utilisé cython pour wrapper une petite lib, ça marche bien, et c’est très facile de faire une interface pythonique par dessus un truc C pas prévu pour. Par contre je ne l’utilise pas en dynamique, j’inclus le code de la lib et je compile le tout comme une extension.

    Ça permet aussi d’optimiser les traitements de tableaux numpy assez facilement.

Comments are closed.

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.