Как уменьшить использование памяти и ускорить работу кода на Python с помощью генераторов

Автор оригинала: Abhinav Sagar
  • Перевод

Всем привет. Сегодня хотим поделиться одним полезным переводом, подготовленным в преддверии запуска курса «Web-разработчик на Python». Писать код эффективный по времени и по памяти на Python особенно важно, когда занимаешься созданием Web-приложения, модели машинного обучения или занимаешься тестированием.



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


Функции-генераторы позволяют объявить функцию, которая будет вести себя как итератор. Они позволяют программистам создавать быстрые, простые и чистые итераторы. Итератор – это объект, который может быть повторен (зациклен). Он используется для того, чтобы абстрагировать контейнер данных и заставить его вести себя как итерируемый объект. Например, примером итерируемого объекта могут быть строки, списки и словари.


Генератор выглядит как функция, но использует вместо return ключевое слово yield. Давайте рассмотрим пример, чтобы стало понятнее.


def generate_numbers():
    n = 0
    while n < 3:
        yield n
        n += 1

Это функция-генератор. Когда вы ее вызываете, она возвращает объект-генератор.


>>> numbers = generate_numbers()
>>> type(numbers)
<class 'generator'>

Важно обратить внимание на то, как состояние инкапсулируется в теле функции генератора. Вы можете итерироваться по одному, используя встроенную функцию next():


>>> next_number = generate_numbers()
>>> next(next_number)
0
>>> next(next_number)
1
>>> next(next_number)
2

Что произойдет, если вы вызовете next() после окончания выполнения?


StopIteration – это встроенный тип исключения, которое возникает автоматически, как только генератор перестает возвращать результат. Это сигнал остановки для цикла for.


Оператор yield


Его основная задача – управлять потоком функции генератора так, чтобы это было похоже на оператор return. При вызове функции генератора или использовании выражения генератора он возвращает специальный итератор, который называется генератором. Чтобы использовать генератор, присвойте его какой-либо переменной. При вызове специальных методов в генераторе, таких как next(), код функции будет выполняться до yield.


При попадании в инструкцию yield, программа приостанавливает выполнение функции и возвращает полученное значение объекту, который инициировал выполнение. (Тогда как return прекращает выполнение функции полностью.) Когда работа функции приостанавливается, ее состояние сохраняется.


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


Постановка проблемы


Предположим, нам нужно пройтись по большому списку чисел (например, 100000000) и сохранить квадраты всех чисел, которые нужно хранить отдельно в другом списке.


Обычный подход


import memory_profiler
import time
def check_even(numbers):
    even = []
    for num in numbers:
        if num % 2 == 0: 
            even.append(num*num)

    return even
if __name__ == '__main__':
    m1 = memory_profiler.memory_usage()
    t1 = time.clock()
    cubes = check_even(range(100000000))
    t2 = time.clock()
    m2 = memory_profiler.memory_usage()
    time_diff = t2 - t1
    mem_diff = m2[0] - m1[0]
    print(f"It took {time_diff} Secs and {mem_diff} Mb to execute this method")

После запуска кода выше, мы получим следующее:


It took 21.876470000000005 Secs and 1929.703125 Mb to execute this method

С использованием генераторов


import memory_profiler
import time
def check_even(numbers):
    for num in numbers:
        if num % 2 == 0:
            yield num * num 

if __name__ == '__main__':
    m1 = memory_profiler.memory_usage()
    t1 = time.clock()
    cubes = check_even(range(100000000))
    t2 = time.clock()
    m2 = memory_profiler.memory_usage()
    time_diff = t2 - t1
    mem_diff = m2[0] - m1[0]
    print(f"It took {time_diff} Secs and {mem_diff} Mb to execute this method")

После запуска кода выше, мы получим следующее:


It took 2.9999999995311555e-05 Secs and 0.02656277 Mb to execute this method

Как мы видим, время выполнения и затраченная память значительно сократились. Генераторы работают по принципу, известному как «ленивые вычисления». Это значит, что они могут экономить ресурсы процессора, памяти и других вычислительных ресурсов.


Заключение


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

