Пару слов о профилировании памяти в Python | OTUS

Курсы

Курсы в разработке Подготовительные курсы
Работа в компаниях Компаниям Блог +7 499 110-61-65

Пару слов о профилировании памяти в Python

WebDev_Deep_12.07_site-5020-04a647.png

Проблемы с памятью в приложениях — явление довольно частое. Правда, в Python, где работать с памятью напрямую приходится разве что при написании CPython-расширений, сталкиваться с этим приходится реже. Ещё часть рисков снимают фреймворки.

Тем не менее понимать, как распределяется память в приложении, всегда полезно. Давайте посмотрим, какие возможности у нас есть на примере небольшого Django-проекта.

Итак, у нас есть простая модель данных:

class Department(models.Model):
    title = models.CharField(max_length=120)


class Course(models.Model):
    title = models.CharField(max_length=120)
    department = models.ForeignKey(Department, on_delete=models.CASCADE)
    description = models.TextField()


class Lesson(models.Model):
    title = models.CharField(max_length=120)
    datetime = models.DateTimeField()

    course = models.ForeignKey(Course, on_delete=models.CASCADE)

И десяток вьюх, отдающих JSON. И вот где-то среди них у нас утекает память.

Прежде всего, нам надо сузить границы поиска. Один из способов — использовать приложение memory-profiler.

В его состав входит удобный декоратор, который позволяет смотреть динамику памяти в отдельных функциях. Повесим его на одну из вьюх:

@profile
def get_lessons(request):
   lessons = list(Lesson.objects.all())

   data = []
   for lesson in lessons:
       data.append({
           'title': lesson.title,
           'datetime': lesson.datetime.isoformat()
       })

   dump = json.dumps(data)
   return HttpResponse(dump, content_type="text/json")

Без дополнительных настроек profile выводит результаты прямо в стандартный вывод. И выглядит он примерно так:

Line #    Mem usage    Increment   Line Contents
================================================
    29     59.7 MiB     59.7 MiB   @profile
    30                             def get_lessons(request):
    31     66.6 MiB      6.9 MiB       lessons = list(Lesson.objects.all())
    32                                 
    33     66.6 MiB      0.0 MiB       data = []
    34     69.1 MiB      0.3 MiB       for lesson in lessons:
    35     69.1 MiB      0.3 MiB           data.append({
    36     69.1 MiB      0.2 MiB               'title': lesson.title,
    37     69.1 MiB      0.3 MiB               'datetime': lesson.datetime.isoformat()
    38                                     })
    39                             
    40     70.6 MiB      1.5 MiB       dump = json.dumps(data)
    41     71.3 MiB      0.7 MiB       return HttpResponse(dump, content_type="text/json")

Абсолютные значения тут не настолько важны, как прирост памяти. Увы, он не всегда нагляден. Например, наполнение списка data-словарями в цикле даёт прирост в 2.5 мегабайта, хотя сумма инкрементов показывает всего 1.1. Кроме того, далеко не всегда (ладно, почти никогда) показатели памяти в рамках одного вызова расскажут об утечке — нужно собирать статистику. И для этого в memory-profiler есть разные инструменты. Но, допустим, мы нашли злополучную точку прироста памяти. На что же она уходит? Memory-profiler не показывает деталей, и тут на помощь нам приходит другая библиотека — pympler.

Она позволяет нам получить статистику разных типов данных. Давайте посмотрим на примере:

from memtest.models import Lesson                                                                                                                                                  
from pympler import tracker                                                                                                                                                   
tr = tracker.SummaryTracker()                                                                                                                                                 
lessons = list(Lesson.objects.all())                                                                                                                                          
tr.print_diff()     

Давайте посмотрим, сколько всего появилось в памяти при получении из базы 10050 записей Lesson:

                                     types |   # objects |   total size
========================================== | =========== | ============
                               <class 'str |       20172 |      1.30 MB
                              <class 'dict |       20206 |      1.89 MB
                              <class 'list |        9977 |      1.00 MB
                               <class 'int |       21891 |    598.59 KB
  <class 'django.db.models.base.ModelState |       10050 |    549.61 KB
             <class 'memtest.models.Lesson |       10050 |    549.61 KB
                 <class 'datetime.datetime |       10050 |    471.09 KB
                              <class 'type |           9 |      9.68 KB
                              <class 'code |          41 |      5.97 KB
                           <class 'weakref |          25 |      1.95 KB
                       function (<lambda>) |           9 |      1.20 KB
                 <class 'method_descriptor |          12 |    864     B
                 <class 'getset_descriptor |          10 |    720     B
                         function (as_sql) |           5 |    680     B
                 <class 'collections.deque |           1 |    632     B

Итак, больше всего у нас строк. Это ожидаемо. В конце концов, в вебе почти все данные — строки. А вот на втором месте у нас словари. И их количество прямо пропорционально количеству записей, которые мы загружаем — ведь на каждый инстанс у нас создается __dict__. Кроме того, видно, что на каждый инстанс появляется еще и по одному django.db.models.base.ModelState (класс, в котором хранится состояние инстанса) и datetime.datetime — но это из-за наличия DateTimeField в модели.

Любопытно, что если мы достанем данные из базы не только для Lesson, но и для связанных таблиц, используя select_related, картина расхода памяти поменяется:

tr = tracker.SummaryTracker()                                                                                                                                                 
lessons = list(Lesson.objects.select_related("course__department").all())                                                                                                                                          
tr.print_diff()


                                              types |   # objects |   total size
=================================================== | =========== | ============
                                       <class 'dict |       70409 |     10.01 MB
                                        <class 'str |       50323 |      7.87 MB
           <class 'django.db.models.base.ModelState |       30150 |      1.61 MB
                                        <class 'int |       51233 |      1.37 MB
                                       <class 'list |        9980 |      1.00 MB
                  <class 'memtest.models.Department |       10050 |    549.61 KB
                      <class 'memtest.models.Lesson |       10050 |    549.61 KB
                      <class 'memtest.models.Course |       10050 |    549.61 KB
                          <class 'datetime.datetime |       10050 |    471.09 KB
                                       <class 'type |           9 |     10.05 KB
                                       <class 'code |          41 |      5.97 KB
  <class 'django.utils.datastructures.ImmutableList |          28 |      2.22 KB
                                    <class 'weakref |          19 |      1.48 KB
                          <class 'method_descriptor |          12 |    864     B
                          <class 'getset_descriptor |          10 |    720     B

Фатально увеличилось количество словарей, что логично — ведь и экземпляров различных классов теперь в разы больше. По сути, на каждый инстанс наших моделей приходится 2 словаря — для самого инстанса и для ModelState.

В целом, поведение памяти в примере вполне штатно. Я лишь хотел проиллюстрировать некоторые из существующих инструментов для профилирования памяти в python-приложениях.

Напоследок, вам «небольшая» картинка объектного графа одного инстанса Lesson, сгенерированная с помощью библиотеки objgraph =)

1-20219-92d1d4.png

Есть вопрос? Напишите в комментариях!

Не пропустите новые полезные статьи!

Спасибо за подписку!

Мы отправили вам письмо для подтверждения вашего email.
С уважением, OTUS!

Автор
1 комментарий
0

А мы можем снизить потребление памяти с помощью использования Слотов? Если да, то можно пример как реализовать это в данном случае?

Для комментирования необходимо авторизоваться