为啥这个循环比创建字典的字典理解更快?

Posted

技术标签:

【中文标题】为啥这个循环比创建字典的字典理解更快?【英文标题】:Why is this loop faster than a dictionary comprehension for creating a dictionary?为什么这个循环比创建字典的字典理解更快? 【发布时间】:2019-03-03 16:59:52 【问题描述】:

我不是软件/计算机科学背景,但我喜欢用 Python 编写代码,并且通常可以理解为什么事情会更快。我真的很想知道为什么这个 for 循环比字典理解运行得更快。有什么见解吗?

问题:给定一个包含这些键和值的字典a,返回一个字典,其中值作为键,键作为值。 (挑战:一行完成)

和代码

a = 'a':'hi','b':'hey','c':'yo'

b = 
for i,j in a.items():
    b[j]=i

%% timeit 932 ns ± 37.2 ns per loop

b = v: k for k, v in a.items()

%% timeit 1.08 µs ± 16.4 ns per loop

【问题讨论】:

这是一本强大的小字典,可以用来测试它...... 类似的时间是否适用于更大的字典?只有 3 个元素并不是什么考验。 [编辑:被 Martijn 打败!我很高兴我不是唯一一个认为 3 是一个小数字的人 :-)] 当我使用具有 1000 个随机键和值的字典执行此操作时,dictcomp 稍微快一点。但幅度不大。 非常感谢您的所有回复,很抱歉,我应该用更大的字典进行测试。 【参考方案1】:

您正在使用太小的输入进行测试;虽然与列表推导相比,字典推导对for 循环的性能优势并不大,但对于实际问题大小,它可以并且确实击败for 循环,尤其是在针对全局名称时。

您的输入仅包含 3 个键值对。相反,使用 1000 个元素进行测试,我们发现时间非常接近:

>>> import timeit
>>> from random import choice, randint; from string import ascii_lowercase as letters
>>> looped = '''\
... b = 
... for i,j in a.items():
...     b[j]=i
... '''
>>> dictcomp = '''b = v: k for k, v in a.items()'''
>>> def rs(): return ''.join([choice(letters) for _ in range(randint(3, 15))])
...
>>> a = rs(): rs() for _ in range(1000)
>>> len(a)
1000
>>> count, total = timeit.Timer(looped, 'from __main__ import a').autorange()
>>> (total / count) * 1000000   # microseconds per run
66.62004760000855
>>> count, total = timeit.Timer(dictcomp, 'from __main__ import a').autorange()
>>> (total / count) * 1000000   # microseconds per run
64.5464928005822

区别就在那里,dict comp 更快,但只有 just 在这个规模。键值对的数量是 100 倍时,差异会更大:

>>> a = rs(): rs() for _ in range(100000)
>>> len(a)
98476
>>> count, total = timeit.Timer(looped, 'from __main__ import a').autorange()
>>> total / count * 1000  # milliseconds, different scale!
15.48140200029593
>>> count, total = timeit.Timer(dictcomp, 'from __main__ import a').autorange()
>>> total / count * 1000  # milliseconds, different scale!
13.674790799996117

当您考虑到都处理了近 10 万个键值对时,这并不是 大的差异。尽管如此,for 循环显然更慢

那么为什么有 3 个元素的速度差异呢?因为推导式(字典、集合、列表推导式或生成器表达式)在底层实现为新的函数,并且调用该函数具有基本成本,普通循环不必支付。

这是两种替代方案的字节码反汇编;请注意 dict 理解的***字节码中的 MAKE_FUNCTIONCALL_FUNCTION 操作码,该函数的作用有一个单独的部分,实际上这两种方法之间几乎没有区别:

>>> import dis
>>> dis.dis(looped)
  1           0 BUILD_MAP                0
              2 STORE_NAME               0 (b)

  2           4 SETUP_LOOP              28 (to 34)
              6 LOAD_NAME                1 (a)
              8 LOAD_METHOD              2 (items)
             10 CALL_METHOD              0
             12 GET_ITER
        >>   14 FOR_ITER                16 (to 32)
             16 UNPACK_SEQUENCE          2
             18 STORE_NAME               3 (i)
             20 STORE_NAME               4 (j)

  3          22 LOAD_NAME                3 (i)
             24 LOAD_NAME                0 (b)
             26 LOAD_NAME                4 (j)
             28 STORE_SUBSCR
             30 JUMP_ABSOLUTE           14
        >>   32 POP_BLOCK
        >>   34 LOAD_CONST               0 (None)
             36 RETURN_VALUE
