Как дизассемблировать код Python и повысить его производительность

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

Но что если речь идет о целом проекте? Нельзя не признать, что при работе в продакшен-средах код должен быть максимально оптимизирован. Но такое окажется возможным только, если каждые ~50–100 строк уделять время его доработке в среде тестирования.

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

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

В этой статье вы узнаете, как:

  1. Дизассемблировать код Python.
  2. Находить и заменять строки и процессы, которые можно оптимизировать.

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

Дизассемблирование кода Python

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

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

Дизассемблирование можно выполнить с помощью модуля Python dis.

Вот, как выглядит дизассемблирование простой функции:

import dis


def my_very_special_function(word):
len_word = len(word)
print(len_word)


dis.dis(my_very_special_function)

Мы импортировали модуль dis, после чего вызвали метод dis() для дизассемблирования my_very_special_function. Помните, что не нужно включать круглые скобки. Эту функцию мы не вызываем.

А вот вывод:

5     |      0 | LOAD_GLOBAL            | 0 | (len)
| 2 | LOAD_FAST | 0 | (word)
| 4 | CALL_FUNCTION | 1
| 6 | STORE_FAST | 1 | (len_word)
6 | 8 | LOAD_GLOBAL | 1 | (print)
| 10 | LOAD_FAST | 1 | (len_word)
| 12 | CALL_FUNCTION | 1
| 14 | POP_TOP
| 16 | LOAD_CONST | 0 | (None)
| 18 | RETURN_VALUE

Разберем, что этот вывод из себя представляет:

  • Первый столбец: соответствующий номер строки в коде;
  • Второй столбец: соответствующий байтовый индекс;
  • Третий столбец: opname, понятное человеку имя операции;
  • Четвертый столбец: аргумент инструкции;
  • Пятый столбец: понятный человеку аргумент инструкции.

Прошу иметь в виду, что в зависимости от функции, столбцов может быть на два больше. За подробным разъяснением рекомендую обратиться к ответу на StackOverflow.

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

Поиск и замена процессов, допускающих оптимизацию

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

Главная задача дизассемблирования  —  увидеть, какие инструкции исполняются в среде выполнения и сколько их. Таким образом, вы можете сравнить несколько вариантов кода, выполняющих одно и то же действие, чтобы оценить, какой задействует меньше инструкций.

Примечание: дальше вы узнаете, что меньшее число инструкций не всегда подразумевает лучшую производительность.

Словари против множества if и elif

Предлагаю взять в качестве примера прием из моей предыдущей статьи, Too Many If-Elif Conditions in Python? Use Dictionaries Instead. В ней я разъяснял, как словари Python помогают оптимизировать излишнее количество if и elif. Сейчас же давайте рассмотрим разницу между двумя обсуждаемыми в той статье реализациями.

import dis


def if_elifs(u_in):
if u_in == 1:
say_hello()
elif u_in == 2:
say_hello_1()
elif u_in == 3:
say_hello_2()
elif u_in == 4:
say_hello_3()


def dict_mode(u_in):
my_dict.get(u_in)()


def say_hello():
print("Hello")


def say_hello_1():
print("Hello 1")


def say_hello_2():
print("Hello 2")


def say_hello_3():
print("Hello 3")


my_dict = {
1: say_hello,
2: say_hello_1,
3: say_hello_2,
4: say_hello_3,
}

print('If-Elifs Mode:')

dis.dis(if_elifs)

print('Dictionary Mode:')

dis.dis(dict_mode)

if_elifs(u_in=3)

print('++++++++++++++++++++++++')

dict_mode(u_in=3)

На строках 42 и 46 мы дизассемблируем две функции. В одной используется четыре if и elif, а во второй задействован словарь. Далее на строках 48 и 52 я показываю, что эти две функции выполняют одну и ту же задачу. 

Выполнение этого файла дает такой вывод:

If-Elifs Mode:
5 0 LOAD_FAST 0 (u_in)
2 LOAD_CONST 1 (1)
4 COMPARE_OP 2 (==)
6 POP_JUMP_IF_FALSE 16
6 8 LOAD_GLOBAL 0 (say_hello)
10 CALL_FUNCTION 0
12 POP_TOP
14 JUMP_FORWARD 46 (to 62)
7 >> 16 LOAD_FAST 0 (u_in)
18 LOAD_CONST 2 (2)
20 COMPARE_OP 2 (==)
22 POP_JUMP_IF_FALSE 32
8 24 LOAD_GLOBAL 1 (say_hello_1)
26 CALL_FUNCTION 0
28 POP_TOP
30 JUMP_FORWARD 30 (to 62)
9 >> 32 LOAD_FAST 0 (u_in)
34 LOAD_CONST 3 (3)
36 COMPARE_OP 2 (==)
38 POP_JUMP_IF_FALSE 48
10 40 LOAD_GLOBAL 2 (say_hello_2)
42 CALL_FUNCTION 0
44 POP_TOP
46 JUMP_FORWARD 14 (to 62)
11 >> 48 LOAD_FAST 0 (u_in)
50 LOAD_CONST 4 (4)
52 COMPARE_OP 2 (==)
54 POP_JUMP_IF_FALSE 62
12 56 LOAD_GLOBAL 3 (say_hello_3)
58 CALL_FUNCTION 0
60 POP_TOP
>> 62 LOAD_CONST 0 (None)
64 RETURN_VALUE
Dictionary Mode:
16 0 LOAD_GLOBAL 0 (my_dict)
2 LOAD_METHOD 1 (get)
4 LOAD_FAST 0 (u_in)
6 CALL_METHOD 1
8 CALL_FUNCTION 0
10 POP_TOP
12 LOAD_CONST 0 (None)
14 RETURN_VALUE
Hello 2
++++++++++++++++++++++++
Hello 2

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

  • константу, с котором сравнивается u_in (1,2,3,4);
  • оператор сравнения (==);
  • глобальные функции (say_hello).

