Développement de programmes Python extrêmement rapides

Les ennemis de Python disent toujours que l'une des raisons pour lesquelles ils ne veulent pas utiliser ce langage est que Python est lent. Mais le fait qu'un certain programme, quel que soit le langage de programmation utilisé, puisse être considéré comme rapide ou lent, dépend beaucoup du développeur qui l'a écrit, de ses connaissances et de la capacité à créer du code optimisé et performant.



L'auteur de l'article, que nous publions aujourd'hui, propose de prouver que ceux qui appellent Python lent ont tort. Il veut parler de la façon d'améliorer les performances des programmes Python et de les rendre très rapides.

Mesure du temps et profilage


Avant de commencer à optimiser un code, vous devez d'abord savoir quelles parties de celui-ci ralentissent l'ensemble du programme. Parfois, un goulot d'étranglement peut être évident, mais si le programmeur ne sait pas où il se trouve, il peut profiter de certaines opportunités pour l'identifier.

Vous trouverez ci-dessous le code du programme, que j'utiliserai à des fins de démonstration. Il est extrait de la documentation Python. Ce code élève e à la puissance de x :

 # slow_program.py from decimal import * def exp(x):    getcontext().prec += 2    i, lasts, s, fact, num = 0, 0, 1, 1, 1    while s != lasts:        lasts = s        i += 1        fact *= i        num *= x        s += num / fact    getcontext().prec -= 2    return +s exp(Decimal(150)) exp(Decimal(400)) exp(Decimal(3000)) 

La façon la plus simple de «profiler» le code


Pour commencer, envisagez la façon la plus simple de profiler votre code. Pour ainsi dire, «profilage pour les paresseux». Elle consiste à utiliser la commande de time Unix:

 ~ $ time python3.8 slow_program.py real 0m11,058s user 0m11,050s sys 0m0,008s 

Un tel profilage pourrait bien donner au programmeur des informations utiles - dans le cas où il aurait besoin de mesurer le temps d'exécution de l'ensemble du programme. Mais ce n'est généralement pas suffisant.

La méthode de profilage la plus précise


À l'autre extrémité du spectre des méthodes de profilage de code se trouve l'outil cProfile , qui donne, certes, trop d'informations au programmeur:

 ~ $ python3.8 -m cProfile -s time slow_program.py         1297 function calls (1272 primitive calls) in 11.081 seconds   Ordered by: internal time   ncalls tottime percall cumtime percall filename:lineno(function)        3  11.079  3.693 11.079  3.693 slow_program.py:4(exp)        1  0.000  0.000 0.002  0.002 {built-in method _imp.create_dynamic}      4/1  0.000  0.000 11.081  11.081 {built-in method builtins.exec}        6  0.000  0.000 0.000  0.000 {built-in method __new__ of type object at 0x9d12c0}        6  0.000  0.000 0.000  0.000 abc.py:132(__new__)       23  0.000  0.000 0.000  0.000 _weakrefset.py:36(__init__)      245  0.000  0.000 0.000  0.000 {built-in method builtins.getattr}        2  0.000  0.000 0.000  0.000 {built-in method marshal.loads}       10  0.000  0.000 0.000  0.000 <frozen importlib._bootstrap_external>:1233(find_spec)      8/4  0.000  0.000 0.000  0.000 abc.py:196(__subclasscheck__)       15  0.000  0.000 0.000  0.000 {built-in method posix.stat}        6  0.000  0.000 0.000  0.000 {built-in method builtins.__build_class__}        1  0.000  0.000 0.000  0.000 __init__.py:357(namedtuple)       48  0.000  0.000 0.000  0.000 <frozen importlib._bootstrap_external>:57(_path_join)       48  0.000  0.000 0.000  0.000 <frozen importlib._bootstrap_external>:59(<listcomp>)        1  0.000  0.000 11.081  11.081 slow_program.py:1(<module>) 

Ici, nous cProfile le script étudié à l'aide du module cProfile et utilisons l'argument time . En conséquence, les lignes de sortie sont triées par heure interne ( cumtime ). Cela nous donne beaucoup d'informations. En fait, ce qui est indiqué ci-dessus ne représente qu'environ 10% de la sortie de cProfile .

