Управление памятью в Python

в 16:52, , рубрики: memory management, python, Анализ и проектирование систем, Блог компании Mail.Ru Group, высокая производительность, никто не читает теги, Проектирование и рефакторинг

Управление памятью в Python - 1

Одна из главных проблем при написании крупных (относительно) программ на Python — минимизация потребления памяти. Однако управлять памятью здесь легко — если вас вообще это волнует. Память в Python выделяется прозрачно, управление объектами происходит с помощью системы счётчиков ссылок (reference count), и память высвобождается, когда счётчик падает до нуля. В теории всё прекрасно. А на практике вам нужно знать несколько вещей об управлении памятью в Python, чтобы ваши программы эффективно её использовали. Первая вещь, надо хорошо в ней разбираться: размеры основных объектов в Python. И вторая вещь: как устроено управление «под капотом» языка.

Начнём с размеров объектов. В Python есть много примитивных типов данных: целые числа (int), long (версия int с неограниченной точностью), числа с плавающей запятой (они же числа с двойной точностью, double), кортежи (tuple), строковые значения, списки, словари и классы.

Основные объекты

Каков размер int? Программист, пишущий на C или C++, вероятно, скажет, что размер машинно-зависимого (machine-specific) int — около 32 бит, возможно, 64; а следовательно, занимает не более 8 байтов. Но так ли это в Python?

Давайте напишем функцию, показывающую размер объектов (рекурсивно, если нужно):

import sys
def show_sizeof(x, level=0):
    print "t" * level, x.__class__, sys.getsizeof(x), x
    if hasattr(x, '__iter__'):
        if hasattr(x, 'items'):
            for xx in x.items():
                show_sizeof(xx, level + 1)
        else:
            for xx in x:
                show_sizeof(xx, level + 1)

Теперь с помощью этой функции можно исследовать размеры основных типов данных:

show_sizeof(None)
show_sizeof(3)
show_sizeof(2**63)
show_sizeof(102947298469128649161972364837164)
show_sizeof(918659326943756134897561304875610348756384756193485761304875613948576297485698417)

Если у вас 32-битный Python 2.7x, то вы увидите:

8 None
12 3
22 9223372036854775808
28 102947298469128649161972364837164
48 918659326943756134897561304875610348756384756193485761304875613948576297485698417

А если 64-битный Python 2.7x, то увидите:

16 None
24 3
36 9223372036854775808
40 102947298469128649161972364837164
60 918659326943756134897561304875610348756384756193485761304875613948576297485698417

Давайте сосредоточимся на 64-битной версии (в основном потому, что в нашем случае она более востребована). None занимает 16 байтов. int — 24 байта, в три раза больше по сравнению с int64_t в языке С, хотя это в какой-то мере machine-friendly целое число. Минимальный размер значений типа long (с неограниченной точностью), используемых для представления чисел больше 263 – 1, это — 36 байтов. Затем они увеличиваются линейно, как логарифм представляемого числа.

Числа с плавающей запятой в Python зависят от реализации, но похожи на числа с двойной точностью в C. Однако они не занимают всего лишь 8 байтов:

show_sizeof(3.14159265358979323846264338327950288)

На 32-битной платформе выдаёт:

16 3.14159265359

И на 64-битной:

24 3.14159265359

Это опять втрое больше, чем предположил бы программист на C. А что насчёт строковых значений?

show_sizeof("")
show_sizeof("My hovercraft is full of eels")

На 32-битной платформе:

21
50 My hovercraft is full of eels

И на 64-битной:

37
66 My hovercraft is full of eels

Пустое строковое значение занимает 37 байтов в 64-битной среде! Затем потребление памяти увеличивается в соответствии с размером (полезного) значения.


Давайте разберёмся и с другими часто востребованными структурами: кортежами, списками и словарями. Списки (реализованные как списки массивов, а не как связные списки, со всеми вытекающими) — это массивы ссылок на Python-объекты, что позволяет им быть гетерогенными. Их размеры:

