Решил прояснить для себя возможности оптимизации приложений написанных на Python. В интернете существует довольно много рекомендаций на этот счет, так что я просто пытаюсь свести всю информацию вместе и выяснить чем вызваны те или иные отличия.
Хотя, на первый взгляд кажется, что Python и быстрый код не совместимые понятия, это не совсем правда.
Все тесты проводились на Python 3.3.3 и, само собой, не обошлось без IPython, который ну просто killer-feature этого языка.
Строки
Python предоставляет довольно удобные возможности по работе с строками, тем не менее, не все способы одинаково быстры. Так что просто возьмем несколько строк и попробуем их объеденить тем или иным способом.
str2 = 'val2'
str3 = 'val3'
Честно говоря, получившиеся замеры скорости конкатенации строк вышли несколько озадачивающими. На мой взгляд, они противоречат не только рекомендациям не использовать
%timeit res = 'str1: %s str2: %s str3: %s' % (str1, str2, str3)
%timeit res = 'str1: {} str2: {} str3: {}'.format(str1, str2, str3) # (2)
%timeit res = 'str1: {0} str2: {1} str3: {2}'.format(str1, str2, str3) # (3)
%timeit "".join(['str1: ', str1, 'str2: ', str2, 'str3: ', str3]) # (4)
1000000 loops, best of 3: 453 ns per loop
1000000 loops, best of 3: 675 ns per loop
1000000 loops, best of 3: 416 ns per loop
1000000 loops, best of 3: 375 ns per loop
Как видно из тестов, самый лучший, да и самый читабельный вариант – использовать функцию format явно указывая порядковый номер подставляемой подстроки
Часто рекомендуемый вариант с
%timeit "".join(l)
Массивы и списки
К сожалению, в Python отсутствуют привычные C++ разработчикам массивы. При этом, доступны списки, в CPython реализованные массивами указателей, обеспечивающие константный доступ к элементам. Кроме того, есть массивы из модуля array, массивы из NumPy.
Я ожидал что наилучший результат в произвольном RW доступе покажут массивы из NumPy, но, с учетом того что они были разработаны не для этого, предположение оказалось ложным. Поэтому, приведу тесты в порядке возрастания для массива размером 1000000 элементов. Для массивов данных в 10-100 элементов вариант с преаллокацией оказывается более быстрым, но дальше начинает отставать.
%%timeit
l = [i for i in range(count)]
Кстати, C/C++ разработчики, обратит внимание на следующий момент. Казалось бы более быстрый способ с предварительной аллокацией массива нужного размера оказывается более медленным вариантом по сравнению с использованием List comprehensions. Все дело в том, что Python – интерпретируемый язык и любая встроенная функция дает ощутимый прирост производительности.
l = [0]*count
for i in range(count):
l[i] = i
При использовании NumPy массивов важно сразу указать тип размещаемых в нем данных, почему это важно будет понятно из пункта “Сравнение типов”, иначе скорость работы будет еще ниже.
import numpy as np
l = np.array(np.zeros(count), dtype=int) # (1)
for i in range(count):
l[i] = i
Приведенный ниже пример использует тип
from array import array
l = array('i', (i for i in range(count)))
Мне кажется, результат интересный: самый быстрый способ является самым очевидным способом. После C++ это даже немного удивительно
Умножение и деление
Умножение и деление – еще один сюрприз от авторов Python для C++ разработчиков. Самый быстрый способ поделить/умножить на степень двойки – сдвиги. Все помнят, верно? Верно-то верно, но не везде.
%timeit bit_k >> 1
%timeit bit_k / 2
10000000 loops, best of 3: 52.7 ns per loop
%timeit bit_k * 2
10000000 loops, best of 3: 47.4 ns per loop
В Python сдвиги ощутимо медленнее операций умножения/деления.
Сравнение типов
На этот пункт нужно обратить особо пристальное внимание, т.к. на алгоритмах что-то активно считающих, благодаря невнимательному отношению к типу данных, можно получить падение скорости в 2-4 раза (!!!).
Все дело в том, что операции сравнения между целочисленными типами и типами с плавающей запятой необычайно медленные, что хорошо видно из примера ниже. Поэтому, если в процессе оптимизации какая-то функция активно сравнивает что-либо, желательно удостовериться в том, что типы сравниваемых данных совпадают. Например, тесты на Computer Language Shootout нередко подверженны этой проблеме и путем добавления .0 некоторые из них можно ускорить в те самые 2-4 раза.
%timeit 10.0 < 10.0
%timeit 10 < 10
10000000 loops, best of 3: 33.2 ns per loop
10000000 loops, best of 3: 31.4 ns per loop
Можно предположить, что все дело все в том, что конвертация между типами очень медленная операция:
%timeit int(10.0)
1000000 loops, best of 3: 380 ns per loop
Но, несмотря на то, что конвертация операция действительно очень медленная, дело не в этом:
1 < 10.0
def conversion():
int(10.0)
import dis
print("Comparation:")
dis.dis(comparation)
print("Conversion:")
dis.dis(conversion)
2 0 LOAD_CONST 1 (1)
3 LOAD_CONST 2 (10.0)
6 COMPARE_OP 0 (<)
9 POP_TOP
10 LOAD_CONST 0 (None)
13 RETURN_VALUE
Conversion:
5 0 LOAD_GLOBAL 0 (int)
3 LOAD_CONST 1 (10.0)
6 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
9 POP_TOP
10 LOAD_CONST 0 (None)
13 RETURN_VALUE
А в том, что инструкция VM Python
Циклы
Все принципы оптимизации в Python схожи, и циклы не исключение. Если что-то можно заменить на встроенные функции – надо менять, т.к. VM довольно медленная.
val = 0
for i in range(1000):
val += i
from functools import reduce
from operator import add
reduce(add, range(1000))
Встроенная функция из Python может оказаться в ~1.7 раз быстрее самописной:
from functools import reduce
reduce(lambda res, x: res+x, range(1000))
И лямбды тут практически ни при чем.
from operator import add
%timeit reduce(lambda res, x: res+x, range(1000))
def my_add(res, x):
return res + x
%timeit reduce(my_add, range(1000))
%timeit reduce(add, range(1000))
10000 loops, best of 3: 139 µs per loop
10000 loops, best of 3: 76.8 µs per loop
Глобальные и локальные переменные
[cc lang='python']
newlist = []
def f_global():
for word in oldlist:
newlist.append(word.upper())
%timeit f_global()
newlist = []
for word in l:
newlist.append(word.upper())
return newlist
%timeit newlist = f_local(oldlist)
В данном случае все дело в инструкциях использующихся для загрузки локальных и глобальных переменных
print("Global data:")
dis.dis(f_global)
print("Local data:")
dis.dis(f_local)
4 0 SETUP_LOOP 33 (to 36)
3 LOAD_GLOBAL 0 (oldlist)
6 GET_ITER
>> 7 FOR_ITER 25 (to 35)
10 STORE_FAST 0 (word)
5 13 LOAD_GLOBAL 1 (newlist)
16 LOAD_ATTR 2 (append)
19 LOAD_FAST 0 (word)
22 LOAD_ATTR 3 (upper)
25 CALL_FUNCTION 0 (0 positional, 0 keyword pair)
28 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
31 POP_TOP
32 JUMP_ABSOLUTE 7
>> 35 POP_BLOCK
>> 36 LOAD_CONST 0 (None)
39 RETURN_VALUE
Local data:
2 0 BUILD_LIST 0
3 STORE_FAST 1 (newlist)
3 6 SETUP_LOOP 33 (to 42)
9 LOAD_FAST 0 (l)
12 GET_ITER
>> 13 FOR_ITER 25 (to 41)
16 STORE_FAST 2 (word)
4 19 LOAD_FAST 1 (newlist)
22 LOAD_ATTR 0 (append)
25 LOAD_FAST 2 (word)
28 LOAD_ATTR 1 (upper)
31 CALL_FUNCTION 0 (0 positional, 0 keyword pair)
34 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
37 POP_TOP
38 JUMP_ABSOLUTE 13
>> 41 POP_BLOCK
5 >> 42 LOAD_FAST 1 (newlist)
45 RETURN_VALUE
Вызов функции VS сохранение и вызов
В рекомендациях по оптимизации кода на Python можно встретить упоминания того, что если функцию, вызывающуюся много раз подряд сначала сохранить в какой-либо переменной, а потом вызывать, то код более быстрым (в ущерб читабельности, конечно). Причина все та же – глобальные данные (в данном случае функция) загружаются дольше чем локальные. Теперь в роли медленной команды выступает
newlist = []
newlist.append(1)
return newlist
def store_and_call():
newlist = []
na = newlist.append
na(1)
return newlist
import dis
print("Direct call:")
dis.dis(direct_call)
print("Store and call:")
dis.dis(store_and_call)
2 0 BUILD_LIST 0
3 STORE_FAST 0 (newlist)
3 6 LOAD_FAST 0 (newlist)
9 LOAD_ATTR 0 (append)
12 LOAD_CONST 1 (1)
15 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
18 POP_TOP
4 19 LOAD_FAST 0 (newlist)
22 RETURN_VALUE
Store and call:
7 0 BUILD_LIST 0
3 STORE_FAST 0 (newlist)
8 6 LOAD_FAST 0 (newlist)
9 LOAD_ATTR 0 (append)
12 STORE_FAST 1 (na)
9 15 LOAD_FAST 1 (na)
18 LOAD_CONST 1 (1)
21 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
24 POP_TOP
10 25 LOAD_FAST 0 (newlist)
28 RETURN_VALUE
Кажется, на этом полезные рекомендации заканчиваются, в крайнем случае мне больше ничего работающего и не требующего установки чего-то нетривиального и сложного, например PyCUDA, Numba и т.д. не попалось. А про PyCUDA, Numba я, наверное, напишу отдельно и чуть позже.
bpython вроде круче IPython…
Важен не IPython как таковой, а IPython с флагом –notepad. Вот в такой конфигурации больше и желать нечего