Panduan untuk Pengujian dan Pengoptimalan Kinerja dengan Python dan Django

Diterbitkan: 2022-03-11

Donald Knuth mengatakan bahwa "optimasi prematur adalah akar dari semua kejahatan." Tetapi ada saatnya, biasanya dalam proyek yang sudah matang dengan beban tinggi, ketika ada kebutuhan yang tak terelakkan untuk pengoptimalan. Pada artikel ini, saya ingin berbicara tentang lima metode umum untuk mengoptimalkan kode proyek web Anda. Saya akan menggunakan Django, tetapi prinsipnya harus serupa untuk kerangka kerja dan bahasa lain. Dalam artikel ini, saya akan menggunakan metode ini untuk mengurangi waktu respons kueri dari 77 menjadi 3,7 detik.

Panduan Pengoptimalan Kinerja dan Pengujian Kinerja Dengan Python dan Django

Kode contoh diadaptasi dari proyek nyata yang pernah saya tangani dan menunjukkan teknik pengoptimalan kinerja. Jika Anda ingin mengikuti dan melihat sendiri hasilnya, Anda dapat mengambil kode pada status awalnya di GitHub dan membuat perubahan yang sesuai sambil mengikuti artikel. Saya akan menggunakan Python 2, karena beberapa paket pihak ketiga belum tersedia untuk Python 3.

Memperkenalkan Aplikasi Kami

Proyek web kami hanya melacak penawaran real estat per negara. Oleh karena itu, hanya ada dua model:

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

HashableModel abstrak menyediakan model apa pun yang mewarisi properti hash yang berisi kunci utama instance dan tipe konten model. Ini menyembunyikan data sensitif, seperti ID instans, dengan menggantinya dengan hash. Mungkin juga berguna jika proyek Anda memiliki banyak model dan Anda memerlukan tempat terpusat yang menghapus hash dan memutuskan apa yang harus dilakukan dengan contoh model yang berbeda dari kelas yang berbeda. Perhatikan bahwa untuk proyek kecil kami, hashing tidak terlalu diperlukan, karena kami dapat menanganinya tanpa hash, tetapi ini akan membantu mendemonstrasikan beberapa teknik pengoptimalan, jadi saya akan menyimpannya di sana.

Berikut adalah kelas 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]

Karena kami ingin menyajikan data ini melalui titik akhir API, kami menginstal Django REST Framework dan mendefinisikan serializer dan tampilan berikut:

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

Sekarang, kami mengisi database kami dengan beberapa data (total 100.000 instance rumah yang dihasilkan menggunakan factory-boy : 50.000 untuk satu negara, 40.000 untuk yang lain, dan 10.000 untuk negara ketiga) dan siap untuk menguji kinerja aplikasi kami.

Optimasi Kinerja Adalah Semua Tentang Pengukuran

Ada beberapa hal yang dapat kita ukur dalam sebuah proyek:

  • Waktu eksekusi
  • Jumlah baris kode
  • Jumlah panggilan fungsi
  • Memori yang dialokasikan
  • Dll.

Namun tidak semuanya relevan dalam mengukur seberapa bagus kinerja proyek kita. Secara umum, ada dua metrik utama yang paling penting: berapa lama sesuatu dijalankan dan berapa banyak memori yang dibutuhkan.

Dalam proyek web, waktu respons (waktu yang dibutuhkan server untuk menerima permintaan yang dihasilkan oleh tindakan beberapa pengguna, memprosesnya, dan mengirim kembali hasilnya) biasanya merupakan metrik yang paling penting, karena tidak membuat pengguna bosan saat menunggu untuk tanggapan dan beralih ke tab lain di browser mereka.

Dalam pemrograman, menganalisis kinerja proyek disebut profiling. Untuk membuat profil kinerja titik akhir API kami, kami akan menggunakan paket Silk. Setelah menginstalnya dan membuat panggilan /api/v1/houses/?country=5T22RI (hash yang sesuai dengan negara dengan 50.000 entri rumah), kami mendapatkan ini:

200 DAPATKAN
/api/v1/houses/

77292ms secara keseluruhan
15854ms pada kueri
5.0004 pertanyaan

