Una guida al test delle prestazioni e all'ottimizzazione con Python e Django

Pubblicato: 2022-03-11

Donald Knuth ha affermato che "l'ottimizzazione prematura è la radice di tutti i mali". Ma arriva un momento, solitamente nei progetti maturi con carichi elevati, in cui c'è un'inevitabile necessità di ottimizzazione. In questo articolo, vorrei parlare di cinque metodi comuni per ottimizzare il codice del tuo progetto web. Userò Django, ma i principi dovrebbero essere simili per altri framework e linguaggi. In questo articolo, utilizzerò questi metodi per ridurre il tempo di risposta di una query da 77 a 3,7 secondi.

Guida all'ottimizzazione delle prestazioni e al test delle prestazioni con Python e Django

Il codice di esempio è adattato da un progetto reale con cui ho lavorato ed è dimostrativo delle tecniche di ottimizzazione delle prestazioni. Nel caso in cui desideri seguire e vedere tu stesso i risultati, puoi prendere il codice nel suo stato iniziale su GitHub e apportare le modifiche corrispondenti mentre segui l'articolo. Userò Python 2, poiché alcuni pacchetti di terze parti non sono ancora disponibili per Python 3.

Presentazione della nostra applicazione

Il nostro progetto web tiene semplicemente traccia delle offerte immobiliari per paese. Pertanto, ci sono solo due modelli:

 # 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' HashableModel astratto fornisce a qualsiasi modello che ne erediti una proprietà hash che contiene la chiave primaria dell'istanza e il tipo di contenuto del modello. Questo nasconde i dati sensibili, come gli ID istanza, sostituendoli con un hash. Può anche essere utile nei casi in cui il tuo progetto ha più modelli e hai bisogno di una posizione centralizzata che annulli l'hashing e decida cosa fare con diverse istanze di modelli di classi diverse. Nota che per il nostro piccolo progetto, l'hashing non è realmente necessario, poiché possiamo farne a meno, ma aiuterà a dimostrare alcune tecniche di ottimizzazione, quindi lo terrò lì.

Ecco 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]

Poiché vorremmo fornire questi dati tramite un endpoint API, installiamo Django REST Framework e definiamo i seguenti serializzatori e visualizziamo:

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

Ora popolamo il nostro database con alcuni dati (un totale di 100.000 istanze house generate utilizzando factory-boy : 50.000 per un paese, 40.000 per un altro e 10.000 per un terzo paese) e siamo pronti per testare le prestazioni della nostra app.

L'ottimizzazione delle prestazioni riguarda la misurazione

Ci sono diverse cose che possiamo misurare in un progetto:

  • Tempo di esecuzione
  • Numero di righe di codice
  • Numero di chiamate di funzione
  • Memoria allocata
  • Eccetera.

Ma non tutti sono rilevanti per misurare il rendimento del nostro progetto. In generale, ci sono due parametri principali che sono i più importanti: per quanto tempo viene eseguito qualcosa e quanta memoria ha bisogno.

In un progetto web, il tempo di risposta (il tempo necessario al server per ricevere una richiesta generata dall'azione di un utente, elaborarla e rispedire il risultato) è solitamente la metrica più importante, in quanto non fa annoiare gli utenti durante l'attesa per una risposta e passare a un'altra scheda nel browser.

Nella programmazione, l'analisi delle prestazioni del progetto è chiamata profilazione. Per profilare le prestazioni del nostro endpoint API, utilizzeremo il pacchetto Silk. Dopo averlo installato e aver effettuato la nostra chiamata /api/v1/houses/?country=5T22RI (l'hash che corrisponde al paese con 50.000 voci di casa), otteniamo questo:

200 OTTIENI
/api/v1/case/

77292 ms complessivi
15854 ms sulle query
50004 query

Il tempo di risposta complessivo è di 77 secondi, di cui 16 secondi vengono spesi per le query nel database, dove sono state effettuate un totale di 50.000 query. Con numeri così grandi, c'è molto spazio per migliorare, quindi iniziamo.

1. Ottimizzazione delle query del database

Uno dei suggerimenti più frequenti sull'ottimizzazione delle prestazioni è assicurarsi che le query del database siano ottimizzate. Questo caso non fa eccezione. Inoltre, possiamo fare diverse cose sulle nostre domande per ottimizzare i tempi di risposta.

1.1 Fornire tutti i dati in una volta

Dando un'occhiata più da vicino a quali sono quelle 50.000 query, puoi vedere che queste sono tutte query ridondanti alla tabella houses_country :

200 OTTIENI
/api/v1/case/

77292 ms complessivi
15854 ms sulle query
50004 query

In Tabelle Si unisce Tempo di esecuzione (ms)
+0:01:15.874374 "case_paese" 0 0,176
+0:01:15.873304 "case_paese" 0 0,218
+0:01:15.872225 "case_paese" 0 0,218
+0:01:15.871155 "case_paese" 0 0,198
+0:01:15.870099 "case_paese" 0 0,173
+0:01:15.869050 "case_paese" 0 0,197
+0:01:15.867877 "case_paese" 0 0,221
+0:01:15.866807 "case_paese" 0 0,203
+0:01:15.865646 "case_paese" 0 0,211
+0:01:15.864562 "case_paese" 0 0,209
+0:01:15.863511 "case_paese" 0 0,181
+0:01:15.862435 "case_paese" 0 0,228
+0:01:15.861413 "case_paese" 0 0,174

