Как стать автором
Обновить

Оптимизируем использование памяти в приложениях Python

Время на прочтение10 мин
Количество просмотров9.2K
Автор оригинала: Martin Heinz
Когда дело доходит до оптимизации производительности, чаще всего особое внимание уделяется скорости и активности использования ЦП. Гораздо реже кто-либо задумывается о потреблении памяти, конечно, пока не будут израсходованы мощности RAM. Есть много причин, по которым предпринимаются попытки лимитировать использование памяти – не только стремление избежать отказа приложения из-за ошибок, связанных с её исчерпанием.

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

Какой вообще повод для волнений?


Начнём с того, почему вообще стоит беспокоиться о сбережении RAM? Есть ли вообще какие-либо причины экономить память, кроме как во избежание вышеупомянутых ошибок и отказов, связанных с исчерпанием памяти?

Есть простая причина — деньги. Ресурсы, как ЦП, так и RAM, стоят денег, так зачем же напрасно расходовать память, пользуясь неэффективными приложениями, если есть возможности сократить отводимую на приложения память?

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

Наконец, бывает и так, что производительность можно улучшить простым добавлением памяти (если производительность приложения зависит от памяти), но так сделать не получится, если памяти на машине уже не осталось.

Поиск узких мест


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

Первый инструмент, с которым мы познакомимся — memory_profiler. Он построчно измеряет, сколько памяти использует каждая конкретная функция:

# https://github.com/pythonprofilers/memory_profiler
pip install memory_profiler psutil
# psutil нужен для повышения производительности memory_profiler 

python -m memory_profiler some-code.py
Filename: some-code.py

Line #    Mem usage    Increment  Occurrences   Line Contents
============================================================
    15   39.113 MiB   39.113 MiB            1   @profile
    16                                          def memory_intensive():
    17   46.539 MiB    7.426 MiB            1       small_list = [None] * 1000000
    18  122.852 MiB   76.312 MiB            1       big_list = [None] * 10000000
    19   46.766 MiB  -76.086 MiB            1       del big_list
    20   46.766 MiB    0.000 MiB            1       return small_list

Чтобы приступить к его использованию, установите этот инструмент при помощи pip вместе с пакетом psutil, который существенно повышает производительность профилировщика. Кроме того, отметим декоратором profile ту функцию, выполнение которой мы хотим проконтролировать. Наконец, прогоняем профилировщик по нашему коду при помощи python -m memory_profiler. Эта команда построчно демонстрирует, как используется и выделяется память. В данном случае рассматривается функция, отмеченная декоратором, а именно memory_intensive, при помощи которой мы целенаправленно создаём и удаляем большие списки.

Теперь мы знаем, как сузить проблему и найти конкретные строки, в которых возрастает потребление памяти. Но, возможно, нам захочется большей детализации, и мы попробуем узнать, сколько памяти использует каждая переменная. Возможно, вам уже приходилось видеть, как для этого используется sys.getsizeof. Однако, по некоторым типам структур данных эта функция выдаёт небесспорную информацию. Так, по целочисленным значениям или байтовым массивам вас интересует реальный размер в байтах, а по контейнерам (список – один из видов контейнера) бывает нужно получить только размер самого контейнера, а не его содержимого:

import sys
print(sys.getsizeof(1))
# 28
print(sys.getsizeof(2**30))
# 32
print(sys.getsizeof(2**60))
# 36

print(sys.getsizeof("a"))
# 50
print(sys.getsizeof("aa"))
# 51
print(sys.getsizeof("aaa"))
# 52

print(sys.getsizeof([]))
# 56
print(sys.getsizeof([1]))
# 64
print(sys.getsizeof([1, 2, 3, 4, 5]))
# 96, но пустой список равен 56, а каждое значение внутри него - 28.

Как видите, при работе с обычными целыми числами, всякий раз при превышении порогового значения размер возрастает на 4 байта. Аналогично, при работе с обычными строками, при добавлении каждого нового символа размер увеличивается на один байт. Но со списками это правило не работает — sys.getsizeof не «обходит» структуру данных, а возвращает только размер родительского объекта, в данном случае, list.

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

# pip install pympler
from pympler import asizeof
print(asizeof.asizeof([1, 2, 3, 4, 5]))
# 256

print(asizeof.asized([1, 2, 3, 4, 5], detail=1).format())
# [1, 2, 3, 4, 5] size=256 flat=96
#     1 size=32 flat=32
#     2 size=32 flat=32
#     3 size=32 flat=32
#     4 size=32 flat=32
#     5 size=32 flat=32

print(asizeof.asized([1, 2, [3, 4], "string"], detail=1).format())
# [1, 2, [3, 4], 'string'] size=344 flat=88
#     [3, 4] size=136 flat=72
#     'string' size=56 flat=56
#     1 size=32 flat=32
#     2 size=32 flat=32

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

Как сэкономить RAM


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

