Пару слов о профилировании памяти в Python
2026-03-10 09:53 Diff

Проблемы с памятью в приложениях — явление довольно частое. Правда, в 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 =)

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