Python потребляет много памяти или как уменьшить размер объектов?

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


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


    Для простоты будем рассматривать структуры в Python для представления точки с координатами x, y, z с доступом к значениям координат по имени.


    Dict


    В небольших программах, особенно в скриптах, довольно просто и удобно использовать встроенный dict для представления структурной информации:


    >>> ob = {'x':1, 'y':2, 'z':3}
    >>> x = ob['x']
    >>> ob['y'] = y

    С появлением более "компактной" реализации в Python 3.6 с упорядоченным набором ключей dict стал еще более привлекательным. Однако, посмотрим на размер его следа в оперативной памяти:


    >>> print(sys.getsizeof(ob))
    240

    Он занимает много памяти, особенно, если вдруг понадобится создать большое число экземпляров:


    Количество экземпляров Размер следа
    1 000 000 240 Мб
    10 000 000 2.40 Гб
    100 000 000 24 Гб

    Class instance


    Для любителей все облекать в классы более предпочтительным является определение в виде класса с доступом по имени атрибута:


    class Point:
        #
        def __init__(self, x, y, z):
            self.x = x
            self.y = y
            self.z = z
    
    >>> ob = Point(1,2,3)
    >>> x = ob.x
    >>> ob.y = y

    Интересна структура экземпляра класса:


    Поле Размер (байт)
    PyGC_Head 24
    PyObject_HEAD 16
    __weakref__ 8
    __dict__ 8
    ВСЕГО: 56

    Здесь __weakref__ это ссылка на список, так называемых, слабых ссылок (weak reference) на данный объект, поле __dict__ это ссылка на словарь экземпляра класса, в котором содержатся значения атрибутов экземпляра (заметим, что ссылки на 64-битной платформе занимают 8 байт). Начиная с Python 3.3, используется общее пространство для хранения ключей в словаре для всех экземпляров класса. Это сокращает размер следа экземпляра в памяти:


    >>> print(sys.getsizeof(ob), sys.getsizeof(ob.__dict__)) 
    56 112

    Как результате большое количество экземпляров класса оставляют меньший след в памяти, чем обычный словарь (dict):


    Количество экземпляров Размер следа
    1 000 000 168 Мб
    10 000 000 1.68 Гб
    100 000 000 16.8 Гб

    Нетрудно заметить, что след экземпляра в памяти все еще велик из-за размера словаря экземпляра.


    Instance of class with __slots__


    Существенное уменьшение следа экземпляра в памяти достигается путем исключения __dict__ и __weakref__. Это возможно при помощи "трюка" со __slots__:


    class Point:
        __slots__ = 'x', 'y', 'z'
    
        def __init__(self, x, y, z):
            self.x = x
            self.y = y
            self.z = z
    
    >>> ob = Point(1,2,3)
    >>> print(sys.getsizeof(ob))
    64

    След в памяти стал существенно компактнее:


    Поле Размер (байт)
    PyGC_Head 24
    PyObject_HEAD 16
    x 8
    y 8
    z 8
    ВСЕГО: 64

    Использование __slots__ в определении класса приводит к тому, что след большого числа экземпляров в памяти существенно уменьшается:


    Количество экземпляров Размер следа
    1 000 000 64 Мб
    10 000 000 640 Мб
    100 000 000 6.4 Гб

    В настоящее время это основной метод существенного сокращения следа памяти экземпляра класса в памяти программы.


    Достигается такое сокращение тем, что в памяти после заголовка объекта хранятся ссылки на объекты, а доступ к ним осуществляется при помощи специальных дескрипторов (descriptor), которые находятся в словаре класса:


    >>> pprint(Point.__dict__)
    mappingproxy(
                  ....................................
                  'x': <member 'x' of 'Point' objects>,
                  'y': <member 'y' of 'Point' objects>,
                  'z': <member 'z' of 'Point' objects>})

    Для автоматизации процесса создания класса со __slots__ существует библиотека namedlist. Функция namedlist.namedlist создает класс по структуре идентичный классу со __slots__:


    >>> Point = namedlist('Point', ('x', 'y', 'z'))

    Другой пакет attrs позволяет автоматизировать процесс создания классов как со __slots__, так и без него.


    Tuple


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


    >>> ob = (1,2,3)
    >>> x = ob[0]
    >>> ob[1] = y # НЕВОЗМОЖНО

    Экземпляры tuple вполне компактны:


    >>> print(sys.getsizeof(ob))
    72

    Они занимают в памяти на 8 байт больше, чем экземпляры классов со __slots__, так как след tuple в памяти также содержит количеством полей:


    Поле Размер (байт)
    PyGC_Head 24
    PyObject_HEAD 16
    ob_size 8
    [0] 8
    [1] 8
    [2] 8
    ВСЕГО: 72

    Namedtuple


    Так как tuple используется очень широко, то однажды возник запрос на то, чтобы можно было все-таки иметь доступ к полям и по имени тоже. Ответом на этот запрос стал модуль collections.namedtuple.


    Функция namedtuple создана, чтобы автоматизировать процесс порождения таких классов:


    >>> Point = namedtuple('Point', ('x', 'y', 'z'))

    Она создает подкласс tuple, в котором определены дескрипторы для доступа в полям по имени. Для нашего примера это будет выглядеть примерно так:


     class Point(tuple):
         #
         @property
         def _get_x(self):
             return self[0]
         @property
         def _get_y(self):
             return self[1]
         @property
         def _get_y(self):
             return self[2]
         #
         def __new__(cls, x, y, z):
             return tuple.__new__(cls, (x, y, z))

    Все экземпляры таких классов имеет след в памяти, идентичный с tuple. Большое число экземпляров оставляют чуть больший след памяти:


    Количество экземпляров Размер следа
    1 000 000 72 Мб
    10 000 000 720 Мб

    Recordclass: мутируемый namedtuple без GC


    Так как tuple и, соответственно, namedtuple-классы порождают немутируемые объекты в том смысле, что объект значение ob.x уже нельзя связать с другим объектом-значением, то возник запрос на мутируемый вариант namedtuple. Так как в Python нет встроенного типа, идентичного tuple, поддерживающего присваивания, то было создано множество вариантов. Мы остановимся на recordclass, получившем оценку на stackoverflow. Кроме того, с его помощью можно уменьшить размер следа объекта в памяти по сравнению с размером следа объектов типа tuple.


    В пакете recordclass вводится в обиход тип recordclass.mutabletuple, который практически во всем идентичен tuple, но также поддерживает присваивания. На его основе создаются подклассы, которые практически во всем идентичны namedtuples, но также поддерживают присваивание новых значений полям (не создавая новых экземпляров). Функция recordclass подобно функции namedtuple позволяет автоматизировать создание таких классов:


     >>> Point = recordclass('Point', ('x', 'y', 'z'))
     >>> ob = Point(1, 2, 3)

    Экземпляры класса имеют аналогичную стуктуру, что и tuple, но только без PyGC_Head:


    Поле Размер (байт)
    PyObject_HEAD 16
    ob_size 8
    x 8
    y 8
    y 8
    ВСЕГО: 48

    По умолчанию функция recordclass порождает класс, который не участвует в механизме циклической сборки мусора. Обычно namedtuple и recordclass используют для порождения классов, представляющих записи или простые (нерекурсивные) структуры данных. Корректное их использование в Python не порождает циклических ссылок. По этой причине в следе экземпляров классов, порожденных recordclass по умолчанию, исключен фрагмент PyGC_Head, который необходим для классов, поддерживающих механизм циклической сборки мусора (более точно: в структуре PyTypeObject, соответствующей создаваемому классу в поле flags по умолчанию не установлен флаг Py_TPFLAGS_HAVE_GC).


    Размер следа большого количества экземпляров оказывается меньше, чем у экземпляров класса со __slots__:


    Количество экземпляров Размер следа
    1 000 000 48 Мб
    10 000 000 480 Мб
    100 000 000 4.8 Гб

    Dataobject


    Другое решение, предложенное в библиотеке recordclass основано на идее: использовать структуру хранения в памяти, как у экземпляров классов со __slots__, но не участвовать при этом в механизме циклической сборки мусора. Класс порождается при помощи функции recordclass.make_dataclass:


     >>> Point = make_dataclass('Point', ('x', 'y', 'z'))

    Созданный таким образом класс по умолчанию, создает мутируемые экземпляры.


    Другой способ – использовать объявление класса путем наследования от recordclass.dataobject:


    class Point(dataobject):
        x:int
        y:int
        z:int

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


    Поле Размер (байт)
    PyObject_HEAD 16
    x 8
    y 8
    y 8
    ВСЕГО: 40

    >>> ob = Point(1,2,3)
    >>> print(sys.getsizeof(ob))
    40

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


    mappingproxy({'__new__': <staticmethod at 0x7f203c4e6be0>,
                  .......................................
                  'x': <recordclass.dataobject.dataslotgetset at 0x7f203c55c690>,
                  'y': <recordclass.dataobject.dataslotgetset at 0x7f203c55c670>,
                  'z': <recordclass.dataobject.dataslotgetset at 0x7f203c55c410>})

    Размер следа большого количества экземпляров оказывается минимально возможным для CPython :


    Количество экземпляров Размер следа
    1 000 000 40 Мб
    10 000 000 400 Мб
    100 000 000 4.0 Гб

    Cython


    Есть один подход, основанный на использовании Cython. Его достоинство состоит в том, что поля могут принимать значения типов языка C. Дескрипторы для доступа к полям из чистого Python создаются автоматически. Например:


    cdef class Python:
        cdef public int x, y, z
    
     def __init__(self, x, y, z):
          self.x = x
          self.y = y
          self.z = z

    В этом случае экземпляры имеют еще меньший размер памяти:


    >>> ob = Point(1,2,3)
    >>> print(sys.getsizeof(ob))
    32

    След экземпляра в памяти имеет следующую структуру:


    Поле Размер (байт)
    PyObject_HEAD 16
    x 4
    y 4
    y 4
    пусто 4
    ВСЕГО: 32

    Размер следа большого количества экземпляров получается меньше:


    Количество экземпляров Размер следа
    1 000 000 32 Мб
    10 000 000 320 Мб
    100 000 000 3.2 Гб

    Однако следует помнить, что при доступе из кода на Python всякий раз будет осуществляться преобразование из int в объект Python и наоборот.


    Numpy


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


    >>> Point = numpy.dtype(('x', numpy.int32), ('y', numpy.int32), ('z', numpy.int32)])

    Массив и N элементов, инициализированный нулями создается при помощи функции:


     >>> points = numpy.zeros(N, dtype=Point)

    Размер массива минимально возможный:


    Количество экземпляров Размер следа
    1 000 000 12 Мб
    10 000 000 120 Мб
    100 000 000 1.20 Гб

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


      >>> sys.getsizeof(points[0])
      68

    Поэтому, как было отмечено выше, в коде на Python необходимо осуществлять обработку массивов, используя функции из пакета numpy.


    Заключение


    На наглядном и простом примере можно было убедиться, что сообщество разработчиков и пользователей языка программирования Python (CPython) располагает реальными возможностями для существенного сокращения объема памяти, используемой объектами.

    Поделиться публикацией

    Похожие публикации

    Комментарии 21

      –6

      Как-то рассуждая о проблемах оптимизации использования памяти и времени выполнения я услышал на мой взгляд классный вывод:


      Сегодня проблемы памяти и времени не существует. Существует проблема денег.
        +2

        Ну не всегда это правильно, существуют моменты пикового потребления памяти, например до 10..20гб, а в остальное время, 99.9%, приложение живет в скромных 100..200мб.
        Плюс, не надо забывать, что сначала к нам приходит 10 человек в минуту, а через неделю 100 и так далее. Можно и устать масштабироваться :)

        +1
        Recordclass: мутируемый namedtuple без GC


        А как в данном случае чистить память? Или ждать конца работы программы?
          +2

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

            0

            Можно обосновать минус? Что в ответе не верно?

              +2
              Минус ставил не я, но задам вопрос. Подсчет ссылок это же и есть GC?
                0

                И да, и нет. Подсчет ссылок — это часть GC. Т.к. GC брлее широкое понятие, включающее в себе определение, например, кластеров объектов, ссылающихся друг на друга (но не имеющих ссылки извне), и, например, релокации объектов в памяти для уменьшения ее фрагментации

                  +2

                  Нет, это разные вещи. С разным механизмом работы, разными возможностями и разным влиянием на производительность.


                  Простым подсчетом ссылок не определишь, что набор объектов с зацикленными ссылками стал недостижимым (напр. все двусвязные списки из >1 ноды будут утекать. Т.к. в структуре A ⇆ B и на A и на B всегда есть как минимум одна ссылка. Даже когда кроме их самих никто на них не ссылается).


                  У GC принцип работы другой — он просто обходит всю компоненту связности графа объектов, начиная с "корневого" объекта, и затем убивает все объекты, которые во время этого обхода оказались недостижимыми (на самом деле обходится не весь граф, есть разделение поколений объектов и другие оптимизации, но сам принцип остается прежним — обходим (под-)граф и удаляем все, к чему не дошли).


                  В Python используются оба этих механизма.

                    0
                    Не совсем, например Appleвские языки: Objectiv-C, Swift не имеют GC, но имеют автоматическую сборку мусора.
                      +3

                      Вы меня запутали ) GC — garbage collector, то есть сборка мусора

                        0
                        Нет, GC- сборщик мусора, то есть компонент рантайма, который периодически выходит на уборку.

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

                          Вероятно опечатка) В эппловких языках нет GC, но есть подсчет ссылок. А скажем, в джаве есть GC, а подсчета ссылок нет.


                          В Python есть и то и другое. Можно рассматривать подсчет ссылок как одну из оптимизаций GC, но в общем случае это независимые вещи, которые прекрасно работают отдельно друг от друга, что видно из примеров выше.

                            0

                            GC — garbage collector — это модуль рантайма, т.к. сборщик мусора
                            GC — как garbage collection — это процесс (в смысле как явление, а не в смысле потока рантайма), который он обеспечивает.

                          +1

                          В Python есть механизм подсчёта ссылок — это основной способ управления памятью и жизнью объектов. И есть GC, который основан на поколениях: https://docs.python.org/3/library/gc.html


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


                          А ещё в Python есть модуль weakref для создания слабых ссылок, но не уверен, что этот механизм будет работать со штуками из recordclass.

                            0

                            Можно создавать классы с поддержкой weakref, но без участия в механизме циклической сборки мусора:


                             @clsconfig(weakref=True)
                              class Point(dataobject):
                                  x:int
                                  y:int

                            или


                            Point = make_dataclass('Point', ('x','y'), use_weakref=True)
                            +1

                            В Python есть основной механизм подсчета ссылок на объект. Если объект таков, что циклические ссылки на него в графе зависимостей между объектами не возникают, то после того, как счетчик ссылок (находится в заголовке объекта PyObject_HEAD) обнуляется, что означает что объект более никем не используется, то автоматически утилизируется из памяти. Тут правда есть нюансы, связанные с распределителем памяти объектов в Python, но оставим это за скобками. Дополнительно есть механизм для утилизации для объектов, в которым возможны циклические ссылки. В объекте-типе для классов установлен специальный флаг, который сигнализирует, что объект должен быть утилизирован через механизм циклической сборки мусора, который включает в себя обход таких объектов с целью ликвидации циклов в графе зависимостей между объектами. Для простых объектов типа str, long, date/time такой флаг не выставлен и они утилизируются через механизм подсчета ссылок. Контейнерные типы list, dict, любой класс созданный через обычное определение class, и даже немутируемый tuple имеют такой флаг. Однако не всегда маркирование класса автоматически, как потенциальный для возникновения циклических ссылок, оправдано. Например, объекты, которые представляют простые структуры, которые в графе зависимостей между объектами по-существу являются терминальными. Корректное использование таких классов не приводит к возникновению циклических ссылок. Поэтому здесь механизм циклической сборки мусора избыточен и может быть и может быть исключен.


                            Задавшему вопрос отдельная благодарность за возможность прояснить ситуацию.

                      0
                      Странное ощущение, что уже видел эту статью
                        +2
                        у меня только один вопрос: а где array.array или ctypes.Structure? же реализуют идею data locality, т.е для единичного объекта может и не шибко эффективно как и половина способов описанных автором, а вот для пачки (большой пачки) выгода может быть очень даже заметная.
                          0

                          Вы правы, это вышло за скобки. Я не преследовал цель сделать полный обзор. Но отметил наиболее часто используемые. Что касается array.array для большого числа значений, выигрыш по памяти есть, так же как и в случае с numpy. Но остается нагрузка в связи с необходимостью преобразования С-шных типов в Python-овские и наоборот. Если выигрыш в памяти важнее, чем потеря производительности в связи с boxing/unboxing, то да действительно это работает. Что касается ctypes.Structure, у меня нет опыта работы с ctypes. Однако, исторически ctypes создавался для того, чтобы обеспечивать доступ к структурам, которые изначально живут в бинарных модулях (dll/so), которые создавались при помощи C/C++ или следовали соответствующим конвенциям.
                          Проблема boxing/unboxing здесь остается.


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

                          +2
                          Из за особенностей объектов в python sys.getsizeof() выдает результат только для верхнего уровня данных не переходя по ссылкам. Есть удобный инструмент pympler. Он циклически раскручивает объект целиком.
                          import sys
                          from pympler import asizeof
                          
                          class A:
                              def __init__(self):
                                  self.x = 1
                          
                          class B:
                              def __init__(self):
                                  self.y = "hello world"
                                  self.z = {'a': 1, 'b': 2}
                          
                          a = A()
                          b = B()
                          
                          print(asizeof.asizeof(a))  # 256
                          print(sys.getsizeof(a))  # 56
                          
                          print(asizeof.asizeof(b))  # 760
                          print(sys.getsizeof(b))  # 56
                          
                            –1

                            Для экземпляров классов, порожденных recordclass и унаследованных от dataobject pympler не может вычислить правильный размер экземпляра. Похоже, что для экземпляров классов со __slots__ тоже почему-то дает завышенное значение.

                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                          Самое читаемое