Un guide des tests et de l'optimisation des performances avec Python et Django

Publié: 2022-03-11

Donald Knuth a déclaré que "l'optimisation prématurée est la racine de tous les maux". Mais il arrive un moment, généralement dans des projets matures avec des charges élevées, où il y a un besoin inévitable d'optimisation. Dans cet article, j'aimerais parler de cinq méthodes courantes pour optimiser le code de votre projet Web. J'utiliserai Django, mais les principes devraient être similaires pour d'autres frameworks et langages. Dans cet article, je vais utiliser ces méthodes pour réduire le temps de réponse d'une requête de 77 à 3,7 secondes.

Guide d'optimisation et de test de performances avec Python et Django

L'exemple de code est adapté d'un projet réel sur lequel j'ai travaillé et illustre les techniques d'optimisation des performances. Si vous souhaitez suivre et voir les résultats vous-même, vous pouvez récupérer le code dans son état initial sur GitHub et apporter les modifications correspondantes tout en suivant l'article. J'utiliserai Python 2, car certains packages tiers ne sont pas encore disponibles pour Python 3.

Présentation de notre application

Notre projet web suit simplement les offres immobilières par pays. Il n'y a donc que deux modèles :

 # houses/models.py from utils.hash import Hasher class HashableModel(models.Model): """Provide a hash property for models.""" class Meta: abstract = True @property def hash(self): return Hasher.from_model(self) class Country(HashableModel): """Represent a country in which the house is positioned.""" name = models.CharField(max_length=30) def __unicode__(self): return self.name class House(HashableModel): """Represent a house with its characteristics.""" # Relations country = models.ForeignKey(Country, related_name='houses') # Attributes address = models.CharField(max_length=255) sq_meters = models.PositiveIntegerField() kitchen_sq_meters = models.PositiveSmallIntegerField() nr_bedrooms = models.PositiveSmallIntegerField() nr_bathrooms = models.PositiveSmallIntegerField() nr_floors = models.PositiveSmallIntegerField(default=1) year_built = models.PositiveIntegerField(null=True, blank=True) house_color_outside = models.CharField(max_length=20) distance_to_nearest_kindergarten = models.PositiveIntegerField(null=True, blank=True) distance_to_nearest_school = models.PositiveIntegerField(null=True, blank=True) distance_to_nearest_hospital = models.PositiveIntegerField(null=True, blank=True) has_cellar = models.BooleanField(default=False) has_pool = models.BooleanField(default=False) has_garage = models.BooleanField(default=False) price = models.PositiveIntegerField() def __unicode__(self): return '{} {}'.format(self.country, self.address)

L'abstrait HashableModel fournit à tout modèle qui en hérite une propriété de hash qui contient la clé primaire de l'instance et le type de contenu du modèle. Cela masque les données sensibles, comme les ID d'instance, en les remplaçant par un hachage. Cela peut également être utile dans les cas où votre projet comporte plusieurs modèles et que vous avez besoin d'un emplacement centralisé qui déchiffre et décide quoi faire avec différentes instances de modèle de différentes classes. Notez que pour notre petit projet, le hachage n'est pas vraiment nécessaire, car nous pouvons nous en passer, mais cela aidera à démontrer certaines techniques d'optimisation, donc je vais le garder là.

Voici la classe Hasher :

 # utils/hash.py import basehash class Hasher(object): @classmethod def from_model(cls, obj, klass=None): if obj.pk is None: return None return cls.make_hash(obj.pk, klass if klass is not None else obj) @classmethod def make_hash(cls, object_pk, klass): base36 = basehash.base36() content_type = ContentType.objects.get_for_model(klass, for_concrete_model=False) return base36.hash('%(contenttype_pk)03d%(object_pk)06d' % { 'contenttype_pk': content_type.pk, 'object_pk': object_pk }) @classmethod def parse_hash(cls, obj_hash): base36 = basehash.base36() unhashed = '%09d' % base36.unhash(obj_hash) contenttype_pk = int(unhashed[:-6]) object_pk = int(unhashed[-6:]) return contenttype_pk, object_pk @classmethod def to_object_pk(cls, obj_hash): return cls.parse_hash(obj_hash)[1]

Comme nous aimerions servir ces données via un point de terminaison API, nous installons Django REST Framework et définissons les sérialiseurs et la vue suivants :

 # houses/serializers.py class HouseSerializer(serializers.ModelSerializer): """Serialize a `houses.House` instance.""" id = serializers.ReadOnlyField(source="hash") country = serializers.ReadOnlyField(source="country.hash") class Meta: model = House fields = ( 'id', 'address', 'country', 'sq_meters', 'price' )
 # houses/views.py class HouseListAPIView(ListAPIView): model = House serializer_class = HouseSerializer country = None def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country) return queryset def list(self, request, *args, **kwargs): # Skipping validation code for brevity country = self.request.GET.get("country") self.country = Hasher.to_object_pk(country) queryset = self.get_queryset() serializer = self.serializer_class(queryset, many=True) return Response(serializer.data)

Maintenant, nous remplissons notre base de données avec quelques données (un total de 100 000 instances de maison générées à l'aide factory-boy : 50 000 pour un pays, 40 000 pour un autre et 10 000 pour un pays tiers) et sommes prêts à tester les performances de notre application.

L'optimisation des performances consiste à mesurer

Il y a plusieurs choses que nous pouvons mesurer dans un projet :

  • Temps d'exécution
  • Nombre de lignes de code
  • Nombre d'appels de fonction
  • Mémoire allouée
  • Etc.

Mais tous ne sont pas pertinents pour mesurer la performance de notre projet. De manière générale, il existe deux mesures principales qui sont les plus importantes : la durée d'exécution d'un élément et la quantité de mémoire dont il a besoin.

Dans un projet Web, le temps de réponse (le temps nécessaire au serveur pour recevoir une requête générée par l'action d'un utilisateur, la traiter et renvoyer le résultat) est généralement la mesure la plus importante, car elle ne laisse pas les utilisateurs s'ennuyer en attendant pour une réponse et passer à un autre onglet dans leur navigateur.

En programmation, l'analyse des performances d'un projet s'appelle le profilage. Afin de profiler les performances de notre point de terminaison API, nous utiliserons le package Silk. Après l'avoir installé et avoir fait notre appel /api/v1/houses/?country=5T22RI (le hash qui correspond au pays avec 50 000 entrées de maison), nous obtenons ceci :

200 OBTENIR
/api/v1/maisons/

77292 ms au total
15854ms sur les requêtes
50004 requêtes

Le temps de réponse global est de 77 secondes, dont 16 secondes sont consacrées aux requêtes dans la base de données, où un total de 50 000 requêtes ont été effectuées. Avec des chiffres aussi énormes, il y a beaucoup de place à l'amélioration, alors commençons.

1. Optimisation des requêtes de base de données

L'un des conseils les plus fréquents sur l'optimisation des performances consiste à s'assurer que vos requêtes de base de données sont optimisées. Ce cas ne fait pas exception. De plus, nous pouvons faire plusieurs choses sur nos requêtes pour optimiser le temps de réponse.

1.1 Fournir toutes les données en une seule fois

En regardant de plus près ce que sont ces 50 000 requêtes, vous pouvez voir que ce sont toutes des requêtes redondantes sur la table houses_country :

200 OBTENIR
/api/v1/maisons/

77292 ms au total
15854ms sur les requêtes
50004 requêtes

À les tables Jointures Temps d'exécution (ms)
+0:01:15.874374 "maisons_pays" 0 0,176
+0:01:15.873304 "maisons_pays" 0 0,218
+0:01:15.872225 "maisons_pays" 0 0,218
+0:01:15.871155 "maisons_pays" 0 0,198
+0:01:15.870099 "maisons_pays" 0 0,173
+0:01:15.869050 "maisons_pays" 0 0,197
+0:01:15.867877 "maisons_pays" 0 0,221
+0:01:15.866807 "maisons_pays" 0 0,203
+0:01:15.865646 "maisons_pays" 0 0,211
+0:01:15.864562 "maisons_pays" 0 0,209
+0:01:15.863511 "maisons_pays" 0 0,181
+0:01:15.862435 "maisons_pays" 0 0,228
+0:01:15.861413 "maisons_pays" 0 0,174

La source de ce problème est le fait que, dans Django, les ensembles de requêtes sont paresseux . Cela signifie qu'un ensemble de requêtes n'est pas évalué et qu'il n'atteint pas la base de données tant que vous n'avez pas réellement besoin d'obtenir les données. En même temps, il n'obtient que les données que vous lui avez demandées, en faisant des demandes ultérieures si des données supplémentaires sont nécessaires.

C'est exactement ce qui s'est passé dans notre cas. Lors de l'obtention de la requête définie via House.objects.filter(country=country) , Django obtient une liste de toutes les maisons dans le pays donné. Cependant, lors de la sérialisation d'une instance de house , HouseSerializer requiert l'instance de country de la maison pour calculer le champ de country du sérialiseur. Comme les données du pays ne sont pas présentes dans le jeu de requêtes, Django fait une requête supplémentaire pour obtenir ces données. Et il le fait pour chaque maison de l'ensemble de requêtes, soit 50 000 fois en tout.

La solution est pourtant très simple. Afin d'extraire toutes les données requises pour la sérialisation, vous pouvez utiliser la méthode select_related() sur l'ensemble de requêtes. Ainsi, notre get_queryset ressemblera à :

 def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country).select_related('country') return queryset

