Pull to refresh

Python & оптимизация времени и памяти

Reading time8 min
Views33K

Введение

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

Хотелось бы поделиться несколькими методами, которые помогают в реальных задачах. Я пользуюсь win10 x64.

Экономим память силами Python

В качестве примера рассмотрим вполне реальный пример. Пусть у нас имеется некоторый магазин в котором есть список товаров. Вот нам понадобилось поработать с этими товарами. Самый хороший вариант, когда все товары хранятся в БД, но вдруг что-то пошло не так, и мы решили загрузить все товары в память, дабы обработать их. И тут встает резонный вопрос, а хватит ли нам памяти для работы с таким количеством товаров?

Давайте первым делом создадим некий класс, отвечающий за наш магазин. У него будет лишь 2 поля: name и listGoods, которые отвечают за название магазина и список товаров соответственно.

class ShopClass:
    def __init__(self, name=""):
        self.name = name
        self.listGoods = []

Теперь мы хотим наполнить магазин товарами (а именно заполнить поле listGoods). Для этого создадим класс, отвечающий за информацию об одном товаре (я использую dataclass’ы для таких примеров).

# если ругается на dataclass, то делайте 
# pip install dataclasses
# затем в коде вызывайте импорт
# from dataclasses import dataclass 
@dataclass
class DataGoods:
    name:str
    price:int
    unit:str

Далее необходимо заполнить наш магазин товарами. Для чистоты эксперимента я создам по 200 одинаковых товаров в 3х категориях:

shop = ShopClass("MyShop")
for _ in range(200):
    shop.listGoods.extend([
        DataGoods("телефон", 20000, "RUB"),
        DataGoods("телевизор", 45000, "RUB"),
        DataGoods("тостер", 2000, "RUB")
    ])

Теперь пришло время измерить память, которую занимает наш магазин в оперативке (для измерения памяти я использую модуль pympler):

from pympler import asizeof
print("Размер магазина:", asizeof.asizeof(shop))
>>> Размер магазина: 106648

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

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

shop = ShopClass("MyShop")
print(shop.__dict__)  
>>> {'name': 'MyShop', 'listGoods': []}

shop.city = "Москва"
print(shop.__dict__) 
>>> {'name': 'MyShop', 'listGoods': [], 'city': 'Москва'}

Однако в нашем примере это абсолютно не играет никакой роли. Мы уже заранее знаем, какие атрибуты должны быть у нас. В python’e есть магический атрибут __slots__, который позволяет отказаться от __dict__. Отказ от __dict__ приведет к тому, что для новых классов не будет создаваться словарь со всеми атрибутами и хранимым в них данными, по итогу объем занимаемой памяти должен будет уменьшиться. Изменим немного наши классы:

class ShopClass:
    __slots__ = ("name", "listGoods")
    def __init__(self, name=""):
        self.name = name
        self.listGoods = []
@dataclass
class DataGoods:
    __slots__ = ("name", "price", "unit")
    name:str
    price:int
    unit:str

И протестируем по памяти наш магазин.

from pympler import asizeof
print("Размер магазина:", asizeof.asizeof(shop))
>>> Размер магазина: 43904

Как видно, объем, занимаемый магазином, уменьшился почти в 2.4 раза (значение может варьироваться в зависимости от операционной системы, версии Python, значений и других факторов). У нас получилось оптимизировать занимаемый объем памяти, добавив всего пару строчек кода. Но у такого подхода есть и минусы, например, если вы захотите добавить новый атрибут, вы получите ошибку:

shop = ShopClass("MyShop")
shop.city = "Москва"
>>> AttributeError: 'ShopClass' object has no attribute 'city'

На этом преимущества использования слотов не заканчиваются, из-за того, что мы избавились от атрибута __dict__ теперь ptyhon'у нет необходимости заполнять словарь каждого класса, что влияет и на скорость работы алгоритма. Протестируем наш код при помощи модуля timeit, первый раз протестируем наш код на отключенных __slots__ (включенном__dict__):