show_sizeof([])
show_sizeof([4, "toaster", 230.1])
На 32-битной платформе выдаёт:
32 []
44 [4, 'toaster', 230.1]
И на 64-битной:
72 []
96 [4, 'toaster', 230.1]

Пустой список занимает 72 байта. Размер пустого std::list() в 64-битном С — всего 16 байтов, в 4—5 раз меньше. Что насчёт кортежей? И словарей?

show_sizeof({})
show_sizeof({'a':213, 'b':2131})

На 32-битной платформе выдаёт:

136 {}
 136 {'a': 213, 'b': 2131}
        32 ('a', 213)
                22 a
                12 213
        32 ('b', 2131)
                22 b
                12 2131

И на 64-битной:

280 {}
 280 {'a': 213, 'b': 2131}
        72 ('a', 213)
                38 a
                24 213
        72 ('b', 2131)
                38 b
                24 2131

Последний пример особенно интересен, потому что он «не складывается». Пары ключ/значение занимают 72 байта (их компоненты занимают 38 + 24 = 62 байта, а ещё 10 тратится на саму пару), но весь словарь весит уже 280 байтов (а не минимально необходимые 144 = 72 × 2 байта). Словарь считается эффективной структурой данных для поиска, и две вероятные реализации будут занимать памяти больше, чем необходимый минимум. Если это какое-то дерево, то приходится расплачиваться за внутренние ноды, содержащие ключ и два указателя на дочерние ноды. Если это хеш-таблица, то ради хорошей производительности нужно иметь место для свободных записей.

Эквивалентная (относительно) структура std::map из C++ при создании занимает 48 байтов (пока ещё пустая). А пустое строковое значение в C++ требует 8 байтов (затем размер линейно растёт вместе с размером строки). Целочисленное значение — 4 байта (32 бит).


И что нам всё это даёт? Тот факт, что пустое строковое значение занимает 8 или 37 байтов, мало что меняет. Действительно. Но лишь до тех пор, пока ваш проект не начнёт разрастаться. Тогда вам придётся очень аккуратно следить за количеством создаваемых объектов, чтобы ограничить объём потребляемой приложением памяти. Для настоящих приложений это проблема. Чтобы разработать действительно хорошую стратегию управления памятью, нам нужно следить не только за размером новых объектов, но и за количеством и порядком их создания. Для Python-программ это очень важно. Давайте теперь разберёмся со следующим ключевым моментом: с внутренней организацией выделения памяти в Python.

Внутреннее управление памятью

Чтобы ускорить выделение памяти (и её повторное применение), Python использует ряд списков для маленьких объектов. Каждый список содержит объекты одного размера: может быть один список для объектов от 1 до 8 байтов, другой — для объектов 9—16 байтов и т. д. Когда нужно создать маленький объект, мы вновь используем свободный блок в списке или выделяем новый.
Есть несколько нюансов, как Python распределяет эти списки по блокам, пулам и «аренам»: несколько блоков формируют пул, пулы собираются в арену и т. д. Но мы в это углубляться не будем (если хотите, то можете почитать мысли Эвана Джонса о том, как улучшить выделение памяти в Python). Нам важно знать, что эти списки неуменьшаемы.

В самом деле: если элемент (размером x) удалён из памяти (стёрта ссылка на него), то занимавшийся им объём не возвращается в пул глобальной памяти Python (в том числе и в систему), а помечается свободным и добавляется к списку свободных элементов размером x. Занимаемый мёртвым объектом объём может быть использован вновь, если понадобится другой объект подходящего размера. А если подходящего мёртвого объекта нет, то создаётся новый.

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


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

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


Увеличить списки свободных элементов — не особая проблема, потому что эта память всё ещё доступна для Python-программы. Но с точки зрения ОС размер вашей программы равен общему размеру выделенной для Python памяти. И только под Windows память возвращается в кучу ОС (и применяется для размещения и других объектов, помимо маленьких), а под Linux общий объём используемой вашим приложением памяти будет только расти.