Voyons comment cela a eu un impact sur les performances :

200 OBTENIR
/api/v1/maisons/

35979 ms au total
102ms sur les requêtes
4 requêtes

Le temps de réponse global est tombé à 36 secondes et le temps passé dans la base de données est d'environ 100 ms avec seulement 4 requêtes ! C'est une excellente nouvelle, mais nous pouvons faire plus.

1.2 Ne fournir que les données pertinentes

Par défaut, Django extrait tous les champs de la base de données. Cependant, lorsque vous avez d'énormes tables avec de nombreuses colonnes et lignes, il est logique d'indiquer à Django quels champs spécifiques extraire, afin qu'il ne passe pas de temps à obtenir des informations qui ne seront pas du tout utilisées. Dans notre cas, nous n'avons besoin que de cinq champs pour la sérialisation, mais nous avons 17 champs. Il est logique de spécifier exactement quels champs extraire de la base de données, afin de réduire davantage le temps de réponse.

Django a les méthodes d'ensemble de requêtes defer() et only() pour faire exactement cela. Le premier spécifie quels champs ne pas charger et le second spécifie quels champs charger uniquement .

 def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country)\ .select_related('country')\ .only('id', 'address', 'country', 'sq_meters', 'price') return queryset

Cela a réduit de moitié le temps passé sur les requêtes, ce qui est bien, mais 50 ms, ce n'est pas tant que ça. Le temps global a également légèrement baissé, mais il y a plus d'espace pour le couper.

200 OBTENIR
/api/v1/maisons/

33111ms au total
52ms sur les requêtes
4 requêtes

2. Optimiser votre code

Vous ne pouvez pas optimiser à l'infini les requêtes de la base de données, et notre dernier résultat vient de le montrer. Même si nous réduisions hypothétiquement le temps consacré aux requêtes à 0, nous serions toujours confrontés à la réalité d'attendre une demi-minute pour obtenir la réponse. Il est temps de passer à un autre niveau d'optimisation : la logique métier .

2.1 Simplifiez votre code

Parfois, les packages tiers entraînent beaucoup de surcharge pour des tâches simples. Un tel exemple est notre tâche de renvoyer des instances de maison sérialisées.

Django REST Framework est génial, avec de nombreuses fonctionnalités utiles prêtes à l'emploi. Cependant, notre objectif principal en ce moment est de réduire le temps de réponse, c'est donc un excellent candidat pour l'optimisation, d'autant plus que les objets sérialisés sont assez simples.

Écrivons un sérialiseur personnalisé à cet effet. Pour faire simple, nous aurons une seule méthode statique qui fera le travail. En réalité, vous voudrez peut-être avoir les mêmes signatures de classe et de méthode pour pouvoir utiliser les sérialiseurs de manière interchangeable :

 # houses/serializers.py class HousePlainSerializer(object): """ Serializes a House queryset consisting of dicts with the following keys: 'id', 'address', 'country', 'sq_meters', 'price'. """ @staticmethod def serialize_data(queryset): """ Return a list of hashed objects from the given queryset. """ return [ { 'id': Hasher.from_pk_and_class(entry['id'], House), 'address': entry['address'], 'country': Hasher.from_pk_and_class(entry['country'], Country), 'sq_meters': entry['sq_meters'], 'price': entry['price'] } for entry in queryset ] # houses/views.py class HouseListAPIView(ListAPIView): model = House serializer_class = HouseSerializer plain_serializer_class = HousePlainSerializer # <-- added custom serializer country = None def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country) return queryset def list(self, request, *args, **kwargs): # Skipping validation code for brevity country = self.request.GET.get("country") self.country = Hasher.to_object_pk(country) queryset = self.get_queryset() data = self.plain_serializer_class.serialize_data(queryset) # <-- serialize return Response(data)