Кроме того, если замерить время выполнения обеих функций:

start_time_1 = time.time()
if_elifs(u_in=3)
print(f"{time.time() - start_time_1)} seconds")
print('++++++++++++++++++++++++')
start_time_2 = time.time()
dict_mode(u_in=3)
print(f"{time.time() - start_time_2)} seconds")

То станет очевидно, что dict_mode всегда выполняется быстрее:

Hello 2
3.0994415283203125e-06 seconds
++++++++++++++++++++++++
Hello 2
2.6226043701171875e-06 seconds

Но победа состоит не в этом. На деле за счет использования модуля timeit я получил странные результаты (не забудьте импортировать его с помощью import timeit):

if_code = "if_elifs(3)"
setup = "from __main__ import if_elifs, dict_mode"
dict_code = "dict_mode(3)"

if_exec_time = timeit.timeit(setup=setup,
stmt=if_code,
number=5)
dict_exec_time = timeit.timeit(setup=setup,
stmt=dict_code,
number=5)
print('If-Elifs Mode:')
print(if_exec_time)
print('Dictionary Mode:')
print(dict_exec_time)

Таким образом, мы видим, что режим словаря практически всегда превосходит режим if и elif.

Однако, если применить этот тест в больших масштабах (например, для 1000), то результаты будут не столь однозначны. К примеру, в первом выполнении мы получим:

If-Elifs Mode:
0.009563888
Dictionary Mode:
0.006643314999999997

А во втором:

If-Elifs Mode:
0.005110607000000003
Dictionary Mode:
0.005760672000000001

Как видите, наличие меньшего числа инструкций не всегда подразумевает (существенно) большее быстродействие. 

На StackOverflow есть отличный ответ, доказывающий это через сравнение оператора + и метода .__add__.

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

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

import dis


def add_compr():
x = [x for x in range(100)]
return x


def add_for():
x = list()
for n in range(100):
x.append(n)

return x


print("Using traditional for loop")

dis.dis(add_for)

print("\n\n Using list comprehensions")

dis.dis(add_compr)

Заметьте, что в add_compr() используется списковое включение, а в add_for() традиционный цикл for. Выполним этот скрипт:

Using traditional for loop
10 0 LOAD_GLOBAL 0 (list)
2 CALL_FUNCTION 0
4 STORE_FAST 0 (x)
11 6 LOAD_GLOBAL 1 (range)
8 LOAD_CONST 1 (100)
10 CALL_FUNCTION 1
12 GET_ITER
>> 14 FOR_ITER 14 (to 30)
16 STORE_FAST 1 (n)
12 18 LOAD_FAST 0 (x)
20 LOAD_METHOD 2 (append)
22 LOAD_FAST 1 (n)
24 CALL_METHOD 1
26 POP_TOP
28 JUMP_ABSOLUTE 14
14 >> 30 LOAD_FAST 0 (x)
32 RETURN_VALUE
Using list comprehensions
5 0 LOAD_CONST 1 (<code object <listcomp> at 0x7fe26b3795b0, file "list_compr.py", line 5>)
2 LOAD_CONST 2 ('add_compr.<locals>.<listcomp>')
4 MAKE_FUNCTION 0
6 LOAD_GLOBAL 0 (range)
8 LOAD_CONST 3 (100)
10 CALL_FUNCTION 1
12 GET_ITER
14 CALL_FUNCTION 1
16 STORE_FAST 0 (x)
6 18 LOAD_FAST 0 (x)
20 RETURN_VALUE

А вот фактическое дизассемблирование спискового включения:

Disassembly of <code object <listcomp> at 0x7fe26b3795b0, file "list_compr.py", line 5>:
5 0 BUILD_LIST 0
2 LOAD_FAST 0 (.0)
>> 4 FOR_ITER 8 (to 14)
6 STORE_FAST 1 (x)
8 LOAD_FAST 1 (x)
10 LIST_APPEND 2
12 JUMP_ABSOLUTE 4
>> 14 RETURN_VALUE

Мы видим, что здесь не загружаются глобальные объекты, методы (append) и нет нужды в n, а значит необходимости загружать ее и хранить тоже нет. Но основная часть оптимизации заключается в разнице между байткодом LIST_APPEND и традиционным методом append().

По факту время выполнения в списковом включении меньше, чем в цикле for (не забудьте сперва импортировать временной модуль через import time):

print("Using traditional for loop")

start_time_1 = time.time()

add_for()

print(f"{time.time() - start_time_1)} seconds")
print("\n\n Using list comprehensions")

start_time_2 = time.time()

add_compr()

print(f"{time.time() - start_time_2)} seconds")

Вывод:

Using traditional for loop
5.9604644775390625e-06 secondsUsing list comprehensions
2.86102294921875e-06 seconds

Опять же, во всех тестах списковое включение оказывается быстрее.

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

Заключение

Вот вы и научились дизассемблировать код Python, а также узнали в чем его польза для:

  • оптимизации кода;
  • углубления понимания функционирования Python.

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

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Tommaso De Ponti: How To Disassemble Python Code and Improve It for Performance

Предыдущая статья6 шагов по созданию расширения Chrome, которое можно продать
Следующая статья7 признаков того, что Flutter готов к разработке корпоративных приложений