Программирование PythonПамять и производительностьСтарший инженер по производительности Python

При создании тысяч однотипных объектов память неожиданно велика: как словари экземпляров могут разделять ст...

При создании тысяч однотипных объектов память неожиданно велика: как словари экземпляров могут разделять структуру ключей?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

В CPython словари __dict__ однотипных экземпляров могут использовать общую таблицу ключей, а каждый объект хранит только собственные значения. Это называется key-sharing dictionaries или разделяемыми словарями ключей: повторяющиеся имена атрибутов не дублируются в каждом экземпляре.

Экономия возникает только при совместимом формате экземпляров. Динамическое добавление необычных атрибутов, различающаяся структура объектов и особенности реализации могут уменьшить или устранить этот эффект.

Исторический контекст

Механизм появился в CPython 3.3 в рамках оптимизации словарей, описанной в PEP 412. Исходная проблема состояла в том, что объектно-ориентированные программы создают много экземпляров одного класса, а обычный словарь каждого экземпляра отдельно хранил одинаковые строки-ключи.

Разделение ключей позволяет сохранить семантику обычного __dict__, но не повторять его структурную часть для каждого объекта. Это особенно полезно для приложений с большим количеством небольших однотипных объектов.

Постановка проблемы

Предположим, сервис создает миллионы объектов одного класса с одинаковыми атрибутами. Даже если значения атрибутов невелики, отдельные словари экземпляров несут заметные накладные расходы: таблицы, служебные поля, ссылки на ключи и резерв емкости.

Неверно считать, что одинаковые экземпляры автоматически занимают одинаково мало памяти. Если часть объектов получает уникальные атрибуты или создается с различающимися наборами полей, словари могут перестать эффективно использовать общую структуру. Поэтому оптимизацию нужно подтверждать профилированием на реальных данных.

Подробное решение

У словаря экземпляра можно концептуально разделить две части: таблицу ключей и массив значений. Для экземпляров одного класса таблица с именами атрибутов может быть общей, тогда как позиция каждого значения в массиве соответствует конкретному ключу.

Например, если у всех экземпляров есть атрибуты name и status, строки с этими именами и служебная структура ключей не обязаны храниться заново в каждом __dict__. Каждый экземпляр хранит собственные ссылки на значения name и status.

Такой словарь часто называют split table dictionary. Обычный словарь, в котором ключи и значения находятся в одной структуре, называют combined table dictionary. Переключение между представлениями зависит от операций над словарем и деталей версии CPython.

Разделение не означает, что значения тоже становятся общими. Изменение obj.status меняет значение только у данного объекта. Общей является структура ключей, а не состояние экземпляров.

Практические ограничения:

  • механизм является деталью реализации CPython, а не гарантией языка Python;
  • наибольшая польза достигается у экземпляров одного класса с похожим набором атрибутов;
  • добавление редких динамических атрибутов может нарушить разделяемую структуру для конкретного экземпляра или привести к менее эффективному представлению;
  • sys.getsizeof не следует использовать как единственное доказательство экономии: он не дает полноценной картины памяти связанных структур;
  • __slots__ может дать еще меньшую память, но меняет модель хранения атрибутов и ограничивает динамическое добавление полей, если __dict__ явно не предусмотрен.

Таким образом, key-sharing dictionaries — автоматическая оптимизация типичного случая, а не замена проектированию компактной модели данных. При жестких требованиях к памяти иногда лучше хранить поля в слотах, массивах или специализированных структурах, принимая связанные с этим ограничения.

Ситуация из практики

В обработчике событий создавались миллионы объектов одного класса. Почти все имели одинаковые поля, но около одного процента событий получали дополнительный диагностический атрибут с большим текстом. Память процесса росла сильнее, чем ожидалось.

Рассматривались три варианта. Оставить обычный __dict__ было проще всего, но это сохраняло накладные расходы и зависело от характера динамических полей. Перевести класс на __slots__ могло заметно уменьшить память, однако потребовало явно описать поля и отдельно решить, где хранить редкие диагностические данные. Хранить обязательные поля в компактных массивах было наиболее экономно, но усложняло код и доступ к данным.

Выбрали слоты для обязательных полей, а диагностические сведения вынесли в отдельное хранилище, индексированное идентификатором события. Это устранило необходимость держать редкий большой атрибут внутри каждого объекта и сделало потребление памяти более предсказуемым. Выбор подтвердили сравнением снимков памяти и измерением полного жизненного цикла объектов, а не только размера одного экземпляра.

Что кандидаты часто упускают

  1. Разделяется ли между экземплярами таблица значений?

Нет. Разделяется прежде всего структура ключей: имена атрибутов и связанная с ними организация доступа. Значения, такие как ссылки на строки, числа или вложенные объекты, принадлежат конкретному экземпляру. Поэтому изменение атрибута одного объекта не изменяет такой же атрибут другого.

  1. Гарантирует ли наличие одинакового класса одинаковое представление __dict__?

Нет. Это типичный путь оптимизации, но не обещание языка Python. На представление влияют порядок и способ добавления атрибутов, динамические изменения экземпляров, версия CPython и конкретные операции над словарем. Кроме того, наследование и смешанный набор полей могут сделать структуру менее однородной.

  1. Почему нельзя просто объявить все возможные поля и считать проблему решенной?

Потому что объявление полей помогает разделяемой структуре, но не устраняет память самих значений и объектов, на которые они ссылаются. Если поля содержат большие строки, коллекции или дублирующиеся данные, основная память может находиться именно там. Нужно отдельно анализировать накладные расходы экземпляров, размер значений, время жизни объектов и стоимость альтернатив вроде __slots__ или массивного хранения.