Оптимизация потребления памяти в Python: анализ и пример реализации
В данной статье я расскажу, как оптимизировать потребление памяти в Python. Очень символично, что рассказ будет на примере плагина для gdb, который отслеживает аллокации памяти в разных модулях программы на C++. Оптимизация была необходима, так как при анализе большого дампа в 6 ГБ, gdb требовалось до 20 ГБ ОЗУ - непозволительная роскошь.
В этой статье не привожу никаких профилировщиков, так как ими не пользовался. Только тщательное чтение кода, только хардкор (и немного помощи от google и chatGPT).
Введение
Основная цель плагина — собирать статистику аллокаций. В коде вся информация хранится в иерархической структуре:
Lib -> list(Func)
— для каждой библиотеки хранится список функций, в которых были сделаны аллокации;Func -> list(RetAddr)
— для каждой функции хранится список адресов внутри функции, где были сделаны аллокации;RetAddr -> list(MemoryRecord)
— для каждого адреса, где была сделана аллокация, хранится информация об аллокациях: размеры, место аллокации.
Оптимизация велась поэтапно, без изменения основной структуры хранения. Ниже приведу ход работы.
Первый коммит: исходная версия
Сбор дампа и статистики
Для начала соберем приложение с аллокациями. В репозитории есть hello_world:
cmake -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_HELLO_WORLD=ON && cmake --build build
cmake --install build --prefix ./output
Запустим приложение и соберём дамп:
LD_PRELOAD=output/libmalloc_tracer.so ./output/hello_world $
sudo gcore -o output/app.dump $(pgrep hello_world) $
С помощью утилиты chap
посмотрим на количество аллокаций:
./chap output/app.dump.*
chap> count used
2000011 allocations use 0xb2bf790 (187,430,800) bytes.
chap> redirect on
chap> list used
Wrote results to output/app.dump.555251.list_used
chap> redirect off
Файл описания аллокаций занимает ~84 МБ:
ls -lah output/*.list_used
-rw-r--r-- 1 cema cema 84M Dec 21 19:53 output/app.dump.555251.list_used
84 МБ - выглядит допустимо для описания 2 млн аллокаций. Вероятно, при анализе аллокаций gdb будет использовать соизмеримое количество памяти.
Потребление памяти в gdb
Откроем дамп с помощью gdb и плагина:
gdb ./output/hello_world ./output/app.dump.555251 -ex "python chap_file='output/app.dump.555251.list_used'" -x "$(pwd)/gdb_plugin/gdb_malloc_tracer"
heap_total
Использование памяти:
ps aux | grep gdb
USER PID %CPU %MEM VSZ RSS
cema 556462 20.4 4.7 2009752 774096
~750 МБ — это в 10 раз больше, чем размер текстового файла. Слишком много, по сравнению с исходными данными, поэтому можно задуматься об оптимизации.
Больше пересобирать приложение не придется, только открывать дамп с помощью gdb при каждом изменении.
Второй коммит: использование слотов
В этом плагине больше всего памяти выделяется под описание аллокаций. За это отвечают dataclass'ы
: Lib
, Func
, RetAddr
, MemoryRecord
.
dataclass
допускают добавление новых атрибутов. Это свойство сильно увеличивает размер объектов, но его можно отключить с помощью параметра slots
.
Решение: изменить декоратор dataclass
на @dataclass(slots=True)
.
Результат: потребление памяти уменьшается в 2 раза:
ps aux | grep gdb
USER PID %CPU %MEM VSZ RSS
cema 560792 58.6 2.2 1598120 372912
~360 МБ.
Третий коммит: оптимизация структуры хранения
Следующий шаг — сократить хранение лишних объектов. Класс RetAddr
содержит список объектов MemoryRecord
. Как он их использует?
В функции repr_memory_records
вызывется метод MemoryRecord.__repr__
, который использует только start_of_data
. Значит мы можем хранить не список объектов MemoryRecord
, а хранить список start_of_data
с целыми числами.
Решение: хранить вместо объектов только список целых чисел:
data_addrs: list[int] = field(default_factory=list)
Результат: снижение памяти в 2.5 раза:
ps aux | grep gdb
USER PID %CPU %MEM VSZ RSS
cema 565821 53.7 0.9 1377972 146764
~140 МБ.
При написании плагина думал, что размеры для каждой аллокации будут нужны, но нет. Для статистики хватает хранить суммарные размеры для каждого адреса, где были сделаны аллокации. По каждой конкретной аллокации можно извлекать размер и сами данные из дампа по ходу работы с конкретной аллокацией.
Четвёртый коммит: использование array
В списке RetAddr.data_addrs
хранятся только целые числа, поэтому этот список можно заменить на массив array
для экономии памяти:
data_addrs: array = field(default_factory=lambda: array("L"))
Результат: уменьшение потребления памяти ещё в 1.7 раза:
$ ps aux | grep gdb $
USER PID %CPU %MEM VSZ RSS
cema 562327 46.5 0.5 1314644 82760
~80 МБ.
Итог
Оптимизация позволила уменьшить использование памяти с 750 МБ до 80 МБ — в 9 раз. При этом структура хранения осталась удобной иерархичной.