>>> dis.dis(dictcomp)
  1           0 LOAD_CONST               0 (<code object <dictcomp> at 0x11d6ade40, file "<dis>", line 1>)
              2 LOAD_CONST               1 ('<dictcomp>')
              4 MAKE_FUNCTION            0
              6 LOAD_NAME                0 (a)
              8 LOAD_METHOD              1 (items)
             10 CALL_METHOD              0
             12 GET_ITER
             14 CALL_FUNCTION            1
             16 STORE_NAME               2 (b)
             18 LOAD_CONST               2 (None)
             20 RETURN_VALUE

Disassembly of <code object <dictcomp> at 0x11d6ade40, file "<dis>", line 1>:
  1           0 BUILD_MAP                0
              2 LOAD_FAST                0 (.0)
        >>    4 FOR_ITER                14 (to 20)
              6 UNPACK_SEQUENCE          2
              8 STORE_FAST               1 (k)
             10 STORE_FAST               2 (v)
             12 LOAD_FAST                1 (k)
             14 LOAD_FAST                2 (v)
             16 MAP_ADD                  2
             18 JUMP_ABSOLUTE            4
        >>   20 RETURN_VALUE

实质区别:循环代码使用LOAD_NAME 代表b 每次迭代,STORE_SUBSCR 将键值对存储在加载的字典中。字典理解使用MAP_ADD 来实现与STORE_SUBSCR 相同的功能,但不必每次都加载b 名称。

但只有 3 次迭代,dict 理解必须执行的 MAKE_FUNCTION / CALL_FUNCTION 组合才是性能的真正拖累:

>>> make_and_call = '(lambda i: None)(None)'
>>> dis.dis(make_and_call)
  1           0 LOAD_CONST               0 (<code object <lambda> at 0x11d6ab270, file "<dis>", line 1>)
              2 LOAD_CONST               1 ('<lambda>')
              4 MAKE_FUNCTION            0
              6 LOAD_CONST               2 (None)
              8 CALL_FUNCTION            1
             10 RETURN_VALUE

Disassembly of <code object <lambda> at 0x11d6ab270, file "<dis>", line 1>:
  1           0 LOAD_CONST               0 (None)
              2 RETURN_VALUE
>>> count, total = timeit.Timer(make_and_call).autorange()
>>> total / count * 1000000
0.12945385499915574

超过 0.1 μs 来创建一个带有一个参数的函数对象,然后调用它(我们传入的 None 值是额外的 LOAD_CONST)!这就是 3 个键值对的循环时间和理解时间之间的差异。

你可以把这比作一个人用铲子挖一个小洞的速度比反铲挖得快。反铲当然可以快速挖掘,但如果您需要先启动反铲并移动到适当位置,一个有铲子的人可以更快地开始!

除了几个键值对(挖一个更大的洞)之外,函数 create 和 call 成本逐渐消失。此时,dict 理解和显式循环基本上做同样的事情:

