Aller au contenu
Développement medium

Accélérer Python avec Cython et les extensions C

16 min de lecture

logo python

Quand une fonction Python passe son temps dans une boucle numérique, la réécrire en Cython la rend souvent 50 à 100 fois plus rapide sans quitter votre projet Python. Dans le lab de ce guide, une intégration numérique de dix millions de pas passe de 399 ms en Python pur à 6,2 ms en Cython, soit une accélération mesurée de x64, avec un résultat identique. Cython compile un dialecte de Python en code C natif : vous ajoutez un typage statique (cdef, cpdef), vous compilez un module .pyx, et la boucle chaude tourne à la vitesse du C. Les exemples ont été exécutés avec Python 3.12 et Cython 3.2.8.

Ce guide s'adresse aux développeurs qui ont déjà identifié un hotspot CPU par profiling et qui veulent le rendre nettement plus rapide. Vous verrez comment écrire et compiler un module Cython, comment mesurer le gain, comment relâcher le GIL avec nogil, et quelles sont les autres passerelles vers le C (ctypes, cffi, pybind11). Vous verrez surtout quand ce travail en vaut la peine et quand il vaut mieux rester en Python pur.

  • Reconnaître le moment où le Python pur atteint sa limite CPU.
  • Écrire un module .pyx typé avec cdef, cpdef et les annotations.
  • Compiler l'extension via cythonize dans un setup.py ou pyproject.toml.
  • Mesurer le gain réel d'une boucle numérique face au Python pur.
  • Relâcher le GIL avec nogil et choisir entre ctypes, cffi et pybind11.

Python est interprété et typé dynamiquement : à chaque tour de boucle, l'interpréteur vérifie le type de chaque variable, gère le comptage de références et repasse par la machine virtuelle. Sur une boucle serrée qui fait des millions d'opérations arithmétiques, ce surcoût domine le temps de calcul. C'est là que le code natif devient intéressant : une même boucle compilée en C ne paie plus aucun de ces frais.

La règle d'or reste de mesurer avant d'agir. On ne réécrit pas un programme entier en Cython : on cible la poignée de fonctions qui concentrent le temps CPU, repérées par un profileur. Notre guide sur le profiling Python montre comment isoler ces hotspots avec cProfile et timeit. Sans cette étape, vous risquez d'optimiser du code qui ne représente que 2 % du temps d'exécution, pour un gain global invisible.

Cython vise un profil précis de problèmes : boucles numériques, traitement de tableaux, calculs scientifiques, parsing bas niveau. Pour un code dominé par des appels réseau, des accès disque ou une base de données, le goulot n'est pas le CPU et Cython n'apportera rien. Dans ces cas, regardez plutôt l'optimisation algorithmique et les entrées-sorties asynchrones.

Un module Cython vit dans un fichier .pyx. Vous pouvez y écrire du Python quasiment normal, mais le gain vient du typage statique : en déclarant qu'une variable est un double ou un int C, vous permettez au compilateur de générer des opérations natives au lieu de manipuler des objets Python. Trois mots-clés structurent ce typage.

Le mot-clé cdef déclare une variable ou une fonction en types C purs. Une fonction cdef est la plus rapide, mais elle n'est appelable que depuis le code Cython, pas depuis Python. Le mot-clé cpdef crée une fonction hybride : une version C rapide pour les appels internes, et une enveloppe Python pour l'extérieur. C'est le bon choix pour la fonction d'entrée de votre module, celle que le reste du programme appellera.

Voici la boucle chaude de notre lab : une intégration numérique de la fonction mathématique qui à chaque abscisse vaut x * x - x, calculée par la méthode des rectangles. Le fichier integ_cy.pyx type toutes les variables de travail.

# cython: boundscheck=False, wraparound=False, cdivision=True
cdef inline double f(double x) nogil:
return x * x - x
cpdef double integrer(double a, double b, int n):
cdef double dx = (b - a) / n
cdef double total = 0.0
cdef double x = a
cdef int i
for i in range(n):
total += f(x) * dx
x += dx
return total

Chaque déclaration compte. La fonction interne f est en cdef inline : elle sera insérée directement dans la boucle, sans coût d'appel. Les variables dx, total, x et i sont des types C (double, int), donc la boucle ne crée aucun objet Python. La directive cdivision=True évite la vérification de division par zéro à la mode Python, et boundscheck=False désactive le contrôle des indices. La fonction publique integrer est en cpdef pour rester appelable depuis Python.

Cython accepte aussi une syntaxe d'annotations proche des type hints Python, pratique quand vous voulez garder un fichier lisible par les outils Python. Les deux styles se compilent de la même façon.

