为什么这个循环比使用字典推导式创建字典更快?

47

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

61
那本词典太小了,用它来测试有些勉强。 - Martijn Pieters
4
更大的字典是否也具有相似的时间?仅有3个元素并不足以进行充分测试。[编辑:Martijn已经先回答了!我很高兴我不是唯一一个认为3太小的人:-)] - KarlMW
2
当我对一个包含1000个随机键值对的字典执行此操作时,dictcomp稍微快一些。但是并不快很多。 - Martijn Pieters
非常感谢您的所有回复,我很抱歉我应该使用更大的词典进行测试。 - Nadim Younes
2个回答

84

您的输入过于小,测试只有三个键值对;尽管与列表推导式相比,字典推导式的性能优势不是很大,但对于实际问题规模,它可以并且确实击败了for循环,特别是当针对全局名称时。

如果将输入增加到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

有差别,字典解析更快,但仅限于这个规模。如果键值对多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个元素时会出现速度差异?因为推导式(字典、集合、列表推导式或生成器表达式)在底层实现为一个新的函数,调用该函数具有基本成本,而普通的循环则无需付出这样的成本。

以下是两种替代方案的字节码反汇编;请注意,在字典推导式的顶级字节码中的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次迭代,字典推导式必须执行的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微秒的时间(还有额外的LOAD_CONST为传入的None值)!这只是循环和推导式之间处理3个键值对所需的时间差异。

你可以将其类比为惊讶于用铲子挖一个小洞比用挖掘机快。挖掘机当然能够快速挖掘,但如果需要先让挖掘机启动并移动到适当位置,用铲子开始会更快!

在几个键值对之后(挖掘更大的洞),创建和调用函数的成本就消失了。此时,字典推导式和显式循环基本上执行相同的操作:

  • 获取下一个键值对,并将其推送到堆栈中
  • 通过字节码操作调用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

所以只需使用字典推导式。在处理少于30个元素的情况下,区别太小并且一旦生成全局或有更多项,字典推导式就会胜出。


从你所说的,Martijn,可以得出结论,理解是为了提高代码的可读性?从你回答中的最后一段也似乎表明,所有字典理解都可以用for循环编写,但反之则不行? - Rook
10
@Rook:字典推导式仅在函数内部具有轻微的速度优势。其他推导式更可能更快,前提是您按照它们的用途使用它们,例如构建列表、字典或集合。所有推导式都可以用一个普通循环来编写,但这里不讨论这个问题。 - Martijn Pieters

18

从某种意义上说,这个问题与我很久以前回答过的一个问题非常相似:为什么列表推导比向列表追加元素快得多?。然而,你感到惊讶的原因显然是因为你的字典太小了,无法抵消创建新函数框架并在堆栈中推送/拉取它的成本。为了更好地理解这一点,让我们深入探讨一下你拥有的两个代码片段:

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操作码,正如文档所述,它会将新的函数对象推送到堆栈上。然后是CALL_FUNCTION操作码,用位置参数调用可调用对象。之后:

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

所有这些操作都有成本,但当字典变得更大时,将键值对分配给字典的成本将会超过在内部创建函数的成本。换句话说,从某一点开始,调用字典的__setitem__方法的成本将超过在运行时创建和挂起字典对象的成本。

此外,请注意,在此游戏中肯定还有多个其他操作(OP_CODES)发挥着关键作用,我认为值得研究和考虑,我将把它留给您作为练习;)。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接