Python >> Tutoriel Python >  >> Python

Améliorer les performances de sérialisation dans Django Rest Framework

Lorsqu'un développeur choisit Python, Django ou Django Rest Framework, ce n'est généralement pas en raison de ses performances fulgurantes. Python a toujours été le choix "confortable", le langage que vous choisissez lorsque vous vous souciez davantage de l'ergonomie que d'écrémer quelques microsecondes d'un processus.

Rien à redire sur l'ergonomie. La plupart des projets n'ont pas vraiment besoin de cette micro-seconde d'amélioration des performances, mais ils doivent fournir rapidement un code de qualité.

Tout cela ne signifie pas que la performance n'est pas importante. Comme cette histoire nous l'a appris, des améliorations majeures des performances peuvent être obtenues avec juste un peu d'attention et quelques petits changements.

Performances du sérialiseur de modèle

Il y a quelque temps, nous avons remarqué de très mauvaises performances de l'un de nos principaux points de terminaison d'API. Le point de terminaison a extrait des données d'une très grande table, nous avons donc naturellement supposé que le problème devait provenir de la base de données.

Lorsque nous avons remarqué que même de petits ensembles de données obtenaient des performances médiocres, nous avons commencé à examiner d'autres parties de l'application. Ce voyage nous a finalement conduits aux sérialiseurs Django Rest Framework (DRF).

version

Dans le benchmark, nous utilisons Python 3.7, Django 2.1.1 et Django Rest Framework 3.9.4.

Fonction simple

Les sérialiseurs sont utilisés pour transformer des données en objets et des objets en données. C'est une fonction simple, écrivons-en une qui accepte un User instance, et renvoie un dict :

from typing import Dict, Any

from django.contrib.auth.models import User


def serialize_user(user: User) -> Dict[str, Any]:
 return {
 'id': user.id,
 'last_login': user.last_login.isoformat() if user.last_login is not None else None,
 'is_superuser': user.is_superuser,
 'username': user.username,
 'first_name': user.first_name,
 'last_name': user.last_name,
 'email': user.email,
 'is_staff': user.is_staff,
 'is_active': user.is_active,
 'date_joined': user.date_joined.isoformat(),
 }

Créez un utilisateur à utiliser dans le benchmark :

>>> from django.contrib.auth.models import User
>>> u = User.objects.create_user(
>>> username='hakib',
>>> first_name='haki',
>>> last_name='benita',
>>> email='[email protected]',
>>> )

Pour notre benchmark, nous utilisons cProfile . Pour éliminer les influences externes telles que la base de données, nous récupérons un utilisateur à l'avance et le sérialisons 5 000 fois :

>>> import cProfile
>>> cProfile.run('for i in range(5000): serialize_user(u)', sort='tottime')
15003 function calls in 0.034 seconds

Ordered by: internal time
ncalls tottime percall cumtime percall filename:lineno(function)
 5000 0.020 0.000 0.021 0.000 {method 'isoformat' of 'datetime.datetime' objects}
 5000 0.010 0.000 0.030 0.000 drf_test.py:150(serialize_user)
 1 0.003 0.003 0.034 0.034 <string>:1(<module>)
 5000 0.001 0.000 0.001 0.000 __init__.py:208(utcoffset)
 1 0.000 0.000 0.034 0.034 {built-in method builtins.exec}
 1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}

La fonction simple a pris 0,034 secondes pour sérialiser un User objet 5 000 fois.

ModelSerializer

Django Rest Framework (DRF) est livré avec quelques classes utilitaires, à savoir le ModelSerializer .

Un ModelSerializer pour le User intégré le modèle pourrait ressembler à ceci :

from rest_framework import serializers

class UserModelSerializer(serializers.ModelSerializer):
 class Meta:
 model = User
 fields = [
 'id',
 'last_login',
 'is_superuser',
 'username',
 'first_name',
 'last_name',
 'email',
 'is_staff',
 'is_active',
 'date_joined',
 ]