import cython
@cython.cfunc
@cython.inline
def f(x: cython.double) -> cython.double:
return x * x - x

Un fichier .pyx n'est pas directement importable : il faut le transformer en C puis le compiler en bibliothèque partagée. Cette étape s'appuie sur cythonize, la fonction fournie par Cython qui pilote la traduction. Il vous faut un compilateur C installé sur la machine (gcc ou clang sous Linux, les Build Tools sous Windows).

La méthode classique passe par un setup.py qui appelle cythonize sur votre fichier. Ce script décrit l'extension à construire.

from setuptools import setup
from Cython.Build import cythonize
setup(ext_modules=cythonize("integ_cy.pyx", language_level="3"))

On déclare aussi les dépendances de build dans un pyproject.toml, pour que l'installation dans un environnement propre récupère Cython et setuptools avant de compiler. C'est la forme moderne attendue par pip.

[build-system]
requires = ["setuptools>=68", "Cython>=3.0"]
build-backend = "setuptools.build_meta"
[project]
name = "integ-cy"
version = "0.1.0"

La compilation en place se lance avec build_ext. Elle génère un fichier integ_cy.c puis une bibliothèque partagée .so (ou .pyd sous Windows) importable comme n'importe quel module.

Fenêtre de terminal
python setup.py build_ext --inplace

La sortie se termine par la création du fichier natif, que vous pouvez vérifier ainsi. Le module est alors importable dans Python.

$ ls *.so
integ_cy.cpython-312-x86_64-linux-gnu.so

Un gain annoncé ne vaut rien sans mesure. On compare donc la version Python pure et la version Cython sur exactement le même calcul, avec timeit pour lisser les variations. La version Python pure est un fichier integ_py.py au code identique, mais sans aucun typage.

def f(x):
return x * x - x
def integrer(a, b, n):
dx = (b - a) / n
total = 0.0
x = a
for _ in range(n):
total += f(x) * dx
x += dx
return total

Le banc d'essai appelle les deux fonctions sur dix millions de pas et affiche le temps moyen de chaque version ainsi que le rapport. On vérifie d'abord que les deux résultats sont identiques, sinon la comparaison n'aurait aucun sens.

import timeit
import integ_py
import integ_cy
N = 10_000_000
a, b = 0.0, 100.0
print(f"resultat python : {integ_py.integrer(a, b, N):.4f}")
print(f"resultat cython : {integ_cy.integrer(a, b, N):.4f}")
t_py = timeit.timeit(lambda: integ_py.integrer(a, b, N), number=3) / 3
t_cy = timeit.timeit(lambda: integ_cy.integrer(a, b, N), number=3) / 3
print(f"python pur : {t_py*1000:.1f} ms")
print(f"cython : {t_cy*1000:.1f} ms")
print(f"acceleration : x{t_py/t_cy:.1f}")

La sortie réelle du lab est sans appel. Le calcul donne le même résultat au centième près, mais la version compilée écrase la version interprétée.

resultat python : 328333.2839
resultat cython : 328333.2839
python pur : 399.0 ms
cython : 6.2 ms
acceleration : x64.2

Ce facteur x64 est typique d'une boucle purement numérique correctement typée. Le message essentiel : le gain vient du typage statique, pas de la magie du compilateur. Un fichier .pyx sans aucun cdef reste presque aussi lent que le Python pur, car chaque variable resterait un objet Python. Ce sont les déclarations de type qui débloquent la vitesse du C.

Le verrou global de l'interpréteur (GIL) empêche deux threads Python d'exécuter du bytecode en même temps, ce qui bride le parallélisme CPU en Python pur. Les extensions natives ont un privilège que le Python pur n'a pas : elles peuvent relâcher le GIL pendant un calcul qui ne touche pas aux objets Python. C'est ce que signale le mot-clé nogil sur notre fonction f.

Une fois le GIL relâché dans une section de code, plusieurs threads peuvent calculer en parallèle sur plusieurs cœurs, même depuis le module threading classique. Cython offre pour cela le bloc with nogil: et la boucle parallèle prange (basée sur OpenMP), qui distribue les itérations sur les cœurs disponibles.

from cython.parallel import prange
cpdef double somme_parallele(int n):
cdef double total = 0.0
cdef int i
for i in prange(n, nogil=True):
total += i * 0.5
return total

Attention à un piège de compilation : prange ne distribue le travail que si l'extension est compilée avec OpenMP (extra_compile_args=["-fopenmp"] et extra_link_args=["-fopenmp"] sur l'Extension du setup.py avec gcc). Sans ce drapeau, le code compile mais la boucle reste séquentielle.

