Skip to main content
Niveau : Expert

Chapitre 40 : Profilage et Optimisation avec cProfile

Objectif

Ce module a pour but de vous apprendre à profiler votre code Python pour identifier les goulots d'étranglement. Vous découvrirez comment utiliser le module cProfile pour collecter des statistiques de performance et comment analyser ces données pour guider vos efforts d'optimisation.

"L'optimisation prématurée est la racine de tous les maux." - Donald Knuth

Cette citation célèbre nous rappelle qu'il ne faut pas optimiser à l'aveugle. Le profilage est l'outil qui nous permet de savoir et quoi optimiser.

1. Qu'est-ce que le Profilage ?

Le profilage est le processus d'analyse d'un programme pour déterminer quelles parties consomment le plus de ressources (temps CPU, mémoire, etc.). Un profileur est un outil qui collecte ces informations pendant l'exécution du programme.

En Python, le profileur standard pour le temps d'exécution est cProfile. Il est écrit en C, ce qui le rend très rapide et adapté pour profiler même des programmes complexes avec un surcoût minimal.

cProfile enregistre des informations sur chaque appel de fonction :

  • Le nombre de fois où la fonction a été appelée.
  • Le temps total passé dans la fonction.
  • Le temps cumulé passé dans la fonction et toutes les fonctions qu'elle a appelées.

2. Utiliser cProfile

Il y a plusieurs manières d'utiliser cProfile.

a. Depuis la Ligne de Commande

C'est la manière la plus simple de profiler un script entier sans modifier son code.

python -m cProfile -o stats.prof mon_script.py
  • python -m cProfile: Exécute le module cProfile comme un script.
  • -o stats.prof: (Optionnel) Sauvegarde les résultats du profilage dans un fichier binaire nommé stats.prof. C'est la méthode recommandée car elle permet une analyse plus poussée.
  • mon_script.py: Le script à profiler.

Si vous n'utilisez pas l'option -o, les résultats sont affichés directement dans la console, mais ils peuvent être très longs et difficiles à lire.

b. Dans le Code

Vous pouvez aussi utiliser cProfile directement dans votre code pour profiler une section spécifique.

import cProfile
import pstats

def ma_fonction_lente():
# ... du code à profiler ...
total = 0
for i in range(1000000):
total += i
return total

# Créer un objet Profile
profiler = cProfile.Profile()

# Activer le profileur
profiler.enable()

# Exécuter le code à profiler
ma_fonction_lente()

# Désactiver le profileur
profiler.disable()

# Sauvegarder les statistiques
profiler.dump_stats("stats.prof")

Cette approche est utile pour isoler une partie précise de votre application.

3. Analyser les Résultats avec pstats

Une fois que vous avez votre fichier de statistiques (stats.prof), le module pstats (Python Stats) devient votre meilleur ami. Il fournit une interface interactive pour trier et analyser les données.

Lancez l'interpréteur pstats depuis la ligne de commande :

python -m pstats stats.prof

Vous obtiendrez un prompt (stats.prof). Voici les commandes les plus utiles :

  • stats [n]: Affiche les statistiques. n est un entier optionnel pour limiter le nombre de lignes.
  • sort <clé>: Trie le rapport selon une clé. C'est la commande la plus importante.
  • callers [n]: Affiche les fonctions qui ont appelé d'autres fonctions.
  • callees [n]: Affiche les fonctions qui ont été appelées par d'autres fonctions.
  • help: Affiche l'aide.
  • quit: Quitte l'interpréteur.

Les Clés de Tri Essentielles

  • tottime (Total Time) : Temps total passé dans la fonction elle-même, sans compter le temps passé dans les sous-fonctions qu'elle appelle. C'est la métrique la plus importante pour trouver les fonctions qui sont lentes par elles-mêmes.
  • cumtime (Cumulative Time) : Temps cumulé passé dans la fonction ET dans toutes les sous-fonctions qu'elle appelle. Utile pour identifier les points de départ des opérations coûteuses.
  • ncalls (Number of Calls) : Nombre de fois où la fonction a été appelée. Une fonction rapide appelée des millions de fois peut devenir un goulot d'étranglement.
  • pcalls (Primitive Calls) : Nombre d'appels non récursifs.

