为啥 itertools.chain 比扁平化列表理解更快?

Posted

技术标签:

【中文标题】为啥 itertools.chain 比扁平化列表理解更快?【英文标题】:Why is itertools.chain faster than a flattening list comprehension?为什么 itertools.chain 比扁平化列表理解更快? 【发布时间】:2018-09-12 20:29:30 【问题描述】:

在this question 的 cmets 中讨论的上下文中提到,虽然连接一个字符串序列只需要 ''.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 对象可能会有一个lenlist 构造函数可以使用它来预分配必要的内存,但事实并非如此(不能在itertools.chain 对象上调用len)。是某种自定义的itertools.chain-to-list 转换以某种方式发生还是itertools.chain 利用了其他一些机制?

在 Windows 10 x64 上的 Python 3.6.3 中测试,如果相关的话。

编辑:

似乎最快的方法毕竟是调用.extend 为每个列表创建一个空列表,正如@zwer 所建议的那样,可能是因为它适用于“块”数据而不是基于每个元素。

【问题讨论】:

list(x for y in lsts for x in y) 呢? 我的猜测是列表推导必须在 python 空间中进行一些表达式评估,而 itertools 变体没有进行任何表达式评估(它只是完全在 C 空间中产生东西)。 @jdehesa byte-code generated by LCs 与直接委托给 C 的函数相比有很大的不同。 如果有 C 经验的人可以挖掘其中的差异,那将会很有用。可能与内存分配有关,但只是猜测。 哦,对了,相关的,还没回答:***.com/questions/45190729/… 【参考方案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 中)进行所有操作,而不是让解释器对每个字节码步骤(在理解中)进行单步执行,就能提高性能。

不过,这种提升是如此之小,我不会担心。这是 python,可读性胜过速度。


编辑:

对于列表包装的生成器理解

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

因此,解释器在运行由列表解包的生成器表达式时需要执行相似数量的步骤,但正如您所料,list 解包生成器的 Python 级开销(与 C @ 987654326@ 指令)是它减慢速度的原因。

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

【讨论】:

这只能解释为什么第一种方法比其他两种方法慢得多,但不能解释为什么itertools.chain 方法比将生成器理解转换为列表要快,不是吗? @Rightleg 列表包装的生成器推导比两者都慢,因为列表推导是 c 优化的列表包装生成器推导。 (这么多形容词:/) @FHTMitchell “列表推导是一个 c 优化的列表包装生成器推导”是完全错误的。 LC 不会创建生成器表达式,它会创建一个列表(在您的答案中检查 BUILD_LIST),然后使用调用 PyList_Append 的特殊 LIST_APPEND 字节码填充它。 @AshwiniChaudhary 很公平。我确实知道,我想我是在试图简化事情。我将编辑我的答案。 我已经更新了答案,因为正如用户建议的那样,一个简单的 for 循环实际上比所有其他选项都快...

以上是关于为啥 itertools.chain 比扁平化列表理解更快?的主要内容,如果未能解决你的问题,请参考以下文章

函数调用中的星号[重复]

python itertools.chain的用法

python itertools.chain的用法

理解 Python 的 itertools.chain 和 next

自然语言处理(NLP)——分词统计itertools.chain—nltk工具

itertools内置库