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