Exécution du même benchmark qu'auparavant :

>>> cProfile.run('for i in range(5000): UserModelSerializer(u).data', sort='tottime')
18845053 function calls (18735053 primitive calls) in 12.818 seconds

Ordered by: internal time
 ncalls tottime percall cumtime percall filename:lineno(function)
 85000 2.162 0.000 4.706 0.000 functional.py:82(__prepare_class__)
 7955000 1.565 0.000 1.565 0.000 {built-in method builtins.hasattr}
 1080000 0.701 0.000 0.701 0.000 functional.py:102(__promise__)
 50000 0.594 0.000 4.886 0.000 field_mapping.py:66(get_field_kwargs)
 1140000 0.563 0.000 0.581 0.000 {built-in method builtins.getattr}
 55000 0.489 0.000 0.634 0.000 fields.py:319(__init__)
 1240000 0.389 0.000 0.389 0.000 {built-in method builtins.setattr}
 5000 0.342 0.000 11.773 0.002 serializers.py:992(get_fields)
 20000 0.338 0.000 0.446 0.000 {built-in method builtins.__build_class__}
 210000 0.333 0.000 0.792 0.000 trans_real.py:275(gettext)
 75000 0.312 0.000 2.285 0.000 functional.py:191(wrapper)
 20000 0.248 0.000 4.817 0.000 fields.py:762(__init__)
 1300000 0.230 0.000 0.264 0.000 {built-in method builtins.isinstance}
 50000 0.224 0.000 5.311 0.000 serializers.py:1197(build_standard_field)

Il a fallu 12,8 secondes à DRF pour sérialiser un utilisateur 5 000 fois, ou 2,56 ms pour sérialiser un seul utilisateur. C'est 377 fois plus lent que la fonction ordinaire .

Nous pouvons voir qu'une quantité importante de temps est passée en functional.py . ModelSerializer utilise le lazy fonction à partir de django.utils.functional pour évaluer les validations. Il est également utilisé par les noms verbeux de Django, etc., qui sont également évalués par DRF. Cette fonction semble alourdir le sérialiseur.

Lecture seule ModelSerializer

Les validations de champs sont ajoutées par ModelSerializer uniquement pour les champs inscriptibles. Pour mesurer l'effet de la validation, nous créons un ModelSerializer et marquez tous les champs en lecture seule :

from rest_framework import serializers

class UserReadOnlyModelSerializer(serializers.ModelSerializer):
 class Meta:
 model = User
 fields = [
 'id',
 'last_login',
 'is_superuser',
 'username',
 'first_name',
 'last_name',
 'email',
 'is_staff',
 'is_active',
 'date_joined',
 ]
 read_only_fields = fields

Lorsque tous les champs sont en lecture seule, le sérialiseur ne peut pas être utilisé pour créer de nouvelles instances.

Exécutons notre benchmark avec le sérialiseur en lecture seule :

>>> cProfile.run('for i in range(5000): UserReadOnlyModelSerializer(u).data', sort='tottime')
14540060 function calls (14450060 primitive calls) in 7.407 seconds

 Ordered by: internal time
 ncalls tottime percall cumtime percall filename:lineno(function)
6090000 0.809 0.000 0.809 0.000 {built-in method builtins.hasattr}
 65000 0.725 0.000 1.516 0.000 functional.py:82(__prepare_class__)
 50000 0.561 0.000 4.182 0.000 field_mapping.py:66(get_field_kwargs)
 55000 0.435 0.000 0.558 0.000 fields.py:319(__init__)
 840000 0.330 0.000 0.346 0.000 {built-in method builtins.getattr}
 210000 0.294 0.000 0.688 0.000 trans_real.py:275(gettext)
 5000 0.282 0.000 6.510 0.001 serializers.py:992(get_fields)
 75000 0.220 0.000 1.989 0.000 functional.py:191(wrapper)
