Циклы в Python: их сравнение и производительность

Содержание

На сегодняшний день Python является одним из самых популярных языков программирования. Это интерпретируемый высокоуровневый язык с элегантным и легко читаемым синтаксисом. Но Python, как правило, значительно медленнее, чем Java, C#, особенно C и C++, а также Fortran. И иногда вопросы производительности могут сильно влиять на возможность полноценно использовать приложения.

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

И наконец, всегда есть возможность написать функции Python на C, C++ или Cython и вызывать их потом из Python-приложения, ликвидировав таким образом узкие места в программе. Но это уже крайняя мера, и на практике так делать приходится редко.

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

В нашей статье мы будет сравнивать производительность различных способов поэлементного суммирования двух последовательностей. А именно:

  • с использованием цикла while;
  • с использованием цикла for;
  • с использованием представления списков;
  • с использованием библиотеки NumPy;

Но производительность это не главное при разработке программного обеспечения. Более того, как сказал Дональд Кнут в своей книге «Искусство программирования»: «Поспешная оптимизация — это корень всех (или почти всех) зол в программировании.» В конечном итоге, «важна читаемость (readability counts)». Это сказано в «Дзен Python» Тима Питерса.

Постановка задачи

Мы будем производить поэлементное суммирование двух последовательностей. Иными словами, мы возьмем две последовательности одинаковой длины (представленные либо списками, либо массивами) и создадим третью последовательность, элементы которой будут представлять из себя суммы соответствующих элементов первых двух последовательностей.

Подготовка

Мы импортируем встроенный пакет random и создадим список r, который будет содержать 100000 псевдослучайных целых чисел, принимающих значение от 0 до 99 включительно.

import random
r = [random.randrange(100) for _ in range(100_000)]

Также мы будем использовать библиотеку NumPy, поэтому импортируем и ее.

import numpy as np

Теперь мы готовы двигаться дальше!

Простые циклы

Давайте для начала рассмотрим в действии обычные циклы Python.

Используем чистый Python

Начнем с двух списков по тысяче элементов в каждом. Целочисленная переменная n равна длине этих списков. Списки x и y получены путем случайного выбора из списка r.

n = 1_000
x, y = random.sample(r, n), random.sample(r, n)

Давайте посмотрим, сколько времени потребуется для получения списка z, n элементов которого являются суммами соответствующих элементов списков x и y.

%%timeit
i, z = 0, []
while i < n:
    z.append(x[i] + y[i])
    i += 1

В результате получим:

160 µs ± 1.44 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)

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

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

%%timeit
z = []
for i in range(n):
    z.append(x[i] + y[i])

Результатом будет:

122 µs ± 188 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)

В данном случае цикл for не только работает быстрее, но и выглядит гораздо лучше, чем цикл while.

Представления списков в языке Python очень похожи на обычные циклы for и весьма удобны в простых ситуациях. Они имеют более компактный код и обычно чуть-чуть быстрее обычных циклов.

%%timeit
z = [x[i] + y[i] for i in range(n)]

В результате получим:

87.2 µs ± 490 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)

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

Использование библиотеки NumPy

NumPy — это сторонняя библиотека, которая очень часто используется в численных вычислениях. Она особенно удобна для операций с массивами (для этого в ней есть множество полезных функций). Также она позволяет писать простой и элегантный код без использования циклов.

На самом деле циклы, равно как и другие критичные для производительности операции, реализованы в NumPy на системном уровне. Именно это и позволяет функциям NumPy быть быстрее обычных функций языка Python. Еще одним преимуществом является то, как Numpy обрабатывает переменные и типы.

Давайте для начала создадим из списков целого типа Python x и y массивы NumPy типа integer 64-bit (целочисленный 64-х битный тип числа).

x_, y_ = np.array(x, dtype=np.int64), np.array(y, dtype=np.int64)

Код для поэлементного сложения двух массивов так же прост, как и для сложения двух обычных переменных: x_ + y_. Но давайте проверим производительность:

%%timeit
z = x_ + y_

Результат будет следующий:

1.03 µs ± 5.09 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

Это почти в 85 раз быстрее, чем при использовании представления списков! И код крайне прост и красив. При работе с большими данными массивы NumPy — это самый лучший выбор. Причем, чем больше данных, тем больше выигрыш во времени.

