Aller au contenu
Développement medium

Python : profiling et performance (cProfile, timeit)

21 min de lecture

logo python

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.

  • Chronométrer une expression avec timeit, en ligne de commande et via l'API.
  • Profiler un programme entier avec cProfile et lire un rapport pstats.
  • Trier les fonctions par tottime et cumtime pour isoler le hotspot.
  • Mesurer l'empreinte mémoire et comparer deux snapshots avec tracemalloc.
  • Échantillonner un processus en production avec py-spy, sans instrumentation.

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.

QuestionOutilCe qu'il donne
Quelle écriture est la plus rapide ?timeitTemps d'une expression isolée
Quelle fonction coûte le plus ?cProfile + pstatsRépartition du temps par appel
Combien de mémoire est allouée et où ?tracemallocTop des allocations, diff de snapshots
Où passe le temps d'un process vivant ?py-spyÉchantillonnage sans instrumentation

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.

Fenêtre de terminal
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 loop
20000000 loops, best of 5: 10 nsec per loop

L'é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 us

On 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 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.

Fenêtre de terminal
python3 -m cProfile -s tottime lent.py
Clients 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.

ColonneSignification
ncallsNombre d'appels de la fonction
tottimeTemps passé dans la fonction seule, hors sous-appels
percalltottime divisé par ncalls
cumtimeTemps cumulé, sous-appels inclus
filename:linenoEmplacement 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 cProfile
import 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é.

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.

Fenêtre de terminal
python3 -m cProfile -s tottime rapide.py
Clients 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 s
Set (5 executions) : 0.0016 s
Gain : x3856

Le 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.

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 B
memoire.py:7: size=3117 KiB, count=99743, average=32 B
tracemalloc.py:558: size=56 B, count=1, average=56 B
Memoire courante : 26.4 Mio
Pic de memoire : 26.4 Mio

Le 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 B
diff_memoire.py:5: size=400 B (+400 B), count=1 (+1), average=400 B
tracemalloc.py:560: size=312 B (+312 B), count=2 (+2), average=156 B

La 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.

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.

Fenêtre de terminal
pipx install py-spy
py-spy top --pid 12345

Pour 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é.

Fenêtre de terminal
py-spy record -o profil.svg --pid 12345

Quand 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é.

Fenêtre de terminal
py-spy dump --pid 12345

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.

Fenêtre de terminal
python3 -m cProfile -o profil.prof lent.py
pipx run snakeviz profil.prof

Le 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.

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

  • On mesure avant d'optimiser : l'intuition se trompe presque toujours sur la localisation du hotspot.
  • timeit compare deux écritures d'une expression isolée ; on retient le meilleur temps, jamais la moyenne.
  • cProfile profile un programme entier ; tottime isole la fonction coûteuse, cumtime remonte la chaîne d'appels.
  • pstats filtre et trie le rapport (strip_dirs, sort_stats, print_stats) et relit un fichier .prof archivé.
  • 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.
  • tracemalloc donne 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, record et dump couvrent le direct, le graphe de flamme et le blocage.
  • snakeviz rend un profil complexe lisible dans le navigateur ; on re-mesure toujours pour chiffrer le gain.
  • 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.

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