200 OBTENIR
/api/v1/maisons/

17312 ms au total
38ms sur les requêtes
4 requêtes

Cela semble mieux maintenant. Le temps de réponse a été presque divisé par deux car nous n'avons pas utilisé de code de sérialisation DRF.

Un autre résultat mesurable - le nombre total d'appels de fonction effectués pendant le cycle demande/réponse - est passé de 15 859 427 appels (de la demande faite à la section 1.2 ci-dessus) à 9 257 469 appels. Cela signifie qu'environ 1/3 de tous les appels de fonction ont été effectués par Django REST Framework.

2.2 Mettre à jour/substituer des packages tiers

Les techniques d'optimisation décrites ci-dessus sont les plus courantes, celles que vous pouvez faire sans analyse ni réflexion approfondies. Cependant, 17 secondes semblent encore assez longues; Afin de réduire ce nombre, nous devrons approfondir notre code et analyser ce qui se passe sous le capot. En d'autres termes, nous devrons profiler notre code.

Vous pouvez faire le profilage vous-même, en utilisant le profileur Python intégré, ou vous pouvez utiliser des packages tiers pour cela (qui utilisent le profileur Python intégré). Comme nous utilisons déjà silk , il peut profiler le code et générer un fichier de profil binaire, que nous pouvons visualiser davantage. Il existe plusieurs packages de visualisation qui transforment un profil binaire en quelques visualisations perspicaces. Je vais utiliser le package snakeviz .

Voici la visualisation du profil binaire de la dernière requête d'en haut, accrochée à la méthode dispatch de la vue :

Image de la méthode de répartition de la vue

De haut en bas se trouve la pile d'appels, affichant le nom du fichier, le nom de la méthode/fonction avec son numéro de ligne et le temps cumulé correspondant passé dans cette méthode. Maintenant, il est plus facile de voir qu'une part importante du temps est consacrée au calcul du hachage (les rectangles __init__.py et primes.py de couleur violette).

Actuellement, il s'agit du principal goulot d'étranglement des performances de notre code, mais en même temps, ce n'est pas vraiment notre code, c'est un package tiers.

Dans de telles situations, il y a un nombre limité de choses que nous pouvons faire :

  • Recherchez une nouvelle version du package (qui, espérons-le, offre de meilleures performances).
  • Trouvez un autre package qui fonctionne mieux sur les tâches dont nous avons besoin.
  • Écrivez notre propre implémentation qui surpassera les performances du package que nous utilisons actuellement.

Heureusement pour moi, il existe une version plus récente du package basehash qui est responsable du hachage. Le code utilise la v.2.1.0, mais il existe une v.3.0.4. De telles situations, lorsque vous êtes en mesure de mettre à jour vers une version plus récente d'un package, sont plus probables lorsque vous travaillez sur un projet existant.

Lors de la vérification des notes de version pour la v.3, il y a cette phrase spécifique qui semble très prometteuse :

Une refonte massive a été effectuée avec les algorithmes de primalité. Y compris (sic) le support de gmpy2 s'il est disponible (sic) sur le système pour une augmentation beaucoup plus importante.

Découvrons cela !

 pip install -U basehash gmpy2

200 OBTENIR
/api/v1/maisons/

7738 ms au total
59ms sur les requêtes
4 requêtes

Nous avons réduit le temps de réponse de 17 à moins de 8 secondes. Excellent résultat, mais il y a encore une chose que nous devrions regarder.

2.3 Refactoriser votre propre code

Jusqu'à présent, nous avons amélioré nos requêtes, remplacé le code complexe et générique tiers par nos propres fonctions très spécifiques et mis à jour les packages tiers, mais nous avons laissé notre code existant intact. Mais parfois, une petite refactorisation du code existant peut apporter des résultats impressionnants. Mais pour cela, nous devons à nouveau analyser les résultats du profilage.

Image des résultats de profilage

