Numba : compile tes boucles Python et divise leur temps d'exécution par 100

De quoi avez-vous besoin

Version de Python

3.x

Packages

  • {"nom": "numba", "version": "0.67.0"}

Difficulté

Intermédiaire

Python est lent quand tu écris des boucles à la main. Chaque tour de boucle force l'interpréteur à revérifier les types, à chercher la fonction dans les modules, à gérer la mémoire dynamique. Résultat : un calcul simple sur dix millions d'éléments peut prendre plusieurs secondes. La parade classique, c'est de tout déléguer à NumPy ou Pandas, qui font le vrai travail en C. Mais il reste un cas où tu ne peux pas y échapper : la boucle personnalisée. Une simulation, un calcul sur mesure, un traitement qui ne rentre dans aucune fonction toute faite. C'est exactement là que Numba intervient.

Numba est un compilateur juste-à-temps pour Python. Tu poses un décorateur sur ta fonction, et il la traduit en code machine à la première exécution. Ton code reste du Python lisible, mais il tourne à la vitesse du C. Sans changer de langage, sans fichier séparé, sans quitter ton script.

Installe Numba

Un seul paquet suffit. Numba s'appuie sur LLVM, le même moteur de compilation que Clang, donc l'installation est propre et sans surprise.

bash
pip install numba

Écris une boucle volontairement lente

Prends un cas concret : sommer la racine carrée des dix premiers millions d'entiers. Aucune fonction NumPy ne fait ça d'un coup, donc tu écris une boucle.

python
import math
import time

def somme_racines(n):
    total = 0.0
    for i in range(1, n + 1):
        total += math.sqrt(i)
    return total

t0 = time.perf_counter()
resultat = somme_racines(10_000_000)
t1 = time.perf_counter()
print(f"Python pur : {t1 - t0:.3f} s — resultat {resultat:.2f}")

Ajoute un décorateur et mesure

Importe njit et colle le décorateur au-dessus de la même fonction. À la première exécution, Numba analyse la fonction, la compile en code machine et la garde en cache. Les appels suivants utilisent directement le binaire.

python
from numba import njit

@njit
def somme_racines_numba(n):
    total = 0.0
    for i in range(1, n + 1):
        total += math.sqrt(i)
    return total

somme_racines_numba(1)  # premiere execution : compilation

t0 = time.perf_counter()
resultat = somme_racines_numba(10_000_000)
t1 = time.perf_counter()
print(f"Numba : {t1 - t0:.3f} s — resultat {resultat:.2f}")

Sur ma machine, la version Python pur met environ 1,8 seconde ; la version Numba descend sous les 20 millisecondes. Le facteur dépasse largement 50, et il grimpe avec la taille de la boucle. La première exécution est plus lente, parce qu'elle inclut la compilation, mais elle ne se produit qu'une fois.

Accélère ton code NumPy existant

Numba ne sert pas qu'aux boucles pures. Il comprend nativement les tableaux NumPy, ce qui te permet de garder ton code lisible ET d'optimiser les boucles internes qu'on évite d'habitude d'écrire.

python
import numpy as np
from numba import njit

@njit
def normalise_colonnes(matrice):
    lignes, colonnes = matrice.shape
    resultat = np.empty_like(matrice)
    for c in range(colonnes):
        s = 0.0
        for l in range(lignes):
            s += matrice[l, c] ** 2
        norme = s ** 0.5
        for l in range(lignes):
            resultat[l, c] = matrice[l, c] / norme
    return resultat

donnes = np.random.rand(2000, 2000)
normalise_colonnes(donnes)  # compile puis execute

Ici, normaliser chaque colonne d'une matrice exigerait soit du code NumPy illisible, soit trois boucles imbriquées que Python pur traînerait comme un boulet. Numba avale les trois boucles et sort un code aussi rapide qu'une fonction C écrite à la main.

Parallélise avec prange

Si ta machine a plusieurs cœurs, remplace range par prange et passe parallel=True. Numba répartit les itérations entre les cœurs sans que tu aies à gérer des threads.

python
from numba import njit, prange
import numpy as np
import math

@njit(parallel=True)
def table_des_racines(n):
    resultat = np.empty(n)
    for i in prange(n):
        resultat[i] = math.sqrt(i + 1)
    return resultat

print(table_des_racines(5))

Les limites, pour ne pas te planter

Numba ne compile pas tout Python. Les classes, les listes hétérogènes, la plupart des modules externes, les closures : autant de choses qu'il refuse. Reste sur des fonctions courtes, des types simples (int, float, bool) et des tableaux NumPy. Quand le code est trop tordu, Numba lève une erreur claire et tu sais qu'il faut soit simplifier, soit garder cette partie en Python pur.

Si tu veux affiner, njit(cache=True) évite de recompiler à chaque lancement, et fastmath=True autorise des optimisations numériques plus agressives au prix de quelques arrondis.

Conclusion

Numba comble un trou précis dans l'écosystème Python : le moment où tu as besoin d'une boucle rapide, mais pas envie de réécrire ton code en C ou Cython. Un décorateur, et ton script gagne un ordre de grandeur. Pour du traitement de données, des simulations ou du prétraitement avant entraînement d'un modèle, c'est un réflexe qui coûte deux lignes et rapporte gros.