Почему списковое включение работает быстрее, чем цикл for?
Всех приветствую. Есть такой кусок кода:
def a(l: list[int]):
res = []
for item in l:
if item % 2:
res.append(item)
return res
def b(l: list[int]):
return [item for item in l if item % 2]
Обе функции отбирают нечётные элементы, но одна использует включения, а другая обычный for.
Инструкции через модуль dis одинаковы для обеих функций:
dis.dis('a([1, 2, 3, 4, 5])')
print('-' * 20)
dis.dis('b([1, 2, 3, 4, 5])')
0 0 RESUME 0
1 2 PUSH_NULL
4 LOAD_NAME 0 (a)
6 BUILD_LIST 0
8 LOAD_CONST 0 ((1, 2, 3, 4, 5))
10 LIST_EXTEND 1
12 CALL 1
20 RETURN_VALUE
--------------------
0 0 RESUME 0
1 2 PUSH_NULL
4 LOAD_NAME 0 (b)
6 BUILD_LIST 0
8 LOAD_CONST 0 ((1, 2, 3, 4, 5))
10 LIST_EXTEND 1
12 CALL 1
20 RETURN_VALUE
Но при запуске кода функция со списковым включением чуть быстрее. Тестировал всё на простеньком примере (запускал несколько раз и считал среднее значение):
start = time.time()
for _ in range(1_000_000):
a([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]) # В среднем 2.62
end = time.time()
print(end - start)
И отдельно для функции b:
start = time.time()
for _ in range(1_000_000):
b([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]) # 2.18 в среднем
end = time.time()
print(end - start)
Я ожидал, что время выполнения будет примерно одинаковое, но после данного результата пошёл искать причины. Все пишут про то, что включения быстрее, но не пишут почему именно. Подскажите, почему так происходит, когда при одинаковых инструкциях байткода время выполнения разное? Или это я где-то ошибся?
P.S. Есть вот такой вопрос на SO, но там в ответе рассуждают на тему разного байткода. В моём случае всё почему-то идентичное, но время разное:
if we have a look at the bytecode we can see that in the case of the list comprehension python is able to use a built-in bytecode command called LIST_APPEND instead of
К сожалению, полное цитирование со всем кодом будет плохо читаться, поэтому выше я приложил ссылку на данный вопрос
Ответы (1 шт):
Простым языком:
Cписковое включение напрямую записывает данные в список не используя временную переменную и глобальный метод .append.
Подробным языком:
Обе функции возвращают список нечетных чисел. И как вы увидите ниже, у вывода цикла for на 26 байт-коде присутствует метод append. В списковом включении отсутствует и пропадает надобность во временной переменной i. Как раз именно эта подгрузка и влияет на производительность.
import dis
def f1():
x = []
for i in range(100):
if i % 2:
x.append(i)
return x
def f2():
return [x for x in range(100) if x % 2]
dis.dis(f1)
dis.dis(f2)
Вывод для цикла for (f1):
4 0 BUILD_LIST 0
2 STORE_FAST 0 (x)
5 4 LOAD_GLOBAL 0 (range)
6 LOAD_CONST 1 (100)
8 CALL_FUNCTION 1
10 GET_ITER
>> 12 FOR_ITER 22 (to 36)
14 STORE_FAST 1 (i)
6 16 LOAD_FAST 1 (i)
18 LOAD_CONST 2 (2)
20 BINARY_MODULO
22 POP_JUMP_IF_FALSE 12
7 24 LOAD_FAST 0 (x)
26 LOAD_METHOD 1 (append)
28 LOAD_FAST 1 (i)
30 CALL_METHOD 1
32 POP_TOP
34 JUMP_ABSOLUTE 12
8 >> 36 LOAD_FAST 0 (x)
38 RETURN_VALUE
Вывод для спискового включения (f2):
12 0 LOAD_CONST 1 (<code object <listcomp> at 0x7fc67c04a7c0, file "BIO-SERVER.py", line 12>)
2 LOAD_CONST 2 ('f2.<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 RETURN_VALUE
Disassembly of <code object <listcomp> at 0x7fc67c04a7c0, file "BIO-SERVER.py", line 12>:
12 0 BUILD_LIST 0
2 LOAD_FAST 0 (.0)
>> 4 FOR_ITER 16 (to 22)
6 STORE_FAST 1 (x)
8 LOAD_FAST 1 (x)
10 LOAD_CONST 0 (2)
12 BINARY_MODULO
14 POP_JUMP_IF_FALSE 4
16 LOAD_FAST 1 (x)
18 LIST_APPEND 2
20 JUMP_ABSOLUTE 4
>> 22 RETURN_VALUE