import timeit
code = """
class ShopClass:
    #__slots__ = ("name", "listGoods")
    def __init__(self, name=""):
        self.name = name
        self.listGoods = []
@dataclass
class DataGoods:
    #__slots__ = ("name", "price", "unit")
    name:str
    price:int
    unit:str
shop = ShopClass("MyShop")
for _ in range(200):
    shop.listGoods.extend([
        DataGoods("телефон", 20000, "RUB"),
        DataGoods("телевизор", 45000, "RUB"),
        DataGoods("тостер", 2000, "RUB")
])
"""
print(timeit.timeit(code, number=60000))
>>> 33.4812513

Теперь включим __slots__ (#__slots__ = ("name", "price", "unit") -> __slots__ = ("name", "price", "unit") и # __slots__ = ("name", "listGoods") -> __slots__ = ("name", "listGoods")):

# включили __slots__ в коде выше
print(timeit.timeit(code, number=60000))
>>> 28.535005599999998

Результат оказался более чем удовлетворительным, получилось ускорить код примерно на 15% (тестирование проводилось несколько раз, результат был всегда примерно одинаковый).

Таким образом, у нас получилось не только уменьшить объем памяти, занимаемой программой, но и ускорить наш код.

Пытаемся ускорить код

Способов ускорить python существует несколько, начиная от использования встроенных фишек язык (например, описанных в прошлой главе), заканчивая написанием расширений на C/C++ и других языках.

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

Cython

На мой взгляд Cython является отличным решением, если вы хотите писать код на Python, но при этом вам важна скорость выполнения кода. Реализуем код для подсчета сумм стоимости всех телевизоров, телефонов и тостеров на чистом Python и рассчитаем время, которое было затрачено (будем создавать 20.000.000 товаров):

import time
class ShopClass:
   __slots__ = ("name", "listGoods")
   def __init__(self, name=""):
      self.name = name
      self.listGoods = []
@dataclass
class DataGoods:
   __slots__ = ("name", "price", "unit")
   name: str
   price: int
   unit: str
shop = ShopClass("MyShop")
t = time.time()
for _ in range(200*100000):
   shop.listGoods.extend([
      DataGoods("телефон", 20000, "RUB"),
      DataGoods("телевизор", 45000, "RUB"),
      DataGoods("тостер", 2000, "RUB")
   ])
print("СОЗДАЕМ ТОВАРЫ НА PYTHON:", time.time()-t)
>>> СОЗДАЕМ ТОВАРЫ НА PYTHON: 44.49887752532959
telephoneSum, televizorSum, tosterSum = 0, 0, 0
t = time.time()
for goods in shop.listGoods:
   if goods.name == "телефон":
      telephoneSum += goods.price
   elif goods.name == "телевизор":
      televizorSum += goods.price
   elif goods.name == "тостер":
      tosterSum += goods.price
print("ВРЕМЯ НА ПОДСЧЁТ СУММ PYTHON:", time.time() - t)
>>> ВРЕМЯ НА ПОДСЧЁТ СУММ PYTHON: 13.135360717773438

Как мы видим, время обработки весьма неутешительно. Теперь приступим к использованию cython. Для начала ставим библиотеку cython_npm (см. официальный гитхаб): pip install cython-npm. Теперь создадим новую папку в нашем проекте, назовем её cython_code и в ней создадим файл cython_data.pyx (программы cython пишутся с расширением .pyx).

Перепишем класс магазина под cython:

cdef class CythonShopClass:
   cdef str name
   cdef list listGoods

   def __init__(self, str name):
       self.name = name
       self.listGoods = []

В cython необходимо строго типизировать каждую переменную, которую вы используете в коде (это не обязательно, но если этого не делать, то уменьшения по времени не будет). Для этого необходимо писать cdef <тип данных> <название переменной> в каждом классе или методе. Реализуем остальной код на cython. Функцию my_def() реализуем без cdef, а с привычным нам def, так как её мы будем вызывать из основного python файла. Также в начале нашего файла .pyx необходимо прописать версию языка (# cython: language_level=3).

# cython: language_level=3
# на забывает вставить код класса магазина
cdef class CythonDataGoods:
   cdef str name
   cdef int price
   cdef str unit
   def __init__(self, str name, int price, str unit):
       self.name = name
       self.price = price
       self.unit = unit
cdef int c_testFunc():
    cdef CythonShopClass shop
    cdef CythonDataGoods goods
    cdef int i, t, telephoneSum, televizorSum, tosterSum
    size, i, telephoneSum, televizorSum, tosterSum = 0, 0, 0, 0, 0
    shop = CythonShopClass("MyShop")
    t = time.time()
    for i in range(200*100000):
       shop.listGoods.extend([
           CythonDataGoods("телефон", 20000, "RUB"),
           CythonDataGoods("телевизор", 45000, "RUB"),
           CythonDataGoods("тостер", 2000, "RUB")
       ])
    print("СОЗДАЕМ ТОВАРЫ НА CYTHON:", time.time()-t)
    t = time.time()
    for goods in shop.listGoods:
        if goods.name == "телефон":
            telephoneSum += goods.price
        elif goods.name == "телевизор":
            televizorSum += goods.price
        elif goods.name == "тостер":
            tosterSum += goods.price
    print("ВРЕМЯ НА ПОДСЧЁТ СУММ CYTHON:", time.time() - t)
    return 0
def my_def():
    data = c_testFunc()
    return data

Теперь в main.py нашего проекта сделаем вызов cython кода. Для этого делаем сначала импорт всех установленных библиотек:

from cython_npm.cythoncompile import export
from cython_npm.cythoncompile import install
import time

И делаем сразу же компиляцию нашего cython и его импорт в основной python код

export('cython_code/cython_data.pyx')
import cython_code.cython_data as cython_data

Теперь необходимо вызвать код cython

if __name__ == "__main__":
   a = cython_data.my_def()

Запускаем. Обратим внимание, что было выведено в консоли. В cython, где мы делали вывод времени на создание товаров, мы получили:

>>> СОЗДАЕМ ТОВАРЫ НА CYTHON: 4.082242012023926

А там где был вывод после подсчета сумм получили:

>>> ВРЕМЯ НА ПОДСЧЁТ СУММ CYTHON: 1.0513946056365967

Как мы видим, скорость создания товаров сократилась с 44 до 4 секунд, то есть мы ускорили данную часть кода почти в 11 раз. При подсчете сумм время сократилось с 13 секунд до 1 секунды, примерно в 13 раз.

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

Магия Python

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

shop = ShopClass("MyShop")
t = time.time()
getGoods = lambda index: {0: ("телефон", 20000, "RUB"), 
                          1: ("телевизор", 45000, "RUB"), 
                          2:("тостер", 2000, "RUB")}.get(index) 
shop.listGoods = [DataGoods(*getGoods(i%3)) for i in range(200*100000)]
print("СОЗДАЕМ ТОВАРЫ НА PYTHON:", time.time()-t)
>>>  СОЗДАЕМ ТОВАРЫ НА PYTHON: 19.719463109970093

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

PyPy

Бывает так, что нет возможности переписать код на cython или другой язык, потому что уже имеется достаточно большая кодовая база (или по другой причине), а скорость выполнения программы хочется увеличить. Рассмотрим код из прошлого примера, где мы использовали лямбда функции и генератор списков. Тут на помощь может прийти PyPy, это интерпретатор языка python, использующий JIT компилятор. Однако PyPy поддерживает не все сторонние библиотеки, если вы используете в коде таковые, то изучите подробнее документацию. Выполнить python код при помощи PyPy очень легко. 

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

Таким образом, 19 секунд python’овского кода из прошлого примера у нас получилось сократить до 4.5 секунд вообще без переписывания кода, то есть почти в 4 раза.

Вывод

Мы рассмотрели несколько вариантов оптимизации кода по времени и памяти. На зло всем хейтерам, которые говорят, что python медленный, мы смогли достичь ускорения кода в десятки раз.

Были рассмотрены не все возможные варианты ускорения кода. В некоторых случаях можно использовать Numba, NumPy, Nim или multiprocessing. Все зависит от того, какую задачу вы решаете. Некоторые задачи будет проще решать на других языках, так как python не способен решить всё на этом свете.

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

Tags:
Hubs:
Total votes 13: ↑9 and ↓4+6
Comments6

Articles