Пару слов о профилировании памяти в Python
Проблемы с памятью в приложениях — явление довольно частое. Правда, в 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
Итак, больше всего у нас строк. Это ожидаемо. В конце концов, в вебе почти все данные — строки. А вот на втором месте у нас словари. И их количество прямо пропорционально количеству записей, которые мы загружаем — ведь на каждый инстанс у нас создается
Любопытно, что если мы достанем данные из базы не только для Lesson, но и для связанных таблиц, используя
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 =)
Есть вопрос? Напишите в комментариях!