Una guía para pruebas de rendimiento y optimización con Python y Django

Publicado: 2022-03-11

Donald Knuth dijo que “la optimización prematura es la raíz de todos los males”. Pero llega un momento, generalmente en proyectos maduros con cargas elevadas, en que surge la inevitable necesidad de optimizar. En este artículo, me gustaría hablar sobre cinco métodos comunes para optimizar el código de su proyecto web. Usaré Django, pero los principios deberían ser similares para otros marcos y lenguajes. En este artículo, usaré estos métodos para reducir el tiempo de respuesta de una consulta de 77 a 3,7 segundos.

Guía para la optimización del rendimiento y las pruebas de rendimiento con Python y Django

El código de ejemplo está adaptado de un proyecto real con el que he trabajado y demuestra las técnicas de optimización del rendimiento. En caso de que desee seguir y ver los resultados usted mismo, puede obtener el código en su estado inicial en GitHub y realizar los cambios correspondientes mientras sigue el artículo. Usaré Python 2, ya que algunos paquetes de terceros aún no están disponibles para Python 3.

Presentamos nuestra aplicación

Nuestro proyecto web simplemente rastrea las ofertas inmobiliarias por país. Por lo tanto, solo hay dos modelos:

 # 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)

El HashableModel abstracto proporciona a cualquier modelo que herede de él una propiedad hash que contiene la clave principal de la instancia y el tipo de contenido del modelo. Esto oculta datos confidenciales, como ID de instancia, sustituyéndolos por un hash. También puede ser útil en los casos en que su proyecto tiene varios modelos y necesita un lugar centralizado que deshaga y decida qué hacer con diferentes instancias de modelos de diferentes clases. Tenga en cuenta que para nuestro pequeño proyecto, el hash no es realmente necesario, ya que podemos trabajar sin él, pero ayudará a demostrar algunas técnicas de optimización, por lo que lo mantendré allí.

Aquí está la clase 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]

Como nos gustaría servir estos datos a través de un punto final de API, instalamos Django REST Framework y definimos los siguientes serializadores y vistas:

 # 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)

Ahora, completamos nuestra base de datos con algunos datos (un total de 100 000 instancias de casas generadas con factory-boy : 50 000 para un país, 40 000 para otro y 10 000 para un tercer país) y estamos listos para probar el rendimiento de nuestra aplicación.

La optimización del rendimiento tiene que ver con la medición

Hay varias cosas que podemos medir en un proyecto:

  • Tiempo de ejecución
  • Número de líneas de código
  • Número de llamadas de función
  • Memoria asignada
  • Etc

Pero no todos son relevantes para medir el desempeño de nuestro proyecto. En términos generales, hay dos métricas principales que son las más importantes: cuánto tiempo se ejecuta algo y cuánta memoria necesita.

En un proyecto web, el tiempo de respuesta (el tiempo necesario para que el servidor reciba una solicitud generada por la acción de algún usuario, la procese y envíe el resultado) suele ser la métrica más importante, ya que no permite que los usuarios se aburran mientras esperan. para obtener una respuesta y cambiar a otra pestaña en su navegador.

En programación, el análisis del rendimiento del proyecto se denomina elaboración de perfiles. Para perfilar el rendimiento de nuestro punto final de API, utilizaremos el paquete Silk. Después de instalarlo y hacer nuestra llamada /api/v1/houses/?country=5T22RI (el hash que corresponde al país con 50.000 entradas de casas), obtenemos esto:

200 OBTENER
/api/v1/casas/

77292ms total
15854ms en consultas
50004 consultas

El tiempo de respuesta global es de 77 segundos, de los cuales 16 segundos se dedican a consultas en la base de datos, donde se han realizado un total de 50.000 consultas. Con números tan grandes, hay mucho margen de mejora, así que comencemos.

1. Optimización de consultas de bases de datos

Uno de los consejos más frecuentes sobre la optimización del rendimiento es asegurarse de que las consultas de la base de datos estén optimizadas. Este caso no es una excepción. Además, podemos hacer varias cosas con nuestras consultas para optimizar el tiempo de respuesta.

1.1 Proporcionar todos los datos a la vez

Al observar más de cerca cuáles son esas 50,000 consultas, puede ver que todas estas son consultas redundantes a la tabla houses_country :

200 OBTENER
/api/v1/casas/

77292ms total
15854ms en consultas
50004 consultas

En Mesas Uniones Tiempo de ejecución (ms)
+0:01:15.874374 "casas_país" 0 0.176
+0:01:15.873304 "casas_país" 0 0.218
+0:01:15.872225 "casas_país" 0 0.218
+0:01:15.871155 "casas_país" 0 0.198
+0:01:15.870099 "casas_país" 0 0.173
+0:01:15.869050 "casas_país" 0 0.197
+0:01:15.867877 "casas_país" 0 0.221
+0:01:15.866807 "casas_país" 0 0.203
+0:01:15.865646 "casas_país" 0 0.211
+0:01:15.864562 "casas_país" 0 0.209
+0:01:15.863511 "casas_país" 0 0.181
+0:01:15.862435 "casas_país" 0 0.228
+0:01:15.861413 "casas_país" 0 0.174