C'est un avantage décisif face au Python pur : là où threading ne parallélise jamais un calcul CPU à cause du GIL, une extension Cython bien écrite exploite réellement tous les cœurs. La contrepartie est la rigueur : dans une section nogil, vous ne pouvez manipuler aucun objet Python, uniquement des types C.

Cython n'est pas la seule façon d'appeler du code natif depuis Python. Selon que vous voulez réutiliser une bibliothèque existante ou écrire du nouveau code, d'autres outils sont plus adaptés. Il est utile de connaître les trois principaux pour choisir le bon.

Le module ctypes fait partie de la bibliothèque standard : il charge une bibliothèque partagée déjà compilée (.so, .dll) et appelle ses fonctions, sans rien compiler côté Python. C'est idéal pour piloter une lib C dont vous avez le binaire. Dans le lab, une même intégration écrite en C pur et compilée avec gcc -shared -fPIC s'appelle directement.

import ctypes
lib = ctypes.CDLL("./libmathutils.so")
lib.integrer.argtypes = [ctypes.c_double, ctypes.c_double, ctypes.c_int]
lib.integrer.restype = ctypes.c_double
print(lib.integrer(0.0, 100.0, 10_000_000))

Il faut déclarer explicitement les types des arguments (argtypes) et du retour (restype), sinon ctypes suppose des entiers et renvoie des valeurs fausses. Cet appel a bien renvoyé 328333.2839, le même résultat que Cython. La bibliothèque cffi joue le même rôle mais de façon plus robuste : vous lui donnez les déclarations C du header, et elle génère l'interface, ce qui évite les erreurs de type manuelles de ctypes.

Pour du C++, l'outil de référence est pybind11 : une bibliothèque d'en-têtes qui expose des classes et fonctions C++ à Python avec une syntaxe concise. C'est le choix habituel quand vous écrivez une extension en C++ moderne ou que vous enrobez une bibliothèque C++ existante. Le tableau ci-dessous résume les usages.

OutilRôle principalQuand le choisir
CythonCompiler du Python typé en CAccélérer vos propres boucles chaudes
ctypesCharger une lib partagéeAppeler un binaire C sans compiler
cffiInterface C par déclarationsMême besoin que ctypes, plus sûr
pybind11Exposer du C++ à PythonEnrober ou écrire du C++

Passer une fonction en Cython n'est pas gratuit. Vous ajoutez une étape de compilation, une dépendance à un compilateur C, un code plus verbeux et plus difficile à déboguer, et des binaires à construire pour chaque plateforme cible. Ce coût ne se justifie que si le gain est réel et mesuré sur un hotspot qui pèse lourd dans votre profil.

Avant de sortir Cython, épuisez les options plus simples. Un meilleur algorithme peut battre n'importe quelle optimisation bas niveau : passer d'une recherche linéaire à un set, mémoïser avec lru_cache, éviter des recalculs. Notre guide optimiser son code Python détaille ces leviers à coût nul. Et pour du calcul sur tableaux, NumPy vectorise déjà les boucles en C : réécrire en NumPy est souvent plus rapide à faire et aussi performant que Cython.

Restez donc en Python pur tant que le CPU n'est pas votre goulot, tant qu'un profileur n'a pas désigné une fonction précise, et tant qu'une solution vectorisée ou algorithmique suffit. Cython est un outil de dernière étape, quand vous avez confirmé par la mesure qu'une boucle numérique incompressible domine votre temps d'exécution.

Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

6 questions
6 min.
70% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

  • Cython compile un Python typé en code C natif : le gain vient du typage statique, pas du compilateur seul.
  • Le typage repose sur cdef (types C purs, interne), cpdef (hybride, appelable depuis Python) et les annotations cython.double.
  • La compilation passe par cythonize dans un setup.py, avec les dépendances déclarées en pyproject.toml, et exige un compilateur C.
  • Sur une boucle numérique de dix millions de pas, le lab mesure 399 ms en Python contre 6,2 ms en Cython, soit x64, résultat identique.
  • Le mot-clé nogil relâche le GIL et permet un vrai parallélisme CPU (prange), impossible en threading Python pur.
  • Pour appeler du code natif existant, choisissez ctypes ou cffi (lib C) ou pybind11 (C++), selon le besoin.
  • Profilez d'abord : Cython ne vaut le coût que sur un hotspot CPU confirmé. Un meilleur algorithme ou NumPy suffit souvent.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn