【问题标题】:Dictionary size reduces upon increasing one element字典大小在增加一个元素时减小
【发布时间】:2019-10-12 06:28:22
【问题描述】:

我跑了这个:

import sys

diii = {'key1':1,'key2':2,'key3':1,'key4':2,'key5':1,'key6':2,'key7':1}
print sys.getsizeof(diii)
# output: 1048

diii = {'key1':1,'key2':2,'key3':1,'key4':2,'key5':1,'key6':2,'key7':1,'key8':2}
print sys.getsizeof(diii)
# output: 664  

在问这里之前,我重新启动了我的 python shell 并在网上也尝试了它并得到了相同的结果。
我认为多一个元素的字典将提供与输出相同或更多的字节,而不是包含少一个元素的字典。

知道我做错了什么吗?

【问题讨论】:

  • 使用 dict 文字,dict 的“大小”为 0-5 个键为 280 字节,6 或 7 个键为 1048 字节,然后为 8-10 为 664。使用 dictcomps,0-5 个键的大小为 280,然后 22 个键为 1048。字面量的预调整大小一定有一些怪癖。

标签: python python-2.7 dictionary


【解决方案1】:

之前的答案已经提到你不必担心,所以我将深入研究一些更多的技术细节。有点长,请多多包涵。

TLDR:这与调整大小的算法有关。每次调整大小都会分配2**i 内存,其中2**i > requested_size; 2**i >= 8,但是如果2/3 的插槽被填满,则每次插入都会进一步调整基础表的大小,但这次是new_size = old_size * 4。这样,您的第一个字典最终分配了 32 个单元格,而第二个字典分配了 16 个单元格(因为它预先获得了更大的初始大小)。

答案:正如@snakecharmerb 在 cmets 中指出的,这取决于字典的创建方式。为简洁起见,我建议您参考this, excellent blog post,它解释了dict() 构造函数和字典文字{} 在Python 字节码和CPython 实现级别上的区别。

让我们从 8 键的神奇数字开始。它原来是一个常量,在dictobject.h 头文件中为 Python 的 2.7 实现预定义 - Python 字典的最小尺寸:

/* PyDict_MINSIZE is the minimum size of a dictionary.  This many slots are
 * allocated directly in the dict object (in the ma_smalltable member).
 * It must be a power of 2, and at least 4.  8 allows dicts with no more
 * than 5 active entries to live in ma_smalltable (and so avoid an
 * additional malloc); instrumentation suggested this suffices for the
 * majority of dicts (consisting mostly of usually-small instance dicts and
 * usually-small dicts created to pass keyword arguments).
 */
#define PyDict_MINSIZE 8

因此,具体的 Python 实现之间可能会有所不同,但我们假设我们都使用相同的 CPython 版本。但是,大小为 8 的 dict 预计仅包含 5 个元素;不要担心这个,因为这个特定的优化对我们来说并不像看起来那么重要。

现在,当您使用 dict 文字 {} 创建字典时,CPython 会采用快捷方式(与调用 dict 构造函数时的显式创建相比)。稍微简化一点,字节码操作BUILD_MAP 得到解析,它会调用_PyDict_NewPresized 函数,该函数将构造一个我们预先知道其大小的字典:

/* Create a new dictionary pre-sized to hold an estimated number of elements.
   Underestimates are okay because the dictionary will resize as necessary.
   Overestimates just mean the dictionary will be more sparse than usual.
*/

PyObject *
_PyDict_NewPresized(Py_ssize_t minused)
{
    PyObject *op = PyDict_New();

    if (minused>5 && op != NULL && dictresize((PyDictObject *)op, minused) == -1) {
        Py_DECREF(op);
        return NULL;
    }
    return op;
}

此函数调用普通字典构造函数 (PyDict_New) 并请求调整新创建的字典的大小 - 但前提是它预计包含 5 个以上的元素。这是由于一项优化允许 Python 通过将数据保存在预分配的“小表”中来加速某些事情,而无需调用昂贵的内存分配和取消分配函数。

然后,dictresize 将尝试确定新字典的最小大小。它还将使用幻数 8 - 作为起点并迭代地乘以 2,直到找到大于请求大小的最小大小。对于第一个字典,这只是 8,然而,对于第二个(以及所有由 dict 字面量创建的少于 15 个键的字典),它是 16。

现在,在dictresize函数中,前者有a special case,更小的new_size == 8,意在提出上述优化(使用“小表”减少内存操作操作)。但是,因为不需要调整新创建的 dict 的大小(例如,到目前为止没有删除任何元素,因此表是“干净的”),所以什么都没有发生。

相反,当new_size != 8 时,重新分配哈希表的通常过程如下。这最终会分配一个新表来存储 “大”词典。虽然这很直观(更大的 dict 有更大的表),但这似乎并没有让我们继续观察到的行为 - 但是,请再忍耐一下。

