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

в 12:44, , рубрики: python, Блог компании Wunder Fund, Клиентская оптимизация, оптимизация, Разработка веб-сайтов

Прим. Wunder Fund: мы занимаемся высокочастотной торговлей и это заставляет нас часто думать об оптимизации кода, но в основном, конечно, плюсового. В этой короткой статье описаны несколько подходов к оптимизации Python-программ по памяти. И хотя много проблем можно решить, просто докупив ещё памяти, но не все.

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

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

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

Зачем вообще об этом беспокоиться?

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

Вот одна причина — простая и понятная. Это — деньги. Ресурсы компьютера — и CPU, и 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 байта. То же самое происходит и с обычными строками. Каждый раз, когда в строку добавляют один символ — её размер увеличивается на 1 байт. А вот в случае со списками этот механизм уже не работает — 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, правда, обладает и многими другими возможностями. В том числе — умеет отслеживать экземпляры класса и идентифицировать утечки памяти. Если вашему приложению нечто подобное пойдёт на пользу — рекомендую заглянуть в учебные руководства, которые имеются в документации к Pympler.

Сэкономим немного памяти

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

Python-объекты list, при хранении с их помощью наборов неких значений, реализуют один из самых неэффективных подходов к использованию памяти:

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 в степени 7
peak = max(usage)
print(f"Использование в различные моменты времени: {usage}")
Использование в различные моменты времени: [38.85546875, 39.05859375, 204.33984375, 357.81640625, 39.71484375]
print(f"Peak usage: {peak}")
Пиковое использование: 357.81640625

Простая функция, которую мы проанализировали выше (allocate), создаёт, используя заданный размер (size) Python-объект list, содержащий набор чисел. Для того чтобы измерить объём занимаемой этим объектом памяти, мы можем воспользоваться инструментом memory_profiler, рассмотренным выше. Он даст нам сведения об объёме памяти, потребляемом функцией во время её выполнения, с интервалом в 0,2 секунды. Тут можно видеть, что генерирование объекта list, содержащего 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}")
Использование в различные моменты времени: [39.71484375, 39.71484375, 55.34765625, 71.14453125, 86.54296875, 101.49609375, 39.73046875]
print(f"Пиковое использование: {peak}")
Пиковое использование: 101.49609375

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

import array
help(array)
#  ...
#  |  Объекты array представляют простые значения, их поведение очень похоже на объекты list,
#  |  за исключением того, что набор типов объектов, которые в них хранятся, ограничен. Тип указывают
#  |  во время создания объекта, используя односимвольный код типа.
#  |  Существуют следующие коды типов:
#  |
#  |      Код типа   Тип C             Минимальный размер в байтах
#  |      'b'         signed integer     1
#  |      'B'         unsigned integer   1
#  |      'u'         Unicode character  2 (see note)
#  |      'h'         signed integer     2
#  |      'H'         unsigned integer   2
#  |      'i'         signed integer     2
#  |      'I'         unsigned integer   2
#  |      'l'         signed integer     4
#  |      'L'         unsigned integer   4
#  |      'q'         signed integer     8 (see note)
#  |      'Q'         unsigned integer   8 (see note)
#  |      'f'         floating point     4
#  |      'd'         floating point     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}")
Использование в различные моменты времени: [52.0625, 52.25390625, ..., 97.28515625, 107.28515625, 115.28515625, 123.28515625, 52.0625]
print(f"Пиковое использование: {peak}")
Пиковое использование: 123.28515625
Массивы NumPy поддерживают больше типов:
data = np.ones(int(1e7), np.complex128)
Полезные вспомогательные функции:
print(f"Размер в байтах: {data.nbytes:,}, Размер массива (количество элементов): {data.size:,}")
Размер в байтах: 160,000,000, Размер массива (количество элементов): 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 меньше экземпляра класса Normal. Отсутствие dict позволяет каждому экземпляру избавиться аж от 104 байтов. А это, если создаются миллионы таких экземпляров, способно привести к серьёзнейшей экономии памяти.

Те рекомендации по оптимизации приложений, которые мы только что рассмотрели, пригодятся при работе с числовыми значениями, а так же с объектами, описываемыми некими классами. А как насчёт строк? Способ их хранения, как правило, зависит от того, что с ними планируется делать. Если надо выполнять поиск по множеству строковых значений — тогда, как мы уже видели, использование list — это очень плохая идея. Объект set, вероятно, подойдёт для этого немного лучше, в том случае, если важна скорость выполнения кода. Но памяти такое решение, скорее всего, потребует ещё больше. По-видимому, лучший вариант — использование оптимизированной структуры данных наподобие trie. Особенно — если речь идёт о статических наборах данных, которые, например, используются при выполнении запросов. И, как это обычно бывает в Python, для этого уже существует специальная библиотека. Есть и многие другие древовидные структуры данных, реализации некоторых из которых можно найти в репозитории pytries.

Полный отказ от использования RAM

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

Самый эффективный инструмент, который можно тут использовать — это файлы, отображаемые на память. Их применение позволяет загружать в память лишь часть данных из файла. Для решения этой задачи в стандартной библиотеке Python есть модуль mmap. Его можно использовать для создания таких файлов, которые ведут себя и как обычные файлы, и как байтовые массивы. И тем, и другим можно пользоваться при выполнении файловых операций вроде readseek или 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': {m.read(15)}")
        # Чтение с использованием метода 'read': b'Lorem ipsum dol'
        m.seek(0)  # Перейти в начало
        print(f"Чтение путём получения среза строки: {m[:15]}")
        # Чтение путём получения среза строки: b'Lorem ipsum dol'

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

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

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, тогда вам, возможно, больше понравится класс numpy.memmap, который подходит для массивов NumPy, хранящихся в двоичных файлах.

Итоги

Оптимизация приложений — это, в целом, сложная задача. Оптимизация, кроме прочего, сильно зависит от конкретной задачи, решаемой приложением, и от того, данные каких типов оно использует. В этом материале мы разобрали общие подходы к поиску проблем, связанных с использованием памяти, поговорили о некоторых вариантах решения этих проблем. Но есть и много других подходов к уменьшению объёма памяти, который занимает приложение. Сюда входит нахождение баланса между точностью вычислений и местом, необходимым для хранения данных, что делается путём использования вероятностных структур данных вроде фильтра Блума или HyperLogLog. Ещё одна возможность заключается в использовании древовидных структур данных вроде DAWG или marisa-trie. Они очень эффективно решают задачу хранения строковых данных.

О, а приходите к нам работать? 😏

Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.

Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.

Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.

Присоединяйтесь к нашей команде.

Автор:
mr-pickles

Источник

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


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