Почему списковое включение работает быстрее, чем цикл 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 шт):

Автор решения: Zahar

Простым языком:

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
→ Ссылка