В данной статье я расскажу, как оптимизировать потребление памяти в 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 раз. При этом структура хранения осталась удобной иерархичной.