Après avoir analysé ces données, nous pouvons voir que la fonction exp est la raison du lent fonctionnement du programme (c'est une surprise!). Après cela, nous pouvons faire du profilage de code en utilisant des outils plus précis.

L'étude des indicateurs de performance temporaires d'une fonction spécifique


Nous connaissons maintenant la place du programme sur laquelle nous devons diriger notre attention. Par conséquent, nous pouvons décider d'étudier la fonction lente sans profiler d'autres codes de programme. Pour ce faire, vous pouvez utiliser un simple décorateur:

 def timeit_wrapper(func):    @wraps(func)    def wrapper(*args, **kwargs):        start = time.perf_counter() #       time.process_time()        func_return_val = func(*args, **kwargs)        end = time.perf_counter()        print('{0:<10}.{1:<8} : {2:<8}'.format(func.__module__, func.__name__, end - start))        return func_return_val    return wrapper 

Ce décorateur peut être appliqué à la fonction à explorer:

 @timeit_wrapper def exp(x):    ... print('{0:<10} {1:<8} {2:^8}'.format('module', 'function', 'time')) exp(Decimal(150)) exp(Decimal(400)) exp(Decimal(3000)) 

Maintenant, après le démarrage du programme, nous recevrons les informations suivantes:

 ~ $ python3.8 slow_program.py module   function time __main__ .exp : 0.003267502994276583 __main__ .exp : 0.038535295985639095 __main__ .exp : 11.728486061969306 

Ici, il convient de prêter attention à l'heure exacte que nous prévoyons de mesurer. Le package correspondant nous fournit des indicateurs tels que time.perf_counter et time.process_time . La différence entre eux est que perf_counter renvoie une valeur absolue, qui inclut le temps pendant lequel le processus du programme Python ne s'exécute pas. Cela signifie que cet indicateur peut être affecté par la charge sur l'ordinateur créée par d'autres programmes. La process_time renvoie uniquement le temps utilisateur. Il n'inclut pas l'heure système. Cela nous donne uniquement des informations sur le temps d'exécution de notre processus.

Accélération du code


Et maintenant pour la partie amusante. Travaillons à accélérer le programme. Je ne vais (pour la plupart) pas montrer ici toutes sortes de hacks, astuces et morceaux de code mystérieux qui résolvent comme par magie les problèmes de performances. Je veux essentiellement parler d'idées et de stratégies communes qui, si elles sont utilisées, peuvent avoir un impact important sur les performances. Dans certains cas, nous parlons d'une augmentation de 30% de la vitesse d'exécution du code.

▍Utilisez les types de données intégrés


L'utilisation de types de données intégrés est une approche très évidente pour accélérer le code. Les types de données intégrés sont extrêmement rapides, surtout si vous les comparez avec des types personnalisés comme des arbres ou des listes liées. Le point ici est principalement que les mécanismes intégrés du langage sont implémentés en utilisant C. Si vous décrivez quelque chose en utilisant Python, vous ne pouvez pas atteindre le même niveau de performances.

▍Appliquer la mise en cache (mémorisation) avec lru_cache


La mise en cache est une approche populaire pour améliorer les performances du code. J'ai déjà écrit à son sujet, mais je pense qu'il vaut la peine d'en parler ici:

 import functools import time #   12   @functools.lru_cache(maxsize=12) def slow_func(x):    time.sleep(2) #       return x slow_func(1) # ...  2     slow_func(1) #    -   ! slow_func(3) # ...   2     

La fonction ci-dessus simule des calculs complexes en utilisant time.sleep . Lorsqu'il est appelé pour la première fois avec le paramètre 1 , il attend 2 secondes et ne renvoie le résultat qu'après cela. Lorsqu'elle est à nouveau appelée avec le même paramètre, il s'avère que le résultat de son travail est déjà mis en cache. Le corps de la fonction dans cette situation n'est pas exécuté et le résultat est renvoyé immédiatement. Vous trouverez ici des exemples de mise en cache plus proches de la réalité.

▍Utiliser des variables locales


En appliquant des variables locales, nous prenons en compte la vitesse de recherche d'une variable dans chaque portée. Je parle spécifiquement de "tous les domaines de visibilité", car ici je n'ai pas seulement en tête une comparaison de la vitesse de travail avec les variables locales et globales. En fait, la différence de travail avec les variables est même observée, par exemple, entre les variables locales dans une fonction (la vitesse la plus élevée), les attributs au niveau de la classe (par exemple, self.name , c'est déjà plus lent) et les entités importées globales comme time.time (le plus lent de ces trois mécanismes).

Vous pouvez améliorer les performances en utilisant les approches suivantes pour attribuer des valeurs qui peuvent sembler complètement inutiles et inutiles à une personne non informée:

 #  #1 class FastClass:    def do_stuff(self):        temp = self.value #           for i in range(10000):            ... #      `temp` #  #2 import random def fast_function():    r = random.random    for i in range(10000):        print(r()) #   `r()` ,     random.random() 

▍ Envelopper le code dans la fonction


Ce conseil peut sembler contraire au bon sens, car lorsqu'une fonction est appelée, certaines données sont poussées sur la pile et le système subit une charge supplémentaire qui traite l'opération de retour de la fonction. Cependant, cette recommandation est liée à la précédente. Si vous venez de mettre tout votre code dans un fichier sans l'écrire en tant que fonction, il s'exécutera beaucoup plus lentement en raison de l'utilisation de variables globales. Cela signifie que le code peut être accéléré en l'enveloppant simplement dans la fonction main() et en l'appelant une fois:

 def main():    ... #  ,     main() 

▍Ne pas accéder aux attributs


Un autre mécanisme qui peut ralentir un programme est l'opérateur point ( . ), Qui est utilisé pour accéder aux attributs des objets. Cette instruction appelle une __getattribute__ dictionnaire à l'aide de __getattribute__ , ce qui __getattribute__ un stress supplémentaire sur le système. Comment limiter l'impact sur les performances de cette fonctionnalité Python?

 # : import re def slow_func():    for i in range(10000):        re.findall(regex, line) # ! # : from re import findall def fast_func():    for i in range(10000):        findall(regex, line) # ! 

▍Méfiez-vous des chaînes


Les opérations sur chaîne peuvent ralentir considérablement un programme si elles sont exécutées en boucle. En particulier, nous parlons de formatage de chaînes à l'aide de %s et .format() . Est-il possible de les remplacer par quelque chose? Si vous regardez un tweet récent de Raymond Hettinger, vous pouvez voir que le seul mécanisme qui doit être utilisé dans de telles situations est les lignes f. Il s'agit de la méthode de formatage de chaîne la plus lisible, concise et la plus rapide. Voici, conformément à ce tweet, une liste de méthodes qui peuvent être utilisées pour travailler avec des chaînes - de la plus rapide à la plus lente:

 f'{s} {t}' # ! s + ' ' + t ' '.join((s, t)) '%s %s' % (s, t) '{} {}'.format(s, t) Template('$s $t').substitute(s=s, t=t) # ! 

▍Sachez que les générateurs peuvent également fonctionner rapidement


Les générateurs ne sont pas ces mécanismes qui, de par leur nature, sont rapides. Le fait est qu'ils ont été créés pour effectuer des calculs «paresseux», ce qui fait gagner non pas du temps, mais de la mémoire. Cependant, économiser de la mémoire peut accélérer l'exécution des programmes. Comment est-ce possible? Le fait est que lors du traitement d'un grand ensemble de données sans utiliser de générateurs (itérateurs), les données peuvent entraîner un débordement du cache L1 du processeur, ce qui ralentira considérablement le processus de recherche de valeurs en mémoire.

En ce qui concerne les performances, il est très important de veiller à ce que le processeur puisse accéder rapidement aux données qu'il traite, afin qu'elles soient aussi proches que possible de celles-ci. Et cela signifie que ces données doivent être placées dans le cache du processeur. Cette question est abordée dans cette présentation par Raymond Hettinger.

Résumé


La première règle d'optimisation est que l'optimisation n'est pas nécessaire. Mais si vous ne pouvez pas vous passer de l'optimisation, j'espère que les conseils que j'ai partagés vous y aideront.

Chers lecteurs! Comment abordez-vous l'optimisation des performances de votre code Python?

Source: https://habr.com/ru/post/fr483678/


All Articles