En regardant de plus près, vous pouvez voir que le hachage est toujours un problème (sans surprise, c'est la seule chose que nous faisons avec nos données), bien que nous nous soyons améliorés dans cette direction. Cependant, le rectangle verdâtre qui dit que __init__.py consomme 2,14 secondes me dérange, ainsi que le grisâtre __init__.py:54(hash) qui va juste après. Cela signifie que certaines initialisations prennent beaucoup de temps.

Examinons le code source du package basehash .

 # basehash/__init__.py # Initialization of `base36` class initializes the parent, `base` class. class base36(base): def __init__(self, length=HASH_LENGTH, generator=GENERATOR): super(base36, self).__init__(BASE36, length, generator) class base(object): def __init__(self, alphabet, length=HASH_LENGTH, generator=GENERATOR): if len(set(alphabet)) != len(alphabet): raise ValueError('Supplied alphabet cannot contain duplicates.') self.alphabet = tuple(alphabet) self.base = len(alphabet) self.length = length self.generator = generator self.maximum = self.base ** self.length - 1 self.prime = next_prime(int((self.maximum + 1) * self.generator)) # `next_prime` call on each initialized instance

Comme vous pouvez le voir, l'initialisation d'une instance de base nécessite un appel de la fonction next_prime ; c'est assez lourd comme on peut le voir dans les rectangles en bas à gauche de la visualisation ci-dessus.

Reprenons ma classe Hash :

 class Hasher(object): @classmethod def from_model(cls, obj, klass=None): if obj.pk is None: return None return cls.make_hash(obj.pk, klass if klass is not None else obj) @classmethod def make_hash(cls, object_pk, klass): base36 = basehash.base36() # <-- initializing on each method call content_type = ContentType.objects.get_for_model(klass, for_concrete_model=False) return base36.hash('%(contenttype_pk)03d%(object_pk)06d' % { 'contenttype_pk': content_type.pk, 'object_pk': object_pk }) @classmethod def parse_hash(cls, obj_hash): base36 = basehash.base36() # <-- initializing on each method call unhashed = '%09d' % base36.unhash(obj_hash) contenttype_pk = int(unhashed[:-6]) object_pk = int(unhashed[-6:]) return contenttype_pk, object_pk @classmethod def to_object_pk(cls, obj_hash): return cls.parse_hash(obj_hash)[1]

Comme vous pouvez le voir, j'ai étiqueté deux méthodes qui initialisent une instance base36 à chaque appel de méthode, ce qui n'est pas vraiment nécessaire.

Comme le hachage est une procédure déterministe, c'est-à-dire que pour une valeur d'entrée donnée il doit toujours générer la même valeur de hachage, on peut en faire un attribut de classe sans craindre qu'il casse quelque chose. Voyons comment cela fonctionnera :

 class Hasher(object): base36 = basehash.base36() # <-- initialize hasher only once @classmethod def from_model(cls, obj, klass=None): if obj.pk is None: return None return cls.make_hash(obj.pk, klass if klass is not None else obj) @classmethod def make_hash(cls, object_pk, klass): content_type = ContentType.objects.get_for_model(klass, for_concrete_model=False) return cls.base36.hash('%(contenttype_pk)03d%(object_pk)06d' % { 'contenttype_pk': content_type.pk, 'object_pk': object_pk }) @classmethod def parse_hash(cls, obj_hash): unhashed = '%09d' % cls.base36.unhash(obj_hash) contenttype_pk = int(unhashed[:-6]) object_pk = int(unhashed[-6:]) return contenttype_pk, object_pk @classmethod def to_object_pk(cls, obj_hash): return cls.parse_hash(obj_hash)[1]

200 OBTENIR
/api/v1/maisons/

3766 ms au total
38ms sur les requêtes
4 requêtes

Le résultat final est inférieur à quatre secondes, ce qui est beaucoup plus petit que ce avec quoi nous avons commencé. Une optimisation supplémentaire du temps de réponse peut être obtenue en utilisant la mise en cache, mais je ne l'aborderai pas dans cet article.

Conclusion

L'optimisation des performances est un processus d'analyse et de découverte. Il n'y a pas de règles strictes qui s'appliquent à tous les cas, car chaque projet a son propre flux et ses propres goulots d'étranglement. Cependant, la première chose à faire est de profiler votre code. Et si dans un exemple aussi court, je pouvais réduire le temps de réponse de 77 secondes à 3,7 secondes, les grands projets ont encore plus de potentiel d'optimisation.

Si vous souhaitez lire d'autres articles liés à Django, consultez le Top 10 des erreurs commises par les développeurs de Django par Alexandr Shurigin, un autre développeur de Toptal Django.