Докажем это утверждение с помощью memory_profiler, модуля для Python (зависящего от пакета python-psutil) (страница на Github). Он добавляет декоратор @profile, позволяющий отслеживать какое-то конкретное применение памяти. Пользоваться им крайне просто. Рассмотрим следующую программу:

import copy
import memory_profiler

@profile
def function():
    x = list(range(1000000))  # allocate a big list
    y = copy.deepcopy(x)
    del x
    return y

if __name__ == "__main__":
    function()
invoking
python -m memory_profiler memory-profile-me.py

На 64-битном компьютере она выводит:

Filename: memory-profile-me.py

Line #    Mem usage    Increment   Line Contents
================================================
     4                             @profile
     5      9.11 MB      0.00 MB   def function():
     6     40.05 MB     30.94 MB       x = list(range(1000000)) # allocate a big list
     7     89.73 MB     49.68 MB       y = copy.deepcopy(x)
     8     82.10 MB     -7.63 MB       del x
     9     82.10 MB      0.00 MB       return y

Программа создаёт n = 1 000 000 целых чисел (n × 24 байта = ~23 Мб) и дополнительный список ссылок (n × 8 байтов = ~7,6 Мб), и в сумме получаем ~31 Мб. copy.deepcopy копирует оба списка, и копии занимают ~50 Мб (не знаю, откуда берутся лишние 50 – 31 = 19 Мб). Любопытно, что del x удаляет x, но потребление памяти снижается лишь на 7,63 Мб! Причина в том, что del удаляет только список ссылок, а реальные целочисленные значения остаются в куче и приводят к избыточному потреблению в ~23 Мб.

В этом примере в сумме занято ~73 Мб, что более чем вдвое превышает объём, необходимый для хранения списка, весящего ~31 Мб. Как видите, при потере бдительности порой возникают очень неприятные сюрпризы с точки зрения потребления памяти!

Вы можете получить иные результаты на других платформах и других версиях Python.

Pickle

Кстати, а что насчёт pickle?

Pickle — стандартный способ (де)сериализации Python-объектов в файл. Каково его потребление памяти? Он создаёт дополнительные копии данных или работает умнее? Рассмотрим короткий пример:

import memory_profiler
import pickle
import random

def random_string():
    return "".join([chr(64 + random.randint(0, 25)) for _ in xrange(20)])

@profile
def create_file():
    x = [(random.random(),
          random_string(),
          random.randint(0, 2 ** 64))
         for _ in xrange(1000000)]

    pickle.dump(x, open('machin.pkl', 'w'))

@profile
def load_file():
    y = pickle.load(open('machin.pkl', 'r'))
    return y

if __name__=="__main__":
    create_file()
    #load_file()

При первом вызове мы профилируем создание pickled-данных, а при втором вызове заново считываем их (можно закомментировать функцию, чтобы она не вызывалась). При использовании memory_profiler в ходе создания данных потребляется много памяти:

Filename: test-pickle.py

Line #    Mem usage    Increment   Line Contents
================================================
     8                             @profile
     9      9.18 MB      0.00 MB   def create_file():
    10      9.33 MB      0.15 MB       x=[ (random.random(),
    11                                      random_string(),
    12                                      random.randint(0,2**64))
    13    246.11 MB    236.77 MB           for _ in xrange(1000000) ]
    14
    15    481.64 MB    235.54 MB       pickle.dump(x,open('machin.pkl','w'))

А при считывании — немного меньше:

Filename: test-pickle.py

Line #    Mem usage    Increment   Line Contents
================================================
    18                             @profile
    19      9.18 MB      0.00 MB   def load_file():
    20    311.02 MB    301.83 MB       y=pickle.load(open('machin.pkl','r'))
    21    311.02 MB      0.00 MB       return y

Так что picklе очень плохо влияет на потребление памяти. Исходный список занимает около 230 Мб, а при сериализации потребляется ещё примерно столько же.

