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

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

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

Зачем вообще заморачиваться?

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

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

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

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

Найдите узкие места

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

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

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

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

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

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

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

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

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

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

Python lists — один из самых требовательных к памяти вариантов, когда речь идет о хранении массивов значений:

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

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

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

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

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

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

Здесь мы видим, насколько на самом деле меньше экземпляр класса Smaller. Отсутствие __dict__ удаляет целых 104 байта из каждого экземпляра, что может сэкономить огромное количество памяти при создании экземпляров миллионов значений.

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

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

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

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

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

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

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

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

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

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

Эта статья изначально была опубликована на martinheinz.dev