OTUS. Онлайн-образование
426,21
Цифровые навыки от ведущих экспертов
Поделиться публикацией

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

    +4

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

      0

      Там ещё и не квадраты чисел — а проверка на чётность.


      Но это уже вопрос к автору оригинала, там тоже речь про квадраты — а код про чётность. Индус, которому код писал другой индус?

        +1
        Мы же считаем num*num, т.е. квадраты четных чисел — только и всего.

        Для второго примера стоит добавить реальный обход генератора, иначе цифры по времени и памяти бессмысленны.
      +5
      код с использованием генератора ничего не делает. почитайте коммент от 11 ноября к оригиналу статьи medium.com/@rt.van.der.ham/i-am-afraid-the-timing-conclusion-from-this-article-143c2a013e45
        +5
        Вы во втором примере просто создали генератор, не получив из него ни одного значения, и уж тем более не «сохранили квадраты всех чисел, которые нужно хранить отдельно в другом списке». Естественно, и время выполнения было меньше, и память не занята.
          +4
          Как мы видим, время выполнения

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

          Также и тут, в случае с генератором мы не храним квадраты всех чисел в отдельном списке, мы можем получить слудующий квадрат при необходимости.

          Как мне кажется лучше было бы добавить:
          for i in cubes: 
              do_something()

          так сравнение будет более честным
            +1
            Еще ими можно перебирать большие файлы.
            Примерно так:

            def get_data_from_big_file():
                with open('big_file.txt', 'r', encoding='utf-8') as f:
                    while True:
                        line = f.readline()
                        if not line:
                            break
                        yield line
              0
              Да, только в том случае, если он разбит на строки, и эти строки достаточно коротки по сравнению с самим файлом.
                0

                Серьёзно? "Не учите детей плохому!" (с)


                Для чего создавать отдельную генератор-функцию, внутри которой изобретать велосипед с прерываемым бесконечным циклом, если абсолютно ту же функциональность даёт встроенный open:


                for line in open('big_file.txt', 'r', encoding='utf-8'):
                    # process line

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

                +3
                Генераторы работают по принципу, известному как «ленивые вычисления». Это значит, что они могут экономить ресурсы процессора, памяти и других вычислительных ресурсов.

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


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

                  0
                  Вообще то, когда вы вызываете объект генератора, он сразу доходит до yield, и отдает управление (кооперативная многозадачность), ничего не возвращается, когда вы вызываете next() (py 3) или gen.__next__() (py 2), то возвращается значение после yield и поток выполняется до другого yield или return, если был return и генератор нормально завершился, то будет StopIteration, и ваш return будет в StopIteration().value, есть еще сопрограммы, это двухсторонняя связь, но тут не об этом
                    0

                    Нет, это не так. При создании объекта внутри объекта никаких вычислений не производится, а впервые интерпретатор зайдет внутрь лишь при первом next. Именно поэтому требуется при создании корутин первым вызовом всегда делать next или send(None). И, соответственно, потому останавливается сразу ПОСЛЕ инструкции yield, а не до.

                      0
                      Да, вы правы, у меня какой-то сдвиг на полуфазу в голове произошел
                    0

                    Упоминали что ещё один студент остался с переводами на хабр, но походу незачет ;)

                      +1
                      Данное сравнение времени и памяти совершенно неверное.

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

                      Генераторы будут эффективны только в том случае, если нам не нужны все значения сразу (либо не все значения вообще будут задействованы). Если же нам нужно работать со всеми значениями вычисленными в теле функции (генератора) сразу, то в этом случае функция может оказаться даже более эффективной по времени выполнения.
                        0

                        В комментариях много раз упоминали, что тест скорости неверный — генератор ничего не делает. Посмотрим же на истинные результаты. Для этого исправим код — мы будем не просто получать все квадраты ЧЁТНЫХ (привет автору) чисел, а будем вычислять их общую сумму.


                        Первый тест без генератора:


                        # python3 -m pip install memory_profiler
                        
                        import memory_profiler
                        import time
                        
                        def check_even(numbers):
                        
                            even = []
                            for num in numbers:
                                if num % 2 == 0:
                                    even.append(num * num) 
                            return even
                        
                        if __name__ == '__main__':
                        
                            number    = 10**8
                            m1        = memory_profiler.memory_usage()
                            t1        = time.clock()
                            cubes     = check_even(range(number))
                            sum_cubes = sum(cubes)
                            m2        = memory_profiler.memory_usage()
                            t2        = time.clock()
                            time_diff = t2 - t1
                            mem_diff  = m2[0] - m1[0]
                            print(f"It took {time_diff} Secs and {mem_diff} Mb to calculate\n" +
                                  f"the sum of squares of first {number} even numbers (it is {sum_cubes})")

                        Результат:


                        It took 22.652704714 Secs and 1936.12109375 Mb to calculate
                        the sum of squares of first 100000000 even numbers (it is 166666661666666700000000)

                        Причём заметим, что сначала я отдельно вычислил все значения, а уже потом посчитал их сумму и записал в новую переменную. Если сделать это в одну строку sum_cubes = sum(check_even(range(number))), то замер используемой памяти покажет всего 1.109375 Mb использованной памяти. Но по факту будет использованы все те же 2 ГБ памяти для вычислений списка квадратов — он просто будет сразу же уничтожен. Поэтому здесь может показаться, что память используется мало — это ошибочное предположение. При number = 10**9 мой тест завершается с MemoryError (переполнение памяти).


                        А вот тест с генератором:


                        # python3 -m pip install memory_profiler
                        
                        import memory_profiler
                        import time
                        
                        def check_even(numbers):
                        
                            for num in numbers:
                                if num % 2 == 0:
                                    yield num * num 
                        
                        if __name__ == '__main__':
                        
                            number    = 10**8
                            m1        = memory_profiler.memory_usage()
                            t1        = time.clock()
                            sum_cubes = sum(check_even(range(number)))
                            m2        = memory_profiler.memory_usage()
                            t2        = time.clock()
                            time_diff = t2 - t1
                            mem_diff  = m2[0] - m1[0]
                            print(f"It took {time_diff} Secs and {mem_diff} Mb to calculate\n" +
                                  f"the sum of squares of first {number} even numbers (it is {sum_cubes})")

                        Результат:


                        It took 17.981277267 Secs and 0.04296875 Mb to calculate
                        the sum of squares of first 100000000 even numbers (it is 166666661666666700000000)

                        Подводим итог: с генератором скорость увеличилась на 15%, при этом памяти использовалось почти на 2 ГБ меньше.


                        А вот результаты теста с генератором при number == 10**9 (в 10 раз больше, чем в первоначальном тесте):


                        It took 182.005517055 Secs and 0.078125 Mb to calculate
                        the sum of squares of first 1000000000 even numbers (it is 166666666166666667000000000)

                        без генератора вообще получим переполнение памяти

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

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