1305000 0.200 0.000 0.228 0.000 {built-in method builtins.isinstance}
 50000 0.182 0.000 4.531 0.000 serializers.py:1197(build_standard_field)
 50000 0.145 0.000 0.259 0.000 serializers.py:1310(include_extra_kwargs)
 55000 0.133 0.000 0.696 0.000 text.py:14(capfirst)
 50000 0.127 0.000 2.377 0.000 field_mapping.py:46(needs_label)
 210000 0.119 0.000 0.145 0.000 gettext.py:451(gettext)

Seulement 7,4 secondes. Une amélioration de 40 % par rapport au ModelSerializer inscriptible .

Dans la sortie du benchmark, nous pouvons voir que beaucoup de temps est passé en field_mapping.py et fields.py . Ceux-ci sont liés au fonctionnement interne du ModelSerializer . Dans le processus de sérialisation et d'initialisation, le ModelSerializer utilise beaucoup de métadonnées pour construire et valider les champs du sérialiseur, et cela a un coût.

"Régulier" Serializer

Dans le benchmark suivant, nous voulions mesurer exactement combien le ModelSerializer nous "coûte". Créons un Serializer "normal" pour le User modèle :

from rest_framework import serializers

class UserSerializer(serializers.Serializer):
 id = serializers.IntegerField()
 last_login = serializers.DateTimeField()
 is_superuser = serializers.BooleanField()
 username = serializers.CharField()
 first_name = serializers.CharField()
 last_name = serializers.CharField()
 email = serializers.EmailField()
 is_staff = serializers.BooleanField()
 is_active = serializers.BooleanField()
 date_joined = serializers.DateTimeField()

Exécution du même benchmark à l'aide du sérialiseur "normal" :

>>> cProfile.run('for i in range(5000): UserSerializer(u).data', sort='tottime')
3110007 function calls (3010007 primitive calls) in 2.101 seconds

Ordered by: internal time
 ncalls tottime percall cumtime percall filename:lineno(function)
 55000 0.329 0.000 0.430 0.000 fields.py:319(__init__)
105000/5000 0.188 0.000 1.247 0.000 copy.py:132(deepcopy)
 50000 0.145 0.000 0.863 0.000 fields.py:626(__deepcopy__)
 20000 0.093 0.000 0.320 0.000 fields.py:762(__init__)
 310000 0.092 0.000 0.092 0.000 {built-in method builtins.getattr}
 50000 0.087 0.000 0.125 0.000 fields.py:365(bind)
 5000 0.072 0.000 1.934 0.000 serializers.py:508(to_representation)
 55000 0.055 0.000 0.066 0.000 fields.py:616(__new__)
 5000 0.053 0.000 1.204 0.000 copy.py:268(_reconstruct)
 235000 0.052 0.000 0.052 0.000 {method 'update' of 'dict' objects}
 50000 0.048 0.000 0.097 0.000 fields.py:55(is_simple_callable)
 260000 0.048 0.000 0.075 0.000 {built-in method builtins.isinstance}
 25000 0.047 0.000 0.051 0.000 deconstruct.py:14(__new__)
 55000 0.042 0.000 0.057 0.000 copy.py:252(_keep_alive)
 50000 0.041 0.000 0.197 0.000 fields.py:89(get_attribute)
 5000 0.037 0.000 1.459 0.000 serializers.py:353(fields)

Voici le saut que nous attendions !

Le sérialiseur "normal" n'a pris que 2,1 secondes. C'est 60 % plus rapide que la lecture seule ModelSerializer , et 85 % plus rapide que le ModelSerializer inscriptible .

À ce stade, il devient évident que le ModelSerializer n'est pas bon marché !

Lecture seule "normale" Serializer

Dans le ModelSerializer inscriptible beaucoup de temps a été consacré aux validations. Nous avons pu l'accélérer en marquant tous les champs en lecture seule. Le sérialiseur "normal" ne définit aucune validation, donc le marquage des champs en lecture seule ne devrait pas être plus rapide. Assurons-nous :

