Comment profiler et optimiser n'importe quel type de code Python
Une fonction, un script, une API, une application web Django ?
Python est polyvalent, flexible et nous permet de réaliser beaucoup de choses.
Cependant, parfois notre code est lent et aurait bien besoin d’être optimisé !
Dans ce guide, nous allons voir des méthodes pour profiler les cas les plus fréquents dans le monde réel :
Une fonction Python
Un script Python
Une API Flask & FastAPI
Une route Django
Le profiling est une étape essentielle à l’optimisation de vos performances.
Sans profiling vous risquez de faire des optimisations inutiles, voire dans le pire des cas, nuire aux performances.
Le code fourni a été pensé afin d’être réutilisable et adaptable à vos besoins.
Sans plus attendre, analysons notre script à profiler.
Note : Il existe des dizaines de méthodes pour profiler et mesurer les performances : le module timeit, la librairie py-spy, la commande Linux perf… Ici, je ne vous parlerai que de ceux que je trouve les plus performants et qui apportent le plus d’informations avec le minimum d’efforts.
Notre point de départ
Prenons comme point de départ un script de base qui traite une liste de 446 901 villes, extraites d'un fichier texte nommé cities.txt.
Notre défi ! Compter les villes dont les noms contiennent plus de X voyelles.
Ce script, intentionnellement non optimisé, va nous servir de cobaye. Il illustre une situation courante en développement :
on écrit d'abord un code qui fonctionne.
on mesure les performances.
on l'optimise pour améliorer ses performances si besoin.
Mais ici, nous irons plus loin.
Non seulement, nous profilerons ce script, mais également nous l'adapterons pour le profiler dans les divers environnements cités précédemment (script, API, Django …).
Notre objectif : comprendre comment visualiser les problèmes de performances en fonction de la situation . Bien que nous ne réaliserons pas l'optimisation elle-même, cette analyse fournira un cadre solide afin d’effectuer vos propres améliorations et mesurer l'impact sur les performances.
Voici notre code :
1) Une fonction
Ici nous voulons analyser la fonction find_cities_with_vowel_count
afin de mesurer le temps qu’elle prend, et les endroits chronophages.
1.1) Un décorateur pour mesurer le temps
Afin d’estimer le temps pris par la fonction, nous pouvons créer un décorateur que nous appliquerons sur les fonctions qui nous intéressent, et avoir une idée du temps d’execution. L’avantage est d’avoir un court overhead, donc de ne pas trop fausser les résultats.
Voici une implémentation possible de ce décorateur :
Ensuite, nous pouvons l’ajouter à notre fonction pour pouvoir mesurer le temps :
Lorsque nous exécutons notre programme normalement, nous avons ceci qui s’affiche dans la console :
92768
Elapsed time: 0.3522 seconds
Okay, donc cela traduit que dans notre liste, nous avons 92 768 villes ayant 5 ou plus de 5 voyelles. Et nous pouvons constater que notre script a pris 0.3522 secondes pour s’exécuter.
Mais, les indications sont trop maigres pour nous permettre de trouver les endroits chronophages.
1.2) Un décorateur pour profiler
Dans cet article, nous avons recours à cProfile, un deterministic profiler intégré à Python.
cProfile est un module de Python, donc nous pouvons l’utiliser directement dans notre script, nous pouvons donc réaliser un décorateur.
Un rapide rappel sur la manière de lire les résultats de cProfile:
Nombre d'appels (ncalls) : cProfile affiche le nombre total d'appels pour chaque fonction sous la colonne 'ncalls'. Cela inclut les appels directs et récursifs.
Appels récursifs (ncalls) : Les appels récursifs sont également inclus dans 'ncalls'. Les appels récursifs sont souvent indiqués sous une forme '3/1', où le premier nombre est le nombre total d'appels et le second le nombre d'appels récursifs distincts.
Temps total par fonction (cumtime) : Sous la colonne 'cumtime', cProfile indique le temps cumulatif passé dans la fonction et toutes les fonctions qu'elle appelle.
Temps propre par fonction (tottime) : Le temps propre, indiqué dans la colonne 'tottime', représente le temps passé uniquement dans la fonction elle-même, excluant le temps passé dans les fonctions appelées.
Voici un exemple :
Ce décorateur fonctionne sur le même principe que le précédent.
Il commence un profiling au début de l’exécution de la fonction, et arrête ce profiling aprés que la fonction ait terminé. Les résultats sont ensuite affichés en utilisant le module pstats.
Après avoir décoré notre fonction avec ce décorateur et exécuté notre code, nous observons plusieurs éléments clés :
Nous pouvons voir :
Le temps d'exécution passe de 0.3522 secondes à 2.10 secondes, soit presque un facteur de 6.
Bien que cet overhead soit important, il fournit des informations précieuses :
Par exemple, la méthode str.lower
est appelée 19 002 850 fois, prenant 0.909 secondes – presque la moitié de notre temps total. De plus, la fonction count_vowels
est appelée 446 902 fois (une fois par ville) et prend 1.102 secondes.
Ces deux fonctions représentent donc la majorité du temps d'exécution.
Ces observations indiquent clairement que pour optimiser notre script, nous devons réduire le nombre d'appels à .lower(),
et examiner de près le code de count_vowels
.
2) Un script
Lorsqu'il s'agit de mesurer le temps d'exécution total d'un script Python, plusieurs méthodes sont à notre disposition.
2.1) Utiliser la command Linux “time”
La méthode la plus simple consiste à utiliser la commande Linux time
. Cette approche offre un mesure rapide à mettre en place, avec un très faible overhead.
Avec time
, nous obtenons des informations basiques, mais utiles. Par exemple, nous pouvons voir que le script prend 0.367 secondes pour s'exécuter, ce qui est similaire à ce que nous avons mesuré précédemment avec time.perf_counter
.
De plus, l'indication de 99% d'utilisation du CPU suggère que notre programme consacre presque tout son temps à des opérations de calcul. Pour plus de détails sur la commande time
, vous pouvez consulter l'article dédié aux différents types de profilers.
Bien que ces informations soient utiles, elles n’apportent pas suffisamment de détails pour une mettre en place des optimisations.
2.2) Utiliser cProfile en CLI
Pour obtenir des informations plus détaillées, nous pouvons utiliser cProfile en ligne de commande. Cette méthode permet de profiler le script et d'en examiner les résultats de manière approfondie. Pour ce faire, il suffit d'exécuter la commande suivante :
Si vous voulez visualiser les résultats d’une manière un peu plus friendly, vous pouvez utiliser une librairie comme SnakeViz :
>>> pip install snakeviz
>>> python -m cProfile -o result.cprof profile_script.py
>>> snakeviz result.cprof
2.3) Utiliser PyInstrument
Nous pouvons également utiliser un statistical profiler comme PyInstrument pour mesurer le script en CLI.
Il est également possible d’avoir un rendu différent si on le souhaite, par exemple, en utilisant speedscope.
>>> pip install pyinstrument
>>> npm install -g speedscope
>>> pyinstrument -r speedscope -o profile.speedscope.json profile_script.py
>>> speedscope profile.speedscope.json
De la même manière que SnakeViz, c’est un rendu plus intéractif. Il est possible de naviguer dans les différentes vues afin de visualiser le déroulement de notre programme.
Nous pouvons filtrer, réorganiser, exporter, voir les call stacks. Bref, c’est un outil assez complet.
3) Une API
L'un des cas les plus courants est le profiling d’applications web.
Examinons trois scénarios fréquents : le profiling de routes dans Flask, FastAPI et Django.
Nous allons mettre en place des solutions génériques que vous pourrez réutiliser facilement.
3.1) Flask
Prenons l'exemple d'une application Flask basique.
Nous avons configuré un serveur Flask avec une route simple vers "/", qui exécute notre code et renvoie le résultat.
En utilisant Flask, il est possible d’executer du code juste avant qu'une requête HTTP ne soit traitée, et également après qu’elle ait été traitée.
Voyez-vous les possibilités ? 🧐
Finalement nous nous retrouvons à faire quasiment la même chose qu'avec le décorateur que l'on a vu plus haut !
Avant de rentrer dans notre route, nous démarrons le profiler et nous le stoppons une fois l’opération terminée.
Le véritable avantage ici, c'est la capacité de choisir quand profiler notre code. Comment ? Simplement en ajoutant un query parameter ?profile
dans l'URL.
Cette approche est très pratique car elle ne nécessite aucun changement dans les routes existantes ou l'ajout de décorateurs supplémentaires.
Accès standard à l'application :
URL : http://127.0.0.1:5000/
Résultat : “OK”, affichage normal sans profiling
Accès avec profiling activé :
URL : http://127.0.0.1:5000/?profile
Résultat : le résultat du profiling est généré et renvoyé
Vous pouvez évidemment modifier ce code pour utiliser d’autres types de profilers plutôt que PyInstrument.
3.2) FastAPI
De la même manière que Flask, nous allons utiliser le concept de middleware sur FastAPI.
C’est du code qui est executé pour chaque requête.
Nous allons vérifier s’il y a un paramètre“profile” dans l’URL. Si oui, nous renvoyons le résultat du profiling. Dans le cas contraire, nous renvoyons le résultat normal.
Et hop là :
Comme pour les autres cas, nous utilisons PyInstrument dans cet exemple, mais vous pouvez utiliser un autre profiler sans soucis.
3.3) Django
Si vous utilisez Django, l'intégration de PyInstrument pour le profiling requiert seulement quelques étapes simples :
Installation de PyInstrument : Commencez par installer PyInstrument dans votre projet.
Configuration du Middleware : Il suffit d'ajouter un middleware en modifiant la variable
MIDDLEWARE
dans vos paramètres Django.
Cette modification se résume à l'ajout d'une seule ligne dans la configuration.
Quand le middleware de PyInstrument est configuré, il suffit d'ajouter un query parameter “profile”
à vos URL pour activer le profiling.
Voici comment cela fonctionne en pratique :
URL Standard : Accéder à
http://127.0.0.1:5000/example
renverra le résultat normalURL avec Profiling : Accéder à
http://127.0.0.1:5000/example?profile=1
les résultats du profiling seront renvoyés pour cette route.
Vous pouvez également ajouter de la configuration pour activer ou désactiver ce comportement en production.
Conclusion
Le profiling peut s'appliquer à presque tout en Python: scripts, fonctions, API, applications web.
Les exemples de code fournis sont conçus pour être directement réutilisables et adaptables à différents types de profilers selon les besoins.
Les concepts de base sont similaires, rendant ces techniques transposables à d'autres frameworks web.
Liens utiles
Outils de profiling pour Django :
Les outils de visualisation: