اختبار الأداء والتحسين باستخدام Python و Django

اختبار الأداء والتحسين باستخدام Python

رمز المثال مقتبس من مشروع حقيقي عملت معه ويظهر تقنيات تحسين الأداء. في حالة رغبتك في المتابعة ورؤية النتائج بنفسك يمكنك الحصول على الكود في حالته الأولية على GitHub وإجراء التغييرات المقابلة أثناء متابعة المقالة. سأستخدم Python 2 نظرًا لأن بعض حزم الجهات الخارجية غير متوفرة حتى الآن لـ Python 3.

تقديم تطبيقنا

يتتبع مشروع الويب الخاص بنا ببساطة عروض العقارات في كل بلد. لذلك يوجد نموذجان فقط:

# 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 المجرد أي نموذج يرث منه خاصية تجزئة تحتوي على المفتاح الأساسي للمثيل ونوع محتوى النموذج. يؤدي هذا إلى إخفاء البيانات الحساسة مثل معرفات المثيلات عن طريق استبدالها بعلامة تجزئة. قد يكون مفيدًا أيضًا في الحالات التي يكون فيها مشروعك به نماذج متعددة وتحتاج إلى مكان مركزي يفكك ويقرر ما يجب فعله مع مثيلات نماذج مختلفة من فئات مختلفة. لاحظ أنه بالنسبة لمشروعنا الصغير فإن التجزئة ليست ضرورية حقًا حيث يمكننا التعامل بدونها ولكنها ستساعد في إظهار بعض تقنيات التحسين لذلك سأحتفظ بها هناك.

ها هو فئه 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]

نظرًا لأننا نرغب في تقديم هذه البيانات من خلال نقطة نهاية واجهة برمجة التطبيقات فإننا نقوم بتثبيت إطار عمل Django REST وتحديد المسلسلات التالية وعرضها:

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

الآن نقوم بتعبئة قاعدة البيانات الخاصة بنا ببعض البيانات (ما مجموعه 100000 نسخة منزلية تم إنشاؤها باستخدام factory-boy: 50000 لدولة واحدة و 40000 لبلد آخر و 10000 لبلد ثالث) ونحن على استعداد لاختبار أداء تطبيقنا.

تحسين الأداء هو كل شيء عن القياس

هناك عدة أشياء يمكننا قياسها في المشروع:

  • وقت التنفيذ
  • عدد سطور الكود
  • عدد مكالمات الوظيفة
  • الذاكرة المخصصة

ولكن ليست جميعها ذات صلة بقياس مدى جودة أداء مشروعنا. بشكل عام هناك مقياسان رئيسيان هما الأكثر أهمية: طول مدة تنفيذ شيء ما ومقدار الذاكرة التي يحتاجها.

في مشروع الويب عادةً ما يكون وقت الاستجابة (الوقت المطلوب للخادم لتلقي طلب تم إنشاؤه بواسطة إجراء بعض المستخدمين ومعالجته وإرسال النتيجة مرة أخرى) هو المقياس الأكثر أهمية لأنه لا يسمح للمستخدمين بالملل أثناء الانتظار للرد والتبديل إلى علامة تبويب أخرى في متصفحهم.

في البرمجة يسمى تحليل أداء المشروع التنميط. من أجل تحديد أداء نقطة نهاية API الخاصة بنا سوف نستخدم حزمة الحرير. بعد تثبيته وجعل  /api/v1/houses/?country=5T22RI اتصال (التجزئة التي تتوافق مع البلد مع إدخال 50000 منزل) نحصل على هذا:

200 GET
/api/v1/houses/
77292ms overall
15854ms on queries
50004 queries

يبلغ إجمالي وقت الاستجابة 77 ثانية يتم إنفاق 16 ثانية منها على الاستفسارات في قاعدة البيانات حيث تم إجراء ما مجموعه 50000 استفسار. مع هذه الأعداد الضخمة هناك مجال كبير للتحسين فلنبدأ.

1. تحسين استعلامات قاعدة البيانات

إحدى النصائح الأكثر شيوعًا حول تحسين الأداء هي التأكد من تحسين استعلامات قاعدة البيانات. هذه الحالة ليست استثناء. علاوة على ذلك يمكننا القيام بالعديد من الأشياء بخصوص استفساراتنا لتحسين وقت الاستجابة.

1.1 تقديم البيانات ذات الصلة فقط

بشكل افتراضي يستخرج Django جميع الحقول من قاعدة البيانات. ومع ذلك عندما يكون لديك جداول ضخمة تحتوي على العديد من الأعمدة والصفوف فمن المنطقي إخبار Django بالحقول المحددة التي يجب استخراجها حتى لا يقضي الوقت في الحصول على معلومات لن يتم استخدامها على الإطلاق. في حالتنا نحتاج فقط إلى خمسة حقول للتسلسل لكن لدينا 17 حقلاً. من المنطقي تحديد الحقول المراد استخلاصها من قاعدة البيانات بالضبط حتى نخفض وقت الاستجابة بشكل أكبر.

لدى Django طريقتا defer() و  only()لمجموعة الاستعلام للقيام بذلك بالضبط. الأول يحدد الحقول التي لا يجب تحميلها والثاني يحدد الحقول التي سيتم تحميلها فقط.

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

أدى هذا إلى تقليل الوقت المستغرق في الاستعلامات إلى النصف وهو أمر جيد ولكن 50 مللي ثانية ليست بهذا القدر. انخفض الوقت الإجمالي أيضًا بشكل طفيف ولكن هناك مساحة أكبر لقطعه.

200 GET
/api/v1/houses/
33111ms overall
52ms on queries
4 queries

2. تحسين التعليمات البرمجية الخاصة بك

لا يمكنك تحسين استعلامات قاعدة البيانات بشكل غير محدود وقد أظهرت نتائجنا الأخيرة ذلك للتو. حتى إذا قللنا افتراضيًا الوقت الذي نقضيه في الاستفسارات إلى 0 فسنظل نواجه حقيقة الانتظار لمدة نصف دقيقة للحصول على الرد. حان الوقت للانتقال إلى مستوى آخر من التحسين: منطق الأعمال.

2.1 تبسيط التعليمات البرمجية الخاصة بك

في بعض الأحيان تأتي حزم الجهات الخارجية مع الكثير من النفقات العامة للمهام البسيطة. أحد الأمثلة على ذلك هو مهمتنا في إعادة أمثلة المنزل المتسلسلة.

يعد برنامج Django REST Framework رائعًا مع الكثير من الميزات المفيدة خارج الصندوق. ومع ذلك فإن هدفنا الرئيسي الآن هو تقليل وقت الاستجابة لذا فهو مرشح رائع للتحسين خاصة وأن العناصر المتسلسلة بسيطة للغاية.

دعونا نكتب مسلسل مخصص لهذا الغرض. لتبسيط الأمر سيكون لدينا طريقة ثابتة واحدة تؤدي المهمة. في الواقع قد ترغب في الحصول على نفس توقيعات الفئة والداله لتتمكن من استخدام المتسلسلات بالتبادل:

# 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 GET
/api/v1/houses/
17312ms overall
38ms on queries
4 queries

هذا يبدو أفضل الآن. انخفض وقت الاستجابة إلى النصف تقريبًا نظرًا لحقيقة أننا لم نستخدم رمز DRF المتسلسل.

نتيجة أخرى قابلة للقياس – عدد مكالمات الدوال الإجمالية التي تم إجراؤها أثناء دورة الطلب / الاستجابة – انخفض من 15،859،427 مكالمة (من الطلب الذي تم إجراؤه في القسم 1.2 أعلاه) إلى 9،257،469 مكالمة. هذا يعني أن حوالي 1/3 من جميع استدعاءات الدوال تم إجراؤها بواسطة Django REST Framework.

2.2 تحديث / استبدال حزم الطرف الثالث (third-party packeges)

تعد تقنيات التحسين الموضحة أعلاه هي الأكثر شيوعًا وهي تلك التي يمكنك القيام بها بدون تحليل وتفكير شاملين. ومع ذلك لا تزال 17 ثانية تبدو طويلة جدًا لتقليل هذا الرقم سنحتاج إلى التعمق في الكود الخاص بنا وتحليل ما يحدث تحت الغطاء. بعبارة أخرى سنحتاج إلى تشكيل الكود الخاص بنا.

يمكنك إجراء التنميط بنفسك باستخدام ملف تعريف Python المدمج أو يمكنك استخدام بعض حزم الجهات الخارجية لذلك (التي تستخدم ملف تعريف Python المدمج). نظرًا لأننا نستخدم الحرير بالفعل فيمكنه تحديد الكود وإنشاء ملف ملف تعريف ثنائي يمكننا تصوره بشكل أكبر. هناك العديد من حزم التصور التي تحول ملف تعريف ثنائي إلى بعض التصورات الثاقبة. سأستخدم حزمة snakeviz.

فيما يلي تصور للملف الشخصي الثنائي للطلب الأخير أعلاه مرتبطًا بطريقة إرسال العرض:

اختبار الأداء والتحسين باستخدام Python و Django

من أعلى إلى أسفل هو مكدس الاستدعاءات ويعرض اسم الملف واسم الدالة مع رقم السطر والوقت التراكمي المقابل الذي يقضيه في تلك الطريقة. من السهل الآن رؤية أن حصة الأسد من الوقت مخصصة لحساب التجزئة (المستطيلات __init__.py و primes.py من اللون البنفسجي).

حاليًا هذا هو العقبة الرئيسية في الأداء في شفرتنا لكنه في الوقت نفسه ليس رمزنا حقًا – إنه حزمة تابعة لجهة خارجية.

في مثل هذه الحالات هناك عدد محدود من الأشياء التي يمكننا القيام بها:

  1. تحقق من وجود إصدار جديد من الحزمة (نأمل أن يكون له أداء أفضل).
  2. ابحث عن حزمة أخرى تعمل بشكل أفضل في المهام التي نحتاجها.
  3. اكتب تطبيقنا الخاص الذي سيتفوق على أداء الحزمة التي نستخدمها حاليًا.

لحسن الحظ بالنسبة لي هناك إصدار أحدث من الحزمة الأساسية المسؤولة عن التجزئة. يستخدم الرمز v.2.1.0 ولكن هناك v.3.0.4. مثل هذه المواقف عندما تكون قادرًا على التحديث إلى إصدار أحدث من الحزمة تكون أكثر احتمالًا عندما تعمل في مشروع موجود.

عند التحقق من ملاحظات الإصدار للإصدار 3 توجد هذه الجملة المحددة التي تبدو واعدة جدًا:

    تم إجراء إصلاح شامل باستخدام خوارزميات البدائية. بما في ذلك (كذا) دعم gmpy2 إذا كان متاحًا (كذا) على النظام من أجل زيادة أكبر بكثير.

دعونا نكتشف ذلك!

pip install -U basehash gmpy2
200 GET
/api/v1/houses/
7738ms overall
59ms on queries
4 queries

قللنا وقت الاستجابة من 17 إلى أقل من 8 ثوانٍ. نتيجة رائعة ولكن هناك شيء آخر يجب أن ننظر إليه.

2.3 إعادة بناء الكود الخاص بك

حتى الآن قمنا بتحسين استفساراتنا واستبدلنا الشفرة المعقدة والعامة لطرف ثالث بوظائفنا المحددة للغاية وحزم الطرف الثالث المحدثة لكننا تركنا الكود الحالي كما هو. لكن في بعض الأحيان يمكن أن تؤدي عملية إعادة هيكلة صغيرة للشفرة الحالية إلى نتائج مبهرة. لكن لهذا نحتاج مرة أخرى إلى تحليل نتائج التنميط.

عند إلقاء نظرة فاحصة يمكنك أن ترى أن التجزئة لا تزال تمثل مشكلة (وليس من المستغرب أنها الشيء الوحيد الذي نفعله ببياناتنا) على الرغم من أننا تحسننا في هذا الاتجاه. ومع ذلك فإن المستطيل الأخضر الذي يقول أن __init__.py يستهلك 2.14 ثانية يزعجني جنبًا إلى جنب مع __init__.py:54(hash) الرمادي الذي يليه مباشرة. هذا يعني أن بعض التهيئة تستغرق وقتًا طويلاً.

دعنا نلقي نظرة على الكود المصدري للحزمة 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

كما ترى فإن تهيئة المثيل الأساسي تتطلب استدعاء دالة next_prime هذا ثقيل جدًا كما نرى في المستطيلات اليسرى السفلية من التصور أعلاه.

دعونا نلقي نظرة على فئه 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]

كما ترى لقد قمت بتسمية دالتین تقومان بتهيئة مثيل base36 على كل استدعاء طريقة وهو أمر غير مطلوب حقًا.

نظرًا لأن التجزئة هي إجراء حتمي ، مما يعني أنه بالنسبة لقيمة إدخال معينة يجب أن تولد دائمًا نفس قيمة التجزئة فيمكننا جعلها سمة فئة دون الخوف من كسر شيء ما. دعنا نتحقق من كيفية أدائها:

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 GET
/api/v1/houses/
3766ms overall
38ms on queries
4 queries

النتيجة النهائية أقل من أربع ثوان وهي أصغر بكثير مما بدأنا به. يمكن تحقيق مزيد من التحسين لوقت الاستجابة باستخدام التخزين المؤقت لكنني لن أتطرق إلى ذلك في هذه المقالة.

استنتاج

تحسين الأداء هو عملية تحليل واكتشاف. لا توجد قواعد صارمة تنطبق على جميع الحالات حيث أن لكل مشروع تدفقه واختناقاته الخاصة. ومع ذلك فإن أول شيء يجب عليك فعله هو إنشاء ملف تعريف للكود الخاص بك. وإذا كان بإمكاني في مثل هذا المثال القصير تقليل وقت الاستجابة من 77 ثانية إلى 3.7 ثانية فإن المشاريع الضخمة لديها إمكانات تحسين أكبر.

منشور ذات صلة

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *

السلة