HangOps.ru

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

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

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

Хотите начать программировать на Python? Посетите нашу страницу с подборкой курсов Python для начинающих от ведущих платформ и начните свой путь в мире программирования уже сегодня!

Зачем Беспокоиться?

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

Одна простая причина - это деньги. Ресурсы - как процессор, так и оперативная память - стоят денег, зачем тратить память впустую, запуская неэффективные приложения, если есть способы уменьшить объем занимаемой памяти?

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

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

Найдем «бутылочное горлышко»?

Очевидно, что есть веские причины сократить использование памяти нашими приложениями на Python, однако, прежде чем мы это сделаем, нам сначала нужно найти узкие места или части кода, которые занимают всю память.

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

# https://github.com/pythonprofilers/memory_profiler
pip install memory_profiler psutil
# psutil is needed for better memory_profiler performance

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, используемый для измерения этого раньше. Однако эта функция даст вам сомнительную информацию для некоторых типов структур данных. Для целых чисел или байтовых массивов вы получите реальный размер в байтах, для контейнеров, таких как list, вы получите только размер самого контейнера, а не его содержимого:

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, yet empty list is 56 and each value inside is 28.

Мы можем видеть, что с простыми целыми числами каждый раз, когда мы пересекаем пороговое значение, к размеру добавляется 4 байта. Аналогично, с обычными строками каждый раз, когда мы добавляем еще один символ, добавляется один дополнительный байт. Однако со списками это не работает - sys.getsizeof не "обходит" структуру данных и возвращает только размер родительского объекта, в данном случае list.

Лучшим подходом является использование специального инструмента, предназначенного для анализа поведения памяти. Одним из таких инструментов является Pimples, который может помочь вам получить более реалистичное представление о размерах объектов 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

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

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

Экономим немного оперативной памяти

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

Списки 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}")
# Usage over time: [38.85546875, 39.05859375, 204.33984375, 357.81640625, 39.71484375]
print(f"Peak usage: {peak}")
# Peak usage: 357.81640625

Приведенная выше простая функция (allocate) создает список list чисел Python, используя указанный размер 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}")
# Usage over time: [39.71484375, 39.71484375, 55.34765625, 71.14453125, 86.54296875, 101.49609375, 39.73046875]
print(f"Peak usage: {peak}")
# Peak usage: 101.49609375

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

import array

help(array)

#  ...
#  |  Arrays represent basic values and behave very much like lists, except
#  |  the type of objects stored in them is constrained. The type is specified
#  |  at object creation time by using a type code, which is a single character.
#  |  The following type codes are defined:
#  |
#  |      Type code   C Type             Minimum size in bytes
#  |      '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 over time: {usage}")
# Usage over time: [52.0625, 52.25390625, ..., 97.28515625, 107.28515625, 115.28515625, 123.28515625, 52.0625]
print(f"Peak usage: {peak}")
# Peak usage: 123.28515625

# More type options with NumPy:
data = np.ones(int(1e7), np.complex128)
# Useful helper functions:
print(f"Size in bytes: {data.nbytes:,}, Size of array (value count): {data.size:,}")
# Size in bytes: 160,000,000, Size of array (value count): 10,000,000

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

Вышеупомянутые оптимизации помогают уменьшить общий размер массивов значений, но мы также можем внести некоторые улучшения в размер отдельных объектов, определенных классами 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 байта из каждого экземпляра, что может сэкономить огромный объем памяти при создании экземпляров миллионов значений.

Приведенные выше советы и рекомендации должны быть полезны при работе с числовыми значениями, а также с объектами классов. Но как насчет строк? То, как вы должны их хранить, обычно зависит от того, что вы собираетесь с ними делать. Если вы собираетесь выполнять поиск по огромному количеству строковых значений, то, как мы уже видели, использование списка list - очень плохая идея. Множество set может быть немного более подходящим, если важна скорость выполнения, но, вероятно, будет потреблять еще больше оперативной памяти. Лучшим вариантом может быть использование оптимизированной структуры данных, такой как trie, особенно для статических наборов данных, которые вы используете, например, для запросов. Как это обычно бывает с Python, для этого уже существует библиотека, а также для многих других древовидных структур данных, некоторые из которых вы найдете на https://github.com/pytries.

Вообще не используем оперативную память

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

Более сильный инструмент, который вы можете использовать - это memory-mapped файлы, которые позволяют нам загружать только части данных из файла. Стандартная библиотека Python предоставляет для этого модуль 'mmap', который, можно использовать для создания memory-mapped файлов, которые ведут себя как файлы, так и байты bytearrays. Вы можете использовать их как с файловыми операциями, такими как чтение, поиск или запись, так и со строковыми операциями:

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 using 'read' method: b'Lorem ipsum dol'
        m.seek(0)  # Rewind to start
        print(f"Read using slice method: {m[:15]}")
        # Read using slice method: b'Lorem ipsum dol'

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

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

import mmap
import re

with open("some-data.txt", "r+") as file:
    with mmap.mmap(file.fileno(), 0) as m:
        # Words starting with capital letter
        pattern = re.compile(rb'\b[A-Z].*?\b')

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

        # Delete first 10 characters
        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, который копирует байты данных с конца индекса в начало индекса, что в данном случае приводит к удалению первых 10 байт.

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

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

Оптимизация приложений в целом является сложной задачей. Это также в значительной степени зависит от поставленной задачи, а также от типа самих данных. В этой статье мы рассмотрели распространенные способы обнаружения проблем с использованием памяти и некоторые варианты их устранения. Однако существует множество других подходов к сокращению объема памяти приложения. Это включает в себя обмен точностью на место для хранения с использованием вероятностных структур данных, таких как фильтры Блума или HyperLogLog. Другим вариантом является использование древовидных структур данных, таких как DAWG или Marissa trie, которые очень эффективны при хранении строковых данных.

Источник

Python