étude de performances¶
imaginons que vous venez d’écrire un code magnifique pour résoudre le Rubik’s cube ou calculer la quadrature de la Tour Eiffel, ou peu importe
bon, le code marche, il retourne bien les bonnes valeurs; sauf que c’est mou, le programme se traîne et n’en finit pas de mouliner, alors que vous avez le sentiment que ça devrait aller beaucoup plus vite que ça
dans ces cas-là, le premier réflexe, c’est de commencer par une petite analyse introspective
ça devrait être quoi en principe la complexité ?
ça vaut toujours la peine de s’arrêter 5 minutes pour ré-évaluer, sur le code présent et non pas sur la version d’il y a deux semaines, la complexité théorique du code
en n’oubliant pas de prendre en compte les spécificités des types de données qu’on a choisies; rappelez-vous le cours, et regardez bien toutes les fois où vous balayez toute une liste (on vous bassine depuis le début du cours sur le fait que les recherches dans une liste sont en O(n)); assurez-vous de bien utiliser des ensembles si vous avez beaucoup de recherches à faire, ce genre de chosesest-ce que cette complexité théorique est bien confirmée par l’expérience ?
par exemple si vous attendez à un algo quadratique, est-ce que de multiplier la taille des entrées par 3 ou par 10, ça provoque un allongement dans un rapport 9 ou 100 ?
c’est important de faire cette analyse, et de ne pas se jeter immédiatement dans le profiling
mais clairement avec juste un cerveau ça n’est pas forcément trivial de tout prendre en compte, surtout lorsqu’on utilise des librairies tierces qui fonctionnent on ne sait pas toujours exactement comment
c’est quoi le profiling ?¶
du coup, pour pouvoir faire une analyse basée sur des faits objectifs, il existe une technique, le profiling donc, qui repose sur une idée relativement simple.
dans le cas de Python, le code est interprété; ça signifie que c’est l’interpréteur qui se charge de faire avancer le programme; et si on lui demande gentiment, on peut faire en sorte qu’il enregistre les événements du type appel de fonction, avec l’instant où ça se passe.
pour information, on appelle cette méthode le profiling déterministe, car tous les événements sont pris en compte; avec les langages compilés comme C++, on est obligé d’utiliser une approche statistique, qui consiste à échantillonner à intervalle régulier l’état de la pile d’exécution; mais peu importe..
en pratique : cProfile
¶
pour commencer on va utiliser un script simplissime qui est celui-ci
(souvenez-vous que les commandes avec un !
sont à destination du terminal et pas de Python)
! cat fibo.py
def fibo(n):
unused = 10
if n <= 1:
return 1
return fibo(n-1) + fibo(n-2)
def main():
import sys
print(fibo(int(sys.argv[1])))
main()
lancer en mode profiling¶
si vous devez ne retenir qu’une chose pour la mise en pratique, c’est que pour un programme qui se lance comme ceci
! python fibo.py 32
3524578
alors vous pouvez le lancer en mode profiling en invoquant le module cProfile
comme ceci
# il suffit d'ajouter le '-m cProfile'
! python -m cProfile fibo.py 32
3524578
7049160 function calls (6 primitive calls) in 1.842 seconds
Ordered by: standard name
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 1.842 1.842 fibo.py:1(<module>)
7049155/1 1.842 0.000 1.842 1.842 fibo.py:1(fibo)
1 0.000 0.000 1.842 1.842 fibo.py:7(main)
1 0.000 0.000 1.842 1.842 {built-in method builtins.exec}
1 0.000 0.000 0.000 0.000 {built-in method builtins.print}
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
comment lire le résultat¶
en gros, on y trouve une ligne par fonction appelée dans le programme
dans notre premier exemple trivialissime, nous n’avons défini que deux fonctions fibo
et main
; (il y a toujours un peu de bruit, des fonctions appelées par l’interpréteur, à ignorer)
pour chaque fonction on va trouver
ncalls
: le nombre de fois que la fonction a été appeléetottime
: le temps CPU pendant laquelle la fonction a eu le CPUcumtime
: le temps CPU pendant laquelle la fonction ou une fonction appelée par elle, a eu le CPU
ainsi dans notre exemple, la fonction main
a un cumtime
quasi égal
à celui de fibo
(parce que pendant tout le temps on est dans fibo
,
on est aussi indirectement dans main
) , mais un temps “en propre”
quasi nul
notez la subtilité, ici on parle de temps CPU, pas de temps ressenti
c’est quoi la différence, me direz-vous ?
imaginez une fonction qui attend un paquet en provenance du réseau
le temps ressenti (en anglais on parle de wallclock time) c’est basé sur l’heure usuelle; on va mesurer le temps ressenti comme la différence entre l’heure à laquelle la fonction commence son travail et celle où elle le finit; si le paquet réseau met 1 s à arriver, le temps ressenti pour exécuter la fonction c’est 1s
le temps CPU au contraire, c’est lié au nombre de cycles pendant lesquels la fonction a le CPU, et dans notre cas ça va être plutôt quelque chose d’un ordre de grandeur largement inférieur à la milliseconde
trier le résultat¶
pour trier selon une colonne, on ajoute encore l’option -s
, (s comme
sort évidemment)
# pour trier selon le nombre d'appels - la colonne 'ncalls'
! python -m cProfile -s ncalls fibo.py 32
3524578
7049160 function calls (6 primitive calls) in 1.784 seconds
Ordered by: call count
ncalls tottime percall cumtime percall filename:lineno(function)
7049155/1 1.784 0.000 1.784 1.784 fibo.py:1(fibo)
1 0.000 0.000 1.784 1.784 {built-in method builtins.exec}
1 0.000 0.000 0.000 0.000 {built-in method builtins.print}
1 0.000 0.000 1.784 1.784 fibo.py:7(main)
1 0.000 0.000 1.784 1.784 fibo.py:1(<module>)
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
comment s’en servir¶
dans un cas réel, ce n’est pas ncalls
qui nous intéresse le plus, et souvent la découverte la plus intéressante est faite en triant selon la colonne tottime
, la fonction qui mange le plus de CPU
exercice¶
reprenez un de vos codes - par exemple le taquin, sujet qui se prête particulièremente bien ici
utilisez le profiler pour trouver la fonction qui consomme le plus de CPU
comparez ça par rapport au temps total d’exécution du programme
si le gagnant consomme plus de 20% du temps total, il y a matière à agir
faites une ou deux itérations d’optimisation, tout en utilisant le profiler
right tool for the job¶
juste pour bien préciser le vocabulaire, on a vu pendant le cours plusieurs techniques qui sont bien distinctes, qu’il faut savoir utiliser à bon escient
le debugging c’est l’art de passer d’un premier jet à un code qui marche vraiment; dit autrement, il s’agit de trouver où et/ou pourquoi un programme ne fait pas tout à fait exactement ce qu’on voudrait
pour cette phase, on peut soit a) instrumenter manuellement le code en ajoutant desprint()
, ou b) faire tourner le programme sous un debugger - par exemple sous vs-code - qui au prix d’un peu de setup et d’apprentissage, est un outil bien plus puissant pour gérer les breakpoint à la souris, exécuter le programme pas à pas tout en inspectant la valeur des variables, etc…le benchmarking, qui consiste à mesurer très finement le temps d’exécution d’un fragment de code
pour ça on a rencontré déjà le moduletimeit
, avec comme compagnons les magic Jupyter en%timeit
et%%timeit
le profiling, qui consiste à observer le programme en cours d’exécution, pour créer des statistiques, et ultimement déterminer la partie de code la plus sollicitée, en vue d’optimiser le code
pour ça on utilise les outils vus plus haut, commecProfile
le test : on veut avoir une info de synthèse sur le bon comportement du code dans idéalement un grand nombre de situtations - the more the better
pour ça on utilise des outils comme
pytest
; l’objectif ici étant de donner une synthèse sur le plus grand nombre possible de tests, ces outils sont mal adaptés au debugging; dit autrement, lorsqu’un test échoue on est censé retourner à la case debug
see also¶
la documentation standard Python sur le profiling
il y a notamment toute une API pour utiliser le profiler
cet article très intéressant vous parle des alternatives à cProfile
https://pythonspeed.com/articles/beyond-cprofile/