from rest_framework import serializers

class UserReadOnlySerializer(serializers.Serializer):
 id = serializers.IntegerField(read_only=True)
 last_login = serializers.DateTimeField(read_only=True)
 is_superuser = serializers.BooleanField(read_only=True)
 username = serializers.CharField(read_only=True)
 first_name = serializers.CharField(read_only=True)
 last_name = serializers.CharField(read_only=True)
 email = serializers.EmailField(read_only=True)
 is_staff = serializers.BooleanField(read_only=True)
 is_active = serializers.BooleanField(read_only=True)
 date_joined = serializers.DateTimeField(read_only=True)

Et en exécutant le benchmark pour une instance d'utilisateur :

>>> cProfile.run('for i in range(5000): UserReadOnlySerializer(u).data', sort='tottime')
3360009 function calls (3210009 primitive calls) in 2.254 seconds

Ordered by: internal time
 ncalls tottime percall cumtime percall filename:lineno(function)
 55000 0.329 0.000 0.433 0.000 fields.py:319(__init__)
155000/5000 0.241 0.000 1.385 0.000 copy.py:132(deepcopy)
 50000 0.161 0.000 1.000 0.000 fields.py:626(__deepcopy__)
 310000 0.095 0.000 0.095 0.000 {built-in method builtins.getattr}
 20000 0.088 0.000 0.319 0.000 fields.py:762(__init__)
 50000 0.087 0.000 0.129 0.000 fields.py:365(bind)
 5000 0.073 0.000 2.086 0.000 serializers.py:508(to_representation)
 55000 0.055 0.000 0.067 0.000 fields.py:616(__new__)
 5000 0.054 0.000 1.342 0.000 copy.py:268(_reconstruct)
 235000 0.053 0.000 0.053 0.000 {method 'update' of 'dict' objects}
 25000 0.052 0.000 0.057 0.000 deconstruct.py:14(__new__)
 260000 0.049 0.000 0.076 0.000 {built-in method builtins.isinstance}

Comme prévu, le marquage des champs en lecture seule n'a pas fait de différence significative par rapport au sérialiseur "normal". Cela réaffirme que le temps a été consacré aux validations dérivées des définitions de champs du modèle.

Résumé des résultats

Voici un résumé des résultats jusqu'à présent :

sérialiseur secondes
UserModelSerializer 12.818
UserReadOnlyModelSerializer 7.407
UserSerializer 2.101
UserReadOnlySerializer 2.254
serialize_user 0,034

Pourquoi cela se passe-t-il ?

De nombreux articles ont été écrits sur les performances de sérialisation en Python. Comme prévu, la plupart des articles se concentrent sur l'amélioration de l'accès à la base de données à l'aide de techniques telles que select_related et prefetch_related . Bien que les deux soient des moyens valables d'améliorer l'ensemble temps de réponse d'une requête API, ils ne traitent pas la sérialisation elle-même. Je suppose que c'est parce que personne ne s'attend à ce que la sérialisation soit lente.

Travail antérieur

D'autres articles qui se concentrent uniquement sur la sérialisation évitent généralement de corriger DRF et motivent à la place de nouveaux cadres de sérialisation tels que marshmallow et serpy. Il existe même un site consacré à la comparaison des formats de sérialisation en Python. Pour vous éviter un clic, DRF vient toujours en dernier.

Fin 2013, Tom Christie, le créateur de Django Rest Framework, a écrit un article sur certains des inconvénients de DRF. Dans ses benchmarks, la sérialisation représentait 12 % du temps total consacré au traitement d'une seule demande. En résumé, Tom recommande de ne pas toujours recourir à la sérialisation :

Comme nous le verrons dans un instant, ce sont des conseils solides.

Correction du lazy de Django

