Bonjour à tous. Aujourd'hui, nous voulons partager une autre traduction préparée à la veille du lancement du
cours Python Developer . C'est parti!

J'ai utilisé Python plus souvent que tout autre langage de programmation au cours des 4 à 5 dernières années. Python est le langage prédominant pour les builds sous Firefox, les tests et l'outil CI. Mercurial est également principalement écrit en Python. J'ai également écrit un grand nombre de mes projets tiers dessus.
Au cours de mon travail, j'ai acquis un peu de connaissances sur les performances de Python et ses outils d'optimisation. Dans cet article, je voudrais partager cette connaissance.
Mon expérience avec Python est principalement liée à l'interpréteur CPython, en particulier CPython 2.7. Toutes mes observations ne sont pas universelles pour toutes les distributions Python, ou pour celles qui ont les mêmes caractéristiques dans des versions similaires de Python. Je vais essayer de le mentionner dans le récit. Gardez à l'esprit que cet article n'est pas un aperçu détaillé des performances Python. Je ne parlerai que de ce que j'ai rencontré par moi-même.
La charge due aux particularités du démarrage et de l'importation des modules
Le démarrage de l'interpréteur Python et l'importation des modules est un processus assez long en millisecondes.
Si vous devez démarrer des centaines ou des milliers de processus Python dans l'un de vos projets, ce délai en millisecondes se transformera en un délai pouvant aller jusqu'à plusieurs secondes.
Si vous utilisez Python pour fournir des outils CLI, la surcharge peut provoquer un gel notable pour l'utilisateur. Si vous avez besoin instantanément d'outils CLI, l'exécution de l'interpréteur Python avec chaque appel rendra plus difficile l'obtention de cet outil complexe.
J'ai déjà écrit sur ce problème. Quelques-unes de mes notes antérieures en parlent, par exemple
en 2014 ,
en mai 2018 et
octobre 2018 .
Il n'y a pas beaucoup de choses que vous pouvez faire pour réduire le délai de démarrage: la correction de ce cas fait référence à la manipulation de l'interpréteur Python, car c'est lui qui contrôle l'exécution du code, ce qui prend trop de temps. La meilleure chose que vous puissiez faire est de désactiver l'importation du module de
site dans les appels pour éviter d'exécuter du code Python supplémentaire au démarrage. D'un autre côté, de nombreuses applications utilisent les fonctionnalités du module site.py, vous pouvez donc l'utiliser à vos risques et périls.
Nous devons également considérer le problème de l'importation de modules. À quoi sert l'interpréteur Python s'il ne traite aucun code? Le fait est que le code est mis à la disposition de l'interprète plus souvent grâce à l'utilisation de modules.
Pour importer des modules, vous devez suivre plusieurs étapes. Et dans chacun d'eux, il existe une source potentielle de charges et de retards.
Un certain retard se produit en raison de la recherche de modules et de la lecture de leurs données. Comme je l'ai démontré avec
PyOxidizer , en remplaçant la recherche et le chargement d'un module d'un système de fichiers par une solution architecturale plus simple, qui consiste à lire les données du module à partir d'une structure de données en mémoire, vous pouvez importer la bibliothèque Python standard pour 70 à 80% du temps de solution initial pour cette tâche. Avoir un module par fichier de système de fichiers augmente la charge sur le système de fichiers et peut ralentir une application Python pendant les premières millisecondes critiques d'exécution. Des solutions comme PyOxidizer peuvent aider à éviter cela. J'espère que la communauté Python voit ces coûts de l'approche actuelle et envisage la transition vers les mécanismes de distribution des modules, qui ne dépendent pas tant des fichiers individuels du module.
Une autre source de coûts d'importation supplémentaires pour un module est l'exécution de code dans ce module lors de l'importation. Certains modules contiennent des parties du code dans une zone en dehors des fonctions et des classes du module, qui est exécutée lors de l'importation du module. L'exécution d'un tel code augmente le coût de l'importation. Solution: n'exécutez pas tout le code au moment de l'importation, mais exécutez-le uniquement si nécessaire. Python 3.7 prend en charge le module
__getattr__
, qui sera appelé si l'attribut d'un module n'a pas été trouvé. Cela peut être utilisé pour remplir paresseusement les attributs de module lors du premier accès.
Une autre façon de se débarrasser du ralentissement de l'importation consiste à importer paresseusement le module. Au lieu de charger le module directement lors de l'importation, vous enregistrez un module d'importation personnalisé qui renvoie un stub à la place. Lorsque vous accédez pour la première fois à ce talon, il charge le module réel et «mute» pour devenir ce module.
Vous pouvez économiser des dizaines de millisecondes avec des applications qui importent plusieurs dizaines de modules si vous contournez le système de fichiers et évitez d'exécuter des parties inutiles du module (les modules sont généralement importés globalement, mais seules certaines fonctions de module sont utilisées).
L'importation paresseuse de modules est une chose fragile. De nombreux modules ont des modèles qui ont les choses suivantes:
try: import foo
;
except ImportError:
Un importateur de module paresseux ne peut jamais lancer une importation d'erreur, car s'il le fait, il devra chercher dans le système de fichiers un module pour voir s'il existe en principe. Cela ajoutera une charge supplémentaire et augmentera le temps passé, donc les importateurs paresseux ne le font pas en principe! Ce problème est assez ennuyeux. Importateur de modules paresseux Mercurial traite une liste de modules qui ne peuvent pas être importés paresseusement et doit les contourner. Un autre problème est la syntaxe
from foo import x, y
, qui interrompt également l'importation du module paresseux dans les cas où foo est un module (par opposition à un package), car le module doit toujours être importé pour renvoyer une référence à x et y.
PyOxidizer a un ensemble fixe de modules câblés dans le binaire, il peut donc être efficace pour augmenter ImportError. Le module __getattr__ de Python 3.7 offre une flexibilité supplémentaire pour les importateurs de modules paresseux. J'espère intégrer un importateur paresseux fiable dans PyOxidizer pour automatiser certains processus.
La meilleure solution pour éviter de démarrer l'interpréteur et de provoquer des retards est de démarrer le processus d'arrière-plan en Python. Si vous démarrez le processus Python en tant que processus démon, par exemple pour un serveur Web, vous pouvez le faire. La solution proposée par Mercurial consiste à démarrer un processus d'arrière-plan qui fournit un
protocole de serveur de commandes . hg est l'exécutable C (ou maintenant Rust), qui se connecte à ce processus d'arrière-plan et envoie une commande. Pour trouver une approche du serveur de commandes, vous devez faire beaucoup de travail, il est extrêmement instable et a des problèmes de sécurité. J'envisage l'idée de fournir un serveur de commandes à l'aide de PyOxidizer afin que l'exécutable ait ses avantages, et le problème du coût de la solution logicielle elle-même a été résolu en créant le projet PyOxidizer.
Délai d'appel de fonction
L'appel de fonctions en Python est un processus relativement lent. (Cette observation est moins applicable à PyPy, qui peut exécuter du code JIT.)
J'ai vu des dizaines de correctifs pour Mercurial, ce qui a permis d'aligner et de combiner le code de manière à éviter une charge inutile lors de l'appel de fonctions. Dans le cycle de développement actuel, certains efforts ont été faits pour réduire le nombre de fonctions appelées lors de la mise à jour de la barre de progression. (Nous utilisons des barres de progression pour toutes les opérations qui peuvent prendre un certain temps, afin que l'utilisateur comprenne ce qui se passe). Obtenir les résultats de l'appel de
fonctions et éviter les recherches simples parmi les
fonctions permet d'économiser des dizaines de centaines de millisecondes lors de l'exécution, lorsque nous parlons d'un million d'exécutions, par exemple.
Si vous avez des boucles serrées ou des fonctions récursives en Python où des centaines de milliers ou plus d'appels de fonctions peuvent se produire, vous devez être conscient de la surcharge d'appeler une seule fonction, car cela est d'une grande importance. Gardez à l'esprit les fonctions intégrées simples et la possibilité de combiner des fonctions pour éviter les frais généraux.
Frais généraux de recherche d'attribut
Ce problème est similaire à la surcharge due à un appel de fonction, car la signification est presque la même!
La recherche d'attributs de résolution en Python peut être lente. (Et encore une fois, dans PyPy, c'est plus rapide). Cependant, gérer ce problème est ce que nous faisons souvent dans Mercurial.
Disons que vous avez le code suivant:
obj = MyObject() total = 0 for i in len(obj.member): total += obj.member[i]
Nous omettons qu'il existe des moyens plus efficaces d'écrire cet exemple (par exemple,
total = sum(obj.member)
), et notons que la boucle doit définir obj.member à chaque itération. Python possède un mécanisme relativement sophistiqué pour définir des
attributs . Pour les types simples, cela peut être assez rapide. Mais pour les types complexes, cet accès aux attributs peut appeler automatiquement
__getattr__
,
__getattribute__
, diverses méthodes de
dunder
et même des fonctions définies par l'utilisateur
@property
. Ceci est similaire à une recherche rapide d'un attribut qui peut effectuer plusieurs appels de fonction, ce qui entraînera une charge supplémentaire. Et cette charge peut être aggravée si vous utilisez des choses comme
obj.member1.member2.member3
, etc.
Chaque définition d'attribut entraîne une charge supplémentaire. Et comme presque tout en Python est un dictionnaire, nous pouvons dire que chaque recherche d'attribut est une recherche de dictionnaire. D'après les concepts généraux sur les structures de données de base, nous savons que la recherche par dictionnaire n'est pas aussi rapide que, disons, la recherche d'index. Oui, bien sûr, il existe quelques astuces dans CPython qui peuvent se débarrasser des frais généraux dus aux recherches dans les dictionnaires. Mais le sujet principal que je veux aborder est que toute recherche d'attributs est une fuite potentielle de performances.
Pour les boucles serrées, en particulier celles qui dépassent potentiellement des centaines de milliers d'itérations, vous pouvez éviter ces frais généraux mesurables pour trouver des attributs en affectant une valeur à une variable locale. Regardons l'exemple suivant:
obj = MyObject() total = 0 member = obj.member for i in len(member): total += member[i]
Bien sûr, cela ne peut être fait en toute sécurité que s'il n'est pas remplacé dans un cycle. Si cela se produit, l'itérateur gardera un lien vers l'ancien élément et tout pourrait exploser.
La même astuce peut être effectuée lors de l'appel de la méthode de l'objet. Au lieu de cela
obj = MyObject() for i in range(1000000): obj.process(i)
Vous pouvez effectuer les opérations suivantes:
obj = MyObject() fn = obj.process for i in range(1000000:) fn(i)
Il convient également de noter que dans le cas où la recherche d'attributs doit appeler une méthode (comme dans l'exemple précédent), alors Python 3.7 est relativement
plus rapide que les versions précédentes. Mais je suis sûr qu'ici, la charge excessive est liée, tout d'abord, à l'appel de fonction, et non à la charge sur la recherche d'attributs. Par conséquent, tout fonctionnera plus rapidement si vous abandonnez la recherche supplémentaire d'attributs.
Enfin, comme une recherche d'attribut appelle une fonction pour cela, on peut dire que la recherche d'attribut pose généralement moins de problème qu'une charge due à un appel de fonction. En règle générale, pour remarquer des changements importants de vitesse, vous devrez éliminer un grand nombre de recherches d'attributs. Dans ce cas, dès que vous donnez accès à tous les attributs à l'intérieur de la boucle, vous ne pouvez parler de 10 ou 20 attributs que dans la boucle avant d'appeler la fonction. Et les boucles avec aussi peu que des milliers ou moins de dizaines de milliers d'itérations peuvent rapidement fournir des centaines de milliers ou des millions de recherches d'attributs. Soyez donc prudent!
Charge d'objet
Du point de vue de l'interpréteur Python, toutes les valeurs sont des objets. En CPython, chaque élément est une structure PyObject. Chaque objet contrôlé par l'interpréteur se trouve sur le tas et possède sa propre mémoire contenant le nombre de références, le type d'objet et d'autres paramètres. Chaque objet est éliminé par le garbage collector. Cela signifie que chaque nouvel objet ajoute une surcharge en raison du comptage des références, de la récupération de place, etc. (Et encore une fois, PyPy peut éviter ce fardeau inutile, car il «s'attache plus soigneusement» à la durée de vie des valeurs à court terme.)
Généralement, plus vous créez de valeurs et d'objets Python uniques, plus les choses fonctionnent lentement pour vous.
Disons que vous parcourez une collection d'un million d'objets. Vous appelez une fonction pour collecter cet objet dans un tuple:
for x in my_collection: a, b, c, d, e, f, g, h = process(x)
Dans cet exemple,
process()
renverra un tuple à 8 tuples. Peu importe que nous détruisions la valeur de retour ou non: ce tuple nécessite de créer au moins 9 valeurs en Python: 1 pour le tuple lui-même et 8 pour ses membres internes. Eh bien, dans la vie réelle, il peut y avoir moins de valeurs si
process()
renvoie une référence à un objet existant. Ou, au contraire, il peut y en avoir plus si leurs types ne sont pas simples et nécessitent de nombreux PyObjects à représenter. Je veux juste dire que sous le capot de l'interprète il y a un vrai jonglage d'objets pour la présentation complète de certaines constructions.
D'après ma propre expérience, je peux dire que ces frais généraux ne sont pertinents que pour les opérations qui fournissent des gains de vitesse lorsqu'ils sont implémentés dans une langue native comme C ou Rust. Le problème est que l'interpréteur CPython est tout simplement incapable d'exécuter le bytecode si rapidement que la charge supplémentaire due au nombre d'objets compte. Au lieu de cela, vous êtes le plus susceptible de réduire les performances en appelant une fonction, ou par des calculs lourds, etc. avant de remarquer la charge supplémentaire due aux objets. Il y a bien sûr quelques exceptions, à savoir la construction de tuples ou de dictionnaires de plusieurs valeurs.
Comme exemple concret de surcharge, vous pouvez citer Mercurial avec du code C qui analyse les structures de données de bas niveau. Pour une plus grande vitesse d'analyse, le code C s'exécute un ordre de grandeur plus rapidement que CPython. Mais dès que le code C crée PyObject pour représenter le résultat, la vitesse chute plusieurs fois. En d'autres termes, la charge implique la création et la gestion d'éléments Python afin qu'ils puissent être utilisés dans du code.
Un moyen de contourner ce problème est de produire moins d'éléments en Python. Si vous devez vous référer à un seul élément, démarrez la fonction et renvoyez-la, et non un tuple ou un dictionnaire de N éléments. Cependant, n'arrêtez pas de surveiller la charge possible due aux appels de fonction!
Si vous avez beaucoup de code qui fonctionne assez rapidement à l'aide de l'API CPython C, et des éléments qui doivent être distribués entre différents modules, faites sans les types Python qui représentent différentes données en tant que structures C et ont déjà compilé du code pour accéder à ces structures au lieu de passer par l'API CPython C. En évitant l'API CPython C pour accéder aux données, vous vous débarrasserez de beaucoup de charge supplémentaire.
Traiter les éléments comme des données (au lieu d'avoir des fonctions pour accéder à tout dans une rangée) serait la meilleure approche pour un pythoniste. Une autre solution de contournement pour le code déjà compilé consiste à instancier paresseusement PyObject. Si vous créez un type personnalisé en Python (PyTypeObject) pour représenter des éléments complexes, vous devez définir les
champs tp_members ou
tp_getset pour créer des fonctions C personnalisées afin de rechercher la valeur de l'attribut. Si vous, par exemple, écrivez un analyseur et savez que les clients n'auront accès qu'à un sous-ensemble des champs analysés, vous pouvez rapidement créer un type contenant des données brutes, renvoyer ce type et appeler une fonction C pour rechercher des attributs Python qui traitent PyObject. Vous pouvez même retarder l'analyse jusqu'à ce que la fonction soit appelée pour économiser des ressources si l'analyse n'est jamais nécessaire! Cette technique est assez rare, car elle nécessite d'écrire du code non trivial, mais elle donne un résultat positif.
Détermination préliminaire de la taille de la collection
Cela s'applique à l'API CPython C.
Lors de la création de collections, telles que des listes ou des dictionnaires, utilisez
PyList_New()
+
PyList_SET_ITEM()
pour remplir une nouvelle collection si sa taille a déjà été déterminée au moment de la création. Cela permettra de déterminer à l'avance la taille de la collection pour pouvoir y contenir un nombre fini d'éléments. Cela permet d'éviter de rechercher une taille de collection suffisante lors de l'insertion d'éléments. Lorsque vous créez une collection de milliers d'articles, cela vous fera économiser des ressources!
Utilisation de Zero-copy dans l'API C
L'API Python C aime vraiment créer des copies d'objets plutôt que de leur renvoyer des références. Par exemple,
PyBytes_FromStringAndSize () copie
char*
dans la mémoire réservée par Python. Si vous faites cela pour un grand nombre de valeurs ou de mégadonnées, alors nous pourrions parler de gigaoctets d'E / S de mémoire et de la charge associée sur l'allocateur.
Si vous avez besoin d'écrire du code haute performance sans API C, vous devez vous familiariser avec le
protocole tampon et les types associés, tels que
memoryview .Buffer protocol
est intégré aux types Python et permet aux interprètes de convertir le type de / en octets. Il permet également à l'interpréteur de code C de recevoir un descripteur
void*
d'une certaine taille. Cela vous permet d'associer n'importe quelle adresse en mémoire à PyObject. De nombreuses fonctions qui fonctionnent avec des données binaires acceptent de manière transparente tout objet qui implémente le
buffer protocol
. Et si vous souhaitez accepter tout objet pouvant être considéré comme des octets, vous devez utiliser des
unités au format s*
,
y*
ou
w*
lors de la réception des arguments de fonction.
En utilisant le
buffer protocol
, vous donnez à l'interpréteur la meilleure opportunité disponible pour utiliser
zero-copy
opérations de
zero-copy
et refuser de copier des octets supplémentaires dans la mémoire.
En utilisant des types en Python de la forme
memoryview
, vous autoriserez également Python à accéder aux niveaux de mémoire par référence, au lieu de faire des copies.
Si vous avez des gigaoctets de code qui passent par votre programme Python, l'utilisation perspicace des types Python qui prennent en charge la copie zéro vous évitera des différences de performances. J'ai remarqué une fois que
python-zstandard s'est avéré être plus rapide que toutes les liaisons Python LZ4 (bien que ce devrait être l'inverse), car j'ai trop utilisé le
buffer protocol
et évité les E / S de mémoire excessives dans
python-zstandard
!
Conclusion
Dans cet article, j'ai cherché à parler de certaines des choses que j'ai apprises tout en optimisant mes programmes Python pendant plusieurs années. Je répète et dis que ce n'est en aucun cas un aperçu complet des méthodes d'amélioration des performances Python. J'avoue que j'utilise probablement Python plus exigeant que les autres, et mes recommandations ne peuvent pas être appliquées à tous les programmes.
Vous ne devez en aucun cas corriger massivement votre code Python et supprimer, par exemple, la recherche d'attributs après avoir lu cet article . Comme toujours, en matière d'optimisation des performances, corrigez d'abord où le code est particulièrement lent.
py-spy Python. , , Python, . , , , !
, Python . , , Python - . Python – PyPy, . Python . , Python , . , « ». , , , Python, , , .
;-)