一旦我们有了预分配的字典,STORE_MAP optcodes 就会告诉解释器插入连续的键值对。这是通过dict_set_item_by_hash_or_entry 函数实现的,重要的是,如果超过 2/3 的插槽已经用完,则在每次增加大小(即成功插入)后调整字典的大小。大小将增加 x4(in our case,对于大型 dicts 仅增加 x2)。

因此,当您创建包含 7 个元素的 dict 时会发生以下情况:

# note 2/3 = 0.(6)
BUILD_MAP   # initial_size = 8, filled = 0
STORE_MAP   # 'key_1' ratio_filled = 1/8 = 0.125, not resizing
STORE_MAP   # 'key_2' ratio_filled = 2/8 = 0.250, not resizing
STORE_MAP   # 'key_3' ratio_filled = 3/8 = 0.375, not resizing
STORE_MAP   # 'key_4' ratio_filled = 4/8 = 0.500, not resizing
STORE_MAP   # 'key_5' ratio_filled = 5/8 = 0.625, not resizing
STORE_MAP   # 'key_6' ratio_filled = 6/8 = 0.750, RESIZING! new_size = 8*4 = 32
STORE_MAP   # 'key_7' ratio_filled = 7/32 = 0.21875

你最终会得到一个哈希表中总大小为 32 个元素的 dict。

但是,当添加 8 个元素时,初始大小将增加两倍(16),因此我们永远不会调整大小,因为条件 ratio_filled > 2/3 永远不会满足!

这就是为什么在第二种情况下你最终会得到一张更小的桌子。

【讨论】:

    【解决方案2】:

    sys.getsizeof 返回分配给这些字典的底层哈希表实现的内存,这与字典的实际大小有一些不明显的关系。

    Python 2.7 的 CPython 实现将分配给哈希表的内存量翻四倍,每次填充到其容量的 2/3,但如果分配给它的内存过多(即一个大的连续块内存已分配,但实际上只使用了几个地址)。

    恰好有 8 到 11 个元素的字典为 CPython 分配了足够的内存,使其认为它们“过度分配”,并被缩小。

    【讨论】:

    • 根据the docs for the BUILD_MAP opcode,它创建的字典是预先调整文字中的项目数的。那么字典是创建后立即缩小的吗?
    • @snakecharmerb,dg-vwp - 我在下面的回答中写了更多细节(stackoverflow.com/a/56315369/6646912)。 dg-vwp 的回答很好而且很简洁,尽管最后一段并不完全正确(它们不会缩小,只是永远不够大)。
    【解决方案3】:

    你没有做错任何事。字典的大小与元素的数量并不完全对应,因为一旦使用了一定百分比的内存空间,字典就会被过度分配并动态调整大小。在您的示例中,我不确定是什么使 dict 在 2.7 中变小(在 3 中没有),但您不必担心。为什么要使用 2.7 以及为什么要知道 dict 的确切内存使用情况(顺便说一句,其中不包括字典中包含的变量使用的内存,因为字典本身充满了指针。

    【讨论】:

      【解决方案4】:

      dict 字面量的分配在这里处理:dictobject.c#L685-L695。

      由于实现的怪癖,元素的大小与数量最终不会是monotonically increasing。

      import sys
      
      def getsizeof_dict_literal(n):
          pairs = ["{0}:{0}".format(i) for i in range(n)]
          dict_literal = "{%s}" % ", ".join(pairs)
          source = "sys.getsizeof({})".format(dict_literal)
          size = eval(source)
          return size
      

      所表现出的奇怪的增长和收缩行为不仅仅是一次奇怪的事故,而是经常重复发生。对于前几千个结果,可视化如下所示:

      在较新的 Python 版本中,dict 实现完全不同,分配细节更加合理。请参阅bpo28731 - _PyDict_NewPresized() creates too small dict,以获取最近的一些更改的示例。在 Python 3.7.3 中,可视化现在看起来像这样,通常具有较小的 dicts 和单调分配:

      【讨论】:

        【解决方案5】:

        你实际上并没有做错什么。 getsizeof 获取的不是字典中元素的大小,而是字典的粗略估计。这个问题的替代方法是来自json 库的json.dumps()。虽然它没有给你对象的实际大小,但它与你对对象所做的更改是一致的。

        这是一个例子

        import sys
        import json
        
        
        diii = {'key1':1,'key2':2,'key3':1,'key4':2,'key5':1,'key6':2,'key7':1}
        print sys.getsizeof(json.dumps(diii)) # <----
        
        diii = {'key1':1,'key2':2,'key3':1,'key4':2,'key5':1,'key6':2,'key7':1,'key8':2}
        print sys.getsizeof(json.dumps(diii)) # <----
        

        json.dumps() 将字典更改为 json 字符串,然后 diii 可以被评估为字符串。 阅读更多关于python的json库here

        【讨论】:

        • json 提供了哪些 str 不适合此应用程序的功能?为什么要提到它?
        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 2011-12-03
        • 2022-01-02
        • 2014-08-06
        • 2020-08-11
        • 1970-01-01
        • 1970-01-01
        • 2010-11-14
        相关资源
        最近更新 更多