为什么此循环比创建字典的字典理解要快?

时间:2018-09-27 18:03:40

标签: python python-3.x performance python-internals dictionary-comprehension

我并非来自软件/计算机科学背景,但我喜欢用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

2 个答案:

答案 0 :(得分:70)

您正在用很小的输入进行测试;尽管与列表理解相比,字典理解与for循环相比在for循环方面没有太多的性能优势,但对于实际的问题大小,它可以并且确实胜过>>> 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 循环,尤其是在针对全局变量时名称。

您的输入仅包含3个键值对。通过测试1000个元素,我们发现计时非常接近:

>>> 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

存在差异,dict comp更快,但只有 just 如此规模。键值对的数量是100倍,则差异会更大:

for

当您认为这两个都处理了将近100k的键/值对时,差异并不大。尽管如此,MAKE_FUNCTION循环显然更慢

那么为什么有3个元素的速度差异?因为理解(字典,集合,列表理解或生成器表达式)是作为新的 function 实现的,调用该函数具有基本成本,因此普通循环无需支付费用。

这是两种选择的字节码的反汇编;请注意,用于dict理解的顶级字节码中的CALL_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将键值对存储在dict中。字典理解使用STORE_SUBSCR来实现与b相同的功能,但不必每次都加载MAKE_FUNCTION的名称。

但是仅使用 3次迭代,必须执行dict理解的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 组合是对性能的真正拖累:

LOAD_CONST

要用一个参数创建一个函数对象,并用超过0.1μs的时间来调用它(我们传入的None值要额外加上dict.__setitem__)!而这恰好是3个键值对的循环和理解时间之间的差异。

您可能会感到惊讶,一个人用铁锹可以比反铲挖土机更快地挖一个小洞。反铲当然可以快速挖掘,但是如果您需要先启动反铲并首先将其移到位,那么使用铲子的人可以更快地入门!

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

  • 获取下一个键值对,将其弹出堆栈
  • 通过字节码操作使用栈中的前两项(STORE_SUBSCRMAP_ADD来调用list.append()钩子。这并不算作“函数调用”所有这些都在解释器循环内部处理。

这与列表理解不同,在列表理解中,纯循环版本必须使用b,涉及属性查找和函数调用每个循环迭代。列表理解速度的优势来自于这种差异。参见Python list comprehension expensive

dict理解的确增加了一点,那就是在将>>> 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 绑定到最终字典对象时,只需要查找一次目标字典名称。如果目标字典是 global 而不是局部变量,那么理解力就大,放手:

query getArtwork($id: String!) {
  artwork(id: $id) {
    title
  }
}

因此,只需使用dict理解即可。 <30个要处理的元素之间的差异太小而无济于事,而当您生成一个全局对象或具有更多项目时,对dict的理解仍然会胜出。

答案 1 :(得分:16)

从某种意义上讲,这个问题与我很久以前回答的Why is a list comprehension so much faster than appending to a list?很相似。但是,这种行为令您感到惊讶的原因显然是因为您的字典太小了,无法克服创建新功能框架并将其推入/拉入堆栈的成本。为了更好地理解它,让我们深入了解一下您所拥有的拖车片段:

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

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

  

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

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

此外,请注意,当然还有很多其他操作(在这种情况下为OP_CODES)在此游戏中起着至关重要的作用,我认为值得研究并考虑将其作为实践来实践给您;)。