La fonte di questo problema è il fatto che, in Django, i set di query sono pigri . Ciò significa che un set di query non viene valutato e non raggiunge il database finché non è necessario ottenere i dati. Allo stesso tempo, ottiene solo i dati a cui gli hai detto, effettuando richieste successive se sono necessari dati aggiuntivi.

Questo è esattamente quello che è successo nel nostro caso. Quando ottiene la query impostata tramite House.objects.filter(country=country) , Django ottiene un elenco di tutte le case nel paese specificato. Tuttavia, quando si serializza un'istanza house , HouseSerializer richiede l'istanza country della casa per calcolare il campo country del serializzatore. Poiché i dati del paese non sono presenti nel set di query, django fa una richiesta aggiuntiva per ottenere quei dati. E lo fa per ogni casa nel set di query, ovvero 50.000 volte in tutto.

La soluzione è molto semplice, però. Per estrarre tutti i dati richiesti per la serializzazione, puoi utilizzare il metodo select_related() sul set di query. Pertanto, il nostro get_queryset sarà simile a:

 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

Vediamo come questo ha influito sulle prestazioni:

200 OTTIENI
/api/v1/case/

35979 ms complessivi
102 ms sulle query
4 domande

Il tempo di risposta complessivo è sceso a 36 secondi e il tempo trascorso nel database è di circa 100 ms con solo 4 query! È un'ottima notizia, ma possiamo fare di più.

1.2 Fornire solo i dati rilevanti

Per impostazione predefinita, Django estrae tutti i campi dal database. Tuttavia, quando hai tabelle enormi con molte colonne e righe, ha senso dire a Django quali campi specifici estrarre, in modo che non impieghi tempo per ottenere informazioni che non verranno affatto utilizzate. Nel nostro caso, abbiamo bisogno solo di cinque campi per la serializzazione, ma abbiamo 17 campi. Ha senso specificare esattamente quali campi estrarre dal database, in modo da ridurre ulteriormente i tempi di risposta.

Django ha i metodi del set di query defer() e only() per fare esattamente questo. Il primo specifica quali campi non caricare e il secondo specifica solo quali campi caricare.

 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

Ciò ha dimezzato il tempo dedicato alle query, il che è positivo, ma 50 ms non sono così tanti. Anche il tempo complessivo è leggermente diminuito, ma c'è più spazio per tagliarlo.

200 OTTIENI
/api/v1/case/

33111 ms complessivi
52 ms sulle query
4 domande

2. Ottimizzazione del codice

Non puoi ottimizzare all'infinito le query del database e il nostro ultimo risultato lo ha appena mostrato. Anche se ipoteticamente riduciamo a 0 il tempo dedicato alle query, ci troveremmo comunque ad affrontare la realtà di aspettare mezzo minuto per ottenere la risposta. È ora di passare a un altro livello di ottimizzazione: la logica aziendale .

2.1 Semplifica il tuo codice

A volte, i pacchetti di terze parti comportano molto sovraccarico per attività semplici. Uno di questi esempi è il nostro compito di restituire istanze house serializzate.

Django REST Framework è eccezionale, con molte funzioni utili pronte all'uso. Tuttavia, il nostro obiettivo principale in questo momento è ridurre i tempi di risposta, quindi è un ottimo candidato per l'ottimizzazione, soprattutto perché gli oggetti serializzati sono abbastanza semplici.

Scriviamo un serializzatore personalizzato per questo scopo. Per semplificare, avremo un unico metodo statico che fa il lavoro. In realtà, potresti voler avere le stesse firme di classe e metodo per poter utilizzare i serializzatori in modo intercambiabile:

 # 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 OTTIENI
/api/v1/case/

17312 ms complessivi
38 ms sulle query
4 domande

Questo sembra migliore ora. Il tempo di risposta è stato quasi dimezzato a causa del fatto che non abbiamo utilizzato il codice dei serializzatori DRF.

Un altro risultato misurabile, il numero di chiamate di funzione totali effettuate durante il ciclo di richiesta/risposta, è sceso da 15.859.427 chiamate (dalla richiesta effettuata nella sezione 1.2 sopra) a 9.257.469 chiamate. Ciò significa che circa 1/3 di tutte le chiamate di funzione sono state effettuate da Django REST Framework.

2.2 Aggiornare/sostituire i pacchetti di terze parti

Le tecniche di ottimizzazione sopra descritte sono le più comuni, quelle che puoi fare senza un'analisi e un pensiero approfonditi. Tuttavia, 17 secondi sembrano ancora piuttosto lunghi; per ridurre questo numero, dovremo approfondire il nostro codice e analizzare cosa succede sotto il cofano. In altre parole, dovremo profilare il nostro codice.

