Retour au cours

backend / python

Performance : profiling, cProfile et gestion mémoire

Leçon 241 exercice

Explication

Ce que vous allez apprendre

  • Profiler un programme entier avec cProfile pour trouver où le temps est réellement passé
  • Mesurer précisément un micro-benchmark isolé avec timeit
  • Reconnaître les optimisations idiomatiques qui changent la complexité (join, set, lru_cache)
  • Mesurer la consommation mémoire d'un programme avec tracemalloc
  • Savoir reconnaître quand Python pur atteint ses limites et qu'il faut envisager NumPy ou Cython

Dans quel contexte ?

Un développeur reçoit une alerte de monitoring : un endpoint qui traite des rapports met désormais 8 secondes à répondre, contre moins d'une seconde le mois dernier. Plutôt que de deviner que "c'est sûrement la boucle sur les lignes", il lance cProfile sur la fonction concernée et découvre que 95% du temps est passé dans une concaténation de chaînes avec += répétée sur des milliers d'itérations — un problème que l'intuition n'aurait probablement pas pointé du premier coup.

L'intuition ment souvent sur les performances

Face à un programme lent, la tentation est de deviner où se situe le goulet d'étranglement et de l'optimiser directement. C'est presque toujours une erreur : l'intuition se trompe fréquemment sur l'endroit réel où le temps est passé. La seule méthode fiable consiste à mesurer d'abord, avec des outils dédiés, avant de toucher au moindre code.

Piège fréquent

Optimiser du code sans l'avoir profilé au préalable fait perdre du temps sur des parties qui ne représentent qu'une fraction négligeable du temps d'exécution réel. Mesurez toujours avec cProfile ou timeit avant de changer la moindre ligne pour des raisons de performance.

Deux outils, deux échelles de mesure

cProfile donne une vue d'ensemble d'un programme entier, en indiquant combien de temps est passé dans chaque fonction. timeit, à l'inverse, mesure précisément un micro-benchmark isolé, une seule expression répétée un grand nombre de fois, en éliminant le bruit lié au reste du système. On choisit l'un ou l'autre selon qu'on cherche à comprendre un programme complet ou à comparer deux façons d'écrire une même opération.

Des optimisations qui reviennent constamment

Certaines habitudes améliorent systématiquement les performances Python sans sacrifier la lisibilité : utiliser str.join() plutôt que concaténer des chaînes avec += en boucle, utiliser un set plutôt qu'une liste pour tester des appartenances répétées, ou mémoïser une fonction pure coûteuse avec functools.lru_cache. Ce ne sont pas des micro-optimisations anecdotiques : sur de gros volumes, elles changent la complexité algorithmique du programme.

Anti-patternAlternativeGain typique
resultat += mot en boucle" ".join(mots)O(n²) devient O(n)
if x in ma_liste: répétéif x in mon_set:O(n) devient O(1)
Recalcul répété d'une fonction pure@lru_cacheÉvite tout recalcul redondant

Savoir reconnaître les limites de Python pur

Certains problèmes, notamment le calcul numérique intensif, dépassent ce que l'optimisation Python pure peut offrir. Cette leçon prépare directement la suivante, consacrée aux extensions C et à Cython, pour les cas où profiler révèle qu'aucune optimisation Python ne suffira.

Commandes & code

Performance : profiling et optimisation

Mesurer avant d'optimiser : identifier les vrais goulets d'étranglement.

python
import cProfile
import pstats
import timeit
from functools import lru_cache

# --- cProfile : profiler l'ensemble d'un programme ---
def fonction_lente():
    total = 0
    for i in range(1_000_000):
        total += i ** 2
    return total

def fonction_rapide():
    return sum(i ** 2 for i in range(1_000_000))

def programme():
    fonction_lente()
    fonction_rapide()

cProfile.run("programme()", "profile_output.prof")

stats = pstats.Stats("profile_output.prof")
stats.sort_stats("cumulative").print_stats(10)     # top 10 fonctions les plus couteuses

# --- timeit : mesurer precisement un micro-benchmark ---
temps_boucle = timeit.timeit(
    "sum(i**2 for i in range(1000))", number=10_000
)
temps_liste = timeit.timeit(
    "sum([i**2 for i in range(1000)])", number=10_000
)
print(f"Generateur : {temps_boucle:.3f}s, Liste : {temps_liste:.3f}s")

# --- Optimisations idiomatiques courantes ---

# 1. Concatenation de chaines : eviter += en boucle (O(n^2)), preferer join
def mauvais_join(mots):
    resultat = ""
    for mot in mots:
        resultat += mot + " "        # cree une nouvelle chaine a chaque iteration
    return resultat

def bon_join(mots):
    return " ".join(mots)             # une seule allocation

# 2. Recherche : set/dict (O(1)) plutot que list (O(n)) pour un "in" repete
def mauvais_recherche(items, valeurs_recherchees):
    return [v for v in valeurs_recherchees if v in items]        # items est une list

def bon_recherche(items, valeurs_recherchees):
    items_set = set(items)
    return [v for v in valeurs_recherchees if v in items_set]     # O(1) par recherche

# 3. Memoisation avec lru_cache pour eviter les recalculs
@lru_cache(maxsize=None)
def fibonacci(n):
    return n if n < 2 else fibonacci(n - 1) + fibonacci(n - 2)

# 4. Utiliser les fonctions natives (implementees en C, bien plus rapides)
donnees = list(range(100_000))
somme_native = sum(donnees)                        # rapide (C)
somme_manuelle = 0
for x in donnees:
    somme_manuelle += x                              # plus lent (bytecode Python)

# 5. Local variable caching dans les boucles chaudes
class Traitement:
    def methode_lente(self, donnees):
        resultat = []
        for x in donnees:
            resultat.append(self.transformer(x))      # attribut lookup a chaque iteration
        return resultat

    def methode_rapide(self, donnees):
        transformer = self.transformer                 # cache la reference localement
        resultat = []
        for x in donnees:
            resultat.append(transformer(x))
        return resultat

    def transformer(self, x):
        return x * 2

# --- Profiling memoire ---
import tracemalloc

tracemalloc.start()
grosse_liste = [i for i in range(1_000_000)]
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics("lineno")
for stat in top_stats[:3]:
    print(stat)
tracemalloc.stop()

# --- Quand aller au-dela de Python pur ---
# NumPy : vectorisation, delegue les boucles a du code C optimise (BLAS/LAPACK)
# import numpy as np
# array = np.arange(1_000_000)
# resultat = array ** 2          # bien plus rapide qu'une comprehension pure Python

# Cython : compiler un sous-ensemble de Python (avec annotations de type) vers du C
# fichier .pyx :
# def fibonacci_cython(int n):
#     cdef int a = 0, b = 1, i
#     for i in range(n):
#         a, b = b, a + b
#     return a

# PyPy : implementation alternative avec JIT, souvent 4-10x plus rapide sur du code CPU-bound pur

Résumé

  • Toujours profiler (cProfile, timeit) avant d'optimiser : l'intuition sur les goulets est souvent fausse.
  • str.join() plutôt que += en boucle, set/dict plutôt que list pour les recherches fréquentes.
  • functools.lru_cache élimine les recalculs redondants dans les fonctions pures.
  • Au-delà de l'optimisation Python pure : NumPy (vectorisation), Cython ou PyPy pour le calcul intensif.

Exercices pratiques

1 disponible
1

Mission : retrouver les 95% du temps perdu dans un rapport

Objectif : Utiliser le profiling pour localiser un vrai goulet d'étranglement plutôt que de deviner, puis appliquer les bonnes optimisations idiomatiques.

Contexte

Un endpoint de génération de rapports est passé de moins d'une seconde à 8 secondes de temps de réponse. Un développeur est persuadé que le problème vient de la boucle de calcul sur les lignes et s'apprête à la réécrire entièrement, sans avoir mesuré quoi que ce soit au préalable.

Tu dois d'abord établir pourquoi deviner est risqué, profiler mentalement un scénario de concaténation de chaînes en boucle, corriger le code, puis choisir la bonne structure de données pour une recherche répétée.

Résoudre l’exercice →