Измерение памяти в Python: tracemalloc и memory_profiler
Скрипт съел 3 ГБ на обработке файла в 200 МБ, и htop это показал, но не сказал где. Разбираем, как найти строку-виновника через tracemalloc и line-by-line профилирование.

Скрипт разбирал CSV на 200 МБ, а htop показывал 3 ГБ занятой памяти и растущий RSS. Проблема не в том, что памяти много — а в том, что htop, ps и resource.getrusage показывают только суммарный RSS процесса и молчат о том, какая именно строка кода его раздула. Ниже — два инструмента, которые отвечают на вопрос «где», и граница, за которой каждый из них перестаёт помогать.
Всё написано под CPython 3.11 и 3.12. tracemalloc входит в стандартную библиотеку начиная с Python 3.4, memory_profiler — сторонний пакет, ставится через pip install memory_profiler (на момент написания актуальна версия 0.61). Оба измеряют разные вещи, и путаница между ними — источник ложных выводов.
Что именно вы меряете
Прежде чем запускать профайлер, определитесь, какое число вам нужно. Их три, и они не совпадают.
| Метрика | Что показывает | Инструмент |
|---|---|---|
| RSS процесса | Вся физическая память процесса: Python-объекты, буферы C-расширений, фрагментация аллокатора | memory_profiler, htop, psutil |
| Аллокации Python | Только память, выделенная через аллокатор CPython под объекты | tracemalloc |
| Пиковое потребление | Максимум за период — важно для OOM, а не средняя нагрузка | tracemalloc.get_traced_memory() |
Разница практическая. Если numpy или pandas держат данные в C-буферах, tracemalloc покажет копейки — эта память выделена не через аллокатор Python. А RSS будет расти. Поэтому numpy-тяжёлый код меряют по RSS, а «утечку» из-за незакрытых замыканий и накапливающихся списков ловят через tracemalloc.
Если tracemalloc показывает мало, а RSS растёт — память ест не Python-объект, а C-расширение или фрагментация. Меняйте инструмент, а не гипотезу.
Сноска: что такое RSS
RSS (Resident Set Size) — объём оперативной памяти, физически занятый процессом прямо сейчас, без учёта того, что ушло в swap. Именно по RSS ядро принимает решение убить процесс при нехватке памяти (OOM killer). Это верхнеуровневая метрика: она не знает про Python-объекты, только про страницы памяти.
tracemalloc: найти строку, которая выделила больше всех
tracemalloc хранит трассировку каждой аллокации Python-объекта и умеет группировать статистику по файлу и строке. Базовый сценарий — снять снимок и посмотреть топ.
import tracemalloc
# Начинаем отслеживание. Аргумент — глубина стека трассировки на аллокацию.
tracemalloc.start(10)
def load_rows(path):
rows = []
with open(path, encoding="utf-8") as f:
for line in f:
# Каждая строка режется и складывается в общий список —
# именно здесь память копится и не освобождается
rows.append(line.rstrip("\n").split(","))
return rows
data = load_rows("big.csv")
snapshot = tracemalloc.take_snapshot()
top = snapshot.statistics("lineno")
for stat in top[:5]:
print(stat)
На выходе — строки вида big_script.py:11: size=812 MiB, count=4200000, average=203 B. Число после двоеточия в :11 — та самая строка. count — сколько живых объектов её породили. Если count в миллионах, а вы ждали тысячи — вот и находка.
Сравнить два момента: что выросло между А и Б
Одиночный снимок показывает распределение памяти. Утечку ловят двумя снимками и разницей между ними — метод compare_to.
import tracemalloc
tracemalloc.start()
# Снимок до подозрительного участка
snap_before = tracemalloc.take_snapshot()
result = process_batch() # подозрительный участок
# Снимок после
snap_after = tracemalloc.take_snapshot()
# Разница, отсортированная по приросту размера
diff = snap_after.compare_to(snap_before, "lineno")
for stat in diff[:10]:
print(stat)
В выводе compare_to появляется знак: +45.2 MiB означает, что между снимками эта строка нарастила память и не вернула. Если вы прогоняете process_batch() в цикле и прирост повторяется от итерации к итерации — это утечка, а не разовая аллокация.
Пиковое значение
Для диагностики OOM важен не средний, а пиковый расход. Его отдаёт get_traced_memory():
current, peak = tracemalloc.get_traced_memory()
print(f"текущее: {current / 1024 / 1024:.1f} MiB")
print(f"пик: {peak / 1024 / 1024:.1f} MiB")
tracemalloc.stop()
Пик — это максимум, которого достигла отслеживаемая память с момента start(). Если пик вдвое выше текущего значения, где-то создаётся временная копия данных, которая живёт недолго, но именно она определяет, влезет ли процесс в лимит памяти.
memory_profiler: расход по строкам в единицах RSS
memory_profiler решает другую задачу — показывает потребление RSS построчно, включая память C-расширений. Декорируете функцию @profile и запускаете скрипт через специальную команду.
from memory_profiler import profile
@profile
def build_matrix():
import numpy as np
# numpy-массив живёт в C-буфере, tracemalloc его почти не увидит,
# а memory_profiler покажет реальный прирост RSS
big = np.ones((10000, 10000), dtype=np.float64) # ~800 МБ
doubled = big * 2 # ещё одна копия того же размера
return doubled.sum()
build_matrix()
Запуск (декоратор @profile подставляется автоматически при запуске через модуль):
python -m memory_profiler build_matrix.py
Вывод — таблица с колонками Line #, Mem usage, Increment, Occurrences и исходной строкой. Колонка Increment — то, ради чего всё затевалось: сколько мегабайт RSS добавила конкретная строка. Строка с big = np.ones(...) покажет +763 MiB, строка с doubled = big * 2 — ещё столько же. Вот вам две живые копии вместо одной.
Замер во времени, а не по строкам
Если нужен график RSS во времени (например, чтобы увидеть, как память пилой растёт и не спадает), у пакета есть команда mprof:
# записать профиль во времени
mprof run build_matrix.py
# построить график по последней записи (нужен matplotlib)
mprof plot
Это даёт картину «память во времени», которой не даёт построчный режим: видно плато, пилу утечки и пиковый скачок с привязкой к секундам работы.
Когда какой инструмент
- Список/словарь/строки распухли — чистый Python, объекты, накопление в структурах данных. Берите
tracemalloc: он назовёт строку и посчитает объекты. - numpy, pandas, PIL, тяжёлые C-расширения — память в C-буферах.
tracemallocеё недосчитает, нуженmemory_profilerпо RSS. - Подозрение на утечку в цикле — два снимка
tracemallocиcompare_to, повтор от итерации к итерации. - Диагностика OOM — интересует пик.
get_traced_memory()для Python-объектов илиmprofдля полной картины RSS во времени.
Где это ломается
tracemalloc тормозит и сам ест память. Хранение трассировок каждой аллокации даёт замедление в разы и заметный расход на сами трассировки. Не включайте его в проде постоянно. Аргумент глубины стека (start(10)) прямо влияет на накладные расходы: глубже стек — больше памяти под трассировки. Начинайте с 1–5.
memory_profiler показывает RSS, а он не спадает мгновенно. После del и сборки мусора Python-аллокатор часто не возвращает страницы ОС сразу — они остаются в пуле для повторного использования. Поэтому Increment может показать прирост на аллокации, но не показать симметричного минуса на освобождении. Это не баг профайлера, а поведение аллокатора: не делайте вывод «память не освободилась» только по отсутствию отрицательного инкремента.
Оверхед на запуск. Скрипт под memory_profiler выполняется медленнее из-за замера на каждой строке декорированной функции. Не декорируйте функцию, которая вызывается миллионы раз в горячем цикле — вы измерите оверхед профайлера, а не свой код.
Фрагментация. Ни один из двух инструментов не покажет фрагментацию аллокатора напрямую. Если RSS выше суммы живых объектов и памяти C-расширений — виновата фрагментация. Здесь смотрят в сторону malloc_trim, смены аллокатора или перезапуска воркеров по счётчику запросов.
Что делать дальше
После того как строка-виновник найдена: если это накопление в списке — переводите на генератор или потоковую обработку, чтобы не держать всё в памяти разом. Если это лишняя копия массива numpy — ищите операции с out= и in-place (*= вместо x = x * 2). Если утечка в долгоживущем сервисе — проверьте кеши без ограничения размера и незакрытые ссылки в замыканиях и коллбэках.
Для регулярного контроля в CI поставьте порог по пику через tracemalloc.get_traced_memory() и роняйте сборку, если пик вырос выше ожидаемого. Инвалидация и настройка порогов — отдельная тема; здесь важно, что измерять память нужно на фиксированных данных, иначе цифры между прогонами несравнимы.
Агент-продажник: Скрипты B2B-продаж, обработка возражений по BANT и SPIN.
Спросить за 20 ₽Источники: tracemalloc — Trace memory allocations (Python docs), memory_profiler на PyPI
Частые вопросы
Почему tracemalloc показывает мало памяти, а процесс явно ест гигабайты?
А если у меня Python 3.9 или другая версия?
Почему после del и gc.collect() память не вернулась ОС?
Можно ли включать tracemalloc в продакшене постоянно?
Профайлер показывает огромный расход на одной строке, но она простая — почему?
Что взять для разовой проверки, если не знаю, где проблема?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.