Waktu respons keseluruhan adalah 77 detik, di mana 16 detik dihabiskan untuk kueri di database, di mana total ada 50.000 kueri yang dibuat. Dengan jumlah yang begitu besar, ada banyak ruang untuk perbaikan, jadi mari kita mulai.

1. Mengoptimalkan Kueri Basis Data

Salah satu tip paling umum tentang pengoptimalan kinerja adalah memastikan kueri basis data Anda dioptimalkan. Kasus ini tidak terkecuali. Selain itu, kami dapat melakukan beberapa hal tentang kueri kami untuk mengoptimalkan waktu respons.

1.1 Menyediakan semua data sekaligus

Melihat lebih dekat apa 50.000 kueri itu, Anda dapat melihat bahwa ini semua adalah kueri redundan ke houses_country :

200 DAPATKAN
/api/v1/houses/

77292ms secara keseluruhan
15854ms pada kueri
5.0004 pertanyaan

Pada Tabel bergabung Waktu Eksekusi (md)
+0:01:15.874374 "rumah_negara" 0 0,176
+0:01:15,873304 "rumah_negara" 0 0.218
+0:01:15.872225 "rumah_negara" 0 0.218
+0:01:15,871155 "rumah_negara" 0 0.198
+0:01:15.870099 "rumah_negara" 0 0,173
+0:01:15,869050 "rumah_negara" 0 0.197
+0:01:15.867877 "rumah_negara" 0 0.221
+0:01:15.866807 "rumah_negara" 0 0,203
+0:01:15.865646 "rumah_negara" 0 0.211
+0:01:15.864562 "rumah_negara" 0 0.209
+0:01:15.863511 "rumah_negara" 0 0,181
+0:01:15.862435 "rumah_negara" 0 0.228
+0:01:15.861413 "rumah_negara" 0 0,174

Sumber dari masalah ini adalah fakta bahwa, di Django, set kueri adalah lazy . Ini berarti bahwa kumpulan kueri tidak dievaluasi dan tidak mengenai database sampai Anda benar-benar perlu mendapatkan datanya. Pada saat yang sama, ia hanya mendapatkan data yang Anda perintahkan, membuat permintaan berikutnya jika ada data tambahan yang diperlukan.

Itulah yang terjadi dalam kasus kami. Saat mendapatkan kueri disetel melalui House.objects.filter(country=country) , Django mendapatkan daftar semua rumah di negara yang diberikan. Namun, saat membuat serial instance house , HouseSerializer memerlukan instance rumah country untuk menghitung bidang country pembuat serial. Karena data negara tidak ada dalam kumpulan kueri, Django membuat permintaan tambahan untuk mendapatkan data itu. Dan itu berlaku untuk setiap rumah di kumpulan kueri—semuanya 50.000 kali.

Solusinya sangat sederhana. Untuk mengekstrak semua data yang diperlukan untuk serialisasi, Anda dapat menggunakan metode select_related() pada kumpulan kueri. Dengan demikian, get_queryset kami akan terlihat seperti:

 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

Mari kita lihat bagaimana hal ini memengaruhi kinerja:

200 DAPATKAN
/api/v1/houses/

35979ms secara keseluruhan
102ms pada kueri
4 pertanyaan

Waktu respons keseluruhan turun menjadi 36 detik dan waktu yang dihabiskan di database ~100 md dengan hanya 4 kueri! Itu berita bagus, tapi kita bisa berbuat lebih banyak.

1.2 Berikan hanya data yang relevan

Secara default, Django mengekstrak semua bidang dari database. Namun, ketika Anda memiliki tabel besar dengan banyak kolom dan baris, masuk akal untuk memberitahu Django bidang spesifik apa yang akan diekstrak, sehingga tidak akan menghabiskan waktu untuk mendapatkan informasi yang tidak akan digunakan sama sekali. Dalam kasus kami, kami hanya membutuhkan lima bidang untuk serialisasi, tetapi kami memiliki 17 bidang. Masuk akal untuk menentukan dengan tepat bidang apa yang akan diekstrak dari database, sehingga kami semakin mengurangi waktu respons.

Django memiliki metode set kueri defer() dan only() untuk melakukan hal ini. Yang pertama menentukan bidang apa yang tidak dimuat dan yang kedua menentukan bidang apa yang hanya dimuat .

 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