Puoi eseguire tu stesso la profilazione, utilizzando il profiler Python integrato, oppure puoi utilizzare alcuni pacchetti di terze parti (che utilizzano il profiler Python integrato). Poiché utilizziamo già silk , può profilare il codice e generare un file di profilo binario, che possiamo visualizzare ulteriormente. Esistono diversi pacchetti di visualizzazione che trasformano un profilo binario in visualizzazioni approfondite. Userò il pacchetto snakeviz .

Ecco la visualizzazione del profilo binario dell'ultima richiesta dall'alto, agganciato al metodo di invio della vista:

Immagine del metodo di invio della vista

Dall'alto verso il basso c'è lo stack delle chiamate, che mostra il nome del file, il nome del metodo/funzione con il suo numero di riga e il tempo cumulativo corrispondente trascorso in quel metodo. Ora è più facile vedere che gran parte del tempo è dedicato al calcolo dell'hash (i rettangoli __init__.py e primes.py di colore viola).

Attualmente, questo è il principale collo di bottiglia delle prestazioni nel nostro codice, ma allo stesso tempo non è proprio il nostro codice: è un pacchetto di terze parti.

In tali situazioni, c'è un numero limitato di cose che possiamo fare:

  • Verifica la presenza di una nuova versione del pacchetto (che si spera abbia prestazioni migliori).
  • Trova un altro pacchetto che funzioni meglio per le attività di cui abbiamo bisogno.
  • Scrivi la nostra implementazione che supererà le prestazioni del pacchetto che utilizziamo attualmente.

Fortunatamente per me, esiste una versione più recente del pacchetto basehash responsabile dell'hashing. Il codice utilizza la v.2.1.0, ma esiste una v.3.0.4. Tali situazioni, quando è possibile eseguire l'aggiornamento a una versione più recente di un pacchetto, sono più probabili quando si lavora su un progetto esistente.

Quando si controllano le note di rilascio per la v.3, c'è questa frase specifica che suona molto promettente:

È stata eseguita una massiccia revisione degli algoritmi di primalità. Compreso (sic) il supporto per gmpy2 se disponibile (sic) sul sistema per un aumento molto maggiore.

Scopriamolo!

 pip install -U basehash gmpy2

200 OTTIENI
/api/v1/case/

7738 ms complessivi
59 ms sulle query
4 domande

Abbiamo ridotto il tempo di risposta da 17 a meno di 8 secondi. Ottimo risultato, ma c'è un'altra cosa che dovremmo guardare.

2.3 Refactoring del proprio codice

Finora, abbiamo migliorato le nostre query, sostituito il codice generico e complesso di terze parti con le nostre funzioni molto specifiche e aggiornato i pacchetti di terze parti, ma abbiamo lasciato intatto il nostro codice esistente. Ma a volte un piccolo refactoring del codice esistente può portare a risultati impressionanti. Ma per questo abbiamo bisogno di analizzare nuovamente i risultati della profilazione.

Immagine dei risultati di profilazione

Dando un'occhiata più da vicino, puoi vedere che l'hashing è ancora un problema (non sorprendentemente, è l'unica cosa che facciamo con i nostri dati), anche se siamo migliorati in quella direzione. Tuttavia, il rettangolo verdastro che dice che __init__.py consuma 2,14 secondi mi infastidisce, insieme al grigio __init__.py:54(hash) che lo segue. Ciò significa che alcune inizializzazioni richiedono molto tempo.

Diamo un'occhiata al codice sorgente del pacchetto 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

Come puoi vedere, l'inizializzazione di un'istanza base richiede una chiamata della funzione next_prime ; che è piuttosto pesante come possiamo vedere nei rettangoli in basso a sinistra della visualizzazione sopra.

Diamo di nuovo un'occhiata alla mia 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]

Come puoi vedere, ho etichettato due metodi che stanno inizializzando un'istanza base36 su ciascuna chiamata al metodo, che non è realmente richiesta.

Poiché l'hashing è una procedura deterministica, il che significa che per un dato valore di input deve generare sempre lo stesso valore hash, possiamo renderlo un attributo di classe senza temere che si rompa qualcosa. Vediamo come si comporterà:

 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 OTTIENI
/api/v1/case/

3766 ms complessivi
38 ms sulle query
4 domande

Il risultato finale è inferiore a quattro secondi, che è molto più piccolo di quello con cui abbiamo iniziato. È possibile ottenere un'ulteriore ottimizzazione del tempo di risposta utilizzando la memorizzazione nella cache, ma non la affronterò in questo articolo.

Conclusione

L'ottimizzazione delle prestazioni è un processo di analisi e scoperta. Non ci sono regole rigide che si applicano a tutti i casi, poiché ogni progetto ha il proprio flusso e colli di bottiglia. Tuttavia, la prima cosa che dovresti fare è profilare il tuo codice. E se in un esempio così breve potessi ridurre il tempo di risposta da 77 secondi a 3,7 secondi, i progetti di grandi dimensioni hanno ancora più potenziale di ottimizzazione.

Se sei interessato a leggere altri articoli relativi a Django, dai un'occhiata ai 10 principali errori commessi dagli sviluppatori di Django dal collega sviluppatore di Toptal Django Alexandr Shurigin.