La fuente de este problema es el hecho de que, en Django, los conjuntos de consultas son perezosos . Esto significa que un conjunto de consultas no se evalúa y no llega a la base de datos hasta que realmente necesita obtener los datos. Al mismo tiempo, obtiene solo los datos que le indicaste, y realiza solicitudes posteriores si se necesitan datos adicionales.

Eso es exactamente lo que sucedió en nuestro caso. Al establecer la consulta a través House.objects.filter(country=country) , Django obtiene una lista de todas las casas en el país dado. Sin embargo, al serializar una instancia de house , HouseSerializer requiere la instancia de country de la casa para calcular el campo de country del serializador. Como los datos del país no están presentes en el conjunto de consultas, Django realiza una solicitud adicional para obtener esos datos. Y lo hace para cada casa en el conjunto de consulta, eso es 50,000 veces en total.

Aunque la solución es muy sencilla. Para extraer todos los datos necesarios para la serialización, puede utilizar el método select_related() en el conjunto de consultas. Por lo tanto, nuestro get_queryset se verá así:

 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

Veamos cómo esto impactó el rendimiento:

200 OBTENER
/api/v1/casas/

35979ms total
102ms en consultas
4 consultas

El tiempo de respuesta general se redujo a 36 segundos y el tiempo de permanencia en la base de datos es de ~100 ms con solo 4 consultas. Esa es una gran noticia, pero podemos hacer más.

1.2 Proporcione solo los datos relevantes

Por defecto, Django extrae todos los campos de la base de datos. Sin embargo, cuando tiene tablas enormes con muchas columnas y filas, tiene sentido decirle a Django qué campos específicos extraer, para que no pierda tiempo en obtener información que no se usará en absoluto. En nuestro caso, solo necesitamos cinco campos para la serialización, pero tenemos 17 campos. Tiene sentido especificar exactamente qué campos extraer de la base de datos, para reducir aún más el tiempo de respuesta.

Django tiene los métodos de conjunto de consultas defer() y only() para hacer exactamente esto. El primero especifica qué campos no cargar y el segundo especifica qué campos cargar solamente .

 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

Esto redujo a la mitad el tiempo dedicado a las consultas, lo cual es bueno, pero 50 ms no es mucho. El tiempo total también se redujo ligeramente, pero hay más espacio para reducirlo.

200 OBTENER
/api/v1/casas/

33111 ms en general
52ms en consultas
4 consultas

2. Optimización de su código

No puede optimizar infinitamente las consultas de la base de datos, y nuestro último resultado acaba de demostrarlo. Incluso si hipotéticamente disminuyéramos el tiempo dedicado a las consultas a 0, todavía nos enfrentaríamos a la realidad de esperar medio minuto para obtener la respuesta. Es hora de pasar a otro nivel de optimización: la lógica empresarial .

2.1 Simplifique su código

A veces, los paquetes de terceros conllevan muchos gastos generales para tareas simples. Un ejemplo de ello es nuestra tarea de devolver instancias de casa serializadas.

Django REST Framework es excelente, con muchas características útiles listas para usar. Sin embargo, nuestro objetivo principal en este momento es reducir el tiempo de respuesta, por lo que es un gran candidato para la optimización, especialmente porque los objetos serializados son bastante simples.

Escribamos un serializador personalizado para este propósito. Para mantenerlo simple, tendremos un solo método estático que hace el trabajo. En realidad, es posible que desee tener las mismas firmas de clase y método para poder usar serializadores indistintamente:

 # 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 OBTENER
/api/v1/casas/

17312 ms en general
38ms en consultas
4 consultas

Esto se ve mejor ahora. El tiempo de respuesta se redujo casi a la mitad debido al hecho de que no empleamos código de serializadores DRF.

Otro resultado medible, la cantidad total de llamadas de función realizadas durante el ciclo de solicitud/respuesta, se redujo de 15 859 427 llamadas (de la solicitud realizada en la sección 1.2 anterior) a 9 257 469 llamadas. Esto significa que alrededor de 1/3 de todas las llamadas a funciones fueron realizadas por Django REST Framework.

2.2 Actualizar/sustituir paquetes de terceros

Las técnicas de optimización descritas anteriormente son las más comunes, las que puede realizar sin un análisis ni una reflexión exhaustivos. Sin embargo, 17 segundos todavía se sienten bastante largos; para reducir este número, tendremos que profundizar en nuestro código y analizar lo que sucede debajo del capó. En otras palabras, necesitaremos perfilar nuestro código.