Списки (lists) в Python – один из самых затратных вариантов использования памяти, если требуется хранить массивы значений:

from memory_profiler import memory_usage

def allocate(size):
    some_var = [n for n in range(size)]

usage = memory_usage((allocate, (int(1e7),)))  # `1e7` is 10 to the power of 7
peak = max(usage)
print(f"Usage over time: {usage}")
# Использование с течением времени: [38.85546875, 39.05859375, 204.33984375, 357.81640625, 39.71484375]
print(f"Peak usage: {peak}")
# Пиковое использование: 357.81640625

Простая функция, приведённая выше (allocate) создаёт в Python список (list) чисел, и этот список имеет указанный размер size. Чтобы измерить, сколько памяти на него требуется, можно воспользоваться показанной выше структурой memory_profiler, которая показывает, сколько памяти расходуется с интервалом 0,2 с. при выполнении функции. Как видим, чтобы сгенерировать список из 10 миллионов чисел, требуется более 350 МиБиБ памяти. Что ж, это очень много чисел. Можно ли здесь поступить получше?

import array

def allocate(size):
    some_var = array.array('l', range(size))

usage = memory_usage((allocate, (int(1e7),)))
peak = max(usage)
print(f"Usage over time: {usage}")
# Использование с течением времени: [39.71484375, 39.71484375, 55.34765625, 71.14453125, 86.54296875, 101.49609375, 39.73046875]
print(f"Peak usage: {peak}")
# Пиковое использование: 101.49609375

В этом примере мы воспользовались модулем array из Python. В этом модуле можно хранить примитивы, например, целые числа или символы. Как видите, в данном случае пиковый уровень использования памяти только чуть-чуть превышал 100 МиБиБ. Огромная разница по сравнению с list. Можно ещё сильнее сократить использование памяти, выставив нужную точность:

import array
help(array)

#  ...
#  |  Массивы представляют базовые значения и ведут себя во многом подобно спискам, с той оговоркой, что
#  |  в них могут храниться объекты ограниченного количества типов. 
#  |  Тип указывается в момент создания объекта, для этого используется односимвольный код.
#  |  Определены следующие коды типов:
#  |
#  |      Код типа    тип в C                 Минимальный размер, байт
#  |      'b'         целое со знаком     		     1
#  |      'B'         беззнаковое целое   		     1
#  |      'u'         Символ Unicode  			     2 
#  |      'h'         целое со знаком	     	 	 2
#  |      'H'         беззнаковое целое   		     2
#  |      'i'         целое со знаком     		     2
#  |      'I'         беззнаковое целое   		     2
#  |      'l'         целое со знаком     		     4
#  |      'L'         беззнаковое целое   		     4
#  |      'q'         целое со знаком     		     8 
#  |      'Q'         беззнаковое целое   		     8 
#  |      'f'         число с плавающей точкой       4
#  |      'd'         число с плавающей точкой       8

Один из основных минусов использования array в качестве контейнера для данных – в том, что он не поддерживает такого множества типов.

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

import numpy as np

def allocate(size):
    some_var = np.arange(size)

usage = memory_usage((allocate, (int(1e7),)))
peak = max(usage)
print(f"Usage over time: {usage}")
# Использование с течением времени: [52.0625, 52.25390625, ..., 97.28515625, 107.28515625, 115.28515625, 123.28515625, 52.0625]
print(f"Peak usage: {peak}")
# Пиковое использование: 123.28515625

# Больше вариантов типов, обеспечиваемых NumPy:
data = np.ones(int(1e7), np.complex128)
# Полезные вспомогательные функции:
print(f"Size in bytes: {data.nbytes:,}, Size of array (value count): {data.size:,}")
# Размер в байтах: 160,000,000, Size of array (value count): 10,000,000

Как видим, массивы NumPy также очень работоспособны, когда речь идёт об использовании памяти. Пиковый размер массива составляет ~123 МиБиБ. Это чуть больше, чем с array, но NumPy выигрывает благодаря быстрым математическим функциям, а также тем типам, которые не поддерживаются array – таковы, например, комплексные числа.

Вышеприведённые варианты оптимизации помогают в целом улучшить размеры массивов значений, но можно доработать и размеры отдельных объектов, определяемых классами Python. Это можно сделать при помощи атрибута класса __slots__, при помощи которого явно объявляются свойства класса. Если определить __slots__ с классом, то также приобретается приятный побочный эффект – исключается создание атрибутов __dict__ и __weakref__:

from pympler import asizeof

class Normal:
    pass

class Smaller:
    __slots__ = ()

print(asizeof.asized(Normal(), detail=1).format())
# <__main__.Normal object at 0x7f3c46c9ce50> size=152 flat=48
#     __dict__ size=104 flat=104
#     __class__ size=0 flat=0

print(asizeof.asized(Smaller(), detail=1).format())
# <__main__.Smaller object at 0x7f3c4266f780> size=32 flat=32
#     __class__ size=0 flat=0

