Aller au contenu
Développement medium

Python : optimiser son code, mesurer avant d'accélérer

20 min de lecture

logo python

La première technique d'optimisation, c'est la mesure : tant que vous n'avez pas chiffré où le programme passe son temps, chaque changement est une supposition. Ce guide part de ce principe et parcourt les leviers qui rapportent vraiment en Python : choisir une structure de données adaptée, préférer les compréhensions, économiser la mémoire avec des générateurs, mémoïser avec lru_cache, alléger les objets avec __slots__, et construire les chaînes avec str.join. Il s'adresse aux développeurs à l'aise avec le langage qui veulent des gains réels et non des micro-astuces. Pour chaque technique, le gain est mesuré avec timeit, chiffres avant/après à l'appui. Les exemples ont été exécutés avec Python 3.12.

  • Mesurer un fragment de code avec timeit avant de le toucher.
  • Choisir entre list, set et dict selon l'opération dominante.
  • Réduire l'empreinte mémoire avec les générateurs et __slots__.
  • Éliminer les recalculs avec la mémoïsation lru_cache.
  • Construire des chaînes et parcourir des collections sans gaspillage.
  • Éviter l'optimisation prématurée qui complique sans accélérer.

Optimiser sans mesurer, c'est deviner. Le coupable réel d'une lenteur est presque toujours ailleurs que là où l'intuition le place : une requête répétée, une structure mal choisie, une copie cachée. Le module timeit exécute un fragment un grand nombre de fois et renvoie un temps stable, à l'abri des variations d'une exécution unique. C'est l'outil de base pour comparer deux versions d'un même code.

L'idée est de fournir un setup (le décor, exécuté une fois) et un stmt (le code mesuré, répété number fois). Le temps total divisé par number donne le coût par appel.

import timeit
setup = "data = list(range(10000))"
stmt = "sum(x * x for x in data)"
total = timeit.timeit(stmt, setup=setup, number=1000)
print(f"{total / 1000 * 1e6:.1f} us par appel")
1043.7 us par appel

Ce chiffre n'a de sens que comparé à une alternative. Toute la suite du guide applique ce protocole : une version de référence, une version optimisée, et l'écart mesuré entre les deux. Retenez la règle : mesurez, changez, remesurez. Sans le troisième temps, vous ne saurez jamais si le changement a aidé.

C'est le levier le plus rentable de tout Python, et de loin. Tester l'appartenance d'un élément (x in collection) coûte un temps proportionnel à la taille dans une list (parcours élément par élément, complexité O(n)), mais un temps quasi constant dans un set ou un dict (accès par table de hachage, complexité O(1)). Sur des milliers de recherches, l'écart devient colossal.

import timeit
setup = """
data_list = list(range(10000))
data_set = set(data_list)
cible = 9999
"""
t_list = timeit.timeit("cible in data_list", setup=setup, number=100000)
t_set = timeit.timeit("cible in data_set", setup=setup, number=100000)
print(f"list : {t_list / 100000 * 1e6:.3f} us par test")
print(f"set : {t_set / 100000 * 1e6:.3f} us par test")
list : 42.729 us par test
set : 0.013 us par test

Plus de 3000 fois plus rapide pour la recherche, sur une collection de 10 000 éléments. Et l'écart grandit avec la taille : la list ralentit à mesure qu'elle grossit, le set reste plat. Dès que votre code fait des tests d'appartenance répétés (dédoublonnage, filtrage, vérification d'existence), convertissez la liste en set une fois, puis interrogez le set. Le dict offre la même vitesse quand vous avez besoin d'associer une valeur à chaque clé.

Construire une liste avec une compréhension ([i * i for i in ...]) est plus rapide qu'une boucle for qui appelle append à chaque tour. La raison est interne : la compréhension exécute la construction dans une boucle optimisée au niveau du bytecode, sans repayer la résolution de la méthode append ni l'appel de fonction à chaque itération. Le gain reste modéré, mais il est gratuit et le code est plus lisible.

import timeit
stmt_boucle = """
res = []
for i in range(1000):
res.append(i * i)
"""
stmt_comp = "[i * i for i in range(1000)]"
t_boucle = timeit.timeit(stmt_boucle, number=10000)
t_comp = timeit.timeit(stmt_comp, number=10000)
print(f"boucle append : {t_boucle / 10000 * 1e6:.1f} us")
print(f"comprehension : {t_comp / 10000 * 1e6:.1f} us")
boucle append : 19.5 us
comprehension : 16.9 us

Environ 13% de temps en moins ici, sans autre effort que réécrire la boucle. Le point important : la compréhension gagne quand elle construit réellement une liste. Si votre boucle fait un travail complexe à chaque tour ou déclenche des effets de bord, ne la tordez pas en compréhension illisible pour grappiller ces pourcents. La lisibilité prime sur un gain marginal.

Quand vous n'avez pas besoin de tous les éléments en même temps, un générateur remplace une liste et supprime son coût mémoire. Une liste stocke chaque valeur en RAM ; un générateur ne garde que l'état nécessaire pour produire la valeur suivante à la demande. Sur de grands volumes, la différence n'est pas un pourcentage, c'est un changement d'ordre de grandeur.

import sys
liste = [i for i in range(1000000)]
generateur = (i for i in range(1000000))
print(f"liste : {sys.getsizeof(liste)} octets")
print(f"generateur : {sys.getsizeof(generateur)} octets")
liste : 8448728 octets
generateur : 192 octets

La liste occupe plus de 8 Mo, le générateur 192 octets, quel que soit le nombre d'éléments à venir. Cet écart change ce qui est possible : un fichier de plusieurs gigaoctets se traite ligne par ligne avec un générateur alors qu'il ferait exploser la mémoire chargé d'un bloc. Le compromis est réel : un générateur se parcourt une seule fois et n'autorise pas l'accès par indice. Utilisez-le pour un flux que vous consommez une fois, gardez la liste quand vous devez relire ou indexer.

Si une fonction pure (même entrée, même sortie, sans effet de bord) est appelée plusieurs fois avec les mêmes arguments, recalculer est du gaspillage. Le décorateur functools.lru_cache mémorise les résultats déjà obtenus et les restitue instantanément. Sur un calcul récursif qui explore les mêmes sous-problèmes, l'effet est spectaculaire.

import timeit
setup = """
from functools import lru_cache
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
@lru_cache(maxsize=None)
def fib_cache(n):
return n if n < 2 else fib_cache(n - 1) + fib_cache(n - 2)
"""
t_sans = timeit.timeit("fib(30)", setup=setup, number=100)
t_avec = timeit.timeit("fib_cache(30)", setup=setup, number=100)
print(f"sans cache : {t_sans / 100 * 1e3:.2f} ms par appel")
print(f"avec cache : {t_avec / 100 * 1e6:.3f} us par appel")
sans cache : 63.16 ms par appel
avec cache : 0.095 us par appel

La version naïve recalcule les mêmes valeurs des millions de fois ; la version mémoïsée les stocke et devient quasi gratuite après le premier appel. Deux garde-fous s'imposent. Le cache ne vaut que pour une fonction déterministe : ne jamais mémoïser une fonction qui lit une base, un fichier ou l'heure. Et maxsize=None garde tout en mémoire sans limite ; sur un domaine d'entrées vaste, fixez une taille (lru_cache(maxsize=1024)) pour borner la consommation.

Par défaut, chaque instance Python range ses attributs dans un dictionnaire interne (__dict__), souple mais coûteux en mémoire. Déclarer __slots__ remplace ce dictionnaire par un stockage fixe et compact : les attributs sont figés, mais l'empreinte fond. Sur des centaines de milliers d'objets, ce gain devient décisif.

import tracemalloc
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
class PointSlots:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
tracemalloc.start()
objets = [Point(i, i) for i in range(100000)]
courant, _ = tracemalloc.get_traced_memory()
print(f"sans slots : {courant / 1e6:.2f} Mo")
tracemalloc.stop()
tracemalloc.start()
objets2 = [PointSlots(i, i) for i in range(100000)]
courant2, _ = tracemalloc.get_traced_memory()
print(f"avec slots : {courant2 / 1e6:.2f} Mo")
tracemalloc.stop()
sans slots : 12.00 Mo
avec slots : 8.79 Mo

Environ 27% de mémoire en moins pour 100 000 objets, et un accès aux attributs légèrement plus rapide en prime. La contrepartie est la rigidité : avec __slots__, on ne peut plus ajouter d'attribut non déclaré à l'instance, et l'héritage multiple demande de la prudence. Réservez ce levier aux classes nombreuses et stables, typiquement des points, des enregistrements ou des noeuds que vous créez en masse.

Résoudre les noms en local dans les boucles chaudes

Section intitulée « Résoudre les noms en local dans les boucles chaudes »

Python cherche un nom d'abord dans l'espace local, puis dans le global, puis dans les builtins. Cette cascade a un coût minuscule, mais dans une boucle très chaude qui appelle des millions de fois la même fonction globale, copier la référence dans une variable locale avant la boucle fait gagner quelques pourcents. C'est un levier de dernier recours, à ne sortir que sur un point chaud identifié par la mesure.

import timeit
setup = """
import math
def globale():
total = 0.0
for i in range(10000):
total += math.sqrt(i) + math.sin(i)
return total
def locale():
sqrt = math.sqrt
sin = math.sin
total = 0.0
for i in range(10000):
total += sqrt(i) + sin(i)
return total
"""
t_glob = timeit.timeit("globale()", setup=setup, number=2000)
t_loc = timeit.timeit("locale()", setup=setup, number=2000)
print(f"acces global : {t_glob:.3f} s")
print(f"acces local : {t_loc:.3f} s")
acces global : 1.190 s
acces local : 1.132 s

De l'ordre de 5 à 10% ici, selon la charge de la boucle. C'est réel, mais faible : n'appliquez cette réécriture que sur un noyau de calcul avéré, jamais partout par réflexe. En Python 3.12, l'interpréteur adaptatif optimise déjà une partie de ces accès, et l'astuce peut même n'apporter presque rien selon le cas. Autrement dit : mesurez avant de compliquer votre code pour ça.

Assembler une chaîne en concaténant dans une boucle (s += morceau) est un piège classique. Les chaînes Python sont immuables : chaque += peut recréer une nouvelle chaîne et recopier tout l'accumulé. La bonne méthode collecte les morceaux puis les assemble en une seule passe avec str.join. L'écart est net dès quelques milliers de fragments.

import timeit
setup = "mots = [str(i) for i in range(10000)]"
stmt_plus = """
s = ""
for m in mots:
s += m
"""
stmt_join = "''.join(mots)"
t_plus = timeit.timeit(stmt_plus, setup=setup, number=2000)
t_join = timeit.timeit(stmt_join, setup=setup, number=2000)
print(f"concatenation += : {t_plus:.3f} s")
print(f"str.join : {t_join:.3f} s")
concatenation += : 0.317 s
str.join : 0.048 s

Plus de 6 fois plus rapide pour assembler 10 000 fragments, et le code est plus clair. La règle pratique : dès que vous accumulez des morceaux de texte dans une boucle, mettez-les dans une liste et faites "".join(liste) à la fin. Réservez l'opérateur + aux assemblages ponctuels de deux ou trois chaînes, où il reste parfaitement lisible.

Beaucoup de lenteurs viennent de données recopiées sans raison. Le cas le plus courant est le slice complet (data[:]) utilisé pour itérer : il duplique toute la collection en mémoire alors qu'un simple parcours direct suffit. Le surcoût est double, en temps et en RAM.

import timeit
setup = "data = list(range(10000))"
stmt_copie = """
total = 0
for x in data[:]:
total += x
"""
stmt_direct = """
total = 0
for x in data:
total += x
"""
t_copie = timeit.timeit(stmt_copie, setup=setup, number=10000)
t_direct = timeit.timeit(stmt_direct, setup=setup, number=10000)
print(f"data[:] (copie) : {t_copie / 10000 * 1e6:.1f} us")
print(f"data (direct) : {t_direct / 10000 * 1e6:.1f} us")
data[:] (copie) : 135.2 us
data (direct) : 123.9 us

Environ 9% de temps en moins, plus une copie complète de la liste évitée à chaque tour. Le slice n'est justifié que si vous modifiez la collection pendant le parcours et voulez itérer sur un instantané figé. En dehors de ce cas, itérez directement. Même logique pour les fonctions : passer une grande structure en argument ne la copie pas en Python (un appel transmet des références aux objets, jamais des copies), inutile donc de la dupliquer par prudence.

La technique la plus importante de tout ce guide est de savoir quand ne pas optimiser. La très grande majorité d'un programme n'est pas un goulot d'étranglement : y appliquer des micro-astuces ne fait que compliquer le code sans effet mesurable sur le temps total. Un code plus tordu est plus long à lire, plus dur à corriger, et cache mieux ses bugs. Le vrai coût d'une optimisation inutile est là.

La bonne démarche tient en trois temps. D'abord, écrire un code clair qui marche. Ensuite, mesurer pour trouver les 20% de code qui consomment 80% du temps. Enfin, optimiser uniquement ces points chauds, en remesurant après chaque changement. Un profileur identifie ces zones avec précision ; timeit chiffre ensuite chaque candidat de remplacement. Optimiser au jugé, partout, sans données, est la façon la plus sûre de perdre du temps et de la clarté à la fois.

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

  • Mesurez avant tout : timeit compare deux versions, un profileur trouve les points chauds. Sans chiffres, vous devinez.
  • La structure de données est le premier levier : set/dict en O(1) contre list en O(n), mesuré à plus de 3000 fois plus rapide pour l'appartenance.
  • Les compréhensions battent la boucle append d'environ 13%, à lisibilité égale ou meilleure.
  • Un générateur remplace une liste par 192 octets au lieu de 8 Mo quand vous consommez un flux une seule fois.
  • lru_cache rend quasi gratuite une fonction pure rappelée avec les mêmes arguments ; à réserver au déterministe et à borner en taille.
  • __slots__ réduit d'environ 27% l'empreinte d'objets créés en masse, au prix de la rigidité.
  • str.join est plus de 6 fois plus rapide que la concaténation += en boucle.
  • Évitez les copies inutiles et surtout l'optimisation prématurée : un code clair d'abord, l'accélération ensuite, sur les seuls points chauds mesurés.
  • Générateurs et itérateurs : le mécanisme complet du yield et de l'évaluation paresseuse pour traiter de grands flux sans saturer la mémoire.
  • Structures de données et collections : deque, Counter, defaultdict et les autres outils du module collections pour choisir la bonne structure selon l'opération.
  • itertools et functools : les briques de la bibliothèque standard pour composer des traitements efficaces sans réécrire de boucles.
  • Décorateurs en Python : comprendre le mécanisme derrière lru_cache pour écrire vos propres décorateurs de mise en cache.
  • Cython : le cran d'après quand les leviers du Python pur ne suffisent plus, en compilant la boucle chaude en C.

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