为什么从元组列表中创建python dict比从kwargs中慢3倍

时间:2018-09-02 21:25:32

标签: python python-3.x performance benchmarking python-internals

有两种方法可以在python中构造字典,例如:

keyvals = [('foo', 1), ('bar', 'bar'), ('baz', 100)]

dict(keyvals)

dkwargs = {'foo': 1, 'bar': 'bar', 'baz': 100}

dict(**dkwargs)

基准测试

In [0]: %timeit dict(keyvals)
667 ns ± 38 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

In [1]: %timeit dict(**dkwargs)
225 ns ± 7.09 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

您看到第一种方法的速度几乎比第二种方法慢3倍。为什么会这样?

3 个答案:

答案 0 :(得分:14)

dict(**kwargs)传入现成的字典,因此Python可以复制已经存在的内部结构。

另一方面,元组列表需要迭代,验证,散列并将结果放入新的空表中。那不是那么快。

Python字典被实现为hash table,并且随着时间的推移而不断增加键,它们会动态地增长。它们从小开始,并在需要时构建新的更大的哈希表,并复制数据(键,值和哈希)。这在Python代码中是不可见的,但是调整大小需要时间。但是,当您使用dict(**kwargs)(或dict(other_dict))时,CPython(用于测试的默认Python实现)可以采用一种快捷方式:从立即足够大的哈希表开始。您不能对元组序列执行相同的操作,因为您无法预先知道序列中是否没有重复的键。

有关更多详细信息,请参见dict类型的C源代码,尤其是dict_update_common implementation(从dict_init()调用);对于元组序列,这将调用PyDict_MergeFromSeq2(),或者在传入关键字参数时将调用PyDict_Merge()

PyDict_MergeFromSeq2() function对序列进行迭代,测试每个结果以确保有两个元素,然后本质上在字典上调用.__setitem__(key, value)。这可能需要在某些时候调整字典的大小!

PyDict_Merge()函数(通过dict_merge())专门检测是否传入了常规词典,然后检测executes a fast path来一次调整内部结构的大小,一次 直接使用insertdict()调用从原始字典中复制散列和结构(遵循override == 1路径,因为目标字典为空时override已设置为1dict(**kwargs)总是这样。只需调整一次大小并直接使用内部数据就可以快很多,而要做的工作就少得多!

所有这些都是特定于CPython的实现细节。其他Python实现(例如Jython,IronPython和PyPy)可以自行决定dict类型的内部如何工作,并且对于同一操作将显示不同的性能差异。

答案 1 :(得分:3)

简短回答(TL; DR)

这是因为在第一个测试中,dict的CPython实现将从列表中创建一个新字典,但是第二个仅复制字典。复制所需的时间少于解析列表所需的时间。

其他信息

考虑以下代码:

import dis
dis.dis("dict([('foo', 1), ('bar', 'bar'), ('baz', 100)])", depth=10)
print("------------")
dis.dis("dict({'foo': 1, 'bar': 'bar', 'baz': 100})", depth=10)

哪里

  

dis模块支持通过以下方式分析CPython字节码   拆卸它。

这让我们看到执行的字节码操作。输出显示

  1           0 LOAD_NAME                0 (dict)
              2 LOAD_CONST               0 (('foo', 1))
              4 LOAD_CONST               1 (('bar', 'bar'))
              6 LOAD_CONST               2 (('baz', 100))
              8 BUILD_LIST               3
             10 CALL_FUNCTION            1
             12 RETURN_VALUE
------------
  1           0 LOAD_NAME                0 (dict)
              2 LOAD_CONST               0 (1)
              4 LOAD_CONST               1 ('bar')
              6 LOAD_CONST               2 (100)
              8 LOAD_CONST               3 (('foo', 'bar', 'baz'))
             10 BUILD_CONST_KEY_MAP      3
             12 CALL_FUNCTION            1
             14 RETURN_VALUE

从输出中您可以看到:

  1. 两个调用都需要加载将要调用的dict名称。
  2. 之后,第一种方法将列表加载到内存(BUILD_LIST),而第二种方法构建字典(BUILD_CONST_KEY_MAP)(请参见here
  3. 因此,在调用dict函数(CALL_FUNCTION步骤(请参阅here)时,在第二种情况下它花费的时间要短得多,因为已经创建了字典,因此只需简单地进行复制,而不必遍历列表以创建哈希表。

注意:使用字节码,您不能最终决定CALL_FUNCTION是这样做的,因为它的实现是用C编写的,只有通过阅读它,您才能真正知道这一点(请参阅Martijn彼得的答案,以准确解释这部分的工作原理。但是,它有助于查看字典对象是如何在外部 dict()中创建的(在示例中是逐步的,不是语法上的),而对于列表,情况并非如此

编辑

要清楚,当你说

  

有两种方法可以在python中构建字典

确实可以这样做:

dkwargs = {'foo': 1, 'bar': 'bar', 'baz': 100}

从解释器将表达式转换为存储在内存中的字典对象的意义上说,您正在创建字典,并使变量dkwargs指向该字典对象。但是,通过执行dict(**kwargs)或如果您更喜欢dict(kwargs),您并不是真正地创建字典,而只是复制一个已经存在的对象(并强调复制)很重要:

>>> dict(dkwargs) is dkwargs
False

dict(kwargs)强制Python创建一个新对象;但是,这并不意味着它必须重建对象。实际上,该操作没有用,因为实际上它们是相等的对象(尽管不是相同的对象)。

>>> id(dkwargs)
2787648914560
>>> new_dict = dict(dkwargs)
>>> id(new_dict)
2787652299584
>>> new_dict == dkwargs
True
>>> id(dkwargs) is id(new_dict)
False

其中的id:

  

返回对象的“身份”。这是一个整数,在这个对象的生存期内,保证它是唯一且恒定的[...]

     

CPython实现细节:这是对象在内存中的地址。

当然,除非您要专门复制该对象以修改一个对象,以使编辑不链接到另一个参考。

答案 2 :(得分:0)

dkwargs已经是字典,因此您基本上可以复制它。这就是为什么它要快得多的原因。