backend / python
Extensions C et interopérabilité (Cython, ctypes)
Explication
Ce que vous allez apprendre
- Comprendre pourquoi certaines boucles restent lentes même après toutes les optimisations Python
- Appeler une bibliothèque C existante depuis Python avec
ctypes, sans rien recompiler - Comprendre le principe de Cython : compiler un sur-ensemble de Python typé vers du C
- Situer l'API C de CPython et PyO3 (Rust) parmi les options disponibles
- Choisir l'outil adapté selon le compromis entre simplicité et gain de performance recherché
Dans quel contexte ?
Un développeur a suivi toutes les optimisations Python classiques (profiling, set au lieu de list, lru_cache) sur une fonction de traitement d'image pixel par pixel, mais elle reste 50 fois trop lente pour un usage en production. Le profiling confirme que le temps est bien dans cette boucle, pas ailleurs. C'est exactement le signal qui justifie de sortir de l'optimisation Python pure et de déléguer cette boucle à du code compilé via Cython ou NumPy.
Quand Python pur atteint ses limites
Certains traitements, en particulier le calcul numérique répété des millions de fois, restent lents même après toutes les optimisations Python possibles, à cause du coût inhérent à l'interprétation du bytecode. La solution éprouvée par tout l'écosystème scientifique Python (NumPy, pandas) consiste à déléguer les boucles critiques à du code natif compilé, bien plus rapide qu'une boucle Python interprétée.
Prérequis
Cette leçon suppose que vous avez déjà profilé votre code (leçon précédente) et confirmé qu'un vrai goulet d'étranglement CPU existe. Se tourner vers Cython ou une extension C sans avoir mesuré au préalable revient à optimiser à l'aveugle.
Trois portes d'entrée vers le code natif
ctypes permet d'appeler directement une bibliothèque C déjà compilée, sans rien recompiler soi-même : pratique pour réutiliser du code existant. Cython va plus loin en compilant un sur-ensemble de Python, enrichi d'annotations de type optionnelles, directement vers du C : c'est l'approche la plus utilisée en production, car elle reste proche de la syntaxe Python habituelle. Enfin, l'API C de CPython permet d'écrire une extension entièrement en C, la voie la plus rapide mais aussi la plus exigeante.
Le compromis vitesse contre simplicité
Chaque outil déplace le curseur entre facilité d'écriture et gain de performance. ctypes ne demande aucune compilation mais reste limité aux fonctions déjà exposées par une bibliothèque C. Cython demande une étape de compilation mais reste lisible pour un développeur Python. L'API C brute offre un contrôle total au prix d'une courbe d'apprentissage bien plus raide, proche du C pur.
Une alternative moderne à connaître
PyO3, qui permet d'écrire des extensions Python en Rust, gagne du terrain car il offre la sécurité mémoire de Rust avec des performances comparables au C, sans les risques classiques de segfault d'une extension C mal écrite.
| Outil | Compilation requise | Courbe d'apprentissage | Gain typique |
|---|---|---|---|
ctypes | Non (lib déjà compilée) | Faible | Dépend de la lib appelée |
| Cython | Oui | Modérée | 10-50x sur du calcul pur |
| API C CPython | Oui | Élevée | Maximal, contrôle total |
| PyO3 (Rust) | Oui | Élevée | Comparable au C, plus sûr |
Commandes & code
Extensions C et interopérabilité
Quand Python pur ne suffit plus : accélérer des sections critiques avec du code natif.
# --- ctypes : appeler des bibliotheques C existantes sans les recompiler ---
import ctypes
# Charger la libc et appeler une fonction C standard
libc = ctypes.CDLL(None) # None charge la libc du systeme
libc.printf(b"Hello depuis C via ctypes : %d\n", 42)
# Definir les types d'arguments/retour pour la securite et la correction
libm = ctypes.CDLL("libm.so.6" if ctypes.util is not None else None) if False else None
# (exemple illustratif : le chargement precis depend de l'OS)
class Point(ctypes.Structure):
# Mapper une structure C directement en Python
_fields_ = [("x", ctypes.c_double), ("y", ctypes.c_double)]
p = Point(1.5, 2.5)
print(p.x, p.y)
# --- cffi / ctypes pour wrapper une lib partagee custom ---
# supposons libcalcul.so compilee depuis :
# double distance(double x1, double y1, double x2, double y2) { ... }
#
# lib = ctypes.CDLL("./libcalcul.so")
# lib.distance.argtypes = [ctypes.c_double] * 4
# lib.distance.restype = ctypes.c_double
# resultat = lib.distance(0, 0, 3, 4) # 5.0
# --- Cython : le pont le plus utilise en production (NumPy, pandas l'utilisent) ---
# fichier calcul.pyx :
'''
def fibonacci_cython(int n):
cdef long a = 0
cdef long b = 1
cdef int i
for i in range(n):
a, b = b, a + b
return a
def somme_carres(int n):
cdef long total = 0
cdef int i
for i in range(n):
total += i * i
return total
'''
# fichier setup.py pour compiler l'extension Cython :
'''
from setuptools import setup
from Cython.Build import cythonize
setup(
ext_modules=cythonize("calcul.pyx", compiler_directives={"language_level": "3"}),
)
'''
# Compilation puis usage :
# $ python setup.py build_ext --inplace
# >>> from calcul import fibonacci_cython
# >>> fibonacci_cython(30) # 10 a 50x plus rapide que l'equivalent Python pur
# --- L'API C de CPython : ecrire une extension native en C ---
# fichier module.c (simplifie, sans gestion d'erreur complete) :
'''
#include <Python.h>
static PyObject* addition(PyObject* self, PyObject* args) {
int a, b;
if (!PyArg_ParseTuple(args, "ii", &a, &b)) {
return NULL;
}
return PyLong_FromLong(a + b);
}
static PyMethodDef Methods[] = {
{"addition", addition, METH_VARARGS, "Additionne deux entiers"},
{NULL, NULL, 0, NULL}
};
static struct PyModuleDef monmodule = {
PyModuleDef_HEAD_INIT, "monmodule", NULL, -1, Methods
};
PyMODINIT_FUNC PyInit_monmodule(void) {
return PyModule_Create(&monmodule);
}
'''
# --- Alternative moderne : PyO3 (Rust) ---
# de plus en plus utilise en production (ex: certaines parties de pydantic v2, polars)
# offre la securite memoire de Rust avec des performances proches du C
# --- multiprocessing.shared_memory : partager de gros buffers sans copie ---
from multiprocessing import shared_memory
import numpy as np
# creation d'un buffer partage entre processus (evite la serialisation couteuse)
shm = shared_memory.SharedMemory(create=True, size=1024)
tableau_partage = np.ndarray((128,), dtype=np.float64, buffer=shm.buf)
tableau_partage[:] = 0
shm.close()
shm.unlink()Résumé
ctypesappelle des bibliothèques C existantes sans étape de compilation dédiée.- Cython compile un sur-ensemble de Python (avec annotations de type) vers du C, gain typique 10-50x.
- L'API C de CPython permet d'écrire des extensions natives complètes (utilisée par NumPy).
- PyO3 (Rust) est une alternative moderne de plus en plus adoptée pour la sécurité mémoire.
Exercices pratiques
Mission : choisir le bon outil pour un traitement d'image 50 fois trop lent
Objectif : Décider entre ctypes, Cython et une réécriture en NumPy pour accélérer une boucle CPU-bound déjà profilée, en justifiant le compromis simplicité/performance.
Contexte
Une fonction de traitement d'image pixel par pixel a déjà été profilée avec cProfile et confirmée comme le seul vrai goulet d'étranglement : elle reste 50 fois trop lente pour la production, même après toutes les optimisations Python classiques (set, join, lru_cache) déjà appliquées ailleurs dans le projet.
Tu dois choisir et justifier un outil de sortie de Python pur, écrire une fonction Cython typée, puis expliquer pourquoi appeler ctypes ne suffirait pas ici.