Workflow typique d'analyse :

  1. Lancer pstats stats.prof.
  2. Taper sort tottime.
  3. Taper stats 10 pour voir les 10 fonctions où le plus de temps a été passé.
  4. Analyser la première ligne. Si c'est une fonction de votre code, vous avez trouvé un candidat à l'optimisation.
  5. Taper sort cumtime.
  6. Taper stats 10 pour voir les 10 fonctions qui sont à l'origine des chaînes d'appels les plus longues.

Exemple d'Analyse

Imaginons le script test.py suivant :

# test.py
def fonction_rapide():
pass

def fonction_lente():
total = 0
for _ in range(100000):
total += 1

def fonction_intermediaire():
for _ in range(10):
fonction_lente()

def main():
for _ in range(100):
fonction_rapide()
fonction_intermediaire()

main()

Après avoir lancé python -m cProfile -o test.prof test.py et python -m pstats test.prof :

(test.prof) sort tottime
(test.prof) stats 5

La sortie ressemblerait à ceci :

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
1000 0.045 0.000 0.045 0.000 test.py:5(fonction_lente)
1 0.001 0.001 0.046 0.046 test.py:9(fonction_intermediaire)
...
  • Analyse de tottime : On voit immédiatement que fonction_lente est la fonction où le programme passe le plus de temps intrinsèquement (0.045s). C'est notre cible n°1 pour l'optimisation.
(test.prof) sort cumtime
(test.prof) stats 5

La sortie ressemblerait à ceci :

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
1 0.000 0.000 0.046 0.046 test.py:17(<module>)
1 0.000 0.000 0.046 0.046 test.py:13(main)
1 0.001 0.001 0.046 0.046 test.py:9(fonction_intermediaire)
1000 0.045 0.000 0.045 0.000 test.py:5(fonction_lente)
...
  • Analyse de cumtime : On voit que main, fonction_intermediaire et le module lui-même ont un temps cumulé élevé. Cela nous montre la chaîne d'appels qui mène à la fonction lente. main appelle fonction_intermediaire, qui appelle fonction_lente.

4. Visualiser les Résultats

Lire les sorties textuelles de pstats est puissant mais peut être austère. Il existe des outils pour visualiser ces données de manière graphique.

  • SnakeViz : Un outil très populaire qui crée une visualisation interactive et solaire (sunburst) des données de profilage.

    1. pip install snakeviz
    2. snakeviz stats.prof (ou python -m snakeviz stats.prof) Cela ouvre une page web dans votre navigateur où vous pouvez explorer les données de manière intuitive.
  • gprof2dot et Graphviz : Ces outils permettent de générer un graphe d'appel où les nœuds sont colorés en fonction du temps passé.

    1. pip install gprof2dot
    2. Installer Graphviz (via brew, apt, etc.)
    3. python -m cProfile -o stats.prof mon_script.py
    4. gprof2dot -f pstats stats.prof | dot -Tpng -o output.png

Conclusion

Le profilage est une étape non négociable de toute optimisation sérieuse. cProfile est l'outil standard de la bibliothèque Python pour mesurer les performances temporelles de votre code. En l'utilisant conjointement avec pstats ou des outils de visualisation comme SnakeViz, vous pouvez obtenir des informations précises sur les parties de votre code qui nécessitent une attention particulière. N'oubliez jamais : Mesurez avant d'optimiser.

Exercice : Profilage et Optimisation d'un Calcul de Fibonacci

Objectif

Cet exercice a pour but de vous faire utiliser cProfile et pstats pour profiler une fonction inefficace, identifier le goulot d'étranglement, l'optimiser, et vérifier le gain de performance en profilant à nouveau.

Contexte

La suite de Fibonacci est un exemple classique en informatique. Une implémentation récursive naïve est très simple à écrire, mais terriblement inefficace car elle recalcule les mêmes valeurs des milliers de fois.

fib(n) = fib(n-1) + fib(n-2)

Par exemple, pour calculer fib(5), on calcule fib(4) et fib(3). Mais pour fib(4), on recalcule fib(3) et fib(2). fib(3) est donc calculé deux fois. Ce problème s'aggrave de manière exponentielle.

Vous allez profiler cette fonction, puis l'optimiser en utilisant la mémoïsation (une forme de cache), et comparer les résultats.

Énoncé