Ini memotong waktu yang dihabiskan untuk kueri menjadi dua, yang bagus, tetapi 50 ms tidak terlalu banyak. Waktu keseluruhan juga sedikit turun, tetapi ada lebih banyak ruang untuk memotongnya.

200 DAPATKAN
/api/v1/houses/

33111ms secara keseluruhan
52ms pada kueri
4 pertanyaan

2. Mengoptimalkan Kode Anda

Anda tidak dapat mengoptimalkan kueri database tanpa batas, dan hasil terakhir kami baru saja menunjukkan hal itu. Bahkan jika kita secara hipotetis mengurangi waktu yang dihabiskan untuk kueri menjadi 0, kita masih akan menghadapi kenyataan menunggu setengah menit untuk mendapatkan tanggapan. Saatnya beralih ke tingkat pengoptimalan lain: logika bisnis .

2.1 Sederhanakan kode Anda

Terkadang, paket pihak ketiga datang dengan banyak overhead untuk tugas-tugas sederhana. Salah satu contohnya adalah tugas kita untuk mengembalikan instance rumah serial.

Django REST Framework sangat bagus, dengan banyak fitur berguna di luar kotak. Namun, tujuan utama kami saat ini adalah untuk mengurangi waktu respons, jadi ini adalah kandidat yang bagus untuk pengoptimalan, terutama objek serial yang cukup sederhana.

Mari kita menulis serializer khusus untuk tujuan ini. Untuk membuatnya tetap sederhana, kita akan memiliki satu metode statis yang melakukan pekerjaan itu. Pada kenyataannya, Anda mungkin ingin memiliki kelas dan tanda tangan metode yang sama untuk dapat menggunakan serializer secara bergantian:

 # 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 DAPATKAN
/api/v1/houses/

17312ms secara keseluruhan
38ms pada kueri
4 pertanyaan

Ini terlihat lebih baik sekarang. Waktu respons hampir setengahnya karena fakta bahwa kami tidak menggunakan kode serializer DRF.

Hasil terukur lainnya—jumlah panggilan fungsi total yang dilakukan selama siklus permintaan/tanggapan—turun dari 15.859.427 panggilan (dari permintaan yang dibuat di bagian 1.2 di atas) menjadi 9.257.469 panggilan. Ini berarti bahwa sekitar 1/3 dari semua panggilan fungsi dibuat oleh Django REST Framework.

2.2 Perbarui/ganti paket pihak ketiga

Teknik pengoptimalan yang dijelaskan di atas adalah yang paling umum, yang dapat Anda lakukan tanpa analisis dan pemikiran yang mendalam. Namun, 17 detik masih terasa cukup lama; untuk mengurangi jumlah ini, kita perlu menyelam lebih dalam ke kode kita dan menganalisis apa yang terjadi di balik layar. Dengan kata lain, kita perlu membuat profil kode kita.

Anda dapat melakukan pembuatan profil sendiri, menggunakan profiler Python bawaan, atau Anda dapat menggunakan beberapa paket pihak ketiga untuk itu (yang menggunakan profiler Python bawaan). Karena kita sudah menggunakan silk , ini dapat membuat profil kode dan menghasilkan file profil biner, yang dapat kita visualisasikan lebih lanjut. Ada beberapa paket visualisasi yang mengubah profil biner menjadi beberapa visualisasi yang mendalam. Saya akan menggunakan paket snakeviz .

Berikut adalah visualisasi profil biner dari permintaan terakhir dari atas, terkait dengan metode pengiriman tampilan:

Gambar metode pengiriman tampilan

Dari atas ke bawah adalah tumpukan panggilan, menampilkan nama file, nama metode/fungsi dengan nomor barisnya dan waktu kumulatif terkait yang dihabiskan dalam metode itu. Sekarang lebih mudah untuk melihat bahwa sebagian besar waktu didedikasikan untuk menghitung hash (persegi panjang __init__.py dan primes.py warna ungu).

Saat ini, ini adalah hambatan kinerja utama dalam kode kami, tetapi pada saat yang sama itu bukan benar-benar kode kami —ini adalah paket pihak ketiga.

