【问题标题】:Why is python dict creation from list of tuples 3x slower than from kwargs为什么从元组列表创建python dict比从kwargs慢3倍
【发布时间】:2018-09-02 21:25:31
【问题描述】:

在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 倍。这是为什么呢?

【问题讨论】:

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


    【解决方案1】:

    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 类型的内部如何工作,并且会显示相同操作的不同性能差异。

    【讨论】:

    • 你的回答很好也很准确,但到最后有点复杂,对于普通读者来说可能不太清楚。
    • 然而,如果您想知道为什么会有如此大的差异,那么重要的是那部分:内部实现细节。
    • 是的。但是您假设 OP:1. 知道并且可以理解一点 C(当他们只询问 Python 时),2. 想要阅读 CPython 实现代码以真正相信您复制字典的一种方式和您解析的另一种方式列表并每次都创建它,我个人认为这不是必需的。
    • 也就是说,我真的很喜欢阅读您的回答;这很好——事实上我从中学到了。
    • 我已经编辑了答案,以添加更多关于 dict 内部结构的说明,以及为什么这很重要。
    【解决方案2】:

    简答 (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 Pieters'回答有关这部分如何工作的准确解释)。但是,它有助于了解字典对象是如何在外部 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
    

    身份证号:

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

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

    当然,除非您想专门复制对象以修改一个对象,这样编辑就不会链接到另一个引用。

    【讨论】:

    • 这实际上并不能解释为什么 dict() 调用 已经构建的对象 的速度不同。字节码没有说明任何内容,因为它甚至不对应于问题中正在测试的内容。
    • @MartijnPieters 不对应是什么意思?是同样的操作,没有变量赋值,不需要为此分析。
    • 答案说明了原因:“不是创建字典,而是复制一个已经存在的对象”。你的意思是字节码并不能证明CALL_FUNCTION 做了我(和你)声称的事情。但是,它确实显示了在调用函数之前字典是如何创建的,并且如果您认为它为真,它会与参数相协调。
    • 在你的解释中,你给出了CALL_FUNCTION复制已经存在的哈希表结构的充分理由;但是,对我来说,这些原因对于某些用户来说不一定是可以理解的相关的。这就是为什么我给出了一个更简单的详细解释,更多没有经验的用户可以理解
    • 由于两个定时块都在dict 上执行CALL_FUNCTION,所以正是调用本身造成了不同。由于调用本身完全在 C 中处理,实际上无法通过查看字节码来说明 dict() 的执行情况。
    【解决方案3】:

    dkwargs 已经是一本字典,所以你基本上可以复制它。这就是为什么它要快得多。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2015-11-27
      • 1970-01-01
      • 2011-03-17
      • 2015-08-03
      • 1970-01-01
      • 2019-12-20
      • 2017-03-21
      相关资源
      最近更新 更多