На этом примере видно, насколько же мельче на самом деле экземпляр класса Smaller. При отсутствии __dict__ на каждом экземпляре экономится целых 104 байта, что позволяет сэкономить огромный объём памяти в случае инстанцирования миллионов значений.
Вышеприведённые советы и приёмы должны пригодиться вам при работе с числовыми значениями, а также с объектами class. Но что же насчёт строк? Приёмы хранения строк, в принципе, зависят от того, что вы собираетесь с этими строками делать. Если вы хотите выполнить поиск по большому количеству строковых значений, то, как вы уже убедились, использовать list – очень неудачная идея. Если программа должна выполняться поскорее, то вариант set может оказаться предпочтительнее, но в таком случае оперативная память, вероятно, будет тратиться ещё активнее. Возможно, лучше всего в таком случае подойдёт оптимизированная структура данных префиксное дерево (trie) – в особенности при работе со статическими множествами данных, используемыми, например, при запросах. Как часто бывает с Python, для этой цели (а также для работы со многими другими древовидными структурами) уже существует библиотека. Некоторые варианты таких деревьев рассмотрены здесь.

А если вообще не использовать RAM?


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

Более мощный инструмент, имеющийся у вас в распоряжении – это файлы, отображаемые в память. Работая с ними, можно загружать в память лишь часть данных из файла. В стандартной библиотеке Python для этого предоставляется модуль mmap. С его помощью можно создавать отображаемые в память сущности, которые функционально похожи как на файлы, так и на байтовые массивы. К ним применимы как файловые операции, например, read, seek или write, так и строковые:

import mmap

with open("some-data.txt", "r") as file:
    with mmap.mmap(file.fileno(), 0, access=mmap.ACCESS_READ) as m:
        print(f"Read using 'read' method: {m.read(15)}")
        # Читаем при помощи метода 'read': b'Lorem ipsum dol'
        m.seek(0)  # Перематываем в начало
        print(f"Read using slice method: {m[:15]}")
        # Читаем при помощи метода slice: b'Lorem ipsum dol'

Загружать и считывать файлы, отображаемые в память, очень просто. Сначала, как обычно, открываем файл для чтения. Затем пользуемся его дескриптором (file.fileno()), чтобы создать из обычного файла отображаемый в память. С этого момента можно обращаться к его данным как при помощи файловых операций, например, read, так и при помощи строковых, например, slice.
Как правило, вас заинтересует именно чтение файла, как показано выше. Но в файл, отображаемый в память, также можно и писать:

import mmap
import re

with open("some-data.txt", "r+") as file:
    with mmap.mmap(file.fileno(), 0) as m:
        # Слова, начинающиеся с заглавной буквы
        pattern = re.compile(rb'\b[A-Z].*?\b')

        for match in pattern.findall(m):
            print(match)
            # b'Lorem'
            # b'Morbi'
            # b'Nullam'
            # ...

        # Удалить 10 первых символов
        start = 0
        end = 10
        length = end - start
        size = len(m)
        new_size = size - length
        m.move(start, end, size - end)
        m.flush()
    file.truncate(new_size)

Первое отличие, сразу заметное в коде – это изменение режима доступа на r+. Такой режим допускает как чтение, так и запись. Чтобы продемонстрировать, что нам действительно доступны как чтение, так и запись, сначала прочитаем информацию из файла, а затем при помощи регулярного выражения найдём все слова, начинающиеся с заглавной буквы. Затем продемонстрируем удаление данных из файла. Эта операция не так проста, как чтение и поиск, поскольку после того, как мы удалим часть содержимого из файла, размер этого файла потребуется скорректировать. Для этого воспользуемся методом move(dest, src, count) из модуля mmap, копирующим байты данных size — end от индекса end до индекса start. В данном случае эта операция приводит к удалению первых 10 байт.

Если вы выполняете вычисления при помощи NumPy, то, возможно, предпочтёте возможности memmap из этой библиотеки (документация), применимые к массивам NumPy, хранимым в двоичных файлах.

Заключительные мысли


Оптимизировать приложения – это вообще сложно. Кроме того, оптимизация сильно зависит от стоящей перед вами задачи, а также от типа самих данных. В этой статье были рассмотрены обычные способы выявления проблем с использованием памяти, а также некоторые способы справиться с этими проблемами. Конечно же, есть и многие другие способы уменьшить отпечаток приложения в памяти. Например, это компромиссы, связанные с использованием вероятностных структур данных – скажем, фильтры Блума или HyperLogLog. Ещё один вариант – воспользоваться древовидной структурой данных, например, DAWG или Marissa, которые очень эффективны при хранении строковой информации.
Теги:
Хабы:
Всего голосов 19: ↑19 и ↓0+19
Комментарии0

Публикации

Информация

Сайт
piter.com
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия