Почему itertools.chain быстрее, чем понимание сглаживания списка?

В контексте обсуждения в комментариях к этому вопросу было упомянуто, что при объединении последовательности строк просто берется ''.join([str1, str2,...]), объединение последовательности списков будет чем-то вроде list(itertools.chain(lst1, lst2,...)), хотя вы также можете использовать понимание списка, например [x for y in [lst1, lst2,...] for x in y]. Меня удивило то, что первый метод последовательно быстрее второго:

import random
import itertools

random.seed(100)
lsts = [[1] * random.randint(100, 1000) for i in range(1000)]

%timeit [x for y in lsts for x in y]
# 39.3 ms ± 436 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
%timeit list(itertools.chain.from_iterable(lsts))
# 30.6 ms ± 866 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
%timeit list(x for y in lsts for x in y)  # Proposed in comments
# 62.5 ms ± 504 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
# Loop-based methods proposed in the comments
%%timeit
a = []
for lst in lsts: a += lst
# 26.4 ms ± 634 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
%%timeit
a = []
for lst in lsts: a.extend(lst)
# 26.7 ms ± 728 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

Это не разница на порядки, но она не является ничтожной. Мне было интересно, как это может быть так, поскольку перечни понятий часто относятся к самым быстрым методам решения данной проблемы. Сначала я подумал, что, возможно, объект itertools.chain будет иметь len который конструктор list мог бы использовать для предопределения необходимой памяти, но это не так (не может вызвать len на объектах itertools.chain). Является ли какое-то itertools.chain преобразование itertools.chain list каким-то образом или itertools.chain использует какой-то другой механизм?

Протестировано в Python 3.6.3 на Windows 10 x64, если это актуально.

РЕДАКТИРОВАТЬ:

Кажется, что самый быстрый метод - это вызов. .extend пустой список с каждым списком, как это было предложено @zwer, вероятно, потому, что он работает с "кусками" данных, а не на основе каждого элемента.

Ответы

Ответ 1

Вот itertools.chain.from_iterable. Это не трудно читать, даже если вы не знаете C, и вы можете сказать, что все происходит на уровне c (прежде чем использовать для создания списка в вашем коде).

Байт-код для понимания списков выглядит так:

def f(lsts):
    return [x for y in lsts for x in y]

dis.dis(f.__code__.co_consts[1])
  2           0 BUILD_LIST               0
              2 LOAD_FAST                0 (.0)
        >>    4 FOR_ITER                18 (to 24)
              6 STORE_FAST               1 (y)
              8 LOAD_FAST                1 (y)
             10 GET_ITER
        >>   12 FOR_ITER                 8 (to 22)
             14 STORE_FAST               2 (x)
             16 LOAD_FAST                2 (x)
             18 LIST_APPEND              3
             20 JUMP_ABSOLUTE           12
        >>   22 JUMP_ABSOLUTE            4
        >>   24 RETURN_VALUE

Это все операции интерпретатора python, связанные с созданием понимания списка. Просто наличие всех операций на уровне C (в chain), а не нахождение интерпретатора над каждым шагом байтового кода (в понимании) - это то, что даст вам повышение производительности.

Тем не менее, этот импульс настолько мал, что я не стал бы беспокоиться об этом. Это питон, читаемость по скорости.


Редактировать:

Для просмотра списка завершенных генераторов

def g(lists):
    return list(x for y in lsts for x in y)

# the comprehension
dis.dis(g.__code__.co_consts[1])
  2           0 LOAD_FAST                0 (.0)
        >>    2 FOR_ITER                20 (to 24)
              4 STORE_FAST               1 (y)
              6 LOAD_FAST                1 (y)
              8 GET_ITER
        >>   10 FOR_ITER                10 (to 22)
             12 STORE_FAST               2 (x)
             14 LOAD_FAST                2 (x)
             16 YIELD_VALUE
             18 POP_TOP
             20 JUMP_ABSOLUTE           10
        >>   22 JUMP_ABSOLUTE            2
        >>   24 LOAD_CONST               0 (None)
             26 RETURN_VALUE

Таким образом, у интерпретатора есть аналогичное количество шагов, которые нужно выполнить при запуске выражения генератора, который распаковывается списком, но, как и следовало ожидать, накладные расходы на уровне python из list распаковки генератора (в отличие от команды C LIST_APPEND) - это то, что замедляется это вниз.

dis.dis(f)
  2           0 LOAD_CONST               1 (<code object <listcomp> at 0x000000000FB58B70, file "<ipython-input-33-1d46ced34d66>", line 2>)
              2 LOAD_CONST               2 ('f.<locals>.<listcomp>')
              4 MAKE_FUNCTION            0
              6 LOAD_FAST                0 (lsts)
              8 GET_ITER
             10 CALL_FUNCTION            1
             12 RETURN_VALUE

dis.dis(g)
  2           0 LOAD_GLOBAL              0 (list)
              2 LOAD_CONST               1 (<code object <genexpr> at 0x000000000FF6F420, file "<ipython-input-40-0334a7cdeb8f>", line 2>)
              4 LOAD_CONST               2 ('g.<locals>.<genexpr>')
              6 MAKE_FUNCTION            0
              8 LOAD_GLOBAL              1 (lsts)
             10 GET_ITER
             12 CALL_FUNCTION            1
             14 CALL_FUNCTION            1
             16 RETURN_VALUE