Dalam situasi seperti itu, ada beberapa hal yang dapat kita lakukan:

  • Periksa versi baru dari paket (yang diharapkan memiliki kinerja yang lebih baik).
  • Temukan paket lain yang berkinerja lebih baik pada tugas yang kami butuhkan.
  • Tulis implementasi kita sendiri yang akan mengalahkan performa paket yang saat ini kita gunakan.

Untungnya bagi saya, ada versi terbaru dari paket basehash yang bertanggung jawab untuk hashing. Kode menggunakan v.2.1.0, tetapi ada v.3.0.4. Situasi seperti itu, ketika Anda dapat memperbarui ke versi paket yang lebih baru, lebih mungkin terjadi saat Anda mengerjakan proyek yang sudah ada.

Saat memeriksa catatan rilis untuk v.3, ada kalimat khusus yang terdengar sangat menjanjikan:

Perombakan besar-besaran dilakukan dengan algoritma primality. Termasuk (sic) dukungan untuk gmpy2 jika tersedia (sic) pada sistem untuk peningkatan yang lebih besar.

Mari kita cari tahu ini!

 pip install -U basehash gmpy2

200 DAPATKAN
/api/v1/houses/

7738ms secara keseluruhan
59 ms pada kueri
4 pertanyaan

Kami mengurangi waktu respons dari 17 menjadi kurang dari 8 detik. Hasil yang bagus, tetapi ada satu hal lagi yang harus kita perhatikan.

2.3 Refactor kode Anda sendiri

Sejauh ini, kami telah meningkatkan kueri kami, mengganti kode kompleks dan generik pihak ketiga dengan fungsi kami sendiri yang sangat spesifik, dan memperbarui paket pihak ketiga, tetapi kami membiarkan kode yang ada tidak tersentuh. Namun terkadang refactoring kecil dari kode yang ada dapat memberikan hasil yang mengesankan. Tetapi untuk ini kita perlu menganalisis kembali hasil profiling.

Gambar hasil pembuatan profil

Melihat lebih dekat, Anda dapat melihat bahwa hashing masih menjadi masalah (tidak mengherankan, itu adalah satu-satunya hal yang kami lakukan dengan data kami), meskipun kami meningkatkan ke arah itu. Namun, persegi panjang kehijauan yang mengatakan bahwa __init__.py menghabiskan 2,14 detik mengganggu saya, bersama dengan __init__.py:54(hash) keabu-abuan yang berada tepat setelahnya. Ini berarti bahwa beberapa inisialisasi membutuhkan waktu lama.

Mari kita lihat kode sumber paket 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

Seperti yang Anda lihat, inisialisasi instance base memerlukan panggilan fungsi next_prime ; yang cukup berat seperti yang bisa kita lihat di kiri bawah persegi panjang dari visualisasi di atas.

Mari kita lihat kelas Hash saya lagi:

 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]

Seperti yang Anda lihat, saya telah memberi label dua metode yang menginisialisasi instance base36 pada setiap panggilan metode, yang sebenarnya tidak diperlukan.

Karena hashing adalah prosedur deterministik, artinya untuk nilai input yang diberikan harus selalu menghasilkan nilai hash yang sama, kita dapat menjadikannya atribut kelas tanpa khawatir akan merusak sesuatu. Mari kita lihat bagaimana kinerjanya:

 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 DAPATKAN
/api/v1/houses/

3766ms secara keseluruhan
38ms pada kueri
4 pertanyaan

Hasil akhirnya kurang dari empat detik, yang jauh lebih kecil dari yang kita mulai. Optimalisasi lebih lanjut dari waktu respons dapat dicapai dengan menggunakan caching, tetapi saya tidak akan membahasnya di artikel ini.

Kesimpulan

Optimalisasi kinerja adalah proses analisis dan penemuan. Tidak ada aturan keras yang berlaku untuk semua kasus, karena setiap proyek memiliki alur dan hambatannya sendiri. Namun, hal pertama yang harus Anda lakukan adalah membuat profil kode Anda. Dan jika dalam contoh sesingkat itu saya dapat mengurangi waktu respons dari 77 detik menjadi 3,7 detik, proyek besar memiliki potensi pengoptimalan yang lebih besar lagi.

Jika Anda tertarik untuk membaca lebih banyak artikel terkait Django, lihat 10 Kesalahan Teratas yang Dilakukan Pengembang Django oleh sesama Pengembang Django Toptal Alexandr Shurigin.