Puede crear el perfil usted mismo, utilizando el generador de perfiles de Python incorporado, o puede usar algunos paquetes de terceros para ello (que usan el generador de perfiles de Python incorporado). Como ya usamos silk , puede perfilar el código y generar un archivo de perfil binario, que podemos visualizar aún más. Hay varios paquetes de visualización que transforman un perfil binario en algunas visualizaciones perspicaces. Usaré el paquete snakeviz .

Aquí está la visualización del perfil binario de la última solicitud de arriba, enganchada al método de envío de la vista:

Imagen del método de envío de la vista

De arriba a abajo está la pila de llamadas, que muestra el nombre del archivo, el nombre del método/función con su número de línea y el tiempo acumulado correspondiente empleado en ese método. Ahora es más fácil ver que la mayor parte del tiempo se dedica a calcular el hash (los rectángulos __init__.py y primes.py de color violeta).

Actualmente, este es el principal cuello de botella de rendimiento en nuestro código, pero al mismo tiempo no es realmente nuestro código, es un paquete de terceros.

En tales situaciones, hay un número limitado de cosas que podemos hacer:

  • Busque una nueva versión del paquete (que con suerte tiene un mejor rendimiento).
  • Encuentre otro paquete que funcione mejor en las tareas que necesitamos.
  • Escriba nuestra propia implementación que superará el rendimiento del paquete que usamos actualmente.

Por suerte para mí, hay una versión más nueva del paquete basehash que es responsable del hash. El código usa v.2.1.0, pero hay una v.3.0.4. Tales situaciones, cuando puede actualizar a una versión más nueva de un paquete, son más probables cuando está trabajando en un proyecto existente.

Al revisar las notas de la versión para v.3, hay esta oración específica que suena muy prometedora:

Se realizó una revisión masiva con los algoritmos de primalidad. Incluyendo (sic) soporte para gmpy2 si está disponible (sic) en el sistema para un aumento mucho mayor.

¡Averigüemos esto!

 pip install -U basehash gmpy2

200 OBTENER
/api/v1/casas/

7738ms total
59ms en consultas
4 consultas

Reducimos el tiempo de respuesta de 17 a menos de 8 segundos. Gran resultado, pero hay una cosa más que debemos mirar.

2.3 Refactoriza tu propio código

Hasta ahora, hemos mejorado nuestras consultas, hemos sustituido el código complejo y genérico de terceros con nuestras propias funciones muy específicas y hemos actualizado los paquetes de terceros, pero dejamos intacto nuestro código existente. Pero a veces, una pequeña refactorización del código existente puede generar resultados impresionantes. Pero para esto necesitamos nuevamente analizar los resultados del perfilado.

Imagen de resultados de perfiles

Mirando más de cerca, puede ver que el hashing sigue siendo un problema (no es sorprendente que sea lo único que hacemos con nuestros datos), aunque mejoramos en esa dirección. Sin embargo, me molesta el rectángulo verdoso que dice que __init__.py consume 2.14 segundos, junto con el grisáceo __init__.py:54(hash) que va justo después. Esto significa que algunas inicializaciones tardan mucho tiempo.

Echemos un vistazo al código fuente del paquete 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

Como puede ver, la inicialización de una instancia base requiere una llamada a la función next_prime ; eso es bastante pesado, como podemos ver en los rectángulos inferiores izquierdos de la visualización anterior.

Echemos un vistazo a mi clase Hash nuevamente:

 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]

Como puede ver, he etiquetado dos métodos que inicializan una instancia base36 en cada llamada de método, lo cual no es realmente necesario.

Como hashing es un procedimiento determinista, lo que significa que para un valor de entrada dado siempre debe generar el mismo valor hash, podemos convertirlo en un atributo de clase sin temor a que rompa algo. Veamos cómo funcionará:

 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 OBTENER
/api/v1/casas/

3766 ms en general
38ms en consultas
4 consultas

El resultado final es de menos de cuatro segundos, que es mucho más pequeño de lo que comenzamos. Se puede lograr una mayor optimización del tiempo de respuesta mediante el almacenamiento en caché, pero no lo abordaré en este artículo.

Conclusión

La optimización del rendimiento es un proceso de análisis y descubrimiento. No existen reglas estrictas que se apliquen a todos los casos, ya que cada proyecto tiene su propio flujo y cuellos de botella. Sin embargo, lo primero que debe hacer es perfilar su código. Y si en un ejemplo tan breve pudiera reducir el tiempo de respuesta de 77 segundos a 3,7 segundos, los grandes proyectos tienen aún más potencial de optimización.

Si está interesado en leer más artículos relacionados con Django, consulte los 10 errores principales que cometen los desarrolladores de Django por el desarrollador de Toptal Django, Alexandr Shurigin.