2025, Dec 02 18:02

Почему sys.getsizeof не растет для key-sharing dicts и что изменилось в Python 3.12

Разбираем, что измеряет sys.getsizeof для словарей с разделяемыми ключами, почему рост значений не меняет размер, и откуда разница между Python 3.12 и 3.10.

Словари с разделяемыми ключами (Key-Sharing) в Python помогают сдерживать расход памяти для множества однотипных объектов: метаданные отделяются от данных каждой конкретной инстанции. Но если измерять такие объекты через sys.getsizeof, результаты могут показаться нелогичными. Разберёмся, что именно измеряется, почему изменение значений не влияет на показываемый размер и откуда берётся более высокий базовый показатель в Python 3.12 по сравнению с 3.10.

Reproducing the question

Настройка минимальна: экземпляр с несколькими атрибутами и измерение размера его __dict__:

import sys
class Record:
    def __init__(self, x0, x1, x2, x3, x4):
        self.f0 = x0
        self.f1 = x1
        self.f2 = x2
        self.f3 = x3
        self.f4 = x4
scheme = Record('blue', 'orange', 'green', 'yellow', 'red')
sys.getsizeof(vars(scheme))  # например, 296 в Python 3.12.5

В Python 3.12.5 это даёт 296 байт для словаря атрибутов объекта, тогда как Python 3.10.11 возвращает 104. Замена значений на очень длинные строки или использование крайне длинного имени атрибута никак не изменяют этот показатель.

What Key-Sharing dicts actually store

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

В CPython это реализовано внутри PyDictObject. Внутреннее поле ma_values либо равно NULL, либо указывает на массив, используемый для «раздельных» таблиц. Важный для пользователя Python момент: отображение атрибутов, которое вы видите как obj.__dict__, с точки зрения системы типов Python остаётся обычным dict. Механизм разделения — это оптимизация внутреннего устройства, а не особый тип на уровне Python.

What sys.getsizeof really measures here

sys.getsizeof возвращает объём памяти объекта, который вы ему передаёте. Если вы вызываете его для vars(obj) или obj.__dict__, измеряется сам словарь, а не объекты, на которые он ссылается. Словарь хранит указатели на значения атрибутов, и размер этих указателей постоянен, вне зависимости от размеров самих значений.

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

С целыми числами ситуация аналогичная. В Python целые — это объекты, а словарь хранит на них указатели. Размер указателя не зависит от того, на что он указывает.

Why Python 3.12 reports a larger size than 3.10

Разница в размере связана с изменениями внутреннего представления словарей. В Python 3.12 появился новый тип члена для словарей с разделяемыми ключами. Раньше ma_values имело тип PyObject**; теперь это PyDictValues*, из‑за чего базовый размер словарей вырос, чтобы уместить новую структуру. Изменение отражено в исходниках CPython и истории коммитов.

typedef struct _dictkeysobject PyDictKeysObject;
+++ typedef struct _dictvalues PyDictValues;
/* Указатель ma_values равен NULL для объединённой таблицы
 * или указывает на массив PyObject* для раздельной таблицы
 */
typedef struct {
    PyObject_HEAD
    /* Число элементов в словаре */
    Py_ssize_t ma_used;
    /* Глобально уникальная версия словаря; значение меняется при каждом
       изменении словаря */
    uint64_t ma_version_tag;
    PyDictKeysObject *ma_keys;
    /* Если ma_values равен NULL, таблица «объединённая»: ключи и значения
       хранятся в ma_keys.
       Если ma_values не равен NULL, таблица «раздельная»:
       ключи хранятся в ma_keys, а значения — в ma_values */
---    PyObject **ma_values;
+++    PyDictValues *ma_values;
} PyDictObject;

and

struct _dictvalues {
    uint64_t mv_order;
    PyObject *values[1];
};

Поскольку obj.__dict__ по‑прежнему является PyDictObject под капотом, sys.getsizeof видит и измеряет именно dict. Разделение ключей и раздельная раскладка — это внутренние детали этого словаря.

Putting the understanding to use

Ничего «исправлять» в коде не нужно, когда sys.getsizeof возвращает число, не отражающее суммарную память значений, на которые ссылается словарь. Функция делает ровно то, что обещает: показывает след самого объекта. Для словаря экземпляра это контейнер и его внутренние поля, а не транзитивное множество всех объектов, на которые он указывает.

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

Why this matters

При анализе памяти объектов Python в продакшене или при работе над производительностью трактовка показаний sys.getsizeof «как есть» может ввести в заблуждение, если ожидать, что туда входят размеры связанных объектов. Для словарей с разделяемыми ключами особенно важно помнить: ключи общие и хранятся на уровне класса, а измеряемый словарь экземпляра содержит указатели на значения. Различия между версиями также могут быть следствием внутренних изменений представления данных, как, например, появление PyDictValues в Python 3.12.

Conclusion

Словари с разделяемыми ключами отделяют общие ключи от значений на уровне экземпляров, а sys.getsizeof отражает только память, занятую самим словарём, представляющим это отображение на уровне объекта. Размер не меняется из‑за больших строк или других типов значений, потому что указатели имеют постоянный размер, а ключи не входят в словарь экземпляра. Более высокий базовый размер в Python 3.12 по сравнению с 3.10 связан с внутренними изменениями структуры словаря — прежде всего с переходом на PyDictValues для раздельных таблиц. Учитывайте эти особенности, когда интерпретируете замеры памяти и сравниваете результаты между версиями Python.