C другой стороны, десериализация выглядит более эффективной. Потребляется больше памяти, чем исходный список (300 Мб вместо 230), но это хотя бы не вдвое больше.

В целом лучше избегать (де)сериализации в приложениях, чувствительных к потреблению памяти. Какие есть альтернативы? Сериализация сохраняет всю структуру данных, так что позднее вы сможете полностью восстановить её из получившегося файла. Но это не всегда нужно. Если файл содержит список, как в предыдущем примере, то, возможно, целесообразно использовать простой, текстовый формат. Давайте посмотрим, что это даёт.

Простейшая (naïve) реализация:

import memory_profiler
import random
import pickle

def random_string():
    return "".join([chr(64 + random.randint(0, 25)) for _ in xrange(20)])

@profile
def create_file():
    x = [(random.random(),
          random_string(),
          random.randint(0, 2 ** 64))
         for _ in xrange(1000000) ]

    f = open('machin.flat', 'w')
    for xx in x:
        print >>f, xx
    f.close()

@profile
def load_file():
    y = []
    f = open('machin.flat', 'r')
    for line in f:
        y.append(eval(line))
    f.close()
    return y

if __name__== "__main__":
    create_file()
    #load_file()

Создаём файл:

Filename: test-flat.py

Line #    Mem usage    Increment   Line Contents
================================================
     8                             @profile
     9      9.19 MB      0.00 MB   def create_file():
    10      9.34 MB      0.15 MB       x=[ (random.random(),
    11                                      random_string(),
    12                                      random.randint(0, 2**64))
    13    246.09 MB    236.75 MB           for _ in xrange(1000000) ]
    14
    15    246.09 MB      0.00 MB       f=open('machin.flat', 'w')
    16    308.27 MB     62.18 MB       for xx in x:
    17                                     print >>f, xx

Считываем файл:

Filename: test-flat.py

Line #    Mem usage    Increment   Line Contents
================================================
    20                             @profile
    21      9.19 MB      0.00 MB   def load_file():
    22      9.34 MB      0.15 MB       y=[]
    23      9.34 MB      0.00 MB       f=open('machin.flat', 'r')
    24    300.99 MB    291.66 MB       for line in f:
    25    300.99 MB      0.00 MB           y.append(eval(line))
    26    301.00 MB      0.00 MB       return y

При записи потребляется гораздо меньше памяти. Всё ещё создаётся много временных маленьких объектов (примерно 60 Мб), но это не сравнить с удвоенным потреблением. Чтение сравнимо по затратам (используется чуть меньше памяти).

Этот пример тривиален, но он обобщает стратегии, при которых вы сначала не загружаете данные целиком с последующей обработкой, а считываете несколько элементов, обрабатываете их и заново используете выделенную память. Загружая данные в массив Numpy, к примеру, можно сначала создать массив Numpy, затем построчно считывать файл, постепенно заполняя массив. Это позволит разместить в памяти только одну копию всех данных. А при использовании pickle данные будут размещены в памяти (как минимум) дважды: один раз pickle, второй раз при работе с Numpy.

Или, ещё лучше, применяйте массивы Numpy (или PyTables). Но это уже совсем другая история. В то же время в директории Theano/doc/tutorial вы можете почитать другое руководство по загрузке и сохранению.


Цели архитектуры Python никак не совпадают, допустим, с целями архитектуры C. Последний спроектирован так, чтобы дать вам хороший контроль над тем, что вы делаете, за счёт более сложного и явного программирования. А первый спроектирован так, чтобы вы могли писать код быстрее, но при этом язык прячет большинство подробностей реализации (если не все). Хотя это звучит красиво, но игнорирование неэффективных реализаций языка в production-среде порой приводит к неприятным последствиям, иногда неисправимым. Надеюсь, что знание этих особенностей Python при работе с памятью (архитектурных особенностей!) поможет вам писать код, который будет лучше соответствовать требованиям production, хорошо масштабироваться или, напротив, окажется горящим адом для памяти.

Автор: Макс

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js