Partie 1 : Profilage de la version inefficace

  1. Créez un nouveau fichier Python nommé profile_fib.py.

  2. Écrivez la fonction Fibonacci récursive naïve.

    def fib(n):
    if n < 2:
    return n
    return fib(n - 1) + fib(n - 2)
  3. Écrivez une fonction principale main qui appelle fib(35). Le nombre 35 est assez grand pour que la fonction prenne quelques secondes.

    def main():
    result = fib(35)
    print(f"Fib(35) = {result}")
  4. Profilez le script depuis la ligne de commande.

    • Exécutez la commande : python -m cProfile -o fib_stats.prof profile_fib.py
    • Cela va prendre un certain temps.
  5. Analysez les résultats avec pstats.

    • Lancez pstats : python -m pstats fib_stats.prof
    • Triez par ncalls (nombre d'appels) et affichez les 5 premières lignes (sort ncalls, stats 5).
    • Triez par tottime (temps total) et affichez les 5 premières lignes (sort tottime, stats 5).
    • Notez le nombre total d'appels à la fonction fib et le temps total d'exécution.

Partie 2 : Optimisation et nouveau profilage

  1. Optimisez la fonction fib avec la mémoïsation.

    • La mémoïsation consiste à stocker les résultats des appels de fonction dans un cache (un dictionnaire) pour éviter de les recalculer.
    • Le module functools fournit un décorateur parfait pour cela : @functools.lru_cache.
  2. Modifiez votre fichier profile_fib.py.

    • Importez functools.
    • Ajoutez le décorateur @functools.lru_cache(maxsize=None) juste au-dessus de votre fonction fib.
    import functools

    @functools.lru_cache(maxsize=None)
    def fib(n):
    # ... même code qu'avant
  3. Profilez la version optimisée.

    • Exécutez à nouveau le profilage, mais en sauvegardant dans un autre fichier : python -m cProfile -o fib_optimized_stats.prof profile_fib.py
    • Notez que l'exécution est maintenant quasi instantanée.
  4. Analysez les nouveaux résultats.

    • Lancez pstats sur le nouveau fichier : python -m pstats fib_optimized_stats.prof
    • Triez par ncalls et tottime.
    • Comparez le nombre d'appels à fib et le temps d'exécution avec la version précédente.

Résultat Attendu

Analyse de la version 1 (naïve)

  • sort ncalls / stats 5 devrait montrer un nombre d'appels à fib extraordinairement élevé (plusieurs dizaines de millions).
    ncalls
    29860703 ... profile_fib.py:4(fib)
  • sort tottime / stats 5 devrait montrer que la quasi-totalité du temps est passée dans la fonction fib.
    tottime
    3.512 ... profile_fib.py:4(fib)

Analyse de la version 2 (optimisée)

  • sort ncalls / stats 5 devrait montrer que la fonction fib n'est appelée qu'environ 36 fois (une fois pour chaque nombre de 0 à 35).
    ncalls
    36 ... profile_fib.py:7(fib)
  • sort tottime / stats 5 devrait montrer un temps d'exécution négligeable (proche de 0.000s).

Cette comparaison démontre de manière spectaculaire comment le profilage permet d'identifier une fonction inefficace et de valider l'impact d'une optimisation.

Solution
# profile_fib.py

import functools

# Pour tester la version non optimisée, commentez la ligne @functools.lru_cache
@functools.lru_cache(maxsize=None)
def fib(n: int) -> int:
"""
Calcule le n-ième nombre de Fibonacci.
Version optimisée avec mémoïsation via lru_cache.
"""
if n < 2:
return n
return fib(n - 1) + fib(n - 2)

def main():
"""Fonction principale pour lancer le calcul."""
print("Calcul de Fib(35)...")
result = fib(35)
print(f"Fib(35) = {result}")

if __name__ == "__main__":
main()

Instructions d'exécution (rappel)

  1. Version non optimisée :

    • Commentez la ligne @functools.lru_cache(maxsize=None).
    • python -m cProfile -o fib_stats.prof profile_fib.py
    • python -m pstats fib_stats.prof
    • Dans pstats, tapez sort ncalls puis stats 5.
  2. Version optimisée :

    • Décommentez la ligne @functools.lru_cache(maxsize=None).
    • python -m cProfile -o fib_optimized_stats.prof profile_fib.py
    • python -m pstats fib_optimized_stats.prof
    • Dans pstats, tapez sort ncalls puis stats 5.