取出下一个键值对,将它们出栈 通过字节码操作调用dict.__setitem__钩子,堆栈顶部的两项(STORE_SUBSCRMAP_ADD。这不算作“函数调用”,因为它都是在解释器内部处理的循环。

这与列表推导不同,普通循环版本必须使用list.append(),涉及属性查找和函数调用每次循环迭代。列表理解速度优势来自于这种差异;见Python list comprehension expensive

字典推导式添加的内容是,在将b 绑定到最终字典对象时,只需查找一次目标字典名称。如果目标字典是一个全局而不是一个局部变量,则理解获胜,放下手:

>>> a = rs(): rs() for _ in range(1000)
>>> len(a)
1000
>>> namespace = 
>>> count, total = timeit.Timer(looped, 'from __main__ import a; global b', globals=namespace).autorange()
>>> (total / count) * 1000000
76.72348440100905
>>> count, total = timeit.Timer(dictcomp, 'from __main__ import a; global b', globals=namespace).autorange()
>>> (total / count) * 1000000
64.72114819916897
>>> len(namespace['b'])
1000

所以只需使用 dict 理解。处理

【讨论】:

那么从你所说的,Martijn,是否可以得出结论,推导真的只是为了提高代码的可读性?从你答案的最后一段来看,似乎所有的字典理解都可以用 for 循环编写,但反之则不行? @Rook: dictionary 推导式在函数内部只有很小的速度优势。 如果您将它们用于它们的目的,其他理解有更好的机会更快,因此构建一个列表或一个字典或一个集合。所有推导式都可以用常规循环编写,但这里不讨论。【参考方案2】:

这个问题在某种意义上与我很久以前回答过的Why is a list comprehension so much faster than appending to a list? 非常相似。但是,这种行为让您感到惊讶的原因显然是因为您的字典太小而无法克服创建新函数框架并将其推入/拉入堆栈的成本。为了更好地理解这一点,让我们深入了解一下你拥有的两个 sn-ps:

In [1]: a = 'a':'hi','b':'hey','c':'yo'
   ...: 
   ...: def reg_loop(a):
   ...:     b = 
   ...:     for i,j in a.items():
   ...:         b[j]=i
   ...:         

In [2]: def dict_comp(a):
   ...:     b = v: k for k, v in a.items()
   ...:     

In [3]: 

In [3]: %timeit reg_loop(a)
529 ns ± 7.89 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

In [4]: 

In [4]: %timeit dict_comp(a)
656 ns ± 5.39 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

In [5]: 

In [5]: import dis

In [6]: dis.dis(reg_loop)
  4           0 BUILD_MAP                0
              2 STORE_FAST               1 (b)

  5           4 SETUP_LOOP              28 (to 34)
              6 LOAD_FAST                0 (a)
              8 LOAD_METHOD              0 (items)
             10 CALL_METHOD              0
             12 GET_ITER
        >>   14 FOR_ITER                16 (to 32)
             16 UNPACK_SEQUENCE          2
             18 STORE_FAST               2 (i)
             20 STORE_FAST               3 (j)

  6          22 LOAD_FAST                2 (i)
             24 LOAD_FAST                1 (b)
             26 LOAD_FAST                3 (j)
             28 STORE_SUBSCR
             30 JUMP_ABSOLUTE           14
        >>   32 POP_BLOCK
        >>   34 LOAD_CONST               0 (None)
             36 RETURN_VALUE

In [7]: 

In [7]: dis.dis(dict_comp)
  2           0 LOAD_CONST               1 (<code object <dictcomp> at 0x7fbada1adf60, file "<ipython-input-2-aac022159794>", line 2>)
              2 LOAD_CONST               2 ('dict_comp.<locals>.<dictcomp>')
              4 MAKE_FUNCTION            0
              6 LOAD_FAST                0 (a)
              8 LOAD_METHOD              0 (items)
             10 CALL_METHOD              0
             12 GET_ITER
             14 CALL_FUNCTION            1
             16 STORE_FAST               1 (b)
             18 LOAD_CONST               0 (None)
             20 RETURN_VALUE

在第二个反汇编代码(字典理解)上,您有一个 MAKE_FUNCTION 操作码,正如文档 pushes a new function object on the stack. 和后来的 CALL_FUNCTION 以及 Calls a callable object with positional arguments. 中所述,然后:

从堆栈中弹出所有参数和可调用对象,使用这些参数调用可调用对象,并推送可调用对象返回的返回值。

所有这些操作都有其成本,但是当字典变大时,将键值项分配给字典的成本将比在后台创建函数更大。换句话说,从某个点调用字典的__setitem__ 方法的成本将超过动态创建和挂起字典对象的成本。

另外,请注意,肯定有多个其他操作(在本例中为 OP_CODES)在这个游戏中发挥着至关重要的作用,我认为值得研究并考虑哪些我将把它作为一种练习;)。

【讨论】:

以上是关于为啥这个循环比创建字典的字典理解更快?的主要内容,如果未能解决你的问题,请参考以下文章

在 Python 循环中构建字典 - 列表和字典理解

Python Dict 理解创建和更新字典

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

比较列表理解和显式循环(3 个数组生成器比 1 个 for 循环更快)

Pythonic循环字典的方法

哈希表与字典:更快?