Dans le premier benchmark en utilisant ModelSerializer nous avons vu beaucoup de temps passé en functional.py , et plus précisément dans la fonction lazy .

La fonction lazy est utilisé en interne par Django pour de nombreuses choses telles que les noms verbeux, les modèles, etc. La source décrit lazy comme suit :

Le lazy La fonction fait sa magie en créant un proxy de la classe de résultat. Pour créer le proxy, lazy itère sur tous les attributs et fonctions de la classe de résultat (et de ses super-classes) et crée une classe wrapper qui évalue la fonction uniquement lorsque son résultat est réellement utilisé.

Pour les grandes classes de résultats, la création du proxy peut prendre un certain temps. Donc, pour accélérer les choses, lazy met en cache le proxy. Mais il s'avère qu'un petit oubli dans le code a complètement cassé le mécanisme de cache, rendant le lazy fonction très très lent.

Pour avoir une idée de la lenteur lazy est sans mise en cache appropriée, utilisons une fonction simple qui renvoie un str (la classe de résultat), comme upper . Nous choisissons str car il a beaucoup de méthodes, donc cela devrait prendre un certain temps pour configurer un proxy pour cela.

Pour établir une ligne de base, nous comparons à l'aide de str.upper directement, sans lazy :

>>> import cProfile
>>> from django.utils.functional import lazy
>>> upper = str.upper
>>> cProfile.run('''for i in range(50000): upper('hello') + ""''', sort='cumtime')

 50003 function calls in 0.034 seconds

 Ordered by: cumulative time

 ncalls tottime percall cumtime percall filename:lineno(function)
 1 0.000 0.000 0.034 0.034 {built-in method builtins.exec}
 1 0.024 0.024 0.034 0.034 <string>:1(<module>)
 50000 0.011 0.000 0.011 0.000 {method 'upper' of 'str' objects}
 1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}

Maintenant, pour la partie effrayante, exactement la même fonction mais cette fois enveloppée avec lazy :

>>> lazy_upper = lazy(upper, str)
>>> cProfile.run('''for i in range(50000): lazy_upper('hello') + ""''', sort='cumtime')

 4900111 function calls in 1.139 seconds

 Ordered by: cumulative time

 ncalls tottime percall cumtime percall filename:lineno(function)
 1 0.000 0.000 1.139 1.139 {built-in method builtins.exec}
 1 0.037 0.037 1.139 1.139 <string>:1(<module>)
 50000 0.018 0.000 1.071 0.000 functional.py:160(__wrapper__)
 50000 0.028 0.000 1.053 0.000 functional.py:66(__init__)
 50000 0.500 0.000 1.025 0.000 functional.py:83(__prepare_class__)
4600000 0.519 0.000 0.519 0.000 {built-in method builtins.hasattr}
 50000 0.024 0.000 0.031 0.000 functional.py:106(__wrapper__)
 50000 0.006 0.000 0.006 0.000 {method 'mro' of 'type' objects}
 50000 0.006 0.000 0.006 0.000 {built-in method builtins.getattr}
 54 0.000 0.000 0.000 0.000 {built-in method builtins.setattr}
 54 0.000 0.000 0.000 0.000 functional.py:103(__promise__)
 1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}

Pas d'erreur! Utilisation de lazy il a fallu 1,139 secondes pour transformer 5 000 chaînes en majuscules. La même fonction exacte utilisée directement n'a pris que 0,034 seconde. C'est 33,5 fois plus rapide.

C'était évidemment un oubli. Les développeurs étaient clairement conscients de l'importance de la mise en cache du proxy. Un PR a été publié et fusionné peu de temps après (diff ici). Une fois publié, ce patch est censé améliorer un peu les performances globales de Django.

Réparer le framework Django Rest

DRF utilise lazy pour les validations et les noms verbeux des champs. Lorsque toutes ces évaluations paresseuses sont réunies, vous obtenez un ralentissement notable.

Le correctif de lazy dans Django aurait également résolu ce problème pour DRF après une correction mineure, mais néanmoins, une correction distincte de DRF a été faite pour remplacer lazy avec quelque chose de plus efficace.

Pour voir l'effet des modifications, installez la dernière version de Django et DRF :

(venv) $ pip install git+https://github.com/encode/django-rest-framework
(venv) $ pip install git+https://github.com/django/django

Après avoir appliqué les deux correctifs, nous avons exécuté à nouveau le même test de performance. Voici les résultats côte à côte :

sérialiseur avant après % de changement
UserModelSerializer 12.818 5.674 -55 %
UserReadOnlyModelSerializer 7.407 5.323 -28 %
UserSerializer 2.101 2.146 +2 %
UserReadOnlySerializer 2.254 2.125 -5 %
serialize_user 0,034 0,034 0 %

Pour résumer les résultats des modifications apportées à Django et à DRF :

  • Temps de sérialisation pour ModelSerializer inscriptible a été réduit de moitié.
  • Temps de sérialisation pour une lecture seule ModelSerializer a été réduit de près d'un tiers.
  • Comme prévu, il n'y a pas de différence notable dans les autres méthodes de sérialisation.

À emporter

Nos plats à emporter de cette expérience étaient :

A emporter

Mettez à niveau DRF et Django une fois que ces correctifs auront fait leur chemin dans une version officielle.

Les deux PR ont été fusionnés mais pas encore publiés.

A emporter

Dans les points de terminaison critiques en termes de performances, utilisez un sérialiseur "normal", ou aucun.

Nous avions plusieurs endroits où les clients récupéraient de grandes quantités ou des données à l'aide d'une API. L'API n'était utilisée que pour lire les données du serveur, nous avons donc décidé de ne pas utiliser de Serializer pas du tout, et intégrez la sérialisation à la place.

A emporter

Les champs de sérialiseur qui ne sont pas utilisés pour l'écriture ou la validation doivent être en lecture seule.

Comme nous l'avons vu dans les benchmarks, la façon dont les validations sont mises en œuvre les rend coûteuses. Le marquage des champs en lecture seule élimine les coûts supplémentaires inutiles.

Bonus :Forcer les bonnes habitudes

Pour s'assurer que les développeurs n'oublient pas de définir des champs en lecture seule, nous avons ajouté une vérification Django pour s'assurer que tous les ModelSerializer s définir read_only_fields :

# common/checks.py

import django.core.checks

@django.core.checks.register('rest_framework.serializers')
def check_serializers(app_configs, **kwargs):
 import inspect
 from rest_framework.serializers import ModelSerializer
 import conf.urls # noqa, force import of all serializers.

 for serializer in ModelSerializer.__subclasses__():

 # Skip third-party apps.
 path = inspect.getfile(serializer)
 if path.find('site-packages') > -1:
 continue

 if hasattr(serializer.Meta, 'read_only_fields'):
 continue

 yield django.core.checks.Warning(
 'ModelSerializer must define read_only_fields.',
 hint='Set read_only_fields in ModelSerializer.Meta',
 obj=serializer,
 id='H300',
 )

Avec cette vérification en place, lorsqu'un développeur ajoute un sérialiseur, il doit également définir read_only_fields . Si le sérialiseur est accessible en écriture, read_only_fields peut être défini sur un tuple vide. Si un développeur oublie de définir read_only_fields , elle obtient l'erreur suivante :

$ python manage.py check
System check identified some issues:

WARNINGS:
<class 'serializers.UserSerializer'>: (H300) ModelSerializer must define read_only_fields.
 HINT: Set read_only_fields in ModelSerializer.Meta

System check identified 1 issue (4 silenced).

Nous utilisons beaucoup les vérifications Django pour nous assurer que rien ne passe entre les mailles du filet. Vous pouvez trouver de nombreuses autres vérifications utiles dans cet article sur la façon dont nous utilisons le cadre de vérification du système Django.