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

    Введение

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

    Комментарии 5

      –1
      Было интересно, но с точки зрения ускорения можно было ужать в одну строчку «Хочешь быстро, используй С», что и было сделано
        0
        Не совсем так. В примерах не было ни одной строчки написано на чистом Си. У Cython синтаксис больше python, чем С.
          0
          Я не столько о синтаксисе, сколько о потере преимуществ питона перед С, прежде всего о потере динамической типизации.
          Cython требует серьезного вмешательства в код, на мой взгляд сопоставимого с написанием отдельных кусков кода на чистом С, что даст сопоставимое ускорение.
        0

        Есть два метода оптимизации, которые нужно бы попробовать перед тем, как пытаться делать вещи из статьи:


        1. Для хранения одной записи перейти на именованные кортежи
        2. Если нужно что-то считать, по множеству записей с идентичными полями, то сложить их в Pandas Dataframe.

        Что-то мне подсказывает что оно будет столько же, но при этом все лаконично и в пару строчек

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

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


          очищать руками память и удалять ненужные элементы по ходу выполнения кода

          Я бы скорее это во вредные советы записал. Это всё загромождает код. А с тем что реально получится легко ошибиться.


          Например если в dict есть 10000 ключей, мы удаляем оттуда 10000 ключей. Как изменится место занимаемое в памяти?


          multiprocessing

          Я бы добавил еще асинхронщину.


          Разные интересные моменты про производительности
          James Powell — Furious & Fast Python 7: Writing Fast Python Code

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое