
Avant d'optimiser du code Python, on le mesure : c'est la seule façon de savoir où le temps et la mémoire partent vraiment. L'intuition se trompe presque toujours sur la localisation du goulot d'étranglement (le hotspot). Ce guide couvre les quatre outils de la bibliothèque standard et deux outils externes : timeit pour chronométrer une expression isolée, cProfile avec pstats pour trouver la fonction coûteuse d'un programme, tracemalloc pour suivre l'empreinte mémoire, py-spy pour échantillonner un processus déjà lancé sans le modifier, et snakeviz pour visualiser un profil dans le navigateur.
Il s'adresse aux développeurs confirmés qui veulent une méthode reproductible : mesurer, identifier le hotspot, corriger, puis re-mesurer le gain réel. Le fil rouge du guide part d'un code lent, le profile, isole la fonction responsable, la remplace et vérifie l'accélération. Les exemples ont été exécutés avec Python 3.12.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Chronométrer une expression avec
timeit, en ligne de commande et via l'API. - Profiler un programme entier avec
cProfileet lire un rapportpstats. - Trier les fonctions par
tottimeetcumtimepour isoler le hotspot. - Mesurer l'empreinte mémoire et comparer deux snapshots avec
tracemalloc. - Échantillonner un processus en production avec
py-spy, sans instrumentation.
Mesurer avant d'optimiser
Section intitulée « Mesurer avant d'optimiser »L'optimisation prématurée fait perdre du temps sur du code qui ne pèse rien dans le total. La règle est simple : on ne touche à rien tant qu'une mesure n'a pas désigné le vrai coupable. Un programme passe souvent 90 % de son temps dans quelques lignes, et le reste est déjà assez rapide. Optimiser ailleurs complexifie le code sans effet visible.
La démarche tient en quatre temps : mesurer l'état actuel, identifier la fonction ou la boucle qui domine, corriger cette zone précise, puis re-mesurer pour chiffrer le gain. Chaque outil couvre une question différente. Le tableau ci-dessous aide à choisir selon ce que vous cherchez.
| Question | Outil | Ce qu'il donne |
|---|---|---|
| Quelle écriture est la plus rapide ? | timeit | Temps d'une expression isolée |
| Quelle fonction coûte le plus ? | cProfile + pstats | Répartition du temps par appel |
| Combien de mémoire est allouée et où ? | tracemalloc | Top des allocations, diff de snapshots |
| Où passe le temps d'un process vivant ? | py-spy | Échantillonnage sans instrumentation |
timeit : chronométrer une expression
Section intitulée « timeit : chronométrer une expression »timeit répond à une question précise : entre deux façons d'écrire la même chose, laquelle est la plus rapide ? Il exécute l'expression des milliers de fois, écarte les perturbations du système et garde le meilleur temps. C'est l'outil des micro-benchmarks, pas d'un programme entier.
En ligne de commande, l'option -s fournit le code de préparation (exécuté une seule fois) et l'argument final est l'expression mesurée. Cet exemple compare la recherche d'un élément dans une liste puis dans un set de 10 000 entiers.
python3 -m timeit -s "data = list(range(10000))" "999999 in data"python3 -m timeit -s "data = set(range(10000))" "999999 in data"10000 loops, best of 5: 40.2 usec per loop20000000 loops, best of 5: 10 nsec per loopL'écart est brutal : 40 microsecondes contre 10 nanosecondes, soit un facteur de plusieurs milliers. La raison est algorithmique. La recherche dans une liste est linéaire (elle parcourt les éléments un à un, en O(n)), tandis qu'un set utilise une table de hachage et répond en temps constant (O(1)). Ce genre de constat ne se devine pas, il se mesure.
L'API Python sert quand la préparation est trop longue pour tenir sur une ligne. timeit.repeat lance number exécutions et répète l'ensemble repeat fois, ce qui donne plusieurs totaux dont on garde le meilleur. Ici on mesure le tri de 1 000 flottants.
import timeit
mesures = timeit.repeat( stmt="sorted(donnees)", setup="import random; donnees = [random.random() for _ in range(1000)]", number=1000, repeat=5,)
print(f"5 mesures (1000 tris chacune) : {[round(m, 4) for m in mesures]}")print(f"Meilleur temps par tri : {min(mesures) / 1000 * 1e6:.1f} us")5 mesures (1000 tris chacune) : [0.0536, 0.048, 0.0451, 0.0463, 0.05]Meilleur temps par tri : 45.1 usOn retient toujours le minimum, jamais la moyenne. Un temps élevé signale une interruption du système d'exploitation (le noyau a rendu la main à un autre processus), pas une lenteur du code. Le meilleur temps reflète le comportement sans bruit.
cProfile : trouver le hotspot d'un programme
Section intitulée « cProfile : trouver le hotspot d'un programme »cProfile est le profileur déterministe de la bibliothèque standard. Il enregistre chaque appel de fonction et le temps passé dedans, puis produit un rapport trié. Contrairement à timeit, il éclaire un programme complet et désigne la fonction qui domine, sans que vous ayez à deviner.
Voici le code du fil rouge : il compte combien de commandes proviennent de clients présents dans une liste de référence. La fonction est_connu teste l'appartenance à cette liste, appelée pour chaque commande.
def charger_reference(): return list(range(20000))
def est_connu(identifiant, reference): return identifiant in reference
def compter_connus(commandes, reference): total = 0 for identifiant in commandes: if est_connu(identifiant, reference): total += 1 return total
def main(): reference = charger_reference() commandes = list(range(0, 40000, 2)) resultat = compter_connus(commandes, reference) print(f"Clients connus : {resultat}")
if __name__ == "__main__": main()La façon la plus rapide de le profiler est d'invoquer cProfile en ligne de commande avec -s tottime, qui trie le rapport par temps passé dans chaque fonction elle-même.
python3 -m cProfile -s tottime lent.pyClients connus : 10000 20007 function calls in 1.192 seconds
Ordered by: internal time
ncalls tottime percall cumtime percall filename:lineno(function) 20000 1.189 0.000 1.189 0.000 lent.py:5(est_connu) 1 0.003 0.003 1.191 1.191 lent.py:9(compter_connus) 1 0.000 0.000 1.192 1.192 lent.py:17(main) 1 0.000 0.000 0.000 0.000 lent.py:1(charger_reference) 1 0.000 0.000 1.192 1.192 lent.py:1(<module>) 1 0.000 0.000 0.000 0.000 {built-in method builtins.print}Le rapport est sans ambiguïté. La fonction est_connu est appelée 20 000 fois et concentre 1,189 seconde sur les 1,192 du total. C'est le hotspot. Deux colonnes racontent l'histoire : ncalls (nombre d'appels) et tottime (temps passé dans la fonction hors sous-appels). Le tableau ci-dessous détaille chaque colonne.
| Colonne | Signification |
|---|---|
ncalls | Nombre d'appels de la fonction |
tottime | Temps passé dans la fonction seule, hors sous-appels |
percall | tottime divisé par ncalls |
cumtime | Temps cumulé, sous-appels inclus |
filename:lineno | Emplacement de la fonction |
Pour remonter la chaîne d'appels, on trie plutôt par cumtime (temps cumulé, sous-appels compris). L'API pstats offre ce contrôle fin. La méthode strip_dirs raccourcit les chemins de fichiers, sort_stats choisit le critère de tri et print_stats limite le nombre de lignes affichées.
import cProfileimport pstats
from lent import main
profiler = cProfile.Profile()profiler.enable()main()profiler.disable()
stats = pstats.Stats(profiler)stats.strip_dirs()stats.sort_stats("cumulative")stats.print_stats(6) 20005 function calls in 1.231 seconds
Ordered by: cumulative time
ncalls tottime percall cumtime percall filename:lineno(function) 1 0.000 0.000 1.231 1.231 lent.py:17(main) 1 0.003 0.003 1.231 1.231 lent.py:9(compter_connus) 20000 1.228 0.000 1.228 0.000 lent.py:5(est_connu) 1 0.000 0.000 0.000 0.000 lent.py:1(charger_reference)Trié par cumtime, le rapport se lit de haut en bas comme une pile d'appels : main délègue à compter_connus, qui passe tout son temps dans est_connu. Le tottime proche de zéro pour main et compter_connus confirme qu'elles ne font quasiment rien elles-mêmes : le coût est entièrement dans la fonction feuille. Le hotspot est isolé.
Corriger le hotspot et re-mesurer le gain
Section intitulée « Corriger le hotspot et re-mesurer le gain »L'analyse a montré que est_connu teste l'appartenance à une liste, une opération linéaire répétée 20 000 fois. La correction est algorithmique : charger la référence dans un set transforme chaque test en temps constant. On en profite pour condenser la boucle avec sum sur un générateur.
def charger_reference(): return set(range(20000))
def compter_connus(commandes, reference): return sum(1 for identifiant in commandes if identifiant in reference)
def main(): reference = charger_reference() commandes = list(range(0, 40000, 2)) resultat = compter_connus(commandes, reference) print(f"Clients connus : {resultat}")
if __name__ == "__main__": main()On re-profile exactement de la même manière pour vérifier que le hotspot a disparu. C'est l'étape que l'on saute trop souvent : une optimisation non mesurée est une hypothèse, pas un résultat.
python3 -m cProfile -s tottime rapide.pyClients connus : 10000 10009 function calls in 0.004 seconds
ncalls tottime percall cumtime percall filename:lineno(function) 1 0.002 0.002 0.002 0.002 rapide.py:1(charger_reference) 10001 0.001 0.000 0.001 0.000 rapide.py:6(<genexpr>) 1 0.001 0.001 0.002 0.002 {built-in method builtins.sum}Le temps total passe de 1,192 seconde à 0,004 seconde, et est_connu a disparu du rapport. Pour chiffrer le gain hors surcoût du profileur (qui ralentit tout code instrumenté), on mesure les deux versions avec timeit sur la même charge.
import timeit
setup_lent = """reference = list(range(20000))commandes = list(range(0, 40000, 2))def compter(commandes, reference): return sum(1 for c in commandes if c in reference)"""
setup_rapide = """reference = set(range(20000))commandes = list(range(0, 40000, 2))def compter(commandes, reference): return sum(1 for c in commandes if c in reference)"""
lent = timeit.timeit("compter(commandes, reference)", setup=setup_lent, number=5)rapide = timeit.timeit("compter(commandes, reference)", setup=setup_rapide, number=5)
print(f"Liste (5 executions) : {lent:.3f} s")print(f"Set (5 executions) : {rapide:.4f} s")print(f"Gain : x{lent / rapide:.0f}")Liste (5 executions) : 6.333 sSet (5 executions) : 0.0016 sGain : x3856Le gain mesuré est de l'ordre de plusieurs milliers, obtenu en changeant une seule ligne : le type de la structure de référence. C'est la leçon centrale du profiling. La performance vient rarement de micro-astuces sur la syntaxe, elle vient du bon algorithme et de la bonne structure de données au bon endroit, désigné par la mesure.
tracemalloc : mesurer l'empreinte mémoire
Section intitulée « tracemalloc : mesurer l'empreinte mémoire »Le temps n'est pas la seule ressource. Un programme peut être rapide et pourtant saturer la mémoire. tracemalloc, dans la bibliothèque standard, suit chaque allocation et indique combien d'octets sont alloués et par quelle ligne. On l'active avec start, on prend un snapshot avec take_snapshot, puis on trie les statistiques par ligne.
import tracemalloc
def construire_index(): lignes = [] for i in range(100000): lignes.append({"id": i, "nom": f"client-{i}", "actif": True}) return lignes
tracemalloc.start()
index = construire_index()
snapshot = tracemalloc.take_snapshot()top = snapshot.statistics("lineno")
print("Top 3 des allocations :")for stat in top[:3]: print(stat)
actuel, pic = tracemalloc.get_traced_memory()print(f"\nMemoire courante : {actuel / 1024 / 1024:.1f} Mio")print(f"Pic de memoire : {pic / 1024 / 1024:.1f} Mio")tracemalloc.stop()Top 3 des allocations :memoire.py:8: size=23.4 MiB, count=299974, average=82 Bmemoire.py:7: size=3117 KiB, count=99743, average=32 Btracemalloc.py:558: size=56 B, count=1, average=56 B
Memoire courante : 26.4 MioPic de memoire : 26.4 MioLe résultat pointe la ligne 8 (la création des dictionnaires) comme responsable de 23,4 Mio répartis sur près de 300 000 objets. La fonction get_traced_memory ajoute deux chiffres essentiels : la mémoire courante et le pic atteint depuis le démarrage du suivi. Le pic révèle les pointes transitoires qu'une simple mesure finale masquerait, par exemple une liste intermédiaire construite puis jetée.
Le second usage clé est la comparaison de deux snapshots. Elle sert à traquer une fuite mémoire : on capture un état, on exécute la section suspecte, on capture à nouveau, et compare_to classe les lignes par hausse d'allocation.
import tracemalloc
tracemalloc.start()
avant = tracemalloc.take_snapshot()
cache = []for i in range(50000): cache.append(f"evenement-{i}-traite")
apres = tracemalloc.take_snapshot()
diff = apres.compare_to(avant, "lineno")print("Plus fortes hausses entre les deux snapshots :")for stat in diff[:3]: print(stat)Plus fortes hausses entre les deux snapshots :diff_memoire.py:10: size=3499 KiB (+3499 KiB), count=50001 (+50001), average=72 Bdiff_memoire.py:5: size=400 B (+400 B), count=1 (+1), average=400 Btracemalloc.py:560: size=312 B (+312 B), count=2 (+2), average=156 BLa ligne 10 affiche +3499 Kio pour 50 001 objets créés entre les deux instants. Sur un service qui tourne longtemps, cette technique isole la structure qui grossit sans jamais se vider, souvent un cache sans limite ou une liste globale que personne ne purge. Le diff désigne le fichier et la ligne exacts à corriger.
py-spy : profiler un processus en production
Section intitulée « py-spy : profiler un processus en production »cProfile instrumente le code, ce qui le ralentit et suppose de pouvoir modifier ou relancer le programme. En production, ces deux conditions manquent souvent. py-spy est un profileur par échantillonnage écrit en Rust : il s'attache à un processus Python déjà lancé via son identifiant (PID), lit sa pile d'appels à intervalles réguliers et déduit où le temps passe, sans modifier le programme ni le ralentir de façon notable.
On l'installe hors de la bibliothèque standard, de préférence dans un environnement isolé avec pipx pour ne pas polluer le système. La commande top affiche en direct, à la manière de l'utilitaire système du même nom, les fonctions qui consomment le plus.
pipx install py-spypy-spy top --pid 12345Pour garder une trace exploitable, record échantillonne pendant une durée donnée et génère un graphe de flamme (flame graph) au format SVG. Ce visuel empile les appels : plus une fonction est large, plus elle consomme de temps cumulé.
py-spy record -o profil.svg --pid 12345Quand un processus semble figé, dump affiche instantanément la pile d'appels courante de chaque thread. C'est l'équivalent d'un débogueur d'urgence : on voit immédiatement sur quelle ligne le programme est bloqué.
py-spy dump --pid 12345Visualiser un profil avec snakeviz
Section intitulée « Visualiser un profil avec snakeviz »Un rapport pstats texte suffit pour un hotspot évident, mais devient illisible sur un programme aux centaines de fonctions imbriquées. snakeviz transforme un fichier de profil en visualisation interactive dans le navigateur, où la taille de chaque bloc reflète le temps consommé. On produit d'abord un fichier de profil avec l'option -o de cProfile, puis on l'ouvre.
python3 -m cProfile -o profil.prof lent.pypipx run snakeviz profil.profLe même fichier .prof se relit aussi par programme avec pstats, ce qui permet d'archiver un profil et de l'analyser plus tard sans relancer la charge. Cette persistance est précieuse pour comparer un avant et un après sur un cas reproductible, exactement la logique du fil rouge de ce guide.
Contrôle de connaissances
Section intitulée « Contrôle de connaissances »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
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
Vérification
(0/0)Profil de compétences
Quoi faire maintenant
Ressources pour progresser
Des indices pour retenter votre chance ?
Nouveau quiz complet avec des questions aléatoires
Retravailler uniquement les questions ratées
Retour à la liste des certifications
À retenir
Section intitulée « À retenir »- On mesure avant d'optimiser : l'intuition se trompe presque toujours sur la localisation du hotspot.
timeitcompare deux écritures d'une expression isolée ; on retient le meilleur temps, jamais la moyenne.cProfileprofile un programme entier ;tottimeisole la fonction coûteuse,cumtimeremonte la chaîne d'appels.pstatsfiltre et trie le rapport (strip_dirs,sort_stats,print_stats) et relit un fichier.profarchivé.- Le vrai gain vient du bon algorithme et de la bonne structure de données : passer d'une liste à un set a divisé le temps par plusieurs milliers.
tracemallocdonne le top des allocations, le pic de mémoire et le diff entre deux snapshots pour traquer une fuite.py-spyéchantillonne un processus vivant sans l'instrumenter ;top,recordetdumpcouvrent le direct, le graphe de flamme et le blocage.snakevizrend un profil complexe lisible dans le navigateur ; on re-mesure toujours pour chiffrer le gain.
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Les générateurs : produire les données à la demande pour réduire l'empreinte mémoire mesurée avec tracemalloc.
- itertools et functools : des briques optimisées en C qui remplacent souvent une boucle Python coûteuse repérée au profileur.
- Le linting en Python : attraper en amont les motifs lents et les structures inadaptées avant même de profiler.
- La gestion de la mémoire : comprendre
__slots__, l'interning et les fuites une fois que le profil pointe une empreinte anormale. - Cython : réécrire en natif le point chaud identifié au profileur quand le Python pur atteint ses limites.