И это не все. Если нам подходит 32-х битный целочисленный тип данных вместо 64-х битного, мы можем еще сэкономить не только память, но и время.

x_, y_ = np.array(x, dtype=np.int32), np.array(y, dtype=np.int32)

Точно так же теперь складываем два массива:

%%timeit
z = x_ + y_

И результат будет следующий:

814 ns ± 5.8 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

Результаты с большими n (10 000 и 100 000) приведены в таблице в конце статьи. Они иллюстрируют ту же зависимость, причем выигрыш в производительности NumPy при росте n тоже увеличивается.

Вложенные циклы

Теперь сравним работу вложенных циклов.

Используем чистый Python

Как и раньше, мы будем работать с двумя списками: x и y. Каждый из них состоит из ста списков, которые в свою очередь содержат по 1000 псевдослучайных целых чисел. Таким образом, x и y фактически представляют собой матрицы размером 100 на 1000.

m, n = 100, 1_000
x = [random.sample(r, n) for _ in range(m)]
y = [random.sample(r, n) for _ in range(m)]

Теперь давайте сложим их и посмотрим скорость работы при использовании двух вложенных циклов while.

%%timeit
i, z = 0, []
while i < m:
    j, z_ = 0, []
    while j < n:
        z_.append(x[i][j] + y[i][j])
        j += 1
    z.append(z_)
    i += 1

В результате получим:

19.7 ms ± 271 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Как и в прошлый раз, мы можем несколько улучшить производительность, использовав циклы for.

%%timeit
z = []
for i in range(m):
    z_ = []
    for j in range(n):
         z_.append(x[i][j] + y[i][j])
    z.append(z_)

Результат будет следующий:

16.4 ms ± 303 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

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

%%timeit
z = [[x[i][j] + y[i][j] for j in range(n)] for i in range(m)]

Результат:

12.1 ms ± 99.4 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Мы опять видим, что и в случае вложенных циклов представление списков быстрее обычных циклов for, которые в свою очередь быстрее циклов while.

В этом примере у нас было 100000 (100 * 1000) элементов в списке. Его обработка лишь чуть-чуть медленней, чем обработка одиночным циклом одного обычного списка со 100000 элементов. Этот вывод верен для всех трех рассмотренных нами подходов (представление списков, циклы for и циклы while).

Использование библиотеки NumPy

NumPy великолепно подходит для работы с многомерными массивами. Давайте опять используем списки x и y для создания из них массивов NumPy типа integer 64-bit (целочисленный 64-х битный тип числа).

x_, y_ = np.array(x, dtype=np.int64), np.array(y, dtype=np.int64)

И снова измерим производительность операции сложения:

%%timeit
z = x_ + y_

Результат будет следующим:

69.9 µs ± 909 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)

Это примерно в 173 раза быстрее, чем представление списков (самый быстрый способ использования циклов Python). Но результат может быть еще лучше, если мы будем использовать 32-х битные целые числа.

x_, y_ = np.array(x, dtype=np.int32), np.array(y, dtype=np.int32)

Снова замеряем, как и прежде, скорость работы:

%%timeit
z = x_ + y_

И в результате получаем:

34.3 µs ± 44.6 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)

Это еще в два раза быстрее, чем при использовании 64-х битных целых чисел.

Результаты

Количество элементов, m × n 1×1.000 1×10.000 1×100.000 100×1.000
Цикл while 160 μs 1.66 ms 17.0 ms 19.7 ms
Цикл for 122 μs 1.24 ms 13.4 ms 16.4 ms
Представление списков 87.2 μs 894 μs 8.92 ms 12.1 ms
numpy с 64-х битными целыми числами 1.03 μs 6.36 μs 71.9 μs 69.9 μs
numpy с 32-х битными целыми числами 814 ns 2.87 μs 34.1 μs 34.3 μs

Выводы

В данной статье было произведено сравнение скорости работы циклов Python при поэлементном сложении списков или массивов. Результаты показывают, что представления списков работают быстрее обычных циклов for, которые в свою очередь работают быстрее циклов while. Простые циклы работают чуть-чуть быстрее вложенных (при одинаковом количестве элементов) во всех трех случаях.

Библиотека NumPy дает нам функции и операторы, которые существенно повышают скорость работы и сильно уменьшают количество кода в программе. Это особенно полезно при работе с одномерными и многомерными массивами.

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