mardi 13 juin 2017

Recherche de performance d'un programme python

Il est toujours intéressant de comprendre comment se décompose le temps d'exécution d'un programme. Cette démarche peut s'inscrire dans le cadre d'une recherche de la performance mais aussi dans le but d'utiliser au mieux des bonnes pratiques.

Avant tout, il est nécessaire d'avoir une mesure du temps d'exécution d'un programme. Le moyen le plus simple est d'utiliser la commande 'time'.  J'emploie cette commande avec l'option '-p' qui permet un affichage en seconde.

exemple:


time -p  python   programm1.py






Pour avoir le détail sur les fonctions qui consomment le plus en terme de temps d'exécution, il faut réaliser une opératoin de 'profilage'.  Le module cProfile est mon préféré car il s'utilise de manière externe sans modification du source et il est déjà installé. 

La commande pour l'activer est la suivante: 
python -m cProfile  -o eg.prof programme1.py 

L'option '-o'   est suivie du nom de fichier où seront stocké les statistiques.  L'option -s vient en exclusion avec l'option -oo , l'option 's' comme 'sort'  affecte un critere de tri  : exemple -s cumtime pour trier sur le temps cumulé.

 Exemple de sortie: 

       28917791 function calls (28917181 primitive calls) in 376.750 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
       66    0.000    0.000    0.000    0.000 :102(release)
       59    0.000    0.000    0.000    0.000 :142(__init__)
       59    0.000    0.000    0.002    0.000 :146(__enter__)
       59    0.000    0.000    0.001    0.000 :153(__exit__)
       66    0.001    0.000    0.001    0.000 :159(_get_module_lock)


Cette sortie n'est pas toujours facile à lire aussi j'utilise une interface graphique Kcachegrind (ou qcachegring sur MacoSX) 

L'installation de qcachefrind se fait par: 
brew install qcqchegrind
Puis
brew install graphviz

Il reste à convertir un fichier cProfile au format callgrind avec la commande:
pyprof2calltree -i eg.prof -o ant2.grind

(avec -i pour le fichier en entrée, l'option -o pour le fichier en sortie )


Le travail d'optimisation peut commencer en tenant compte de deux axes: 
Le temps d exécution d'une méthode (en seconde ou en %) et le nombre d'appel à cette fonction




Dans cet exemple la fonction recherche_conditionC3 est appelée 26524 fois et représente un coût de 85%.

La fonction avant optimisation est comme ceci :

def recherche_conditionC3(self, cle,date):
    resultat = 'KO'
    ligne=[]
    for item in self.tableC3:
       if item[0] == cle:
        if date >= item[1]:
            ligne = item
            resultat = 'OK'
       if item[0] > cle:
         break
    return(resultat,ligne)

Il est possible de remplacer une recherche systématique par un pattern de memoization: c'est à dire un type de cache.
....
  clecache = cle + date.strftime('%m-%d-%Y')
    if clecache in C3taux.dictC3taux:
      #pdb.set_trace()
      return('OK',C3taux.dictC3taux[clecache])
sinon : continuer dans la méthode.


Avec comme résultat: un cout de moins de 1% 




Après une série d'optimisation,  de 260 secondes on tombe à 80 secondes. 

Ces techniques n'empêchent pas d'avoir une réflexion préalable sur une conception capable d'encaisser des montées en charge.

L'outil qcachegrind propose des vues  sur les arbres d'appel des méthodes